#!/usr/bin/env bash is_valid_stack_site_name() { local site_name="${1}" if [ -z "${site_name}" ]; then return 1 fi case "${site_name}" in *[!A-Za-z0-9._-]*) return 1 ;; *) return 0 ;; esac } is_safe_stack_site_cleanup_name() { local site_name="${1}" if ! is_valid_stack_site_name "${site_name}"; then return 1 fi case "${site_name}" in "." | ".." | "/" | "") return 1 ;; *) return 0 ;; esac } shell_quote_site_command_arg() { local raw_value="${1}" printf "'%s'" "$(printf '%s' "${raw_value}" | sed "s/'/'\"'\"'/g")" } get_stack_primary_site_name_suggestion() { local stack_dir="${1}" local env_path="" local site_domains="" local primary_domain="" env_path="$(get_stack_env_path "${stack_dir}")" site_domains="$(get_env_file_key_value "${env_path}" "SITE_DOMAINS" || true)" primary_domain="${site_domains%%,*}" primary_domain="${primary_domain%% *}" if [ -n "${primary_domain}" ]; then printf '%s\n' "${primary_domain}" return 0 fi printf '%s.localhost\n' "${stack_dir##*/}" return 0 } get_stack_database_id() { local stack_dir="${1}" get_metadata_string_field "${stack_dir}/metadata.json" "database_id" } get_stack_redis_id() { local stack_dir="${1}" get_metadata_string_field "${stack_dir}/metadata.json" "redis_id" } get_stack_database_root_password() { local stack_dir="${1}" local env_path="" local db_password="" env_path="$(get_stack_env_path "${stack_dir}")" db_password="$(get_env_file_key_value "${env_path}" "DB_PASSWORD" || true)" if [ -z "${db_password}" ]; then db_password="123" fi printf '%s\n' "${db_password}" return 0 } stack_site_bootstrap_supports_database() { local stack_dir="${1}" local database_id="" database_id="$(get_stack_database_id "${stack_dir}" || true)" case "${database_id}" in mariadb) return 0 ;; *) return 1 ;; esac } stack_supports_single_site_management() { local stack_dir="${1}" local stack_topology="" stack_topology="$(get_stack_topology "${stack_dir}" || true)" case "${stack_topology}" in single-host) return 0 ;; *) return 1 ;; esac } run_stack_backend_bash_command() { local stack_dir="${1}" local backend_command="${2}" local wrapped_backend_command="" local metadata_path="" local env_path="" local compose_files_lines="" local compose_file="" local source_compose_path="" local env_erpnext_version="" local fallback_erpnext_version="" local compose_project_name="" local stack_topology="" local repo_root="" local -a compose_args=() metadata_path="${stack_dir}/metadata.json" env_path="$(get_stack_env_path "${stack_dir}")" compose_project_name="$(get_stack_compose_project_name "${stack_dir}")" if [ ! -f "${metadata_path}" ]; then return 54 fi if [ ! -f "${env_path}" ]; then return 54 fi stack_topology="$(get_stack_topology "${stack_dir}" || true)" if [ -z "${stack_topology}" ]; then return 54 fi case "${stack_topology}" in single-host) ;; *) return 52 ;; esac 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 54 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 54 fi compose_args+=(-f "${source_compose_path}") done <"${absolute_path}"; then return 1 fi printf -v "${result_var}" "%s" "${relative_path}" return 0 } run_stack_backend_bash_command_capture() { local result_var="${1}" local stack_dir="${2}" local backend_command="${3}" local command_output="" local command_status=0 reset_easy_docker_site_error_state command_output="$(run_stack_backend_bash_command "${stack_dir}" "${backend_command}" 2>&1)" command_status=$? if [ -n "${command_output}" ]; then printf '%s\n' "${command_output}" fi printf -v "${result_var}" "%s" "${command_output}" return "${command_status}" } capture_stack_site_error_log() { local stack_dir="${1}" local action_name="${2:-site-error}" local error_output="${3:-}" local log_path="" EASY_DOCKER_SITE_ERROR_LOG_PATH="" if [ -z "${error_output}" ]; then return 0 fi if ! write_stack_site_error_log log_path "${stack_dir}" "${action_name}" "${error_output}"; then EASY_DOCKER_SITE_ERROR_DETAIL="${EASY_DOCKER_SITE_ERROR_DETAIL:-Failed to write site error log.}" return 1 fi EASY_DOCKER_SITE_ERROR_LOG_PATH="${log_path}" return 0 } stack_backend_service_is_running() { local stack_dir="${1}" local backend_ready_status=0 if run_stack_backend_bash_command "${stack_dir}" "true" >/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}" } get_stack_site_runtime_app_names_lines() { local stack_dir="${1}" local site_name="${2}" local list_apps_command="" if ! is_safe_stack_site_cleanup_name "${site_name}"; then return 61 fi list_apps_command="$( printf "bench --site %s list-apps | awk 'NF { print \$1 }'" \ "$(shell_quote_site_command_arg "${site_name}")" )" run_stack_backend_bash_command "${stack_dir}" "${list_apps_command}" } get_stack_runtime_available_app_lines() { local stack_dir="${1}" run_stack_backend_bash_command "${stack_dir}" "ls -1 apps" } get_stack_site_runtime_selected_apps_lines() { local result_var="${1}" local stack_dir="${2}" local site_name="${3}" local selected_app_lines="" local runtime_app_lines="" local selected_app_name="" local installed_app_lines="" if ! get_stack_selected_installable_apps selected_app_lines "${stack_dir}"; then printf -v "${result_var}" "%s" "" return 0 fi if [ -z "${selected_app_lines}" ]; then printf -v "${result_var}" "%s" "" return 0 fi runtime_app_lines="$(get_stack_site_runtime_app_names_lines "${stack_dir}" "${site_name}" || true)" if [ -z "${runtime_app_lines}" ]; then printf -v "${result_var}" "%s" "" return 1 fi while IFS= read -r selected_app_name; do if [ -z "${selected_app_name}" ]; then continue fi if ! printf '%s\n' "${runtime_app_lines}" | grep -F -x -- "${selected_app_name}" >/dev/null 2>&1; then continue fi if [ -z "${installed_app_lines}" ]; then installed_app_lines="${selected_app_name}" else installed_app_lines="${installed_app_lines}"$'\n'"${selected_app_name}" fi done </dev/null 2>&1 || true 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="" local install_app_output="" local available_app_lines="" local -a selected_apps=() if ! get_stack_selected_installable_apps selected_app_lines "${stack_dir}"; then printf -v "${result_var}" "%s" "" return 0 fi if [ -z "${selected_app_lines}" ]; then printf -v "${result_var}" "%s" "" return 0 fi available_app_lines="$(get_stack_runtime_available_app_lines "${stack_dir}" || true)" if [ -z "${available_app_lines}" ]; then EASY_DOCKER_SITE_ERROR_DETAIL="Could not inspect available apps in the backend image." capture_stack_site_error_log "${stack_dir}" "site-install-apps-error" "easy-docker could not list /home/frappe/frappe-bench/apps before install-app." >/dev/null 2>&1 || true return 63 fi mapfile -t selected_apps <<<"${selected_app_lines}" for app_name in "${selected_apps[@]}"; do if [ -z "${app_name}" ]; then continue fi if ! printf '%s\n' "${available_app_lines}" | grep -F -x -- "${app_name}" >/dev/null 2>&1; then EASY_DOCKER_SITE_ERROR_DETAIL="$(printf "Selected app '%s' is not available in the backend image." "${app_name}")" capture_stack_site_error_log "${stack_dir}" "site-install-apps-error" "$(printf "Selected app '%s' was requested in stack metadata but is missing from /home/frappe/frappe-bench/apps.\nAvailable apps:\n%s" "${app_name}" "${available_app_lines}")" >/dev/null 2>&1 || true if [ -n "${EASY_DOCKER_SITE_ERROR_LOG_PATH}" ]; then printf 'Details written to %s\n' "${stack_dir}/${EASY_DOCKER_SITE_ERROR_LOG_PATH}" >&2 fi printf -v "${result_var}" "%s" "${installed_app_lines}" return 63 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_capture install_app_output "${stack_dir}" "${install_app_command}"; then EASY_DOCKER_SITE_ERROR_DETAIL="$(printf "bench install-app failed for '%s'." "${app_name}")" capture_stack_site_error_log "${stack_dir}" "site-install-apps-error" "${install_app_output}" >/dev/null 2>&1 || true 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 printf -v "${result_var}" "%s" "${installed_app_lines}" return 0 } bootstrap_first_stack_site() { local stack_dir="${1}" local site_name="${2}" local admin_password="${3}" local created_at="" local updated_at="" local installed_app_lines="" local site_create_status=0 local app_install_status=0 local cleanup_status=0 if ! is_safe_stack_site_cleanup_name "${site_name}"; then return 61 fi if ! stack_supports_single_site_management "${stack_dir}"; then return 52 fi if ! stack_site_bootstrap_supports_database "${stack_dir}"; then return 57 fi if stack_has_site_configured "${stack_dir}"; then return 53 fi if ! stack_backend_service_is_running "${stack_dir}"; then return 51 fi if ! repair_stack_site_runtime_state "${stack_dir}"; then return $? fi if ! stack_database_service_is_reachable "${stack_dir}"; then return 59 fi created_at="$(get_current_utc_timestamp)" updated_at="${created_at}" if ! persist_stack_site_metadata "${stack_dir}" "single-site" "${site_name}" "requested" "" "create-site" "" "" "${created_at}" "${updated_at}"; then return 58 fi if cleanup_partial_stack_site "${stack_dir}" "${site_name}"; then : else cleanup_status=$? case "${cleanup_status}" in 54 | 52) return "${cleanup_status}" ;; 60) mark_stack_site_failed "${stack_dir}" "${site_name}" "" "cleanup-partial-site" "Partial site artifacts could not be removed automatically. Manual cleanup is required." "" "" >/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." "${EASY_DOCKER_SITE_ERROR_LOG_PATH}" "${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." "${EASY_DOCKER_SITE_ERROR_LOG_PATH}" "${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 | 63) if cleanup_partial_stack_site "${stack_dir}" "${site_name}"; then mark_stack_site_failed "${stack_dir}" "${site_name}" "${installed_app_lines}" "install-apps" "${EASY_DOCKER_SITE_ERROR_DETAIL:-App installation failed. Partial site data was cleaned up automatically.}" "${EASY_DOCKER_SITE_ERROR_LOG_PATH}" "${created_at}" >/dev/null 2>&1 || true else cleanup_status=$? mark_stack_site_failed "${stack_dir}" "${site_name}" "${installed_app_lines}" "install-apps" "${EASY_DOCKER_SITE_ERROR_DETAIL:-App installation failed and partial site data could not be cleaned up automatically. Manual cleanup is required.}" "${EASY_DOCKER_SITE_ERROR_LOG_PATH}" "${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." "${EASY_DOCKER_SITE_ERROR_LOG_PATH}" "${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 }