feat(easy-docker): manage apps on existing sites

This commit is contained in:
RocketQuack 2026-04-02 17:40:38 +02:00
parent 227d6652b8
commit 2c97955a07
4 changed files with 760 additions and 2 deletions

View file

@ -1,5 +1,13 @@
#!/usr/bin/env bash
load_easy_docker_site_apps_modules() {
local apps_dir=""
apps_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/apps"
# shellcheck source=scripts/easy-docker/lib/app/wizard/common/site/apps/lifecycle.sh
source "${apps_dir}/lifecycle.sh"
}
append_stack_installable_app_line() {
local result_var="${1}"
local existing_lines="${2:-}"
@ -74,3 +82,5 @@ get_stack_selected_installable_apps() {
printf -v "${result_var}" "%s" "${ordered_app_lines}"
return 0
}
load_easy_docker_site_apps_modules

View file

@ -0,0 +1,437 @@
#!/usr/bin/env bash
stack_site_app_lines_contain() {
local app_lines="${1:-}"
local app_name="${2:-}"
if [ -z "${app_name}" ]; then
return 1
fi
printf '%s\n' "${app_lines}" | grep -F -x -- "${app_name}" >/dev/null 2>&1
}
remove_stack_site_app_line() {
local result_var="${1}"
local existing_lines="${2:-}"
local app_name="${3:-}"
local existing_app=""
local remaining_lines=""
while IFS= read -r existing_app; do
if [ -z "${existing_app}" ] || [ "${existing_app}" = "${app_name}" ]; then
continue
fi
if [ -z "${remaining_lines}" ]; then
remaining_lines="${existing_app}"
else
remaining_lines="${remaining_lines}"$'\n'"${existing_app}"
fi
done <<EOF
${existing_lines}
EOF
printf -v "${result_var}" "%s" "${remaining_lines}"
}
get_stack_site_managed_runtime_app_lines() {
local result_var="${1}"
local stack_dir="${2}"
local site_name="${3}"
local runtime_app_lines=""
local app_name=""
local managed_app_lines=""
local runtime_status=0
if ! is_safe_stack_site_cleanup_name "${site_name}"; then
return 61
fi
if runtime_app_lines="$(get_stack_site_runtime_app_names_lines "${stack_dir}" "${site_name}")"; then
:
runtime_status=0
else
runtime_status=$?
fi
if [ "${runtime_status}" -eq 54 ] || [ "${runtime_status}" -eq 52 ]; then
return "${runtime_status}"
fi
if [ -z "${runtime_app_lines}" ]; then
return 1
fi
while IFS= read -r app_name; do
if [ -z "${app_name}" ] || [ "${app_name}" = "frappe" ]; then
continue
fi
append_stack_installable_app_line managed_app_lines "${managed_app_lines}" "${app_name}"
done <<EOF
${runtime_app_lines}
EOF
printf -v "${result_var}" "%s" "${managed_app_lines}"
return 0
}
persist_stack_site_app_operation_metadata() {
local stack_dir="${1}"
local site_name="${2}"
local apps_installed_lines="${3:-}"
local last_action="${4:-manage-site-apps}"
local last_error="${5:-}"
local error_log_path="${6:-}"
local created_at=""
local updated_at=""
created_at="$(get_stack_site_created_at "${stack_dir}" || true)"
updated_at="$(get_current_utc_timestamp)"
persist_stack_site_metadata \
"${stack_dir}" \
"single-site" \
"${site_name}" \
"${apps_installed_lines}" \
"${last_action}" \
"${last_error}" \
"${error_log_path}" \
"${created_at}" \
"${updated_at}"
}
get_configured_stack_site_installable_app_lines() {
local result_var="${1}"
local stack_dir="${2}"
local site_name=""
local backend_status=0
local selected_app_lines=""
local available_app_lines=""
local installed_app_lines=""
local candidate_app=""
local installable_app_lines=""
local inspect_status=0
if ! stack_supports_single_site_management "${stack_dir}"; then
return 82
fi
site_name="$(get_stack_site_name "${stack_dir}" || true)"
if ! is_safe_stack_site_cleanup_name "${site_name}"; then
return 83
fi
if stack_backend_service_is_running "${stack_dir}"; then
:
else
backend_status=$?
case "${backend_status}" in
54)
return 84
;;
52)
return 82
;;
*)
return 81
;;
esac
fi
if ! get_stack_selected_installable_apps selected_app_lines "${stack_dir}"; then
return 84
fi
if available_app_lines="$(get_stack_runtime_available_app_lines "${stack_dir}")"; then
:
else
inspect_status=$?
case "${inspect_status}" in
54)
return 84
;;
52)
return 82
;;
*)
return 87
;;
esac
fi
if get_stack_site_managed_runtime_app_lines installed_app_lines "${stack_dir}" "${site_name}"; then
:
else
inspect_status=$?
case "${inspect_status}" in
54)
return 84
;;
52)
return 82
;;
61)
return 83
;;
*)
return 87
;;
esac
fi
while IFS= read -r candidate_app; do
if [ -z "${candidate_app}" ]; then
continue
fi
if ! stack_site_app_lines_contain "${available_app_lines}" "${candidate_app}"; then
continue
fi
if stack_site_app_lines_contain "${installed_app_lines}" "${candidate_app}"; then
continue
fi
append_stack_installable_app_line installable_app_lines "${installable_app_lines}" "${candidate_app}"
done <<EOF
${selected_app_lines}
EOF
printf -v "${result_var}" "%s" "${installable_app_lines}"
return 0
}
get_configured_stack_site_uninstallable_app_lines() {
local result_var="${1}"
local stack_dir="${2}"
local site_name=""
local backend_status=0
local installed_app_lines=""
if ! stack_supports_single_site_management "${stack_dir}"; then
return 92
fi
site_name="$(get_stack_site_name "${stack_dir}" || true)"
if ! is_safe_stack_site_cleanup_name "${site_name}"; then
return 93
fi
if stack_backend_service_is_running "${stack_dir}"; then
:
else
backend_status=$?
case "${backend_status}" in
54)
return 94
;;
52)
return 92
;;
*)
return 91
;;
esac
fi
if get_stack_site_managed_runtime_app_lines installed_app_lines "${stack_dir}" "${site_name}"; then
:
else
backend_status=$?
case "${backend_status}" in
54)
return 94
;;
52)
return 92
;;
61)
return 93
;;
*)
return 97
;;
esac
fi
printf -v "${result_var}" "%s" "${installed_app_lines}"
return 0
}
install_app_on_configured_stack_site() {
local stack_dir="${1}"
local app_name="${2:-}"
local site_name=""
local installable_app_lines=""
local current_installed_app_lines=""
local updated_installed_app_lines=""
local install_command=""
local install_output=""
local command_status=0
local installable_status=0
reset_easy_docker_site_error_state
if [ -z "${app_name}" ]; then
return 86
fi
if get_configured_stack_site_installable_app_lines installable_app_lines "${stack_dir}"; then
:
else
installable_status=$?
return "${installable_status}"
fi
if [ -z "${installable_app_lines}" ]; then
return 85
fi
site_name="$(get_stack_site_name "${stack_dir}" || true)"
if ! get_stack_site_managed_runtime_app_lines current_installed_app_lines "${stack_dir}" "${site_name}"; then
current_installed_app_lines="$(get_stack_site_apps_installed_lines "${stack_dir}" || true)"
fi
if ! stack_site_app_lines_contain "${installable_app_lines}" "${app_name}"; then
return 86
fi
install_command="$(
printf "bench --site %s install-app %s" \
"$(shell_quote_site_command_arg "${site_name}")" \
"$(shell_quote_site_command_arg "${app_name}")"
)"
if run_stack_backend_bash_command_capture install_output "${stack_dir}" "${install_command}"; then
:
else
command_status=$?
EASY_DOCKER_SITE_ERROR_DETAIL="$(printf "bench install-app failed for '%s'." "${app_name}")"
capture_stack_site_error_log "${stack_dir}" "site-install-app-error" "${install_output}" >/dev/null 2>&1 || true
if ! persist_stack_site_app_operation_metadata \
"${stack_dir}" \
"${site_name}" \
"${current_installed_app_lines}" \
"install-app" \
"${EASY_DOCKER_SITE_ERROR_DETAIL}" \
"${EASY_DOCKER_SITE_ERROR_LOG_PATH}"; then
return 89
fi
case "${command_status}" in
54)
return 84
;;
52)
return 82
;;
*)
return 88
;;
esac
fi
if ! get_stack_site_managed_runtime_app_lines updated_installed_app_lines "${stack_dir}" "${site_name}"; then
updated_installed_app_lines="${current_installed_app_lines}"
append_stack_installable_app_line updated_installed_app_lines "${updated_installed_app_lines}" "${app_name}"
fi
if ! persist_stack_site_app_operation_metadata \
"${stack_dir}" \
"${site_name}" \
"${updated_installed_app_lines}" \
"install-app" \
"" \
""; then
return 89
fi
return 0
}
uninstall_app_from_configured_stack_site() {
local stack_dir="${1}"
local app_name="${2:-}"
local site_name=""
local uninstallable_app_lines=""
local current_installed_app_lines=""
local updated_installed_app_lines=""
local uninstall_command=""
local uninstall_output=""
local command_status=0
local uninstallable_status=0
reset_easy_docker_site_error_state
if [ -z "${app_name}" ] || [ "${app_name}" = "frappe" ]; then
return 96
fi
if get_configured_stack_site_uninstallable_app_lines uninstallable_app_lines "${stack_dir}"; then
:
else
uninstallable_status=$?
return "${uninstallable_status}"
fi
if [ -z "${uninstallable_app_lines}" ]; then
return 95
fi
site_name="$(get_stack_site_name "${stack_dir}" || true)"
current_installed_app_lines="${uninstallable_app_lines}"
if ! stack_site_app_lines_contain "${uninstallable_app_lines}" "${app_name}"; then
return 96
fi
uninstall_command="$(
printf "bench --site %s uninstall-app %s --yes" \
"$(shell_quote_site_command_arg "${site_name}")" \
"$(shell_quote_site_command_arg "${app_name}")"
)"
if run_stack_backend_bash_command_capture uninstall_output "${stack_dir}" "${uninstall_command}"; then
:
else
command_status=$?
EASY_DOCKER_SITE_ERROR_DETAIL="$(printf "bench uninstall-app failed for '%s'." "${app_name}")"
capture_stack_site_error_log "${stack_dir}" "site-uninstall-app-error" "${uninstall_output}" >/dev/null 2>&1 || true
if ! persist_stack_site_app_operation_metadata \
"${stack_dir}" \
"${site_name}" \
"${current_installed_app_lines}" \
"uninstall-app" \
"${EASY_DOCKER_SITE_ERROR_DETAIL}" \
"${EASY_DOCKER_SITE_ERROR_LOG_PATH}"; then
return 99
fi
case "${command_status}" in
54)
return 94
;;
52)
return 92
;;
*)
return 98
;;
esac
fi
if ! get_stack_site_managed_runtime_app_lines updated_installed_app_lines "${stack_dir}" "${site_name}"; then
remove_stack_site_app_line updated_installed_app_lines "${current_installed_app_lines}" "${app_name}"
fi
if ! persist_stack_site_app_operation_metadata \
"${stack_dir}" \
"${site_name}" \
"${updated_installed_app_lines}" \
"uninstall-app" \
"" \
""; then
return 99
fi
return 0
}

View file

@ -14,6 +14,10 @@ handle_manage_stack_site_flow() {
local existing_site_apps_csv=""
local existing_site_last_backup_at=""
local existing_site_details_action=""
local existing_site_apps_action=""
local existing_site_app_lines=""
local existing_site_app_selection=""
local existing_site_app_confirmation=""
local site_delete_confirmation=""
while true; do
@ -100,7 +104,7 @@ handle_manage_stack_site_flow() {
existing_site_apps_lines="$(get_stack_site_apps_installed_lines "${stack_dir}" || true)"
existing_site_last_backup_at="$(get_stack_site_last_backup_at "${stack_dir}" || true)"
if stack_backend_service_is_running "${stack_dir}" >/dev/null 2>&1; then
get_stack_site_runtime_selected_apps_lines existing_site_apps_lines "${stack_dir}" "${existing_site_name}" || true
get_stack_site_managed_runtime_app_lines existing_site_apps_lines "${stack_dir}" "${existing_site_name}" || true
fi
if [ -n "${existing_site_apps_lines}" ]; then
existing_site_apps_csv="$(printf '%s' "${existing_site_apps_lines}" | tr '\n' ',' | sed 's/,$//')"
@ -118,6 +122,230 @@ handle_manage_stack_site_flow() {
"${existing_site_last_backup_at}" || true
)"
case "${existing_site_details_action}" in
"Manage apps on this site")
while true; do
existing_site_apps_action="$(
show_manage_stack_site_apps_menu \
"${stack_name}" \
"${stack_dir}" \
"${existing_site_name}" || true
)"
case "${existing_site_apps_action}" in
"Install app on this site")
if ! get_configured_stack_site_installable_app_lines existing_site_app_lines "${stack_dir}"; then
site_flow_status=$?
case "${site_flow_status}" in
81)
show_warning_and_wait "Cannot install app: backend service is not running yet. Start the stack first." 4
;;
82)
show_warning_and_wait "Cannot install app for this topology yet. Only single-host stacks are supported." 4
;;
83)
show_warning_and_wait "Cannot install app because no configured site was found in metadata.json." 4
;;
84)
show_warning_and_wait "Cannot install app because stack metadata, env, or compose inputs are incomplete." 4
;;
87)
show_warning_and_wait "Cannot inspect installable apps on this site right now. Check backend readiness and try again." 4
;;
*)
show_warning_and_wait "Could not prepare installable site apps (${site_flow_status})." 4
;;
esac
continue
fi
if [ -z "${existing_site_app_lines}" ]; then
show_warning_and_wait "No additional selected stack apps are available to install on this site. No further apps are currently available in this stack environment." 4
continue
fi
existing_site_app_selection="$(
show_manage_stack_site_app_selection \
"${stack_name}" \
"${stack_dir}" \
"${existing_site_name}" \
"Install app on this site" \
"${existing_site_app_lines}" || true
)"
case "${existing_site_app_selection}" in
"Back" | "")
continue
;;
"Exit and close easy-docker")
return "${FLOW_EXIT_APP}"
;;
*)
show_warning_message "Installing app on site: ${existing_site_app_selection}"
if install_app_on_configured_stack_site "${stack_dir}" "${existing_site_app_selection}"; then
show_warning_and_wait "App installed successfully on site ${existing_site_name}: ${existing_site_app_selection}" 4
continue
fi
site_flow_status=$?
case "${site_flow_status}" in
81)
show_warning_and_wait "Cannot install app: backend service is not running yet. Start the stack first." 4
;;
82)
show_warning_and_wait "Cannot install app for this topology yet. Only single-host stacks are supported." 4
;;
83)
show_warning_and_wait "Cannot install app because no configured site was found in metadata.json." 4
;;
84)
show_warning_and_wait "Cannot install app because stack metadata, env, or compose inputs are incomplete." 4
;;
85)
show_warning_and_wait "No additional selected stack apps are available to install on this site. No further apps are currently available in this stack environment." 4
;;
86)
show_warning_and_wait "The selected app is not currently installable on this site." 4
;;
87)
show_warning_and_wait "Cannot inspect current site apps right now. Check backend readiness and try again." 4
;;
88)
show_warning_and_wait "App installation failed. ${EASY_DOCKER_SITE_ERROR_DETAIL:-Check the output above.} ${EASY_DOCKER_SITE_ERROR_LOG_PATH:+See ${stack_dir}/${EASY_DOCKER_SITE_ERROR_LOG_PATH}}" 6
;;
89)
show_warning_and_wait "The app command finished, but site metadata could not be written to metadata.json." 5
;;
*)
show_warning_and_wait "App installation failed (${site_flow_status})." 4
;;
esac
continue
;;
esac
;;
"Uninstall app from this site")
if ! get_configured_stack_site_uninstallable_app_lines existing_site_app_lines "${stack_dir}"; then
site_flow_status=$?
case "${site_flow_status}" in
91)
show_warning_and_wait "Cannot uninstall app: backend service is not running yet. Start the stack first." 4
;;
92)
show_warning_and_wait "Cannot uninstall app for this topology yet. Only single-host stacks are supported." 4
;;
93)
show_warning_and_wait "Cannot uninstall app because no configured site was found in metadata.json." 4
;;
94)
show_warning_and_wait "Cannot uninstall app because stack metadata, env, or compose inputs are incomplete." 4
;;
97)
show_warning_and_wait "Cannot inspect uninstallable apps on this site right now. Check backend readiness and try again." 4
;;
*)
show_warning_and_wait "Could not prepare uninstallable site apps (${site_flow_status})." 4
;;
esac
continue
fi
if [ -z "${existing_site_app_lines}" ]; then
show_warning_and_wait "No installed site apps are currently available for uninstall." 4
continue
fi
existing_site_app_selection="$(
show_manage_stack_site_app_selection \
"${stack_name}" \
"${stack_dir}" \
"${existing_site_name}" \
"Uninstall app from this site" \
"${existing_site_app_lines}" || true
)"
case "${existing_site_app_selection}" in
"Back" | "")
continue
;;
"Exit and close easy-docker")
return "${FLOW_EXIT_APP}"
;;
*)
existing_site_app_confirmation="$(
show_manage_stack_site_app_uninstall_confirmation \
"${stack_name}" \
"${stack_dir}" \
"${existing_site_name}" \
"${existing_site_app_selection}" || true
)"
case "${existing_site_app_confirmation}" in
"No" | "")
continue
;;
"Exit and close easy-docker")
return "${FLOW_EXIT_APP}"
;;
"Yes")
show_warning_message "Uninstalling app from site: ${existing_site_app_selection}"
if uninstall_app_from_configured_stack_site "${stack_dir}" "${existing_site_app_selection}"; then
show_warning_and_wait "App uninstalled successfully from site ${existing_site_name}: ${existing_site_app_selection}" 4
continue
fi
site_flow_status=$?
case "${site_flow_status}" in
91)
show_warning_and_wait "Cannot uninstall app: backend service is not running yet. Start the stack first." 4
;;
92)
show_warning_and_wait "Cannot uninstall app for this topology yet. Only single-host stacks are supported." 4
;;
93)
show_warning_and_wait "Cannot uninstall app because no configured site was found in metadata.json." 4
;;
94)
show_warning_and_wait "Cannot uninstall app because stack metadata, env, or compose inputs are incomplete." 4
;;
95)
show_warning_and_wait "No installed site apps are currently available for uninstall." 4
;;
96)
show_warning_and_wait "The selected app cannot be uninstalled here. frappe stays blocked, but erpnext is allowed." 4
;;
97)
show_warning_and_wait "Cannot inspect current site apps right now. Check backend readiness and try again." 4
;;
98)
show_warning_and_wait "App uninstall failed. ${EASY_DOCKER_SITE_ERROR_DETAIL:-Check the output above.} ${EASY_DOCKER_SITE_ERROR_LOG_PATH:+See ${stack_dir}/${EASY_DOCKER_SITE_ERROR_LOG_PATH}}" 6
;;
99)
show_warning_and_wait "The app command finished, but site metadata could not be written to metadata.json." 5
;;
*)
show_warning_and_wait "App uninstall failed (${site_flow_status})." 4
;;
esac
continue
;;
*)
show_warning_and_wait "Unknown uninstall app confirmation action: ${existing_site_app_confirmation}" 2
continue
;;
esac
;;
esac
;;
"Back" | "")
break
;;
"Exit and close easy-docker")
return "${FLOW_EXIT_APP}"
;;
*)
show_warning_and_wait "Unknown site apps action: ${existing_site_apps_action}" 2
continue
;;
esac
done
continue
;;
"Backup site now")
show_warning_message "Creating backup for site: ${existing_site_name}"
if backup_configured_stack_site "${stack_dir}"; then

View file

@ -204,16 +204,99 @@ show_manage_stack_site_details() {
render_box_message "${status_text}" "0 2" >&2
gum choose \
--height 9 \
--height 10 \
--header "Site details" \
--cursor.foreground 63 \
--selected.foreground 45 \
"Manage apps on this site" \
"Backup site now" \
"Delete site" \
"Back" \
"Exit and close easy-docker"
}
show_manage_stack_site_apps_menu() {
local stack_name="${1}"
local stack_dir="${2}"
local site_name="${3}"
local status_text=""
render_main_screen 1 >&2
status_text="$(printf "Manage site apps\n\nStack: %s\nDirectory: %s\nSite: %s\n\nInstall or uninstall apps for this existing site." "${stack_name}" "${stack_dir}" "${site_name}")"
render_box_message "${status_text}" "0 2" >&2
gum choose \
--height 9 \
--header "Site app actions" \
--cursor.foreground 63 \
--selected.foreground 45 \
"Install app on this site" \
"Uninstall app from this site" \
"Back" \
"Exit and close easy-docker"
}
show_manage_stack_site_app_selection() {
local stack_name="${1}"
local stack_dir="${2}"
local site_name="${3}"
local action_label="${4}"
local app_lines="${5:-}"
local status_text=""
local app_name=""
local -a menu_options=()
render_main_screen 1 >&2
status_text="$(printf "%s\n\nStack: %s\nDirectory: %s\nSite: %s\n\nSelect one app." "${action_label}" "${stack_name}" "${stack_dir}" "${site_name}")"
render_box_message "${status_text}" "0 2" >&2
while IFS= read -r app_name; do
if [ -z "${app_name}" ]; then
continue
fi
menu_options+=("${app_name}")
done <<EOF
${app_lines}
EOF
if [ "${#menu_options[@]}" -eq 0 ]; then
return 1
fi
gum choose \
--height 12 \
--header "${action_label}" \
--cursor.foreground 63 \
--selected.foreground 45 \
"${menu_options[@]}" \
"Back" \
"Exit and close easy-docker"
}
show_manage_stack_site_app_uninstall_confirmation() {
local stack_name="${1}"
local stack_dir="${2}"
local site_name="${3}"
local app_name="${4}"
local status_text=""
render_main_screen 1 >&2
status_text="$(printf "Uninstall app from site\n\nStack: %s\nDirectory: %s\nSite: %s\nApp: %s\n\nThis removes the app from the site. frappe itself cannot be removed here." "${stack_name}" "${stack_dir}" "${site_name}" "${app_name}")"
render_box_message "${status_text}" "0 2" >&2
gum choose \
--height 8 \
--header "Confirm uninstall app" \
--cursor.foreground 63 \
--selected.foreground 45 \
"Yes" \
"No" \
"Exit and close easy-docker"
}
show_manage_stack_site_delete_confirmation() {
local stack_name="${1}"
local stack_dir="${2}"