diff --git a/scripts/easy-docker/lib/app/run.sh b/scripts/easy-docker/lib/app/run.sh index 2f8696f9..e2d0abb2 100755 --- a/scripts/easy-docker/lib/app/run.sh +++ b/scripts/easy-docker/lib/app/run.sh @@ -1,5 +1,10 @@ #!/usr/bin/env bash +readonly FLOW_CONTINUE=0 +readonly FLOW_BACK_TO_MAIN=10 +readonly FLOW_EXIT_APP=11 +readonly FLOW_ABORT_INPUT=12 + get_easy_docker_repo_root() { local app_lib_dir="" app_lib_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" @@ -10,6 +15,10 @@ get_easy_docker_stacks_dir() { printf '%s/.easy-docker/stacks\n' "$(get_easy_docker_repo_root)" } +get_current_utc_timestamp() { + date -u +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null || date +"%Y-%m-%dT%H:%M:%SZ" +} + is_valid_stack_name() { local stack_name="${1}" @@ -27,29 +36,69 @@ is_valid_stack_name() { esac } -create_stack_env_file() { - local result_var="${1}" +create_stack_directory_with_metadata() { + local stack_dir_var="${1}" local stack_name="${2}" local stacks_dir="" - local env_path="" + local created_stack_dir="" + local metadata_path="" + local created_at="" stacks_dir="$(get_easy_docker_stacks_dir)" - env_path="${stacks_dir}/${stack_name}.env" + created_stack_dir="${stacks_dir}/${stack_name}" + metadata_path="${created_stack_dir}/metadata.json" if ! mkdir -p "${stacks_dir}"; then return 1 fi - if [ -e "${env_path}" ]; then + if [ -e "${created_stack_dir}" ]; then return 2 fi - : >"${env_path}" + if ! mkdir -p "${created_stack_dir}"; then + return 1 + fi - printf -v "${result_var}" "%s" "${env_path}" + created_at="$(get_current_utc_timestamp)" + if ! cat >"${metadata_path}" </dev/null 2>&1 || true + return 1 + fi + + printf -v "${stack_dir_var}" "%s" "${created_stack_dir}" return 0 } +rollback_stack_directory() { + local stack_dir="${1}" + local stacks_dir="" + + if [ -z "${stack_dir}" ]; then + return 1 + fi + + stacks_dir="$(get_easy_docker_stacks_dir)" + case "${stack_dir}" in + "${stacks_dir}"/*) ;; + *) + return 2 + ;; + esac + + if [ ! -d "${stack_dir}" ]; then + return 0 + fi + + rm -rf -- "${stack_dir}" +} + prompt_stack_name_with_cancel() { local result_var="${1}" local input_value="" @@ -58,38 +107,283 @@ prompt_stack_name_with_cancel() { input_value="$(prompt_new_stack_name)" input_status=$? if [ "${input_status}" -ne 0 ]; then - return 3 + return "${FLOW_ABORT_INPUT}" fi input_value="$(printf '%s' "${input_value}" | tr -d '\r\n')" case "${input_value}" in /cancel | /CANCEL | /Cancel) - return 3 + return "${FLOW_ABORT_INPUT}" ;; esac printf -v "${result_var}" "%s" "${input_value}" - return 0 + return "${FLOW_CONTINUE}" +} + +show_warning_and_wait() { + local message="${1}" + local seconds="${2:-1}" + + show_warning_message "${message}" + sleep "${seconds}" +} + +handle_topology_examples_flow() { + local topology_name="${1}" + local detail_action="" + + case "${topology_name}" in + "Single-host") + detail_action="$(show_single_host_examples || true)" + ;; + "Split services") + detail_action="$(show_split_services_examples || true)" + ;; + "Advanced") + detail_action="$(show_advanced_examples || true)" + ;; + *) + show_warning_and_wait "Unknown topology: ${topology_name}" + return "${FLOW_CONTINUE}" + ;; + esac + + case "${detail_action}" in + "Use this topology") + show_warning_and_wait "Topology '${topology_name}' selected. Next wizard step is coming soon." 2 + return "${FLOW_CONTINUE}" + ;; + "Back to topology selection" | "") + return "${FLOW_CONTINUE}" + ;; + *) + show_warning_and_wait "Unknown topology action: ${detail_action}" + return "${FLOW_CONTINUE}" + ;; + esac +} + +handle_abort_wizard_flow() { + local stack_dir="${1}" + local abort_action="" + local rollback_status=0 + + abort_action="$(show_abort_wizard_prompt "${stack_dir}" || true)" + case "${abort_action}" in + "Rollback files and return to main menu") + if rollback_stack_directory "${stack_dir}"; then + return "${FLOW_BACK_TO_MAIN}" + fi + + rollback_status=$? + if [ "${rollback_status}" -eq 2 ]; then + show_warning_and_wait "Refused rollback for unsafe path: ${stack_dir}" 2 + else + show_warning_and_wait "Could not rollback stack files: ${stack_dir}" 2 + fi + return "${FLOW_CONTINUE}" + ;; + "Keep files and return to main menu") + return "${FLOW_BACK_TO_MAIN}" + ;; + "Back to topology selection" | "") + return "${FLOW_CONTINUE}" + ;; + *) + show_warning_and_wait "Unknown abort action: ${abort_action}" + return "${FLOW_CONTINUE}" + ;; + esac +} + +handle_stack_topology_flow() { + local stack_dir="${1}" + local topology_action="" + local abort_status=0 + + while true; do + topology_action="$(show_stack_topology_menu "${stack_dir}" || true)" + case "${topology_action}" in + "Single-host" | "Split services" | "Advanced") + handle_topology_examples_flow "${topology_action}" + ;; + "Abort wizard to main menu") + handle_abort_wizard_flow "${stack_dir}" + abort_status=$? + case "${abort_status}" in + "${FLOW_BACK_TO_MAIN}") + return "${FLOW_BACK_TO_MAIN}" + ;; + *) ;; + esac + ;; + "") + return "${FLOW_CONTINUE}" + ;; + *) + show_warning_and_wait "Unknown topology selection: ${topology_action}" + ;; + esac + done +} + +handle_create_new_stack_flow() { + local stack_name="" + local stack_dir="" + local create_stack_status=0 + local stack_input_status=0 + local topology_status=0 + + while true; do + stack_name="" + if prompt_stack_name_with_cancel stack_name; then + : + else + stack_input_status=$? + if [ "${stack_input_status}" -eq "${FLOW_ABORT_INPUT}" ]; then + return "${FLOW_CONTINUE}" + fi + + show_warning_and_wait "Input canceled." + return "${FLOW_CONTINUE}" + fi + + if [ -z "${stack_name}" ]; then + return "${FLOW_CONTINUE}" + fi + + if ! is_valid_stack_name "${stack_name}"; then + show_warning_and_wait "Invalid stack name. Use letters, numbers, dot, underscore, or hyphen." 2 + continue + fi + + stack_dir="" + if create_stack_directory_with_metadata stack_dir "${stack_name}"; then + handle_stack_topology_flow "${stack_dir}" + topology_status=$? + case "${topology_status}" in + "${FLOW_BACK_TO_MAIN}") + return "${FLOW_BACK_TO_MAIN}" + ;; + "${FLOW_EXIT_APP}") + return "${FLOW_EXIT_APP}" + ;; + *) + return "${FLOW_CONTINUE}" + ;; + esac + else + create_stack_status=$? + if [ "${create_stack_status}" -eq 2 ]; then + show_warning_and_wait "Stack already exists: ${stack_name}" 2 + continue + fi + + show_warning_and_wait "Could not create stack directory for: ${stack_name}" 2 + return "${FLOW_CONTINUE}" + fi + done +} + +handle_manage_existing_stacks_flow() { + local manage_action="" + + manage_action="$(show_manage_stacks_placeholder || true)" + case "${manage_action}" in + "Back to production setup") + return "${FLOW_CONTINUE}" + ;; + "Back to main menu" | "") + return "${FLOW_BACK_TO_MAIN}" + ;; + "Exit and close easy-docker") + return "${FLOW_EXIT_APP}" + ;; + *) + show_warning_and_wait "Unknown manage-stacks action: ${manage_action}" + return "${FLOW_CONTINUE}" + ;; + esac +} + +handle_production_setup_flow() { + local production_action="" + + while true; do + production_action="$(show_production_setup_menu || true)" + + case "${production_action}" in + "Create new stack") + if handle_create_new_stack_flow; then + : + else + case "$?" in + "${FLOW_BACK_TO_MAIN}") + return "${FLOW_BACK_TO_MAIN}" + ;; + "${FLOW_EXIT_APP}") + return "${FLOW_EXIT_APP}" + ;; + *) ;; + esac + fi + ;; + "Manage existing stacks") + if handle_manage_existing_stacks_flow; then + : + else + case "$?" in + "${FLOW_BACK_TO_MAIN}") + return "${FLOW_BACK_TO_MAIN}" + ;; + "${FLOW_EXIT_APP}") + return "${FLOW_EXIT_APP}" + ;; + *) ;; + esac + fi + ;; + "Back to main menu" | "") + return "${FLOW_BACK_TO_MAIN}" + ;; + "Exit and close easy-docker") + return "${FLOW_EXIT_APP}" + ;; + *) + show_warning_and_wait "Unknown production action: ${production_action}" + ;; + esac + done +} + +handle_environment_check_flow() { + local environment_action="" + + environment_action="$(show_environment_status || true)" + case "${environment_action}" in + "Back to main menu" | "") + return "${FLOW_BACK_TO_MAIN}" + ;; + "Exit and close easy-docker") + return "${FLOW_EXIT_APP}" + ;; + *) + show_warning_and_wait "Unknown environment action: ${environment_action}" + return "${FLOW_CONTINUE}" + ;; + esac } run_easy_docker_app() { local action="" - local local_env_action="" - local local_production_action="" - local local_production_sub_action="" - local stack_name="" - local stack_env_path="" - local create_stack_status=0 - local stack_input_status=0 + local handler_status=0 enter_alt_screen render_main_screen 1 while true; do - local_env_action="" - local_production_action="" - local_production_sub_action="" action="$(show_main_menu || true)" if [ -z "${action}" ]; then @@ -98,116 +392,42 @@ run_easy_docker_app() { case "${action}" in "Production setup") - while true; do - local_production_action="$(show_production_setup_menu || true)" - case "${local_production_action}" in - "Create new stack") - while true; do - stack_name="" - if ! prompt_stack_name_with_cancel stack_name; then - stack_input_status=$? - if [ "${stack_input_status}" -eq 3 ]; then - break - fi - - show_warning_message "Input canceled." - sleep 1 - break - fi - - if [ -z "${stack_name}" ]; then - break - fi - - if ! is_valid_stack_name "${stack_name}"; then - show_warning_message "Invalid stack name. Use letters, numbers, dot, underscore, or hyphen." - sleep 2 - continue - fi - - stack_env_path="" - if create_stack_env_file stack_env_path "${stack_name}"; then - local_production_sub_action="$(show_create_stack_created "${stack_name}" "${stack_env_path}" || true)" - else - create_stack_status=$? - if [ "${create_stack_status}" -eq 2 ]; then - show_warning_message "Stack already exists: ${stack_name}" - sleep 2 - continue - else - show_warning_message "Could not create stack env file for: ${stack_name}" - sleep 2 - break - fi - fi - - case "${local_production_sub_action}" in - "Continue stack wizard") - show_warning_message "Next wizard step is coming soon." - sleep 2 - ;; - "Back to production setup" | "") ;; - *) - show_warning_message "Unknown create-stack action: ${local_production_sub_action}" - sleep 1 - ;; - esac - - break - done - ;; - "Manage existing stacks") - local_production_sub_action="$(show_manage_stacks_placeholder || true)" - case "${local_production_sub_action}" in - "Back to production setup") ;; - "Back to main menu" | "") - render_main_screen 1 - break - ;; - "Exit and close easy-docker") - return 0 - ;; - *) - show_warning_message "Unknown manage-stacks action: ${local_production_sub_action}" - sleep 1 - ;; - esac - ;; - "Back to main menu" | "") - render_main_screen 1 - break - ;; - "Exit and close easy-docker") - return 0 - ;; - *) - show_warning_message "Unknown production action: ${local_production_action}" - sleep 1 - ;; - esac - done - ;; - "Environment check") - local_env_action="$(show_environment_status || true)" - case "${local_env_action}" in - "Back to main menu" | "") + if handle_production_setup_flow; then + handler_status="${FLOW_CONTINUE}" + else + handler_status=$? + fi + case "${handler_status}" in + "${FLOW_BACK_TO_MAIN}") render_main_screen 1 ;; - "Exit and close easy-docker") + "${FLOW_EXIT_APP}") return 0 ;; - *) - show_warning_message "Unknown environment action: ${local_env_action}" - sleep 1 + *) ;; + esac + ;; + "Environment check") + if handle_environment_check_flow; then + handler_status="${FLOW_CONTINUE}" + else + handler_status=$? + fi + case "${handler_status}" in + "${FLOW_BACK_TO_MAIN}") + render_main_screen 1 ;; + "${FLOW_EXIT_APP}") + return 0 + ;; + *) ;; esac ;; "Exit") return 0 ;; *) - show_warning_message "Unknown action: ${action}" - sleep 1 + show_warning_and_wait "Unknown action: ${action}" ;; esac done diff --git a/scripts/easy-docker/lib/load.sh b/scripts/easy-docker/lib/load.sh new file mode 100755 index 00000000..3d76d77c --- /dev/null +++ b/scripts/easy-docker/lib/load.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash + +load_easy_docker_modules() { + local lib_dir="" + lib_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + + # shellcheck source=scripts/easy-docker/lib/core/commands.sh + source "${lib_dir}/core/commands.sh" + # shellcheck source=scripts/easy-docker/lib/core/messages.sh + source "${lib_dir}/core/messages.sh" + # shellcheck source=scripts/easy-docker/lib/install/gum/load.sh + source "${lib_dir}/install/gum/load.sh" + # shellcheck source=scripts/easy-docker/lib/checks/docker.sh + source "${lib_dir}/checks/docker.sh" + # shellcheck source=scripts/easy-docker/lib/ui/screens.sh + source "${lib_dir}/ui/screens.sh" + # shellcheck source=scripts/easy-docker/lib/app/screen.sh + source "${lib_dir}/app/screen.sh" + # shellcheck source=scripts/easy-docker/lib/app/options.sh + source "${lib_dir}/app/options.sh" + # shellcheck source=scripts/easy-docker/lib/app/run.sh + source "${lib_dir}/app/run.sh" +} + +load_easy_docker_modules diff --git a/scripts/easy-docker/lib/ui/screens/production.sh b/scripts/easy-docker/lib/ui/screens/production.sh index 90c1acac..54f73369 100755 --- a/scripts/easy-docker/lib/ui/screens/production.sh +++ b/scripts/easy-docker/lib/ui/screens/production.sh @@ -35,24 +35,96 @@ prompt_new_stack_name() { --placeholder "my-production-stack" } -show_create_stack_created() { - local stack_name="${1}" - local env_path="${2}" +show_stack_topology_menu() { + local stack_dir="${1}" + local stack_name="" local status_text="" render_main_screen 1 >&2 - status_text="$(printf "Create new stack\n\nStack created: %s\nEnv file: %s" "${stack_name}" "${env_path}")" - + stack_name="${stack_dir##*/}" + status_text="$(printf "Stack created: %s\nDirectory: %s\n\nChoose the deployment topology." "${stack_name}" "${stack_dir}")" render_box_message "${status_text}" "0 2" >&2 gum choose \ - --height 6 \ - --header "Create stack actions" \ + --height 9 \ + --header "Topology" \ --cursor.foreground 63 \ --selected.foreground 45 \ - "Continue stack wizard" \ - "Back to production setup" + "Single-host" \ + "Split services" \ + "Advanced" \ + "Abort wizard to main menu" +} + +show_single_host_examples() { + local status_text="" + + render_main_screen 1 >&2 + + status_text="$(printf "Single-host examples\n\n- One server, one compose project.\n- Local DB/Redis/Proxy with app services together.\n- Typical small production VM setup.")" + render_box_message "${status_text}" "0 2" >&2 + + gum choose \ + --height 7 \ + --header "Single-host" \ + --cursor.foreground 63 \ + --selected.foreground 45 \ + "Use this topology" \ + "Back to topology selection" +} + +show_split_services_examples() { + local status_text="" + + render_main_screen 1 >&2 + + status_text="$(printf "Split services examples\n\n- DB in a separate stack/project.\n- Proxy in a separate stack/project.\n- One or more app stacks referencing shared infra.")" + render_box_message "${status_text}" "0 2" >&2 + + gum choose \ + --height 7 \ + --header "Split services" \ + --cursor.foreground 63 \ + --selected.foreground 45 \ + "Use this topology" \ + "Back to topology selection" +} + +show_advanced_examples() { + local status_text="" + + render_main_screen 1 >&2 + + status_text="$(printf "Advanced examples\n\n- Managed external DB/Redis.\n- Multiple benches with custom images/tags.\n- GitOps-style rendered compose and custom networks/secrets.")" + render_box_message "${status_text}" "0 2" >&2 + + gum choose \ + --height 7 \ + --header "Advanced" \ + --cursor.foreground 63 \ + --selected.foreground 45 \ + "Use this topology" \ + "Back to topology selection" +} + +show_abort_wizard_prompt() { + local stack_dir="${1}" + local status_text="" + + render_main_screen 1 >&2 + + status_text="$(printf "Abort wizard\n\nStack directory:\n%s\n\nRollback created files before returning to main menu?" "${stack_dir}")" + render_box_message "${status_text}" "0 2" >&2 + + gum choose \ + --height 8 \ + --header "Abort options" \ + --cursor.foreground 63 \ + --selected.foreground 45 \ + "Rollback files and return to main menu" \ + "Keep files and return to main menu" \ + "Back to topology selection" } show_manage_stacks_placeholder() { diff --git a/scripts/easy-docker/main.sh b/scripts/easy-docker/main.sh index 626b9b8f..16f171f3 100755 --- a/scripts/easy-docker/main.sh +++ b/scripts/easy-docker/main.sh @@ -3,22 +3,8 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -# shellcheck source=scripts/easy-docker/lib/core/commands.sh -source "${SCRIPT_DIR}/lib/core/commands.sh" -# shellcheck source=scripts/easy-docker/lib/core/messages.sh -source "${SCRIPT_DIR}/lib/core/messages.sh" -# shellcheck source=scripts/easy-docker/lib/install/gum/load.sh -source "${SCRIPT_DIR}/lib/install/gum/load.sh" -# shellcheck source=scripts/easy-docker/lib/checks/docker.sh -source "${SCRIPT_DIR}/lib/checks/docker.sh" -# shellcheck source=scripts/easy-docker/lib/ui/screens.sh -source "${SCRIPT_DIR}/lib/ui/screens.sh" -# shellcheck source=scripts/easy-docker/lib/app/screen.sh -source "${SCRIPT_DIR}/lib/app/screen.sh" -# shellcheck source=scripts/easy-docker/lib/app/options.sh -source "${SCRIPT_DIR}/lib/app/options.sh" -# shellcheck source=scripts/easy-docker/lib/app/run.sh -source "${SCRIPT_DIR}/lib/app/run.sh" +# shellcheck source=scripts/easy-docker/lib/load.sh +source "${SCRIPT_DIR}/lib/load.sh" disable_installation_fallback=0 if parse_cli_options disable_installation_fallback "$@"; then