Coverage for frappe_manager / commands / update.py: 11%
209 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
1from typing import Annotated
3import typer
4from typer_examples import example
6from frappe_manager import CLI_BENCHES_DIRECTORY, EnableDisableOptionsEnum
7from frappe_manager.metadata_manager import FMConfigManager
8from frappe_manager.output_manager import get_global_output_handler, spinner
9from frappe_manager.site_manager.bench_config import (
10 AppConfig,
11 FMBenchEnvType,
12 RestartPolicyEnum,
13 extract_node_version_requirement,
14 extract_python_version_requirement,
15 parse_node_version_for_runtime,
16 parse_python_version_for_runtime,
17 validate_node_version_compatibility,
18 validate_python_version_compatibility,
19)
20from frappe_manager.site_manager.domain_conflict import DomainConflictError, validate_domains_unique
21from frappe_manager.site_manager.exceptions import BenchNotRunning
22from frappe_manager.site_manager.site import Bench
23from frappe_manager.utils.callbacks import (
24 alias_domains_validation_callback,
25 sitename_callback,
26 sites_autocompletion_callback,
27)
30@example(
31 "Enable admin tools (Mailpit, Adminer)",
32 "{benchname} --admin-tools enable",
33 detail="Enables admin tools like Mailpit and Adminer for debugging and database access in development benches.",
34 benchname="mybench",
35)
36@example(
37 "Disable admin tools",
38 "{benchname} --admin-tools disable",
39 detail="Disables admin tools for security or production setups.",
40 benchname="mybench",
41)
42@example(
43 "Switch to production environment",
44 "{benchname} -e prod",
45 detail="Switches the bench to production environment settings and recreates necessary containers.",
46 benchname="mybench",
47)
48@example(
49 "Switch to development environment",
50 "{benchname} -e dev",
51 detail="Switches the bench to development environment settings and enables developer conveniences.",
52 benchname="mybench",
53)
54@example(
55 "Enable developer mode",
56 "{benchname} --developer-mode enable",
57 detail="Turns on Frappe developer mode which enables features useful for app development.",
58 benchname="mybench",
59)
60@example(
61 "Add alias domains",
62 "{benchname} --add-alias www.example.com,api.example.com",
63 detail="Adds alias domains to the bench; remember to add SSL certificates separately with 'fm ssl add'.",
64 benchname="mybench",
65)
66@example(
67 "Remove alias domains",
68 "{benchname} --remove-alias shop.example.com",
69 detail="Removes alias domains from bench configuration.",
70 benchname="mybench",
71)
72@example(
73 "Update Python version",
74 "{benchname} --python 3.11",
75 detail="Updates the bench Python runtime, recreates the virtual environment, and reinstalls all apps into the new environment.",
76 benchname="mybench",
77)
78@example(
79 "Install Python without recreating venv",
80 "{benchname} --python 3.14 --no-recreate-python-env",
81 detail="Installs Python 3.14 via uv and sets it as default without touching the existing virtual environment.",
82 benchname="mybench",
83)
84@example(
85 "Update Node version",
86 "{benchname} --node 20",
87 detail="Updates Node.js runtime used by the bench and rebuilds related assets.",
88 benchname="mybench",
89)
90@example(
91 "Set upload size limit",
92 "{benchname} --upload-limit 100M",
93 detail="Sets the maximum file upload size for the bench (useful for large attachments).",
94 benchname="mybench",
95)
96def update(
97 ctx: typer.Context,
98 benchname: Annotated[
99 str | None,
100 typer.Argument(
101 help="Name of the bench.",
102 autocompletion=sites_autocompletion_callback,
103 callback=sitename_callback,
104 ),
105 ] = None,
106 admin_tools: Annotated[
107 EnableDisableOptionsEnum | None,
108 typer.Option("--admin-tools", help="Toggle admin-tools.", show_default=False),
109 ] = None,
110 environment: Annotated[
111 FMBenchEnvType | None,
112 typer.Option("--environment", "-e", help="Switch bench environment.", show_default=False),
113 ] = None,
114 developer_mode: Annotated[
115 EnableDisableOptionsEnum | None,
116 typer.Option(help="Toggle frappe developer mode.", show_default=False),
117 ] = None,
118 mailpit_as_default_mail_server: Annotated[
119 bool,
120 typer.Option(
121 "--mailpit-as-default-mail-server",
122 help="Configure Mailpit as default mail server",
123 show_default=False,
124 ),
125 ] = False,
126 add_alias: Annotated[
127 str | None,
128 typer.Option(
129 "--add-alias",
130 help="Add alias domains to the site (comma-separated, e.g., www.example.com,api.example.com)",
131 callback=alias_domains_validation_callback,
132 show_default=False,
133 ),
134 ] = None,
135 remove_alias: Annotated[
136 str | None,
137 typer.Option(
138 "--remove-alias",
139 help="Remove alias domains from the site (comma-separated, e.g., shop.example.com)",
140 callback=alias_domains_validation_callback,
141 show_default=False,
142 ),
143 ] = None,
144 upload_limit: Annotated[
145 str | None,
146 typer.Option(
147 "--upload-limit",
148 help="Set maximum upload size for files (e.g., '50M', '100M', '500M', '1G')",
149 show_default=False,
150 ),
151 ] = None,
152 python_version: Annotated[
153 str | None,
154 typer.Option(
155 "--python",
156 help="Update Python version (e.g., '3.11', '3.12', '>=3.11,<3.14'). Will recreate virtual environment.",
157 show_default=False,
158 ),
159 ] = None,
160 node_version: Annotated[
161 str | None,
162 typer.Option(
163 "--node",
164 help="Update Node version (e.g., '18', '20', '>=18'). Will install and set as default.",
165 show_default=False,
166 ),
167 ] = None,
168 skip_version_check: Annotated[
169 bool,
170 typer.Option(
171 "--skip-version-check",
172 help="Skip validation of Python/Node versions against Frappe requirements. Use with caution.",
173 show_default=False,
174 ),
175 ] = False,
176 recreate_python_env: Annotated[
177 bool,
178 typer.Option(
179 "--recreate-python-env/--no-recreate-python-env",
180 help="Recreate the Python virtual environment. Use --no-recreate-python-env to skip if current version already satisfies the requirement.",
181 show_default=True,
182 ),
183 ] = True,
184 restart: Annotated[
185 RestartPolicyEnum | None,
186 typer.Option(
187 "--restart",
188 help="Update Docker restart policy for all bench services.",
189 show_default=False,
190 ),
191 ] = None,
192 allow_domain_conflicts: Annotated[
193 bool,
194 typer.Option(
195 "--allow-domain-conflicts",
196 help="Skip domain uniqueness validation when adding aliases (not recommended).",
197 show_default=False,
198 ),
199 ] = False,
200 newrelic: Annotated[
201 bool | None,
202 typer.Option(
203 "--newrelic/--no-newrelic",
204 help="Enable or disable NewRelic APM monitoring for the web process.",
205 show_default=False,
206 ),
207 ] = None,
208 newrelic_license_key: Annotated[
209 str | None,
210 typer.Option(
211 "--newrelic-license-key",
212 help="NewRelic ingest license key.",
213 show_default=False,
214 ),
215 ] = None,
216):
217 """
218 Update bench configuration and settings.
220 Adjusts environment type, developer mode, runtime versions, alias domains, and other bench settings.
221 """
223 services_manager = ctx.obj["services"]
224 fm_config: FMConfigManager = ctx.obj["fm_config_manager"]
226 output = get_global_output_handler()
227 logger = ctx.obj.get("logger")
228 bench = Bench.get_object(benchname, services_manager, logger=logger, output_handler=output)
230 bench_config_save = False
232 if not bench.running:
233 raise BenchNotRunning(bench_name=bench.name)
235 with spinner(output, "Updating bench configuration"):
236 if developer_mode:
237 if developer_mode == EnableDisableOptionsEnum.enable:
238 bench.bench_config.developer_mode = True
239 output.change_head("Enabling frappe developer mode")
240 bench.set_common_bench_config({"developer_mode": bench.bench_config.developer_mode})
241 output.print("Enabled frappe developer mode")
242 elif developer_mode == EnableDisableOptionsEnum.disable:
243 bench.bench_config.developer_mode = False
244 output.change_head("Disabling frappe developer mode")
245 bench.set_common_bench_config({"developer_mode": bench.bench_config.developer_mode})
246 output.print("Disabled frappe developer mode")
248 bench_config_save = True
250 if environment:
251 output.change_head(f"Switching bench environment to {environment.value}")
252 bench.bench_config.environment_type = environment
254 compose_inputs = bench.bench_config.export_to_compose_inputs()
255 compose_inputs.setdefault("environment", {}).setdefault("frappe", {})
256 compose_inputs["environment"]["frappe"]["FRAPPE_ENV"] = environment.value
258 bench.generate_compose(compose_inputs)
260 output.print("Recreating frappe container to apply environment change..")
261 bench.docker_client.compose.up(services=["frappe"], detach=True, force_recreate=True)
263 output.print(f"Switched bench environment to {environment.value}")
264 bench_config_save = True
266 if restart:
267 old_policy = bench.bench_config.restart_policy.value
268 if restart != bench.bench_config.restart_policy:
269 output.change_head(f"Updating restart policy from '{old_policy}' to '{restart.value}'")
271 if restart == RestartPolicyEnum.no and bench.bench_config.environment_type == FMBenchEnvType.prod:
272 output.warning("Setting restart policy to 'no' on production bench")
273 output.warning("Containers will not auto-recover from failures or system reboots")
275 bench.bench_config.restart_policy = restart
276 bench.generate_compose(bench.bench_config.export_to_compose_inputs())
278 if bench.workers.compose_file_manager.compose_path.exists():
279 bench.workers.generate_compose()
281 if bench.admin_tools.compose_file_manager.compose_path.exists():
282 db_host = bench.services.database_manager.database_server_info.host
283 bench.admin_tools.generate_compose(db_host)
285 output.print("Restarting containers to apply restart policy..")
286 bench.docker_client.compose.up(detach=True, force_recreate=True)
288 output.print(f"Updated restart policy to '{restart.value}'")
289 bench_config_save = True
290 else:
291 output.print(f"Restart policy is already set to '{restart.value}'")
293 if admin_tools:
294 if admin_tools == EnableDisableOptionsEnum.enable:
295 output.change_head("Enabling Admin-tools")
296 bench.bench_config.admin_tools = True
298 if not bench.admin_tools.compose_file_manager.compose_path.exists():
299 bench.sync_admin_tools_compose()
300 else:
301 bench.admin_tools.enable(force_configure=mailpit_as_default_mail_server)
303 bench_config_save = True
304 output.print("Enabled Admin-tools")
306 elif admin_tools == EnableDisableOptionsEnum.disable:
307 if (
308 not bench.admin_tools.compose_file_manager.compose_path.exists()
309 or not bench.bench_config.admin_tools
310 ):
311 output.print("Admin tools is already disabled")
312 return
313 bench.bench_config.admin_tools = False
314 bench.admin_tools.disable()
315 bench_config_save = True
317 if add_alias or remove_alias:
318 add_domains_list = add_alias if add_alias else []
319 remove_domains_list = remove_alias if remove_alias else []
321 if add_domains_list:
322 skip_check = allow_domain_conflicts or not fm_config.validation.enforce_domain_uniqueness
324 try:
325 validate_domains_unique(
326 add_domains_list,
327 benches_root=CLI_BENCHES_DIRECTORY,
328 exclude_bench=bench.name,
329 skip_check=skip_check,
330 )
331 except DomainConflictError as e:
332 output.display_error(str(e))
333 output.print("\nTo proceed anyway, use: --allow-domain-conflicts", emoji_code="")
334 raise typer.Exit(1)
336 output.change_head("Updating alias domains")
337 bench.update_alias_domains(add_domains=add_domains_list, remove_domains=remove_domains_list)
338 output.print("Alias domains updated successfully")
340 # Handle upload limit update
341 if upload_limit:
342 output.change_head(f"Updating upload size limit to {upload_limit}")
343 bench.update_upload_limit(upload_limit)
345 if newrelic is not None or newrelic_license_key is not None:
346 if newrelic is True and not (newrelic_license_key or bench.bench_config.newrelic_license_key):
347 raise typer.BadParameter("--newrelic-license-key is required when enabling NewRelic.")
349 if newrelic is not None:
350 bench.bench_config.newrelic_enabled = newrelic
352 if newrelic_license_key is not None:
353 bench.bench_config.newrelic_license_key = newrelic_license_key
355 bench.generate_compose(bench.bench_config.export_to_compose_inputs())
356 bench.save_bench_config()
357 bench_config_save = False
359 bench.supervisor.setup_newrelic(bench.path)
361 output.change_head("Restarting frappe container to apply NewRelic changes")
362 bench.docker_client.compose.up(services=["frappe"], detach=True, force_recreate=True)
363 output.print("NewRelic configuration updated")
365 if python_version or node_version:
366 frappe_app_path = bench.path / "workspace" / "frappe-bench" / "apps" / "frappe"
368 if python_version or node_version:
369 current_versions = bench.app_manager.get_current_runtime_versions(use_run=True)
371 if python_version:
372 old_python = current_versions.get("python") or "not set"
374 frappe_python_req = None
375 if frappe_app_path.exists():
376 frappe_python_req = extract_python_version_requirement(frappe_app_path)
377 if frappe_python_req and not skip_version_check:
378 is_compatible, error_msg = validate_python_version_compatibility(
379 python_version,
380 frappe_python_req,
381 )
382 if not is_compatible:
383 output.change_head("Python version validation failed")
384 output.print(f"Python: {old_python} -> {python_version}")
385 output.print(f"Frappe requires: {frappe_python_req}")
386 output.display_error(f"{error_msg}", emoji_code=":cross_mark:")
387 suggested = parse_python_version_for_runtime(frappe_python_req)
388 if suggested:
389 output.print(f"Hint: Try --python {suggested}", emoji_code=":light_bulb:")
390 output.print("Use --skip-version-check to bypass this validation (not recommended)")
391 raise typer.Exit(code=1)
393 bench.bench_config.python_version = python_version
394 output.change_head("Updating Python version")
395 output.print(f"Python: {old_python} -> {python_version}")
396 if frappe_python_req:
397 output.print(f"Frappe requires: {frappe_python_req}")
398 if skip_version_check:
399 new_compatible, _ = validate_python_version_compatibility(python_version, frappe_python_req)
400 if not new_compatible:
401 output.warning(f" Python {python_version} is incompatible with Frappe requirement")
402 suggested = parse_python_version_for_runtime(frappe_python_req)
403 if suggested:
404 output.warning(f" Consider using --python {suggested} instead")
406 bench_config_save = True
408 if node_version:
409 old_node = current_versions.get("node") or "not set"
410 frappe_node_req = None
411 if frappe_app_path.exists():
412 frappe_node_req = extract_node_version_requirement(frappe_app_path)
413 if frappe_node_req and not skip_version_check:
414 is_compatible, error_msg = validate_node_version_compatibility(node_version, frappe_node_req)
415 if not is_compatible:
416 output.change_head("Node version validation failed")
417 output.print(f"Node: {old_node} -> {node_version}")
418 output.print(f"Frappe requires: {frappe_node_req}")
419 output.display_error(f"{error_msg}", emoji_code=":cross_mark:")
421 suggested = parse_node_version_for_runtime(frappe_node_req)
422 if suggested:
423 output.print(f"Hint: Try --node {suggested}", emoji_code=":light_bulb:")
424 output.print("Use --skip-version-check to bypass this validation (not recommended)")
425 raise typer.Exit(code=1)
427 bench.bench_config.node_version = node_version
428 output.change_head("Updating Node version")
429 output.print(f"Node: {old_node} -> {node_version}")
430 if frappe_node_req:
431 output.print(f"Frappe requires: {frappe_node_req}")
432 if skip_version_check:
433 new_compatible, _ = validate_node_version_compatibility(node_version, frappe_node_req)
434 if not new_compatible:
435 output.warning(f" Node {node_version} is incompatible with Frappe requirement")
436 suggested = parse_node_version_for_runtime(frappe_node_req)
437 if suggested:
438 output.warning(f" Consider using --node {suggested} instead")
440 bench_config_save = True
442 bench.save_bench_config()
443 bench_config_save = False
445 output.change_head("Setting up new runtime environment")
446 venv_recreated = bench.app_manager.setup_python_and_node_environments(
447 use_run=True, recreate_python_env=recreate_python_env
448 )
449 output.print("Runtime versions updated successfully")
451 if venv_recreated:
452 apps_txt_path = bench.path / "workspace" / "frappe-bench" / "sites" / "apps.txt"
453 if apps_txt_path.exists():
454 installed_apps = [line.strip() for line in apps_txt_path.read_text().splitlines() if line.strip()]
455 apps_list_dicts = [{"app": app_name, "branch": None} for app_name in installed_apps]
456 apps_list = [
457 AppConfig.from_dict(d, github_token=bench.bench_config.github_token) for d in apps_list_dicts
458 ]
460 output.change_head("Reinstalling apps into new virtual environment")
461 output.print(f"Found {len(apps_list)} installed apps: {', '.join([a.name for a in apps_list])}")
462 bench.app_manager.install_apps(
463 apps_list=apps_list,
464 github_token=bench.bench_config.github_token,
465 use_uv=bench.bench_config.use_uv,
466 skip_clone=True,
467 use_run=True,
468 )
469 output.print("All apps reinstalled successfully")
470 else:
471 output.warning("No apps.txt found, skipping app reinstallation")
473 output.change_head("Restarting services to apply new runtime versions")
474 output.print("Restarting web services (frappe, socketio)..")
475 bench.restart_web_containers_services(use_container_restart=False)
476 output.print("Restarting worker services (schedule, workers)..")
477 bench.restart_workers_containers_services(use_container_restart=False)
478 output.print("All services restarted successfully")
480 if bench_config_save:
481 bench.save_bench_config()