diff --git a/scripts/easy-docker/lib/app/wizard/common.sh b/scripts/easy-docker/lib/app/wizard/common.sh index b7a9d146..dfb28c3a 100755 --- a/scripts/easy-docker/lib/app/wizard/common.sh +++ b/scripts/easy-docker/lib/app/wizard/common.sh @@ -16,6 +16,8 @@ load_easy_docker_wizard_common_modules() { source "${wizard_dir}/common/frappe.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/site.sh + source "${wizard_dir}/common/site.sh" # shellcheck source=scripts/easy-docker/lib/app/wizard/common/ux.sh source "${wizard_dir}/common/ux.sh" } diff --git a/scripts/easy-docker/lib/app/wizard/common/site.sh b/scripts/easy-docker/lib/app/wizard/common/site.sh new file mode 100755 index 00000000..12a81680 --- /dev/null +++ b/scripts/easy-docker/lib/app/wizard/common/site.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash + +load_easy_docker_site_modules() { + local site_dir="" + site_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/site" + + # shellcheck source=scripts/easy-docker/lib/app/wizard/common/site/metadata.sh + 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/apps.sh + source "${site_dir}/apps.sh" +} + +load_easy_docker_site_modules diff --git a/scripts/easy-docker/lib/app/wizard/common/site/apps.sh b/scripts/easy-docker/lib/app/wizard/common/site/apps.sh new file mode 100755 index 00000000..4c57110e --- /dev/null +++ b/scripts/easy-docker/lib/app/wizard/common/site/apps.sh @@ -0,0 +1,76 @@ +#!/usr/bin/env bash + +append_stack_installable_app_line() { + local result_var="${1}" + local existing_lines="${2:-}" + local app_name="${3:-}" + + if [ -z "${app_name}" ]; then + printf -v "${result_var}" "%s" "${existing_lines}" + return 0 + fi + + while IFS= read -r existing_app; do + if [ "${existing_app}" = "${app_name}" ]; then + printf -v "${result_var}" "%s" "${existing_lines}" + return 0 + fi + done </dev/null 2>&1; then + return 0 + fi + + backend_ready_status=$? + if [ "${backend_ready_status}" -eq 54 ] || [ "${backend_ready_status}" -eq 52 ]; then + return "${backend_ready_status}" + fi + + # If exec fails, the backend service is not ready for site actions yet. + return 1 +} + +stack_database_service_is_reachable() { + local stack_dir="${1}" + local reachability_command="" + local db_ready_status=0 + + IFS= read -r -d '' reachability_command <<'EOF' || true +python - <<'PY' +import json +import socket +from pathlib import Path + +config_path = Path("/home/frappe/frappe-bench/sites/common_site_config.json") +with config_path.open(encoding="utf-8") as handle: + config = json.load(handle) + +db_host = config.get("db_host") +db_port = int(config.get("db_port", 3306)) +socket.create_connection((db_host, db_port), 5).close() +PY +EOF + + if run_stack_backend_bash_command "${stack_dir}" "${reachability_command}" >/dev/null 2>&1; then + return 0 + fi + + db_ready_status=$? + if [ "${db_ready_status}" -eq 54 ] || [ "${db_ready_status}" -eq 52 ]; then + return "${db_ready_status}" + fi + + return 1 +} + +stack_site_exists_in_bench() { + local stack_dir="${1}" + local site_name="${2}" + local exists_command="" + local exists_status=0 + + if ! is_safe_stack_site_cleanup_name "${site_name}"; then + return 61 + fi + + exists_command="$( + printf "bench list-sites | grep -F -x -- %s >/dev/null" "$(shell_quote_site_command_arg "${site_name}")" + )" + if run_stack_backend_bash_command "${stack_dir}" "${exists_command}" >/dev/null 2>&1; then + return 0 + fi + + exists_status=$? + if [ "${exists_status}" -eq 54 ] || [ "${exists_status}" -eq 52 ]; then + return "${exists_status}" + fi + + return 1 +} + +stack_site_directory_exists() { + local stack_dir="${1}" + local site_name="${2}" + local exists_command="" + local exists_status=0 + + if ! is_safe_stack_site_cleanup_name "${site_name}"; then + return 61 + fi + + exists_command="$( + printf "test -d sites/%s" "$(shell_quote_site_command_arg "${site_name}")" + )" + if run_stack_backend_bash_command "${stack_dir}" "${exists_command}" >/dev/null 2>&1; then + return 0 + fi + + exists_status=$? + if [ "${exists_status}" -eq 54 ] || [ "${exists_status}" -eq 52 ]; then + return "${exists_status}" + fi + + return 1 +} + +stack_site_config_exists() { + local stack_dir="${1}" + local site_name="${2}" + local exists_command="" + local exists_status=0 + + if ! is_safe_stack_site_cleanup_name "${site_name}"; then + return 61 + fi + + exists_command="$( + printf "test -f sites/%s/site_config.json" "$(shell_quote_site_command_arg "${site_name}")" + )" + if run_stack_backend_bash_command "${stack_dir}" "${exists_command}" >/dev/null 2>&1; then + return 0 + fi + + exists_status=$? + if [ "${exists_status}" -eq 54 ] || [ "${exists_status}" -eq 52 ]; then + return "${exists_status}" + fi + + return 1 +} + +get_stack_site_database_name() { + local stack_dir="${1}" + local site_name="${2}" + local read_command="" + + if ! is_safe_stack_site_cleanup_name "${site_name}"; then + return 61 + fi + + read_command="$( + printf "python - <<'PY'\nimport json\nfrom pathlib import Path\npath = Path('sites') / %s / 'site_config.json'\nwith path.open(encoding='utf-8') as handle:\n print(json.load(handle).get('db_name', ''))\nPY" \ + "$(shell_quote_site_command_arg "${site_name}")" + )" + + run_stack_backend_bash_command "${stack_dir}" "${read_command}" +} + +get_stack_common_db_endpoint() { + local stack_dir="${1}" + local read_command="" + + read_command="$( + cat <<'EOF' +python - <<'PY' +import json +from pathlib import Path +path = Path("sites/common_site_config.json") +with path.open(encoding="utf-8") as handle: + config = json.load(handle) +print(f"{config.get('db_host', '')}|{config.get('db_port', 3306)}") +PY +EOF + )" + + run_stack_backend_bash_command "${stack_dir}" "${read_command}" +} + +repair_stack_site_runtime_state() { + local stack_dir="${1}" + local database_id="" + local redis_id="" + local db_host="" + local db_port="" + local repair_command="" + + database_id="$(get_stack_database_id "${stack_dir}" || true)" + redis_id="$(get_stack_redis_id "${stack_dir}" || true)" + + case "${database_id}" in + mariadb) + db_host="db" + db_port="3306" + ;; + postgres) + db_host="db" + db_port="5432" + ;; + *) + return 57 + ;; + esac + + repair_command="$( + cat < sites/common_site_config.json +ls -1 apps > sites/apps.txt +bench set-config -g db_host ${db_host} +bench set-config -gp db_port ${db_port} +EOF + )" + + case "${redis_id}" in + enabled) + repair_command="${repair_command}"$'\n'"bench set-config -g redis_cache redis://redis-cache:6379" + repair_command="${repair_command}"$'\n'"bench set-config -g redis_queue redis://redis-queue:6379" + repair_command="${repair_command}"$'\n'"bench set-config -g redis_socketio redis://redis-queue:6379" + ;; + "" | disabled) + : + ;; + *) + return 62 + ;; + esac + + repair_command="${repair_command}"$'\n'"bench set-config -gp socketio_port 9000" + repair_command="${repair_command}"$'\n'"bench set-config -g chromium_path /usr/bin/chromium-headless-shell" + + if ! run_stack_backend_bash_command "${stack_dir}" "${repair_command}"; then + return 62 + fi + + return 0 +} + +stack_site_has_partial_artifacts() { + local stack_dir="${1}" + local site_name="${2}" + + if ! is_safe_stack_site_cleanup_name "${site_name}"; then + return 61 + fi + + if stack_site_exists_in_bench "${stack_dir}" "${site_name}"; then + return 0 + fi + + case $? in + 61) + return 61 + ;; + 54 | 52) + return $? + ;; + esac + + if stack_site_directory_exists "${stack_dir}" "${site_name}"; then + return 0 + fi + + case $? in + 61) + return 61 + ;; + 54 | 52) + return $? + ;; + esac + + return 1 +} + +drop_stack_site_database() { + local stack_dir="${1}" + local db_name="${2}" + local db_password="" + local db_endpoint="" + local db_host="" + local db_port="" + local drop_db_command="" + + db_password="$(get_stack_database_root_password "${stack_dir}")" + db_endpoint="$(get_stack_common_db_endpoint "${stack_dir}" || true)" + db_host="${db_endpoint%%|*}" + db_port="${db_endpoint#*|}" + + if [ -z "${db_host}" ] || [ -z "${db_port}" ]; then + return 1 + fi + + drop_db_command="$( + printf "mysql --protocol=TCP -h %s -P %s -u root -p%s -e %s" \ + "$(shell_quote_site_command_arg "${db_host}")" \ + "$(shell_quote_site_command_arg "${db_port}")" \ + "$(printf '%s' "${db_password}" | sed "s/'/'\"'\"'/g")" \ + "$(shell_quote_site_command_arg "DROP DATABASE IF EXISTS \`${db_name}\`; DROP USER IF EXISTS '${db_name}'@'%'; DROP USER IF EXISTS '${db_name}'@'localhost'; FLUSH PRIVILEGES;")" + )" + + if ! run_stack_backend_bash_command "${stack_dir}" "${drop_db_command}"; then + return 1 + fi + + return 0 +} + +remove_stack_site_directory() { + local stack_dir="${1}" + local site_name="${2}" + local remove_command="" + + if ! is_safe_stack_site_cleanup_name "${site_name}"; then + return 61 + fi + + remove_command="$( + printf "rm -rf -- sites/%s archived_sites/%s" \ + "$(shell_quote_site_command_arg "${site_name}")" \ + "$(shell_quote_site_command_arg "${site_name}")" + )" + + if ! run_stack_backend_bash_command "${stack_dir}" "${remove_command}"; then + return 1 + fi + + return 0 +} + +cleanup_partial_stack_site() { + local stack_dir="${1}" + local site_name="${2}" + local artifact_status=0 + local db_name="" + local has_site_config=1 + + if ! is_safe_stack_site_cleanup_name "${site_name}"; then + return 61 + fi + + if stack_site_has_partial_artifacts "${stack_dir}" "${site_name}"; then + : + else + artifact_status=$? + case "${artifact_status}" in + 61) + return 61 + ;; + 54 | 52) + return "${artifact_status}" + ;; + *) + return 0 + ;; + esac + fi + + if stack_site_config_exists "${stack_dir}" "${site_name}"; then + : + else + artifact_status=$? + case "${artifact_status}" in + 61) + return 61 + ;; + 54 | 52) + return "${artifact_status}" + ;; + *) + has_site_config=0 + ;; + esac + fi + + if [ "${has_site_config}" -eq 1 ]; then + db_name="$(get_stack_site_database_name "${stack_dir}" "${site_name}" || true)" + if [ -z "${db_name}" ]; then + return 60 + fi + fi + + if [ "${has_site_config}" -eq 1 ] && ! drop_stack_site_database "${stack_dir}" "${db_name}"; then + return 60 + fi + + if ! remove_stack_site_directory "${stack_dir}" "${site_name}"; then + return 60 + fi + + if stack_site_has_partial_artifacts "${stack_dir}" "${site_name}"; then + return 60 + fi + + artifact_status=$? + case "${artifact_status}" in + 54 | 52) + return "${artifact_status}" + ;; + esac + + return 0 +} + +create_first_stack_site() { + local stack_dir="${1}" + local site_name="${2}" + local admin_password="${3}" + local create_site_command="" + + create_site_command="$( + printf "bench new-site %s --mariadb-user-host-login-scope='%%' --admin-password %s --db-root-username root --db-root-password %s" \ + "$(shell_quote_site_command_arg "${site_name}")" \ + "$(shell_quote_site_command_arg "${admin_password}")" \ + "$(shell_quote_site_command_arg "$(get_stack_database_root_password "${stack_dir}")")" + )" + + if ! run_stack_backend_bash_command "${stack_dir}" "${create_site_command}"; then + return 55 + fi + + return 0 +} + +install_stack_apps_on_site() { + local result_var="${1}" + local stack_dir="${2}" + local site_name="${3}" + local selected_app_lines="" + local installed_app_lines="" + local app_name="" + local install_app_command="" + + if ! get_stack_selected_installable_apps selected_app_lines "${stack_dir}"; then + printf -v "${result_var}" "%s" "" + return 0 + fi + + while IFS= read -r app_name; do + if [ -z "${app_name}" ]; then + continue + fi + + install_app_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 "${stack_dir}" "${install_app_command}"; then + printf -v "${result_var}" "%s" "${installed_app_lines}" + return 56 + fi + + if [ -z "${installed_app_lines}" ]; then + installed_app_lines="${app_name}" + else + installed_app_lines="${installed_app_lines}"$'\n'"${app_name}" + fi + + if ! persist_stack_site_metadata \ + "${stack_dir}" \ + "single-site" \ + "${site_name}" \ + "apps_installing" \ + "${installed_app_lines}" \ + "install-apps" \ + "" \ + "$(get_stack_site_created_at "${stack_dir}" || true)" \ + "$(get_current_utc_timestamp)"; then + return 58 + fi + done </dev/null 2>&1 || true + return 60 + ;; + *) + mark_stack_site_failed "${stack_dir}" "${site_name}" "" "cleanup-partial-site" "Unexpected cleanup failure before create-site." "${created_at}" >/dev/null 2>&1 || true + return 60 + ;; + esac + fi + + updated_at="${created_at}" + if ! persist_stack_site_metadata "${stack_dir}" "single-site" "${site_name}" "creating" "" "create-site" "" "${created_at}" "${updated_at}"; then + return 58 + fi + + if create_first_stack_site "${stack_dir}" "${site_name}" "${admin_password}"; then + : + else + site_create_status=$? + if cleanup_partial_stack_site "${stack_dir}" "${site_name}"; then + mark_stack_site_failed "${stack_dir}" "${site_name}" "" "create-site" "bench new-site failed. Partial site data was cleaned up automatically." "${created_at}" >/dev/null 2>&1 || true + return "${site_create_status}" + fi + + cleanup_status=$? + mark_stack_site_failed "${stack_dir}" "${site_name}" "" "create-site" "bench new-site failed and partial site data could not be cleaned up automatically. Manual cleanup is required." "${created_at}" >/dev/null 2>&1 || true + case "${cleanup_status}" in + 54 | 52) + return "${cleanup_status}" + ;; + *) + return 60 + ;; + esac + fi + + updated_at="$(get_current_utc_timestamp)" + if ! persist_stack_site_metadata "${stack_dir}" "single-site" "${site_name}" "created" "" "create-site" "" "${created_at}" "${updated_at}"; then + return 58 + fi + + if install_stack_apps_on_site installed_app_lines "${stack_dir}" "${site_name}"; then + : + else + app_install_status=$? + case "${app_install_status}" in + 56) + if cleanup_partial_stack_site "${stack_dir}" "${site_name}"; then + mark_stack_site_failed "${stack_dir}" "${site_name}" "${installed_app_lines}" "install-apps" "App installation failed. Partial site data was cleaned up automatically." "${created_at}" >/dev/null 2>&1 || true + else + cleanup_status=$? + mark_stack_site_failed "${stack_dir}" "${site_name}" "${installed_app_lines}" "install-apps" "App installation failed and partial site data could not be cleaned up automatically. Manual cleanup is required." "${created_at}" >/dev/null 2>&1 || true + case "${cleanup_status}" in + 54 | 52) + return "${cleanup_status}" + ;; + *) + return 60 + ;; + esac + fi + ;; + 58) + return 58 + ;; + *) + mark_stack_site_failed "${stack_dir}" "${site_name}" "${installed_app_lines}" "install-apps" "Unknown app installation failure." "${created_at}" >/dev/null 2>&1 || true + ;; + esac + return "${app_install_status}" + fi + + updated_at="$(get_current_utc_timestamp)" + if ! persist_stack_site_metadata "${stack_dir}" "single-site" "${site_name}" "ready" "${installed_app_lines}" "install-apps" "" "${created_at}" "${updated_at}"; then + return 58 + fi + + return 0 +} diff --git a/scripts/easy-docker/lib/app/wizard/common/site/metadata.sh b/scripts/easy-docker/lib/app/wizard/common/site/metadata.sh new file mode 100755 index 00000000..e20e39d9 --- /dev/null +++ b/scripts/easy-docker/lib/app/wizard/common/site/metadata.sh @@ -0,0 +1,350 @@ +#!/usr/bin/env bash + +get_metadata_site_string_field() { + local metadata_path="${1}" + local field_name="${2}" + + if [ ! -f "${metadata_path}" ]; then + return 1 + fi + + awk -v field_name="${field_name}" ' + /"site"[[:space:]]*:[[:space:]]*{/ { + in_site = 1 + site_depth = 1 + next + } + in_site { + if (match($0, "\"" field_name "\"[[:space:]]*:[[:space:]]*\"([^\"]*)\"", parts)) { + print parts[1] + exit + } + + line = $0 + open_count = gsub(/{/, "{", line) + close_count = gsub(/}/, "}", line) + site_depth += open_count - close_count + if (site_depth <= 0) { + exit + } + } + ' "${metadata_path}" +} + +get_metadata_site_apps_installed_lines() { + local metadata_path="${1}" + + if [ ! -f "${metadata_path}" ]; then + return 1 + fi + + awk ' + /"site"[[:space:]]*:[[:space:]]*{/ { + in_site = 1 + site_depth = 1 + next + } + in_site && /"apps_installed"[[:space:]]*:[[:space:]]*\[/ { + in_apps_installed = 1 + next + } + in_apps_installed && /\]/ { + in_apps_installed = 0 + next + } + in_apps_installed { + if (match($0, /"([^"]+)"/, parts)) { + print parts[1] + } + } + in_site { + line = $0 + open_count = gsub(/{/, "{", line) + close_count = gsub(/}/, "}", line) + site_depth += open_count - close_count + if (site_depth <= 0) { + exit + } + } + ' "${metadata_path}" +} + +get_stack_site_name() { + local stack_dir="${1}" + + get_metadata_site_string_field "${stack_dir}/metadata.json" "name" +} + +get_stack_site_state() { + local stack_dir="${1}" + + get_metadata_site_string_field "${stack_dir}/metadata.json" "state" +} + +get_stack_site_created_at() { + local stack_dir="${1}" + + get_metadata_site_string_field "${stack_dir}/metadata.json" "created_at" +} + +get_stack_site_apps_installed_lines() { + local stack_dir="${1}" + + get_metadata_site_apps_installed_lines "${stack_dir}/metadata.json" +} + +stack_has_site_record() { + local stack_dir="${1}" + local site_name="" + + site_name="$(get_stack_site_name "${stack_dir}" || true)" + if [ -n "${site_name}" ]; then + return 0 + fi + + return 1 +} + +stack_has_site_configured() { + local stack_dir="${1}" + local site_state="" + + site_state="$(get_stack_site_state "${stack_dir}" || true)" + case "${site_state}" in + created | apps_installing | ready) + return 0 + ;; + *) + return 1 + ;; + esac +} + +get_stack_site_status_label() { + local result_var="${1}" + local stack_dir="${2}" + local site_state="" + local site_name="" + local site_status_label="" + + site_state="$(get_stack_site_state "${stack_dir}" || true)" + site_name="$(get_stack_site_name "${stack_dir}" || true)" + + case "${site_state}" in + "") + site_status_label="Not configured" + ;; + requested) + site_status_label="Requested" + ;; + creating) + site_status_label="Creating" + ;; + created) + site_status_label="Created" + ;; + apps_installing) + site_status_label="Installing apps" + ;; + ready) + site_status_label="Ready" + ;; + failed) + site_status_label="Failed" + ;; + *) + site_status_label="${site_state}" + ;; + esac + + if [ -n "${site_name}" ]; then + site_status_label="${site_status_label} (${site_name})" + fi + + printf -v "${result_var}" "%s" "${site_status_label}" + return 0 +} + +get_stack_site_menu_entry() { + local result_var="${1}" + local stack_dir="${2}" + local site_name="" + local site_status_label="" + local menu_entry="" + + site_name="$(get_stack_site_name "${stack_dir}" || true)" + if [ -z "${site_name}" ]; then + return 1 + fi + + get_stack_site_status_label site_status_label "${stack_dir}" + menu_entry="$(printf "%s | %s" "${site_name}" "${site_status_label}")" + printf -v "${result_var}" "%s" "${menu_entry}" + return 0 +} + +build_stack_site_apps_installed_json_array() { + local result_var="${1}" + local apps_installed_lines="${2:-}" + local app_name="" + local escaped_app_name="" + local entries_json="" + + while IFS= read -r app_name; do + if [ -z "${app_name}" ]; then + continue + fi + + escaped_app_name="$(json_escape_string "${app_name}")" + if [ -z "${entries_json}" ]; then + entries_json="$(printf ' "%s"' "${escaped_app_name}")" + else + entries_json="${entries_json}"$',\n'"$(printf ' "%s"' "${escaped_app_name}")" + 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 +} + +mark_stack_site_failed() { + local stack_dir="${1}" + local site_name="${2:-}" + local apps_installed_lines="${3:-}" + local last_action="${4:-bootstrap-site}" + local last_error="${5:-Unknown site bootstrap failure}" + local created_at="${6:-}" + local updated_at="" + + updated_at="$(get_current_utc_timestamp)" + persist_stack_site_metadata "${stack_dir}" "single-site" "${site_name}" "failed" "${apps_installed_lines}" "${last_action}" "${last_error}" "${created_at}" "${updated_at}" +} diff --git a/scripts/easy-docker/lib/app/wizard/flows/manage.sh b/scripts/easy-docker/lib/app/wizard/flows/manage.sh index 09ca175f..7e019813 100755 --- a/scripts/easy-docker/lib/app/wizard/flows/manage.sh +++ b/scripts/easy-docker/lib/app/wizard/flows/manage.sh @@ -63,6 +63,198 @@ run_build_stack_custom_image_with_feedback() { return "${build_image_status}" } +prompt_manage_stack_site_name_with_cancel() { + local result_var="${1}" + local stack_name="${2}" + local stack_dir="${3}" + local input_site_name="" + local suggestion="" + local prompt_status=0 + + suggestion="$(get_stack_primary_site_name_suggestion "${stack_dir}" || true)" + while true; do + input_site_name="$(prompt_stack_site_name "${stack_name}" "${suggestion}")" + prompt_status=$? + if [ "${prompt_status}" -ne 0 ]; then + return "${FLOW_ABORT_INPUT}" + fi + + input_site_name="$(printf '%s' "${input_site_name}" | tr -d '\r\n')" + case "${input_site_name}" in + "") + show_warning_and_wait "Site name is required." 2 + ;; + /back | /Back | /BACK) + return "${FLOW_ABORT_INPUT}" + ;; + *) + if ! is_valid_stack_site_name "${input_site_name}"; then + show_warning_and_wait "Site name may only contain letters, numbers, dots, dashes, and underscores." 3 + continue + fi + printf -v "${result_var}" "%s" "${input_site_name}" + return "${FLOW_CONTINUE}" + ;; + esac + done +} + +prompt_manage_stack_site_admin_password_with_cancel() { + local result_var="${1}" + local stack_name="${2}" + local input_admin_password="" + local prompt_status=0 + + while true; do + input_admin_password="$(prompt_stack_site_admin_password "${stack_name}")" + prompt_status=$? + if [ "${prompt_status}" -ne 0 ]; then + return "${FLOW_ABORT_INPUT}" + fi + + input_admin_password="$(printf '%s' "${input_admin_password}" | tr -d '\r\n')" + case "${input_admin_password}" in + "") + show_warning_and_wait "Administrator password is required." 2 + ;; + /back | /Back | /BACK) + return "${FLOW_ABORT_INPUT}" + ;; + *) + printf -v "${result_var}" "%s" "${input_admin_password}" + return "${FLOW_CONTINUE}" + ;; + esac + done +} + +handle_manage_stack_site_flow() { + local stack_name="${1}" + local stack_dir="${2}" + local site_status_label="" + local site_action="" + local site_name="" + local admin_password="" + local site_flow_status=0 + local existing_site_entry="" + local existing_site_name="" + local existing_site_created_at="" + local existing_site_apps_lines="" + local existing_site_apps_csv="" + local existing_site_details_action="" + + while true; do + get_stack_site_status_label site_status_label "${stack_dir}" + existing_site_entry="" + get_stack_site_menu_entry existing_site_entry "${stack_dir}" || true + + site_action="$(show_manage_stack_site_menu "${stack_name}" "${stack_dir}" "${site_status_label}" "${existing_site_entry}" || true)" + case "${site_action}" in + "Create new site") + if ! prompt_manage_stack_site_name_with_cancel site_name "${stack_name}" "${stack_dir}"; then + continue + fi + + if ! prompt_manage_stack_site_admin_password_with_cancel admin_password "${stack_name}"; then + continue + fi + + show_warning_message "Creating the first site for stack: ${stack_name}" + if bootstrap_first_stack_site "${stack_dir}" "${site_name}" "${admin_password}"; then + show_warning_and_wait "Site created successfully and selected stack apps were installed: ${site_name}" 3 + continue + else + site_flow_status=$? + fi + + case "${site_flow_status}" in + 51) + show_warning_and_wait "Cannot manage site: backend service is not running yet. Start the stack first." 4 + ;; + 52) + show_warning_and_wait "Cannot manage site for this topology yet. Only single-host stacks are supported." 4 + ;; + 53) + show_warning_and_wait "A site is already configured for this stack. Phase 1 supports one site per stack." 4 + ;; + 54) + show_warning_and_wait "Cannot manage site because stack metadata, env, or compose inputs are incomplete." 4 + ;; + 55) + show_warning_and_wait "Could not create the site. Check the output above for bench new-site details." 4 + ;; + 56) + show_warning_and_wait "The site was created, but app installation failed. Check the output above." 4 + ;; + 57) + show_warning_and_wait "Site bootstrap currently supports only MariaDB-backed single-host stacks." 4 + ;; + 58) + show_warning_and_wait "The site state could not be written to metadata.json." 4 + ;; + 59) + show_warning_and_wait "Cannot create site: stack services are not ready yet. Wait and try again." 4 + ;; + 60) + show_warning_and_wait "Site creation failed and automatic cleanup could not remove all partial data. Manual cleanup is required." 5 + ;; + 61) + show_warning_and_wait "Cannot create site because the site name was empty or unsafe for cleanup operations." 4 + ;; + 62) + show_warning_and_wait "Cannot prepare the stack for site creation because the bench runtime files could not be repaired." 4 + ;; + *) + show_warning_and_wait "Site bootstrap failed (${site_flow_status})." 4 + ;; + esac + ;; + "Back" | "") + return "${FLOW_CONTINUE}" + ;; + "Exit and close easy-docker") + return "${FLOW_EXIT_APP}" + ;; + *) + if [ -n "${existing_site_entry}" ] && [ "${site_action}" = "${existing_site_entry}" ]; then + 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)" + if [ -n "${existing_site_apps_lines}" ]; then + existing_site_apps_csv="$(printf '%s' "${existing_site_apps_lines}" | tr '\n' ',' | sed 's/,$//')" + else + existing_site_apps_csv="None" + fi + + existing_site_details_action="$( + show_manage_stack_site_details \ + "${stack_name}" \ + "${stack_dir}" \ + "${existing_site_name}" \ + "${site_status_label}" \ + "${existing_site_created_at}" \ + "${existing_site_apps_csv}" || true + )" + case "${existing_site_details_action}" in + "Back" | "") + continue + ;; + "Exit and close easy-docker") + return "${FLOW_EXIT_APP}" + ;; + *) + show_warning_and_wait "Unknown site details action: ${existing_site_details_action}" 2 + ;; + esac + continue + fi + + show_warning_and_wait "Unknown site action: ${site_action}" 2 + ;; + esac + done +} + handle_manage_selected_stack_flow() { local stack_name="${1}" local stack_dir="" @@ -289,6 +481,21 @@ handle_manage_selected_stack_flow() { esac done ;; + "Site") + if handle_manage_stack_site_flow "${stack_name}" "${stack_dir}"; then + : + else + compose_start_status=$? + case "${compose_start_status}" in + "${FLOW_EXIT_APP}") + return "${FLOW_EXIT_APP}" + ;; + *) + continue + ;; + esac + fi + ;; "Back" | "") return "${FLOW_CONTINUE}" ;; diff --git a/scripts/easy-docker/lib/ui/screens/production/manage.sh b/scripts/easy-docker/lib/ui/screens/production/manage.sh index 2a74884f..9bc59be4 100755 --- a/scripts/easy-docker/lib/ui/screens/production/manage.sh +++ b/scripts/easy-docker/lib/ui/screens/production/manage.sh @@ -65,12 +65,13 @@ show_manage_stack_actions_menu() { menu_header="$(printf "Stack actions | %s" "${stack_runtime_status}")" gum choose \ - --height 9 \ + --height 10 \ --header "${menu_header}" \ --cursor.foreground 63 \ --selected.foreground 45 \ "Apps" \ "Docker" \ + "Site" \ "Start stack in Docker Compose" \ "Stop stack in Docker Compose" \ "Back" \ @@ -121,6 +122,95 @@ show_manage_stack_docker_menu() { "Exit and close easy-docker" } +show_manage_stack_site_menu() { + local stack_name="${1}" + local stack_dir="${2}" + local site_status="${3:-Not configured}" + local existing_site_entry="${4:-}" + local status_text="" + + render_main_screen 1 >&2 + + status_text="$(printf "Manage stack site\n\nStack: %s\nDirectory: %s\nSite status: %s\n\nCreate a new site or select an existing site for this stack." "${stack_name}" "${stack_dir}" "${site_status}")" + render_box_message "${status_text}" "0 2" >&2 + + if [ -n "${existing_site_entry}" ]; then + gum choose \ + --height 10 \ + --header "Stack site actions" \ + --cursor.foreground 63 \ + --selected.foreground 45 \ + "Create new site" \ + "${existing_site_entry}" \ + "Back" \ + "Exit and close easy-docker" + return 0 + fi + + gum choose \ + --height 8 \ + --header "Stack site actions" \ + --cursor.foreground 63 \ + --selected.foreground 45 \ + "Create new site" \ + "Back" \ + "Exit and close easy-docker" +} + +prompt_stack_site_name() { + local stack_name="${1}" + local placeholder="${2:-}" + local status_text="" + + render_main_screen 1 >&2 + + status_text="$(printf "Manage stack site\n\nStack: %s\n\nEnter the site name for the first site.\nType /back or press Ctrl+C to cancel." "${stack_name}")" + render_box_message "${status_text}" "0 2" >&2 + + gum input \ + --header "Site name" \ + --prompt "site> " \ + --placeholder "${placeholder}" +} + +prompt_stack_site_admin_password() { + local stack_name="${1}" + local status_text="" + + render_main_screen 1 >&2 + + status_text="$(printf "Manage stack site\n\nStack: %s\n\nEnter the Administrator password for the new site.\nType /back or press Ctrl+C to cancel." "${stack_name}")" + render_box_message "${status_text}" "0 2" >&2 + + gum input \ + --header "Administrator password" \ + --prompt "password> " \ + --password +} + +show_manage_stack_site_details() { + local stack_name="${1}" + local stack_dir="${2}" + local site_name="${3}" + local site_status="${4:-Unknown}" + local created_at="${5:-}" + local installed_apps="${6:-None}" + local status_text="" + + render_main_screen 1 >&2 + + status_text="$(printf "Manage existing site\n\nStack: %s\nDirectory: %s\nSite: %s\nStatus: %s\nCreated at: %s\nInstalled apps: %s" "${stack_name}" "${stack_dir}" "${site_name}" "${site_status}" "${created_at:-n/a}" "${installed_apps}")" + render_box_message "${status_text}" "0 2" >&2 + + gum choose \ + --height 7 \ + --header "Site details" \ + --cursor.foreground 63 \ + --selected.foreground 45 \ + "Back" \ + "Exit and close easy-docker" +} + show_missing_custom_image_start_menu() { local stack_name="${1}" local stack_dir="${2}"