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

1import secrets 

2from typing import Annotated, cast 

3 

4import typer 

5from typer_examples import example 

6 

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 

24 

25 

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. 

163 

164 Creates a bench directory, config, and installs requested apps. If not specified, Frappe is included by default. 

165 """ 

166 

167 services_manager: ServicesManager = ctx.obj["services"] 

168 verbose = ctx.obj["verbose"] 

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

170 

171 benchname = validate_sitename(benchname) 

172 

173 if newrelic and not newrelic_license_key: 

174 raise typer.BadParameter("--newrelic-license-key is required when --newrelic is set.") 

175 

176 all_domains = {benchname} 

177 if alias_domains: 

178 all_domains.update(alias_domains) 

179 

180 skip_check = allow_domain_conflicts or not fm_config.validation.enforce_domain_uniqueness 

181 

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) 

189 

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 

194 

195 if developer_mode == EnableDisableOptionsEnum.enable: 

196 developer_mode_status = True 

197 elif developer_mode == EnableDisableOptionsEnum.disable: 

198 developer_mode_status = False 

199 

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 

203 

204 # Callback returns List[AppConfig], cast for type checker 

205 apps_config = cast("list[AppConfig]", apps) 

206 

207 final_apps_list = [] 

208 frappe_app = None 

209 other_apps = [] 

210 

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) 

216 

217 if frappe_app is None: 

218 frappe_app = AppConfig.from_string(f"frappe:{STABLE_APP_BRANCH_MAPPING_LIST['frappe']}") 

219 

220 final_apps_list = [frappe_app] + other_apps 

221 

222 sanitized_bench_name = benchname.replace(".", "_").replace("-", "_") 

223 db_name = f"fm_{sanitized_bench_name}_{secrets.token_hex(8)}" 

224 

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 ) 

246 

247 if apps: 

248 apps_config = bench_config.get_apps_config() 

249 

250 with spinner(output, f"Validating {len(apps_config)} app repositories"): 

251 validation_result = AppConfig.validate_repos_batch(apps_config, github_token) 

252 

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

258 

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) 

265 

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

270 

271 with spinner(output, "Creating bench"): 

272 bench_service.create_bench(benchname, bench_config, is_template=template)