Coverage for frappe_manager / commands / create.py: 27%
77 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
1import secrets
2from typing import Annotated, cast
4import typer
5from typer_examples import example
7from frappe_manager import (
8 CLI_BENCH_CONFIG_FILE_NAME,
9 CLI_BENCHES_DIRECTORY,
10 STABLE_APP_BRANCH_MAPPING_LIST,
11 EnableDisableOptionsEnum,
12)
13from frappe_manager.metadata_manager import FMConfigManager
14from frappe_manager.output_manager import get_global_output_handler, spinner
15from frappe_manager.services_manager.services import ServicesManager
16from frappe_manager.site_manager.bench_config import AppConfig, BenchConfig, FMBenchEnvType, RestartPolicyEnum
17from frappe_manager.site_manager.bench_service import BenchService
18from frappe_manager.site_manager.domain_conflict import DomainConflictError, validate_domains_unique
19from frappe_manager.utils.callbacks import (
20 alias_domains_validation_callback,
21 apps_list_validation_callback,
22)
23from frappe_manager.utils.site import validate_sitename
26@example(
27 "Create bench with Frappe only",
28 "{benchname}",
29 detail="Creates a new bench with Frappe installed using the default stable branch. Useful for starting a minimal development environment.",
30 benchname="mybench",
31)
32@example(
33 "Create bench with ERPNext and HRMS",
34 "{benchname} --apps erpnext --apps hrms",
35 detail="Creates a new bench and installs ERPNext and HRMS on top of Frappe. Useful when you need these apps together.",
36 benchname="mybench",
37)
38@example(
39 "Create production bench",
40 "{benchname} -e prod",
41 detail="Creates a production-ready bench with production defaults (no developer tools). Use this for deployment environments.",
42 benchname="mybench",
43)
44@example(
45 "Create bench with specific branch",
46 "{benchname} --apps erpnext:version-14",
47 detail="Creates a bench installing ERPNext from a specific branch or tag. Use when you need a particular release.",
48 benchname="mybench",
49)
50@example(
51 "Create bench with a private app",
52 "{benchname} --apps myorg/private-app --github-token ghp_xxx",
53 detail="Installs a private GitHub repository by supplying a token. Keep tokens secret and prefer environment variables.",
54 benchname="mybench",
55)
56@example(
57 "Create bench with custom Python/Node versions",
58 "{benchname} --python 3.11 --node 20",
59 detail="Selects custom Python and Node.js versions for the bench rather than auto-detected defaults.",
60 benchname="mybench",
61)
62@example(
63 "Create bench with alias domains",
64 "{benchname} --alias-domains www.example.com,api.example.com",
65 detail="Adds alias domains to the bench configuration. Use 'fm ssl add' to provision certificates for these domains.",
66 benchname="mybench",
67)
68def create(
69 ctx: typer.Context,
70 benchname: Annotated[str, typer.Argument(help="Bench name")],
71 apps: Annotated[
72 list[str],
73 typer.Option(
74 "--apps",
75 "-a",
76 help="Apps to install. Format: appname:branch or appname (e.g., erpnext:version-15)",
77 callback=apps_list_validation_callback,
78 show_default=False,
79 ),
80 ] = [],
81 environment: Annotated[
82 FMBenchEnvType,
83 typer.Option("--environment", "-e", help="Environment type (dev or prod)"),
84 ] = FMBenchEnvType.dev,
85 developer_mode: Annotated[
86 EnableDisableOptionsEnum,
87 typer.Option(help="Enable/disable developer mode"),
88 ] = EnableDisableOptionsEnum.disable,
89 template: Annotated[bool, typer.Option(help="Create as template bench")] = False,
90 admin_pass: Annotated[
91 str,
92 typer.Option(help="Administrator password"),
93 ] = "admin",
94 alias_domains: Annotated[
95 str | None,
96 typer.Option(
97 help="Alias domains (comma-separated). Use 'fm ssl add' for SSL.",
98 callback=alias_domains_validation_callback,
99 show_default=False,
100 ),
101 ] = None,
102 github_token: Annotated[
103 str | None,
104 typer.Option(
105 "--github-token",
106 "-t",
107 help="GitHub token for private repos (or use GITHUB_TOKEN env var)",
108 envvar="GITHUB_TOKEN",
109 show_default=False,
110 ),
111 ] = None,
112 python_version: Annotated[
113 str | None,
114 typer.Option(
115 "--python",
116 help="Python version (e.g., '3.11'). Auto-detected by default.",
117 show_default=False,
118 ),
119 ] = None,
120 node_version: Annotated[
121 str | None,
122 typer.Option(
123 "--node",
124 help="Node version (e.g., '18', '20'). Auto-detected by default.",
125 show_default=False,
126 ),
127 ] = None,
128 restart: Annotated[
129 RestartPolicyEnum | None,
130 typer.Option(
131 "--restart",
132 help="Docker restart policy. Defaults to 'no' (dev) or 'unless-stopped' (prod).",
133 show_default=False,
134 ),
135 ] = None,
136 allow_domain_conflicts: Annotated[
137 bool,
138 typer.Option(
139 "--allow-domain-conflicts",
140 help="Skip domain uniqueness validation (not recommended). Allows creating benches with duplicate domains.",
141 show_default=False,
142 ),
143 ] = False,
144 newrelic: Annotated[
145 bool,
146 typer.Option(
147 "--newrelic/--no-newrelic",
148 help="Enable NewRelic APM monitoring for the web process.",
149 show_default=False,
150 ),
151 ] = False,
152 newrelic_license_key: Annotated[
153 str | None,
154 typer.Option(
155 "--newrelic-license-key",
156 help="NewRelic ingest license key. Required when --newrelic is set.",
157 show_default=False,
158 ),
159 ] = None,
160):
161 """
162 Create a new bench with apps.
164 Creates a bench directory, config, and installs requested apps. If not specified, Frappe is included by default.
165 """
167 services_manager: ServicesManager = ctx.obj["services"]
168 verbose = ctx.obj["verbose"]
169 fm_config: FMConfigManager = ctx.obj["fm_config_manager"]
171 benchname = validate_sitename(benchname)
173 if newrelic and not newrelic_license_key:
174 raise typer.BadParameter("--newrelic-license-key is required when --newrelic is set.")
176 all_domains = {benchname}
177 if alias_domains:
178 all_domains.update(alias_domains)
180 skip_check = allow_domain_conflicts or not fm_config.validation.enforce_domain_uniqueness
182 try:
183 validate_domains_unique(all_domains, benches_root=CLI_BENCHES_DIRECTORY, skip_check=skip_check)
184 except DomainConflictError as e:
185 output = get_global_output_handler()
186 output.display_error(str(e))
187 output.print("\nTo proceed anyway, use: --allow-domain-conflicts", emoji_code="")
188 raise typer.Exit(1)
190 output = get_global_output_handler()
191 bench_service = BenchService(CLI_BENCHES_DIRECTORY, services_manager, verbose=verbose, output_handler=output)
192 bench_path = bench_service.benches_directory / benchname
193 bench_config_path = bench_path / CLI_BENCH_CONFIG_FILE_NAME
195 if developer_mode == EnableDisableOptionsEnum.enable:
196 developer_mode_status = True
197 elif developer_mode == EnableDisableOptionsEnum.disable:
198 developer_mode_status = False
200 # Ensure frappe is always first in apps_list
201 # If user didn't specify frappe, add default version
202 # If user specified frappe, move it to first position
204 # Callback returns List[AppConfig], cast for type checker
205 apps_config = cast("list[AppConfig]", apps)
207 final_apps_list = []
208 frappe_app = None
209 other_apps = []
211 for app_config in apps_config:
212 if app_config.name == "frappe" or app_config.name.endswith("/frappe"):
213 frappe_app = app_config
214 else:
215 other_apps.append(app_config)
217 if frappe_app is None:
218 frappe_app = AppConfig.from_string(f"frappe:{STABLE_APP_BRANCH_MAPPING_LIST['frappe']}")
220 final_apps_list = [frappe_app] + other_apps
222 sanitized_bench_name = benchname.replace(".", "_").replace("-", "_")
223 db_name = f"fm_{sanitized_bench_name}_{secrets.token_hex(8)}"
225 bench_config: BenchConfig = BenchConfig(
226 name=benchname,
227 apps_list=final_apps_list,
228 developer_mode=True if environment == FMBenchEnvType.dev else developer_mode_status,
229 admin_tools=True if environment == FMBenchEnvType.dev else False,
230 admin_pass=admin_pass,
231 environment_type=environment,
232 root_path=bench_config_path,
233 ssl_certificates=[],
234 alias_domains=alias_domains if alias_domains else [],
235 github_token=github_token,
236 use_uv=True,
237 python_version=python_version,
238 node_version=node_version,
239 db_name=db_name,
240 admin_tools_username=None,
241 admin_tools_password=None,
242 restart_policy=restart,
243 newrelic_enabled=newrelic,
244 newrelic_license_key=newrelic_license_key,
245 )
247 if apps:
248 apps_config = bench_config.get_apps_config()
250 with spinner(output, f"Validating {len(apps_config)} app repositories"):
251 validation_result = AppConfig.validate_repos_batch(apps_config, github_token)
253 for result in validation_result.results:
254 if result.success:
255 output.print(result.display_message, emoji_code=":white_check_mark:")
256 else:
257 output.display_error(result.display_message, emoji_code=":cross_mark:")
259 if not validation_result.all_valid:
260 output.display_error(
261 f"\n⚠️ {validation_result.failure_count}/{len(apps_config)} repositories failed validation",
262 )
263 output.display_error("Please check the repository names, branches, and authentication")
264 raise typer.Exit(1)
266 # Warn if prod bench is being created with restart: no
267 if restart == RestartPolicyEnum.no and environment == FMBenchEnvType.prod:
268 output.warning("⚠️ Creating production bench with restart policy 'no'")
269 output.warning(" Containers will not auto-recover from failures or system reboots")
271 with spinner(output, "Creating bench"):
272 bench_service.create_bench(benchname, bench_config, is_template=template)