feat(easy-docker): add guarded stack and site deletion

This commit is contained in:
RocketQuack 2026-03-28 13:15:00 +01:00
parent 7c4d1d47ca
commit 1a839299ab
5 changed files with 364 additions and 1 deletions

View file

@ -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 <<EOF
${compose_files_lines}
EOF
if [ "${#compose_args[@]}" -eq 0 ]; then
return 52
fi
if [ -n "${fallback_erpnext_version}" ]; then
if ! ERPNEXT_VERSION="${fallback_erpnext_version}" docker compose --project-name "${compose_project_name}" --env-file "${env_path}" "${compose_args[@]}" down -v --remove-orphans --rmi local; then
return 54
fi
elif ! docker compose --project-name "${compose_project_name}" --env-file "${env_path}" "${compose_args[@]}" down -v --remove-orphans --rmi local; then
return 54
fi
if [ -n "${custom_image_ref}" ]; then
if docker image inspect "${custom_image_ref}" >/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}"

View file

@ -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}"

View file

@ -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}"
}

View file

@ -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)"

View file

@ -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}"