diff --git a/scripts/easy-docker/lib/app/wizard/common/compose/start.sh b/scripts/easy-docker/lib/app/wizard/common/compose/start.sh index be75672c..c10f81ce 100755 --- a/scripts/easy-docker/lib/app/wizard/common/compose/start.sh +++ b/scripts/easy-docker/lib/app/wizard/common/compose/start.sh @@ -218,6 +218,115 @@ EOF return 0 } +delete_stack_with_compose_from_metadata() { + local stack_dir="${1}" + local metadata_path="" + local env_path="" + local compose_files_lines="" + local compose_file="" + local source_compose_path="" + local env_erpnext_version="" + local fallback_erpnext_version="" + local compose_project_name="" + local stack_topology="" + local repo_root="" + local custom_image="" + local custom_tag="" + local custom_image_ref="" + local -a compose_args=() + + # shellcheck disable=SC2034 # Read by manage flow after delete_stack_with_compose_from_metadata fails. + EASY_DOCKER_COMPOSE_ERROR_DETAIL="" + + metadata_path="${stack_dir}/metadata.json" + env_path="$(get_stack_env_path "${stack_dir}")" + compose_project_name="$(get_stack_compose_project_name "${stack_dir}")" + + if [ ! -f "${metadata_path}" ]; then + return 48 + fi + + if [ ! -f "${env_path}" ]; then + return 49 + fi + + stack_topology="$(get_stack_topology "${stack_dir}" || true)" + if [ -z "${stack_topology}" ]; then + EASY_DOCKER_COMPOSE_ERROR_DETAIL="metadata.json missing topology" + return 50 + fi + + case "${stack_topology}" in + "single-host") ;; + *) + EASY_DOCKER_COMPOSE_ERROR_DETAIL="${stack_topology}" + return 51 + ;; + esac + + env_erpnext_version="$(get_env_file_key_value "${env_path}" "ERPNEXT_VERSION" || true)" + if [ -z "${env_erpnext_version}" ]; then + fallback_erpnext_version="$(get_default_erpnext_version || true)" + fi + + custom_image="$(get_env_file_key_value "${env_path}" "CUSTOM_IMAGE" || true)" + custom_tag="$(get_env_file_key_value "${env_path}" "CUSTOM_TAG" || true)" + if [ -n "${custom_image}" ] && [ -n "${custom_tag}" ]; then + custom_image_ref="${custom_image}:${custom_tag}" + fi + + compose_files_lines="$(get_metadata_compose_files_lines "${metadata_path}" || true)" + if [ -z "${compose_files_lines}" ]; then + return 52 + fi + + repo_root="$(get_easy_docker_repo_root)" + while IFS= read -r compose_file; do + if [ -z "${compose_file}" ]; then + continue + fi + + source_compose_path="${repo_root}/${compose_file}" + if [ ! -f "${source_compose_path}" ]; then + EASY_DOCKER_COMPOSE_ERROR_DETAIL="${source_compose_path}" + return 53 + fi + + compose_args+=(-f "${source_compose_path}") + done </dev/null 2>&1; then + if ! docker image rm "${custom_image_ref}"; then + EASY_DOCKER_COMPOSE_ERROR_DETAIL="${custom_image_ref}" + return 55 + fi + fi + fi + + if ! rollback_stack_directory "${stack_dir}"; then + # shellcheck disable=SC2034 # Read by manage flow after delete_stack_with_compose_from_metadata returns 56. + EASY_DOCKER_COMPOSE_ERROR_DETAIL="${stack_dir}" + return 56 + fi + + return 0 +} + get_stack_compose_runtime_status_label() { local result_var="${1}" local stack_dir="${2}" diff --git a/scripts/easy-docker/lib/app/wizard/common/site/bootstrap.sh b/scripts/easy-docker/lib/app/wizard/common/site/bootstrap.sh index 3b5fd2ae..7ce8c33d 100755 --- a/scripts/easy-docker/lib/app/wizard/common/site/bootstrap.sh +++ b/scripts/easy-docker/lib/app/wizard/common/site/bootstrap.sh @@ -741,6 +741,45 @@ cleanup_partial_stack_site() { return 0 } +delete_configured_stack_site() { + local stack_dir="${1}" + local site_name="" + local delete_status=0 + + if ! stack_supports_single_site_management "${stack_dir}"; then + return 52 + fi + + site_name="$(get_stack_site_name "${stack_dir}" || true)" + if ! is_safe_stack_site_cleanup_name "${site_name}"; then + return 61 + fi + + if ! stack_backend_service_is_running "${stack_dir}"; then + return 51 + fi + + if cleanup_partial_stack_site "${stack_dir}" "${site_name}"; then + : + else + delete_status=$? + case "${delete_status}" in + 54 | 52 | 61) + return "${delete_status}" + ;; + *) + return 60 + ;; + esac + fi + + if ! clear_stack_site_metadata "${stack_dir}"; then + return 58 + fi + + return 0 +} + create_first_stack_site() { local stack_dir="${1}" local site_name="${2}" diff --git a/scripts/easy-docker/lib/app/wizard/common/site/metadata.sh b/scripts/easy-docker/lib/app/wizard/common/site/metadata.sh index 49a7a187..7da9f2e9 100755 --- a/scripts/easy-docker/lib/app/wizard/common/site/metadata.sh +++ b/scripts/easy-docker/lib/app/wizard/common/site/metadata.sh @@ -352,3 +352,11 @@ mark_stack_site_failed() { updated_at="$(get_current_utc_timestamp)" persist_stack_site_metadata "${stack_dir}" "single-site" "${site_name}" "failed" "${apps_installed_lines}" "${last_action}" "${last_error}" "${error_log_path}" "${created_at}" "${updated_at}" } + +clear_stack_site_metadata() { + local stack_dir="${1}" + local updated_at="" + + updated_at="$(get_current_utc_timestamp)" + persist_stack_site_metadata "${stack_dir}" "single-site" "" "not_created" "" "delete-site" "" "" "" "${updated_at}" +} diff --git a/scripts/easy-docker/lib/app/wizard/flows/manage.sh b/scripts/easy-docker/lib/app/wizard/flows/manage.sh index 7d4c3063..639f5e06 100755 --- a/scripts/easy-docker/lib/app/wizard/flows/manage.sh +++ b/scripts/easy-docker/lib/app/wizard/flows/manage.sh @@ -128,6 +128,35 @@ prompt_manage_stack_site_admin_password_with_cancel() { done } +prompt_manage_stack_delete_keyword_with_cancel() { + local result_var="${1}" + local stack_name="${2}" + local delete_confirmation="" + local prompt_status=0 + + while true; do + delete_confirmation="$(prompt_manage_stack_delete_keyword "${stack_name}")" + prompt_status=$? + if [ "${prompt_status}" -ne 0 ]; then + return "${FLOW_ABORT_INPUT}" + fi + + delete_confirmation="$(printf '%s' "${delete_confirmation}" | tr -d '\r\n')" + case "${delete_confirmation}" in + /back | /Back | /BACK | "") + return "${FLOW_ABORT_INPUT}" + ;; + delete) + printf -v "${result_var}" "%s" "${delete_confirmation}" + return "${FLOW_CONTINUE}" + ;; + *) + show_warning_and_wait "Type exactly delete to confirm stack removal." 3 + ;; + esac + done +} + handle_manage_stack_site_flow() { local stack_name="${1}" local stack_dir="${2}" @@ -141,6 +170,7 @@ handle_manage_stack_site_flow() { local existing_site_apps_lines="" local existing_site_apps_csv="" local existing_site_details_action="" + local site_delete_confirmation="" while true; do existing_site_entry="" @@ -239,6 +269,59 @@ handle_manage_stack_site_flow() { "${existing_site_apps_csv}" || true )" case "${existing_site_details_action}" in + "Delete site") + site_delete_confirmation="$( + show_manage_stack_site_delete_confirmation \ + "${stack_name}" \ + "${stack_dir}" \ + "${existing_site_name}" || true + )" + case "${site_delete_confirmation}" in + "Yes") + show_warning_message "Deleting site for stack: ${stack_name}" + if delete_configured_stack_site "${stack_dir}"; then + show_warning_and_wait "Site deleted successfully with its database: ${existing_site_name}" 3 + continue + fi + + site_flow_status=$? + case "${site_flow_status}" in + 51) + show_warning_and_wait "Cannot delete site: backend service is not running yet. Start the stack first." 4 + ;; + 52) + show_warning_and_wait "Cannot delete site for this topology yet. Only single-host stacks are supported." 4 + ;; + 54) + show_warning_and_wait "Cannot delete site because stack metadata, env, or compose inputs are incomplete." 4 + ;; + 58) + show_warning_and_wait "The cleared site state could not be written to metadata.json." 4 + ;; + 60) + show_warning_and_wait "Site deletion could not remove all site or database data automatically. Manual cleanup is required." 5 + ;; + 61) + show_warning_and_wait "Cannot delete site because the configured site name was empty or unsafe for cleanup operations." 4 + ;; + *) + show_warning_and_wait "Site deletion failed (${site_flow_status})." 4 + ;; + esac + continue + ;; + "No" | "") + continue + ;; + "Exit and close easy-docker") + return "${FLOW_EXIT_APP}" + ;; + *) + show_warning_and_wait "Unknown site delete confirmation action: ${site_delete_confirmation}" 2 + continue + ;; + esac + ;; "Back" | "") continue ;; @@ -273,6 +356,8 @@ handle_manage_selected_stack_flow() { local generated_compose_path="" local stack_runtime_status="" local missing_custom_image_action="" + local delete_stack_confirmation_action="" + local delete_stack_keyword="" stack_dir="$(get_stack_dir_by_name "${stack_name}" || true)" if [ -z "${stack_dir}" ]; then @@ -449,6 +534,70 @@ handle_manage_selected_stack_flow() { ;; esac ;; + "Delete stack") + delete_stack_confirmation_action="$( + show_manage_stack_delete_confirmation "${stack_name}" "${stack_dir}" || true + )" + case "${delete_stack_confirmation_action}" in + "Yes") + if ! prompt_manage_stack_delete_keyword_with_cancel delete_stack_keyword "${stack_name}"; then + continue + fi + if [ "${delete_stack_keyword}" != "delete" ]; then + continue + fi + + show_warning_message "Deleting stack with docker compose resources: ${stack_name}" + if delete_stack_with_compose_from_metadata "${stack_dir}"; then + show_warning_and_wait "Stack deleted successfully with containers, networks, volumes, image, and stack directory: ${stack_name}" 5 + return "${FLOW_CONTINUE}" + fi + + compose_start_status=$? + case "${compose_start_status}" in + 48) + show_warning_and_wait "Cannot delete stack: metadata.json is missing in ${stack_dir}." 4 + ;; + 49) + show_warning_and_wait "Cannot delete stack: stack env file not found in ${stack_dir}." 4 + ;; + 50) + show_warning_and_wait "Cannot delete stack: topology is missing in metadata.json. Re-run the topology wizard for this stack." 4 + ;; + 51) + show_warning_and_wait "Cannot delete stack via docker compose for topology '${EASY_DOCKER_COMPOSE_ERROR_DETAIL}'. Use the topology-specific runbook path." 5 + ;; + 52) + show_warning_and_wait "Cannot delete stack: no compose files configured in metadata.json." 4 + ;; + 53) + show_warning_and_wait "Cannot delete stack: compose file is missing -> ${EASY_DOCKER_COMPOSE_ERROR_DETAIL}" 4 + ;; + 54) + show_warning_and_wait "docker compose down failed. Check the output above for details." 4 + ;; + 55) + show_warning_and_wait "Stack resources were removed, but the configured custom image could not be deleted -> ${EASY_DOCKER_COMPOSE_ERROR_DETAIL}" 5 + ;; + 56) + show_warning_and_wait "Docker resources were removed, but the stack directory could not be deleted -> ${EASY_DOCKER_COMPOSE_ERROR_DETAIL}" 5 + ;; + *) + show_warning_and_wait "Cannot delete stack with docker compose (${compose_start_status})." 4 + ;; + esac + ;; + "No" | "") + continue + ;; + "Exit and close easy-docker") + return "${FLOW_EXIT_APP}" + ;; + *) + show_warning_and_wait "Unknown delete-stack action: ${delete_stack_confirmation_action}" 2 + ;; + esac + ;; "Docker") while true; do docker_action="$(show_manage_stack_docker_menu "${stack_name}" "${stack_dir}" || true)" diff --git a/scripts/easy-docker/lib/ui/screens/production/manage.sh b/scripts/easy-docker/lib/ui/screens/production/manage.sh index 5c2a3009..b501f5e9 100755 --- a/scripts/easy-docker/lib/ui/screens/production/manage.sh +++ b/scripts/easy-docker/lib/ui/screens/production/manage.sh @@ -65,7 +65,7 @@ show_manage_stack_actions_menu() { menu_header="$(printf "Stack actions | %s" "${stack_runtime_status}")" gum choose \ - --height 10 \ + --height 11 \ --header "${menu_header}" \ --cursor.foreground 63 \ --selected.foreground 45 \ @@ -74,6 +74,7 @@ show_manage_stack_actions_menu() { "Site" \ "Start stack in Docker Compose" \ "Stop stack in Docker Compose" \ + "Delete stack" \ "Back" \ "Exit and close easy-docker" } @@ -205,10 +206,67 @@ show_manage_stack_site_details() { --header "Site details" \ --cursor.foreground 63 \ --selected.foreground 45 \ + "Delete site" \ "Back" \ "Exit and close easy-docker" } +show_manage_stack_site_delete_confirmation() { + local stack_name="${1}" + local stack_dir="${2}" + local site_name="${3}" + local status_text="" + + render_main_screen 1 >&2 + + status_text="$(printf "Delete site\n\nStack: %s\nDirectory: %s\nSite: %s\n\nAll site data and the site database will be permanently deleted." "${stack_name}" "${stack_dir}" "${site_name}")" + render_box_message "${status_text}" "0 2" >&2 + + gum choose \ + --height 8 \ + --header "Confirm delete site" \ + --cursor.foreground 63 \ + --selected.foreground 45 \ + "Yes" \ + "No" \ + "Exit and close easy-docker" +} + +show_manage_stack_delete_confirmation() { + local stack_name="${1}" + local stack_dir="${2}" + local status_text="" + + render_main_screen 1 >&2 + + status_text="$(printf "Delete stack\n\nStack: %s\nDirectory: %s\n\nThis will permanently remove the stack directory, Docker containers, networks, volumes, and configured custom image." "${stack_name}" "${stack_dir}")" + render_box_message "${status_text}" "0 2" >&2 + + gum choose \ + --height 8 \ + --header "Confirm delete stack" \ + --cursor.foreground 63 \ + --selected.foreground 45 \ + "Yes" \ + "No" \ + "Exit and close easy-docker" +} + +prompt_manage_stack_delete_keyword() { + local stack_name="${1}" + local status_text="" + + render_main_screen 1 >&2 + + status_text="$(printf "Delete stack\n\nStack: %s\n\nFinal confirmation required.\nType delete to permanently remove the stack and all its data.\nType /back or press Ctrl+C to cancel." "${stack_name}")" + render_box_message "${status_text}" "0 2" >&2 + + gum input \ + --header "Type delete to confirm" \ + --prompt "confirm> " \ + --placeholder "delete" +} + show_missing_custom_image_start_menu() { local stack_name="${1}" local stack_dir="${2}"