from __future__ import annotations from typing import TYPE_CHECKING, Annotated import typer from rich.console import Console from fc.printer import Print if TYPE_CHECKING: from fc.authentication.session import CloudSession deploy = typer.Typer(help="Bench/Deploy Commands") console = Console() @deploy.command(help="Trigger initial deploy for a bench group") def create_initial_deploy( ctx: typer.Context, name: Annotated[str, typer.Argument(help="Bench group name")], force: Annotated[ bool, typer.Option("--force", "-f", is_flag=True, help="Skip confirmation"), ] = False, ): session: CloudSession = ctx.obj payload = { "dt": "Release Group", "dn": name, "method": "initial_deploy", "args": None, } try: if not _should_proceed( f"Trigger initial deploy for bench group '{name}'? This will start a full build & deploy.", force, ): Print.info(console, "Operation cancelled.") return url = _build_method_url(session, "jcloude.api.client.run_pg_method") response = session.post( url, json=payload, message=f"[bold green]Triggering initial deploy for '{name}'..." ) if response and (response.get("success") or not response.get("exc_type")): Print.success(console, f"Initial deploy triggered for bench group:{name}") else: error_msg = response.get("message") or response.get("exception") or "Unknown error" Print.error(console, f"Failed to trigger initial deploy: {error_msg}") except Exception as e: Print.error(console, f"Error triggering initial deploy: {e}") @deploy.command(help="Create bench group") def create_bench_group( ctx: typer.Context, name: Annotated[str, typer.Argument(help="Bench group name")], version: Annotated[ str, typer.Option("--version", help="Jingrow Framework Version (e.g. Version 15)") ] = "", server: Annotated[str, typer.Option("--server", help="Server name")] = "", cluster: Annotated[str, typer.Option("--cluster", help="Cluster (e.g. Mumbai)")] = "", ): session: CloudSession = ctx.obj try: options_url = _bench_options_url(session) options = session.get(options_url) jingrow_source = _find_jingrow_source(options, version) if not jingrow_source: Print.error(console, f"Could not find valid source for jingrow in version '{version}'.") return _warn_server_name_format(server) _create_bench(session, name, version, cluster, jingrow_source, server) except Exception as e: Print.error(console, f"Error creating bench group: {e}") @deploy.command(help="Drop (archive) a bench group") def drop_bench_group( ctx: typer.Context, name: Annotated[str, typer.Argument(help="Bench group name to drop/archive")], force: Annotated[ bool, typer.Option("--force", "-f", is_flag=True, help="Skip confirmation"), ] = False, ): session: CloudSession = ctx.obj try: if not _should_proceed( f"Are you sure you want to drop/archive bench group '{name}'? This action may be irreversible.", force, ): Print.info(console, "Operation cancelled.") return payload = {"pagetype": "Release Group", "name": name} delete_url = _build_method_url(session, "jcloude.api.client.delete") response = session.post( delete_url, json=payload, message=f"[bold red]Dropping bench group '{name}'...", ) if response and response.get("exc_type"): Print.error(console, f"Failed to drop bench group: {response.get('exception', 'Unknown error')}") return if response.get("success") or (response and not response.get("exc_type")): Print.success(console, f"Successfully dropped bench group: {name}") else: error_msg = response.get("message") or response.get("exception") or "Unknown error" Print.error(console, f"Failed to drop bench group: {error_msg}") except Exception as e: Print.error(console, f"Error dropping bench group: {e}") @deploy.command(help="Add app to bench group by name and version") def add_app( ctx: typer.Context, app: Annotated[str, typer.Argument(help="App name")], bench: Annotated[str, typer.Option("--bench", help="Bench group name")] = "", branch: Annotated[str, typer.Option("--branch", help="App branch (e.g. 'version-15')")] = "", ): session: CloudSession = ctx.obj url = _build_method_url(session, "jcloude.api.bench.all_apps") payload = {"name": bench} try: response = session.post(url, json=payload) if not response or not isinstance(response, list): Print.error(console, "Failed to fetch apps list.") return source = _find_app_source(response, app, branch) if not source: Print.error(console, f"Source not found for app '{app}' and branch '{branch}'") return add_url = _build_method_url(session, "jcloude.api.bench.add_app") add_payload = {"name": bench, "source": source, "app": app} add_response = session.post(add_url, json=add_payload) if isinstance(add_response, dict): if add_response.get("success") or not add_response.get("exc_type"): Print.success( console, f"Successfully added app '{app}' (branch '{branch}') to bench '{bench}'" ) else: error_msg = ( add_response.get("message") or add_response.get("exception") or add_response.get("exc") or "Unknown error" ) Print.error(console, f"Failed to add app '{app}' to bench '{bench}': {error_msg}") elif isinstance(add_response, str): if "already exists" in add_response: Print.info(console, f"App '{app}' is already added to bench '{bench}'.") else: Print.error(console, f"Failed to add app '{app}' to bench '{bench}': {add_response}") else: Print.error(console, f"Failed to add app '{app}' to bench '{bench}': Unknown error") except Exception as e: Print.error(console, f"Error adding app: {e}") @deploy.command(help="Remove app from bench group") def remove_app( ctx: typer.Context, bench: Annotated[str, typer.Argument(help="Bench group name")], app: Annotated[str, typer.Option("--app", help="App name to remove")], ): session: CloudSession = ctx.obj url = _build_method_url(session, "jcloude.api.client.run_pg_method") payload = {"dt": "Release Group", "dn": bench, "method": "remove_app", "args": {"app": app}} try: response = session.post(url, json=payload) if isinstance(response, dict): if response.get("success") or not response.get("exc_type"): Print.success(console, f"Successfully removed app '{app}' from bench '{bench}'") else: error_msg = ( response.get("message") or response.get("exception") or response.get("exc") or "Unknown error" ) Print.error(console, f"Failed to remove app '{app}' from bench '{bench}': {error_msg}") elif isinstance(response, str): if response.strip() == app: Print.success(console, f"Successfully removed app '{app}' from bench '{bench}'") elif "not found" in response or "does not exist" in response: Print.info(console, f"App '{app}' is not present in bench '{bench}'.") else: Print.error(console, f"Failed to remove app '{app}' from bench '{bench}': {response}") else: Print.error(console, f"Failed to remove app '{app}' from bench '{bench}': Unknown error") except Exception as e: Print.error(console, f"Error removing app: {e}") if __name__ == "__main__": pass @deploy.command(help="Update a single app in a bench by release hash prefix") def update_app( ctx: typer.Context, app: Annotated[list[str], typer.Option("--app", help="App name to update (repeat for multiple)")], hash_prefix: Annotated[ list[str], typer.Option("--hash", help="Release hash prefix (repeat for multiple)") ], bench: Annotated[str, typer.Argument(help="Bench group name")], sites_opt: Annotated[ list[str] | None, typer.Option( "--site", help="Site(s) to include in update (repeat --site for multiple)", ), ] = None, ): session: CloudSession = ctx.obj try: target_bench = bench if not app or not hash_prefix or len(app) != len(hash_prefix): Print.error(console, "You must provide equal numbers of --app and --hash options.") return info = _get_deploy_info(session, target_bench) apps_payload = _build_apps_payload(info, app, hash_prefix, target_bench) if not apps_payload: Print.error(console, "No valid app/release pairs to update.") return selected_sites = _resolve_sites_for_update(info, sites_opt, target_bench) payload = { "name": target_bench, "apps": apps_payload, "sites": selected_sites, "run_will_fail_check": False, } result = session.post( "jcloude.api.bench.deploy_and_update", json=payload, message=( f"[bold green]Updating {len(payload['apps'])} app(s) on bench '{target_bench}'" f" with {len(selected_sites)} site(s) ..." ), ) if _is_success_response(result): if isinstance(result, str) and result: Print.success(console, f"Update scheduled. Tracking name: {result}") else: Print.success(console, "Update scheduled successfully.") else: Print.error(console, f"Failed to schedule update: {_format_error_message(result)}") except Exception as e: Print.error(console, f"Error scheduling app update: {e}") def _pick_app_entry(info: dict, app: str) -> dict | None: apps = info.get("apps", []) or [] return next((a for a in apps if (a.get("app") == app or a.get("name") == app)), None) def _select_release_by_prefix(releases: list[dict], hash_prefix: str) -> tuple[str | None, str]: matches = [r for r in releases if str(r.get("hash", "")).startswith(hash_prefix)] if not matches: suggestions = ", ".join([r.get("hash", "")[:7] for r in releases][:10]) if suggestions: Print.error(console, f"No matching release found. Try one of: {suggestions} ...") else: Print.error(console, "No releases available to match against.") return None, "" if len(matches) > 1: details = ", ".join([f"{r.get('hash', '')[:12]} ({r.get('name')})" for r in matches]) Print.error(console, f"Ambiguous hash prefix; matches multiple releases: {details}") return None, "" selected = matches[0] return selected.get("name"), selected.get("hash", "") def _get_deploy_info(session: "CloudSession", bench_name: str) -> dict: """Fetch deploy information for a bench.""" return ( session.get( "jcloude.api.bench.deploy_information", params={"name": bench_name}, ) or {} ) def _build_apps_payload(info: dict, apps: list[str], hashes: list[str], bench_name: str) -> list[dict]: apps_payload: list[dict] = [] for app_name, hash_val in zip(apps, hashes, strict=True): app_entry = _pick_app_entry(info, app_name) if not app_entry: Print.error(console, f"App '{app_name}' not found in bench '{bench_name}'. Skipping.") continue selected_release, _ = _select_release_by_prefix(app_entry.get("releases", []) or [], hash_val) if not selected_release: continue apps_payload.append({"app": app_name, "release": selected_release}) return apps_payload def _resolve_sites_for_update(info: dict, sites_opt: list[str] | None, bench_name: str) -> list[dict]: try: bench_sites = info.get("sites", []) or [] if not sites_opt: return [] by_name = {s.get("name"): s for s in bench_sites if s.get("name")} selected = [by_name[s] for s in sites_opt if s in by_name] missing = [s for s in sites_opt if s not in by_name] if missing: Print.warn(console, f"Skipping unknown site(s) for bench '{bench_name}': {', '.join(missing)}") return selected except Exception: return [] def _is_success_response(result: object) -> bool: if isinstance(result, str): return bool(result) if isinstance(result, dict): return bool(result.get("success") or not result.get("exc_type")) return False def _format_error_message(result: object) -> str: if isinstance(result, dict): return str(result.get("message") or result.get("exception") or result) or "Unknown error" return str(result) or "Unknown error" def _should_proceed(message: str, confirm_token: str | None) -> bool: if isinstance(confirm_token, bool) and confirm_token: return True if isinstance(confirm_token, str) and confirm_token.lower() in {"force", "yes", "y"}: return True return typer.confirm(message, default=False) def _build_method_url(session: "CloudSession", method: str) -> str: """Build a full URL to an API method using the session's base URL.""" base_url = session.base_url.rstrip("/") if base_url.endswith("/api/method"): base_url = base_url[: -len("/api/method")] return f"{base_url}/api/action/{method}" def _bench_options_url(session: "CloudSession") -> str: """Build bench options URL from session.base_url consistently.""" return _build_method_url(session, "jcloude.api.bench.options") def _prepare_bench_payload( title: str, version: str, region: str, jingrow_source: str, server: str, ) -> dict: return { "title": title, "version": version, "cluster": region, "apps": [{"name": "jingrow", "source": jingrow_source}], "saas_app": "", "server": server.strip().rstrip("\\"), } def _find_jingrow_source(options: dict, version: str) -> str | None: try: for v in options.get("versions", []): if v.get("name") == version: for a in v.get("apps", []): if a.get("name") == "jingrow": src = a.get("source", {}) return src.get("name") except Exception: return None return None def _find_app_source(apps_list: list[dict], app: str, branch: str) -> str | None: for entry in apps_list: if entry.get("app") == app: for src in entry.get("sources", []): if src.get("branch") == branch: return src.get("name") return None def _warn_server_name_format(server: str) -> None: if server.endswith("\\") or server.strip() != server: Print.warn(console, "Server name contains trailing backslash or spaces. Please check your input.") def _create_bench( session: "CloudSession", title: str, version: str, region: str, jingrow_source: str, server: str, ) -> None: bench_payload = _prepare_bench_payload(title, version, region, jingrow_source, server) try: response = session.post( "jcloude.api.bench.new", json={"bench": bench_payload}, message=f"[bold green]Creating bench group '{title}' for version '{version}', region '{region}', and server '{server}'...", ) if isinstance(response, dict) and response.get("success"): Print.success(console, f"Successfully created bench group: {title}") elif isinstance(response, dict): msg = ( response.get("message") or response.get("exception") or response.get("exc") or "Unknown error" ) Print.error(console, f"Failed to create bench group: {msg}") elif isinstance(response, str): Print.success(console, f"Successfully created bench group: {response}") else: Print.error(console, f"Backend error: {response}") except Exception as req_exc: Print.error(console, f"Request error: {req_exc}")