feat(easy-docker): add guided single-site management

This commit is contained in:
RocketQuack 2026-03-27 15:10:58 +01:00
parent bba9e70dd8
commit bfa70da36e
7 changed files with 1536 additions and 1 deletions

View file

@ -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"
}

View file

@ -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

View file

@ -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 <<EOF
${existing_lines}
EOF
if [ -z "${existing_lines}" ]; then
printf -v "${result_var}" "%s" "${app_name}"
else
printf -v "${result_var}" "%s\n%s" "${existing_lines}" "${app_name}"
fi
}
get_stack_selected_installable_apps() {
local result_var="${1}"
local stack_dir="${2}"
local metadata_path=""
local predefined_apps_csv=""
local app_name=""
local installable_app_lines=""
local deferred_erpnext=""
local ordered_app_lines=""
local -a predefined_apps=()
metadata_path="${stack_dir}/metadata.json"
if [ ! -f "${metadata_path}" ]; then
return 1
fi
predefined_apps_csv="$(get_metadata_apps_predefined_csv "${metadata_path}" || true)"
if [ -z "${predefined_apps_csv}" ]; then
printf -v "${result_var}" "%s" ""
return 0
fi
IFS=',' read -r -a predefined_apps <<<"${predefined_apps_csv}"
for app_name in "${predefined_apps[@]}"; do
if [ -z "${app_name}" ] || [ "${app_name}" = "frappe" ]; then
continue
fi
if [ "${app_name}" = "erpnext" ]; then
deferred_erpnext="${app_name}"
continue
fi
append_stack_installable_app_line installable_app_lines "${installable_app_lines}" "${app_name}"
done
if [ -n "${deferred_erpnext}" ]; then
ordered_app_lines="${deferred_erpnext}"
if [ -n "${installable_app_lines}" ]; then
ordered_app_lines="${ordered_app_lines}"$'\n'"${installable_app_lines}"
fi
else
ordered_app_lines="${installable_app_lines}"
fi
printf -v "${result_var}" "%s" "${ordered_app_lines}"
return 0
}

View file

@ -0,0 +1,795 @@
#!/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 <<EOF
${compose_files_lines}
EOF
if [ "${#compose_args[@]}" -eq 0 ]; then
return 54
fi
wrapped_backend_command="$(printf "cd /home/frappe/frappe-bench && %s" "${backend_command}")"
if [ -n "${fallback_erpnext_version}" ]; then
ERPNEXT_VERSION="${fallback_erpnext_version}" docker compose --project-name "${compose_project_name}" --env-file "${env_path}" "${compose_args[@]}" exec -T backend bash -lc "${wrapped_backend_command}"
return $?
fi
docker compose --project-name "${compose_project_name}" --env-file "${env_path}" "${compose_args[@]}" exec -T backend bash -lc "${wrapped_backend_command}"
}
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}"
}
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 <<EOF
mkdir -p sites
test -f sites/common_site_config.json || printf '{}' > 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 <<EOF
${selected_app_lines}
EOF
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." "${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
}

View file

@ -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 <<EOF
${apps_installed_lines}
EOF
if [ -z "${entries_json}" ]; then
printf -v "${result_var}" '[\n ]'
else
printf -v "${result_var}" '[\n%s\n ]' "${entries_json}"
fi
}
build_stack_site_metadata_json_object() {
local result_var="${1}"
local site_mode="${2:-single-site}"
local site_name="${3:-}"
local site_state="${4:-not_created}"
local apps_installed_lines="${5:-}"
local last_action="${6:-}"
local last_error="${7:-}"
local created_at="${8:-}"
local updated_at="${9:-}"
local apps_installed_json_array=""
build_stack_site_apps_installed_json_array apps_installed_json_array "${apps_installed_lines}"
printf -v "${result_var}" '{\n "mode": "%s",\n "name": "%s",\n "state": "%s",\n "apps_installed": %s,\n "last_action": "%s",\n "last_error": "%s",\n "created_at": "%s",\n "updated_at": "%s"\n }' \
"$(json_escape_string "${site_mode}")" \
"$(json_escape_string "${site_name}")" \
"$(json_escape_string "${site_state}")" \
"${apps_installed_json_array}" \
"$(json_escape_string "${last_action}")" \
"$(json_escape_string "${last_error}")" \
"$(json_escape_string "${created_at}")" \
"$(json_escape_string "${updated_at}")"
}
persist_stack_site_metadata() {
local stack_dir="${1}"
local site_mode="${2:-single-site}"
local site_name="${3:-}"
local site_state="${4:-not_created}"
local apps_installed_lines="${5:-}"
local last_action="${6:-}"
local last_error="${7:-}"
local created_at="${8:-}"
local updated_at="${9:-}"
local metadata_path=""
local metadata_tmp_path=""
local site_json_object=""
metadata_path="${stack_dir}/metadata.json"
metadata_tmp_path="${metadata_path}.tmp"
if [ ! -f "${metadata_path}" ]; then
return 1
fi
build_stack_site_metadata_json_object site_json_object "${site_mode}" "${site_name}" "${site_state}" "${apps_installed_lines}" "${last_action}" "${last_error}" "${created_at}" "${updated_at}"
if ! awk -v site_object="${site_json_object}" '
BEGIN {
in_site = 0
inserted = 0
site_depth = 0
prev = ""
}
function flush_prev() {
if (prev != "") {
print prev
prev = ""
}
}
{
if (!in_site && $0 ~ /^ "site"[[:space:]]*:[[:space:]]*{/) {
in_site = 1
site_depth = 1
next
}
if (in_site) {
line = $0
open_count = gsub(/{/, "{", line)
close_count = gsub(/}/, "}", line)
site_depth += open_count - close_count
if (site_depth <= 0) {
in_site = 0
}
next
}
if (!inserted && $0 ~ /^}$/) {
if (prev != "") {
if (prev ~ /,$/) {
print prev
} else {
print prev ","
}
prev = ""
}
print " \"site\": " site_object
print "}"
inserted = 1
next
}
flush_prev()
prev = $0
}
END {
if (!inserted) {
if (prev != "") {
if (prev ~ /,$/) {
print prev
} else {
print prev ","
}
}
print " \"site\": " site_object
print "}"
} else if (prev != "") {
print prev
}
}
' "${metadata_path}" >"${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}"
}

View file

@ -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}"
;;

View file

@ -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}"