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
« 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.
4v0.19.0 supports migrations from v0.18.0 and later only.
5Users on older versions must upgrade to v0.18.0 first.
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"""
14import json
15import shlex
16from pathlib import Path
17from typing import Any, cast
19import tomlkit
20from ruamel.yaml import YAML
21from ruamel.yaml.scalarstring import DoubleQuotedScalarString
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
30class MigrationV0190(MigrationBase):
31 version = Version("0.19.0")
33 def bench_basic_backup(self, bench: MigrationBench):
34 """
35 Override parent to add additional backups for runtime rebuild.
37 Backs up:
38 - supervisor.conf and *.fm.supervisor.conf (regenerated during rebuild)
39 - nginx default.conf (may be modified during migration)
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)
46 if self.migration_executor.skip_backup or bench.name in self.migration_executor.skip_backup_for:
47 return
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")
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}")
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")
66 def _backup_env_for_rollback(self, bench: MigrationBench):
67 """Move existing env/ to env.backup.migration for rollback support.
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
76 env_backup_path = bench.path / "workspace" / "frappe-bench" / "env.backup.migration"
77 import shutil
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")
84 def undo_bench_migrate(self, bench: MigrationBench):
85 """
86 Rollback bench changes on migration failure.
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
93 env_dir = bench.path / "workspace" / "frappe-bench" / "env"
94 env_backup_path = bench.path / "workspace" / "frappe-bench" / "env.backup.migration"
96 if env_backup_path.exists():
97 if env_dir.exists():
98 self.output.print("Removing new env/")
99 shutil.rmtree(env_dir)
101 self.output.print("Restoring env/ from env.backup.migration")
102 shutil.move(str(env_backup_path), str(env_dir))
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
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")
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)
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)
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)
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)
145 self._cleanup_admin_tools_nginx_config(bench)
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)
154 self._rebuild_runtime_environment(bench)
156 self.output.print(f"Successfully migrated {bench.name} to {self.version.version_string()}")
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")
162 content = config_path.read_text()
163 doc = tomlkit.parse(content)
165 self._transform_ssl_config(doc, bench.name)
166 self._add_new_config_fields(doc)
168 config_path.write_text(tomlkit.dumps(doc))
169 self.output.print("Updated SSL configuration format")
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
176 old_ssl = doc["ssl"]
178 if isinstance(old_ssl, list):
179 return
181 old_ssl_dict = cast("dict[str, Any]", old_ssl)
183 ssl_type_value = old_ssl_dict.get("ssl_type", "letsencrypt")
184 hsts_value = old_ssl_dict.get("hsts", "off")
186 challenge_type_value = old_ssl_dict.get("preferred_challenge") or old_ssl_dict.get("challenge_type") or "http01"
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 }
196 self._move_dns_credentials(doc, old_ssl_dict)
198 del doc["ssl"]
199 doc["ssl_certificates"] = [ssl_cert]
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")
206 if not (api_token or api_key):
207 return
209 dns_providers_table = tomlkit.table()
210 cloudflare_table = tomlkit.table()
212 if api_token:
213 cloudflare_table["api_token"] = api_token
214 if api_key:
215 cloudflare_table["api_key"] = api_key
217 dns_providers_table["cloudflare"] = cloudflare_table
218 doc["dns_providers"] = dns_providers_table
220 self.output.print("Migrated DNS credentials to dns_providers.cloudflare")
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"] = []
227 if "upload_limit" not in doc:
228 doc["upload_limit"] = "50M"
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"
234 if "use_uv" not in doc:
235 doc["use_uv"] = True
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")
241 yaml = YAML(typ="rt")
242 yaml.preserve_quotes = True
243 yaml.default_flow_style = False
245 with open(compose_path) as f:
246 compose_data = yaml.load(f)
248 if not compose_data or "services" not in compose_data:
249 return
251 services = compose_data["services"]
253 self._update_service_images(services)
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")
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)
269 self._transform_nginx_environment(services, upload_limit)
270 self._add_restart_policy_to_services(services, restart_policy)
272 # Update x-version to current version (plain semver — no ``v`` prefix)
273 compose_data["x-version"] = str(self.version)
275 with open(compose_path, "w") as f:
276 yaml.dump(compose_data, f)
278 def _update_service_images(self, services: dict[str, Any]):
279 """Replace any existing version tag with runtime-determined version."""
280 import re
282 effective_tag = self._get_image_tag_for_migration()
284 version_pattern = re.compile(r"(ghcr\.io/rtcamp/frappe-manager-[^:]+):v[0-9]+\.[0-9]+\.[0-9]+(?:\.dev[0-9]+)?")
286 for service_name, service_config in services.items():
287 if "image" not in service_config:
288 continue
290 old_image = service_config["image"]
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"
295 new_image = version_pattern.sub(rf"\1:{effective_tag}", old_image)
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}")
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
307 nginx_env = services["nginx"]["environment"]
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)
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})")
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()
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)
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()}")
348 return new_env
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.
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)
361 def _resolve_upload_limit(self, bench: MigrationBench) -> str:
362 """Resolve upload limit, respecting existing site_config.json max_file_size.
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
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
400 # 3. Default
401 self.output.print("Using default upload_limit: 50M")
402 return "50M"
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
408 # Global nginx-proxy vhostd directory
409 vhostd_dir = bench.path.parent.parent / "services" / "nginx-proxy" / "vhostd"
411 if not vhostd_dir.exists():
412 self.output.print("Warning: nginx-proxy vhostd directory not found, skipping upload limit config")
413 return
415 domains = [bench.name]
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)
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)
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)")
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
439 site_config_path = bench.path / "workspace" / "frappe-bench" / "sites" / "common_site_config.json"
440 if not site_config_path.exists():
441 return
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
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
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)
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")
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
471 custom_conf_dir = bench.path / "configs" / "nginx" / "conf" / "custom"
472 custom_conf_dir.mkdir(parents=True, exist_ok=True)
474 upload_limit_conf = custom_conf_dir / "upload-limit.conf"
476 # Track whether this is a new file vs pre-existing before we write
477 was_pre_existing = upload_limit_conf.exists()
479 if was_pre_existing:
480 self.backup_manager.backup(upload_limit_conf, bench_name=bench.name)
482 upload_limit_conf.write_text(f"client_max_body_size {upload_limit.lower()};\n")
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)
488 self.output.print("Created custom nginx upload-limit.conf")
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")
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")
507 yaml = YAML(typ="rt")
508 yaml.preserve_quotes = True
509 yaml.default_flow_style = False
511 with open(compose_path) as f:
512 compose_data = yaml.load(f)
514 if not compose_data or "services" not in compose_data:
515 return
517 services = compose_data["services"]
519 self._update_service_images(services)
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)
529 # Update x-version to current version (plain semver — no ``v`` prefix)
530 compose_data["x-version"] = str(self.version)
532 with open(compose_path, "w") as f:
533 yaml.dump(compose_data, f)
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"
539 if admin_tools_config.exists():
540 admin_tools_config.unlink()
541 self.output.print("Cleaned up stale admin-tools nginx config")
543 if htpasswd_file.exists():
544 htpasswd_file.unlink()
546 def _pull_bench_images(self, bench: MigrationBench):
547 from frappe_manager.migration_manager.migration_exections import MigrationExceptionInBench
549 self.output.print(f"Pulling updated images ({self._get_image_tag_for_migration()})...", emoji_code="📦")
551 result = bench.compose.pull(stream=False)
553 if result.exit_code != 0:
554 raise MigrationExceptionInBench(f"Failed to pull images for {bench.name}. Docker pull failed.")
556 self.output.print("✓ Images ready", emoji_code="✅")
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
562 if not cf.exists():
563 self.logger.debug("[_update_global_nginx_proxy_image] Services compose not found, skipping")
564 return
566 services = cf.yml.get("services")
567 if not services:
568 return
570 nginx_service = services.get("global-nginx-proxy")
571 if not nginx_service or "image" not in nginx_service:
572 return
574 old_image = nginx_service["image"]
575 new_image = "jwilder/nginx-proxy:1.11"
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}")
583 def migrate_services(self):
584 """
585 Pull current version Docker images and update global-nginx-proxy image tag.
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
592 self._update_global_nginx_proxy_image()
594 self.logger.info(f"[migrate_services] Starting Docker image pull for {self.version.version_string()}")
596 with spinner(self.output, f"Pulling Docker images for {self.version.version_string()}"): # type: ignore[arg-type]
597 success = pull_docker_images()
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)
605 self.output.print(f"All {self.version.version_string()} images pulled successfully")
606 self.logger.info("[migrate_services] Docker image pull completed successfully")
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")
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()}")
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.
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.
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 )
641 bench_config_path = bench.path / "bench_config.toml"
642 config_doc = None
643 target_python = None
644 target_node = None
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 )
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 )
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")
674 return target_python, target_node, config_doc
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.
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.
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
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
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
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')
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')
741 check_script = "set -x\n" + "".join(fragments)
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
755 if not isinstance(result, SubprocessOutput) or result.exit_code != 0:
756 return False, False
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
766 def _rebuild_runtime_environment(self, bench: MigrationBench):
767 """Rebuild Python/Node environment using uv/fnm (current version runtime system).
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}")
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")
787 target_python, target_node, _config_doc = self._resolve_runtime_versions(bench)
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)
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)
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")
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
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)
812 self.output.print("Cleaning up old runtime directories...")
813 self._cleanup_old_runtime_dirs(bench)
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
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)
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)
835 self.output.print("Reinstalling apps and rebuilding assets...")
836 self._reinstall_apps_and_rebuild(bench)
838 self.output.print("Regenerating supervisor configuration...")
839 self._regenerate_supervisor_config(bench)
841 if bench.running or bench.workers_running:
842 self.output.print("Recreating & restarting services (force-recreate)...")
843 self._restart_services(bench)
845 self.output.print("Runtime environment rebuilt successfully")
846 self.logger.info(f"[_rebuild_runtime_environment] Completed successfully for {bench.name}")
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}")
852 quoted_pkg = shlex.quote(f"cpython-{python_version}")
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
862echo "Installing Python {python_version} via uv..."
863export UV_PYTHON_INSTALL_DIR=/workspace/frappe-bench/.uv/python
864uv python install {quoted_pkg}
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")
874echo "Updating python-default symlink..."
875cd /workspace/frappe-bench/.uv
876rm -f python-default
877ln -sf "python/$PYTHON_BASENAME" python-default
879echo "Creating new venv with $PYTHON_BASENAME..."
880cd /workspace/frappe-bench
881uv venv env --clear --python "$PYTHON_BASENAME" --seed --link-mode=copy
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...")
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
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")
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}")
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}")
919 self.logger.debug("[_setup_python_with_uv] Python setup completed successfully")
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}")
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
934echo "Setting Node {node_version} as default..."
935fnm default {node_version}
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
944echo "Node environment setup complete"
945"""
946 self.logger.debug("[_setup_node_with_fnm] Executing docker compose run...")
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 )
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")
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}")
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}")
967 self.logger.debug("[_setup_node_with_fnm] Node setup completed successfully")
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
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)
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 )
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}")
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)
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)
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...")
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 )
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")
1021 self.logger.debug(f"[_cleanup_old_runtime_dirs] Exit code: {result.exit_code}")
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")
1028 def _reinstall_apps_and_rebuild(self, bench: MigrationBench):
1029 """Reinstall apps into new venv and rebuild static assets.
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}")
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
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"
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()]
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")
1057 self.logger.debug(
1058 f"[_reinstall_apps_and_rebuild] env_rebuilt={self._env_was_rebuilt}, node_setup={self._node_was_setup}",
1059 )
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 ]
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 )
1082 if self._node_was_setup:
1083 script_parts.append(
1084 """
1085echo "Installing Node dependencies..."
1086bench setup requirements --node
1088echo "Building static assets..."
1089bench build""",
1090 )
1092 script_parts.append('\necho "Apps reinstalled and assets built successfully"')
1093 reinstall_script = "\n".join(script_parts)
1095 self.logger.debug("[_reinstall_apps_and_rebuild] Executing docker compose run...")
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 )
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")
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")
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}")
1117 self.logger.debug("[_reinstall_apps_and_rebuild] Completed successfully")
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
1126 from jinja2 import Template
1128 from frappe_manager.utils.helpers import get_template_path
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"
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
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)
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 }
1168 template_path = get_template_path("supervisor.conf.tmpl")
1169 rendered = Template(template_path.read_text()).render(**context)
1171 config = configparser.ConfigParser(allow_no_value=True, strict=False, interpolation=None)
1172 config.read_string(rendered)
1174 config_dir = frappe_bench_dir / "config"
1175 config_dir.mkdir(parents=True, exist_ok=True)
1177 for section in config.sections():
1178 if section.startswith("group:"):
1179 continue
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)
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 )
1194 buf = io.StringIO()
1195 section_config.write(buf)
1196 (config_dir / file_name).write_text(buf.getvalue())
1198 # Generate fm-web-server.sh script (required by new supervisor config)
1199 self._generate_fm_web_server_script(config_dir, context)
1201 self.logger.debug(f"[_regenerate_supervisor_config] Done for {bench.name}")
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
1207 from frappe_manager.utils.helpers import get_template_path
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 )
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 )
1227 wrapper_path = config_dir / "fm-web-server.sh"
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()
1233 if was_pre_existing:
1234 self.backup_manager.backup(wrapper_path, bench_name=bench_name)
1236 wrapper_path.write_text(script)
1237 wrapper_path.chmod(0o755)
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)
1243 self.output.print("Generated fm-web-server.sh")
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
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")
1258 bench.compose.up(services=["frappe", "socketio", "schedule", "nginx"], force_recreate=True, detach=True)
1260 if bench.workers_running:
1261 bench.workers_docker.compose.up(force_recreate=True, detach=True)
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}")
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.
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
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 )
1284 current_python = None
1285 current_node = None
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
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}")
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
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}")
1324 frappe_app_path = bench.path / "workspace" / "frappe-bench" / "apps" / "frappe"
1326 frappe_python_req = None
1327 frappe_node_req = None
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)
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}")
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)
1341 return final_python, final_node
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.
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
1355 from frappe_manager.site_manager.bench_config import parse_python_version_for_runtime
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}")
1363 if current:
1364 current_tuple = tuple(map(int, current.split(".")))
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)
1370 if match_min:
1371 min_ver = (int(match_min.group(1)), int(match_min.group(2)))
1373 if match_max:
1374 max_ver = (int(match_max.group(1)), int(match_max.group(2)))
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
1391 if current_tuple >= (3, 10):
1392 self.output.print(f"Keeping current Python {current}")
1393 return current
1395 if frappe_min_version:
1396 self.output.print(f"Using Frappe minimum Python: {frappe_min_version}")
1397 return frappe_min_version
1399 self.output.print("Using safe default Python: 3.11")
1400 return "3.11"
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.
1406 Strategy similar to Python version selection.
1407 """
1409 from frappe_manager.site_manager.bench_config import parse_node_version_for_runtime
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}")
1417 if current:
1418 current_major = int(current)
1420 if frappe_min_version:
1421 frappe_major = int(frappe_min_version)
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
1431 if current_major >= 18:
1432 self.output.print(f"Keeping current Node {current}")
1433 return current
1435 if frappe_min_version:
1436 self.output.print(f"Using Frappe minimum Node: {frappe_min_version}")
1437 return frappe_min_version
1439 self.output.print("Using safe default Node: 18")
1440 return "18"