diff --git a/benchmate/api/actions/bench_start.py b/benchmate/api/actions/bench_start.py new file mode 100644 index 0000000..7a4cfa7 --- /dev/null +++ b/benchmate/api/actions/bench_start.py @@ -0,0 +1,64 @@ +import os +import platform +import subprocess + +import frappe + + +@frappe.whitelist() +def execute(bench_name: str, bench_path: str): + """ + Start all services of a given bench in the background. + This will simulate `bench start` using nohup and run it detached. + """ + try: + if not bench_path: + frappe.throw("bench_path parameter is required.", frappe.ValidationError) + + bench_path = os.path.abspath(bench_path) + + if not os.path.isdir(bench_path): + frappe.throw(f"Invalid bench path: {bench_path}", frappe.ValidationError) + + is_linux = "Linux" in platform.system() + + if is_linux: + # nohup will detach the bench start process so it survives API exit + log_file = os.path.join(bench_path, "bench_start.log") + cmd = f"nohup {bench_path}/env/bin/bench start > {log_file} 2>&1 &" + + subprocess.Popen( + cmd, + shell=True, + cwd=bench_path, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + preexec_fn=os.setpgrp, + ) + + else: + # Windows fallback (though not common for benches) + cmd = "bench start" + subprocess.Popen( + cmd, + shell=True, + cwd=bench_path, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + + return { + "success": True, + "message": f"Bench '{bench_name}' services start command issued successfully.", + "data": {"bench_path": bench_path}, + } + + except frappe.ValidationError as ve: + return {"success": False, "message": str(ve), "data": None} + + except Exception as e: + return { + "success": False, + "message": f"Error starting bench: {e}", + "data": None, + } diff --git a/benchmate/api/actions/bench_stop.py b/benchmate/api/actions/bench_stop.py new file mode 100644 index 0000000..3f9a192 --- /dev/null +++ b/benchmate/api/actions/bench_stop.py @@ -0,0 +1,172 @@ +import errno +import glob +import json +import os +import platform +import socket +import time + +import frappe + + +def read_redis_ports(config_dir): + """ + Reads all `redis_*.conf` files from the given config directory + and extracts the 'port' values used by Redis instances. + """ + ports = [] + # ? Loop through all redis_*.conf files inside config directory + for conf_file in glob.glob(os.path.join(config_dir, "redis_*.conf")): + try: + with open(conf_file) as f: + for line in f: + # ? Extract port number if line starts with 'port' + if line.strip().startswith("port"): + port = int(line.strip().split()[1]) + ports.append(port) + break + except Exception: + # ? Ignore faulty/missing config files and continue checking others + continue + # ? Return unique ports only + return list(set(ports)) + + +def read_site_ports(sites_dir): + """ + Reads `site_config.json` for each site inside the sites directory + and extracts `webserver_port` and `socketio_port` values. + """ + ports = [] + # ? Ensure that sites folder actually exists + if not os.path.isdir(sites_dir): + frappe.throw( + f"Sites directory not found at {sites_dir}", + frappe.DoesNotExistError, + ) + + # ? Iterate over all sites present inside the sites directory + for site_name in os.listdir(sites_dir): + site_config_path = os.path.join(sites_dir, site_name, "site_config.json") + if os.path.isfile(site_config_path): + try: + with open(site_config_path) as f: + data = json.load(f) + # ? Collect webserver_port if present + if "webserver_port" in data: + ports.append(int(data["webserver_port"])) + # ? Collect socketio_port if present + if "socketio_port" in data: + ports.append(int(data["socketio_port"])) + except Exception: + # ? Continue checking other sites if a site config fails + continue + # ? Return unique site ports only + return list(set(ports)) + + +def stop_port(port, is_linux): + """ + Attempts to stop services running on a specific port. + + Args: + port (int): Port number to stop. + is_linux (bool): Whether the system is Linux based. + + Returns: + bool: True if a port was in use and a stop command was attempted. + False if port was free or no action was needed. + """ + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + try: + # ? Try binding to port; if succeeds, port is free (no service running) + sock.bind(("127.0.0.1", port)) + # ? Port not in use + return False + except OSError as e: + # ? If port is already in use + if e.errno == errno.EADDRINUSE: + # ? If port seems to be Redis (within range), attempt graceful shutdown + if 1100 <= port <= 14000: + os.system(f"echo 'shutdown' | redis-cli -h 127.0.0.1 -p {port} 2>/dev/null") + # ? Give Redis process some time to shutdown + time.sleep(1) + + # ? Force kill remaining process using OS-based commands + if is_linux: + os.system(f"fuser {port}/tcp -k") # Linux command to kill process on a port + else: + # ? macOS fallback + os.system(f"lsof -i tcp:{port} | grep -v PID | awk '{{print $2}}' | xargs kill") + return True + else: + # ? Unexpected socket error + return False + finally: + # ? Ensure socket is closed + sock.close() + + +@frappe.whitelist() +def execute(bench_name: str, bench_path: str): + """ + API method to stop bench services for the bench located at the given path. + + Args: + bench_path (str): Absolute or relative path to the bench directory. + + Returns: + dict: Status message and list of stopped ports enclosed in `data`. + On error, uses frappe.throw for validation or returns structured error dict. + """ + try: + # ? Validate input parameter + if not bench_path: + frappe.throw( + "bench_path parameter is required.", + frappe.ValidationError, + ) + + bench_path = os.path.abspath(bench_path) + config_dir = os.path.join(bench_path, "config") + sites_dir = os.path.join(bench_path, "sites") + + # ? Validate essential folders and throw if missing + if not os.path.isdir(config_dir) or not os.path.isdir(sites_dir): + frappe.throw( + f"Invalid bench path or missing 'config' or 'sites' folder: {bench_path}", + frappe.ValidationError, + ) + + is_linux = "Linux" in platform.system() + + # ? Gather all related ports from Redis + Sites configurations + redis_ports = read_redis_ports(config_dir) + site_ports = read_site_ports(sites_dir) + all_ports = list(set(redis_ports + site_ports)) + + if not all_ports: + frappe.throw("No bench service ports found to stop.") + + # ? Stop all detected services running on collected ports + for port in all_ports: + stop_port(port, is_linux) + + # ? Return success response with data + return { + "success": True, + "message": f"{bench_name} Bench services are stopped successfully.", + "data": {"stopped_ports": all_ports}, + } + + except frappe.ValidationError as ve: + # ? Raised by frappe.throw validation + return {"success": False, "message": str(ve), "data": None} + + except Exception as e: + # ? General unexpected error + return { + "success": False, + "message": f"Error stopping bench: {e!s}", + "data": None, + } diff --git a/benchmate/api/actions/create_site.py b/benchmate/api/actions/create_site.py new file mode 100644 index 0000000..f6dfdc2 --- /dev/null +++ b/benchmate/api/actions/create_site.py @@ -0,0 +1,164 @@ +import os +import subprocess +import time + +import frappe + +from benchmate.api.utils import get_benchmate_settings + + +def update_log_status(docname, new_text=None, status=None): + """ + ? Update the BM Site Creation Logs record for a given docname. + ? Appends log text and/or updates the status field, committing immediately. + """ + try: + log_doc = frappe.get_doc("BM Site Creation Logs", docname) + + # ? Append new log text if provided + if new_text: + updated_log = (log_doc.log or "") + new_text + log_doc.db_set("log", updated_log, update_modified=False) + + # ? Update status if provided (e.g., "In Process", "Success", "Error") + if status: + log_doc.db_set("status", status, update_modified=False) + + frappe.db.commit() + log_doc.reload() + except Exception as e: + frappe.log_error(f"Error updating BM Site Creation Logs: {e}", "BenchMate SiteCreationLogs") + + +def create_site_background(bench_path: str, site_name: str, sudo_password: str, mysql_root_password: str): + """ + Background task to create a new Frappe site inside a given bench. + Captures real-time logs into BM Site Creation Logs doctype, + and cleans up temporary log files after completion. + """ + bench_path = os.path.abspath(bench_path) + log_file = os.path.join(bench_path, f"bench_new_site_{site_name}.log") + + # ? Create a unique BM Site Creation Logs record for tracking + log_timestamp = int(time.time()) + log_name = f"{site_name}-{log_timestamp}" + if not frappe.db.exists("BM Site Creation Logs", log_name): + frappe.get_doc( + { + "doctype": "BM Site Creation Logs", + "site_name": site_name, + "log": "", + "log_timestamp": log_timestamp, + "status": "In Process", + } + ).insert(ignore_permissions=True) + frappe.db.commit() + + # ? Command to create a new site with root DB password and default admin password + cmd = [ + "sudo", + "-S", + "bench", + "new-site", + site_name, + "--db-root-password", + mysql_root_password, + "--admin-password", + "root", + "--verbose", + ] + + # ? Launch subprocess and redirect stdout/stderr into a log file + with open(log_file, "w") as f: + proc = subprocess.Popen( + cmd, + cwd=bench_path, + stdin=subprocess.PIPE, + stdout=f, + stderr=subprocess.STDOUT, + text=True, + ) + # ? Send sudo password to subprocess + proc.stdin.write(sudo_password + "\n") + proc.stdin.flush() + proc.stdin.close() + + # ? Tail the log file and update BM Site Creation Logs in real-time + try: + with open(log_file) as f: + f.seek(0, os.SEEK_END) # ? Move to end for live tailing + while proc.poll() is None: + where = f.tell() + line = f.readline() + if not line: + time.sleep(1) + f.seek(where) + else: + update_log_status(log_name, new_text=line) + + # ? Capture any remaining output after process finishes + remaining = f.read() + if remaining: + update_log_status(log_name, new_text=remaining) + + # ? Update status based on exit code + if proc.returncode == 0: + update_log_status(log_name, status="Success") + else: + update_log_status(log_name, status="Error") + + except Exception as e: + frappe.log_error(f"Error tailing bench new-site log: {e}", "BenchMate SiteCreationLogs") + update_log_status(log_name, status="Error") + + finally: + # ? Always clean up the temporary log file + try: + if os.path.exists(log_file): + os.remove(log_file) + except Exception as cleanup_error: + frappe.log_error( + f"Failed to remove temp log file {log_file}: {cleanup_error}", + "BenchMate SiteCreationLogs", + ) + + +@frappe.whitelist() +def execute(bench_path: str, site_name: str): + """ + ? Public API method (whitelisted) to enqueue site creation. + ? Validates input and enqueues the background site creation task. + """ + if not bench_path or not site_name: + frappe.throw("bench_path and site_name are required", frappe.ValidationError) + + # ? Fetch global BenchMate settings (sudo password, DB password) + settings = get_benchmate_settings() + sudo_password = settings.get("sudo_password") + mysql_root_password = settings.get("db_password") + + # ? Validate required passwords are configured + if not sudo_password: + frappe.throw("Sudo password not configured", frappe.ValidationError) + if not mysql_root_password: + frappe.throw("MySQL root password not configured", frappe.ValidationError) + + # ? Enqueue background task for long execution + try: + frappe.enqueue( + create_site_background, + queue="long", + timeout=3600, + bench_path=bench_path, + site_name=site_name, + sudo_password=sudo_password, + mysql_root_password=mysql_root_password, + ) + except Exception as e: + frappe.throw(f"Failed to enqueue site creation: {e!s}") + + return { + "success": True, + "message": f"Creating {site_name} in background Check BM Site Creation Logs for more details.", + "data": {"bench_path": bench_path}, + } diff --git a/benchmate/benchmate/doctype/bm_bench/bm_bench.js b/benchmate/benchmate/doctype/bm_bench/bm_bench.js index c1cd975..22a74f3 100644 --- a/benchmate/benchmate/doctype/bm_bench/bm_bench.js +++ b/benchmate/benchmate/doctype/bm_bench/bm_bench.js @@ -1,8 +1,162 @@ // Copyright (c) 2025, Karan Mistry and contributors // For license information, please see license.txt -// frappe.ui.form.on("BM Bench", { -// refresh(frm) { +frappe.ui.form.on("BM Bench", { + refresh: function (frm) { + // ? Function to add bench actions + addBenchActions(frm); + }, +}); -// }, -// }); +// ? Function to add bench actions +function addBenchActions(frm) { + // ? Add "Create Site" button and pair it with handler + frm.add_custom_button( + __("Create Site"), + function () { + createSite(frm); + }, + __("Actions") + ); + + // ? Add "Start Bench" button and pair it with handler + frm.add_custom_button( + __("Start Bench"), + function () { + startBench(frm); + }, + __("Actions") + ); + + // ? Add "Stop Bench" button and pair it with handler + frm.add_custom_button( + __("Stop Bench"), + function () { + stopBench(frm); + }, + __("Actions") + ); +} + +function createSite(frm) { + let d = new frappe.ui.Dialog({ + title: __("Create New Site"), + fields: [ + { + fieldtype: "Data", + label: __("Site Name"), + fieldname: "site_name", + reqd: 1, + }, + ], + primary_action_label: __("Create"), + primary_action(values) { + d.hide(); + frappe.call({ + method: "benchmate.api.actions.create_site.execute", + args: { + bench_path: frm.doc.path, + site_name: values.site_name, + }, + freeze: true, + freeze_message: `Creating Site ${values.site_name}...`, + callback: function (r) { + // ? If success show success message + if (r.message.success) { + frappe.show_alert( + { + message: __(r.message.message), + indicator: "green", + }, + 5 + ); + } + + // ? If error show error message + else { + frappe.show_alert( + { + message: __(r.message.message), + indicator: "red", + }, + 5 + ); + } + }, + }); + }, + }); + d.show(); +} + +// ? Function to handle Start Bench action +function startBench(frm) { + frappe.call({ + method: "benchmate.api.actions.bench_start.execute", + args: { + bench_name: frm.doc.name, + bench_path: frm.doc.path, + }, + freeze: true, + freeze_message: "Starting Bench...", + callback: function (r) { + // ? If success show success message + if (r.message.success) { + frappe.show_alert( + { + message: __(r.message.message), + indicator: "green", + }, + 5 + ); + } + + // ? If error show error message + else { + frappe.show_alert( + { + message: __(r.message.message), + indicator: "red", + }, + 5 + ); + } + }, + }); +} + +// ? Function to handle Stop Bench action +function stopBench(frm) { + frappe.call({ + method: "benchmate.api.actions.bench_stop.execute", + args: { + bench_name: frm.doc.name, + bench_path: frm.doc.path, + }, + freeze: true, + freeze_message: "Stopping Bench...", + callback: function (r) { + // ? If success show success message + if (r.message.success) { + frappe.show_alert( + { + message: __(r.message.message), + indicator: "green", + }, + 5 + ); + } + + // ? If error show error message + else { + frappe.show_alert( + { + message: __(r.message.message), + indicator: "red", + }, + 5 + ); + } + }, + }); +} diff --git a/benchmate/benchmate/doctype/bm_site_creation_logs/__init__.py b/benchmate/benchmate/doctype/bm_site_creation_logs/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/benchmate/benchmate/doctype/bm_site_creation_logs/bm_site_creation_logs.js b/benchmate/benchmate/doctype/bm_site_creation_logs/bm_site_creation_logs.js new file mode 100644 index 0000000..57d60fc --- /dev/null +++ b/benchmate/benchmate/doctype/bm_site_creation_logs/bm_site_creation_logs.js @@ -0,0 +1,8 @@ +// Copyright (c) 2025, Karan Mistry and contributors +// For license information, please see license.txt + +// frappe.ui.form.on("BM Site Creation Logs", { +// refresh(frm) { + +// }, +// }); diff --git a/benchmate/benchmate/doctype/bm_site_creation_logs/bm_site_creation_logs.json b/benchmate/benchmate/doctype/bm_site_creation_logs/bm_site_creation_logs.json new file mode 100644 index 0000000..e8c4c71 --- /dev/null +++ b/benchmate/benchmate/doctype/bm_site_creation_logs/bm_site_creation_logs.json @@ -0,0 +1,102 @@ +{ + "actions": [], + "allow_rename": 1, + "autoname": "format:{site_name}-{log_timestamp}", + "creation": "2025-08-27 02:56:40.293176", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "site_name", + "column_break_bkvd", + "status", + "log_timestamp", + "section_break_jlrx", + "log" + ], + "fields": [ + { + "fieldname": "column_break_bkvd", + "fieldtype": "Column Break" + }, + { + "fieldname": "section_break_jlrx", + "fieldtype": "Section Break" + }, + { + "fieldname": "log", + "fieldtype": "Long Text", + "in_list_view": 1, + "label": "Log", + "read_only": 1 + }, + { + "default": "In Process", + "fieldname": "status", + "fieldtype": "Select", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Status", + "options": "\nIn Process\nSuccess\nError", + "read_only": 1 + }, + { + "fieldname": "site_name", + "fieldtype": "Data", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Site Name", + "read_only": 1 + }, + { + "fieldname": "log_timestamp", + "fieldtype": "Data", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Log Timestamp", + "read_only": 1, + "unique": 1 + } + ], + "grid_page_length": 50, + "index_web_pages_for_search": 1, + "links": [], + "modified": "2025-08-27 03:39:40.158232", + "modified_by": "Administrator", + "module": "BenchMate", + "name": "BM Site Creation Logs", + "naming_rule": "Expression", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + } + ], + "row_format": "Dynamic", + "sort_field": "creation", + "sort_order": "DESC", + "states": [ + { + "color": "Blue", + "title": "In Process" + }, + { + "color": "Green", + "title": "Success" + }, + { + "color": "Red", + "title": "Error" + } + ], + "title_field": "site_name", + "track_seen": 1 +} diff --git a/benchmate/benchmate/doctype/bm_site_creation_logs/bm_site_creation_logs.py b/benchmate/benchmate/doctype/bm_site_creation_logs/bm_site_creation_logs.py new file mode 100644 index 0000000..1639f9c --- /dev/null +++ b/benchmate/benchmate/doctype/bm_site_creation_logs/bm_site_creation_logs.py @@ -0,0 +1,9 @@ +# Copyright (c) 2025, Karan Mistry and contributors +# For license information, please see license.txt + +# import frappe +from frappe.model.document import Document + + +class BMSiteCreationLogs(Document): + pass diff --git a/benchmate/benchmate/doctype/bm_site_creation_logs/test_bm_site_creation_logs.py b/benchmate/benchmate/doctype/bm_site_creation_logs/test_bm_site_creation_logs.py new file mode 100644 index 0000000..8169f2c --- /dev/null +++ b/benchmate/benchmate/doctype/bm_site_creation_logs/test_bm_site_creation_logs.py @@ -0,0 +1,20 @@ +# Copyright (c) 2025, Karan Mistry and Contributors +# See license.txt + +# import frappe +from frappe.tests import IntegrationTestCase + +# On IntegrationTestCase, the doctype test records and all +# link-field test record dependencies are recursively loaded +# Use these module variables to add/remove to/from that list +EXTRA_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"] +IGNORE_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"] + + +class IntegrationTestBMSiteCreationLogs(IntegrationTestCase): + """ + Integration tests for BMSiteCreationLogs. + Use this class for testing interactions between multiple components. + """ + + pass diff --git a/benchmate/benchmate/doctype/bm_sync_log/bm_sync_log.json b/benchmate/benchmate/doctype/bm_sync_log/bm_sync_log.json index 7380294..2b3bbdf 100644 --- a/benchmate/benchmate/doctype/bm_sync_log/bm_sync_log.json +++ b/benchmate/benchmate/doctype/bm_sync_log/bm_sync_log.json @@ -28,7 +28,7 @@ "read_only": 1 }, { - "default": "Log", + "default": "Error", "fieldname": "status", "fieldtype": "Select", "in_list_view": 1, @@ -48,7 +48,7 @@ "grid_page_length": 50, "index_web_pages_for_search": 1, "links": [], - "modified": "2025-08-18 02:47:10.030458", + "modified": "2025-08-27 03:00:11.397603", "modified_by": "Administrator", "module": "BenchMate", "name": "BM Sync Log",