From 61890d94469943c490f4b1d65a57edcc35918d00 Mon Sep 17 00:00:00 2001 From: RocketQuack <202538874+Rocket-Quack@users.noreply.github.com> Date: Tue, 14 Apr 2026 11:22:14 +0200 Subject: [PATCH] feat(easy-docker): add split-services stack workflow --- scripts/easy-docker/lib/app/wizard/common.sh | 2 + .../common/compose/runtime/lifecycle.sh | 6 +- .../wizard/common/compose/runtime/shared.sh | 4 +- .../wizard/common/compose/runtime/status.sh | 4 +- .../app/wizard/common/compose/start/delete.sh | 2 +- .../app/wizard/common/compose/start/start.sh | 2 +- .../app/wizard/common/compose/start/status.sh | 4 +- .../app/wizard/common/compose/start/stop.sh | 2 +- .../wizard/common/site/bootstrap/runtime.sh | 2 +- scripts/easy-docker/lib/app/wizard/env.sh | 2 + .../easy-docker/lib/app/wizard/env/apps.sh | 12 +- .../easy-docker/lib/app/wizard/env/collect.sh | 103 ++++-- .../lib/app/wizard/env/split_services.sh | 319 ++++++++++++++++++ .../lib/app/wizard/env/validation.sh | 80 +++++ scripts/easy-docker/lib/app/wizard/flows.sh | 2 + .../lib/app/wizard/flows/navigation.sh | 34 +- .../lib/app/wizard/flows/single_host.sh | 29 -- .../lib/app/wizard/flows/split_services.sh | 130 +++++++ .../easy-docker/lib/app/wizard/single_host.sh | 2 +- .../lib/app/wizard/split_services.sh | 164 +++++++++ .../easy-docker/lib/ui/screens/production.sh | 2 + .../lib/ui/screens/production/manage.sh | 33 +- .../ui/screens/production/split_services.sh | 115 +++++++ .../lib/ui/screens/production/topology.sh | 33 +- 24 files changed, 969 insertions(+), 119 deletions(-) create mode 100755 scripts/easy-docker/lib/app/wizard/env/split_services.sh create mode 100755 scripts/easy-docker/lib/app/wizard/flows/split_services.sh create mode 100755 scripts/easy-docker/lib/app/wizard/split_services.sh create mode 100755 scripts/easy-docker/lib/ui/screens/production/split_services.sh diff --git a/scripts/easy-docker/lib/app/wizard/common.sh b/scripts/easy-docker/lib/app/wizard/common.sh index dfb28c3a..502fb4dd 100755 --- a/scripts/easy-docker/lib/app/wizard/common.sh +++ b/scripts/easy-docker/lib/app/wizard/common.sh @@ -20,6 +20,8 @@ load_easy_docker_wizard_common_modules() { source "${wizard_dir}/common/site.sh" # shellcheck source=scripts/easy-docker/lib/app/wizard/common/ux.sh source "${wizard_dir}/common/ux.sh" + # shellcheck source=scripts/easy-docker/lib/app/wizard/split_services.sh + source "${wizard_dir}/split_services.sh" } load_easy_docker_wizard_common_modules diff --git a/scripts/easy-docker/lib/app/wizard/common/compose/runtime/lifecycle.sh b/scripts/easy-docker/lib/app/wizard/common/compose/runtime/lifecycle.sh index 5b246c90..c1aa6643 100755 --- a/scripts/easy-docker/lib/app/wizard/common/compose/runtime/lifecycle.sh +++ b/scripts/easy-docker/lib/app/wizard/common/compose/runtime/lifecycle.sh @@ -27,7 +27,7 @@ start_stack_with_compose_from_metadata() { return 32 fi - if ! easy_docker_compose_require_single_host_topology "${stack_dir}" 33 34; then + if ! easy_docker_compose_require_supported_topology "${stack_dir}" 33 34; then return $? fi @@ -105,7 +105,7 @@ stop_stack_with_compose_from_metadata() { return 42 fi - if ! easy_docker_compose_require_single_host_topology "${stack_dir}" 43 44; then + if ! easy_docker_compose_require_supported_topology "${stack_dir}" 43 44; then return $? fi @@ -150,7 +150,7 @@ delete_stack_with_compose_from_metadata() { return 49 fi - if ! easy_docker_compose_require_single_host_topology "${stack_dir}" 50 51; then + if ! easy_docker_compose_require_supported_topology "${stack_dir}" 50 51; then return $? fi diff --git a/scripts/easy-docker/lib/app/wizard/common/compose/runtime/shared.sh b/scripts/easy-docker/lib/app/wizard/common/compose/runtime/shared.sh index 1d62093a..5799614d 100755 --- a/scripts/easy-docker/lib/app/wizard/common/compose/runtime/shared.sh +++ b/scripts/easy-docker/lib/app/wizard/common/compose/runtime/shared.sh @@ -32,7 +32,7 @@ easy_docker_compose_get_fallback_erpnext_version() { printf -v "${result_var}" "%s" "${fallback_erpnext_version}" } -easy_docker_compose_require_single_host_topology() { +easy_docker_compose_require_supported_topology() { local stack_dir="${1}" local missing_topology_code="${2}" local unsupported_topology_code="${3}" @@ -46,7 +46,7 @@ easy_docker_compose_require_single_host_topology() { fi case "${stack_topology}" in - "single-host") + "single-host" | "split-services") return 0 ;; *) diff --git a/scripts/easy-docker/lib/app/wizard/common/compose/runtime/status.sh b/scripts/easy-docker/lib/app/wizard/common/compose/runtime/status.sh index 7d79adc7..9bd33d57 100755 --- a/scripts/easy-docker/lib/app/wizard/common/compose/runtime/status.sh +++ b/scripts/easy-docker/lib/app/wizard/common/compose/runtime/status.sh @@ -48,9 +48,9 @@ get_stack_compose_runtime_status_label() { fi case "${stack_topology}" in - "single-host") ;; + "single-host" | "split-services") ;; *) - printf -v "${result_var}" "%s" "N/A (${stack_topology})" + printf -v "${result_var}" "%s" "Unsupported (${stack_topology})" return 0 ;; esac diff --git a/scripts/easy-docker/lib/app/wizard/common/compose/start/delete.sh b/scripts/easy-docker/lib/app/wizard/common/compose/start/delete.sh index cf4b7385..14b2a53f 100755 --- a/scripts/easy-docker/lib/app/wizard/common/compose/start/delete.sh +++ b/scripts/easy-docker/lib/app/wizard/common/compose/start/delete.sh @@ -61,7 +61,7 @@ delete_stack_with_compose_from_metadata() { fi case "${stack_topology}" in - "single-host") ;; + "single-host" | "split-services") ;; *) EASY_DOCKER_COMPOSE_ERROR_DETAIL="${stack_topology}" return 51 diff --git a/scripts/easy-docker/lib/app/wizard/common/compose/start/start.sh b/scripts/easy-docker/lib/app/wizard/common/compose/start/start.sh index a04e4b55..004143f4 100755 --- a/scripts/easy-docker/lib/app/wizard/common/compose/start/start.sh +++ b/scripts/easy-docker/lib/app/wizard/common/compose/start/start.sh @@ -43,7 +43,7 @@ start_stack_with_compose_from_metadata() { fi case "${stack_topology}" in - "single-host") ;; + "single-host" | "split-services") ;; *) # shellcheck disable=SC2034 # Read by manage flow after start_stack_with_compose_from_metadata returns 34. EASY_DOCKER_COMPOSE_ERROR_DETAIL="${stack_topology}" diff --git a/scripts/easy-docker/lib/app/wizard/common/compose/start/status.sh b/scripts/easy-docker/lib/app/wizard/common/compose/start/status.sh index 758642b7..27e04e3d 100755 --- a/scripts/easy-docker/lib/app/wizard/common/compose/start/status.sh +++ b/scripts/easy-docker/lib/app/wizard/common/compose/start/status.sh @@ -51,9 +51,9 @@ get_stack_compose_runtime_status_label() { fi case "${stack_topology}" in - "single-host") ;; + "single-host" | "split-services") ;; *) - printf -v "${result_var}" "%s" "N/A (${stack_topology})" + printf -v "${result_var}" "%s" "Unsupported (${stack_topology})" return 0 ;; esac diff --git a/scripts/easy-docker/lib/app/wizard/common/compose/start/stop.sh b/scripts/easy-docker/lib/app/wizard/common/compose/start/stop.sh index b4ce5aa3..a609b266 100755 --- a/scripts/easy-docker/lib/app/wizard/common/compose/start/stop.sh +++ b/scripts/easy-docker/lib/app/wizard/common/compose/start/stop.sh @@ -37,7 +37,7 @@ stop_stack_with_compose_from_metadata() { fi case "${stack_topology}" in - "single-host") ;; + "single-host" | "split-services") ;; *) # shellcheck disable=SC2034 # Read by manage flow after stop_stack_with_compose_from_metadata returns 44. EASY_DOCKER_COMPOSE_ERROR_DETAIL="${stack_topology}" diff --git a/scripts/easy-docker/lib/app/wizard/common/site/bootstrap/runtime.sh b/scripts/easy-docker/lib/app/wizard/common/site/bootstrap/runtime.sh index 59315fd6..501eefe8 100755 --- a/scripts/easy-docker/lib/app/wizard/common/site/bootstrap/runtime.sh +++ b/scripts/easy-docker/lib/app/wizard/common/site/bootstrap/runtime.sh @@ -34,7 +34,7 @@ run_stack_backend_bash_command() { fi case "${stack_topology}" in - single-host) ;; + single-host | split-services) ;; *) return 52 ;; diff --git a/scripts/easy-docker/lib/app/wizard/env.sh b/scripts/easy-docker/lib/app/wizard/env.sh index 305dbfe2..c7bb2eb4 100755 --- a/scripts/easy-docker/lib/app/wizard/env.sh +++ b/scripts/easy-docker/lib/app/wizard/env.sh @@ -12,6 +12,8 @@ load_easy_docker_wizard_env_modules() { source "${wizard_dir}/env/update.sh" # shellcheck source=scripts/easy-docker/lib/app/wizard/env/collect.sh source "${wizard_dir}/env/collect.sh" + # shellcheck source=scripts/easy-docker/lib/app/wizard/env/split_services.sh + source "${wizard_dir}/env/split_services.sh" } load_easy_docker_wizard_env_modules diff --git a/scripts/easy-docker/lib/app/wizard/env/apps.sh b/scripts/easy-docker/lib/app/wizard/env/apps.sh index 30f50e1c..ea357578 100755 --- a/scripts/easy-docker/lib/app/wizard/env/apps.sh +++ b/scripts/easy-docker/lib/app/wizard/env/apps.sh @@ -249,7 +249,7 @@ prompt_custom_modular_apps_data() { local existing_branch_lines="" local selected_branch_lines="" local selected_app_count=0 - local built_apps_metadata_json_object="" + local assembled_apps_metadata_json_object="" local -a predefined_catalog_entries=() local -a selected_predefined_ids=() @@ -393,8 +393,8 @@ EOF continue fi - build_predefined_apps_metadata_json_object built_apps_metadata_json_object "${selected_predefined_csv}" "${selected_branch_lines}" - printf -v "${result_apps_metadata_var}" "%s" "${built_apps_metadata_json_object}" + build_predefined_apps_metadata_json_object assembled_apps_metadata_json_object "${selected_predefined_csv}" "${selected_branch_lines}" + printf -v "${result_apps_metadata_var}" "%s" "${assembled_apps_metadata_json_object}" return 0 done } @@ -446,7 +446,7 @@ prompt_selected_stack_app_branches_data() { local existing_branch_lines="" local selected_branch_lines="" local selected_app_count=0 - local built_apps_metadata_json_object="" + local assembled_apps_metadata_json_object="" local prompt_status=0 local -a selected_predefined_ids=() @@ -515,8 +515,8 @@ prompt_selected_stack_app_branches_data() { return 4 fi - build_predefined_apps_metadata_json_object built_apps_metadata_json_object "${selected_predefined_csv}" "${selected_branch_lines}" - printf -v "${result_apps_metadata_var}" "%s" "${built_apps_metadata_json_object}" + build_predefined_apps_metadata_json_object assembled_apps_metadata_json_object "${selected_predefined_csv}" "${selected_branch_lines}" + printf -v "${result_apps_metadata_var}" "%s" "${assembled_apps_metadata_json_object}" return 0 } diff --git a/scripts/easy-docker/lib/app/wizard/env/collect.sh b/scripts/easy-docker/lib/app/wizard/env/collect.sh index 48b46323..a881fdb8 100755 --- a/scripts/easy-docker/lib/app/wizard/env/collect.sh +++ b/scripts/easy-docker/lib/app/wizard/env/collect.sh @@ -1,21 +1,13 @@ #!/usr/bin/env bash -collect_single_host_env_lines() { +collect_stack_image_and_apps_env_lines() { local result_env_var="${1}" local result_apps_metadata_var="${2}" local stack_dir="${3}" - local proxy_mode_id="${4}" - local database_id="${5}" - local collected_env_lines="" - local value="" - local domains_value="" - local domain_lines="" - local site_domains_value="" + local built_env_lines="" local custom_image_value="" local custom_tag_value="" - local selected_apps_metadata_json_object="" - local sites_rule_value="" - local nginx_proxy_hosts_value="" + local built_apps_metadata_json_object="" local prompt_status=0 if prompt_env_value_with_validation custom_image_value "${stack_dir}" "CUSTOM_IMAGE" "Required for custom modular image mode.\nExample: ghcr.io/acme/frappe-custom\nType /back to return." "ghcr.io/acme/frappe-custom" "required" "none"; then @@ -24,7 +16,7 @@ collect_single_host_env_lines() { prompt_status=$? return "${prompt_status}" fi - collected_env_lines="$(append_env_line "${collected_env_lines}" "CUSTOM_IMAGE" "${custom_image_value}")" + built_env_lines="$(append_env_line "${built_env_lines}" "CUSTOM_IMAGE" "${custom_image_value}")" if prompt_env_value_with_validation custom_tag_value "${stack_dir}" "CUSTOM_TAG" "Required for custom modular image mode.\nExample: v1.0.0\nType /back to return." "v1.0.0" "required" "none"; then : @@ -32,19 +24,48 @@ collect_single_host_env_lines() { prompt_status=$? return "${prompt_status}" fi - collected_env_lines="$(append_env_line "${collected_env_lines}" "CUSTOM_TAG" "${custom_tag_value}")" + built_env_lines="$(append_env_line "${built_env_lines}" "CUSTOM_TAG" "${custom_tag_value}")" - if prompt_custom_modular_apps_data selected_apps_metadata_json_object "${stack_dir}"; then + if prompt_custom_modular_apps_data built_apps_metadata_json_object "${stack_dir}"; then : else prompt_status=$? return "${prompt_status}" fi - if [ -z "${selected_apps_metadata_json_object}" ]; then + if [ -z "${built_apps_metadata_json_object}" ]; then return 1 fi + printf -v "${result_env_var}" "%s" "${built_env_lines}" + printf -v "${result_apps_metadata_var}" "%s" "${built_apps_metadata_json_object}" + return 0 +} + +collect_single_host_env_lines() { + local result_env_var="${1}" + local result_apps_metadata_var="${2}" + local stack_dir="${3}" + local proxy_mode_id="${4}" + local database_id="${5}" + local redis_id="${6}" + local collected_single_host_env_lines="" + local collected_single_host_apps_metadata_json_object="" + local value="" + local domains_value="" + local domain_lines="" + local site_domains_value="" + local sites_rule_value="" + local nginx_proxy_hosts_value="" + local prompt_status=0 + + if collect_stack_image_and_apps_env_lines collected_single_host_env_lines collected_single_host_apps_metadata_json_object "${stack_dir}"; then + : + else + prompt_status=$? + return "${prompt_status}" + fi + case "${proxy_mode_id}" in traefik-https) if prompt_env_value_with_validation domains_value "${stack_dir}" "SITE_DOMAINS" "Required for Traefik HTTPS routing.\nUse hostnames like example.com, app.example.com, localhost, or dev.localhost.\nEnter multiple domains separated by comma or space.\nLet's Encrypt still requires a public DNS name.\nType /back to return." "erp.example.com dev.localhost" "required" "domains"; then @@ -60,10 +81,10 @@ collect_single_host_env_lines() { fi site_domains_value="$(domain_lines_to_csv "${domain_lines}")" - collected_env_lines="$(append_env_line "${collected_env_lines}" "SITE_DOMAINS" "${site_domains_value}")" + collected_single_host_env_lines="$(append_env_line "${collected_single_host_env_lines}" "SITE_DOMAINS" "${site_domains_value}")" sites_rule_value="$(domain_lines_to_sites_rule "${domain_lines}")" - collected_env_lines="$(append_env_line "${collected_env_lines}" "SITES_RULE" "${sites_rule_value}")" + collected_single_host_env_lines="$(append_env_line "${collected_single_host_env_lines}" "SITES_RULE" "${sites_rule_value}")" if prompt_env_value_with_validation value "${stack_dir}" "LETSENCRYPT_EMAIL" "Required for Let's Encrypt certificate registration.\nType /back to return." "admin@example.com" "required" "email"; then : @@ -71,7 +92,7 @@ collect_single_host_env_lines() { prompt_status=$? return "${prompt_status}" fi - collected_env_lines="$(append_env_line "${collected_env_lines}" "LETSENCRYPT_EMAIL" "${value}")" + collected_single_host_env_lines="$(append_env_line "${collected_single_host_env_lines}" "LETSENCRYPT_EMAIL" "${value}")" if prompt_env_value_with_validation value "${stack_dir}" "HTTP_PUBLISH_PORT" "Optional. Press Enter to keep default 80.\nType /back to return." "80" "optional" "port"; then : @@ -80,7 +101,7 @@ collect_single_host_env_lines() { return "${prompt_status}" fi if [ -n "${value}" ]; then - collected_env_lines="$(append_env_line "${collected_env_lines}" "HTTP_PUBLISH_PORT" "${value}")" + collected_single_host_env_lines="$(append_env_line "${collected_single_host_env_lines}" "HTTP_PUBLISH_PORT" "${value}")" fi if prompt_env_value_with_validation value "${stack_dir}" "HTTPS_PUBLISH_PORT" "Optional. Press Enter to keep default 443.\nType /back to return." "443" "optional" "port"; then @@ -90,7 +111,7 @@ collect_single_host_env_lines() { return "${prompt_status}" fi if [ -n "${value}" ]; then - collected_env_lines="$(append_env_line "${collected_env_lines}" "HTTPS_PUBLISH_PORT" "${value}")" + collected_single_host_env_lines="$(append_env_line "${collected_single_host_env_lines}" "HTTPS_PUBLISH_PORT" "${value}")" fi ;; nginxproxy-https) @@ -107,10 +128,10 @@ collect_single_host_env_lines() { fi site_domains_value="$(domain_lines_to_csv "${domain_lines}")" - collected_env_lines="$(append_env_line "${collected_env_lines}" "SITE_DOMAINS" "${site_domains_value}")" + collected_single_host_env_lines="$(append_env_line "${collected_single_host_env_lines}" "SITE_DOMAINS" "${site_domains_value}")" nginx_proxy_hosts_value="$(domain_lines_to_csv "${domain_lines}")" - collected_env_lines="$(append_env_line "${collected_env_lines}" "NGINX_PROXY_HOSTS" "${nginx_proxy_hosts_value}")" + collected_single_host_env_lines="$(append_env_line "${collected_single_host_env_lines}" "NGINX_PROXY_HOSTS" "${nginx_proxy_hosts_value}")" if prompt_env_value_with_validation value "${stack_dir}" "LETSENCRYPT_EMAIL" "Required for Let's Encrypt certificate registration.\nType /back to return." "admin@example.com" "required" "email"; then : @@ -118,7 +139,7 @@ collect_single_host_env_lines() { prompt_status=$? return "${prompt_status}" fi - collected_env_lines="$(append_env_line "${collected_env_lines}" "LETSENCRYPT_EMAIL" "${value}")" + collected_single_host_env_lines="$(append_env_line "${collected_single_host_env_lines}" "LETSENCRYPT_EMAIL" "${value}")" if prompt_env_value_with_validation value "${stack_dir}" "HTTP_PUBLISH_PORT" "Optional. Press Enter to keep default 80.\nType /back to return." "80" "optional" "port"; then : @@ -127,7 +148,7 @@ collect_single_host_env_lines() { return "${prompt_status}" fi if [ -n "${value}" ]; then - collected_env_lines="$(append_env_line "${collected_env_lines}" "HTTP_PUBLISH_PORT" "${value}")" + collected_single_host_env_lines="$(append_env_line "${collected_single_host_env_lines}" "HTTP_PUBLISH_PORT" "${value}")" fi if prompt_env_value_with_validation value "${stack_dir}" "HTTPS_PUBLISH_PORT" "Optional. Press Enter to keep default 443.\nType /back to return." "443" "optional" "port"; then @@ -137,7 +158,7 @@ collect_single_host_env_lines() { return "${prompt_status}" fi if [ -n "${value}" ]; then - collected_env_lines="$(append_env_line "${collected_env_lines}" "HTTPS_PUBLISH_PORT" "${value}")" + collected_single_host_env_lines="$(append_env_line "${collected_single_host_env_lines}" "HTTPS_PUBLISH_PORT" "${value}")" fi ;; nginxproxy-http) @@ -154,10 +175,10 @@ collect_single_host_env_lines() { fi site_domains_value="$(domain_lines_to_csv "${domain_lines}")" - collected_env_lines="$(append_env_line "${collected_env_lines}" "SITE_DOMAINS" "${site_domains_value}")" + collected_single_host_env_lines="$(append_env_line "${collected_single_host_env_lines}" "SITE_DOMAINS" "${site_domains_value}")" nginx_proxy_hosts_value="$(domain_lines_to_csv "${domain_lines}")" - collected_env_lines="$(append_env_line "${collected_env_lines}" "NGINX_PROXY_HOSTS" "${nginx_proxy_hosts_value}")" + collected_single_host_env_lines="$(append_env_line "${collected_single_host_env_lines}" "NGINX_PROXY_HOSTS" "${nginx_proxy_hosts_value}")" if prompt_env_value_with_validation value "${stack_dir}" "HTTP_PUBLISH_PORT" "Optional. Press Enter to keep default 80.\nType /back to return." "80" "optional" "port"; then : @@ -166,7 +187,7 @@ collect_single_host_env_lines() { return "${prompt_status}" fi if [ -n "${value}" ]; then - collected_env_lines="$(append_env_line "${collected_env_lines}" "HTTP_PUBLISH_PORT" "${value}")" + collected_single_host_env_lines="$(append_env_line "${collected_single_host_env_lines}" "HTTP_PUBLISH_PORT" "${value}")" fi ;; traefik-http) @@ -177,7 +198,7 @@ collect_single_host_env_lines() { return "${prompt_status}" fi if [ -n "${value}" ]; then - collected_env_lines="$(append_env_line "${collected_env_lines}" "HTTP_PUBLISH_PORT" "${value}")" + collected_single_host_env_lines="$(append_env_line "${collected_single_host_env_lines}" "HTTP_PUBLISH_PORT" "${value}")" fi ;; caddy-external | no-proxy) @@ -188,7 +209,7 @@ collect_single_host_env_lines() { return "${prompt_status}" fi if [ -n "${value}" ]; then - collected_env_lines="$(append_env_line "${collected_env_lines}" "HTTP_PUBLISH_PORT" "${value}")" + collected_single_host_env_lines="$(append_env_line "${collected_single_host_env_lines}" "HTTP_PUBLISH_PORT" "${value}")" fi ;; *) @@ -205,7 +226,7 @@ collect_single_host_env_lines() { prompt_status=$? return "${prompt_status}" fi - collected_env_lines="$(append_env_line "${collected_env_lines}" "DB_PASSWORD" "${value}")" + collected_single_host_env_lines="$(append_env_line "${collected_single_host_env_lines}" "DB_PASSWORD" "${value}")" ;; mariadb) if prompt_env_value_with_validation value "${stack_dir}" "DB_PASSWORD" "Optional but recommended for MariaDB.\nPress Enter to use default from override.\nType /back to return." "changeit" "optional" "none"; then @@ -215,7 +236,7 @@ collect_single_host_env_lines() { return "${prompt_status}" fi if [ -n "${value}" ]; then - collected_env_lines="$(append_env_line "${collected_env_lines}" "DB_PASSWORD" "${value}")" + collected_single_host_env_lines="$(append_env_line "${collected_single_host_env_lines}" "DB_PASSWORD" "${value}")" fi ;; *) @@ -224,7 +245,21 @@ collect_single_host_env_lines() { ;; esac - printf -v "${result_env_var}" "%s" "${collected_env_lines}" - printf -v "${result_apps_metadata_var}" "%s" "${selected_apps_metadata_json_object}" + case "${redis_id}" in + enabled) + collected_single_host_env_lines="$(append_env_line "${collected_single_host_env_lines}" "REDIS_CACHE" "redis-cache:6379")" + collected_single_host_env_lines="$(append_env_line "${collected_single_host_env_lines}" "REDIS_QUEUE" "redis-queue:6379")" + ;; + disabled | "") + : + ;; + *) + show_warning_and_wait "Unknown Redis id: ${redis_id}" 2 + return 1 + ;; + esac + + printf -v "${result_env_var}" "%s" "${collected_single_host_env_lines}" + printf -v "${result_apps_metadata_var}" "%s" "${collected_single_host_apps_metadata_json_object}" return 0 } diff --git a/scripts/easy-docker/lib/app/wizard/env/split_services.sh b/scripts/easy-docker/lib/app/wizard/env/split_services.sh new file mode 100755 index 00000000..bcbbeda8 --- /dev/null +++ b/scripts/easy-docker/lib/app/wizard/env/split_services.sh @@ -0,0 +1,319 @@ +#!/usr/bin/env bash + +collect_split_services_env_lines() { + local result_env_var="${1}" + local result_apps_metadata_var="${2}" + local stack_dir="${3}" + local proxy_mode_id="${4}" + local data_mode_id="${5}" + local database_id="${6}" + local redis_id="${7}" + local collected_split_services_env_lines="" + local collected_split_services_apps_metadata_json_object="" + local value="" + local domains_value="" + local domain_lines="" + local site_domains_value="" + local sites_rule_value="" + local nginx_proxy_hosts_value="" + local db_port="" + local prompt_status=0 + + if collect_stack_image_and_apps_env_lines collected_split_services_env_lines collected_split_services_apps_metadata_json_object "${stack_dir}"; then + : + else + prompt_status=$? + return "${prompt_status}" + fi + + case "${proxy_mode_id}" in + traefik-https) + if prompt_env_value_with_validation domains_value "${stack_dir}" "SITE_DOMAINS" "Required for Traefik HTTPS routing.\nUse hostnames like example.com, app.example.com, localhost, or dev.localhost.\nEnter multiple domains separated by comma or space.\nLet's Encrypt still requires a public DNS name.\nType /back to return." "erp.example.com dev.localhost" "required" "domains"; then + : + else + prompt_status=$? + return "${prompt_status}" + fi + + if ! parse_domains_input_to_lines domain_lines "${domains_value}"; then + show_warning_message "Could not parse SITE_DOMAINS." + return 1 + fi + + site_domains_value="$(domain_lines_to_csv "${domain_lines}")" + collected_split_services_env_lines="$(append_env_line "${collected_split_services_env_lines}" "SITE_DOMAINS" "${site_domains_value}")" + + sites_rule_value="$(domain_lines_to_sites_rule "${domain_lines}")" + collected_split_services_env_lines="$(append_env_line "${collected_split_services_env_lines}" "SITES_RULE" "${sites_rule_value}")" + + if prompt_env_value_with_validation value "${stack_dir}" "LETSENCRYPT_EMAIL" "Required for Let's Encrypt certificate registration.\nType /back to return." "admin@example.com" "required" "email"; then + : + else + prompt_status=$? + return "${prompt_status}" + fi + collected_split_services_env_lines="$(append_env_line "${collected_split_services_env_lines}" "LETSENCRYPT_EMAIL" "${value}")" + + if prompt_env_value_with_validation value "${stack_dir}" "HTTP_PUBLISH_PORT" "Optional. Press Enter to keep default 80.\nType /back to return." "80" "optional" "port"; then + : + else + prompt_status=$? + return "${prompt_status}" + fi + if [ -n "${value}" ]; then + collected_split_services_env_lines="$(append_env_line "${collected_split_services_env_lines}" "HTTP_PUBLISH_PORT" "${value}")" + fi + + if prompt_env_value_with_validation value "${stack_dir}" "HTTPS_PUBLISH_PORT" "Optional. Press Enter to keep default 443.\nType /back to return." "443" "optional" "port"; then + : + else + prompt_status=$? + return "${prompt_status}" + fi + if [ -n "${value}" ]; then + collected_split_services_env_lines="$(append_env_line "${collected_split_services_env_lines}" "HTTPS_PUBLISH_PORT" "${value}")" + fi + ;; + nginxproxy-https) + if prompt_env_value_with_validation domains_value "${stack_dir}" "SITE_DOMAINS" "Required for nginx-proxy routing.\nUse hostnames like example.com, app.example.com, localhost, or dev.localhost.\nEnter multiple domains separated by comma or space.\nLet's Encrypt still requires a public DNS name.\nType /back to return." "erp.example.com dev.localhost" "required" "domains"; then + : + else + prompt_status=$? + return "${prompt_status}" + fi + + if ! parse_domains_input_to_lines domain_lines "${domains_value}"; then + show_warning_message "Could not parse SITE_DOMAINS." + return 1 + fi + + site_domains_value="$(domain_lines_to_csv "${domain_lines}")" + collected_split_services_env_lines="$(append_env_line "${collected_split_services_env_lines}" "SITE_DOMAINS" "${site_domains_value}")" + + nginx_proxy_hosts_value="$(domain_lines_to_csv "${domain_lines}")" + collected_split_services_env_lines="$(append_env_line "${collected_split_services_env_lines}" "NGINX_PROXY_HOSTS" "${nginx_proxy_hosts_value}")" + + if prompt_env_value_with_validation value "${stack_dir}" "LETSENCRYPT_EMAIL" "Required for Let's Encrypt certificate registration.\nType /back to return." "admin@example.com" "required" "email"; then + : + else + prompt_status=$? + return "${prompt_status}" + fi + collected_split_services_env_lines="$(append_env_line "${collected_split_services_env_lines}" "LETSENCRYPT_EMAIL" "${value}")" + + if prompt_env_value_with_validation value "${stack_dir}" "HTTP_PUBLISH_PORT" "Optional. Press Enter to keep default 80.\nType /back to return." "80" "optional" "port"; then + : + else + prompt_status=$? + return "${prompt_status}" + fi + if [ -n "${value}" ]; then + collected_split_services_env_lines="$(append_env_line "${collected_split_services_env_lines}" "HTTP_PUBLISH_PORT" "${value}")" + fi + + if prompt_env_value_with_validation value "${stack_dir}" "HTTPS_PUBLISH_PORT" "Optional. Press Enter to keep default 443.\nType /back to return." "443" "optional" "port"; then + : + else + prompt_status=$? + return "${prompt_status}" + fi + if [ -n "${value}" ]; then + collected_split_services_env_lines="$(append_env_line "${collected_split_services_env_lines}" "HTTPS_PUBLISH_PORT" "${value}")" + fi + ;; + nginxproxy-http) + if prompt_env_value_with_validation domains_value "${stack_dir}" "SITE_DOMAINS" "Required for nginx-proxy routing.\nUse hostnames like example.com, app.example.com, localhost, or dev.localhost.\nEnter multiple domains separated by comma or space.\nType /back to return." "erp.example.com dev.localhost" "required" "domains"; then + : + else + prompt_status=$? + return "${prompt_status}" + fi + + if ! parse_domains_input_to_lines domain_lines "${domains_value}"; then + show_warning_message "Could not parse SITE_DOMAINS." + return 1 + fi + + site_domains_value="$(domain_lines_to_csv "${domain_lines}")" + collected_split_services_env_lines="$(append_env_line "${collected_split_services_env_lines}" "SITE_DOMAINS" "${site_domains_value}")" + + nginx_proxy_hosts_value="$(domain_lines_to_csv "${domain_lines}")" + collected_split_services_env_lines="$(append_env_line "${collected_split_services_env_lines}" "NGINX_PROXY_HOSTS" "${nginx_proxy_hosts_value}")" + + if prompt_env_value_with_validation value "${stack_dir}" "HTTP_PUBLISH_PORT" "Optional. Press Enter to keep default 80.\nType /back to return." "80" "optional" "port"; then + : + else + prompt_status=$? + return "${prompt_status}" + fi + if [ -n "${value}" ]; then + collected_split_services_env_lines="$(append_env_line "${collected_split_services_env_lines}" "HTTP_PUBLISH_PORT" "${value}")" + fi + ;; + traefik-http) + if prompt_env_value_with_validation value "${stack_dir}" "HTTP_PUBLISH_PORT" "Optional. Press Enter to keep default 80.\nType /back to return." "80" "optional" "port"; then + : + else + prompt_status=$? + return "${prompt_status}" + fi + if [ -n "${value}" ]; then + collected_split_services_env_lines="$(append_env_line "${collected_split_services_env_lines}" "HTTP_PUBLISH_PORT" "${value}")" + fi + ;; + caddy-external | no-proxy) + if prompt_env_value_with_validation value "${stack_dir}" "HTTP_PUBLISH_PORT" "Optional. Press Enter to keep default 8080 for no-proxy frontend publishing.\nType /back to return." "8080" "optional" "port"; then + : + else + prompt_status=$? + return "${prompt_status}" + fi + if [ -n "${value}" ]; then + collected_split_services_env_lines="$(append_env_line "${collected_split_services_env_lines}" "HTTP_PUBLISH_PORT" "${value}")" + fi + ;; + *) + show_warning_and_wait "Unknown proxy mode id: ${proxy_mode_id}" 2 + return 1 + ;; + esac + + case "${data_mode_id}" in + managed) + case "${database_id}" in + postgres) + if prompt_env_value_with_validation value "${stack_dir}" "DB_PASSWORD" "Required for PostgreSQL database service.\nType /back to return." "changeit" "required" "none"; then + : + else + prompt_status=$? + return "${prompt_status}" + fi + collected_split_services_env_lines="$(append_env_line "${collected_split_services_env_lines}" "DB_PASSWORD" "${value}")" + ;; + mariadb) + if prompt_env_value_with_validation value "${stack_dir}" "DB_PASSWORD" "Optional but recommended for MariaDB.\nPress Enter to use default from override.\nType /back to return." "changeit" "optional" "none"; then + : + else + prompt_status=$? + return "${prompt_status}" + fi + if [ -n "${value}" ]; then + collected_split_services_env_lines="$(append_env_line "${collected_split_services_env_lines}" "DB_PASSWORD" "${value}")" + fi + ;; + *) + show_warning_and_wait "Unknown database id: ${database_id}" 2 + return 1 + ;; + esac + + case "${redis_id}" in + managed) + collected_split_services_env_lines="$(append_env_line "${collected_split_services_env_lines}" "REDIS_CACHE" "redis-cache:6379")" + collected_split_services_env_lines="$(append_env_line "${collected_split_services_env_lines}" "REDIS_QUEUE" "redis-queue:6379")" + ;; + external) + if prompt_env_value_with_validation value "${stack_dir}" "REDIS_CACHE" "Required for external Redis cache.\nUse host:port such as redis.example.internal:6379.\nType /back to return." "redis.example.internal:6379" "required" "hostport"; then + : + else + prompt_status=$? + return "${prompt_status}" + fi + collected_split_services_env_lines="$(append_env_line "${collected_split_services_env_lines}" "REDIS_CACHE" "${value}")" + + if prompt_env_value_with_validation value "${stack_dir}" "REDIS_QUEUE" "Required for external Redis queue.\nUse host:port such as redis.example.internal:6379.\nType /back to return." "redis.example.internal:6379" "required" "hostport"; then + : + else + prompt_status=$? + return "${prompt_status}" + fi + collected_split_services_env_lines="$(append_env_line "${collected_split_services_env_lines}" "REDIS_QUEUE" "${value}")" + ;; + disabled | "") + : + ;; + *) + show_warning_and_wait "Unknown Redis id: ${redis_id}" 2 + return 1 + ;; + esac + ;; + external) + case "${database_id}" in + postgres) + db_port="5432" + ;; + mariadb) + db_port="3306" + ;; + *) + show_warning_and_wait "Unknown database id: ${database_id}" 2 + return 1 + ;; + esac + + if prompt_env_value_with_validation value "${stack_dir}" "DB_HOST" "Required for external database.\nUse a hostname or IP address.\nType /back to return." "db.example.internal" "required" "host"; then + : + else + prompt_status=$? + return "${prompt_status}" + fi + collected_split_services_env_lines="$(append_env_line "${collected_split_services_env_lines}" "DB_HOST" "${value}")" + + if prompt_env_value_with_validation value "${stack_dir}" "DB_PORT" "Required for external database.\nPress Enter to keep the default port.\nType /back to return." "${db_port}" "required" "port"; then + : + else + prompt_status=$? + return "${prompt_status}" + fi + collected_split_services_env_lines="$(append_env_line "${collected_split_services_env_lines}" "DB_PORT" "${value}")" + + if prompt_env_value_with_validation value "${stack_dir}" "DB_PASSWORD" "Required for external database access.\nType /back to return." "changeit" "required" "none"; then + : + else + prompt_status=$? + return "${prompt_status}" + fi + collected_split_services_env_lines="$(append_env_line "${collected_split_services_env_lines}" "DB_PASSWORD" "${value}")" + + case "${redis_id}" in + managed) + collected_split_services_env_lines="$(append_env_line "${collected_split_services_env_lines}" "REDIS_CACHE" "redis-cache:6379")" + collected_split_services_env_lines="$(append_env_line "${collected_split_services_env_lines}" "REDIS_QUEUE" "redis-queue:6379")" + ;; + external) + if prompt_env_value_with_validation value "${stack_dir}" "REDIS_CACHE" "Required for external Redis cache.\nUse host:port such as redis.example.internal:6379.\nType /back to return." "redis.example.internal:6379" "required" "hostport"; then + : + else + prompt_status=$? + return "${prompt_status}" + fi + collected_split_services_env_lines="$(append_env_line "${collected_split_services_env_lines}" "REDIS_CACHE" "${value}")" + + if prompt_env_value_with_validation value "${stack_dir}" "REDIS_QUEUE" "Required for external Redis queue.\nUse host:port such as redis.example.internal:6379.\nType /back to return." "redis.example.internal:6379" "required" "hostport"; then + : + else + prompt_status=$? + return "${prompt_status}" + fi + collected_split_services_env_lines="$(append_env_line "${collected_split_services_env_lines}" "REDIS_QUEUE" "${value}")" + ;; + disabled | "") + : + ;; + *) + show_warning_and_wait "Unknown Redis id: ${redis_id}" 2 + return 1 + ;; + esac + ;; + *) + show_warning_and_wait "Unknown data mode id: ${data_mode_id}" 2 + return 1 + ;; + esac + + printf -v "${result_env_var}" "%s" "${collected_split_services_env_lines}" + printf -v "${result_apps_metadata_var}" "%s" "${collected_split_services_apps_metadata_json_object}" + return 0 +} diff --git a/scripts/easy-docker/lib/app/wizard/env/validation.sh b/scripts/easy-docker/lib/app/wizard/env/validation.sh index e40a1bf7..8930a76c 100755 --- a/scripts/easy-docker/lib/app/wizard/env/validation.sh +++ b/scripts/easy-docker/lib/app/wizard/env/validation.sh @@ -27,6 +27,74 @@ is_valid_port_number() { return 0 } +is_valid_host_value() { + local value="${1}" + + if [ -z "${value}" ] || [ "${#value}" -gt 253 ]; then + return 1 + fi + + if printf '%s' "${value}" | grep -Eq '[[:space:]/,:;?!@#]'; then + return 1 + fi + + case "${value}" in + .* | *. | *..*) + return 1 + ;; + esac + + case "${value}" in + [A-Za-z0-9]*) ;; + *) + return 1 + ;; + esac + + case "${value}" in + *[A-Za-z0-9]) ;; + *) + return 1 + ;; + esac + + return 0 +} + +is_valid_host_port_value() { + local value="${1}" + local host="" + local port="" + + if [ -z "${value}" ]; then + return 1 + fi + + case "${value}" in + *:*) + host="${value%:*}" + port="${value##*:}" + ;; + *) + return 1 + ;; + esac + + if [ -z "${host}" ] || [ -z "${port}" ]; then + return 1 + fi + + if ! is_valid_host_value "${host}"; then + return 1 + fi + + if ! is_valid_port_number "${port}"; then + return 1 + fi + + return 0 +} + EASY_DOCKER_LAST_INVALID_DOMAIN="" reset_domain_validation_feedback() { @@ -407,6 +475,18 @@ prompt_env_value_with_validation() { continue fi ;; + host) + if ! is_valid_host_value "${normalized_value}"; then + validation_feedback="Invalid host for ${variable_name}. Use a hostname or IP address without spaces." + continue + fi + ;; + hostport) + if ! is_valid_host_port_value "${normalized_value}"; then + validation_feedback="Invalid endpoint for ${variable_name}. Use host:port, for example redis.example.internal:6379." + continue + fi + ;; domains) if ! is_valid_domains_value "${normalized_value}"; then invalid_domain_input="${EASY_DOCKER_LAST_INVALID_DOMAIN}" diff --git a/scripts/easy-docker/lib/app/wizard/flows.sh b/scripts/easy-docker/lib/app/wizard/flows.sh index 7a15031a..675000e2 100755 --- a/scripts/easy-docker/lib/app/wizard/flows.sh +++ b/scripts/easy-docker/lib/app/wizard/flows.sh @@ -6,6 +6,8 @@ load_easy_docker_wizard_flow_modules() { # shellcheck source=scripts/easy-docker/lib/app/wizard/flows/single_host.sh source "${wizard_dir}/flows/single_host.sh" + # shellcheck source=scripts/easy-docker/lib/app/wizard/flows/split_services.sh + source "${wizard_dir}/flows/split_services.sh" # shellcheck source=scripts/easy-docker/lib/app/wizard/flows/manage.sh source "${wizard_dir}/flows/manage.sh" # shellcheck source=scripts/easy-docker/lib/app/wizard/flows/navigation.sh diff --git a/scripts/easy-docker/lib/app/wizard/flows/navigation.sh b/scripts/easy-docker/lib/app/wizard/flows/navigation.sh index 720a34b5..dab887d5 100755 --- a/scripts/easy-docker/lib/app/wizard/flows/navigation.sh +++ b/scripts/easy-docker/lib/app/wizard/flows/navigation.sh @@ -38,6 +38,7 @@ handle_stack_topology_flow() { local topology_action="" local abort_status=0 local single_host_status=0 + local split_services_status=0 local manage_status=0 local stack_name="" @@ -76,7 +77,38 @@ handle_stack_topology_flow() { esac ;; "Split services") - handle_topology_examples_flow "${topology_action}" + if handle_split_services_stack_flow "${stack_dir}"; then + split_services_status="${FLOW_CONTINUE}" + else + split_services_status=$? + fi + + case "${split_services_status}" in + "${FLOW_OPEN_MANAGE_STACK}") + stack_name="${stack_dir##*/}" + if handle_manage_selected_stack_flow "${stack_name}"; then + manage_status="${FLOW_CONTINUE}" + else + manage_status=$? + fi + + case "${manage_status}" in + "${FLOW_EXIT_APP}") + return "${FLOW_EXIT_APP}" + ;; + *) + return "${FLOW_CONTINUE}" + ;; + esac + ;; + "${FLOW_BACK_TO_MAIN}") + return "${FLOW_BACK_TO_MAIN}" + ;; + "${FLOW_EXIT_APP}") + return "${FLOW_EXIT_APP}" + ;; + *) ;; + esac ;; "Abort wizard to main menu") handle_abort_wizard_flow "${stack_dir}" diff --git a/scripts/easy-docker/lib/app/wizard/flows/single_host.sh b/scripts/easy-docker/lib/app/wizard/flows/single_host.sh index 202e3f9d..fa3e7436 100755 --- a/scripts/easy-docker/lib/app/wizard/flows/single_host.sh +++ b/scripts/easy-docker/lib/app/wizard/flows/single_host.sh @@ -79,32 +79,3 @@ handle_single_host_stack_flow() { 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_OPEN_MANAGE_STACK}" } - -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/flows/split_services.sh b/scripts/easy-docker/lib/app/wizard/flows/split_services.sh new file mode 100755 index 00000000..354d214c --- /dev/null +++ b/scripts/easy-docker/lib/app/wizard/flows/split_services.sh @@ -0,0 +1,130 @@ +#!/usr/bin/env bash + +handle_split_services_stack_flow() { + local stack_dir="${1}" + local data_mode="" + local database_choice="" + local redis_choice="" + local proxy_mode="" + local summary_action="" + local stack_env_path="" + local stack_apps_path="" + local generated_compose_path="" + local save_selection_status=0 + local render_compose_status=0 + + data_mode="$(show_split_services_data_mode_menu "${stack_dir}" || true)" + case "${data_mode}" in + "Back to topology selection" | "") + return "${FLOW_CONTINUE}" + ;; + *) + if ! get_split_services_data_mode_id "${data_mode}" >/dev/null; then + show_warning_and_wait "Unknown data services mode: ${data_mode}" + return "${FLOW_CONTINUE}" + fi + ;; + esac + + database_choice="$(show_split_services_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_split_services_redis_mode_menu "${stack_dir}" || true)" + case "${redis_choice}" in + "Back to topology selection" | "") + return "${FLOW_CONTINUE}" + ;; + *) + if ! get_split_services_redis_id "${redis_choice}" >/dev/null; then + show_warning_and_wait "Unknown Redis choice: ${redis_choice}" + return "${FLOW_CONTINUE}" + fi + ;; + esac + + proxy_mode="$(show_split_services_proxy_mode_menu "${stack_dir}" || true)" + case "${proxy_mode}" in + "Back to topology selection" | "") + return "${FLOW_CONTINUE}" + ;; + *) + if ! get_single_host_proxy_mode_id "${proxy_mode}" >/dev/null; then + show_warning_and_wait "Unknown reverse proxy mode: ${proxy_mode}" + return "${FLOW_CONTINUE}" + fi + ;; + esac + + summary_action="$(show_split_services_summary_menu "${stack_dir}" "${data_mode}" "${database_choice}" "${redis_choice}" "${proxy_mode}" || true)" + case "${summary_action}" in + "Yes, write stack files") ;; + "Back to topology selection" | "") + return "${FLOW_CONTINUE}" + ;; + "Abort wizard to main menu") + handle_abort_wizard_flow "${stack_dir}" + return $? + ;; + *) + show_warning_and_wait "Unknown split-services summary action: ${summary_action}" + return "${FLOW_CONTINUE}" + ;; + esac + + if save_split_services_selection "${stack_dir}" "${proxy_mode}" "${data_mode}" "${database_choice}" "${redis_choice}"; then + : + else + save_selection_status=$? + if [ "${save_selection_status}" -eq 2 ] || [ "${save_selection_status}" -eq 130 ]; then + return "${FLOW_CONTINUE}" + fi + case "${save_selection_status}" in + 31) + show_warning_and_wait "Could not write the split-services env file for stack: ${stack_dir}" 3 + ;; + 32) + show_warning_and_wait "Could not write the split-services wizard metadata in ${stack_dir}/metadata.json." 3 + ;; + 33) + show_warning_and_wait "Split-services app selection is empty. Select at least one app before writing stack files." 3 + ;; + 34) + show_warning_and_wait "Could not write the selected app metadata in ${stack_dir}/metadata.json." 3 + ;; + 35) + show_warning_and_wait "Could not generate ${stack_dir}/apps.json from the selected split-services apps." 3 + ;; + *) + show_warning_and_wait "Could not save split-services selection for stack: ${stack_dir} (${save_selection_status})." 3 + ;; + esac + return "${FLOW_CONTINUE}" + fi + + if render_stack_compose_from_metadata "${stack_dir}"; then + : + else + 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 "Split-services selection saved in ${stack_dir}/metadata.json, ${stack_env_path}, and ${stack_apps_path}. Rendered compose: ${generated_compose_path}." 3 + return "${FLOW_OPEN_MANAGE_STACK}" +} diff --git a/scripts/easy-docker/lib/app/wizard/single_host.sh b/scripts/easy-docker/lib/app/wizard/single_host.sh index 87ff2ecb..1a99dd71 100755 --- a/scripts/easy-docker/lib/app/wizard/single_host.sh +++ b/scripts/easy-docker/lib/app/wizard/single_host.sh @@ -224,7 +224,7 @@ save_single_host_selection() { 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 + if collect_single_host_env_lines env_lines apps_metadata_json_object "${stack_dir}" "${proxy_mode_id}" "${database_id}" "${redis_id}"; then : else collect_env_status=$? diff --git a/scripts/easy-docker/lib/app/wizard/split_services.sh b/scripts/easy-docker/lib/app/wizard/split_services.sh new file mode 100755 index 00000000..bce4c019 --- /dev/null +++ b/scripts/easy-docker/lib/app/wizard/split_services.sh @@ -0,0 +1,164 @@ +#!/usr/bin/env bash + +get_split_services_data_mode_id() { + local data_mode="${1}" + + case "${data_mode}" in + "Managed Data Services") + printf 'managed\n' + ;; + "External Data Services") + printf 'external\n' + ;; + *) + return 1 + ;; + esac +} + +get_split_services_redis_id() { + local redis_choice="${1}" + + case "${redis_choice}" in + "Managed Redis Services") + printf 'managed\n' + ;; + "External Redis Services") + printf 'external\n' + ;; + "No Redis Services") + printf 'disabled\n' + ;; + *) + return 1 + ;; + esac +} + +persist_split_services_selection_metadata() { + local stack_dir="${1}" + local proxy_mode_id="${2}" + local data_mode_id="${3}" + local database_id="${4}" + local redis_id="${5}" + local compose_files_lines="${6}" + local env_lines="${7}" + local updated_at="" + local compose_files_json="" + local env_json_object="" + local wizard_json_object="" + + 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 ! wizard_json_object="$( + cat <&2 - status_text="$(printf "Manage stack\n\nStack: %s\nDirectory: %s\nRuntime status: %s\n\nChoose an action for this stack." "${stack_name}" "${stack_dir}" "${stack_runtime_status}")" + stack_topology="$(get_stack_topology "${stack_dir}" || true)" + status_text="$(printf "Manage stack\n\nStack: %s\nDirectory: %s\nRuntime status: %s\nTopology: %s\n\nChoose an action for this stack." "${stack_name}" "${stack_dir}" "${stack_runtime_status}" "${stack_topology:-unknown}")" render_box_message "${status_text}" "0 2" >&2 menu_header="$(printf "Stack actions | %s" "${stack_runtime_status}")" + menu_options=( + "Apps" + "Updates" + ) + case "${stack_topology}" in + single-host) + menu_options+=("Site") + ;; + esac + menu_options+=( + "Start stack in Docker Compose" + "Restart stack in Docker Compose" + "Stop stack in Docker Compose" + "Delete stack" + "Back" + "Exit and close easy-docker" + ) + gum choose \ --height 12 \ --header "${menu_header}" \ --cursor.foreground 63 \ --selected.foreground 45 \ - "Apps" \ - "Updates" \ - "Site" \ - "Start stack in Docker Compose" \ - "Restart stack in Docker Compose" \ - "Stop stack in Docker Compose" \ - "Delete stack" \ - "Back" \ - "Exit and close easy-docker" + "${menu_options[@]}" } show_manage_stack_apps_menu() { diff --git a/scripts/easy-docker/lib/ui/screens/production/split_services.sh b/scripts/easy-docker/lib/ui/screens/production/split_services.sh new file mode 100755 index 00000000..5817f78d --- /dev/null +++ b/scripts/easy-docker/lib/ui/screens/production/split_services.sh @@ -0,0 +1,115 @@ +#!/usr/bin/env bash + +show_split_services_data_mode_menu() { + local stack_dir="${1}" + local stack_name="" + local status_text="" + + render_main_screen 1 >&2 + + stack_name="${stack_dir##*/}" + status_text="$(printf "Stack: %s\n\nSplit-services setup (step 1/5)\nApplication Services run the Frappe image, workers, scheduler, and frontend.\nData Services provide the database and Redis layer.\n\nChoose how the data layer should be handled." "${stack_name}")" + render_box_message "${status_text}" "0 2" >&2 + + gum choose \ + --height 9 \ + --header "Data Services" \ + --cursor.foreground 63 \ + --selected.foreground 45 \ + "Managed Data Services" \ + "External Data Services" \ + "Back to topology selection" +} + +show_split_services_database_menu() { + local stack_dir="${1}" + local stack_name="" + local status_text="" + + render_main_screen 1 >&2 + + stack_name="${stack_dir##*/}" + status_text="$(printf "Stack: %s\n\nSplit-services setup (step 2/5)\nChoose the database engine for the data layer.\nMariaDB is the default choice for most users.\nPostgreSQL is available if that is the database you want to run with this stack." "${stack_name}")" + render_box_message "${status_text}" "0 2" >&2 + + gum choose \ + --height 8 \ + --header "Data Services: database engine" \ + --cursor.foreground 63 \ + --selected.foreground 45 \ + "MariaDB (recommended)" \ + "PostgreSQL" \ + "Back to topology selection" +} + +show_split_services_redis_mode_menu() { + local stack_dir="${1}" + local stack_name="" + local status_text="" + + render_main_screen 1 >&2 + + stack_name="${stack_dir##*/}" + status_text="$(printf "Stack: %s\n\nSplit-services setup (step 3/5)\nChoose how Redis should be handled.\nManaged Redis keeps the Redis services inside the generated stack.\nExternal Redis uses endpoints you provide manually.\nChoose no Redis services only if you know you want to handle Redis yourself." "${stack_name}")" + render_box_message "${status_text}" "0 2" >&2 + + gum choose \ + --height 9 \ + --header "Redis Services" \ + --cursor.foreground 63 \ + --selected.foreground 45 \ + "Managed Redis Services" \ + "External Redis Services" \ + "No Redis Services" \ + "Back to topology selection" +} + +show_split_services_proxy_mode_menu() { + local stack_dir="${1}" + local stack_name="" + local status_text="" + + render_main_screen 1 >&2 + + stack_name="${stack_dir##*/}" + status_text="$(printf "Stack: %s\n\nSplit-services setup (step 4/5)\nChoose the reverse proxy mode.\nThe reverse proxy is optional and can stay outside the stack if you already manage it elsewhere." "${stack_name}")" + render_box_message "${status_text}" "0 2" >&2 + + gum choose \ + --height 11 \ + --header "Reverse proxy mode" \ + --cursor.foreground 63 \ + --selected.foreground 45 \ + "Traefik (HTTP, built-in proxy)" \ + "Traefik (HTTPS + Let's Encrypt)" \ + "nginx-proxy (HTTP)" \ + "nginx-proxy + acme-companion (HTTPS)" \ + "Caddy (external reverse proxy)" \ + "No reverse proxy (direct :8080)" \ + "Back to topology selection" +} + +show_split_services_summary_menu() { + local stack_dir="${1}" + local data_mode_label="${2}" + local database_label="${3}" + local redis_label="${4}" + local proxy_label="${5}" + local stack_name="" + local status_text="" + + render_main_screen 1 >&2 + + stack_name="${stack_dir##*/}" + status_text="$(printf "Stack: %s\n\nSplit-services setup (step 5/5)\nReview the selected layout before the stack files are written.\n\nApplication Services: managed in this stack\nData Services: %s\nDatabase engine: %s\nRedis Services: %s\nReverse Proxy: %s" "${stack_name}" "${data_mode_label}" "${database_label}" "${redis_label}" "${proxy_label}")" + render_box_message "${status_text}" "0 2" >&2 + + gum choose \ + --height 8 \ + --header "Split-services summary" \ + --cursor.foreground 63 \ + --selected.foreground 45 \ + "Yes, write stack files" \ + "Back to topology selection" \ + "Abort wizard to main menu" +} diff --git a/scripts/easy-docker/lib/ui/screens/production/topology.sh b/scripts/easy-docker/lib/ui/screens/production/topology.sh index 8f17fc07..9fb8f68a 100755 --- a/scripts/easy-docker/lib/ui/screens/production/topology.sh +++ b/scripts/easy-docker/lib/ui/screens/production/topology.sh @@ -8,7 +8,7 @@ show_stack_topology_menu() { render_main_screen 1 >&2 stack_name="${stack_dir##*/}" - status_text="$(printf "Stack created: %s\nDirectory: %s\n\nChoose the deployment topology.\n\n- Single-host: easiest setup on one server.\n- Split services: separate app and infra stacks for more control (currently in development)." "${stack_name}" "${stack_dir}")" + status_text="$(printf "Stack created: %s\nDirectory: %s\n\nChoose the deployment topology.\n\n- Single-host: easiest setup on one server.\n- Split services: separate application services, data services, and an optional reverse proxy." "${stack_name}" "${stack_dir}")" render_box_message "${status_text}" "0 2" >&2 gum choose \ @@ -17,7 +17,7 @@ show_stack_topology_menu() { --cursor.foreground 63 \ --selected.foreground 45 \ "Single-host (recommended)" \ - "Split services (in development)" \ + "Split services" \ "Abort wizard to main menu" } @@ -29,12 +29,12 @@ show_single_host_proxy_menu() { render_main_screen 1 >&2 stack_name="${stack_dir##*/}" - status_text="$(printf "Stack: %s\n\nSingle-host setup (step 1/3)\nChoose the proxy mode.\n\n- Traefik and nginx-proxy run inside compose.\n- Caddy is external and uses the no-proxy compose mode." "${stack_name}")" + status_text="$(printf "Stack: %s\n\nChoose the reverse proxy mode.\n\n- Traefik and nginx-proxy run inside compose.\n- Caddy is external and uses the no-proxy compose mode." "${stack_name}")" render_box_message "${status_text}" "0 2" >&2 gum choose \ --height 11 \ - --header "Single-host: proxy mode" \ + --header "Reverse proxy mode" \ --cursor.foreground 63 \ --selected.foreground 45 \ "Traefik (HTTP, built-in proxy)" \ @@ -54,12 +54,12 @@ show_single_host_database_menu() { render_main_screen 1 >&2 stack_name="${stack_dir##*/}" - status_text="$(printf "Stack: %s\n\nSingle-host setup (step 2/3)\nChoose the database service." "${stack_name}")" + status_text="$(printf "Stack: %s\n\nChoose the database engine." "${stack_name}")" render_box_message "${status_text}" "0 2" >&2 gum choose \ --height 8 \ - --header "Single-host: database" \ + --header "Database engine" \ --cursor.foreground 63 \ --selected.foreground 45 \ "MariaDB (recommended)" \ @@ -75,12 +75,12 @@ show_single_host_redis_menu() { render_main_screen 1 >&2 stack_name="${stack_dir##*/}" - status_text="$(printf "Stack: %s\n\nSingle-host setup (step 3/3)\nChoose whether Redis services should be included." "${stack_name}")" + status_text="$(printf "Stack: %s\n\nChoose whether Redis services should be included in this stack." "${stack_name}")" render_box_message "${status_text}" "0 2" >&2 gum choose \ --height 8 \ - --header "Single-host: redis" \ + --header "Redis services" \ --cursor.foreground 63 \ --selected.foreground 45 \ "Include Redis (recommended)" \ @@ -169,23 +169,6 @@ prompt_single_host_env_value() { --placeholder "${placeholder}" } -show_split_services_examples() { - local status_text="" - - render_main_screen 1 >&2 - - status_text="$(printf "Split services examples\n\n- DB in a separate stack/project.\n- Proxy in a separate stack/project.\n- One or more app stacks referencing shared infra.")" - render_box_message "${status_text}" "0 2" >&2 - - gum choose \ - --height 7 \ - --header "Split services" \ - --cursor.foreground 63 \ - --selected.foreground 45 \ - "Use this topology" \ - "Back to topology selection" -} - show_abort_wizard_prompt() { local stack_dir="${1}" local status_text=""