From 6d17bf0d2900f48612267bfa6a0140f0b522a242 Mon Sep 17 00:00:00 2001 From: RocketQuack <202538874+Rocket-Quack@users.noreply.github.com> Date: Thu, 2 Apr 2026 16:55:20 +0200 Subject: [PATCH] feat(easy-docker): add stack restart and site backup flows --- .../lib/app/wizard/common/compose/start.sh | 2 + .../wizard/common/compose/start/restart.sh | 74 +++++++++++++ .../easy-docker/lib/app/wizard/common/site.sh | 2 + .../lib/app/wizard/common/site/backup.sh | 11 ++ .../wizard/common/site/backup/lifecycle.sh | 103 ++++++++++++++++++ .../app/wizard/common/site/metadata/read.sh | 6 + .../app/wizard/common/site/metadata/write.sh | 15 ++- .../lib/app/wizard/flows/manage/build.sh | 4 +- .../lib/app/wizard/flows/manage/docker.sh | 4 +- .../lib/app/wizard/flows/manage/site.sh | 43 +++++++- .../lib/app/wizard/flows/manage/stack.sh | 80 +++++++++++++- .../lib/ui/screens/production/manage.sh | 9 +- 12 files changed, 335 insertions(+), 18 deletions(-) create mode 100755 scripts/easy-docker/lib/app/wizard/common/compose/start/restart.sh create mode 100755 scripts/easy-docker/lib/app/wizard/common/site/backup.sh create mode 100755 scripts/easy-docker/lib/app/wizard/common/site/backup/lifecycle.sh 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 9fa2faa1..e0c604fd 100755 --- a/scripts/easy-docker/lib/app/wizard/common/compose/start.sh +++ b/scripts/easy-docker/lib/app/wizard/common/compose/start.sh @@ -8,6 +8,8 @@ load_easy_docker_compose_lifecycle_modules() { source "${lifecycle_dir}/start.sh" # shellcheck source=scripts/easy-docker/lib/app/wizard/common/compose/start/stop.sh source "${lifecycle_dir}/stop.sh" + # shellcheck source=scripts/easy-docker/lib/app/wizard/common/compose/start/restart.sh + source "${lifecycle_dir}/restart.sh" # shellcheck source=scripts/easy-docker/lib/app/wizard/common/compose/start/delete.sh source "${lifecycle_dir}/delete.sh" # shellcheck source=scripts/easy-docker/lib/app/wizard/common/compose/start/status.sh diff --git a/scripts/easy-docker/lib/app/wizard/common/compose/start/restart.sh b/scripts/easy-docker/lib/app/wizard/common/compose/start/restart.sh new file mode 100755 index 00000000..a4702733 --- /dev/null +++ b/scripts/easy-docker/lib/app/wizard/common/compose/start/restart.sh @@ -0,0 +1,74 @@ +#!/usr/bin/env bash + +restart_stack_with_compose_from_metadata() { + local stack_dir="${1}" + local stop_status=0 + local start_status=0 + + # shellcheck disable=SC2034 # Read by manage flow after restart_stack_with_compose_from_metadata fails. + EASY_DOCKER_COMPOSE_ERROR_DETAIL="" + + if stop_stack_with_compose_from_metadata "${stack_dir}"; then + : + else + stop_status=$? + case "${stop_status}" in + 41) + return 57 + ;; + 42) + return 58 + ;; + 43) + return 59 + ;; + 44) + return 60 + ;; + 45) + return 61 + ;; + 46) + return 62 + ;; + *) + return 63 + ;; + esac + fi + + if start_stack_with_compose_from_metadata "${stack_dir}"; then + return 0 + fi + + start_status=$? + case "${start_status}" in + 31) + return 57 + ;; + 32) + return 58 + ;; + 33) + return 59 + ;; + 34) + return 60 + ;; + 35) + return 61 + ;; + 36) + return 62 + ;; + 38) + return 64 + ;; + 39) + return 65 + ;; + *) + return 63 + ;; + esac +} diff --git a/scripts/easy-docker/lib/app/wizard/common/site.sh b/scripts/easy-docker/lib/app/wizard/common/site.sh index 098140cd..0f6af17d 100755 --- a/scripts/easy-docker/lib/app/wizard/common/site.sh +++ b/scripts/easy-docker/lib/app/wizard/common/site.sh @@ -11,6 +11,8 @@ load_easy_docker_site_modules() { source "${site_dir}/metadata.sh" # shellcheck source=scripts/easy-docker/lib/app/wizard/common/site/bootstrap.sh source "${site_dir}/bootstrap.sh" + # shellcheck source=scripts/easy-docker/lib/app/wizard/common/site/backup.sh + source "${site_dir}/backup.sh" # shellcheck source=scripts/easy-docker/lib/app/wizard/common/site/apps.sh source "${site_dir}/apps.sh" } diff --git a/scripts/easy-docker/lib/app/wizard/common/site/backup.sh b/scripts/easy-docker/lib/app/wizard/common/site/backup.sh new file mode 100755 index 00000000..7c7b323e --- /dev/null +++ b/scripts/easy-docker/lib/app/wizard/common/site/backup.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash + +load_easy_docker_site_backup_modules() { + local backup_dir="" + backup_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/backup" + + # shellcheck source=scripts/easy-docker/lib/app/wizard/common/site/backup/lifecycle.sh + source "${backup_dir}/lifecycle.sh" +} + +load_easy_docker_site_backup_modules diff --git a/scripts/easy-docker/lib/app/wizard/common/site/backup/lifecycle.sh b/scripts/easy-docker/lib/app/wizard/common/site/backup/lifecycle.sh new file mode 100755 index 00000000..9ae137c4 --- /dev/null +++ b/scripts/easy-docker/lib/app/wizard/common/site/backup/lifecycle.sh @@ -0,0 +1,103 @@ +#!/usr/bin/env bash + +backup_configured_stack_site() { + local stack_dir="${1}" + local site_name="" + local backup_command="" + local backup_output="" + local command_status=0 + local created_at="" + local updated_at="" + local apps_installed_lines="" + local last_backup_at="" + local existing_last_backup_at="" + local backend_status=0 + + reset_easy_docker_site_error_state + + if ! stack_supports_single_site_management "${stack_dir}"; then + return 72 + fi + + site_name="$(get_stack_site_name "${stack_dir}" || true)" + if ! is_safe_stack_site_cleanup_name "${site_name}"; then + return 73 + fi + + if stack_backend_service_is_running "${stack_dir}"; then + : + else + backend_status=$? + case "${backend_status}" in + 54) + return 74 + ;; + 52) + return 72 + ;; + *) + return 71 + ;; + esac + fi + + created_at="$(get_stack_site_created_at "${stack_dir}" || true)" + apps_installed_lines="$(get_stack_site_apps_installed_lines "${stack_dir}" || true)" + existing_last_backup_at="$(get_stack_site_last_backup_at "${stack_dir}" || true)" + + backup_command="$( + printf "bench --site %s backup --with-files" \ + "$(shell_quote_site_command_arg "${site_name}")" + )" + + if run_stack_backend_bash_command_capture backup_output "${stack_dir}" "${backup_command}"; then + : + else + command_status=$? + EASY_DOCKER_SITE_ERROR_DETAIL="$(printf "bench backup failed for '%s'." "${site_name}")" + capture_stack_site_error_log "${stack_dir}" "site-backup-error" "${backup_output}" >/dev/null 2>&1 || true + updated_at="$(get_current_utc_timestamp)" + if ! persist_stack_site_metadata \ + "${stack_dir}" \ + "single-site" \ + "${site_name}" \ + "${apps_installed_lines}" \ + "backup-site" \ + "${EASY_DOCKER_SITE_ERROR_DETAIL}" \ + "${EASY_DOCKER_SITE_ERROR_LOG_PATH}" \ + "${created_at}" \ + "${updated_at}" \ + "${existing_last_backup_at}"; then + return 76 + fi + case "${command_status}" in + 54) + return 74 + ;; + 52) + return 72 + ;; + *) + return 75 + ;; + esac + fi + + last_backup_at="$(get_current_utc_timestamp)" + updated_at="${last_backup_at}" + if ! persist_stack_site_metadata \ + "${stack_dir}" \ + "single-site" \ + "${site_name}" \ + "${apps_installed_lines}" \ + "backup-site" \ + "" \ + "" \ + "${created_at}" \ + "${updated_at}" \ + "${last_backup_at}"; then + return 76 + fi + + return 0 +} diff --git a/scripts/easy-docker/lib/app/wizard/common/site/metadata/read.sh b/scripts/easy-docker/lib/app/wizard/common/site/metadata/read.sh index 51790f06..b0f072f4 100755 --- a/scripts/easy-docker/lib/app/wizard/common/site/metadata/read.sh +++ b/scripts/easy-docker/lib/app/wizard/common/site/metadata/read.sh @@ -87,6 +87,12 @@ get_stack_site_created_at() { get_metadata_site_string_field "${stack_dir}/metadata.json" "created_at" } +get_stack_site_last_backup_at() { + local stack_dir="${1}" + + get_metadata_site_string_field "${stack_dir}/metadata.json" "last_backup_at" +} + get_stack_site_apps_installed_lines() { local stack_dir="${1}" diff --git a/scripts/easy-docker/lib/app/wizard/common/site/metadata/write.sh b/scripts/easy-docker/lib/app/wizard/common/site/metadata/write.sh index 15435d57..dafc4393 100755 --- a/scripts/easy-docker/lib/app/wizard/common/site/metadata/write.sh +++ b/scripts/easy-docker/lib/app/wizard/common/site/metadata/write.sh @@ -39,11 +39,12 @@ build_stack_site_metadata_json_object() { local error_log_path="${7:-}" local created_at="${8:-}" local updated_at="${9:-}" + local last_backup_at="${10:-}" local apps_installed_json_array="" build_stack_site_apps_installed_json_array apps_installed_json_array "${apps_installed_lines}" - printf -v "${result_var}" '{\n "mode": "%s",\n "name": "%s",\n "apps_installed": %s,\n "last_action": "%s",\n "last_error": "%s",\n "error_log_path": "%s",\n "created_at": "%s",\n "updated_at": "%s"\n }' \ + printf -v "${result_var}" '{\n "mode": "%s",\n "name": "%s",\n "apps_installed": %s,\n "last_action": "%s",\n "last_error": "%s",\n "error_log_path": "%s",\n "created_at": "%s",\n "updated_at": "%s",\n "last_backup_at": "%s"\n }' \ "$(json_escape_string "${site_mode}")" \ "$(json_escape_string "${site_name}")" \ "${apps_installed_json_array}" \ @@ -51,7 +52,8 @@ build_stack_site_metadata_json_object() { "$(json_escape_string "${last_error}")" \ "$(json_escape_string "${error_log_path}")" \ "$(json_escape_string "${created_at}")" \ - "$(json_escape_string "${updated_at}")" + "$(json_escape_string "${updated_at}")" \ + "$(json_escape_string "${last_backup_at}")" } persist_stack_site_metadata() { @@ -64,6 +66,7 @@ persist_stack_site_metadata() { local error_log_path="${7:-}" local created_at="${8:-}" local updated_at="${9:-}" + local last_backup_at="${10-__KEEP_CURRENT__}" local metadata_path="" local metadata_tmp_path="" local site_json_object="" @@ -74,7 +77,11 @@ persist_stack_site_metadata() { return 1 fi - build_stack_site_metadata_json_object site_json_object "${site_mode}" "${site_name}" "${apps_installed_lines}" "${last_action}" "${last_error}" "${error_log_path}" "${created_at}" "${updated_at}" + if [ "${last_backup_at}" = "__KEEP_CURRENT__" ]; then + last_backup_at="$(get_metadata_site_string_field "${metadata_path}" "last_backup_at" || true)" + fi + + build_stack_site_metadata_json_object site_json_object "${site_mode}" "${site_name}" "${apps_installed_lines}" "${last_action}" "${last_error}" "${error_log_path}" "${created_at}" "${updated_at}" "${last_backup_at}" if ! awk -v site_object="${site_json_object}" ' BEGIN { @@ -172,5 +179,5 @@ clear_stack_site_metadata() { local updated_at="" updated_at="$(get_current_utc_timestamp)" - persist_stack_site_metadata "${stack_dir}" "single-site" "" "" "delete-site" "" "" "" "${updated_at}" + persist_stack_site_metadata "${stack_dir}" "single-site" "" "" "delete-site" "" "" "" "${updated_at}" "" } diff --git a/scripts/easy-docker/lib/app/wizard/flows/manage/build.sh b/scripts/easy-docker/lib/app/wizard/flows/manage/build.sh index 9d18a0fb..b6b5a4b1 100755 --- a/scripts/easy-docker/lib/app/wizard/flows/manage/build.sh +++ b/scripts/easy-docker/lib/app/wizard/flows/manage/build.sh @@ -9,9 +9,9 @@ run_build_stack_custom_image_with_feedback() { if build_stack_custom_image "${stack_dir}"; then show_warning_and_wait "Custom image build finished successfully for stack: ${stack_name}" 3 return 0 + else + build_image_status=$? fi - - build_image_status=$? case "${build_image_status}" in 11) show_warning_and_wait "Custom image build failed: missing metadata.json in ${stack_dir}." 4 diff --git a/scripts/easy-docker/lib/app/wizard/flows/manage/docker.sh b/scripts/easy-docker/lib/app/wizard/flows/manage/docker.sh index 9d18a0fb..b6b5a4b1 100755 --- a/scripts/easy-docker/lib/app/wizard/flows/manage/docker.sh +++ b/scripts/easy-docker/lib/app/wizard/flows/manage/docker.sh @@ -9,9 +9,9 @@ run_build_stack_custom_image_with_feedback() { if build_stack_custom_image "${stack_dir}"; then show_warning_and_wait "Custom image build finished successfully for stack: ${stack_name}" 3 return 0 + else + build_image_status=$? fi - - build_image_status=$? case "${build_image_status}" in 11) show_warning_and_wait "Custom image build failed: missing metadata.json in ${stack_dir}." 4 diff --git a/scripts/easy-docker/lib/app/wizard/flows/manage/site.sh b/scripts/easy-docker/lib/app/wizard/flows/manage/site.sh index e408a324..b2f055a4 100755 --- a/scripts/easy-docker/lib/app/wizard/flows/manage/site.sh +++ b/scripts/easy-docker/lib/app/wizard/flows/manage/site.sh @@ -12,6 +12,7 @@ handle_manage_stack_site_flow() { local existing_site_created_at="" local existing_site_apps_lines="" local existing_site_apps_csv="" + local existing_site_last_backup_at="" local existing_site_details_action="" local site_delete_confirmation="" @@ -97,6 +98,7 @@ handle_manage_stack_site_flow() { existing_site_name="$(get_stack_site_name "${stack_dir}" || true)" existing_site_created_at="$(get_stack_site_created_at "${stack_dir}" || true)" 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 fi @@ -112,9 +114,44 @@ handle_manage_stack_site_flow() { "${stack_dir}" \ "${existing_site_name}" \ "${existing_site_created_at}" \ - "${existing_site_apps_csv}" || true + "${existing_site_apps_csv}" \ + "${existing_site_last_backup_at}" || true )" case "${existing_site_details_action}" in + "Backup site now") + show_warning_message "Creating backup for site: ${existing_site_name}" + if backup_configured_stack_site "${stack_dir}"; then + existing_site_last_backup_at="$(get_stack_site_last_backup_at "${stack_dir}" || true)" + show_warning_and_wait "Site backup completed successfully: ${existing_site_name}${existing_site_last_backup_at:+ (last backup at ${existing_site_last_backup_at})}" 4 + continue + fi + + site_flow_status=$? + case "${site_flow_status}" in + 71) + show_warning_and_wait "Cannot back up site: backend service is not running yet. Start the stack first." 4 + ;; + 72) + show_warning_and_wait "Cannot back up site for this topology yet. Only single-host stacks are supported." 4 + ;; + 73) + show_warning_and_wait "Cannot back up site because no configured site was found in metadata.json." 4 + ;; + 74) + show_warning_and_wait "Cannot back up site because stack metadata, env, or compose inputs are incomplete." 4 + ;; + 75) + show_warning_and_wait "Site backup 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 + ;; + 76) + show_warning_and_wait "The backup command finished, but the backup metadata could not be written to metadata.json." 5 + ;; + *) + show_warning_and_wait "Site backup failed (${site_flow_status})." 4 + ;; + esac + continue + ;; "Delete site") site_delete_confirmation="$( show_manage_stack_site_delete_confirmation \ @@ -128,9 +165,9 @@ handle_manage_stack_site_flow() { if delete_configured_stack_site "${stack_dir}"; then show_warning_and_wait "Site deleted successfully with its database: ${existing_site_name}" 3 continue + else + site_flow_status=$? 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 diff --git a/scripts/easy-docker/lib/app/wizard/flows/manage/stack.sh b/scripts/easy-docker/lib/app/wizard/flows/manage/stack.sh index 340c9bd0..4593eb3b 100755 --- a/scripts/easy-docker/lib/app/wizard/flows/manage/stack.sh +++ b/scripts/easy-docker/lib/app/wizard/flows/manage/stack.sh @@ -158,14 +158,86 @@ handle_manage_selected_stack_flow() { esac done ;; + "Restart stack in Docker Compose") + while true; do + show_warning_message "Restarting stack with docker compose: ${stack_name}" + if restart_stack_with_compose_from_metadata "${stack_dir}"; then + show_warning_and_wait "Stack restarted successfully with docker compose: ${stack_name}" 3 + break + else + compose_start_status=$? + fi + case "${compose_start_status}" in + 57) + show_warning_and_wait "Cannot restart stack: metadata.json is missing in ${stack_dir}." 4 + break + ;; + 58) + show_warning_and_wait "Cannot restart stack: stack env file not found in ${stack_dir}." 4 + break + ;; + 59) + show_warning_and_wait "Cannot restart stack: topology is missing in metadata.json. Re-run the topology wizard for this stack." 4 + break + ;; + 60) + show_warning_and_wait "Cannot restart stack via docker compose for topology '${EASY_DOCKER_COMPOSE_ERROR_DETAIL}'. Use the topology-specific runbook path." 5 + break + ;; + 61) + show_warning_and_wait "Cannot restart stack: no compose files configured in metadata.json." 4 + break + ;; + 62) + show_warning_and_wait "Cannot restart stack: compose file is missing -> ${EASY_DOCKER_COMPOSE_ERROR_DETAIL}" 4 + break + ;; + 63) + show_warning_and_wait "docker compose restart failed. Check the output above for details." 4 + break + ;; + 64) + missing_custom_image_action="$( + show_missing_custom_image_start_menu "${stack_name}" "${stack_dir}" "${EASY_DOCKER_COMPOSE_ERROR_DETAIL}" || true + )" + case "${missing_custom_image_action}" in + "Build custom image now") + if run_build_stack_custom_image_with_feedback "${stack_name}" "${stack_dir}"; then + continue + fi + break + ;; + "Back" | "") + break + ;; + "Exit and close easy-docker") + return "${FLOW_EXIT_APP}" + ;; + *) + show_warning_and_wait "Unknown missing-image action: ${missing_custom_image_action}" 2 + break + ;; + esac + ;; + 65) + show_warning_and_wait "Cannot inspect custom image before restart. Check Docker and try again. Details: ${EASY_DOCKER_COMPOSE_ERROR_DETAIL}" 5 + break + ;; + *) + show_warning_and_wait "Cannot restart stack with docker compose (${compose_start_status})." 4 + break + ;; + esac + done + ;; "Stop stack in Docker Compose") show_warning_message "Stopping stack with docker compose: ${stack_name}" if stop_stack_with_compose_from_metadata "${stack_dir}"; then show_warning_and_wait "Stack stopped successfully with docker compose: ${stack_name}" 3 continue + else + compose_start_status=$? fi - - compose_start_status=$? case "${compose_start_status}" in 41) show_warning_and_wait "Cannot stop stack: metadata.json is missing in ${stack_dir}." 4 @@ -210,9 +282,9 @@ handle_manage_selected_stack_flow() { 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}" + else + compose_start_status=$? 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 diff --git a/scripts/easy-docker/lib/ui/screens/production/manage.sh b/scripts/easy-docker/lib/ui/screens/production/manage.sh index b501f5e9..cfbba951 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 11 \ + --height 12 \ --header "${menu_header}" \ --cursor.foreground 63 \ --selected.foreground 45 \ @@ -73,6 +73,7 @@ show_manage_stack_actions_menu() { "Docker" \ "Site" \ "Start stack in Docker Compose" \ + "Restart stack in Docker Compose" \ "Stop stack in Docker Compose" \ "Delete stack" \ "Back" \ @@ -194,18 +195,20 @@ show_manage_stack_site_details() { local site_name="${3}" local created_at="${4:-}" local installed_apps="${5:-None}" + local last_backup_at="${6:-}" local status_text="" render_main_screen 1 >&2 - status_text="$(printf "Manage existing site\n\nStack: %s\nDirectory: %s\nSite: %s\nCreated at: %s\nInstalled apps: %s" "${stack_name}" "${stack_dir}" "${site_name}" "${created_at:-n/a}" "${installed_apps}")" + status_text="$(printf "Manage existing site\n\nStack: %s\nDirectory: %s\nSite: %s\nCreated at: %s\nInstalled apps: %s\nLast backup: %s" "${stack_name}" "${stack_dir}" "${site_name}" "${created_at:-n/a}" "${installed_apps}" "${last_backup_at:-n/a}")" render_box_message "${status_text}" "0 2" >&2 gum choose \ - --height 8 \ + --height 9 \ --header "Site details" \ --cursor.foreground 63 \ --selected.foreground 45 \ + "Backup site now" \ "Delete site" \ "Back" \ "Exit and close easy-docker"