Skip to content

Commit 902bbd6

Browse files
feat(deploy): Add deploy CLI commands
1 parent 99a48dc commit 902bbd6

File tree

2 files changed

+271
-1
lines changed

2 files changed

+271
-1
lines changed

libs/fc_cli/fc/commands/deploy.py

Lines changed: 268 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,268 @@
1+
from __future__ import annotations
2+
3+
from typing import TYPE_CHECKING, Annotated
4+
5+
import typer
6+
7+
from fc.commands.utils import handle_exception, print_error, print_info, print_success
8+
9+
if TYPE_CHECKING:
10+
from fc.authentication.session import CloudSession
11+
12+
13+
deploy = typer.Typer(help="Bench/Deploy Commands")
14+
15+
16+
# Trigger initial deploy for a bench group
17+
@deploy.command(help="Trigger initial deploy for a bench group")
18+
def bench_init(
19+
ctx: typer.Context,
20+
name: Annotated[str, typer.Option("--name", help="Bench group name")] = ...,
21+
):
22+
session: CloudSession = ctx.obj
23+
payload = {
24+
"dt": "Release Group",
25+
"dn": name,
26+
"method": "initial_deploy",
27+
"args": None,
28+
}
29+
try:
30+
url = _build_method_url(session, "press.api.client.run_doc_method")
31+
response = session.post(
32+
url, json=payload, message=f"[bold green]Triggering initial deploy for '{name}'..."
33+
)
34+
if response and (response.get("success") or not response.get("exc_type")):
35+
print_success(f"Initial deploy triggered for bench group:{name}")
36+
else:
37+
error_msg = response.get("message") or response.get("exception") or "Unknown error"
38+
print_error(f"Failed to trigger initial deploy: {error_msg}")
39+
except Exception as e:
40+
handle_exception(e, "Error triggering initial deploy")
41+
42+
43+
@deploy.command(help="Create bench group")
44+
def create_bench_group(
45+
ctx: typer.Context,
46+
version: Annotated[
47+
str, typer.Option("--version", help="Frappe Framework Version (e.g. Version 15)")
48+
] = ...,
49+
region: Annotated[str, typer.Option("--region", help="Region (cluster name, e.g. Mumbai)")] = ...,
50+
title: Annotated[str, typer.Option("--title", help="Bench Group Title (e.g. cli-test-bench)")] = ...,
51+
server: Annotated[str, typer.Option("--server", help="Server name (required)")] = ...,
52+
):
53+
session: CloudSession = ctx.obj
54+
try:
55+
options_url = _bench_options_url(session)
56+
options = session.get(options_url)
57+
frappe_source = _find_frappe_source(options, version)
58+
if not frappe_source:
59+
print_error(f"Could not find valid source for frappe in version '{version}'.")
60+
return
61+
_warn_server_name_format(server)
62+
_create_bench(session, title, version, region, frappe_source, server)
63+
except Exception as e:
64+
handle_exception(e, "Error creating bench group")
65+
66+
67+
@deploy.command(help="Drop (archive) a bench group")
68+
def drop_bench_group(
69+
ctx: typer.Context,
70+
name: Annotated[str, typer.Option("--name", help="Bench group name to drop/archive")] = ...,
71+
):
72+
session: CloudSession = ctx.obj
73+
try:
74+
payload = {"doctype": "Release Group", "name": name}
75+
delete_url = _build_method_url(session, "press.api.client.delete")
76+
response = session.post(
77+
delete_url,
78+
json=payload,
79+
message=f"[bold red]Dropping bench group '{name}'...",
80+
)
81+
if response and response.get("exc_type"):
82+
print_error(f"Failed to drop bench group: {response.get('exception', 'Unknown error')}")
83+
return
84+
# Consider success if no error and either 'success' is True or no error but response is present
85+
if response.get("success") or (response and not response.get("exc_type")):
86+
print_success(f"Successfully dropped bench group: {name}")
87+
else:
88+
error_msg = response.get("message") or response.get("exception") or "Unknown error"
89+
print_error(f"Failed to drop bench group: {error_msg}")
90+
except Exception as e:
91+
handle_exception(e, "Error dropping bench group")
92+
93+
94+
@deploy.command(help="Add app to bench group by name and version")
95+
def add_app(
96+
ctx: typer.Context,
97+
bench: Annotated[str, typer.Option("--bench", help="Bench group name")] = ...,
98+
app: Annotated[str, typer.Option("--app", help="App name")] = ...,
99+
branch: Annotated[str, typer.Option("--branch", help="App branch (e.g. 'version-15-beta')")] = ...,
100+
):
101+
session: CloudSession = ctx.obj
102+
url = _build_method_url(session, "press.api.bench.all_apps")
103+
payload = {"name": bench}
104+
try:
105+
response = session.post(url, json=payload)
106+
if not response or not isinstance(response, list):
107+
print_error("Failed to fetch apps list.")
108+
return
109+
source = _find_app_source(response, app, branch)
110+
if not source:
111+
print_error(f"Source not found for app '{app}' and branch '{branch}'")
112+
return
113+
add_url = _build_method_url(session, "press.api.bench.add_app")
114+
add_payload = {"name": bench, "source": source, "app": app}
115+
add_response = session.post(add_url, json=add_payload)
116+
if isinstance(add_response, dict):
117+
if add_response.get("success") or not add_response.get("exc_type"):
118+
print_success(f"Successfully added app '{app}' (branch '{branch}') to bench '{bench}'")
119+
else:
120+
error_msg = (
121+
add_response.get("message")
122+
or add_response.get("exception")
123+
or add_response.get("exc")
124+
or "Unknown error"
125+
)
126+
print_error(f"Failed to add app '{app}' to bench '{bench}': {error_msg}")
127+
elif isinstance(add_response, str):
128+
# Handle string response (e.g. when app is already added or not found)
129+
if "already exists" in add_response:
130+
print_info(f"App '{app}' is already added to bench '{bench}'.")
131+
else:
132+
print_error(f"Failed to add app '{app}' to bench '{bench}': {add_response}")
133+
else:
134+
print_error(f"Failed to add app '{app}' to bench '{bench}': Unknown error")
135+
except Exception as e:
136+
handle_exception(e, "Error adding app")
137+
138+
139+
@deploy.command(help="Remove app from bench group")
140+
def remove_app(
141+
ctx: typer.Context,
142+
bench: Annotated[str, typer.Option("--bench", help="Bench group name")] = ...,
143+
app: Annotated[str, typer.Option("--app", help="App name to remove")] = ...,
144+
):
145+
session: CloudSession = ctx.obj
146+
url = _build_method_url(session, "press.api.client.run_doc_method")
147+
payload = {"dt": "Release Group", "dn": bench, "method": "remove_app", "args": {"app": app}}
148+
try:
149+
response = session.post(url, json=payload)
150+
if isinstance(response, dict):
151+
if response.get("success") or not response.get("exc_type"):
152+
print_success(f"Successfully removed app '{app}' from bench '{bench}'")
153+
else:
154+
error_msg = (
155+
response.get("message")
156+
or response.get("exception")
157+
or response.get("exc")
158+
or "Unknown error"
159+
)
160+
print_error(f"Failed to remove app '{app}' from bench '{bench}': {error_msg}")
161+
elif isinstance(response, str):
162+
# If response is just the app name, treat as success (already removed or not present)
163+
if response.strip() == app:
164+
print_success(f"Successfully removed app '{app}' from bench '{bench}'")
165+
elif "not found" in response or "does not exist" in response:
166+
print_info(f"App '{app}' is not present in bench '{bench}'.")
167+
else:
168+
print_error(f"Failed to remove app '{app}' from bench '{bench}': {response}")
169+
else:
170+
print_error(f"Failed to remove app '{app}' from bench '{bench}': Unknown error")
171+
except Exception as e:
172+
handle_exception(e, "Error removing app")
173+
174+
175+
if __name__ == "__main__":
176+
pass
177+
178+
# --------------------
179+
# Internal helpers (no behavior change, reduce complexity)
180+
# --------------------
181+
182+
183+
def _find_frappe_source(options: dict, version: str) -> str | None:
184+
"""Given bench options and a version, return the frappe source name or None."""
185+
try:
186+
for v in options.get("versions", []):
187+
if v.get("name") == version:
188+
for a in v.get("apps", []):
189+
if a.get("name") == "frappe":
190+
src = a.get("source", {})
191+
return src.get("name")
192+
except Exception:
193+
return None
194+
return None
195+
196+
197+
def _find_app_source(apps_list: list[dict], app: str, branch: str) -> str | None:
198+
"""Find source name for an app/branch in the all_apps response."""
199+
for entry in apps_list:
200+
if entry.get("app") == app:
201+
for src in entry.get("sources", []):
202+
if src.get("branch") == branch:
203+
return src.get("name")
204+
return None
205+
206+
207+
def _warn_server_name_format(server: str) -> None:
208+
if server.endswith("\\") or server.strip() != server:
209+
print_error("Warning: Server name contains trailing backslash or spaces. Please check your input.")
210+
211+
212+
def _create_bench(
213+
session: "CloudSession",
214+
title: str,
215+
version: str,
216+
region: str,
217+
frappe_source: str,
218+
server: str,
219+
) -> None:
220+
bench_payload = _prepare_bench_payload(title, version, region, frappe_source, server)
221+
try:
222+
response = session.post(
223+
"press.api.bench.new",
224+
json={"bench": bench_payload},
225+
message=f"[bold green]Creating bench group '{title}' for version '{version}', region '{region}', and server '{server}'...",
226+
)
227+
if isinstance(response, dict) and response.get("success"):
228+
print_success(f"Successfully created bench group: {title}")
229+
elif isinstance(response, dict):
230+
print_error(f"Failed to create bench group: {response}")
231+
elif isinstance(response, str):
232+
print_success(f"Successfully created bench group: {response}")
233+
else:
234+
print_error(f"Backend error: {response}")
235+
except Exception as req_exc:
236+
handle_exception(req_exc, "Request error")
237+
if hasattr(req_exc, "response") and req_exc.response is not None:
238+
print_error(f"Backend response: {req_exc.response.text}")
239+
240+
241+
def _bench_options_url(session: "CloudSession") -> str:
242+
"""Build bench options URL from session.base_url consistently."""
243+
return _build_method_url(session, "press.api.bench.options")
244+
245+
246+
def _build_method_url(session: "CloudSession", method: str) -> str:
247+
"""Build a full URL to an API method using the session's base URL."""
248+
base_url = session.base_url.rstrip("/")
249+
if base_url.endswith("/api/method"):
250+
base_url = base_url[: -len("/api/method")]
251+
return f"{base_url}/api/method/{method}"
252+
253+
254+
def _prepare_bench_payload(
255+
title: str,
256+
version: str,
257+
region: str,
258+
frappe_source: str,
259+
server: str,
260+
) -> dict:
261+
return {
262+
"title": title,
263+
"version": version,
264+
"cluster": region,
265+
"apps": [{"name": "frappe", "source": frappe_source}],
266+
"saas_app": "",
267+
"server": server.strip().rstrip("\\"),
268+
}

libs/fc_cli/fc/main.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from fc.authentication.login import session_file_path
66
from fc.authentication.session import CloudSession
77
from fc.commands.auth import auth
8+
from fc.commands.deploy import deploy
89
from fc.commands.servers import server
910

1011
app = typer.Typer(help="FC CLI")
@@ -22,5 +23,6 @@ def init_session(ctx: typer.Context):
2223
pass
2324

2425

25-
app.add_typer(server, name="server")
2626
app.add_typer(auth, name="auth")
27+
app.add_typer(server, name="server")
28+
app.add_typer(deploy, name="deploy")

0 commit comments

Comments
 (0)