Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
206 changes: 206 additions & 0 deletions benchmate/api/actions/drop_site.py
Original file line number Diff line number Diff line change
@@ -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 <b>{site_name}</b> in the background. "
f"Check the <b>BM Site Deletion Logs</b> for more details."
),
"data": {"bench_path": bench_path},
}
32 changes: 32 additions & 0 deletions benchmate/api/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
112 changes: 109 additions & 3 deletions benchmate/benchmate/doctype/bm_bench/bm_bench.js
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand All @@ -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: [
{
Expand All @@ -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: {
Expand Down Expand Up @@ -87,7 +96,7 @@ function createSite(frm) {
});
},
});
d.show();
dialog.show();
}

// ? Function to handle Start Bench action
Expand Down Expand Up @@ -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();
}
Empty file.
Original file line number Diff line number Diff line change
@@ -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) {

// },
// });
Loading