From 1f1d5c71333549e6163dfc428c406b16817d6c0d Mon Sep 17 00:00:00 2001 From: RocketQuack <202538874+Rocket-Quack@users.noreply.github.com> Date: Sat, 28 Feb 2026 00:17:09 +0100 Subject: [PATCH] feat(easy-docker-wizard): persist apps in metadata and modularize scripts --- scripts/easy-docker/lib/app/run.sh | 441 +----------------- scripts/easy-docker/lib/app/wizard/common.sh | 19 + .../easy-docker/lib/app/wizard/common/apps.sh | 274 +++++++++++ .../lib/app/wizard/common/compose.sh | 76 +++ .../easy-docker/lib/app/wizard/common/core.sh | 314 +++++++++++++ .../lib/app/wizard/common/helpers.sh | 92 ++++ .../easy-docker/lib/app/wizard/common/ux.sh | 92 ++++ scripts/easy-docker/lib/app/wizard/env.sh | 15 + .../easy-docker/lib/app/wizard/env/apps.sh | 339 ++++++++++++++ .../easy-docker/lib/app/wizard/env/collect.sh | 228 +++++++++ .../lib/app/wizard/env/validation.sh | 418 +++++++++++++++++ scripts/easy-docker/lib/app/wizard/flows.sh | 17 + .../lib/app/wizard/flows/manage.sh | 116 +++++ .../lib/app/wizard/flows/navigation.sh | 68 +++ .../easy-docker/lib/app/wizard/flows/setup.sh | 281 +++++++++++ .../lib/app/wizard/flows/single_host.sh | 111 +++++ .../easy-docker/lib/app/wizard/single_host.sh | 289 ++++++++++++ 17 files changed, 2761 insertions(+), 429 deletions(-) create mode 100755 scripts/easy-docker/lib/app/wizard/common.sh create mode 100755 scripts/easy-docker/lib/app/wizard/common/apps.sh create mode 100755 scripts/easy-docker/lib/app/wizard/common/compose.sh create mode 100755 scripts/easy-docker/lib/app/wizard/common/core.sh create mode 100755 scripts/easy-docker/lib/app/wizard/common/helpers.sh create mode 100755 scripts/easy-docker/lib/app/wizard/common/ux.sh create mode 100755 scripts/easy-docker/lib/app/wizard/env.sh create mode 100755 scripts/easy-docker/lib/app/wizard/env/apps.sh create mode 100755 scripts/easy-docker/lib/app/wizard/env/collect.sh create mode 100755 scripts/easy-docker/lib/app/wizard/env/validation.sh create mode 100755 scripts/easy-docker/lib/app/wizard/flows.sh create mode 100755 scripts/easy-docker/lib/app/wizard/flows/manage.sh create mode 100755 scripts/easy-docker/lib/app/wizard/flows/navigation.sh create mode 100755 scripts/easy-docker/lib/app/wizard/flows/setup.sh create mode 100755 scripts/easy-docker/lib/app/wizard/flows/single_host.sh create mode 100755 scripts/easy-docker/lib/app/wizard/single_host.sh diff --git a/scripts/easy-docker/lib/app/run.sh b/scripts/easy-docker/lib/app/run.sh index e2d0abb2..80f88b26 100755 --- a/scripts/easy-docker/lib/app/run.sh +++ b/scripts/easy-docker/lib/app/run.sh @@ -1,434 +1,17 @@ #!/usr/bin/env bash -readonly FLOW_CONTINUE=0 -readonly FLOW_BACK_TO_MAIN=10 -readonly FLOW_EXIT_APP=11 -readonly FLOW_ABORT_INPUT=12 +load_easy_docker_app_modules() { + local app_dir="" + app_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -get_easy_docker_repo_root() { - local app_lib_dir="" - app_lib_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" - (cd "${app_lib_dir}/../../../.." && pwd) + # shellcheck source=scripts/easy-docker/lib/app/wizard/common.sh + source "${app_dir}/wizard/common.sh" + # shellcheck source=scripts/easy-docker/lib/app/wizard/env.sh + source "${app_dir}/wizard/env.sh" + # shellcheck source=scripts/easy-docker/lib/app/wizard/single_host.sh + source "${app_dir}/wizard/single_host.sh" + # shellcheck source=scripts/easy-docker/lib/app/wizard/flows.sh + source "${app_dir}/wizard/flows.sh" } -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}" - - if [ -z "${stack_name}" ]; then - return 1 - fi - - case "${stack_name}" in - *[!A-Za-z0-9._-]*) - return 1 - ;; - *) - return 0 - ;; - esac -} - -create_stack_directory_with_metadata() { - local stack_dir_var="${1}" - local stack_name="${2}" - local stacks_dir="" - local created_stack_dir="" - local metadata_path="" - local created_at="" - - stacks_dir="$(get_easy_docker_stacks_dir)" - 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 "${created_stack_dir}" ]; then - return 2 - fi - - if ! mkdir -p "${created_stack_dir}"; then - return 1 - fi - - 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="" - local input_status=0 - - input_value="$(prompt_new_stack_name)" - input_status=$? - if [ "${input_status}" -ne 0 ]; then - return "${FLOW_ABORT_INPUT}" - fi - - input_value="$(printf '%s' "${input_value}" | tr -d '\r\n')" - - case "${input_value}" in - /cancel | /CANCEL | /Cancel) - return "${FLOW_ABORT_INPUT}" - ;; - esac - - printf -v "${result_var}" "%s" "${input_value}" - 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 handler_status=0 - - enter_alt_screen - render_main_screen 1 - - while true; do - action="$(show_main_menu || true)" - - if [ -z "${action}" ]; then - return 0 - fi - - case "${action}" in - "Production setup") - 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 - ;; - "${FLOW_EXIT_APP}") - return 0 - ;; - *) ;; - 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_and_wait "Unknown action: ${action}" - ;; - esac - done -} +load_easy_docker_app_modules diff --git a/scripts/easy-docker/lib/app/wizard/common.sh b/scripts/easy-docker/lib/app/wizard/common.sh new file mode 100755 index 00000000..83cd59e9 --- /dev/null +++ b/scripts/easy-docker/lib/app/wizard/common.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash + +load_easy_docker_wizard_common_modules() { + local wizard_dir="" + wizard_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + + # shellcheck source=scripts/easy-docker/lib/app/wizard/common/core.sh + source "${wizard_dir}/common/core.sh" + # shellcheck source=scripts/easy-docker/lib/app/wizard/common/helpers.sh + source "${wizard_dir}/common/helpers.sh" + # shellcheck source=scripts/easy-docker/lib/app/wizard/common/compose.sh + source "${wizard_dir}/common/compose.sh" + # shellcheck source=scripts/easy-docker/lib/app/wizard/common/apps.sh + source "${wizard_dir}/common/apps.sh" + # shellcheck source=scripts/easy-docker/lib/app/wizard/common/ux.sh + source "${wizard_dir}/common/ux.sh" +} + +load_easy_docker_wizard_common_modules diff --git a/scripts/easy-docker/lib/app/wizard/common/apps.sh b/scripts/easy-docker/lib/app/wizard/common/apps.sh new file mode 100755 index 00000000..0a3bfbba --- /dev/null +++ b/scripts/easy-docker/lib/app/wizard/common/apps.sh @@ -0,0 +1,274 @@ +#!/usr/bin/env bash + +persist_stack_apps_json_content() { + local stack_dir="${1}" + local apps_json_content="${2}" + local apps_json_path="" + local apps_json_tmp_path="" + + apps_json_path="${stack_dir}/apps.json" + apps_json_tmp_path="${apps_json_path}.tmp" + + if ! printf '%s\n' "${apps_json_content}" >"${apps_json_tmp_path}"; then + rm -f -- "${apps_json_tmp_path}" >/dev/null 2>&1 || true + return 1 + fi + + if ! mv -- "${apps_json_tmp_path}" "${apps_json_path}"; then + rm -f -- "${apps_json_tmp_path}" >/dev/null 2>&1 || true + return 1 + fi + + return 0 +} + +get_metadata_apps_predefined_csv() { + local metadata_path="${1}" + + if [ ! -f "${metadata_path}" ]; then + return 1 + fi + + awk ' + /"apps"[[:space:]]*:[[:space:]]*{/ { + in_apps = 1 + } + in_apps && /"predefined"[[:space:]]*:[[:space:]]*\[/ { + in_predefined = 1 + next + } + in_predefined && /\]/ { + in_predefined = 0 + next + } + in_predefined { + if (match($0, /"([^"]+)"/, parts)) { + if (csv == "") { + csv = parts[1] + } else { + csv = csv "," parts[1] + } + } + } + END { + if (csv != "") { + print csv + } + } + ' "${metadata_path}" +} + +get_metadata_apps_custom_lines() { + local metadata_path="${1}" + + if [ ! -f "${metadata_path}" ]; then + return 1 + fi + + awk ' + /"apps"[[:space:]]*:[[:space:]]*{/ { + in_apps = 1 + } + in_apps && /"custom"[[:space:]]*:[[:space:]]*\[/ { + in_custom = 1 + next + } + in_custom && /\]/ { + in_custom = 0 + repo = "" + branch = "" + next + } + in_custom { + if (match($0, /"repo"[[:space:]]*:[[:space:]]*"([^"]+)"/, repo_parts)) { + repo = repo_parts[1] + } + if (match($0, /"branch"[[:space:]]*:[[:space:]]*"([^"]+)"/, branch_parts)) { + branch = branch_parts[1] + } + if (repo != "" && branch != "") { + print repo "|" branch + repo = "" + branch = "" + } + } + ' "${metadata_path}" +} + +build_stack_apps_json_content_from_metadata_apps() { + local result_var="${1}" + local stack_dir="${2}" + local metadata_path="" + local preset_apps_csv="" + local custom_apps_lines="" + local preset_branch="" + local app="" + local line="" + local repo="" + local branch="" + local url="" + local escaped_url="" + local escaped_branch="" + local entry_json="" + local entries_json="" + local -a preset_apps=() + + metadata_path="${stack_dir}/metadata.json" + if [ ! -f "${metadata_path}" ]; then + return 1 + fi + + preset_apps_csv="$(get_metadata_apps_predefined_csv "${metadata_path}" || true)" + custom_apps_lines="$(get_metadata_apps_custom_lines "${metadata_path}" || true)" + preset_branch="$(get_default_frappe_branch)" + + if [ -n "${preset_apps_csv}" ]; then + IFS=',' read -r -a preset_apps <<<"${preset_apps_csv}" + for app in "${preset_apps[@]}"; do + case "${app}" in + erpnext) + url="https://github.com/frappe/erpnext" + ;; + crm) + url="https://github.com/frappe/crm" + ;; + *) + continue + ;; + esac + + escaped_url="$(json_escape_string "${url}")" + escaped_branch="$(json_escape_string "${preset_branch}")" + entry_json="$(printf ' {"url": "%s", "branch": "%s"}' "${escaped_url}" "${escaped_branch}")" + if [ -z "${entries_json}" ]; then + entries_json="${entry_json}" + else + entries_json="${entries_json}"$',\n'"${entry_json}" + fi + done + fi + + while IFS= read -r line; do + if [ -z "${line}" ]; then + continue + fi + + repo="${line%%|*}" + branch="${line#*|}" + if [ -z "${repo}" ] || [ -z "${branch}" ]; then + continue + fi + + escaped_url="$(json_escape_string "${repo}")" + escaped_branch="$(json_escape_string "${branch}")" + entry_json="$(printf ' {"url": "%s", "branch": "%s"}' "${escaped_url}" "${escaped_branch}")" + if [ -z "${entries_json}" ]; then + entries_json="${entry_json}" + else + entries_json="${entries_json}"$',\n'"${entry_json}" + fi + done <"${metadata_tmp_path}"; then + rm -f -- "${metadata_tmp_path}" >/dev/null 2>&1 || true + return 1 + fi + + if ! mv -- "${metadata_tmp_path}" "${metadata_path}"; then + rm -f -- "${metadata_tmp_path}" >/dev/null 2>&1 || true + return 1 + fi + + return 0 +} diff --git a/scripts/easy-docker/lib/app/wizard/common/compose.sh b/scripts/easy-docker/lib/app/wizard/common/compose.sh new file mode 100755 index 00000000..b6691146 --- /dev/null +++ b/scripts/easy-docker/lib/app/wizard/common/compose.sh @@ -0,0 +1,76 @@ +#!/usr/bin/env bash + +render_stack_compose_from_metadata() { + local stack_dir="${1}" + local metadata_path="" + local env_path="" + local generated_compose_path="" + local generated_compose_tmp_path="" + local compose_files_lines="" + local compose_file="" + local source_compose_path="" + local env_erpnext_version="" + local fallback_erpnext_version="" + local repo_root="" + local -a compose_args=() + + metadata_path="${stack_dir}/metadata.json" + env_path="$(get_stack_env_path "${stack_dir}")" + generated_compose_path="$(get_stack_generated_compose_path "${stack_dir}")" + generated_compose_tmp_path="${generated_compose_path}.tmp" + + if [ ! -f "${metadata_path}" ]; then + return 1 + fi + + if [ ! -f "${env_path}" ]; then + return 1 + fi + + 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 + + compose_files_lines="$(get_metadata_compose_files_lines "${metadata_path}" || true)" + if [ -z "${compose_files_lines}" ]; then + return 1 + 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 + return 1 + fi + + compose_args+=(-f "${source_compose_path}") + done <"${generated_compose_tmp_path}"; then + rm -f -- "${generated_compose_tmp_path}" >/dev/null 2>&1 || true + return 1 + fi + elif ! docker compose --env-file "${env_path}" "${compose_args[@]}" config >"${generated_compose_tmp_path}"; then + rm -f -- "${generated_compose_tmp_path}" >/dev/null 2>&1 || true + return 1 + fi + + if ! mv -- "${generated_compose_tmp_path}" "${generated_compose_path}"; then + rm -f -- "${generated_compose_tmp_path}" >/dev/null 2>&1 || true + return 1 + fi + + return 0 +} diff --git a/scripts/easy-docker/lib/app/wizard/common/core.sh b/scripts/easy-docker/lib/app/wizard/common/core.sh new file mode 100755 index 00000000..5a47743c --- /dev/null +++ b/scripts/easy-docker/lib/app/wizard/common/core.sh @@ -0,0 +1,314 @@ +#!/usr/bin/env bash + +# shellcheck disable=SC2034 +readonly FLOW_CONTINUE=0 +# shellcheck disable=SC2034 +readonly FLOW_ABORT_INPUT=12 + +get_easy_docker_repo_root() { + local app_lib_dir="" + app_lib_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + (cd "${app_lib_dir}/../../../../.." && pwd) +} + +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}" + + if [ -z "${stack_name}" ]; then + return 1 + fi + + case "${stack_name}" in + *[!A-Za-z0-9._-]*) + return 1 + ;; + *) + return 0 + ;; + esac +} + +create_stack_directory_with_metadata() { + local stack_dir_var="${1}" + local stack_name="${2}" + local setup_type="${3:-production}" + local stacks_dir="" + local created_stack_dir="" + local metadata_path="" + local created_at="" + + stacks_dir="$(get_easy_docker_stacks_dir)" + 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 "${created_stack_dir}" ]; then + return 2 + fi + + if ! mkdir -p "${created_stack_dir}"; then + return 1 + fi + + 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}" +} + +get_metadata_string_field() { + local metadata_path="${1}" + local field_name="${2}" + + if [ ! -f "${metadata_path}" ]; then + return 1 + fi + + awk -v field_name="${field_name}" ' + match($0, "\"" field_name "\"[[:space:]]*:[[:space:]]*\"([^\"]*)\"", parts) { + print parts[1] + exit + } + ' "${metadata_path}" +} + +get_metadata_number_field() { + local metadata_path="${1}" + local field_name="${2}" + + if [ ! -f "${metadata_path}" ]; then + return 1 + fi + + awk -v field_name="${field_name}" ' + match($0, "\"" field_name "\"[[:space:]]*:[[:space:]]*([0-9]+)", parts) { + print parts[1] + exit + } + ' "${metadata_path}" +} + +get_env_file_key_value() { + local env_file="${1}" + local key="${2}" + local value="" + + if [ ! -f "${env_file}" ]; then + return 1 + fi + + value="$( + awk -v key="${key}" ' + /^[[:space:]]*#/ { next } + $0 !~ /=/ { next } + { + k = $1 + gsub(/^[[:space:]]+|[[:space:]]+$/, "", k) + if (k != key) { + next + } + v = substr($0, index($0, "=") + 1) + gsub(/^[[:space:]]+|[[:space:]]+$/, "", v) + print v + exit + } + ' "${env_file}" + )" + + if [ -z "${value}" ]; then + return 1 + fi + + case "${value}" in + \"*\") + value="${value#\"}" + value="${value%\"}" + ;; + \'*\') + value="${value#\'}" + value="${value%\'}" + ;; + esac + + printf '%s\n' "${value}" +} + +get_default_erpnext_version() { + local repo_root="" + local source_env_file="" + local value="" + + if [ -n "${ERPNEXT_VERSION:-}" ]; then + printf '%s\n' "${ERPNEXT_VERSION}" + return 0 + fi + + repo_root="$(get_easy_docker_repo_root)" + for source_env_file in "${repo_root}/.env" "${repo_root}/example.env"; do + value="$(get_env_file_key_value "${source_env_file}" "ERPNEXT_VERSION" || true)" + if [ -n "${value}" ]; then + printf '%s\n' "${value}" + return 0 + fi + done + + return 1 +} + +get_default_frappe_branch() { + local repo_root="" + local source_env_file="" + local value="" + local erpnext_version="" + local major_version="" + + if [ -n "${FRAPPE_BRANCH:-}" ]; then + printf '%s\n' "${FRAPPE_BRANCH}" + return 0 + fi + + repo_root="$(get_easy_docker_repo_root)" + for source_env_file in "${repo_root}/.env" "${repo_root}/example.env"; do + value="$(get_env_file_key_value "${source_env_file}" "FRAPPE_BRANCH" || true)" + if [ -n "${value}" ]; then + printf '%s\n' "${value}" + return 0 + fi + done + + erpnext_version="$(get_default_erpnext_version || true)" + case "${erpnext_version}" in + v[0-9]*) + major_version="${erpnext_version#v}" + major_version="${major_version%%.*}" + if [[ "${major_version}" =~ ^[0-9]+$ ]]; then + printf 'version-%s\n' "${major_version}" + return 0 + fi + ;; + esac + + printf 'version-15\n' + return 0 +} + +get_stack_env_path() { + local stack_dir="${1}" + local metadata_path="" + local stack_name="" + + metadata_path="${stack_dir}/metadata.json" + stack_name="$(get_metadata_string_field "${metadata_path}" "stack_name" || true)" + if [ -z "${stack_name}" ]; then + stack_name="${stack_dir##*/}" + fi + + printf '%s/%s.env\n' "${stack_dir}" "${stack_name}" +} + +get_stack_generated_compose_path() { + local stack_dir="${1}" + + printf '%s/compose.generated.yaml\n' "${stack_dir}" +} + +get_stack_dir_by_name() { + local stack_name="${1}" + local stacks_dir="" + local stack_dir="" + local metadata_path="" + local candidate_name="" + + stacks_dir="$(get_easy_docker_stacks_dir)" + if [ ! -d "${stacks_dir}" ]; then + return 1 + fi + + for stack_dir in "${stacks_dir}"/*; do + if [ ! -d "${stack_dir}" ]; then + continue + fi + + metadata_path="${stack_dir}/metadata.json" + if [ ! -f "${metadata_path}" ]; then + continue + fi + + candidate_name="$(get_metadata_string_field "${metadata_path}" "stack_name" || true)" + if [ "${candidate_name}" = "${stack_name}" ]; then + printf '%s\n' "${stack_dir}" + return 0 + fi + done + + return 1 +} + +get_metadata_compose_files_lines() { + local metadata_path="${1}" + + if [ ! -f "${metadata_path}" ]; then + return 1 + fi + + awk ' + /"compose_files"[[:space:]]*:[[:space:]]*\[/ { + in_compose_files = 1 + next + } + in_compose_files && /\]/ { + in_compose_files = 0 + exit + } + in_compose_files { + if (match($0, /"([^"]+)"/, parts)) { + print parts[1] + } + } + ' "${metadata_path}" +} diff --git a/scripts/easy-docker/lib/app/wizard/common/helpers.sh b/scripts/easy-docker/lib/app/wizard/common/helpers.sh new file mode 100755 index 00000000..a55cf4d4 --- /dev/null +++ b/scripts/easy-docker/lib/app/wizard/common/helpers.sh @@ -0,0 +1,92 @@ +#!/usr/bin/env bash + +json_escape_string() { + local raw_value="${1}" + + raw_value="${raw_value//\\/\\\\}" + raw_value="${raw_value//\"/\\\"}" + raw_value="${raw_value//$'\n'/\\n}" + raw_value="${raw_value//$'\r'/\\r}" + raw_value="${raw_value//$'\t'/\\t}" + + printf '%s' "${raw_value}" +} + +build_compose_files_json_array() { + local compose_files_lines="${1}" + local line="" + local first=1 + + while IFS= read -r line; do + if [ -z "${line}" ]; then + continue + fi + + if [ "${first}" -eq 1 ]; then + printf ' "%s"' "${line}" + first=0 + else + printf ',\n "%s"' "${line}" + fi + done </dev/null; then + show_warning_and_wait "Unknown proxy mode: ${proxy_mode}" + return "${FLOW_CONTINUE}" + fi + ;; + esac + + database_choice="$(show_single_host_database_menu "${stack_dir}" || true)" + case "${database_choice}" in + "Back to topology selection" | "") + return "${FLOW_CONTINUE}" + ;; + *) + if ! get_single_host_database_id "${database_choice}" >/dev/null; then + show_warning_and_wait "Unknown database choice: ${database_choice}" + return "${FLOW_CONTINUE}" + fi + ;; + esac + + redis_choice="$(show_single_host_redis_menu "${stack_dir}" || true)" + case "${redis_choice}" in + "Back to topology selection" | "") + return "${FLOW_CONTINUE}" + ;; + *) + if ! get_single_host_redis_id "${redis_choice}" >/dev/null; then + show_warning_and_wait "Unknown Redis choice: ${redis_choice}" + return "${FLOW_CONTINUE}" + fi + ;; + esac + + if ! save_single_host_selection "${stack_dir}" "${proxy_mode}" "${database_choice}" "${redis_choice}"; then + save_selection_status=$? + if [ "${save_selection_status}" -eq 2 ]; then + return "${FLOW_CONTINUE}" + fi + + show_warning_and_wait "Could not save single-host selection for stack: ${stack_dir}" 2 + return "${FLOW_CONTINUE}" + fi + + if ! render_stack_compose_from_metadata "${stack_dir}"; then + render_compose_status=$? + stack_env_path="$(get_stack_env_path "${stack_dir}")" + stack_apps_path="${stack_dir}/apps.json" + generated_compose_path="$(get_stack_generated_compose_path "${stack_dir}")" + show_warning_and_wait "Selection saved in ${stack_dir}/metadata.json, ${stack_env_path}, and ${stack_apps_path}, but compose rendering failed (${render_compose_status}) for ${generated_compose_path}." 3 + return "${FLOW_CONTINUE}" + fi + + stack_env_path="$(get_stack_env_path "${stack_dir}")" + stack_apps_path="${stack_dir}/apps.json" + generated_compose_path="$(get_stack_generated_compose_path "${stack_dir}")" + show_warning_and_wait "Single-host selection saved in ${stack_dir}/metadata.json, ${stack_env_path}, and ${stack_apps_path}. Rendered compose: ${generated_compose_path}." 3 + return "${FLOW_CONTINUE}" +} + +handle_topology_examples_flow() { + local topology_name="${1}" + local detail_action="" + + case "${topology_name}" in + "Split services") + detail_action="$(show_split_services_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 +} diff --git a/scripts/easy-docker/lib/app/wizard/single_host.sh b/scripts/easy-docker/lib/app/wizard/single_host.sh new file mode 100755 index 00000000..90a0f824 --- /dev/null +++ b/scripts/easy-docker/lib/app/wizard/single_host.sh @@ -0,0 +1,289 @@ +#!/usr/bin/env bash + +get_single_host_proxy_mode_id() { + local proxy_mode="${1}" + + case "${proxy_mode}" in + "Traefik (HTTP, built-in proxy)") + printf 'traefik-http\n' + ;; + "Traefik (HTTPS + Let's Encrypt)") + printf 'traefik-https\n' + ;; + "nginx-proxy (HTTP)") + printf 'nginxproxy-http\n' + ;; + "nginx-proxy + acme-companion (HTTPS)") + printf 'nginxproxy-https\n' + ;; + "Caddy (external reverse proxy)") + printf 'caddy-external\n' + ;; + "No reverse proxy (direct :8080)") + printf 'no-proxy\n' + ;; + *) + return 1 + ;; + esac +} + +get_single_host_proxy_overrides() { + local proxy_mode="${1}" + + case "${proxy_mode}" in + "Traefik (HTTP, built-in proxy)") + printf 'overrides/compose.proxy.yaml\n' + ;; + "Traefik (HTTPS + Let's Encrypt)") + printf 'overrides/compose.https.yaml\n' + ;; + "nginx-proxy (HTTP)") + printf 'overrides/compose.nginxproxy.yaml\n' + ;; + "nginx-proxy + acme-companion (HTTPS)") + printf 'overrides/compose.nginxproxy.yaml\noverrides/compose.nginxproxy-ssl.yaml\n' + ;; + "Caddy (external reverse proxy)" | "No reverse proxy (direct :8080)") + printf 'overrides/compose.noproxy.yaml\n' + ;; + *) + return 1 + ;; + esac +} + +get_single_host_database_id() { + local database_choice="${1}" + + case "${database_choice}" in + "MariaDB (recommended)") + printf 'mariadb\n' + ;; + "PostgreSQL") + printf 'postgres\n' + ;; + *) + return 1 + ;; + esac +} + +get_single_host_database_override() { + local database_choice="${1}" + + case "${database_choice}" in + "MariaDB (recommended)") + printf 'overrides/compose.mariadb.yaml\n' + ;; + "PostgreSQL") + printf 'overrides/compose.postgres.yaml\n' + ;; + *) + return 1 + ;; + esac +} + +get_single_host_redis_id() { + local redis_choice="${1}" + + case "${redis_choice}" in + "Include Redis (recommended)") + printf 'enabled\n' + ;; + "Skip Redis (experienced users only)") + printf 'disabled\n' + ;; + *) + return 1 + ;; + esac +} + +get_single_host_redis_override() { + local redis_choice="${1}" + + case "${redis_choice}" in + "Include Redis (recommended)") + printf 'overrides/compose.redis.yaml\n' + ;; + "Skip Redis (experienced users only)") + printf '' + ;; + *) + return 1 + ;; + esac +} + +persist_single_host_env_file() { + local stack_dir="${1}" + local env_lines="${2}" + local env_path="" + local env_tmp_path="" + local generated_at="" + + env_path="$(get_stack_env_path "${stack_dir}")" + env_tmp_path="${env_path}.tmp" + generated_at="$(get_current_utc_timestamp)" + + if ! { + printf '# Generated by easy-docker wizard at %s\n' "${generated_at}" + printf '# Adjust values as needed for this stack.\n' + if [ -n "${env_lines}" ]; then + printf '\n%s\n' "${env_lines}" + else + printf '\n# No additional environment variables configured by the wizard.\n' + fi + } >"${env_tmp_path}"; then + rm -f -- "${env_tmp_path}" >/dev/null 2>&1 || true + return 1 + fi + + if ! mv -- "${env_tmp_path}" "${env_path}"; then + rm -f -- "${env_tmp_path}" >/dev/null 2>&1 || true + return 1 + fi + + return 0 +} + +persist_single_host_selection_metadata() { + local stack_dir="${1}" + local proxy_mode_id="${2}" + local database_id="${3}" + local redis_id="${4}" + local compose_files_lines="${5}" + local apps_json_object="${6}" + local env_lines="${7}" + local metadata_path="" + local metadata_tmp_path="" + local schema_version="" + local stack_name="" + local setup_type="" + local created_at="" + local updated_at="" + local compose_files_json="" + local env_json_object="" + + metadata_path="${stack_dir}/metadata.json" + metadata_tmp_path="${metadata_path}.tmp" + + schema_version="$(get_metadata_number_field "${metadata_path}" "schema_version" || true)" + if [ -z "${schema_version}" ]; then + schema_version="1" + fi + + stack_name="$(get_metadata_string_field "${metadata_path}" "stack_name" || true)" + if [ -z "${stack_name}" ]; then + stack_name="${stack_dir##*/}" + fi + + setup_type="$(get_metadata_string_field "${metadata_path}" "setup_type" || true)" + if [ -z "${setup_type}" ]; then + setup_type="production" + fi + + created_at="$(get_metadata_string_field "${metadata_path}" "created_at" || true)" + if [ -z "${created_at}" ]; then + created_at="$(get_current_utc_timestamp)" + fi + + updated_at="$(get_current_utc_timestamp)" + compose_files_json="$(build_compose_files_json_array "${compose_files_lines}")" + env_json_object="$(build_env_json_object "${env_lines}")" + if [ -z "${apps_json_object}" ]; then + return 1 + fi + + if ! cat >"${metadata_tmp_path}" </dev/null 2>&1 || true + return 1 + fi + + if ! mv -- "${metadata_tmp_path}" "${metadata_path}"; then + rm -f -- "${metadata_tmp_path}" >/dev/null 2>&1 || true + return 1 + fi + + return 0 +} + +save_single_host_selection() { + local stack_dir="${1}" + local proxy_mode="${2}" + local database_choice="${3}" + local redis_choice="${4}" + local proxy_mode_id="" + local database_id="" + local redis_id="" + local database_override="" + local redis_override="" + local proxy_overrides="" + local compose_files_lines="" + local env_lines="" + local apps_metadata_json_object="" + local collect_env_status=0 + + proxy_mode_id="$(get_single_host_proxy_mode_id "${proxy_mode}")" || return 1 + database_id="$(get_single_host_database_id "${database_choice}")" || return 1 + redis_id="$(get_single_host_redis_id "${redis_choice}")" || return 1 + + database_override="$(get_single_host_database_override "${database_choice}")" || return 1 + redis_override="$(get_single_host_redis_override "${redis_choice}")" || return 1 + proxy_overrides="$(get_single_host_proxy_overrides "${proxy_mode}")" || return 1 + + compose_files_lines="$(printf 'compose.yaml\n%s' "${database_override}")" + if [ -n "${redis_override}" ]; then + compose_files_lines="$(printf '%s\n%s' "${compose_files_lines}" "${redis_override}")" + fi + compose_files_lines="$(printf '%s\n%s' "${compose_files_lines}" "${proxy_overrides}")" + + if ! collect_single_host_env_lines env_lines apps_metadata_json_object "${stack_dir}" "${proxy_mode_id}" "${database_id}"; then + collect_env_status=$? + return "${collect_env_status}" + fi + + if ! persist_single_host_env_file "${stack_dir}" "${env_lines}"; then + return 1 + fi + + if ! persist_single_host_selection_metadata \ + "${stack_dir}" \ + "${proxy_mode_id}" \ + "${database_id}" \ + "${redis_id}" \ + "${compose_files_lines}" \ + "${apps_metadata_json_object}" \ + "${env_lines}"; then + return 1 + fi + + if ! persist_stack_apps_json_from_metadata_apps "${stack_dir}"; then + return 1 + fi + + return 0 +}