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

1from typing import Annotated 

2 

3import typer 

4from typer_examples import example 

5 

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) 

28 

29 

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. 

219 

220 Adjusts environment type, developer mode, runtime versions, alias domains, and other bench settings. 

221 """ 

222 

223 services_manager = ctx.obj["services"] 

224 fm_config: FMConfigManager = ctx.obj["fm_config_manager"] 

225 

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) 

229 

230 bench_config_save = False 

231 

232 if not bench.running: 

233 raise BenchNotRunning(bench_name=bench.name) 

234 

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

247 

248 bench_config_save = True 

249 

250 if environment: 

251 output.change_head(f"Switching bench environment to {environment.value}") 

252 bench.bench_config.environment_type = environment 

253 

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 

257 

258 bench.generate_compose(compose_inputs) 

259 

260 output.print("Recreating frappe container to apply environment change..") 

261 bench.docker_client.compose.up(services=["frappe"], detach=True, force_recreate=True) 

262 

263 output.print(f"Switched bench environment to {environment.value}") 

264 bench_config_save = True 

265 

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

270 

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

274 

275 bench.bench_config.restart_policy = restart 

276 bench.generate_compose(bench.bench_config.export_to_compose_inputs()) 

277 

278 if bench.workers.compose_file_manager.compose_path.exists(): 

279 bench.workers.generate_compose() 

280 

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) 

284 

285 output.print("Restarting containers to apply restart policy..") 

286 bench.docker_client.compose.up(detach=True, force_recreate=True) 

287 

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

292 

293 if admin_tools: 

294 if admin_tools == EnableDisableOptionsEnum.enable: 

295 output.change_head("Enabling Admin-tools") 

296 bench.bench_config.admin_tools = True 

297 

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) 

302 

303 bench_config_save = True 

304 output.print("Enabled Admin-tools") 

305 

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 

316 

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 [] 

320 

321 if add_domains_list: 

322 skip_check = allow_domain_conflicts or not fm_config.validation.enforce_domain_uniqueness 

323 

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) 

335 

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

339 

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) 

344 

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

348 

349 if newrelic is not None: 

350 bench.bench_config.newrelic_enabled = newrelic 

351 

352 if newrelic_license_key is not None: 

353 bench.bench_config.newrelic_license_key = newrelic_license_key 

354 

355 bench.generate_compose(bench.bench_config.export_to_compose_inputs()) 

356 bench.save_bench_config() 

357 bench_config_save = False 

358 

359 bench.supervisor.setup_newrelic(bench.path) 

360 

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

364 

365 if python_version or node_version: 

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

367 

368 if python_version or node_version: 

369 current_versions = bench.app_manager.get_current_runtime_versions(use_run=True) 

370 

371 if python_version: 

372 old_python = current_versions.get("python") or "not set" 

373 

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) 

392 

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

405 

406 bench_config_save = True 

407 

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

420 

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) 

426 

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

439 

440 bench_config_save = True 

441 

442 bench.save_bench_config() 

443 bench_config_save = False 

444 

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

450 

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 ] 

459 

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

472 

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

479 

480 if bench_config_save: 

481 bench.save_bench_config()