diff --git a/benchmate/api/actions/drop_site.py b/benchmate/api/actions/drop_site.py new file mode 100644 index 0000000..75bd5a5 --- /dev/null +++ b/benchmate/api/actions/drop_site.py @@ -0,0 +1,206 @@ +import os +import subprocess +import time + +import frappe + +from benchmate.api.utils import get_benchmate_settings + + +def update_deletion_log_status(docname, new_text=None, status=None): + """ + ? Update the BM Site Deletion 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 Deletion 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 + 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 Deletion Logs: {e}", "BenchMate SiteDeletionLogs") + + +def drop_site_background( + bench_name: str, bench_path: str, site_name: str, sudo_password: str, mysql_root_password: str +): + """ + Background task to drop (delete) a Frappe site inside a given bench. + Captures real-time logs into BM Site Deletion 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_drop_site_{site_name}.log") + + # ? Create a unique BM Site Deletion Logs record for tracking + log_timestamp = int(time.time()) + log_name = f"{site_name}-{log_timestamp}" + if not frappe.db.exists("BM Site Deletion Logs", log_name): + frappe.get_doc( + { + "doctype": "BM Site Deletion Logs", + "site_name": site_name, + "log": "", + "log_timestamp": log_timestamp, + "status": "In Process", + } + ).insert(ignore_permissions=True) + frappe.db.commit() + + # ? Command to drop the site with root DB password, no --verbose + cmd = [ + "sudo", + "-S", + "bench", + "drop-site", + site_name, + "--db-root-password", + mysql_root_password, + "--no-backup", + "--force", + ] + + # ? Launch subprocess and redirect stdout/stderr into a log file + try: + 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() + + # ? Wait for process completion with timeout (10 mins) + try: + proc.wait(timeout=600) + except subprocess.TimeoutExpired: + proc.kill() + update_deletion_log_status( + log_name, + new_text="\nTimed out while deleting site!\n", + status="Error", + ) + frappe.msgprint( + msg=f"Timeout expired while deleting site {site_name}.", + title="Site Deletion Timeout", + alert=True, + indicator="red", + ) + return + + # ? Tail the log file and update BM Site Deletion Logs in real-time + with open(log_file) as f: + f.seek(0, os.SEEK_SET) + # ? Stream entire log file content to document + for line in f: + update_deletion_log_status(log_name, new_text=line) + + # ? Update status based on exit code + if proc.returncode == 0: + update_deletion_log_status(log_name, status="Success") + remove_bm_site(bench_name=bench_name, bench_path=bench_path, site_name=site_name) + else: + update_deletion_log_status(log_name, status="Error") + + except Exception as e: + frappe.msgprint( + msg=f"Error While Deleting Site {site_name} in bench {bench_name}", + title="Site Deletion Error", + realtime=True, + alert=True, + indicator="red", + ) + frappe.log_error(f"Error running bench drop-site: {e}", "BenchMate SiteDeletionLogs") + update_deletion_log_status(log_name, status="Error") + + else: + frappe.msgprint( + msg=f"Site {site_name} deleted successfully from bench {bench_name}", + title="Site Deletion Success", + realtime=True, + alert=True, + indicator="green", + ) + + 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 SiteDeletionLogs", + ) + + +def remove_bm_site(bench_name: str, bench_path: str, site_name: str): + """ + Remove the BM Site record from the system. + Unlinks the site from its bench and deletes its record. + """ + # ? Find and delete the BM Site record matching this site + site_doc_name = frappe.db.get_value("BM Site", {"bench_name": bench_name, "site_name": site_name}) + if site_doc_name: + frappe.delete_doc("BM Site", site_doc_name, ignore_permissions=True) + frappe.db.commit() + + +@frappe.whitelist() +def execute(bench_name: str, bench_path: str, site_name: str): + """ + ? Public API method (whitelisted) to enqueue site deletion. + ? Validates input and enqueues the background site deletion 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( + drop_site_background, + queue="long", + timeout=3600, + bench_name=bench_name, + 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 deletion: {e!s}") + + return { + "success": True, + "message": ( + f"Deleting site {site_name} in the background. " + f"Check the BM Site Deletion Logs for more details." + ), + "data": {"bench_path": bench_path}, + } diff --git a/benchmate/api/utils.py b/benchmate/api/utils.py index eb4893a..7306609 100644 --- a/benchmate/api/utils.py +++ b/benchmate/api/utils.py @@ -23,3 +23,35 @@ def get_benchmate_settings(): "Benchmate Settings are not enabled. Please enable them to proceed.", frappe.ValidationError, ) + + +# ! benchmate.api.utils.get_sites +@frappe.whitelist() +def get_sites(bench_name: str): + try: + site_list = frappe.get_all( + "BM Site", + filters={"bench_name": bench_name}, + fields=["name", "site_name", "status"], + ) + + if not site_list or len(site_list) < 0: + frappe.throw( + f"There are no sites in bench {bench_name}", + frappe.ValidationError, + ) + + except Exception as e: + frappe.log_error("Error while benchmate.api.utils.get_sites", frappe.get_traceback()) + return { + "success": True, + "message": str(e), + "data": None, + } + + else: + return { + "success": True, + "message": "Successfully Get The Site List", + "data": site_list, + } diff --git a/benchmate/benchmate/doctype/bm_bench/bm_bench.js b/benchmate/benchmate/doctype/bm_bench/bm_bench.js index d5cf6ef..4c6eb72 100644 --- a/benchmate/benchmate/doctype/bm_bench/bm_bench.js +++ b/benchmate/benchmate/doctype/bm_bench/bm_bench.js @@ -19,6 +19,15 @@ function addBenchActions(frm) { __("Actions") ); + // ? Add "Drop Site" button and pair it with handler + frm.add_custom_button( + __("Drop Site"), + function () { + dropSite(frm); + }, + __("Actions") + ); + // ? Add "Start Bench" button and pair it with handler frm.add_custom_button( __("Start Bench"), @@ -39,7 +48,7 @@ function addBenchActions(frm) { } function createSite(frm) { - let d = new frappe.ui.Dialog({ + let dialog = new frappe.ui.Dialog({ title: __("Create New Site"), fields: [ { @@ -51,7 +60,7 @@ function createSite(frm) { ], primary_action_label: __("Create"), primary_action(values) { - d.hide(); + dialog.hide(); frappe.call({ method: "benchmate.api.actions.create_site.execute", args: { @@ -87,7 +96,7 @@ function createSite(frm) { }); }, }); - d.show(); + dialog.show(); } // ? Function to handle Start Bench action @@ -161,3 +170,100 @@ function stopBench(frm) { }, }); } + +// ? Function to handle the Drop Site action from BM Bench form +function dropSite(frm) { + // ? Create a dialog box for site selection and drop confirmation + let dialog = new frappe.ui.Dialog({ + title: __("Drop A Site"), + + // ? Fields inside the dialog + fields: [ + { + label: __("Site"), // ? BM Site link field + fieldname: "site", + fieldtype: "Link", + options: "BM Site", + reqd: 1, + get_query: function () { + // ? Restrict sites only for the current bench + return { + filters: [["bench_name", "=", frm.doc.name]], + }; + }, + onchange: function () { + // ? On site selection, fetch the actual site_name + let site = dialog.get_value("site"); + if (site) { + frappe.call({ + method: "frappe.client.get_value", + args: { + doctype: "BM Site", + filters: { name: site }, + fieldname: ["site_name"], + }, + freeze: true, + freeze_message: __(`Fetching Site Name...`), + callback: function (response) { + // ? Auto-populate the readonly Site Name field + dialog.set_value("site_name", response.message.site_name); + }, + }); + } + }, + }, + { + label: __("Site Name"), // ? Read-only field to confirm exact site_name + fieldname: "site_name", + fieldtype: "Data", + read_only: 1, + reqd: 1, + }, + ], + + // ? Primary action button: Drop Site + primary_action_label: __("Drop"), + primary_action(values) { + // ? Hide dialog after confirmation + dialog.hide(); + + // ? Call server-side method to drop the site + frappe.call({ + method: "benchmate.api.actions.drop_site.execute", + args: { + bench_name: frm.doc.name, // ? Current bench name + bench_path: frm.doc.path, // ? Bench path + site_name: values.site_name, // ? Site selected to drop + }, + freeze: true, + freeze_message: __(`Dropping Site ${values.site_name}...`), + + // ? Handle callback after server execution + callback: function (r) { + if (r.message.success) { + // ? Show success message on site drop + frappe.show_alert( + { + message: __(r.message.message), + indicator: "green", + }, + 5 + ); + } else { + // ? Show error message if failed + frappe.show_alert( + { + message: __(r.message.message), + indicator: "red", + }, + 5 + ); + } + }, + }); + }, + }); + + // ? Display the dialog to the user + dialog.show(); +} diff --git a/benchmate/benchmate/doctype/bm_site_deletion_logs/__init__.py b/benchmate/benchmate/doctype/bm_site_deletion_logs/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/benchmate/benchmate/doctype/bm_site_deletion_logs/bm_site_deletion_logs.js b/benchmate/benchmate/doctype/bm_site_deletion_logs/bm_site_deletion_logs.js new file mode 100644 index 0000000..95cd396 --- /dev/null +++ b/benchmate/benchmate/doctype/bm_site_deletion_logs/bm_site_deletion_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 Deletion Logs", { +// refresh(frm) { + +// }, +// }); diff --git a/benchmate/benchmate/doctype/bm_site_deletion_logs/bm_site_deletion_logs.json b/benchmate/benchmate/doctype/bm_site_deletion_logs/bm_site_deletion_logs.json new file mode 100644 index 0000000..5302bd1 --- /dev/null +++ b/benchmate/benchmate/doctype/bm_site_deletion_logs/bm_site_deletion_logs.json @@ -0,0 +1,102 @@ +{ + "actions": [], + "allow_rename": 1, + "autoname": "format:{site_name}-{log_timestamp}", + "creation": "2025-09-07 23:41:44.865581", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "site_name", + "column_break_bkvd", + "status", + "log_timestamp", + "section_break_jlrx", + "log" + ], + "fields": [ + { + "fieldname": "site_name", + "fieldtype": "Data", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Site Name", + "read_only": 1 + }, + { + "fieldname": "column_break_bkvd", + "fieldtype": "Column Break" + }, + { + "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": "log_timestamp", + "fieldtype": "Data", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Log Timestamp", + "read_only": 1, + "unique": 1 + }, + { + "fieldname": "section_break_jlrx", + "fieldtype": "Section Break" + }, + { + "fieldname": "log", + "fieldtype": "Long Text", + "in_list_view": 1, + "label": "Log", + "read_only": 1 + } + ], + "grid_page_length": 50, + "index_web_pages_for_search": 1, + "links": [], + "modified": "2025-09-07 23:41:44.865581", + "modified_by": "Administrator", + "module": "BenchMate", + "name": "BM Site Deletion 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_deletion_logs/bm_site_deletion_logs.py b/benchmate/benchmate/doctype/bm_site_deletion_logs/bm_site_deletion_logs.py new file mode 100644 index 0000000..0d96f97 --- /dev/null +++ b/benchmate/benchmate/doctype/bm_site_deletion_logs/bm_site_deletion_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 BMSiteDeletionLogs(Document): + pass diff --git a/benchmate/benchmate/doctype/bm_site_deletion_logs/test_bm_site_deletion_logs.py b/benchmate/benchmate/doctype/bm_site_deletion_logs/test_bm_site_deletion_logs.py new file mode 100644 index 0000000..c239de8 --- /dev/null +++ b/benchmate/benchmate/doctype/bm_site_deletion_logs/test_bm_site_deletion_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 IntegrationTestBMSiteDeletionLogs(IntegrationTestCase): + """ + Integration tests for BMSiteDeletionLogs. + Use this class for testing interactions between multiple components. + """ + + pass diff --git a/benchmate/benchmate/workspace/benchmate/benchmate.json b/benchmate/benchmate/workspace/benchmate/benchmate.json index ea97eca..ad73c54 100644 --- a/benchmate/benchmate/workspace/benchmate/benchmate.json +++ b/benchmate/benchmate/workspace/benchmate/benchmate.json @@ -1,7 +1,7 @@ { "app": "benchmate", "charts": [], - "content": "[{\"id\":\"E4TboPQAiW\",\"type\":\"header\",\"data\":{\"text\":\"BenchMate\",\"col\":12}},{\"id\":\"pckRgVGLCJ\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"BM Bench\",\"col\":3}},{\"id\":\"uIKLe2weO2\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"BM Site\",\"col\":3}},{\"id\":\"1zPvETmYcy\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"BM App\",\"col\":3}},{\"id\":\"OdX7vGpNVa\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"BM Sync Log\",\"col\":3}},{\"id\":\"CZYPa4MOMK\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Bm Site Creation Logs\",\"col\":3}},{\"id\":\"Zlbft996Gt\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"BM Settings\",\"col\":3}}]", + "content": "[{\"id\":\"E4TboPQAiW\",\"type\":\"header\",\"data\":{\"text\":\"BenchMate\",\"col\":12}},{\"id\":\"pckRgVGLCJ\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"BM Bench\",\"col\":3}},{\"id\":\"uIKLe2weO2\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"BM Site\",\"col\":3}},{\"id\":\"1zPvETmYcy\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"BM App\",\"col\":3}},{\"id\":\"OdX7vGpNVa\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"BM Sync Log\",\"col\":3}},{\"id\":\"CZYPa4MOMK\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Bm Site Creation Logs\",\"col\":3}},{\"id\":\"5GjUZsGiN_\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Bm Site Deletion Logs\",\"col\":3}},{\"id\":\"Zlbft996Gt\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"BM Settings\",\"col\":3}}]", "creation": "2025-08-18 03:30:43.609066", "custom_blocks": [], "docstatus": 0, @@ -15,7 +15,7 @@ "label": "BenchMate", "link_type": "DocType", "links": [], - "modified": "2025-09-07 21:22:03.176848", + "modified": "2025-09-07 23:42:31.333942", "modified_by": "Administrator", "module": "BenchMate", "name": "BenchMate", @@ -44,6 +44,14 @@ "stats_filter": "[]", "type": "DocType" }, + { + "color": "Grey", + "doc_view": "List", + "label": "Bm Site Deletion Logs", + "link_to": "BM Site Deletion Logs", + "stats_filter": "[]", + "type": "DocType" + }, { "color": "Grey", "doc_view": "List",