Coverage for frappe_manager / migration_manager / migrations / migrate_0_19_0.py: 12%

723 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-07-02 18:13 +0530

1""" 

2Migration for v0.19.0 - Rolling migration window start. 

3 

4v0.19.0 supports migrations from v0.18.0 and later only. 

5Users on older versions must upgrade to v0.18.0 first. 

6 

7BREAKING CHANGES: 

8- SSL: [ssl] table → [[ssl_certificates]] array, preferred_challenge → challenge_type 

9- Docker: SITENAME → SITE_MAPPINGS env var, v0.18.0 → v0.19.0 images 

10- Config: alias_domains, upload_limit, restart_policy, use_uv fields added 

11- Runtime: pyenv/nvm → uv/fnm, certbot → acme.sh 

12""" 

13 

14import json 

15import shlex 

16from pathlib import Path 

17from typing import Any, cast 

18 

19import tomlkit 

20from ruamel.yaml import YAML 

21from ruamel.yaml.scalarstring import DoubleQuotedScalarString 

22 

23from frappe_manager.docker.subprocess_output import SubprocessOutput 

24from frappe_manager.migration_manager.migration_base import MigrationBase 

25from frappe_manager.migration_manager.migration_helpers import MigrationBench 

26from frappe_manager.migration_manager.version import Version 

27from frappe_manager.output_manager.context_managers import spinner 

28 

29 

30class MigrationV0190(MigrationBase): 

31 version = Version("0.19.0") 

32 

33 def bench_basic_backup(self, bench: MigrationBench): 

34 """ 

35 Override parent to add additional backups for runtime rebuild. 

36 

37 Backs up: 

38 - supervisor.conf and *.fm.supervisor.conf (regenerated during rebuild) 

39 - nginx default.conf (may be modified during migration) 

40 

41 Note: env/ backup is handled inside _rebuild_runtime_environment right 

42 before the Python venv is recreated (so the guard is the same code path). 

43 """ 

44 super().bench_basic_backup(bench) 

45 

46 if self.migration_executor.skip_backup or bench.name in self.migration_executor.skip_backup_for: 

47 return 

48 

49 supervisor_config_dir = bench.path / "workspace" / "frappe-bench" / "config" 

50 if supervisor_config_dir.exists(): 

51 supervisor_conf = supervisor_config_dir / "supervisor.conf" 

52 if supervisor_conf.exists(): 

53 self.backup_manager.backup(supervisor_conf, bench_name=bench.name) 

54 self.output.print("Backed up supervisor.conf") 

55 

56 for conf_file in supervisor_config_dir.glob("*.fm.supervisor.conf"): 

57 self.backup_manager.backup(conf_file, bench_name=bench.name) 

58 self.output.print(f"Backed up {conf_file.name}") 

59 

60 # Backup nginx default.conf — gets modified during migration 

61 nginx_default_conf = bench.path / "configs" / "nginx" / "conf" / "conf.d" / "default.conf" 

62 if nginx_default_conf.exists(): 

63 self.backup_manager.backup(nginx_default_conf, bench_name=bench.name) 

64 self.output.print("Backed up nginx default.conf") 

65 

66 def _backup_env_for_rollback(self, bench: MigrationBench): 

67 """Move existing env/ to env.backup.migration for rollback support. 

68 

69 Only moves if env/ exists. If env.backup.migration already exists 

70 (from a prior incomplete migration), it is replaced. 

71 """ 

72 env_dir = bench.path / "workspace" / "frappe-bench" / "env" 

73 if not env_dir.exists() or not env_dir.is_dir(): 

74 return 

75 

76 env_backup_path = bench.path / "workspace" / "frappe-bench" / "env.backup.migration" 

77 import shutil 

78 

79 if env_backup_path.exists(): 

80 shutil.rmtree(env_backup_path) 

81 shutil.move(str(env_dir), str(env_backup_path)) 

82 self.output.print("Moved env/ to env.backup.migration") 

83 

84 def undo_bench_migrate(self, bench: MigrationBench): 

85 """ 

86 Rollback bench changes on migration failure. 

87 

88 Restores env/ by moving env.backup.migration back to env/. 

89 Much faster than copying since env/ can be hundreds of MB. 

90 """ 

91 import shutil 

92 

93 env_dir = bench.path / "workspace" / "frappe-bench" / "env" 

94 env_backup_path = bench.path / "workspace" / "frappe-bench" / "env.backup.migration" 

95 

96 if env_backup_path.exists(): 

97 if env_dir.exists(): 

98 self.output.print("Removing new env/") 

99 shutil.rmtree(env_dir) 

100 

101 self.output.print("Restoring env/ from env.backup.migration") 

102 shutil.move(str(env_backup_path), str(env_dir)) 

103 

104 # Restore .bashrc if it was backed up via BackupManager 

105 bm = getattr(self, "backup_manager", None) 

106 if bm is not None: 

107 for backup in bm.backups: 

108 if backup.src.name == ".bashrc": 

109 bm.restore(backup, force=True) 

110 self.output.print("Restored .bashrc from backup") 

111 break 

112 

113 # Restore nginx default.conf if it was backed up 

114 nginx_default_conf = bench.path / "configs" / "nginx" / "conf" / "conf.d" / "default.conf" 

115 nginx_default_backup = bench.path / "configs" / "nginx" / "conf" / "conf.d" / "default.conf.migration.bak" 

116 if nginx_default_backup.exists(): 

117 if nginx_default_conf.exists(): 

118 nginx_default_conf.unlink() 

119 shutil.move(str(nginx_default_backup), str(nginx_default_conf)) 

120 self.output.print("Restored nginx default.conf from backup") 

121 

122 def migrate_bench(self, bench: MigrationBench): 

123 """Migrate bench from v0.18.0 to current version.""" 

124 self._images_updated = False 

125 with spinner(self.output, f"Migrating bench configuration for {bench.name}"): # type: ignore[arg-type] 

126 bench_config_path = bench.path / "bench_config.toml" 

127 if bench_config_path.exists(): 

128 self._migrate_bench_config_toml(bench, bench_config_path) 

129 

130 docker_compose_path = bench.path / "docker-compose.yml" 

131 if docker_compose_path.exists(): 

132 self._migrate_docker_compose_yml(bench, docker_compose_path) 

133 

134 workers_compose_path = bench.path / "docker-compose.workers.yml" 

135 if workers_compose_path.exists(): 

136 self._migrate_workers_compose_yml(bench, workers_compose_path) 

137 

138 # Pull images only when at least one image tag was actually changed 

139 # (tracked by _update_service_images via _images_updated flag). 

140 # This avoids failures on transient registry/network issues when 

141 # images are already correct. 

142 if self._images_updated: 

143 self._pull_bench_images(bench) 

144 

145 self._cleanup_admin_tools_nginx_config(bench) 

146 

147 # Apply upload limit configuration across all locations 

148 # Resolve upload limit: prefer existing site_config.json max_file_size, then bench_config, then default 

149 upload_limit = self._resolve_upload_limit(bench) 

150 self._write_upload_limit_vhostd(bench, upload_limit) 

151 self._write_upload_limit_site_config(bench, upload_limit) 

152 self._write_upload_limit_nginx_conf(bench, upload_limit) 

153 

154 self._rebuild_runtime_environment(bench) 

155 

156 self.output.print(f"Successfully migrated {bench.name} to {self.version.version_string()}") 

157 

158 def _migrate_bench_config_toml(self, bench: MigrationBench, config_path: Path): 

159 """Transform bench_config.toml: [ssl] → [[ssl_certificates]], add new fields.""" 

160 self.output.print("Migrating bench_config.toml") 

161 

162 content = config_path.read_text() 

163 doc = tomlkit.parse(content) 

164 

165 self._transform_ssl_config(doc, bench.name) 

166 self._add_new_config_fields(doc) 

167 

168 config_path.write_text(tomlkit.dumps(doc)) 

169 self.output.print("Updated SSL configuration format") 

170 

171 def _transform_ssl_config(self, doc: tomlkit.TOMLDocument, bench_name: str): 

172 """Transform [ssl] table to [[ssl_certificates]] array with proper field names.""" 

173 if "ssl" not in doc: 

174 return 

175 

176 old_ssl = doc["ssl"] 

177 

178 if isinstance(old_ssl, list): 

179 return 

180 

181 old_ssl_dict = cast("dict[str, Any]", old_ssl) 

182 

183 ssl_type_value = old_ssl_dict.get("ssl_type", "letsencrypt") 

184 hsts_value = old_ssl_dict.get("hsts", "off") 

185 

186 challenge_type_value = old_ssl_dict.get("preferred_challenge") or old_ssl_dict.get("challenge_type") or "http01" 

187 

188 ssl_cert: dict[str, Any] = { 

189 "domain": str(bench_name), 

190 "ssl_type": ssl_type_value, 

191 "acme_client": "acme.sh", 

192 "hsts": hsts_value, 

193 "challenge_type": challenge_type_value, 

194 } 

195 

196 self._move_dns_credentials(doc, old_ssl_dict) 

197 

198 del doc["ssl"] 

199 doc["ssl_certificates"] = [ssl_cert] 

200 

201 def _move_dns_credentials(self, doc: tomlkit.TOMLDocument, old_ssl_dict: dict[str, Any]): 

202 """Move api_token/api_key from ssl to dns_providers.cloudflare if present.""" 

203 api_token = old_ssl_dict.get("api_token") 

204 api_key = old_ssl_dict.get("api_key") 

205 

206 if not (api_token or api_key): 

207 return 

208 

209 dns_providers_table = tomlkit.table() 

210 cloudflare_table = tomlkit.table() 

211 

212 if api_token: 

213 cloudflare_table["api_token"] = api_token 

214 if api_key: 

215 cloudflare_table["api_key"] = api_key 

216 

217 dns_providers_table["cloudflare"] = cloudflare_table 

218 doc["dns_providers"] = dns_providers_table 

219 

220 self.output.print("Migrated DNS credentials to dns_providers.cloudflare") 

221 

222 def _add_new_config_fields(self, doc: tomlkit.TOMLDocument): 

223 """Add alias_domains, upload_limit, restart_policy, use_uv if missing.""" 

224 if "alias_domains" not in doc: 

225 doc["alias_domains"] = [] 

226 

227 if "upload_limit" not in doc: 

228 doc["upload_limit"] = "50M" 

229 

230 if "restart_policy" not in doc: 

231 env_type = doc.get("environment_type", "prod") 

232 doc["restart_policy"] = "unless-stopped" if env_type == "prod" else "no" 

233 

234 if "use_uv" not in doc: 

235 doc["use_uv"] = True 

236 

237 def _migrate_docker_compose_yml(self, bench: MigrationBench, compose_path: Path): 

238 """Update compose: v0.18.0 → current version images, SITENAME → SITE_MAPPINGS.""" 

239 self.output.print("Migrating docker-compose.yml") 

240 

241 yaml = YAML(typ="rt") 

242 yaml.preserve_quotes = True 

243 yaml.default_flow_style = False 

244 

245 with open(compose_path) as f: 

246 compose_data = yaml.load(f) 

247 

248 if not compose_data or "services" not in compose_data: 

249 return 

250 

251 services = compose_data["services"] 

252 

253 self._update_service_images(services) 

254 

255 # Read config values from bench_config.toml 

256 bench_config_path = bench.path / "bench_config.toml" 

257 restart_policy = "unless-stopped" 

258 if bench_config_path.exists(): 

259 config = tomlkit.parse(bench_config_path.read_text()) 

260 restart_policy = config.get("restart_policy", "unless-stopped") 

261 

262 # Resolve upload limit using the same source-of-truth logic that 

263 # _resolve_upload_limit uses (prefers site_config.json max_file_size 

264 # over bench_config.toml, then defaults to "50M"). This keeps 

265 # CLIENT_MAX_BODY_SIZE in the compose env consistent with what 

266 # upload-limit.conf and vhost.d will eventually contain. 

267 upload_limit = self._resolve_upload_limit(bench) 

268 

269 self._transform_nginx_environment(services, upload_limit) 

270 self._add_restart_policy_to_services(services, restart_policy) 

271 

272 # Update x-version to current version (plain semver — no ``v`` prefix) 

273 compose_data["x-version"] = str(self.version) 

274 

275 with open(compose_path, "w") as f: 

276 yaml.dump(compose_data, f) 

277 

278 def _update_service_images(self, services: dict[str, Any]): 

279 """Replace any existing version tag with runtime-determined version.""" 

280 import re 

281 

282 effective_tag = self._get_image_tag_for_migration() 

283 

284 version_pattern = re.compile(r"(ghcr\.io/rtcamp/frappe-manager-[^:]+):v[0-9]+\.[0-9]+\.[0-9]+(?:\.dev[0-9]+)?") 

285 

286 for service_name, service_config in services.items(): 

287 if "image" not in service_config: 

288 continue 

289 

290 old_image = service_config["image"] 

291 

292 old_version_match = re.search(r":v([0-9]+\.[0-9]+\.[0-9]+(?:\.dev[0-9]+)?)", old_image) 

293 old_version = old_version_match.group(1) if old_version_match else "unknown" 

294 

295 new_image = version_pattern.sub(rf"\1:{effective_tag}", old_image) 

296 

297 if new_image != old_image: 

298 service_config["image"] = new_image 

299 self._images_updated = True 

300 self.output.print(f"Updated {service_name} image: v{old_version}{effective_tag}") 

301 

302 def _transform_nginx_environment(self, services: dict[str, Any], upload_limit: str): 

303 """Transform nginx SITENAME → SITE_MAPPINGS environment variable.""" 

304 if "nginx" not in services or "environment" not in services["nginx"]: 

305 return 

306 

307 nginx_env = services["nginx"]["environment"] 

308 

309 if isinstance(nginx_env, dict): 

310 self._transform_nginx_env_dict(nginx_env, upload_limit) 

311 elif isinstance(nginx_env, list): 

312 services["nginx"]["environment"] = self._transform_nginx_env_list(nginx_env, upload_limit) 

313 

314 def _transform_nginx_env_dict(self, nginx_env: dict[str, Any], upload_limit: str): 

315 """Transform dict format: {SITENAME: value} → {SITE_MAPPINGS: '{"value": "value"}'}.""" 

316 if "SITENAME" in nginx_env: 

317 sitename_value = nginx_env.pop("SITENAME") 

318 # Convert plain site name to JSON mapping expected by nginx entrypoint 

319 site_mapping = json.dumps({sitename_value: sitename_value}) 

320 nginx_env["SITE_MAPPINGS"] = site_mapping 

321 self.output.print(f"Migrated SITENAME → SITE_MAPPINGS ({site_mapping})") 

322 

323 if "HTTPS_METHOD" not in nginx_env: 

324 nginx_env["HTTPS_METHOD"] = "noredirect" 

325 if "CLIENT_MAX_BODY_SIZE" not in nginx_env: 

326 nginx_env["CLIENT_MAX_BODY_SIZE"] = upload_limit.lower() 

327 

328 def _transform_nginx_env_list(self, nginx_env: list, upload_limit: str) -> list: 

329 """Transform list format: [SITENAME=value] → [SITE_MAPPINGS='{"value": "value"}'].""" 

330 new_env = [] 

331 for env_var in nginx_env: 

332 if isinstance(env_var, str) and env_var.startswith("SITENAME="): 

333 sitename_value = env_var.split("=", 1)[1] 

334 # Convert plain site name to JSON mapping expected by nginx entrypoint 

335 site_mapping = json.dumps({sitename_value: sitename_value}) 

336 new_env.append(f"SITE_MAPPINGS={site_mapping}") 

337 self.output.print(f"Migrated SITENAME → SITE_MAPPINGS ({site_mapping})") 

338 else: 

339 new_env.append(env_var) 

340 

341 # Add HTTPS_METHOD and CLIENT_MAX_BODY_SIZE if not already present 

342 existing_keys = {env.split("=", 1)[0] for env in new_env if isinstance(env, str) and "=" in env} 

343 if "HTTPS_METHOD" not in existing_keys: 

344 new_env.append("HTTPS_METHOD=noredirect") 

345 if "CLIENT_MAX_BODY_SIZE" not in existing_keys: 

346 new_env.append(f"CLIENT_MAX_BODY_SIZE={upload_limit.lower()}") 

347 

348 return new_env 

349 

350 def _add_restart_policy_to_services(self, services: dict[str, Any], restart_policy: str): 

351 """Add restart policy to all services in compose file. 

352 

353 Uses ``DoubleQuotedScalarString`` so that values like ``"no"`` (a valid 

354 Docker restart policy) are quoted in the YAML output instead of being 

355 interpreted as YAML 1.1 booleans (``no`` → ``false``). 

356 """ 

357 for service_name, service_config in services.items(): 

358 if "restart" not in service_config: 

359 service_config["restart"] = DoubleQuotedScalarString(restart_policy) 

360 

361 def _resolve_upload_limit(self, bench: MigrationBench) -> str: 

362 """Resolve upload limit, respecting existing site_config.json max_file_size. 

363 

364 Priority: 

365 1. Existing max_file_size in site_config.json (converted back to string like "50M") 

366 2. upload_limit from bench_config.toml 

367 3. Default "50M" 

368 """ 

369 # 1. Check site_config.json for existing max_file_size 

370 site_config_path = bench.path / "workspace" / "frappe-bench" / "sites" / "common_site_config.json" 

371 if site_config_path.exists(): 

372 try: 

373 site_config = json.loads(site_config_path.read_text()) 

374 max_file_size = site_config.get("max_file_size") 

375 if max_file_size: 

376 # Convert bytes back to human-readable string 

377 if max_file_size >= 1024 * 1024 * 1024 and max_file_size % (1024 * 1024 * 1024) == 0: 

378 resolved = f"{max_file_size // (1024 * 1024 * 1024)}G" 

379 elif max_file_size >= 1024 * 1024 and max_file_size % (1024 * 1024) == 0: 

380 resolved = f"{max_file_size // (1024 * 1024)}M" 

381 else: 

382 # Round to nearest MB 

383 resolved = f"{round(max_file_size / (1024 * 1024))}M" 

384 self.output.print( 

385 f"Using existing site_config.json max_file_size: {resolved} ({max_file_size} bytes)" 

386 ) 

387 return resolved 

388 except Exception: 

389 pass 

390 

391 # 2. Fall back to bench_config.toml 

392 bench_config_path = bench.path / "bench_config.toml" 

393 if bench_config_path.exists(): 

394 config = tomlkit.parse(bench_config_path.read_text()) 

395 upload_limit = config.get("upload_limit") 

396 if upload_limit: 

397 self.output.print(f"Using bench_config.toml upload_limit: {upload_limit}") 

398 return upload_limit 

399 

400 # 3. Default 

401 self.output.print("Using default upload_limit: 50M") 

402 return "50M" 

403 

404 def _write_upload_limit_vhostd(self, bench: MigrationBench, upload_limit: str): 

405 """Write nginx-proxy vhost.d files for upload limit.""" 

406 from frappe_manager.site_manager.modules.upload_limit_manager import UploadLimitManager 

407 

408 # Global nginx-proxy vhostd directory 

409 vhostd_dir = bench.path.parent.parent / "services" / "nginx-proxy" / "vhostd" 

410 

411 if not vhostd_dir.exists(): 

412 self.output.print("Warning: nginx-proxy vhostd directory not found, skipping upload limit config") 

413 return 

414 

415 domains = [bench.name] 

416 

417 # Also include alias_domains if available 

418 bench_config_path = bench.path / "bench_config.toml" 

419 if bench_config_path.exists(): 

420 config = tomlkit.parse(bench_config_path.read_text()) 

421 alias_domains = config.get("alias_domains", []) 

422 if alias_domains: 

423 domains.extend(alias_domains) 

424 

425 # Backup existing vhost.d files before modifying (for rollback support) 

426 for domain in domains: 

427 vhost_file = vhostd_dir / domain 

428 if vhost_file.exists(): 

429 self.backup_manager.backup(vhost_file, bench_name=bench.name) 

430 

431 upload_mgr = UploadLimitManager(vhostd_dir) 

432 upload_mgr.set_upload_limit_for_domains(domains, upload_limit.lower()) 

433 self.output.print(f"Set upload limit ({upload_limit}) for {len(domains)} domain(s)") 

434 

435 def _write_upload_limit_site_config(self, bench: MigrationBench, upload_limit: str): 

436 """Update site_config.json max_file_size to match upload_limit (only if not already set).""" 

437 import re 

438 

439 site_config_path = bench.path / "workspace" / "frappe-bench" / "sites" / "common_site_config.json" 

440 if not site_config_path.exists(): 

441 return 

442 

443 try: 

444 site_config = json.loads(site_config_path.read_text()) 

445 # Respect previously configured max_file_size — only set if missing 

446 if "max_file_size" in site_config: 

447 self.output.print( 

448 f"site_config.json max_file_size already set ({site_config['max_file_size']}), skipping" 

449 ) 

450 return 

451 

452 # Parse size to bytes (same logic as site.py _parse_size_to_bytes) 

453 match = re.match(r"^(\d+)([MG])$", upload_limit, re.IGNORECASE) 

454 if not match: 

455 return 

456 

457 value = int(match.group(1)) 

458 unit = match.group(2).upper() 

459 size_bytes = value * (1024 * 1024) if unit == "M" else value * (1024 * 1024 * 1024) 

460 

461 site_config["max_file_size"] = size_bytes 

462 site_config_path.write_text(json.dumps(site_config, indent=4)) 

463 self.output.print(f"Updated site_config.json (max_file_size: {size_bytes} bytes)") 

464 except Exception: 

465 self.output.print("Warning: Could not update site_config.json max_file_size") 

466 

467 def _write_upload_limit_nginx_conf(self, bench: MigrationBench, upload_limit: str): 

468 """Create custom nginx config file for bench-level upload limit.""" 

469 import re 

470 

471 custom_conf_dir = bench.path / "configs" / "nginx" / "conf" / "custom" 

472 custom_conf_dir.mkdir(parents=True, exist_ok=True) 

473 

474 upload_limit_conf = custom_conf_dir / "upload-limit.conf" 

475 

476 # Track whether this is a new file vs pre-existing before we write 

477 was_pre_existing = upload_limit_conf.exists() 

478 

479 if was_pre_existing: 

480 self.backup_manager.backup(upload_limit_conf, bench_name=bench.name) 

481 

482 upload_limit_conf.write_text(f"client_max_body_size {upload_limit.lower()};\n") 

483 

484 if not was_pre_existing: 

485 # Track for rollback cleanup (file didn't exist before migration) 

486 self.backup_manager.track_new_file(upload_limit_conf) 

487 

488 self.output.print("Created custom nginx upload-limit.conf") 

489 

490 # Strip any old client_max_body_size from the generated default.conf 

491 # to avoid duplicate-directive errors. Older FM versions baked the 

492 # value directly into the nginx template; the migration now uses 

493 # upload-limit.conf instead, so the old line must be removed. 

494 default_conf = bench.path / "configs" / "nginx" / "conf" / "conf.d" / "default.conf" 

495 if default_conf.exists(): 

496 old = default_conf.read_text() 

497 cleaned = re.sub(r"\s*client_max_body_size\s+[^;]+;\n?", "", old) 

498 if old != cleaned: 

499 self.backup_manager.backup(default_conf, bench_name=bench.name) 

500 default_conf.write_text(cleaned) 

501 self.output.print("Removed duplicate client_max_body_size from default.conf") 

502 

503 def _migrate_workers_compose_yml(self, bench: MigrationBench, compose_path: Path): 

504 """Update worker compose: v0.18.0 → current version images.""" 

505 self.output.print("Migrating docker-compose.workers.yml") 

506 

507 yaml = YAML(typ="rt") 

508 yaml.preserve_quotes = True 

509 yaml.default_flow_style = False 

510 

511 with open(compose_path) as f: 

512 compose_data = yaml.load(f) 

513 

514 if not compose_data or "services" not in compose_data: 

515 return 

516 

517 services = compose_data["services"] 

518 

519 self._update_service_images(services) 

520 

521 # Add restart policy from bench_config 

522 bench_config_path = bench.path / "bench_config.toml" 

523 restart_policy = "unless-stopped" 

524 if bench_config_path.exists(): 

525 config = tomlkit.parse(bench_config_path.read_text()) 

526 restart_policy = config.get("restart_policy", "unless-stopped") 

527 self._add_restart_policy_to_services(services, restart_policy) 

528 

529 # Update x-version to current version (plain semver — no ``v`` prefix) 

530 compose_data["x-version"] = str(self.version) 

531 

532 with open(compose_path, "w") as f: 

533 yaml.dump(compose_data, f) 

534 

535 def _cleanup_admin_tools_nginx_config(self, bench: MigrationBench): 

536 admin_tools_config = bench.path / "configs" / "nginx" / "conf" / "custom" / "admin-tools.conf" 

537 htpasswd_file = bench.path / "configs" / "nginx" / "conf" / "http_auth" / f"{bench.name}-admin-tools.htpasswd" 

538 

539 if admin_tools_config.exists(): 

540 admin_tools_config.unlink() 

541 self.output.print("Cleaned up stale admin-tools nginx config") 

542 

543 if htpasswd_file.exists(): 

544 htpasswd_file.unlink() 

545 

546 def _pull_bench_images(self, bench: MigrationBench): 

547 from frappe_manager.migration_manager.migration_exections import MigrationExceptionInBench 

548 

549 self.output.print(f"Pulling updated images ({self._get_image_tag_for_migration()})...", emoji_code="📦") 

550 

551 result = bench.compose.pull(stream=False) 

552 

553 if result.exit_code != 0: 

554 raise MigrationExceptionInBench(f"Failed to pull images for {bench.name}. Docker pull failed.") 

555 

556 self.output.print("✓ Images ready", emoji_code="✅") 

557 

558 def _update_global_nginx_proxy_image(self): 

559 """Update global-nginx-proxy image from jwilder/nginx-proxy:1.6 to 1.11.""" 

560 cf = self.services_manager.compose_file_manager 

561 

562 if not cf.exists(): 

563 self.logger.debug("[_update_global_nginx_proxy_image] Services compose not found, skipping") 

564 return 

565 

566 services = cf.yml.get("services") 

567 if not services: 

568 return 

569 

570 nginx_service = services.get("global-nginx-proxy") 

571 if not nginx_service or "image" not in nginx_service: 

572 return 

573 

574 old_image = nginx_service["image"] 

575 new_image = "jwilder/nginx-proxy:1.11" 

576 

577 if old_image != new_image: 

578 nginx_service["image"] = new_image 

579 cf.write_to_file() 

580 self.output.print(f"Updated global-nginx-proxy image: {old_image}{new_image}") 

581 self.logger.info(f"[_update_global_nginx_proxy_image] Updated: {old_image}{new_image}") 

582 

583 def migrate_services(self): 

584 """ 

585 Pull current version Docker images and update global-nginx-proxy image tag. 

586 

587 Images are shared resources across all benches - must be pulled 

588 at system level before any bench can use them. 

589 """ 

590 from frappe_manager.utils.site import pull_docker_images 

591 

592 self._update_global_nginx_proxy_image() 

593 

594 self.logger.info(f"[migrate_services] Starting Docker image pull for {self.version.version_string()}") 

595 

596 with spinner(self.output, f"Pulling Docker images for {self.version.version_string()}"): # type: ignore[arg-type] 

597 success = pull_docker_images() 

598 

599 if not success: 

600 error_msg = "Failed to pull one or more Docker images" 

601 self.logger.error(f"[migrate_services] {error_msg}") 

602 self.output.display_error(error_msg) 

603 raise Exception(error_msg) 

604 

605 self.output.print(f"All {self.version.version_string()} images pulled successfully") 

606 self.logger.info("[migrate_services] Docker image pull completed successfully") 

607 

608 # Recreate global-nginx-proxy to pick up the updated image tag. 

609 self.output.print("Restarting global-nginx-proxy with new image...") 

610 self.services_manager.compose.up( 

611 services=["global-nginx-proxy"], 

612 force_recreate=True, 

613 detach=True, 

614 ) 

615 self.logger.info("[migrate_services] global-nginx-proxy recreated with new image") 

616 

617 def undo_services_migrate(self): 

618 """No global services rollback needed.""" 

619 self.output.print(f"No services rollback needed for {self.version.version_string()}") 

620 

621 def _resolve_runtime_versions( 

622 self, 

623 bench: MigrationBench, 

624 ) -> tuple[str | None, str | None, Any | None]: 

625 """Resolve target Python and Node versions for this bench. 

626 

627 Reads from bench_config.toml if already set, or auto-detects from the 

628 running container. Persists auto-detected versions back to the config. 

629 

630 Returns: 

631 (target_python, target_node, config_doc) 

632 - target_python: Python version to use (e.g. "3.11") 

633 - target_node: Node version to use (e.g. "18") 

634 - config_doc: The parsed TOML document (None if no config file) 

635 """ 

636 from frappe_manager.site_manager.bench_config import ( 

637 parse_node_version_for_runtime, 

638 parse_python_version_for_runtime, 

639 ) 

640 

641 bench_config_path = bench.path / "bench_config.toml" 

642 config_doc = None 

643 target_python = None 

644 target_node = None 

645 

646 if bench_config_path.exists(): 

647 config_doc = tomlkit.parse(bench_config_path.read_text()) 

648 raw_python = config_doc.get("python_version") 

649 raw_node = config_doc.get("node_version") 

650 target_python = parse_python_version_for_runtime(raw_python) if raw_python else None 

651 target_node = parse_node_version_for_runtime(raw_node) if raw_node else None 

652 self.logger.debug( 

653 f"[_resolve_runtime_versions] From config: Python={target_python}, Node={target_node}", 

654 ) 

655 

656 if not target_python or not target_node: 

657 self.output.print( 

658 "No Python/Node versions in config, auto-detecting from container and Frappe requirements...", 

659 ) 

660 self.logger.info("[_resolve_runtime_versions] Auto-detecting versions...") 

661 target_python, target_node = self._auto_detect_runtime_versions(bench) 

662 self.logger.info( 

663 f"[_resolve_runtime_versions] Auto-detected: Python={target_python}, Node={target_node}", 

664 ) 

665 

666 if config_doc and (target_python or target_node): 

667 if target_python: 

668 config_doc["python_version"] = target_python 

669 if target_node: 

670 config_doc["node_version"] = target_node 

671 bench_config_path.write_text(tomlkit.dumps(config_doc)) 

672 self.output.print("Updated bench_config.toml with detected versions") 

673 

674 return target_python, target_node, config_doc 

675 

676 def _check_runtime_current( 

677 self, 

678 bench: MigrationBench, 

679 target_python: str | None, 

680 target_node: str | None, 

681 ) -> tuple[bool, bool]: 

682 """Check if existing runtime environment already matches target versions. 

683 

684 Runs a single docker compose run to check both Python env and fnm Node 

685 installation. This avoids expensive checks when versions haven't changed. 

686 

687 Returns: 

688 (env_current, node_current) — True if already correct, False if rebuild needed. 

689 """ 

690 if not target_python and not target_node: 

691 return False, False 

692 

693 fragments = [] 

694 if target_python: 

695 fragments.append( 

696 f""" 

697# Check 1 — UV Python install cache 

698UV_OK=false 

699UV_PY_DIR=/workspace/frappe-bench/.uv/python 

700if [ -d "$UV_PY_DIR" ]; then 

701 PYTHON_DIRS=$(ls -1d "$UV_PY_DIR"/cpython-{target_python}* 2>/dev/null) 

702 if [ -n "$PYTHON_DIRS" ]; then 

703 UV_OK=true 

704 fi 

705fi 

706 

707# Check 2 — Virtual environment built from that python 

708VENV_OK=false 

709if [ -d /workspace/frappe-bench/env ] && [ -f /workspace/frappe-bench/env/bin/python ]; then 

710 PY_VER=$(/workspace/frappe-bench/env/bin/python --version 2>&1) 

711 if echo "$PY_VER" | grep -q "Python {target_python}"; then 

712 VENV_OK=true 

713 fi 

714fi 

715 

716# Both must be true — only the FINAL line uses "ENV_OK=" so the 

717# Python side can reliably parse the combined result. 

718if [ "$UV_OK" = "true" ] && [ "$VENV_OK" = "true" ]; then 

719 echo "ENV_OK=true" 

720else 

721 echo "ENV_OK=false" 

722fi 

723""" 

724 ) 

725 else: 

726 fragments.append('echo "ENV_OK=false"\n') 

727 

728 if target_node: 

729 fragments.append( 

730 f""" 

731if fnm list 2>/dev/null | grep -q "v{target_node}"; then 

732 echo "NODE_OK=true" 

733else 

734 echo "NODE_OK=false" 

735fi 

736""" 

737 ) 

738 else: 

739 fragments.append('echo "NODE_OK=false"\n') 

740 

741 check_script = "set -x\n" + "".join(fragments) 

742 

743 self.logger.debug("[_check_runtime_current] Checking current runtime state...") 

744 try: 

745 result = bench.compose.run( 

746 service="frappe", 

747 command=f"bash -c {shlex.quote(check_script)}", 

748 rm=True, 

749 entrypoint="/exec-entrypoint.sh", 

750 ) 

751 except Exception: 

752 self.logger.debug("[_check_runtime_current] Docker check failed, assuming rebuild needed") 

753 return False, False 

754 

755 if not isinstance(result, SubprocessOutput) or result.exit_code != 0: 

756 return False, False 

757 

758 output = " ".join(result.combined) 

759 env_current = "ENV_OK=true" in output 

760 node_current = "NODE_OK=true" in output 

761 self.logger.debug( 

762 f"[_check_runtime_current] env_current={env_current}, node_current={node_current}", 

763 ) 

764 return env_current, node_current 

765 

766 def _rebuild_runtime_environment(self, bench: MigrationBench): 

767 """Rebuild Python/Node environment using uv/fnm (current version runtime system). 

768 

769 Idempotent: skips the entire rebuild if Python/Node versions haven't 

770 changed and the existing environment is healthy. Only the supervisor 

771 config regeneration and service restart still run when needed. 

772 """ 

773 self.logger.info(f"[_rebuild_runtime_environment] Starting for {bench.name}") 

774 

775 # IMPORTANT: read prev versions BEFORE _resolve_runtime_versions. 

776 # That method auto-detects and *writes* versions to config when they 

777 # are missing. If we read after it we would always see a value and 

778 # the ``prev_python is None`` guard below would never fire. 

779 bench_config_path = bench.path / "bench_config.toml" 

780 prev_python = None 

781 prev_node = None 

782 if bench_config_path.exists(): 

783 cfg = tomlkit.parse(bench_config_path.read_text()) 

784 prev_python = cfg.get("python_version") 

785 prev_node = cfg.get("node_version") 

786 

787 target_python, target_node, _config_doc = self._resolve_runtime_versions(bench) 

788 

789 # Compute "version changed" flags. When both prev and target come from 

790 # the same config field this will always be False on re-run (they match). 

791 # The authoritative check is _check_runtime_current below. 

792 self._python_version_changed = prev_python is not None and str(prev_python) != str(target_python) 

793 self._node_version_changed = prev_node is not None and str(prev_node) != str(target_node) 

794 

795 # Authoritative check: verify actual runtime state matches config. 

796 # Catches cases like user manually editing config, env corruption, etc. 

797 env_current, node_current = self._check_runtime_current(bench, target_python, target_node) 

798 

799 # ── Early return when everything is already current ───────────────── 

800 if env_current and node_current: 

801 self.output.print("Runtime environment already up to date") 

802 

803 # Still restart if images were updated (e.g. dev → stable tag) 

804 if self._images_updated and (bench.running or bench.workers_running): 

805 self._restart_services(bench) 

806 return 

807 

808 # ── Full rebuild path ─────────────────────────────────────────────── 

809 with spinner(self.output, "Rebuilding runtime environment (pyenv/nvm → uv/fnm)"): # type: ignore[arg-type] 

810 self._ensure_runtime_dirs(bench) 

811 

812 self.output.print("Cleaning up old runtime directories...") 

813 self._cleanup_old_runtime_dirs(bench) 

814 

815 # Decide what needs rebuilding based on BOTH the config comparison 

816 # and the actual runtime check. First run (prev is None) always 

817 # triggers a rebuild. --rerun does NOT force a rebuild — the 

818 # runtime is only rebuilt when versions actually changed or the 

819 # existing environment is corrupted. 

820 self._env_was_rebuilt = (prev_python is None) or self._python_version_changed or not env_current 

821 self._node_was_setup = (prev_node is None) or self._node_version_changed or not node_current 

822 

823 if self._env_was_rebuilt and target_python: 

824 # Backup existing env/ before recreating (for rollback support). 

825 # Uses the same decision path as the rebuild guard, so the backup 

826 # always matches whether the env will actually be rebuilt. 

827 self._backup_env_for_rollback(bench) 

828 self.output.print(f"Setting up Python {target_python} with uv...") 

829 self._setup_python_with_uv(bench, target_python) 

830 

831 if self._node_was_setup and target_node: 

832 self.output.print(f"Setting up Node {target_node} with fnm...") 

833 self._setup_node_with_fnm(bench, target_node) 

834 

835 self.output.print("Reinstalling apps and rebuilding assets...") 

836 self._reinstall_apps_and_rebuild(bench) 

837 

838 self.output.print("Regenerating supervisor configuration...") 

839 self._regenerate_supervisor_config(bench) 

840 

841 if bench.running or bench.workers_running: 

842 self.output.print("Recreating & restarting services (force-recreate)...") 

843 self._restart_services(bench) 

844 

845 self.output.print("Runtime environment rebuilt successfully") 

846 self.logger.info(f"[_rebuild_runtime_environment] Completed successfully for {bench.name}") 

847 

848 def _setup_python_with_uv(self, bench: MigrationBench, python_version: str): 

849 """Setup Python using uv python manager.""" 

850 self.logger.debug(f"[_setup_python_with_uv] Starting Python {python_version} setup for {bench.name}") 

851 

852 quoted_pkg = shlex.quote(f"cpython-{python_version}") 

853 

854 setup_script = f""" 

855cd /workspace/frappe-bench 

856if [ -d env ]; then 

857 echo "Backing up old venv..." 

858 rm -rf env.bak 2>/dev/null || true 

859 mv env env.bak 

860fi 

861 

862echo "Installing Python {python_version} via uv..." 

863export UV_PYTHON_INSTALL_DIR=/workspace/frappe-bench/.uv/python 

864uv python install {quoted_pkg} 

865 

866echo "Detecting installed Python..." 

867PYTHON_DIR=$(ls -1d /workspace/frappe-bench/.uv/python/{quoted_pkg}* 2>/dev/null | sort -V | tail -1 || echo "") 

868if [ -z "$PYTHON_DIR" ]; then 

869 echo "Error: Could not find installed Python" 

870 exit 1 

871fi 

872PYTHON_BASENAME=$(basename "$PYTHON_DIR") 

873 

874echo "Updating python-default symlink..." 

875cd /workspace/frappe-bench/.uv 

876rm -f python-default 

877ln -sf "python/$PYTHON_BASENAME" python-default 

878 

879echo "Creating new venv with $PYTHON_BASENAME..." 

880cd /workspace/frappe-bench 

881uv venv env --clear --python "$PYTHON_BASENAME" --seed --link-mode=copy 

882 

883echo "Python environment setup complete" 

884echo "Verifying env directory..." 

885ls -la env/ || echo "ERROR: env directory not found!" 

886""" 

887 self.logger.debug("[_setup_python_with_uv] Executing docker compose run...") 

888 

889 try: 

890 result = bench.compose.run( 

891 service="frappe", 

892 command=f"bash -c {shlex.quote(setup_script)}", 

893 rm=True, 

894 entrypoint="/exec-entrypoint.sh", 

895 ) 

896 except Exception as e: 

897 error_str = str(e) 

898 if "network" in error_str.lower() and ( 

899 "not found" in error_str.lower() or "could not be found" in error_str.lower() 

900 ): 

901 self.logger.error(f"[_setup_python_with_uv] Docker network error: {e}") 

902 raise Exception( 

903 "Docker network not found. Global services may not be running. Try: fm services start", 

904 ) from e 

905 raise 

906 

907 if not isinstance(result, SubprocessOutput): 

908 self.logger.error("[_setup_python_with_uv] Unexpected streaming output received") 

909 raise Exception("Unexpected streaming output received") 

910 

911 self.logger.debug(f"[_setup_python_with_uv] Exit code: {result.exit_code}") 

912 self.logger.debug(f"[_setup_python_with_uv] Output: {result.combined}") 

913 

914 if result.exit_code != 0: 

915 self.logger.error(f"[_setup_python_with_uv] Python setup failed with exit code {result.exit_code}") 

916 self.logger.error(f"[_setup_python_with_uv] Output: {result.combined}") 

917 raise Exception(f"Python setup failed with exit code {result.exit_code}") 

918 

919 self.logger.debug("[_setup_python_with_uv] Python setup completed successfully") 

920 

921 def _setup_node_with_fnm(self, bench: MigrationBench, node_version: str): 

922 """Setup Node using fnm node manager.""" 

923 self.logger.debug(f"[_setup_node_with_fnm] Starting Node {node_version} setup for {bench.name}") 

924 

925 setup_script = f""" 

926echo "Checking if Node {node_version} is installed..." 

927if fnm list | grep -q "v{node_version}"; then 

928 echo "Node {node_version} already installed" 

929else 

930 echo "Installing Node {node_version} via fnm..." 

931 fnm install {node_version} 

932fi 

933 

934echo "Setting Node {node_version} as default..." 

935fnm default {node_version} 

936 

937echo "Verifying yarn is available (auto-enabled by FNM_COREPACK_ENABLED)..." 

938if yarn --version >/dev/null 2>&1; then 

939 echo "Yarn is available" 

940else 

941 echo "WARNING: Yarn not available after Node installation - corepack may have failed" 

942fi 

943 

944echo "Node environment setup complete" 

945""" 

946 self.logger.debug("[_setup_node_with_fnm] Executing docker compose run...") 

947 

948 result = bench.compose.run( 

949 service="frappe", 

950 command=f"bash -c {shlex.quote(setup_script)}", 

951 rm=True, 

952 entrypoint="/exec-entrypoint.sh", 

953 ) 

954 

955 if not isinstance(result, SubprocessOutput): 

956 self.logger.error("[_setup_node_with_fnm] Unexpected streaming output received") 

957 raise Exception("Unexpected streaming output received") 

958 

959 self.logger.debug(f"[_setup_node_with_fnm] Exit code: {result.exit_code}") 

960 self.logger.debug(f"[_setup_node_with_fnm] Output: {result.combined}") 

961 

962 if result.exit_code != 0: 

963 self.logger.error(f"[_setup_node_with_fnm] Node setup failed with exit code {result.exit_code}") 

964 self.logger.error(f"[_setup_node_with_fnm] Output: {result.combined}") 

965 raise Exception(f"Node setup failed with exit code {result.exit_code}") 

966 

967 self.logger.debug("[_setup_node_with_fnm] Node setup completed successfully") 

968 

969 def _ensure_runtime_dirs(self, bench: MigrationBench): 

970 """Ensure runtime directories exist on host with correct ownership.""" 

971 from frappe_manager.utils.docker import fix_host_path_ownership 

972 

973 frappe_bench_dir = bench.path / "workspace" / "frappe-bench" 

974 (frappe_bench_dir / ".uv").mkdir(parents=True, exist_ok=True) 

975 (frappe_bench_dir / ".fnm").mkdir(parents=True, exist_ok=True) 

976 

977 # Fix ownership if Docker created them as root (volume mount point creation) 

978 fix_host_path_ownership( 

979 paths=[frappe_bench_dir / ".uv", frappe_bench_dir / ".fnm"], 

980 output=self.output, 

981 ) 

982 

983 def _cleanup_old_runtime_dirs(self, bench: MigrationBench): 

984 """Remove old pyenv and nvm directories to prevent path conflicts.""" 

985 self.logger.debug(f"[_cleanup_old_runtime_dirs] Cleaning up old runtime directories for {bench.name}") 

986 

987 # Backup .pyenv, .nvm, and .bashrc on the host before removing 

988 # (rollback support via BackupManager). 

989 frappe_bench_dir = bench.path / "workspace" / "frappe-bench" 

990 for dirname in [".pyenv", ".nvm"]: 

991 dirpath = frappe_bench_dir / dirname 

992 if dirpath.exists(): 

993 self.backup_manager.backup(dirpath, bench_name=bench.name) 

994 

995 # .bashrc lives at /workspace/.bashrc inside the container 

996 # (mounted from bench.path/workspace on the host). 

997 bashrc_host = bench.path / "workspace" / ".bashrc" 

998 if bashrc_host.exists(): 

999 self.backup_manager.backup(bashrc_host, bench_name=bench.name) 

1000 

1001 cleanup_script = """ 

1002echo "Removing old runtime directories..." 

1003rm -f /workspace/.bashrc 2>/dev/null || true 

1004rm -rf /workspace/.pyenv 2>/dev/null || true 

1005rm -rf /workspace/.nvm 2>/dev/null || true 

1006echo "Old runtime directories cleaned up" 

1007""" 

1008 self.logger.debug("[_cleanup_old_runtime_dirs] Executing docker compose run...") 

1009 

1010 result = bench.compose.run( 

1011 service="frappe", 

1012 command=f"bash -c {shlex.quote(cleanup_script)}", 

1013 rm=True, 

1014 entrypoint="/exec-entrypoint.sh", 

1015 ) 

1016 

1017 if not isinstance(result, SubprocessOutput): 

1018 self.logger.error("[_cleanup_old_runtime_dirs] Unexpected streaming output received") 

1019 raise Exception("Unexpected streaming output received") 

1020 

1021 self.logger.debug(f"[_cleanup_old_runtime_dirs] Exit code: {result.exit_code}") 

1022 

1023 if result.exit_code != 0: 

1024 self.logger.warning("[_cleanup_old_runtime_dirs] Cleanup had non-zero exit, but continuing...") 

1025 else: 

1026 self.logger.debug("[_cleanup_old_runtime_dirs] Cleanup completed successfully") 

1027 

1028 def _reinstall_apps_and_rebuild(self, bench: MigrationBench): 

1029 """Reinstall apps into new venv and rebuild static assets. 

1030 

1031 Idempotent: only executes the sub-steps whose inputs actually changed: 

1032 - ``uv pip install -e apps/*`` only when the Python env was actually 

1033 rebuilt (``self._env_was_rebuilt``). 

1034 - ``bench setup requirements --node`` + ``bench build`` only when 

1035 Node was set up (``self._node_was_setup``). 

1036 """ 

1037 self.logger.debug(f"[_reinstall_apps_and_rebuild] Starting for {bench.name}") 

1038 

1039 if not self._env_was_rebuilt and not self._node_was_setup: 

1040 self.output.print("No env or Node changes — skipping app reinstall and build") 

1041 return 

1042 

1043 # Only check apps.txt when we actually need to reinstall apps 

1044 if self._env_was_rebuilt: 

1045 apps_txt_path = bench.path / "workspace" / "frappe-bench" / "sites" / "apps.txt" 

1046 

1047 if not apps_txt_path.exists(): 

1048 self.logger.warning(f"[_reinstall_apps_and_rebuild] No apps.txt found at {apps_txt_path}") 

1049 self.output.warning("No apps.txt found, skipping app reinstallation") 

1050 else: 

1051 installed_apps = [line.strip() for line in apps_txt_path.read_text().splitlines() if line.strip()] 

1052 

1053 if not installed_apps: 

1054 self.logger.warning("[_reinstall_apps_and_rebuild] No apps in apps.txt") 

1055 self.output.warning("No apps found in apps.txt") 

1056 

1057 self.logger.debug( 

1058 f"[_reinstall_apps_and_rebuild] env_rebuilt={self._env_was_rebuilt}, node_setup={self._node_was_setup}", 

1059 ) 

1060 

1061 # Build the script conditionally based on which inputs changed 

1062 script_parts = [ 

1063 "set -x", 

1064 "cd /workspace/frappe-bench", 

1065 "# Source bashrc to load fnm environment (node/yarn in PATH)", 

1066 "source /etc/bash.bashrc", 

1067 ] 

1068 

1069 if self._env_was_rebuilt: 

1070 script_parts.append( 

1071 """ 

1072echo "Reinstalling apps into new venv..." 

1073for app in $(ls -1 apps); do 

1074 if [ -d "apps/$app" ]; then 

1075 echo "Installing $app..." 

1076 uv pip install --python env/bin/python --no-cache-dir -e "apps/$app" || \ 

1077 ./env/bin/pip install --no-cache-dir -e "apps/$app" 

1078 fi 

1079done""", 

1080 ) 

1081 

1082 if self._node_was_setup: 

1083 script_parts.append( 

1084 """ 

1085echo "Installing Node dependencies..." 

1086bench setup requirements --node 

1087 

1088echo "Building static assets..." 

1089bench build""", 

1090 ) 

1091 

1092 script_parts.append('\necho "Apps reinstalled and assets built successfully"') 

1093 reinstall_script = "\n".join(script_parts) 

1094 

1095 self.logger.debug("[_reinstall_apps_and_rebuild] Executing docker compose run...") 

1096 

1097 result = bench.compose.run( 

1098 service="frappe", 

1099 command=f"bash -c {shlex.quote(reinstall_script)}", 

1100 rm=True, 

1101 entrypoint="/exec-entrypoint.sh", 

1102 ) 

1103 

1104 if not isinstance(result, SubprocessOutput): 

1105 self.logger.error("[_reinstall_apps_and_rebuild] Unexpected streaming output received") 

1106 raise Exception("Unexpected streaming output received") 

1107 

1108 self.logger.debug(f"[_reinstall_apps_and_rebuild] Exit code: {result.exit_code}") 

1109 output_str = " ".join(result.combined) 

1110 self.logger.debug(f"[_reinstall_apps_and_rebuild] Output length: {len(output_str)} chars") 

1111 

1112 if result.exit_code != 0: 

1113 self.logger.error(f"[_reinstall_apps_and_rebuild] Failed with exit code {result.exit_code}") 

1114 self.logger.error(f"[_reinstall_apps_and_rebuild] Full output: {output_str}") 

1115 raise Exception(f"App reinstallation failed with exit code {result.exit_code}") 

1116 

1117 self.logger.debug("[_reinstall_apps_and_rebuild] Completed successfully") 

1118 

1119 def _regenerate_supervisor_config(self, bench: MigrationBench): 

1120 """Regenerate supervisor configuration using FM's own template (no bench CLI).""" 

1121 import configparser 

1122 import io 

1123 import json 

1124 import multiprocessing 

1125 

1126 from jinja2 import Template 

1127 

1128 from frappe_manager.utils.helpers import get_template_path 

1129 

1130 # Render the supervisor config in memory using FM's own template. 

1131 # This avoids `bench setup supervisor` entirely — the FM template already 

1132 # has correct paths (0.0.0.0, .fnm node binary) so no post-processing needed. 

1133 frappe_bench_dir = bench.path / "workspace" / "frappe-bench" 

1134 common_site_config_path = frappe_bench_dir / "sites" / "common_site_config.json" 

1135 

1136 site_config: dict = {} 

1137 if common_site_config_path.exists(): 

1138 try: 

1139 site_config = json.loads(common_site_config_path.read_text()) 

1140 except Exception: 

1141 pass 

1142 

1143 cpu_count = multiprocessing.cpu_count() 

1144 gunicorn_workers = site_config.get("gunicorn_workers", (cpu_count * 2) + 1) 

1145 gunicorn_threads = site_config.get("gunicorn_threads", max(2, min(cpu_count, 4))) 

1146 max_requests = site_config.get("gunicorn_max_requests", 1000) 

1147 

1148 context = { 

1149 "bench_dir": "/workspace/frappe-bench", 

1150 "sites_dir": "/workspace/frappe-bench/sites", 

1151 "user": "frappe", 

1152 "use_rq": True, 

1153 "http_timeout": site_config.get("http_timeout", 120), 

1154 "node": "/workspace/frappe-bench/.fnm/aliases/default/bin/node", 

1155 "webserver_port": site_config.get("webserver_port", 80), 

1156 "gunicorn_workers": gunicorn_workers, 

1157 "gunicorn_threads": gunicorn_threads, 

1158 "gunicorn_max_requests": max_requests, 

1159 "gunicorn_max_requests_jitter": int(max_requests * 0.1), 

1160 "bench_name": "frappe-bench", 

1161 "background_workers": site_config.get("background_workers") or 1, 

1162 "bench_cmd": "/opt/user/.bin/bench", 

1163 "workers": site_config.get("workers", {}), 

1164 "multi_queue_consumption": True, 

1165 "supervisor_startretries": 10, 

1166 } 

1167 

1168 template_path = get_template_path("supervisor.conf.tmpl") 

1169 rendered = Template(template_path.read_text()).render(**context) 

1170 

1171 config = configparser.ConfigParser(allow_no_value=True, strict=False, interpolation=None) 

1172 config.read_string(rendered) 

1173 

1174 config_dir = frappe_bench_dir / "config" 

1175 config_dir.mkdir(parents=True, exist_ok=True) 

1176 

1177 for section in config.sections(): 

1178 if section.startswith("group:"): 

1179 continue 

1180 

1181 section_config = configparser.ConfigParser(allow_no_value=True, strict=False, interpolation=None) 

1182 section_config.add_section(section) 

1183 for key, value in config.items(section): 

1184 section_config.set(section, key, value) 

1185 

1186 delimiter = "-node-" if "-node-" in section else "-frappe-" 

1187 file_name_prefix = section.split(delimiter)[-1] 

1188 file_name = ( 

1189 file_name_prefix + ".workers.fm.supervisor.conf" 

1190 if "worker" in section 

1191 else file_name_prefix + ".fm.supervisor.conf" 

1192 ) 

1193 

1194 buf = io.StringIO() 

1195 section_config.write(buf) 

1196 (config_dir / file_name).write_text(buf.getvalue()) 

1197 

1198 # Generate fm-web-server.sh script (required by new supervisor config) 

1199 self._generate_fm_web_server_script(config_dir, context) 

1200 

1201 self.logger.debug(f"[_regenerate_supervisor_config] Done for {bench.name}") 

1202 

1203 def _generate_fm_web_server_script(self, config_dir: Path, context: dict): 

1204 """Generate fm-web-server.sh script required by the new supervisor config.""" 

1205 from jinja2 import Template 

1206 

1207 from frappe_manager.utils.helpers import get_template_path 

1208 

1209 gunicorn_args = ( 

1210 f"--bind 0.0.0.0:{context['webserver_port']}" 

1211 f" --workers {context['gunicorn_workers']}" 

1212 f" --threads {context.get('gunicorn_threads', 1)}" 

1213 f" --max-requests {context['gunicorn_max_requests']}" 

1214 f" --max-requests-jitter {context['gunicorn_max_requests_jitter']}" 

1215 f" -t {context['http_timeout']}" 

1216 f" --graceful-timeout 30" 

1217 f" frappe.app:application --preload" 

1218 ) 

1219 

1220 template_path = get_template_path("fm-web-server.sh.tmpl") 

1221 script = Template(template_path.read_text()).render( 

1222 bench_dir=context["bench_dir"], 

1223 gunicorn_args=gunicorn_args, 

1224 bench_name=context["bench_name"], 

1225 ) 

1226 

1227 wrapper_path = config_dir / "fm-web-server.sh" 

1228 

1229 # Track whether this is a new file vs pre-existing before we write 

1230 bench_name = context.get("bench_name") 

1231 was_pre_existing = wrapper_path.exists() 

1232 

1233 if was_pre_existing: 

1234 self.backup_manager.backup(wrapper_path, bench_name=bench_name) 

1235 

1236 wrapper_path.write_text(script) 

1237 wrapper_path.chmod(0o755) 

1238 

1239 if not was_pre_existing: 

1240 # Track for rollback cleanup (file didn't exist before migration) 

1241 self.backup_manager.track_new_file(wrapper_path) 

1242 

1243 self.output.print("Generated fm-web-server.sh") 

1244 

1245 def _restart_services(self, bench: MigrationBench): 

1246 try: 

1247 # Delete stale nginx default.conf so entrypoint regenerates with new SITE_MAPPINGS 

1248 nginx_default_conf = bench.path / "configs" / "nginx" / "conf" / "conf.d" / "default.conf" 

1249 if nginx_default_conf.exists(): 

1250 # Backup before deletion 

1251 backup_path = bench.path / "configs" / "nginx" / "conf" / "conf.d" / "default.conf.migration.bak" 

1252 import shutil 

1253 

1254 shutil.copy2(str(nginx_default_conf), str(backup_path)) 

1255 nginx_default_conf.unlink() 

1256 self.output.print("Backed up and removed stale nginx default.conf for regeneration") 

1257 

1258 bench.compose.up(services=["frappe", "socketio", "schedule", "nginx"], force_recreate=True, detach=True) 

1259 

1260 if bench.workers_running: 

1261 bench.workers_docker.compose.up(force_recreate=True, detach=True) 

1262 

1263 except Exception as e: 

1264 self.output.warning(f"Service restart (force-recreate) failed: {e}") 

1265 self.output.warning(f"Please restart services manually: fm restart {bench.name}") 

1266 

1267 def _auto_detect_runtime_versions(self, bench: MigrationBench) -> tuple[str | None, str | None]: 

1268 """ 

1269 Auto-detect Python/Node versions using multi-source strategy. 

1270 

1271 Priority: 

1272 1. Current container runtime (what's actually installed) 

1273 2. Frappe pyproject.toml/package.json requirements 

1274 3. Validate compatibility and choose best version 

1275 

1276 Returns: 

1277 (python_version, node_version) - versions to use for rebuild 

1278 """ 

1279 from frappe_manager.site_manager.bench_config import ( 

1280 extract_node_version_requirement, 

1281 extract_python_version_requirement, 

1282 ) 

1283 

1284 current_python = None 

1285 current_node = None 

1286 

1287 try: 

1288 result = bench.compose.run( 

1289 service="frappe", 

1290 command="bash -c '/workspace/frappe-bench/env/bin/python --version 2>&1'", 

1291 rm=True, 

1292 entrypoint="/exec-entrypoint.sh", 

1293 ) 

1294 if isinstance(result, SubprocessOutput) and result.exit_code == 0: 

1295 import re 

1296 

1297 output = " ".join(result.combined) 

1298 match = re.search(r"Python (\d+)\.(\d+)\.(\d+)", output) 

1299 if match: 

1300 major, minor = match.group(1), match.group(2) 

1301 current_python = f"{major}.{minor}" 

1302 self.output.print(f"Detected current Python: {current_python}") 

1303 except Exception as e: 

1304 self.logger.debug(f"Could not detect current Python (expected during migration): {e}") 

1305 

1306 try: 

1307 result = bench.compose.run( 

1308 service="frappe", 

1309 command="bash -c 'node --version'", 

1310 rm=True, 

1311 entrypoint="/exec-entrypoint.sh", 

1312 ) 

1313 if isinstance(result, SubprocessOutput) and result.exit_code == 0: 

1314 import re 

1315 

1316 output = " ".join(result.combined) 

1317 match = re.search(r"v(\d+)\.\d+\.\d+", output) 

1318 if match: 

1319 current_node = match.group(1) 

1320 self.output.print(f"Detected current Node: {current_node}") 

1321 except Exception as e: 

1322 self.logger.debug(f"Could not detect current Node (expected during migration): {e}") 

1323 

1324 frappe_app_path = bench.path / "workspace" / "frappe-bench" / "apps" / "frappe" 

1325 

1326 frappe_python_req = None 

1327 frappe_node_req = None 

1328 

1329 if frappe_app_path.exists(): 

1330 frappe_python_req = extract_python_version_requirement(frappe_app_path) 

1331 frappe_node_req = extract_node_version_requirement(frappe_app_path) 

1332 

1333 if frappe_python_req: 

1334 self.output.print(f"Frappe requires Python: {frappe_python_req}") 

1335 if frappe_node_req: 

1336 self.output.print(f"Frappe requires Node: {frappe_node_req}") 

1337 

1338 final_python = self._choose_best_python_version(current_python, frappe_python_req) 

1339 final_node = self._choose_best_node_version(current_node, frappe_node_req) 

1340 

1341 return final_python, final_node 

1342 

1343 def _choose_best_python_version(self, current: str | None, frappe_requirement: str | None) -> str | None: 

1344 """ 

1345 Choose best Python version based on current and Frappe requirements. 

1346 

1347 Strategy: 

1348 1. If Frappe requires specific version → check if current satisfies 

1349 2. If current version satisfies Frappe requirement → keep current 

1350 3. If current version too old → upgrade to Frappe minimum 

1351 4. If no Frappe requirement → keep current (if valid) or default to 3.11 

1352 """ 

1353 import re 

1354 

1355 from frappe_manager.site_manager.bench_config import parse_python_version_for_runtime 

1356 

1357 frappe_min_version = None 

1358 if frappe_requirement: 

1359 frappe_min_version = parse_python_version_for_runtime(frappe_requirement) 

1360 if frappe_min_version: 

1361 self.output.print(f"Frappe minimum Python: {frappe_min_version}") 

1362 

1363 if current: 

1364 current_tuple = tuple(map(int, current.split("."))) 

1365 

1366 if frappe_requirement and frappe_min_version: 

1367 match_min = re.search(r">=(\d+)\.(\d+)", frappe_requirement) 

1368 match_max = re.search(r"<(\d+)\.(\d+)", frappe_requirement) 

1369 

1370 if match_min: 

1371 min_ver = (int(match_min.group(1)), int(match_min.group(2))) 

1372 

1373 if match_max: 

1374 max_ver = (int(match_max.group(1)), int(match_max.group(2))) 

1375 

1376 if min_ver <= current_tuple < max_ver: 

1377 self.output.print(f"Current Python {current} satisfies Frappe requirement") 

1378 return current 

1379 self.output.print( 

1380 f"Current Python {current} doesn't satisfy requirement, upgrading to {frappe_min_version}", 

1381 ) 

1382 return frappe_min_version 

1383 if current_tuple >= min_ver: 

1384 self.output.print(f"Current Python {current} satisfies Frappe requirement") 

1385 return current 

1386 self.output.print( 

1387 f"Current Python {current} doesn't satisfy requirement, upgrading to {frappe_min_version}", 

1388 ) 

1389 return frappe_min_version 

1390 

1391 if current_tuple >= (3, 10): 

1392 self.output.print(f"Keeping current Python {current}") 

1393 return current 

1394 

1395 if frappe_min_version: 

1396 self.output.print(f"Using Frappe minimum Python: {frappe_min_version}") 

1397 return frappe_min_version 

1398 

1399 self.output.print("Using safe default Python: 3.11") 

1400 return "3.11" 

1401 

1402 def _choose_best_node_version(self, current: str | None, frappe_requirement: str | None) -> str | None: 

1403 """ 

1404 Choose best Node version based on current and Frappe requirements. 

1405 

1406 Strategy similar to Python version selection. 

1407 """ 

1408 

1409 from frappe_manager.site_manager.bench_config import parse_node_version_for_runtime 

1410 

1411 frappe_min_version = None 

1412 if frappe_requirement: 

1413 frappe_min_version = parse_node_version_for_runtime(frappe_requirement) 

1414 if frappe_min_version: 

1415 self.output.print(f"Frappe minimum Node: {frappe_min_version}") 

1416 

1417 if current: 

1418 current_major = int(current) 

1419 

1420 if frappe_min_version: 

1421 frappe_major = int(frappe_min_version) 

1422 

1423 if current_major >= frappe_major: 

1424 self.output.print(f"Current Node {current} satisfies Frappe requirement") 

1425 return current 

1426 self.output.print( 

1427 f"Current Node {current} doesn't satisfy requirement, upgrading to {frappe_min_version}", 

1428 ) 

1429 return frappe_min_version 

1430 

1431 if current_major >= 18: 

1432 self.output.print(f"Keeping current Node {current}") 

1433 return current 

1434 

1435 if frappe_min_version: 

1436 self.output.print(f"Using Frappe minimum Node: {frappe_min_version}") 

1437 return frappe_min_version 

1438 

1439 self.output.print("Using safe default Node: 18") 

1440 return "18"