From ce750515f232c9628adcf0ad122684089cea2a5e Mon Sep 17 00:00:00 2001 From: RocketQuack <202538874+Rocket-Quack@users.noreply.github.com> Date: Wed, 25 Feb 2026 22:50:08 +0100 Subject: [PATCH 01/51] feat(easy-docker): add interactive bootstrap TUI with dependency handling --- easy-docker.sh | 6 + scripts/easy-docker/README.md | 24 ++ scripts/easy-docker/lib/env.sh | 387 +++++++++++++++++++++++++++++++++ scripts/easy-docker/lib/ui.sh | 60 +++++ scripts/easy-docker/main.sh | 108 +++++++++ 5 files changed, 585 insertions(+) create mode 100755 easy-docker.sh create mode 100644 scripts/easy-docker/README.md create mode 100755 scripts/easy-docker/lib/env.sh create mode 100755 scripts/easy-docker/lib/ui.sh create mode 100755 scripts/easy-docker/main.sh diff --git a/easy-docker.sh b/easy-docker.sh new file mode 100755 index 00000000..d5c09025 --- /dev/null +++ b/easy-docker.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +exec "${ROOT_DIR}/scripts/easy-docker/main.sh" "$@" diff --git a/scripts/easy-docker/README.md b/scripts/easy-docker/README.md new file mode 100644 index 00000000..d1ad7408 --- /dev/null +++ b/scripts/easy-docker/README.md @@ -0,0 +1,24 @@ +# Easy-Frappe-Docker + +Easy installation script for Frappe Docker for development and production + +## Run + +```bash +bash easy-docker.sh +``` + +## Dependencies + +- `gum` is used for the TUI +- The script checks required dependencies on startup +- Missing dependencies are installed automatically when possible +- If package manager installation for `gum` fails, the script can use a GitHub binary fallback + +## Options + +- `-h`, `--help` + - Shows usage and exits without starting the TUI +- `--no-github-binary-fallback` + - Disables GitHub binary fallback for `gum` + - If package manager installation fails, the script exits with manual installation guidance diff --git a/scripts/easy-docker/lib/env.sh b/scripts/easy-docker/lib/env.sh new file mode 100755 index 00000000..d6c7c70c --- /dev/null +++ b/scripts/easy-docker/lib/env.sh @@ -0,0 +1,387 @@ +#!/usr/bin/env bash + +command_exists() { + command -v "${1}" >/dev/null 2>&1 || command -v "${1}.exe" >/dev/null 2>&1 +} + +run_with_privileges() { + if command_exists sudo; then + sudo "$@" + return + fi + + "$@" +} + +copy_binary() { + local source_path="${1}" + local target_path="${2}" + + if command_exists install; then + install -m 0755 "${source_path}" "${target_path}" + return $? + fi + + cp "${source_path}" "${target_path}" && chmod +x "${target_path}" +} + +copy_binary_with_privileges() { + local source_path="${1}" + local target_path="${2}" + + if command_exists install; then + run_with_privileges install -m 0755 "${source_path}" "${target_path}" + return $? + fi + + run_with_privileges cp "${source_path}" "${target_path}" && + run_with_privileges chmod +x "${target_path}" +} + +detect_gum_platform() { + local raw_os="" + local raw_arch="" + + raw_os="$(uname -s 2>/dev/null || echo unknown)" + raw_arch="$(uname -m 2>/dev/null || echo unknown)" + + case "${raw_os}" in + Linux*) + GUM_OS="Linux" + ;; + Darwin*) + GUM_OS="Darwin" + ;; + MINGW* | MSYS* | CYGWIN* | Windows_NT) + GUM_OS="Windows" + ;; + *) + return 1 + ;; + esac + + case "${raw_arch}" in + x86_64 | amd64) + GUM_ARCH="x86_64" + ;; + aarch64 | arm64) + GUM_ARCH="arm64" + ;; + armv7l | armv7) + GUM_ARCH="armv7" + ;; + *) + return 1 + ;; + esac + + return 0 +} + +get_os_aliases() { + local os_name="${1}" + local os_lower="" + + os_lower="$(printf '%s' "${os_name}" | tr '[:upper:]' '[:lower:]')" + + if [ "${os_lower}" = "${os_name}" ]; then + printf '%s\n' "${os_name}" + return + fi + + printf '%s\n%s\n' "${os_name}" "${os_lower}" +} + +get_arch_aliases() { + case "${1}" in + x86_64) + printf '%s\n%s\n' "x86_64" "amd64" + ;; + arm64) + printf '%s\n%s\n' "arm64" "aarch64" + ;; + armv7) + printf '%s\n%s\n' "armv7" "armv7l" + ;; + *) + printf '%s\n' "${1}" + ;; + esac +} + +get_gum_asset_candidates() { + local release_version="${1}" + local os_alias="" + local arch_alias="" + local ext="" + + while IFS= read -r os_alias; do + while IFS= read -r arch_alias; do + for ext in tar.gz zip; do + printf 'gum_%s_%s_%s.%s\n' "${release_version}" "${os_alias}" "${arch_alias}" "${ext}" + done + done < <(get_arch_aliases "${GUM_ARCH}") + done < <(get_os_aliases "${GUM_OS}") +} + +extract_gum_asset() { + local asset_path="${1}" + local extract_dir="${2}" + + mkdir -p "${extract_dir}" + + case "${asset_path}" in + *.tar.gz) + if ! command_exists tar; then + echo "tar is required to extract gum tar.gz assets." + return 1 + fi + tar -xzf "${asset_path}" -C "${extract_dir}" + ;; + *.zip) + if ! command_exists unzip; then + echo "unzip is required to extract gum zip assets." + return 1 + fi + unzip -q "${asset_path}" -d "${extract_dir}" + ;; + *) + return 1 + ;; + esac +} + +find_gum_binary() { + local search_dir="${1}" + local found_path="" + + found_path="$( + find "${search_dir}" -type f \( -name "gum" -o -name "gum.exe" \) 2>/dev/null | + head -n 1 + )" + + if [ -n "${found_path}" ]; then + printf '%s\n' "${found_path}" + return 0 + fi + + return 1 +} + +install_gum_from_github_release() { + local release_version="" + local asset_name="" + local downloaded_asset_path="" + local download_url="" + local tmp_dir="" + local extract_dir="" + local target_dir="" + local gum_binary_path="" + local target_binary_name="gum" + + if ! command_exists curl; then + return 1 + fi + + if ! detect_gum_platform; then + echo "Unsupported platform for automatic GitHub fallback." + return 1 + fi + + release_version="$( + curl -fsSL "https://api.github.com/repos/charmbracelet/gum/releases/latest" | + sed -n 's/.*"tag_name":[[:space:]]*"v\([^"]*\)".*/\1/p' | + head -n 1 + )" + + if [ -z "${release_version}" ]; then + return 1 + fi + + tmp_dir="$(mktemp -d)" + extract_dir="${tmp_dir}/extract" + + while IFS= read -r asset_name; do + download_url="https://github.com/charmbracelet/gum/releases/download/v${release_version}/${asset_name}" + if curl -fsSL "${download_url}" -o "${tmp_dir}/${asset_name}"; then + downloaded_asset_path="${tmp_dir}/${asset_name}" + break + fi + done < <(get_gum_asset_candidates "${release_version}") + + if [ -z "${downloaded_asset_path}" ]; then + rm -rf "${tmp_dir}" + return 1 + fi + + if ! extract_gum_asset "${downloaded_asset_path}" "${extract_dir}"; then + rm -rf "${tmp_dir}" + return 1 + fi + + gum_binary_path="$(find_gum_binary "${extract_dir}" || true)" + + if [ -z "${gum_binary_path}" ]; then + rm -rf "${tmp_dir}" + return 1 + fi + + if [[ "${gum_binary_path}" == *.exe ]]; then + target_binary_name="gum.exe" + fi + + if [ "${GUM_OS}" != "Windows" ] && [ -w "/usr/local/bin" ]; then + target_dir="/usr/local/bin" + if copy_binary "${gum_binary_path}" "${target_dir}/${target_binary_name}"; then + rm -rf "${tmp_dir}" + return 0 + fi + fi + + if [ "${GUM_OS}" != "Windows" ] && command_exists sudo; then + if copy_binary_with_privileges "${gum_binary_path}" "/usr/local/bin/${target_binary_name}"; then + rm -rf "${tmp_dir}" + return 0 + fi + fi + + target_dir="${HOME}/.local/bin" + mkdir -p "${target_dir}" + if copy_binary "${gum_binary_path}" "${target_dir}/${target_binary_name}"; then + rm -rf "${tmp_dir}" + return 0 + fi + + rm -rf "${tmp_dir}" + return 1 +} + +install_gum_with_package_manager() { + local pm_attempted=0 + + if command_exists brew; then + pm_attempted=1 + if brew install gum; then + return 0 + fi + fi + + if command_exists apt-get; then + pm_attempted=1 + if run_with_privileges apt-get update && run_with_privileges apt-get install -y gum; then + return 0 + fi + fi + + if command_exists dnf; then + pm_attempted=1 + if run_with_privileges dnf install -y gum; then + return 0 + fi + fi + + if command_exists pacman; then + pm_attempted=1 + if run_with_privileges pacman -Sy --noconfirm gum; then + return 0 + fi + fi + + if command_exists zypper; then + pm_attempted=1 + if run_with_privileges zypper --non-interactive install gum; then + return 0 + fi + fi + + if command_exists winget; then + pm_attempted=1 + if winget install --id Charmbracelet.Gum -e --accept-source-agreements --accept-package-agreements; then + return 0 + fi + fi + + if command_exists choco; then + pm_attempted=1 + if choco install gum -y; then + return 0 + fi + fi + + if [ "${pm_attempted}" -eq 0 ]; then + echo "No supported package manager was found." + else + echo "Package manager installation did not succeed." + fi + + return 1 +} + +should_use_github_fallback() { + local answer="" + + if [ ! -t 0 ]; then + echo "GitHub fallback prompt requires an interactive terminal." + return 1 + fi + + printf "Use GitHub binary fallback for gum? [y/N]: " + read -r answer + + case "${answer}" in + y | Y | yes | YES) + return 0 + ;; + *) + return 1 + ;; + esac +} + +ensure_gum() { + local disable_github_binary_fallback="${1:-0}" + + if command_exists gum; then + return 0 + fi + + echo "gum is not installed. Trying package manager installation..." + + if install_gum_with_package_manager; then + hash -r + fi + + if command_exists gum; then + echo "gum was installed successfully." + return 0 + fi + + if [ "${disable_github_binary_fallback}" = "1" ]; then + echo "GitHub binary fallback is disabled." + echo "Install gum manually: https://github.com/charmbracelet/gum#installation" + echo "If installed into ~/.local/bin, add it to PATH first." + exit 1 + fi + + if should_use_github_fallback; then + echo "Trying GitHub release fallback..." + if install_gum_from_github_release; then + hash -r + fi + else + echo "GitHub fallback was not selected." + echo "Install gum manually: https://github.com/charmbracelet/gum#installation" + echo "If installed into ~/.local/bin, add it to PATH first." + exit 1 + fi + + if command_exists gum; then + echo "gum was installed successfully." + return 0 + fi + + echo "Automatic installation failed." + echo "Install gum manually: https://github.com/charmbracelet/gum#installation" + echo "If installed into ~/.local/bin, add it to PATH first." + exit 1 +} diff --git a/scripts/easy-docker/lib/ui.sh b/scripts/easy-docker/lib/ui.sh new file mode 100755 index 00000000..097ba93c --- /dev/null +++ b/scripts/easy-docker/lib/ui.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash + +render_main_screen() { + local clear_screen="${1:-0}" + local header_text="" + + if [ "${clear_screen}" = "1" ]; then + clear + fi + + header_text="$(printf "Easy Frappe Docker\nMinimal TUI bootstrap")" + + gum style \ + --border rounded \ + --border-foreground 63 \ + --padding "1 2" \ + --margin "1 2" \ + --foreground 252 \ + "${header_text}" +} + +show_main_menu() { + gum choose \ + --height 7 \ + --header "Choose an action" \ + --cursor.foreground 63 \ + --selected.foreground 45 \ + "Environment check" \ + "Exit" +} + +show_environment_status() { + local docker_status="not installed" + local gum_status="installed" + local status_text="" + + if command_exists docker; then + docker_status="installed" + fi + + render_main_screen 1 >&2 + + status_text="$(printf "Environment status\n\n- gum: %s\n- docker: %s" "${gum_status}" "${docker_status}")" + + gum style \ + --border rounded \ + --border-foreground 63 \ + --padding "1 2" \ + --margin "0 2" \ + --foreground 252 \ + "${status_text}" >&2 + + gum choose \ + --height 6 \ + --header "Environment actions" \ + --cursor.foreground 63 \ + --selected.foreground 45 \ + "Back to main menu" \ + "Exit and close easy-docker" +} diff --git a/scripts/easy-docker/main.sh b/scripts/easy-docker/main.sh new file mode 100755 index 00000000..b196542c --- /dev/null +++ b/scripts/easy-docker/main.sh @@ -0,0 +1,108 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# shellcheck source=scripts/easy-docker/lib/env.sh +source "${SCRIPT_DIR}/lib/env.sh" +# shellcheck source=scripts/easy-docker/lib/ui.sh +source "${SCRIPT_DIR}/lib/ui.sh" + +print_usage() { + cat <<'USAGE' +Usage: bash easy-docker.sh [options] + +Options: + --no-github-binary-fallback Disable GitHub binary fallback prompt + -h, --help Show this help +USAGE +} + +DISABLE_GITHUB_BINARY_FALLBACK=0 + +while [ "$#" -gt 0 ]; do + case "$1" in + --no-github-binary-fallback) + DISABLE_GITHUB_BINARY_FALLBACK=1 + ;; + -h | --help) + print_usage + exit 0 + ;; + *) + echo "Unknown option: $1" + print_usage + exit 1 + ;; + esac + shift +done + +ensure_gum "${DISABLE_GITHUB_BINARY_FALLBACK}" + +ALT_SCREEN_ACTIVE=0 + +enter_alt_screen() { + if [ -t 1 ] && command -v tput >/dev/null 2>&1; then + tput smcup || true + tput civis || true + ALT_SCREEN_ACTIVE=1 + fi +} + +leave_alt_screen() { + if [ "${ALT_SCREEN_ACTIVE}" = "1" ] && command -v tput >/dev/null 2>&1; then + tput cnorm || true + tput rmcup || true + ALT_SCREEN_ACTIVE=0 + fi +} + +cleanup_screen() { + leave_alt_screen +} + +cleanup_and_exit() { + exit 0 +} + +trap cleanup_and_exit INT TERM +trap cleanup_screen EXIT + +enter_alt_screen + +render_main_screen 1 + +while true; do + local_env_action="" + action="$(show_main_menu || true)" + + if [ -z "${action}" ]; then + cleanup_and_exit + fi + + case "${action}" in + "Environment check") + local_env_action="$(show_environment_status || true)" + case "${local_env_action}" in + "Back to main menu" | "") + render_main_screen 1 + ;; + "Exit and close easy-docker") + cleanup_and_exit + ;; + *) + gum style --foreground 214 "Unknown environment action: ${local_env_action}" + sleep 1 + ;; + esac + ;; + "Exit") + cleanup_and_exit + ;; + *) + gum style --foreground 214 "Unknown action: ${action}" + sleep 1 + ;; + esac +done From 89ab6a6658a58ba861aa75118f2fbf87c4592a65 Mon Sep 17 00:00:00 2001 From: RocketQuack <202538874+Rocket-Quack@users.noreply.github.com> Date: Wed, 25 Feb 2026 23:54:51 +0100 Subject: [PATCH 02/51] refactor(easy-docker): modularize bootstrap and remove shared shell globals --- scripts/easy-docker/README.md | 11 +- scripts/easy-docker/lib/app/options.sh | 38 ++ scripts/easy-docker/lib/app/run.sh | 43 ++ scripts/easy-docker/lib/app/screen.sh | 19 + scripts/easy-docker/lib/checks/docker.sh | 79 ++++ scripts/easy-docker/lib/core/commands.sh | 39 ++ scripts/easy-docker/lib/core/messages.sh | 29 ++ scripts/easy-docker/lib/env.sh | 387 ------------------ scripts/easy-docker/lib/install/gum/assets.sh | 82 ++++ scripts/easy-docker/lib/install/gum/ensure.sh | 67 +++ .../lib/install/gum/github_release.sh | 105 +++++ scripts/easy-docker/lib/install/gum/load.sh | 19 + .../lib/install/gum/package_manager.sh | 62 +++ .../easy-docker/lib/install/gum/platform.sh | 75 ++++ .../easy-docker/lib/{ui.sh => ui/screens.sh} | 15 +- scripts/easy-docker/main.sh | 129 ++---- 16 files changed, 709 insertions(+), 490 deletions(-) create mode 100755 scripts/easy-docker/lib/app/options.sh create mode 100755 scripts/easy-docker/lib/app/run.sh create mode 100755 scripts/easy-docker/lib/app/screen.sh create mode 100755 scripts/easy-docker/lib/checks/docker.sh create mode 100755 scripts/easy-docker/lib/core/commands.sh create mode 100755 scripts/easy-docker/lib/core/messages.sh delete mode 100755 scripts/easy-docker/lib/env.sh create mode 100755 scripts/easy-docker/lib/install/gum/assets.sh create mode 100755 scripts/easy-docker/lib/install/gum/ensure.sh create mode 100755 scripts/easy-docker/lib/install/gum/github_release.sh create mode 100755 scripts/easy-docker/lib/install/gum/load.sh create mode 100755 scripts/easy-docker/lib/install/gum/package_manager.sh create mode 100755 scripts/easy-docker/lib/install/gum/platform.sh rename scripts/easy-docker/lib/{ui.sh => ui/screens.sh} (70%) diff --git a/scripts/easy-docker/README.md b/scripts/easy-docker/README.md index d1ad7408..f6f0c4d1 100644 --- a/scripts/easy-docker/README.md +++ b/scripts/easy-docker/README.md @@ -10,15 +10,18 @@ bash easy-docker.sh ## Dependencies -- `gum` is used for the TUI -- The script checks required dependencies on startup -- Missing dependencies are installed automatically when possible +- `gum` is used for the TUI and is installed automatically when possible +- `docker` CLI is required and checked on startup +- `docker compose` (Compose v2 command) is required and checked on startup +- Docker Desktop includes Compose v2 by default; on Linux Engine-only setups you may need the `docker-compose-plugin` package +- Docker daemon must be running before the TUI starts +- Required docker commands are validated (`docker ps/exec/inspect/cp` and `docker compose config/up/down/logs/exec/pull/ps`) - If package manager installation for `gum` fails, the script can use a GitHub binary fallback ## Options - `-h`, `--help` - Shows usage and exits without starting the TUI -- `--no-github-binary-fallback` +- `--no-installation-fallback` - Disables GitHub binary fallback for `gum` - If package manager installation fails, the script exits with manual installation guidance diff --git a/scripts/easy-docker/lib/app/options.sh b/scripts/easy-docker/lib/app/options.sh new file mode 100755 index 00000000..7b010ff7 --- /dev/null +++ b/scripts/easy-docker/lib/app/options.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash + +print_usage() { + cat <<'USAGE' +Usage: bash easy-docker.sh [options] + +Options: + --no-installation-fallback Disable installation fallback prompt + -h, --help Show this help +USAGE +} + +parse_cli_options() { + local result_var="${1}" + local disable_installation_fallback=0 + shift + + while [ "$#" -gt 0 ]; do + case "$1" in + --no-installation-fallback) + disable_installation_fallback=1 + ;; + -h | --help) + print_usage + return 2 + ;; + *) + echo "Unknown option: $1" + print_usage + return 1 + ;; + esac + shift + done + + printf -v "${result_var}" "%s" "${disable_installation_fallback}" + return 0 +} diff --git a/scripts/easy-docker/lib/app/run.sh b/scripts/easy-docker/lib/app/run.sh new file mode 100755 index 00000000..874ae91c --- /dev/null +++ b/scripts/easy-docker/lib/app/run.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash + +run_easy_docker_app() { + local action="" + local local_env_action="" + + enter_alt_screen + render_main_screen 1 + + while true; do + local_env_action="" + action="$(show_main_menu || true)" + + if [ -z "${action}" ]; then + return 0 + fi + + case "${action}" in + "Environment check") + local_env_action="$(show_environment_status || true)" + case "${local_env_action}" in + "Back to main menu" | "") + render_main_screen 1 + ;; + "Exit and close easy-docker") + return 0 + ;; + *) + show_warning_message "Unknown environment action: ${local_env_action}" + sleep 1 + ;; + esac + ;; + "Exit") + return 0 + ;; + *) + show_warning_message "Unknown action: ${action}" + sleep 1 + ;; + esac + done +} diff --git a/scripts/easy-docker/lib/app/screen.sh b/scripts/easy-docker/lib/app/screen.sh new file mode 100755 index 00000000..ce293994 --- /dev/null +++ b/scripts/easy-docker/lib/app/screen.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash + +ALT_SCREEN_ACTIVE=0 + +enter_alt_screen() { + if [ -t 1 ] && command_exists tput; then + tput smcup || true + tput civis || true + ALT_SCREEN_ACTIVE=1 + fi +} + +leave_alt_screen() { + if [ "${ALT_SCREEN_ACTIVE}" = "1" ] && command_exists tput; then + tput cnorm || true + tput rmcup || true + ALT_SCREEN_ACTIVE=0 + fi +} diff --git a/scripts/easy-docker/lib/checks/docker.sh b/scripts/easy-docker/lib/checks/docker.sh new file mode 100755 index 00000000..1b703f7f --- /dev/null +++ b/scripts/easy-docker/lib/checks/docker.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash + +docker_compose_available() { + docker compose version >/dev/null 2>&1 +} + +docker_daemon_running() { + docker info >/dev/null 2>&1 +} + +docker_supports_command() { + docker "$@" --help >/dev/null 2>&1 +} + +get_missing_docker_commands() { + local missing=() + local subcommand="" + + for subcommand in ps exec inspect cp; do + if ! docker_supports_command "${subcommand}"; then + missing+=("docker ${subcommand}") + fi + done + + for subcommand in config up down logs exec pull ps; do + if ! docker_supports_command compose "${subcommand}"; then + missing+=("docker compose ${subcommand}") + fi + done + + if [ "${#missing[@]}" -eq 0 ]; then + return 0 + fi + + printf '%s\n' "${missing[@]}" + return 1 +} + +format_missing_commands_list() { + local missing_commands="${1}" + local missing_list="" + + missing_list="$(printf '%s' "${missing_commands}" | tr '\n' ',')" + missing_list="${missing_list%,}" + missing_list="${missing_list#,}" + printf '%s\n' "${missing_list}" +} + +ensure_docker() { + local missing_commands="" + local missing_list="" + + if ! command_exists docker; then + echo "docker is not installed." + print_docker_install_guidance + return 1 + fi + + if ! docker_compose_available; then + echo "docker compose (Compose v2 command) is not available." + print_docker_compose_install_guidance + return 1 + fi + + if ! docker_daemon_running; then + echo "docker daemon is not running." + print_docker_daemon_start_guidance + return 1 + fi + + if ! missing_commands="$(get_missing_docker_commands)"; then + missing_list="$(format_missing_commands_list "${missing_commands}")" + echo "Missing required docker commands: ${missing_list}" + print_docker_command_support_guidance + return 1 + fi + + return 0 +} diff --git a/scripts/easy-docker/lib/core/commands.sh b/scripts/easy-docker/lib/core/commands.sh new file mode 100755 index 00000000..6933bf03 --- /dev/null +++ b/scripts/easy-docker/lib/core/commands.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash + +command_exists() { + command -v "${1}" >/dev/null 2>&1 || command -v "${1}.exe" >/dev/null 2>&1 +} + +run_with_privileges() { + if command_exists sudo; then + sudo "$@" + return + fi + + "$@" +} + +copy_binary() { + local source_path="${1}" + local target_path="${2}" + + if command_exists install; then + install -m 0755 "${source_path}" "${target_path}" + return $? + fi + + cp "${source_path}" "${target_path}" && chmod +x "${target_path}" +} + +copy_binary_with_privileges() { + local source_path="${1}" + local target_path="${2}" + + if command_exists install; then + run_with_privileges install -m 0755 "${source_path}" "${target_path}" + return $? + fi + + run_with_privileges cp "${source_path}" "${target_path}" && + run_with_privileges chmod +x "${target_path}" +} diff --git a/scripts/easy-docker/lib/core/messages.sh b/scripts/easy-docker/lib/core/messages.sh new file mode 100755 index 00000000..a2fd3fde --- /dev/null +++ b/scripts/easy-docker/lib/core/messages.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash + +print_manual_gum_install_guidance() { + echo "Install gum manually: https://github.com/charmbracelet/gum#installation" + echo "If installed into ~/.local/bin, add it to PATH first." +} + +print_docker_install_guidance() { + echo "Install Docker first: https://docs.docker.com/get-started/get-docker/" +} + +print_docker_compose_install_guidance() { + echo "This script requires Docker Compose v2 via the 'docker compose' command." + echo "Docker Desktop includes it by default." + echo "On Linux Engine-only setups, install the Docker Compose CLI plugin package (commonly 'docker-compose-plugin')." + echo "Setup docs:" + echo "https://docs.docker.com/compose/install/" + echo "Note: this script uses 'docker compose' (Compose v2), not the old standalone 'docker-compose'." +} + +print_docker_daemon_start_guidance() { + echo "Start the Docker daemon/service and retry." + echo "If you use Docker Desktop, ensure it is running." +} + +print_docker_command_support_guidance() { + echo "Update Docker to a recent version and ensure Compose v2 is available as 'docker compose'." + echo "Standard 'docker' and 'docker compose' commands are required." +} diff --git a/scripts/easy-docker/lib/env.sh b/scripts/easy-docker/lib/env.sh deleted file mode 100755 index d6c7c70c..00000000 --- a/scripts/easy-docker/lib/env.sh +++ /dev/null @@ -1,387 +0,0 @@ -#!/usr/bin/env bash - -command_exists() { - command -v "${1}" >/dev/null 2>&1 || command -v "${1}.exe" >/dev/null 2>&1 -} - -run_with_privileges() { - if command_exists sudo; then - sudo "$@" - return - fi - - "$@" -} - -copy_binary() { - local source_path="${1}" - local target_path="${2}" - - if command_exists install; then - install -m 0755 "${source_path}" "${target_path}" - return $? - fi - - cp "${source_path}" "${target_path}" && chmod +x "${target_path}" -} - -copy_binary_with_privileges() { - local source_path="${1}" - local target_path="${2}" - - if command_exists install; then - run_with_privileges install -m 0755 "${source_path}" "${target_path}" - return $? - fi - - run_with_privileges cp "${source_path}" "${target_path}" && - run_with_privileges chmod +x "${target_path}" -} - -detect_gum_platform() { - local raw_os="" - local raw_arch="" - - raw_os="$(uname -s 2>/dev/null || echo unknown)" - raw_arch="$(uname -m 2>/dev/null || echo unknown)" - - case "${raw_os}" in - Linux*) - GUM_OS="Linux" - ;; - Darwin*) - GUM_OS="Darwin" - ;; - MINGW* | MSYS* | CYGWIN* | Windows_NT) - GUM_OS="Windows" - ;; - *) - return 1 - ;; - esac - - case "${raw_arch}" in - x86_64 | amd64) - GUM_ARCH="x86_64" - ;; - aarch64 | arm64) - GUM_ARCH="arm64" - ;; - armv7l | armv7) - GUM_ARCH="armv7" - ;; - *) - return 1 - ;; - esac - - return 0 -} - -get_os_aliases() { - local os_name="${1}" - local os_lower="" - - os_lower="$(printf '%s' "${os_name}" | tr '[:upper:]' '[:lower:]')" - - if [ "${os_lower}" = "${os_name}" ]; then - printf '%s\n' "${os_name}" - return - fi - - printf '%s\n%s\n' "${os_name}" "${os_lower}" -} - -get_arch_aliases() { - case "${1}" in - x86_64) - printf '%s\n%s\n' "x86_64" "amd64" - ;; - arm64) - printf '%s\n%s\n' "arm64" "aarch64" - ;; - armv7) - printf '%s\n%s\n' "armv7" "armv7l" - ;; - *) - printf '%s\n' "${1}" - ;; - esac -} - -get_gum_asset_candidates() { - local release_version="${1}" - local os_alias="" - local arch_alias="" - local ext="" - - while IFS= read -r os_alias; do - while IFS= read -r arch_alias; do - for ext in tar.gz zip; do - printf 'gum_%s_%s_%s.%s\n' "${release_version}" "${os_alias}" "${arch_alias}" "${ext}" - done - done < <(get_arch_aliases "${GUM_ARCH}") - done < <(get_os_aliases "${GUM_OS}") -} - -extract_gum_asset() { - local asset_path="${1}" - local extract_dir="${2}" - - mkdir -p "${extract_dir}" - - case "${asset_path}" in - *.tar.gz) - if ! command_exists tar; then - echo "tar is required to extract gum tar.gz assets." - return 1 - fi - tar -xzf "${asset_path}" -C "${extract_dir}" - ;; - *.zip) - if ! command_exists unzip; then - echo "unzip is required to extract gum zip assets." - return 1 - fi - unzip -q "${asset_path}" -d "${extract_dir}" - ;; - *) - return 1 - ;; - esac -} - -find_gum_binary() { - local search_dir="${1}" - local found_path="" - - found_path="$( - find "${search_dir}" -type f \( -name "gum" -o -name "gum.exe" \) 2>/dev/null | - head -n 1 - )" - - if [ -n "${found_path}" ]; then - printf '%s\n' "${found_path}" - return 0 - fi - - return 1 -} - -install_gum_from_github_release() { - local release_version="" - local asset_name="" - local downloaded_asset_path="" - local download_url="" - local tmp_dir="" - local extract_dir="" - local target_dir="" - local gum_binary_path="" - local target_binary_name="gum" - - if ! command_exists curl; then - return 1 - fi - - if ! detect_gum_platform; then - echo "Unsupported platform for automatic GitHub fallback." - return 1 - fi - - release_version="$( - curl -fsSL "https://api.github.com/repos/charmbracelet/gum/releases/latest" | - sed -n 's/.*"tag_name":[[:space:]]*"v\([^"]*\)".*/\1/p' | - head -n 1 - )" - - if [ -z "${release_version}" ]; then - return 1 - fi - - tmp_dir="$(mktemp -d)" - extract_dir="${tmp_dir}/extract" - - while IFS= read -r asset_name; do - download_url="https://github.com/charmbracelet/gum/releases/download/v${release_version}/${asset_name}" - if curl -fsSL "${download_url}" -o "${tmp_dir}/${asset_name}"; then - downloaded_asset_path="${tmp_dir}/${asset_name}" - break - fi - done < <(get_gum_asset_candidates "${release_version}") - - if [ -z "${downloaded_asset_path}" ]; then - rm -rf "${tmp_dir}" - return 1 - fi - - if ! extract_gum_asset "${downloaded_asset_path}" "${extract_dir}"; then - rm -rf "${tmp_dir}" - return 1 - fi - - gum_binary_path="$(find_gum_binary "${extract_dir}" || true)" - - if [ -z "${gum_binary_path}" ]; then - rm -rf "${tmp_dir}" - return 1 - fi - - if [[ "${gum_binary_path}" == *.exe ]]; then - target_binary_name="gum.exe" - fi - - if [ "${GUM_OS}" != "Windows" ] && [ -w "/usr/local/bin" ]; then - target_dir="/usr/local/bin" - if copy_binary "${gum_binary_path}" "${target_dir}/${target_binary_name}"; then - rm -rf "${tmp_dir}" - return 0 - fi - fi - - if [ "${GUM_OS}" != "Windows" ] && command_exists sudo; then - if copy_binary_with_privileges "${gum_binary_path}" "/usr/local/bin/${target_binary_name}"; then - rm -rf "${tmp_dir}" - return 0 - fi - fi - - target_dir="${HOME}/.local/bin" - mkdir -p "${target_dir}" - if copy_binary "${gum_binary_path}" "${target_dir}/${target_binary_name}"; then - rm -rf "${tmp_dir}" - return 0 - fi - - rm -rf "${tmp_dir}" - return 1 -} - -install_gum_with_package_manager() { - local pm_attempted=0 - - if command_exists brew; then - pm_attempted=1 - if brew install gum; then - return 0 - fi - fi - - if command_exists apt-get; then - pm_attempted=1 - if run_with_privileges apt-get update && run_with_privileges apt-get install -y gum; then - return 0 - fi - fi - - if command_exists dnf; then - pm_attempted=1 - if run_with_privileges dnf install -y gum; then - return 0 - fi - fi - - if command_exists pacman; then - pm_attempted=1 - if run_with_privileges pacman -Sy --noconfirm gum; then - return 0 - fi - fi - - if command_exists zypper; then - pm_attempted=1 - if run_with_privileges zypper --non-interactive install gum; then - return 0 - fi - fi - - if command_exists winget; then - pm_attempted=1 - if winget install --id Charmbracelet.Gum -e --accept-source-agreements --accept-package-agreements; then - return 0 - fi - fi - - if command_exists choco; then - pm_attempted=1 - if choco install gum -y; then - return 0 - fi - fi - - if [ "${pm_attempted}" -eq 0 ]; then - echo "No supported package manager was found." - else - echo "Package manager installation did not succeed." - fi - - return 1 -} - -should_use_github_fallback() { - local answer="" - - if [ ! -t 0 ]; then - echo "GitHub fallback prompt requires an interactive terminal." - return 1 - fi - - printf "Use GitHub binary fallback for gum? [y/N]: " - read -r answer - - case "${answer}" in - y | Y | yes | YES) - return 0 - ;; - *) - return 1 - ;; - esac -} - -ensure_gum() { - local disable_github_binary_fallback="${1:-0}" - - if command_exists gum; then - return 0 - fi - - echo "gum is not installed. Trying package manager installation..." - - if install_gum_with_package_manager; then - hash -r - fi - - if command_exists gum; then - echo "gum was installed successfully." - return 0 - fi - - if [ "${disable_github_binary_fallback}" = "1" ]; then - echo "GitHub binary fallback is disabled." - echo "Install gum manually: https://github.com/charmbracelet/gum#installation" - echo "If installed into ~/.local/bin, add it to PATH first." - exit 1 - fi - - if should_use_github_fallback; then - echo "Trying GitHub release fallback..." - if install_gum_from_github_release; then - hash -r - fi - else - echo "GitHub fallback was not selected." - echo "Install gum manually: https://github.com/charmbracelet/gum#installation" - echo "If installed into ~/.local/bin, add it to PATH first." - exit 1 - fi - - if command_exists gum; then - echo "gum was installed successfully." - return 0 - fi - - echo "Automatic installation failed." - echo "Install gum manually: https://github.com/charmbracelet/gum#installation" - echo "If installed into ~/.local/bin, add it to PATH first." - exit 1 -} diff --git a/scripts/easy-docker/lib/install/gum/assets.sh b/scripts/easy-docker/lib/install/gum/assets.sh new file mode 100755 index 00000000..8cfd9f00 --- /dev/null +++ b/scripts/easy-docker/lib/install/gum/assets.sh @@ -0,0 +1,82 @@ +#!/usr/bin/env bash + +get_gum_asset_candidates() { + local release_version="${1}" + local gum_os="${2}" + local gum_arch="${3}" + local os_alias="" + local arch_alias="" + local ext="" + + while IFS= read -r os_alias; do + while IFS= read -r arch_alias; do + for ext in tar.gz zip; do + printf 'gum_%s_%s_%s.%s\n' "${release_version}" "${os_alias}" "${arch_alias}" "${ext}" + done + done < <(get_arch_aliases "${gum_arch}") + done < <(get_os_aliases "${gum_os}") +} + +extract_gum_asset() { + local asset_path="${1}" + local extract_dir="${2}" + + mkdir -p "${extract_dir}" + + case "${asset_path}" in + *.tar.gz) + if ! command_exists tar; then + echo "tar is required to extract gum tar.gz assets." + return 1 + fi + tar -xzf "${asset_path}" -C "${extract_dir}" + ;; + *.zip) + if ! command_exists unzip; then + echo "unzip is required to extract gum zip assets." + return 1 + fi + unzip -q "${asset_path}" -d "${extract_dir}" + ;; + *) + return 1 + ;; + esac +} + +find_gum_binary() { + local search_dir="${1}" + local found_path="" + + found_path="$( + find "${search_dir}" -type f \( -name "gum" -o -name "gum.exe" \) 2>/dev/null | + head -n 1 + )" + + if [ -n "${found_path}" ]; then + printf '%s\n' "${found_path}" + return 0 + fi + + return 1 +} + +fetch_latest_gum_release_version() { + local api_payload="" + local tag_name="" + + api_payload="$(curl -fsSL "https://api.github.com/repos/charmbracelet/gum/releases/latest")" || return 1 + + if command_exists jq; then + tag_name="$(printf '%s' "${api_payload}" | jq -r '.tag_name // empty')" + else + tag_name="$(printf '%s' "${api_payload}" | sed -n 's/.*"tag_name":[[:space:]]*"\([^"]*\)".*/\1/p' | head -n 1)" + fi + + tag_name="${tag_name#v}" + if [ -z "${tag_name}" ]; then + return 1 + fi + + printf '%s\n' "${tag_name}" +} diff --git a/scripts/easy-docker/lib/install/gum/ensure.sh b/scripts/easy-docker/lib/install/gum/ensure.sh new file mode 100755 index 00000000..b7587a26 --- /dev/null +++ b/scripts/easy-docker/lib/install/gum/ensure.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash + +should_use_github_fallback() { + local answer="" + + if [ ! -t 0 ]; then + echo "GitHub fallback prompt requires an interactive terminal." + return 1 + fi + + printf "Use GitHub binary fallback for gum? [y/N]: " + read -r answer + + case "${answer}" in + y | Y | yes | YES) + return 0 + ;; + *) + return 1 + ;; + esac +} + +ensure_gum() { + local disable_installation_fallback="${1:-0}" + + if command_exists gum; then + return 0 + fi + + echo "gum is not installed. Trying package manager installation..." + + if install_gum_with_package_manager; then + hash -r + fi + + if command_exists gum; then + echo "gum was installed successfully." + return 0 + fi + + if [ "${disable_installation_fallback}" = "1" ]; then + echo "Installation fallback is disabled." + print_manual_gum_install_guidance + return 1 + fi + + if should_use_github_fallback; then + echo "Trying GitHub release fallback..." + if install_gum_from_github_release; then + hash -r + fi + else + echo "GitHub fallback was not selected." + print_manual_gum_install_guidance + return 1 + fi + + if command_exists gum; then + echo "gum was installed successfully." + return 0 + fi + + echo "Automatic installation failed." + print_manual_gum_install_guidance + return 1 +} diff --git a/scripts/easy-docker/lib/install/gum/github_release.sh b/scripts/easy-docker/lib/install/gum/github_release.sh new file mode 100755 index 00000000..d157afd0 --- /dev/null +++ b/scripts/easy-docker/lib/install/gum/github_release.sh @@ -0,0 +1,105 @@ +#!/usr/bin/env bash + +cleanup_gum_tmp_dir() { + local tmp_dir="${1:-}" + + if [ -n "${tmp_dir}" ] && [ -d "${tmp_dir}" ]; then + rm -rf "${tmp_dir}" + fi +} + +install_gum_from_github_release() { + local release_version="" + local asset_name="" + local asset_path="" + local download_url="" + local tmp_dir="" + local extract_dir="" + local target_dir="" + local gum_binary_path="" + local target_binary_name="gum" + local gum_os="" + local gum_arch="" + + if ! command_exists curl; then + echo "curl is required for the GitHub fallback." + return 1 + fi + + if ! read -r gum_os gum_arch < <(detect_gum_platform); then + echo "Unsupported platform for automatic GitHub fallback." + return 1 + fi + + release_version="$(fetch_latest_gum_release_version || true)" + if [ -z "${release_version}" ]; then + echo "Could not determine latest gum release version." + return 1 + fi + + tmp_dir="$(mktemp -d 2>/dev/null || true)" + if [ -z "${tmp_dir}" ] || [ ! -d "${tmp_dir}" ]; then + echo "Failed to create temporary directory for gum installation." + return 1 + fi + extract_dir="${tmp_dir}/extract" + + while IFS= read -r asset_name; do + asset_path="${tmp_dir}/${asset_name}" + download_url="https://github.com/charmbracelet/gum/releases/download/v${release_version}/${asset_name}" + + if ! curl -fsSL "${download_url}" -o "${asset_path}"; then + continue + fi + + rm -rf "${extract_dir}" + mkdir -p "${extract_dir}" + + if ! extract_gum_asset "${asset_path}" "${extract_dir}"; then + continue + fi + + gum_binary_path="$(find_gum_binary "${extract_dir}" || true)" + if [ -n "${gum_binary_path}" ]; then + break + fi + done < <(get_gum_asset_candidates "${release_version}" "${gum_os}" "${gum_arch}") + + if [ -z "${gum_binary_path}" ]; then + cleanup_gum_tmp_dir "${tmp_dir}" + echo "No compatible gum binary was found in GitHub release assets." + return 1 + fi + + if [[ "${gum_binary_path}" == *.exe ]]; then + target_binary_name="gum.exe" + fi + + if [ "${gum_os}" != "Windows" ] && [ -w "/usr/local/bin" ]; then + target_dir="/usr/local/bin" + if copy_binary "${gum_binary_path}" "${target_dir}/${target_binary_name}"; then + cleanup_gum_tmp_dir "${tmp_dir}" + return 0 + fi + fi + + if [ "${gum_os}" != "Windows" ] && command_exists sudo; then + if copy_binary_with_privileges "${gum_binary_path}" "/usr/local/bin/${target_binary_name}"; then + cleanup_gum_tmp_dir "${tmp_dir}" + return 0 + fi + fi + + if [ -n "${HOME:-}" ]; then + target_dir="${HOME}/.local/bin" + mkdir -p "${target_dir}" + if copy_binary "${gum_binary_path}" "${target_dir}/${target_binary_name}"; then + cleanup_gum_tmp_dir "${tmp_dir}" + return 0 + fi + fi + + cleanup_gum_tmp_dir "${tmp_dir}" + echo "Failed to install gum binary from GitHub release." + return 1 +} diff --git a/scripts/easy-docker/lib/install/gum/load.sh b/scripts/easy-docker/lib/install/gum/load.sh new file mode 100755 index 00000000..d3983a57 --- /dev/null +++ b/scripts/easy-docker/lib/install/gum/load.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash + +load_gum_install_modules() { + local gum_lib_dir="" + gum_lib_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + + # shellcheck source=scripts/easy-docker/lib/install/gum/platform.sh + source "${gum_lib_dir}/platform.sh" + # shellcheck source=scripts/easy-docker/lib/install/gum/assets.sh + source "${gum_lib_dir}/assets.sh" + # shellcheck source=scripts/easy-docker/lib/install/gum/package_manager.sh + source "${gum_lib_dir}/package_manager.sh" + # shellcheck source=scripts/easy-docker/lib/install/gum/github_release.sh + source "${gum_lib_dir}/github_release.sh" + # shellcheck source=scripts/easy-docker/lib/install/gum/ensure.sh + source "${gum_lib_dir}/ensure.sh" +} + +load_gum_install_modules diff --git a/scripts/easy-docker/lib/install/gum/package_manager.sh b/scripts/easy-docker/lib/install/gum/package_manager.sh new file mode 100755 index 00000000..8d6884c1 --- /dev/null +++ b/scripts/easy-docker/lib/install/gum/package_manager.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash + +install_gum_with_package_manager() { + local pm_attempted=0 + + if command_exists brew; then + pm_attempted=1 + if brew install gum; then + return 0 + fi + fi + + if command_exists apt-get; then + pm_attempted=1 + if run_with_privileges apt-get update && run_with_privileges apt-get install -y gum; then + return 0 + fi + fi + + if command_exists dnf; then + pm_attempted=1 + if run_with_privileges dnf install -y gum; then + return 0 + fi + fi + + if command_exists pacman; then + pm_attempted=1 + if run_with_privileges pacman -Sy --noconfirm gum; then + return 0 + fi + fi + + if command_exists zypper; then + pm_attempted=1 + if run_with_privileges zypper --non-interactive install gum; then + return 0 + fi + fi + + if command_exists winget; then + pm_attempted=1 + if winget install --id Charmbracelet.Gum -e --accept-source-agreements --accept-package-agreements; then + return 0 + fi + fi + + if command_exists choco; then + pm_attempted=1 + if choco install gum -y; then + return 0 + fi + fi + + if [ "${pm_attempted}" -eq 0 ]; then + echo "No supported package manager was found." + else + echo "Package manager installation did not succeed." + fi + + return 1 +} diff --git a/scripts/easy-docker/lib/install/gum/platform.sh b/scripts/easy-docker/lib/install/gum/platform.sh new file mode 100755 index 00000000..5f145aac --- /dev/null +++ b/scripts/easy-docker/lib/install/gum/platform.sh @@ -0,0 +1,75 @@ +#!/usr/bin/env bash + +detect_gum_platform() { + local raw_os="" + local raw_arch="" + local gum_os="" + local gum_arch="" + + raw_os="$(uname -s 2>/dev/null || echo unknown)" + raw_arch="$(uname -m 2>/dev/null || echo unknown)" + + case "${raw_os}" in + Linux*) + gum_os="Linux" + ;; + Darwin*) + gum_os="Darwin" + ;; + MINGW* | MSYS* | CYGWIN* | Windows_NT) + gum_os="Windows" + ;; + *) + return 1 + ;; + esac + + case "${raw_arch}" in + x86_64 | amd64) + gum_arch="x86_64" + ;; + aarch64 | arm64) + gum_arch="arm64" + ;; + armv7l | armv7) + gum_arch="armv7" + ;; + *) + return 1 + ;; + esac + + printf '%s %s\n' "${gum_os}" "${gum_arch}" + return 0 +} + +get_os_aliases() { + local os_name="${1}" + local os_lower="" + + os_lower="$(printf '%s' "${os_name}" | tr '[:upper:]' '[:lower:]')" + + if [ "${os_lower}" = "${os_name}" ]; then + printf '%s\n' "${os_name}" + return + fi + + printf '%s\n%s\n' "${os_name}" "${os_lower}" +} + +get_arch_aliases() { + case "${1}" in + x86_64) + printf '%s\n%s\n' "x86_64" "amd64" + ;; + arm64) + printf '%s\n%s\n' "arm64" "aarch64" + ;; + armv7) + printf '%s\n%s\n' "armv7" "armv7l" + ;; + *) + printf '%s\n' "${1}" + ;; + esac +} diff --git a/scripts/easy-docker/lib/ui.sh b/scripts/easy-docker/lib/ui/screens.sh similarity index 70% rename from scripts/easy-docker/lib/ui.sh rename to scripts/easy-docker/lib/ui/screens.sh index 097ba93c..9b2a391d 100755 --- a/scripts/easy-docker/lib/ui.sh +++ b/scripts/easy-docker/lib/ui/screens.sh @@ -8,7 +8,7 @@ render_main_screen() { clear fi - header_text="$(printf "Easy Frappe Docker\nMinimal TUI bootstrap")" + header_text="$(printf "Easy Frappe Docker\nManage Docker setups quickly and easily")" gum style \ --border rounded \ @@ -31,16 +31,20 @@ show_main_menu() { show_environment_status() { local docker_status="not installed" - local gum_status="installed" + local docker_daemon_status="not running" local status_text="" if command_exists docker; then docker_status="installed" + + if docker_daemon_running; then + docker_daemon_status="running" + fi fi render_main_screen 1 >&2 - status_text="$(printf "Environment status\n\n- gum: %s\n- docker: %s" "${gum_status}" "${docker_status}")" + status_text="$(printf "Environment status\n\n- docker: %s\n- docker daemon: %s" "${docker_status}" "${docker_daemon_status}")" gum style \ --border rounded \ @@ -58,3 +62,8 @@ show_environment_status() { "Back to main menu" \ "Exit and close easy-docker" } + +show_warning_message() { + local message="${1}" + gum style --foreground 214 "${message}" +} diff --git a/scripts/easy-docker/main.sh b/scripts/easy-docker/main.sh index b196542c..626b9b8f 100755 --- a/scripts/easy-docker/main.sh +++ b/scripts/easy-docker/main.sh @@ -3,106 +3,43 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -# shellcheck source=scripts/easy-docker/lib/env.sh -source "${SCRIPT_DIR}/lib/env.sh" -# shellcheck source=scripts/easy-docker/lib/ui.sh -source "${SCRIPT_DIR}/lib/ui.sh" +# shellcheck source=scripts/easy-docker/lib/core/commands.sh +source "${SCRIPT_DIR}/lib/core/commands.sh" +# shellcheck source=scripts/easy-docker/lib/core/messages.sh +source "${SCRIPT_DIR}/lib/core/messages.sh" +# shellcheck source=scripts/easy-docker/lib/install/gum/load.sh +source "${SCRIPT_DIR}/lib/install/gum/load.sh" +# shellcheck source=scripts/easy-docker/lib/checks/docker.sh +source "${SCRIPT_DIR}/lib/checks/docker.sh" +# shellcheck source=scripts/easy-docker/lib/ui/screens.sh +source "${SCRIPT_DIR}/lib/ui/screens.sh" +# shellcheck source=scripts/easy-docker/lib/app/screen.sh +source "${SCRIPT_DIR}/lib/app/screen.sh" +# shellcheck source=scripts/easy-docker/lib/app/options.sh +source "${SCRIPT_DIR}/lib/app/options.sh" +# shellcheck source=scripts/easy-docker/lib/app/run.sh +source "${SCRIPT_DIR}/lib/app/run.sh" -print_usage() { - cat <<'USAGE' -Usage: bash easy-docker.sh [options] - -Options: - --no-github-binary-fallback Disable GitHub binary fallback prompt - -h, --help Show this help -USAGE -} - -DISABLE_GITHUB_BINARY_FALLBACK=0 - -while [ "$#" -gt 0 ]; do - case "$1" in - --no-github-binary-fallback) - DISABLE_GITHUB_BINARY_FALLBACK=1 - ;; - -h | --help) - print_usage +disable_installation_fallback=0 +if parse_cli_options disable_installation_fallback "$@"; then + : +else + parse_status=$? + if [ "${parse_status}" -eq 2 ]; then exit 0 - ;; - *) - echo "Unknown option: $1" - print_usage - exit 1 - ;; - esac - shift -done - -ensure_gum "${DISABLE_GITHUB_BINARY_FALLBACK}" - -ALT_SCREEN_ACTIVE=0 - -enter_alt_screen() { - if [ -t 1 ] && command -v tput >/dev/null 2>&1; then - tput smcup || true - tput civis || true - ALT_SCREEN_ACTIVE=1 fi -} + exit "${parse_status}" +fi -leave_alt_screen() { - if [ "${ALT_SCREEN_ACTIVE}" = "1" ] && command -v tput >/dev/null 2>&1; then - tput cnorm || true - tput rmcup || true - ALT_SCREEN_ACTIVE=0 - fi -} +if ! ensure_gum "${disable_installation_fallback}"; then + exit 1 +fi -cleanup_screen() { - leave_alt_screen -} +if ! ensure_docker; then + exit 1 +fi -cleanup_and_exit() { - exit 0 -} +trap 'leave_alt_screen; exit 0' INT TERM +trap 'leave_alt_screen' EXIT -trap cleanup_and_exit INT TERM -trap cleanup_screen EXIT - -enter_alt_screen - -render_main_screen 1 - -while true; do - local_env_action="" - action="$(show_main_menu || true)" - - if [ -z "${action}" ]; then - cleanup_and_exit - fi - - case "${action}" in - "Environment check") - local_env_action="$(show_environment_status || true)" - case "${local_env_action}" in - "Back to main menu" | "") - render_main_screen 1 - ;; - "Exit and close easy-docker") - cleanup_and_exit - ;; - *) - gum style --foreground 214 "Unknown environment action: ${local_env_action}" - sleep 1 - ;; - esac - ;; - "Exit") - cleanup_and_exit - ;; - *) - gum style --foreground 214 "Unknown action: ${action}" - sleep 1 - ;; - esac -done +run_easy_docker_app From c2c90e62aa10cd54c193aa1a9cac613026d1dc84 Mon Sep 17 00:00:00 2001 From: RocketQuack <202538874+Rocket-Quack@users.noreply.github.com> Date: Thu, 26 Feb 2026 01:02:43 +0100 Subject: [PATCH 03/51] chore(gitignore): ignore easy-docker runtime data directory --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index 591cbaff..246f69f9 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,5 @@ node_modules # VitePress **/.vitepress/dist **/.vitepress/cache +# easy-docker local runtime data (contains secrets) +.easy-docker/ From 37122d20c1a10ea9cb4f0c640ca23c5afcdfc0a6 Mon Sep 17 00:00:00 2001 From: RocketQuack <202538874+Rocket-Quack@users.noreply.github.com> Date: Thu, 26 Feb 2026 01:03:20 +0100 Subject: [PATCH 04/51] feat(easy-docker): add production stack wizard flow and modularize tui screens --- scripts/easy-docker/lib/app/run.sh | 171 ++++++++++++++++++ scripts/easy-docker/lib/ui/screens.sh | 74 +------- scripts/easy-docker/lib/ui/screens/base.sh | 89 +++++++++ .../easy-docker/lib/ui/screens/environment.sh | 29 +++ .../easy-docker/lib/ui/screens/production.sh | 75 ++++++++ 5 files changed, 374 insertions(+), 64 deletions(-) create mode 100755 scripts/easy-docker/lib/ui/screens/base.sh create mode 100755 scripts/easy-docker/lib/ui/screens/environment.sh create mode 100755 scripts/easy-docker/lib/ui/screens/production.sh diff --git a/scripts/easy-docker/lib/app/run.sh b/scripts/easy-docker/lib/app/run.sh index 874ae91c..2f8696f9 100755 --- a/scripts/easy-docker/lib/app/run.sh +++ b/scripts/easy-docker/lib/app/run.sh @@ -1,14 +1,95 @@ #!/usr/bin/env bash +get_easy_docker_repo_root() { + local app_lib_dir="" + app_lib_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + (cd "${app_lib_dir}/../../../.." && pwd) +} + +get_easy_docker_stacks_dir() { + printf '%s/.easy-docker/stacks\n' "$(get_easy_docker_repo_root)" +} + +is_valid_stack_name() { + local stack_name="${1}" + + if [ -z "${stack_name}" ]; then + return 1 + fi + + case "${stack_name}" in + *[!A-Za-z0-9._-]*) + return 1 + ;; + *) + return 0 + ;; + esac +} + +create_stack_env_file() { + local result_var="${1}" + local stack_name="${2}" + local stacks_dir="" + local env_path="" + + stacks_dir="$(get_easy_docker_stacks_dir)" + env_path="${stacks_dir}/${stack_name}.env" + + if ! mkdir -p "${stacks_dir}"; then + return 1 + fi + + if [ -e "${env_path}" ]; then + return 2 + fi + + : >"${env_path}" + + printf -v "${result_var}" "%s" "${env_path}" + return 0 +} + +prompt_stack_name_with_cancel() { + local result_var="${1}" + local input_value="" + local input_status=0 + + input_value="$(prompt_new_stack_name)" + input_status=$? + if [ "${input_status}" -ne 0 ]; then + return 3 + fi + + input_value="$(printf '%s' "${input_value}" | tr -d '\r\n')" + + case "${input_value}" in + /cancel | /CANCEL | /Cancel) + return 3 + ;; + esac + + printf -v "${result_var}" "%s" "${input_value}" + return 0 +} + run_easy_docker_app() { local action="" local local_env_action="" + local local_production_action="" + local local_production_sub_action="" + local stack_name="" + local stack_env_path="" + local create_stack_status=0 + local stack_input_status=0 enter_alt_screen render_main_screen 1 while true; do local_env_action="" + local_production_action="" + local_production_sub_action="" action="$(show_main_menu || true)" if [ -z "${action}" ]; then @@ -16,6 +97,96 @@ run_easy_docker_app() { fi case "${action}" in + "Production setup") + while true; do + local_production_action="$(show_production_setup_menu || true)" + case "${local_production_action}" in + "Create new stack") + while true; do + stack_name="" + if ! prompt_stack_name_with_cancel stack_name; then + stack_input_status=$? + if [ "${stack_input_status}" -eq 3 ]; then + break + fi + + show_warning_message "Input canceled." + sleep 1 + break + fi + + if [ -z "${stack_name}" ]; then + break + fi + + if ! is_valid_stack_name "${stack_name}"; then + show_warning_message "Invalid stack name. Use letters, numbers, dot, underscore, or hyphen." + sleep 2 + continue + fi + + stack_env_path="" + if create_stack_env_file stack_env_path "${stack_name}"; then + local_production_sub_action="$(show_create_stack_created "${stack_name}" "${stack_env_path}" || true)" + else + create_stack_status=$? + if [ "${create_stack_status}" -eq 2 ]; then + show_warning_message "Stack already exists: ${stack_name}" + sleep 2 + continue + else + show_warning_message "Could not create stack env file for: ${stack_name}" + sleep 2 + break + fi + fi + + case "${local_production_sub_action}" in + "Continue stack wizard") + show_warning_message "Next wizard step is coming soon." + sleep 2 + ;; + "Back to production setup" | "") ;; + *) + show_warning_message "Unknown create-stack action: ${local_production_sub_action}" + sleep 1 + ;; + esac + + break + done + ;; + "Manage existing stacks") + local_production_sub_action="$(show_manage_stacks_placeholder || true)" + case "${local_production_sub_action}" in + "Back to production setup") ;; + "Back to main menu" | "") + render_main_screen 1 + break + ;; + "Exit and close easy-docker") + return 0 + ;; + *) + show_warning_message "Unknown manage-stacks action: ${local_production_sub_action}" + sleep 1 + ;; + esac + ;; + "Back to main menu" | "") + render_main_screen 1 + break + ;; + "Exit and close easy-docker") + return 0 + ;; + *) + show_warning_message "Unknown production action: ${local_production_action}" + sleep 1 + ;; + esac + done + ;; "Environment check") local_env_action="$(show_environment_status || true)" case "${local_env_action}" in diff --git a/scripts/easy-docker/lib/ui/screens.sh b/scripts/easy-docker/lib/ui/screens.sh index 9b2a391d..80456d69 100755 --- a/scripts/easy-docker/lib/ui/screens.sh +++ b/scripts/easy-docker/lib/ui/screens.sh @@ -1,69 +1,15 @@ #!/usr/bin/env bash -render_main_screen() { - local clear_screen="${1:-0}" - local header_text="" +load_ui_screen_modules() { + local screens_dir="" + screens_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/screens" - if [ "${clear_screen}" = "1" ]; then - clear - fi - - header_text="$(printf "Easy Frappe Docker\nManage Docker setups quickly and easily")" - - gum style \ - --border rounded \ - --border-foreground 63 \ - --padding "1 2" \ - --margin "1 2" \ - --foreground 252 \ - "${header_text}" + # shellcheck source=scripts/easy-docker/lib/ui/screens/base.sh + source "${screens_dir}/base.sh" + # shellcheck source=scripts/easy-docker/lib/ui/screens/production.sh + source "${screens_dir}/production.sh" + # shellcheck source=scripts/easy-docker/lib/ui/screens/environment.sh + source "${screens_dir}/environment.sh" } -show_main_menu() { - gum choose \ - --height 7 \ - --header "Choose an action" \ - --cursor.foreground 63 \ - --selected.foreground 45 \ - "Environment check" \ - "Exit" -} - -show_environment_status() { - local docker_status="not installed" - local docker_daemon_status="not running" - local status_text="" - - if command_exists docker; then - docker_status="installed" - - if docker_daemon_running; then - docker_daemon_status="running" - fi - fi - - render_main_screen 1 >&2 - - status_text="$(printf "Environment status\n\n- docker: %s\n- docker daemon: %s" "${docker_status}" "${docker_daemon_status}")" - - gum style \ - --border rounded \ - --border-foreground 63 \ - --padding "1 2" \ - --margin "0 2" \ - --foreground 252 \ - "${status_text}" >&2 - - gum choose \ - --height 6 \ - --header "Environment actions" \ - --cursor.foreground 63 \ - --selected.foreground 45 \ - "Back to main menu" \ - "Exit and close easy-docker" -} - -show_warning_message() { - local message="${1}" - gum style --foreground 214 "${message}" -} +load_ui_screen_modules diff --git a/scripts/easy-docker/lib/ui/screens/base.sh b/scripts/easy-docker/lib/ui/screens/base.sh new file mode 100755 index 00000000..e7d0a76f --- /dev/null +++ b/scripts/easy-docker/lib/ui/screens/base.sh @@ -0,0 +1,89 @@ +#!/usr/bin/env bash + +get_terminal_cols() { + local cols="80" + + if command_exists tput; then + cols="$(tput cols 2>/dev/null || printf "80")" + fi + + if ! [[ "${cols}" =~ ^[0-9]+$ ]] || [ "${cols}" -le 0 ]; then + cols="80" + fi + + printf '%s\n' "${cols}" +} + +get_box_wrap_width() { + local cols="" + local width="" + + cols="$(get_terminal_cols)" + width="$((cols - 16))" + + if [ "${width}" -lt 12 ]; then + width="12" + fi + + printf '%s\n' "${width}" +} + +wrap_box_text() { + local raw_text="${1}" + local wrap_width="" + + wrap_width="$(get_box_wrap_width)" + + if command_exists fold; then + printf '%s' "${raw_text}" | fold -s -w "${wrap_width}" + return + fi + + printf '%s' "${raw_text}" +} + +render_box_message() { + local raw_text="${1}" + local margin="${2:-0 2}" + local padding="${3:-0 1}" + local wrapped_text="" + + wrapped_text="$(wrap_box_text "${raw_text}")" + + gum style \ + --border rounded \ + --border-foreground 63 \ + --padding "${padding}" \ + --margin "${margin}" \ + --foreground 252 \ + "${wrapped_text}" +} + +render_main_screen() { + local clear_screen="${1:-0}" + local header_text="" + + if [ "${clear_screen}" = "1" ]; then + clear + fi + + header_text="$(printf "Easy Frappe Docker\nManage Docker setups quickly and easily")" + + render_box_message "${header_text}" "1 2" "0 1" +} + +show_main_menu() { + gum choose \ + --height 7 \ + --header "Choose an action" \ + --cursor.foreground 63 \ + --selected.foreground 45 \ + "Production setup" \ + "Environment check" \ + "Exit" +} + +show_warning_message() { + local message="${1}" + gum style --foreground 214 "${message}" +} diff --git a/scripts/easy-docker/lib/ui/screens/environment.sh b/scripts/easy-docker/lib/ui/screens/environment.sh new file mode 100755 index 00000000..c5248e89 --- /dev/null +++ b/scripts/easy-docker/lib/ui/screens/environment.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash + +show_environment_status() { + local docker_status="not installed" + local docker_daemon_status="not running" + local status_text="" + + if command_exists docker; then + docker_status="installed" + + if docker_daemon_running; then + docker_daemon_status="running" + fi + fi + + render_main_screen 1 >&2 + + status_text="$(printf "Environment status\n\n- docker: %s\n- docker daemon: %s" "${docker_status}" "${docker_daemon_status}")" + + render_box_message "${status_text}" "0 2" >&2 + + gum choose \ + --height 6 \ + --header "Environment actions" \ + --cursor.foreground 63 \ + --selected.foreground 45 \ + "Back to main menu" \ + "Exit and close easy-docker" +} diff --git a/scripts/easy-docker/lib/ui/screens/production.sh b/scripts/easy-docker/lib/ui/screens/production.sh new file mode 100755 index 00000000..90c1acac --- /dev/null +++ b/scripts/easy-docker/lib/ui/screens/production.sh @@ -0,0 +1,75 @@ +#!/usr/bin/env bash + +show_production_setup_menu() { + local status_text="" + + render_main_screen 1 >&2 + + status_text="$(printf "Production setup\n\nChoose whether to create a new stack or manage an existing one.")" + + render_box_message "${status_text}" "0 2" >&2 + + gum choose \ + --height 8 \ + --header "Production setup actions" \ + --cursor.foreground 63 \ + --selected.foreground 45 \ + "Create new stack" \ + "Manage existing stacks" \ + "Back to main menu" \ + "Exit and close easy-docker" +} + +prompt_new_stack_name() { + local status_text="" + + render_main_screen 1 >&2 + + status_text="$(printf "Create new stack\n\nEnter a stack name.\nType /cancel or press Ctrl+C to abort.")" + + render_box_message "${status_text}" "0 2" >&2 + + gum input \ + --header "Stack name (/cancel to abort)" \ + --prompt "name> " \ + --placeholder "my-production-stack" +} + +show_create_stack_created() { + local stack_name="${1}" + local env_path="${2}" + local status_text="" + + render_main_screen 1 >&2 + + status_text="$(printf "Create new stack\n\nStack created: %s\nEnv file: %s" "${stack_name}" "${env_path}")" + + render_box_message "${status_text}" "0 2" >&2 + + gum choose \ + --height 6 \ + --header "Create stack actions" \ + --cursor.foreground 63 \ + --selected.foreground 45 \ + "Continue stack wizard" \ + "Back to production setup" +} + +show_manage_stacks_placeholder() { + local status_text="" + + render_main_screen 1 >&2 + + status_text="$(printf "Manage existing stacks")" + + render_box_message "${status_text}" "0 2" >&2 + + gum choose \ + --height 7 \ + --header "Manage stacks actions" \ + --cursor.foreground 63 \ + --selected.foreground 45 \ + "Back to production setup" \ + "Back to main menu" \ + "Exit and close easy-docker" +} From 32f94c5f5112eb265e04a287cfd84a74b6a9faf2 Mon Sep 17 00:00:00 2001 From: RocketQuack <202538874+Rocket-Quack@users.noreply.github.com> Date: Fri, 27 Feb 2026 02:03:44 +0100 Subject: [PATCH 05/51] refactor(easy-docker): modularize loader and production flow --- scripts/easy-docker/lib/app/run.sh | 458 +++++++++++++----- scripts/easy-docker/lib/load.sh | 25 + .../easy-docker/lib/ui/screens/production.sh | 90 +++- scripts/easy-docker/main.sh | 18 +- 4 files changed, 447 insertions(+), 144 deletions(-) create mode 100755 scripts/easy-docker/lib/load.sh diff --git a/scripts/easy-docker/lib/app/run.sh b/scripts/easy-docker/lib/app/run.sh index 2f8696f9..e2d0abb2 100755 --- a/scripts/easy-docker/lib/app/run.sh +++ b/scripts/easy-docker/lib/app/run.sh @@ -1,5 +1,10 @@ #!/usr/bin/env bash +readonly FLOW_CONTINUE=0 +readonly FLOW_BACK_TO_MAIN=10 +readonly FLOW_EXIT_APP=11 +readonly FLOW_ABORT_INPUT=12 + get_easy_docker_repo_root() { local app_lib_dir="" app_lib_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" @@ -10,6 +15,10 @@ get_easy_docker_stacks_dir() { printf '%s/.easy-docker/stacks\n' "$(get_easy_docker_repo_root)" } +get_current_utc_timestamp() { + date -u +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null || date +"%Y-%m-%dT%H:%M:%SZ" +} + is_valid_stack_name() { local stack_name="${1}" @@ -27,29 +36,69 @@ is_valid_stack_name() { esac } -create_stack_env_file() { - local result_var="${1}" +create_stack_directory_with_metadata() { + local stack_dir_var="${1}" local stack_name="${2}" local stacks_dir="" - local env_path="" + local created_stack_dir="" + local metadata_path="" + local created_at="" stacks_dir="$(get_easy_docker_stacks_dir)" - env_path="${stacks_dir}/${stack_name}.env" + created_stack_dir="${stacks_dir}/${stack_name}" + metadata_path="${created_stack_dir}/metadata.json" if ! mkdir -p "${stacks_dir}"; then return 1 fi - if [ -e "${env_path}" ]; then + if [ -e "${created_stack_dir}" ]; then return 2 fi - : >"${env_path}" + if ! mkdir -p "${created_stack_dir}"; then + return 1 + fi - printf -v "${result_var}" "%s" "${env_path}" + created_at="$(get_current_utc_timestamp)" + if ! cat >"${metadata_path}" </dev/null 2>&1 || true + return 1 + fi + + printf -v "${stack_dir_var}" "%s" "${created_stack_dir}" return 0 } +rollback_stack_directory() { + local stack_dir="${1}" + local stacks_dir="" + + if [ -z "${stack_dir}" ]; then + return 1 + fi + + stacks_dir="$(get_easy_docker_stacks_dir)" + case "${stack_dir}" in + "${stacks_dir}"/*) ;; + *) + return 2 + ;; + esac + + if [ ! -d "${stack_dir}" ]; then + return 0 + fi + + rm -rf -- "${stack_dir}" +} + prompt_stack_name_with_cancel() { local result_var="${1}" local input_value="" @@ -58,38 +107,283 @@ prompt_stack_name_with_cancel() { input_value="$(prompt_new_stack_name)" input_status=$? if [ "${input_status}" -ne 0 ]; then - return 3 + return "${FLOW_ABORT_INPUT}" fi input_value="$(printf '%s' "${input_value}" | tr -d '\r\n')" case "${input_value}" in /cancel | /CANCEL | /Cancel) - return 3 + return "${FLOW_ABORT_INPUT}" ;; esac printf -v "${result_var}" "%s" "${input_value}" - return 0 + return "${FLOW_CONTINUE}" +} + +show_warning_and_wait() { + local message="${1}" + local seconds="${2:-1}" + + show_warning_message "${message}" + sleep "${seconds}" +} + +handle_topology_examples_flow() { + local topology_name="${1}" + local detail_action="" + + case "${topology_name}" in + "Single-host") + detail_action="$(show_single_host_examples || true)" + ;; + "Split services") + detail_action="$(show_split_services_examples || true)" + ;; + "Advanced") + detail_action="$(show_advanced_examples || true)" + ;; + *) + show_warning_and_wait "Unknown topology: ${topology_name}" + return "${FLOW_CONTINUE}" + ;; + esac + + case "${detail_action}" in + "Use this topology") + show_warning_and_wait "Topology '${topology_name}' selected. Next wizard step is coming soon." 2 + return "${FLOW_CONTINUE}" + ;; + "Back to topology selection" | "") + return "${FLOW_CONTINUE}" + ;; + *) + show_warning_and_wait "Unknown topology action: ${detail_action}" + return "${FLOW_CONTINUE}" + ;; + esac +} + +handle_abort_wizard_flow() { + local stack_dir="${1}" + local abort_action="" + local rollback_status=0 + + abort_action="$(show_abort_wizard_prompt "${stack_dir}" || true)" + case "${abort_action}" in + "Rollback files and return to main menu") + if rollback_stack_directory "${stack_dir}"; then + return "${FLOW_BACK_TO_MAIN}" + fi + + rollback_status=$? + if [ "${rollback_status}" -eq 2 ]; then + show_warning_and_wait "Refused rollback for unsafe path: ${stack_dir}" 2 + else + show_warning_and_wait "Could not rollback stack files: ${stack_dir}" 2 + fi + return "${FLOW_CONTINUE}" + ;; + "Keep files and return to main menu") + return "${FLOW_BACK_TO_MAIN}" + ;; + "Back to topology selection" | "") + return "${FLOW_CONTINUE}" + ;; + *) + show_warning_and_wait "Unknown abort action: ${abort_action}" + return "${FLOW_CONTINUE}" + ;; + esac +} + +handle_stack_topology_flow() { + local stack_dir="${1}" + local topology_action="" + local abort_status=0 + + while true; do + topology_action="$(show_stack_topology_menu "${stack_dir}" || true)" + case "${topology_action}" in + "Single-host" | "Split services" | "Advanced") + handle_topology_examples_flow "${topology_action}" + ;; + "Abort wizard to main menu") + handle_abort_wizard_flow "${stack_dir}" + abort_status=$? + case "${abort_status}" in + "${FLOW_BACK_TO_MAIN}") + return "${FLOW_BACK_TO_MAIN}" + ;; + *) ;; + esac + ;; + "") + return "${FLOW_CONTINUE}" + ;; + *) + show_warning_and_wait "Unknown topology selection: ${topology_action}" + ;; + esac + done +} + +handle_create_new_stack_flow() { + local stack_name="" + local stack_dir="" + local create_stack_status=0 + local stack_input_status=0 + local topology_status=0 + + while true; do + stack_name="" + if prompt_stack_name_with_cancel stack_name; then + : + else + stack_input_status=$? + if [ "${stack_input_status}" -eq "${FLOW_ABORT_INPUT}" ]; then + return "${FLOW_CONTINUE}" + fi + + show_warning_and_wait "Input canceled." + return "${FLOW_CONTINUE}" + fi + + if [ -z "${stack_name}" ]; then + return "${FLOW_CONTINUE}" + fi + + if ! is_valid_stack_name "${stack_name}"; then + show_warning_and_wait "Invalid stack name. Use letters, numbers, dot, underscore, or hyphen." 2 + continue + fi + + stack_dir="" + if create_stack_directory_with_metadata stack_dir "${stack_name}"; then + handle_stack_topology_flow "${stack_dir}" + topology_status=$? + case "${topology_status}" in + "${FLOW_BACK_TO_MAIN}") + return "${FLOW_BACK_TO_MAIN}" + ;; + "${FLOW_EXIT_APP}") + return "${FLOW_EXIT_APP}" + ;; + *) + return "${FLOW_CONTINUE}" + ;; + esac + else + create_stack_status=$? + if [ "${create_stack_status}" -eq 2 ]; then + show_warning_and_wait "Stack already exists: ${stack_name}" 2 + continue + fi + + show_warning_and_wait "Could not create stack directory for: ${stack_name}" 2 + return "${FLOW_CONTINUE}" + fi + done +} + +handle_manage_existing_stacks_flow() { + local manage_action="" + + manage_action="$(show_manage_stacks_placeholder || true)" + case "${manage_action}" in + "Back to production setup") + return "${FLOW_CONTINUE}" + ;; + "Back to main menu" | "") + return "${FLOW_BACK_TO_MAIN}" + ;; + "Exit and close easy-docker") + return "${FLOW_EXIT_APP}" + ;; + *) + show_warning_and_wait "Unknown manage-stacks action: ${manage_action}" + return "${FLOW_CONTINUE}" + ;; + esac +} + +handle_production_setup_flow() { + local production_action="" + + while true; do + production_action="$(show_production_setup_menu || true)" + + case "${production_action}" in + "Create new stack") + if handle_create_new_stack_flow; then + : + else + case "$?" in + "${FLOW_BACK_TO_MAIN}") + return "${FLOW_BACK_TO_MAIN}" + ;; + "${FLOW_EXIT_APP}") + return "${FLOW_EXIT_APP}" + ;; + *) ;; + esac + fi + ;; + "Manage existing stacks") + if handle_manage_existing_stacks_flow; then + : + else + case "$?" in + "${FLOW_BACK_TO_MAIN}") + return "${FLOW_BACK_TO_MAIN}" + ;; + "${FLOW_EXIT_APP}") + return "${FLOW_EXIT_APP}" + ;; + *) ;; + esac + fi + ;; + "Back to main menu" | "") + return "${FLOW_BACK_TO_MAIN}" + ;; + "Exit and close easy-docker") + return "${FLOW_EXIT_APP}" + ;; + *) + show_warning_and_wait "Unknown production action: ${production_action}" + ;; + esac + done +} + +handle_environment_check_flow() { + local environment_action="" + + environment_action="$(show_environment_status || true)" + case "${environment_action}" in + "Back to main menu" | "") + return "${FLOW_BACK_TO_MAIN}" + ;; + "Exit and close easy-docker") + return "${FLOW_EXIT_APP}" + ;; + *) + show_warning_and_wait "Unknown environment action: ${environment_action}" + return "${FLOW_CONTINUE}" + ;; + esac } run_easy_docker_app() { local action="" - local local_env_action="" - local local_production_action="" - local local_production_sub_action="" - local stack_name="" - local stack_env_path="" - local create_stack_status=0 - local stack_input_status=0 + local handler_status=0 enter_alt_screen render_main_screen 1 while true; do - local_env_action="" - local_production_action="" - local_production_sub_action="" action="$(show_main_menu || true)" if [ -z "${action}" ]; then @@ -98,116 +392,42 @@ run_easy_docker_app() { case "${action}" in "Production setup") - while true; do - local_production_action="$(show_production_setup_menu || true)" - case "${local_production_action}" in - "Create new stack") - while true; do - stack_name="" - if ! prompt_stack_name_with_cancel stack_name; then - stack_input_status=$? - if [ "${stack_input_status}" -eq 3 ]; then - break - fi - - show_warning_message "Input canceled." - sleep 1 - break - fi - - if [ -z "${stack_name}" ]; then - break - fi - - if ! is_valid_stack_name "${stack_name}"; then - show_warning_message "Invalid stack name. Use letters, numbers, dot, underscore, or hyphen." - sleep 2 - continue - fi - - stack_env_path="" - if create_stack_env_file stack_env_path "${stack_name}"; then - local_production_sub_action="$(show_create_stack_created "${stack_name}" "${stack_env_path}" || true)" - else - create_stack_status=$? - if [ "${create_stack_status}" -eq 2 ]; then - show_warning_message "Stack already exists: ${stack_name}" - sleep 2 - continue - else - show_warning_message "Could not create stack env file for: ${stack_name}" - sleep 2 - break - fi - fi - - case "${local_production_sub_action}" in - "Continue stack wizard") - show_warning_message "Next wizard step is coming soon." - sleep 2 - ;; - "Back to production setup" | "") ;; - *) - show_warning_message "Unknown create-stack action: ${local_production_sub_action}" - sleep 1 - ;; - esac - - break - done - ;; - "Manage existing stacks") - local_production_sub_action="$(show_manage_stacks_placeholder || true)" - case "${local_production_sub_action}" in - "Back to production setup") ;; - "Back to main menu" | "") - render_main_screen 1 - break - ;; - "Exit and close easy-docker") - return 0 - ;; - *) - show_warning_message "Unknown manage-stacks action: ${local_production_sub_action}" - sleep 1 - ;; - esac - ;; - "Back to main menu" | "") - render_main_screen 1 - break - ;; - "Exit and close easy-docker") - return 0 - ;; - *) - show_warning_message "Unknown production action: ${local_production_action}" - sleep 1 - ;; - esac - done - ;; - "Environment check") - local_env_action="$(show_environment_status || true)" - case "${local_env_action}" in - "Back to main menu" | "") + if handle_production_setup_flow; then + handler_status="${FLOW_CONTINUE}" + else + handler_status=$? + fi + case "${handler_status}" in + "${FLOW_BACK_TO_MAIN}") render_main_screen 1 ;; - "Exit and close easy-docker") + "${FLOW_EXIT_APP}") return 0 ;; - *) - show_warning_message "Unknown environment action: ${local_env_action}" - sleep 1 + *) ;; + esac + ;; + "Environment check") + if handle_environment_check_flow; then + handler_status="${FLOW_CONTINUE}" + else + handler_status=$? + fi + case "${handler_status}" in + "${FLOW_BACK_TO_MAIN}") + render_main_screen 1 ;; + "${FLOW_EXIT_APP}") + return 0 + ;; + *) ;; esac ;; "Exit") return 0 ;; *) - show_warning_message "Unknown action: ${action}" - sleep 1 + show_warning_and_wait "Unknown action: ${action}" ;; esac done diff --git a/scripts/easy-docker/lib/load.sh b/scripts/easy-docker/lib/load.sh new file mode 100755 index 00000000..3d76d77c --- /dev/null +++ b/scripts/easy-docker/lib/load.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash + +load_easy_docker_modules() { + local lib_dir="" + lib_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + + # shellcheck source=scripts/easy-docker/lib/core/commands.sh + source "${lib_dir}/core/commands.sh" + # shellcheck source=scripts/easy-docker/lib/core/messages.sh + source "${lib_dir}/core/messages.sh" + # shellcheck source=scripts/easy-docker/lib/install/gum/load.sh + source "${lib_dir}/install/gum/load.sh" + # shellcheck source=scripts/easy-docker/lib/checks/docker.sh + source "${lib_dir}/checks/docker.sh" + # shellcheck source=scripts/easy-docker/lib/ui/screens.sh + source "${lib_dir}/ui/screens.sh" + # shellcheck source=scripts/easy-docker/lib/app/screen.sh + source "${lib_dir}/app/screen.sh" + # shellcheck source=scripts/easy-docker/lib/app/options.sh + source "${lib_dir}/app/options.sh" + # shellcheck source=scripts/easy-docker/lib/app/run.sh + source "${lib_dir}/app/run.sh" +} + +load_easy_docker_modules diff --git a/scripts/easy-docker/lib/ui/screens/production.sh b/scripts/easy-docker/lib/ui/screens/production.sh index 90c1acac..54f73369 100755 --- a/scripts/easy-docker/lib/ui/screens/production.sh +++ b/scripts/easy-docker/lib/ui/screens/production.sh @@ -35,24 +35,96 @@ prompt_new_stack_name() { --placeholder "my-production-stack" } -show_create_stack_created() { - local stack_name="${1}" - local env_path="${2}" +show_stack_topology_menu() { + local stack_dir="${1}" + local stack_name="" local status_text="" render_main_screen 1 >&2 - status_text="$(printf "Create new stack\n\nStack created: %s\nEnv file: %s" "${stack_name}" "${env_path}")" - + stack_name="${stack_dir##*/}" + status_text="$(printf "Stack created: %s\nDirectory: %s\n\nChoose the deployment topology." "${stack_name}" "${stack_dir}")" render_box_message "${status_text}" "0 2" >&2 gum choose \ - --height 6 \ - --header "Create stack actions" \ + --height 9 \ + --header "Topology" \ --cursor.foreground 63 \ --selected.foreground 45 \ - "Continue stack wizard" \ - "Back to production setup" + "Single-host" \ + "Split services" \ + "Advanced" \ + "Abort wizard to main menu" +} + +show_single_host_examples() { + local status_text="" + + render_main_screen 1 >&2 + + status_text="$(printf "Single-host examples\n\n- One server, one compose project.\n- Local DB/Redis/Proxy with app services together.\n- Typical small production VM setup.")" + render_box_message "${status_text}" "0 2" >&2 + + gum choose \ + --height 7 \ + --header "Single-host" \ + --cursor.foreground 63 \ + --selected.foreground 45 \ + "Use this topology" \ + "Back to topology selection" +} + +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_advanced_examples() { + local status_text="" + + render_main_screen 1 >&2 + + status_text="$(printf "Advanced examples\n\n- Managed external DB/Redis.\n- Multiple benches with custom images/tags.\n- GitOps-style rendered compose and custom networks/secrets.")" + render_box_message "${status_text}" "0 2" >&2 + + gum choose \ + --height 7 \ + --header "Advanced" \ + --cursor.foreground 63 \ + --selected.foreground 45 \ + "Use this topology" \ + "Back to topology selection" +} + +show_abort_wizard_prompt() { + local stack_dir="${1}" + local status_text="" + + render_main_screen 1 >&2 + + status_text="$(printf "Abort wizard\n\nStack directory:\n%s\n\nRollback created files before returning to main menu?" "${stack_dir}")" + render_box_message "${status_text}" "0 2" >&2 + + gum choose \ + --height 8 \ + --header "Abort options" \ + --cursor.foreground 63 \ + --selected.foreground 45 \ + "Rollback files and return to main menu" \ + "Keep files and return to main menu" \ + "Back to topology selection" } show_manage_stacks_placeholder() { diff --git a/scripts/easy-docker/main.sh b/scripts/easy-docker/main.sh index 626b9b8f..16f171f3 100755 --- a/scripts/easy-docker/main.sh +++ b/scripts/easy-docker/main.sh @@ -3,22 +3,8 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -# shellcheck source=scripts/easy-docker/lib/core/commands.sh -source "${SCRIPT_DIR}/lib/core/commands.sh" -# shellcheck source=scripts/easy-docker/lib/core/messages.sh -source "${SCRIPT_DIR}/lib/core/messages.sh" -# shellcheck source=scripts/easy-docker/lib/install/gum/load.sh -source "${SCRIPT_DIR}/lib/install/gum/load.sh" -# shellcheck source=scripts/easy-docker/lib/checks/docker.sh -source "${SCRIPT_DIR}/lib/checks/docker.sh" -# shellcheck source=scripts/easy-docker/lib/ui/screens.sh -source "${SCRIPT_DIR}/lib/ui/screens.sh" -# shellcheck source=scripts/easy-docker/lib/app/screen.sh -source "${SCRIPT_DIR}/lib/app/screen.sh" -# shellcheck source=scripts/easy-docker/lib/app/options.sh -source "${SCRIPT_DIR}/lib/app/options.sh" -# shellcheck source=scripts/easy-docker/lib/app/run.sh -source "${SCRIPT_DIR}/lib/app/run.sh" +# shellcheck source=scripts/easy-docker/lib/load.sh +source "${SCRIPT_DIR}/lib/load.sh" disable_installation_fallback=0 if parse_cli_options disable_installation_fallback "$@"; then From 759e0822a8ba15f6eb6cddbb8b56ffcc4ddcfc68 Mon Sep 17 00:00:00 2001 From: RocketQuack <202538874+Rocket-Quack@users.noreply.github.com> Date: Sat, 28 Feb 2026 00:13:39 +0100 Subject: [PATCH 06/51] feat(easy-docker-ui): add stack type and manage stack menus --- scripts/easy-docker/lib/ui/screens/base.sh | 5 +- .../easy-docker/lib/ui/screens/production.sh | 272 +++++++++++++++--- 2 files changed, 241 insertions(+), 36 deletions(-) diff --git a/scripts/easy-docker/lib/ui/screens/base.sh b/scripts/easy-docker/lib/ui/screens/base.sh index e7d0a76f..2b02c3fc 100755 --- a/scripts/easy-docker/lib/ui/screens/base.sh +++ b/scripts/easy-docker/lib/ui/screens/base.sh @@ -74,11 +74,12 @@ render_main_screen() { show_main_menu() { gum choose \ - --height 7 \ + --height 8 \ --header "Choose an action" \ --cursor.foreground 63 \ --selected.foreground 45 \ - "Production setup" \ + "Production Stack" \ + "Development Stack" \ "Environment check" \ "Exit" } diff --git a/scripts/easy-docker/lib/ui/screens/production.sh b/scripts/easy-docker/lib/ui/screens/production.sh index 54f73369..4098d6e0 100755 --- a/scripts/easy-docker/lib/ui/screens/production.sh +++ b/scripts/easy-docker/lib/ui/screens/production.sh @@ -1,25 +1,49 @@ #!/usr/bin/env bash -show_production_setup_menu() { +get_setup_display_label() { + local setup_type="${1}" + + case "${setup_type}" in + development) + printf 'Development' + ;; + production | *) + printf 'Production' + ;; + esac +} + +show_setup_menu() { + local setup_type="${1}" + local setup_label="" local status_text="" render_main_screen 1 >&2 - status_text="$(printf "Production setup\n\nChoose whether to create a new stack or manage an existing one.")" + setup_label="$(get_setup_display_label "${setup_type}")" + status_text="$(printf "%s stack\n\nChoose whether to create a new stack or manage an existing one." "${setup_label}")" render_box_message "${status_text}" "0 2" >&2 gum choose \ --height 8 \ - --header "Production setup actions" \ + --header "${setup_label} stack actions" \ --cursor.foreground 63 \ --selected.foreground 45 \ "Create new stack" \ "Manage existing stacks" \ - "Back to main menu" \ + "Back" \ "Exit and close easy-docker" } +show_production_setup_menu() { + show_setup_menu "production" +} + +show_development_setup_menu() { + show_setup_menu "development" +} + prompt_new_stack_name() { local status_text="" @@ -43,37 +67,139 @@ 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." "${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." "${stack_name}" "${stack_dir}")" render_box_message "${status_text}" "0 2" >&2 gum choose \ - --height 9 \ + --height 8 \ --header "Topology" \ --cursor.foreground 63 \ --selected.foreground 45 \ - "Single-host" \ + "Single-host (recommended)" \ "Split services" \ - "Advanced" \ "Abort wizard to main menu" } -show_single_host_examples() { +show_single_host_proxy_menu() { + local stack_dir="${1}" + local stack_name="" local status_text="" render_main_screen 1 >&2 - status_text="$(printf "Single-host examples\n\n- One server, one compose project.\n- Local DB/Redis/Proxy with app services together.\n- Typical small production VM setup.")" + 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}")" render_box_message "${status_text}" "0 2" >&2 gum choose \ - --height 7 \ - --header "Single-host" \ + --height 11 \ + --header "Single-host: proxy mode" \ --cursor.foreground 63 \ --selected.foreground 45 \ - "Use this topology" \ + "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_single_host_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\nSingle-host setup (step 2/3)\nChoose the database service." "${stack_name}")" + render_box_message "${status_text}" "0 2" >&2 + + gum choose \ + --height 8 \ + --header "Single-host: database" \ + --cursor.foreground 63 \ + --selected.foreground 45 \ + "MariaDB (recommended)" \ + "PostgreSQL" \ + "Back to topology selection" +} + +show_single_host_redis_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\nSingle-host setup (step 3/3)\nChoose whether Redis services should be included." "${stack_name}")" + render_box_message "${status_text}" "0 2" >&2 + + gum choose \ + --height 8 \ + --header "Single-host: redis" \ + --cursor.foreground 63 \ + --selected.foreground 45 \ + "Include Redis (recommended)" \ + "Skip Redis (experienced users only)" \ + "Back to topology selection" +} + +show_custom_modular_apps_multi_select() { + local stack_dir="${1}" + local back_option_label="${2:-Back to topology selection}" + local stack_name="" + local status_text="" + + render_main_screen 1 >&2 + + stack_name="${stack_dir##*/}" + status_text="$(printf "Stack: %s\n\nCustom modular apps\nSelect one or more options.\nUse Space to toggle and Enter to confirm." "${stack_name}")" + render_box_message "${status_text}" "0 2" >&2 + + gum choose \ + --no-limit \ + --height 10 \ + --header "Custom modular apps" \ + --cursor.foreground 63 \ + --selected.foreground 45 \ + "ERPNext" \ + "CRM" \ + "Custom Git app(s)" \ + "${back_option_label}" +} + +prompt_single_host_env_value() { + local stack_dir="${1}" + local variable_name="${2}" + local guidance_text="${3}" + local placeholder="${4:-}" + local render_context="${5:-1}" + local input_feedback="${6:-}" + local stack_name="" + local status_text="" + + if [ "${render_context}" = "1" ]; then + render_main_screen 1 >&2 + + stack_name="${stack_dir##*/}" + guidance_text="${guidance_text//\\n/$'\n'}" + status_text="$(printf "Stack: %s\n\nConfigure %s\n\n%s" "${stack_name}" "${variable_name}" "${guidance_text}")" + render_box_message "${status_text}" "0 2" >&2 + fi + + if [ -n "${input_feedback}" ]; then + gum style --foreground 214 "${input_feedback}" >&2 + fi + + gum input \ + --header "${variable_name}" \ + --prompt "value> " \ + --placeholder "${placeholder}" +} + show_split_services_examples() { local status_text="" @@ -91,23 +217,6 @@ show_split_services_examples() { "Back to topology selection" } -show_advanced_examples() { - local status_text="" - - render_main_screen 1 >&2 - - status_text="$(printf "Advanced examples\n\n- Managed external DB/Redis.\n- Multiple benches with custom images/tags.\n- GitOps-style rendered compose and custom networks/secrets.")" - render_box_message "${status_text}" "0 2" >&2 - - gum choose \ - --height 7 \ - --header "Advanced" \ - --cursor.foreground 63 \ - --selected.foreground 45 \ - "Use this topology" \ - "Back to topology selection" -} - show_abort_wizard_prompt() { local stack_dir="${1}" local status_text="" @@ -127,12 +236,43 @@ show_abort_wizard_prompt() { "Back to topology selection" } -show_manage_stacks_placeholder() { +show_manage_stacks_menu() { + local setup_type="${1}" + shift + local stack_count="${#}" + local setup_label="" local status_text="" render_main_screen 1 >&2 - status_text="$(printf "Manage existing stacks")" + setup_label="$(get_setup_display_label "${setup_type}")" + if [ "${stack_count}" -eq 1 ]; then + status_text="$(printf "Manage existing %s stacks\n\n1 stack found. Select a stack." "${setup_label}")" + else + status_text="$(printf "Manage existing %s stacks\n\n%s stacks found. Select a stack." "${setup_label}" "${stack_count}")" + fi + + render_box_message "${status_text}" "0 2" >&2 + + gum choose \ + --height 14 \ + --header "Existing stacks" \ + --cursor.foreground 63 \ + --selected.foreground 45 \ + "$@" \ + "Back" \ + "Exit and close easy-docker" +} + +show_manage_stacks_placeholder() { + local setup_type="${1}" + local setup_label="" + local status_text="" + + render_main_screen 1 >&2 + + setup_label="$(get_setup_display_label "${setup_type}")" + status_text="$(printf "Manage existing %s stacks\n\nNo stacks found in .easy-docker/stacks yet." "${setup_label}")" render_box_message "${status_text}" "0 2" >&2 @@ -141,7 +281,71 @@ show_manage_stacks_placeholder() { --header "Manage stacks actions" \ --cursor.foreground 63 \ --selected.foreground 45 \ - "Back to production setup" \ - "Back to main menu" \ + "Back" \ + "Exit and close easy-docker" +} + +show_manage_stack_actions_menu() { + local stack_name="${1}" + local stack_dir="${2}" + local status_text="" + + render_main_screen 1 >&2 + + status_text="$(printf "Manage stack\n\nStack: %s\nDirectory: %s\n\nChoose an action for this stack." "${stack_name}" "${stack_dir}")" + + render_box_message "${status_text}" "0 2" >&2 + + gum choose \ + --height 7 \ + --header "Stack actions" \ + --cursor.foreground 63 \ + --selected.foreground 45 \ + "Apps" \ + "Docker" \ + "Back" \ + "Exit and close easy-docker" +} + +show_manage_stack_apps_menu() { + local stack_name="${1}" + local stack_dir="${2}" + local status_text="" + + render_main_screen 1 >&2 + + status_text="$(printf "Manage stack apps\n\nStack: %s\nDirectory: %s\n\nChoose an app-related action for this stack." "${stack_name}" "${stack_dir}")" + + render_box_message "${status_text}" "0 2" >&2 + + gum choose \ + --height 8 \ + --header "Stack apps actions" \ + --cursor.foreground 63 \ + --selected.foreground 45 \ + "Generate apps.json" \ + "Update custom image apps" \ + "Back" \ + "Exit and close easy-docker" +} + +show_manage_stack_docker_menu() { + local stack_name="${1}" + local stack_dir="${2}" + local status_text="" + + render_main_screen 1 >&2 + + status_text="$(printf "Manage stack docker\n\nStack: %s\nDirectory: %s\n\nChoose a docker-related action for this stack." "${stack_name}" "${stack_dir}")" + + render_box_message "${status_text}" "0 2" >&2 + + gum choose \ + --height 7 \ + --header "Stack docker actions" \ + --cursor.foreground 63 \ + --selected.foreground 45 \ + "Generate docker compose from env" \ + "Back" \ "Exit and close easy-docker" } From 1f1d5c71333549e6163dfc428c406b16817d6c0d Mon Sep 17 00:00:00 2001 From: RocketQuack <202538874+Rocket-Quack@users.noreply.github.com> Date: Sat, 28 Feb 2026 00:17:09 +0100 Subject: [PATCH 07/51] feat(easy-docker-wizard): persist apps in metadata and modularize scripts --- scripts/easy-docker/lib/app/run.sh | 441 +----------------- scripts/easy-docker/lib/app/wizard/common.sh | 19 + .../easy-docker/lib/app/wizard/common/apps.sh | 274 +++++++++++ .../lib/app/wizard/common/compose.sh | 76 +++ .../easy-docker/lib/app/wizard/common/core.sh | 314 +++++++++++++ .../lib/app/wizard/common/helpers.sh | 92 ++++ .../easy-docker/lib/app/wizard/common/ux.sh | 92 ++++ scripts/easy-docker/lib/app/wizard/env.sh | 15 + .../easy-docker/lib/app/wizard/env/apps.sh | 339 ++++++++++++++ .../easy-docker/lib/app/wizard/env/collect.sh | 228 +++++++++ .../lib/app/wizard/env/validation.sh | 418 +++++++++++++++++ scripts/easy-docker/lib/app/wizard/flows.sh | 17 + .../lib/app/wizard/flows/manage.sh | 116 +++++ .../lib/app/wizard/flows/navigation.sh | 68 +++ .../easy-docker/lib/app/wizard/flows/setup.sh | 281 +++++++++++ .../lib/app/wizard/flows/single_host.sh | 111 +++++ .../easy-docker/lib/app/wizard/single_host.sh | 289 ++++++++++++ 17 files changed, 2761 insertions(+), 429 deletions(-) create mode 100755 scripts/easy-docker/lib/app/wizard/common.sh create mode 100755 scripts/easy-docker/lib/app/wizard/common/apps.sh create mode 100755 scripts/easy-docker/lib/app/wizard/common/compose.sh create mode 100755 scripts/easy-docker/lib/app/wizard/common/core.sh create mode 100755 scripts/easy-docker/lib/app/wizard/common/helpers.sh create mode 100755 scripts/easy-docker/lib/app/wizard/common/ux.sh create mode 100755 scripts/easy-docker/lib/app/wizard/env.sh create mode 100755 scripts/easy-docker/lib/app/wizard/env/apps.sh create mode 100755 scripts/easy-docker/lib/app/wizard/env/collect.sh create mode 100755 scripts/easy-docker/lib/app/wizard/env/validation.sh create mode 100755 scripts/easy-docker/lib/app/wizard/flows.sh create mode 100755 scripts/easy-docker/lib/app/wizard/flows/manage.sh create mode 100755 scripts/easy-docker/lib/app/wizard/flows/navigation.sh create mode 100755 scripts/easy-docker/lib/app/wizard/flows/setup.sh create mode 100755 scripts/easy-docker/lib/app/wizard/flows/single_host.sh create mode 100755 scripts/easy-docker/lib/app/wizard/single_host.sh diff --git a/scripts/easy-docker/lib/app/run.sh b/scripts/easy-docker/lib/app/run.sh index e2d0abb2..80f88b26 100755 --- a/scripts/easy-docker/lib/app/run.sh +++ b/scripts/easy-docker/lib/app/run.sh @@ -1,434 +1,17 @@ #!/usr/bin/env bash -readonly FLOW_CONTINUE=0 -readonly FLOW_BACK_TO_MAIN=10 -readonly FLOW_EXIT_APP=11 -readonly FLOW_ABORT_INPUT=12 +load_easy_docker_app_modules() { + local app_dir="" + app_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -get_easy_docker_repo_root() { - local app_lib_dir="" - app_lib_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" - (cd "${app_lib_dir}/../../../.." && pwd) + # shellcheck source=scripts/easy-docker/lib/app/wizard/common.sh + source "${app_dir}/wizard/common.sh" + # shellcheck source=scripts/easy-docker/lib/app/wizard/env.sh + source "${app_dir}/wizard/env.sh" + # shellcheck source=scripts/easy-docker/lib/app/wizard/single_host.sh + source "${app_dir}/wizard/single_host.sh" + # shellcheck source=scripts/easy-docker/lib/app/wizard/flows.sh + source "${app_dir}/wizard/flows.sh" } -get_easy_docker_stacks_dir() { - printf '%s/.easy-docker/stacks\n' "$(get_easy_docker_repo_root)" -} - -get_current_utc_timestamp() { - date -u +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null || date +"%Y-%m-%dT%H:%M:%SZ" -} - -is_valid_stack_name() { - local stack_name="${1}" - - if [ -z "${stack_name}" ]; then - return 1 - fi - - case "${stack_name}" in - *[!A-Za-z0-9._-]*) - return 1 - ;; - *) - return 0 - ;; - esac -} - -create_stack_directory_with_metadata() { - local stack_dir_var="${1}" - local stack_name="${2}" - local stacks_dir="" - local created_stack_dir="" - local metadata_path="" - local created_at="" - - stacks_dir="$(get_easy_docker_stacks_dir)" - created_stack_dir="${stacks_dir}/${stack_name}" - metadata_path="${created_stack_dir}/metadata.json" - - if ! mkdir -p "${stacks_dir}"; then - return 1 - fi - - if [ -e "${created_stack_dir}" ]; then - return 2 - fi - - if ! mkdir -p "${created_stack_dir}"; then - return 1 - fi - - created_at="$(get_current_utc_timestamp)" - if ! cat >"${metadata_path}" </dev/null 2>&1 || true - return 1 - fi - - printf -v "${stack_dir_var}" "%s" "${created_stack_dir}" - return 0 -} - -rollback_stack_directory() { - local stack_dir="${1}" - local stacks_dir="" - - if [ -z "${stack_dir}" ]; then - return 1 - fi - - stacks_dir="$(get_easy_docker_stacks_dir)" - case "${stack_dir}" in - "${stacks_dir}"/*) ;; - *) - return 2 - ;; - esac - - if [ ! -d "${stack_dir}" ]; then - return 0 - fi - - rm -rf -- "${stack_dir}" -} - -prompt_stack_name_with_cancel() { - local result_var="${1}" - local input_value="" - local input_status=0 - - input_value="$(prompt_new_stack_name)" - input_status=$? - if [ "${input_status}" -ne 0 ]; then - return "${FLOW_ABORT_INPUT}" - fi - - input_value="$(printf '%s' "${input_value}" | tr -d '\r\n')" - - case "${input_value}" in - /cancel | /CANCEL | /Cancel) - return "${FLOW_ABORT_INPUT}" - ;; - esac - - printf -v "${result_var}" "%s" "${input_value}" - return "${FLOW_CONTINUE}" -} - -show_warning_and_wait() { - local message="${1}" - local seconds="${2:-1}" - - show_warning_message "${message}" - sleep "${seconds}" -} - -handle_topology_examples_flow() { - local topology_name="${1}" - local detail_action="" - - case "${topology_name}" in - "Single-host") - detail_action="$(show_single_host_examples || true)" - ;; - "Split services") - detail_action="$(show_split_services_examples || true)" - ;; - "Advanced") - detail_action="$(show_advanced_examples || true)" - ;; - *) - show_warning_and_wait "Unknown topology: ${topology_name}" - return "${FLOW_CONTINUE}" - ;; - esac - - case "${detail_action}" in - "Use this topology") - show_warning_and_wait "Topology '${topology_name}' selected. Next wizard step is coming soon." 2 - return "${FLOW_CONTINUE}" - ;; - "Back to topology selection" | "") - return "${FLOW_CONTINUE}" - ;; - *) - show_warning_and_wait "Unknown topology action: ${detail_action}" - return "${FLOW_CONTINUE}" - ;; - esac -} - -handle_abort_wizard_flow() { - local stack_dir="${1}" - local abort_action="" - local rollback_status=0 - - abort_action="$(show_abort_wizard_prompt "${stack_dir}" || true)" - case "${abort_action}" in - "Rollback files and return to main menu") - if rollback_stack_directory "${stack_dir}"; then - return "${FLOW_BACK_TO_MAIN}" - fi - - rollback_status=$? - if [ "${rollback_status}" -eq 2 ]; then - show_warning_and_wait "Refused rollback for unsafe path: ${stack_dir}" 2 - else - show_warning_and_wait "Could not rollback stack files: ${stack_dir}" 2 - fi - return "${FLOW_CONTINUE}" - ;; - "Keep files and return to main menu") - return "${FLOW_BACK_TO_MAIN}" - ;; - "Back to topology selection" | "") - return "${FLOW_CONTINUE}" - ;; - *) - show_warning_and_wait "Unknown abort action: ${abort_action}" - return "${FLOW_CONTINUE}" - ;; - esac -} - -handle_stack_topology_flow() { - local stack_dir="${1}" - local topology_action="" - local abort_status=0 - - while true; do - topology_action="$(show_stack_topology_menu "${stack_dir}" || true)" - case "${topology_action}" in - "Single-host" | "Split services" | "Advanced") - handle_topology_examples_flow "${topology_action}" - ;; - "Abort wizard to main menu") - handle_abort_wizard_flow "${stack_dir}" - abort_status=$? - case "${abort_status}" in - "${FLOW_BACK_TO_MAIN}") - return "${FLOW_BACK_TO_MAIN}" - ;; - *) ;; - esac - ;; - "") - return "${FLOW_CONTINUE}" - ;; - *) - show_warning_and_wait "Unknown topology selection: ${topology_action}" - ;; - esac - done -} - -handle_create_new_stack_flow() { - local stack_name="" - local stack_dir="" - local create_stack_status=0 - local stack_input_status=0 - local topology_status=0 - - while true; do - stack_name="" - if prompt_stack_name_with_cancel stack_name; then - : - else - stack_input_status=$? - if [ "${stack_input_status}" -eq "${FLOW_ABORT_INPUT}" ]; then - return "${FLOW_CONTINUE}" - fi - - show_warning_and_wait "Input canceled." - return "${FLOW_CONTINUE}" - fi - - if [ -z "${stack_name}" ]; then - return "${FLOW_CONTINUE}" - fi - - if ! is_valid_stack_name "${stack_name}"; then - show_warning_and_wait "Invalid stack name. Use letters, numbers, dot, underscore, or hyphen." 2 - continue - fi - - stack_dir="" - if create_stack_directory_with_metadata stack_dir "${stack_name}"; then - handle_stack_topology_flow "${stack_dir}" - topology_status=$? - case "${topology_status}" in - "${FLOW_BACK_TO_MAIN}") - return "${FLOW_BACK_TO_MAIN}" - ;; - "${FLOW_EXIT_APP}") - return "${FLOW_EXIT_APP}" - ;; - *) - return "${FLOW_CONTINUE}" - ;; - esac - else - create_stack_status=$? - if [ "${create_stack_status}" -eq 2 ]; then - show_warning_and_wait "Stack already exists: ${stack_name}" 2 - continue - fi - - show_warning_and_wait "Could not create stack directory for: ${stack_name}" 2 - return "${FLOW_CONTINUE}" - fi - done -} - -handle_manage_existing_stacks_flow() { - local manage_action="" - - manage_action="$(show_manage_stacks_placeholder || true)" - case "${manage_action}" in - "Back to production setup") - return "${FLOW_CONTINUE}" - ;; - "Back to main menu" | "") - return "${FLOW_BACK_TO_MAIN}" - ;; - "Exit and close easy-docker") - return "${FLOW_EXIT_APP}" - ;; - *) - show_warning_and_wait "Unknown manage-stacks action: ${manage_action}" - return "${FLOW_CONTINUE}" - ;; - esac -} - -handle_production_setup_flow() { - local production_action="" - - while true; do - production_action="$(show_production_setup_menu || true)" - - case "${production_action}" in - "Create new stack") - if handle_create_new_stack_flow; then - : - else - case "$?" in - "${FLOW_BACK_TO_MAIN}") - return "${FLOW_BACK_TO_MAIN}" - ;; - "${FLOW_EXIT_APP}") - return "${FLOW_EXIT_APP}" - ;; - *) ;; - esac - fi - ;; - "Manage existing stacks") - if handle_manage_existing_stacks_flow; then - : - else - case "$?" in - "${FLOW_BACK_TO_MAIN}") - return "${FLOW_BACK_TO_MAIN}" - ;; - "${FLOW_EXIT_APP}") - return "${FLOW_EXIT_APP}" - ;; - *) ;; - esac - fi - ;; - "Back to main menu" | "") - return "${FLOW_BACK_TO_MAIN}" - ;; - "Exit and close easy-docker") - return "${FLOW_EXIT_APP}" - ;; - *) - show_warning_and_wait "Unknown production action: ${production_action}" - ;; - esac - done -} - -handle_environment_check_flow() { - local environment_action="" - - environment_action="$(show_environment_status || true)" - case "${environment_action}" in - "Back to main menu" | "") - return "${FLOW_BACK_TO_MAIN}" - ;; - "Exit and close easy-docker") - return "${FLOW_EXIT_APP}" - ;; - *) - show_warning_and_wait "Unknown environment action: ${environment_action}" - return "${FLOW_CONTINUE}" - ;; - esac -} - -run_easy_docker_app() { - local action="" - local handler_status=0 - - enter_alt_screen - render_main_screen 1 - - while true; do - action="$(show_main_menu || true)" - - if [ -z "${action}" ]; then - return 0 - fi - - case "${action}" in - "Production setup") - if handle_production_setup_flow; then - handler_status="${FLOW_CONTINUE}" - else - handler_status=$? - fi - case "${handler_status}" in - "${FLOW_BACK_TO_MAIN}") - render_main_screen 1 - ;; - "${FLOW_EXIT_APP}") - return 0 - ;; - *) ;; - esac - ;; - "Environment check") - if handle_environment_check_flow; then - handler_status="${FLOW_CONTINUE}" - else - handler_status=$? - fi - case "${handler_status}" in - "${FLOW_BACK_TO_MAIN}") - render_main_screen 1 - ;; - "${FLOW_EXIT_APP}") - return 0 - ;; - *) ;; - esac - ;; - "Exit") - return 0 - ;; - *) - show_warning_and_wait "Unknown action: ${action}" - ;; - esac - done -} +load_easy_docker_app_modules diff --git a/scripts/easy-docker/lib/app/wizard/common.sh b/scripts/easy-docker/lib/app/wizard/common.sh new file mode 100755 index 00000000..83cd59e9 --- /dev/null +++ b/scripts/easy-docker/lib/app/wizard/common.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash + +load_easy_docker_wizard_common_modules() { + local wizard_dir="" + wizard_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + + # shellcheck source=scripts/easy-docker/lib/app/wizard/common/core.sh + source "${wizard_dir}/common/core.sh" + # shellcheck source=scripts/easy-docker/lib/app/wizard/common/helpers.sh + source "${wizard_dir}/common/helpers.sh" + # shellcheck source=scripts/easy-docker/lib/app/wizard/common/compose.sh + source "${wizard_dir}/common/compose.sh" + # shellcheck source=scripts/easy-docker/lib/app/wizard/common/apps.sh + source "${wizard_dir}/common/apps.sh" + # shellcheck source=scripts/easy-docker/lib/app/wizard/common/ux.sh + source "${wizard_dir}/common/ux.sh" +} + +load_easy_docker_wizard_common_modules diff --git a/scripts/easy-docker/lib/app/wizard/common/apps.sh b/scripts/easy-docker/lib/app/wizard/common/apps.sh new file mode 100755 index 00000000..0a3bfbba --- /dev/null +++ b/scripts/easy-docker/lib/app/wizard/common/apps.sh @@ -0,0 +1,274 @@ +#!/usr/bin/env bash + +persist_stack_apps_json_content() { + local stack_dir="${1}" + local apps_json_content="${2}" + local apps_json_path="" + local apps_json_tmp_path="" + + apps_json_path="${stack_dir}/apps.json" + apps_json_tmp_path="${apps_json_path}.tmp" + + if ! printf '%s\n' "${apps_json_content}" >"${apps_json_tmp_path}"; then + rm -f -- "${apps_json_tmp_path}" >/dev/null 2>&1 || true + return 1 + fi + + if ! mv -- "${apps_json_tmp_path}" "${apps_json_path}"; then + rm -f -- "${apps_json_tmp_path}" >/dev/null 2>&1 || true + return 1 + fi + + return 0 +} + +get_metadata_apps_predefined_csv() { + local metadata_path="${1}" + + if [ ! -f "${metadata_path}" ]; then + return 1 + fi + + awk ' + /"apps"[[:space:]]*:[[:space:]]*{/ { + in_apps = 1 + } + in_apps && /"predefined"[[:space:]]*:[[:space:]]*\[/ { + in_predefined = 1 + next + } + in_predefined && /\]/ { + in_predefined = 0 + next + } + in_predefined { + if (match($0, /"([^"]+)"/, parts)) { + if (csv == "") { + csv = parts[1] + } else { + csv = csv "," parts[1] + } + } + } + END { + if (csv != "") { + print csv + } + } + ' "${metadata_path}" +} + +get_metadata_apps_custom_lines() { + local metadata_path="${1}" + + if [ ! -f "${metadata_path}" ]; then + return 1 + fi + + awk ' + /"apps"[[:space:]]*:[[:space:]]*{/ { + in_apps = 1 + } + in_apps && /"custom"[[:space:]]*:[[:space:]]*\[/ { + in_custom = 1 + next + } + in_custom && /\]/ { + in_custom = 0 + repo = "" + branch = "" + next + } + in_custom { + if (match($0, /"repo"[[:space:]]*:[[:space:]]*"([^"]+)"/, repo_parts)) { + repo = repo_parts[1] + } + if (match($0, /"branch"[[:space:]]*:[[:space:]]*"([^"]+)"/, branch_parts)) { + branch = branch_parts[1] + } + if (repo != "" && branch != "") { + print repo "|" branch + repo = "" + branch = "" + } + } + ' "${metadata_path}" +} + +build_stack_apps_json_content_from_metadata_apps() { + local result_var="${1}" + local stack_dir="${2}" + local metadata_path="" + local preset_apps_csv="" + local custom_apps_lines="" + local preset_branch="" + local app="" + local line="" + local repo="" + local branch="" + local url="" + local escaped_url="" + local escaped_branch="" + local entry_json="" + local entries_json="" + local -a preset_apps=() + + metadata_path="${stack_dir}/metadata.json" + if [ ! -f "${metadata_path}" ]; then + return 1 + fi + + preset_apps_csv="$(get_metadata_apps_predefined_csv "${metadata_path}" || true)" + custom_apps_lines="$(get_metadata_apps_custom_lines "${metadata_path}" || true)" + preset_branch="$(get_default_frappe_branch)" + + if [ -n "${preset_apps_csv}" ]; then + IFS=',' read -r -a preset_apps <<<"${preset_apps_csv}" + for app in "${preset_apps[@]}"; do + case "${app}" in + erpnext) + url="https://github.com/frappe/erpnext" + ;; + crm) + url="https://github.com/frappe/crm" + ;; + *) + continue + ;; + esac + + escaped_url="$(json_escape_string "${url}")" + escaped_branch="$(json_escape_string "${preset_branch}")" + entry_json="$(printf ' {"url": "%s", "branch": "%s"}' "${escaped_url}" "${escaped_branch}")" + if [ -z "${entries_json}" ]; then + entries_json="${entry_json}" + else + entries_json="${entries_json}"$',\n'"${entry_json}" + fi + done + fi + + while IFS= read -r line; do + if [ -z "${line}" ]; then + continue + fi + + repo="${line%%|*}" + branch="${line#*|}" + if [ -z "${repo}" ] || [ -z "${branch}" ]; then + continue + fi + + escaped_url="$(json_escape_string "${repo}")" + escaped_branch="$(json_escape_string "${branch}")" + entry_json="$(printf ' {"url": "%s", "branch": "%s"}' "${escaped_url}" "${escaped_branch}")" + if [ -z "${entries_json}" ]; then + entries_json="${entry_json}" + else + entries_json="${entries_json}"$',\n'"${entry_json}" + fi + done <"${metadata_tmp_path}"; then + rm -f -- "${metadata_tmp_path}" >/dev/null 2>&1 || true + return 1 + fi + + if ! mv -- "${metadata_tmp_path}" "${metadata_path}"; then + rm -f -- "${metadata_tmp_path}" >/dev/null 2>&1 || true + return 1 + fi + + return 0 +} diff --git a/scripts/easy-docker/lib/app/wizard/common/compose.sh b/scripts/easy-docker/lib/app/wizard/common/compose.sh new file mode 100755 index 00000000..b6691146 --- /dev/null +++ b/scripts/easy-docker/lib/app/wizard/common/compose.sh @@ -0,0 +1,76 @@ +#!/usr/bin/env bash + +render_stack_compose_from_metadata() { + local stack_dir="${1}" + local metadata_path="" + local env_path="" + local generated_compose_path="" + local generated_compose_tmp_path="" + local compose_files_lines="" + local compose_file="" + local source_compose_path="" + local env_erpnext_version="" + local fallback_erpnext_version="" + local repo_root="" + local -a compose_args=() + + metadata_path="${stack_dir}/metadata.json" + env_path="$(get_stack_env_path "${stack_dir}")" + generated_compose_path="$(get_stack_generated_compose_path "${stack_dir}")" + generated_compose_tmp_path="${generated_compose_path}.tmp" + + if [ ! -f "${metadata_path}" ]; then + return 1 + fi + + if [ ! -f "${env_path}" ]; then + return 1 + fi + + env_erpnext_version="$(get_env_file_key_value "${env_path}" "ERPNEXT_VERSION" || true)" + if [ -z "${env_erpnext_version}" ]; then + fallback_erpnext_version="$(get_default_erpnext_version || true)" + fi + + compose_files_lines="$(get_metadata_compose_files_lines "${metadata_path}" || true)" + if [ -z "${compose_files_lines}" ]; then + return 1 + fi + + repo_root="$(get_easy_docker_repo_root)" + while IFS= read -r compose_file; do + if [ -z "${compose_file}" ]; then + continue + fi + + source_compose_path="${repo_root}/${compose_file}" + if [ ! -f "${source_compose_path}" ]; then + return 1 + fi + + compose_args+=(-f "${source_compose_path}") + done <"${generated_compose_tmp_path}"; then + rm -f -- "${generated_compose_tmp_path}" >/dev/null 2>&1 || true + return 1 + fi + elif ! docker compose --env-file "${env_path}" "${compose_args[@]}" config >"${generated_compose_tmp_path}"; then + rm -f -- "${generated_compose_tmp_path}" >/dev/null 2>&1 || true + return 1 + fi + + if ! mv -- "${generated_compose_tmp_path}" "${generated_compose_path}"; then + rm -f -- "${generated_compose_tmp_path}" >/dev/null 2>&1 || true + return 1 + fi + + return 0 +} diff --git a/scripts/easy-docker/lib/app/wizard/common/core.sh b/scripts/easy-docker/lib/app/wizard/common/core.sh new file mode 100755 index 00000000..5a47743c --- /dev/null +++ b/scripts/easy-docker/lib/app/wizard/common/core.sh @@ -0,0 +1,314 @@ +#!/usr/bin/env bash + +# shellcheck disable=SC2034 +readonly FLOW_CONTINUE=0 +# shellcheck disable=SC2034 +readonly FLOW_ABORT_INPUT=12 + +get_easy_docker_repo_root() { + local app_lib_dir="" + app_lib_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + (cd "${app_lib_dir}/../../../../.." && pwd) +} + +get_easy_docker_stacks_dir() { + printf '%s/.easy-docker/stacks\n' "$(get_easy_docker_repo_root)" +} + +get_current_utc_timestamp() { + date -u +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null || date +"%Y-%m-%dT%H:%M:%SZ" +} + +is_valid_stack_name() { + local stack_name="${1}" + + if [ -z "${stack_name}" ]; then + return 1 + fi + + case "${stack_name}" in + *[!A-Za-z0-9._-]*) + return 1 + ;; + *) + return 0 + ;; + esac +} + +create_stack_directory_with_metadata() { + local stack_dir_var="${1}" + local stack_name="${2}" + local setup_type="${3:-production}" + local stacks_dir="" + local created_stack_dir="" + local metadata_path="" + local created_at="" + + stacks_dir="$(get_easy_docker_stacks_dir)" + created_stack_dir="${stacks_dir}/${stack_name}" + metadata_path="${created_stack_dir}/metadata.json" + + if ! mkdir -p "${stacks_dir}"; then + return 1 + fi + + if [ -e "${created_stack_dir}" ]; then + return 2 + fi + + if ! mkdir -p "${created_stack_dir}"; then + return 1 + fi + + created_at="$(get_current_utc_timestamp)" + if ! cat >"${metadata_path}" </dev/null 2>&1 || true + return 1 + fi + + printf -v "${stack_dir_var}" "%s" "${created_stack_dir}" + return 0 +} + +rollback_stack_directory() { + local stack_dir="${1}" + local stacks_dir="" + + if [ -z "${stack_dir}" ]; then + return 1 + fi + + stacks_dir="$(get_easy_docker_stacks_dir)" + case "${stack_dir}" in + "${stacks_dir}"/*) ;; + *) + return 2 + ;; + esac + + if [ ! -d "${stack_dir}" ]; then + return 0 + fi + + rm -rf -- "${stack_dir}" +} + +get_metadata_string_field() { + local metadata_path="${1}" + local field_name="${2}" + + if [ ! -f "${metadata_path}" ]; then + return 1 + fi + + awk -v field_name="${field_name}" ' + match($0, "\"" field_name "\"[[:space:]]*:[[:space:]]*\"([^\"]*)\"", parts) { + print parts[1] + exit + } + ' "${metadata_path}" +} + +get_metadata_number_field() { + local metadata_path="${1}" + local field_name="${2}" + + if [ ! -f "${metadata_path}" ]; then + return 1 + fi + + awk -v field_name="${field_name}" ' + match($0, "\"" field_name "\"[[:space:]]*:[[:space:]]*([0-9]+)", parts) { + print parts[1] + exit + } + ' "${metadata_path}" +} + +get_env_file_key_value() { + local env_file="${1}" + local key="${2}" + local value="" + + if [ ! -f "${env_file}" ]; then + return 1 + fi + + value="$( + awk -v key="${key}" ' + /^[[:space:]]*#/ { next } + $0 !~ /=/ { next } + { + k = $1 + gsub(/^[[:space:]]+|[[:space:]]+$/, "", k) + if (k != key) { + next + } + v = substr($0, index($0, "=") + 1) + gsub(/^[[:space:]]+|[[:space:]]+$/, "", v) + print v + exit + } + ' "${env_file}" + )" + + if [ -z "${value}" ]; then + return 1 + fi + + case "${value}" in + \"*\") + value="${value#\"}" + value="${value%\"}" + ;; + \'*\') + value="${value#\'}" + value="${value%\'}" + ;; + esac + + printf '%s\n' "${value}" +} + +get_default_erpnext_version() { + local repo_root="" + local source_env_file="" + local value="" + + if [ -n "${ERPNEXT_VERSION:-}" ]; then + printf '%s\n' "${ERPNEXT_VERSION}" + return 0 + fi + + repo_root="$(get_easy_docker_repo_root)" + for source_env_file in "${repo_root}/.env" "${repo_root}/example.env"; do + value="$(get_env_file_key_value "${source_env_file}" "ERPNEXT_VERSION" || true)" + if [ -n "${value}" ]; then + printf '%s\n' "${value}" + return 0 + fi + done + + return 1 +} + +get_default_frappe_branch() { + local repo_root="" + local source_env_file="" + local value="" + local erpnext_version="" + local major_version="" + + if [ -n "${FRAPPE_BRANCH:-}" ]; then + printf '%s\n' "${FRAPPE_BRANCH}" + return 0 + fi + + repo_root="$(get_easy_docker_repo_root)" + for source_env_file in "${repo_root}/.env" "${repo_root}/example.env"; do + value="$(get_env_file_key_value "${source_env_file}" "FRAPPE_BRANCH" || true)" + if [ -n "${value}" ]; then + printf '%s\n' "${value}" + return 0 + fi + done + + erpnext_version="$(get_default_erpnext_version || true)" + case "${erpnext_version}" in + v[0-9]*) + major_version="${erpnext_version#v}" + major_version="${major_version%%.*}" + if [[ "${major_version}" =~ ^[0-9]+$ ]]; then + printf 'version-%s\n' "${major_version}" + return 0 + fi + ;; + esac + + printf 'version-15\n' + return 0 +} + +get_stack_env_path() { + local stack_dir="${1}" + local metadata_path="" + local stack_name="" + + metadata_path="${stack_dir}/metadata.json" + stack_name="$(get_metadata_string_field "${metadata_path}" "stack_name" || true)" + if [ -z "${stack_name}" ]; then + stack_name="${stack_dir##*/}" + fi + + printf '%s/%s.env\n' "${stack_dir}" "${stack_name}" +} + +get_stack_generated_compose_path() { + local stack_dir="${1}" + + printf '%s/compose.generated.yaml\n' "${stack_dir}" +} + +get_stack_dir_by_name() { + local stack_name="${1}" + local stacks_dir="" + local stack_dir="" + local metadata_path="" + local candidate_name="" + + stacks_dir="$(get_easy_docker_stacks_dir)" + if [ ! -d "${stacks_dir}" ]; then + return 1 + fi + + for stack_dir in "${stacks_dir}"/*; do + if [ ! -d "${stack_dir}" ]; then + continue + fi + + metadata_path="${stack_dir}/metadata.json" + if [ ! -f "${metadata_path}" ]; then + continue + fi + + candidate_name="$(get_metadata_string_field "${metadata_path}" "stack_name" || true)" + if [ "${candidate_name}" = "${stack_name}" ]; then + printf '%s\n' "${stack_dir}" + return 0 + fi + done + + return 1 +} + +get_metadata_compose_files_lines() { + local metadata_path="${1}" + + if [ ! -f "${metadata_path}" ]; then + return 1 + fi + + awk ' + /"compose_files"[[:space:]]*:[[:space:]]*\[/ { + in_compose_files = 1 + next + } + in_compose_files && /\]/ { + in_compose_files = 0 + exit + } + in_compose_files { + if (match($0, /"([^"]+)"/, parts)) { + print parts[1] + } + } + ' "${metadata_path}" +} diff --git a/scripts/easy-docker/lib/app/wizard/common/helpers.sh b/scripts/easy-docker/lib/app/wizard/common/helpers.sh new file mode 100755 index 00000000..a55cf4d4 --- /dev/null +++ b/scripts/easy-docker/lib/app/wizard/common/helpers.sh @@ -0,0 +1,92 @@ +#!/usr/bin/env bash + +json_escape_string() { + local raw_value="${1}" + + raw_value="${raw_value//\\/\\\\}" + raw_value="${raw_value//\"/\\\"}" + raw_value="${raw_value//$'\n'/\\n}" + raw_value="${raw_value//$'\r'/\\r}" + raw_value="${raw_value//$'\t'/\\t}" + + printf '%s' "${raw_value}" +} + +build_compose_files_json_array() { + local compose_files_lines="${1}" + local line="" + local first=1 + + while IFS= read -r line; do + if [ -z "${line}" ]; then + continue + fi + + if [ "${first}" -eq 1 ]; then + printf ' "%s"' "${line}" + first=0 + else + printf ',\n "%s"' "${line}" + fi + done </dev/null; then + show_warning_and_wait "Unknown proxy mode: ${proxy_mode}" + return "${FLOW_CONTINUE}" + fi + ;; + esac + + database_choice="$(show_single_host_database_menu "${stack_dir}" || true)" + case "${database_choice}" in + "Back to topology selection" | "") + return "${FLOW_CONTINUE}" + ;; + *) + if ! get_single_host_database_id "${database_choice}" >/dev/null; then + show_warning_and_wait "Unknown database choice: ${database_choice}" + return "${FLOW_CONTINUE}" + fi + ;; + esac + + redis_choice="$(show_single_host_redis_menu "${stack_dir}" || true)" + case "${redis_choice}" in + "Back to topology selection" | "") + return "${FLOW_CONTINUE}" + ;; + *) + if ! get_single_host_redis_id "${redis_choice}" >/dev/null; then + show_warning_and_wait "Unknown Redis choice: ${redis_choice}" + return "${FLOW_CONTINUE}" + fi + ;; + esac + + if ! save_single_host_selection "${stack_dir}" "${proxy_mode}" "${database_choice}" "${redis_choice}"; then + save_selection_status=$? + if [ "${save_selection_status}" -eq 2 ]; then + return "${FLOW_CONTINUE}" + fi + + show_warning_and_wait "Could not save single-host selection for stack: ${stack_dir}" 2 + return "${FLOW_CONTINUE}" + fi + + if ! render_stack_compose_from_metadata "${stack_dir}"; then + render_compose_status=$? + stack_env_path="$(get_stack_env_path "${stack_dir}")" + stack_apps_path="${stack_dir}/apps.json" + generated_compose_path="$(get_stack_generated_compose_path "${stack_dir}")" + show_warning_and_wait "Selection saved in ${stack_dir}/metadata.json, ${stack_env_path}, and ${stack_apps_path}, but compose rendering failed (${render_compose_status}) for ${generated_compose_path}." 3 + return "${FLOW_CONTINUE}" + fi + + stack_env_path="$(get_stack_env_path "${stack_dir}")" + stack_apps_path="${stack_dir}/apps.json" + generated_compose_path="$(get_stack_generated_compose_path "${stack_dir}")" + show_warning_and_wait "Single-host selection saved in ${stack_dir}/metadata.json, ${stack_env_path}, and ${stack_apps_path}. Rendered compose: ${generated_compose_path}." 3 + return "${FLOW_CONTINUE}" +} + +handle_topology_examples_flow() { + local topology_name="${1}" + local detail_action="" + + case "${topology_name}" in + "Split services") + detail_action="$(show_split_services_examples || true)" + ;; + *) + show_warning_and_wait "Unknown topology: ${topology_name}" + return "${FLOW_CONTINUE}" + ;; + esac + + case "${detail_action}" in + "Use this topology") + show_warning_and_wait "Topology '${topology_name}' selected. Next wizard step is coming soon." 2 + return "${FLOW_CONTINUE}" + ;; + "Back to topology selection" | "") + return "${FLOW_CONTINUE}" + ;; + *) + show_warning_and_wait "Unknown topology action: ${detail_action}" + return "${FLOW_CONTINUE}" + ;; + esac +} diff --git a/scripts/easy-docker/lib/app/wizard/single_host.sh b/scripts/easy-docker/lib/app/wizard/single_host.sh new file mode 100755 index 00000000..90a0f824 --- /dev/null +++ b/scripts/easy-docker/lib/app/wizard/single_host.sh @@ -0,0 +1,289 @@ +#!/usr/bin/env bash + +get_single_host_proxy_mode_id() { + local proxy_mode="${1}" + + case "${proxy_mode}" in + "Traefik (HTTP, built-in proxy)") + printf 'traefik-http\n' + ;; + "Traefik (HTTPS + Let's Encrypt)") + printf 'traefik-https\n' + ;; + "nginx-proxy (HTTP)") + printf 'nginxproxy-http\n' + ;; + "nginx-proxy + acme-companion (HTTPS)") + printf 'nginxproxy-https\n' + ;; + "Caddy (external reverse proxy)") + printf 'caddy-external\n' + ;; + "No reverse proxy (direct :8080)") + printf 'no-proxy\n' + ;; + *) + return 1 + ;; + esac +} + +get_single_host_proxy_overrides() { + local proxy_mode="${1}" + + case "${proxy_mode}" in + "Traefik (HTTP, built-in proxy)") + printf 'overrides/compose.proxy.yaml\n' + ;; + "Traefik (HTTPS + Let's Encrypt)") + printf 'overrides/compose.https.yaml\n' + ;; + "nginx-proxy (HTTP)") + printf 'overrides/compose.nginxproxy.yaml\n' + ;; + "nginx-proxy + acme-companion (HTTPS)") + printf 'overrides/compose.nginxproxy.yaml\noverrides/compose.nginxproxy-ssl.yaml\n' + ;; + "Caddy (external reverse proxy)" | "No reverse proxy (direct :8080)") + printf 'overrides/compose.noproxy.yaml\n' + ;; + *) + return 1 + ;; + esac +} + +get_single_host_database_id() { + local database_choice="${1}" + + case "${database_choice}" in + "MariaDB (recommended)") + printf 'mariadb\n' + ;; + "PostgreSQL") + printf 'postgres\n' + ;; + *) + return 1 + ;; + esac +} + +get_single_host_database_override() { + local database_choice="${1}" + + case "${database_choice}" in + "MariaDB (recommended)") + printf 'overrides/compose.mariadb.yaml\n' + ;; + "PostgreSQL") + printf 'overrides/compose.postgres.yaml\n' + ;; + *) + return 1 + ;; + esac +} + +get_single_host_redis_id() { + local redis_choice="${1}" + + case "${redis_choice}" in + "Include Redis (recommended)") + printf 'enabled\n' + ;; + "Skip Redis (experienced users only)") + printf 'disabled\n' + ;; + *) + return 1 + ;; + esac +} + +get_single_host_redis_override() { + local redis_choice="${1}" + + case "${redis_choice}" in + "Include Redis (recommended)") + printf 'overrides/compose.redis.yaml\n' + ;; + "Skip Redis (experienced users only)") + printf '' + ;; + *) + return 1 + ;; + esac +} + +persist_single_host_env_file() { + local stack_dir="${1}" + local env_lines="${2}" + local env_path="" + local env_tmp_path="" + local generated_at="" + + env_path="$(get_stack_env_path "${stack_dir}")" + env_tmp_path="${env_path}.tmp" + generated_at="$(get_current_utc_timestamp)" + + if ! { + printf '# Generated by easy-docker wizard at %s\n' "${generated_at}" + printf '# Adjust values as needed for this stack.\n' + if [ -n "${env_lines}" ]; then + printf '\n%s\n' "${env_lines}" + else + printf '\n# No additional environment variables configured by the wizard.\n' + fi + } >"${env_tmp_path}"; then + rm -f -- "${env_tmp_path}" >/dev/null 2>&1 || true + return 1 + fi + + if ! mv -- "${env_tmp_path}" "${env_path}"; then + rm -f -- "${env_tmp_path}" >/dev/null 2>&1 || true + return 1 + fi + + return 0 +} + +persist_single_host_selection_metadata() { + local stack_dir="${1}" + local proxy_mode_id="${2}" + local database_id="${3}" + local redis_id="${4}" + local compose_files_lines="${5}" + local apps_json_object="${6}" + local env_lines="${7}" + local metadata_path="" + local metadata_tmp_path="" + local schema_version="" + local stack_name="" + local setup_type="" + local created_at="" + local updated_at="" + local compose_files_json="" + local env_json_object="" + + metadata_path="${stack_dir}/metadata.json" + metadata_tmp_path="${metadata_path}.tmp" + + schema_version="$(get_metadata_number_field "${metadata_path}" "schema_version" || true)" + if [ -z "${schema_version}" ]; then + schema_version="1" + fi + + stack_name="$(get_metadata_string_field "${metadata_path}" "stack_name" || true)" + if [ -z "${stack_name}" ]; then + stack_name="${stack_dir##*/}" + fi + + setup_type="$(get_metadata_string_field "${metadata_path}" "setup_type" || true)" + if [ -z "${setup_type}" ]; then + setup_type="production" + fi + + created_at="$(get_metadata_string_field "${metadata_path}" "created_at" || true)" + if [ -z "${created_at}" ]; then + created_at="$(get_current_utc_timestamp)" + fi + + updated_at="$(get_current_utc_timestamp)" + compose_files_json="$(build_compose_files_json_array "${compose_files_lines}")" + env_json_object="$(build_env_json_object "${env_lines}")" + if [ -z "${apps_json_object}" ]; then + return 1 + fi + + if ! cat >"${metadata_tmp_path}" </dev/null 2>&1 || true + return 1 + fi + + if ! mv -- "${metadata_tmp_path}" "${metadata_path}"; then + rm -f -- "${metadata_tmp_path}" >/dev/null 2>&1 || true + return 1 + fi + + return 0 +} + +save_single_host_selection() { + local stack_dir="${1}" + local proxy_mode="${2}" + local database_choice="${3}" + local redis_choice="${4}" + local proxy_mode_id="" + local database_id="" + local redis_id="" + local database_override="" + local redis_override="" + local proxy_overrides="" + local compose_files_lines="" + local env_lines="" + local apps_metadata_json_object="" + local collect_env_status=0 + + proxy_mode_id="$(get_single_host_proxy_mode_id "${proxy_mode}")" || return 1 + database_id="$(get_single_host_database_id "${database_choice}")" || return 1 + redis_id="$(get_single_host_redis_id "${redis_choice}")" || return 1 + + database_override="$(get_single_host_database_override "${database_choice}")" || return 1 + redis_override="$(get_single_host_redis_override "${redis_choice}")" || return 1 + proxy_overrides="$(get_single_host_proxy_overrides "${proxy_mode}")" || return 1 + + compose_files_lines="$(printf 'compose.yaml\n%s' "${database_override}")" + if [ -n "${redis_override}" ]; then + compose_files_lines="$(printf '%s\n%s' "${compose_files_lines}" "${redis_override}")" + fi + compose_files_lines="$(printf '%s\n%s' "${compose_files_lines}" "${proxy_overrides}")" + + if ! collect_single_host_env_lines env_lines apps_metadata_json_object "${stack_dir}" "${proxy_mode_id}" "${database_id}"; then + collect_env_status=$? + return "${collect_env_status}" + fi + + if ! persist_single_host_env_file "${stack_dir}" "${env_lines}"; then + return 1 + fi + + if ! persist_single_host_selection_metadata \ + "${stack_dir}" \ + "${proxy_mode_id}" \ + "${database_id}" \ + "${redis_id}" \ + "${compose_files_lines}" \ + "${apps_metadata_json_object}" \ + "${env_lines}"; then + return 1 + fi + + if ! persist_stack_apps_json_from_metadata_apps "${stack_dir}"; then + return 1 + fi + + return 0 +} From 67efd3367e8d60d7a3bb05e278bb3334c7aa1588 Mon Sep 17 00:00:00 2001 From: RocketQuack <202538874+Rocket-Quack@users.noreply.github.com> Date: Sat, 28 Feb 2026 00:20:34 +0100 Subject: [PATCH 08/51] refactor(easy-docker-wizard): centralize shared flow constants --- scripts/easy-docker/lib/app/wizard/common.sh | 2 ++ scripts/easy-docker/lib/app/wizard/common/constants.sh | 8 ++++++++ scripts/easy-docker/lib/app/wizard/common/core.sh | 5 ----- scripts/easy-docker/lib/app/wizard/flows/single_host.sh | 5 ----- 4 files changed, 10 insertions(+), 10 deletions(-) create mode 100755 scripts/easy-docker/lib/app/wizard/common/constants.sh diff --git a/scripts/easy-docker/lib/app/wizard/common.sh b/scripts/easy-docker/lib/app/wizard/common.sh index 83cd59e9..6c9396cc 100755 --- a/scripts/easy-docker/lib/app/wizard/common.sh +++ b/scripts/easy-docker/lib/app/wizard/common.sh @@ -4,6 +4,8 @@ load_easy_docker_wizard_common_modules() { local wizard_dir="" wizard_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + # shellcheck source=scripts/easy-docker/lib/app/wizard/common/constants.sh + source "${wizard_dir}/common/constants.sh" # shellcheck source=scripts/easy-docker/lib/app/wizard/common/core.sh source "${wizard_dir}/common/core.sh" # shellcheck source=scripts/easy-docker/lib/app/wizard/common/helpers.sh diff --git a/scripts/easy-docker/lib/app/wizard/common/constants.sh b/scripts/easy-docker/lib/app/wizard/common/constants.sh new file mode 100755 index 00000000..4f61acc2 --- /dev/null +++ b/scripts/easy-docker/lib/app/wizard/common/constants.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash + +# Shared flow/status constants used across sourced wizard modules. +# shellcheck disable=SC2034 +readonly FLOW_CONTINUE=0 +readonly FLOW_BACK_TO_MAIN=10 +readonly FLOW_EXIT_APP=11 +readonly FLOW_ABORT_INPUT=12 diff --git a/scripts/easy-docker/lib/app/wizard/common/core.sh b/scripts/easy-docker/lib/app/wizard/common/core.sh index 5a47743c..f4bdb28d 100755 --- a/scripts/easy-docker/lib/app/wizard/common/core.sh +++ b/scripts/easy-docker/lib/app/wizard/common/core.sh @@ -1,10 +1,5 @@ #!/usr/bin/env bash -# shellcheck disable=SC2034 -readonly FLOW_CONTINUE=0 -# shellcheck disable=SC2034 -readonly FLOW_ABORT_INPUT=12 - get_easy_docker_repo_root() { local app_lib_dir="" app_lib_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" 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 850b331c..921ca172 100755 --- a/scripts/easy-docker/lib/app/wizard/flows/single_host.sh +++ b/scripts/easy-docker/lib/app/wizard/flows/single_host.sh @@ -1,10 +1,5 @@ #!/usr/bin/env bash -# shellcheck disable=SC2034 -readonly FLOW_BACK_TO_MAIN=10 -# shellcheck disable=SC2034 -readonly FLOW_EXIT_APP=11 - handle_single_host_stack_flow() { local stack_dir="${1}" local proxy_mode="" From 8e56cbb1f9fb98e153a4dd322016ca918945bad2 Mon Sep 17 00:00:00 2001 From: RocketQuack <202538874+Rocket-Quack@users.noreply.github.com> Date: Sun, 1 Mar 2026 16:07:22 +0100 Subject: [PATCH 09/51] feat(easy-docker): add app catalog tools wizard and branch profile flow --- scripts/easy-docker/README.md | 23 + scripts/easy-docker/config/apps.tsv | 7 + scripts/easy-docker/config/frappe.tsv | 4 + scripts/easy-docker/lib/app/wizard/common.sh | 2 + .../easy-docker/lib/app/wizard/common/apps.sh | 728 +++++++++++++++++- .../lib/app/wizard/common/constants.sh | 1 + .../easy-docker/lib/app/wizard/common/core.sh | 43 +- .../lib/app/wizard/common/frappe.sh | 165 ++++ .../easy-docker/lib/app/wizard/common/ux.sh | 61 ++ .../easy-docker/lib/app/wizard/env/apps.sh | 575 ++++++++------ .../easy-docker/lib/app/wizard/env/collect.sh | 4 +- scripts/easy-docker/lib/app/wizard/flows.sh | 2 + .../lib/app/wizard/flows/manage.sh | 22 +- .../lib/app/wizard/flows/navigation.sh | 33 +- .../easy-docker/lib/app/wizard/flows/setup.sh | 33 +- .../lib/app/wizard/flows/single_host.sh | 12 +- .../easy-docker/lib/app/wizard/flows/tools.sh | 199 +++++ .../easy-docker/lib/app/wizard/single_host.sh | 74 +- scripts/easy-docker/lib/ui/screens.sh | 2 + scripts/easy-docker/lib/ui/screens/base.sh | 3 +- .../easy-docker/lib/ui/screens/production.sh | 85 +- scripts/easy-docker/lib/ui/screens/tools.sh | 53 ++ 22 files changed, 1783 insertions(+), 348 deletions(-) create mode 100644 scripts/easy-docker/config/apps.tsv create mode 100644 scripts/easy-docker/config/frappe.tsv create mode 100755 scripts/easy-docker/lib/app/wizard/common/frappe.sh create mode 100755 scripts/easy-docker/lib/app/wizard/flows/tools.sh create mode 100755 scripts/easy-docker/lib/ui/screens/tools.sh diff --git a/scripts/easy-docker/README.md b/scripts/easy-docker/README.md index f6f0c4d1..53b32396 100644 --- a/scripts/easy-docker/README.md +++ b/scripts/easy-docker/README.md @@ -25,3 +25,26 @@ bash easy-docker.sh - `--no-installation-fallback` - Disables GitHub binary fallback for `gum` - If package manager installation fails, the script exits with manual installation guidance + +## Apps Catalog + +- App options in the wizard are read from: + - `scripts/easy-docker/config/apps.tsv` +- Format per line: + - `idlabelrepodefault_branchbranches_csv` +- Example: + - `erpnextERPNexthttps://github.com/frappe/erpnextversion-15version-15,version-16,develop` +- The install selection in the wizard is limited to apps from this catalog. +- For each selected app, the wizard shows the configured branch list from this catalog and prompts branch selection. + +## Frappe Version Profiles + +- During new stack creation (after stack name), the wizard asks for a Frappe branch profile from: + - `scripts/easy-docker/config/frappe.tsv` +- Format per line: + - `idlabelfrappe_branch` +- Example: + - `v16Frappe v16 (version-16)version-16` +- The selected `frappe_branch` is saved in stack `metadata.json` and used as default branch suggestion for app branch selection. +- In `metadata.json`, this value is stored top-level as: + - `"frappe_branch": "version-16"` (example) diff --git a/scripts/easy-docker/config/apps.tsv b/scripts/easy-docker/config/apps.tsv new file mode 100644 index 00000000..bbce0ae4 --- /dev/null +++ b/scripts/easy-docker/config/apps.tsv @@ -0,0 +1,7 @@ +# id label repo default_branch branches_csv +erpnext ERPNext https://github.com/frappe/erpnext develop version-15,version-16,develop +crm CRM https://github.com/frappe/crm develop develop,version-16,version-15 +hrms HRMS https://github.com/frappe/hrms develop develop,version-16,version-15 +lms LMS https://github.com/frappe/lms develop develop,version-16,version-15 +helpdesk Helpdesk https://github.com/frappe/helpdesk develop develop,version-16,version-15 +drive Drive https://github.com/frappe/drive develop develop,version-16,version-15 diff --git a/scripts/easy-docker/config/frappe.tsv b/scripts/easy-docker/config/frappe.tsv new file mode 100644 index 00000000..e612713b --- /dev/null +++ b/scripts/easy-docker/config/frappe.tsv @@ -0,0 +1,4 @@ +# id label frappe_branch +v16 Frappe v16 (version-16) version-16 +v15 Frappe v15 (version-15) version-15 +develop Frappe develop (develop) develop diff --git a/scripts/easy-docker/lib/app/wizard/common.sh b/scripts/easy-docker/lib/app/wizard/common.sh index 6c9396cc..b7a9d146 100755 --- a/scripts/easy-docker/lib/app/wizard/common.sh +++ b/scripts/easy-docker/lib/app/wizard/common.sh @@ -12,6 +12,8 @@ load_easy_docker_wizard_common_modules() { source "${wizard_dir}/common/helpers.sh" # shellcheck source=scripts/easy-docker/lib/app/wizard/common/compose.sh source "${wizard_dir}/common/compose.sh" + # shellcheck source=scripts/easy-docker/lib/app/wizard/common/frappe.sh + 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/ux.sh diff --git a/scripts/easy-docker/lib/app/wizard/common/apps.sh b/scripts/easy-docker/lib/app/wizard/common/apps.sh index 0a3bfbba..b9ecd802 100755 --- a/scripts/easy-docker/lib/app/wizard/common/apps.sh +++ b/scripts/easy-docker/lib/app/wizard/common/apps.sh @@ -1,5 +1,541 @@ #!/usr/bin/env bash +trim_predefined_catalog_field() { + local result_var="${1}" + local value="${2}" + + value="${value#"${value%%[![:space:]]*}"}" + value="${value%"${value##*[![:space:]]}"}" + + printf -v "${result_var}" "%s" "${value}" +} + +is_valid_predefined_app_id() { + local value="${1}" + + if [ -z "${value}" ]; then + return 1 + fi + + case "${value}" in + *[!a-z0-9._-]*) + return 1 + ;; + *) + return 0 + ;; + esac +} + +generate_predefined_app_id_from_label() { + local result_var="${1}" + local app_label="${2}" + local generated_id="" + + generated_id="$( + printf '%s' "${app_label}" | + tr '[:upper:]' '[:lower:]' | + sed -E 's/[[:space:]]+/_/g; s/[^a-z0-9._-]+/_/g; s/_+/_/g; s/^_+//; s/_+$//' + )" + + if ! is_valid_predefined_app_id "${generated_id}"; then + return 1 + fi + + printf -v "${result_var}" "%s" "${generated_id}" + return 0 +} + +is_valid_predefined_app_repo() { + local value="${1}" + + if [ -z "${value}" ]; then + return 1 + fi + + case "${value}" in + https://* | http://* | ssh://* | git://* | git@*:* | file://*) + return 0 + ;; + *) + return 1 + ;; + esac +} + +is_valid_predefined_app_branch() { + local value="${1}" + + if [ -z "${value}" ]; then + return 1 + fi + + case "${value}" in + *[!A-Za-z0-9._/-]* | .* | *..* | */ | /*) + return 1 + ;; + *) + return 0 + ;; + esac +} + +csv_contains_branch() { + local csv_values="${1}" + local value="${2}" + + case ",${csv_values}," in + *,"${value}",*) + return 0 + ;; + *) + return 1 + ;; + esac +} + +normalize_predefined_branches_csv() { + local result_csv_var="${1}" + local branches_csv_raw="${2}" + local branch_token="" + local normalized_csv="" + local -a raw_tokens=() + + IFS=',' read -r -a raw_tokens <<<"${branches_csv_raw}" + for branch_token in "${raw_tokens[@]}"; do + trim_predefined_catalog_field branch_token "${branch_token}" + if [ -z "${branch_token}" ]; then + continue + fi + + if ! is_valid_predefined_app_branch "${branch_token}"; then + return 1 + fi + + if csv_contains_branch "${normalized_csv}" "${branch_token}"; then + continue + fi + + if [ -z "${normalized_csv}" ]; then + normalized_csv="${branch_token}" + else + normalized_csv="${normalized_csv},${branch_token}" + fi + done + + if [ -z "${normalized_csv}" ]; then + return 1 + fi + + printf -v "${result_csv_var}" "%s" "${normalized_csv}" + return 0 +} + +get_predefined_apps_catalog_path() { + local repo_root="" + + repo_root="$(get_easy_docker_repo_root)" + printf '%s/scripts/easy-docker/config/apps.tsv\n' "${repo_root}" +} + +get_predefined_apps_catalog_entries() { + local catalog_path="" + local raw_line="" + local line="" + local app_id="" + local app_label="" + local app_repo="" + local app_default_branch="" + local app_branches_csv="" + local normalized_branches_csv="" + local first_branch="" + local extra="" + local seen_ids="," + local seen_labels="," + + catalog_path="$(get_predefined_apps_catalog_path)" + if [ ! -f "${catalog_path}" ]; then + return 1 + fi + + while IFS= read -r raw_line || [ -n "${raw_line}" ]; do + trim_predefined_catalog_field line "${raw_line}" + if [ -z "${line}" ]; then + continue + fi + + case "${line}" in + \#*) + continue + ;; + esac + + if [[ "${line}" == *$'\t'* ]]; then + IFS=$'\t' read -r app_id app_label app_repo app_default_branch app_branches_csv extra <<<"${line}" + else + # Backward compatibility for older catalog rows. + IFS='|' read -r app_id app_label app_repo app_default_branch app_branches_csv extra <<<"${line}" + fi + trim_predefined_catalog_field app_id "${app_id}" + trim_predefined_catalog_field app_label "${app_label}" + trim_predefined_catalog_field app_repo "${app_repo}" + trim_predefined_catalog_field app_default_branch "${app_default_branch}" + trim_predefined_catalog_field app_branches_csv "${app_branches_csv}" + trim_predefined_catalog_field extra "${extra}" + + if [ -n "${extra}" ] || [ -z "${app_id}" ] || [ -z "${app_label}" ] || [ -z "${app_repo}" ] || [ -z "${app_branches_csv}" ]; then + return 1 + fi + + if ! is_valid_predefined_app_id "${app_id}"; then + return 1 + fi + + if ! is_valid_predefined_app_repo "${app_repo}"; then + return 1 + fi + + if ! normalize_predefined_branches_csv normalized_branches_csv "${app_branches_csv}"; then + return 1 + fi + + if [ -z "${app_default_branch}" ]; then + first_branch="${normalized_branches_csv%%,*}" + app_default_branch="${first_branch}" + fi + + if ! is_valid_predefined_app_branch "${app_default_branch}"; then + return 1 + fi + + if ! csv_contains_branch "${normalized_branches_csv}" "${app_default_branch}"; then + return 1 + fi + + case "${seen_ids}" in + *,"${app_id}",*) + return 1 + ;; + esac + case "${seen_labels}" in + *,"${app_label}",*) + return 1 + ;; + esac + + seen_ids="${seen_ids}${app_id}," + seen_labels="${seen_labels}${app_label}," + + printf '%s|%s|%s|%s|%s\n' "${app_id}" "${app_label}" "${app_repo}" "${app_default_branch}" "${normalized_branches_csv}" + done <"${catalog_path}" +} + +get_predefined_app_labels_lines() { + local entry="" + local app_label="" + + while IFS= read -r entry; do + if [ -z "${entry}" ]; then + continue + fi + + app_label="${entry#*|}" + app_label="${app_label%%|*}" + printf '%s\n' "${app_label}" + done < <(get_predefined_apps_catalog_entries) +} + +get_predefined_app_id_by_label() { + local label="${1}" + local entry="" + local app_id="" + local app_label="" + local app_repo="" + local app_default_branch="" + local app_branches_csv="" + + while IFS= read -r entry; do + if [ -z "${entry}" ]; then + continue + fi + + IFS='|' read -r app_id app_label app_repo app_default_branch app_branches_csv <<<"${entry}" + if [ "${app_label}" = "${label}" ]; then + printf '%s\n' "${app_id}" + return 0 + fi + done < <(get_predefined_apps_catalog_entries) + + return 1 +} + +get_predefined_app_repo_by_id() { + local app_id_lookup="${1}" + local entry="" + local app_id="" + local app_label="" + local app_repo="" + local app_default_branch="" + local app_branches_csv="" + + while IFS= read -r entry; do + if [ -z "${entry}" ]; then + continue + fi + + IFS='|' read -r app_id app_label app_repo app_default_branch app_branches_csv <<<"${entry}" + if [ "${app_id}" = "${app_id_lookup}" ]; then + printf '%s\n' "${app_repo}" + return 0 + fi + done < <(get_predefined_apps_catalog_entries) + + return 1 +} + +get_predefined_app_label_by_id() { + local app_id_lookup="${1}" + local entry="" + local app_id="" + local app_label="" + local app_repo="" + local app_default_branch="" + local app_branches_csv="" + + while IFS= read -r entry; do + if [ -z "${entry}" ]; then + continue + fi + + IFS='|' read -r app_id app_label app_repo app_default_branch app_branches_csv <<<"${entry}" + if [ "${app_id}" = "${app_id_lookup}" ]; then + printf '%s\n' "${app_label}" + return 0 + fi + done < <(get_predefined_apps_catalog_entries) + + return 1 +} + +get_predefined_app_default_branch_by_id() { + local app_id_lookup="${1}" + local entry="" + local app_id="" + local app_label="" + local app_repo="" + local app_default_branch="" + local app_branches_csv="" + + while IFS= read -r entry; do + if [ -z "${entry}" ]; then + continue + fi + + IFS='|' read -r app_id app_label app_repo app_default_branch app_branches_csv <<<"${entry}" + if [ "${app_id}" = "${app_id_lookup}" ]; then + printf '%s\n' "${app_default_branch}" + return 0 + fi + done < <(get_predefined_apps_catalog_entries) + + return 1 +} + +get_predefined_app_branch_lines_by_id() { + local result_var="${1}" + local app_id_lookup="${2}" + local entry="" + local app_id="" + local app_label="" + local app_repo="" + local app_default_branch="" + local app_branches_csv="" + local branch="" + local branch_lines="" + local -a branches=() + + while IFS= read -r entry; do + if [ -z "${entry}" ]; then + continue + fi + + IFS='|' read -r app_id app_label app_repo app_default_branch app_branches_csv <<<"${entry}" + if [ "${app_id}" != "${app_id_lookup}" ]; then + continue + fi + + IFS=',' read -r -a branches <<<"${app_branches_csv}" + for branch in "${branches[@]}"; do + trim_predefined_catalog_field branch "${branch}" + if [ -z "${branch}" ]; then + continue + fi + if [ -z "${branch_lines}" ]; then + branch_lines="${branch}" + else + branch_lines="${branch_lines}"$'\n'"${branch}" + fi + done + + if [ -z "${branch_lines}" ]; then + return 1 + fi + + printf -v "${result_var}" "%s" "${branch_lines}" + return 0 + done < <(get_predefined_apps_catalog_entries) + + return 1 +} + +predefined_app_catalog_has_id() { + local app_id_lookup="${1}" + local entry="" + local app_id="" + local app_label="" + local app_repo="" + local app_default_branch="" + local app_branches_csv="" + + if [ -z "${app_id_lookup}" ]; then + return 1 + fi + + while IFS= read -r entry; do + if [ -z "${entry}" ]; then + continue + fi + + IFS='|' read -r app_id app_label app_repo app_default_branch app_branches_csv <<<"${entry}" + if [ "${app_id}" = "${app_id_lookup}" ]; then + return 0 + fi + done < <(get_predefined_apps_catalog_entries || true) + + return 1 +} + +predefined_app_catalog_has_label() { + local app_label_lookup="${1}" + local entry="" + local app_id="" + local app_label="" + local app_repo="" + local app_default_branch="" + local app_branches_csv="" + + if [ -z "${app_label_lookup}" ]; then + return 1 + fi + + while IFS= read -r entry; do + if [ -z "${entry}" ]; then + continue + fi + + IFS='|' read -r app_id app_label app_repo app_default_branch app_branches_csv <<<"${entry}" + if [ "${app_label}" = "${app_label_lookup}" ]; then + return 0 + fi + done < <(get_predefined_apps_catalog_entries || true) + + return 1 +} + +append_predefined_app_catalog_entry() { + local app_id="${1}" + local app_label="${2}" + local app_repo="${3}" + local app_default_branch="${4}" + local app_branches_csv="${5}" + local normalized_branches_csv="" + local first_branch="" + local catalog_path="" + local catalog_tmp_path="" + local last_char="" + + if ! get_predefined_apps_catalog_entries >/dev/null 2>&1; then + return 1 + fi + + trim_predefined_catalog_field app_id "${app_id}" + trim_predefined_catalog_field app_label "${app_label}" + trim_predefined_catalog_field app_repo "${app_repo}" + trim_predefined_catalog_field app_default_branch "${app_default_branch}" + trim_predefined_catalog_field app_branches_csv "${app_branches_csv}" + + if ! is_valid_predefined_app_id "${app_id}"; then + return 1 + fi + if [ -z "${app_label}" ]; then + return 1 + fi + if ! is_valid_predefined_app_repo "${app_repo}"; then + return 1 + fi + if ! normalize_predefined_branches_csv normalized_branches_csv "${app_branches_csv}"; then + return 1 + fi + + if [ -z "${app_default_branch}" ]; then + first_branch="${normalized_branches_csv%%,*}" + app_default_branch="${first_branch}" + fi + if ! is_valid_predefined_app_branch "${app_default_branch}"; then + return 1 + fi + if ! csv_contains_branch "${normalized_branches_csv}" "${app_default_branch}"; then + return 1 + fi + + if predefined_app_catalog_has_id "${app_id}"; then + return 1 + fi + if predefined_app_catalog_has_label "${app_label}"; then + return 1 + fi + + catalog_path="$(get_predefined_apps_catalog_path)" + catalog_tmp_path="${catalog_path}.tmp" + if [ ! -f "${catalog_path}" ]; then + return 1 + fi + + if ! cp -- "${catalog_path}" "${catalog_tmp_path}"; then + rm -f -- "${catalog_tmp_path}" >/dev/null 2>&1 || true + return 1 + fi + + if [ -s "${catalog_tmp_path}" ]; then + if command_exists tail; then + last_char="$(tail -c 1 "${catalog_tmp_path}" 2>/dev/null || true)" + if [ -n "${last_char}" ]; then + if ! printf '\n' >>"${catalog_tmp_path}"; then + rm -f -- "${catalog_tmp_path}" >/dev/null 2>&1 || true + return 1 + fi + fi + else + if ! printf '\n' >>"${catalog_tmp_path}"; then + rm -f -- "${catalog_tmp_path}" >/dev/null 2>&1 || true + return 1 + fi + fi + fi + + if ! printf '%s\t%s\t%s\t%s\t%s\n' "${app_id}" "${app_label}" "${app_repo}" "${app_default_branch}" "${normalized_branches_csv}" >>"${catalog_tmp_path}"; then + rm -f -- "${catalog_tmp_path}" >/dev/null 2>&1 || true + return 1 + fi + + if ! mv -- "${catalog_tmp_path}" "${catalog_path}"; then + rm -f -- "${catalog_tmp_path}" >/dev/null 2>&1 || true + return 1 + fi + + return 0 +} + persist_stack_apps_json_content() { local stack_dir="${1}" local apps_json_content="${2}" @@ -95,12 +631,63 @@ get_metadata_apps_custom_lines() { ' "${metadata_path}" } +get_metadata_apps_predefined_branch_lines() { + local metadata_path="${1}" + + if [ ! -f "${metadata_path}" ]; then + return 1 + fi + + awk ' + /"apps"[[:space:]]*:[[:space:]]*{/ { + in_apps = 1 + } + in_apps && /"predefined_branches"[[:space:]]*:[[:space:]]*{/ { + in_predefined_branches = 1 + next + } + in_predefined_branches && /}/ { + in_predefined_branches = 0 + next + } + in_predefined_branches { + if (match($0, /"([^"]+)"[[:space:]]*:[[:space:]]*"([^"]+)"/, parts)) { + print parts[1] "|" parts[2] + } + } + ' "${metadata_path}" +} + +get_metadata_apps_predefined_branch_for_id() { + local metadata_path="${1}" + local app_id_lookup="${2}" + local line="" + local app_id="" + local app_branch="" + + while IFS= read -r line; do + if [ -z "${line}" ]; then + continue + fi + + app_id="${line%%|*}" + app_branch="${line#*|}" + if [ "${app_id}" = "${app_id_lookup}" ] && [ -n "${app_branch}" ]; then + printf '%s\n' "${app_branch}" + return 0 + fi + done < <(get_metadata_apps_predefined_branch_lines "${metadata_path}" || true) + + return 1 +} + build_stack_apps_json_content_from_metadata_apps() { local result_var="${1}" local stack_dir="${2}" local metadata_path="" local preset_apps_csv="" local custom_apps_lines="" + local predefined_branch="" local preset_branch="" local app="" local line="" @@ -120,25 +707,26 @@ build_stack_apps_json_content_from_metadata_apps() { preset_apps_csv="$(get_metadata_apps_predefined_csv "${metadata_path}" || true)" custom_apps_lines="$(get_metadata_apps_custom_lines "${metadata_path}" || true)" - preset_branch="$(get_default_frappe_branch)" + preset_branch="$(get_stack_frappe_branch "${stack_dir}" || true)" + if [ -z "${preset_branch}" ]; then + preset_branch="$(get_default_frappe_branch)" + fi if [ -n "${preset_apps_csv}" ]; then IFS=',' read -r -a preset_apps <<<"${preset_apps_csv}" for app in "${preset_apps[@]}"; do - case "${app}" in - erpnext) - url="https://github.com/frappe/erpnext" - ;; - crm) - url="https://github.com/frappe/crm" - ;; - *) - continue - ;; - esac + url="$(get_predefined_app_repo_by_id "${app}" || true)" + if [ -z "${url}" ]; then + return 1 + fi + + predefined_branch="$(get_metadata_apps_predefined_branch_for_id "${metadata_path}" "${app}" || true)" + if [ -z "${predefined_branch}" ]; then + predefined_branch="${preset_branch}" + fi escaped_url="$(json_escape_string "${url}")" - escaped_branch="$(json_escape_string "${preset_branch}")" + escaped_branch="$(json_escape_string "${predefined_branch}")" entry_json="$(printf ' {"url": "%s", "branch": "%s"}' "${escaped_url}" "${escaped_branch}")" if [ -z "${entries_json}" ]; then entries_json="${entry_json}" @@ -216,9 +804,17 @@ persist_stack_metadata_apps_object() { in_top_level_apps = 0 apps_depth = 0 inserted = 0 + prev = "" + } + function flush_prev() { + if (prev != "") { + print prev + prev = "" + } } { if (!in_top_level_apps && $0 ~ /^ "apps"[[:space:]]*:/) { + flush_prev() print " \"apps\": " apps_object "," in_top_level_apps = 1 inserted = 1 @@ -244,18 +840,122 @@ persist_stack_metadata_apps_object() { } if (!inserted && $0 ~ /^ "wizard"[[:space:]]*:/) { + flush_prev() print " \"apps\": " apps_object "," inserted = 1 } if (!inserted && $0 ~ /^}/) { + if (prev != "") { + if (prev !~ /,[[:space:]]*$/) { + prev = prev "," + } + print prev + prev = "" + } print " \"apps\": " apps_object inserted = 1 + print $0 + next } - print + flush_prev() + prev = $0 } END { + flush_prev() + if (!inserted) { + exit 2 + } + } + ' "${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 +} + +persist_stack_metadata_wizard_object() { + local stack_dir="${1}" + local wizard_json_object="${2}" + local metadata_path="" + local metadata_tmp_path="" + + metadata_path="${stack_dir}/metadata.json" + metadata_tmp_path="${metadata_path}.tmp" + if [ ! -f "${metadata_path}" ]; then + return 1 + fi + + if [ -z "${wizard_json_object}" ]; then + return 1 + fi + + if ! awk -v wizard_object="${wizard_json_object}" ' + BEGIN { + in_top_level_wizard = 0 + wizard_depth = 0 + inserted = 0 + prev = "" + } + function flush_prev() { + if (prev != "") { + print prev + prev = "" + } + } + { + if (!in_top_level_wizard && $0 ~ /^ "wizard"[[:space:]]*:/) { + flush_prev() + print " \"wizard\": " wizard_object + in_top_level_wizard = 1 + inserted = 1 + if ($0 ~ /{/) { + wizard_depth += gsub(/{/, "{", $0) + wizard_depth -= gsub(/}/, "}", $0) + } else { + wizard_depth = 0 + } + if (wizard_depth <= 0) { + in_top_level_wizard = 0 + } + next + } + + if (in_top_level_wizard) { + wizard_depth += gsub(/{/, "{", $0) + wizard_depth -= gsub(/}/, "}", $0) + if (wizard_depth <= 0) { + in_top_level_wizard = 0 + } + next + } + + if (!inserted && $0 ~ /^}/) { + if (prev != "") { + if (prev !~ /,[[:space:]]*$/) { + prev = prev "," + } + print prev + prev = "" + } + print " \"wizard\": " wizard_object + inserted = 1 + print $0 + next + } + + flush_prev() + prev = $0 + } + END { + flush_prev() if (!inserted) { exit 2 } diff --git a/scripts/easy-docker/lib/app/wizard/common/constants.sh b/scripts/easy-docker/lib/app/wizard/common/constants.sh index 4f61acc2..9809dc09 100755 --- a/scripts/easy-docker/lib/app/wizard/common/constants.sh +++ b/scripts/easy-docker/lib/app/wizard/common/constants.sh @@ -6,3 +6,4 @@ readonly FLOW_CONTINUE=0 readonly FLOW_BACK_TO_MAIN=10 readonly FLOW_EXIT_APP=11 readonly FLOW_ABORT_INPUT=12 +readonly FLOW_OPEN_MANAGE_STACK=13 diff --git a/scripts/easy-docker/lib/app/wizard/common/core.sh b/scripts/easy-docker/lib/app/wizard/common/core.sh index f4bdb28d..3402f3d1 100755 --- a/scripts/easy-docker/lib/app/wizard/common/core.sh +++ b/scripts/easy-docker/lib/app/wizard/common/core.sh @@ -3,7 +3,9 @@ get_easy_docker_repo_root() { local app_lib_dir="" app_lib_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" - (cd "${app_lib_dir}/../../../../.." && pwd) + # core.sh lives in scripts/easy-docker/lib/app/wizard/common + # so we need 6 levels up to reach repository root. + (cd "${app_lib_dir}/../../../../../.." && pwd) } get_easy_docker_stacks_dir() { @@ -35,6 +37,7 @@ create_stack_directory_with_metadata() { local stack_dir_var="${1}" local stack_name="${2}" local setup_type="${3:-production}" + local frappe_branch="${4:-}" local stacks_dir="" local created_stack_dir="" local metadata_path="" @@ -57,11 +60,16 @@ create_stack_directory_with_metadata() { fi created_at="$(get_current_utc_timestamp)" + if [ -z "${frappe_branch}" ]; then + return 1 + fi + if ! cat >"${metadata_path}" <&2 + status_text="$(printf "Stack: %s\n\nSelect branch for %s (%s)\nRepo: %s\n%s" "${stack_dir##*/}" "${app_label}" "${app_id}" "${repo_url}" "${default_hint}")" + render_box_message "${status_text}" "0 2" >&2 + + if selection="$( + gum choose \ + --height 16 \ + --header "Branch selection (${app_label})" \ + --cursor.foreground 63 \ + --selected.foreground 45 \ + "${branch_options[@]}" \ + "Back to app selection" + )"; then + : + else + return 2 + fi + + case "${selection}" in + "Back to app selection" | "") + return 2 + ;; + *) + printf -v "${result_var}" "%s" "${selection}" + return 0 + ;; + esac } prompt_custom_modular_apps_data() { local result_apps_metadata_var="${1}" local stack_dir="${2}" - local back_option_label="${3:-Back to topology selection}" + local metadata_path="" + local options_lines="" + local selected_labels_csv="" local selection_raw="" - local selection="" - local preset_apps_csv="" - local has_custom_apps=0 local prompt_status=0 - local preset_app="" - local preset_repo_url="" - local preset_branch="" - local escaped_url="" - local escaped_branch="" - local apps_entry="" - local metadata_predefined_entry="" - local custom_apps_entries="" - local metadata_custom_entries="" - local apps_entries="" - local metadata_predefined_entries="" + local selected_predefined_csv="" + local parsed_predefined_csv="" + local selected_label="" + local predefined_app_id="" + local predefined_app_label="" + local predefined_repo_url="" + local selected_branch="" + local preferred_branch="" + local existing_branch_lines="" + local selected_branch_lines="" + local selected_app_count=0 local built_apps_metadata_json_object="" - local -a selections=() - local -a preset_apps=() + local -a predefined_catalog_entries=() + local -a selected_predefined_ids=() + + metadata_path="${stack_dir}/metadata.json" + if [ -f "${metadata_path}" ]; then + selected_predefined_csv="$(get_metadata_apps_predefined_csv "${metadata_path}" || true)" + existing_branch_lines="$(get_metadata_apps_predefined_branch_lines "${metadata_path}" || true)" + fi while true; do - selection_raw="$(show_custom_modular_apps_multi_select "${stack_dir}" "${back_option_label}" || true)" - prompt_status=$? + options_lines="" + selected_labels_csv="" + predefined_catalog_entries=() + + mapfile -t predefined_catalog_entries < <(get_predefined_apps_catalog_entries || true) + for selected_label in "${predefined_catalog_entries[@]}"; do + IFS='|' read -r predefined_app_id predefined_app_label predefined_repo_url _ _ <<<"${selected_label}" + if [ -z "${predefined_app_id}" ] || [ -z "${predefined_app_label}" ]; then + continue + fi + + if [ -z "${options_lines}" ]; then + options_lines="$(printf '%s' "${predefined_app_label}")" + else + options_lines="$(printf '%s\n%s' "${options_lines}" "${predefined_app_label}")" + fi + done + + if [ -n "${selected_predefined_csv}" ]; then + IFS=',' read -r -a selected_predefined_ids <<<"${selected_predefined_csv}" + for predefined_app_id in "${selected_predefined_ids[@]}"; do + if [ -z "${predefined_app_id}" ]; then + continue + fi + predefined_app_label="$(get_predefined_app_label_by_id "${predefined_app_id}" || true)" + if [ -z "${predefined_app_label}" ]; then + continue + fi + append_csv_unique selected_labels_csv "${selected_labels_csv}" "${predefined_app_label}" + done + fi + + if [ -z "${options_lines}" ]; then + show_warning_and_wait "No apps available in catalog." 3 + return 1 + fi + + if selection_raw="$(show_custom_modular_apps_multi_select "${stack_dir}" "${options_lines}" "${selected_labels_csv}")"; then + prompt_status=0 + else + prompt_status=$? + fi if [ "${prompt_status}" -ne 0 ]; then return 2 fi if [ -z "${selection_raw}" ]; then - show_warning_message "Select at least one app option." + show_warning_message "Select at least one app." continue fi - preset_apps_csv="" - has_custom_apps=0 - custom_apps_entries="" - metadata_custom_entries="" - apps_entries="" - metadata_predefined_entries="" + parsed_predefined_csv="" - mapfile -t selections <<<"${selection_raw}" - for selection in "${selections[@]}"; do - case "${selection}" in - "ERPNext") - if [ -z "${preset_apps_csv}" ]; then - preset_apps_csv="erpnext" - else - preset_apps_csv="${preset_apps_csv},erpnext" + while IFS= read -r selected_label; do + if [ -z "${selected_label}" ]; then + continue + fi + + predefined_app_id="$(get_predefined_app_id_by_label "${selected_label}" || true)" + if [ -z "${predefined_app_id}" ]; then + continue + fi + append_csv_unique parsed_predefined_csv "${parsed_predefined_csv}" "${predefined_app_id}" + done </dev/null 2>&1; then + show_warning_and_wait "Could not load scripts/easy-docker/config/apps.tsv. Check format before adding new entries." 3 + return 1 + fi + + while true; do + if prompt_tools_apps_catalog_value_with_back \ + app_label \ + "App Label" \ + "Display name used in the app selection list." \ + "My Custom App"; then + : + else + input_status=$? + return "${input_status}" + fi + + trim_predefined_catalog_field app_label "${app_label}" + if [ -z "${app_label}" ]; then + show_warning_and_wait "App label is required." 2 + continue + fi + + if predefined_app_catalog_has_label "${app_label}"; then + show_warning_and_wait "App label already exists in apps.tsv: ${app_label}" 2 + continue + fi + + if ! generate_predefined_app_id_from_label app_id "${app_label}"; then + show_warning_and_wait "Could not generate a valid app id from label. Use letters/numbers and simple separators." 2 + continue + fi + + if predefined_app_catalog_has_id "${app_id}"; then + show_warning_and_wait "Generated app id already exists (${app_id}). Choose a different label." 2 + continue + fi + + break + done + + while true; do + if prompt_tools_apps_catalog_value_with_back \ + app_repo \ + "Repository URL" \ + "Git repository URL for this app." \ + "https://github.com/acme/my-custom-app"; then + : + else + input_status=$? + return "${input_status}" + fi + + if ! is_valid_predefined_app_repo "${app_repo}"; then + show_warning_and_wait "Invalid repository URL. Use https/http/ssh/git formats." 2 + continue + fi + + break + done + + while true; do + if prompt_tools_apps_catalog_value_with_back \ + app_branches_csv \ + "Branches (CSV)" \ + "Comma-separated branches for selection. Example: version-15,version-16,develop" \ + "version-15,version-16,develop"; then + : + else + input_status=$? + return "${input_status}" + fi + + if ! normalize_predefined_branches_csv normalized_branches_csv "${app_branches_csv}"; then + show_warning_and_wait "Invalid branch list. Use a comma-separated list with valid branch names." 2 + continue + fi + + break + done + + while true; do + if prompt_tools_apps_default_branch_from_csv_with_back app_default_branch "${normalized_branches_csv}"; then + : + else + input_status=$? + if [ "${input_status}" -eq "${FLOW_ABORT_INPUT}" ]; then + return "${FLOW_ABORT_INPUT}" + fi + show_warning_and_wait "Could not select default branch from branch list." 2 + return "${input_status}" + fi + break + done + + if ! append_predefined_app_catalog_entry "${app_id}" "${app_label}" "${app_repo}" "${app_default_branch}" "${normalized_branches_csv}"; then + show_warning_and_wait "Could not append app entry to scripts/easy-docker/config/apps.tsv." 3 + return 1 + fi + + show_warning_and_wait "App added to apps.tsv: ${app_label} (${app_id})" 2 + return 0 +} + +handle_tools_flow() { + local tools_action="" + + while true; do + tools_action="$(show_tools_menu || true)" + + case "${tools_action}" in + "Add Apps for App Selection") + run_add_app_catalog_entry_wizard || true + ;; + "Back to main menu" | "") + return "${FLOW_BACK_TO_MAIN}" + ;; + "Exit and close easy-docker") + return "${FLOW_EXIT_APP}" + ;; + *) + show_warning_and_wait "Unknown tools action: ${tools_action}" + ;; + esac + done +} diff --git a/scripts/easy-docker/lib/app/wizard/single_host.sh b/scripts/easy-docker/lib/app/wizard/single_host.sh index 90a0f824..87ff2ecb 100755 --- a/scripts/easy-docker/lib/app/wizard/single_host.sh +++ b/scripts/easy-docker/lib/app/wizard/single_host.sh @@ -155,56 +155,19 @@ persist_single_host_selection_metadata() { local database_id="${3}" local redis_id="${4}" local compose_files_lines="${5}" - local apps_json_object="${6}" - local env_lines="${7}" - local metadata_path="" - local metadata_tmp_path="" - local schema_version="" - local stack_name="" - local setup_type="" - local created_at="" + local env_lines="${6}" local updated_at="" local compose_files_json="" local env_json_object="" - - metadata_path="${stack_dir}/metadata.json" - metadata_tmp_path="${metadata_path}.tmp" - - schema_version="$(get_metadata_number_field "${metadata_path}" "schema_version" || true)" - if [ -z "${schema_version}" ]; then - schema_version="1" - fi - - stack_name="$(get_metadata_string_field "${metadata_path}" "stack_name" || true)" - if [ -z "${stack_name}" ]; then - stack_name="${stack_dir##*/}" - fi - - setup_type="$(get_metadata_string_field "${metadata_path}" "setup_type" || true)" - if [ -z "${setup_type}" ]; then - setup_type="production" - fi - - created_at="$(get_metadata_string_field "${metadata_path}" "created_at" || true)" - if [ -z "${created_at}" ]; then - created_at="$(get_current_utc_timestamp)" - fi + 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 [ -z "${apps_json_object}" ]; then - return 1 - fi - if ! cat >"${metadata_tmp_path}" </dev/null 2>&1 || true + )"; then return 1 fi - if ! mv -- "${metadata_tmp_path}" "${metadata_path}"; then - rm -f -- "${metadata_tmp_path}" >/dev/null 2>&1 || true + if ! persist_stack_metadata_wizard_object "${stack_dir}" "${wizard_json_object}"; then return 1 fi @@ -245,6 +206,8 @@ save_single_host_selection() { local compose_files_lines="" local env_lines="" local apps_metadata_json_object="" + local wizard_metadata_status=0 + local apps_metadata_status=0 local collect_env_status=0 proxy_mode_id="$(get_single_host_proxy_mode_id "${proxy_mode}")" || return 1 @@ -261,7 +224,9 @@ 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}"; then + : + else collect_env_status=$? return "${collect_env_status}" fi @@ -270,17 +235,30 @@ save_single_host_selection() { return 1 fi - if ! persist_single_host_selection_metadata \ + if persist_single_host_selection_metadata \ "${stack_dir}" \ "${proxy_mode_id}" \ "${database_id}" \ "${redis_id}" \ "${compose_files_lines}" \ - "${apps_metadata_json_object}" \ "${env_lines}"; then + : + else + wizard_metadata_status=$? + return "${wizard_metadata_status}" + fi + + if [ -z "${apps_metadata_json_object}" ]; then return 1 fi + if persist_stack_metadata_apps_object "${stack_dir}" "${apps_metadata_json_object}"; then + : + else + apps_metadata_status=$? + return "${apps_metadata_status}" + fi + if ! persist_stack_apps_json_from_metadata_apps "${stack_dir}"; then return 1 fi diff --git a/scripts/easy-docker/lib/ui/screens.sh b/scripts/easy-docker/lib/ui/screens.sh index 80456d69..d6d917ad 100755 --- a/scripts/easy-docker/lib/ui/screens.sh +++ b/scripts/easy-docker/lib/ui/screens.sh @@ -10,6 +10,8 @@ load_ui_screen_modules() { source "${screens_dir}/production.sh" # shellcheck source=scripts/easy-docker/lib/ui/screens/environment.sh source "${screens_dir}/environment.sh" + # shellcheck source=scripts/easy-docker/lib/ui/screens/tools.sh + source "${screens_dir}/tools.sh" } load_ui_screen_modules diff --git a/scripts/easy-docker/lib/ui/screens/base.sh b/scripts/easy-docker/lib/ui/screens/base.sh index 2b02c3fc..18215e7b 100755 --- a/scripts/easy-docker/lib/ui/screens/base.sh +++ b/scripts/easy-docker/lib/ui/screens/base.sh @@ -74,12 +74,13 @@ render_main_screen() { show_main_menu() { gum choose \ - --height 8 \ + --height 10 \ --header "Choose an action" \ --cursor.foreground 63 \ --selected.foreground 45 \ "Production Stack" \ "Development Stack" \ + "Tools" \ "Environment check" \ "Exit" } diff --git a/scripts/easy-docker/lib/ui/screens/production.sh b/scripts/easy-docker/lib/ui/screens/production.sh index 4098d6e0..a28df88d 100755 --- a/scripts/easy-docker/lib/ui/screens/production.sh +++ b/scripts/easy-docker/lib/ui/screens/production.sh @@ -59,6 +59,46 @@ prompt_new_stack_name() { --placeholder "my-production-stack" } +show_frappe_version_profile_menu() { + local stack_name="${1}" + local options_lines="${2:-}" + local selected_label="${3:-}" + local status_text="" + local option_line="" + local -a menu_options=() + local -a gum_args=() + + render_main_screen 1 >&2 + + status_text="$(printf "Create stack: %s\n\nSelect the Frappe branch profile from frappe.tsv.\nThis sets the stack default for branch suggestions." "${stack_name}")" + render_box_message "${status_text}" "0 2" >&2 + + while IFS= read -r option_line; do + if [ -z "${option_line}" ]; then + continue + fi + menu_options+=("${option_line}") + done <&2 stack_name="${stack_dir##*/}" - status_text="$(printf "Stack: %s\n\nCustom modular apps\nSelect one or more options.\nUse Space to toggle and Enter to confirm." "${stack_name}")" + status_text="$(printf "Stack: %s\n\nApps\nUse Space to toggle apps from apps.tsv. Press Enter to continue to branch selection per app.\nUse Ctrl+C to go back." "${stack_name}")" render_box_message "${status_text}" "0 2" >&2 - gum choose \ - --no-limit \ - --height 10 \ - --header "Custom modular apps" \ - --cursor.foreground 63 \ - --selected.foreground 45 \ - "ERPNext" \ - "CRM" \ - "Custom Git app(s)" \ - "${back_option_label}" + while IFS= read -r option_line; do + if [ -z "${option_line}" ]; then + continue + fi + menu_options+=("${option_line}") + done <&2 + + status_text="$(printf "Tools\n\nManage helper wizards for easy-docker.\nUse this area to maintain the app catalog shown in app selection.")" + render_box_message "${status_text}" "0 2" >&2 + + gum choose \ + --height 8 \ + --header "Tools - App Catalog Utilities" \ + --cursor.foreground 63 \ + --selected.foreground 45 \ + "Add Apps for App Selection" \ + "Back to main menu" \ + "Exit and close easy-docker" +} + +prompt_tools_apps_catalog_input() { + local field_label="${1}" + local help_text="${2}" + local placeholder="${3:-}" + local status_text="" + + render_main_screen 1 >&2 + + status_text="$(printf "Tools\n\nAdd Apps for App Selection\nThis wizard updates scripts/easy-docker/config/apps.tsv used by app selection.\n\n%s\nType /back or press Ctrl+C to cancel." "${help_text}")" + render_box_message "${status_text}" "0 2" >&2 + + gum input \ + --header "${field_label}" \ + --prompt "value> " \ + --placeholder "${placeholder}" +} + +show_tools_apps_default_branch_menu() { + local status_text="" + + render_main_screen 1 >&2 + + status_text="$(printf "Tools\n\nAdd Apps for App Selection\nSelect the default branch from the configured branch list.\nUse Ctrl+C or choose Back to return.")" + render_box_message "${status_text}" "0 2" >&2 + + gum choose \ + --height 14 \ + --header "Default Branch - Choose from List" \ + --cursor.foreground 63 \ + --selected.foreground 45 \ + "$@" \ + "Back" +} From cbe5c7042c303c1484c2bd10510bfa26dd790cfa Mon Sep 17 00:00:00 2001 From: RocketQuack <202538874+Rocket-Quack@users.noreply.github.com> Date: Sun, 1 Mar 2026 17:19:00 +0100 Subject: [PATCH 10/51] fix(easy-docker): align app branch defaults with catalog --- scripts/easy-docker/config/apps.tsv | 10 +++++----- .../easy-docker/lib/app/wizard/common/apps.sh | 9 ++++++++- .../easy-docker/lib/app/wizard/env/apps.sh | 20 +++++++++---------- 3 files changed, 22 insertions(+), 17 deletions(-) diff --git a/scripts/easy-docker/config/apps.tsv b/scripts/easy-docker/config/apps.tsv index bbce0ae4..3ed9591c 100644 --- a/scripts/easy-docker/config/apps.tsv +++ b/scripts/easy-docker/config/apps.tsv @@ -1,7 +1,7 @@ # id label repo default_branch branches_csv -erpnext ERPNext https://github.com/frappe/erpnext develop version-15,version-16,develop -crm CRM https://github.com/frappe/crm develop develop,version-16,version-15 +erpnext ERPNext https://github.com/frappe/erpnext develop develop,version-15,version-16 +crm CRM https://github.com/frappe/crm develop develop,main hrms HRMS https://github.com/frappe/hrms develop develop,version-16,version-15 -lms LMS https://github.com/frappe/lms develop develop,version-16,version-15 -helpdesk Helpdesk https://github.com/frappe/helpdesk develop develop,version-16,version-15 -drive Drive https://github.com/frappe/drive develop develop,version-16,version-15 +lms LMS https://github.com/frappe/lms develop develop,main +helpdesk Helpdesk https://github.com/frappe/helpdesk develop develop,main +drive Drive https://github.com/frappe/drive develop develop diff --git a/scripts/easy-docker/lib/app/wizard/common/apps.sh b/scripts/easy-docker/lib/app/wizard/common/apps.sh index b9ecd802..7c23a198 100755 --- a/scripts/easy-docker/lib/app/wizard/common/apps.sh +++ b/scripts/easy-docker/lib/app/wizard/common/apps.sh @@ -689,6 +689,7 @@ build_stack_apps_json_content_from_metadata_apps() { local custom_apps_lines="" local predefined_branch="" local preset_branch="" + local catalog_default_branch="" local app="" local line="" local repo="" @@ -721,8 +722,14 @@ build_stack_apps_json_content_from_metadata_apps() { fi predefined_branch="$(get_metadata_apps_predefined_branch_for_id "${metadata_path}" "${app}" || true)" + if [ -z "${predefined_branch}" ]; then - predefined_branch="${preset_branch}" + catalog_default_branch="$(get_predefined_app_default_branch_by_id "${app}" || true)" + if [ -n "${catalog_default_branch}" ]; then + predefined_branch="${catalog_default_branch}" + else + predefined_branch="${preset_branch}" + fi fi escaped_url="$(json_escape_string "${url}")" diff --git a/scripts/easy-docker/lib/app/wizard/env/apps.sh b/scripts/easy-docker/lib/app/wizard/env/apps.sh index dc6a1ef8..6b2d1939 100755 --- a/scripts/easy-docker/lib/app/wizard/env/apps.sh +++ b/scripts/easy-docker/lib/app/wizard/env/apps.sh @@ -174,7 +174,6 @@ choose_predefined_app_branch() { local status_text="" local selection="" local default_hint="" - local preferred_found=0 local -a branch_options=() if ! get_predefined_app_branch_lines_by_id branches_lines "${app_id}"; then @@ -186,20 +185,11 @@ choose_predefined_app_branch() { if [ -z "${branch}" ]; then continue fi - if [ "${branch}" = "${preferred_branch}" ]; then - branch_options=("${branch}" "${branch_options[@]}") - preferred_found=1 - else - branch_options+=("${branch}") - fi + branch_options+=("${branch}") done < Date: Sun, 1 Mar 2026 17:19:16 +0100 Subject: [PATCH 11/51] fix(easy-docker): support export-prefixed env keys --- scripts/easy-docker/lib/app/wizard/common/core.sh | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/scripts/easy-docker/lib/app/wizard/common/core.sh b/scripts/easy-docker/lib/app/wizard/common/core.sh index 3402f3d1..f55fe562 100755 --- a/scripts/easy-docker/lib/app/wizard/common/core.sh +++ b/scripts/easy-docker/lib/app/wizard/common/core.sh @@ -150,12 +150,19 @@ get_env_file_key_value() { /^[[:space:]]*#/ { next } $0 !~ /=/ { next } { - k = $1 + line = $0 + sub(/\r$/, "", line) + pos = index(line, "=") + if (pos == 0) { + next + } + k = substr(line, 1, pos - 1) + sub(/^[[:space:]]*export[[:space:]]+/, "", k) gsub(/^[[:space:]]+|[[:space:]]+$/, "", k) if (k != key) { next } - v = substr($0, index($0, "=") + 1) + v = substr(line, pos + 1) gsub(/^[[:space:]]+|[[:space:]]+$/, "", v) print v exit From 6cd1723a40a5e43c2ace95572020d731ffcfa739 Mon Sep 17 00:00:00 2001 From: RocketQuack <202538874+Rocket-Quack@users.noreply.github.com> Date: Sun, 1 Mar 2026 17:21:09 +0100 Subject: [PATCH 12/51] feat(easy-docker): add custom image build flow --- .../lib/app/wizard/common/compose.sh | 121 ++++++++++++++++++ .../lib/app/wizard/flows/manage.sh | 61 ++++++++- scripts/easy-docker/lib/checks/docker.sh | 2 +- .../easy-docker/lib/ui/screens/production.sh | 5 +- scripts/easy-docker/lib/ui/screens/tools.sh | 2 +- 5 files changed, 186 insertions(+), 5 deletions(-) diff --git a/scripts/easy-docker/lib/app/wizard/common/compose.sh b/scripts/easy-docker/lib/app/wizard/common/compose.sh index b6691146..ec5def28 100755 --- a/scripts/easy-docker/lib/app/wizard/common/compose.sh +++ b/scripts/easy-docker/lib/app/wizard/common/compose.sh @@ -1,5 +1,7 @@ #!/usr/bin/env bash +EASY_DOCKER_BUILD_ERROR_DETAIL="" + render_stack_compose_from_metadata() { local stack_dir="${1}" local metadata_path="" @@ -74,3 +76,122 @@ EOF return 0 } + +build_stack_custom_image() { + local stack_dir="${1}" + local metadata_path="" + local env_path="" + local apps_json_path="" + local custom_image="" + local custom_tag="" + local frappe_branch="" + local frappe_path="https://github.com/frappe/frappe" + local repo_root="" + local containerfile_path="" + local apps_json_base64="" + local apps_refs_lines="" + local app_ref_line="" + local app_url="" + local app_branch="" + local git_error="" + local image_ref="" + + EASY_DOCKER_BUILD_ERROR_DETAIL="" + + metadata_path="${stack_dir}/metadata.json" + env_path="$(get_stack_env_path "${stack_dir}")" + apps_json_path="${stack_dir}/apps.json" + + if [ ! -f "${metadata_path}" ]; then + return 11 + fi + if [ ! -f "${env_path}" ]; then + return 12 + fi + + custom_image="$(get_env_file_key_value "${env_path}" "CUSTOM_IMAGE" || true)" + custom_tag="$(get_env_file_key_value "${env_path}" "CUSTOM_TAG" || true)" + frappe_branch="$(get_stack_frappe_branch "${stack_dir}" || true)" + if [ -z "${custom_image}" ]; then + return 13 + fi + if [ -z "${custom_tag}" ]; then + return 14 + fi + if [ -z "${frappe_branch}" ]; then + return 15 + fi + + # Keep apps.json aligned with current metadata app selection before build. + if ! persist_stack_apps_json_from_metadata_apps "${stack_dir}"; then + return 16 + fi + if [ ! -f "${apps_json_path}" ]; then + return 17 + fi + + if ! command_exists git; then + return 22 + fi + + apps_refs_lines="$( + awk ' + match($0, /"url"[[:space:]]*:[[:space:]]*"([^"]+)"/, url_parts) && + match($0, /"branch"[[:space:]]*:[[:space:]]*"([^"]+)"/, branch_parts) { + print url_parts[1] "|" branch_parts[1] + } + ' "${apps_json_path}" + )" + if [ -z "${apps_refs_lines}" ]; then + return 23 + fi + + while IFS= read -r app_ref_line; do + if [ -z "${app_ref_line}" ]; then + continue + fi + + app_url="${app_ref_line%%|*}" + app_branch="${app_ref_line#*|}" + if [ -z "${app_url}" ] || [ -z "${app_branch}" ]; then + continue + fi + + if git_error="$(git ls-remote --exit-code --heads "${app_url}" "${app_branch}" 2>&1)"; then + : + else + # shellcheck disable=SC2034 # Read by manage flow after build_stack_custom_image returns 24. + EASY_DOCKER_BUILD_ERROR_DETAIL="$(printf '%s@%s :: %s' "${app_url}" "${app_branch}" "${git_error}")" + return 24 + fi + done < ${EASY_DOCKER_BUILD_ERROR_DETAIL}" 6 + ;; + *) + show_warning_and_wait "Custom image build failed (${build_image_status})." 4 + ;; + esac + continue + fi + + show_warning_and_wait "Custom image build finished successfully for stack: ${stack_name}" 3 + ;; "Generate docker compose from env") generated_compose_path="$(get_stack_generated_compose_path "${stack_dir}")" if render_stack_compose_from_metadata "${stack_dir}"; then diff --git a/scripts/easy-docker/lib/checks/docker.sh b/scripts/easy-docker/lib/checks/docker.sh index 1b703f7f..33ebb826 100755 --- a/scripts/easy-docker/lib/checks/docker.sh +++ b/scripts/easy-docker/lib/checks/docker.sh @@ -16,7 +16,7 @@ get_missing_docker_commands() { local missing=() local subcommand="" - for subcommand in ps exec inspect cp; do + for subcommand in ps exec inspect cp build; do if ! docker_supports_command "${subcommand}"; then missing+=("docker ${subcommand}") fi diff --git a/scripts/easy-docker/lib/ui/screens/production.sh b/scripts/easy-docker/lib/ui/screens/production.sh index a28df88d..4c1d3ade 100755 --- a/scripts/easy-docker/lib/ui/screens/production.sh +++ b/scripts/easy-docker/lib/ui/screens/production.sh @@ -382,7 +382,7 @@ show_manage_stack_apps_menu() { --header "Stack apps actions" \ --cursor.foreground 63 \ --selected.foreground 45 \ - "Generate apps.json" \ + "Regenerate apps.json from metadata" \ "Select apps and branches" \ "Back" \ "Exit and close easy-docker" @@ -400,10 +400,11 @@ show_manage_stack_docker_menu() { render_box_message "${status_text}" "0 2" >&2 gum choose \ - --height 7 \ + --height 8 \ --header "Stack docker actions" \ --cursor.foreground 63 \ --selected.foreground 45 \ + "Build custom image" \ "Generate docker compose from env" \ "Back" \ "Exit and close easy-docker" diff --git a/scripts/easy-docker/lib/ui/screens/tools.sh b/scripts/easy-docker/lib/ui/screens/tools.sh index d6d94711..4a22a7f2 100755 --- a/scripts/easy-docker/lib/ui/screens/tools.sh +++ b/scripts/easy-docker/lib/ui/screens/tools.sh @@ -9,7 +9,7 @@ show_tools_menu() { render_box_message "${status_text}" "0 2" >&2 gum choose \ - --height 8 \ + --height 9 \ --header "Tools - App Catalog Utilities" \ --cursor.foreground 63 \ --selected.foreground 45 \ From 84ca792a236bc57f2cb08d3e424b898464ee7fa2 Mon Sep 17 00:00:00 2001 From: RocketQuack <202538874+Rocket-Quack@users.noreply.github.com> Date: Sun, 1 Mar 2026 19:38:52 +0100 Subject: [PATCH 13/51] fix(easy-docker): restore app selection mapping and parsing --- .../easy-docker/lib/app/wizard/common/apps.sh | 291 ++++++++---------- .../easy-docker/lib/app/wizard/common/core.sh | 16 - .../easy-docker/lib/app/wizard/env/apps.sh | 7 +- .../easy-docker/lib/ui/screens/production.sh | 11 +- 4 files changed, 138 insertions(+), 187 deletions(-) diff --git a/scripts/easy-docker/lib/app/wizard/common/apps.sh b/scripts/easy-docker/lib/app/wizard/common/apps.sh index 7c23a198..faab91cf 100755 --- a/scripts/easy-docker/lib/app/wizard/common/apps.sh +++ b/scripts/easy-docker/lib/app/wizard/common/apps.sh @@ -230,216 +230,169 @@ get_predefined_apps_catalog_entries() { done <"${catalog_path}" } -get_predefined_app_labels_lines() { - local entry="" - local app_label="" +parse_predefined_app_catalog_entry() { + local entry="${1}" + local app_id_var="${2}" + local app_label_var="${3}" + local app_repo_var="${4}" + local app_default_branch_var="${5}" + local app_branches_csv_var="${6}" + local parsed_app_id="" + local parsed_app_label="" + local parsed_app_repo="" + local parsed_app_default_branch="" + local parsed_app_branches_csv="" - while IFS= read -r entry; do - if [ -z "${entry}" ]; then - continue - fi - - app_label="${entry#*|}" - app_label="${app_label%%|*}" - printf '%s\n' "${app_label}" - done < <(get_predefined_apps_catalog_entries) + IFS='|' read -r parsed_app_id parsed_app_label parsed_app_repo parsed_app_default_branch parsed_app_branches_csv <<<"${entry}" + printf -v "${app_id_var}" "%s" "${parsed_app_id}" + printf -v "${app_label_var}" "%s" "${parsed_app_label}" + printf -v "${app_repo_var}" "%s" "${parsed_app_repo}" + printf -v "${app_default_branch_var}" "%s" "${parsed_app_default_branch}" + printf -v "${app_branches_csv_var}" "%s" "${parsed_app_branches_csv}" } -get_predefined_app_id_by_label() { - local label="${1}" +get_predefined_app_field_by_field() { + local lookup_field="${1}" + local lookup_value="${2}" + local result_field="${3}" local entry="" local app_id="" local app_label="" local app_repo="" local app_default_branch="" local app_branches_csv="" + local lookup_candidate="" + local result_value="" + + trim_predefined_catalog_field lookup_value "${lookup_value}" + if [ -z "${lookup_value}" ]; then + return 1 + fi while IFS= read -r entry; do if [ -z "${entry}" ]; then continue fi - IFS='|' read -r app_id app_label app_repo app_default_branch app_branches_csv <<<"${entry}" - if [ "${app_label}" = "${label}" ]; then - printf '%s\n' "${app_id}" - return 0 - fi - done < <(get_predefined_apps_catalog_entries) + parse_predefined_app_catalog_entry "${entry}" app_id app_label app_repo app_default_branch app_branches_csv - return 1 -} - -get_predefined_app_repo_by_id() { - local app_id_lookup="${1}" - local entry="" - local app_id="" - local app_label="" - local app_repo="" - local app_default_branch="" - local app_branches_csv="" - - while IFS= read -r entry; do - if [ -z "${entry}" ]; then - continue - fi - - IFS='|' read -r app_id app_label app_repo app_default_branch app_branches_csv <<<"${entry}" - if [ "${app_id}" = "${app_id_lookup}" ]; then - printf '%s\n' "${app_repo}" - return 0 - fi - done < <(get_predefined_apps_catalog_entries) - - return 1 -} - -get_predefined_app_label_by_id() { - local app_id_lookup="${1}" - local entry="" - local app_id="" - local app_label="" - local app_repo="" - local app_default_branch="" - local app_branches_csv="" - - while IFS= read -r entry; do - if [ -z "${entry}" ]; then - continue - fi - - IFS='|' read -r app_id app_label app_repo app_default_branch app_branches_csv <<<"${entry}" - if [ "${app_id}" = "${app_id_lookup}" ]; then - printf '%s\n' "${app_label}" - return 0 - fi - done < <(get_predefined_apps_catalog_entries) - - return 1 -} - -get_predefined_app_default_branch_by_id() { - local app_id_lookup="${1}" - local entry="" - local app_id="" - local app_label="" - local app_repo="" - local app_default_branch="" - local app_branches_csv="" - - while IFS= read -r entry; do - if [ -z "${entry}" ]; then - continue - fi - - IFS='|' read -r app_id app_label app_repo app_default_branch app_branches_csv <<<"${entry}" - if [ "${app_id}" = "${app_id_lookup}" ]; then - printf '%s\n' "${app_default_branch}" - return 0 - fi - done < <(get_predefined_apps_catalog_entries) - - return 1 -} - -get_predefined_app_branch_lines_by_id() { - local result_var="${1}" - local app_id_lookup="${2}" - local entry="" - local app_id="" - local app_label="" - local app_repo="" - local app_default_branch="" - local app_branches_csv="" - local branch="" - local branch_lines="" - local -a branches=() - - while IFS= read -r entry; do - if [ -z "${entry}" ]; then - continue - fi - - IFS='|' read -r app_id app_label app_repo app_default_branch app_branches_csv <<<"${entry}" - if [ "${app_id}" != "${app_id_lookup}" ]; then - continue - fi - - IFS=',' read -r -a branches <<<"${app_branches_csv}" - for branch in "${branches[@]}"; do - trim_predefined_catalog_field branch "${branch}" - if [ -z "${branch}" ]; then - continue - fi - if [ -z "${branch_lines}" ]; then - branch_lines="${branch}" - else - branch_lines="${branch_lines}"$'\n'"${branch}" - fi - done - - if [ -z "${branch_lines}" ]; then + case "${lookup_field}" in + id) + lookup_candidate="${app_id}" + ;; + label) + lookup_candidate="${app_label}" + ;; + *) return 1 + ;; + esac + + trim_predefined_catalog_field lookup_candidate "${lookup_candidate}" + if [ "${lookup_candidate}" != "${lookup_value}" ]; then + continue fi - printf -v "${result_var}" "%s" "${branch_lines}" + case "${result_field}" in + id) + result_value="${app_id}" + ;; + label) + result_value="${app_label}" + ;; + repo) + result_value="${app_repo}" + ;; + default_branch) + result_value="${app_default_branch}" + ;; + branches_csv) + result_value="${app_branches_csv}" + ;; + *) + return 1 + ;; + esac + + printf '%s\n' "${result_value}" return 0 done < <(get_predefined_apps_catalog_entries) return 1 } +get_predefined_app_id_by_label() { + local label="${1}" + get_predefined_app_field_by_field "label" "${label}" "id" +} + +get_predefined_app_repo_by_id() { + local app_id_lookup="${1}" + get_predefined_app_field_by_field "id" "${app_id_lookup}" "repo" +} + +get_predefined_app_label_by_id() { + local app_id_lookup="${1}" + get_predefined_app_field_by_field "id" "${app_id_lookup}" "label" +} + +get_predefined_app_default_branch_by_id() { + local app_id_lookup="${1}" + get_predefined_app_field_by_field "id" "${app_id_lookup}" "default_branch" +} + +get_predefined_app_branch_lines_by_id() { + local result_var="${1}" + local app_id_lookup="${2}" + local app_branches_csv="" + local branch="" + local branch_lines="" + local -a branches=() + + app_branches_csv="$(get_predefined_app_field_by_field "id" "${app_id_lookup}" "branches_csv" || true)" + if [ -z "${app_branches_csv}" ]; then + return 1 + fi + + IFS=',' read -r -a branches <<<"${app_branches_csv}" + for branch in "${branches[@]}"; do + trim_predefined_catalog_field branch "${branch}" + if [ -z "${branch}" ]; then + continue + fi + if [ -z "${branch_lines}" ]; then + branch_lines="${branch}" + else + branch_lines="${branch_lines}"$'\n'"${branch}" + fi + done + + if [ -z "${branch_lines}" ]; then + return 1 + fi + + printf -v "${result_var}" "%s" "${branch_lines}" + return 0 +} + predefined_app_catalog_has_id() { local app_id_lookup="${1}" - local entry="" - local app_id="" - local app_label="" - local app_repo="" - local app_default_branch="" - local app_branches_csv="" if [ -z "${app_id_lookup}" ]; then return 1 fi - while IFS= read -r entry; do - if [ -z "${entry}" ]; then - continue - fi - - IFS='|' read -r app_id app_label app_repo app_default_branch app_branches_csv <<<"${entry}" - if [ "${app_id}" = "${app_id_lookup}" ]; then - return 0 - fi - done < <(get_predefined_apps_catalog_entries || true) - - return 1 + get_predefined_app_field_by_field "id" "${app_id_lookup}" "id" >/dev/null 2>&1 } predefined_app_catalog_has_label() { local app_label_lookup="${1}" - local entry="" - local app_id="" - local app_label="" - local app_repo="" - local app_default_branch="" - local app_branches_csv="" if [ -z "${app_label_lookup}" ]; then return 1 fi - while IFS= read -r entry; do - if [ -z "${entry}" ]; then - continue - fi - - IFS='|' read -r app_id app_label app_repo app_default_branch app_branches_csv <<<"${entry}" - if [ "${app_label}" = "${app_label_lookup}" ]; then - return 0 - fi - done < <(get_predefined_apps_catalog_entries || true) - - return 1 + get_predefined_app_field_by_field "label" "${app_label_lookup}" "label" >/dev/null 2>&1 } append_predefined_app_catalog_entry() { diff --git a/scripts/easy-docker/lib/app/wizard/common/core.sh b/scripts/easy-docker/lib/app/wizard/common/core.sh index f55fe562..d8ed9d75 100755 --- a/scripts/easy-docker/lib/app/wizard/common/core.sh +++ b/scripts/easy-docker/lib/app/wizard/common/core.sh @@ -120,22 +120,6 @@ get_metadata_string_field() { ' "${metadata_path}" } -get_metadata_number_field() { - local metadata_path="${1}" - local field_name="${2}" - - if [ ! -f "${metadata_path}" ]; then - return 1 - fi - - awk -v field_name="${field_name}" ' - match($0, "\"" field_name "\"[[:space:]]*:[[:space:]]*([0-9]+)", parts) { - print parts[1] - exit - } - ' "${metadata_path}" -} - get_env_file_key_value() { local env_file="${1}" local key="${2}" diff --git a/scripts/easy-docker/lib/app/wizard/env/apps.sh b/scripts/easy-docker/lib/app/wizard/env/apps.sh index 6b2d1939..4b939107 100755 --- a/scripts/easy-docker/lib/app/wizard/env/apps.sh +++ b/scripts/easy-docker/lib/app/wizard/env/apps.sh @@ -235,6 +235,7 @@ prompt_custom_modular_apps_data() { local options_lines="" local selected_labels_csv="" local selection_raw="" + local selection_lines="" local prompt_status=0 local selected_predefined_csv="" local parsed_predefined_csv="" @@ -311,8 +312,12 @@ prompt_custom_modular_apps_data() { fi parsed_predefined_csv="" + # gum choose can return multiple values separated by newlines or commas + # depending on version/configuration. Normalize to one label per line. + selection_lines="$(printf '%s' "${selection_raw}" | tr ',' '\n')" while IFS= read -r selected_label; do + trim_predefined_catalog_field selected_label "${selected_label}" if [ -z "${selected_label}" ]; then continue fi @@ -323,7 +328,7 @@ prompt_custom_modular_apps_data() { fi append_csv_unique parsed_predefined_csv "${parsed_predefined_csv}" "${predefined_app_id}" done <&2 @@ -224,7 +226,14 @@ EOF --selected.foreground 45 ) if [ -n "${selected_labels_csv}" ]; then - gum_args+=(--selected "${selected_labels_csv}") + IFS=',' read -r -a selected_labels <<<"${selected_labels_csv}" + for selected_label in "${selected_labels[@]}"; do + trim_predefined_catalog_field selected_label "${selected_label}" + if [ -z "${selected_label}" ]; then + continue + fi + gum_args+=(--selected "${selected_label}") + done fi gum choose "${gum_args[@]}" "${menu_options[@]}" From 32136ac6fd87e3093c71ac46e1c99077442c49f6 Mon Sep 17 00:00:00 2001 From: RocketQuack <202538874+Rocket-Quack@users.noreply.github.com> Date: Sun, 1 Mar 2026 20:16:39 +0100 Subject: [PATCH 14/51] feat(easy-docker): add stack-level compose start with topology guard --- .../lib/app/wizard/common/compose.sh | 113 ++++++++++++++++++ .../easy-docker/lib/app/wizard/common/core.sh | 19 +++ .../lib/app/wizard/flows/manage.sh | 38 ++++++ .../easy-docker/lib/ui/screens/production.sh | 3 +- 4 files changed, 172 insertions(+), 1 deletion(-) diff --git a/scripts/easy-docker/lib/app/wizard/common/compose.sh b/scripts/easy-docker/lib/app/wizard/common/compose.sh index ec5def28..abe50ee0 100755 --- a/scripts/easy-docker/lib/app/wizard/common/compose.sh +++ b/scripts/easy-docker/lib/app/wizard/common/compose.sh @@ -1,6 +1,8 @@ #!/usr/bin/env bash EASY_DOCKER_BUILD_ERROR_DETAIL="" +# shellcheck disable=SC2034 # Read by manage flow after start_stack_with_compose_from_metadata fails. +EASY_DOCKER_COMPOSE_ERROR_DETAIL="" render_stack_compose_from_metadata() { local stack_dir="${1}" @@ -77,6 +79,117 @@ EOF return 0 } +start_stack_with_compose_from_metadata() { + local stack_dir="${1}" + 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 configured_pull_policy="" + local runtime_pull_policy="" + local custom_image="" + local custom_tag="" + local image_ref="" + local stack_topology="" + local repo_root="" + local -a compose_args=() + + # shellcheck disable=SC2034 # Read by manage flow after start_stack_with_compose_from_metadata fails. + EASY_DOCKER_COMPOSE_ERROR_DETAIL="" + + metadata_path="${stack_dir}/metadata.json" + env_path="$(get_stack_env_path "${stack_dir}")" + + if [ ! -f "${metadata_path}" ]; then + return 31 + fi + + if [ ! -f "${env_path}" ]; then + return 32 + fi + + stack_topology="$(get_stack_topology "${stack_dir}" || true)" + if [ -z "${stack_topology}" ]; then + # shellcheck disable=SC2034 # Read by manage flow after start_stack_with_compose_from_metadata returns 33. + EASY_DOCKER_COMPOSE_ERROR_DETAIL="metadata.json missing topology" + return 33 + fi + + case "${stack_topology}" in + "single-host") ;; + *) + # shellcheck disable=SC2034 # Read by manage flow after start_stack_with_compose_from_metadata returns 34. + EASY_DOCKER_COMPOSE_ERROR_DETAIL="${stack_topology}" + return 34 + ;; + 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 + + configured_pull_policy="$(get_env_file_key_value "${env_path}" "PULL_POLICY" || true)" + if [ -z "${configured_pull_policy}" ]; then + custom_image="$(get_env_file_key_value "${env_path}" "CUSTOM_IMAGE" || true)" + custom_tag="$(get_env_file_key_value "${env_path}" "CUSTOM_TAG" || true)" + if [ -n "${custom_image}" ] && [ -n "${custom_tag}" ]; then + image_ref="${custom_image}:${custom_tag}" + if docker image inspect "${image_ref}" >/dev/null 2>&1; then + runtime_pull_policy="if_not_present" + fi + fi + fi + + compose_files_lines="$(get_metadata_compose_files_lines "${metadata_path}" || true)" + if [ -z "${compose_files_lines}" ]; then + return 35 + 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 + # shellcheck disable=SC2034 # Read by manage flow after start_stack_with_compose_from_metadata returns 36. + EASY_DOCKER_COMPOSE_ERROR_DETAIL="${source_compose_path}" + return 36 + fi + + compose_args+=(-f "${source_compose_path}") + done < ${EASY_DOCKER_COMPOSE_ERROR_DETAIL}" 4 + ;; + 37) + show_warning_and_wait "docker compose up failed. Check the output above for details." 4 + ;; + *) + show_warning_and_wait "Cannot start stack with docker compose (${compose_start_status})." 4 + ;; + esac + continue + fi + + show_warning_and_wait "Stack started successfully with docker compose: ${stack_name}" 3 + ;; "Docker") while true; do docker_action="$(show_manage_stack_docker_menu "${stack_name}" "${stack_dir}" || true)" diff --git a/scripts/easy-docker/lib/ui/screens/production.sh b/scripts/easy-docker/lib/ui/screens/production.sh index a2721f7c..b99d9284 100755 --- a/scripts/easy-docker/lib/ui/screens/production.sh +++ b/scripts/easy-docker/lib/ui/screens/production.sh @@ -365,12 +365,13 @@ show_manage_stack_actions_menu() { render_box_message "${status_text}" "0 2" >&2 gum choose \ - --height 7 \ + --height 8 \ --header "Stack actions" \ --cursor.foreground 63 \ --selected.foreground 45 \ "Apps" \ "Docker" \ + "Start stack in Docker Compose" \ "Back" \ "Exit and close easy-docker" } From d2ee473c68bbbef372265ed454a5282a20e49fc4 Mon Sep 17 00:00:00 2001 From: RocketQuack <202538874+Rocket-Quack@users.noreply.github.com> Date: Sun, 1 Mar 2026 20:23:14 +0100 Subject: [PATCH 15/51] feat(easy-docker): show stack runtime status in manage header --- .../lib/app/wizard/common/compose.sh | 113 ++++++++++++++++++ .../lib/app/wizard/flows/manage.sh | 4 +- .../easy-docker/lib/ui/screens/production.sh | 8 +- 3 files changed, 122 insertions(+), 3 deletions(-) diff --git a/scripts/easy-docker/lib/app/wizard/common/compose.sh b/scripts/easy-docker/lib/app/wizard/common/compose.sh index abe50ee0..f890efe5 100755 --- a/scripts/easy-docker/lib/app/wizard/common/compose.sh +++ b/scripts/easy-docker/lib/app/wizard/common/compose.sh @@ -190,6 +190,119 @@ EOF return 0 } +get_stack_compose_runtime_status_label() { + local result_var="${1}" + local stack_dir="${2}" + local metadata_path="" + local env_path="" + local stack_topology="" + local compose_files_lines="" + local compose_file="" + local source_compose_path="" + local env_erpnext_version="" + local fallback_erpnext_version="" + local running_services_lines="" + local compose_status=0 + local running_services_count=0 + local repo_root="" + local status_label="" + local -a compose_args=() + + metadata_path="${stack_dir}/metadata.json" + env_path="$(get_stack_env_path "${stack_dir}")" + + if [ ! -f "${metadata_path}" ]; then + printf -v "${result_var}" "%s" "Unknown (metadata missing)" + return 0 + fi + + stack_topology="$(get_stack_topology "${stack_dir}" || true)" + if [ -z "${stack_topology}" ]; then + printf -v "${result_var}" "%s" "Unknown (topology missing)" + return 0 + fi + + case "${stack_topology}" in + "single-host") ;; + *) + printf -v "${result_var}" "%s" "N/A (${stack_topology})" + return 0 + ;; + esac + + if [ ! -f "${env_path}" ]; then + printf -v "${result_var}" "%s" "Unknown (env missing)" + return 0 + fi + + env_erpnext_version="$(get_env_file_key_value "${env_path}" "ERPNEXT_VERSION" || true)" + if [ -z "${env_erpnext_version}" ]; then + fallback_erpnext_version="$(get_default_erpnext_version || true)" + fi + + compose_files_lines="$(get_metadata_compose_files_lines "${metadata_path}" || true)" + if [ -z "${compose_files_lines}" ]; then + printf -v "${result_var}" "%s" "Unknown (compose files missing)" + return 0 + 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 + printf -v "${result_var}" "%s" "Unknown (missing file: ${compose_file})" + return 0 + fi + + compose_args+=(-f "${source_compose_path}") + done </dev/null + )" + compose_status=$? + else + running_services_lines="$( + docker compose --env-file "${env_path}" "${compose_args[@]}" ps --status running --services 2>/dev/null + )" + compose_status=$? + fi + + if [ "${compose_status}" -ne 0 ]; then + printf -v "${result_var}" "%s" "Unknown (docker compose status failed)" + return 0 + fi + + while IFS= read -r compose_file; do + if [ -n "${compose_file}" ]; then + running_services_count=$((running_services_count + 1)) + fi + done <&2 - status_text="$(printf "Manage stack\n\nStack: %s\nDirectory: %s\n\nChoose an action for this stack." "${stack_name}" "${stack_dir}")" + 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}")" render_box_message "${status_text}" "0 2" >&2 + menu_header="$(printf "Stack actions | %s" "${stack_runtime_status}")" + gum choose \ --height 8 \ - --header "Stack actions" \ + --header "${menu_header}" \ --cursor.foreground 63 \ --selected.foreground 45 \ "Apps" \ From 2a9aabbdb3d04f0b9632d59f191b1e16117515cf Mon Sep 17 00:00:00 2001 From: RocketQuack <202538874+Rocket-Quack@users.noreply.github.com> Date: Wed, 25 Mar 2026 13:10:17 +0100 Subject: [PATCH 16/51] refactor(easy-docker): split compose and production modules --- .../lib/app/wizard/common/compose.sh | 424 +---------------- .../lib/app/wizard/common/compose/build.sh | 120 +++++ .../lib/app/wizard/common/compose/render.sh | 76 ++++ .../lib/app/wizard/common/compose/start.sh | 242 ++++++++++ .../easy-docker/lib/ui/screens/production.sh | 430 +----------------- .../lib/ui/screens/production/manage.sh | 142 ++++++ .../lib/ui/screens/production/setup.sh | 100 ++++ .../lib/ui/screens/production/topology.sh | 206 +++++++++ 8 files changed, 906 insertions(+), 834 deletions(-) create mode 100755 scripts/easy-docker/lib/app/wizard/common/compose/build.sh create mode 100755 scripts/easy-docker/lib/app/wizard/common/compose/render.sh create mode 100755 scripts/easy-docker/lib/app/wizard/common/compose/start.sh create mode 100755 scripts/easy-docker/lib/ui/screens/production/manage.sh create mode 100755 scripts/easy-docker/lib/ui/screens/production/setup.sh create mode 100755 scripts/easy-docker/lib/ui/screens/production/topology.sh diff --git a/scripts/easy-docker/lib/app/wizard/common/compose.sh b/scripts/easy-docker/lib/app/wizard/common/compose.sh index f890efe5..9bfc7f56 100755 --- a/scripts/easy-docker/lib/app/wizard/common/compose.sh +++ b/scripts/easy-docker/lib/app/wizard/common/compose.sh @@ -4,420 +4,16 @@ EASY_DOCKER_BUILD_ERROR_DETAIL="" # shellcheck disable=SC2034 # Read by manage flow after start_stack_with_compose_from_metadata fails. EASY_DOCKER_COMPOSE_ERROR_DETAIL="" -render_stack_compose_from_metadata() { - local stack_dir="${1}" - local metadata_path="" - local env_path="" - local generated_compose_path="" - local generated_compose_tmp_path="" - local compose_files_lines="" - local compose_file="" - local source_compose_path="" - local env_erpnext_version="" - local fallback_erpnext_version="" - local repo_root="" - local -a compose_args=() +load_easy_docker_compose_modules() { + local compose_dir="" + compose_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/compose" - metadata_path="${stack_dir}/metadata.json" - env_path="$(get_stack_env_path "${stack_dir}")" - generated_compose_path="$(get_stack_generated_compose_path "${stack_dir}")" - generated_compose_tmp_path="${generated_compose_path}.tmp" - - if [ ! -f "${metadata_path}" ]; then - return 1 - fi - - if [ ! -f "${env_path}" ]; then - return 1 - fi - - env_erpnext_version="$(get_env_file_key_value "${env_path}" "ERPNEXT_VERSION" || true)" - if [ -z "${env_erpnext_version}" ]; then - fallback_erpnext_version="$(get_default_erpnext_version || true)" - fi - - compose_files_lines="$(get_metadata_compose_files_lines "${metadata_path}" || true)" - if [ -z "${compose_files_lines}" ]; then - return 1 - fi - - repo_root="$(get_easy_docker_repo_root)" - while IFS= read -r compose_file; do - if [ -z "${compose_file}" ]; then - continue - fi - - source_compose_path="${repo_root}/${compose_file}" - if [ ! -f "${source_compose_path}" ]; then - return 1 - fi - - compose_args+=(-f "${source_compose_path}") - done <"${generated_compose_tmp_path}"; then - rm -f -- "${generated_compose_tmp_path}" >/dev/null 2>&1 || true - return 1 - fi - elif ! docker compose --env-file "${env_path}" "${compose_args[@]}" config >"${generated_compose_tmp_path}"; then - rm -f -- "${generated_compose_tmp_path}" >/dev/null 2>&1 || true - return 1 - fi - - if ! mv -- "${generated_compose_tmp_path}" "${generated_compose_path}"; then - rm -f -- "${generated_compose_tmp_path}" >/dev/null 2>&1 || true - return 1 - fi - - return 0 + # shellcheck source=scripts/easy-docker/lib/app/wizard/common/compose/render.sh + source "${compose_dir}/render.sh" + # shellcheck source=scripts/easy-docker/lib/app/wizard/common/compose/start.sh + source "${compose_dir}/start.sh" + # shellcheck source=scripts/easy-docker/lib/app/wizard/common/compose/build.sh + source "${compose_dir}/build.sh" } -start_stack_with_compose_from_metadata() { - local stack_dir="${1}" - 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 configured_pull_policy="" - local runtime_pull_policy="" - local custom_image="" - local custom_tag="" - local image_ref="" - local stack_topology="" - local repo_root="" - local -a compose_args=() - - # shellcheck disable=SC2034 # Read by manage flow after start_stack_with_compose_from_metadata fails. - EASY_DOCKER_COMPOSE_ERROR_DETAIL="" - - metadata_path="${stack_dir}/metadata.json" - env_path="$(get_stack_env_path "${stack_dir}")" - - if [ ! -f "${metadata_path}" ]; then - return 31 - fi - - if [ ! -f "${env_path}" ]; then - return 32 - fi - - stack_topology="$(get_stack_topology "${stack_dir}" || true)" - if [ -z "${stack_topology}" ]; then - # shellcheck disable=SC2034 # Read by manage flow after start_stack_with_compose_from_metadata returns 33. - EASY_DOCKER_COMPOSE_ERROR_DETAIL="metadata.json missing topology" - return 33 - fi - - case "${stack_topology}" in - "single-host") ;; - *) - # shellcheck disable=SC2034 # Read by manage flow after start_stack_with_compose_from_metadata returns 34. - EASY_DOCKER_COMPOSE_ERROR_DETAIL="${stack_topology}" - return 34 - ;; - 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 - - configured_pull_policy="$(get_env_file_key_value "${env_path}" "PULL_POLICY" || true)" - if [ -z "${configured_pull_policy}" ]; then - custom_image="$(get_env_file_key_value "${env_path}" "CUSTOM_IMAGE" || true)" - custom_tag="$(get_env_file_key_value "${env_path}" "CUSTOM_TAG" || true)" - if [ -n "${custom_image}" ] && [ -n "${custom_tag}" ]; then - image_ref="${custom_image}:${custom_tag}" - if docker image inspect "${image_ref}" >/dev/null 2>&1; then - runtime_pull_policy="if_not_present" - fi - fi - fi - - compose_files_lines="$(get_metadata_compose_files_lines "${metadata_path}" || true)" - if [ -z "${compose_files_lines}" ]; then - return 35 - 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 - # shellcheck disable=SC2034 # Read by manage flow after start_stack_with_compose_from_metadata returns 36. - EASY_DOCKER_COMPOSE_ERROR_DETAIL="${source_compose_path}" - return 36 - fi - - compose_args+=(-f "${source_compose_path}") - done </dev/null - )" - compose_status=$? - else - running_services_lines="$( - docker compose --env-file "${env_path}" "${compose_args[@]}" ps --status running --services 2>/dev/null - )" - compose_status=$? - fi - - if [ "${compose_status}" -ne 0 ]; then - printf -v "${result_var}" "%s" "Unknown (docker compose status failed)" - return 0 - fi - - while IFS= read -r compose_file; do - if [ -n "${compose_file}" ]; then - running_services_count=$((running_services_count + 1)) - fi - done <&1)"; then - : - else - # shellcheck disable=SC2034 # Read by manage flow after build_stack_custom_image returns 24. - EASY_DOCKER_BUILD_ERROR_DETAIL="$(printf '%s@%s :: %s' "${app_url}" "${app_branch}" "${git_error}")" - return 24 - fi - done <&1)"; then + : + else + # shellcheck disable=SC2034 # Read by manage flow after build_stack_custom_image returns 24. + EASY_DOCKER_BUILD_ERROR_DETAIL="$(printf '%s@%s :: %s' "${app_url}" "${app_branch}" "${git_error}")" + return 24 + fi + done <"${generated_compose_tmp_path}"; then + rm -f -- "${generated_compose_tmp_path}" >/dev/null 2>&1 || true + return 1 + fi + elif ! docker compose --env-file "${env_path}" "${compose_args[@]}" config >"${generated_compose_tmp_path}"; then + rm -f -- "${generated_compose_tmp_path}" >/dev/null 2>&1 || true + return 1 + fi + + if ! mv -- "${generated_compose_tmp_path}" "${generated_compose_path}"; then + rm -f -- "${generated_compose_tmp_path}" >/dev/null 2>&1 || true + return 1 + fi + + return 0 +} diff --git a/scripts/easy-docker/lib/app/wizard/common/compose/start.sh b/scripts/easy-docker/lib/app/wizard/common/compose/start.sh new file mode 100755 index 00000000..2cfde7e7 --- /dev/null +++ b/scripts/easy-docker/lib/app/wizard/common/compose/start.sh @@ -0,0 +1,242 @@ +#!/usr/bin/env bash + +start_stack_with_compose_from_metadata() { + local stack_dir="${1}" + 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 configured_pull_policy="" + local runtime_pull_policy="" + local custom_image="" + local custom_tag="" + local image_ref="" + local image_inspect_error="" + local stack_topology="" + local repo_root="" + local -a compose_args=() + + # shellcheck disable=SC2034 # Read by manage flow after start_stack_with_compose_from_metadata fails. + EASY_DOCKER_COMPOSE_ERROR_DETAIL="" + + metadata_path="${stack_dir}/metadata.json" + env_path="$(get_stack_env_path "${stack_dir}")" + + if [ ! -f "${metadata_path}" ]; then + return 31 + fi + + if [ ! -f "${env_path}" ]; then + return 32 + fi + + stack_topology="$(get_stack_topology "${stack_dir}" || true)" + if [ -z "${stack_topology}" ]; then + # shellcheck disable=SC2034 # Read by manage flow after start_stack_with_compose_from_metadata returns 33. + EASY_DOCKER_COMPOSE_ERROR_DETAIL="metadata.json missing topology" + return 33 + fi + + case "${stack_topology}" in + "single-host") ;; + *) + # shellcheck disable=SC2034 # Read by manage flow after start_stack_with_compose_from_metadata returns 34. + EASY_DOCKER_COMPOSE_ERROR_DETAIL="${stack_topology}" + return 34 + ;; + 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 + + configured_pull_policy="$(get_env_file_key_value "${env_path}" "PULL_POLICY" || true)" + if [ -z "${configured_pull_policy}" ]; then + custom_image="$(get_env_file_key_value "${env_path}" "CUSTOM_IMAGE" || true)" + custom_tag="$(get_env_file_key_value "${env_path}" "CUSTOM_TAG" || true)" + if [ -n "${custom_image}" ] && [ -n "${custom_tag}" ]; then + image_ref="${custom_image}:${custom_tag}" + if image_inspect_error="$(docker image inspect "${image_ref}" 2>&1 >/dev/null)"; then + runtime_pull_policy="if_not_present" + else + case "${image_inspect_error}" in + *"No such image"* | *"No such object"*) + # shellcheck disable=SC2034 # Read by manage flow after start_stack_with_compose_from_metadata returns 38. + EASY_DOCKER_COMPOSE_ERROR_DETAIL="${image_ref}" + return 38 + ;; + *) + if [ -z "${image_inspect_error}" ]; then + image_inspect_error="docker image inspect failed for ${image_ref}" + fi + # shellcheck disable=SC2034 # Read by manage flow after start_stack_with_compose_from_metadata returns 39. + EASY_DOCKER_COMPOSE_ERROR_DETAIL="${image_inspect_error}" + return 39 + ;; + esac + fi + fi + fi + + compose_files_lines="$(get_metadata_compose_files_lines "${metadata_path}" || true)" + if [ -z "${compose_files_lines}" ]; then + return 35 + 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 + # shellcheck disable=SC2034 # Read by manage flow after start_stack_with_compose_from_metadata returns 36. + EASY_DOCKER_COMPOSE_ERROR_DETAIL="${source_compose_path}" + return 36 + fi + + compose_args+=(-f "${source_compose_path}") + done </dev/null + )" + compose_status=$? + else + running_services_lines="$( + docker compose --env-file "${env_path}" "${compose_args[@]}" ps --status running --services 2>/dev/null + )" + compose_status=$? + fi + + if [ "${compose_status}" -ne 0 ]; then + printf -v "${result_var}" "%s" "Unknown (docker compose status failed)" + return 0 + fi + + while IFS= read -r compose_file; do + if [ -n "${compose_file}" ]; then + running_services_count=$((running_services_count + 1)) + fi + done <&2 - - setup_label="$(get_setup_display_label "${setup_type}")" - status_text="$(printf "%s stack\n\nChoose whether to create a new stack or manage an existing one." "${setup_label}")" - - render_box_message "${status_text}" "0 2" >&2 - - gum choose \ - --height 8 \ - --header "${setup_label} stack actions" \ - --cursor.foreground 63 \ - --selected.foreground 45 \ - "Create new stack" \ - "Manage existing stacks" \ - "Back" \ - "Exit and close easy-docker" -} - -show_production_setup_menu() { - show_setup_menu "production" -} - -show_development_setup_menu() { - show_setup_menu "development" -} - -prompt_new_stack_name() { - local status_text="" - - render_main_screen 1 >&2 - - status_text="$(printf "Create new stack\n\nEnter a stack name.\nType /cancel or press Ctrl+C to abort.")" - - render_box_message "${status_text}" "0 2" >&2 - - gum input \ - --header "Stack name (/cancel to abort)" \ - --prompt "name> " \ - --placeholder "my-production-stack" -} - -show_frappe_version_profile_menu() { - local stack_name="${1}" - local options_lines="${2:-}" - local selected_label="${3:-}" - local status_text="" - local option_line="" - local -a menu_options=() - local -a gum_args=() - - render_main_screen 1 >&2 - - status_text="$(printf "Create stack: %s\n\nSelect the Frappe branch profile from frappe.tsv.\nThis sets the stack default for branch suggestions." "${stack_name}")" - render_box_message "${status_text}" "0 2" >&2 - - while IFS= read -r option_line; do - if [ -z "${option_line}" ]; then - continue - fi - menu_options+=("${option_line}") - done <&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." "${stack_name}" "${stack_dir}")" - render_box_message "${status_text}" "0 2" >&2 - - gum choose \ - --height 8 \ - --header "Topology" \ - --cursor.foreground 63 \ - --selected.foreground 45 \ - "Single-host (recommended)" \ - "Split services" \ - "Abort wizard to main menu" -} - -show_single_host_proxy_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\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}")" - render_box_message "${status_text}" "0 2" >&2 - - gum choose \ - --height 11 \ - --header "Single-host: 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_single_host_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\nSingle-host setup (step 2/3)\nChoose the database service." "${stack_name}")" - render_box_message "${status_text}" "0 2" >&2 - - gum choose \ - --height 8 \ - --header "Single-host: database" \ - --cursor.foreground 63 \ - --selected.foreground 45 \ - "MariaDB (recommended)" \ - "PostgreSQL" \ - "Back to topology selection" -} - -show_single_host_redis_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\nSingle-host setup (step 3/3)\nChoose whether Redis services should be included." "${stack_name}")" - render_box_message "${status_text}" "0 2" >&2 - - gum choose \ - --height 8 \ - --header "Single-host: redis" \ - --cursor.foreground 63 \ - --selected.foreground 45 \ - "Include Redis (recommended)" \ - "Skip Redis (experienced users only)" \ - "Back to topology selection" -} - -show_custom_modular_apps_multi_select() { - local stack_dir="${1}" - local options_lines="${2:-}" - local selected_labels_csv="${3:-}" - local stack_name="" - local status_text="" - local option_line="" - local selected_label="" - local -a menu_options=() - local -a selected_labels=() - local -a gum_args=() - - render_main_screen 1 >&2 - - stack_name="${stack_dir##*/}" - status_text="$(printf "Stack: %s\n\nApps\nUse Space to toggle apps from apps.tsv. Press Enter to continue to branch selection per app.\nUse Ctrl+C to go back." "${stack_name}")" - render_box_message "${status_text}" "0 2" >&2 - - while IFS= read -r option_line; do - if [ -z "${option_line}" ]; then - continue - fi - menu_options+=("${option_line}") - done <&2 - - stack_name="${stack_dir##*/}" - guidance_text="${guidance_text//\\n/$'\n'}" - status_text="$(printf "Stack: %s\n\nConfigure %s\n\n%s" "${stack_name}" "${variable_name}" "${guidance_text}")" - render_box_message "${status_text}" "0 2" >&2 - fi - - if [ -n "${input_feedback}" ]; then - gum style --foreground 214 "${input_feedback}" >&2 - fi - - gum input \ - --header "${variable_name}" \ - --prompt "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="" - - render_main_screen 1 >&2 - - status_text="$(printf "Abort wizard\n\nStack directory:\n%s\n\nRollback created files before returning to main menu?" "${stack_dir}")" - render_box_message "${status_text}" "0 2" >&2 - - gum choose \ - --height 8 \ - --header "Abort options" \ - --cursor.foreground 63 \ - --selected.foreground 45 \ - "Rollback files and return to main menu" \ - "Keep files and return to main menu" \ - "Back to topology selection" -} - -show_manage_stacks_menu() { - local setup_type="${1}" - shift - local stack_count="${#}" - local setup_label="" - local status_text="" - - render_main_screen 1 >&2 - - setup_label="$(get_setup_display_label "${setup_type}")" - if [ "${stack_count}" -eq 1 ]; then - status_text="$(printf "Manage existing %s stacks\n\n1 stack found. Select a stack." "${setup_label}")" - else - status_text="$(printf "Manage existing %s stacks\n\n%s stacks found. Select a stack." "${setup_label}" "${stack_count}")" - fi - - render_box_message "${status_text}" "0 2" >&2 - - gum choose \ - --height 14 \ - --header "Existing stacks" \ - --cursor.foreground 63 \ - --selected.foreground 45 \ - "$@" \ - "Back" \ - "Exit and close easy-docker" -} - -show_manage_stacks_placeholder() { - local setup_type="${1}" - local setup_label="" - local status_text="" - - render_main_screen 1 >&2 - - setup_label="$(get_setup_display_label "${setup_type}")" - status_text="$(printf "Manage existing %s stacks\n\nNo stacks found in .easy-docker/stacks yet." "${setup_label}")" - - render_box_message "${status_text}" "0 2" >&2 - - gum choose \ - --height 7 \ - --header "Manage stacks actions" \ - --cursor.foreground 63 \ - --selected.foreground 45 \ - "Back" \ - "Exit and close easy-docker" -} - -show_manage_stack_actions_menu() { - local stack_name="${1}" - local stack_dir="${2}" - local stack_runtime_status="${3:-Unknown}" - local menu_header="" - local status_text="" - - render_main_screen 1 >&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}")" - - render_box_message "${status_text}" "0 2" >&2 - - menu_header="$(printf "Stack actions | %s" "${stack_runtime_status}")" - - gum choose \ - --height 8 \ - --header "${menu_header}" \ - --cursor.foreground 63 \ - --selected.foreground 45 \ - "Apps" \ - "Docker" \ - "Start stack in Docker Compose" \ - "Back" \ - "Exit and close easy-docker" -} - -show_manage_stack_apps_menu() { - local stack_name="${1}" - local stack_dir="${2}" - local status_text="" - - render_main_screen 1 >&2 - - status_text="$(printf "Manage stack apps\n\nStack: %s\nDirectory: %s\n\nChoose an app-related action for this stack." "${stack_name}" "${stack_dir}")" - - render_box_message "${status_text}" "0 2" >&2 - - gum choose \ - --height 8 \ - --header "Stack apps actions" \ - --cursor.foreground 63 \ - --selected.foreground 45 \ - "Regenerate apps.json from metadata" \ - "Select apps and branches" \ - "Back" \ - "Exit and close easy-docker" -} - -show_manage_stack_docker_menu() { - local stack_name="${1}" - local stack_dir="${2}" - local status_text="" - - render_main_screen 1 >&2 - - status_text="$(printf "Manage stack docker\n\nStack: %s\nDirectory: %s\n\nChoose a docker-related action for this stack." "${stack_name}" "${stack_dir}")" - - render_box_message "${status_text}" "0 2" >&2 - - gum choose \ - --height 8 \ - --header "Stack docker actions" \ - --cursor.foreground 63 \ - --selected.foreground 45 \ - "Build custom image" \ - "Generate docker compose from env" \ - "Back" \ - "Exit and close easy-docker" -} +load_production_screen_modules diff --git a/scripts/easy-docker/lib/ui/screens/production/manage.sh b/scripts/easy-docker/lib/ui/screens/production/manage.sh new file mode 100755 index 00000000..d61ecc58 --- /dev/null +++ b/scripts/easy-docker/lib/ui/screens/production/manage.sh @@ -0,0 +1,142 @@ +#!/usr/bin/env bash + +show_manage_stacks_menu() { + local setup_type="${1}" + shift + local stack_count="${#}" + local setup_label="" + local status_text="" + + render_main_screen 1 >&2 + + setup_label="$(get_setup_display_label "${setup_type}")" + if [ "${stack_count}" -eq 1 ]; then + status_text="$(printf "Manage existing %s stacks\n\n1 stack found. Select a stack." "${setup_label}")" + else + status_text="$(printf "Manage existing %s stacks\n\n%s stacks found. Select a stack." "${setup_label}" "${stack_count}")" + fi + + render_box_message "${status_text}" "0 2" >&2 + + gum choose \ + --height 14 \ + --header "Existing stacks" \ + --cursor.foreground 63 \ + --selected.foreground 45 \ + "$@" \ + "Back" \ + "Exit and close easy-docker" +} + +show_manage_stacks_placeholder() { + local setup_type="${1}" + local setup_label="" + local status_text="" + + render_main_screen 1 >&2 + + setup_label="$(get_setup_display_label "${setup_type}")" + status_text="$(printf "Manage existing %s stacks\n\nNo stacks found in .easy-docker/stacks yet." "${setup_label}")" + + render_box_message "${status_text}" "0 2" >&2 + + gum choose \ + --height 7 \ + --header "Manage stacks actions" \ + --cursor.foreground 63 \ + --selected.foreground 45 \ + "Back" \ + "Exit and close easy-docker" +} + +show_manage_stack_actions_menu() { + local stack_name="${1}" + local stack_dir="${2}" + local stack_runtime_status="${3:-Unknown}" + local menu_header="" + local status_text="" + + render_main_screen 1 >&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}")" + + render_box_message "${status_text}" "0 2" >&2 + + menu_header="$(printf "Stack actions | %s" "${stack_runtime_status}")" + + gum choose \ + --height 8 \ + --header "${menu_header}" \ + --cursor.foreground 63 \ + --selected.foreground 45 \ + "Apps" \ + "Docker" \ + "Start stack in Docker Compose" \ + "Back" \ + "Exit and close easy-docker" +} + +show_manage_stack_apps_menu() { + local stack_name="${1}" + local stack_dir="${2}" + local status_text="" + + render_main_screen 1 >&2 + + status_text="$(printf "Manage stack apps\n\nStack: %s\nDirectory: %s\n\nChoose an app-related action for this stack." "${stack_name}" "${stack_dir}")" + + render_box_message "${status_text}" "0 2" >&2 + + gum choose \ + --height 8 \ + --header "Stack apps actions" \ + --cursor.foreground 63 \ + --selected.foreground 45 \ + "Regenerate apps.json from metadata" \ + "Select apps and branches" \ + "Back" \ + "Exit and close easy-docker" +} + +show_manage_stack_docker_menu() { + local stack_name="${1}" + local stack_dir="${2}" + local status_text="" + + render_main_screen 1 >&2 + + status_text="$(printf "Manage stack docker\n\nStack: %s\nDirectory: %s\n\nChoose a docker-related action for this stack." "${stack_name}" "${stack_dir}")" + + render_box_message "${status_text}" "0 2" >&2 + + gum choose \ + --height 8 \ + --header "Stack docker actions" \ + --cursor.foreground 63 \ + --selected.foreground 45 \ + "Build custom image" \ + "Generate docker compose from env" \ + "Back" \ + "Exit and close easy-docker" +} + +show_missing_custom_image_start_menu() { + local stack_name="${1}" + local stack_dir="${2}" + local image_ref="${3}" + local status_text="" + + render_main_screen 1 >&2 + + status_text="$(printf "Custom image missing\n\nStack: %s\nDirectory: %s\nImage: %s\n\nBuild the custom image now before starting the stack?" "${stack_name}" "${stack_dir}" "${image_ref}")" + render_box_message "${status_text}" "0 2" >&2 + + gum choose \ + --height 8 \ + --header "Missing custom image" \ + --cursor.foreground 63 \ + --selected.foreground 45 \ + "Build custom image now" \ + "Back" \ + "Exit and close easy-docker" +} diff --git a/scripts/easy-docker/lib/ui/screens/production/setup.sh b/scripts/easy-docker/lib/ui/screens/production/setup.sh new file mode 100755 index 00000000..a03a51c5 --- /dev/null +++ b/scripts/easy-docker/lib/ui/screens/production/setup.sh @@ -0,0 +1,100 @@ +#!/usr/bin/env bash + +get_setup_display_label() { + local setup_type="${1}" + + case "${setup_type}" in + development) + printf 'Development' + ;; + production | *) + printf 'Production' + ;; + esac +} + +show_setup_menu() { + local setup_type="${1}" + local setup_label="" + local status_text="" + + render_main_screen 1 >&2 + + setup_label="$(get_setup_display_label "${setup_type}")" + status_text="$(printf "%s stack\n\nChoose whether to create a new stack or manage an existing one." "${setup_label}")" + + render_box_message "${status_text}" "0 2" >&2 + + gum choose \ + --height 8 \ + --header "${setup_label} stack actions" \ + --cursor.foreground 63 \ + --selected.foreground 45 \ + "Create new stack" \ + "Manage existing stacks" \ + "Back" \ + "Exit and close easy-docker" +} + +show_production_setup_menu() { + show_setup_menu "production" +} + +show_development_setup_menu() { + show_setup_menu "development" +} + +prompt_new_stack_name() { + local status_text="" + + render_main_screen 1 >&2 + + status_text="$(printf "Create new stack\n\nEnter a stack name.\nType /cancel or press Ctrl+C to abort.")" + + render_box_message "${status_text}" "0 2" >&2 + + gum input \ + --header "Stack name (/cancel to abort)" \ + --prompt "name> " \ + --placeholder "my-production-stack" +} + +show_frappe_version_profile_menu() { + local stack_name="${1}" + local options_lines="${2:-}" + local selected_label="${3:-}" + local status_text="" + local option_line="" + local -a menu_options=() + local -a gum_args=() + + render_main_screen 1 >&2 + + status_text="$(printf "Create stack: %s\n\nSelect the Frappe branch profile from frappe.tsv.\nThis sets the stack default for branch suggestions." "${stack_name}")" + render_box_message "${status_text}" "0 2" >&2 + + while IFS= read -r option_line; do + if [ -z "${option_line}" ]; then + continue + fi + menu_options+=("${option_line}") + done <&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." "${stack_name}" "${stack_dir}")" + render_box_message "${status_text}" "0 2" >&2 + + gum choose \ + --height 8 \ + --header "Topology" \ + --cursor.foreground 63 \ + --selected.foreground 45 \ + "Single-host (recommended)" \ + "Split services" \ + "Abort wizard to main menu" +} + +show_single_host_proxy_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\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}")" + render_box_message "${status_text}" "0 2" >&2 + + gum choose \ + --height 11 \ + --header "Single-host: 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_single_host_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\nSingle-host setup (step 2/3)\nChoose the database service." "${stack_name}")" + render_box_message "${status_text}" "0 2" >&2 + + gum choose \ + --height 8 \ + --header "Single-host: database" \ + --cursor.foreground 63 \ + --selected.foreground 45 \ + "MariaDB (recommended)" \ + "PostgreSQL" \ + "Back to topology selection" +} + +show_single_host_redis_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\nSingle-host setup (step 3/3)\nChoose whether Redis services should be included." "${stack_name}")" + render_box_message "${status_text}" "0 2" >&2 + + gum choose \ + --height 8 \ + --header "Single-host: redis" \ + --cursor.foreground 63 \ + --selected.foreground 45 \ + "Include Redis (recommended)" \ + "Skip Redis (experienced users only)" \ + "Back to topology selection" +} + +show_custom_modular_apps_multi_select() { + local stack_dir="${1}" + local options_lines="${2:-}" + local selected_labels_csv="${3:-}" + local stack_name="" + local status_text="" + local option_line="" + local selected_label="" + local -a menu_options=() + local -a selected_labels=() + local -a gum_args=() + + render_main_screen 1 >&2 + + stack_name="${stack_dir##*/}" + status_text="$(printf "Stack: %s\n\nApps\nUse Space to toggle apps from apps.tsv. Press Enter to continue to branch selection per app.\nUse Ctrl+C to go back." "${stack_name}")" + render_box_message "${status_text}" "0 2" >&2 + + while IFS= read -r option_line; do + if [ -z "${option_line}" ]; then + continue + fi + menu_options+=("${option_line}") + done <&2 + + stack_name="${stack_dir##*/}" + guidance_text="${guidance_text//\\n/$'\n'}" + status_text="$(printf "Stack: %s\n\nConfigure %s\n\n%s" "${stack_name}" "${variable_name}" "${guidance_text}")" + render_box_message "${status_text}" "0 2" >&2 + fi + + if [ -n "${input_feedback}" ]; then + gum style --foreground 214 "${input_feedback}" >&2 + fi + + gum input \ + --header "${variable_name}" \ + --prompt "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="" + + render_main_screen 1 >&2 + + status_text="$(printf "Abort wizard\n\nStack directory:\n%s\n\nRollback created files before returning to main menu?" "${stack_dir}")" + render_box_message "${status_text}" "0 2" >&2 + + gum choose \ + --height 8 \ + --header "Abort options" \ + --cursor.foreground 63 \ + --selected.foreground 45 \ + "Rollback files and return to main menu" \ + "Keep files and return to main menu" \ + "Back to topology selection" +} From 34cb8287edde4fb6309c4e44e0597314fcc4f30e Mon Sep 17 00:00:00 2001 From: RocketQuack <202538874+Rocket-Quack@users.noreply.github.com> Date: Wed, 25 Mar 2026 13:10:34 +0100 Subject: [PATCH 17/51] fix(easy-docker): handle compose start retries and errors --- .../lib/app/wizard/flows/manage.sh | 171 +++++++++++------- 1 file changed, 109 insertions(+), 62 deletions(-) diff --git a/scripts/easy-docker/lib/app/wizard/flows/manage.sh b/scripts/easy-docker/lib/app/wizard/flows/manage.sh index 2aa63e14..d9ba9aaa 100755 --- a/scripts/easy-docker/lib/app/wizard/flows/manage.sh +++ b/scripts/easy-docker/lib/app/wizard/flows/manage.sh @@ -1,5 +1,68 @@ #!/usr/bin/env bash +run_build_stack_custom_image_with_feedback() { + local stack_name="${1}" + local stack_dir="${2}" + local build_image_status=0 + + show_warning_message "Starting docker build for stack: ${stack_name}" + if build_stack_custom_image "${stack_dir}"; then + show_warning_and_wait "Custom image build finished successfully for stack: ${stack_name}" 3 + return 0 + fi + + build_image_status=$? + case "${build_image_status}" in + 11) + show_warning_and_wait "Custom image build failed: missing metadata.json in ${stack_dir}." 4 + ;; + 12) + show_warning_and_wait "Custom image build failed: stack env file not found in ${stack_dir}." 4 + ;; + 13) + show_warning_and_wait "Custom image build failed: CUSTOM_IMAGE is missing in stack env file." 4 + ;; + 14) + show_warning_and_wait "Custom image build failed: CUSTOM_TAG is missing in stack env file." 4 + ;; + 15) + show_warning_and_wait "Custom image build failed: frappe_branch missing in metadata.json." 4 + ;; + 16) + show_warning_and_wait "Custom image build failed: could not generate apps.json from metadata app selection." 4 + ;; + 17) + show_warning_and_wait "Custom image build failed: apps.json not found after generation." 4 + ;; + 18) + show_warning_and_wait "Custom image build failed: base64 command is not available in this environment." 4 + ;; + 19) + show_warning_and_wait "Custom image build failed: apps.json could not be base64-encoded." 4 + ;; + 20) + show_warning_and_wait "Custom image build failed: images/layered/Containerfile not found." 4 + ;; + 21) + show_warning_and_wait "Custom image build failed: docker build returned an error. Check the output above." 4 + ;; + 22) + show_warning_and_wait "Custom image build failed: git is required for app branch precheck (git ls-remote)." 4 + ;; + 23) + show_warning_and_wait "Custom image build failed: could not parse app entries from apps.json." 4 + ;; + 24) + show_warning_and_wait "Custom image build failed: app branch precheck failed -> ${EASY_DOCKER_BUILD_ERROR_DETAIL}" 6 + ;; + *) + show_warning_and_wait "Custom image build failed (${build_image_status})." 4 + ;; + esac + + return "${build_image_status}" +} + handle_manage_selected_stack_flow() { local stack_name="${1}" local stack_dir="" @@ -11,10 +74,10 @@ handle_manage_selected_stack_flow() { local custom_apps_update_status=0 local persist_apps_status=0 local render_compose_status=0 - local build_image_status=0 local compose_start_status=0 local generated_compose_path="" local stack_runtime_status="" + local missing_custom_image_action="" stack_dir="$(get_stack_dir_by_name "${stack_name}" || true)" if [ -z "${stack_dir}" ]; then @@ -85,103 +148,87 @@ handle_manage_selected_stack_flow() { done ;; "Start stack in Docker Compose") - show_warning_message "Starting stack with docker compose: ${stack_name}" - if start_stack_with_compose_from_metadata "${stack_dir}"; then - : - else - compose_start_status=$? + while true; do + show_warning_message "Starting stack with docker compose: ${stack_name}" + if start_stack_with_compose_from_metadata "${stack_dir}"; then + show_warning_and_wait "Stack started successfully with docker compose: ${stack_name}" 3 + break + else + compose_start_status=$? + fi case "${compose_start_status}" in 31) show_warning_and_wait "Cannot start stack: metadata.json is missing in ${stack_dir}." 4 + break ;; 32) show_warning_and_wait "Cannot start stack: stack env file not found in ${stack_dir}." 4 + break ;; 33) show_warning_and_wait "Cannot start stack: topology is missing in metadata.json. Re-run the topology wizard for this stack." 4 + break ;; 34) show_warning_and_wait "Cannot start stack via docker compose for topology '${EASY_DOCKER_COMPOSE_ERROR_DETAIL}'. Use the topology-specific runbook path." 5 + break ;; 35) show_warning_and_wait "Cannot start stack: no compose files configured in metadata.json." 4 + break ;; 36) show_warning_and_wait "Cannot start stack: compose file is missing -> ${EASY_DOCKER_COMPOSE_ERROR_DETAIL}" 4 + break ;; 37) show_warning_and_wait "docker compose up failed. Check the output above for details." 4 + break + ;; + 38) + missing_custom_image_action="$( + show_missing_custom_image_start_menu "${stack_name}" "${stack_dir}" "${EASY_DOCKER_COMPOSE_ERROR_DETAIL}" || true + )" + case "${missing_custom_image_action}" in + "Build custom image now") + if run_build_stack_custom_image_with_feedback "${stack_name}" "${stack_dir}"; then + continue + fi + break + ;; + "Back" | "") + break + ;; + "Exit and close easy-docker") + return "${FLOW_EXIT_APP}" + ;; + *) + show_warning_and_wait "Unknown missing-image action: ${missing_custom_image_action}" 2 + break + ;; + esac + ;; + 39) + show_warning_and_wait "Cannot inspect custom image before start. Check Docker and try again. Details: ${EASY_DOCKER_COMPOSE_ERROR_DETAIL}" 5 + break ;; *) show_warning_and_wait "Cannot start stack with docker compose (${compose_start_status})." 4 + break ;; esac - continue - fi - - show_warning_and_wait "Stack started successfully with docker compose: ${stack_name}" 3 + done ;; "Docker") while true; do docker_action="$(show_manage_stack_docker_menu "${stack_name}" "${stack_dir}" || true)" case "${docker_action}" in "Build custom image") - show_warning_message "Starting docker build for stack: ${stack_name}" - if build_stack_custom_image "${stack_dir}"; then + if run_build_stack_custom_image_with_feedback "${stack_name}" "${stack_dir}"; then : else - build_image_status=$? - case "${build_image_status}" in - 11) - show_warning_and_wait "Custom image build failed: missing metadata.json in ${stack_dir}." 4 - ;; - 12) - show_warning_and_wait "Custom image build failed: stack env file not found in ${stack_dir}." 4 - ;; - 13) - show_warning_and_wait "Custom image build failed: CUSTOM_IMAGE is missing in stack env file." 4 - ;; - 14) - show_warning_and_wait "Custom image build failed: CUSTOM_TAG is missing in stack env file." 4 - ;; - 15) - show_warning_and_wait "Custom image build failed: frappe_branch missing in metadata.json." 4 - ;; - 16) - show_warning_and_wait "Custom image build failed: could not generate apps.json from metadata app selection." 4 - ;; - 17) - show_warning_and_wait "Custom image build failed: apps.json not found after generation." 4 - ;; - 18) - show_warning_and_wait "Custom image build failed: base64 command is not available in this environment." 4 - ;; - 19) - show_warning_and_wait "Custom image build failed: apps.json could not be base64-encoded." 4 - ;; - 20) - show_warning_and_wait "Custom image build failed: images/layered/Containerfile not found." 4 - ;; - 21) - show_warning_and_wait "Custom image build failed: docker build returned an error. Check the output above." 4 - ;; - 22) - show_warning_and_wait "Custom image build failed: git is required for app branch precheck (git ls-remote)." 4 - ;; - 23) - show_warning_and_wait "Custom image build failed: could not parse app entries from apps.json." 4 - ;; - 24) - show_warning_and_wait "Custom image build failed: app branch precheck failed -> ${EASY_DOCKER_BUILD_ERROR_DETAIL}" 6 - ;; - *) - show_warning_and_wait "Custom image build failed (${build_image_status})." 4 - ;; - esac continue fi - - show_warning_and_wait "Custom image build finished successfully for stack: ${stack_name}" 3 ;; "Generate docker compose from env") generated_compose_path="$(get_stack_generated_compose_path "${stack_dir}")" From 3c3e92a815fb95ed46f94e995c5965ea92a5356c Mon Sep 17 00:00:00 2001 From: RocketQuack <202538874+Rocket-Quack@users.noreply.github.com> Date: Wed, 25 Mar 2026 14:01:48 +0100 Subject: [PATCH 18/51] feat(easy-docker): improve stack runtime controls and isolation --- .../lib/app/wizard/common/compose/render.sh | 6 +- .../lib/app/wizard/common/compose/start.sh | 249 ++++++++++++++++-- .../easy-docker/lib/app/wizard/common/core.sh | 24 ++ .../lib/app/wizard/flows/manage.sh | 35 +++ .../lib/ui/screens/production/manage.sh | 3 +- 5 files changed, 297 insertions(+), 20 deletions(-) diff --git a/scripts/easy-docker/lib/app/wizard/common/compose/render.sh b/scripts/easy-docker/lib/app/wizard/common/compose/render.sh index b6691146..b55ca4e1 100755 --- a/scripts/easy-docker/lib/app/wizard/common/compose/render.sh +++ b/scripts/easy-docker/lib/app/wizard/common/compose/render.sh @@ -11,11 +11,13 @@ render_stack_compose_from_metadata() { local source_compose_path="" local env_erpnext_version="" local fallback_erpnext_version="" + local compose_project_name="" 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}")" generated_compose_path="$(get_stack_generated_compose_path "${stack_dir}")" generated_compose_tmp_path="${generated_compose_path}.tmp" @@ -58,11 +60,11 @@ EOF fi if [ -n "${fallback_erpnext_version}" ]; then - if ! ERPNEXT_VERSION="${fallback_erpnext_version}" docker compose --env-file "${env_path}" "${compose_args[@]}" config >"${generated_compose_tmp_path}"; then + if ! ERPNEXT_VERSION="${fallback_erpnext_version}" docker compose --project-name "${compose_project_name}" --env-file "${env_path}" "${compose_args[@]}" config >"${generated_compose_tmp_path}"; then rm -f -- "${generated_compose_tmp_path}" >/dev/null 2>&1 || true return 1 fi - elif ! docker compose --env-file "${env_path}" "${compose_args[@]}" config >"${generated_compose_tmp_path}"; then + elif ! docker compose --project-name "${compose_project_name}" --env-file "${env_path}" "${compose_args[@]}" config >"${generated_compose_tmp_path}"; then rm -f -- "${generated_compose_tmp_path}" >/dev/null 2>&1 || true return 1 fi diff --git a/scripts/easy-docker/lib/app/wizard/common/compose/start.sh b/scripts/easy-docker/lib/app/wizard/common/compose/start.sh index 2cfde7e7..be75672c 100755 --- a/scripts/easy-docker/lib/app/wizard/common/compose/start.sh +++ b/scripts/easy-docker/lib/app/wizard/common/compose/start.sh @@ -15,6 +15,7 @@ start_stack_with_compose_from_metadata() { local custom_tag="" local image_ref="" local image_inspect_error="" + local compose_project_name="" local stack_topology="" local repo_root="" local -a compose_args=() @@ -24,6 +25,7 @@ start_stack_with_compose_from_metadata() { 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 31 @@ -110,24 +112,112 @@ EOF fi if [ -n "${fallback_erpnext_version}" ] && [ -n "${runtime_pull_policy}" ]; then - if ! ERPNEXT_VERSION="${fallback_erpnext_version}" PULL_POLICY="${runtime_pull_policy}" docker compose --env-file "${env_path}" "${compose_args[@]}" up -d; then + if ! ERPNEXT_VERSION="${fallback_erpnext_version}" PULL_POLICY="${runtime_pull_policy}" docker compose --project-name "${compose_project_name}" --env-file "${env_path}" "${compose_args[@]}" up -d; then return 37 fi elif [ -n "${fallback_erpnext_version}" ]; then - if ! ERPNEXT_VERSION="${fallback_erpnext_version}" docker compose --env-file "${env_path}" "${compose_args[@]}" up -d; then + if ! ERPNEXT_VERSION="${fallback_erpnext_version}" docker compose --project-name "${compose_project_name}" --env-file "${env_path}" "${compose_args[@]}" up -d; then return 37 fi elif [ -n "${runtime_pull_policy}" ]; then - if ! PULL_POLICY="${runtime_pull_policy}" docker compose --env-file "${env_path}" "${compose_args[@]}" up -d; then + if ! PULL_POLICY="${runtime_pull_policy}" docker compose --project-name "${compose_project_name}" --env-file "${env_path}" "${compose_args[@]}" up -d; then return 37 fi - elif ! docker compose --env-file "${env_path}" "${compose_args[@]}" up -d; then + elif ! docker compose --project-name "${compose_project_name}" --env-file "${env_path}" "${compose_args[@]}" up -d; then return 37 fi return 0 } +stop_stack_with_compose_from_metadata() { + local stack_dir="${1}" + 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=() + + # shellcheck disable=SC2034 # Read by manage flow after stop_stack_with_compose_from_metadata fails. + EASY_DOCKER_COMPOSE_ERROR_DETAIL="" + + 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 41 + fi + + if [ ! -f "${env_path}" ]; then + return 42 + fi + + stack_topology="$(get_stack_topology "${stack_dir}" || true)" + if [ -z "${stack_topology}" ]; then + # shellcheck disable=SC2034 # Read by manage flow after stop_stack_with_compose_from_metadata returns 43. + EASY_DOCKER_COMPOSE_ERROR_DETAIL="metadata.json missing topology" + return 43 + fi + + case "${stack_topology}" in + "single-host") ;; + *) + # shellcheck disable=SC2034 # Read by manage flow after stop_stack_with_compose_from_metadata returns 44. + EASY_DOCKER_COMPOSE_ERROR_DETAIL="${stack_topology}" + return 44 + ;; + 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 45 + 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 + # shellcheck disable=SC2034 # Read by manage flow after stop_stack_with_compose_from_metadata returns 46. + EASY_DOCKER_COMPOSE_ERROR_DETAIL="${source_compose_path}" + return 46 + fi + + compose_args+=(-f "${source_compose_path}") + done </dev/null + container_ids_lines="$( + ERPNEXT_VERSION="${fallback_erpnext_version}" docker compose --project-name "${compose_project_name}" --env-file "${env_path}" "${compose_args[@]}" ps -a -q 2>/dev/null )" compose_status=$? else - running_services_lines="$( - docker compose --env-file "${env_path}" "${compose_args[@]}" ps --status running --services 2>/dev/null + container_ids_lines="$( + docker compose --project-name "${compose_project_name}" --env-file "${env_path}" "${compose_args[@]}" ps -a -q 2>/dev/null )" compose_status=$? fi @@ -223,18 +331,125 @@ EOF return 0 fi - while IFS= read -r compose_file; do - if [ -n "${compose_file}" ]; then - running_services_count=$((running_services_count + 1)) + if [ -z "${container_ids_lines}" ]; then + printf -v "${result_var}" "%s" "Not created" + return 0 + fi + + docker_ps_args=(-a --no-trunc --format "{{.ID}}|{{.State}}|{{.Status}}") + while IFS= read -r container_id; do + if [ -n "${container_id}" ]; then + docker_ps_args+=(--filter "id=${container_id}") fi done </dev/null)" + compose_status=$? + if [ "${compose_status}" -ne 0 ]; then + printf -v "${result_var}" "%s" "Unknown (docker ps status failed)" + return 0 + fi + + while IFS= read -r container_status_line; do + if [ -z "${container_status_line}" ]; then + continue + fi + + total_containers_count=$((total_containers_count + 1)) + IFS='|' read -r container_id container_state container_status_text < ${EASY_DOCKER_COMPOSE_ERROR_DETAIL}" 4 + ;; + 47) + show_warning_and_wait "docker compose stop failed. Check the output above for details." 4 + ;; + *) + show_warning_and_wait "Cannot stop stack with docker compose (${compose_start_status})." 4 + ;; + esac + ;; "Docker") while true; do docker_action="$(show_manage_stack_docker_menu "${stack_name}" "${stack_dir}" || true)" diff --git a/scripts/easy-docker/lib/ui/screens/production/manage.sh b/scripts/easy-docker/lib/ui/screens/production/manage.sh index d61ecc58..2a74884f 100755 --- a/scripts/easy-docker/lib/ui/screens/production/manage.sh +++ b/scripts/easy-docker/lib/ui/screens/production/manage.sh @@ -65,13 +65,14 @@ show_manage_stack_actions_menu() { menu_header="$(printf "Stack actions | %s" "${stack_runtime_status}")" gum choose \ - --height 8 \ + --height 9 \ --header "${menu_header}" \ --cursor.foreground 63 \ --selected.foreground 45 \ "Apps" \ "Docker" \ "Start stack in Docker Compose" \ + "Stop stack in Docker Compose" \ "Back" \ "Exit and close easy-docker" } From ce0e9e00eef0bc326b572c05a6e3a0f53d92bf7b Mon Sep 17 00:00:00 2001 From: RocketQuack <202538874+Rocket-Quack@users.noreply.github.com> Date: Wed, 25 Mar 2026 14:03:48 +0100 Subject: [PATCH 19/51] style(gitignore): improve spacing --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 246f69f9..6b475176 100644 --- a/.gitignore +++ b/.gitignore @@ -32,5 +32,6 @@ node_modules # VitePress **/.vitepress/dist **/.vitepress/cache + # easy-docker local runtime data (contains secrets) .easy-docker/ From bba9e70dd8811ab05803b9007b158a9cfde88c55 Mon Sep 17 00:00:00 2001 From: RocketQuack <202538874+Rocket-Quack@users.noreply.github.com> Date: Fri, 27 Mar 2026 15:10:22 +0100 Subject: [PATCH 20/51] fix(easy-docker): accept localhost-style site domains --- scripts/easy-docker/lib/app/wizard/env/collect.sh | 6 +++--- scripts/easy-docker/lib/app/wizard/env/validation.sh | 12 ++++++++++-- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/scripts/easy-docker/lib/app/wizard/env/collect.sh b/scripts/easy-docker/lib/app/wizard/env/collect.sh index 7ee1aec1..48b46323 100755 --- a/scripts/easy-docker/lib/app/wizard/env/collect.sh +++ b/scripts/easy-docker/lib/app/wizard/env/collect.sh @@ -47,7 +47,7 @@ collect_single_host_env_lines() { 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 only domains in format sub.domain.tld or sub.sub.domain.tld.\nEnter multiple domains separated by comma or space.\nType /back to return." "erp.example.com crm.eu.example.com" "required" "domains"; then + 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=$? @@ -94,7 +94,7 @@ collect_single_host_env_lines() { fi ;; nginxproxy-https) - if prompt_env_value_with_validation domains_value "${stack_dir}" "SITE_DOMAINS" "Required for nginx-proxy routing.\nUse only domains in format sub.domain.tld or sub.sub.domain.tld.\nEnter multiple domains separated by comma or space.\nType /back to return." "erp.example.com crm.eu.example.com" "required" "domains"; then + 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=$? @@ -141,7 +141,7 @@ collect_single_host_env_lines() { fi ;; nginxproxy-http) - if prompt_env_value_with_validation domains_value "${stack_dir}" "SITE_DOMAINS" "Required for nginx-proxy routing.\nUse only domains in format sub.domain.tld or sub.sub.domain.tld.\nEnter multiple domains separated by comma or space.\nType /back to return." "erp.example.com crm.eu.example.com" "required" "domains"; then + 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=$? diff --git a/scripts/easy-docker/lib/app/wizard/env/validation.sh b/scripts/easy-docker/lib/app/wizard/env/validation.sh index 3d30cb3e..3d4a5d66 100755 --- a/scripts/easy-docker/lib/app/wizard/env/validation.sh +++ b/scripts/easy-docker/lib/app/wizard/env/validation.sh @@ -164,13 +164,17 @@ is_valid_domain_name() { ;; esac + if [ "${normalized_domain}" = "localhost" ]; then + return 0 + fi + if [ "${#normalized_domain}" -lt 5 ] || [ "${#normalized_domain}" -gt 253 ]; then return 1 fi local IFS='.' read -r -a labels <<<"${normalized_domain}" - if [ "${#labels[@]}" -ne 3 ] && [ "${#labels[@]}" -ne 4 ]; then + if [ "${#labels[@]}" -lt 2 ]; then return 1 fi @@ -206,6 +210,10 @@ is_valid_domain_name() { last_index=$((${#labels[@]} - 1)) tld="${labels[last_index]}" + if [ "${tld}" = "localhost" ]; then + return 0 + fi + if ! [[ "${tld}" =~ ^[A-Za-z]{2,63}$ ]]; then return 1 fi @@ -395,7 +403,7 @@ prompt_env_value_with_validation() { if [ -z "${invalid_domain_input}" ]; then invalid_domain_input="${normalized_value}" fi - validation_feedback="Domain '${invalid_domain_input}' cannot be used for ${variable_name}. Use sub.domain.tld or sub.sub.domain.tld." + validation_feedback="Domain '${invalid_domain_input}' cannot be used for ${variable_name}. Use a hostname like example.com, app.example.com, localhost, or dev.localhost." continue fi ;; From bfa70da36e43b9a61ec298e7c47024bea618bbc5 Mon Sep 17 00:00:00 2001 From: RocketQuack <202538874+Rocket-Quack@users.noreply.github.com> Date: Fri, 27 Mar 2026 15:10:58 +0100 Subject: [PATCH 21/51] feat(easy-docker): add guided single-site management --- scripts/easy-docker/lib/app/wizard/common.sh | 2 + .../easy-docker/lib/app/wizard/common/site.sh | 15 + .../lib/app/wizard/common/site/apps.sh | 76 ++ .../lib/app/wizard/common/site/bootstrap.sh | 795 ++++++++++++++++++ .../lib/app/wizard/common/site/metadata.sh | 350 ++++++++ .../lib/app/wizard/flows/manage.sh | 207 +++++ .../lib/ui/screens/production/manage.sh | 92 +- 7 files changed, 1536 insertions(+), 1 deletion(-) create mode 100755 scripts/easy-docker/lib/app/wizard/common/site.sh create mode 100755 scripts/easy-docker/lib/app/wizard/common/site/apps.sh create mode 100755 scripts/easy-docker/lib/app/wizard/common/site/bootstrap.sh create mode 100755 scripts/easy-docker/lib/app/wizard/common/site/metadata.sh diff --git a/scripts/easy-docker/lib/app/wizard/common.sh b/scripts/easy-docker/lib/app/wizard/common.sh index b7a9d146..dfb28c3a 100755 --- a/scripts/easy-docker/lib/app/wizard/common.sh +++ b/scripts/easy-docker/lib/app/wizard/common.sh @@ -16,6 +16,8 @@ load_easy_docker_wizard_common_modules() { source "${wizard_dir}/common/frappe.sh" # shellcheck source=scripts/easy-docker/lib/app/wizard/common/apps.sh source "${wizard_dir}/common/apps.sh" + # shellcheck source=scripts/easy-docker/lib/app/wizard/common/site.sh + source "${wizard_dir}/common/site.sh" # shellcheck source=scripts/easy-docker/lib/app/wizard/common/ux.sh source "${wizard_dir}/common/ux.sh" } diff --git a/scripts/easy-docker/lib/app/wizard/common/site.sh b/scripts/easy-docker/lib/app/wizard/common/site.sh new file mode 100755 index 00000000..12a81680 --- /dev/null +++ b/scripts/easy-docker/lib/app/wizard/common/site.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash + +load_easy_docker_site_modules() { + local site_dir="" + site_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/site" + + # shellcheck source=scripts/easy-docker/lib/app/wizard/common/site/metadata.sh + source "${site_dir}/metadata.sh" + # shellcheck source=scripts/easy-docker/lib/app/wizard/common/site/bootstrap.sh + source "${site_dir}/bootstrap.sh" + # shellcheck source=scripts/easy-docker/lib/app/wizard/common/site/apps.sh + source "${site_dir}/apps.sh" +} + +load_easy_docker_site_modules diff --git a/scripts/easy-docker/lib/app/wizard/common/site/apps.sh b/scripts/easy-docker/lib/app/wizard/common/site/apps.sh new file mode 100755 index 00000000..4c57110e --- /dev/null +++ b/scripts/easy-docker/lib/app/wizard/common/site/apps.sh @@ -0,0 +1,76 @@ +#!/usr/bin/env bash + +append_stack_installable_app_line() { + local result_var="${1}" + local existing_lines="${2:-}" + local app_name="${3:-}" + + if [ -z "${app_name}" ]; then + printf -v "${result_var}" "%s" "${existing_lines}" + return 0 + fi + + while IFS= read -r existing_app; do + if [ "${existing_app}" = "${app_name}" ]; then + printf -v "${result_var}" "%s" "${existing_lines}" + return 0 + fi + done </dev/null 2>&1; then + return 0 + fi + + backend_ready_status=$? + if [ "${backend_ready_status}" -eq 54 ] || [ "${backend_ready_status}" -eq 52 ]; then + return "${backend_ready_status}" + fi + + # If exec fails, the backend service is not ready for site actions yet. + return 1 +} + +stack_database_service_is_reachable() { + local stack_dir="${1}" + local reachability_command="" + local db_ready_status=0 + + IFS= read -r -d '' reachability_command <<'EOF' || true +python - <<'PY' +import json +import socket +from pathlib import Path + +config_path = Path("/home/frappe/frappe-bench/sites/common_site_config.json") +with config_path.open(encoding="utf-8") as handle: + config = json.load(handle) + +db_host = config.get("db_host") +db_port = int(config.get("db_port", 3306)) +socket.create_connection((db_host, db_port), 5).close() +PY +EOF + + if run_stack_backend_bash_command "${stack_dir}" "${reachability_command}" >/dev/null 2>&1; then + return 0 + fi + + db_ready_status=$? + if [ "${db_ready_status}" -eq 54 ] || [ "${db_ready_status}" -eq 52 ]; then + return "${db_ready_status}" + fi + + return 1 +} + +stack_site_exists_in_bench() { + local stack_dir="${1}" + local site_name="${2}" + local exists_command="" + local exists_status=0 + + if ! is_safe_stack_site_cleanup_name "${site_name}"; then + return 61 + fi + + exists_command="$( + printf "bench list-sites | grep -F -x -- %s >/dev/null" "$(shell_quote_site_command_arg "${site_name}")" + )" + if run_stack_backend_bash_command "${stack_dir}" "${exists_command}" >/dev/null 2>&1; then + return 0 + fi + + exists_status=$? + if [ "${exists_status}" -eq 54 ] || [ "${exists_status}" -eq 52 ]; then + return "${exists_status}" + fi + + return 1 +} + +stack_site_directory_exists() { + local stack_dir="${1}" + local site_name="${2}" + local exists_command="" + local exists_status=0 + + if ! is_safe_stack_site_cleanup_name "${site_name}"; then + return 61 + fi + + exists_command="$( + printf "test -d sites/%s" "$(shell_quote_site_command_arg "${site_name}")" + )" + if run_stack_backend_bash_command "${stack_dir}" "${exists_command}" >/dev/null 2>&1; then + return 0 + fi + + exists_status=$? + if [ "${exists_status}" -eq 54 ] || [ "${exists_status}" -eq 52 ]; then + return "${exists_status}" + fi + + return 1 +} + +stack_site_config_exists() { + local stack_dir="${1}" + local site_name="${2}" + local exists_command="" + local exists_status=0 + + if ! is_safe_stack_site_cleanup_name "${site_name}"; then + return 61 + fi + + exists_command="$( + printf "test -f sites/%s/site_config.json" "$(shell_quote_site_command_arg "${site_name}")" + )" + if run_stack_backend_bash_command "${stack_dir}" "${exists_command}" >/dev/null 2>&1; then + return 0 + fi + + exists_status=$? + if [ "${exists_status}" -eq 54 ] || [ "${exists_status}" -eq 52 ]; then + return "${exists_status}" + fi + + return 1 +} + +get_stack_site_database_name() { + local stack_dir="${1}" + local site_name="${2}" + local read_command="" + + if ! is_safe_stack_site_cleanup_name "${site_name}"; then + return 61 + fi + + read_command="$( + printf "python - <<'PY'\nimport json\nfrom pathlib import Path\npath = Path('sites') / %s / 'site_config.json'\nwith path.open(encoding='utf-8') as handle:\n print(json.load(handle).get('db_name', ''))\nPY" \ + "$(shell_quote_site_command_arg "${site_name}")" + )" + + run_stack_backend_bash_command "${stack_dir}" "${read_command}" +} + +get_stack_common_db_endpoint() { + local stack_dir="${1}" + local read_command="" + + read_command="$( + cat <<'EOF' +python - <<'PY' +import json +from pathlib import Path +path = Path("sites/common_site_config.json") +with path.open(encoding="utf-8") as handle: + config = json.load(handle) +print(f"{config.get('db_host', '')}|{config.get('db_port', 3306)}") +PY +EOF + )" + + run_stack_backend_bash_command "${stack_dir}" "${read_command}" +} + +repair_stack_site_runtime_state() { + local stack_dir="${1}" + local database_id="" + local redis_id="" + local db_host="" + local db_port="" + local repair_command="" + + database_id="$(get_stack_database_id "${stack_dir}" || true)" + redis_id="$(get_stack_redis_id "${stack_dir}" || true)" + + case "${database_id}" in + mariadb) + db_host="db" + db_port="3306" + ;; + postgres) + db_host="db" + db_port="5432" + ;; + *) + return 57 + ;; + esac + + repair_command="$( + cat < sites/common_site_config.json +ls -1 apps > sites/apps.txt +bench set-config -g db_host ${db_host} +bench set-config -gp db_port ${db_port} +EOF + )" + + case "${redis_id}" in + enabled) + repair_command="${repair_command}"$'\n'"bench set-config -g redis_cache redis://redis-cache:6379" + repair_command="${repair_command}"$'\n'"bench set-config -g redis_queue redis://redis-queue:6379" + repair_command="${repair_command}"$'\n'"bench set-config -g redis_socketio redis://redis-queue:6379" + ;; + "" | disabled) + : + ;; + *) + return 62 + ;; + esac + + repair_command="${repair_command}"$'\n'"bench set-config -gp socketio_port 9000" + repair_command="${repair_command}"$'\n'"bench set-config -g chromium_path /usr/bin/chromium-headless-shell" + + if ! run_stack_backend_bash_command "${stack_dir}" "${repair_command}"; then + return 62 + fi + + return 0 +} + +stack_site_has_partial_artifacts() { + local stack_dir="${1}" + local site_name="${2}" + + if ! is_safe_stack_site_cleanup_name "${site_name}"; then + return 61 + fi + + if stack_site_exists_in_bench "${stack_dir}" "${site_name}"; then + return 0 + fi + + case $? in + 61) + return 61 + ;; + 54 | 52) + return $? + ;; + esac + + if stack_site_directory_exists "${stack_dir}" "${site_name}"; then + return 0 + fi + + case $? in + 61) + return 61 + ;; + 54 | 52) + return $? + ;; + esac + + return 1 +} + +drop_stack_site_database() { + local stack_dir="${1}" + local db_name="${2}" + local db_password="" + local db_endpoint="" + local db_host="" + local db_port="" + local drop_db_command="" + + db_password="$(get_stack_database_root_password "${stack_dir}")" + db_endpoint="$(get_stack_common_db_endpoint "${stack_dir}" || true)" + db_host="${db_endpoint%%|*}" + db_port="${db_endpoint#*|}" + + if [ -z "${db_host}" ] || [ -z "${db_port}" ]; then + return 1 + fi + + drop_db_command="$( + printf "mysql --protocol=TCP -h %s -P %s -u root -p%s -e %s" \ + "$(shell_quote_site_command_arg "${db_host}")" \ + "$(shell_quote_site_command_arg "${db_port}")" \ + "$(printf '%s' "${db_password}" | sed "s/'/'\"'\"'/g")" \ + "$(shell_quote_site_command_arg "DROP DATABASE IF EXISTS \`${db_name}\`; DROP USER IF EXISTS '${db_name}'@'%'; DROP USER IF EXISTS '${db_name}'@'localhost'; FLUSH PRIVILEGES;")" + )" + + if ! run_stack_backend_bash_command "${stack_dir}" "${drop_db_command}"; then + return 1 + fi + + return 0 +} + +remove_stack_site_directory() { + local stack_dir="${1}" + local site_name="${2}" + local remove_command="" + + if ! is_safe_stack_site_cleanup_name "${site_name}"; then + return 61 + fi + + remove_command="$( + printf "rm -rf -- sites/%s archived_sites/%s" \ + "$(shell_quote_site_command_arg "${site_name}")" \ + "$(shell_quote_site_command_arg "${site_name}")" + )" + + if ! run_stack_backend_bash_command "${stack_dir}" "${remove_command}"; then + return 1 + fi + + return 0 +} + +cleanup_partial_stack_site() { + local stack_dir="${1}" + local site_name="${2}" + local artifact_status=0 + local db_name="" + local has_site_config=1 + + if ! is_safe_stack_site_cleanup_name "${site_name}"; then + return 61 + fi + + if stack_site_has_partial_artifacts "${stack_dir}" "${site_name}"; then + : + else + artifact_status=$? + case "${artifact_status}" in + 61) + return 61 + ;; + 54 | 52) + return "${artifact_status}" + ;; + *) + return 0 + ;; + esac + fi + + if stack_site_config_exists "${stack_dir}" "${site_name}"; then + : + else + artifact_status=$? + case "${artifact_status}" in + 61) + return 61 + ;; + 54 | 52) + return "${artifact_status}" + ;; + *) + has_site_config=0 + ;; + esac + fi + + if [ "${has_site_config}" -eq 1 ]; then + db_name="$(get_stack_site_database_name "${stack_dir}" "${site_name}" || true)" + if [ -z "${db_name}" ]; then + return 60 + fi + fi + + if [ "${has_site_config}" -eq 1 ] && ! drop_stack_site_database "${stack_dir}" "${db_name}"; then + return 60 + fi + + if ! remove_stack_site_directory "${stack_dir}" "${site_name}"; then + return 60 + fi + + if stack_site_has_partial_artifacts "${stack_dir}" "${site_name}"; then + return 60 + fi + + artifact_status=$? + case "${artifact_status}" in + 54 | 52) + return "${artifact_status}" + ;; + esac + + return 0 +} + +create_first_stack_site() { + local stack_dir="${1}" + local site_name="${2}" + local admin_password="${3}" + local create_site_command="" + + create_site_command="$( + printf "bench new-site %s --mariadb-user-host-login-scope='%%' --admin-password %s --db-root-username root --db-root-password %s" \ + "$(shell_quote_site_command_arg "${site_name}")" \ + "$(shell_quote_site_command_arg "${admin_password}")" \ + "$(shell_quote_site_command_arg "$(get_stack_database_root_password "${stack_dir}")")" + )" + + if ! run_stack_backend_bash_command "${stack_dir}" "${create_site_command}"; then + return 55 + fi + + return 0 +} + +install_stack_apps_on_site() { + local result_var="${1}" + local stack_dir="${2}" + local site_name="${3}" + local selected_app_lines="" + local installed_app_lines="" + local app_name="" + local install_app_command="" + + if ! get_stack_selected_installable_apps selected_app_lines "${stack_dir}"; then + printf -v "${result_var}" "%s" "" + return 0 + fi + + while IFS= read -r app_name; do + if [ -z "${app_name}" ]; then + continue + fi + + install_app_command="$( + printf "bench --site %s install-app %s" \ + "$(shell_quote_site_command_arg "${site_name}")" \ + "$(shell_quote_site_command_arg "${app_name}")" + )" + + if ! run_stack_backend_bash_command "${stack_dir}" "${install_app_command}"; then + printf -v "${result_var}" "%s" "${installed_app_lines}" + return 56 + fi + + if [ -z "${installed_app_lines}" ]; then + installed_app_lines="${app_name}" + else + installed_app_lines="${installed_app_lines}"$'\n'"${app_name}" + fi + + if ! persist_stack_site_metadata \ + "${stack_dir}" \ + "single-site" \ + "${site_name}" \ + "apps_installing" \ + "${installed_app_lines}" \ + "install-apps" \ + "" \ + "$(get_stack_site_created_at "${stack_dir}" || true)" \ + "$(get_current_utc_timestamp)"; then + return 58 + fi + done </dev/null 2>&1 || true + return 60 + ;; + *) + mark_stack_site_failed "${stack_dir}" "${site_name}" "" "cleanup-partial-site" "Unexpected cleanup failure before create-site." "${created_at}" >/dev/null 2>&1 || true + return 60 + ;; + esac + fi + + updated_at="${created_at}" + if ! persist_stack_site_metadata "${stack_dir}" "single-site" "${site_name}" "creating" "" "create-site" "" "${created_at}" "${updated_at}"; then + return 58 + fi + + if create_first_stack_site "${stack_dir}" "${site_name}" "${admin_password}"; then + : + else + site_create_status=$? + if cleanup_partial_stack_site "${stack_dir}" "${site_name}"; then + mark_stack_site_failed "${stack_dir}" "${site_name}" "" "create-site" "bench new-site failed. Partial site data was cleaned up automatically." "${created_at}" >/dev/null 2>&1 || true + return "${site_create_status}" + fi + + cleanup_status=$? + mark_stack_site_failed "${stack_dir}" "${site_name}" "" "create-site" "bench new-site failed and partial site data could not be cleaned up automatically. Manual cleanup is required." "${created_at}" >/dev/null 2>&1 || true + case "${cleanup_status}" in + 54 | 52) + return "${cleanup_status}" + ;; + *) + return 60 + ;; + esac + fi + + updated_at="$(get_current_utc_timestamp)" + if ! persist_stack_site_metadata "${stack_dir}" "single-site" "${site_name}" "created" "" "create-site" "" "${created_at}" "${updated_at}"; then + return 58 + fi + + if install_stack_apps_on_site installed_app_lines "${stack_dir}" "${site_name}"; then + : + else + app_install_status=$? + case "${app_install_status}" in + 56) + if cleanup_partial_stack_site "${stack_dir}" "${site_name}"; then + mark_stack_site_failed "${stack_dir}" "${site_name}" "${installed_app_lines}" "install-apps" "App installation failed. Partial site data was cleaned up automatically." "${created_at}" >/dev/null 2>&1 || true + else + cleanup_status=$? + mark_stack_site_failed "${stack_dir}" "${site_name}" "${installed_app_lines}" "install-apps" "App installation failed and partial site data could not be cleaned up automatically. Manual cleanup is required." "${created_at}" >/dev/null 2>&1 || true + case "${cleanup_status}" in + 54 | 52) + return "${cleanup_status}" + ;; + *) + return 60 + ;; + esac + fi + ;; + 58) + return 58 + ;; + *) + mark_stack_site_failed "${stack_dir}" "${site_name}" "${installed_app_lines}" "install-apps" "Unknown app installation failure." "${created_at}" >/dev/null 2>&1 || true + ;; + esac + return "${app_install_status}" + fi + + updated_at="$(get_current_utc_timestamp)" + if ! persist_stack_site_metadata "${stack_dir}" "single-site" "${site_name}" "ready" "${installed_app_lines}" "install-apps" "" "${created_at}" "${updated_at}"; then + return 58 + fi + + return 0 +} diff --git a/scripts/easy-docker/lib/app/wizard/common/site/metadata.sh b/scripts/easy-docker/lib/app/wizard/common/site/metadata.sh new file mode 100755 index 00000000..e20e39d9 --- /dev/null +++ b/scripts/easy-docker/lib/app/wizard/common/site/metadata.sh @@ -0,0 +1,350 @@ +#!/usr/bin/env bash + +get_metadata_site_string_field() { + local metadata_path="${1}" + local field_name="${2}" + + if [ ! -f "${metadata_path}" ]; then + return 1 + fi + + awk -v field_name="${field_name}" ' + /"site"[[:space:]]*:[[:space:]]*{/ { + in_site = 1 + site_depth = 1 + next + } + in_site { + if (match($0, "\"" field_name "\"[[:space:]]*:[[:space:]]*\"([^\"]*)\"", parts)) { + print parts[1] + exit + } + + line = $0 + open_count = gsub(/{/, "{", line) + close_count = gsub(/}/, "}", line) + site_depth += open_count - close_count + if (site_depth <= 0) { + exit + } + } + ' "${metadata_path}" +} + +get_metadata_site_apps_installed_lines() { + local metadata_path="${1}" + + if [ ! -f "${metadata_path}" ]; then + return 1 + fi + + awk ' + /"site"[[:space:]]*:[[:space:]]*{/ { + in_site = 1 + site_depth = 1 + next + } + in_site && /"apps_installed"[[:space:]]*:[[:space:]]*\[/ { + in_apps_installed = 1 + next + } + in_apps_installed && /\]/ { + in_apps_installed = 0 + next + } + in_apps_installed { + if (match($0, /"([^"]+)"/, parts)) { + print parts[1] + } + } + in_site { + line = $0 + open_count = gsub(/{/, "{", line) + close_count = gsub(/}/, "}", line) + site_depth += open_count - close_count + if (site_depth <= 0) { + exit + } + } + ' "${metadata_path}" +} + +get_stack_site_name() { + local stack_dir="${1}" + + get_metadata_site_string_field "${stack_dir}/metadata.json" "name" +} + +get_stack_site_state() { + local stack_dir="${1}" + + get_metadata_site_string_field "${stack_dir}/metadata.json" "state" +} + +get_stack_site_created_at() { + local stack_dir="${1}" + + get_metadata_site_string_field "${stack_dir}/metadata.json" "created_at" +} + +get_stack_site_apps_installed_lines() { + local stack_dir="${1}" + + get_metadata_site_apps_installed_lines "${stack_dir}/metadata.json" +} + +stack_has_site_record() { + local stack_dir="${1}" + local site_name="" + + site_name="$(get_stack_site_name "${stack_dir}" || true)" + if [ -n "${site_name}" ]; then + return 0 + fi + + return 1 +} + +stack_has_site_configured() { + local stack_dir="${1}" + local site_state="" + + site_state="$(get_stack_site_state "${stack_dir}" || true)" + case "${site_state}" in + created | apps_installing | ready) + return 0 + ;; + *) + return 1 + ;; + esac +} + +get_stack_site_status_label() { + local result_var="${1}" + local stack_dir="${2}" + local site_state="" + local site_name="" + local site_status_label="" + + site_state="$(get_stack_site_state "${stack_dir}" || true)" + site_name="$(get_stack_site_name "${stack_dir}" || true)" + + case "${site_state}" in + "") + site_status_label="Not configured" + ;; + requested) + site_status_label="Requested" + ;; + creating) + site_status_label="Creating" + ;; + created) + site_status_label="Created" + ;; + apps_installing) + site_status_label="Installing apps" + ;; + ready) + site_status_label="Ready" + ;; + failed) + site_status_label="Failed" + ;; + *) + site_status_label="${site_state}" + ;; + esac + + if [ -n "${site_name}" ]; then + site_status_label="${site_status_label} (${site_name})" + fi + + printf -v "${result_var}" "%s" "${site_status_label}" + return 0 +} + +get_stack_site_menu_entry() { + local result_var="${1}" + local stack_dir="${2}" + local site_name="" + local site_status_label="" + local menu_entry="" + + site_name="$(get_stack_site_name "${stack_dir}" || true)" + if [ -z "${site_name}" ]; then + return 1 + fi + + get_stack_site_status_label site_status_label "${stack_dir}" + menu_entry="$(printf "%s | %s" "${site_name}" "${site_status_label}")" + printf -v "${result_var}" "%s" "${menu_entry}" + return 0 +} + +build_stack_site_apps_installed_json_array() { + local result_var="${1}" + local apps_installed_lines="${2:-}" + local app_name="" + local escaped_app_name="" + local entries_json="" + + while IFS= read -r app_name; do + if [ -z "${app_name}" ]; then + continue + fi + + escaped_app_name="$(json_escape_string "${app_name}")" + if [ -z "${entries_json}" ]; then + entries_json="$(printf ' "%s"' "${escaped_app_name}")" + else + entries_json="${entries_json}"$',\n'"$(printf ' "%s"' "${escaped_app_name}")" + fi + done <"${metadata_tmp_path}"; then + rm -f -- "${metadata_tmp_path}" >/dev/null 2>&1 || true + return 1 + fi + + if ! mv -- "${metadata_tmp_path}" "${metadata_path}"; then + rm -f -- "${metadata_tmp_path}" >/dev/null 2>&1 || true + return 1 + fi + + return 0 +} + +mark_stack_site_failed() { + local stack_dir="${1}" + local site_name="${2:-}" + local apps_installed_lines="${3:-}" + local last_action="${4:-bootstrap-site}" + local last_error="${5:-Unknown site bootstrap failure}" + local created_at="${6:-}" + local updated_at="" + + updated_at="$(get_current_utc_timestamp)" + persist_stack_site_metadata "${stack_dir}" "single-site" "${site_name}" "failed" "${apps_installed_lines}" "${last_action}" "${last_error}" "${created_at}" "${updated_at}" +} diff --git a/scripts/easy-docker/lib/app/wizard/flows/manage.sh b/scripts/easy-docker/lib/app/wizard/flows/manage.sh index 09ca175f..7e019813 100755 --- a/scripts/easy-docker/lib/app/wizard/flows/manage.sh +++ b/scripts/easy-docker/lib/app/wizard/flows/manage.sh @@ -63,6 +63,198 @@ run_build_stack_custom_image_with_feedback() { return "${build_image_status}" } +prompt_manage_stack_site_name_with_cancel() { + local result_var="${1}" + local stack_name="${2}" + local stack_dir="${3}" + local input_site_name="" + local suggestion="" + local prompt_status=0 + + suggestion="$(get_stack_primary_site_name_suggestion "${stack_dir}" || true)" + while true; do + input_site_name="$(prompt_stack_site_name "${stack_name}" "${suggestion}")" + prompt_status=$? + if [ "${prompt_status}" -ne 0 ]; then + return "${FLOW_ABORT_INPUT}" + fi + + input_site_name="$(printf '%s' "${input_site_name}" | tr -d '\r\n')" + case "${input_site_name}" in + "") + show_warning_and_wait "Site name is required." 2 + ;; + /back | /Back | /BACK) + return "${FLOW_ABORT_INPUT}" + ;; + *) + if ! is_valid_stack_site_name "${input_site_name}"; then + show_warning_and_wait "Site name may only contain letters, numbers, dots, dashes, and underscores." 3 + continue + fi + printf -v "${result_var}" "%s" "${input_site_name}" + return "${FLOW_CONTINUE}" + ;; + esac + done +} + +prompt_manage_stack_site_admin_password_with_cancel() { + local result_var="${1}" + local stack_name="${2}" + local input_admin_password="" + local prompt_status=0 + + while true; do + input_admin_password="$(prompt_stack_site_admin_password "${stack_name}")" + prompt_status=$? + if [ "${prompt_status}" -ne 0 ]; then + return "${FLOW_ABORT_INPUT}" + fi + + input_admin_password="$(printf '%s' "${input_admin_password}" | tr -d '\r\n')" + case "${input_admin_password}" in + "") + show_warning_and_wait "Administrator password is required." 2 + ;; + /back | /Back | /BACK) + return "${FLOW_ABORT_INPUT}" + ;; + *) + printf -v "${result_var}" "%s" "${input_admin_password}" + return "${FLOW_CONTINUE}" + ;; + esac + done +} + +handle_manage_stack_site_flow() { + local stack_name="${1}" + local stack_dir="${2}" + local site_status_label="" + local site_action="" + local site_name="" + local admin_password="" + local site_flow_status=0 + local existing_site_entry="" + local existing_site_name="" + local existing_site_created_at="" + local existing_site_apps_lines="" + local existing_site_apps_csv="" + local existing_site_details_action="" + + while true; do + get_stack_site_status_label site_status_label "${stack_dir}" + existing_site_entry="" + get_stack_site_menu_entry existing_site_entry "${stack_dir}" || true + + site_action="$(show_manage_stack_site_menu "${stack_name}" "${stack_dir}" "${site_status_label}" "${existing_site_entry}" || true)" + case "${site_action}" in + "Create new site") + if ! prompt_manage_stack_site_name_with_cancel site_name "${stack_name}" "${stack_dir}"; then + continue + fi + + if ! prompt_manage_stack_site_admin_password_with_cancel admin_password "${stack_name}"; then + continue + fi + + show_warning_message "Creating the first site for stack: ${stack_name}" + if bootstrap_first_stack_site "${stack_dir}" "${site_name}" "${admin_password}"; then + show_warning_and_wait "Site created successfully and selected stack apps were installed: ${site_name}" 3 + continue + else + site_flow_status=$? + fi + + case "${site_flow_status}" in + 51) + show_warning_and_wait "Cannot manage site: backend service is not running yet. Start the stack first." 4 + ;; + 52) + show_warning_and_wait "Cannot manage site for this topology yet. Only single-host stacks are supported." 4 + ;; + 53) + show_warning_and_wait "A site is already configured for this stack. Phase 1 supports one site per stack." 4 + ;; + 54) + show_warning_and_wait "Cannot manage site because stack metadata, env, or compose inputs are incomplete." 4 + ;; + 55) + show_warning_and_wait "Could not create the site. Check the output above for bench new-site details." 4 + ;; + 56) + show_warning_and_wait "The site was created, but app installation failed. Check the output above." 4 + ;; + 57) + show_warning_and_wait "Site bootstrap currently supports only MariaDB-backed single-host stacks." 4 + ;; + 58) + show_warning_and_wait "The site state could not be written to metadata.json." 4 + ;; + 59) + show_warning_and_wait "Cannot create site: stack services are not ready yet. Wait and try again." 4 + ;; + 60) + show_warning_and_wait "Site creation failed and automatic cleanup could not remove all partial data. Manual cleanup is required." 5 + ;; + 61) + show_warning_and_wait "Cannot create site because the site name was empty or unsafe for cleanup operations." 4 + ;; + 62) + show_warning_and_wait "Cannot prepare the stack for site creation because the bench runtime files could not be repaired." 4 + ;; + *) + show_warning_and_wait "Site bootstrap failed (${site_flow_status})." 4 + ;; + esac + ;; + "Back" | "") + return "${FLOW_CONTINUE}" + ;; + "Exit and close easy-docker") + return "${FLOW_EXIT_APP}" + ;; + *) + if [ -n "${existing_site_entry}" ] && [ "${site_action}" = "${existing_site_entry}" ]; then + existing_site_name="$(get_stack_site_name "${stack_dir}" || true)" + existing_site_created_at="$(get_stack_site_created_at "${stack_dir}" || true)" + existing_site_apps_lines="$(get_stack_site_apps_installed_lines "${stack_dir}" || true)" + if [ -n "${existing_site_apps_lines}" ]; then + existing_site_apps_csv="$(printf '%s' "${existing_site_apps_lines}" | tr '\n' ',' | sed 's/,$//')" + else + existing_site_apps_csv="None" + fi + + existing_site_details_action="$( + show_manage_stack_site_details \ + "${stack_name}" \ + "${stack_dir}" \ + "${existing_site_name}" \ + "${site_status_label}" \ + "${existing_site_created_at}" \ + "${existing_site_apps_csv}" || true + )" + case "${existing_site_details_action}" in + "Back" | "") + continue + ;; + "Exit and close easy-docker") + return "${FLOW_EXIT_APP}" + ;; + *) + show_warning_and_wait "Unknown site details action: ${existing_site_details_action}" 2 + ;; + esac + continue + fi + + show_warning_and_wait "Unknown site action: ${site_action}" 2 + ;; + esac + done +} + handle_manage_selected_stack_flow() { local stack_name="${1}" local stack_dir="" @@ -289,6 +481,21 @@ handle_manage_selected_stack_flow() { esac done ;; + "Site") + if handle_manage_stack_site_flow "${stack_name}" "${stack_dir}"; then + : + else + compose_start_status=$? + case "${compose_start_status}" in + "${FLOW_EXIT_APP}") + return "${FLOW_EXIT_APP}" + ;; + *) + continue + ;; + esac + fi + ;; "Back" | "") return "${FLOW_CONTINUE}" ;; diff --git a/scripts/easy-docker/lib/ui/screens/production/manage.sh b/scripts/easy-docker/lib/ui/screens/production/manage.sh index 2a74884f..9bc59be4 100755 --- a/scripts/easy-docker/lib/ui/screens/production/manage.sh +++ b/scripts/easy-docker/lib/ui/screens/production/manage.sh @@ -65,12 +65,13 @@ show_manage_stack_actions_menu() { menu_header="$(printf "Stack actions | %s" "${stack_runtime_status}")" gum choose \ - --height 9 \ + --height 10 \ --header "${menu_header}" \ --cursor.foreground 63 \ --selected.foreground 45 \ "Apps" \ "Docker" \ + "Site" \ "Start stack in Docker Compose" \ "Stop stack in Docker Compose" \ "Back" \ @@ -121,6 +122,95 @@ show_manage_stack_docker_menu() { "Exit and close easy-docker" } +show_manage_stack_site_menu() { + local stack_name="${1}" + local stack_dir="${2}" + local site_status="${3:-Not configured}" + local existing_site_entry="${4:-}" + local status_text="" + + render_main_screen 1 >&2 + + status_text="$(printf "Manage stack site\n\nStack: %s\nDirectory: %s\nSite status: %s\n\nCreate a new site or select an existing site for this stack." "${stack_name}" "${stack_dir}" "${site_status}")" + render_box_message "${status_text}" "0 2" >&2 + + if [ -n "${existing_site_entry}" ]; then + gum choose \ + --height 10 \ + --header "Stack site actions" \ + --cursor.foreground 63 \ + --selected.foreground 45 \ + "Create new site" \ + "${existing_site_entry}" \ + "Back" \ + "Exit and close easy-docker" + return 0 + fi + + gum choose \ + --height 8 \ + --header "Stack site actions" \ + --cursor.foreground 63 \ + --selected.foreground 45 \ + "Create new site" \ + "Back" \ + "Exit and close easy-docker" +} + +prompt_stack_site_name() { + local stack_name="${1}" + local placeholder="${2:-}" + local status_text="" + + render_main_screen 1 >&2 + + status_text="$(printf "Manage stack site\n\nStack: %s\n\nEnter the site name for the first site.\nType /back or press Ctrl+C to cancel." "${stack_name}")" + render_box_message "${status_text}" "0 2" >&2 + + gum input \ + --header "Site name" \ + --prompt "site> " \ + --placeholder "${placeholder}" +} + +prompt_stack_site_admin_password() { + local stack_name="${1}" + local status_text="" + + render_main_screen 1 >&2 + + status_text="$(printf "Manage stack site\n\nStack: %s\n\nEnter the Administrator password for the new site.\nType /back or press Ctrl+C to cancel." "${stack_name}")" + render_box_message "${status_text}" "0 2" >&2 + + gum input \ + --header "Administrator password" \ + --prompt "password> " \ + --password +} + +show_manage_stack_site_details() { + local stack_name="${1}" + local stack_dir="${2}" + local site_name="${3}" + local site_status="${4:-Unknown}" + local created_at="${5:-}" + local installed_apps="${6:-None}" + local status_text="" + + render_main_screen 1 >&2 + + status_text="$(printf "Manage existing site\n\nStack: %s\nDirectory: %s\nSite: %s\nStatus: %s\nCreated at: %s\nInstalled apps: %s" "${stack_name}" "${stack_dir}" "${site_name}" "${site_status}" "${created_at:-n/a}" "${installed_apps}")" + render_box_message "${status_text}" "0 2" >&2 + + gum choose \ + --height 7 \ + --header "Site details" \ + --cursor.foreground 63 \ + --selected.foreground 45 \ + "Back" \ + "Exit and close easy-docker" +} + show_missing_custom_image_start_menu() { local stack_name="${1}" local stack_dir="${2}" From 7c4d1d47ca08946bbf332bc701bd22c1e37a0b1b Mon Sep 17 00:00:00 2001 From: RocketQuack <202538874+Rocket-Quack@users.noreply.github.com> Date: Sat, 28 Mar 2026 13:13:17 +0100 Subject: [PATCH 22/51] fix(easy-docker): harden site bootstrap and details --- .../easy-docker/lib/app/wizard/common/site.sh | 3 + .../lib/app/wizard/common/site/bootstrap.sh | 227 ++++++++++++++++-- .../lib/app/wizard/common/site/metadata.sh | 20 +- .../lib/app/wizard/flows/manage.sh | 15 +- .../lib/ui/screens/production/manage.sh | 14 +- 5 files changed, 237 insertions(+), 42 deletions(-) diff --git a/scripts/easy-docker/lib/app/wizard/common/site.sh b/scripts/easy-docker/lib/app/wizard/common/site.sh index 12a81680..098140cd 100755 --- a/scripts/easy-docker/lib/app/wizard/common/site.sh +++ b/scripts/easy-docker/lib/app/wizard/common/site.sh @@ -1,5 +1,8 @@ #!/usr/bin/env bash +EASY_DOCKER_SITE_ERROR_DETAIL="" +EASY_DOCKER_SITE_ERROR_LOG_PATH="" + load_easy_docker_site_modules() { local site_dir="" site_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/site" diff --git a/scripts/easy-docker/lib/app/wizard/common/site/bootstrap.sh b/scripts/easy-docker/lib/app/wizard/common/site/bootstrap.sh index 54383f61..3b5fd2ae 100755 --- a/scripts/easy-docker/lib/app/wizard/common/site/bootstrap.sh +++ b/scripts/easy-docker/lib/app/wizard/common/site/bootstrap.sh @@ -190,11 +190,98 @@ EOF 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}" + 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}" "${absolute_path}"; then + return 1 + fi + + printf -v "${result_var}" "%s" "${relative_path}" + return 0 +} + +run_stack_backend_bash_command_capture() { + local result_var="${1}" + local stack_dir="${2}" + local backend_command="${3}" + local command_output="" + local command_status=0 + + reset_easy_docker_site_error_state + command_output="$(run_stack_backend_bash_command "${stack_dir}" "${backend_command}" 2>&1)" + command_status=$? + + if [ -n "${command_output}" ]; then + printf '%s\n' "${command_output}" + fi + + printf -v "${result_var}" "%s" "${command_output}" + return "${command_status}" +} + +capture_stack_site_error_log() { + local stack_dir="${1}" + local action_name="${2:-site-error}" + local error_output="${3:-}" + local log_path="" + + EASY_DOCKER_SITE_ERROR_LOG_PATH="" + if [ -z "${error_output}" ]; then + return 0 + fi + + if ! write_stack_site_error_log log_path "${stack_dir}" "${action_name}" "${error_output}"; then + EASY_DOCKER_SITE_ERROR_DETAIL="${EASY_DOCKER_SITE_ERROR_DETAIL:-Failed to write site error log.}" + return 1 + fi + + EASY_DOCKER_SITE_ERROR_LOG_PATH="${log_path}" + return 0 } stack_backend_service_is_running() { @@ -359,6 +446,76 @@ EOF run_stack_backend_bash_command "${stack_dir}" "${read_command}" } +get_stack_site_runtime_app_names_lines() { + local stack_dir="${1}" + local site_name="${2}" + local list_apps_command="" + + if ! is_safe_stack_site_cleanup_name "${site_name}"; then + return 61 + fi + + list_apps_command="$( + printf "bench --site %s list-apps | awk 'NF { print \$1 }'" \ + "$(shell_quote_site_command_arg "${site_name}")" + )" + + run_stack_backend_bash_command "${stack_dir}" "${list_apps_command}" +} + +get_stack_runtime_available_app_lines() { + local stack_dir="${1}" + + run_stack_backend_bash_command "${stack_dir}" "ls -1 apps" +} + +get_stack_site_runtime_selected_apps_lines() { + local result_var="${1}" + local stack_dir="${2}" + local site_name="${3}" + local selected_app_lines="" + local runtime_app_lines="" + local selected_app_name="" + local installed_app_lines="" + + if ! get_stack_selected_installable_apps selected_app_lines "${stack_dir}"; then + printf -v "${result_var}" "%s" "" + return 0 + fi + + if [ -z "${selected_app_lines}" ]; then + printf -v "${result_var}" "%s" "" + return 0 + fi + + runtime_app_lines="$(get_stack_site_runtime_app_names_lines "${stack_dir}" "${site_name}" || true)" + if [ -z "${runtime_app_lines}" ]; then + printf -v "${result_var}" "%s" "" + return 1 + fi + + while IFS= read -r selected_app_name; do + if [ -z "${selected_app_name}" ]; then + continue + fi + + if ! printf '%s\n' "${runtime_app_lines}" | grep -F -x -- "${selected_app_name}" >/dev/null 2>&1; then + continue + fi + + if [ -z "${installed_app_lines}" ]; then + installed_app_lines="${selected_app_name}" + else + installed_app_lines="${installed_app_lines}"$'\n'"${selected_app_name}" + fi + done </dev/null 2>&1 || true return 55 fi @@ -612,24 +772,52 @@ install_stack_apps_on_site() { local installed_app_lines="" local app_name="" local install_app_command="" + local install_app_output="" + local available_app_lines="" + local -a selected_apps=() if ! get_stack_selected_installable_apps selected_app_lines "${stack_dir}"; then printf -v "${result_var}" "%s" "" return 0 fi - while IFS= read -r app_name; do + if [ -z "${selected_app_lines}" ]; then + printf -v "${result_var}" "%s" "" + return 0 + fi + + available_app_lines="$(get_stack_runtime_available_app_lines "${stack_dir}" || true)" + if [ -z "${available_app_lines}" ]; then + EASY_DOCKER_SITE_ERROR_DETAIL="Could not inspect available apps in the backend image." + capture_stack_site_error_log "${stack_dir}" "site-install-apps-error" "easy-docker could not list /home/frappe/frappe-bench/apps before install-app." >/dev/null 2>&1 || true + return 63 + fi + + mapfile -t selected_apps <<<"${selected_app_lines}" + for app_name in "${selected_apps[@]}"; do if [ -z "${app_name}" ]; then continue fi + if ! printf '%s\n' "${available_app_lines}" | grep -F -x -- "${app_name}" >/dev/null 2>&1; then + EASY_DOCKER_SITE_ERROR_DETAIL="$(printf "Selected app '%s' is not available in the backend image." "${app_name}")" + capture_stack_site_error_log "${stack_dir}" "site-install-apps-error" "$(printf "Selected app '%s' was requested in stack metadata but is missing from /home/frappe/frappe-bench/apps.\nAvailable apps:\n%s" "${app_name}" "${available_app_lines}")" >/dev/null 2>&1 || true + if [ -n "${EASY_DOCKER_SITE_ERROR_LOG_PATH}" ]; then + printf 'Details written to %s\n' "${stack_dir}/${EASY_DOCKER_SITE_ERROR_LOG_PATH}" >&2 + fi + printf -v "${result_var}" "%s" "${installed_app_lines}" + return 63 + fi + install_app_command="$( printf "bench --site %s install-app %s" \ "$(shell_quote_site_command_arg "${site_name}")" \ "$(shell_quote_site_command_arg "${app_name}")" )" - if ! run_stack_backend_bash_command "${stack_dir}" "${install_app_command}"; then + if ! run_stack_backend_bash_command_capture install_app_output "${stack_dir}" "${install_app_command}"; then + EASY_DOCKER_SITE_ERROR_DETAIL="$(printf "bench install-app failed for '%s'." "${app_name}")" + capture_stack_site_error_log "${stack_dir}" "site-install-apps-error" "${install_app_output}" >/dev/null 2>&1 || true printf -v "${result_var}" "%s" "${installed_app_lines}" return 56 fi @@ -648,13 +836,12 @@ install_stack_apps_on_site() { "${installed_app_lines}" \ "install-apps" \ "" \ + "" \ "$(get_stack_site_created_at "${stack_dir}" || true)" \ "$(get_current_utc_timestamp)"; then return 58 fi - done </dev/null 2>&1 || true + 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 + 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 + if ! persist_stack_site_metadata "${stack_dir}" "single-site" "${site_name}" "creating" "" "create-site" "" "" "${created_at}" "${updated_at}"; then return 58 fi @@ -734,12 +921,12 @@ bootstrap_first_stack_site() { 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 + mark_stack_site_failed "${stack_dir}" "${site_name}" "" "create-site" "bench new-site failed. Partial site data was cleaned up automatically." "${EASY_DOCKER_SITE_ERROR_LOG_PATH}" "${created_at}" >/dev/null 2>&1 || true return "${site_create_status}" fi cleanup_status=$? - mark_stack_site_failed "${stack_dir}" "${site_name}" "" "create-site" "bench new-site failed and partial site data could not be cleaned up automatically. Manual cleanup is required." "${created_at}" >/dev/null 2>&1 || true + mark_stack_site_failed "${stack_dir}" "${site_name}" "" "create-site" "bench new-site failed and partial site data could not be cleaned up automatically. Manual cleanup is required." "${EASY_DOCKER_SITE_ERROR_LOG_PATH}" "${created_at}" >/dev/null 2>&1 || true case "${cleanup_status}" in 54 | 52) return "${cleanup_status}" @@ -751,7 +938,7 @@ bootstrap_first_stack_site() { 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 + if ! persist_stack_site_metadata "${stack_dir}" "single-site" "${site_name}" "created" "" "create-site" "" "" "${created_at}" "${updated_at}"; then return 58 fi @@ -760,12 +947,12 @@ bootstrap_first_stack_site() { else app_install_status=$? case "${app_install_status}" in - 56) + 56 | 63) if cleanup_partial_stack_site "${stack_dir}" "${site_name}"; then - mark_stack_site_failed "${stack_dir}" "${site_name}" "${installed_app_lines}" "install-apps" "App installation failed. Partial site data was cleaned up automatically." "${created_at}" >/dev/null 2>&1 || true + mark_stack_site_failed "${stack_dir}" "${site_name}" "${installed_app_lines}" "install-apps" "${EASY_DOCKER_SITE_ERROR_DETAIL:-App installation failed. Partial site data was cleaned up automatically.}" "${EASY_DOCKER_SITE_ERROR_LOG_PATH}" "${created_at}" >/dev/null 2>&1 || true else cleanup_status=$? - mark_stack_site_failed "${stack_dir}" "${site_name}" "${installed_app_lines}" "install-apps" "App installation failed and partial site data could not be cleaned up automatically. Manual cleanup is required." "${created_at}" >/dev/null 2>&1 || true + mark_stack_site_failed "${stack_dir}" "${site_name}" "${installed_app_lines}" "install-apps" "${EASY_DOCKER_SITE_ERROR_DETAIL:-App installation failed and partial site data could not be cleaned up automatically. Manual cleanup is required.}" "${EASY_DOCKER_SITE_ERROR_LOG_PATH}" "${created_at}" >/dev/null 2>&1 || true case "${cleanup_status}" in 54 | 52) return "${cleanup_status}" @@ -780,14 +967,14 @@ bootstrap_first_stack_site() { 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 + mark_stack_site_failed "${stack_dir}" "${site_name}" "${installed_app_lines}" "install-apps" "Unknown app installation failure." "${EASY_DOCKER_SITE_ERROR_LOG_PATH}" "${created_at}" >/dev/null 2>&1 || true ;; esac return "${app_install_status}" fi updated_at="$(get_current_utc_timestamp)" - if ! persist_stack_site_metadata "${stack_dir}" "single-site" "${site_name}" "ready" "${installed_app_lines}" "install-apps" "" "${created_at}" "${updated_at}"; then + 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 diff --git a/scripts/easy-docker/lib/app/wizard/common/site/metadata.sh b/scripts/easy-docker/lib/app/wizard/common/site/metadata.sh index e20e39d9..49a7a187 100755 --- a/scripts/easy-docker/lib/app/wizard/common/site/metadata.sh +++ b/scripts/easy-docker/lib/app/wizard/common/site/metadata.sh @@ -220,19 +220,21 @@ build_stack_site_metadata_json_object() { local apps_installed_lines="${5:-}" local last_action="${6:-}" local last_error="${7:-}" - local created_at="${8:-}" - local updated_at="${9:-}" + local error_log_path="${8:-}" + local created_at="${9:-}" + local updated_at="${10:-}" 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 }' \ + 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 "error_log_path": "%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 "${error_log_path}")" \ "$(json_escape_string "${created_at}")" \ "$(json_escape_string "${updated_at}")" } @@ -245,8 +247,9 @@ persist_stack_site_metadata() { local apps_installed_lines="${5:-}" local last_action="${6:-}" local last_error="${7:-}" - local created_at="${8:-}" - local updated_at="${9:-}" + local error_log_path="${8:-}" + local created_at="${9:-}" + local updated_at="${10:-}" local metadata_path="" local metadata_tmp_path="" local site_json_object="" @@ -257,7 +260,7 @@ persist_stack_site_metadata() { 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}" + build_stack_site_metadata_json_object site_json_object "${site_mode}" "${site_name}" "${site_state}" "${apps_installed_lines}" "${last_action}" "${last_error}" "${error_log_path}" "${created_at}" "${updated_at}" if ! awk -v site_object="${site_json_object}" ' BEGIN { @@ -342,9 +345,10 @@ mark_stack_site_failed() { local apps_installed_lines="${3:-}" local last_action="${4:-bootstrap-site}" local last_error="${5:-Unknown site bootstrap failure}" - local created_at="${6:-}" + local error_log_path="${6:-}" + local created_at="${7:-}" 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}" + persist_stack_site_metadata "${stack_dir}" "single-site" "${site_name}" "failed" "${apps_installed_lines}" "${last_action}" "${last_error}" "${error_log_path}" "${created_at}" "${updated_at}" } diff --git a/scripts/easy-docker/lib/app/wizard/flows/manage.sh b/scripts/easy-docker/lib/app/wizard/flows/manage.sh index 7e019813..7d4c3063 100755 --- a/scripts/easy-docker/lib/app/wizard/flows/manage.sh +++ b/scripts/easy-docker/lib/app/wizard/flows/manage.sh @@ -131,7 +131,6 @@ prompt_manage_stack_site_admin_password_with_cancel() { 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="" @@ -144,11 +143,10 @@ handle_manage_stack_site_flow() { 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)" + site_action="$(show_manage_stack_site_menu "${stack_name}" "${stack_dir}" "${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 @@ -181,10 +179,10 @@ handle_manage_stack_site_flow() { 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 + show_warning_and_wait "Could not create the site. ${EASY_DOCKER_SITE_ERROR_DETAIL:-Check the output above for bench new-site details.} ${EASY_DOCKER_SITE_ERROR_LOG_PATH:+See ${stack_dir}/${EASY_DOCKER_SITE_ERROR_LOG_PATH}}" 6 ;; 56) - show_warning_and_wait "The site was created, but app installation failed. Check the output above." 4 + show_warning_and_wait "The site was created, but app installation failed. ${EASY_DOCKER_SITE_ERROR_DETAIL:-Check the output above.} ${EASY_DOCKER_SITE_ERROR_LOG_PATH:+See ${stack_dir}/${EASY_DOCKER_SITE_ERROR_LOG_PATH}}" 6 ;; 57) show_warning_and_wait "Site bootstrap currently supports only MariaDB-backed single-host stacks." 4 @@ -204,6 +202,9 @@ handle_manage_stack_site_flow() { 62) show_warning_and_wait "Cannot prepare the stack for site creation because the bench runtime files could not be repaired." 4 ;; + 63) + show_warning_and_wait "Cannot install the selected stack apps because at least one app is missing from the backend image. ${EASY_DOCKER_SITE_ERROR_DETAIL} ${EASY_DOCKER_SITE_ERROR_LOG_PATH:+See ${stack_dir}/${EASY_DOCKER_SITE_ERROR_LOG_PATH}}" 7 + ;; *) show_warning_and_wait "Site bootstrap failed (${site_flow_status})." 4 ;; @@ -220,6 +221,9 @@ handle_manage_stack_site_flow() { 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 stack_backend_service_is_running "${stack_dir}" >/dev/null 2>&1; then + get_stack_site_runtime_selected_apps_lines existing_site_apps_lines "${stack_dir}" "${existing_site_name}" || true + fi if [ -n "${existing_site_apps_lines}" ]; then existing_site_apps_csv="$(printf '%s' "${existing_site_apps_lines}" | tr '\n' ',' | sed 's/,$//')" else @@ -231,7 +235,6 @@ handle_manage_stack_site_flow() { "${stack_name}" \ "${stack_dir}" \ "${existing_site_name}" \ - "${site_status_label}" \ "${existing_site_created_at}" \ "${existing_site_apps_csv}" || true )" diff --git a/scripts/easy-docker/lib/ui/screens/production/manage.sh b/scripts/easy-docker/lib/ui/screens/production/manage.sh index 9bc59be4..5c2a3009 100755 --- a/scripts/easy-docker/lib/ui/screens/production/manage.sh +++ b/scripts/easy-docker/lib/ui/screens/production/manage.sh @@ -125,13 +125,12 @@ show_manage_stack_docker_menu() { 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 existing_site_entry="${3:-}" 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}")" + status_text="$(printf "Manage stack site\n\nStack: %s\nDirectory: %s\n\nCreate a new site or select an existing site for this stack." "${stack_name}" "${stack_dir}")" render_box_message "${status_text}" "0 2" >&2 if [ -n "${existing_site_entry}" ]; then @@ -192,18 +191,17 @@ 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 created_at="${4:-}" + local installed_apps="${5:-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}")" + status_text="$(printf "Manage existing site\n\nStack: %s\nDirectory: %s\nSite: %s\nCreated at: %s\nInstalled apps: %s" "${stack_name}" "${stack_dir}" "${site_name}" "${created_at:-n/a}" "${installed_apps}")" render_box_message "${status_text}" "0 2" >&2 gum choose \ - --height 7 \ + --height 8 \ --header "Site details" \ --cursor.foreground 63 \ --selected.foreground 45 \ From 1a839299ab6041b47b812a533060301a31caf99f Mon Sep 17 00:00:00 2001 From: RocketQuack <202538874+Rocket-Quack@users.noreply.github.com> Date: Sat, 28 Mar 2026 13:15:00 +0100 Subject: [PATCH 23/51] feat(easy-docker): add guarded stack and site deletion --- .../lib/app/wizard/common/compose/start.sh | 109 +++++++++++++ .../lib/app/wizard/common/site/bootstrap.sh | 39 +++++ .../lib/app/wizard/common/site/metadata.sh | 8 + .../lib/app/wizard/flows/manage.sh | 149 ++++++++++++++++++ .../lib/ui/screens/production/manage.sh | 60 ++++++- 5 files changed, 364 insertions(+), 1 deletion(-) diff --git a/scripts/easy-docker/lib/app/wizard/common/compose/start.sh b/scripts/easy-docker/lib/app/wizard/common/compose/start.sh index be75672c..c10f81ce 100755 --- a/scripts/easy-docker/lib/app/wizard/common/compose/start.sh +++ b/scripts/easy-docker/lib/app/wizard/common/compose/start.sh @@ -218,6 +218,115 @@ EOF return 0 } +delete_stack_with_compose_from_metadata() { + local stack_dir="${1}" + 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 custom_image="" + local custom_tag="" + local custom_image_ref="" + local -a compose_args=() + + # shellcheck disable=SC2034 # Read by manage flow after delete_stack_with_compose_from_metadata fails. + EASY_DOCKER_COMPOSE_ERROR_DETAIL="" + + 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 48 + fi + + if [ ! -f "${env_path}" ]; then + return 49 + fi + + stack_topology="$(get_stack_topology "${stack_dir}" || true)" + if [ -z "${stack_topology}" ]; then + EASY_DOCKER_COMPOSE_ERROR_DETAIL="metadata.json missing topology" + return 50 + fi + + case "${stack_topology}" in + "single-host") ;; + *) + EASY_DOCKER_COMPOSE_ERROR_DETAIL="${stack_topology}" + return 51 + ;; + 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 + + custom_image="$(get_env_file_key_value "${env_path}" "CUSTOM_IMAGE" || true)" + custom_tag="$(get_env_file_key_value "${env_path}" "CUSTOM_TAG" || true)" + if [ -n "${custom_image}" ] && [ -n "${custom_tag}" ]; then + custom_image_ref="${custom_image}:${custom_tag}" + fi + + compose_files_lines="$(get_metadata_compose_files_lines "${metadata_path}" || true)" + if [ -z "${compose_files_lines}" ]; then + return 52 + 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 + EASY_DOCKER_COMPOSE_ERROR_DETAIL="${source_compose_path}" + return 53 + fi + + compose_args+=(-f "${source_compose_path}") + done </dev/null 2>&1; then + if ! docker image rm "${custom_image_ref}"; then + EASY_DOCKER_COMPOSE_ERROR_DETAIL="${custom_image_ref}" + return 55 + fi + fi + fi + + if ! rollback_stack_directory "${stack_dir}"; then + # shellcheck disable=SC2034 # Read by manage flow after delete_stack_with_compose_from_metadata returns 56. + EASY_DOCKER_COMPOSE_ERROR_DETAIL="${stack_dir}" + return 56 + fi + + return 0 +} + get_stack_compose_runtime_status_label() { local result_var="${1}" local stack_dir="${2}" diff --git a/scripts/easy-docker/lib/app/wizard/common/site/bootstrap.sh b/scripts/easy-docker/lib/app/wizard/common/site/bootstrap.sh index 3b5fd2ae..7ce8c33d 100755 --- a/scripts/easy-docker/lib/app/wizard/common/site/bootstrap.sh +++ b/scripts/easy-docker/lib/app/wizard/common/site/bootstrap.sh @@ -741,6 +741,45 @@ cleanup_partial_stack_site() { return 0 } +delete_configured_stack_site() { + local stack_dir="${1}" + local site_name="" + local delete_status=0 + + if ! stack_supports_single_site_management "${stack_dir}"; then + return 52 + fi + + site_name="$(get_stack_site_name "${stack_dir}" || true)" + if ! is_safe_stack_site_cleanup_name "${site_name}"; then + return 61 + fi + + if ! stack_backend_service_is_running "${stack_dir}"; then + return 51 + fi + + if cleanup_partial_stack_site "${stack_dir}" "${site_name}"; then + : + else + delete_status=$? + case "${delete_status}" in + 54 | 52 | 61) + return "${delete_status}" + ;; + *) + return 60 + ;; + esac + fi + + if ! clear_stack_site_metadata "${stack_dir}"; then + return 58 + fi + + return 0 +} + create_first_stack_site() { local stack_dir="${1}" local site_name="${2}" diff --git a/scripts/easy-docker/lib/app/wizard/common/site/metadata.sh b/scripts/easy-docker/lib/app/wizard/common/site/metadata.sh index 49a7a187..7da9f2e9 100755 --- a/scripts/easy-docker/lib/app/wizard/common/site/metadata.sh +++ b/scripts/easy-docker/lib/app/wizard/common/site/metadata.sh @@ -352,3 +352,11 @@ mark_stack_site_failed() { updated_at="$(get_current_utc_timestamp)" persist_stack_site_metadata "${stack_dir}" "single-site" "${site_name}" "failed" "${apps_installed_lines}" "${last_action}" "${last_error}" "${error_log_path}" "${created_at}" "${updated_at}" } + +clear_stack_site_metadata() { + local stack_dir="${1}" + local updated_at="" + + updated_at="$(get_current_utc_timestamp)" + persist_stack_site_metadata "${stack_dir}" "single-site" "" "not_created" "" "delete-site" "" "" "" "${updated_at}" +} diff --git a/scripts/easy-docker/lib/app/wizard/flows/manage.sh b/scripts/easy-docker/lib/app/wizard/flows/manage.sh index 7d4c3063..639f5e06 100755 --- a/scripts/easy-docker/lib/app/wizard/flows/manage.sh +++ b/scripts/easy-docker/lib/app/wizard/flows/manage.sh @@ -128,6 +128,35 @@ prompt_manage_stack_site_admin_password_with_cancel() { done } +prompt_manage_stack_delete_keyword_with_cancel() { + local result_var="${1}" + local stack_name="${2}" + local delete_confirmation="" + local prompt_status=0 + + while true; do + delete_confirmation="$(prompt_manage_stack_delete_keyword "${stack_name}")" + prompt_status=$? + if [ "${prompt_status}" -ne 0 ]; then + return "${FLOW_ABORT_INPUT}" + fi + + delete_confirmation="$(printf '%s' "${delete_confirmation}" | tr -d '\r\n')" + case "${delete_confirmation}" in + /back | /Back | /BACK | "") + return "${FLOW_ABORT_INPUT}" + ;; + delete) + printf -v "${result_var}" "%s" "${delete_confirmation}" + return "${FLOW_CONTINUE}" + ;; + *) + show_warning_and_wait "Type exactly delete to confirm stack removal." 3 + ;; + esac + done +} + handle_manage_stack_site_flow() { local stack_name="${1}" local stack_dir="${2}" @@ -141,6 +170,7 @@ handle_manage_stack_site_flow() { local existing_site_apps_lines="" local existing_site_apps_csv="" local existing_site_details_action="" + local site_delete_confirmation="" while true; do existing_site_entry="" @@ -239,6 +269,59 @@ handle_manage_stack_site_flow() { "${existing_site_apps_csv}" || true )" case "${existing_site_details_action}" in + "Delete site") + site_delete_confirmation="$( + show_manage_stack_site_delete_confirmation \ + "${stack_name}" \ + "${stack_dir}" \ + "${existing_site_name}" || true + )" + case "${site_delete_confirmation}" in + "Yes") + show_warning_message "Deleting site for stack: ${stack_name}" + if delete_configured_stack_site "${stack_dir}"; then + show_warning_and_wait "Site deleted successfully with its database: ${existing_site_name}" 3 + continue + fi + + site_flow_status=$? + case "${site_flow_status}" in + 51) + show_warning_and_wait "Cannot delete site: backend service is not running yet. Start the stack first." 4 + ;; + 52) + show_warning_and_wait "Cannot delete site for this topology yet. Only single-host stacks are supported." 4 + ;; + 54) + show_warning_and_wait "Cannot delete site because stack metadata, env, or compose inputs are incomplete." 4 + ;; + 58) + show_warning_and_wait "The cleared site state could not be written to metadata.json." 4 + ;; + 60) + show_warning_and_wait "Site deletion could not remove all site or database data automatically. Manual cleanup is required." 5 + ;; + 61) + show_warning_and_wait "Cannot delete site because the configured site name was empty or unsafe for cleanup operations." 4 + ;; + *) + show_warning_and_wait "Site deletion failed (${site_flow_status})." 4 + ;; + esac + continue + ;; + "No" | "") + continue + ;; + "Exit and close easy-docker") + return "${FLOW_EXIT_APP}" + ;; + *) + show_warning_and_wait "Unknown site delete confirmation action: ${site_delete_confirmation}" 2 + continue + ;; + esac + ;; "Back" | "") continue ;; @@ -273,6 +356,8 @@ handle_manage_selected_stack_flow() { local generated_compose_path="" local stack_runtime_status="" local missing_custom_image_action="" + local delete_stack_confirmation_action="" + local delete_stack_keyword="" stack_dir="$(get_stack_dir_by_name "${stack_name}" || true)" if [ -z "${stack_dir}" ]; then @@ -449,6 +534,70 @@ handle_manage_selected_stack_flow() { ;; esac ;; + "Delete stack") + delete_stack_confirmation_action="$( + show_manage_stack_delete_confirmation "${stack_name}" "${stack_dir}" || true + )" + case "${delete_stack_confirmation_action}" in + "Yes") + if ! prompt_manage_stack_delete_keyword_with_cancel delete_stack_keyword "${stack_name}"; then + continue + fi + if [ "${delete_stack_keyword}" != "delete" ]; then + continue + fi + + show_warning_message "Deleting stack with docker compose resources: ${stack_name}" + if delete_stack_with_compose_from_metadata "${stack_dir}"; then + show_warning_and_wait "Stack deleted successfully with containers, networks, volumes, image, and stack directory: ${stack_name}" 5 + return "${FLOW_CONTINUE}" + fi + + compose_start_status=$? + case "${compose_start_status}" in + 48) + show_warning_and_wait "Cannot delete stack: metadata.json is missing in ${stack_dir}." 4 + ;; + 49) + show_warning_and_wait "Cannot delete stack: stack env file not found in ${stack_dir}." 4 + ;; + 50) + show_warning_and_wait "Cannot delete stack: topology is missing in metadata.json. Re-run the topology wizard for this stack." 4 + ;; + 51) + show_warning_and_wait "Cannot delete stack via docker compose for topology '${EASY_DOCKER_COMPOSE_ERROR_DETAIL}'. Use the topology-specific runbook path." 5 + ;; + 52) + show_warning_and_wait "Cannot delete stack: no compose files configured in metadata.json." 4 + ;; + 53) + show_warning_and_wait "Cannot delete stack: compose file is missing -> ${EASY_DOCKER_COMPOSE_ERROR_DETAIL}" 4 + ;; + 54) + show_warning_and_wait "docker compose down failed. Check the output above for details." 4 + ;; + 55) + show_warning_and_wait "Stack resources were removed, but the configured custom image could not be deleted -> ${EASY_DOCKER_COMPOSE_ERROR_DETAIL}" 5 + ;; + 56) + show_warning_and_wait "Docker resources were removed, but the stack directory could not be deleted -> ${EASY_DOCKER_COMPOSE_ERROR_DETAIL}" 5 + ;; + *) + show_warning_and_wait "Cannot delete stack with docker compose (${compose_start_status})." 4 + ;; + esac + ;; + "No" | "") + continue + ;; + "Exit and close easy-docker") + return "${FLOW_EXIT_APP}" + ;; + *) + show_warning_and_wait "Unknown delete-stack action: ${delete_stack_confirmation_action}" 2 + ;; + esac + ;; "Docker") while true; do docker_action="$(show_manage_stack_docker_menu "${stack_name}" "${stack_dir}" || true)" diff --git a/scripts/easy-docker/lib/ui/screens/production/manage.sh b/scripts/easy-docker/lib/ui/screens/production/manage.sh index 5c2a3009..b501f5e9 100755 --- a/scripts/easy-docker/lib/ui/screens/production/manage.sh +++ b/scripts/easy-docker/lib/ui/screens/production/manage.sh @@ -65,7 +65,7 @@ show_manage_stack_actions_menu() { menu_header="$(printf "Stack actions | %s" "${stack_runtime_status}")" gum choose \ - --height 10 \ + --height 11 \ --header "${menu_header}" \ --cursor.foreground 63 \ --selected.foreground 45 \ @@ -74,6 +74,7 @@ show_manage_stack_actions_menu() { "Site" \ "Start stack in Docker Compose" \ "Stop stack in Docker Compose" \ + "Delete stack" \ "Back" \ "Exit and close easy-docker" } @@ -205,10 +206,67 @@ show_manage_stack_site_details() { --header "Site details" \ --cursor.foreground 63 \ --selected.foreground 45 \ + "Delete site" \ "Back" \ "Exit and close easy-docker" } +show_manage_stack_site_delete_confirmation() { + local stack_name="${1}" + local stack_dir="${2}" + local site_name="${3}" + local status_text="" + + render_main_screen 1 >&2 + + status_text="$(printf "Delete site\n\nStack: %s\nDirectory: %s\nSite: %s\n\nAll site data and the site database will be permanently deleted." "${stack_name}" "${stack_dir}" "${site_name}")" + render_box_message "${status_text}" "0 2" >&2 + + gum choose \ + --height 8 \ + --header "Confirm delete site" \ + --cursor.foreground 63 \ + --selected.foreground 45 \ + "Yes" \ + "No" \ + "Exit and close easy-docker" +} + +show_manage_stack_delete_confirmation() { + local stack_name="${1}" + local stack_dir="${2}" + local status_text="" + + render_main_screen 1 >&2 + + status_text="$(printf "Delete stack\n\nStack: %s\nDirectory: %s\n\nThis will permanently remove the stack directory, Docker containers, networks, volumes, and configured custom image." "${stack_name}" "${stack_dir}")" + render_box_message "${status_text}" "0 2" >&2 + + gum choose \ + --height 8 \ + --header "Confirm delete stack" \ + --cursor.foreground 63 \ + --selected.foreground 45 \ + "Yes" \ + "No" \ + "Exit and close easy-docker" +} + +prompt_manage_stack_delete_keyword() { + local stack_name="${1}" + local status_text="" + + render_main_screen 1 >&2 + + status_text="$(printf "Delete stack\n\nStack: %s\n\nFinal confirmation required.\nType delete to permanently remove the stack and all its data.\nType /back or press Ctrl+C to cancel." "${stack_name}")" + render_box_message "${status_text}" "0 2" >&2 + + gum input \ + --header "Type delete to confirm" \ + --prompt "confirm> " \ + --placeholder "delete" +} + show_missing_custom_image_start_menu() { local stack_name="${1}" local stack_dir="${2}" From da905fb1c46bc1640d6be914bb2adde70a5706a2 Mon Sep 17 00:00:00 2001 From: RocketQuack <202538874+Rocket-Quack@users.noreply.github.com> Date: Sat, 28 Mar 2026 17:19:08 +0100 Subject: [PATCH 24/51] refactor(easy-docker): split wizard shell modules --- development/vscode-example/settings.json | 6 +- .../docs/development-team-process.md | 128 ++ .../docs/single-stack-readiness.md | 174 +++ scripts/easy-docker/docs/wizard-flow-clean.md | 76 ++ scripts/easy-docker/docs/wizard-flow.md | 136 +++ .../easy-docker/lib/app/wizard/common/apps.sh | 939 +-------------- .../lib/app/wizard/common/apps/catalog.sh | 490 ++++++++ .../lib/app/wizard/common/apps/metadata.sh | 378 ++++++ .../lib/app/wizard/common/apps/persistence.sh | 191 +++ .../common/compose/runtime/lifecycle.sh | 193 +++ .../wizard/common/compose/runtime/shared.sh | 100 ++ .../wizard/common/compose/runtime/status.sh | 233 ++++ .../lib/app/wizard/common/compose/start.sh | 573 +-------- .../app/wizard/common/compose/start/delete.sh | 110 ++ .../app/wizard/common/compose/start/start.sh | 131 +++ .../app/wizard/common/compose/start/status.sh | 239 ++++ .../app/wizard/common/compose/start/stop.sh | 89 ++ .../lib/app/wizard/common/site/bootstrap.sh | 1030 +---------------- .../wizard/common/site/bootstrap/errors.sh | 89 ++ .../wizard/common/site/bootstrap/lifecycle.sh | 409 +++++++ .../wizard/common/site/bootstrap/runtime.sh | 157 +++ .../app/wizard/common/site/bootstrap/state.sh | 253 ++++ .../common/site/bootstrap/validation.sh | 118 ++ .../lib/app/wizard/common/site/metadata.sh | 365 +----- .../app/wizard/common/site/metadata/read.sh | 184 +++ .../app/wizard/common/site/metadata/write.sh | 179 +++ .../lib/app/wizard/flows/manage.sh | 669 +---------- .../lib/app/wizard/flows/manage/build.sh | 64 + .../lib/app/wizard/flows/manage/docker.sh | 64 + .../lib/app/wizard/flows/manage/prompts.sh | 95 ++ .../lib/app/wizard/flows/manage/site.sh | 185 +++ .../lib/app/wizard/flows/manage/stack.sh | 321 +++++ 32 files changed, 4847 insertions(+), 3521 deletions(-) create mode 100644 scripts/easy-docker/docs/development-team-process.md create mode 100644 scripts/easy-docker/docs/single-stack-readiness.md create mode 100644 scripts/easy-docker/docs/wizard-flow-clean.md create mode 100644 scripts/easy-docker/docs/wizard-flow.md create mode 100755 scripts/easy-docker/lib/app/wizard/common/apps/catalog.sh create mode 100755 scripts/easy-docker/lib/app/wizard/common/apps/metadata.sh create mode 100755 scripts/easy-docker/lib/app/wizard/common/apps/persistence.sh create mode 100755 scripts/easy-docker/lib/app/wizard/common/compose/runtime/lifecycle.sh create mode 100755 scripts/easy-docker/lib/app/wizard/common/compose/runtime/shared.sh create mode 100755 scripts/easy-docker/lib/app/wizard/common/compose/runtime/status.sh create mode 100755 scripts/easy-docker/lib/app/wizard/common/compose/start/delete.sh create mode 100755 scripts/easy-docker/lib/app/wizard/common/compose/start/start.sh create mode 100755 scripts/easy-docker/lib/app/wizard/common/compose/start/status.sh create mode 100755 scripts/easy-docker/lib/app/wizard/common/compose/start/stop.sh create mode 100755 scripts/easy-docker/lib/app/wizard/common/site/bootstrap/errors.sh create mode 100755 scripts/easy-docker/lib/app/wizard/common/site/bootstrap/lifecycle.sh create mode 100755 scripts/easy-docker/lib/app/wizard/common/site/bootstrap/runtime.sh create mode 100755 scripts/easy-docker/lib/app/wizard/common/site/bootstrap/state.sh create mode 100755 scripts/easy-docker/lib/app/wizard/common/site/bootstrap/validation.sh create mode 100755 scripts/easy-docker/lib/app/wizard/common/site/metadata/read.sh create mode 100755 scripts/easy-docker/lib/app/wizard/common/site/metadata/write.sh create mode 100755 scripts/easy-docker/lib/app/wizard/flows/manage/build.sh create mode 100755 scripts/easy-docker/lib/app/wizard/flows/manage/docker.sh create mode 100755 scripts/easy-docker/lib/app/wizard/flows/manage/prompts.sh create mode 100755 scripts/easy-docker/lib/app/wizard/flows/manage/site.sh create mode 100755 scripts/easy-docker/lib/app/wizard/flows/manage/stack.sh diff --git a/development/vscode-example/settings.json b/development/vscode-example/settings.json index 1490b727..a60982e0 100644 --- a/development/vscode-example/settings.json +++ b/development/vscode-example/settings.json @@ -1,3 +1,7 @@ { - "python.defaultInterpreterPath": "${workspaceFolder}/frappe-bench/env/bin/python" + "python.defaultInterpreterPath": "${workspaceFolder}/frappe-bench/env/bin/python", + "editor.detectIndentation": false, + "files.eol": "\n", + "files.insertFinalNewline": true, + "files.trimTrailingWhitespace": true } diff --git a/scripts/easy-docker/docs/development-team-process.md b/scripts/easy-docker/docs/development-team-process.md new file mode 100644 index 00000000..af32c4bd --- /dev/null +++ b/scripts/easy-docker/docs/development-team-process.md @@ -0,0 +1,128 @@ +# Easy-Docker Development Team Process + +## Purpose + +This document defines the working model for the easy-docker team. +Focus is process, responsibilities, and execution flow for ongoing refactoring and feature work. + +## Team Setup + +- Lead Developer + - Owns scope, priorities, and release decisions. + - Resolves conflicts between technical proposals. + - Approves final merge readiness. +- Senior Developer A (Correctness) + - Reviews control flow, edge cases, and failure behavior. + - Validates data handling, state transitions, and rollback paths. + - Checks defensive programming and explicit error handling. +- Senior Developer B (Architecture) + - Reviews modularity, coupling, and naming consistency. + - Drives DRY/KISS refactors and shared helper extraction. + - Validates maintainability and testability. +- Implementation Developer + - Delivers code changes according to approved scope. + - Keeps behavior stable unless change is explicitly requested. + - Adds/update docs for structure and flow changes. +- QA/Verification Owner + - Runs pre-commit and targeted checks. + - Executes reproducible manual test matrix for wizard paths. + - Reports pass/fail with concrete reproduction steps. + +## Working Agreement + +- No hidden behavior changes during refactors. +- Source-of-truth decisions must be explicit and documented. +- New code must prefer existing helpers over duplicated logic. +- Every change batch must be reviewable by concern (flow, env, compose, ui). + +## Daily Process (Tomorrow Plan) + +1. Kickoff (15 min) + - Confirm target scope for the day. + - Confirm "no functional change" boundaries. + - Assign owners for implementation and verification. +2. Design sync (20 min) + - Compare at least two technical options for non-trivial edits. + - Select one approach with short tradeoff note. +3. Implementation blocks + - Work in small vertical batches (one concern per batch). + - Keep public function contracts stable where possible. + - Update docs in the same batch when structure changes. +4. Review blocks + - Senior A reviews correctness and failure paths. + - Senior B reviews architecture and maintainability. + - Lead resolves conflicts and accepts/rejects batch. +5. Verification block + - Run pre-commit for changed files. + - Run targeted manual flow checks. + - Record results in short checklist format. +6. Handover + - Write what is done, what is pending, and next first task. + - List any blockers with owner and proposed resolution. + +## Implementation Workflow + +1. Define scope and constraints. +2. Map affected files/functions. +3. Propose options and select approach. +4. Implement with small commits by concern. +5. Validate with checks and manual path coverage. +6. Document final state and next steps. + +## Review Workflow + +1. Findings-first review format. +2. Severity order: BLOCKER, HIGH, MEDIUM, LOW. +3. Each point must include file reference and reason. +4. Lead decision: + - Approved + - Approved with conditions + - Not approved + +## Test and Verification Matrix (Minimum) + +- Create new production stack and complete wizard. +- Create new development stack and complete wizard. +- Manage existing stack: + - Apps -> Generate apps.json + - Apps -> Select apps and branches + - Docker -> Generate docker compose from env + - Docker -> Start stack in Docker Compose (single-host topology) +- Abort/Back paths: + - Back navigation in each submenu + - Abort wizard with rollback +- Validation paths: + - Domain validation error then correction + - Branch selection from apps catalog (including back-navigation) + +## Definition of Done (Team) + +- Scope completed with no unplanned behavior change. +- No avoidable duplication introduced. +- Review completed by both senior roles. +- Lead verdict documented. +- Verification evidence recorded. +- Handover notes prepared for next workday. + +## Handover Template + +Use this at day end: + +```text +Date: +Completed: +- ... + +In Progress: +- ... + +Next First Task: +- ... + +Blockers: +- - - + +Verification: +- pre-commit: +- manual matrix: +``` diff --git a/scripts/easy-docker/docs/single-stack-readiness.md b/scripts/easy-docker/docs/single-stack-readiness.md new file mode 100644 index 00000000..6b5c1fce --- /dev/null +++ b/scripts/easy-docker/docs/single-stack-readiness.md @@ -0,0 +1,174 @@ +# Easy-Docker Single-Stack Readiness + +## Purpose + +This document freezes the required single-stack scope for `easy-docker` +before work moves to `separate services`. + +Current interpretation: + +- `single-stack` means the implemented `single-host` topology. +- The stack must be isolated from other stacks at the Docker Compose project level. +- The supported happy path is one usable site per stack unless a later + product decision explicitly broadens this. +- The current site bootstrap path always installs the full app selection + stored on the stack itself. +- It is not yet supported to create multiple sites in one stack with + different app selections per site. + +## Current Supported Scope + +The current codebase already supports these single-stack paths: + +- Create production stack +- Create development stack +- Choose `single-host` topology +- Select proxy, database, and redis mode +- Select apps and branches +- Persist `metadata.json`, stack `.env`, `apps.json` +- Render `compose.generated.yaml` +- Manage existing stacks +- Regenerate `apps.json` +- Re-select apps and branches +- Build custom image +- Start stack with Docker Compose +- Stop stack with Docker Compose +- Show stack runtime status +- Abort wizard with rollback or keep-files behavior +- Isolate stacks through stack-specific Compose project names + +## Definition Of Done Before Separate Services + +Single-stack is not considered complete when containers merely run. +It is considered complete when the user can move from stack creation +to a usable Frappe/ERPNext site and operate that stack safely. + +Minimum user-facing path: + +1. Create stack +2. Configure single-host topology +3. Build image if needed +4. Start stack +5. Create/bootstrap first site +6. Install selected apps on that site +7. Verify site access behind the chosen proxy mode +8. Stop/restart/down the stack +9. Re-open manage flow and inspect status/logs + +## Required Remaining Changes + +### High Priority + +- Add a documented or automated site/bootstrap path + - create first site + - install selected apps + - verify site routing/access +- Freeze the supported site model + - recommended: one site per stack as the supported happy path + +### Medium Priority + +- Add remaining lifecycle operations + - `restart` + - `down/remove` + - `logs` +- Add post-start recovery guidance + - partial start + - failed bootstrap + - retry after custom image rebuild +- Add one-time cleanup/runbook note for stacks created before + per-stack Compose project isolation + +### Hardening Priority + +- Keep runtime status semantics explicit + - `Not created` + - `Created` + - `Running` + - `Partial` + - `Stopped` + - `Restarting` + - optional uptime hint +- Ensure manage actions only affect the selected stack +- Preserve safe abort/rollback behavior + +## Required Single-Stack Paths + +### Setup Paths + +- Environment check +- Create production stack +- Create development stack +- Complete single-host wizard +- Back/cancel at each prompt +- Abort wizard with rollback +- Abort wizard while keeping files + +### Runtime Paths + +- Generate compose from env +- Build custom image +- Start stack +- Stop stack +- Restart stack +- Down/remove stack resources +- Inspect runtime status +- Inspect logs + +### Site Paths + +- Create first site +- Install selected apps on the site +- Current limitation: the site install set is the stack app set + - one stack -> one supported site -> one shared app selection +- Verify the site is reachable +- Re-open and manage the stack after restart + +### Recovery Paths + +- Missing custom image -> build -> retry start +- Invalid app branch -> mapped build failure +- Partial start -> inspect status/logs -> retry +- Failed bootstrap -> rerun or recover cleanly +- Cleanup of pre-isolation shared Compose leftovers + +## Verification Matrix + +Before calling single-stack ready, the team should execute at least: + +1. Environment/bootstrap gate +2. New production single-stack creation +3. New development single-stack creation +4. Apps regeneration/update path +5. Compose render path +6. Custom image build success and failure paths +7. Start path including missing-image build/retry +8. Stop path +9. Runtime isolation between two stacks +10. Runtime status in not-created/created/running/partial/stopped states +11. Abort/back/rollback paths +12. Validation error and correction paths +13. Site/bootstrap reality check after stack start + +Required automated checks on every single-stack change: + +- `bash -n` on touched shell files +- `pre-commit run --files ` +- compose render/config validation for at least one production + and one development stack + +## Lead Verdict + +`single-stack` is close on the Compose/runtime side but is not yet fully done. + +The largest remaining gap before `separate services` is the missing +site/bootstrap lifecycle. After that, the next most important gaps are +`restart`, `down/remove`, `logs`, and reproducible manual verification. + +Recommended order: + +1. Freeze single-stack site model +2. Add site/bootstrap path +3. Add `restart`, `down/remove`, and `logs` +4. Run the verification matrix +5. Move to `separate services` diff --git a/scripts/easy-docker/docs/wizard-flow-clean.md b/scripts/easy-docker/docs/wizard-flow-clean.md new file mode 100644 index 00000000..1060fd1c --- /dev/null +++ b/scripts/easy-docker/docs/wizard-flow-clean.md @@ -0,0 +1,76 @@ +# Easy Docker Wizard Flow (Clean View) + +This document shows the wizard paths in a clean, forward-only view. +Back/Cancel/Exit loops are intentionally hidden to keep the flow readable. + +## 1) Main Wizard Paths + +```mermaid +flowchart TD + A[Main Menu] + A --> B[Production Setup] + A --> C[Development Setup] + A --> D[Environment Check] + A --> Z[Exit] + + B --> E[Create new stack] + B --> F[Manage existing stacks] + C --> E2[Create new stack] + C --> F2[Manage existing stacks] + + E --> G[Create stack dir + metadata.json] + E2 --> G + G --> H[Topology selection] + + H --> I[Single-host flow] + H --> J[Split services flow] + + I --> K[Persist files + render compose] + K --> L[Done] + + J --> J2[Current status: placeholder only] + J2 --> L2[Pending implementation] + + F --> M[Select existing stack] + F2 --> M + M --> N[Manage stack actions] + N --> N1[Apps actions] + N --> N2[Docker actions] + N1 --> O[apps.json generated/updated] + N2 --> P[compose.generated.yaml rendered] + N2 --> Q[Start stack in Docker Compose] + Q --> Q1{Topology} + Q1 -->|single-host| Q2[docker compose up -d] + Q1 -->|split-services / others| Q3[Show runbook warning] +``` + +## 2) Single-host Detail Path + +```mermaid +flowchart TD + S1[Single-host selected] + S1 --> S2[Choose proxy mode] + S2 --> S3[Choose database mode] + S3 --> S4[Choose redis mode] + S4 --> S5[Set CUSTOM_IMAGE + CUSTOM_TAG] + S5 --> S6[Select apps: apps catalog] + S6 --> S7[For each selected app: fetch branches + choose branch] + S7 --> S8[Proxy-specific questions] + S8 --> S9[Database-specific questions] + S9 --> S10[Write .env] + S10 --> S11[Write metadata.json] + S11 --> S12[Generate apps.json] + S12 --> S13[Render compose.generated.yaml] + S13 --> S14[Success message] +``` + +## 3) Notes + +- This is a readability-focused flow map, not an exhaustive state machine. +- Navigation loops (Back/Cancel/Exit) are intentionally omitted. +- `Split services` remains not fully implemented in the wizard runtime. +- `Start stack in Docker Compose` currently supports only `single-host` topology. +- Site bootstrap is currently scoped to one supported site per stack. +- The site bootstrap installs the full app selection stored on the stack. +- Multiple sites in one stack with different per-site app selections are + not supported yet and are planned for a later phase. diff --git a/scripts/easy-docker/docs/wizard-flow.md b/scripts/easy-docker/docs/wizard-flow.md new file mode 100644 index 00000000..12f3bd52 --- /dev/null +++ b/scripts/easy-docker/docs/wizard-flow.md @@ -0,0 +1,136 @@ +# Easy Docker Wizard Flow + +```mermaid +flowchart TD + A[Main Menu] -->|Production Stack| B[Setup Menu: Production] + A -->|Development Stack| C[Setup Menu: Development] + A -->|Environment check| D[Environment Status] + A -->|Exit| Z1[Exit App] + D -->|Back to main menu| A + D -->|Exit and close easy-docker| Z1 + + B -->|Create new stack| E[Prompt: Stack name] + B -->|Manage existing stacks| F[List existing production stacks] + B -->|Back| A + B -->|Exit| Z1 + + C -->|Create new stack| E2[Prompt: Stack name] + C -->|Manage existing stacks| F2[List existing development stacks] + C -->|Back| A + C -->|Exit| Z1 + + E --> E3[Select Frappe branch profile from frappe.tsv] + E2 --> E4[Select Frappe branch profile from frappe.tsv] + E3 --> G[Create stack directory + metadata.json] + E4 --> G2[Create stack directory + metadata.json] + G --> H[Topology Menu] + G2 --> H2[Topology Menu] + + H -->|Single-host| I[Single-host selection] + H -->|Split services| J[Split services example] + H -->|Abort wizard to main menu| K[Abort prompt] + H -->|Back/Cancel| B + H2 -->|Single-host| I + H2 -->|Split services| J + H2 -->|Abort wizard to main menu| K + H2 -->|Back/Cancel| C + + J -->|Use this topology| J2[Info: placeholder path] + J -->|Back| H + J2 --> H + + K -->|Rollback files and return to main menu| A + K -->|Keep files and return to main menu| A + K -->|Back to topology selection| H + + I --> I1[Proxy mode] + I1 --> I2[Database mode] + I2 --> I3[Redis mode] + I3 --> I6[Prompt CUSTOM_IMAGE + CUSTOM_TAG] + I6 --> I7[App selection list] + I7 -->|Enter| I8[Per selected app: choose branch from apps.tsv] + I8 --> I9[Continue] + + I9 --> P{Proxy specific questions} + P -->|traefik-https| P1[SITE_DOMAINS + LETSENCRYPT_EMAIL + HTTP_PUBLISH_PORT? + HTTPS_PUBLISH_PORT?] + P -->|nginxproxy-https| P2[SITE_DOMAINS + NGINX_PROXY_HOSTS + LETSENCRYPT_EMAIL + HTTP_PUBLISH_PORT? + HTTPS_PUBLISH_PORT?] + P -->|nginxproxy-http| P3[SITE_DOMAINS + NGINX_PROXY_HOSTS + HTTP_PUBLISH_PORT?] + P -->|traefik-http| P4[HTTP_PUBLISH_PORT?] + P -->|caddy-external / no-proxy| P5[HTTP_PUBLISH_PORT? default 8080] + + P1 --> DBQ + P2 --> DBQ + P3 --> DBQ + P4 --> DBQ + P5 --> DBQ + + DBQ{Database specific question} + DBQ -->|postgres| DB1[DB_PASSWORD required] + DBQ -->|mariadb| DB2[DB_PASSWORD optional] + + DB1 --> S[Write stack env file] + DB2 --> S + S --> T[Write metadata.json with top-level apps] + T --> U[Generate apps.json from metadata.json apps] + U --> V[Render compose.generated.yaml from metadata + env] + V --> W[Success message] + W --> B + + F -->|Stack selected| M[Manage selected stack] + F -->|Back| B + F -->|Exit| Z1 + F -->|No stacks found| F0[Manage stacks placeholder] + F0 -->|Back| B + F0 -->|Exit| Z1 + + F2 -->|Stack selected| M + F2 -->|Back| C + F2 -->|Exit| Z1 + F2 -->|No stacks found| F20[Manage stacks placeholder] + F20 -->|Back| C + F20 -->|Exit| Z1 + + M --> M2[Stack actions: Apps / Docker / Back / Exit] + M2 -->|Apps| M3[Apps submenu] + M2 -->|Docker| M4[Docker submenu] + M2 -->|Back| M0[Return to current stack list] + M2 -->|Exit| Z1 + M0 --> F + M0 --> F2 + + M3 -->|Generate apps.json| M31[Read metadata.json apps + regenerate apps.json] + M3 -->|Select apps and branches| M32[Re-prompt app and branch selection] + M32 --> M33[Update metadata.json apps] + M33 --> M34[Regenerate apps.json from metadata] + M34 --> M3 + M3 -->|Back| M2 + M3 -->|Exit| Z1 + M31 --> M3 + + M4 -->|Generate docker compose from env| M41[Render compose.generated.yaml] + M4 -->|Start stack in Docker Compose| M42[Topology gate] + M42 -->|single-host| M43[docker compose up -d] + M42 -->|split-services / others| M44[Show topology-specific runbook message] + M4 -->|Back| M2 + M4 -->|Exit| Z1 + M41 --> M4 + M43 --> M4 + M44 --> M4 +``` + +## Notes + +- `SITE_DOMAINS` validation accepts only domain names in form `sub.domain.tld` or `sub.sub.domain.tld`. +- Existing stack lists are filtered by `setup_type` (`production` vs `development`). +- In `Manage existing stacks`, navigation options are only `Back` and `Exit`. +- `Select apps and branches` writes app selection to top-level `apps` in `metadata.json`. +- `Generate apps.json` uses only `metadata.json -> apps` as source of truth. +- New stack wizard always uses custom image path (no separate official-vs-custom image step). +- `Start stack in Docker Compose` is currently allowed only for `single-host` topology stacks. + +## Module Layout + +- `lib/app/wizard/common.sh` is now a loader for common modules under `lib/app/wizard/common/`. +- `lib/app/wizard/env.sh` is now a loader for env modules under `lib/app/wizard/env/`. +- `lib/app/wizard/flows.sh` is now a loader for flow modules under `lib/app/wizard/flows/`. +- Public function names and flow behavior remain unchanged; only code organization was refactored. diff --git a/scripts/easy-docker/lib/app/wizard/common/apps.sh b/scripts/easy-docker/lib/app/wizard/common/apps.sh index faab91cf..cca37f19 100755 --- a/scripts/easy-docker/lib/app/wizard/common/apps.sh +++ b/scripts/easy-docker/lib/app/wizard/common/apps.sh @@ -1,934 +1,15 @@ #!/usr/bin/env bash -trim_predefined_catalog_field() { - local result_var="${1}" - local value="${2}" +load_easy_docker_wizard_app_modules() { + local apps_dir="" + apps_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/apps" - value="${value#"${value%%[![:space:]]*}"}" - value="${value%"${value##*[![:space:]]}"}" - - printf -v "${result_var}" "%s" "${value}" + # shellcheck source=scripts/easy-docker/lib/app/wizard/common/apps/catalog.sh + source "${apps_dir}/catalog.sh" + # shellcheck source=scripts/easy-docker/lib/app/wizard/common/apps/metadata.sh + source "${apps_dir}/metadata.sh" + # shellcheck source=scripts/easy-docker/lib/app/wizard/common/apps/persistence.sh + source "${apps_dir}/persistence.sh" } -is_valid_predefined_app_id() { - local value="${1}" - - if [ -z "${value}" ]; then - return 1 - fi - - case "${value}" in - *[!a-z0-9._-]*) - return 1 - ;; - *) - return 0 - ;; - esac -} - -generate_predefined_app_id_from_label() { - local result_var="${1}" - local app_label="${2}" - local generated_id="" - - generated_id="$( - printf '%s' "${app_label}" | - tr '[:upper:]' '[:lower:]' | - sed -E 's/[[:space:]]+/_/g; s/[^a-z0-9._-]+/_/g; s/_+/_/g; s/^_+//; s/_+$//' - )" - - if ! is_valid_predefined_app_id "${generated_id}"; then - return 1 - fi - - printf -v "${result_var}" "%s" "${generated_id}" - return 0 -} - -is_valid_predefined_app_repo() { - local value="${1}" - - if [ -z "${value}" ]; then - return 1 - fi - - case "${value}" in - https://* | http://* | ssh://* | git://* | git@*:* | file://*) - return 0 - ;; - *) - return 1 - ;; - esac -} - -is_valid_predefined_app_branch() { - local value="${1}" - - if [ -z "${value}" ]; then - return 1 - fi - - case "${value}" in - *[!A-Za-z0-9._/-]* | .* | *..* | */ | /*) - return 1 - ;; - *) - return 0 - ;; - esac -} - -csv_contains_branch() { - local csv_values="${1}" - local value="${2}" - - case ",${csv_values}," in - *,"${value}",*) - return 0 - ;; - *) - return 1 - ;; - esac -} - -normalize_predefined_branches_csv() { - local result_csv_var="${1}" - local branches_csv_raw="${2}" - local branch_token="" - local normalized_csv="" - local -a raw_tokens=() - - IFS=',' read -r -a raw_tokens <<<"${branches_csv_raw}" - for branch_token in "${raw_tokens[@]}"; do - trim_predefined_catalog_field branch_token "${branch_token}" - if [ -z "${branch_token}" ]; then - continue - fi - - if ! is_valid_predefined_app_branch "${branch_token}"; then - return 1 - fi - - if csv_contains_branch "${normalized_csv}" "${branch_token}"; then - continue - fi - - if [ -z "${normalized_csv}" ]; then - normalized_csv="${branch_token}" - else - normalized_csv="${normalized_csv},${branch_token}" - fi - done - - if [ -z "${normalized_csv}" ]; then - return 1 - fi - - printf -v "${result_csv_var}" "%s" "${normalized_csv}" - return 0 -} - -get_predefined_apps_catalog_path() { - local repo_root="" - - repo_root="$(get_easy_docker_repo_root)" - printf '%s/scripts/easy-docker/config/apps.tsv\n' "${repo_root}" -} - -get_predefined_apps_catalog_entries() { - local catalog_path="" - local raw_line="" - local line="" - local app_id="" - local app_label="" - local app_repo="" - local app_default_branch="" - local app_branches_csv="" - local normalized_branches_csv="" - local first_branch="" - local extra="" - local seen_ids="," - local seen_labels="," - - catalog_path="$(get_predefined_apps_catalog_path)" - if [ ! -f "${catalog_path}" ]; then - return 1 - fi - - while IFS= read -r raw_line || [ -n "${raw_line}" ]; do - trim_predefined_catalog_field line "${raw_line}" - if [ -z "${line}" ]; then - continue - fi - - case "${line}" in - \#*) - continue - ;; - esac - - if [[ "${line}" == *$'\t'* ]]; then - IFS=$'\t' read -r app_id app_label app_repo app_default_branch app_branches_csv extra <<<"${line}" - else - # Backward compatibility for older catalog rows. - IFS='|' read -r app_id app_label app_repo app_default_branch app_branches_csv extra <<<"${line}" - fi - trim_predefined_catalog_field app_id "${app_id}" - trim_predefined_catalog_field app_label "${app_label}" - trim_predefined_catalog_field app_repo "${app_repo}" - trim_predefined_catalog_field app_default_branch "${app_default_branch}" - trim_predefined_catalog_field app_branches_csv "${app_branches_csv}" - trim_predefined_catalog_field extra "${extra}" - - if [ -n "${extra}" ] || [ -z "${app_id}" ] || [ -z "${app_label}" ] || [ -z "${app_repo}" ] || [ -z "${app_branches_csv}" ]; then - return 1 - fi - - if ! is_valid_predefined_app_id "${app_id}"; then - return 1 - fi - - if ! is_valid_predefined_app_repo "${app_repo}"; then - return 1 - fi - - if ! normalize_predefined_branches_csv normalized_branches_csv "${app_branches_csv}"; then - return 1 - fi - - if [ -z "${app_default_branch}" ]; then - first_branch="${normalized_branches_csv%%,*}" - app_default_branch="${first_branch}" - fi - - if ! is_valid_predefined_app_branch "${app_default_branch}"; then - return 1 - fi - - if ! csv_contains_branch "${normalized_branches_csv}" "${app_default_branch}"; then - return 1 - fi - - case "${seen_ids}" in - *,"${app_id}",*) - return 1 - ;; - esac - case "${seen_labels}" in - *,"${app_label}",*) - return 1 - ;; - esac - - seen_ids="${seen_ids}${app_id}," - seen_labels="${seen_labels}${app_label}," - - printf '%s|%s|%s|%s|%s\n' "${app_id}" "${app_label}" "${app_repo}" "${app_default_branch}" "${normalized_branches_csv}" - done <"${catalog_path}" -} - -parse_predefined_app_catalog_entry() { - local entry="${1}" - local app_id_var="${2}" - local app_label_var="${3}" - local app_repo_var="${4}" - local app_default_branch_var="${5}" - local app_branches_csv_var="${6}" - local parsed_app_id="" - local parsed_app_label="" - local parsed_app_repo="" - local parsed_app_default_branch="" - local parsed_app_branches_csv="" - - IFS='|' read -r parsed_app_id parsed_app_label parsed_app_repo parsed_app_default_branch parsed_app_branches_csv <<<"${entry}" - printf -v "${app_id_var}" "%s" "${parsed_app_id}" - printf -v "${app_label_var}" "%s" "${parsed_app_label}" - printf -v "${app_repo_var}" "%s" "${parsed_app_repo}" - printf -v "${app_default_branch_var}" "%s" "${parsed_app_default_branch}" - printf -v "${app_branches_csv_var}" "%s" "${parsed_app_branches_csv}" -} - -get_predefined_app_field_by_field() { - local lookup_field="${1}" - local lookup_value="${2}" - local result_field="${3}" - local entry="" - local app_id="" - local app_label="" - local app_repo="" - local app_default_branch="" - local app_branches_csv="" - local lookup_candidate="" - local result_value="" - - trim_predefined_catalog_field lookup_value "${lookup_value}" - if [ -z "${lookup_value}" ]; then - return 1 - fi - - while IFS= read -r entry; do - if [ -z "${entry}" ]; then - continue - fi - - parse_predefined_app_catalog_entry "${entry}" app_id app_label app_repo app_default_branch app_branches_csv - - case "${lookup_field}" in - id) - lookup_candidate="${app_id}" - ;; - label) - lookup_candidate="${app_label}" - ;; - *) - return 1 - ;; - esac - - trim_predefined_catalog_field lookup_candidate "${lookup_candidate}" - if [ "${lookup_candidate}" != "${lookup_value}" ]; then - continue - fi - - case "${result_field}" in - id) - result_value="${app_id}" - ;; - label) - result_value="${app_label}" - ;; - repo) - result_value="${app_repo}" - ;; - default_branch) - result_value="${app_default_branch}" - ;; - branches_csv) - result_value="${app_branches_csv}" - ;; - *) - return 1 - ;; - esac - - printf '%s\n' "${result_value}" - return 0 - done < <(get_predefined_apps_catalog_entries) - - return 1 -} - -get_predefined_app_id_by_label() { - local label="${1}" - get_predefined_app_field_by_field "label" "${label}" "id" -} - -get_predefined_app_repo_by_id() { - local app_id_lookup="${1}" - get_predefined_app_field_by_field "id" "${app_id_lookup}" "repo" -} - -get_predefined_app_label_by_id() { - local app_id_lookup="${1}" - get_predefined_app_field_by_field "id" "${app_id_lookup}" "label" -} - -get_predefined_app_default_branch_by_id() { - local app_id_lookup="${1}" - get_predefined_app_field_by_field "id" "${app_id_lookup}" "default_branch" -} - -get_predefined_app_branch_lines_by_id() { - local result_var="${1}" - local app_id_lookup="${2}" - local app_branches_csv="" - local branch="" - local branch_lines="" - local -a branches=() - - app_branches_csv="$(get_predefined_app_field_by_field "id" "${app_id_lookup}" "branches_csv" || true)" - if [ -z "${app_branches_csv}" ]; then - return 1 - fi - - IFS=',' read -r -a branches <<<"${app_branches_csv}" - for branch in "${branches[@]}"; do - trim_predefined_catalog_field branch "${branch}" - if [ -z "${branch}" ]; then - continue - fi - if [ -z "${branch_lines}" ]; then - branch_lines="${branch}" - else - branch_lines="${branch_lines}"$'\n'"${branch}" - fi - done - - if [ -z "${branch_lines}" ]; then - return 1 - fi - - printf -v "${result_var}" "%s" "${branch_lines}" - return 0 -} - -predefined_app_catalog_has_id() { - local app_id_lookup="${1}" - - if [ -z "${app_id_lookup}" ]; then - return 1 - fi - - get_predefined_app_field_by_field "id" "${app_id_lookup}" "id" >/dev/null 2>&1 -} - -predefined_app_catalog_has_label() { - local app_label_lookup="${1}" - - if [ -z "${app_label_lookup}" ]; then - return 1 - fi - - get_predefined_app_field_by_field "label" "${app_label_lookup}" "label" >/dev/null 2>&1 -} - -append_predefined_app_catalog_entry() { - local app_id="${1}" - local app_label="${2}" - local app_repo="${3}" - local app_default_branch="${4}" - local app_branches_csv="${5}" - local normalized_branches_csv="" - local first_branch="" - local catalog_path="" - local catalog_tmp_path="" - local last_char="" - - if ! get_predefined_apps_catalog_entries >/dev/null 2>&1; then - return 1 - fi - - trim_predefined_catalog_field app_id "${app_id}" - trim_predefined_catalog_field app_label "${app_label}" - trim_predefined_catalog_field app_repo "${app_repo}" - trim_predefined_catalog_field app_default_branch "${app_default_branch}" - trim_predefined_catalog_field app_branches_csv "${app_branches_csv}" - - if ! is_valid_predefined_app_id "${app_id}"; then - return 1 - fi - if [ -z "${app_label}" ]; then - return 1 - fi - if ! is_valid_predefined_app_repo "${app_repo}"; then - return 1 - fi - if ! normalize_predefined_branches_csv normalized_branches_csv "${app_branches_csv}"; then - return 1 - fi - - if [ -z "${app_default_branch}" ]; then - first_branch="${normalized_branches_csv%%,*}" - app_default_branch="${first_branch}" - fi - if ! is_valid_predefined_app_branch "${app_default_branch}"; then - return 1 - fi - if ! csv_contains_branch "${normalized_branches_csv}" "${app_default_branch}"; then - return 1 - fi - - if predefined_app_catalog_has_id "${app_id}"; then - return 1 - fi - if predefined_app_catalog_has_label "${app_label}"; then - return 1 - fi - - catalog_path="$(get_predefined_apps_catalog_path)" - catalog_tmp_path="${catalog_path}.tmp" - if [ ! -f "${catalog_path}" ]; then - return 1 - fi - - if ! cp -- "${catalog_path}" "${catalog_tmp_path}"; then - rm -f -- "${catalog_tmp_path}" >/dev/null 2>&1 || true - return 1 - fi - - if [ -s "${catalog_tmp_path}" ]; then - if command_exists tail; then - last_char="$(tail -c 1 "${catalog_tmp_path}" 2>/dev/null || true)" - if [ -n "${last_char}" ]; then - if ! printf '\n' >>"${catalog_tmp_path}"; then - rm -f -- "${catalog_tmp_path}" >/dev/null 2>&1 || true - return 1 - fi - fi - else - if ! printf '\n' >>"${catalog_tmp_path}"; then - rm -f -- "${catalog_tmp_path}" >/dev/null 2>&1 || true - return 1 - fi - fi - fi - - if ! printf '%s\t%s\t%s\t%s\t%s\n' "${app_id}" "${app_label}" "${app_repo}" "${app_default_branch}" "${normalized_branches_csv}" >>"${catalog_tmp_path}"; then - rm -f -- "${catalog_tmp_path}" >/dev/null 2>&1 || true - return 1 - fi - - if ! mv -- "${catalog_tmp_path}" "${catalog_path}"; then - rm -f -- "${catalog_tmp_path}" >/dev/null 2>&1 || true - return 1 - fi - - return 0 -} - -persist_stack_apps_json_content() { - local stack_dir="${1}" - local apps_json_content="${2}" - local apps_json_path="" - local apps_json_tmp_path="" - - apps_json_path="${stack_dir}/apps.json" - apps_json_tmp_path="${apps_json_path}.tmp" - - if ! printf '%s\n' "${apps_json_content}" >"${apps_json_tmp_path}"; then - rm -f -- "${apps_json_tmp_path}" >/dev/null 2>&1 || true - return 1 - fi - - if ! mv -- "${apps_json_tmp_path}" "${apps_json_path}"; then - rm -f -- "${apps_json_tmp_path}" >/dev/null 2>&1 || true - return 1 - fi - - return 0 -} - -get_metadata_apps_predefined_csv() { - local metadata_path="${1}" - - if [ ! -f "${metadata_path}" ]; then - return 1 - fi - - awk ' - /"apps"[[:space:]]*:[[:space:]]*{/ { - in_apps = 1 - } - in_apps && /"predefined"[[:space:]]*:[[:space:]]*\[/ { - in_predefined = 1 - next - } - in_predefined && /\]/ { - in_predefined = 0 - next - } - in_predefined { - if (match($0, /"([^"]+)"/, parts)) { - if (csv == "") { - csv = parts[1] - } else { - csv = csv "," parts[1] - } - } - } - END { - if (csv != "") { - print csv - } - } - ' "${metadata_path}" -} - -get_metadata_apps_custom_lines() { - local metadata_path="${1}" - - if [ ! -f "${metadata_path}" ]; then - return 1 - fi - - awk ' - /"apps"[[:space:]]*:[[:space:]]*{/ { - in_apps = 1 - } - in_apps && /"custom"[[:space:]]*:[[:space:]]*\[/ { - in_custom = 1 - next - } - in_custom && /\]/ { - in_custom = 0 - repo = "" - branch = "" - next - } - in_custom { - if (match($0, /"repo"[[:space:]]*:[[:space:]]*"([^"]+)"/, repo_parts)) { - repo = repo_parts[1] - } - if (match($0, /"branch"[[:space:]]*:[[:space:]]*"([^"]+)"/, branch_parts)) { - branch = branch_parts[1] - } - if (repo != "" && branch != "") { - print repo "|" branch - repo = "" - branch = "" - } - } - ' "${metadata_path}" -} - -get_metadata_apps_predefined_branch_lines() { - local metadata_path="${1}" - - if [ ! -f "${metadata_path}" ]; then - return 1 - fi - - awk ' - /"apps"[[:space:]]*:[[:space:]]*{/ { - in_apps = 1 - } - in_apps && /"predefined_branches"[[:space:]]*:[[:space:]]*{/ { - in_predefined_branches = 1 - next - } - in_predefined_branches && /}/ { - in_predefined_branches = 0 - next - } - in_predefined_branches { - if (match($0, /"([^"]+)"[[:space:]]*:[[:space:]]*"([^"]+)"/, parts)) { - print parts[1] "|" parts[2] - } - } - ' "${metadata_path}" -} - -get_metadata_apps_predefined_branch_for_id() { - local metadata_path="${1}" - local app_id_lookup="${2}" - local line="" - local app_id="" - local app_branch="" - - while IFS= read -r line; do - if [ -z "${line}" ]; then - continue - fi - - app_id="${line%%|*}" - app_branch="${line#*|}" - if [ "${app_id}" = "${app_id_lookup}" ] && [ -n "${app_branch}" ]; then - printf '%s\n' "${app_branch}" - return 0 - fi - done < <(get_metadata_apps_predefined_branch_lines "${metadata_path}" || true) - - return 1 -} - -build_stack_apps_json_content_from_metadata_apps() { - local result_var="${1}" - local stack_dir="${2}" - local metadata_path="" - local preset_apps_csv="" - local custom_apps_lines="" - local predefined_branch="" - local preset_branch="" - local catalog_default_branch="" - local app="" - local line="" - local repo="" - local branch="" - local url="" - local escaped_url="" - local escaped_branch="" - local entry_json="" - local entries_json="" - local -a preset_apps=() - - metadata_path="${stack_dir}/metadata.json" - if [ ! -f "${metadata_path}" ]; then - return 1 - fi - - preset_apps_csv="$(get_metadata_apps_predefined_csv "${metadata_path}" || true)" - custom_apps_lines="$(get_metadata_apps_custom_lines "${metadata_path}" || true)" - preset_branch="$(get_stack_frappe_branch "${stack_dir}" || true)" - if [ -z "${preset_branch}" ]; then - preset_branch="$(get_default_frappe_branch)" - fi - - if [ -n "${preset_apps_csv}" ]; then - IFS=',' read -r -a preset_apps <<<"${preset_apps_csv}" - for app in "${preset_apps[@]}"; do - url="$(get_predefined_app_repo_by_id "${app}" || true)" - if [ -z "${url}" ]; then - return 1 - fi - - predefined_branch="$(get_metadata_apps_predefined_branch_for_id "${metadata_path}" "${app}" || true)" - - if [ -z "${predefined_branch}" ]; then - catalog_default_branch="$(get_predefined_app_default_branch_by_id "${app}" || true)" - if [ -n "${catalog_default_branch}" ]; then - predefined_branch="${catalog_default_branch}" - else - predefined_branch="${preset_branch}" - fi - fi - - escaped_url="$(json_escape_string "${url}")" - escaped_branch="$(json_escape_string "${predefined_branch}")" - entry_json="$(printf ' {"url": "%s", "branch": "%s"}' "${escaped_url}" "${escaped_branch}")" - if [ -z "${entries_json}" ]; then - entries_json="${entry_json}" - else - entries_json="${entries_json}"$',\n'"${entry_json}" - fi - done - fi - - while IFS= read -r line; do - if [ -z "${line}" ]; then - continue - fi - - repo="${line%%|*}" - branch="${line#*|}" - if [ -z "${repo}" ] || [ -z "${branch}" ]; then - continue - fi - - escaped_url="$(json_escape_string "${repo}")" - escaped_branch="$(json_escape_string "${branch}")" - entry_json="$(printf ' {"url": "%s", "branch": "%s"}' "${escaped_url}" "${escaped_branch}")" - if [ -z "${entries_json}" ]; then - entries_json="${entry_json}" - else - entries_json="${entries_json}"$',\n'"${entry_json}" - fi - done <"${metadata_tmp_path}"; then - rm -f -- "${metadata_tmp_path}" >/dev/null 2>&1 || true - return 1 - fi - - if ! mv -- "${metadata_tmp_path}" "${metadata_path}"; then - rm -f -- "${metadata_tmp_path}" >/dev/null 2>&1 || true - return 1 - fi - - return 0 -} - -persist_stack_metadata_wizard_object() { - local stack_dir="${1}" - local wizard_json_object="${2}" - local metadata_path="" - local metadata_tmp_path="" - - metadata_path="${stack_dir}/metadata.json" - metadata_tmp_path="${metadata_path}.tmp" - if [ ! -f "${metadata_path}" ]; then - return 1 - fi - - if [ -z "${wizard_json_object}" ]; then - return 1 - fi - - if ! awk -v wizard_object="${wizard_json_object}" ' - BEGIN { - in_top_level_wizard = 0 - wizard_depth = 0 - inserted = 0 - prev = "" - } - function flush_prev() { - if (prev != "") { - print prev - prev = "" - } - } - { - if (!in_top_level_wizard && $0 ~ /^ "wizard"[[:space:]]*:/) { - flush_prev() - print " \"wizard\": " wizard_object - in_top_level_wizard = 1 - inserted = 1 - if ($0 ~ /{/) { - wizard_depth += gsub(/{/, "{", $0) - wizard_depth -= gsub(/}/, "}", $0) - } else { - wizard_depth = 0 - } - if (wizard_depth <= 0) { - in_top_level_wizard = 0 - } - next - } - - if (in_top_level_wizard) { - wizard_depth += gsub(/{/, "{", $0) - wizard_depth -= gsub(/}/, "}", $0) - if (wizard_depth <= 0) { - in_top_level_wizard = 0 - } - next - } - - if (!inserted && $0 ~ /^}/) { - if (prev != "") { - if (prev !~ /,[[:space:]]*$/) { - prev = prev "," - } - print prev - prev = "" - } - print " \"wizard\": " wizard_object - inserted = 1 - print $0 - next - } - - flush_prev() - prev = $0 - } - END { - flush_prev() - if (!inserted) { - exit 2 - } - } - ' "${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 -} +load_easy_docker_wizard_app_modules diff --git a/scripts/easy-docker/lib/app/wizard/common/apps/catalog.sh b/scripts/easy-docker/lib/app/wizard/common/apps/catalog.sh new file mode 100755 index 00000000..075bd44e --- /dev/null +++ b/scripts/easy-docker/lib/app/wizard/common/apps/catalog.sh @@ -0,0 +1,490 @@ +#!/usr/bin/env bash + +trim_predefined_catalog_field() { + local result_var="${1}" + local value="${2}" + + value="${value#"${value%%[![:space:]]*}"}" + value="${value%"${value##*[![:space:]]}"}" + + printf -v "${result_var}" "%s" "${value}" +} + +is_valid_predefined_app_id() { + local value="${1}" + + if [ -z "${value}" ]; then + return 1 + fi + + case "${value}" in + *[!a-z0-9._-]*) + return 1 + ;; + *) + return 0 + ;; + esac +} + +generate_predefined_app_id_from_label() { + local result_var="${1}" + local app_label="${2}" + local generated_id="" + + generated_id="$( + printf '%s' "${app_label}" | + tr '[:upper:]' '[:lower:]' | + sed -E 's/[[:space:]]+/_/g; s/[^a-z0-9._-]+/_/g; s/_+/_/g; s/^_+//; s/_+$//' + )" + + if ! is_valid_predefined_app_id "${generated_id}"; then + return 1 + fi + + printf -v "${result_var}" "%s" "${generated_id}" + return 0 +} + +is_valid_predefined_app_repo() { + local value="${1}" + + if [ -z "${value}" ]; then + return 1 + fi + + case "${value}" in + https://* | http://* | ssh://* | git://* | git@*:* | file://*) + return 0 + ;; + *) + return 1 + ;; + esac +} + +is_valid_predefined_app_branch() { + local value="${1}" + + if [ -z "${value}" ]; then + return 1 + fi + + case "${value}" in + *[!A-Za-z0-9._/-]* | .* | *..* | */ | /*) + return 1 + ;; + *) + return 0 + ;; + esac +} + +csv_contains_branch() { + local csv_values="${1}" + local value="${2}" + + case ",${csv_values}," in + *,"${value}",*) + return 0 + ;; + *) + return 1 + ;; + esac +} + +normalize_predefined_branches_csv() { + local result_csv_var="${1}" + local branches_csv_raw="${2}" + local branch_token="" + local normalized_csv="" + local -a raw_tokens=() + + IFS=',' read -r -a raw_tokens <<<"${branches_csv_raw}" + for branch_token in "${raw_tokens[@]}"; do + trim_predefined_catalog_field branch_token "${branch_token}" + if [ -z "${branch_token}" ]; then + continue + fi + + if ! is_valid_predefined_app_branch "${branch_token}"; then + return 1 + fi + + if csv_contains_branch "${normalized_csv}" "${branch_token}"; then + continue + fi + + if [ -z "${normalized_csv}" ]; then + normalized_csv="${branch_token}" + else + normalized_csv="${normalized_csv},${branch_token}" + fi + done + + if [ -z "${normalized_csv}" ]; then + return 1 + fi + + printf -v "${result_csv_var}" "%s" "${normalized_csv}" + return 0 +} + +get_predefined_apps_catalog_path() { + local repo_root="" + + repo_root="$(get_easy_docker_repo_root)" + printf '%s/scripts/easy-docker/config/apps.tsv\n' "${repo_root}" +} + +get_predefined_apps_catalog_entries() { + local catalog_path="" + local raw_line="" + local line="" + local app_id="" + local app_label="" + local app_repo="" + local app_default_branch="" + local app_branches_csv="" + local normalized_branches_csv="" + local first_branch="" + local extra="" + local seen_ids="," + local seen_labels="," + + catalog_path="$(get_predefined_apps_catalog_path)" + if [ ! -f "${catalog_path}" ]; then + return 1 + fi + + while IFS= read -r raw_line || [ -n "${raw_line}" ]; do + trim_predefined_catalog_field line "${raw_line}" + if [ -z "${line}" ]; then + continue + fi + + case "${line}" in + \#*) + continue + ;; + esac + + if [[ "${line}" == *$'\t'* ]]; then + IFS=$'\t' read -r app_id app_label app_repo app_default_branch app_branches_csv extra <<<"${line}" + else + # Backward compatibility for older catalog rows. + IFS='|' read -r app_id app_label app_repo app_default_branch app_branches_csv extra <<<"${line}" + fi + trim_predefined_catalog_field app_id "${app_id}" + trim_predefined_catalog_field app_label "${app_label}" + trim_predefined_catalog_field app_repo "${app_repo}" + trim_predefined_catalog_field app_default_branch "${app_default_branch}" + trim_predefined_catalog_field app_branches_csv "${app_branches_csv}" + trim_predefined_catalog_field extra "${extra}" + + if [ -n "${extra}" ] || [ -z "${app_id}" ] || [ -z "${app_label}" ] || [ -z "${app_repo}" ] || [ -z "${app_branches_csv}" ]; then + return 1 + fi + + if ! is_valid_predefined_app_id "${app_id}"; then + return 1 + fi + + if ! is_valid_predefined_app_repo "${app_repo}"; then + return 1 + fi + + if ! normalize_predefined_branches_csv normalized_branches_csv "${app_branches_csv}"; then + return 1 + fi + + if [ -z "${app_default_branch}" ]; then + first_branch="${normalized_branches_csv%%,*}" + app_default_branch="${first_branch}" + fi + + if ! is_valid_predefined_app_branch "${app_default_branch}"; then + return 1 + fi + + if ! csv_contains_branch "${normalized_branches_csv}" "${app_default_branch}"; then + return 1 + fi + + case "${seen_ids}" in + *,"${app_id}",*) + return 1 + ;; + esac + case "${seen_labels}" in + *,"${app_label}",*) + return 1 + ;; + esac + + seen_ids="${seen_ids}${app_id}," + seen_labels="${seen_labels}${app_label}," + + printf '%s|%s|%s|%s|%s\n' "${app_id}" "${app_label}" "${app_repo}" "${app_default_branch}" "${normalized_branches_csv}" + done <"${catalog_path}" +} + +parse_predefined_app_catalog_entry() { + local entry="${1}" + local app_id_var="${2}" + local app_label_var="${3}" + local app_repo_var="${4}" + local app_default_branch_var="${5}" + local app_branches_csv_var="${6}" + local parsed_app_id="" + local parsed_app_label="" + local parsed_app_repo="" + local parsed_app_default_branch="" + local parsed_app_branches_csv="" + + IFS='|' read -r parsed_app_id parsed_app_label parsed_app_repo parsed_app_default_branch parsed_app_branches_csv <<<"${entry}" + printf -v "${app_id_var}" "%s" "${parsed_app_id}" + printf -v "${app_label_var}" "%s" "${parsed_app_label}" + printf -v "${app_repo_var}" "%s" "${parsed_app_repo}" + printf -v "${app_default_branch_var}" "%s" "${parsed_app_default_branch}" + printf -v "${app_branches_csv_var}" "%s" "${parsed_app_branches_csv}" +} + +get_predefined_app_field_by_field() { + local lookup_field="${1}" + local lookup_value="${2}" + local result_field="${3}" + local entry="" + local app_id="" + local app_label="" + local app_repo="" + local app_default_branch="" + local app_branches_csv="" + local lookup_candidate="" + local result_value="" + + trim_predefined_catalog_field lookup_value "${lookup_value}" + if [ -z "${lookup_value}" ]; then + return 1 + fi + + while IFS= read -r entry; do + if [ -z "${entry}" ]; then + continue + fi + + parse_predefined_app_catalog_entry "${entry}" app_id app_label app_repo app_default_branch app_branches_csv + + case "${lookup_field}" in + id) + lookup_candidate="${app_id}" + ;; + label) + lookup_candidate="${app_label}" + ;; + *) + return 1 + ;; + esac + + trim_predefined_catalog_field lookup_candidate "${lookup_candidate}" + if [ "${lookup_candidate}" != "${lookup_value}" ]; then + continue + fi + + case "${result_field}" in + id) + result_value="${app_id}" + ;; + label) + result_value="${app_label}" + ;; + repo) + result_value="${app_repo}" + ;; + default_branch) + result_value="${app_default_branch}" + ;; + branches_csv) + result_value="${app_branches_csv}" + ;; + *) + return 1 + ;; + esac + + printf '%s\n' "${result_value}" + return 0 + done < <(get_predefined_apps_catalog_entries) + + return 1 +} + +get_predefined_app_id_by_label() { + local label="${1}" + get_predefined_app_field_by_field "label" "${label}" "id" +} + +get_predefined_app_repo_by_id() { + local app_id_lookup="${1}" + get_predefined_app_field_by_field "id" "${app_id_lookup}" "repo" +} + +get_predefined_app_label_by_id() { + local app_id_lookup="${1}" + get_predefined_app_field_by_field "id" "${app_id_lookup}" "label" +} + +get_predefined_app_default_branch_by_id() { + local app_id_lookup="${1}" + get_predefined_app_field_by_field "id" "${app_id_lookup}" "default_branch" +} + +get_predefined_app_branch_lines_by_id() { + local result_var="${1}" + local app_id_lookup="${2}" + local app_branches_csv="" + local branch="" + local branch_lines="" + local -a branches=() + + app_branches_csv="$(get_predefined_app_field_by_field "id" "${app_id_lookup}" "branches_csv" || true)" + if [ -z "${app_branches_csv}" ]; then + return 1 + fi + + IFS=',' read -r -a branches <<<"${app_branches_csv}" + for branch in "${branches[@]}"; do + trim_predefined_catalog_field branch "${branch}" + if [ -z "${branch}" ]; then + continue + fi + if [ -z "${branch_lines}" ]; then + branch_lines="${branch}" + else + branch_lines="${branch_lines}"$'\n'"${branch}" + fi + done + + if [ -z "${branch_lines}" ]; then + return 1 + fi + + printf -v "${result_var}" "%s" "${branch_lines}" + return 0 +} + +predefined_app_catalog_has_id() { + local app_id_lookup="${1}" + + if [ -z "${app_id_lookup}" ]; then + return 1 + fi + + get_predefined_app_field_by_field "id" "${app_id_lookup}" "id" >/dev/null 2>&1 +} + +predefined_app_catalog_has_label() { + local app_label_lookup="${1}" + + if [ -z "${app_label_lookup}" ]; then + return 1 + fi + + get_predefined_app_field_by_field "label" "${app_label_lookup}" "label" >/dev/null 2>&1 +} + +append_predefined_app_catalog_entry() { + local app_id="${1}" + local app_label="${2}" + local app_repo="${3}" + local app_default_branch="${4}" + local app_branches_csv="${5}" + local normalized_branches_csv="" + local first_branch="" + local catalog_path="" + local catalog_tmp_path="" + local last_char="" + + if ! get_predefined_apps_catalog_entries >/dev/null 2>&1; then + return 1 + fi + + trim_predefined_catalog_field app_id "${app_id}" + trim_predefined_catalog_field app_label "${app_label}" + trim_predefined_catalog_field app_repo "${app_repo}" + trim_predefined_catalog_field app_default_branch "${app_default_branch}" + trim_predefined_catalog_field app_branches_csv "${app_branches_csv}" + + if ! is_valid_predefined_app_id "${app_id}"; then + return 1 + fi + if [ -z "${app_label}" ]; then + return 1 + fi + if ! is_valid_predefined_app_repo "${app_repo}"; then + return 1 + fi + if ! normalize_predefined_branches_csv normalized_branches_csv "${app_branches_csv}"; then + return 1 + fi + + if [ -z "${app_default_branch}" ]; then + first_branch="${normalized_branches_csv%%,*}" + app_default_branch="${first_branch}" + fi + if ! is_valid_predefined_app_branch "${app_default_branch}"; then + return 1 + fi + if ! csv_contains_branch "${normalized_branches_csv}" "${app_default_branch}"; then + return 1 + fi + + if predefined_app_catalog_has_id "${app_id}"; then + return 1 + fi + if predefined_app_catalog_has_label "${app_label}"; then + return 1 + fi + + catalog_path="$(get_predefined_apps_catalog_path)" + catalog_tmp_path="${catalog_path}.tmp" + if [ ! -f "${catalog_path}" ]; then + return 1 + fi + + if ! cp -- "${catalog_path}" "${catalog_tmp_path}"; then + rm -f -- "${catalog_tmp_path}" >/dev/null 2>&1 || true + return 1 + fi + + if [ -s "${catalog_tmp_path}" ]; then + if command_exists tail; then + last_char="$(tail -c 1 "${catalog_tmp_path}" 2>/dev/null || true)" + if [ -n "${last_char}" ]; then + if ! printf '\n' >>"${catalog_tmp_path}"; then + rm -f -- "${catalog_tmp_path}" >/dev/null 2>&1 || true + return 1 + fi + fi + else + if ! printf '\n' >>"${catalog_tmp_path}"; then + rm -f -- "${catalog_tmp_path}" >/dev/null 2>&1 || true + return 1 + fi + fi + fi + + if ! printf '%s\t%s\t%s\t%s\t%s\n' "${app_id}" "${app_label}" "${app_repo}" "${app_default_branch}" "${normalized_branches_csv}" >>"${catalog_tmp_path}"; then + rm -f -- "${catalog_tmp_path}" >/dev/null 2>&1 || true + return 1 + fi + + if ! mv -- "${catalog_tmp_path}" "${catalog_path}"; then + rm -f -- "${catalog_tmp_path}" >/dev/null 2>&1 || true + return 1 + fi + + return 0 +} diff --git a/scripts/easy-docker/lib/app/wizard/common/apps/metadata.sh b/scripts/easy-docker/lib/app/wizard/common/apps/metadata.sh new file mode 100755 index 00000000..9dbbb498 --- /dev/null +++ b/scripts/easy-docker/lib/app/wizard/common/apps/metadata.sh @@ -0,0 +1,378 @@ +#!/usr/bin/env bash + +persist_stack_apps_json_content() { + local stack_dir="${1}" + local apps_json_content="${2}" + local apps_json_path="" + local apps_json_tmp_path="" + + apps_json_path="${stack_dir}/apps.json" + apps_json_tmp_path="${apps_json_path}.tmp" + + if ! printf '%s\n' "${apps_json_content}" >"${apps_json_tmp_path}"; then + rm -f -- "${apps_json_tmp_path}" >/dev/null 2>&1 || true + return 1 + fi + + if ! mv -- "${apps_json_tmp_path}" "${apps_json_path}"; then + rm -f -- "${apps_json_tmp_path}" >/dev/null 2>&1 || true + return 1 + fi + + return 0 +} + +get_metadata_apps_predefined_csv() { + local metadata_path="${1}" + + if [ ! -f "${metadata_path}" ]; then + return 1 + fi + + awk ' + /"apps"[[:space:]]*:[[:space:]]*{/ { + in_apps = 1 + } + in_apps && /"predefined"[[:space:]]*:[[:space:]]*\[/ { + in_predefined = 1 + next + } + in_predefined && /\]/ { + in_predefined = 0 + next + } + in_predefined { + if (match($0, /"([^"]+)"/, parts)) { + if (csv == "") { + csv = parts[1] + } else { + csv = csv "," parts[1] + } + } + } + END { + if (csv != "") { + print csv + } + } + ' "${metadata_path}" +} + +get_metadata_apps_custom_lines() { + local metadata_path="${1}" + + if [ ! -f "${metadata_path}" ]; then + return 1 + fi + + awk ' + /"apps"[[:space:]]*:[[:space:]]*{/ { + in_apps = 1 + } + in_apps && /"custom"[[:space:]]*:[[:space:]]*\[/ { + in_custom = 1 + next + } + in_custom && /\]/ { + in_custom = 0 + repo = "" + branch = "" + next + } + in_custom { + if (match($0, /"repo"[[:space:]]*:[[:space:]]*"([^"]+)"/, repo_parts)) { + repo = repo_parts[1] + } + if (match($0, /"branch"[[:space:]]*:[[:space:]]*"([^"]+)"/, branch_parts)) { + branch = branch_parts[1] + } + if (repo != "" && branch != "") { + print repo "|" branch + repo = "" + branch = "" + } + } + ' "${metadata_path}" +} + +get_metadata_apps_predefined_branch_lines() { + local metadata_path="${1}" + + if [ ! -f "${metadata_path}" ]; then + return 1 + fi + + awk ' + /"apps"[[:space:]]*:[[:space:]]*{/ { + in_apps = 1 + } + in_apps && /"predefined_branches"[[:space:]]*:[[:space:]]*{/ { + in_predefined_branches = 1 + next + } + in_predefined_branches && /}/ { + in_predefined_branches = 0 + next + } + in_predefined_branches { + if (match($0, /"([^"]+)"[[:space:]]*:[[:space:]]*"([^"]+)"/, parts)) { + print parts[1] "|" parts[2] + } + } + ' "${metadata_path}" +} + +get_metadata_apps_predefined_branch_for_id() { + local metadata_path="${1}" + local app_id_lookup="${2}" + local line="" + local app_id="" + local app_branch="" + + while IFS= read -r line; do + if [ -z "${line}" ]; then + continue + fi + + app_id="${line%%|*}" + app_branch="${line#*|}" + if [ "${app_id}" = "${app_id_lookup}" ] && [ -n "${app_branch}" ]; then + printf '%s\n' "${app_branch}" + return 0 + fi + done < <(get_metadata_apps_predefined_branch_lines "${metadata_path}" || true) + + return 1 +} + +build_stack_apps_json_content_from_metadata_apps() { + local result_var="${1}" + local stack_dir="${2}" + local metadata_path="" + local preset_apps_csv="" + local custom_apps_lines="" + local predefined_branch="" + local preset_branch="" + local catalog_default_branch="" + local app="" + local line="" + local repo="" + local branch="" + local url="" + local escaped_url="" + local escaped_branch="" + local entry_json="" + local entries_json="" + local -a preset_apps=() + + metadata_path="${stack_dir}/metadata.json" + if [ ! -f "${metadata_path}" ]; then + return 1 + fi + + preset_apps_csv="$(get_metadata_apps_predefined_csv "${metadata_path}" || true)" + custom_apps_lines="$(get_metadata_apps_custom_lines "${metadata_path}" || true)" + preset_branch="$(get_stack_frappe_branch "${stack_dir}" || true)" + if [ -z "${preset_branch}" ]; then + preset_branch="$(get_default_frappe_branch)" + fi + + if [ -n "${preset_apps_csv}" ]; then + IFS=',' read -r -a preset_apps <<<"${preset_apps_csv}" + for app in "${preset_apps[@]}"; do + url="$(get_predefined_app_repo_by_id "${app}" || true)" + if [ -z "${url}" ]; then + return 1 + fi + + predefined_branch="$(get_metadata_apps_predefined_branch_for_id "${metadata_path}" "${app}" || true)" + + if [ -z "${predefined_branch}" ]; then + catalog_default_branch="$(get_predefined_app_default_branch_by_id "${app}" || true)" + if [ -n "${catalog_default_branch}" ]; then + predefined_branch="${catalog_default_branch}" + else + predefined_branch="${preset_branch}" + fi + fi + + escaped_url="$(json_escape_string "${url}")" + escaped_branch="$(json_escape_string "${predefined_branch}")" + entry_json="$(printf ' {"url": "%s", "branch": "%s"}' "${escaped_url}" "${escaped_branch}")" + if [ -z "${entries_json}" ]; then + entries_json="${entry_json}" + else + entries_json="${entries_json}"$',\n'"${entry_json}" + fi + done + fi + + while IFS= read -r line; do + if [ -z "${line}" ]; then + continue + fi + + repo="${line%%|*}" + branch="${line#*|}" + if [ -z "${repo}" ] || [ -z "${branch}" ]; then + continue + fi + + escaped_url="$(json_escape_string "${repo}")" + escaped_branch="$(json_escape_string "${branch}")" + entry_json="$(printf ' {"url": "%s", "branch": "%s"}' "${escaped_url}" "${escaped_branch}")" + if [ -z "${entries_json}" ]; then + entries_json="${entry_json}" + else + entries_json="${entries_json}"$',\n'"${entry_json}" + fi + done <"${metadata_tmp_path}"; then + rm -f -- "${metadata_tmp_path}" >/dev/null 2>&1 || true + return 1 + fi + + if ! mv -- "${metadata_tmp_path}" "${metadata_path}"; then + rm -f -- "${metadata_tmp_path}" >/dev/null 2>&1 || true + return 1 + fi + + return 0 +} + +persist_stack_metadata_apps_object() { + local stack_dir="${1}" + local apps_json_object="${2}" + + persist_stack_metadata_top_level_object "${stack_dir}" "apps" "${apps_json_object}" "wizard" +} + +persist_stack_metadata_wizard_object() { + local stack_dir="${1}" + local wizard_json_object="${2}" + + persist_stack_metadata_top_level_object "${stack_dir}" "wizard" "${wizard_json_object}" +} diff --git a/scripts/easy-docker/lib/app/wizard/common/apps/persistence.sh b/scripts/easy-docker/lib/app/wizard/common/apps/persistence.sh new file mode 100755 index 00000000..e581f3cc --- /dev/null +++ b/scripts/easy-docker/lib/app/wizard/common/apps/persistence.sh @@ -0,0 +1,191 @@ +#!/usr/bin/env bash + +persist_stack_metadata_apps_object() { + local stack_dir="${1}" + local apps_json_object="${2}" + local metadata_path="" + local metadata_tmp_path="" + + metadata_path="${stack_dir}/metadata.json" + metadata_tmp_path="${metadata_path}.tmp" + if [ ! -f "${metadata_path}" ]; then + return 1 + fi + + if [ -z "${apps_json_object}" ]; then + return 1 + fi + + if ! awk -v apps_object="${apps_json_object}" ' + BEGIN { + in_top_level_apps = 0 + apps_depth = 0 + inserted = 0 + prev = "" + } + function flush_prev() { + if (prev != "") { + print prev + prev = "" + } + } + { + if (!in_top_level_apps && $0 ~ /^ "apps"[[:space:]]*:/) { + flush_prev() + print " \"apps\": " apps_object "," + in_top_level_apps = 1 + inserted = 1 + if ($0 ~ /{/) { + apps_depth += gsub(/{/, "{", $0) + apps_depth -= gsub(/}/, "}", $0) + } else { + apps_depth = 0 + } + if (apps_depth <= 0) { + in_top_level_apps = 0 + } + next + } + + if (in_top_level_apps) { + apps_depth += gsub(/{/, "{", $0) + apps_depth -= gsub(/}/, "}", $0) + if (apps_depth <= 0) { + in_top_level_apps = 0 + } + next + } + + if (!inserted && $0 ~ /^ "wizard"[[:space:]]*:/) { + flush_prev() + print " \"apps\": " apps_object "," + inserted = 1 + } + + if (!inserted && $0 ~ /^}/) { + if (prev != "") { + if (prev !~ /,[[:space:]]*$/) { + prev = prev "," + } + print prev + prev = "" + } + print " \"apps\": " apps_object + inserted = 1 + print $0 + next + } + + flush_prev() + prev = $0 + } + END { + flush_prev() + if (!inserted) { + exit 2 + } + } + ' "${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 +} + +persist_stack_metadata_wizard_object() { + local stack_dir="${1}" + local wizard_json_object="${2}" + local metadata_path="" + local metadata_tmp_path="" + + metadata_path="${stack_dir}/metadata.json" + metadata_tmp_path="${metadata_path}.tmp" + if [ ! -f "${metadata_path}" ]; then + return 1 + fi + + if [ -z "${wizard_json_object}" ]; then + return 1 + fi + + if ! awk -v wizard_object="${wizard_json_object}" ' + BEGIN { + in_top_level_wizard = 0 + wizard_depth = 0 + inserted = 0 + prev = "" + } + function flush_prev() { + if (prev != "") { + print prev + prev = "" + } + } + { + if (!in_top_level_wizard && $0 ~ /^ "wizard"[[:space:]]*:/) { + flush_prev() + print " \"wizard\": " wizard_object + in_top_level_wizard = 1 + inserted = 1 + if ($0 ~ /{/) { + wizard_depth += gsub(/{/, "{", $0) + wizard_depth -= gsub(/}/, "}", $0) + } else { + wizard_depth = 0 + } + if (wizard_depth <= 0) { + in_top_level_wizard = 0 + } + next + } + + if (in_top_level_wizard) { + wizard_depth += gsub(/{/, "{", $0) + wizard_depth -= gsub(/}/, "}", $0) + if (wizard_depth <= 0) { + in_top_level_wizard = 0 + } + next + } + + if (!inserted && $0 ~ /^}/) { + if (prev != "") { + if (prev !~ /,[[:space:]]*$/) { + prev = prev "," + } + print prev + prev = "" + } + print " \"wizard\": " wizard_object + inserted = 1 + print $0 + next + } + + flush_prev() + prev = $0 + } + END { + flush_prev() + if (!inserted) { + exit 2 + } + } + ' "${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 +} 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 new file mode 100755 index 00000000..5b246c90 --- /dev/null +++ b/scripts/easy-docker/lib/app/wizard/common/compose/runtime/lifecycle.sh @@ -0,0 +1,193 @@ +#!/usr/bin/env bash + +start_stack_with_compose_from_metadata() { + local stack_dir="${1}" + local metadata_path="" + local env_path="" + local compose_project_name="" + local fallback_erpnext_version="" + local configured_pull_policy="" + local runtime_pull_policy="" + local custom_image="" + local custom_tag="" + local image_ref="" + local image_inspect_error="" + local -a compose_args=() + + # shellcheck disable=SC2034 # Read by manage flow after start_stack_with_compose_from_metadata fails. + EASY_DOCKER_COMPOSE_ERROR_DETAIL="" + + easy_docker_compose_init_context "${stack_dir}" metadata_path env_path compose_project_name + + if [ ! -f "${metadata_path}" ]; then + return 31 + fi + + if [ ! -f "${env_path}" ]; then + return 32 + fi + + if ! easy_docker_compose_require_single_host_topology "${stack_dir}" 33 34; then + return $? + fi + + easy_docker_compose_get_fallback_erpnext_version fallback_erpnext_version "${env_path}" + + configured_pull_policy="$(get_env_file_key_value "${env_path}" "PULL_POLICY" || true)" + if [ -z "${configured_pull_policy}" ]; then + custom_image="$(get_env_file_key_value "${env_path}" "CUSTOM_IMAGE" || true)" + custom_tag="$(get_env_file_key_value "${env_path}" "CUSTOM_TAG" || true)" + if [ -n "${custom_image}" ] && [ -n "${custom_tag}" ]; then + image_ref="${custom_image}:${custom_tag}" + if image_inspect_error="$(docker image inspect "${image_ref}" 2>&1 >/dev/null)"; then + runtime_pull_policy="if_not_present" + else + case "${image_inspect_error}" in + *"No such image"* | *"No such object"*) + # shellcheck disable=SC2034 # Read by manage flow after start_stack_with_compose_from_metadata returns 38. + EASY_DOCKER_COMPOSE_ERROR_DETAIL="${image_ref}" + return 38 + ;; + *) + if [ -z "${image_inspect_error}" ]; then + image_inspect_error="docker image inspect failed for ${image_ref}" + fi + # shellcheck disable=SC2034 # Read by manage flow after start_stack_with_compose_from_metadata returns 39. + EASY_DOCKER_COMPOSE_ERROR_DETAIL="${image_inspect_error}" + return 39 + ;; + esac + fi + fi + fi + + if ! easy_docker_compose_collect_args compose_args "${metadata_path}" 35 36; then + return $? + fi + + if [ -n "${fallback_erpnext_version}" ] && [ -n "${runtime_pull_policy}" ]; then + if ! ERPNEXT_VERSION="${fallback_erpnext_version}" PULL_POLICY="${runtime_pull_policy}" docker compose --project-name "${compose_project_name}" --env-file "${env_path}" "${compose_args[@]}" up -d; then + return 37 + fi + elif [ -n "${fallback_erpnext_version}" ]; then + if ! ERPNEXT_VERSION="${fallback_erpnext_version}" docker compose --project-name "${compose_project_name}" --env-file "${env_path}" "${compose_args[@]}" up -d; then + return 37 + fi + elif [ -n "${runtime_pull_policy}" ]; then + if ! PULL_POLICY="${runtime_pull_policy}" docker compose --project-name "${compose_project_name}" --env-file "${env_path}" "${compose_args[@]}" up -d; then + return 37 + fi + elif ! docker compose --project-name "${compose_project_name}" --env-file "${env_path}" "${compose_args[@]}" up -d; then + return 37 + fi + + return 0 +} + +stop_stack_with_compose_from_metadata() { + local stack_dir="${1}" + local metadata_path="" + local env_path="" + local compose_project_name="" + local fallback_erpnext_version="" + local -a compose_args=() + + # shellcheck disable=SC2034 # Read by manage flow after stop_stack_with_compose_from_metadata fails. + EASY_DOCKER_COMPOSE_ERROR_DETAIL="" + + easy_docker_compose_init_context "${stack_dir}" metadata_path env_path compose_project_name + + if [ ! -f "${metadata_path}" ]; then + return 41 + fi + + if [ ! -f "${env_path}" ]; then + return 42 + fi + + if ! easy_docker_compose_require_single_host_topology "${stack_dir}" 43 44; then + return $? + fi + + easy_docker_compose_get_fallback_erpnext_version fallback_erpnext_version "${env_path}" + + if ! easy_docker_compose_collect_args compose_args "${metadata_path}" 45 46; then + return $? + fi + + if [ -n "${fallback_erpnext_version}" ]; then + if ! ERPNEXT_VERSION="${fallback_erpnext_version}" docker compose --project-name "${compose_project_name}" --env-file "${env_path}" "${compose_args[@]}" stop; then + return 47 + fi + elif ! docker compose --project-name "${compose_project_name}" --env-file "${env_path}" "${compose_args[@]}" stop; then + return 47 + fi + + return 0 +} + +delete_stack_with_compose_from_metadata() { + local stack_dir="${1}" + local metadata_path="" + local env_path="" + local compose_project_name="" + local fallback_erpnext_version="" + local custom_image="" + local custom_tag="" + local custom_image_ref="" + local -a compose_args=() + + # shellcheck disable=SC2034 # Read by manage flow after delete_stack_with_compose_from_metadata fails. + EASY_DOCKER_COMPOSE_ERROR_DETAIL="" + + easy_docker_compose_init_context "${stack_dir}" metadata_path env_path compose_project_name + + if [ ! -f "${metadata_path}" ]; then + return 48 + fi + + if [ ! -f "${env_path}" ]; then + return 49 + fi + + if ! easy_docker_compose_require_single_host_topology "${stack_dir}" 50 51; then + return $? + fi + + easy_docker_compose_get_fallback_erpnext_version fallback_erpnext_version "${env_path}" + + custom_image="$(get_env_file_key_value "${env_path}" "CUSTOM_IMAGE" || true)" + custom_tag="$(get_env_file_key_value "${env_path}" "CUSTOM_TAG" || true)" + if [ -n "${custom_image}" ] && [ -n "${custom_tag}" ]; then + custom_image_ref="${custom_image}:${custom_tag}" + fi + + if ! easy_docker_compose_collect_args compose_args "${metadata_path}" 52 53; then + return $? + fi + + if [ -n "${fallback_erpnext_version}" ]; then + if ! ERPNEXT_VERSION="${fallback_erpnext_version}" docker compose --project-name "${compose_project_name}" --env-file "${env_path}" "${compose_args[@]}" down -v --remove-orphans --rmi local; then + return 54 + fi + elif ! docker compose --project-name "${compose_project_name}" --env-file "${env_path}" "${compose_args[@]}" down -v --remove-orphans --rmi local; then + return 54 + fi + + if [ -n "${custom_image_ref}" ]; then + if docker image inspect "${custom_image_ref}" >/dev/null 2>&1; then + if ! docker image rm "${custom_image_ref}"; then + EASY_DOCKER_COMPOSE_ERROR_DETAIL="${custom_image_ref}" + return 55 + fi + fi + fi + + if ! rollback_stack_directory "${stack_dir}"; then + # shellcheck disable=SC2034 # Read by manage flow after delete_stack_with_compose_from_metadata returns 56. + EASY_DOCKER_COMPOSE_ERROR_DETAIL="${stack_dir}" + return 56 + fi + + return 0 +} 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 new file mode 100755 index 00000000..1d62093a --- /dev/null +++ b/scripts/easy-docker/lib/app/wizard/common/compose/runtime/shared.sh @@ -0,0 +1,100 @@ +#!/usr/bin/env bash + +easy_docker_compose_init_context() { + local stack_dir="${1}" + local metadata_var="${2}" + local env_var="${3}" + local project_var="${4}" + local metadata_path="" + local env_path="" + local compose_project_name="" + + metadata_path="${stack_dir}/metadata.json" + env_path="$(get_stack_env_path "${stack_dir}")" + compose_project_name="$(get_stack_compose_project_name "${stack_dir}")" + + printf -v "${metadata_var}" "%s" "${metadata_path}" + printf -v "${env_var}" "%s" "${env_path}" + printf -v "${project_var}" "%s" "${compose_project_name}" +} + +easy_docker_compose_get_fallback_erpnext_version() { + local result_var="${1}" + local env_path="${2}" + local env_erpnext_version="" + local fallback_erpnext_version="" + + 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 + + printf -v "${result_var}" "%s" "${fallback_erpnext_version}" +} + +easy_docker_compose_require_single_host_topology() { + local stack_dir="${1}" + local missing_topology_code="${2}" + local unsupported_topology_code="${3}" + local stack_topology="" + + stack_topology="$(get_stack_topology "${stack_dir}" || true)" + if [ -z "${stack_topology}" ]; then + # shellcheck disable=SC2034 # Read by callers after topology resolution fails. + EASY_DOCKER_COMPOSE_ERROR_DETAIL="metadata.json missing topology" + return "${missing_topology_code}" + fi + + case "${stack_topology}" in + "single-host") + return 0 + ;; + *) + # shellcheck disable=SC2034 # Read by callers after unsupported topology is returned. + EASY_DOCKER_COMPOSE_ERROR_DETAIL="${stack_topology}" + return "${unsupported_topology_code}" + ;; + esac +} + +easy_docker_compose_collect_args() { + local result_array_name="${1}" + local metadata_path="${2}" + local missing_compose_code="${3}" + local missing_file_code="${4}" + local compose_files_lines="" + local compose_file="" + local source_compose_path="" + local repo_root="" + local -n compose_args_ref="${result_array_name}" + + compose_args_ref=() + compose_files_lines="$(get_metadata_compose_files_lines "${metadata_path}" || true)" + if [ -z "${compose_files_lines}" ]; then + return "${missing_compose_code}" + 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 + # shellcheck disable=SC2034 # Read by callers after compose file resolution fails. + EASY_DOCKER_COMPOSE_ERROR_DETAIL="${source_compose_path}" + return "${missing_file_code}" + fi + + compose_args_ref+=(-f "${source_compose_path}") + done </dev/null + )" + compose_status=$? + else + container_ids_lines="$( + docker compose --project-name "${compose_project_name}" --env-file "${env_path}" "${compose_args[@]}" ps -a -q 2>/dev/null + )" + compose_status=$? + fi + + if [ "${compose_status}" -ne 0 ]; then + printf -v "${result_var}" "%s" "Unknown (docker compose status failed)" + return 0 + fi + + if [ -z "${container_ids_lines}" ]; then + printf -v "${result_var}" "%s" "Not created" + return 0 + fi + + docker_ps_args=(-a --no-trunc --format "{{.ID}}|{{.State}}|{{.Status}}") + while IFS= read -r container_id; do + if [ -n "${container_id}" ]; then + docker_ps_args+=(--filter "id=${container_id}") + fi + done </dev/null)" + compose_status=$? + if [ "${compose_status}" -ne 0 ]; then + printf -v "${result_var}" "%s" "Unknown (docker ps status failed)" + return 0 + fi + + while IFS= read -r container_status_line; do + if [ -z "${container_status_line}" ]; then + continue + fi + + total_containers_count=$((total_containers_count + 1)) + IFS='|' read -r container_id container_state container_status_text <&1 >/dev/null)"; then - runtime_pull_policy="if_not_present" - else - case "${image_inspect_error}" in - *"No such image"* | *"No such object"*) - # shellcheck disable=SC2034 # Read by manage flow after start_stack_with_compose_from_metadata returns 38. - EASY_DOCKER_COMPOSE_ERROR_DETAIL="${image_ref}" - return 38 - ;; - *) - if [ -z "${image_inspect_error}" ]; then - image_inspect_error="docker image inspect failed for ${image_ref}" - fi - # shellcheck disable=SC2034 # Read by manage flow after start_stack_with_compose_from_metadata returns 39. - EASY_DOCKER_COMPOSE_ERROR_DETAIL="${image_inspect_error}" - return 39 - ;; - esac - fi - fi - fi - - compose_files_lines="$(get_metadata_compose_files_lines "${metadata_path}" || true)" - if [ -z "${compose_files_lines}" ]; then - return 35 - 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 - # shellcheck disable=SC2034 # Read by manage flow after start_stack_with_compose_from_metadata returns 36. - EASY_DOCKER_COMPOSE_ERROR_DETAIL="${source_compose_path}" - return 36 - fi - - compose_args+=(-f "${source_compose_path}") - done </dev/null 2>&1; then - if ! docker image rm "${custom_image_ref}"; then - EASY_DOCKER_COMPOSE_ERROR_DETAIL="${custom_image_ref}" - return 55 - fi - fi - fi - - if ! rollback_stack_directory "${stack_dir}"; then - # shellcheck disable=SC2034 # Read by manage flow after delete_stack_with_compose_from_metadata returns 56. - EASY_DOCKER_COMPOSE_ERROR_DETAIL="${stack_dir}" - return 56 - fi - - return 0 -} - -get_stack_compose_runtime_status_label() { - local result_var="${1}" - local stack_dir="${2}" - local metadata_path="" - local env_path="" - local stack_topology="" - local compose_files_lines="" - local compose_file="" - local source_compose_path="" - local env_erpnext_version="" - local fallback_erpnext_version="" - local container_ids_lines="" - local container_id="" - local container_status_lines="" - local container_status_line="" - local container_state="" - local container_status_text="" - local first_running_status="" - local running_status_excerpt="" - local running_status_varies=0 - local compose_status=0 - local total_containers_count=0 - local running_containers_count=0 - local exited_containers_count=0 - local created_containers_count=0 - local restarting_containers_count=0 - local paused_containers_count=0 - local dead_containers_count=0 - local other_containers_count=0 - local compose_project_name="" - local repo_root="" - local status_label="" - local -a compose_args=() - local -a docker_ps_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 - printf -v "${result_var}" "%s" "Unknown (metadata missing)" - return 0 - fi - - stack_topology="$(get_stack_topology "${stack_dir}" || true)" - if [ -z "${stack_topology}" ]; then - printf -v "${result_var}" "%s" "Unknown (topology missing)" - return 0 - fi - - case "${stack_topology}" in - "single-host") ;; - *) - printf -v "${result_var}" "%s" "N/A (${stack_topology})" - return 0 - ;; - esac - - if [ ! -f "${env_path}" ]; then - printf -v "${result_var}" "%s" "Unknown (env missing)" - return 0 - fi - - env_erpnext_version="$(get_env_file_key_value "${env_path}" "ERPNEXT_VERSION" || true)" - if [ -z "${env_erpnext_version}" ]; then - fallback_erpnext_version="$(get_default_erpnext_version || true)" - fi - - compose_files_lines="$(get_metadata_compose_files_lines "${metadata_path}" || true)" - if [ -z "${compose_files_lines}" ]; then - printf -v "${result_var}" "%s" "Unknown (compose files missing)" - return 0 - 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 - printf -v "${result_var}" "%s" "Unknown (missing file: ${compose_file})" - return 0 - fi - - compose_args+=(-f "${source_compose_path}") - done </dev/null - )" - compose_status=$? - else - container_ids_lines="$( - docker compose --project-name "${compose_project_name}" --env-file "${env_path}" "${compose_args[@]}" ps -a -q 2>/dev/null - )" - compose_status=$? - fi - - if [ "${compose_status}" -ne 0 ]; then - printf -v "${result_var}" "%s" "Unknown (docker compose status failed)" - return 0 - fi - - if [ -z "${container_ids_lines}" ]; then - printf -v "${result_var}" "%s" "Not created" - return 0 - fi - - docker_ps_args=(-a --no-trunc --format "{{.ID}}|{{.State}}|{{.Status}}") - while IFS= read -r container_id; do - if [ -n "${container_id}" ]; then - docker_ps_args+=(--filter "id=${container_id}") - fi - done </dev/null)" - compose_status=$? - if [ "${compose_status}" -ne 0 ]; then - printf -v "${result_var}" "%s" "Unknown (docker ps status failed)" - return 0 - fi - - while IFS= read -r container_status_line; do - if [ -z "${container_status_line}" ]; then - continue - fi - - total_containers_count=$((total_containers_count + 1)) - IFS='|' read -r container_id container_state container_status_text </dev/null 2>&1; then + if ! docker image rm "${custom_image_ref}"; then + EASY_DOCKER_COMPOSE_ERROR_DETAIL="${custom_image_ref}" + return 55 + fi + fi + fi + + if ! rollback_stack_directory "${stack_dir}"; then + # shellcheck disable=SC2034 # Read by manage flow after delete_stack_with_compose_from_metadata returns 56. + EASY_DOCKER_COMPOSE_ERROR_DETAIL="${stack_dir}" + return 56 + fi + + return 0 +} 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 new file mode 100755 index 00000000..a04e4b55 --- /dev/null +++ b/scripts/easy-docker/lib/app/wizard/common/compose/start/start.sh @@ -0,0 +1,131 @@ +#!/usr/bin/env bash + +start_stack_with_compose_from_metadata() { + local stack_dir="${1}" + 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 configured_pull_policy="" + local runtime_pull_policy="" + local custom_image="" + local custom_tag="" + local image_ref="" + local image_inspect_error="" + local compose_project_name="" + local stack_topology="" + local repo_root="" + local -a compose_args=() + + # shellcheck disable=SC2034 # Read by manage flow after start_stack_with_compose_from_metadata fails. + EASY_DOCKER_COMPOSE_ERROR_DETAIL="" + + 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 31 + fi + + if [ ! -f "${env_path}" ]; then + return 32 + fi + + stack_topology="$(get_stack_topology "${stack_dir}" || true)" + if [ -z "${stack_topology}" ]; then + # shellcheck disable=SC2034 # Read by manage flow after start_stack_with_compose_from_metadata returns 33. + EASY_DOCKER_COMPOSE_ERROR_DETAIL="metadata.json missing topology" + return 33 + fi + + case "${stack_topology}" in + "single-host") ;; + *) + # shellcheck disable=SC2034 # Read by manage flow after start_stack_with_compose_from_metadata returns 34. + EASY_DOCKER_COMPOSE_ERROR_DETAIL="${stack_topology}" + return 34 + ;; + 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 + + configured_pull_policy="$(get_env_file_key_value "${env_path}" "PULL_POLICY" || true)" + if [ -z "${configured_pull_policy}" ]; then + custom_image="$(get_env_file_key_value "${env_path}" "CUSTOM_IMAGE" || true)" + custom_tag="$(get_env_file_key_value "${env_path}" "CUSTOM_TAG" || true)" + if [ -n "${custom_image}" ] && [ -n "${custom_tag}" ]; then + image_ref="${custom_image}:${custom_tag}" + if image_inspect_error="$(docker image inspect "${image_ref}" 2>&1 >/dev/null)"; then + runtime_pull_policy="if_not_present" + else + case "${image_inspect_error}" in + *"No such image"* | *"No such object"*) + # shellcheck disable=SC2034 # Read by manage flow after start_stack_with_compose_from_metadata returns 38. + EASY_DOCKER_COMPOSE_ERROR_DETAIL="${image_ref}" + return 38 + ;; + *) + if [ -z "${image_inspect_error}" ]; then + image_inspect_error="docker image inspect failed for ${image_ref}" + fi + # shellcheck disable=SC2034 # Read by manage flow after start_stack_with_compose_from_metadata returns 39. + EASY_DOCKER_COMPOSE_ERROR_DETAIL="${image_inspect_error}" + return 39 + ;; + esac + fi + fi + fi + + compose_files_lines="$(get_metadata_compose_files_lines "${metadata_path}" || true)" + if [ -z "${compose_files_lines}" ]; then + return 35 + 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 + # shellcheck disable=SC2034 # Read by manage flow after start_stack_with_compose_from_metadata returns 36. + EASY_DOCKER_COMPOSE_ERROR_DETAIL="${source_compose_path}" + return 36 + fi + + compose_args+=(-f "${source_compose_path}") + done </dev/null + )" + compose_status=$? + else + container_ids_lines="$( + docker compose --project-name "${compose_project_name}" --env-file "${env_path}" "${compose_args[@]}" ps -a -q 2>/dev/null + )" + compose_status=$? + fi + + if [ "${compose_status}" -ne 0 ]; then + printf -v "${result_var}" "%s" "Unknown (docker compose status failed)" + return 0 + fi + + if [ -z "${container_ids_lines}" ]; then + printf -v "${result_var}" "%s" "Not created" + return 0 + fi + + docker_ps_args=(-a --no-trunc --format "{{.ID}}|{{.State}}|{{.Status}}") + while IFS= read -r container_id; do + if [ -n "${container_id}" ]; then + docker_ps_args+=(--filter "id=${container_id}") + fi + done </dev/null)" + compose_status=$? + if [ "${compose_status}" -ne 0 ]; then + printf -v "${result_var}" "%s" "Unknown (docker ps status failed)" + return 0 + fi + + while IFS= read -r container_status_line; do + if [ -z "${container_status_line}" ]; then + continue + fi + + total_containers_count=$((total_containers_count + 1)) + IFS='|' read -r container_id container_state container_status_text <"${absolute_path}"; then - return 1 - fi - - printf -v "${result_var}" "%s" "${relative_path}" - return 0 -} - -run_stack_backend_bash_command_capture() { - local result_var="${1}" - local stack_dir="${2}" - local backend_command="${3}" - local command_output="" - local command_status=0 - - reset_easy_docker_site_error_state - command_output="$(run_stack_backend_bash_command "${stack_dir}" "${backend_command}" 2>&1)" - command_status=$? - - if [ -n "${command_output}" ]; then - printf '%s\n' "${command_output}" - fi - - printf -v "${result_var}" "%s" "${command_output}" - return "${command_status}" -} - -capture_stack_site_error_log() { - local stack_dir="${1}" - local action_name="${2:-site-error}" - local error_output="${3:-}" - local log_path="" - - EASY_DOCKER_SITE_ERROR_LOG_PATH="" - if [ -z "${error_output}" ]; then - return 0 - fi - - if ! write_stack_site_error_log log_path "${stack_dir}" "${action_name}" "${error_output}"; then - EASY_DOCKER_SITE_ERROR_DETAIL="${EASY_DOCKER_SITE_ERROR_DETAIL:-Failed to write site error log.}" - return 1 - fi - - EASY_DOCKER_SITE_ERROR_LOG_PATH="${log_path}" - return 0 -} - -stack_backend_service_is_running() { - local stack_dir="${1}" - local backend_ready_status=0 - - if run_stack_backend_bash_command "${stack_dir}" "true" >/dev/null 2>&1; then - return 0 - fi - - backend_ready_status=$? - if [ "${backend_ready_status}" -eq 54 ] || [ "${backend_ready_status}" -eq 52 ]; then - return "${backend_ready_status}" - fi - - # If exec fails, the backend service is not ready for site actions yet. - return 1 -} - -stack_database_service_is_reachable() { - local stack_dir="${1}" - local reachability_command="" - local db_ready_status=0 - - IFS= read -r -d '' reachability_command <<'EOF' || true -python - <<'PY' -import json -import socket -from pathlib import Path - -config_path = Path("/home/frappe/frappe-bench/sites/common_site_config.json") -with config_path.open(encoding="utf-8") as handle: - config = json.load(handle) - -db_host = config.get("db_host") -db_port = int(config.get("db_port", 3306)) -socket.create_connection((db_host, db_port), 5).close() -PY -EOF - - if run_stack_backend_bash_command "${stack_dir}" "${reachability_command}" >/dev/null 2>&1; then - return 0 - fi - - db_ready_status=$? - if [ "${db_ready_status}" -eq 54 ] || [ "${db_ready_status}" -eq 52 ]; then - return "${db_ready_status}" - fi - - return 1 -} - -stack_site_exists_in_bench() { - local stack_dir="${1}" - local site_name="${2}" - local exists_command="" - local exists_status=0 - - if ! is_safe_stack_site_cleanup_name "${site_name}"; then - return 61 - fi - - exists_command="$( - printf "bench list-sites | grep -F -x -- %s >/dev/null" "$(shell_quote_site_command_arg "${site_name}")" - )" - if run_stack_backend_bash_command "${stack_dir}" "${exists_command}" >/dev/null 2>&1; then - return 0 - fi - - exists_status=$? - if [ "${exists_status}" -eq 54 ] || [ "${exists_status}" -eq 52 ]; then - return "${exists_status}" - fi - - return 1 -} - -stack_site_directory_exists() { - local stack_dir="${1}" - local site_name="${2}" - local exists_command="" - local exists_status=0 - - if ! is_safe_stack_site_cleanup_name "${site_name}"; then - return 61 - fi - - exists_command="$( - printf "test -d sites/%s" "$(shell_quote_site_command_arg "${site_name}")" - )" - if run_stack_backend_bash_command "${stack_dir}" "${exists_command}" >/dev/null 2>&1; then - return 0 - fi - - exists_status=$? - if [ "${exists_status}" -eq 54 ] || [ "${exists_status}" -eq 52 ]; then - return "${exists_status}" - fi - - return 1 -} - -stack_site_config_exists() { - local stack_dir="${1}" - local site_name="${2}" - local exists_command="" - local exists_status=0 - - if ! is_safe_stack_site_cleanup_name "${site_name}"; then - return 61 - fi - - exists_command="$( - printf "test -f sites/%s/site_config.json" "$(shell_quote_site_command_arg "${site_name}")" - )" - if run_stack_backend_bash_command "${stack_dir}" "${exists_command}" >/dev/null 2>&1; then - return 0 - fi - - exists_status=$? - if [ "${exists_status}" -eq 54 ] || [ "${exists_status}" -eq 52 ]; then - return "${exists_status}" - fi - - return 1 -} - -get_stack_site_database_name() { - local stack_dir="${1}" - local site_name="${2}" - local read_command="" - - if ! is_safe_stack_site_cleanup_name "${site_name}"; then - return 61 - fi - - read_command="$( - printf "python - <<'PY'\nimport json\nfrom pathlib import Path\npath = Path('sites') / %s / 'site_config.json'\nwith path.open(encoding='utf-8') as handle:\n print(json.load(handle).get('db_name', ''))\nPY" \ - "$(shell_quote_site_command_arg "${site_name}")" - )" - - run_stack_backend_bash_command "${stack_dir}" "${read_command}" -} - -get_stack_common_db_endpoint() { - local stack_dir="${1}" - local read_command="" - - read_command="$( - cat <<'EOF' -python - <<'PY' -import json -from pathlib import Path -path = Path("sites/common_site_config.json") -with path.open(encoding="utf-8") as handle: - config = json.load(handle) -print(f"{config.get('db_host', '')}|{config.get('db_port', 3306)}") -PY -EOF - )" - - run_stack_backend_bash_command "${stack_dir}" "${read_command}" -} - -get_stack_site_runtime_app_names_lines() { - local stack_dir="${1}" - local site_name="${2}" - local list_apps_command="" - - if ! is_safe_stack_site_cleanup_name "${site_name}"; then - return 61 - fi - - list_apps_command="$( - printf "bench --site %s list-apps | awk 'NF { print \$1 }'" \ - "$(shell_quote_site_command_arg "${site_name}")" - )" - - run_stack_backend_bash_command "${stack_dir}" "${list_apps_command}" -} - -get_stack_runtime_available_app_lines() { - local stack_dir="${1}" - - run_stack_backend_bash_command "${stack_dir}" "ls -1 apps" -} - -get_stack_site_runtime_selected_apps_lines() { - local result_var="${1}" - local stack_dir="${2}" - local site_name="${3}" - local selected_app_lines="" - local runtime_app_lines="" - local selected_app_name="" - local installed_app_lines="" - - if ! get_stack_selected_installable_apps selected_app_lines "${stack_dir}"; then - printf -v "${result_var}" "%s" "" - return 0 - fi - - if [ -z "${selected_app_lines}" ]; then - printf -v "${result_var}" "%s" "" - return 0 - fi - - runtime_app_lines="$(get_stack_site_runtime_app_names_lines "${stack_dir}" "${site_name}" || true)" - if [ -z "${runtime_app_lines}" ]; then - printf -v "${result_var}" "%s" "" - return 1 - fi - - while IFS= read -r selected_app_name; do - if [ -z "${selected_app_name}" ]; then - continue - fi - - if ! printf '%s\n' "${runtime_app_lines}" | grep -F -x -- "${selected_app_name}" >/dev/null 2>&1; then - continue - fi - - if [ -z "${installed_app_lines}" ]; then - installed_app_lines="${selected_app_name}" - else - installed_app_lines="${installed_app_lines}"$'\n'"${selected_app_name}" - fi - done </dev/null 2>&1 || true - return 55 - fi - - return 0 -} - -install_stack_apps_on_site() { - local result_var="${1}" - local stack_dir="${2}" - local site_name="${3}" - local selected_app_lines="" - local installed_app_lines="" - local app_name="" - local install_app_command="" - local install_app_output="" - local available_app_lines="" - local -a selected_apps=() - - if ! get_stack_selected_installable_apps selected_app_lines "${stack_dir}"; then - printf -v "${result_var}" "%s" "" - return 0 - fi - - if [ -z "${selected_app_lines}" ]; then - printf -v "${result_var}" "%s" "" - return 0 - fi - - available_app_lines="$(get_stack_runtime_available_app_lines "${stack_dir}" || true)" - if [ -z "${available_app_lines}" ]; then - EASY_DOCKER_SITE_ERROR_DETAIL="Could not inspect available apps in the backend image." - capture_stack_site_error_log "${stack_dir}" "site-install-apps-error" "easy-docker could not list /home/frappe/frappe-bench/apps before install-app." >/dev/null 2>&1 || true - return 63 - fi - - mapfile -t selected_apps <<<"${selected_app_lines}" - for app_name in "${selected_apps[@]}"; do - if [ -z "${app_name}" ]; then - continue - fi - - if ! printf '%s\n' "${available_app_lines}" | grep -F -x -- "${app_name}" >/dev/null 2>&1; then - EASY_DOCKER_SITE_ERROR_DETAIL="$(printf "Selected app '%s' is not available in the backend image." "${app_name}")" - capture_stack_site_error_log "${stack_dir}" "site-install-apps-error" "$(printf "Selected app '%s' was requested in stack metadata but is missing from /home/frappe/frappe-bench/apps.\nAvailable apps:\n%s" "${app_name}" "${available_app_lines}")" >/dev/null 2>&1 || true - if [ -n "${EASY_DOCKER_SITE_ERROR_LOG_PATH}" ]; then - printf 'Details written to %s\n' "${stack_dir}/${EASY_DOCKER_SITE_ERROR_LOG_PATH}" >&2 - fi - printf -v "${result_var}" "%s" "${installed_app_lines}" - return 63 - fi - - install_app_command="$( - printf "bench --site %s install-app %s" \ - "$(shell_quote_site_command_arg "${site_name}")" \ - "$(shell_quote_site_command_arg "${app_name}")" - )" - - if ! run_stack_backend_bash_command_capture install_app_output "${stack_dir}" "${install_app_command}"; then - EASY_DOCKER_SITE_ERROR_DETAIL="$(printf "bench install-app failed for '%s'." "${app_name}")" - capture_stack_site_error_log "${stack_dir}" "site-install-apps-error" "${install_app_output}" >/dev/null 2>&1 || true - printf -v "${result_var}" "%s" "${installed_app_lines}" - return 56 - fi - - if [ -z "${installed_app_lines}" ]; then - installed_app_lines="${app_name}" - else - installed_app_lines="${installed_app_lines}"$'\n'"${app_name}" - fi - - if ! persist_stack_site_metadata \ - "${stack_dir}" \ - "single-site" \ - "${site_name}" \ - "apps_installing" \ - "${installed_app_lines}" \ - "install-apps" \ - "" \ - "" \ - "$(get_stack_site_created_at "${stack_dir}" || true)" \ - "$(get_current_utc_timestamp)"; then - return 58 - fi - done - - printf -v "${result_var}" "%s" "${installed_app_lines}" - return 0 -} - -bootstrap_first_stack_site() { - local stack_dir="${1}" - local site_name="${2}" - local admin_password="${3}" - local created_at="" - local updated_at="" - local installed_app_lines="" - local site_create_status=0 - local app_install_status=0 - local cleanup_status=0 - - if ! is_safe_stack_site_cleanup_name "${site_name}"; then - return 61 - fi - - if ! stack_supports_single_site_management "${stack_dir}"; then - return 52 - fi - - if ! stack_site_bootstrap_supports_database "${stack_dir}"; then - return 57 - fi - - if stack_has_site_configured "${stack_dir}"; then - return 53 - fi - - if ! stack_backend_service_is_running "${stack_dir}"; then - return 51 - fi - - if ! repair_stack_site_runtime_state "${stack_dir}"; then - return $? - fi - - if ! stack_database_service_is_reachable "${stack_dir}"; then - return 59 - fi - - created_at="$(get_current_utc_timestamp)" - updated_at="${created_at}" - if ! persist_stack_site_metadata "${stack_dir}" "single-site" "${site_name}" "requested" "" "create-site" "" "" "${created_at}" "${updated_at}"; then - return 58 - fi - - if cleanup_partial_stack_site "${stack_dir}" "${site_name}"; then - : - else - cleanup_status=$? - case "${cleanup_status}" in - 54 | 52) - return "${cleanup_status}" - ;; - 60) - mark_stack_site_failed "${stack_dir}" "${site_name}" "" "cleanup-partial-site" "Partial site artifacts could not be removed automatically. Manual cleanup is required." "" "" >/dev/null 2>&1 || true - return 60 - ;; - *) - mark_stack_site_failed "${stack_dir}" "${site_name}" "" "cleanup-partial-site" "Unexpected cleanup failure before create-site." "" "${created_at}" >/dev/null 2>&1 || true - return 60 - ;; - esac - fi - - updated_at="${created_at}" - if ! persist_stack_site_metadata "${stack_dir}" "single-site" "${site_name}" "creating" "" "create-site" "" "" "${created_at}" "${updated_at}"; then - return 58 - fi - - if create_first_stack_site "${stack_dir}" "${site_name}" "${admin_password}"; then - : - else - site_create_status=$? - if cleanup_partial_stack_site "${stack_dir}" "${site_name}"; then - mark_stack_site_failed "${stack_dir}" "${site_name}" "" "create-site" "bench new-site failed. Partial site data was cleaned up automatically." "${EASY_DOCKER_SITE_ERROR_LOG_PATH}" "${created_at}" >/dev/null 2>&1 || true - return "${site_create_status}" - fi - - cleanup_status=$? - mark_stack_site_failed "${stack_dir}" "${site_name}" "" "create-site" "bench new-site failed and partial site data could not be cleaned up automatically. Manual cleanup is required." "${EASY_DOCKER_SITE_ERROR_LOG_PATH}" "${created_at}" >/dev/null 2>&1 || true - case "${cleanup_status}" in - 54 | 52) - return "${cleanup_status}" - ;; - *) - return 60 - ;; - esac - fi - - updated_at="$(get_current_utc_timestamp)" - if ! persist_stack_site_metadata "${stack_dir}" "single-site" "${site_name}" "created" "" "create-site" "" "" "${created_at}" "${updated_at}"; then - return 58 - fi - - if install_stack_apps_on_site installed_app_lines "${stack_dir}" "${site_name}"; then - : - else - app_install_status=$? - case "${app_install_status}" in - 56 | 63) - if cleanup_partial_stack_site "${stack_dir}" "${site_name}"; then - mark_stack_site_failed "${stack_dir}" "${site_name}" "${installed_app_lines}" "install-apps" "${EASY_DOCKER_SITE_ERROR_DETAIL:-App installation failed. Partial site data was cleaned up automatically.}" "${EASY_DOCKER_SITE_ERROR_LOG_PATH}" "${created_at}" >/dev/null 2>&1 || true - else - cleanup_status=$? - mark_stack_site_failed "${stack_dir}" "${site_name}" "${installed_app_lines}" "install-apps" "${EASY_DOCKER_SITE_ERROR_DETAIL:-App installation failed and partial site data could not be cleaned up automatically. Manual cleanup is required.}" "${EASY_DOCKER_SITE_ERROR_LOG_PATH}" "${created_at}" >/dev/null 2>&1 || true - case "${cleanup_status}" in - 54 | 52) - return "${cleanup_status}" - ;; - *) - return 60 - ;; - esac - fi - ;; - 58) - return 58 - ;; - *) - mark_stack_site_failed "${stack_dir}" "${site_name}" "${installed_app_lines}" "install-apps" "Unknown app installation failure." "${EASY_DOCKER_SITE_ERROR_LOG_PATH}" "${created_at}" >/dev/null 2>&1 || true - ;; - esac - return "${app_install_status}" - fi - - updated_at="$(get_current_utc_timestamp)" - if ! persist_stack_site_metadata "${stack_dir}" "single-site" "${site_name}" "ready" "${installed_app_lines}" "install-apps" "" "" "${created_at}" "${updated_at}"; then - return 58 - fi - - return 0 -} +load_easy_docker_site_bootstrap_modules diff --git a/scripts/easy-docker/lib/app/wizard/common/site/bootstrap/errors.sh b/scripts/easy-docker/lib/app/wizard/common/site/bootstrap/errors.sh new file mode 100755 index 00000000..2b08c2b8 --- /dev/null +++ b/scripts/easy-docker/lib/app/wizard/common/site/bootstrap/errors.sh @@ -0,0 +1,89 @@ +#!/usr/bin/env bash + +reset_easy_docker_site_error_state() { + EASY_DOCKER_SITE_ERROR_DETAIL="" + EASY_DOCKER_SITE_ERROR_LOG_PATH="" +} + +build_stack_site_error_log_relative_path() { + local result_var="${1}" + local action_name="${2:-site-error}" + local raw_timestamp="" + local safe_timestamp="" + local relative_path="" + + raw_timestamp="$(get_current_utc_timestamp)" + safe_timestamp="$(printf '%s' "${raw_timestamp}" | tr ':' '-')" + relative_path="$(printf 'logs/%s-%s.log' "${action_name}" "${safe_timestamp}")" + printf -v "${result_var}" "%s" "${relative_path}" +} + +write_stack_site_error_log() { + local result_var="${1}" + local stack_dir="${2}" + local action_name="${3:-site-error}" + local error_output="${4:-}" + local relative_path="" + local log_dir="" + local absolute_path="" + + if [ -z "${error_output}" ]; then + printf -v "${result_var}" "%s" "" + return 0 + fi + + build_stack_site_error_log_relative_path relative_path "${action_name}" + log_dir="${stack_dir}/logs" + absolute_path="${stack_dir}/${relative_path}" + + if ! mkdir -p "${log_dir}"; then + return 1 + fi + + if ! printf '%s\n' "${error_output}" >"${absolute_path}"; then + return 1 + fi + + printf -v "${result_var}" "%s" "${relative_path}" + return 0 +} + +run_stack_backend_bash_command_capture() { + local result_var="${1}" + local stack_dir="${2}" + local backend_command="${3}" + local command_output="" + local command_status=0 + + reset_easy_docker_site_error_state + command_output="$(run_stack_backend_bash_command "${stack_dir}" "${backend_command}" 2>&1)" + command_status=$? + + if [ -n "${command_output}" ]; then + printf '%s\n' "${command_output}" + fi + + printf -v "${result_var}" "%s" "${command_output}" + return "${command_status}" +} + +capture_stack_site_error_log() { + local stack_dir="${1}" + local action_name="${2:-site-error}" + local error_output="${3:-}" + local log_path="" + + EASY_DOCKER_SITE_ERROR_LOG_PATH="" + if [ -z "${error_output}" ]; then + return 0 + fi + + if ! write_stack_site_error_log log_path "${stack_dir}" "${action_name}" "${error_output}"; then + EASY_DOCKER_SITE_ERROR_DETAIL="${EASY_DOCKER_SITE_ERROR_DETAIL:-Failed to write site error log.}" + return 1 + fi + + # shellcheck disable=SC2034 # Read by manage flow after site bootstrap failures. + EASY_DOCKER_SITE_ERROR_LOG_PATH="${log_path}" + return 0 +} diff --git a/scripts/easy-docker/lib/app/wizard/common/site/bootstrap/lifecycle.sh b/scripts/easy-docker/lib/app/wizard/common/site/bootstrap/lifecycle.sh new file mode 100755 index 00000000..6efb2249 --- /dev/null +++ b/scripts/easy-docker/lib/app/wizard/common/site/bootstrap/lifecycle.sh @@ -0,0 +1,409 @@ +#!/usr/bin/env bash + +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 +} + +delete_configured_stack_site() { + local stack_dir="${1}" + local site_name="" + local delete_status=0 + + if ! stack_supports_single_site_management "${stack_dir}"; then + return 52 + fi + + site_name="$(get_stack_site_name "${stack_dir}" || true)" + if ! is_safe_stack_site_cleanup_name "${site_name}"; then + return 61 + fi + + if ! stack_backend_service_is_running "${stack_dir}"; then + return 51 + fi + + if cleanup_partial_stack_site "${stack_dir}" "${site_name}"; then + : + else + delete_status=$? + case "${delete_status}" in + 54 | 52 | 61) + return "${delete_status}" + ;; + *) + return 60 + ;; + esac + fi + + if ! clear_stack_site_metadata "${stack_dir}"; then + return 58 + fi + + return 0 +} + +create_first_stack_site() { + local stack_dir="${1}" + local site_name="${2}" + local admin_password="${3}" + local create_site_command="" + local create_site_output="" + + 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_capture create_site_output "${stack_dir}" "${create_site_command}"; then + EASY_DOCKER_SITE_ERROR_DETAIL="bench new-site failed." + capture_stack_site_error_log "${stack_dir}" "site-create-error" "${create_site_output}" >/dev/null 2>&1 || true + return 55 + fi + + return 0 +} + +install_stack_apps_on_site() { + local result_var="${1}" + local stack_dir="${2}" + local site_name="${3}" + local selected_app_lines="" + local installed_app_lines="" + local app_name="" + local install_app_command="" + local install_app_output="" + local available_app_lines="" + local -a selected_apps=() + + if ! get_stack_selected_installable_apps selected_app_lines "${stack_dir}"; then + printf -v "${result_var}" "%s" "" + return 0 + fi + + if [ -z "${selected_app_lines}" ]; then + printf -v "${result_var}" "%s" "" + return 0 + fi + + available_app_lines="$(get_stack_runtime_available_app_lines "${stack_dir}" || true)" + if [ -z "${available_app_lines}" ]; then + EASY_DOCKER_SITE_ERROR_DETAIL="Could not inspect available apps in the backend image." + capture_stack_site_error_log "${stack_dir}" "site-install-apps-error" "easy-docker could not list /home/frappe/frappe-bench/apps before install-app." >/dev/null 2>&1 || true + return 63 + fi + + mapfile -t selected_apps <<<"${selected_app_lines}" + for app_name in "${selected_apps[@]}"; do + if [ -z "${app_name}" ]; then + continue + fi + + if ! printf '%s\n' "${available_app_lines}" | grep -F -x -- "${app_name}" >/dev/null 2>&1; then + EASY_DOCKER_SITE_ERROR_DETAIL="$(printf "Selected app '%s' is not available in the backend image." "${app_name}")" + capture_stack_site_error_log "${stack_dir}" "site-install-apps-error" "$(printf "Selected app '%s' was requested in stack metadata but is missing from /home/frappe/frappe-bench/apps.\nAvailable apps:\n%s" "${app_name}" "${available_app_lines}")" >/dev/null 2>&1 || true + if [ -n "${EASY_DOCKER_SITE_ERROR_LOG_PATH}" ]; then + printf 'Details written to %s\n' "${stack_dir}/${EASY_DOCKER_SITE_ERROR_LOG_PATH}" >&2 + fi + printf -v "${result_var}" "%s" "${installed_app_lines}" + return 63 + fi + + install_app_command="$( + printf "bench --site %s install-app %s" \ + "$(shell_quote_site_command_arg "${site_name}")" \ + "$(shell_quote_site_command_arg "${app_name}")" + )" + + if ! run_stack_backend_bash_command_capture install_app_output "${stack_dir}" "${install_app_command}"; then + EASY_DOCKER_SITE_ERROR_DETAIL="$(printf "bench install-app failed for '%s'." "${app_name}")" + capture_stack_site_error_log "${stack_dir}" "site-install-apps-error" "${install_app_output}" >/dev/null 2>&1 || true + printf -v "${result_var}" "%s" "${installed_app_lines}" + return 56 + fi + + if [ -z "${installed_app_lines}" ]; then + installed_app_lines="${app_name}" + else + installed_app_lines="${installed_app_lines}"$'\n'"${app_name}" + fi + + if ! persist_stack_site_metadata \ + "${stack_dir}" \ + "single-site" \ + "${site_name}" \ + "apps_installing" \ + "${installed_app_lines}" \ + "install-apps" \ + "" \ + "" \ + "$(get_stack_site_created_at "${stack_dir}" || true)" \ + "$(get_current_utc_timestamp)"; then + return 58 + fi + done + + printf -v "${result_var}" "%s" "${installed_app_lines}" + return 0 +} + +bootstrap_first_stack_site() { + local stack_dir="${1}" + local site_name="${2}" + local admin_password="${3}" + local created_at="" + local updated_at="" + local installed_app_lines="" + local site_create_status=0 + local app_install_status=0 + local cleanup_status=0 + + if ! is_safe_stack_site_cleanup_name "${site_name}"; then + return 61 + fi + + if ! stack_supports_single_site_management "${stack_dir}"; then + return 52 + fi + + if ! stack_site_bootstrap_supports_database "${stack_dir}"; then + return 57 + fi + + if stack_has_site_configured "${stack_dir}"; then + return 53 + fi + + if ! stack_backend_service_is_running "${stack_dir}"; then + return 51 + fi + + if ! repair_stack_site_runtime_state "${stack_dir}"; then + return $? + fi + + if ! stack_database_service_is_reachable "${stack_dir}"; then + return 59 + fi + + created_at="$(get_current_utc_timestamp)" + updated_at="${created_at}" + if ! persist_stack_site_metadata "${stack_dir}" "single-site" "${site_name}" "requested" "" "create-site" "" "" "${created_at}" "${updated_at}"; then + return 58 + fi + + if cleanup_partial_stack_site "${stack_dir}" "${site_name}"; then + : + else + cleanup_status=$? + case "${cleanup_status}" in + 54 | 52) + return "${cleanup_status}" + ;; + 60) + mark_stack_site_failed "${stack_dir}" "${site_name}" "" "cleanup-partial-site" "Partial site artifacts could not be removed automatically. Manual cleanup is required." "" "" >/dev/null 2>&1 || true + return 60 + ;; + *) + mark_stack_site_failed "${stack_dir}" "${site_name}" "" "cleanup-partial-site" "Unexpected cleanup failure before create-site." "" "${created_at}" >/dev/null 2>&1 || true + return 60 + ;; + esac + fi + + updated_at="${created_at}" + if ! persist_stack_site_metadata "${stack_dir}" "single-site" "${site_name}" "creating" "" "create-site" "" "" "${created_at}" "${updated_at}"; then + return 58 + fi + + if create_first_stack_site "${stack_dir}" "${site_name}" "${admin_password}"; then + : + else + site_create_status=$? + if cleanup_partial_stack_site "${stack_dir}" "${site_name}"; then + mark_stack_site_failed "${stack_dir}" "${site_name}" "" "create-site" "bench new-site failed. Partial site data was cleaned up automatically." "${EASY_DOCKER_SITE_ERROR_LOG_PATH}" "${created_at}" >/dev/null 2>&1 || true + return "${site_create_status}" + fi + + cleanup_status=$? + mark_stack_site_failed "${stack_dir}" "${site_name}" "" "create-site" "bench new-site failed and partial site data could not be cleaned up automatically. Manual cleanup is required." "${EASY_DOCKER_SITE_ERROR_LOG_PATH}" "${created_at}" >/dev/null 2>&1 || true + case "${cleanup_status}" in + 54 | 52) + return "${cleanup_status}" + ;; + *) + return 60 + ;; + esac + fi + + updated_at="$(get_current_utc_timestamp)" + if ! persist_stack_site_metadata "${stack_dir}" "single-site" "${site_name}" "created" "" "create-site" "" "" "${created_at}" "${updated_at}"; then + return 58 + fi + + if install_stack_apps_on_site installed_app_lines "${stack_dir}" "${site_name}"; then + : + else + app_install_status=$? + case "${app_install_status}" in + 56 | 63) + if cleanup_partial_stack_site "${stack_dir}" "${site_name}"; then + mark_stack_site_failed "${stack_dir}" "${site_name}" "${installed_app_lines}" "install-apps" "${EASY_DOCKER_SITE_ERROR_DETAIL:-App installation failed. Partial site data was cleaned up automatically.}" "${EASY_DOCKER_SITE_ERROR_LOG_PATH}" "${created_at}" >/dev/null 2>&1 || true + else + cleanup_status=$? + mark_stack_site_failed "${stack_dir}" "${site_name}" "${installed_app_lines}" "install-apps" "${EASY_DOCKER_SITE_ERROR_DETAIL:-App installation failed and partial site data could not be cleaned up automatically. Manual cleanup is required.}" "${EASY_DOCKER_SITE_ERROR_LOG_PATH}" "${created_at}" >/dev/null 2>&1 || true + case "${cleanup_status}" in + 54 | 52) + return "${cleanup_status}" + ;; + *) + return 60 + ;; + esac + fi + ;; + 58) + return 58 + ;; + *) + mark_stack_site_failed "${stack_dir}" "${site_name}" "${installed_app_lines}" "install-apps" "Unknown app installation failure." "${EASY_DOCKER_SITE_ERROR_LOG_PATH}" "${created_at}" >/dev/null 2>&1 || true + ;; + esac + return "${app_install_status}" + fi + + updated_at="$(get_current_utc_timestamp)" + if ! persist_stack_site_metadata "${stack_dir}" "single-site" "${site_name}" "ready" "${installed_app_lines}" "install-apps" "" "" "${created_at}" "${updated_at}"; then + return 58 + fi + + return 0 +} 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 new file mode 100755 index 00000000..59315fd6 --- /dev/null +++ b/scripts/easy-docker/lib/app/wizard/common/site/bootstrap/runtime.sh @@ -0,0 +1,157 @@ +#!/usr/bin/env bash + +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 </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 +} + +get_stack_common_db_endpoint() { + local stack_dir="${1}" + local read_command="" + + read_command="$( + cat <<'EOF' +python - <<'PY' +import json +from pathlib import Path +path = Path("sites/common_site_config.json") +with path.open(encoding="utf-8") as handle: + config = json.load(handle) +print(f"{config.get('db_host', '')}|{config.get('db_port', 3306)}") +PY +EOF + )" + + run_stack_backend_bash_command "${stack_dir}" "${read_command}" +} + +get_stack_runtime_available_app_lines() { + local stack_dir="${1}" + + run_stack_backend_bash_command "${stack_dir}" "ls -1 apps" +} diff --git a/scripts/easy-docker/lib/app/wizard/common/site/bootstrap/state.sh b/scripts/easy-docker/lib/app/wizard/common/site/bootstrap/state.sh new file mode 100755 index 00000000..79354c07 --- /dev/null +++ b/scripts/easy-docker/lib/app/wizard/common/site/bootstrap/state.sh @@ -0,0 +1,253 @@ +#!/usr/bin/env bash + +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_site_runtime_app_names_lines() { + local stack_dir="${1}" + local site_name="${2}" + local list_apps_command="" + + if ! is_safe_stack_site_cleanup_name "${site_name}"; then + return 61 + fi + + list_apps_command="$( + printf "bench --site %s list-apps | awk 'NF { print \$1 }'" \ + "$(shell_quote_site_command_arg "${site_name}")" + )" + + run_stack_backend_bash_command "${stack_dir}" "${list_apps_command}" +} + +get_stack_site_runtime_selected_apps_lines() { + local result_var="${1}" + local stack_dir="${2}" + local site_name="${3}" + local selected_app_lines="" + local runtime_app_lines="" + local selected_app_name="" + local installed_app_lines="" + + if ! get_stack_selected_installable_apps selected_app_lines "${stack_dir}"; then + printf -v "${result_var}" "%s" "" + return 0 + fi + + if [ -z "${selected_app_lines}" ]; then + printf -v "${result_var}" "%s" "" + return 0 + fi + + runtime_app_lines="$(get_stack_site_runtime_app_names_lines "${stack_dir}" "${site_name}" || true)" + if [ -z "${runtime_app_lines}" ]; then + printf -v "${result_var}" "%s" "" + return 1 + fi + + while IFS= read -r selected_app_name; do + if [ -z "${selected_app_name}" ]; then + continue + fi + + if ! printf '%s\n' "${runtime_app_lines}" | grep -F -x -- "${selected_app_name}" >/dev/null 2>&1; then + continue + fi + + if [ -z "${installed_app_lines}" ]; then + installed_app_lines="${selected_app_name}" + else + installed_app_lines="${installed_app_lines}"$'\n'"${selected_app_name}" + fi + done <"${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 error_log_path="${6:-}" - local created_at="${7:-}" - 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}" "${error_log_path}" "${created_at}" "${updated_at}" -} - -clear_stack_site_metadata() { - local stack_dir="${1}" - local updated_at="" - - updated_at="$(get_current_utc_timestamp)" - persist_stack_site_metadata "${stack_dir}" "single-site" "" "not_created" "" "delete-site" "" "" "" "${updated_at}" -} +load_easy_docker_site_metadata_modules diff --git a/scripts/easy-docker/lib/app/wizard/common/site/metadata/read.sh b/scripts/easy-docker/lib/app/wizard/common/site/metadata/read.sh new file mode 100755 index 00000000..ace5e3dd --- /dev/null +++ b/scripts/easy-docker/lib/app/wizard/common/site/metadata/read.sh @@ -0,0 +1,184 @@ +#!/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 +} diff --git a/scripts/easy-docker/lib/app/wizard/common/site/metadata/write.sh b/scripts/easy-docker/lib/app/wizard/common/site/metadata/write.sh new file mode 100755 index 00000000..1d35697e --- /dev/null +++ b/scripts/easy-docker/lib/app/wizard/common/site/metadata/write.sh @@ -0,0 +1,179 @@ +#!/usr/bin/env bash + +build_stack_site_apps_installed_json_array() { + local result_var="${1}" + local apps_installed_lines="${2:-}" + local app_name="" + local escaped_app_name="" + local entries_json="" + + while IFS= read -r app_name; do + if [ -z "${app_name}" ]; then + continue + fi + + escaped_app_name="$(json_escape_string "${app_name}")" + if [ -z "${entries_json}" ]; then + entries_json="$(printf ' "%s"' "${escaped_app_name}")" + else + entries_json="${entries_json}"$',\n'"$(printf ' "%s"' "${escaped_app_name}")" + fi + done <"${metadata_tmp_path}"; then + rm -f -- "${metadata_tmp_path}" >/dev/null 2>&1 || true + return 1 + fi + + if ! mv -- "${metadata_tmp_path}" "${metadata_path}"; then + rm -f -- "${metadata_tmp_path}" >/dev/null 2>&1 || true + return 1 + fi + + return 0 +} + +mark_stack_site_failed() { + local stack_dir="${1}" + local site_name="${2:-}" + local apps_installed_lines="${3:-}" + local last_action="${4:-bootstrap-site}" + local last_error="${5:-Unknown site bootstrap failure}" + local error_log_path="${6:-}" + local created_at="${7:-}" + 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}" "${error_log_path}" "${created_at}" "${updated_at}" +} + +clear_stack_site_metadata() { + local stack_dir="${1}" + local updated_at="" + + updated_at="$(get_current_utc_timestamp)" + persist_stack_site_metadata "${stack_dir}" "single-site" "" "not_created" "" "delete-site" "" "" "" "${updated_at}" +} diff --git a/scripts/easy-docker/lib/app/wizard/flows/manage.sh b/scripts/easy-docker/lib/app/wizard/flows/manage.sh index 639f5e06..1e9a98ec 100755 --- a/scripts/easy-docker/lib/app/wizard/flows/manage.sh +++ b/scripts/easy-docker/lib/app/wizard/flows/manage.sh @@ -1,662 +1,17 @@ #!/usr/bin/env bash -run_build_stack_custom_image_with_feedback() { - local stack_name="${1}" - local stack_dir="${2}" - local build_image_status=0 +load_easy_docker_manage_flow_modules() { + local manage_dir="" + manage_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/manage" - show_warning_message "Starting docker build for stack: ${stack_name}" - if build_stack_custom_image "${stack_dir}"; then - show_warning_and_wait "Custom image build finished successfully for stack: ${stack_name}" 3 - return 0 - fi - - build_image_status=$? - case "${build_image_status}" in - 11) - show_warning_and_wait "Custom image build failed: missing metadata.json in ${stack_dir}." 4 - ;; - 12) - show_warning_and_wait "Custom image build failed: stack env file not found in ${stack_dir}." 4 - ;; - 13) - show_warning_and_wait "Custom image build failed: CUSTOM_IMAGE is missing in stack env file." 4 - ;; - 14) - show_warning_and_wait "Custom image build failed: CUSTOM_TAG is missing in stack env file." 4 - ;; - 15) - show_warning_and_wait "Custom image build failed: frappe_branch missing in metadata.json." 4 - ;; - 16) - show_warning_and_wait "Custom image build failed: could not generate apps.json from metadata app selection." 4 - ;; - 17) - show_warning_and_wait "Custom image build failed: apps.json not found after generation." 4 - ;; - 18) - show_warning_and_wait "Custom image build failed: base64 command is not available in this environment." 4 - ;; - 19) - show_warning_and_wait "Custom image build failed: apps.json could not be base64-encoded." 4 - ;; - 20) - show_warning_and_wait "Custom image build failed: images/layered/Containerfile not found." 4 - ;; - 21) - show_warning_and_wait "Custom image build failed: docker build returned an error. Check the output above." 4 - ;; - 22) - show_warning_and_wait "Custom image build failed: git is required for app branch precheck (git ls-remote)." 4 - ;; - 23) - show_warning_and_wait "Custom image build failed: could not parse app entries from apps.json." 4 - ;; - 24) - show_warning_and_wait "Custom image build failed: app branch precheck failed -> ${EASY_DOCKER_BUILD_ERROR_DETAIL}" 6 - ;; - *) - show_warning_and_wait "Custom image build failed (${build_image_status})." 4 - ;; - esac - - return "${build_image_status}" + # shellcheck source=scripts/easy-docker/lib/app/wizard/flows/manage/docker.sh + source "${manage_dir}/docker.sh" + # shellcheck source=scripts/easy-docker/lib/app/wizard/flows/manage/prompts.sh + source "${manage_dir}/prompts.sh" + # shellcheck source=scripts/easy-docker/lib/app/wizard/flows/manage/site.sh + source "${manage_dir}/site.sh" + # shellcheck source=scripts/easy-docker/lib/app/wizard/flows/manage/stack.sh + source "${manage_dir}/stack.sh" } -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 -} - -prompt_manage_stack_delete_keyword_with_cancel() { - local result_var="${1}" - local stack_name="${2}" - local delete_confirmation="" - local prompt_status=0 - - while true; do - delete_confirmation="$(prompt_manage_stack_delete_keyword "${stack_name}")" - prompt_status=$? - if [ "${prompt_status}" -ne 0 ]; then - return "${FLOW_ABORT_INPUT}" - fi - - delete_confirmation="$(printf '%s' "${delete_confirmation}" | tr -d '\r\n')" - case "${delete_confirmation}" in - /back | /Back | /BACK | "") - return "${FLOW_ABORT_INPUT}" - ;; - delete) - printf -v "${result_var}" "%s" "${delete_confirmation}" - return "${FLOW_CONTINUE}" - ;; - *) - show_warning_and_wait "Type exactly delete to confirm stack removal." 3 - ;; - esac - done -} - -handle_manage_stack_site_flow() { - local stack_name="${1}" - local stack_dir="${2}" - 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="" - local site_delete_confirmation="" - - while true; do - 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}" "${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. ${EASY_DOCKER_SITE_ERROR_DETAIL:-Check the output above for bench new-site details.} ${EASY_DOCKER_SITE_ERROR_LOG_PATH:+See ${stack_dir}/${EASY_DOCKER_SITE_ERROR_LOG_PATH}}" 6 - ;; - 56) - show_warning_and_wait "The site was created, but app installation failed. ${EASY_DOCKER_SITE_ERROR_DETAIL:-Check the output above.} ${EASY_DOCKER_SITE_ERROR_LOG_PATH:+See ${stack_dir}/${EASY_DOCKER_SITE_ERROR_LOG_PATH}}" 6 - ;; - 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 - ;; - 63) - show_warning_and_wait "Cannot install the selected stack apps because at least one app is missing from the backend image. ${EASY_DOCKER_SITE_ERROR_DETAIL} ${EASY_DOCKER_SITE_ERROR_LOG_PATH:+See ${stack_dir}/${EASY_DOCKER_SITE_ERROR_LOG_PATH}}" 7 - ;; - *) - 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 stack_backend_service_is_running "${stack_dir}" >/dev/null 2>&1; then - get_stack_site_runtime_selected_apps_lines existing_site_apps_lines "${stack_dir}" "${existing_site_name}" || true - fi - 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}" \ - "${existing_site_created_at}" \ - "${existing_site_apps_csv}" || true - )" - case "${existing_site_details_action}" in - "Delete site") - site_delete_confirmation="$( - show_manage_stack_site_delete_confirmation \ - "${stack_name}" \ - "${stack_dir}" \ - "${existing_site_name}" || true - )" - case "${site_delete_confirmation}" in - "Yes") - show_warning_message "Deleting site for stack: ${stack_name}" - if delete_configured_stack_site "${stack_dir}"; then - show_warning_and_wait "Site deleted successfully with its database: ${existing_site_name}" 3 - continue - fi - - site_flow_status=$? - case "${site_flow_status}" in - 51) - show_warning_and_wait "Cannot delete site: backend service is not running yet. Start the stack first." 4 - ;; - 52) - show_warning_and_wait "Cannot delete site for this topology yet. Only single-host stacks are supported." 4 - ;; - 54) - show_warning_and_wait "Cannot delete site because stack metadata, env, or compose inputs are incomplete." 4 - ;; - 58) - show_warning_and_wait "The cleared site state could not be written to metadata.json." 4 - ;; - 60) - show_warning_and_wait "Site deletion could not remove all site or database data automatically. Manual cleanup is required." 5 - ;; - 61) - show_warning_and_wait "Cannot delete site because the configured site name was empty or unsafe for cleanup operations." 4 - ;; - *) - show_warning_and_wait "Site deletion failed (${site_flow_status})." 4 - ;; - esac - continue - ;; - "No" | "") - continue - ;; - "Exit and close easy-docker") - return "${FLOW_EXIT_APP}" - ;; - *) - show_warning_and_wait "Unknown site delete confirmation action: ${site_delete_confirmation}" 2 - continue - ;; - esac - ;; - "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="" - local stack_action="" - local apps_action="" - local docker_action="" - local stack_metadata_path="" - local stack_apps_path="" - local custom_apps_update_status=0 - local persist_apps_status=0 - local render_compose_status=0 - local compose_start_status=0 - local generated_compose_path="" - local stack_runtime_status="" - local missing_custom_image_action="" - local delete_stack_confirmation_action="" - local delete_stack_keyword="" - - stack_dir="$(get_stack_dir_by_name "${stack_name}" || true)" - if [ -z "${stack_dir}" ]; then - show_warning_and_wait "Could not resolve stack directory for '${stack_name}'." 2 - return "${FLOW_CONTINUE}" - fi - - while true; do - get_stack_compose_runtime_status_label stack_runtime_status "${stack_dir}" - stack_action="$(show_manage_stack_actions_menu "${stack_name}" "${stack_dir}" "${stack_runtime_status}" || true)" - case "${stack_action}" in - "Apps") - while true; do - apps_action="$(show_manage_stack_apps_menu "${stack_name}" "${stack_dir}" || true)" - case "${apps_action}" in - "Regenerate apps.json from metadata") - stack_metadata_path="${stack_dir}/metadata.json" - stack_apps_path="${stack_dir}/apps.json" - if [ ! -f "${stack_metadata_path}" ]; then - show_warning_and_wait "Cannot generate apps.json because metadata is missing: ${stack_metadata_path}" 3 - continue - fi - - if persist_stack_apps_json_from_metadata_apps "${stack_dir}"; then - : - else - persist_apps_status=$? - show_warning_and_wait "Could not generate ${stack_apps_path} (${persist_apps_status})." 3 - continue - fi - - show_warning_and_wait "apps.json generated successfully: ${stack_apps_path}" 3 - ;; - "Select apps and branches") - if update_stack_custom_modular_apps "${stack_dir}"; then - : - else - custom_apps_update_status=$? - case "${custom_apps_update_status}" in - 2 | 130) - continue - ;; - 3) - stack_metadata_path="${stack_dir}/metadata.json" - show_warning_and_wait "Cannot update app selection because metadata is missing: ${stack_metadata_path}" 3 - continue - ;; - *) - show_warning_and_wait "Could not update app selection (${custom_apps_update_status}) for stack: ${stack_name}" 3 - continue - ;; - esac - fi - - stack_apps_path="${stack_dir}/apps.json" - show_warning_and_wait "App selection updated in ${stack_dir}/metadata.json and ${stack_apps_path}." 3 - ;; - "Back" | "") - break - ;; - "Exit and close easy-docker") - return "${FLOW_EXIT_APP}" - ;; - *) - show_warning_and_wait "Unknown apps action: ${apps_action}" - ;; - esac - done - ;; - "Start stack in Docker Compose") - while true; do - show_warning_message "Starting stack with docker compose: ${stack_name}" - if start_stack_with_compose_from_metadata "${stack_dir}"; then - show_warning_and_wait "Stack started successfully with docker compose: ${stack_name}" 3 - break - else - compose_start_status=$? - fi - case "${compose_start_status}" in - 31) - show_warning_and_wait "Cannot start stack: metadata.json is missing in ${stack_dir}." 4 - break - ;; - 32) - show_warning_and_wait "Cannot start stack: stack env file not found in ${stack_dir}." 4 - break - ;; - 33) - show_warning_and_wait "Cannot start stack: topology is missing in metadata.json. Re-run the topology wizard for this stack." 4 - break - ;; - 34) - show_warning_and_wait "Cannot start stack via docker compose for topology '${EASY_DOCKER_COMPOSE_ERROR_DETAIL}'. Use the topology-specific runbook path." 5 - break - ;; - 35) - show_warning_and_wait "Cannot start stack: no compose files configured in metadata.json." 4 - break - ;; - 36) - show_warning_and_wait "Cannot start stack: compose file is missing -> ${EASY_DOCKER_COMPOSE_ERROR_DETAIL}" 4 - break - ;; - 37) - show_warning_and_wait "docker compose up failed. Check the output above for details." 4 - break - ;; - 38) - missing_custom_image_action="$( - show_missing_custom_image_start_menu "${stack_name}" "${stack_dir}" "${EASY_DOCKER_COMPOSE_ERROR_DETAIL}" || true - )" - case "${missing_custom_image_action}" in - "Build custom image now") - if run_build_stack_custom_image_with_feedback "${stack_name}" "${stack_dir}"; then - continue - fi - break - ;; - "Back" | "") - break - ;; - "Exit and close easy-docker") - return "${FLOW_EXIT_APP}" - ;; - *) - show_warning_and_wait "Unknown missing-image action: ${missing_custom_image_action}" 2 - break - ;; - esac - ;; - 39) - show_warning_and_wait "Cannot inspect custom image before start. Check Docker and try again. Details: ${EASY_DOCKER_COMPOSE_ERROR_DETAIL}" 5 - break - ;; - *) - show_warning_and_wait "Cannot start stack with docker compose (${compose_start_status})." 4 - break - ;; - esac - done - ;; - "Stop stack in Docker Compose") - show_warning_message "Stopping stack with docker compose: ${stack_name}" - if stop_stack_with_compose_from_metadata "${stack_dir}"; then - show_warning_and_wait "Stack stopped successfully with docker compose: ${stack_name}" 3 - continue - fi - - compose_start_status=$? - case "${compose_start_status}" in - 41) - show_warning_and_wait "Cannot stop stack: metadata.json is missing in ${stack_dir}." 4 - ;; - 42) - show_warning_and_wait "Cannot stop stack: stack env file not found in ${stack_dir}." 4 - ;; - 43) - show_warning_and_wait "Cannot stop stack: topology is missing in metadata.json. Re-run the topology wizard for this stack." 4 - ;; - 44) - show_warning_and_wait "Cannot stop stack via docker compose for topology '${EASY_DOCKER_COMPOSE_ERROR_DETAIL}'. Use the topology-specific runbook path." 5 - ;; - 45) - show_warning_and_wait "Cannot stop stack: no compose files configured in metadata.json." 4 - ;; - 46) - show_warning_and_wait "Cannot stop stack: compose file is missing -> ${EASY_DOCKER_COMPOSE_ERROR_DETAIL}" 4 - ;; - 47) - show_warning_and_wait "docker compose stop failed. Check the output above for details." 4 - ;; - *) - show_warning_and_wait "Cannot stop stack with docker compose (${compose_start_status})." 4 - ;; - esac - ;; - "Delete stack") - delete_stack_confirmation_action="$( - show_manage_stack_delete_confirmation "${stack_name}" "${stack_dir}" || true - )" - case "${delete_stack_confirmation_action}" in - "Yes") - if ! prompt_manage_stack_delete_keyword_with_cancel delete_stack_keyword "${stack_name}"; then - continue - fi - if [ "${delete_stack_keyword}" != "delete" ]; then - continue - fi - - show_warning_message "Deleting stack with docker compose resources: ${stack_name}" - if delete_stack_with_compose_from_metadata "${stack_dir}"; then - show_warning_and_wait "Stack deleted successfully with containers, networks, volumes, image, and stack directory: ${stack_name}" 5 - return "${FLOW_CONTINUE}" - fi - - compose_start_status=$? - case "${compose_start_status}" in - 48) - show_warning_and_wait "Cannot delete stack: metadata.json is missing in ${stack_dir}." 4 - ;; - 49) - show_warning_and_wait "Cannot delete stack: stack env file not found in ${stack_dir}." 4 - ;; - 50) - show_warning_and_wait "Cannot delete stack: topology is missing in metadata.json. Re-run the topology wizard for this stack." 4 - ;; - 51) - show_warning_and_wait "Cannot delete stack via docker compose for topology '${EASY_DOCKER_COMPOSE_ERROR_DETAIL}'. Use the topology-specific runbook path." 5 - ;; - 52) - show_warning_and_wait "Cannot delete stack: no compose files configured in metadata.json." 4 - ;; - 53) - show_warning_and_wait "Cannot delete stack: compose file is missing -> ${EASY_DOCKER_COMPOSE_ERROR_DETAIL}" 4 - ;; - 54) - show_warning_and_wait "docker compose down failed. Check the output above for details." 4 - ;; - 55) - show_warning_and_wait "Stack resources were removed, but the configured custom image could not be deleted -> ${EASY_DOCKER_COMPOSE_ERROR_DETAIL}" 5 - ;; - 56) - show_warning_and_wait "Docker resources were removed, but the stack directory could not be deleted -> ${EASY_DOCKER_COMPOSE_ERROR_DETAIL}" 5 - ;; - *) - show_warning_and_wait "Cannot delete stack with docker compose (${compose_start_status})." 4 - ;; - esac - ;; - "No" | "") - continue - ;; - "Exit and close easy-docker") - return "${FLOW_EXIT_APP}" - ;; - *) - show_warning_and_wait "Unknown delete-stack action: ${delete_stack_confirmation_action}" 2 - ;; - esac - ;; - "Docker") - while true; do - docker_action="$(show_manage_stack_docker_menu "${stack_name}" "${stack_dir}" || true)" - case "${docker_action}" in - "Build custom image") - if run_build_stack_custom_image_with_feedback "${stack_name}" "${stack_dir}"; then - : - else - continue - fi - ;; - "Generate docker compose from env") - generated_compose_path="$(get_stack_generated_compose_path "${stack_dir}")" - if render_stack_compose_from_metadata "${stack_dir}"; then - : - else - render_compose_status=$? - show_warning_and_wait "Could not generate docker compose (${render_compose_status}) for ${generated_compose_path}." 3 - continue - fi - - show_warning_and_wait "Docker compose generated successfully: ${generated_compose_path}" 3 - ;; - "Back" | "") - break - ;; - "Exit and close easy-docker") - return "${FLOW_EXIT_APP}" - ;; - *) - show_warning_and_wait "Unknown docker action: ${docker_action}" - ;; - 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}" - ;; - "Exit and close easy-docker") - return "${FLOW_EXIT_APP}" - ;; - *) - show_warning_and_wait "Unknown stack action: ${stack_action}" - ;; - esac - done -} +load_easy_docker_manage_flow_modules diff --git a/scripts/easy-docker/lib/app/wizard/flows/manage/build.sh b/scripts/easy-docker/lib/app/wizard/flows/manage/build.sh new file mode 100755 index 00000000..9d18a0fb --- /dev/null +++ b/scripts/easy-docker/lib/app/wizard/flows/manage/build.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash + +run_build_stack_custom_image_with_feedback() { + local stack_name="${1}" + local stack_dir="${2}" + local build_image_status=0 + + show_warning_message "Starting docker build for stack: ${stack_name}" + if build_stack_custom_image "${stack_dir}"; then + show_warning_and_wait "Custom image build finished successfully for stack: ${stack_name}" 3 + return 0 + fi + + build_image_status=$? + case "${build_image_status}" in + 11) + show_warning_and_wait "Custom image build failed: missing metadata.json in ${stack_dir}." 4 + ;; + 12) + show_warning_and_wait "Custom image build failed: stack env file not found in ${stack_dir}." 4 + ;; + 13) + show_warning_and_wait "Custom image build failed: CUSTOM_IMAGE is missing in stack env file." 4 + ;; + 14) + show_warning_and_wait "Custom image build failed: CUSTOM_TAG is missing in stack env file." 4 + ;; + 15) + show_warning_and_wait "Custom image build failed: frappe_branch missing in metadata.json." 4 + ;; + 16) + show_warning_and_wait "Custom image build failed: could not generate apps.json from metadata app selection." 4 + ;; + 17) + show_warning_and_wait "Custom image build failed: apps.json not found after generation." 4 + ;; + 18) + show_warning_and_wait "Custom image build failed: base64 command is not available in this environment." 4 + ;; + 19) + show_warning_and_wait "Custom image build failed: apps.json could not be base64-encoded." 4 + ;; + 20) + show_warning_and_wait "Custom image build failed: images/layered/Containerfile not found." 4 + ;; + 21) + show_warning_and_wait "Custom image build failed: docker build returned an error. Check the output above." 4 + ;; + 22) + show_warning_and_wait "Custom image build failed: git is required for app branch precheck (git ls-remote)." 4 + ;; + 23) + show_warning_and_wait "Custom image build failed: could not parse app entries from apps.json." 4 + ;; + 24) + show_warning_and_wait "Custom image build failed: app branch precheck failed -> ${EASY_DOCKER_BUILD_ERROR_DETAIL}" 6 + ;; + *) + show_warning_and_wait "Custom image build failed (${build_image_status})." 4 + ;; + esac + + return "${build_image_status}" +} diff --git a/scripts/easy-docker/lib/app/wizard/flows/manage/docker.sh b/scripts/easy-docker/lib/app/wizard/flows/manage/docker.sh new file mode 100755 index 00000000..9d18a0fb --- /dev/null +++ b/scripts/easy-docker/lib/app/wizard/flows/manage/docker.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash + +run_build_stack_custom_image_with_feedback() { + local stack_name="${1}" + local stack_dir="${2}" + local build_image_status=0 + + show_warning_message "Starting docker build for stack: ${stack_name}" + if build_stack_custom_image "${stack_dir}"; then + show_warning_and_wait "Custom image build finished successfully for stack: ${stack_name}" 3 + return 0 + fi + + build_image_status=$? + case "${build_image_status}" in + 11) + show_warning_and_wait "Custom image build failed: missing metadata.json in ${stack_dir}." 4 + ;; + 12) + show_warning_and_wait "Custom image build failed: stack env file not found in ${stack_dir}." 4 + ;; + 13) + show_warning_and_wait "Custom image build failed: CUSTOM_IMAGE is missing in stack env file." 4 + ;; + 14) + show_warning_and_wait "Custom image build failed: CUSTOM_TAG is missing in stack env file." 4 + ;; + 15) + show_warning_and_wait "Custom image build failed: frappe_branch missing in metadata.json." 4 + ;; + 16) + show_warning_and_wait "Custom image build failed: could not generate apps.json from metadata app selection." 4 + ;; + 17) + show_warning_and_wait "Custom image build failed: apps.json not found after generation." 4 + ;; + 18) + show_warning_and_wait "Custom image build failed: base64 command is not available in this environment." 4 + ;; + 19) + show_warning_and_wait "Custom image build failed: apps.json could not be base64-encoded." 4 + ;; + 20) + show_warning_and_wait "Custom image build failed: images/layered/Containerfile not found." 4 + ;; + 21) + show_warning_and_wait "Custom image build failed: docker build returned an error. Check the output above." 4 + ;; + 22) + show_warning_and_wait "Custom image build failed: git is required for app branch precheck (git ls-remote)." 4 + ;; + 23) + show_warning_and_wait "Custom image build failed: could not parse app entries from apps.json." 4 + ;; + 24) + show_warning_and_wait "Custom image build failed: app branch precheck failed -> ${EASY_DOCKER_BUILD_ERROR_DETAIL}" 6 + ;; + *) + show_warning_and_wait "Custom image build failed (${build_image_status})." 4 + ;; + esac + + return "${build_image_status}" +} diff --git a/scripts/easy-docker/lib/app/wizard/flows/manage/prompts.sh b/scripts/easy-docker/lib/app/wizard/flows/manage/prompts.sh new file mode 100755 index 00000000..c39c9922 --- /dev/null +++ b/scripts/easy-docker/lib/app/wizard/flows/manage/prompts.sh @@ -0,0 +1,95 @@ +#!/usr/bin/env bash + +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 +} + +prompt_manage_stack_delete_keyword_with_cancel() { + local result_var="${1}" + local stack_name="${2}" + local delete_confirmation="" + local prompt_status=0 + + while true; do + delete_confirmation="$(prompt_manage_stack_delete_keyword "${stack_name}")" + prompt_status=$? + if [ "${prompt_status}" -ne 0 ]; then + return "${FLOW_ABORT_INPUT}" + fi + + delete_confirmation="$(printf '%s' "${delete_confirmation}" | tr -d '\r\n')" + case "${delete_confirmation}" in + /back | /Back | /BACK | "") + return "${FLOW_ABORT_INPUT}" + ;; + delete) + printf -v "${result_var}" "%s" "${delete_confirmation}" + return "${FLOW_CONTINUE}" + ;; + *) + show_warning_and_wait "Type exactly delete to confirm stack removal." 3 + ;; + esac + done +} diff --git a/scripts/easy-docker/lib/app/wizard/flows/manage/site.sh b/scripts/easy-docker/lib/app/wizard/flows/manage/site.sh new file mode 100755 index 00000000..6a076ab3 --- /dev/null +++ b/scripts/easy-docker/lib/app/wizard/flows/manage/site.sh @@ -0,0 +1,185 @@ +#!/usr/bin/env bash + +handle_manage_stack_site_flow() { + local stack_name="${1}" + local stack_dir="${2}" + 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="" + local site_delete_confirmation="" + + while true; do + 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}" "${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. ${EASY_DOCKER_SITE_ERROR_DETAIL:-Check the output above for bench new-site details.} ${EASY_DOCKER_SITE_ERROR_LOG_PATH:+See ${stack_dir}/${EASY_DOCKER_SITE_ERROR_LOG_PATH}}" 6 + ;; + 56) + show_warning_and_wait "The site was created, but app installation failed. ${EASY_DOCKER_SITE_ERROR_DETAIL:-Check the output above.} ${EASY_DOCKER_SITE_ERROR_LOG_PATH:+See ${stack_dir}/${EASY_DOCKER_SITE_ERROR_LOG_PATH}}" 6 + ;; + 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 + ;; + 63) + show_warning_and_wait "Cannot install the selected stack apps because at least one app is missing from the backend image. ${EASY_DOCKER_SITE_ERROR_DETAIL} ${EASY_DOCKER_SITE_ERROR_LOG_PATH:+See ${stack_dir}/${EASY_DOCKER_SITE_ERROR_LOG_PATH}}" 7 + ;; + *) + 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 stack_backend_service_is_running "${stack_dir}" >/dev/null 2>&1; then + get_stack_site_runtime_selected_apps_lines existing_site_apps_lines "${stack_dir}" "${existing_site_name}" || true + fi + 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}" \ + "${existing_site_created_at}" \ + "${existing_site_apps_csv}" || true + )" + case "${existing_site_details_action}" in + "Delete site") + site_delete_confirmation="$( + show_manage_stack_site_delete_confirmation \ + "${stack_name}" \ + "${stack_dir}" \ + "${existing_site_name}" || true + )" + case "${site_delete_confirmation}" in + "Yes") + show_warning_message "Deleting site for stack: ${stack_name}" + if delete_configured_stack_site "${stack_dir}"; then + show_warning_and_wait "Site deleted successfully with its database: ${existing_site_name}" 3 + continue + fi + + site_flow_status=$? + case "${site_flow_status}" in + 51) + show_warning_and_wait "Cannot delete site: backend service is not running yet. Start the stack first." 4 + ;; + 52) + show_warning_and_wait "Cannot delete site for this topology yet. Only single-host stacks are supported." 4 + ;; + 54) + show_warning_and_wait "Cannot delete site because stack metadata, env, or compose inputs are incomplete." 4 + ;; + 58) + show_warning_and_wait "The cleared site state could not be written to metadata.json." 4 + ;; + 60) + show_warning_and_wait "Site deletion could not remove all site or database data automatically. Manual cleanup is required." 5 + ;; + 61) + show_warning_and_wait "Cannot delete site because the configured site name was empty or unsafe for cleanup operations." 4 + ;; + *) + show_warning_and_wait "Site deletion failed (${site_flow_status})." 4 + ;; + esac + continue + ;; + "No" | "") + continue + ;; + "Exit and close easy-docker") + return "${FLOW_EXIT_APP}" + ;; + *) + show_warning_and_wait "Unknown site delete confirmation action: ${site_delete_confirmation}" 2 + continue + ;; + esac + ;; + "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 +} diff --git a/scripts/easy-docker/lib/app/wizard/flows/manage/stack.sh b/scripts/easy-docker/lib/app/wizard/flows/manage/stack.sh new file mode 100755 index 00000000..340c9bd0 --- /dev/null +++ b/scripts/easy-docker/lib/app/wizard/flows/manage/stack.sh @@ -0,0 +1,321 @@ +#!/usr/bin/env bash + +handle_manage_selected_stack_flow() { + local stack_name="${1}" + local stack_dir="" + local stack_action="" + local apps_action="" + local docker_action="" + local stack_metadata_path="" + local stack_apps_path="" + local custom_apps_update_status=0 + local persist_apps_status=0 + local render_compose_status=0 + local compose_start_status=0 + local generated_compose_path="" + local stack_runtime_status="" + local missing_custom_image_action="" + local delete_stack_confirmation_action="" + local delete_stack_keyword="" + + stack_dir="$(get_stack_dir_by_name "${stack_name}" || true)" + if [ -z "${stack_dir}" ]; then + show_warning_and_wait "Could not resolve stack directory for '${stack_name}'." 2 + return "${FLOW_CONTINUE}" + fi + + while true; do + get_stack_compose_runtime_status_label stack_runtime_status "${stack_dir}" + stack_action="$(show_manage_stack_actions_menu "${stack_name}" "${stack_dir}" "${stack_runtime_status}" || true)" + case "${stack_action}" in + "Apps") + while true; do + apps_action="$(show_manage_stack_apps_menu "${stack_name}" "${stack_dir}" || true)" + case "${apps_action}" in + "Regenerate apps.json from metadata") + stack_metadata_path="${stack_dir}/metadata.json" + stack_apps_path="${stack_dir}/apps.json" + if [ ! -f "${stack_metadata_path}" ]; then + show_warning_and_wait "Cannot generate apps.json because metadata is missing: ${stack_metadata_path}" 3 + continue + fi + + if persist_stack_apps_json_from_metadata_apps "${stack_dir}"; then + : + else + persist_apps_status=$? + show_warning_and_wait "Could not generate ${stack_apps_path} (${persist_apps_status})." 3 + continue + fi + + show_warning_and_wait "apps.json generated successfully: ${stack_apps_path}" 3 + ;; + "Select apps and branches") + if update_stack_custom_modular_apps "${stack_dir}"; then + : + else + custom_apps_update_status=$? + case "${custom_apps_update_status}" in + 2 | 130) + continue + ;; + 3) + stack_metadata_path="${stack_dir}/metadata.json" + show_warning_and_wait "Cannot update app selection because metadata is missing: ${stack_metadata_path}" 3 + continue + ;; + *) + show_warning_and_wait "Could not update app selection (${custom_apps_update_status}) for stack: ${stack_name}" 3 + continue + ;; + esac + fi + + stack_apps_path="${stack_dir}/apps.json" + show_warning_and_wait "App selection updated in ${stack_dir}/metadata.json and ${stack_apps_path}." 3 + ;; + "Back" | "") + break + ;; + "Exit and close easy-docker") + return "${FLOW_EXIT_APP}" + ;; + *) + show_warning_and_wait "Unknown apps action: ${apps_action}" + ;; + esac + done + ;; + "Start stack in Docker Compose") + while true; do + show_warning_message "Starting stack with docker compose: ${stack_name}" + if start_stack_with_compose_from_metadata "${stack_dir}"; then + show_warning_and_wait "Stack started successfully with docker compose: ${stack_name}" 3 + break + else + compose_start_status=$? + fi + case "${compose_start_status}" in + 31) + show_warning_and_wait "Cannot start stack: metadata.json is missing in ${stack_dir}." 4 + break + ;; + 32) + show_warning_and_wait "Cannot start stack: stack env file not found in ${stack_dir}." 4 + break + ;; + 33) + show_warning_and_wait "Cannot start stack: topology is missing in metadata.json. Re-run the topology wizard for this stack." 4 + break + ;; + 34) + show_warning_and_wait "Cannot start stack via docker compose for topology '${EASY_DOCKER_COMPOSE_ERROR_DETAIL}'. Use the topology-specific runbook path." 5 + break + ;; + 35) + show_warning_and_wait "Cannot start stack: no compose files configured in metadata.json." 4 + break + ;; + 36) + show_warning_and_wait "Cannot start stack: compose file is missing -> ${EASY_DOCKER_COMPOSE_ERROR_DETAIL}" 4 + break + ;; + 37) + show_warning_and_wait "docker compose up failed. Check the output above for details." 4 + break + ;; + 38) + missing_custom_image_action="$( + show_missing_custom_image_start_menu "${stack_name}" "${stack_dir}" "${EASY_DOCKER_COMPOSE_ERROR_DETAIL}" || true + )" + case "${missing_custom_image_action}" in + "Build custom image now") + if run_build_stack_custom_image_with_feedback "${stack_name}" "${stack_dir}"; then + continue + fi + break + ;; + "Back" | "") + break + ;; + "Exit and close easy-docker") + return "${FLOW_EXIT_APP}" + ;; + *) + show_warning_and_wait "Unknown missing-image action: ${missing_custom_image_action}" 2 + break + ;; + esac + ;; + 39) + show_warning_and_wait "Cannot inspect custom image before start. Check Docker and try again. Details: ${EASY_DOCKER_COMPOSE_ERROR_DETAIL}" 5 + break + ;; + *) + show_warning_and_wait "Cannot start stack with docker compose (${compose_start_status})." 4 + break + ;; + esac + done + ;; + "Stop stack in Docker Compose") + show_warning_message "Stopping stack with docker compose: ${stack_name}" + if stop_stack_with_compose_from_metadata "${stack_dir}"; then + show_warning_and_wait "Stack stopped successfully with docker compose: ${stack_name}" 3 + continue + fi + + compose_start_status=$? + case "${compose_start_status}" in + 41) + show_warning_and_wait "Cannot stop stack: metadata.json is missing in ${stack_dir}." 4 + ;; + 42) + show_warning_and_wait "Cannot stop stack: stack env file not found in ${stack_dir}." 4 + ;; + 43) + show_warning_and_wait "Cannot stop stack: topology is missing in metadata.json. Re-run the topology wizard for this stack." 4 + ;; + 44) + show_warning_and_wait "Cannot stop stack via docker compose for topology '${EASY_DOCKER_COMPOSE_ERROR_DETAIL}'. Use the topology-specific runbook path." 5 + ;; + 45) + show_warning_and_wait "Cannot stop stack: no compose files configured in metadata.json." 4 + ;; + 46) + show_warning_and_wait "Cannot stop stack: compose file is missing -> ${EASY_DOCKER_COMPOSE_ERROR_DETAIL}" 4 + ;; + 47) + show_warning_and_wait "docker compose stop failed. Check the output above for details." 4 + ;; + *) + show_warning_and_wait "Cannot stop stack with docker compose (${compose_start_status})." 4 + ;; + esac + ;; + "Delete stack") + delete_stack_confirmation_action="$( + show_manage_stack_delete_confirmation "${stack_name}" "${stack_dir}" || true + )" + case "${delete_stack_confirmation_action}" in + "Yes") + if ! prompt_manage_stack_delete_keyword_with_cancel delete_stack_keyword "${stack_name}"; then + continue + fi + if [ "${delete_stack_keyword}" != "delete" ]; then + continue + fi + + show_warning_message "Deleting stack with docker compose resources: ${stack_name}" + if delete_stack_with_compose_from_metadata "${stack_dir}"; then + show_warning_and_wait "Stack deleted successfully with containers, networks, volumes, image, and stack directory: ${stack_name}" 5 + return "${FLOW_CONTINUE}" + fi + + compose_start_status=$? + case "${compose_start_status}" in + 48) + show_warning_and_wait "Cannot delete stack: metadata.json is missing in ${stack_dir}." 4 + ;; + 49) + show_warning_and_wait "Cannot delete stack: stack env file not found in ${stack_dir}." 4 + ;; + 50) + show_warning_and_wait "Cannot delete stack: topology is missing in metadata.json. Re-run the topology wizard for this stack." 4 + ;; + 51) + show_warning_and_wait "Cannot delete stack via docker compose for topology '${EASY_DOCKER_COMPOSE_ERROR_DETAIL}'. Use the topology-specific runbook path." 5 + ;; + 52) + show_warning_and_wait "Cannot delete stack: no compose files configured in metadata.json." 4 + ;; + 53) + show_warning_and_wait "Cannot delete stack: compose file is missing -> ${EASY_DOCKER_COMPOSE_ERROR_DETAIL}" 4 + ;; + 54) + show_warning_and_wait "docker compose down failed. Check the output above for details." 4 + ;; + 55) + show_warning_and_wait "Stack resources were removed, but the configured custom image could not be deleted -> ${EASY_DOCKER_COMPOSE_ERROR_DETAIL}" 5 + ;; + 56) + show_warning_and_wait "Docker resources were removed, but the stack directory could not be deleted -> ${EASY_DOCKER_COMPOSE_ERROR_DETAIL}" 5 + ;; + *) + show_warning_and_wait "Cannot delete stack with docker compose (${compose_start_status})." 4 + ;; + esac + ;; + "No" | "") + continue + ;; + "Exit and close easy-docker") + return "${FLOW_EXIT_APP}" + ;; + *) + show_warning_and_wait "Unknown delete-stack action: ${delete_stack_confirmation_action}" 2 + ;; + esac + ;; + "Docker") + while true; do + docker_action="$(show_manage_stack_docker_menu "${stack_name}" "${stack_dir}" || true)" + case "${docker_action}" in + "Build custom image") + if run_build_stack_custom_image_with_feedback "${stack_name}" "${stack_dir}"; then + : + else + continue + fi + ;; + "Generate docker compose from env") + generated_compose_path="$(get_stack_generated_compose_path "${stack_dir}")" + if render_stack_compose_from_metadata "${stack_dir}"; then + : + else + render_compose_status=$? + show_warning_and_wait "Could not generate docker compose (${render_compose_status}) for ${generated_compose_path}." 3 + continue + fi + + show_warning_and_wait "Docker compose generated successfully: ${generated_compose_path}" 3 + ;; + "Back" | "") + break + ;; + "Exit and close easy-docker") + return "${FLOW_EXIT_APP}" + ;; + *) + show_warning_and_wait "Unknown docker action: ${docker_action}" + ;; + 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}" + ;; + "Exit and close easy-docker") + return "${FLOW_EXIT_APP}" + ;; + *) + show_warning_and_wait "Unknown stack action: ${stack_action}" + ;; + esac + done +} From 8d9b855ff450cb7ada935e43b56fb99d4476eccc Mon Sep 17 00:00:00 2001 From: RocketQuack <202538874+Rocket-Quack@users.noreply.github.com> Date: Wed, 1 Apr 2026 16:15:10 +0200 Subject: [PATCH 25/51] feat(easy-docker): migrate sites after app install --- .../wizard/common/site/bootstrap/lifecycle.sh | 45 +++++++++++-- .../app/wizard/common/site/metadata/read.sh | 65 +++---------------- .../app/wizard/common/site/metadata/write.sh | 35 +++++----- .../lib/app/wizard/flows/manage/site.sh | 9 ++- 4 files changed, 70 insertions(+), 84 deletions(-) diff --git a/scripts/easy-docker/lib/app/wizard/common/site/bootstrap/lifecycle.sh b/scripts/easy-docker/lib/app/wizard/common/site/bootstrap/lifecycle.sh index 6efb2249..be920cac 100755 --- a/scripts/easy-docker/lib/app/wizard/common/site/bootstrap/lifecycle.sh +++ b/scripts/easy-docker/lib/app/wizard/common/site/bootstrap/lifecycle.sh @@ -259,7 +259,6 @@ install_stack_apps_on_site() { "${stack_dir}" \ "single-site" \ "${site_name}" \ - "apps_installing" \ "${installed_app_lines}" \ "install-apps" \ "" \ @@ -274,6 +273,26 @@ install_stack_apps_on_site() { return 0 } +run_stack_site_migrate() { + local stack_dir="${1}" + local site_name="${2}" + local migrate_command="" + local migrate_output="" + + migrate_command="$( + printf "bench --site %s migrate" \ + "$(shell_quote_site_command_arg "${site_name}")" + )" + + if ! run_stack_backend_bash_command_capture migrate_output "${stack_dir}" "${migrate_command}"; then + EASY_DOCKER_SITE_ERROR_DETAIL="$(printf "bench migrate failed for '%s'." "${site_name}")" + capture_stack_site_error_log "${stack_dir}" "site-migrate-error" "${migrate_output}" >/dev/null 2>&1 || true + return 64 + fi + + return 0 +} + bootstrap_first_stack_site() { local stack_dir="${1}" local site_name="${2}" @@ -283,6 +302,7 @@ bootstrap_first_stack_site() { local installed_app_lines="" local site_create_status=0 local app_install_status=0 + local site_migrate_status=0 local cleanup_status=0 if ! is_safe_stack_site_cleanup_name "${site_name}"; then @@ -315,7 +335,7 @@ bootstrap_first_stack_site() { 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 + if ! persist_stack_site_metadata "${stack_dir}" "single-site" "${site_name}" "" "create-site" "" "" "${created_at}" "${updated_at}"; then return 58 fi @@ -339,7 +359,7 @@ bootstrap_first_stack_site() { fi updated_at="${created_at}" - if ! persist_stack_site_metadata "${stack_dir}" "single-site" "${site_name}" "creating" "" "create-site" "" "" "${created_at}" "${updated_at}"; then + if ! persist_stack_site_metadata "${stack_dir}" "single-site" "${site_name}" "" "create-site" "" "" "${created_at}" "${updated_at}"; then return 58 fi @@ -365,7 +385,7 @@ bootstrap_first_stack_site() { 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 + if ! persist_stack_site_metadata "${stack_dir}" "single-site" "${site_name}" "" "create-site" "" "" "${created_at}" "${updated_at}"; then return 58 fi @@ -400,8 +420,23 @@ bootstrap_first_stack_site() { return "${app_install_status}" fi + if run_stack_site_migrate "${stack_dir}" "${site_name}"; then + : + else + site_migrate_status=$? + case "${site_migrate_status}" in + 64) + mark_stack_site_failed "${stack_dir}" "${site_name}" "${installed_app_lines}" "migrate-site" "${EASY_DOCKER_SITE_ERROR_DETAIL:-Site migration failed.}" "${EASY_DOCKER_SITE_ERROR_LOG_PATH}" "${created_at}" >/dev/null 2>&1 || true + ;; + *) + mark_stack_site_failed "${stack_dir}" "${site_name}" "${installed_app_lines}" "migrate-site" "Unknown site migration failure." "${EASY_DOCKER_SITE_ERROR_LOG_PATH}" "${created_at}" >/dev/null 2>&1 || true + ;; + esac + return "${site_migrate_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 + if ! persist_stack_site_metadata "${stack_dir}" "single-site" "${site_name}" "${installed_app_lines}" "migrate-site" "" "" "${created_at}" "${updated_at}"; then return 58 fi diff --git a/scripts/easy-docker/lib/app/wizard/common/site/metadata/read.sh b/scripts/easy-docker/lib/app/wizard/common/site/metadata/read.sh index ace5e3dd..51790f06 100755 --- a/scripts/easy-docker/lib/app/wizard/common/site/metadata/read.sh +++ b/scripts/easy-docker/lib/app/wizard/common/site/metadata/read.sh @@ -75,10 +75,10 @@ get_stack_site_name() { get_metadata_site_string_field "${stack_dir}/metadata.json" "name" } -get_stack_site_state() { +get_stack_site_last_error() { local stack_dir="${1}" - get_metadata_site_string_field "${stack_dir}/metadata.json" "state" + get_metadata_site_string_field "${stack_dir}/metadata.json" "last_error" } get_stack_site_created_at() { @@ -107,78 +107,29 @@ stack_has_site_record() { 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="" + local last_error="" - site_state="$(get_stack_site_state "${stack_dir}" || true)" site_name="$(get_stack_site_name "${stack_dir}" || true)" + last_error="$(get_stack_site_last_error "${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})" + if [ -n "${site_name}" ] && [ -z "${last_error}" ]; then + return 0 fi - printf -v "${result_var}" "%s" "${site_status_label}" - return 0 + return 1 } 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}" + printf -v "${result_var}" "%s" "${site_name}" return 0 } diff --git a/scripts/easy-docker/lib/app/wizard/common/site/metadata/write.sh b/scripts/easy-docker/lib/app/wizard/common/site/metadata/write.sh index 1d35697e..15435d57 100755 --- a/scripts/easy-docker/lib/app/wizard/common/site/metadata/write.sh +++ b/scripts/easy-docker/lib/app/wizard/common/site/metadata/write.sh @@ -33,21 +33,19 @@ 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 error_log_path="${8:-}" - local created_at="${9:-}" - local updated_at="${10:-}" + local apps_installed_lines="${4:-}" + local last_action="${5:-}" + local last_error="${6:-}" + local error_log_path="${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 "error_log_path": "%s",\n "created_at": "%s",\n "updated_at": "%s"\n }' \ + printf -v "${result_var}" '{\n "mode": "%s",\n "name": "%s",\n "apps_installed": %s,\n "last_action": "%s",\n "last_error": "%s",\n "error_log_path": "%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}")" \ @@ -60,13 +58,12 @@ 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 error_log_path="${8:-}" - local created_at="${9:-}" - local updated_at="${10:-}" + local apps_installed_lines="${4:-}" + local last_action="${5:-}" + local last_error="${6:-}" + local error_log_path="${7:-}" + local created_at="${8:-}" + local updated_at="${9:-}" local metadata_path="" local metadata_tmp_path="" local site_json_object="" @@ -77,7 +74,7 @@ persist_stack_site_metadata() { 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}" "${error_log_path}" "${created_at}" "${updated_at}" + build_stack_site_metadata_json_object site_json_object "${site_mode}" "${site_name}" "${apps_installed_lines}" "${last_action}" "${last_error}" "${error_log_path}" "${created_at}" "${updated_at}" if ! awk -v site_object="${site_json_object}" ' BEGIN { @@ -167,7 +164,7 @@ mark_stack_site_failed() { 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}" "${error_log_path}" "${created_at}" "${updated_at}" + persist_stack_site_metadata "${stack_dir}" "single-site" "${site_name}" "${apps_installed_lines}" "${last_action}" "${last_error}" "${error_log_path}" "${created_at}" "${updated_at}" } clear_stack_site_metadata() { @@ -175,5 +172,5 @@ clear_stack_site_metadata() { local updated_at="" updated_at="$(get_current_utc_timestamp)" - persist_stack_site_metadata "${stack_dir}" "single-site" "" "not_created" "" "delete-site" "" "" "" "${updated_at}" + persist_stack_site_metadata "${stack_dir}" "single-site" "" "" "delete-site" "" "" "" "${updated_at}" } diff --git a/scripts/easy-docker/lib/app/wizard/flows/manage/site.sh b/scripts/easy-docker/lib/app/wizard/flows/manage/site.sh index 6a076ab3..e408a324 100755 --- a/scripts/easy-docker/lib/app/wizard/flows/manage/site.sh +++ b/scripts/easy-docker/lib/app/wizard/flows/manage/site.sh @@ -32,7 +32,7 @@ handle_manage_stack_site_flow() { 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 + show_warning_and_wait "Site created successfully, selected stack apps were installed, and bench migrate completed: ${site_name}" 3 continue else site_flow_status=$? @@ -61,7 +61,7 @@ handle_manage_stack_site_flow() { 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 + show_warning_and_wait "The site metadata 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 @@ -78,6 +78,9 @@ handle_manage_stack_site_flow() { 63) show_warning_and_wait "Cannot install the selected stack apps because at least one app is missing from the backend image. ${EASY_DOCKER_SITE_ERROR_DETAIL} ${EASY_DOCKER_SITE_ERROR_LOG_PATH:+See ${stack_dir}/${EASY_DOCKER_SITE_ERROR_LOG_PATH}}" 7 ;; + 64) + show_warning_and_wait "The site was created and apps were installed, but bench migrate failed. ${EASY_DOCKER_SITE_ERROR_DETAIL:-Check the output above.} ${EASY_DOCKER_SITE_ERROR_LOG_PATH:+See ${stack_dir}/${EASY_DOCKER_SITE_ERROR_LOG_PATH}}" 7 + ;; *) show_warning_and_wait "Site bootstrap failed (${site_flow_status})." 4 ;; @@ -139,7 +142,7 @@ handle_manage_stack_site_flow() { show_warning_and_wait "Cannot delete site because stack metadata, env, or compose inputs are incomplete." 4 ;; 58) - show_warning_and_wait "The cleared site state could not be written to metadata.json." 4 + show_warning_and_wait "The cleared site metadata could not be written to metadata.json." 4 ;; 60) show_warning_and_wait "Site deletion could not remove all site or database data automatically. Manual cleanup is required." 5 From 6833b4be7785a15b1d81a92e52b5cff404d374c6 Mon Sep 17 00:00:00 2001 From: RocketQuack <202538874+Rocket-Quack@users.noreply.github.com> Date: Thu, 2 Apr 2026 15:48:57 +0200 Subject: [PATCH 26/51] chore(easy-docker): remove branch-local docs artifacts --- development/vscode-example/settings.json | 6 +- .../docs/development-team-process.md | 128 ------------- .../docs/single-stack-readiness.md | 174 ------------------ scripts/easy-docker/docs/wizard-flow-clean.md | 76 -------- scripts/easy-docker/docs/wizard-flow.md | 136 -------------- 5 files changed, 1 insertion(+), 519 deletions(-) delete mode 100644 scripts/easy-docker/docs/development-team-process.md delete mode 100644 scripts/easy-docker/docs/single-stack-readiness.md delete mode 100644 scripts/easy-docker/docs/wizard-flow-clean.md delete mode 100644 scripts/easy-docker/docs/wizard-flow.md diff --git a/development/vscode-example/settings.json b/development/vscode-example/settings.json index a60982e0..1490b727 100644 --- a/development/vscode-example/settings.json +++ b/development/vscode-example/settings.json @@ -1,7 +1,3 @@ { - "python.defaultInterpreterPath": "${workspaceFolder}/frappe-bench/env/bin/python", - "editor.detectIndentation": false, - "files.eol": "\n", - "files.insertFinalNewline": true, - "files.trimTrailingWhitespace": true + "python.defaultInterpreterPath": "${workspaceFolder}/frappe-bench/env/bin/python" } diff --git a/scripts/easy-docker/docs/development-team-process.md b/scripts/easy-docker/docs/development-team-process.md deleted file mode 100644 index af32c4bd..00000000 --- a/scripts/easy-docker/docs/development-team-process.md +++ /dev/null @@ -1,128 +0,0 @@ -# Easy-Docker Development Team Process - -## Purpose - -This document defines the working model for the easy-docker team. -Focus is process, responsibilities, and execution flow for ongoing refactoring and feature work. - -## Team Setup - -- Lead Developer - - Owns scope, priorities, and release decisions. - - Resolves conflicts between technical proposals. - - Approves final merge readiness. -- Senior Developer A (Correctness) - - Reviews control flow, edge cases, and failure behavior. - - Validates data handling, state transitions, and rollback paths. - - Checks defensive programming and explicit error handling. -- Senior Developer B (Architecture) - - Reviews modularity, coupling, and naming consistency. - - Drives DRY/KISS refactors and shared helper extraction. - - Validates maintainability and testability. -- Implementation Developer - - Delivers code changes according to approved scope. - - Keeps behavior stable unless change is explicitly requested. - - Adds/update docs for structure and flow changes. -- QA/Verification Owner - - Runs pre-commit and targeted checks. - - Executes reproducible manual test matrix for wizard paths. - - Reports pass/fail with concrete reproduction steps. - -## Working Agreement - -- No hidden behavior changes during refactors. -- Source-of-truth decisions must be explicit and documented. -- New code must prefer existing helpers over duplicated logic. -- Every change batch must be reviewable by concern (flow, env, compose, ui). - -## Daily Process (Tomorrow Plan) - -1. Kickoff (15 min) - - Confirm target scope for the day. - - Confirm "no functional change" boundaries. - - Assign owners for implementation and verification. -2. Design sync (20 min) - - Compare at least two technical options for non-trivial edits. - - Select one approach with short tradeoff note. -3. Implementation blocks - - Work in small vertical batches (one concern per batch). - - Keep public function contracts stable where possible. - - Update docs in the same batch when structure changes. -4. Review blocks - - Senior A reviews correctness and failure paths. - - Senior B reviews architecture and maintainability. - - Lead resolves conflicts and accepts/rejects batch. -5. Verification block - - Run pre-commit for changed files. - - Run targeted manual flow checks. - - Record results in short checklist format. -6. Handover - - Write what is done, what is pending, and next first task. - - List any blockers with owner and proposed resolution. - -## Implementation Workflow - -1. Define scope and constraints. -2. Map affected files/functions. -3. Propose options and select approach. -4. Implement with small commits by concern. -5. Validate with checks and manual path coverage. -6. Document final state and next steps. - -## Review Workflow - -1. Findings-first review format. -2. Severity order: BLOCKER, HIGH, MEDIUM, LOW. -3. Each point must include file reference and reason. -4. Lead decision: - - Approved - - Approved with conditions - - Not approved - -## Test and Verification Matrix (Minimum) - -- Create new production stack and complete wizard. -- Create new development stack and complete wizard. -- Manage existing stack: - - Apps -> Generate apps.json - - Apps -> Select apps and branches - - Docker -> Generate docker compose from env - - Docker -> Start stack in Docker Compose (single-host topology) -- Abort/Back paths: - - Back navigation in each submenu - - Abort wizard with rollback -- Validation paths: - - Domain validation error then correction - - Branch selection from apps catalog (including back-navigation) - -## Definition of Done (Team) - -- Scope completed with no unplanned behavior change. -- No avoidable duplication introduced. -- Review completed by both senior roles. -- Lead verdict documented. -- Verification evidence recorded. -- Handover notes prepared for next workday. - -## Handover Template - -Use this at day end: - -```text -Date: -Completed: -- ... - -In Progress: -- ... - -Next First Task: -- ... - -Blockers: -- - - - -Verification: -- pre-commit: -- manual matrix: -``` diff --git a/scripts/easy-docker/docs/single-stack-readiness.md b/scripts/easy-docker/docs/single-stack-readiness.md deleted file mode 100644 index 6b5c1fce..00000000 --- a/scripts/easy-docker/docs/single-stack-readiness.md +++ /dev/null @@ -1,174 +0,0 @@ -# Easy-Docker Single-Stack Readiness - -## Purpose - -This document freezes the required single-stack scope for `easy-docker` -before work moves to `separate services`. - -Current interpretation: - -- `single-stack` means the implemented `single-host` topology. -- The stack must be isolated from other stacks at the Docker Compose project level. -- The supported happy path is one usable site per stack unless a later - product decision explicitly broadens this. -- The current site bootstrap path always installs the full app selection - stored on the stack itself. -- It is not yet supported to create multiple sites in one stack with - different app selections per site. - -## Current Supported Scope - -The current codebase already supports these single-stack paths: - -- Create production stack -- Create development stack -- Choose `single-host` topology -- Select proxy, database, and redis mode -- Select apps and branches -- Persist `metadata.json`, stack `.env`, `apps.json` -- Render `compose.generated.yaml` -- Manage existing stacks -- Regenerate `apps.json` -- Re-select apps and branches -- Build custom image -- Start stack with Docker Compose -- Stop stack with Docker Compose -- Show stack runtime status -- Abort wizard with rollback or keep-files behavior -- Isolate stacks through stack-specific Compose project names - -## Definition Of Done Before Separate Services - -Single-stack is not considered complete when containers merely run. -It is considered complete when the user can move from stack creation -to a usable Frappe/ERPNext site and operate that stack safely. - -Minimum user-facing path: - -1. Create stack -2. Configure single-host topology -3. Build image if needed -4. Start stack -5. Create/bootstrap first site -6. Install selected apps on that site -7. Verify site access behind the chosen proxy mode -8. Stop/restart/down the stack -9. Re-open manage flow and inspect status/logs - -## Required Remaining Changes - -### High Priority - -- Add a documented or automated site/bootstrap path - - create first site - - install selected apps - - verify site routing/access -- Freeze the supported site model - - recommended: one site per stack as the supported happy path - -### Medium Priority - -- Add remaining lifecycle operations - - `restart` - - `down/remove` - - `logs` -- Add post-start recovery guidance - - partial start - - failed bootstrap - - retry after custom image rebuild -- Add one-time cleanup/runbook note for stacks created before - per-stack Compose project isolation - -### Hardening Priority - -- Keep runtime status semantics explicit - - `Not created` - - `Created` - - `Running` - - `Partial` - - `Stopped` - - `Restarting` - - optional uptime hint -- Ensure manage actions only affect the selected stack -- Preserve safe abort/rollback behavior - -## Required Single-Stack Paths - -### Setup Paths - -- Environment check -- Create production stack -- Create development stack -- Complete single-host wizard -- Back/cancel at each prompt -- Abort wizard with rollback -- Abort wizard while keeping files - -### Runtime Paths - -- Generate compose from env -- Build custom image -- Start stack -- Stop stack -- Restart stack -- Down/remove stack resources -- Inspect runtime status -- Inspect logs - -### Site Paths - -- Create first site -- Install selected apps on the site -- Current limitation: the site install set is the stack app set - - one stack -> one supported site -> one shared app selection -- Verify the site is reachable -- Re-open and manage the stack after restart - -### Recovery Paths - -- Missing custom image -> build -> retry start -- Invalid app branch -> mapped build failure -- Partial start -> inspect status/logs -> retry -- Failed bootstrap -> rerun or recover cleanly -- Cleanup of pre-isolation shared Compose leftovers - -## Verification Matrix - -Before calling single-stack ready, the team should execute at least: - -1. Environment/bootstrap gate -2. New production single-stack creation -3. New development single-stack creation -4. Apps regeneration/update path -5. Compose render path -6. Custom image build success and failure paths -7. Start path including missing-image build/retry -8. Stop path -9. Runtime isolation between two stacks -10. Runtime status in not-created/created/running/partial/stopped states -11. Abort/back/rollback paths -12. Validation error and correction paths -13. Site/bootstrap reality check after stack start - -Required automated checks on every single-stack change: - -- `bash -n` on touched shell files -- `pre-commit run --files ` -- compose render/config validation for at least one production - and one development stack - -## Lead Verdict - -`single-stack` is close on the Compose/runtime side but is not yet fully done. - -The largest remaining gap before `separate services` is the missing -site/bootstrap lifecycle. After that, the next most important gaps are -`restart`, `down/remove`, `logs`, and reproducible manual verification. - -Recommended order: - -1. Freeze single-stack site model -2. Add site/bootstrap path -3. Add `restart`, `down/remove`, and `logs` -4. Run the verification matrix -5. Move to `separate services` diff --git a/scripts/easy-docker/docs/wizard-flow-clean.md b/scripts/easy-docker/docs/wizard-flow-clean.md deleted file mode 100644 index 1060fd1c..00000000 --- a/scripts/easy-docker/docs/wizard-flow-clean.md +++ /dev/null @@ -1,76 +0,0 @@ -# Easy Docker Wizard Flow (Clean View) - -This document shows the wizard paths in a clean, forward-only view. -Back/Cancel/Exit loops are intentionally hidden to keep the flow readable. - -## 1) Main Wizard Paths - -```mermaid -flowchart TD - A[Main Menu] - A --> B[Production Setup] - A --> C[Development Setup] - A --> D[Environment Check] - A --> Z[Exit] - - B --> E[Create new stack] - B --> F[Manage existing stacks] - C --> E2[Create new stack] - C --> F2[Manage existing stacks] - - E --> G[Create stack dir + metadata.json] - E2 --> G - G --> H[Topology selection] - - H --> I[Single-host flow] - H --> J[Split services flow] - - I --> K[Persist files + render compose] - K --> L[Done] - - J --> J2[Current status: placeholder only] - J2 --> L2[Pending implementation] - - F --> M[Select existing stack] - F2 --> M - M --> N[Manage stack actions] - N --> N1[Apps actions] - N --> N2[Docker actions] - N1 --> O[apps.json generated/updated] - N2 --> P[compose.generated.yaml rendered] - N2 --> Q[Start stack in Docker Compose] - Q --> Q1{Topology} - Q1 -->|single-host| Q2[docker compose up -d] - Q1 -->|split-services / others| Q3[Show runbook warning] -``` - -## 2) Single-host Detail Path - -```mermaid -flowchart TD - S1[Single-host selected] - S1 --> S2[Choose proxy mode] - S2 --> S3[Choose database mode] - S3 --> S4[Choose redis mode] - S4 --> S5[Set CUSTOM_IMAGE + CUSTOM_TAG] - S5 --> S6[Select apps: apps catalog] - S6 --> S7[For each selected app: fetch branches + choose branch] - S7 --> S8[Proxy-specific questions] - S8 --> S9[Database-specific questions] - S9 --> S10[Write .env] - S10 --> S11[Write metadata.json] - S11 --> S12[Generate apps.json] - S12 --> S13[Render compose.generated.yaml] - S13 --> S14[Success message] -``` - -## 3) Notes - -- This is a readability-focused flow map, not an exhaustive state machine. -- Navigation loops (Back/Cancel/Exit) are intentionally omitted. -- `Split services` remains not fully implemented in the wizard runtime. -- `Start stack in Docker Compose` currently supports only `single-host` topology. -- Site bootstrap is currently scoped to one supported site per stack. -- The site bootstrap installs the full app selection stored on the stack. -- Multiple sites in one stack with different per-site app selections are - not supported yet and are planned for a later phase. diff --git a/scripts/easy-docker/docs/wizard-flow.md b/scripts/easy-docker/docs/wizard-flow.md deleted file mode 100644 index 12f3bd52..00000000 --- a/scripts/easy-docker/docs/wizard-flow.md +++ /dev/null @@ -1,136 +0,0 @@ -# Easy Docker Wizard Flow - -```mermaid -flowchart TD - A[Main Menu] -->|Production Stack| B[Setup Menu: Production] - A -->|Development Stack| C[Setup Menu: Development] - A -->|Environment check| D[Environment Status] - A -->|Exit| Z1[Exit App] - D -->|Back to main menu| A - D -->|Exit and close easy-docker| Z1 - - B -->|Create new stack| E[Prompt: Stack name] - B -->|Manage existing stacks| F[List existing production stacks] - B -->|Back| A - B -->|Exit| Z1 - - C -->|Create new stack| E2[Prompt: Stack name] - C -->|Manage existing stacks| F2[List existing development stacks] - C -->|Back| A - C -->|Exit| Z1 - - E --> E3[Select Frappe branch profile from frappe.tsv] - E2 --> E4[Select Frappe branch profile from frappe.tsv] - E3 --> G[Create stack directory + metadata.json] - E4 --> G2[Create stack directory + metadata.json] - G --> H[Topology Menu] - G2 --> H2[Topology Menu] - - H -->|Single-host| I[Single-host selection] - H -->|Split services| J[Split services example] - H -->|Abort wizard to main menu| K[Abort prompt] - H -->|Back/Cancel| B - H2 -->|Single-host| I - H2 -->|Split services| J - H2 -->|Abort wizard to main menu| K - H2 -->|Back/Cancel| C - - J -->|Use this topology| J2[Info: placeholder path] - J -->|Back| H - J2 --> H - - K -->|Rollback files and return to main menu| A - K -->|Keep files and return to main menu| A - K -->|Back to topology selection| H - - I --> I1[Proxy mode] - I1 --> I2[Database mode] - I2 --> I3[Redis mode] - I3 --> I6[Prompt CUSTOM_IMAGE + CUSTOM_TAG] - I6 --> I7[App selection list] - I7 -->|Enter| I8[Per selected app: choose branch from apps.tsv] - I8 --> I9[Continue] - - I9 --> P{Proxy specific questions} - P -->|traefik-https| P1[SITE_DOMAINS + LETSENCRYPT_EMAIL + HTTP_PUBLISH_PORT? + HTTPS_PUBLISH_PORT?] - P -->|nginxproxy-https| P2[SITE_DOMAINS + NGINX_PROXY_HOSTS + LETSENCRYPT_EMAIL + HTTP_PUBLISH_PORT? + HTTPS_PUBLISH_PORT?] - P -->|nginxproxy-http| P3[SITE_DOMAINS + NGINX_PROXY_HOSTS + HTTP_PUBLISH_PORT?] - P -->|traefik-http| P4[HTTP_PUBLISH_PORT?] - P -->|caddy-external / no-proxy| P5[HTTP_PUBLISH_PORT? default 8080] - - P1 --> DBQ - P2 --> DBQ - P3 --> DBQ - P4 --> DBQ - P5 --> DBQ - - DBQ{Database specific question} - DBQ -->|postgres| DB1[DB_PASSWORD required] - DBQ -->|mariadb| DB2[DB_PASSWORD optional] - - DB1 --> S[Write stack env file] - DB2 --> S - S --> T[Write metadata.json with top-level apps] - T --> U[Generate apps.json from metadata.json apps] - U --> V[Render compose.generated.yaml from metadata + env] - V --> W[Success message] - W --> B - - F -->|Stack selected| M[Manage selected stack] - F -->|Back| B - F -->|Exit| Z1 - F -->|No stacks found| F0[Manage stacks placeholder] - F0 -->|Back| B - F0 -->|Exit| Z1 - - F2 -->|Stack selected| M - F2 -->|Back| C - F2 -->|Exit| Z1 - F2 -->|No stacks found| F20[Manage stacks placeholder] - F20 -->|Back| C - F20 -->|Exit| Z1 - - M --> M2[Stack actions: Apps / Docker / Back / Exit] - M2 -->|Apps| M3[Apps submenu] - M2 -->|Docker| M4[Docker submenu] - M2 -->|Back| M0[Return to current stack list] - M2 -->|Exit| Z1 - M0 --> F - M0 --> F2 - - M3 -->|Generate apps.json| M31[Read metadata.json apps + regenerate apps.json] - M3 -->|Select apps and branches| M32[Re-prompt app and branch selection] - M32 --> M33[Update metadata.json apps] - M33 --> M34[Regenerate apps.json from metadata] - M34 --> M3 - M3 -->|Back| M2 - M3 -->|Exit| Z1 - M31 --> M3 - - M4 -->|Generate docker compose from env| M41[Render compose.generated.yaml] - M4 -->|Start stack in Docker Compose| M42[Topology gate] - M42 -->|single-host| M43[docker compose up -d] - M42 -->|split-services / others| M44[Show topology-specific runbook message] - M4 -->|Back| M2 - M4 -->|Exit| Z1 - M41 --> M4 - M43 --> M4 - M44 --> M4 -``` - -## Notes - -- `SITE_DOMAINS` validation accepts only domain names in form `sub.domain.tld` or `sub.sub.domain.tld`. -- Existing stack lists are filtered by `setup_type` (`production` vs `development`). -- In `Manage existing stacks`, navigation options are only `Back` and `Exit`. -- `Select apps and branches` writes app selection to top-level `apps` in `metadata.json`. -- `Generate apps.json` uses only `metadata.json -> apps` as source of truth. -- New stack wizard always uses custom image path (no separate official-vs-custom image step). -- `Start stack in Docker Compose` is currently allowed only for `single-host` topology stacks. - -## Module Layout - -- `lib/app/wizard/common.sh` is now a loader for common modules under `lib/app/wizard/common/`. -- `lib/app/wizard/env.sh` is now a loader for env modules under `lib/app/wizard/env/`. -- `lib/app/wizard/flows.sh` is now a loader for flow modules under `lib/app/wizard/flows/`. -- Public function names and flow behavior remain unchanged; only code organization was refactored. From 4ab14b6a397ab73fe146c2f15b1e8dc68545356f Mon Sep 17 00:00:00 2001 From: RocketQuack <202538874+Rocket-Quack@users.noreply.github.com> Date: Thu, 2 Apr 2026 15:49:32 +0200 Subject: [PATCH 27/51] feat(easy-docker): mark split services as in development --- scripts/easy-docker/lib/ui/screens/production/topology.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/easy-docker/lib/ui/screens/production/topology.sh b/scripts/easy-docker/lib/ui/screens/production/topology.sh index f3e55049..8f17fc07 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." "${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}")" 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" \ + "Split services (in development)" \ "Abort wizard to main menu" } From 6d17bf0d2900f48612267bfa6a0140f0b522a242 Mon Sep 17 00:00:00 2001 From: RocketQuack <202538874+Rocket-Quack@users.noreply.github.com> Date: Thu, 2 Apr 2026 16:55:20 +0200 Subject: [PATCH 28/51] feat(easy-docker): add stack restart and site backup flows --- .../lib/app/wizard/common/compose/start.sh | 2 + .../wizard/common/compose/start/restart.sh | 74 +++++++++++++ .../easy-docker/lib/app/wizard/common/site.sh | 2 + .../lib/app/wizard/common/site/backup.sh | 11 ++ .../wizard/common/site/backup/lifecycle.sh | 103 ++++++++++++++++++ .../app/wizard/common/site/metadata/read.sh | 6 + .../app/wizard/common/site/metadata/write.sh | 15 ++- .../lib/app/wizard/flows/manage/build.sh | 4 +- .../lib/app/wizard/flows/manage/docker.sh | 4 +- .../lib/app/wizard/flows/manage/site.sh | 43 +++++++- .../lib/app/wizard/flows/manage/stack.sh | 80 +++++++++++++- .../lib/ui/screens/production/manage.sh | 9 +- 12 files changed, 335 insertions(+), 18 deletions(-) create mode 100755 scripts/easy-docker/lib/app/wizard/common/compose/start/restart.sh create mode 100755 scripts/easy-docker/lib/app/wizard/common/site/backup.sh create mode 100755 scripts/easy-docker/lib/app/wizard/common/site/backup/lifecycle.sh diff --git a/scripts/easy-docker/lib/app/wizard/common/compose/start.sh b/scripts/easy-docker/lib/app/wizard/common/compose/start.sh index 9fa2faa1..e0c604fd 100755 --- a/scripts/easy-docker/lib/app/wizard/common/compose/start.sh +++ b/scripts/easy-docker/lib/app/wizard/common/compose/start.sh @@ -8,6 +8,8 @@ load_easy_docker_compose_lifecycle_modules() { source "${lifecycle_dir}/start.sh" # shellcheck source=scripts/easy-docker/lib/app/wizard/common/compose/start/stop.sh source "${lifecycle_dir}/stop.sh" + # shellcheck source=scripts/easy-docker/lib/app/wizard/common/compose/start/restart.sh + source "${lifecycle_dir}/restart.sh" # shellcheck source=scripts/easy-docker/lib/app/wizard/common/compose/start/delete.sh source "${lifecycle_dir}/delete.sh" # shellcheck source=scripts/easy-docker/lib/app/wizard/common/compose/start/status.sh diff --git a/scripts/easy-docker/lib/app/wizard/common/compose/start/restart.sh b/scripts/easy-docker/lib/app/wizard/common/compose/start/restart.sh new file mode 100755 index 00000000..a4702733 --- /dev/null +++ b/scripts/easy-docker/lib/app/wizard/common/compose/start/restart.sh @@ -0,0 +1,74 @@ +#!/usr/bin/env bash + +restart_stack_with_compose_from_metadata() { + local stack_dir="${1}" + local stop_status=0 + local start_status=0 + + # shellcheck disable=SC2034 # Read by manage flow after restart_stack_with_compose_from_metadata fails. + EASY_DOCKER_COMPOSE_ERROR_DETAIL="" + + if stop_stack_with_compose_from_metadata "${stack_dir}"; then + : + else + stop_status=$? + case "${stop_status}" in + 41) + return 57 + ;; + 42) + return 58 + ;; + 43) + return 59 + ;; + 44) + return 60 + ;; + 45) + return 61 + ;; + 46) + return 62 + ;; + *) + return 63 + ;; + esac + fi + + if start_stack_with_compose_from_metadata "${stack_dir}"; then + return 0 + fi + + start_status=$? + case "${start_status}" in + 31) + return 57 + ;; + 32) + return 58 + ;; + 33) + return 59 + ;; + 34) + return 60 + ;; + 35) + return 61 + ;; + 36) + return 62 + ;; + 38) + return 64 + ;; + 39) + return 65 + ;; + *) + return 63 + ;; + esac +} diff --git a/scripts/easy-docker/lib/app/wizard/common/site.sh b/scripts/easy-docker/lib/app/wizard/common/site.sh index 098140cd..0f6af17d 100755 --- a/scripts/easy-docker/lib/app/wizard/common/site.sh +++ b/scripts/easy-docker/lib/app/wizard/common/site.sh @@ -11,6 +11,8 @@ load_easy_docker_site_modules() { 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/backup.sh + source "${site_dir}/backup.sh" # shellcheck source=scripts/easy-docker/lib/app/wizard/common/site/apps.sh source "${site_dir}/apps.sh" } diff --git a/scripts/easy-docker/lib/app/wizard/common/site/backup.sh b/scripts/easy-docker/lib/app/wizard/common/site/backup.sh new file mode 100755 index 00000000..7c7b323e --- /dev/null +++ b/scripts/easy-docker/lib/app/wizard/common/site/backup.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash + +load_easy_docker_site_backup_modules() { + local backup_dir="" + backup_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/backup" + + # shellcheck source=scripts/easy-docker/lib/app/wizard/common/site/backup/lifecycle.sh + source "${backup_dir}/lifecycle.sh" +} + +load_easy_docker_site_backup_modules diff --git a/scripts/easy-docker/lib/app/wizard/common/site/backup/lifecycle.sh b/scripts/easy-docker/lib/app/wizard/common/site/backup/lifecycle.sh new file mode 100755 index 00000000..9ae137c4 --- /dev/null +++ b/scripts/easy-docker/lib/app/wizard/common/site/backup/lifecycle.sh @@ -0,0 +1,103 @@ +#!/usr/bin/env bash + +backup_configured_stack_site() { + local stack_dir="${1}" + local site_name="" + local backup_command="" + local backup_output="" + local command_status=0 + local created_at="" + local updated_at="" + local apps_installed_lines="" + local last_backup_at="" + local existing_last_backup_at="" + local backend_status=0 + + reset_easy_docker_site_error_state + + if ! stack_supports_single_site_management "${stack_dir}"; then + return 72 + fi + + site_name="$(get_stack_site_name "${stack_dir}" || true)" + if ! is_safe_stack_site_cleanup_name "${site_name}"; then + return 73 + fi + + if stack_backend_service_is_running "${stack_dir}"; then + : + else + backend_status=$? + case "${backend_status}" in + 54) + return 74 + ;; + 52) + return 72 + ;; + *) + return 71 + ;; + esac + fi + + created_at="$(get_stack_site_created_at "${stack_dir}" || true)" + apps_installed_lines="$(get_stack_site_apps_installed_lines "${stack_dir}" || true)" + existing_last_backup_at="$(get_stack_site_last_backup_at "${stack_dir}" || true)" + + backup_command="$( + printf "bench --site %s backup --with-files" \ + "$(shell_quote_site_command_arg "${site_name}")" + )" + + if run_stack_backend_bash_command_capture backup_output "${stack_dir}" "${backup_command}"; then + : + else + command_status=$? + EASY_DOCKER_SITE_ERROR_DETAIL="$(printf "bench backup failed for '%s'." "${site_name}")" + capture_stack_site_error_log "${stack_dir}" "site-backup-error" "${backup_output}" >/dev/null 2>&1 || true + updated_at="$(get_current_utc_timestamp)" + if ! persist_stack_site_metadata \ + "${stack_dir}" \ + "single-site" \ + "${site_name}" \ + "${apps_installed_lines}" \ + "backup-site" \ + "${EASY_DOCKER_SITE_ERROR_DETAIL}" \ + "${EASY_DOCKER_SITE_ERROR_LOG_PATH}" \ + "${created_at}" \ + "${updated_at}" \ + "${existing_last_backup_at}"; then + return 76 + fi + case "${command_status}" in + 54) + return 74 + ;; + 52) + return 72 + ;; + *) + return 75 + ;; + esac + fi + + last_backup_at="$(get_current_utc_timestamp)" + updated_at="${last_backup_at}" + if ! persist_stack_site_metadata \ + "${stack_dir}" \ + "single-site" \ + "${site_name}" \ + "${apps_installed_lines}" \ + "backup-site" \ + "" \ + "" \ + "${created_at}" \ + "${updated_at}" \ + "${last_backup_at}"; then + return 76 + fi + + return 0 +} diff --git a/scripts/easy-docker/lib/app/wizard/common/site/metadata/read.sh b/scripts/easy-docker/lib/app/wizard/common/site/metadata/read.sh index 51790f06..b0f072f4 100755 --- a/scripts/easy-docker/lib/app/wizard/common/site/metadata/read.sh +++ b/scripts/easy-docker/lib/app/wizard/common/site/metadata/read.sh @@ -87,6 +87,12 @@ get_stack_site_created_at() { get_metadata_site_string_field "${stack_dir}/metadata.json" "created_at" } +get_stack_site_last_backup_at() { + local stack_dir="${1}" + + get_metadata_site_string_field "${stack_dir}/metadata.json" "last_backup_at" +} + get_stack_site_apps_installed_lines() { local stack_dir="${1}" diff --git a/scripts/easy-docker/lib/app/wizard/common/site/metadata/write.sh b/scripts/easy-docker/lib/app/wizard/common/site/metadata/write.sh index 15435d57..dafc4393 100755 --- a/scripts/easy-docker/lib/app/wizard/common/site/metadata/write.sh +++ b/scripts/easy-docker/lib/app/wizard/common/site/metadata/write.sh @@ -39,11 +39,12 @@ build_stack_site_metadata_json_object() { local error_log_path="${7:-}" local created_at="${8:-}" local updated_at="${9:-}" + local last_backup_at="${10:-}" 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 "apps_installed": %s,\n "last_action": "%s",\n "last_error": "%s",\n "error_log_path": "%s",\n "created_at": "%s",\n "updated_at": "%s"\n }' \ + printf -v "${result_var}" '{\n "mode": "%s",\n "name": "%s",\n "apps_installed": %s,\n "last_action": "%s",\n "last_error": "%s",\n "error_log_path": "%s",\n "created_at": "%s",\n "updated_at": "%s",\n "last_backup_at": "%s"\n }' \ "$(json_escape_string "${site_mode}")" \ "$(json_escape_string "${site_name}")" \ "${apps_installed_json_array}" \ @@ -51,7 +52,8 @@ build_stack_site_metadata_json_object() { "$(json_escape_string "${last_error}")" \ "$(json_escape_string "${error_log_path}")" \ "$(json_escape_string "${created_at}")" \ - "$(json_escape_string "${updated_at}")" + "$(json_escape_string "${updated_at}")" \ + "$(json_escape_string "${last_backup_at}")" } persist_stack_site_metadata() { @@ -64,6 +66,7 @@ persist_stack_site_metadata() { local error_log_path="${7:-}" local created_at="${8:-}" local updated_at="${9:-}" + local last_backup_at="${10-__KEEP_CURRENT__}" local metadata_path="" local metadata_tmp_path="" local site_json_object="" @@ -74,7 +77,11 @@ persist_stack_site_metadata() { return 1 fi - build_stack_site_metadata_json_object site_json_object "${site_mode}" "${site_name}" "${apps_installed_lines}" "${last_action}" "${last_error}" "${error_log_path}" "${created_at}" "${updated_at}" + if [ "${last_backup_at}" = "__KEEP_CURRENT__" ]; then + last_backup_at="$(get_metadata_site_string_field "${metadata_path}" "last_backup_at" || true)" + fi + + build_stack_site_metadata_json_object site_json_object "${site_mode}" "${site_name}" "${apps_installed_lines}" "${last_action}" "${last_error}" "${error_log_path}" "${created_at}" "${updated_at}" "${last_backup_at}" if ! awk -v site_object="${site_json_object}" ' BEGIN { @@ -172,5 +179,5 @@ clear_stack_site_metadata() { local updated_at="" updated_at="$(get_current_utc_timestamp)" - persist_stack_site_metadata "${stack_dir}" "single-site" "" "" "delete-site" "" "" "" "${updated_at}" + persist_stack_site_metadata "${stack_dir}" "single-site" "" "" "delete-site" "" "" "" "${updated_at}" "" } diff --git a/scripts/easy-docker/lib/app/wizard/flows/manage/build.sh b/scripts/easy-docker/lib/app/wizard/flows/manage/build.sh index 9d18a0fb..b6b5a4b1 100755 --- a/scripts/easy-docker/lib/app/wizard/flows/manage/build.sh +++ b/scripts/easy-docker/lib/app/wizard/flows/manage/build.sh @@ -9,9 +9,9 @@ run_build_stack_custom_image_with_feedback() { if build_stack_custom_image "${stack_dir}"; then show_warning_and_wait "Custom image build finished successfully for stack: ${stack_name}" 3 return 0 + else + build_image_status=$? fi - - build_image_status=$? case "${build_image_status}" in 11) show_warning_and_wait "Custom image build failed: missing metadata.json in ${stack_dir}." 4 diff --git a/scripts/easy-docker/lib/app/wizard/flows/manage/docker.sh b/scripts/easy-docker/lib/app/wizard/flows/manage/docker.sh index 9d18a0fb..b6b5a4b1 100755 --- a/scripts/easy-docker/lib/app/wizard/flows/manage/docker.sh +++ b/scripts/easy-docker/lib/app/wizard/flows/manage/docker.sh @@ -9,9 +9,9 @@ run_build_stack_custom_image_with_feedback() { if build_stack_custom_image "${stack_dir}"; then show_warning_and_wait "Custom image build finished successfully for stack: ${stack_name}" 3 return 0 + else + build_image_status=$? fi - - build_image_status=$? case "${build_image_status}" in 11) show_warning_and_wait "Custom image build failed: missing metadata.json in ${stack_dir}." 4 diff --git a/scripts/easy-docker/lib/app/wizard/flows/manage/site.sh b/scripts/easy-docker/lib/app/wizard/flows/manage/site.sh index e408a324..b2f055a4 100755 --- a/scripts/easy-docker/lib/app/wizard/flows/manage/site.sh +++ b/scripts/easy-docker/lib/app/wizard/flows/manage/site.sh @@ -12,6 +12,7 @@ handle_manage_stack_site_flow() { local existing_site_created_at="" local existing_site_apps_lines="" local existing_site_apps_csv="" + local existing_site_last_backup_at="" local existing_site_details_action="" local site_delete_confirmation="" @@ -97,6 +98,7 @@ handle_manage_stack_site_flow() { 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)" + existing_site_last_backup_at="$(get_stack_site_last_backup_at "${stack_dir}" || true)" if stack_backend_service_is_running "${stack_dir}" >/dev/null 2>&1; then get_stack_site_runtime_selected_apps_lines existing_site_apps_lines "${stack_dir}" "${existing_site_name}" || true fi @@ -112,9 +114,44 @@ handle_manage_stack_site_flow() { "${stack_dir}" \ "${existing_site_name}" \ "${existing_site_created_at}" \ - "${existing_site_apps_csv}" || true + "${existing_site_apps_csv}" \ + "${existing_site_last_backup_at}" || true )" case "${existing_site_details_action}" in + "Backup site now") + show_warning_message "Creating backup for site: ${existing_site_name}" + if backup_configured_stack_site "${stack_dir}"; then + existing_site_last_backup_at="$(get_stack_site_last_backup_at "${stack_dir}" || true)" + show_warning_and_wait "Site backup completed successfully: ${existing_site_name}${existing_site_last_backup_at:+ (last backup at ${existing_site_last_backup_at})}" 4 + continue + fi + + site_flow_status=$? + case "${site_flow_status}" in + 71) + show_warning_and_wait "Cannot back up site: backend service is not running yet. Start the stack first." 4 + ;; + 72) + show_warning_and_wait "Cannot back up site for this topology yet. Only single-host stacks are supported." 4 + ;; + 73) + show_warning_and_wait "Cannot back up site because no configured site was found in metadata.json." 4 + ;; + 74) + show_warning_and_wait "Cannot back up site because stack metadata, env, or compose inputs are incomplete." 4 + ;; + 75) + show_warning_and_wait "Site backup failed. ${EASY_DOCKER_SITE_ERROR_DETAIL:-Check the output above.} ${EASY_DOCKER_SITE_ERROR_LOG_PATH:+See ${stack_dir}/${EASY_DOCKER_SITE_ERROR_LOG_PATH}}" 6 + ;; + 76) + show_warning_and_wait "The backup command finished, but the backup metadata could not be written to metadata.json." 5 + ;; + *) + show_warning_and_wait "Site backup failed (${site_flow_status})." 4 + ;; + esac + continue + ;; "Delete site") site_delete_confirmation="$( show_manage_stack_site_delete_confirmation \ @@ -128,9 +165,9 @@ handle_manage_stack_site_flow() { if delete_configured_stack_site "${stack_dir}"; then show_warning_and_wait "Site deleted successfully with its database: ${existing_site_name}" 3 continue + else + site_flow_status=$? fi - - site_flow_status=$? case "${site_flow_status}" in 51) show_warning_and_wait "Cannot delete site: backend service is not running yet. Start the stack first." 4 diff --git a/scripts/easy-docker/lib/app/wizard/flows/manage/stack.sh b/scripts/easy-docker/lib/app/wizard/flows/manage/stack.sh index 340c9bd0..4593eb3b 100755 --- a/scripts/easy-docker/lib/app/wizard/flows/manage/stack.sh +++ b/scripts/easy-docker/lib/app/wizard/flows/manage/stack.sh @@ -158,14 +158,86 @@ handle_manage_selected_stack_flow() { esac done ;; + "Restart stack in Docker Compose") + while true; do + show_warning_message "Restarting stack with docker compose: ${stack_name}" + if restart_stack_with_compose_from_metadata "${stack_dir}"; then + show_warning_and_wait "Stack restarted successfully with docker compose: ${stack_name}" 3 + break + else + compose_start_status=$? + fi + case "${compose_start_status}" in + 57) + show_warning_and_wait "Cannot restart stack: metadata.json is missing in ${stack_dir}." 4 + break + ;; + 58) + show_warning_and_wait "Cannot restart stack: stack env file not found in ${stack_dir}." 4 + break + ;; + 59) + show_warning_and_wait "Cannot restart stack: topology is missing in metadata.json. Re-run the topology wizard for this stack." 4 + break + ;; + 60) + show_warning_and_wait "Cannot restart stack via docker compose for topology '${EASY_DOCKER_COMPOSE_ERROR_DETAIL}'. Use the topology-specific runbook path." 5 + break + ;; + 61) + show_warning_and_wait "Cannot restart stack: no compose files configured in metadata.json." 4 + break + ;; + 62) + show_warning_and_wait "Cannot restart stack: compose file is missing -> ${EASY_DOCKER_COMPOSE_ERROR_DETAIL}" 4 + break + ;; + 63) + show_warning_and_wait "docker compose restart failed. Check the output above for details." 4 + break + ;; + 64) + missing_custom_image_action="$( + show_missing_custom_image_start_menu "${stack_name}" "${stack_dir}" "${EASY_DOCKER_COMPOSE_ERROR_DETAIL}" || true + )" + case "${missing_custom_image_action}" in + "Build custom image now") + if run_build_stack_custom_image_with_feedback "${stack_name}" "${stack_dir}"; then + continue + fi + break + ;; + "Back" | "") + break + ;; + "Exit and close easy-docker") + return "${FLOW_EXIT_APP}" + ;; + *) + show_warning_and_wait "Unknown missing-image action: ${missing_custom_image_action}" 2 + break + ;; + esac + ;; + 65) + show_warning_and_wait "Cannot inspect custom image before restart. Check Docker and try again. Details: ${EASY_DOCKER_COMPOSE_ERROR_DETAIL}" 5 + break + ;; + *) + show_warning_and_wait "Cannot restart stack with docker compose (${compose_start_status})." 4 + break + ;; + esac + done + ;; "Stop stack in Docker Compose") show_warning_message "Stopping stack with docker compose: ${stack_name}" if stop_stack_with_compose_from_metadata "${stack_dir}"; then show_warning_and_wait "Stack stopped successfully with docker compose: ${stack_name}" 3 continue + else + compose_start_status=$? fi - - compose_start_status=$? case "${compose_start_status}" in 41) show_warning_and_wait "Cannot stop stack: metadata.json is missing in ${stack_dir}." 4 @@ -210,9 +282,9 @@ handle_manage_selected_stack_flow() { if delete_stack_with_compose_from_metadata "${stack_dir}"; then show_warning_and_wait "Stack deleted successfully with containers, networks, volumes, image, and stack directory: ${stack_name}" 5 return "${FLOW_CONTINUE}" + else + compose_start_status=$? fi - - compose_start_status=$? case "${compose_start_status}" in 48) show_warning_and_wait "Cannot delete stack: metadata.json is missing in ${stack_dir}." 4 diff --git a/scripts/easy-docker/lib/ui/screens/production/manage.sh b/scripts/easy-docker/lib/ui/screens/production/manage.sh index b501f5e9..cfbba951 100755 --- a/scripts/easy-docker/lib/ui/screens/production/manage.sh +++ b/scripts/easy-docker/lib/ui/screens/production/manage.sh @@ -65,7 +65,7 @@ show_manage_stack_actions_menu() { menu_header="$(printf "Stack actions | %s" "${stack_runtime_status}")" gum choose \ - --height 11 \ + --height 12 \ --header "${menu_header}" \ --cursor.foreground 63 \ --selected.foreground 45 \ @@ -73,6 +73,7 @@ show_manage_stack_actions_menu() { "Docker" \ "Site" \ "Start stack in Docker Compose" \ + "Restart stack in Docker Compose" \ "Stop stack in Docker Compose" \ "Delete stack" \ "Back" \ @@ -194,18 +195,20 @@ show_manage_stack_site_details() { local site_name="${3}" local created_at="${4:-}" local installed_apps="${5:-None}" + local last_backup_at="${6:-}" local status_text="" render_main_screen 1 >&2 - status_text="$(printf "Manage existing site\n\nStack: %s\nDirectory: %s\nSite: %s\nCreated at: %s\nInstalled apps: %s" "${stack_name}" "${stack_dir}" "${site_name}" "${created_at:-n/a}" "${installed_apps}")" + status_text="$(printf "Manage existing site\n\nStack: %s\nDirectory: %s\nSite: %s\nCreated at: %s\nInstalled apps: %s\nLast backup: %s" "${stack_name}" "${stack_dir}" "${site_name}" "${created_at:-n/a}" "${installed_apps}" "${last_backup_at:-n/a}")" render_box_message "${status_text}" "0 2" >&2 gum choose \ - --height 8 \ + --height 9 \ --header "Site details" \ --cursor.foreground 63 \ --selected.foreground 45 \ + "Backup site now" \ "Delete site" \ "Back" \ "Exit and close easy-docker" From 227d6652b8aa918d829372d31bf008f12595622e Mon Sep 17 00:00:00 2001 From: RocketQuack <202538874+Rocket-Quack@users.noreply.github.com> Date: Thu, 2 Apr 2026 16:55:45 +0200 Subject: [PATCH 29/51] fix(easy-docker): delete metadata-only stack directories --- .../app/wizard/common/compose/start/delete.sh | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) 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 f27a2f7e..cf4b7385 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 @@ -1,5 +1,20 @@ #!/usr/bin/env bash +stack_directory_contains_only_metadata() { + local stack_dir="${1}" + local metadata_path="${2}" + local remaining_entry="" + + remaining_entry="$( + find "${stack_dir}" -mindepth 1 ! -path "${metadata_path}" -print -quit 2>/dev/null || true + )" + if [ -n "${remaining_entry}" ]; then + return 1 + fi + + return 0 +} + delete_stack_with_compose_from_metadata() { local stack_dir="${1}" local metadata_path="" @@ -29,6 +44,13 @@ delete_stack_with_compose_from_metadata() { fi if [ ! -f "${env_path}" ]; then + if stack_directory_contains_only_metadata "${stack_dir}" "${metadata_path}"; then + if ! rollback_stack_directory "${stack_dir}"; then + EASY_DOCKER_COMPOSE_ERROR_DETAIL="${stack_dir}" + return 56 + fi + return 0 + fi return 49 fi From 2c97955a0702e01810b7dbad58af5be2e27ab649 Mon Sep 17 00:00:00 2001 From: RocketQuack <202538874+Rocket-Quack@users.noreply.github.com> Date: Thu, 2 Apr 2026 17:40:38 +0200 Subject: [PATCH 30/51] feat(easy-docker): manage apps on existing sites --- .../lib/app/wizard/common/site/apps.sh | 10 + .../app/wizard/common/site/apps/lifecycle.sh | 437 ++++++++++++++++++ .../lib/app/wizard/flows/manage/site.sh | 230 ++++++++- .../lib/ui/screens/production/manage.sh | 85 +++- 4 files changed, 760 insertions(+), 2 deletions(-) create mode 100755 scripts/easy-docker/lib/app/wizard/common/site/apps/lifecycle.sh diff --git a/scripts/easy-docker/lib/app/wizard/common/site/apps.sh b/scripts/easy-docker/lib/app/wizard/common/site/apps.sh index 4c57110e..6ccb4ab7 100755 --- a/scripts/easy-docker/lib/app/wizard/common/site/apps.sh +++ b/scripts/easy-docker/lib/app/wizard/common/site/apps.sh @@ -1,5 +1,13 @@ #!/usr/bin/env bash +load_easy_docker_site_apps_modules() { + local apps_dir="" + apps_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/apps" + + # shellcheck source=scripts/easy-docker/lib/app/wizard/common/site/apps/lifecycle.sh + source "${apps_dir}/lifecycle.sh" +} + append_stack_installable_app_line() { local result_var="${1}" local existing_lines="${2:-}" @@ -74,3 +82,5 @@ get_stack_selected_installable_apps() { printf -v "${result_var}" "%s" "${ordered_app_lines}" return 0 } + +load_easy_docker_site_apps_modules diff --git a/scripts/easy-docker/lib/app/wizard/common/site/apps/lifecycle.sh b/scripts/easy-docker/lib/app/wizard/common/site/apps/lifecycle.sh new file mode 100755 index 00000000..4526606d --- /dev/null +++ b/scripts/easy-docker/lib/app/wizard/common/site/apps/lifecycle.sh @@ -0,0 +1,437 @@ +#!/usr/bin/env bash + +stack_site_app_lines_contain() { + local app_lines="${1:-}" + local app_name="${2:-}" + + if [ -z "${app_name}" ]; then + return 1 + fi + + printf '%s\n' "${app_lines}" | grep -F -x -- "${app_name}" >/dev/null 2>&1 +} + +remove_stack_site_app_line() { + local result_var="${1}" + local existing_lines="${2:-}" + local app_name="${3:-}" + local existing_app="" + local remaining_lines="" + + while IFS= read -r existing_app; do + if [ -z "${existing_app}" ] || [ "${existing_app}" = "${app_name}" ]; then + continue + fi + + if [ -z "${remaining_lines}" ]; then + remaining_lines="${existing_app}" + else + remaining_lines="${remaining_lines}"$'\n'"${existing_app}" + fi + done </dev/null 2>&1 || true + if ! persist_stack_site_app_operation_metadata \ + "${stack_dir}" \ + "${site_name}" \ + "${current_installed_app_lines}" \ + "install-app" \ + "${EASY_DOCKER_SITE_ERROR_DETAIL}" \ + "${EASY_DOCKER_SITE_ERROR_LOG_PATH}"; then + return 89 + fi + case "${command_status}" in + 54) + return 84 + ;; + 52) + return 82 + ;; + *) + return 88 + ;; + esac + fi + + if ! get_stack_site_managed_runtime_app_lines updated_installed_app_lines "${stack_dir}" "${site_name}"; then + updated_installed_app_lines="${current_installed_app_lines}" + append_stack_installable_app_line updated_installed_app_lines "${updated_installed_app_lines}" "${app_name}" + fi + + if ! persist_stack_site_app_operation_metadata \ + "${stack_dir}" \ + "${site_name}" \ + "${updated_installed_app_lines}" \ + "install-app" \ + "" \ + ""; then + return 89 + fi + + return 0 +} + +uninstall_app_from_configured_stack_site() { + local stack_dir="${1}" + local app_name="${2:-}" + local site_name="" + local uninstallable_app_lines="" + local current_installed_app_lines="" + local updated_installed_app_lines="" + local uninstall_command="" + local uninstall_output="" + local command_status=0 + local uninstallable_status=0 + + reset_easy_docker_site_error_state + + if [ -z "${app_name}" ] || [ "${app_name}" = "frappe" ]; then + return 96 + fi + + if get_configured_stack_site_uninstallable_app_lines uninstallable_app_lines "${stack_dir}"; then + : + else + uninstallable_status=$? + return "${uninstallable_status}" + fi + + if [ -z "${uninstallable_app_lines}" ]; then + return 95 + fi + + site_name="$(get_stack_site_name "${stack_dir}" || true)" + current_installed_app_lines="${uninstallable_app_lines}" + + if ! stack_site_app_lines_contain "${uninstallable_app_lines}" "${app_name}"; then + return 96 + fi + + uninstall_command="$( + printf "bench --site %s uninstall-app %s --yes" \ + "$(shell_quote_site_command_arg "${site_name}")" \ + "$(shell_quote_site_command_arg "${app_name}")" + )" + + if run_stack_backend_bash_command_capture uninstall_output "${stack_dir}" "${uninstall_command}"; then + : + else + command_status=$? + EASY_DOCKER_SITE_ERROR_DETAIL="$(printf "bench uninstall-app failed for '%s'." "${app_name}")" + capture_stack_site_error_log "${stack_dir}" "site-uninstall-app-error" "${uninstall_output}" >/dev/null 2>&1 || true + if ! persist_stack_site_app_operation_metadata \ + "${stack_dir}" \ + "${site_name}" \ + "${current_installed_app_lines}" \ + "uninstall-app" \ + "${EASY_DOCKER_SITE_ERROR_DETAIL}" \ + "${EASY_DOCKER_SITE_ERROR_LOG_PATH}"; then + return 99 + fi + case "${command_status}" in + 54) + return 94 + ;; + 52) + return 92 + ;; + *) + return 98 + ;; + esac + fi + + if ! get_stack_site_managed_runtime_app_lines updated_installed_app_lines "${stack_dir}" "${site_name}"; then + remove_stack_site_app_line updated_installed_app_lines "${current_installed_app_lines}" "${app_name}" + fi + + if ! persist_stack_site_app_operation_metadata \ + "${stack_dir}" \ + "${site_name}" \ + "${updated_installed_app_lines}" \ + "uninstall-app" \ + "" \ + ""; then + return 99 + fi + + return 0 +} diff --git a/scripts/easy-docker/lib/app/wizard/flows/manage/site.sh b/scripts/easy-docker/lib/app/wizard/flows/manage/site.sh index b2f055a4..52e1fd80 100755 --- a/scripts/easy-docker/lib/app/wizard/flows/manage/site.sh +++ b/scripts/easy-docker/lib/app/wizard/flows/manage/site.sh @@ -14,6 +14,10 @@ handle_manage_stack_site_flow() { local existing_site_apps_csv="" local existing_site_last_backup_at="" local existing_site_details_action="" + local existing_site_apps_action="" + local existing_site_app_lines="" + local existing_site_app_selection="" + local existing_site_app_confirmation="" local site_delete_confirmation="" while true; do @@ -100,7 +104,7 @@ handle_manage_stack_site_flow() { existing_site_apps_lines="$(get_stack_site_apps_installed_lines "${stack_dir}" || true)" existing_site_last_backup_at="$(get_stack_site_last_backup_at "${stack_dir}" || true)" if stack_backend_service_is_running "${stack_dir}" >/dev/null 2>&1; then - get_stack_site_runtime_selected_apps_lines existing_site_apps_lines "${stack_dir}" "${existing_site_name}" || true + get_stack_site_managed_runtime_app_lines existing_site_apps_lines "${stack_dir}" "${existing_site_name}" || true fi if [ -n "${existing_site_apps_lines}" ]; then existing_site_apps_csv="$(printf '%s' "${existing_site_apps_lines}" | tr '\n' ',' | sed 's/,$//')" @@ -118,6 +122,230 @@ handle_manage_stack_site_flow() { "${existing_site_last_backup_at}" || true )" case "${existing_site_details_action}" in + "Manage apps on this site") + while true; do + existing_site_apps_action="$( + show_manage_stack_site_apps_menu \ + "${stack_name}" \ + "${stack_dir}" \ + "${existing_site_name}" || true + )" + case "${existing_site_apps_action}" in + "Install app on this site") + if ! get_configured_stack_site_installable_app_lines existing_site_app_lines "${stack_dir}"; then + site_flow_status=$? + case "${site_flow_status}" in + 81) + show_warning_and_wait "Cannot install app: backend service is not running yet. Start the stack first." 4 + ;; + 82) + show_warning_and_wait "Cannot install app for this topology yet. Only single-host stacks are supported." 4 + ;; + 83) + show_warning_and_wait "Cannot install app because no configured site was found in metadata.json." 4 + ;; + 84) + show_warning_and_wait "Cannot install app because stack metadata, env, or compose inputs are incomplete." 4 + ;; + 87) + show_warning_and_wait "Cannot inspect installable apps on this site right now. Check backend readiness and try again." 4 + ;; + *) + show_warning_and_wait "Could not prepare installable site apps (${site_flow_status})." 4 + ;; + esac + continue + fi + + if [ -z "${existing_site_app_lines}" ]; then + show_warning_and_wait "No additional selected stack apps are available to install on this site. No further apps are currently available in this stack environment." 4 + continue + fi + + existing_site_app_selection="$( + show_manage_stack_site_app_selection \ + "${stack_name}" \ + "${stack_dir}" \ + "${existing_site_name}" \ + "Install app on this site" \ + "${existing_site_app_lines}" || true + )" + case "${existing_site_app_selection}" in + "Back" | "") + continue + ;; + "Exit and close easy-docker") + return "${FLOW_EXIT_APP}" + ;; + *) + show_warning_message "Installing app on site: ${existing_site_app_selection}" + if install_app_on_configured_stack_site "${stack_dir}" "${existing_site_app_selection}"; then + show_warning_and_wait "App installed successfully on site ${existing_site_name}: ${existing_site_app_selection}" 4 + continue + fi + + site_flow_status=$? + case "${site_flow_status}" in + 81) + show_warning_and_wait "Cannot install app: backend service is not running yet. Start the stack first." 4 + ;; + 82) + show_warning_and_wait "Cannot install app for this topology yet. Only single-host stacks are supported." 4 + ;; + 83) + show_warning_and_wait "Cannot install app because no configured site was found in metadata.json." 4 + ;; + 84) + show_warning_and_wait "Cannot install app because stack metadata, env, or compose inputs are incomplete." 4 + ;; + 85) + show_warning_and_wait "No additional selected stack apps are available to install on this site. No further apps are currently available in this stack environment." 4 + ;; + 86) + show_warning_and_wait "The selected app is not currently installable on this site." 4 + ;; + 87) + show_warning_and_wait "Cannot inspect current site apps right now. Check backend readiness and try again." 4 + ;; + 88) + show_warning_and_wait "App installation failed. ${EASY_DOCKER_SITE_ERROR_DETAIL:-Check the output above.} ${EASY_DOCKER_SITE_ERROR_LOG_PATH:+See ${stack_dir}/${EASY_DOCKER_SITE_ERROR_LOG_PATH}}" 6 + ;; + 89) + show_warning_and_wait "The app command finished, but site metadata could not be written to metadata.json." 5 + ;; + *) + show_warning_and_wait "App installation failed (${site_flow_status})." 4 + ;; + esac + continue + ;; + esac + ;; + "Uninstall app from this site") + if ! get_configured_stack_site_uninstallable_app_lines existing_site_app_lines "${stack_dir}"; then + site_flow_status=$? + case "${site_flow_status}" in + 91) + show_warning_and_wait "Cannot uninstall app: backend service is not running yet. Start the stack first." 4 + ;; + 92) + show_warning_and_wait "Cannot uninstall app for this topology yet. Only single-host stacks are supported." 4 + ;; + 93) + show_warning_and_wait "Cannot uninstall app because no configured site was found in metadata.json." 4 + ;; + 94) + show_warning_and_wait "Cannot uninstall app because stack metadata, env, or compose inputs are incomplete." 4 + ;; + 97) + show_warning_and_wait "Cannot inspect uninstallable apps on this site right now. Check backend readiness and try again." 4 + ;; + *) + show_warning_and_wait "Could not prepare uninstallable site apps (${site_flow_status})." 4 + ;; + esac + continue + fi + + if [ -z "${existing_site_app_lines}" ]; then + show_warning_and_wait "No installed site apps are currently available for uninstall." 4 + continue + fi + + existing_site_app_selection="$( + show_manage_stack_site_app_selection \ + "${stack_name}" \ + "${stack_dir}" \ + "${existing_site_name}" \ + "Uninstall app from this site" \ + "${existing_site_app_lines}" || true + )" + case "${existing_site_app_selection}" in + "Back" | "") + continue + ;; + "Exit and close easy-docker") + return "${FLOW_EXIT_APP}" + ;; + *) + existing_site_app_confirmation="$( + show_manage_stack_site_app_uninstall_confirmation \ + "${stack_name}" \ + "${stack_dir}" \ + "${existing_site_name}" \ + "${existing_site_app_selection}" || true + )" + case "${existing_site_app_confirmation}" in + "No" | "") + continue + ;; + "Exit and close easy-docker") + return "${FLOW_EXIT_APP}" + ;; + "Yes") + show_warning_message "Uninstalling app from site: ${existing_site_app_selection}" + if uninstall_app_from_configured_stack_site "${stack_dir}" "${existing_site_app_selection}"; then + show_warning_and_wait "App uninstalled successfully from site ${existing_site_name}: ${existing_site_app_selection}" 4 + continue + fi + + site_flow_status=$? + case "${site_flow_status}" in + 91) + show_warning_and_wait "Cannot uninstall app: backend service is not running yet. Start the stack first." 4 + ;; + 92) + show_warning_and_wait "Cannot uninstall app for this topology yet. Only single-host stacks are supported." 4 + ;; + 93) + show_warning_and_wait "Cannot uninstall app because no configured site was found in metadata.json." 4 + ;; + 94) + show_warning_and_wait "Cannot uninstall app because stack metadata, env, or compose inputs are incomplete." 4 + ;; + 95) + show_warning_and_wait "No installed site apps are currently available for uninstall." 4 + ;; + 96) + show_warning_and_wait "The selected app cannot be uninstalled here. frappe stays blocked, but erpnext is allowed." 4 + ;; + 97) + show_warning_and_wait "Cannot inspect current site apps right now. Check backend readiness and try again." 4 + ;; + 98) + show_warning_and_wait "App uninstall failed. ${EASY_DOCKER_SITE_ERROR_DETAIL:-Check the output above.} ${EASY_DOCKER_SITE_ERROR_LOG_PATH:+See ${stack_dir}/${EASY_DOCKER_SITE_ERROR_LOG_PATH}}" 6 + ;; + 99) + show_warning_and_wait "The app command finished, but site metadata could not be written to metadata.json." 5 + ;; + *) + show_warning_and_wait "App uninstall failed (${site_flow_status})." 4 + ;; + esac + continue + ;; + *) + show_warning_and_wait "Unknown uninstall app confirmation action: ${existing_site_app_confirmation}" 2 + continue + ;; + esac + ;; + esac + ;; + "Back" | "") + break + ;; + "Exit and close easy-docker") + return "${FLOW_EXIT_APP}" + ;; + *) + show_warning_and_wait "Unknown site apps action: ${existing_site_apps_action}" 2 + continue + ;; + esac + done + continue + ;; "Backup site now") show_warning_message "Creating backup for site: ${existing_site_name}" if backup_configured_stack_site "${stack_dir}"; then diff --git a/scripts/easy-docker/lib/ui/screens/production/manage.sh b/scripts/easy-docker/lib/ui/screens/production/manage.sh index cfbba951..29369f72 100755 --- a/scripts/easy-docker/lib/ui/screens/production/manage.sh +++ b/scripts/easy-docker/lib/ui/screens/production/manage.sh @@ -204,16 +204,99 @@ show_manage_stack_site_details() { render_box_message "${status_text}" "0 2" >&2 gum choose \ - --height 9 \ + --height 10 \ --header "Site details" \ --cursor.foreground 63 \ --selected.foreground 45 \ + "Manage apps on this site" \ "Backup site now" \ "Delete site" \ "Back" \ "Exit and close easy-docker" } +show_manage_stack_site_apps_menu() { + local stack_name="${1}" + local stack_dir="${2}" + local site_name="${3}" + local status_text="" + + render_main_screen 1 >&2 + + status_text="$(printf "Manage site apps\n\nStack: %s\nDirectory: %s\nSite: %s\n\nInstall or uninstall apps for this existing site." "${stack_name}" "${stack_dir}" "${site_name}")" + render_box_message "${status_text}" "0 2" >&2 + + gum choose \ + --height 9 \ + --header "Site app actions" \ + --cursor.foreground 63 \ + --selected.foreground 45 \ + "Install app on this site" \ + "Uninstall app from this site" \ + "Back" \ + "Exit and close easy-docker" +} + +show_manage_stack_site_app_selection() { + local stack_name="${1}" + local stack_dir="${2}" + local site_name="${3}" + local action_label="${4}" + local app_lines="${5:-}" + local status_text="" + local app_name="" + local -a menu_options=() + + render_main_screen 1 >&2 + + status_text="$(printf "%s\n\nStack: %s\nDirectory: %s\nSite: %s\n\nSelect one app." "${action_label}" "${stack_name}" "${stack_dir}" "${site_name}")" + render_box_message "${status_text}" "0 2" >&2 + + while IFS= read -r app_name; do + if [ -z "${app_name}" ]; then + continue + fi + menu_options+=("${app_name}") + done <&2 + + status_text="$(printf "Uninstall app from site\n\nStack: %s\nDirectory: %s\nSite: %s\nApp: %s\n\nThis removes the app from the site. frappe itself cannot be removed here." "${stack_name}" "${stack_dir}" "${site_name}" "${app_name}")" + render_box_message "${status_text}" "0 2" >&2 + + gum choose \ + --height 8 \ + --header "Confirm uninstall app" \ + --cursor.foreground 63 \ + --selected.foreground 45 \ + "Yes" \ + "No" \ + "Exit and close easy-docker" +} + show_manage_stack_site_delete_confirmation() { local stack_name="${1}" local stack_dir="${2}" From 27bb816ff4cef777ac179913343924924dc2282e Mon Sep 17 00:00:00 2001 From: RocketQuack <202538874+Rocket-Quack@users.noreply.github.com> Date: Fri, 3 Apr 2026 15:28:59 +0200 Subject: [PATCH 31/51] feat(easy-docker): add guided app update workflow --- scripts/easy-docker/lib/app/wizard/env.sh | 2 + .../easy-docker/lib/app/wizard/env/apps.sh | 121 ++++++++++++ .../easy-docker/lib/app/wizard/env/update.sh | 146 +++++++++++++++ .../lib/app/wizard/env/validation.sh | 16 ++ .../lib/app/wizard/flows/manage/docker.sh | 17 ++ .../lib/app/wizard/flows/manage/stack.sh | 174 ++++++++++++------ .../lib/ui/screens/production/manage.sh | 21 ++- 7 files changed, 430 insertions(+), 67 deletions(-) create mode 100755 scripts/easy-docker/lib/app/wizard/env/update.sh diff --git a/scripts/easy-docker/lib/app/wizard/env.sh b/scripts/easy-docker/lib/app/wizard/env.sh index c6874b06..305dbfe2 100755 --- a/scripts/easy-docker/lib/app/wizard/env.sh +++ b/scripts/easy-docker/lib/app/wizard/env.sh @@ -8,6 +8,8 @@ load_easy_docker_wizard_env_modules() { source "${wizard_dir}/env/validation.sh" # shellcheck source=scripts/easy-docker/lib/app/wizard/env/apps.sh source "${wizard_dir}/env/apps.sh" + # shellcheck source=scripts/easy-docker/lib/app/wizard/env/update.sh + source "${wizard_dir}/env/update.sh" # shellcheck source=scripts/easy-docker/lib/app/wizard/env/collect.sh source "${wizard_dir}/env/collect.sh" } diff --git a/scripts/easy-docker/lib/app/wizard/env/apps.sh b/scripts/easy-docker/lib/app/wizard/env/apps.sh index 4b939107..30f50e1c 100755 --- a/scripts/easy-docker/lib/app/wizard/env/apps.sh +++ b/scripts/easy-docker/lib/app/wizard/env/apps.sh @@ -431,3 +431,124 @@ update_stack_custom_modular_apps() { return 0 } + +prompt_selected_stack_app_branches_data() { + local result_apps_metadata_var="${1}" + local stack_dir="${2}" + local metadata_path="" + local selected_predefined_csv="" + local predefined_app_id="" + local predefined_app_label="" + local predefined_repo_url="" + local selected_branch="" + local preferred_branch="" + local available_branch_lines="" + local existing_branch_lines="" + local selected_branch_lines="" + local selected_app_count=0 + local built_apps_metadata_json_object="" + local prompt_status=0 + local -a selected_predefined_ids=() + + metadata_path="${stack_dir}/metadata.json" + if [ ! -f "${metadata_path}" ]; then + return 3 + fi + + selected_predefined_csv="$(get_metadata_apps_predefined_csv "${metadata_path}" || true)" + existing_branch_lines="$(get_metadata_apps_predefined_branch_lines "${metadata_path}" || true)" + if [ -z "${selected_predefined_csv}" ]; then + return 4 + fi + + selected_branch_lines="" + IFS=',' read -r -a selected_predefined_ids <<<"${selected_predefined_csv}" + for predefined_app_id in "${selected_predefined_ids[@]}"; do + if [ -z "${predefined_app_id}" ]; then + continue + fi + + predefined_app_label="$(get_predefined_app_label_by_id "${predefined_app_id}" || true)" + if [ -z "${predefined_app_label}" ]; then + predefined_app_label="${predefined_app_id}" + fi + + predefined_repo_url="$(get_predefined_app_repo_by_id "${predefined_app_id}" || true)" + if [ -z "${predefined_repo_url}" ]; then + show_warning_and_wait "Missing repo URL for app '${predefined_app_id}'." 3 + return 1 + fi + + preferred_branch="$(get_predefined_branch_from_lines "${existing_branch_lines}" "${predefined_app_id}" || true)" + if [ -z "${preferred_branch}" ]; then + preferred_branch="$(get_stack_frappe_branch "${stack_dir}" || true)" + fi + if [ -z "${preferred_branch}" ]; then + preferred_branch="$(get_predefined_app_default_branch_by_id "${predefined_app_id}" || true)" + fi + if [ -z "${preferred_branch}" ]; then + preferred_branch="$(get_default_frappe_branch)" + fi + + available_branch_lines="" + if get_predefined_app_branch_lines_by_id available_branch_lines "${predefined_app_id}"; then + if [ -n "${preferred_branch}" ] && ! lines_contains_line "${available_branch_lines}" "${preferred_branch}"; then + preferred_branch="$(get_predefined_app_default_branch_by_id "${predefined_app_id}" || true)" + fi + fi + + if choose_predefined_app_branch selected_branch "${stack_dir}" "${predefined_app_id}" "${predefined_app_label}" "${predefined_repo_url}" "${preferred_branch}"; then + : + else + prompt_status=$? + if [ "${prompt_status}" -eq 2 ]; then + return 2 + fi + return "${prompt_status}" + fi + + append_line_unique selected_branch_lines "${selected_branch_lines}" "${predefined_app_id}|${selected_branch}" + selected_app_count=$((selected_app_count + 1)) + done + + if [ "${selected_app_count}" -eq 0 ]; then + 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}" + return 0 +} + +update_stack_selected_app_branches() { + local stack_dir="${1}" + local metadata_path="" + local apps_metadata_json_object="" + local prompt_status=0 + + metadata_path="${stack_dir}/metadata.json" + if [ ! -f "${metadata_path}" ]; then + return 3 + fi + + if prompt_selected_stack_app_branches_data apps_metadata_json_object "${stack_dir}"; then + : + else + prompt_status=$? + return "${prompt_status}" + fi + + if [ -z "${apps_metadata_json_object}" ]; then + return 1 + fi + + if ! persist_stack_metadata_apps_object "${stack_dir}" "${apps_metadata_json_object}"; then + return 1 + fi + + if ! persist_stack_apps_json_from_metadata_apps "${stack_dir}"; then + return 1 + fi + + return 0 +} diff --git a/scripts/easy-docker/lib/app/wizard/env/update.sh b/scripts/easy-docker/lib/app/wizard/env/update.sh new file mode 100755 index 00000000..141cbcd3 --- /dev/null +++ b/scripts/easy-docker/lib/app/wizard/env/update.sh @@ -0,0 +1,146 @@ +#!/usr/bin/env bash + +is_valid_docker_image_tag() { + local value="${1}" + + if [ -z "${value}" ] || [ "${#value}" -gt 128 ]; then + return 1 + fi + + case "${value}" in + .* | -*) + return 1 + ;; + *[!A-Za-z0-9_.-]*) + return 1 + ;; + esac + + return 0 +} + +get_stack_custom_image_name() { + local stack_dir="${1}" + local env_path="" + + env_path="$(get_stack_env_path "${stack_dir}")" + get_env_file_key_value "${env_path}" "CUSTOM_IMAGE" +} + +get_stack_custom_image_tag() { + local stack_dir="${1}" + local env_path="" + + env_path="$(get_stack_env_path "${stack_dir}")" + get_env_file_key_value "${env_path}" "CUSTOM_TAG" +} + +get_stack_custom_image_ref() { + local stack_dir="${1}" + local custom_image="" + local custom_tag="" + + custom_image="$(get_stack_custom_image_name "${stack_dir}" || true)" + custom_tag="$(get_stack_custom_image_tag "${stack_dir}" || true)" + if [ -z "${custom_image}" ] || [ -z "${custom_tag}" ]; then + return 1 + fi + + printf '%s:%s\n' "${custom_image}" "${custom_tag}" +} + +persist_env_file_key_value() { + local env_path="${1}" + local key="${2}" + local value="${3}" + local tmp_path="" + + if [ ! -f "${env_path}" ]; then + return 1 + fi + + tmp_path="${env_path}.tmp" + if ! awk -v key="${key}" -v value="${value}" ' + BEGIN { + updated = 0 + } + { + line = $0 + sub(/\r$/, "", line) + + if (line ~ "^[[:space:]]*(export[[:space:]]+)?" key "[[:space:]]*=") { + print key "=" value + updated = 1 + next + } + + print line + } + END { + if (!updated) { + print key "=" value + } + } + ' "${env_path}" >"${tmp_path}"; then + rm -f -- "${tmp_path}" >/dev/null 2>&1 || true + return 1 + fi + + if ! mv -- "${tmp_path}" "${env_path}"; then + rm -f -- "${tmp_path}" >/dev/null 2>&1 || true + return 1 + fi + + return 0 +} + +set_stack_custom_image_tag() { + local stack_dir="${1}" + local custom_tag="${2:-}" + local env_path="" + local custom_image="" + + env_path="$(get_stack_env_path "${stack_dir}")" + if [ ! -f "${env_path}" ]; then + return 31 + fi + + if ! is_valid_docker_image_tag "${custom_tag}"; then + return 32 + fi + + custom_image="$(get_env_file_key_value "${env_path}" "CUSTOM_IMAGE" || true)" + if [ -z "${custom_image}" ]; then + return 33 + fi + + if ! persist_env_file_key_value "${env_path}" "CUSTOM_TAG" "${custom_tag}"; then + return 34 + fi + + return 0 +} + +prompt_stack_custom_image_tag_with_cancel() { + local result_var="${1}" + local stack_dir="${2}" + local current_image="" + local current_tag="" + local guidance_text="" + local custom_tag="" + local prompt_status=0 + + current_image="$(get_stack_custom_image_name "${stack_dir}" || true)" + current_tag="$(get_stack_custom_image_tag "${stack_dir}" || true)" + guidance_text="$(printf "Current custom image: %s\nCurrent custom tag: %s\n\nEnter the next CUSTOM_TAG for the rebuilt image.\nExample: v1.4.3 or 2026-04-02-appupdate.\nType /back to return." "${current_image:-n/a}" "${current_tag:-n/a}")" + + if prompt_env_value_with_validation custom_tag "${stack_dir}" "CUSTOM_TAG" "${guidance_text}" "${current_tag}" "required" "image_tag"; then + : + else + prompt_status=$? + return "${prompt_status}" + fi + + printf -v "${result_var}" "%s" "${custom_tag}" + 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 3d4a5d66..e40a1bf7 100755 --- a/scripts/easy-docker/lib/app/wizard/env/validation.sh +++ b/scripts/easy-docker/lib/app/wizard/env/validation.sh @@ -342,6 +342,16 @@ is_valid_domains_value() { return 0 } +is_valid_image_tag_value() { + local value="${1}" + + if ! is_valid_docker_image_tag "${value}"; then + return 1 + fi + + return 0 +} + prompt_env_value_with_validation() { local result_var="${1}" local stack_dir="${2}" @@ -413,6 +423,12 @@ prompt_env_value_with_validation() { continue fi ;; + image_tag) + if ! is_valid_image_tag_value "${normalized_value}"; then + validation_feedback="Invalid image tag for ${variable_name}. Use letters, numbers, dots, dashes, or underscores." + continue + fi + ;; none | "") ;; *) show_warning_message "Unknown validation rule: ${validation_kind}" diff --git a/scripts/easy-docker/lib/app/wizard/flows/manage/docker.sh b/scripts/easy-docker/lib/app/wizard/flows/manage/docker.sh index b6b5a4b1..6ef17921 100755 --- a/scripts/easy-docker/lib/app/wizard/flows/manage/docker.sh +++ b/scripts/easy-docker/lib/app/wizard/flows/manage/docker.sh @@ -1,5 +1,21 @@ #!/usr/bin/env bash +refresh_stack_generated_compose_with_feedback() { + local stack_dir="${1}" + local generated_compose_path="" + local render_compose_status=0 + + generated_compose_path="$(get_stack_generated_compose_path "${stack_dir}")" + if render_stack_compose_from_metadata "${stack_dir}"; then + show_warning_and_wait "Generated compose refreshed successfully: ${generated_compose_path}" 3 + return 0 + fi + + render_compose_status=$? + show_warning_and_wait "The image build succeeded, but generated compose could not be refreshed (${render_compose_status}) for ${generated_compose_path}." 4 + return "${render_compose_status}" +} + run_build_stack_custom_image_with_feedback() { local stack_name="${1}" local stack_dir="${2}" @@ -8,6 +24,7 @@ run_build_stack_custom_image_with_feedback() { show_warning_message "Starting docker build for stack: ${stack_name}" if build_stack_custom_image "${stack_dir}"; then show_warning_and_wait "Custom image build finished successfully for stack: ${stack_name}" 3 + refresh_stack_generated_compose_with_feedback "${stack_dir}" || true return 0 else build_image_status=$? diff --git a/scripts/easy-docker/lib/app/wizard/flows/manage/stack.sh b/scripts/easy-docker/lib/app/wizard/flows/manage/stack.sh index 4593eb3b..4f8cfd40 100755 --- a/scripts/easy-docker/lib/app/wizard/flows/manage/stack.sh +++ b/scripts/easy-docker/lib/app/wizard/flows/manage/stack.sh @@ -5,15 +5,18 @@ handle_manage_selected_stack_flow() { local stack_dir="" local stack_action="" local apps_action="" - local docker_action="" + local updates_action="" local stack_metadata_path="" local stack_apps_path="" + local stack_env_path="" local custom_apps_update_status=0 - local persist_apps_status=0 - local render_compose_status=0 local compose_start_status=0 - local generated_compose_path="" local stack_runtime_status="" + local stack_frappe_branch="" + local stack_custom_image_ref="" + local stack_custom_tag="" + local custom_tag_prompt_status=0 + local custom_tag_update_status=0 local missing_custom_image_action="" local delete_stack_confirmation_action="" local delete_stack_keyword="" @@ -26,30 +29,14 @@ handle_manage_selected_stack_flow() { while true; do get_stack_compose_runtime_status_label stack_runtime_status "${stack_dir}" + stack_frappe_branch="$(get_stack_frappe_branch "${stack_dir}" || true)" + stack_custom_image_ref="$(get_stack_custom_image_ref "${stack_dir}" || true)" stack_action="$(show_manage_stack_actions_menu "${stack_name}" "${stack_dir}" "${stack_runtime_status}" || true)" case "${stack_action}" in "Apps") while true; do - apps_action="$(show_manage_stack_apps_menu "${stack_name}" "${stack_dir}" || true)" + apps_action="$(show_manage_stack_apps_menu "${stack_name}" "${stack_dir}" "${stack_frappe_branch}" || true)" case "${apps_action}" in - "Regenerate apps.json from metadata") - stack_metadata_path="${stack_dir}/metadata.json" - stack_apps_path="${stack_dir}/apps.json" - if [ ! -f "${stack_metadata_path}" ]; then - show_warning_and_wait "Cannot generate apps.json because metadata is missing: ${stack_metadata_path}" 3 - continue - fi - - if persist_stack_apps_json_from_metadata_apps "${stack_dir}"; then - : - else - persist_apps_status=$? - show_warning_and_wait "Could not generate ${stack_apps_path} (${persist_apps_status})." 3 - continue - fi - - show_warning_and_wait "apps.json generated successfully: ${stack_apps_path}" 3 - ;; "Select apps and branches") if update_stack_custom_modular_apps "${stack_dir}"; then : @@ -86,6 +73,112 @@ handle_manage_selected_stack_flow() { esac done ;; + "Updates") + while true; do + stack_custom_image_ref="$(get_stack_custom_image_ref "${stack_dir}" || true)" + updates_action="$(show_manage_stack_updates_menu "${stack_name}" "${stack_dir}" "${stack_frappe_branch}" "${stack_custom_image_ref}" || true)" + case "${updates_action}" in + "Update selected app branches") + if update_stack_selected_app_branches "${stack_dir}"; then + stack_apps_path="${stack_dir}/apps.json" + show_warning_and_wait "Selected app branches updated in ${stack_dir}/metadata.json and ${stack_apps_path}. Set the next custom image tag, build the updated image, restart the stack, and migrate the site to apply the update." 6 + else + custom_apps_update_status=$? + case "${custom_apps_update_status}" in + 2 | 130) + continue + ;; + 3) + stack_metadata_path="${stack_dir}/metadata.json" + show_warning_and_wait "Cannot update selected app branches because metadata is missing: ${stack_metadata_path}" 3 + continue + ;; + 4) + show_warning_and_wait "No selected stack apps were found for branch updates. Select apps for the stack first." 4 + continue + ;; + *) + show_warning_and_wait "Could not update selected app branches (${custom_apps_update_status}) for stack: ${stack_name}" 3 + continue + ;; + esac + fi + ;; + "Set next custom image tag") + stack_env_path="$(get_stack_env_path "${stack_dir}")" + if [ ! -f "${stack_env_path}" ]; then + show_warning_and_wait "Cannot update CUSTOM_TAG because the stack env file is missing: ${stack_env_path}" 4 + continue + fi + + if [ -z "$(get_stack_custom_image_name "${stack_dir}" || true)" ]; then + show_warning_and_wait "Cannot update CUSTOM_TAG because CUSTOM_IMAGE is missing in the stack env file." 4 + continue + fi + + if prompt_stack_custom_image_tag_with_cancel stack_custom_tag "${stack_dir}"; then + : + else + custom_tag_prompt_status=$? + case "${custom_tag_prompt_status}" in + 2 | 130) + continue + ;; + *) + show_warning_and_wait "Could not collect the next custom image tag (${custom_tag_prompt_status})." 3 + continue + ;; + esac + fi + + if set_stack_custom_image_tag "${stack_dir}" "${stack_custom_tag}"; then + stack_custom_image_ref="$(get_stack_custom_image_ref "${stack_dir}" || true)" + show_warning_message "Custom image tag updated successfully: ${stack_custom_image_ref:-${stack_custom_tag}}" + if run_build_stack_custom_image_with_feedback "${stack_name}" "${stack_dir}"; then + : + else + continue + fi + else + custom_tag_update_status=$? + case "${custom_tag_update_status}" in + 31) + show_warning_and_wait "Cannot update CUSTOM_TAG because the stack env file is missing: ${stack_env_path}" 4 + ;; + 32) + show_warning_and_wait "Cannot update CUSTOM_TAG because the value is not a valid Docker image tag." 4 + ;; + 33) + show_warning_and_wait "Cannot update CUSTOM_TAG because CUSTOM_IMAGE is missing in the stack env file." 4 + ;; + 34) + show_warning_and_wait "Could not write the updated CUSTOM_TAG to ${stack_env_path}." 4 + ;; + *) + show_warning_and_wait "Could not update CUSTOM_TAG (${custom_tag_update_status})." 4 + ;; + esac + fi + ;; + "Build updated image") + if run_build_stack_custom_image_with_feedback "${stack_name}" "${stack_dir}"; then + : + else + continue + fi + ;; + "Back" | "") + break + ;; + "Exit and close easy-docker") + return "${FLOW_EXIT_APP}" + ;; + *) + show_warning_and_wait "Unknown update action: ${updates_action}" + ;; + esac + done + ;; "Start stack in Docker Compose") while true; do show_warning_message "Starting stack with docker compose: ${stack_name}" @@ -329,41 +422,6 @@ handle_manage_selected_stack_flow() { ;; esac ;; - "Docker") - while true; do - docker_action="$(show_manage_stack_docker_menu "${stack_name}" "${stack_dir}" || true)" - case "${docker_action}" in - "Build custom image") - if run_build_stack_custom_image_with_feedback "${stack_name}" "${stack_dir}"; then - : - else - continue - fi - ;; - "Generate docker compose from env") - generated_compose_path="$(get_stack_generated_compose_path "${stack_dir}")" - if render_stack_compose_from_metadata "${stack_dir}"; then - : - else - render_compose_status=$? - show_warning_and_wait "Could not generate docker compose (${render_compose_status}) for ${generated_compose_path}." 3 - continue - fi - - show_warning_and_wait "Docker compose generated successfully: ${generated_compose_path}" 3 - ;; - "Back" | "") - break - ;; - "Exit and close easy-docker") - return "${FLOW_EXIT_APP}" - ;; - *) - show_warning_and_wait "Unknown docker action: ${docker_action}" - ;; - esac - done - ;; "Site") if handle_manage_stack_site_flow "${stack_name}" "${stack_dir}"; then : diff --git a/scripts/easy-docker/lib/ui/screens/production/manage.sh b/scripts/easy-docker/lib/ui/screens/production/manage.sh index 29369f72..9102513a 100755 --- a/scripts/easy-docker/lib/ui/screens/production/manage.sh +++ b/scripts/easy-docker/lib/ui/screens/production/manage.sh @@ -70,7 +70,7 @@ show_manage_stack_actions_menu() { --cursor.foreground 63 \ --selected.foreground 45 \ "Apps" \ - "Docker" \ + "Updates" \ "Site" \ "Start stack in Docker Compose" \ "Restart stack in Docker Compose" \ @@ -83,11 +83,12 @@ show_manage_stack_actions_menu() { show_manage_stack_apps_menu() { local stack_name="${1}" local stack_dir="${2}" + local frappe_branch="${3:-n/a}" local status_text="" render_main_screen 1 >&2 - status_text="$(printf "Manage stack apps\n\nStack: %s\nDirectory: %s\n\nChoose an app-related action for this stack." "${stack_name}" "${stack_dir}")" + status_text="$(printf "Manage stack apps\n\nStack: %s\nDirectory: %s\nFrappe branch: %s\n\nChoose an app-related action for this stack." "${stack_name}" "${stack_dir}" "${frappe_branch}")" render_box_message "${status_text}" "0 2" >&2 @@ -96,30 +97,32 @@ show_manage_stack_apps_menu() { --header "Stack apps actions" \ --cursor.foreground 63 \ --selected.foreground 45 \ - "Regenerate apps.json from metadata" \ "Select apps and branches" \ "Back" \ "Exit and close easy-docker" } -show_manage_stack_docker_menu() { +show_manage_stack_updates_menu() { local stack_name="${1}" local stack_dir="${2}" + local frappe_branch="${3:-n/a}" + local custom_image_ref="${4:-n/a}" local status_text="" render_main_screen 1 >&2 - status_text="$(printf "Manage stack docker\n\nStack: %s\nDirectory: %s\n\nChoose a docker-related action for this stack." "${stack_name}" "${stack_dir}")" + status_text="$(printf "Manage stack updates\n\nStack: %s\nDirectory: %s\nFrappe branch: %s\nCustom image: %s\n\nChoose an update-related action for this stack." "${stack_name}" "${stack_dir}" "${frappe_branch}" "${custom_image_ref}")" render_box_message "${status_text}" "0 2" >&2 gum choose \ - --height 8 \ - --header "Stack docker actions" \ + --height 9 \ + --header "Stack update actions" \ --cursor.foreground 63 \ --selected.foreground 45 \ - "Build custom image" \ - "Generate docker compose from env" \ + "Update selected app branches" \ + "Set next custom image tag" \ + "Build updated image" \ "Back" \ "Exit and close easy-docker" } From db939917746b5544498a6c996a5b3bf159c62ee8 Mon Sep 17 00:00:00 2001 From: RocketQuack <202538874+Rocket-Quack@users.noreply.github.com> Date: Fri, 3 Apr 2026 15:29:36 +0200 Subject: [PATCH 32/51] docs: add root documentation for easy-docker --- docs/01-getting-started/05-easy-docker.md | 37 +++++++++++++++++++++ docs/10-easy-docker/01-overview.md | 32 ++++++++++++++++++ docs/10-easy-docker/02-workflows.md | 27 +++++++++++++++ docs/10-easy-docker/03-updates.md | 26 +++++++++++++++ docs/10-easy-docker/04-generated-compose.md | 23 +++++++++++++ docs/10-easy-docker/index.md | 23 +++++++++++++ 6 files changed, 168 insertions(+) create mode 100644 docs/01-getting-started/05-easy-docker.md create mode 100644 docs/10-easy-docker/01-overview.md create mode 100644 docs/10-easy-docker/02-workflows.md create mode 100644 docs/10-easy-docker/03-updates.md create mode 100644 docs/10-easy-docker/04-generated-compose.md create mode 100644 docs/10-easy-docker/index.md diff --git a/docs/01-getting-started/05-easy-docker.md b/docs/01-getting-started/05-easy-docker.md new file mode 100644 index 00000000..55d1038c --- /dev/null +++ b/docs/01-getting-started/05-easy-docker.md @@ -0,0 +1,37 @@ +--- +title: Easy Docker +--- + +# Easy Docker + +`easy-docker` is the interactive setup and management workflow for this repository. +It guides common stack operations through a terminal UI so you do not have to assemble +every Docker and Bench command manually. + +For the detailed guide, use the dedicated docs area under `docs/10-easy-docker/`. +This getting-started page stays short and focuses on the first steps. + +Current status: + +- `single-host` is the primary supported topology +- `split-services` is still in development +- stack, site, app, backup, restart, and update flows are being expanded iteratively + +The script entrypoint is: + +```bash +bash easy-docker.sh +``` + +Minimal first use: + +1. Start `easy-docker.sh` +2. Create a new stack +3. Choose `single-host` +4. Pick the apps and branches you want +5. Build the custom image +6. Start the stack +7. Create the first site or manage an existing one from the stack menu + +Use this page as the entry point. For the full workflow reference, jump to the +dedicated `easy-docker` docs section in the root `docs` tree. diff --git a/docs/10-easy-docker/01-overview.md b/docs/10-easy-docker/01-overview.md new file mode 100644 index 00000000..7f9b08cd --- /dev/null +++ b/docs/10-easy-docker/01-overview.md @@ -0,0 +1,32 @@ +--- +title: Overview +--- + +# Overview + +`easy-docker` guides the main stack lifecycle through menus instead of requiring +users to assemble long Docker and Bench commands by hand. + +Current scope: + +- create a stack +- choose the topology +- configure apps and branches +- build the custom image +- start, restart, stop, and delete the stack +- create and manage a site +- install and uninstall apps on an existing site +- create a site backup + +Current limitations: + +- `single-host` is the supported path +- `split-services` is still marked as in development +- site management currently assumes one configured site per stack +- backup and app management are focused on the configured stack image and site + +The current entrypoint is: + +```bash +bash easy-docker.sh +``` diff --git a/docs/10-easy-docker/02-workflows.md b/docs/10-easy-docker/02-workflows.md new file mode 100644 index 00000000..02f3deb3 --- /dev/null +++ b/docs/10-easy-docker/02-workflows.md @@ -0,0 +1,27 @@ +--- +title: Workflows +--- + +# Workflows + +The wizard follows a simple order: + +1. Create a stack. +2. Choose `single-host` or review `split-services`. +3. Select the apps and branches for the stack. +4. Generate the stack environment and render the Compose snapshot. +5. Build the custom image. +6. Start the stack. +7. Create or select the configured site. +8. Manage site apps or create a backup. + +Stack actions are grouped around image and Compose lifecycle: + +- `Apps` manages the stack app selection +- `Updates` handles app-branch changes and custom image tag updates +- `Site` handles site creation, backup, install, uninstall, and deletion +- `Start`, `Restart`, `Stop`, and `Delete` control the Compose lifecycle + +Site app management is intentionally scoped to apps that are already part of the +stack image. The wizard does not try to install arbitrary apps that are not part +of the selected stack configuration. diff --git a/docs/10-easy-docker/03-updates.md b/docs/10-easy-docker/03-updates.md new file mode 100644 index 00000000..2db70fef --- /dev/null +++ b/docs/10-easy-docker/03-updates.md @@ -0,0 +1,26 @@ +--- +title: Updates +--- + +# Updates + +App updates are handled as an image update workflow, not as a live in-container +`git pull`. + +The recommended sequence is: + +1. Update the selected app branches. +2. Set a new `CUSTOM_TAG`. +3. Build the updated custom image. +4. Restart the stack. +5. Run `migrate` on the site if required by the app change. + +The wizard keeps the current `frappe_branch` visible while you update apps so +you can see the base version the stack is built against. + +`CUSTOM_TAG` is stored in the stack `.env` file. The Compose stack reads that +value on the next start or restart, so the tag change becomes effective once the +image has been rebuilt and the stack is restarted. + +For now, this update flow focuses on app branch changes. A separate Frappe base +version update flow can be added later without changing the overall model. diff --git a/docs/10-easy-docker/04-generated-compose.md b/docs/10-easy-docker/04-generated-compose.md new file mode 100644 index 00000000..968a42a0 --- /dev/null +++ b/docs/10-easy-docker/04-generated-compose.md @@ -0,0 +1,23 @@ +--- +title: Generated Compose +--- + +# Generated Compose + +`easy-docker` can render a `compose.generated.yaml` snapshot from the stack +metadata and environment. + +This file is useful when you want to inspect or reuse the resolved Compose +configuration outside the wizard, but it is not the primary runtime input for +stack start or stop. + +What is important: + +- the stack runtime reads the original Compose files from metadata +- the runtime also reads the stack `.env` +- `compose.generated.yaml` is a rendered snapshot, not the source of truth +- it is refreshed after a successful custom image build + +That means the generated file stays aligned with the current stack state when the +image has actually been rebuilt, which is the point where manual reuse is most +likely to matter. diff --git a/docs/10-easy-docker/index.md b/docs/10-easy-docker/index.md new file mode 100644 index 00000000..3b005f8d --- /dev/null +++ b/docs/10-easy-docker/index.md @@ -0,0 +1,23 @@ +--- +title: Easy Docker +--- + +# Easy Docker + +`easy-docker` is the interactive setup and management workflow for this repository. +It is designed to make common Frappe Docker tasks easier from the terminal while +keeping the underlying Compose and Bench model visible. + +This section documents the current behavior of the wizard: + +- `single-host` is the supported production workflow today +- `split-services` is still in development +- stack, site, app, and update actions are handled through the wizard +- the generated Compose output is available as a rendered snapshot + +Start here: + +- [Overview](./01-overview.md) +- [Workflows](./02-workflows.md) +- [Updates](./03-updates.md) +- [Generated Compose](./04-generated-compose.md) From 502caaefea527a6f4cf4a0670eab608fcdd9dfa6 Mon Sep 17 00:00:00 2001 From: RocketQuack <202538874+Rocket-Quack@users.noreply.github.com> Date: Fri, 3 Apr 2026 15:32:37 +0200 Subject: [PATCH 33/51] feat(easy-docker): add migrate site now action --- .../lib/app/wizard/flows/manage/site.sh | 113 ++++++++++++++++++ .../lib/ui/screens/production/manage.sh | 22 ++++ 2 files changed, 135 insertions(+) diff --git a/scripts/easy-docker/lib/app/wizard/flows/manage/site.sh b/scripts/easy-docker/lib/app/wizard/flows/manage/site.sh index 52e1fd80..b0b9cb4c 100755 --- a/scripts/easy-docker/lib/app/wizard/flows/manage/site.sh +++ b/scripts/easy-docker/lib/app/wizard/flows/manage/site.sh @@ -1,5 +1,64 @@ #!/usr/bin/env bash +migrate_configured_stack_site() { + local stack_dir="${1}" + local site_name="" + local current_site_apps_lines="" + local created_at="" + local updated_at="" + local backend_status=0 + + reset_easy_docker_site_error_state + + if ! stack_supports_single_site_management "${stack_dir}"; then + return 52 + fi + + site_name="$(get_stack_site_name "${stack_dir}" || true)" + if ! is_safe_stack_site_cleanup_name "${site_name}"; then + return 53 + fi + + if stack_backend_service_is_running "${stack_dir}"; then + : + else + backend_status=$? + case "${backend_status}" in + 54) + return 54 + ;; + 52) + return 52 + ;; + *) + return 51 + ;; + esac + fi + + if ! run_stack_site_migrate "${stack_dir}" "${site_name}"; then + return $? + fi + + current_site_apps_lines="$(get_stack_site_apps_installed_lines "${stack_dir}" || true)" + created_at="$(get_stack_site_created_at "${stack_dir}" || true)" + updated_at="$(get_current_utc_timestamp)" + if ! persist_stack_site_metadata \ + "${stack_dir}" \ + "single-site" \ + "${site_name}" \ + "${current_site_apps_lines}" \ + "migrate-site" \ + "" \ + "" \ + "${created_at}" \ + "${updated_at}"; then + return 65 + fi + + return 0 +} + handle_manage_stack_site_flow() { local stack_name="${1}" local stack_dir="${2}" @@ -15,6 +74,7 @@ handle_manage_stack_site_flow() { local existing_site_last_backup_at="" local existing_site_details_action="" local existing_site_apps_action="" + local existing_site_migrate_confirm="" local existing_site_app_lines="" local existing_site_app_selection="" local existing_site_app_confirmation="" @@ -346,6 +406,59 @@ handle_manage_stack_site_flow() { done continue ;; + "Migrate site now") + existing_site_migrate_confirm="$( + show_manage_stack_site_migrate_confirmation \ + "${stack_name}" \ + "${stack_dir}" \ + "${existing_site_name}" || true + )" + case "${existing_site_migrate_confirm}" in + "Yes") + show_warning_message "Migrating site for stack: ${stack_name}" + if migrate_configured_stack_site "${stack_dir}"; then + show_warning_and_wait "Site migrated successfully: ${existing_site_name}" 3 + continue + fi + + site_flow_status=$? + case "${site_flow_status}" in + 51) + show_warning_and_wait "Cannot migrate site: backend service is not running yet. Start the stack first." 4 + ;; + 52) + show_warning_and_wait "Cannot migrate site for this topology yet. Only single-host stacks are supported." 4 + ;; + 53) + show_warning_and_wait "Cannot migrate site because the configured site name was empty or unsafe for cleanup operations." 4 + ;; + 54) + show_warning_and_wait "Cannot migrate site because stack metadata, env, or compose inputs are incomplete." 4 + ;; + 64) + show_warning_and_wait "Site migration failed. ${EASY_DOCKER_SITE_ERROR_DETAIL:-Check the output above.} ${EASY_DOCKER_SITE_ERROR_LOG_PATH:+See ${stack_dir}/${EASY_DOCKER_SITE_ERROR_LOG_PATH}}" 6 + ;; + 65) + show_warning_and_wait "The migrate command finished, but the site metadata could not be written to metadata.json." 5 + ;; + *) + show_warning_and_wait "Site migration failed (${site_flow_status})." 4 + ;; + esac + continue + ;; + "No" | "") + continue + ;; + "Exit and close easy-docker") + return "${FLOW_EXIT_APP}" + ;; + *) + show_warning_and_wait "Unknown site migrate confirmation action: ${existing_site_migrate_confirm}" 2 + continue + ;; + esac + ;; "Backup site now") show_warning_message "Creating backup for site: ${existing_site_name}" if backup_configured_stack_site "${stack_dir}"; then diff --git a/scripts/easy-docker/lib/ui/screens/production/manage.sh b/scripts/easy-docker/lib/ui/screens/production/manage.sh index 9102513a..4ba922ae 100755 --- a/scripts/easy-docker/lib/ui/screens/production/manage.sh +++ b/scripts/easy-docker/lib/ui/screens/production/manage.sh @@ -212,6 +212,7 @@ show_manage_stack_site_details() { --cursor.foreground 63 \ --selected.foreground 45 \ "Manage apps on this site" \ + "Migrate site now" \ "Backup site now" \ "Delete site" \ "Back" \ @@ -300,6 +301,27 @@ show_manage_stack_site_app_uninstall_confirmation() { "Exit and close easy-docker" } +show_manage_stack_site_migrate_confirmation() { + local stack_name="${1}" + local stack_dir="${2}" + local site_name="${3}" + local status_text="" + + render_main_screen 1 >&2 + + status_text="$(printf "Migrate site\n\nStack: %s\nDirectory: %s\nSite: %s\n\nRun bench migrate for this existing site now?" "${stack_name}" "${stack_dir}" "${site_name}")" + render_box_message "${status_text}" "0 2" >&2 + + gum choose \ + --height 8 \ + --header "Confirm migrate site" \ + --cursor.foreground 63 \ + --selected.foreground 45 \ + "Yes" \ + "No" \ + "Exit and close easy-docker" +} + show_manage_stack_site_delete_confirmation() { local stack_name="${1}" local stack_dir="${2}" From b4cbab62a5fd978ceb6587a379cfba331d6c5fd0 Mon Sep 17 00:00:00 2001 From: RocketQuack <202538874+Rocket-Quack@users.noreply.github.com> Date: Fri, 3 Apr 2026 15:33:10 +0200 Subject: [PATCH 34/51] feat(easy-docker): support postgres in site bootstrap lifecycle --- .../wizard/common/site/bootstrap/lifecycle.sh | 80 +++++++++++++------ .../common/site/bootstrap/validation.sh | 22 ++++- 2 files changed, 77 insertions(+), 25 deletions(-) diff --git a/scripts/easy-docker/lib/app/wizard/common/site/bootstrap/lifecycle.sh b/scripts/easy-docker/lib/app/wizard/common/site/bootstrap/lifecycle.sh index be920cac..9e0ca813 100755 --- a/scripts/easy-docker/lib/app/wizard/common/site/bootstrap/lifecycle.sh +++ b/scripts/easy-docker/lib/app/wizard/common/site/bootstrap/lifecycle.sh @@ -2,35 +2,35 @@ drop_stack_site_database() { local stack_dir="${1}" - local db_name="${2}" + local site_name="${2}" local db_password="" - local db_endpoint="" - local db_host="" - local db_port="" + local db_root_username="" local drop_db_command="" + local drop_db_output="" + local db_drop_status=0 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 + db_root_username="$(get_stack_database_root_username "${stack_dir}" || true)" + if [ -z "${db_root_username}" ]; 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;")" + printf "bench drop-site %s --no-backup --force --db-root-username %s --db-root-password %s" \ + "$(shell_quote_site_command_arg "${site_name}")" \ + "$(shell_quote_site_command_arg "${db_root_username}")" \ + "$(shell_quote_site_command_arg "${db_password}")" )" - if ! run_stack_backend_bash_command "${stack_dir}" "${drop_db_command}"; then - return 1 + if run_stack_backend_bash_command_capture drop_db_output "${stack_dir}" "${drop_db_command}"; then + return 0 + else + db_drop_status=$? fi - return 0 + EASY_DOCKER_SITE_ERROR_DETAIL="$(printf "bench drop-site failed for '%s'." "${site_name}")" + capture_stack_site_error_log "${stack_dir}" "site-delete-error" "${drop_db_output}" >/dev/null 2>&1 || true + return "${db_drop_status}" } remove_stack_site_directory() { @@ -107,7 +107,7 @@ cleanup_partial_stack_site() { fi fi - if [ "${has_site_config}" -eq 1 ] && ! drop_stack_site_database "${stack_dir}" "${db_name}"; then + if [ "${has_site_config}" -eq 1 ] && ! drop_stack_site_database "${stack_dir}" "${site_name}"; then return 60 fi @@ -174,13 +174,45 @@ create_first_stack_site() { local admin_password="${3}" local create_site_command="" local create_site_output="" + local database_id="" + local db_password="" + local db_root_username="" + local db_host="db" + local db_port="3306" - 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}")")" - )" + database_id="$(get_stack_database_id "${stack_dir}" || true)" + db_password="$(get_stack_database_root_password "${stack_dir}")" + db_root_username="$(get_stack_database_root_username "${stack_dir}" || true)" + if [ -z "${db_root_username}" ]; then + return 57 + fi + + case "${database_id}" in + mariadb) + create_site_command="$( + printf "bench new-site %s --mariadb-user-host-login-scope='%%' --admin-password %s --db-root-username %s --db-root-password %s" \ + "$(shell_quote_site_command_arg "${site_name}")" \ + "$(shell_quote_site_command_arg "${admin_password}")" \ + "$(shell_quote_site_command_arg "${db_root_username}")" \ + "$(shell_quote_site_command_arg "${db_password}")" + )" + ;; + postgres) + db_port="5432" + create_site_command="$( + printf "bench new-site %s --db-type postgres --db-host %s --db-port %s --admin-password %s --db-root-username %s --db-root-password %s" \ + "$(shell_quote_site_command_arg "${site_name}")" \ + "$(shell_quote_site_command_arg "${db_host}")" \ + "$(shell_quote_site_command_arg "${db_port}")" \ + "$(shell_quote_site_command_arg "${admin_password}")" \ + "$(shell_quote_site_command_arg "${db_root_username}")" \ + "$(shell_quote_site_command_arg "${db_password}")" + )" + ;; + *) + return 57 + ;; + esac if ! run_stack_backend_bash_command_capture create_site_output "${stack_dir}" "${create_site_command}"; then EASY_DOCKER_SITE_ERROR_DETAIL="bench new-site failed." diff --git a/scripts/easy-docker/lib/app/wizard/common/site/bootstrap/validation.sh b/scripts/easy-docker/lib/app/wizard/common/site/bootstrap/validation.sh index e5d5c807..1d50c1e8 100755 --- a/scripts/easy-docker/lib/app/wizard/common/site/bootstrap/validation.sh +++ b/scripts/easy-docker/lib/app/wizard/common/site/bootstrap/validation.sh @@ -87,13 +87,33 @@ get_stack_database_root_password() { return 0 } -stack_site_bootstrap_supports_database() { +get_stack_database_root_username() { local stack_dir="${1}" local database_id="" database_id="$(get_stack_database_id "${stack_dir}" || true)" case "${database_id}" in mariadb) + printf 'root\n' + return 0 + ;; + postgres) + printf 'postgres\n' + return 0 + ;; + *) + return 1 + ;; + esac +} + +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 | postgres) return 0 ;; *) From 6d43530ff6d8e4a00099e9ee45e302b6f0b92611 Mon Sep 17 00:00:00 2001 From: RocketQuack <202538874+Rocket-Quack@users.noreply.github.com> Date: Wed, 8 Apr 2026 16:44:28 +0200 Subject: [PATCH 35/51] fix(easy-docker): pin and verify gum fallback release --- scripts/easy-docker/README.md | 3 +- scripts/easy-docker/config/gum-checksums.tsv | 7 + scripts/easy-docker/lib/install/gum/assets.sh | 137 +++++++++++++++--- scripts/easy-docker/lib/install/gum/ensure.sh | 2 +- .../lib/install/gum/github_release.sh | 29 +++- 5 files changed, 153 insertions(+), 25 deletions(-) create mode 100644 scripts/easy-docker/config/gum-checksums.tsv diff --git a/scripts/easy-docker/README.md b/scripts/easy-docker/README.md index 53b32396..5b33bb9b 100644 --- a/scripts/easy-docker/README.md +++ b/scripts/easy-docker/README.md @@ -16,7 +16,8 @@ bash easy-docker.sh - Docker Desktop includes Compose v2 by default; on Linux Engine-only setups you may need the `docker-compose-plugin` package - Docker daemon must be running before the TUI starts - Required docker commands are validated (`docker ps/exec/inspect/cp` and `docker compose config/up/down/logs/exec/pull/ps`) -- If package manager installation for `gum` fails, the script can use a GitHub binary fallback +- If package manager installation for `gum` fails, the script can use a pinned GitHub binary fallback +- The GitHub fallback is pinned to `gum` `v0.17.0` and verifies SHA256 checksums from `scripts/easy-docker/config/gum-checksums.tsv` ## Options diff --git a/scripts/easy-docker/config/gum-checksums.tsv b/scripts/easy-docker/config/gum-checksums.tsv new file mode 100644 index 00000000..1babfb01 --- /dev/null +++ b/scripts/easy-docker/config/gum-checksums.tsv @@ -0,0 +1,7 @@ +# version asset_name sha256 +0.17.0 gum_0.17.0_Darwin_arm64.tar.gz e2a4b8596efa05821d8c58d0c1afbcd7ad1699ba69c689cc3ff23a4a99c8b237 +0.17.0 gum_0.17.0_Darwin_x86_64.tar.gz cd66576aeebe6cd19c771863c7e8d696e0e1d5387d1e7075666baa67c2052e53 +0.17.0 gum_0.17.0_Linux_arm64.tar.gz b0b9ed95cbf7c8b7073f17b9591811f5c001e33c7cfd066ca83ce8a07c576f9c +0.17.0 gum_0.17.0_Linux_armv7.tar.gz 25711c2fbc6887cde79ed586972834121a04955968808dd688c688381ac50ab2 +0.17.0 gum_0.17.0_Linux_x86_64.tar.gz 69ee169bd6387331928864e94d47ed01ef649fbfe875baed1bbf27b5377a6fdb +0.17.0 gum_0.17.0_Windows_x86_64.zip b2be80531c6babc8d4e0e6ca95773d58118a2e1582ae006aace08dbc55503072 diff --git a/scripts/easy-docker/lib/install/gum/assets.sh b/scripts/easy-docker/lib/install/gum/assets.sh index 8cfd9f00..977b5975 100755 --- a/scripts/easy-docker/lib/install/gum/assets.sh +++ b/scripts/easy-docker/lib/install/gum/assets.sh @@ -1,5 +1,122 @@ #!/usr/bin/env bash +get_gum_checksums_path() { + local gum_lib_dir="" + + gum_lib_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + printf '%s/../../../config/gum-checksums.tsv\n' "${gum_lib_dir}" +} + +get_pinned_gum_version() { + local checksums_path="" + local release_version="" + + checksums_path="$(get_gum_checksums_path)" + if [ ! -f "${checksums_path}" ]; then + return 1 + fi + + release_version="$( + awk -F '\t' ' + /^[[:space:]]*#/ { next } + NF < 3 { next } + { + print $1 + exit + } + ' "${checksums_path}" + )" + if [ -z "${release_version}" ]; then + return 1 + fi + + printf '%s\n' "${release_version}" +} + +get_pinned_gum_asset_checksum() { + local release_version="${1}" + local asset_name="${2}" + local checksums_path="" + local expected_checksum="" + + checksums_path="$(get_gum_checksums_path)" + if [ ! -f "${checksums_path}" ]; then + return 1 + fi + + expected_checksum="$( + awk -F '\t' -v release_version="${release_version}" -v asset_name="${asset_name}" ' + /^[[:space:]]*#/ { next } + NF < 3 { next } + $1 == release_version && $2 == asset_name { + print $3 + exit + } + ' "${checksums_path}" + )" + if [ -z "${expected_checksum}" ]; then + return 1 + fi + + printf '%s\n' "${expected_checksum}" +} + +sha256_verification_available() { + command_exists sha256sum || + command_exists shasum || + command_exists openssl || + command_exists certutil +} + +compute_file_sha256() { + local file_path="${1}" + local hash_input_path="${file_path}" + + if command_exists sha256sum; then + sha256sum "${file_path}" | awk '{print tolower($1)}' + return $? + fi + + if command_exists shasum; then + shasum -a 256 "${file_path}" | awk '{print tolower($1)}' + return $? + fi + + if command_exists openssl; then + openssl dgst -sha256 -r "${file_path}" | awk '{print tolower($1)}' + return $? + fi + + if command_exists certutil; then + if command_exists cygpath; then + hash_input_path="$(cygpath -w "${file_path}" 2>/dev/null || printf '%s' "${file_path}")" + fi + + certutil -hashfile "${hash_input_path}" SHA256 2>/dev/null | + awk 'NR == 2 { gsub(/ /, "", $0); print tolower($0); exit }' + return $? + fi + + return 1 +} + +verify_file_sha256() { + local file_path="${1}" + local expected_checksum="${2}" + local actual_checksum="" + + actual_checksum="$(compute_file_sha256 "${file_path}" || true)" + if [ -z "${actual_checksum}" ]; then + return 1 + fi + + if [ "${actual_checksum}" != "$(printf '%s' "${expected_checksum}" | tr '[:upper:]' '[:lower:]')" ]; then + return 1 + fi + + return 0 +} + get_gum_asset_candidates() { local release_version="${1}" local gum_os="${2}" @@ -60,23 +177,3 @@ find_gum_binary() { return 1 } - -fetch_latest_gum_release_version() { - local api_payload="" - local tag_name="" - - api_payload="$(curl -fsSL "https://api.github.com/repos/charmbracelet/gum/releases/latest")" || return 1 - - if command_exists jq; then - tag_name="$(printf '%s' "${api_payload}" | jq -r '.tag_name // empty')" - else - tag_name="$(printf '%s' "${api_payload}" | sed -n 's/.*"tag_name":[[:space:]]*"\([^"]*\)".*/\1/p' | head -n 1)" - fi - - tag_name="${tag_name#v}" - if [ -z "${tag_name}" ]; then - return 1 - fi - - printf '%s\n' "${tag_name}" -} diff --git a/scripts/easy-docker/lib/install/gum/ensure.sh b/scripts/easy-docker/lib/install/gum/ensure.sh index b7587a26..e69e2feb 100755 --- a/scripts/easy-docker/lib/install/gum/ensure.sh +++ b/scripts/easy-docker/lib/install/gum/ensure.sh @@ -46,7 +46,7 @@ ensure_gum() { fi if should_use_github_fallback; then - echo "Trying GitHub release fallback..." + echo "Trying pinned GitHub release fallback..." if install_gum_from_github_release; then hash -r fi diff --git a/scripts/easy-docker/lib/install/gum/github_release.sh b/scripts/easy-docker/lib/install/gum/github_release.sh index d157afd0..8ad33d09 100755 --- a/scripts/easy-docker/lib/install/gum/github_release.sh +++ b/scripts/easy-docker/lib/install/gum/github_release.sh @@ -10,6 +10,7 @@ cleanup_gum_tmp_dir() { install_gum_from_github_release() { local release_version="" + local checksums_path="" local asset_name="" local asset_path="" local download_url="" @@ -20,6 +21,7 @@ install_gum_from_github_release() { local target_binary_name="gum" local gum_os="" local gum_arch="" + local expected_checksum="" if ! command_exists curl; then echo "curl is required for the GitHub fallback." @@ -31,9 +33,20 @@ install_gum_from_github_release() { return 1 fi - release_version="$(fetch_latest_gum_release_version || true)" + release_version="$(get_pinned_gum_version || true)" if [ -z "${release_version}" ]; then - echo "Could not determine latest gum release version." + echo "Could not determine the pinned gum release version." + return 1 + fi + + checksums_path="$(get_gum_checksums_path)" + if [ ! -f "${checksums_path}" ]; then + echo "Pinned gum checksum file is missing: ${checksums_path}" + return 1 + fi + + if ! sha256_verification_available; then + echo "A SHA256 verification tool is required for the GitHub fallback." return 1 fi @@ -45,6 +58,11 @@ install_gum_from_github_release() { extract_dir="${tmp_dir}/extract" while IFS= read -r asset_name; do + expected_checksum="$(get_pinned_gum_asset_checksum "${release_version}" "${asset_name}" || true)" + if [ -z "${expected_checksum}" ]; then + continue + fi + asset_path="${tmp_dir}/${asset_name}" download_url="https://github.com/charmbracelet/gum/releases/download/v${release_version}/${asset_name}" @@ -52,6 +70,11 @@ install_gum_from_github_release() { continue fi + if ! verify_file_sha256 "${asset_path}" "${expected_checksum}"; then + echo "Checksum verification failed for ${asset_name}." + continue + fi + rm -rf "${extract_dir}" mkdir -p "${extract_dir}" @@ -67,7 +90,7 @@ install_gum_from_github_release() { if [ -z "${gum_binary_path}" ]; then cleanup_gum_tmp_dir "${tmp_dir}" - echo "No compatible gum binary was found in GitHub release assets." + echo "No compatible, verified gum binary was found in the pinned GitHub release assets." return 1 fi From a03e031faed7d705b17561874e94453a2beec883 Mon Sep 17 00:00:00 2001 From: RocketQuack <202538874+Rocket-Quack@users.noreply.github.com> Date: Wed, 8 Apr 2026 17:43:16 +0200 Subject: [PATCH 36/51] feat(easy-docker): enable developer mode for development stacks --- docs/10-easy-docker/01-overview.md | 4 ++ .../easy-docker/lib/app/wizard/common/core.sh | 19 ++++++ .../wizard/common/site/bootstrap/lifecycle.sh | 60 +++++++++++++++++++ .../lib/app/wizard/flows/manage/site.sh | 5 +- .../lib/ui/screens/production/setup.sh | 9 ++- 5 files changed, 95 insertions(+), 2 deletions(-) diff --git a/docs/10-easy-docker/01-overview.md b/docs/10-easy-docker/01-overview.md index 7f9b08cd..bab00fab 100644 --- a/docs/10-easy-docker/01-overview.md +++ b/docs/10-easy-docker/01-overview.md @@ -25,6 +25,10 @@ Current limitations: - site management currently assumes one configured site per stack - backup and app management are focused on the configured stack image and site +Current setup-type behavior: + +- `development` stacks enable `developer_mode` automatically for newly created sites + The current entrypoint is: ```bash diff --git a/scripts/easy-docker/lib/app/wizard/common/core.sh b/scripts/easy-docker/lib/app/wizard/common/core.sh index 64ba7972..c8de1606 100755 --- a/scripts/easy-docker/lib/app/wizard/common/core.sh +++ b/scripts/easy-docker/lib/app/wizard/common/core.sh @@ -331,6 +331,25 @@ get_stack_topology() { return 0 } +get_stack_setup_type() { + local stack_dir="${1}" + local metadata_path="" + local setup_type="" + + metadata_path="${stack_dir}/metadata.json" + if [ ! -f "${metadata_path}" ]; then + return 1 + fi + + setup_type="$(get_metadata_string_field "${metadata_path}" "setup_type" || true)" + if [ -z "${setup_type}" ]; then + return 1 + fi + + printf '%s\n' "${setup_type}" + return 0 +} + get_metadata_compose_files_lines() { local metadata_path="${1}" diff --git a/scripts/easy-docker/lib/app/wizard/common/site/bootstrap/lifecycle.sh b/scripts/easy-docker/lib/app/wizard/common/site/bootstrap/lifecycle.sh index 9e0ca813..0fedd62b 100755 --- a/scripts/easy-docker/lib/app/wizard/common/site/bootstrap/lifecycle.sh +++ b/scripts/easy-docker/lib/app/wizard/common/site/bootstrap/lifecycle.sh @@ -305,6 +305,42 @@ install_stack_apps_on_site() { return 0 } +stack_site_should_enable_developer_mode() { + local stack_dir="${1}" + local setup_type="" + + setup_type="$(get_stack_setup_type "${stack_dir}" || true)" + case "${setup_type}" in + development) + return 0 + ;; + *) + return 1 + ;; + esac +} + +enable_stack_site_developer_mode() { + local stack_dir="${1}" + local site_name="${2}" + local developer_mode_command="" + local developer_mode_output="" + + developer_mode_command="$( + printf "bench --site %s set-config developer_mode 1 && bench --site %s clear-cache" \ + "$(shell_quote_site_command_arg "${site_name}")" \ + "$(shell_quote_site_command_arg "${site_name}")" + )" + + if run_stack_backend_bash_command_capture developer_mode_output "${stack_dir}" "${developer_mode_command}"; then + return 0 + fi + + EASY_DOCKER_SITE_ERROR_DETAIL="$(printf "Could not enable developer mode for '%s'." "${site_name}")" + capture_stack_site_error_log "${stack_dir}" "site-developer-mode-error" "${developer_mode_output}" >/dev/null 2>&1 || true + return 66 +} + run_stack_site_migrate() { local stack_dir="${1}" local site_name="${2}" @@ -333,6 +369,7 @@ bootstrap_first_stack_site() { local updated_at="" local installed_app_lines="" local site_create_status=0 + local developer_mode_status=0 local app_install_status=0 local site_migrate_status=0 local cleanup_status=0 @@ -416,6 +453,29 @@ bootstrap_first_stack_site() { esac fi + if stack_site_should_enable_developer_mode "${stack_dir}"; then + if enable_stack_site_developer_mode "${stack_dir}" "${site_name}"; then + : + else + developer_mode_status=$? + if cleanup_partial_stack_site "${stack_dir}" "${site_name}"; then + mark_stack_site_failed "${stack_dir}" "${site_name}" "" "enable-developer-mode" "${EASY_DOCKER_SITE_ERROR_DETAIL:-Developer mode activation failed. Partial site data was cleaned up automatically.}" "${EASY_DOCKER_SITE_ERROR_LOG_PATH}" "${created_at}" >/dev/null 2>&1 || true + return "${developer_mode_status}" + fi + + cleanup_status=$? + mark_stack_site_failed "${stack_dir}" "${site_name}" "" "enable-developer-mode" "${EASY_DOCKER_SITE_ERROR_DETAIL:-Developer mode activation failed and partial site data could not be cleaned up automatically. Manual cleanup is required.}" "${EASY_DOCKER_SITE_ERROR_LOG_PATH}" "${created_at}" >/dev/null 2>&1 || true + case "${cleanup_status}" in + 54 | 52) + return "${cleanup_status}" + ;; + *) + return 60 + ;; + esac + fi + fi + updated_at="$(get_current_utc_timestamp)" if ! persist_stack_site_metadata "${stack_dir}" "single-site" "${site_name}" "" "create-site" "" "" "${created_at}" "${updated_at}"; then return 58 diff --git a/scripts/easy-docker/lib/app/wizard/flows/manage/site.sh b/scripts/easy-docker/lib/app/wizard/flows/manage/site.sh index b0b9cb4c..a01ee508 100755 --- a/scripts/easy-docker/lib/app/wizard/flows/manage/site.sh +++ b/scripts/easy-docker/lib/app/wizard/flows/manage/site.sh @@ -123,7 +123,7 @@ handle_manage_stack_site_flow() { show_warning_and_wait "The site was created, but app installation failed. ${EASY_DOCKER_SITE_ERROR_DETAIL:-Check the output above.} ${EASY_DOCKER_SITE_ERROR_LOG_PATH:+See ${stack_dir}/${EASY_DOCKER_SITE_ERROR_LOG_PATH}}" 6 ;; 57) - show_warning_and_wait "Site bootstrap currently supports only MariaDB-backed single-host stacks." 4 + show_warning_and_wait "Site bootstrap currently supports only supported single-host database stacks." 4 ;; 58) show_warning_and_wait "The site metadata could not be written to metadata.json." 4 @@ -146,6 +146,9 @@ handle_manage_stack_site_flow() { 64) show_warning_and_wait "The site was created and apps were installed, but bench migrate failed. ${EASY_DOCKER_SITE_ERROR_DETAIL:-Check the output above.} ${EASY_DOCKER_SITE_ERROR_LOG_PATH:+See ${stack_dir}/${EASY_DOCKER_SITE_ERROR_LOG_PATH}}" 7 ;; + 66) + show_warning_and_wait "Site bootstrap failed while enabling developer mode for this development stack. ${EASY_DOCKER_SITE_ERROR_DETAIL:-Check the output above.} ${EASY_DOCKER_SITE_ERROR_LOG_PATH:+See ${stack_dir}/${EASY_DOCKER_SITE_ERROR_LOG_PATH}}" 7 + ;; *) show_warning_and_wait "Site bootstrap failed (${site_flow_status})." 4 ;; diff --git a/scripts/easy-docker/lib/ui/screens/production/setup.sh b/scripts/easy-docker/lib/ui/screens/production/setup.sh index a03a51c5..91a0c417 100755 --- a/scripts/easy-docker/lib/ui/screens/production/setup.sh +++ b/scripts/easy-docker/lib/ui/screens/production/setup.sh @@ -21,7 +21,14 @@ show_setup_menu() { render_main_screen 1 >&2 setup_label="$(get_setup_display_label "${setup_type}")" - status_text="$(printf "%s stack\n\nChoose whether to create a new stack or manage an existing one." "${setup_label}")" + case "${setup_type}" in + development) + status_text="$(printf "%s stack\n\nChoose whether to create a new stack or manage an existing one.\nNew sites created in this stack enable developer mode automatically." "${setup_label}")" + ;; + *) + status_text="$(printf "%s stack\n\nChoose whether to create a new stack or manage an existing one." "${setup_label}")" + ;; + esac render_box_message "${status_text}" "0 2" >&2 From 4f91d83c504a7a5cf5cf08b100e9be2721bdd667 Mon Sep 17 00:00:00 2001 From: RocketQuack <202538874+Rocket-Quack@users.noreply.github.com> Date: Wed, 8 Apr 2026 18:38:15 +0200 Subject: [PATCH 37/51] docs(easy-docker): expand overview with guided screenshots --- docs/10-easy-docker/01-overview.md | 208 ++++++++++++++++-- .../images/easy-docker/overview/main-menu.png | Bin 0 -> 2914 bytes .../overview/stack-creation/app-selection.png | Bin 0 -> 3975 bytes .../overview/stack-creation/app-version.png | Bin 0 -> 4101 bytes .../overview/stack-creation/database.png | Bin 0 -> 3404 bytes .../stack-creation/frappe-version.png | Bin 0 -> 4743 bytes .../overview/stack-creation/image-name.png | Bin 0 -> 3550 bytes .../overview/stack-creation/image-version.png | Bin 0 -> 3181 bytes .../overview/stack-creation/name.png | Bin 0 -> 3880 bytes .../overview/stack-creation/proxy-mode.png | Bin 0 -> 6221 bytes .../overview/stack-creation/topology.png | Bin 0 -> 5626 bytes .../overview/stack-management/build-image.png | Bin 0 -> 4697 bytes .../stack-management/running-stack.png | Bin 0 -> 6086 bytes .../overview/stack-management/start-stack.png | Bin 0 -> 5524 bytes 14 files changed, 191 insertions(+), 17 deletions(-) create mode 100644 docs/images/easy-docker/overview/main-menu.png create mode 100644 docs/images/easy-docker/overview/stack-creation/app-selection.png create mode 100644 docs/images/easy-docker/overview/stack-creation/app-version.png create mode 100644 docs/images/easy-docker/overview/stack-creation/database.png create mode 100644 docs/images/easy-docker/overview/stack-creation/frappe-version.png create mode 100644 docs/images/easy-docker/overview/stack-creation/image-name.png create mode 100644 docs/images/easy-docker/overview/stack-creation/image-version.png create mode 100644 docs/images/easy-docker/overview/stack-creation/name.png create mode 100644 docs/images/easy-docker/overview/stack-creation/proxy-mode.png create mode 100644 docs/images/easy-docker/overview/stack-creation/topology.png create mode 100644 docs/images/easy-docker/overview/stack-management/build-image.png create mode 100644 docs/images/easy-docker/overview/stack-management/running-stack.png create mode 100644 docs/images/easy-docker/overview/stack-management/start-stack.png diff --git a/docs/10-easy-docker/01-overview.md b/docs/10-easy-docker/01-overview.md index bab00fab..b05f1a8c 100644 --- a/docs/10-easy-docker/01-overview.md +++ b/docs/10-easy-docker/01-overview.md @@ -4,32 +4,206 @@ title: Overview # Overview -`easy-docker` guides the main stack lifecycle through menus instead of requiring -users to assemble long Docker and Bench commands by hand. +`easy-docker` is the guided terminal workflow for setting up and managing a +Frappe Docker stack from one place. -Current scope: +Instead of collecting Docker Compose, image build, and Bench commands manually, +you move through a small set of menus that guide the main lifecycle of a stack. -- create a stack -- choose the topology -- configure apps and branches +The interface is powered by `gum`, which is used to render the interactive +terminal menus and prompts. + +All stack data created by the wizard is written into the repository-local +`.easy-docker` directory. That includes the generated stack environment files +and the stack-specific metadata used by the workflow. + +This means `easy-docker` is not a closed system. After the setup has been +created, you can still inspect the generated files, keep working with them +manually, and continue outside the wizard if that fits your workflow better. + +## What It Helps With + +`easy-docker` is built to make the usual stack workflow easier to follow: + +- create a new stack +- choose the stack setup path +- select apps and branches +- generate the stack environment - build the custom image -- start, restart, stop, and delete the stack +- start, stop, restart, or delete the stack - create and manage a site -- install and uninstall apps on an existing site -- create a site backup +- install or uninstall apps on the site +- create backups and run site maintenance actions -Current limitations: +## How It Feels to Use -- `single-host` is the supported path -- `split-services` is still marked as in development -- site management currently assumes one configured site per stack -- backup and app management are focused on the configured stack image and site +The workflow is organized as a guided sequence. -Current setup-type behavior: +You start with a stack, define how it should be configured, let the wizard write +the stack files, then continue into the management area for image, runtime, and +site actions. -- `development` stacks enable `developer_mode` automatically for newly created sites +This makes `easy-docker` useful both for first-time setup and for returning to +an existing stack later when you need to update apps, rebuild an image, restart +services, or work on the site itself. -The current entrypoint is: +If you have not worked with a guided Docker setup before, it helps to think of +`easy-docker` as a step-by-step assistant. It does not ask you to memorize the +Docker commands first. Instead, it asks a small number of questions, writes the +stack configuration for you, and then gives you a menu for the most common next +actions. + +![Easy Docker main menu](../images/easy-docker/overview/main-menu.png) + +## What It Needs + +To run `easy-docker`, the environment should have: + +- a working `docker` CLI +- Docker Compose v2 through `docker compose` +- a running Docker daemon +- `gum` for the interactive terminal UI + +When `gum` is already installed, the wizard uses it directly. + +When `gum` is missing, `easy-docker` first tries to install it through the +system package manager. If that is not available or does not succeed, the +wizard can fall back to a pinned GitHub release and install `gum` +automatically when possible. + +This means the usual setup flow is: + +- check whether `gum` is already available +- try package-manager installation first +- use the verified fallback path only if needed +- continue into the wizard once the required tooling is ready + +The Docker requirements are also checked on startup so the workflow stops early +with guidance instead of failing later in the middle of stack setup. + +## Main Areas + +### Stack Creation + +The stack creation flow collects the main decisions up front and stores them in +the stack directory so the workflow can be resumed later. + +This is where you define the stack identity, choose the setup path, and prepare +the generated configuration for the next steps. + +A typical stack creation run moves through these prompts: + +1. Name the stack and choose the Frappe version profile. + +The stack name is simply the label under which `easy-docker` will remember this +setup. If you plan to run more than one setup later, choose a name that makes +the purpose obvious. + +The Frappe version profile is the base version the stack should start from. If +you are unsure, pick the version you intend to use for the actual project or +the version your apps are built for. + +![Stack name](../images/easy-docker/overview/stack-creation/name.png) + +![Frappe version profile](../images/easy-docker/overview/stack-creation/frappe-version.png) + +2. Choose the deployment topology and the main infrastructure options. + +In this phase, the wizard asks how the stack should be structured. For most +users, this is the point where you choose the simplest practical setup and let +the wizard generate the rest of the configuration around it. + +The proxy and database choices decide how traffic reaches the stack and where +the site data is stored. Even if you do not know every Docker detail yet, the +important part is that these choices describe how your stack should behave once +it is running. + +![Topology selection](../images/easy-docker/overview/stack-creation/topology.png) + +![Proxy mode selection](../images/easy-docker/overview/stack-creation/proxy-mode.png) + +![Database selection](../images/easy-docker/overview/stack-creation/database.png) + +3. Define the image naming and versioning that should be used for the stack. + +This step controls the image that will later be built for your stack. You can +think of it as naming the packaged application that Docker should run. + +The image name identifies the image, while the image version or tag helps you +track which build you are currently using. That becomes especially useful when +you rebuild the stack after changing app branches or updating the setup. + +![Custom image naming](../images/easy-docker/overview/stack-creation/image-name.png) + +![Custom image version](../images/easy-docker/overview/stack-creation/image-version.png) + +4. Select the apps and branches that should be built into the stack image. + +This is the point where you decide what should actually be included in the +stack. The app selection defines the application set, and the branch selection +defines which code line of each app should be used for the build. + +For new users, the practical rule is simple: only include the apps you really +need, and choose branches that match the Frappe version profile you selected +earlier. + +![App selection](../images/easy-docker/overview/stack-creation/app-selection.png) + +![App version selection](../images/easy-docker/overview/stack-creation/app-version.png) + +After these decisions, `easy-docker` has enough information to write the stack +files and prepare the next phase. At that point, the workflow moves from +planning the stack to actually building and running it. + +### Stack Management + +Once a stack exists, `easy-docker` becomes the control point for the stack: + +- app selection and branch updates +- custom image build and rebuild +- Compose lifecycle actions +- site operations such as create, migrate, backup, and delete + +That means the same workflow continues after setup instead of ending once the +first stack files are written. + +The first management steps usually focus on preparing the image and bringing the +stack up in Docker Compose. + +The build step creates the actual Docker image for the stack you just defined. +Until that image exists, there is nothing concrete for Docker Compose to start. +That is why the build action comes before the start action. + +![Build image action](../images/easy-docker/overview/stack-management/build-image.png) + +Once the image has been built successfully, you can start the stack. This tells +Docker Compose to create the containers and launch the services that belong to +your setup. + +![Start stack action](../images/easy-docker/overview/stack-management/start-stack.png) + +After startup, the status view helps you confirm that the stack is actually +running. This is especially useful for beginners because it gives a visible +checkpoint before moving on to site creation or later maintenance steps. + +![Running stack status](../images/easy-docker/overview/stack-management/running-stack.png) + +From there, the workflow usually continues into site-level actions such as +creating the first site, installing apps on the site, running migrations, or +creating backups. In other words: stack creation defines the environment, and +stack management is where that environment becomes usable. + +### Development Stacks + +When you create a stack through the development path, newly created sites in +that stack automatically enable `developer_mode`. + +This keeps the development-specific behavior attached to the stack itself, so +the workflow stays consistent when you return to manage it later. + +## Entry Point + +Run the wizard from the repository root: ```bash bash easy-docker.sh diff --git a/docs/images/easy-docker/overview/main-menu.png b/docs/images/easy-docker/overview/main-menu.png new file mode 100644 index 0000000000000000000000000000000000000000..5f1b078f2323bb24fd73004a415761c048a5e38d GIT binary patch literal 2914 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 literal 0 HcmV?d00001 diff --git a/docs/images/easy-docker/overview/stack-creation/app-selection.png b/docs/images/easy-docker/overview/stack-creation/app-selection.png new file mode 100644 index 0000000000000000000000000000000000000000..bd30bc0f62b70cb762ccabe2388a8aed6187aa9b GIT binary patch literal 3975 zcmai0c{CL6yPw4}hOx~MGmL%T#+pPTYlMWsSVorYWXTq0Dn+yi$-WiY#}=BejHP7D z*cnSG*_RZN?bh#m@45HC`<&-|&hxzIbI#{k-uIk0!NSZC21P;v007L`NY4@g08#$L z`w;LS95u{W0RVvN7N%DEyhxO@^C^`|QIh7{kuIgW4yP7D zCy@b%ypkV`jGFEm#z5+*F3PCv>`rWU6(V!hObW$o{tnRE_|&TOrg;pnWF&n?Amc-v zfS@v)0qQamrEIuutyWx2NHTU9%C5}m$jVky4&p_Pu=s&hprKc(poYYDknw)jxqd?3q*N5ol;}byBeqH zb+uj;_1cndo2hoWctePWLM%&XO`kGkaA6x^k$L?CM%y6#G+hiQQr_?0P5wr2>bn{Sf{xU=O?s1sU*G`<2mB~As3eM~hq}YGq{buDoX4{dWy(#i!{yV$n3KNwxR~rPD$Q)347f8LcTa7) zu=l#@S%^NuzO+Vf)hqVShc`Z;lzubCyNP${6b-%?8~hy9Jo!BApD>dCh1G#K_1L4- zya!@L{eiiKdmTw>BOOKKwlHrm2#SzShAywQy3&B`SsklEKG5H+a_HMa}uzs!19G>oM88IFFK)XyCeemVWWEQCW zHed@Sz7^h;sq^8(Dmwo(*?Yl2<0CMGJ>1ea+IU(Y(PMpj*(RZc|Hab^y1lAGiYS)Y zvSN;S;U;_z3_QQplJ+r6J+~y4x z@Ds+f%E3f)wm~{m6(IZyMGuboe?l^1dugdGLD7MXJ&x5w9oc$`YFy<9%RYIyy+ z&Ok#F55~WC36svB90Wk7-|n7eCXI<;cJOpI5j>#adozgUDUO7c(zR=wdkZMvj=_v* zRW0&V@w7^2?b%p_Dknl%k99nS#5G*dc#+L{_ICI=_*urdK8%?d1mbG|N|un6^pYhK zJ`gostqXH`xZ-{VC24YMIYmfsUuV@Dzr!AodB^kc)j4&~%bD_mNg*3GvsK1;ry$4R zhArCnTd9uwG019|$4ExVAd4@qV33b>iCg~>A`a5XHty4^A+v~HF7PJLRGXLhB9xRx z92TpEhzi8VU%O>f@pgh)?d*)xBL50o@CXIg?gV$mo9Ph5(IR@Z>i#yJYeONb6^(mF zSQ|0YaFw64UkDS_S$2@SNX-1*NnaI1(yx7rXHwuh$<=(xREe0TT>kLbkQW!p#*Y0R zKYmzvG8jya&gOwYWNlUAM>yi>t8yT73n7p&IwqNIU6R2oYZ$)A;yA z#k3unP!;??TkF6B0V~DrhyFL_Q^{Fghl*QjC#HuaFlsf+;Q1?6ON4MI>$nV&$PF^Q zy3fs`)Uj+J5Q~%?2Z2m-zutjDSX5XY60QG;SRuqn2Q1bpuQi(|;+GM0hy|(L`oCH# zVhd8xhUX0uktW^g@Cm^tFSXFYYjZ@Dw{Nud#yizwzj&@1p8g|(^c@6@EB*6p5^v{-*7=>e9)YwQ;BB_qRoKEZ+1 zR24^9xzCf`TlAn3w@T&bzAX=Kj7V{?r70{DmXGzlb3Ug%Vh^7_cgVk5%D#U#>euj) z0sDuWM^bURQ9eOTK6TCOoE^fGyOx~Wf`fYOT&E^D+^bhxr(!=dcH2B=c!J{9zr<*> zOH^Gl)0U{4bbi*j533#yPE2sey#GZ54}4x>3x5(te-C~ zuEnOgD(&koN2rp(m1zeb`TDi_gV0fEPa0q<)qTUeoP=eSm+xNFx-il(IA-Tr%{T$= z=>&IZrZM?MyGf6d5j-uvnN2WnPr`=67+4&v6ILQz6msU^^!^P$F z%eAuvHgCBHBwBzgaFBP6+(1SiL5e2`l$6hbr+Wkt@hoQub_TF*RBLSM0=66~kaIcw zz<+|tA~F&$RC(St&!neq+>T=0Uo`mlkMl1gT>_Li1#QPAvKT>hWeo?rk!9Ke;vIu& zu-|J`7~$8R0Tw5cJ^`N8s?ou^1MlS9ZJ4u$@N-WhCk4l4TOpTtCuh7=DcIWcd}j39 zv$Jlm4MqWptL8?SqY|%Bsj;tbKhm~IDscRMK$!h~>w($nVwadXP1gB9lWN$-a0up( z0RadAPy8wJ9k+*7Khy_jyIoDL&uKqab!B657QMT1RDs4PL@q)rR4A3-T?+2@cWWpD?<=r& zeKApNNKt1d>3wPEcDnM0xy8r{vi;kBrRMeO8fA0c4|8TNhDF;`ThVjblv-^W-jFvZLl3c|8?! z%5t>%;r(v~mfmVo6h!+=aNu+Ixmt+FnSOPwVZUnvW+HX^lt2g|cupy*fa8{n_V)FG8 z0>+pMZ!VhQYA~645~_0jA;CCDmn=V{dkD^Z3C+?i(1He*P(O9qb;!!qhk^Lc&_WmK z?q4aOqUqJ8>th$R+yB)6uQ5MQgOeBDH~07SH|k$q=f`)~HY4U12R`(-Ry^BC1Xmcp z8GQx7R{J0LG~l$(Q_vZ#vmL! zVAN`1gf^x7R-SKOC@z=Iw^>DZBy#=o{hZe_j4x_e+;(DtV`*@nH~$>^)azPhT_Wt> zVfybq1N?0_qrVBeuL`H!Tm{G!*O5td=YR%J*zrGUij};lWpXbhGu> zOv8Za<=yo%L-+>hK9|s+D!3VK>oR2-IQM?mm!Ob%Y|l*=+rSeXq`&OUY6*K47O_K|w zJU2u6?KjDbl^)cXmCB)`uX-HObM&q35mVmVGkegoMy7iTyB!5G*ZVq2uTXd`;&7eD zD}#vrC1W&#j^{&&0mSRIA#hAdFZXhiR8@yMY;} oU$e^QS2z%2{h#{)0Qi5@R$F}PX<8ib`uEUS-%PJc7k}r!0L%+Ipa1{> literal 0 HcmV?d00001 diff --git a/docs/images/easy-docker/overview/stack-creation/app-version.png b/docs/images/easy-docker/overview/stack-creation/app-version.png new file mode 100644 index 0000000000000000000000000000000000000000..4b16d755bf28ab77802481d3380642c0e4eded6c GIT binary patch literal 4101 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 literal 0 HcmV?d00001 diff --git a/docs/images/easy-docker/overview/stack-creation/database.png b/docs/images/easy-docker/overview/stack-creation/database.png new file mode 100644 index 0000000000000000000000000000000000000000..f6f26a19de0515ab21363e555da91bc517e561a5 GIT binary patch literal 3404 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 LfVOY24W#}ba=lWm literal 0 HcmV?d00001 diff --git a/docs/images/easy-docker/overview/stack-creation/frappe-version.png b/docs/images/easy-docker/overview/stack-creation/frappe-version.png new file mode 100644 index 0000000000000000000000000000000000000000..6ac629338428cb86a71bfce48eec8804a0168ea9 GIT binary patch literal 4743 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 literal 0 HcmV?d00001 diff --git a/docs/images/easy-docker/overview/stack-creation/image-name.png b/docs/images/easy-docker/overview/stack-creation/image-name.png new file mode 100644 index 0000000000000000000000000000000000000000..499a603abd42518fb565685dfba881f11baa650f GIT binary patch literal 3550 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 literal 0 HcmV?d00001 diff --git a/docs/images/easy-docker/overview/stack-creation/image-version.png b/docs/images/easy-docker/overview/stack-creation/image-version.png new file mode 100644 index 0000000000000000000000000000000000000000..b4333e3b8588d8b4e92c695c8b7ebcc2feee7341 GIT binary patch literal 3181 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 T;-Gl^f8a2ISr`(bzsLO(E$V8_ literal 0 HcmV?d00001 diff --git a/docs/images/easy-docker/overview/stack-creation/name.png b/docs/images/easy-docker/overview/stack-creation/name.png new file mode 100644 index 0000000000000000000000000000000000000000..2a07299b60f9b8b4cbc0fc6606ca07ba420bd4de GIT binary patch literal 3880 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 Nz&b|SxVuk6{|j!mpyvPp literal 0 HcmV?d00001 diff --git a/docs/images/easy-docker/overview/stack-creation/proxy-mode.png b/docs/images/easy-docker/overview/stack-creation/proxy-mode.png new file mode 100644 index 0000000000000000000000000000000000000000..4417b31bf5dc5bdbeef4b3ec617567a307b251a8 GIT binary patch literal 6221 zcmai1cTf|~)>TnJK%`1Yq)V@X(4|A@AYDL;L`rB95J99D6-Yp;^j;)11?hqWlZX&{ zlO{sw9i-Qf-+MD}zVgR+XLo1z-ZN*<-aEUq8~w;Y^9}`&;@Y)qceJ(Cjjvt1j=$*DyF^xK_V>)JCt989!5R2)bnUfk_YSqyRZRUS)-(0pCY5R4NHbDbjClY`u%huv3T7wc8G{jvMS^nf_+I5)GYbIC}|Z6 z)K^CFg`8GZvTw$VD4>9~)wBu&w=hMqik2iMHQK4r;zaj1Ujk0{ zmbsi+qY&zad$(!+ego!%&pj2Z@m#=x+7WWG1QU~**9mp{l4zV2|p5tMIVP4Ohr@$&CM1`2XX6=5l}PJa*dcPvymJ5vgvbt@H{d z&;iFC&49Zdoq91>zA z$z{y2r=EJSXUO9PsttBH-6;Ji%WY8jp(FP+Qxd~l(Q98vITY`61OcdgFm0tccaasz zb#`JcA&Ki#`Yu#%(uhJ_I-DwGjBV%SozEw|;W>&NVMdY<#{1Y~KUDlRtX5U>_<2Le z+BWp@rCkt30~nyG%l}8n<&>N?gV@gd)xueqDCZf$~>|zqg%75BZXIzZH z)yR5BT+OUst#~q^t=!7ogq^2ZRL3H7*$R0gBzY^~uparJ|InOo?){iEtxNUJvFdI9 z_BgM4tc{ZEisQBRF8gZ zl87D+H|n+|(~%3}fad*DTrf+*+=rZ(Ti%lMsxmL1(6RzX{2MZf^KYn^;Z_P8keOJ5 z>XZ63>RieR8H)K-8JADIl(3nXHmhVL#wOR6ggCfwiTT{qF=^w_jeLHGh-hKEO|*aL z=||Ztg)GB1zT(Ey=pZ?g0cw2UH;){s^8b_4|3Bd3iClHr=%#AS3a&fl!_xK**Gi7G!4bg3ud;DFN)-9X28bYH} z8&SAl2%94-^i!hx?}m&NxUoo}FxQ_no};+RV@x>m?cI#ct)r@eIk4?|$I>@Zc|Qi? zy;3>ewsMsq-3Inkc{`CneHvcxTFZ1E`}-nqp4~icA&aed0_C43?QE;~h%(O9!B+rm zedlxnaSp69hAX*7>XFlX%Yg2v>f4)MoL6f$VXkv6B_Ne=sfBA;OV|VMRx=$M|Fy}y z7x7tCK^X8e2B!d|EB;-7C!GI7uQbKyT2cjyCqOA)4|vTn zIPuSO%#kH|DO_w&4t(LfMQsdv6UG;M!r5lB6_Vk!qkI;bmmbwln}I^WD?0piwkhq# zK324qf-~reT(G@Qxi?-P%)j?qo8d6rm#EMQ@F{l~jD=N-Ib_Y&I=ihj^aA(C85+BX zgDi7iBC3%CGx}j1QPTc?`rugP}nEIdXV^y%LuW=2)<}&-m?CngjrsZYH4};PPG!(Y4qWVLQ-#U zKG_yh3V_2k+@#gpTJQ>?R2<31_;GWmEvi>-dcL2H|#JYg5wVEcGC*`;ptVBjp-Vp};Acy=YV@urmg*M$Tysw9! z;BkwV#Qw6Pe}S0xh`acVJvQM-i90z(xBtN1;9W%y`cX+P%Yx0pjyX1Z=sbT#q?wRP zVZp!bf-xPkFZ6^A#q0wpZ_MVH?MzjA#SmmnS>`Z+G5)8qqcQrve421y@J-_N z&^r$i0|Krw!d1r%$eZvxYa{N+C*|6yT*PdzP+sEHXBHZI!?|9>o4IB90L#e~XZ*;I32b-tE8L)v)6*(8P zrK+WQ8RGO_CI3cnnn|aq)>_*guvFj&-#56z0)3$u6@7692819hH6$nNuS{<_sAa$= zDrbmqPubKQS8row?l{q2o2e zv|l^hnp?*uo00*T)gDqrIle>GiI4~pwSC#6hSpt*@+a$iKXdS|t>H#wwDbxA)wFa? zU4-jUe}i)=fqKwgE+@GC;B~yO#C0xP@xx{Wcr(yGMoi(1>A=JEg4y0Q;jwwe^R#;! zw^{f~vxOl~igNIsIh1z7Ke6NM-j!we6nrvi)PT>Jk2mE+W^$eW3~O!YoWRI9-RCld zl3ghgd8>EMd(}F6WxYsGAjbK}U)1wXf7}Bezx6>s_}yjGSj`(8zUJiYHpTa^zfBCFWGfEQt~`wF{K*vKk;uHx7L;K zr^KB{u%P1WAO2Ce;C(c6o`kmwLI(i%1|QGht_P4J0{rCHf5<{6!mJoM?IP_nGz+^p zJZs=ZLe$fhxZG1-4ZjGDYFb@aL(l4X5-nz;uV*#A)(}+TQaC>+YlMU{!Zl= zq>zjV0bicJph?~jZX{)tBbH%qYk`gYcluo#Qk3RO202xvGhEi;XSZU5%l*Gl_w(Ty z9sLR+2P6vlMujDPcs2PaL;AzPO)qT@xB5I-S{dZaRSQ|DN;Pr0EO+``re3|E^}dXO zx=kNyJ_92L)eN~viXkzwrMUtw!yi2vl1_DeH#E{lh)s|)4KA=LZR)4_B-ii!hraH{ zw=zCz79@lLSiLwRAn5jYb&6B!5XHisylW2Amq=)jV|pJnF9+&UG%@~i<|#1Ut4B+k zt&Po$_xGf&PKeZQm*d^$zKjxxa7SV`K;ZZo9n}m59typG17CMCA$7p5Qkof@wbBAN z*aXeuuihdamYX{J-Y3=ei#4};XD}9dh-ZcHBCbx{3??Tn0!s#+6P| zq1x`Am>J-X&F_R!hLwMP3y;Q#S1WKiz zwv>)sgI%e>0uJQ`T_YW^mVB#Dnp{Ap9KZ2IZlldjDb#9{zergXX(JK zDsau!%9z^2&v*ElH=%f7fAW{fotrnVlH*m^7}B`QSR~?QS{WBPf0=_P++6mbZgPG0 zK>0bu()rGKnl)LcJ&&RP<--2knRuPw{aT@NC)lVRmtN-dKK}!}?Ct)^DT*f=QU>q` z5#tZ7+o<<5*|z!?*A0dTctwz!J|fYSSbE~uo6Gv1glcw>Jc@cZ(x`cJ<`9mLmJga6 zjMwymu2B0sWMy7!#aQ~@8JE!);)!5}lf<1sh*fUYrPYn%Og0 zJTzIm<0o8?O@Da68Hj=y!mpM%ySZEmCDzn@9$IE5Ec1F50bV}R%xx57?J)uFU}fa5 z2h{fA9UWn%0}^RJ>E9GewEE`7DIzcti%;m7-jxo?2wo3}5FFulBrdf3f8bSS60j2G z1bvf1opDe^#f<89;zbQ%>t&?>F!3pcsmz)s|%pGyx*=Rg`Y@ zv>Nn)ouWsJoyES>%?afOlmG}EsTl5s;de#I4BcFQRO9^?QlLs5q?FnZ*;KGkD*bh7 zkdOVvJ3tKP{+#W$hG;4X`d~qw)D(05g$P6pFVx!$|7*QRjT|rV{aGsjDcC51P%qLP zhKq*BPfeu-cGTDMK<2jYxJM2h!X~ofO<|FE_(1bAmDBmPA9d5oc+7o=k=x6-{&(Q~ z1jXDTEs`ghExbKVtp5;>yJcC$M)$p|708%o!P!%TRo>YWIN4x2TtUGO{Yht@(_x(Y*jbE$I754i>+{Y-P&kv=%5ELGEDxR3`FzC7fF+mmkuK$SXffIJDK$g(^_Gpi;EfBHkF>%SxNHQcC4On2~W#w zrzIm68-8U`gU%QI&?swj>;T*GwN!z=y#ZM#SRpKsnMsHDm3?9#vKvm5$2M!>sI<5Gj-1Ds+c(P&D zgnL6Er0dgH*k5hkaLG4DXr;zv|D_K6Y%t+|z2amF$Dg5JjQ}ZwNiWKC_xbcF#cV&W z^x`43cEEipy#6HiN`#GL|2Dj{hPs<>p3lMpJs_$SKMr>c2)v!~f}C`EN_KxviZx6{ zS|-*94!YZ(XPsqIC)8adWD4#ii$Z~yCr45s%Nu^TYK!vRxA*G&aHhioi*lV#9~rYfucd5-P2Cw zxeN$HrN|kw#C-K<)SYJ6KeGy~4SS$nN-A!S1#@&ykc*adKm~TIZ-E4T@p!{mU{Eb8 zQ_7VA@>zsRb9SD>^OStELts7Q1~U{gw+8i@SCI1In0t*q9x{!Xz`A z5QUmN9&5c@5J3rQ*sVSlG%T}Bb^LE_-+GS}daL$82SMHU&ksmj!$7@C)js^c04|jV Aq5uE@ literal 0 HcmV?d00001 diff --git a/docs/images/easy-docker/overview/stack-creation/topology.png b/docs/images/easy-docker/overview/stack-creation/topology.png new file mode 100644 index 0000000000000000000000000000000000000000..da81592892cca4e5e3ef073464d422bdd5f9663e GIT binary patch literal 5626 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 GQU3#wA;H%G literal 0 HcmV?d00001 diff --git a/docs/images/easy-docker/overview/stack-management/build-image.png b/docs/images/easy-docker/overview/stack-management/build-image.png new file mode 100644 index 0000000000000000000000000000000000000000..51a1ac3e9707d020ed32c9acd4c4427418c52efa GIT binary patch literal 4697 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 literal 0 HcmV?d00001 diff --git a/docs/images/easy-docker/overview/stack-management/running-stack.png b/docs/images/easy-docker/overview/stack-management/running-stack.png new file mode 100644 index 0000000000000000000000000000000000000000..30f239f72d37b19e677539c320ea39936a46deec GIT binary patch literal 6086 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 literal 0 HcmV?d00001 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 38/51] 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="" From 9363fd19e4ad22621e0c7a163e287e61405b261f Mon Sep 17 00:00:00 2001 From: RocketQuack <202538874+Rocket-Quack@users.noreply.github.com> Date: Tue, 14 Apr 2026 11:22:41 +0200 Subject: [PATCH 39/51] docs(easy-docker): reorganize screenshots and document split-services --- docs/01-getting-started/05-easy-docker.md | 3 +- docs/10-easy-docker/01-overview.md | 30 +-- docs/10-easy-docker/02-workflows.md | 10 +- docs/10-easy-docker/05-split-services.md | 215 ++++++++++++++++++ docs/10-easy-docker/index.md | 4 +- .../{overview => entry}/main-menu.png | Bin .../overview/stack-creation/topology.png | Bin 5626 -> 0 bytes .../database-engine.png} | Bin .../proxy-mode.png | Bin .../split-services/data-services-choice.png | Bin 0 -> 6140 bytes .../split-services/database-engine.png | Bin 0 -> 6439 bytes .../split-services/manage-stack-actions.png | Bin 0 -> 6478 bytes .../split-services/proxy-choice.png | Bin 0 -> 7560 bytes .../split-services/redis-services.png | Bin 0 -> 6652 bytes .../easy-docker/split-services/summary.png | Bin 0 -> 7149 bytes .../split-services/topology-menu.png | Bin 0 -> 6255 bytes .../apps}/app-selection.png | Bin .../apps}/app-version.png | Bin .../core}/frappe-version.png | Bin .../core}/name.png | Bin .../image}/image-name.png | Bin .../image}/image-version.png | Bin .../stack-creation/topology/topology-menu.png | Bin 0 -> 6557 bytes .../build-image.png | Bin .../running-stack.png | Bin .../start-stack.png | Bin 26 files changed, 244 insertions(+), 18 deletions(-) create mode 100644 docs/10-easy-docker/05-split-services.md rename docs/images/easy-docker/{overview => entry}/main-menu.png (100%) delete mode 100644 docs/images/easy-docker/overview/stack-creation/topology.png rename docs/images/easy-docker/{overview/stack-creation/database.png => single-host/database-engine.png} (100%) rename docs/images/easy-docker/{overview/stack-creation => single-host}/proxy-mode.png (100%) create mode 100644 docs/images/easy-docker/split-services/data-services-choice.png create mode 100644 docs/images/easy-docker/split-services/database-engine.png create mode 100644 docs/images/easy-docker/split-services/manage-stack-actions.png create mode 100644 docs/images/easy-docker/split-services/proxy-choice.png create mode 100644 docs/images/easy-docker/split-services/redis-services.png create mode 100644 docs/images/easy-docker/split-services/summary.png create mode 100644 docs/images/easy-docker/split-services/topology-menu.png rename docs/images/easy-docker/{overview/stack-creation => stack-creation/apps}/app-selection.png (100%) rename docs/images/easy-docker/{overview/stack-creation => stack-creation/apps}/app-version.png (100%) rename docs/images/easy-docker/{overview/stack-creation => stack-creation/core}/frappe-version.png (100%) rename docs/images/easy-docker/{overview/stack-creation => stack-creation/core}/name.png (100%) rename docs/images/easy-docker/{overview/stack-creation => stack-creation/image}/image-name.png (100%) rename docs/images/easy-docker/{overview/stack-creation => stack-creation/image}/image-version.png (100%) create mode 100644 docs/images/easy-docker/stack-creation/topology/topology-menu.png rename docs/images/easy-docker/{overview/stack-management => stack-runtime}/build-image.png (100%) rename docs/images/easy-docker/{overview/stack-management => stack-runtime}/running-stack.png (100%) rename docs/images/easy-docker/{overview/stack-management => stack-runtime}/start-stack.png (100%) diff --git a/docs/01-getting-started/05-easy-docker.md b/docs/01-getting-started/05-easy-docker.md index 55d1038c..e3d5041b 100644 --- a/docs/01-getting-started/05-easy-docker.md +++ b/docs/01-getting-started/05-easy-docker.md @@ -14,7 +14,8 @@ This getting-started page stays short and focuses on the first steps. Current status: - `single-host` is the primary supported topology -- `split-services` is still in development +- `split-services` is available for separated stack setup and Compose runtime control +- site actions currently remain part of the `single-host` workflow - stack, site, app, backup, restart, and update flows are being expanded iteratively The script entrypoint is: diff --git a/docs/10-easy-docker/01-overview.md b/docs/10-easy-docker/01-overview.md index b05f1a8c..b1ee3bbf 100644 --- a/docs/10-easy-docker/01-overview.md +++ b/docs/10-easy-docker/01-overview.md @@ -53,7 +53,7 @@ Docker commands first. Instead, it asks a small number of questions, writes the stack configuration for you, and then gives you a menu for the most common next actions. -![Easy Docker main menu](../images/easy-docker/overview/main-menu.png) +![Easy Docker main menu](../images/easy-docker/entry/main-menu.png) ## What It Needs @@ -103,9 +103,9 @@ The Frappe version profile is the base version the stack should start from. If you are unsure, pick the version you intend to use for the actual project or the version your apps are built for. -![Stack name](../images/easy-docker/overview/stack-creation/name.png) +![Stack name](../images/easy-docker/stack-creation/core/name.png) -![Frappe version profile](../images/easy-docker/overview/stack-creation/frappe-version.png) +![Frappe version profile](../images/easy-docker/stack-creation/core/frappe-version.png) 2. Choose the deployment topology and the main infrastructure options. @@ -118,11 +118,11 @@ the site data is stored. Even if you do not know every Docker detail yet, the important part is that these choices describe how your stack should behave once it is running. -![Topology selection](../images/easy-docker/overview/stack-creation/topology.png) +![Topology selection](../images/easy-docker/stack-creation/topology/topology-menu.png) -![Proxy mode selection](../images/easy-docker/overview/stack-creation/proxy-mode.png) +![Proxy mode selection](../images/easy-docker/single-host/proxy-mode.png) -![Database selection](../images/easy-docker/overview/stack-creation/database.png) +![Database selection](../images/easy-docker/single-host/database-engine.png) 3. Define the image naming and versioning that should be used for the stack. @@ -133,9 +133,9 @@ The image name identifies the image, while the image version or tag helps you track which build you are currently using. That becomes especially useful when you rebuild the stack after changing app branches or updating the setup. -![Custom image naming](../images/easy-docker/overview/stack-creation/image-name.png) +![Custom image naming](../images/easy-docker/stack-creation/image/image-name.png) -![Custom image version](../images/easy-docker/overview/stack-creation/image-version.png) +![Custom image version](../images/easy-docker/stack-creation/image/image-version.png) 4. Select the apps and branches that should be built into the stack image. @@ -147,9 +147,9 @@ For new users, the practical rule is simple: only include the apps you really need, and choose branches that match the Frappe version profile you selected earlier. -![App selection](../images/easy-docker/overview/stack-creation/app-selection.png) +![App selection](../images/easy-docker/stack-creation/apps/app-selection.png) -![App version selection](../images/easy-docker/overview/stack-creation/app-version.png) +![App version selection](../images/easy-docker/stack-creation/apps/app-version.png) After these decisions, `easy-docker` has enough information to write the stack files and prepare the next phase. At that point, the workflow moves from @@ -174,19 +174,23 @@ The build step creates the actual Docker image for the stack you just defined. Until that image exists, there is nothing concrete for Docker Compose to start. That is why the build action comes before the start action. -![Build image action](../images/easy-docker/overview/stack-management/build-image.png) +![Build image action](../images/easy-docker/stack-runtime/build-image.png) Once the image has been built successfully, you can start the stack. This tells Docker Compose to create the containers and launch the services that belong to your setup. -![Start stack action](../images/easy-docker/overview/stack-management/start-stack.png) +![Start stack action](../images/easy-docker/stack-runtime/start-stack.png) After startup, the status view helps you confirm that the stack is actually running. This is especially useful for beginners because it gives a visible checkpoint before moving on to site creation or later maintenance steps. -![Running stack status](../images/easy-docker/overview/stack-management/running-stack.png) +![Running stack status](../images/easy-docker/stack-runtime/running-stack.png) + +In this example, the one stopped container shown in the status output is the +`configurator` container. That container is expected to finish and stop after +its setup work has completed. From there, the workflow usually continues into site-level actions such as creating the first site, installing apps on the site, running migrations, or diff --git a/docs/10-easy-docker/02-workflows.md b/docs/10-easy-docker/02-workflows.md index 02f3deb3..7b5b72ae 100644 --- a/docs/10-easy-docker/02-workflows.md +++ b/docs/10-easy-docker/02-workflows.md @@ -7,13 +7,12 @@ title: Workflows The wizard follows a simple order: 1. Create a stack. -2. Choose `single-host` or review `split-services`. +2. Choose `single-host` or `split-services`. 3. Select the apps and branches for the stack. 4. Generate the stack environment and render the Compose snapshot. 5. Build the custom image. 6. Start the stack. -7. Create or select the configured site. -8. Manage site apps or create a backup. +7. Continue into site actions when the selected workflow supports them. Stack actions are grouped around image and Compose lifecycle: @@ -25,3 +24,8 @@ Stack actions are grouped around image and Compose lifecycle: Site app management is intentionally scoped to apps that are already part of the stack image. The wizard does not try to install arbitrary apps that are not part of the selected stack configuration. + +For the split-services path, see +[Split Services](./05-split-services.md). That page explains the intended flow +in simple terms and shows where the proxy, application, database, and Redis +choices fit into the setup. diff --git a/docs/10-easy-docker/05-split-services.md b/docs/10-easy-docker/05-split-services.md new file mode 100644 index 00000000..5e02e3d1 --- /dev/null +++ b/docs/10-easy-docker/05-split-services.md @@ -0,0 +1,215 @@ +--- +title: Split Services +--- + +# Split Services + +`split-services` is the guided setup path for users who want to keep the +application part of the stack separate from the data part, with an optional +proxy layer in front. + +The goal of this page is to help a first-time user understand what each step +means and what choice they are making in the wizard. + +The current split-services flow focuses on: + +- writing the split stack files +- rendering the generated Compose snapshot +- building the custom image +- starting, stopping, restarting, and deleting the stack + +This keeps the first version easier to understand while still making the stack +layout explicit. + +Think of the split-services setup as three simple parts: + +- `Application Services` run the Frappe application itself +- `Data Services` provide the database and Redis services +- `Reverse Proxy` handles incoming web traffic when you want a proxy in front + +This page uses the clearer names above so the setup is easier to understand the +first time you see it. + +## What This Setup Is For + +Split services are useful when you do not want every service in one combined +stack. + +This is a good fit if you want to: + +- keep the application layer separate from the data layer +- run the database and Redis independently from the app stack +- place a proxy in front only when you actually need it +- keep the setup readable so you can manage parts of it later + +If you are new to Docker, you can think of this as splitting one big stack into +smaller parts that each have a clearer job. + +## How The Wizard Feels + +The wizard still behaves like the other `easy-docker` flows. + +It asks one question at a time, explains what that choice affects, and then +writes the stack files for you. You do not need to build the final Compose +files by hand before you start. + +![Split Services topology menu](../images/easy-docker/split-services/topology-menu.png) + +## Step 1. Choose The Split Topology + +When you select `Split services`, the wizard first explains that the stack is +being divided into separate parts instead of using one combined setup. + +At this point you are not changing any service settings yet. You are only +choosing the layout of the stack. + +This matters because later steps will follow the same idea. If the setup is +split, the wizard will ask about the application side, the data side, and the +proxy side separately. + +## Step 2. Decide How The Data Services Should Work + +The next decision is the data layer. + +This is where you decide whether `easy-docker` manages the database and Redis +for you, or whether the stack should connect to services that already exist +elsewhere. + +### Managed Data Services + +Choose this if you want `easy-docker` to create the database and Redis +containers as part of the generated split setup. + +This is usually the easier choice when you are trying split-services for the +first time. + +It means: + +- the wizard writes the data service configuration for you +- the stack can start with its own managed database and Redis services +- you still keep the data layer separate from the application layer + +### External Data Services + +Choose this if the database and Redis are already running somewhere else. + +This is more advanced, but it can be useful when: + +- your organization already provides shared database and Redis services +- you want the application stack to connect to existing infrastructure +- you are splitting responsibilities across different systems + +If you choose this path, the wizard asks for the required connection values and +stores them in the generated stack configuration. + +![Data services choice](../images/easy-docker/split-services/data-services-choice.png) + +## Step 3. Choose The Database Engine + +After choosing how the data layer should be handled, the wizard asks which +database engine should be used for the stack. + +For most users, this is the simpler way to think about it: + +- `MariaDB` is the default path and usually the easiest starting point +- `PostgreSQL` is available if that is the database you already use or want to use + +If the data layer is managed by `easy-docker`, this choice decides which +database service will be created in the generated setup. + +If the data layer is external, this choice still matters because it tells the +stack which kind of database it is configured to connect to. + +![Database engine choice](../images/easy-docker/split-services/database-engine.png) + +## Step 4. Decide How Redis Should Work + +Redis is asked about separately so the setup stays explicit. + +This is useful because some users want Redis managed together with the split +stack, while others already have Redis running elsewhere. + +The choices are simple: + +- `Managed Redis Services` means the stack includes Redis services for you +- `External Redis Services` means you provide the Redis endpoints yourself +- `No Redis Services` is the advanced option when you do not want the wizard to + configure Redis for this stack + +The important beginner-friendly idea is that Redis belongs to the data side of +the setup, even though it is asked in its own step. + +![Redis services choice](../images/easy-docker/split-services/redis-services.png) + +## Step 5. Decide Whether A Proxy Should Be Included + +The proxy layer is optional. + +If you want the stack to answer HTTP or HTTPS traffic directly in front of the +application services, include a proxy. + +If you do not need that yet, you can skip it and keep the setup simpler. + +For a first-time user, the important idea is: + +- the proxy is the front door +- the application services do the actual work +- the data services keep the site data and queue state safe + +![Proxy choice](../images/easy-docker/split-services/proxy-choice.png) + +## Step 6. Review The Setup Before It Is Written + +Before the wizard writes the files, it shows a summary of the choices you just +made. + +This summary is the last chance to stop and check whether the stack is shaped +the way you expected. + +The summary makes the split very obvious: + +- what runs as application services +- what runs as data services +- whether a proxy is included +- whether the data services are managed by `easy-docker` or external + +![Split services summary](../images/easy-docker/split-services/summary.png) + +## What Happens After Setup + +Once the split stack has been written, the generated files are stored in the +same repository-local `.easy-docker` area as the other wizard data. + +That means you can still inspect the generated files later and continue working +with them manually if needed. + +After the files are written, the wizard returns to the stack management view for +that split-services stack. From there you can work with the stack runtime, app +selection, and update actions that are currently supported for this topology. + +![Split Services manage stack actions](../images/easy-docker/split-services/manage-stack-actions.png) + +For split-services, the most important practical point is that the application +side, the data side, and the proxy side remain easy to understand +individually. If you come back later to change one part, you should not have to +guess where the other parts are defined. + +## A Simple Mental Model + +If this is the first time you use a split setup, this is the easiest way to +think about it: + +- `Application Services` are the part you interact with most often +- `Data Services` keep the database and Redis available +- `Reverse Proxy` is optional and sits in front of the application side + +You do not need to understand every Docker detail before using the wizard. +You only need to know which part you want to manage separately. + +## Where To Go Next + +After reading this page, the next useful pages are: + +- [Overview](./01-overview.md) +- [Workflows](./02-workflows.md) +- [Generated Compose](./04-generated-compose.md) diff --git a/docs/10-easy-docker/index.md b/docs/10-easy-docker/index.md index 3b005f8d..ff79c647 100644 --- a/docs/10-easy-docker/index.md +++ b/docs/10-easy-docker/index.md @@ -11,7 +11,8 @@ keeping the underlying Compose and Bench model visible. This section documents the current behavior of the wizard: - `single-host` is the supported production workflow today -- `split-services` is still in development +- `split-services` is available for separated stack setup and Compose runtime control +- site actions currently remain part of the `single-host` workflow - stack, site, app, and update actions are handled through the wizard - the generated Compose output is available as a rendered snapshot @@ -21,3 +22,4 @@ Start here: - [Workflows](./02-workflows.md) - [Updates](./03-updates.md) - [Generated Compose](./04-generated-compose.md) +- [Split Services](./05-split-services.md) diff --git a/docs/images/easy-docker/overview/main-menu.png b/docs/images/easy-docker/entry/main-menu.png similarity index 100% rename from docs/images/easy-docker/overview/main-menu.png rename to docs/images/easy-docker/entry/main-menu.png diff --git a/docs/images/easy-docker/overview/stack-creation/topology.png b/docs/images/easy-docker/overview/stack-creation/topology.png deleted file mode 100644 index da81592892cca4e5e3ef073464d422bdd5f9663e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5626 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 GQU3#wA;H%G diff --git a/docs/images/easy-docker/overview/stack-creation/database.png b/docs/images/easy-docker/single-host/database-engine.png similarity index 100% rename from docs/images/easy-docker/overview/stack-creation/database.png rename to docs/images/easy-docker/single-host/database-engine.png diff --git a/docs/images/easy-docker/overview/stack-creation/proxy-mode.png b/docs/images/easy-docker/single-host/proxy-mode.png similarity index 100% rename from docs/images/easy-docker/overview/stack-creation/proxy-mode.png rename to docs/images/easy-docker/single-host/proxy-mode.png diff --git a/docs/images/easy-docker/split-services/data-services-choice.png b/docs/images/easy-docker/split-services/data-services-choice.png new file mode 100644 index 0000000000000000000000000000000000000000..7a356daff14346f946bc84b82d7d48f7357ea733 GIT binary patch literal 6140 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 literal 0 HcmV?d00001 diff --git a/docs/images/easy-docker/split-services/proxy-choice.png b/docs/images/easy-docker/split-services/proxy-choice.png new file mode 100644 index 0000000000000000000000000000000000000000..6c8a79eba8e5b0d2861a493c55e1fd68dbc44aee GIT binary patch literal 7560 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 e)qj3%aa{O-gA@%KR=u0Tpt6EGv{KF@a literal 0 HcmV?d00001 diff --git a/docs/images/easy-docker/split-services/summary.png b/docs/images/easy-docker/split-services/summary.png new file mode 100644 index 0000000000000000000000000000000000000000..9b5da88cb1adff01bc9542212b1d74a948140fab GIT binary patch literal 7149 zcmaKQ2UHW$wl=*t0l_3l34-(@kt#)sgbvbCqzVM2M>?VR-g}b(qJ$38dl99Xph&L* zp-G7#An@?s`~G+T_13$yX7;Q(``h2yXYV@&`6(VAKKABv zB_q5UF+JmUHYN-@=gFja)M`}NJtJURye<6MB$ z-00{#b>r*&fdLt->ol98{QLp9>luJ&D9~-lIKTQO;(GjJU%p8&Tms~em`ik-Q&VgI zR8c%IP@oP3nec#mMn|P(;`0Zp2l9j2*u`w&b3wph1rbD;>lG?ESo2Y7YS0_2M<6qk zpAW>`f>6~cRB%-lN=j=kFAwz!abPDuWR&wR5QvcB9h!v-o8#dDqt%oj8~Dub7R7;HB|A%uTE}Ja7*#f3s(r1?6}Sou}NB!c9Agn+#GWmq$qhfp15-+ zv#2F)#(xv~e;sb})gN?v=;XWKgIli7O+y|doDJ~K_i2L!{pr^szjkD^uJ|4&k{i9Yv*FIr&!?G0s7##|NhYM59wiS9W2g+HvUgW-)6M zqO=?;jFdl5AkjT_Hl`kj*TQXM&kN*Ti*ajde1eLjcJIra#62@(f2n6QQ*(LqXWF~U zRgF9aPFH5oX*p%y*E&a-W4c@k1K;xrM(zzo>9kBvxEpdgK z$daIWUw!QH^wi@5v)8t(9)5M)ly!{u+tIw*Silz&l`%V^8m6pEZJ4XITK2L1m89By zDv}?@RI@QJP>P1jNx%tqw$r<;OTu2dc?~dDG-5)k--~s>JL+U~n#AIy3(R8CAXrP1 z@%Ft6HJuY>%JAo`k{`m#WUtQk6OB0@LZs=r{iV9C?qfz&}wj?R@nJV(%}SqP5BEukLI(%J2$OZO%LE&j5V4 zYedJNxvDdi6O^@|3Qw&b%>*dt^di+M^?3vhN^xH+d{6i4b+#!L&HJqPx~5r7?Gy^? zOhRaOuNdtb57rr`qm>x)?gZR$a8xm(Uj509rFi(``l|WSyQHUQqH)u zuWVJAO(rv{-V$l~-B+mRfIwk6M0)e}TB% zeqC2E$anbAGGQnSklgRK`z`;3TS>iwP|e;39l`+yvL zHrnCe1pCq-HQw4of4=jDi#ZJ_!=a?9ss@Fl{#nttNk0oE(W!Ir|+wGa(AM^6mH>=I>rz3RBxY`(aZj3&b@(F^{a{(N4 zA3RvStZcpy`iWIszj0gmiiu;JFs{GG1>C3q8Rv7d(Vq*{%PrX6lCEQ6rPWNuFkdFH z_)lM;N?Ow_S`+;A%j{7L0j+TVj%Rm0eKtoOeTB8$?h29)E1BuMX+(OaeYAo^%u{-2 zkaj&2bXK}J1>W^OOt=XCFm9;}YwqxTL?-*A^TX211@S8`;dkk^^^-4f-n?3Q`rfle zJ+r3WHa`3se${YCDG}Ceb=b`0()r7_Ln-bQS4ENy^|S;o{@$VsVJX4V`G>64XbkVz zt#8`Si2NuX3I zi|-4z3)K(}V3Un-7p#S&5wa?6#(IaA@^@Vz=#%5mUMcGRb%Ts%v&UF}_gwY48AtN6 zpTjUdYF0KLbIN~9L(q&&RzZaoI|ZjfD!~svB7P!)@AcN7Da6@B=qMFBuEUZ!ibR#- zN08)_1%Q}t$&>H;MYCFKYc|46Q2#oG**IeK3xVVl>ySofN@S-A``#b{?Eh3zL2JtG zg8Tiv9!zI`AX*66upOQNYlX+=_Hsj~-`g2`civ;u3}q81y3+im?e!=7;Vmjh60ZhM zE#2ef{iADy+TFn2F>_>$h{YKDUcI!J(n-R-1QF?CXN6lV>iY(Qc>-lBX49S@jC6XE zQYm1~G$}yRmbkOI>KIFXF-g`Tt(4H|0ydeVi}k2xd=)pIHvNZCOdIQ;(dI`f17+eEoeDB)+&|=~spW4A;yb>Jw!2Vu&DGIlIQL z$D7?W)NpAfnS}Gki}r~-7pVlE4NqIL@W8S{lm07ltJhpt~S{g zRd0N&vyssIyE;e3w%*}jg8&_4$Sna!5=LU`bWt~jxnQs)<3gnw_YWBzn&ZER*g!Cvjk zwd%URZD9zHm9C)feWt@uTr#h5X$Hke3p;U+tqoT0(ZUK7oZ}l^9KI4~B&4ujaUBJ7 zGr8%#*505C=*Wv9R%ymJSD?rY1v811{fUIAiX%;27Bd)~IC5D^!k}@_O9>5P(v8U% z5iUg3Uo@n)!I##+xU12WijQn6=Ywa^&-Hkem$@cAgN3)3S-?{f97$l6=8$UQzl_lr za7|BJ!cBQ7Z>A-#-f%-l7qAd^LpTR9AYb%zA#!t=eUgKCEI4TL^h0V^)(Us0a*I89 ztX=_3#$%1otn;AixZ!2fV)mcdSA>R?C63l1qoMl9vk9N8evts%W&!Y+<7-D(7DvhF zY?bu9HB249=E-I!p;=hw^-OEE1&XrLa0pGf|5bLTcN!T%?2VVtT;&2T!3j*#{Aagk zNZv8ISToiYM?-Q*eJ?7)aGKPxkoK#-?`J>g^aKK+r?I9>>>n+5Tvhk7RP;wEvhMRzjiQy?+Dg8|S zNAw05vEWN^cLq$L^%*~Otrm!;BP#zV%O2wTS8-OXL%}~H7crv8Kxz9Ccb+J~HgmA{ zhZHMN$uLf^Ke&vKN7@$O;dpT3$yxq)jZ;sfxxC*i=6Q16(7->1AKsJ5{dAed2|`<$dr*%nhOm4oGsf!)VZC<~xU+dO zAi6ODd>LvMTQ`YXhaE-jb4B1(8fs51o9m(S21&?%4S9D?qgt!>jP2E$(eto z(XHzd;Dv5~-|ldDna(p7{NZuF$YpA*yEoU|eq0o!n6l>z(HxT&g@=zYqeKTJ61Ml% zVs@O8pfC`T&kQH6jZG8POaqI-&*=z5K_apx)G*ne@z9j|jJ z6npP!IDV0$0b<`vB%UtHcgl-vP!e$<-mL5D8TFJ1L?(0gZa9D;5>?L7(nmeo>u-u> zE>+ApQMW0lVrb(g+p%nWXDdjUcxn!t74N!`mZkIx4l@F8<1H8DHsn%g7Aks%h>DkK- zhx~*kK*rDnyzEAwC=8J?W}I3Lo)2&3&Yi=Qa+9RJYX+5olf;%m-Ra}LWOQ3DvMYA7 z!zIzU%Q{mt=wp_$%`i=i%acpLUvV(Ky0aXSFXj3K_{F^QUaNSWLO8}EsE&@Znd;yHD^duA$+c5( zl(oj+qA_=6XL8vMk4g(MsdWyfKI#)!HEZ$x3pcdJhk~Ms z=ZJhBOYCkeVH8u)H)wu($_MSk3IgB7doGvK_YPnVbd(+JcLxZBp`>*^)M%>^xC#5+ zzpeXgak*sjrxi5QCKxa;Jmn58SrD>`yvpjb3#)q!Ll|JfbO^ z>^1VkCKp}Kqu(r7ytBP+(9cEYasczBnBY)6USE!k;XAz1#4&oshU9K<59rxx*$ox^ zmAQGslndM4iya+Ew#16ddOkbfWvA6V*XFd}hlYBCkHoRfRbUdtx zMB3UhZUv%7O8PWf2z)JZrFbbL3S$!VmZ5}($Gu8LA+ot0TX?2)mkCF;(^!95?ofCV zo=Ev56*TS)lW-Df&FFwkQ;Nt^c>nv0A1lVUPE?P=t=O@e`nz^YdGr%MMc?oD7aU}{ z2`I>sCSB7OFyt2W4lxiGJLN+Tgb7{6Q{|AzmbWjNO?+Uv8FfImZn<+9*Z`WjGEJW-tT4PVhiH=R^s36l|laMAK~{Pcw@ zW4IC7$cL}u*g+KBpL7zfd0l9?zN+wbl4{a^61rskPZQzOMeBBJ#*)5JfxANyt4!&ni3@V8kEWYdfz*T6wBlR| zx-l4(Vkk8XVpY*Omkm)A9rqfK%zk;z2E&4y#A|ef9Tb69PxAK}>35+zKgiM{9t>zQ zu~m3QvZadOb5&iZV?Tb!|2M>nIqLc~+acFkOb!bNxVPy;e~&NTUWZT-J~lwoC;xB@ z6=PctPL_1%%~xP)Il7KoMh)M_m>7xQPGI;+Rw=pQgDQ~Jy7?x^g`Hil&OhqzET({k z-&%X6&0&SU4cOc`+aorFa>n1W_oN{AEB`?d;c@)uB$N+%>q&frR7CiX>d0J(BEM$d zzl4}3C>|Cjx*!sdHL}#)j8&5==}gQ;!!pSHz7h^1Ok$smPEgL5WaS8T|K^Y-97mn) z3{)6`9YR#uA08sw3L{_w@CRux%XuHA&Qk@^b2*OHl)0&MDXM?hXA0tp#48_skgJf0 zSEf|SED%;sT$_awxhq(Ai*;&IpIB)`rl7qN&LLcxjAIX$^cks9FaM<{=jU=3GvR5d z7ZV&VYkw#k$036GLgYbIFLJke#M?S>05=`xO{#&%+PIizrqnAlVZdkpQG;*U-_A^Z zQPF4J!F^?N)hND@^`QaycXqA(r&aqwI+rz5`P^{4VZuM#)KZj;pvgC0%+~PQdBS%7 zPR>3NUf(F*3MtQ2iUCNkn$Pp=!23^u^L;{Q26{2eHhqN+zfDEM@2TI?V$pt?UA-dD zEJuz$?%YY^(voaCMSj*hNerRMW>x1~&lxJ&5`UJ{izfXrna5;k0A{zEFb$wrTE^o} z>vy#|_E zZa_JQD*H;Jl~3Sles*SU39UN;2G-ksCMa7A+u~7V!&-9^G8Jacv3C>Rc#lVTU=@v?a)_VJK066x z$}@3u@)C>ye*5UAg0tv(&gUm=d|sodQ9@Ggo~9(pfH`=CY6&{c~vWaByprp*$%{vqLt^UVt@m zd)s^SXx{r^g_GHJ3_c!Cr%1-}-@=E_h9dr@{dKanLI>ofeLMg>U59PX#5UEA@QR62 zJD634UqSpwx7G?b**Po zrZk8#ZvRykfjNvFmD00GNK|8mR0JOj5z5naranw^gWWG){SI`sw-GSA(7GmqNWvB( zDCwX~z5@12H|s8G*062k{f&WDT*ay* zP{^*l4j~)y%*OGNq(ByX`BEfa+m~K?60)NGyptI4Y=fT0HDyNNJcfvGr!e6Y7ju;( z$$^5t9(%&l!@Uhrz}^f^j!B_bVqtm40Q{xB#A{;~%?3LAheKQ8LT?m+!^$>x<)kbE zRyxMDV*`DVp%6+&;jya}!YNC#+&?TDRuO@~jlS2NcI1RG7S;BGf<>X`yjTaF%eH5Q z%PSd)8Vkc1T$fWqzyUiZ%oFo~s(V9~)@y^tHci$XuzPHg1rnT!@J#>?- zlKft}Qo9Rv{`@nj^`(SNJ`QXZ)_e~Z?_o%5SEbSxD7&~Y^<(-|^O^2_=MbC+Ly_`q zrJ?+bJw@J?7e+C7ZJo|1btNl~q7Cf+gHR`9eYJM>-1g?%Yq(M9oH7e2!)M*{-X1eY zQ}N)CR8uP?IBc-X37M7;Bf;#_dA!@hp6O))Q|I#g%cg8r~yfniZH`%n8__tN+0 zXJxIpGp_kpUws>4u_sUV|2Q?7aBTRYzX(NdyyARZJC}MzGPziB7wdP+V~a^GOGdR6 zO(w^dyFAC_AoJq$*Rz)}#^{NZL@&m%FH9f})=s+A_Yhb&S>?jkSSdR7;RUIC%j~b+ z^UZfT@J7!Khc$+PT%`TMSE3tCiOXZM{zQmS+5PK)CXF^fxTzodQ7vYic39lg4pgBm zVkgV3o$M7yht0hDeB|IpO=>$Z=j^^VzO8XAnrt`hqbLL$E`->F63z2yB`c116ShC4 zY*==Djhl>1U#f_x54@t3Wu<7mrbvMXv3$~rHC2^6@O*4z+!QOzrCxLoCF}K8w=3mx35AON?o~ctzphaAVnjsxKk{GO)fZb41N3! zy(pQ5Ehzs-Ceb0P?+C>R(cKUE#+FGO5Tdf4$h%E8YR7s=*W@Rzg?8lVsAzF@u9lPs zL#2i0OMB(?j2{WJ2c8EhkX<~>@XW6^PHEl)WEvUc!6pWor3={3P?ADNI#y;E{ z=}U_FZkVO6%RX&GIk|cL`*hr zScu6Fw5@!iie!W(V%0E0D!FVlDK4tGmZ;{GQHFtpMr$F3dzW0GK<@mlvQf)me2vl$ zDbrpXqyO_(+JIUl~$PUCYqorw)*QKw)DI}zouI>Q9Ngb@l>8>U@J ubxy26Rg5#4oc`rL?>{%szeybJ4zt{>(MP==A*sJVht*WHm8%skL;nj>-gg)P literal 0 HcmV?d00001 diff --git a/docs/images/easy-docker/split-services/topology-menu.png b/docs/images/easy-docker/split-services/topology-menu.png new file mode 100644 index 0000000000000000000000000000000000000000..ffe8b8f179883d59c4875adf68ce04407a4c9d3f GIT binary patch literal 6255 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 g`V&_F^Km>UpszL`cCt{<`6t{J< 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 Date: Tue, 14 Apr 2026 14:27:10 +0200 Subject: [PATCH 40/51] docs(readme): add easy-docker overview section --- README.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/README.md b/README.md index 6619e4d2..dde00f20 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,24 @@ frappe_docker/ If you are already familiar with Frappe, you can jump right into the [different deployment methods](docs/01-getting-started/01-choosing-a-deployment-method.md) and select the one best suited to your use case. +### Easy Docker + +`easy-docker` is the guided terminal workflow for creating and managing Frappe +Docker stacks from one place. It helps with stack creation, app and branch +selection, image builds, runtime actions, and supported site operations without +requiring users to assemble the Compose and Bench commands manually. + +If you want a guided setup path, start with the +[Easy Docker docs](docs/10-easy-docker/index.md). + +Run it from the repository root with: + +```bash +bash easy-docker.sh +``` + +![Easy Docker main menu](docs/images/easy-docker/entry/main-menu.png) + ## Prerequisites - [Docker](https://docs.docker.com/get-docker/) From f88cf1d8b87e14026121072e1393a2cce7d29bf9 Mon Sep 17 00:00:00 2001 From: RocketQuack <202538874+Rocket-Quack@users.noreply.github.com> Date: Tue, 14 Apr 2026 15:55:56 +0200 Subject: [PATCH 41/51] fix(easy-docker): restore site app install state handling --- .../app/wizard/common/site/apps/lifecycle.sh | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/scripts/easy-docker/lib/app/wizard/common/site/apps/lifecycle.sh b/scripts/easy-docker/lib/app/wizard/common/site/apps/lifecycle.sh index 4526606d..1541630f 100755 --- a/scripts/easy-docker/lib/app/wizard/common/site/apps/lifecycle.sh +++ b/scripts/easy-docker/lib/app/wizard/common/site/apps/lifecycle.sh @@ -41,7 +41,7 @@ get_stack_site_managed_runtime_app_lines() { local site_name="${3}" local runtime_app_lines="" local app_name="" - local managed_app_lines="" + local resolved_managed_app_lines="" local runtime_status=0 if ! is_safe_stack_site_cleanup_name "${site_name}"; then @@ -67,12 +67,12 @@ get_stack_site_managed_runtime_app_lines() { continue fi - append_stack_installable_app_line managed_app_lines "${managed_app_lines}" "${app_name}" + append_stack_installable_app_line resolved_managed_app_lines "${resolved_managed_app_lines}" "${app_name}" done < Date: Wed, 15 Apr 2026 17:15:39 +0200 Subject: [PATCH 42/51] test(easy-docker): add bats coverage for critical flows --- .github/workflows/easy-docker.yml | 38 ++++ scripts/easy-docker/lib/app/options.sh | 6 +- .../easy-docker/lib/app/wizard/common/core.sh | 13 +- tests/easy-docker/10_cli_smoke.bats | 86 ++++++++ tests/easy-docker/20_core_render.bats | 179 +++++++++++++++ tests/easy-docker/30_gum_ensure.bats | 120 ++++++++++ tests/easy-docker/40_docker_checks.bats | 147 +++++++++++++ tests/easy-docker/50_stack_metadata.bats | 149 +++++++++++++ .../60_compose_render_failures.bats | 207 ++++++++++++++++++ tests/easy-docker/test_helper.bash | 147 +++++++++++++ 10 files changed, 1086 insertions(+), 6 deletions(-) create mode 100644 .github/workflows/easy-docker.yml create mode 100755 tests/easy-docker/10_cli_smoke.bats create mode 100755 tests/easy-docker/20_core_render.bats create mode 100755 tests/easy-docker/30_gum_ensure.bats create mode 100755 tests/easy-docker/40_docker_checks.bats create mode 100755 tests/easy-docker/50_stack_metadata.bats create mode 100755 tests/easy-docker/60_compose_render_failures.bats create mode 100755 tests/easy-docker/test_helper.bash diff --git a/.github/workflows/easy-docker.yml b/.github/workflows/easy-docker.yml new file mode 100644 index 00000000..270be5a7 --- /dev/null +++ b/.github/workflows/easy-docker.yml @@ -0,0 +1,38 @@ +name: Easy Docker Tests + +on: + push: + branches: + - main + paths: + - "easy-docker.sh" + - "scripts/easy-docker/**" + - "tests/easy-docker/**" + - ".github/workflows/easy-docker.yml" + pull_request: + branches: + - main + paths: + - "easy-docker.sh" + - "scripts/easy-docker/**" + - "tests/easy-docker/**" + - ".github/workflows/easy-docker.yml" + +jobs: + bats: + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Install Bats + run: | + BATS_VERSION="v1.11.1" + curl -fsSL "https://github.com/bats-core/bats-core/archive/refs/tags/${BATS_VERSION}.tar.gz" -o bats-core.tar.gz + tar -xzf bats-core.tar.gz + sudo "./bats-core-${BATS_VERSION#v}/install.sh" /usr/local + + - name: Run easy-docker Bats tests + run: bats -p --recursive tests/easy-docker diff --git a/scripts/easy-docker/lib/app/options.sh b/scripts/easy-docker/lib/app/options.sh index 7b010ff7..54716723 100755 --- a/scripts/easy-docker/lib/app/options.sh +++ b/scripts/easy-docker/lib/app/options.sh @@ -12,13 +12,13 @@ USAGE parse_cli_options() { local result_var="${1}" - local disable_installation_fallback=0 + local disable_installation_fallback_value=0 shift while [ "$#" -gt 0 ]; do case "$1" in --no-installation-fallback) - disable_installation_fallback=1 + disable_installation_fallback_value=1 ;; -h | --help) print_usage @@ -33,6 +33,6 @@ parse_cli_options() { shift done - printf -v "${result_var}" "%s" "${disable_installation_fallback}" + printf -v "${result_var}" "%s" "${disable_installation_fallback_value}" return 0 } diff --git a/scripts/easy-docker/lib/app/wizard/common/core.sh b/scripts/easy-docker/lib/app/wizard/common/core.sh index c8de1606..54711793 100755 --- a/scripts/easy-docker/lib/app/wizard/common/core.sh +++ b/scripts/easy-docker/lib/app/wizard/common/core.sh @@ -2,6 +2,12 @@ get_easy_docker_repo_root() { local app_lib_dir="" + + if [ -n "${EASY_DOCKER_REPO_ROOT_OVERRIDE:-}" ]; then + printf '%s\n' "${EASY_DOCKER_REPO_ROOT_OVERRIDE}" + return 0 + fi + app_lib_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # core.sh lives in scripts/easy-docker/lib/app/wizard/common # so we need 6 levels up to reach repository root. @@ -55,14 +61,15 @@ create_stack_directory_with_metadata() { return 2 fi + if [ -z "${frappe_branch}" ]; then + return 1 + fi + if ! mkdir -p "${created_stack_dir}"; then return 1 fi created_at="$(get_current_utc_timestamp)" - if [ -z "${frappe_branch}" ]; then - return 1 - fi if ! cat >"${metadata_path}" <"${STUB_BIN}/${name}" + "${SYSTEM_CHMOD}" +x "${STUB_BIN}/${name}" +} + +write_passthrough_stub() { + local name="$1" + local target="$2" + + { + printf '#!%s\n' "${SYSTEM_BASH}" + printf 'exec "%s" "$@"\n' "${target}" + } >"${STUB_BIN}/${name}" + "${SYSTEM_CHMOD}" +x "${STUB_BIN}/${name}" +} + +@test "help prints usage and exits cleanly" { + run "${SYSTEM_ENV}" "PATH=${STUB_BIN}" "${SYSTEM_BASH}" "${MAIN_SCRIPT}" --help + + [ "${status}" -eq 0 ] + [[ "${output}" == *"Usage: bash easy-docker.sh [options]"* ]] + [[ "${output}" == *"--no-installation-fallback"* ]] +} + +@test "unknown option is rejected before startup" { + run "${SYSTEM_ENV}" "PATH=${STUB_BIN}" "${SYSTEM_BASH}" "${MAIN_SCRIPT}" --definitely-unknown + + [ "${status}" -eq 1 ] + [[ "${output}" == *"Unknown option: --definitely-unknown"* ]] + [[ "${output}" == *"Usage: bash easy-docker.sh [options]"* ]] +} + +@test "missing gum fails without interactive fallback when disabled" { + run "${SYSTEM_ENV}" "PATH=${STUB_BIN}" "${SYSTEM_BASH}" "${MAIN_SCRIPT}" --no-installation-fallback + + [ "${status}" -eq 1 ] + [[ "${output}" == *"gum is not installed. Trying package manager installation..."* ]] + [[ "${output}" == *"No supported package manager was found."* ]] + [[ "${output}" == *"Installation fallback is disabled."* ]] + [[ "${output}" == *"Install gum manually:"* ]] +} + +@test "missing docker stops after gum dependency succeeds" { + write_stub gum 'exit 0' + + run "${SYSTEM_ENV}" "PATH=${STUB_BIN}" "${SYSTEM_BASH}" "${MAIN_SCRIPT}" --no-installation-fallback + + [ "${status}" -eq 1 ] + [[ "${output}" == *"docker is not installed."* ]] + [[ "${output}" == *"Install Docker first:"* ]] +} diff --git a/tests/easy-docker/20_core_render.bats b/tests/easy-docker/20_core_render.bats new file mode 100755 index 00000000..c0df3908 --- /dev/null +++ b/tests/easy-docker/20_core_render.bats @@ -0,0 +1,179 @@ +#!/usr/bin/env bats + +load 'test_helper.bash' + +setup() { + easy_docker_test_begin + easy_docker_test_source_core_render_modules + unset ERPNEXT_VERSION + unset FRAPPE_BRANCH +} + +teardown() { + easy_docker_test_end +} + +@test "is_valid_stack_name accepts safe names" { + local name="" + + for name in alpha alpha-1 alpha_1 alpha.1; do + run is_valid_stack_name "${name}" + [ "${status}" -eq 0 ] + done +} + +@test "is_valid_stack_name rejects empty and unsafe names" { + local name="" + + for name in "" "bad name" "bad/name" "bad:name" "bad*name"; do + run is_valid_stack_name "${name}" + [ "${status}" -eq 1 ] + done +} + +@test "get_env_file_key_value parses exported and quoted values" { + local sandbox_root="" + local stack_dir="" + local env_file="" + + sandbox_root="$(easy_docker_test_create_repo_sandbox "env-parse")" + easy_docker_test_override_repo_root "${sandbox_root}" + stack_dir="$(easy_docker_test_stack_dir "env-parse")" + mkdir -p "${stack_dir}" + env_file="${stack_dir}/stack.env" + + cat >"${env_file}" <<'EOF' +# comment +export ERPNEXT_VERSION=15.9.0-test +STACK_NAME="My Stack" +IGNORED=value +EOF + + run get_env_file_key_value "${env_file}" ERPNEXT_VERSION + [ "${status}" -eq 0 ] + [ "${output}" = "15.9.0-test" ] + + run get_env_file_key_value "${env_file}" STACK_NAME + [ "${status}" -eq 0 ] + [ "${output}" = "My Stack" ] +} + +@test "get_stack_compose_project_name normalizes metadata stack names" { + local sandbox_root="" + local stack_dir="" + + sandbox_root="$(easy_docker_test_create_repo_sandbox "project-name")" + easy_docker_test_override_repo_root "${sandbox_root}" + stack_dir="$(easy_docker_test_stack_dir "project-name")" + mkdir -p "${stack_dir}" + + cat >"${stack_dir}/metadata.json" <<'EOF' +{ + "stack_name": "My Stack! 01" +} +EOF + + run get_stack_compose_project_name "${stack_dir}" + [ "${status}" -eq 0 ] + [ "${output}" = "easydocker-my-stack-01" ] +} + +@test "get_metadata_compose_files_lines returns compose file entries" { + local sandbox_root="" + local stack_dir="" + local expected="" + + sandbox_root="$(easy_docker_test_create_repo_sandbox "compose-lines")" + easy_docker_test_override_repo_root "${sandbox_root}" + stack_dir="$(easy_docker_test_stack_dir "compose-lines")" + mkdir -p "${stack_dir}" + + cat >"${stack_dir}/metadata.json" <<'EOF' +{ + "stack_name": "compose-lines", + "compose_files": [ + "compose.yaml", + "overrides/compose.proxy.yaml", + "overrides/compose.redis.yaml" + ] +} +EOF + + expected=$'compose.yaml\noverrides/compose.proxy.yaml\noverrides/compose.redis.yaml' + + run get_metadata_compose_files_lines "${stack_dir}/metadata.json" + [ "${status}" -eq 0 ] + [ "${output}" = "${expected}" ] +} + +@test "render_stack_compose_from_metadata writes generated compose with stubbed docker config" { + local sandbox_root="" + local stack_dir="" + local env_path="" + local generated_compose_path="" + local invocation_log="" + + easy_docker_test_install_docker_stub + + sandbox_root="$(easy_docker_test_create_repo_sandbox "render-smoke")" + easy_docker_test_override_repo_root "${sandbox_root}" + stack_dir="$(easy_docker_test_stack_dir "render-smoke")" + mkdir -p "${stack_dir}" + + cat >"${sandbox_root}/compose.yaml" <<'EOF' +services: + backend: + image: frappe/backend +EOF + + cat >"${sandbox_root}/overrides/compose.proxy.yaml" <<'EOF' +services: + frontend: + image: frappe/frontend +EOF + + cat >"${sandbox_root}/overrides/compose.redis.yaml" <<'EOF' +services: + redis-cache: + image: redis:7 +EOF + + cat >"${stack_dir}/metadata.json" <<'EOF' +{ + "stack_name": "My Stack! 01", + "compose_files": [ + "compose.yaml", + "overrides/compose.proxy.yaml", + "overrides/compose.redis.yaml" + ] +} +EOF + + env_path="${stack_dir}/My Stack! 01.env" + cat >"${env_path}" <<'EOF' +DB_HOST=localhost +EOF + + generated_compose_path="${stack_dir}/compose.generated.yaml" + invocation_log="${EASY_DOCKER_TEST_TMPDIR}/docker.invocations" + + export ERPNEXT_VERSION="15.9.0-test" + + run render_stack_compose_from_metadata "${stack_dir}" + [ "${status}" -eq 0 ] + [ -f "${generated_compose_path}" ] + [ ! -f "${generated_compose_path}.tmp" ] + + run cat "${generated_compose_path}" + [ "${status}" -eq 0 ] + [[ "${output}" == *"invocation=docker compose --project-name easydocker-my-stack-01 --env-file ${env_path}"* ]] + [[ "${output}" == *"-f ${sandbox_root}/compose.yaml"* ]] + [[ "${output}" == *"-f ${sandbox_root}/overrides/compose.proxy.yaml"* ]] + [[ "${output}" == *"-f ${sandbox_root}/overrides/compose.redis.yaml"* ]] + [[ "${output}" == *"erpnext=15.9.0-test"* ]] + + run cat "${invocation_log}" + [ "${status}" -eq 0 ] + [[ "${output}" == *"docker compose --project-name easydocker-my-stack-01 --env-file ${env_path} -f "* ]] + [[ "${output}" == *"config"* ]] +} diff --git a/tests/easy-docker/30_gum_ensure.bats b/tests/easy-docker/30_gum_ensure.bats new file mode 100755 index 00000000..cb91a6d5 --- /dev/null +++ b/tests/easy-docker/30_gum_ensure.bats @@ -0,0 +1,120 @@ +#!/usr/bin/env bats + +load 'test_helper.bash' + +setup() { + easy_docker_test_begin + easy_docker_test_source_gum_modules +} + +teardown() { + easy_docker_test_end +} + +@test "should_use_github_fallback rejects non-interactive terminals" { + local script_path="" + local repo_root="" + + repo_root="$(easy_docker_test_repo_root)" + script_path="${EASY_DOCKER_TEST_TMPDIR}/run-should-use-github-fallback" + easy_docker_test_write_executable "${script_path}" \ + "source \"${repo_root}/scripts/easy-docker/lib/install/gum/ensure.sh\"" \ + 'should_use_github_fallback' + + run "${script_path}" "${metadata_path}" + + result_stack_dir="" + if create_stack_directory_with_metadata result_stack_dir "duplicate-stack" "production" "version-15"; then + status_code=0 + else + status_code=$? + fi + [ "${status_code}" -eq 2 ] + [ "${result_stack_dir}" = "" ] + [ "$(cat "${metadata_path}")" = "original" ] +} + +@test "create_stack_directory_with_metadata does not leave a partial stack behind when frappe_branch is missing" { + local sandbox_root="" + local stack_dir="" + local result_stack_dir="" + local status_code="" + + sandbox_root="$(easy_docker_test_create_repo_sandbox "missing-frappe-branch")" + easy_docker_test_override_repo_root "${sandbox_root}" + stack_dir="$(easy_docker_test_stack_dir "missing-frappe-branch")" + + if create_stack_directory_with_metadata result_stack_dir "missing-frappe-branch" "production" ""; then + status_code=0 + else + status_code=$? + fi + + [ "${status_code}" -eq 1 ] + [ "${result_stack_dir}" = "" ] + [ ! -d "${stack_dir}" ] +} + +@test "rollback_stack_directory removes managed stack directories" { + local sandbox_root="" + local stack_dir="" + + sandbox_root="$(easy_docker_test_create_repo_sandbox "rollback-stack")" + easy_docker_test_override_repo_root "${sandbox_root}" + stack_dir="$(easy_docker_test_stack_dir "rollback-stack")" + mkdir -p "${stack_dir}/nested" + printf '%s\n' "payload" >"${stack_dir}/nested/file.txt" + + if ! rollback_stack_directory "${stack_dir}"; then + false + fi + [ ! -d "${stack_dir}" ] +} + +@test "rollback_stack_directory rejects paths outside the managed stacks tree" { + local sandbox_root="" + local outside_dir="" + local status_code="" + + sandbox_root="$(easy_docker_test_create_repo_sandbox "rollback-outside")" + easy_docker_test_override_repo_root "${sandbox_root}" + outside_dir="$(mktemp -d)" + + if rollback_stack_directory "${outside_dir}"; then + status_code=0 + else + status_code=$? + fi + [ "${status_code}" -eq 2 ] + [ -d "${outside_dir}" ] + + rm -rf "${outside_dir}" +} + +@test "get_stack_dir_by_name returns the matching stack directory and ignores junk entries" { + local sandbox_root="" + local stacks_dir="" + local matching_stack_dir="" + local junk_dir="" + + sandbox_root="$(easy_docker_test_create_repo_sandbox "stack-lookup")" + easy_docker_test_override_repo_root "${sandbox_root}" + stacks_dir="${sandbox_root}/.easy-docker/stacks" + + junk_dir="${stacks_dir}/not-a-stack" + matching_stack_dir="${stacks_dir}/target-stack" + + mkdir -p "${junk_dir}" "${matching_stack_dir}" + printf '%s\n' '{ "stack_name": "target-stack" }' >"${matching_stack_dir}/metadata.json" + + run get_stack_dir_by_name "target-stack" + [ "${status}" -eq 0 ] + [ "${output}" = "${matching_stack_dir}" ] +} + +@test "get_stack_dir_by_name fails when the stack is absent" { + local sandbox_root="" + + sandbox_root="$(easy_docker_test_create_repo_sandbox "stack-missing")" + easy_docker_test_override_repo_root "${sandbox_root}" + + run get_stack_dir_by_name "missing-stack" + [ "${status}" -eq 1 ] + [ -z "${output}" ] +} diff --git a/tests/easy-docker/60_compose_render_failures.bats b/tests/easy-docker/60_compose_render_failures.bats new file mode 100755 index 00000000..3845e041 --- /dev/null +++ b/tests/easy-docker/60_compose_render_failures.bats @@ -0,0 +1,207 @@ +#!/usr/bin/env bats + +load 'test_helper.bash' + +setup() { + easy_docker_test_begin + easy_docker_test_source_core_render_modules +} + +teardown() { + easy_docker_test_end +} + +write_docker_stub() { + local body="${1}" + + easy_docker_test_write_bin_command docker \ + 'set -euo pipefail' \ + "${body}" + easy_docker_test_prepend_bin_dir +} + +@test "render_stack_compose_from_metadata fails when metadata.json is missing" { + local sandbox_root="" + local stack_dir="" + local generated_compose_path="" + local docker_log="" + + sandbox_root="$(easy_docker_test_create_repo_sandbox "render-missing-metadata")" + easy_docker_test_override_repo_root "${sandbox_root}" + stack_dir="$(easy_docker_test_stack_dir "render-missing-metadata")" + mkdir -p "${stack_dir}" + + # shellcheck disable=SC2016 + write_docker_stub 'echo "docker should not have been called" >&2; exit 99' + + generated_compose_path="${stack_dir}/compose.generated.yaml" + docker_log="${EASY_DOCKER_TEST_TMPDIR}/docker.log" + + run render_stack_compose_from_metadata "${stack_dir}" + [ "${status}" -eq 1 ] + [ ! -f "${generated_compose_path}" ] + [ ! -f "${generated_compose_path}.tmp" ] + [ ! -f "${docker_log}" ] +} + +@test "render_stack_compose_from_metadata fails when the env file is missing" { + local sandbox_root="" + local stack_dir="" + local generated_compose_path="" + local docker_log="" + + sandbox_root="$(easy_docker_test_create_repo_sandbox "render-missing-env")" + easy_docker_test_override_repo_root "${sandbox_root}" + stack_dir="$(easy_docker_test_stack_dir "render-missing-env")" + mkdir -p "${stack_dir}" + + cat >"${stack_dir}/metadata.json" <<'EOF' +{ + "stack_name": "render-missing-env", + "compose_files": [ + "compose.yaml" + ] +} +EOF + + # shellcheck disable=SC2016 + write_docker_stub 'echo "docker should not have been called" >&2; exit 99' + + generated_compose_path="${stack_dir}/compose.generated.yaml" + docker_log="${EASY_DOCKER_TEST_TMPDIR}/docker.log" + + run render_stack_compose_from_metadata "${stack_dir}" + [ "${status}" -eq 1 ] + [ ! -f "${generated_compose_path}" ] + [ ! -f "${generated_compose_path}.tmp" ] + [ ! -f "${docker_log}" ] +} + +@test "render_stack_compose_from_metadata fails when compose_files are missing" { + local sandbox_root="" + local stack_dir="" + local generated_compose_path="" + local docker_log="" + + sandbox_root="$(easy_docker_test_create_repo_sandbox "render-missing-compose-files")" + easy_docker_test_override_repo_root "${sandbox_root}" + stack_dir="$(easy_docker_test_stack_dir "render-missing-compose-files")" + mkdir -p "${stack_dir}" + + cat >"${stack_dir}/metadata.json" <<'EOF' +{ + "stack_name": "render-missing-compose-files" +} +EOF + + cat >"${stack_dir}/render-missing-compose-files.env" <<'EOF' +ERPNEXT_VERSION=15.9.0-test +EOF + + # shellcheck disable=SC2016 + write_docker_stub 'echo "docker should not have been called" >&2; exit 99' + + generated_compose_path="${stack_dir}/compose.generated.yaml" + docker_log="${EASY_DOCKER_TEST_TMPDIR}/docker.log" + + run render_stack_compose_from_metadata "${stack_dir}" + [ "${status}" -eq 1 ] + [ ! -f "${generated_compose_path}" ] + [ ! -f "${generated_compose_path}.tmp" ] + [ ! -f "${docker_log}" ] +} + +@test "render_stack_compose_from_metadata fails when a referenced compose file is missing" { + local sandbox_root="" + local stack_dir="" + local generated_compose_path="" + local docker_log="" + + sandbox_root="$(easy_docker_test_create_repo_sandbox "render-missing-source")" + easy_docker_test_override_repo_root "${sandbox_root}" + stack_dir="$(easy_docker_test_stack_dir "render-missing-source")" + mkdir -p "${stack_dir}" + + cat >"${stack_dir}/metadata.json" <<'EOF' +{ + "stack_name": "render-missing-source", + "compose_files": [ + "compose.yaml", + "overrides/compose.proxy.yaml" + ] +} +EOF + + cat >"${stack_dir}/render-missing-source.env" <<'EOF' +ERPNEXT_VERSION=15.9.0-test +EOF + + cat >"${sandbox_root}/compose.yaml" <<'EOF' +services: + backend: + image: frappe/backend +EOF + + # shellcheck disable=SC2016 + write_docker_stub 'echo "docker should not have been called" >&2; exit 99' + + generated_compose_path="${stack_dir}/compose.generated.yaml" + docker_log="${EASY_DOCKER_TEST_TMPDIR}/docker.log" + + run render_stack_compose_from_metadata "${stack_dir}" + [ "${status}" -eq 1 ] + [ ! -f "${generated_compose_path}" ] + [ ! -f "${generated_compose_path}.tmp" ] + [ ! -f "${docker_log}" ] +} + +@test "render_stack_compose_from_metadata removes its temporary file after a docker config failure" { + local sandbox_root="" + local stack_dir="" + local generated_compose_path="" + local docker_log="" + + sandbox_root="$(easy_docker_test_create_repo_sandbox "render-docker-failure")" + easy_docker_test_override_repo_root "${sandbox_root}" + stack_dir="$(easy_docker_test_stack_dir "render-docker-failure")" + mkdir -p "${stack_dir}" + + cat >"${stack_dir}/metadata.json" <<'EOF' +{ + "stack_name": "render-docker-failure", + "compose_files": [ + "compose.yaml", + "overrides/compose.proxy.yaml" + ] +} +EOF + + cat >"${stack_dir}/render-docker-failure.env" <<'EOF' +ERPNEXT_VERSION=15.9.0-test +EOF + + cat >"${sandbox_root}/compose.yaml" <<'EOF' +services: + backend: + image: frappe/backend +EOF + mkdir -p "${sandbox_root}/overrides" + cat >"${sandbox_root}/overrides/compose.proxy.yaml" <<'EOF' +services: + frontend: + image: frappe/frontend +EOF + + # shellcheck disable=SC2016 + write_docker_stub 'printf "%s\n" "docker $*" >>"${EASY_DOCKER_TEST_TMPDIR}/docker.log"; if [ "${*: -1}" = "config" ]; then echo "simulated docker compose config failure" >&2; exit 23; fi; exit 0' + + generated_compose_path="${stack_dir}/compose.generated.yaml" + docker_log="${EASY_DOCKER_TEST_TMPDIR}/docker.log" + + run render_stack_compose_from_metadata "${stack_dir}" + [ "${status}" -eq 1 ] + [ ! -f "${generated_compose_path}" ] + [ ! -f "${generated_compose_path}.tmp" ] + [ -f "${docker_log}" ] + [ "$(cat "${docker_log}")" != "" ] +} diff --git a/tests/easy-docker/test_helper.bash b/tests/easy-docker/test_helper.bash new file mode 100755 index 00000000..4371f2aa --- /dev/null +++ b/tests/easy-docker/test_helper.bash @@ -0,0 +1,147 @@ +#!/usr/bin/env bash + +easy_docker_test_repo_root() { + local helper_dir="" + + helper_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + (cd "${helper_dir}/../.." && pwd) +} + +easy_docker_test_begin() { + EASY_DOCKER_TEST_TMPDIR="$(mktemp -d)" + export EASY_DOCKER_TEST_TMPDIR + unset EASY_DOCKER_REPO_ROOT_OVERRIDE +} + +easy_docker_test_end() { + if [ -n "${EASY_DOCKER_TEST_TMPDIR:-}" ] && [ -d "${EASY_DOCKER_TEST_TMPDIR}" ]; then + rm -rf "${EASY_DOCKER_TEST_TMPDIR}" + fi +} + +easy_docker_test_bin_dir() { + printf '%s/bin\n' "${EASY_DOCKER_TEST_TMPDIR}" +} + +easy_docker_test_write_executable() { + local target_path="${1}" + local system_bash="" + shift + + system_bash="$(command -v bash)" + mkdir -p "$(dirname "${target_path}")" + + { + printf '#!%s\n' "${system_bash}" + printf '%s\n' "$@" + } >"${target_path}" + chmod +x "${target_path}" +} + +easy_docker_test_write_bin_command() { + local command_name="${1}" + local target_path="" + shift + + target_path="$(easy_docker_test_bin_dir)/${command_name}" + easy_docker_test_write_executable "${target_path}" "$@" +} + +easy_docker_test_prepend_bin_dir() { + PATH="$(easy_docker_test_bin_dir):${PATH}" + export PATH +} + +easy_docker_test_source_common_modules() { + local repo_root="" + + repo_root="$(easy_docker_test_repo_root)" + + # shellcheck source=scripts/easy-docker/lib/core/commands.sh + source "${repo_root}/scripts/easy-docker/lib/core/commands.sh" + # shellcheck source=scripts/easy-docker/lib/core/messages.sh + source "${repo_root}/scripts/easy-docker/lib/core/messages.sh" +} + +easy_docker_test_source_core_render_modules() { + local repo_root="" + + repo_root="$(easy_docker_test_repo_root)" + + easy_docker_test_source_common_modules + + # shellcheck source=scripts/easy-docker/lib/app/wizard/common/core.sh + source "${repo_root}/scripts/easy-docker/lib/app/wizard/common/core.sh" + # shellcheck source=scripts/easy-docker/lib/app/wizard/common/compose/render.sh + source "${repo_root}/scripts/easy-docker/lib/app/wizard/common/compose/render.sh" +} + +easy_docker_test_source_docker_modules() { + local repo_root="" + + repo_root="$(easy_docker_test_repo_root)" + + easy_docker_test_source_common_modules + + # shellcheck source=scripts/easy-docker/lib/checks/docker.sh + source "${repo_root}/scripts/easy-docker/lib/checks/docker.sh" +} + +easy_docker_test_source_gum_modules() { + local repo_root="" + + repo_root="$(easy_docker_test_repo_root)" + + easy_docker_test_source_common_modules + + # shellcheck source=scripts/easy-docker/lib/install/gum/package_manager.sh + source "${repo_root}/scripts/easy-docker/lib/install/gum/package_manager.sh" + # shellcheck source=scripts/easy-docker/lib/install/gum/github_release.sh + source "${repo_root}/scripts/easy-docker/lib/install/gum/github_release.sh" + # shellcheck source=scripts/easy-docker/lib/install/gum/ensure.sh + source "${repo_root}/scripts/easy-docker/lib/install/gum/ensure.sh" +} + +easy_docker_test_create_repo_sandbox() { + local sandbox_name="${1}" + local sandbox_root="" + + sandbox_root="${EASY_DOCKER_TEST_TMPDIR}/repo-${sandbox_name}" + mkdir -p "${sandbox_root}/.easy-docker/stacks" "${sandbox_root}/overrides" + printf '%s\n' "${sandbox_root}" +} + +easy_docker_test_override_repo_root() { + EASY_DOCKER_REPO_ROOT_OVERRIDE="${1}" + export EASY_DOCKER_REPO_ROOT_OVERRIDE +} + +easy_docker_test_stack_dir() { + local stack_name="${1}" + + printf '%s/.easy-docker/stacks/%s\n' "${EASY_DOCKER_REPO_ROOT_OVERRIDE}" "${stack_name}" +} + +easy_docker_test_install_docker_stub() { + local log_file="" + + log_file="${EASY_DOCKER_TEST_TMPDIR}/docker.invocations" + + # shellcheck disable=SC2016 + easy_docker_test_write_bin_command docker \ + 'set -euo pipefail' \ + "log_file=\"${log_file}\"" \ + 'printf '"'"'%s\n'"'"' "docker $*" >>"${log_file}"' \ + 'if [ "${1:-}" != "compose" ]; then' \ + ' echo "unexpected docker subcommand: ${1:-}" >&2' \ + ' exit 64' \ + 'fi' \ + 'if [ "${!#}" != "config" ]; then' \ + ' echo "expected docker compose config invocation" >&2' \ + ' exit 65' \ + 'fi' \ + 'printf '"'"'invocation=%s\n'"'"' "docker $*"' \ + 'printf '"'"'erpnext=%s\n'"'"' "${ERPNEXT_VERSION:-}"' + + easy_docker_test_prepend_bin_dir +} From c754d0554486041eb9f26b448274b4a0cca8655c Mon Sep 17 00:00:00 2001 From: RocketQuack <202538874+Rocket-Quack@users.noreply.github.com> Date: Wed, 15 Apr 2026 17:51:13 +0200 Subject: [PATCH 43/51] fix(ci): run easy-docker bats tests without pretty formatter --- .github/workflows/easy-docker.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/easy-docker.yml b/.github/workflows/easy-docker.yml index 270be5a7..2d55a23f 100644 --- a/.github/workflows/easy-docker.yml +++ b/.github/workflows/easy-docker.yml @@ -35,4 +35,4 @@ jobs: sudo "./bats-core-${BATS_VERSION#v}/install.sh" /usr/local - name: Run easy-docker Bats tests - run: bats -p --recursive tests/easy-docker + run: bats --recursive tests/easy-docker From 6fdd9a3b844c48c4115c7736b8c8e98dc470420b Mon Sep 17 00:00:00 2001 From: RocketQuack <202538874+Rocket-Quack@users.noreply.github.com> Date: Mon, 20 Apr 2026 14:30:09 +0200 Subject: [PATCH 44/51] fix(easy-docker): preserve custom apps and simplify manage flows --- .../lib/app/wizard/common/apps/metadata.sh | 123 --------------- .../lib/app/wizard/common/apps/persistence.sh | 149 +++++------------- .../easy-docker/lib/app/wizard/env/apps.sh | 38 ++++- .../lib/app/wizard/flows/manage.sh | 4 +- .../lib/app/wizard/flows/manage/build.sh | 17 ++ .../lib/app/wizard/flows/manage/docker.sh | 81 ---------- 6 files changed, 95 insertions(+), 317 deletions(-) delete mode 100755 scripts/easy-docker/lib/app/wizard/flows/manage/docker.sh diff --git a/scripts/easy-docker/lib/app/wizard/common/apps/metadata.sh b/scripts/easy-docker/lib/app/wizard/common/apps/metadata.sh index 9dbbb498..3dba35b4 100755 --- a/scripts/easy-docker/lib/app/wizard/common/apps/metadata.sh +++ b/scripts/easy-docker/lib/app/wizard/common/apps/metadata.sh @@ -253,126 +253,3 @@ persist_stack_apps_json_from_metadata_apps() { return 0 } - -persist_stack_metadata_top_level_object() { - local stack_dir="${1}" - local object_key="${2}" - local object_json="${3}" - local insert_before_key="${4:-}" - local metadata_path="" - local metadata_tmp_path="" - - metadata_path="${stack_dir}/metadata.json" - metadata_tmp_path="${metadata_path}.tmp" - if [ ! -f "${metadata_path}" ]; then - return 1 - fi - - if [ -z "${object_json}" ]; then - return 1 - fi - - if ! awk -v object_key="${object_key}" -v object_json="${object_json}" -v insert_before_key="${insert_before_key}" ' - BEGIN { - target_regex = "^ \"" object_key "\"[[:space:]]*:" - before_regex = "" - if (insert_before_key != "") { - before_regex = "^ \"" insert_before_key "\"[[:space:]]*:" - } - in_target = 0 - target_depth = 0 - inserted = 0 - prev = "" - } - function flush_prev() { - if (prev != "") { - print prev - prev = "" - } - } - { - if (!in_target && $0 ~ target_regex) { - flush_prev() - if (object_key == "wizard") { - print " \"" object_key "\": " object_json - } else { - print " \"" object_key "\": " object_json "," - } - in_target = 1 - inserted = 1 - if ($0 ~ /{/) { - target_depth += gsub(/{/, "{", $0) - target_depth -= gsub(/}/, "}", $0) - } else { - target_depth = 0 - } - if (target_depth <= 0) { - in_target = 0 - } - next - } - - if (in_target) { - target_depth += gsub(/{/, "{", $0) - target_depth -= gsub(/}/, "}", $0) - if (target_depth <= 0) { - in_target = 0 - } - next - } - - if (!inserted && before_regex != "" && $0 ~ before_regex) { - flush_prev() - print " \"" object_key "\": " object_json "," - inserted = 1 - } - - if (!inserted && $0 ~ /^}/) { - if (prev != "") { - if (prev !~ /,[[:space:]]*$/) { - prev = prev "," - } - print prev - prev = "" - } - print " \"" object_key "\": " object_json - inserted = 1 - print $0 - next - } - - flush_prev() - prev = $0 - } - END { - flush_prev() - if (!inserted) { - exit 2 - } - } - ' "${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 -} - -persist_stack_metadata_apps_object() { - local stack_dir="${1}" - local apps_json_object="${2}" - - persist_stack_metadata_top_level_object "${stack_dir}" "apps" "${apps_json_object}" "wizard" -} - -persist_stack_metadata_wizard_object() { - local stack_dir="${1}" - local wizard_json_object="${2}" - - persist_stack_metadata_top_level_object "${stack_dir}" "wizard" "${wizard_json_object}" -} diff --git a/scripts/easy-docker/lib/app/wizard/common/apps/persistence.sh b/scripts/easy-docker/lib/app/wizard/common/apps/persistence.sh index e581f3cc..4a1dead2 100755 --- a/scripts/easy-docker/lib/app/wizard/common/apps/persistence.sh +++ b/scripts/easy-docker/lib/app/wizard/common/apps/persistence.sh @@ -1,8 +1,10 @@ #!/usr/bin/env bash -persist_stack_metadata_apps_object() { +persist_stack_metadata_top_level_object() { local stack_dir="${1}" - local apps_json_object="${2}" + local object_key="${2}" + local object_json="${3}" + local insert_before_key="${4:-}" local metadata_path="" local metadata_tmp_path="" @@ -12,14 +14,19 @@ persist_stack_metadata_apps_object() { return 1 fi - if [ -z "${apps_json_object}" ]; then + if [ -z "${object_json}" ]; then return 1 fi - if ! awk -v apps_object="${apps_json_object}" ' + if ! awk -v object_key="${object_key}" -v object_json="${object_json}" -v insert_before_key="${insert_before_key}" ' BEGIN { - in_top_level_apps = 0 - apps_depth = 0 + target_regex = "^ \"" object_key "\"[[:space:]]*:" + before_regex = "" + if (insert_before_key != "") { + before_regex = "^ \"" insert_before_key "\"[[:space:]]*:" + } + in_target = 0 + target_depth = 0 inserted = 0 prev = "" } @@ -30,35 +37,39 @@ persist_stack_metadata_apps_object() { } } { - if (!in_top_level_apps && $0 ~ /^ "apps"[[:space:]]*:/) { + if (!in_target && $0 ~ target_regex) { flush_prev() - print " \"apps\": " apps_object "," - in_top_level_apps = 1 + if (object_key == "wizard") { + print " \"" object_key "\": " object_json + } else { + print " \"" object_key "\": " object_json "," + } + in_target = 1 inserted = 1 if ($0 ~ /{/) { - apps_depth += gsub(/{/, "{", $0) - apps_depth -= gsub(/}/, "}", $0) + target_depth += gsub(/{/, "{", $0) + target_depth -= gsub(/}/, "}", $0) } else { - apps_depth = 0 + target_depth = 0 } - if (apps_depth <= 0) { - in_top_level_apps = 0 + if (target_depth <= 0) { + in_target = 0 } next } - if (in_top_level_apps) { - apps_depth += gsub(/{/, "{", $0) - apps_depth -= gsub(/}/, "}", $0) - if (apps_depth <= 0) { - in_top_level_apps = 0 + if (in_target) { + target_depth += gsub(/{/, "{", $0) + target_depth -= gsub(/}/, "}", $0) + if (target_depth <= 0) { + in_target = 0 } next } - if (!inserted && $0 ~ /^ "wizard"[[:space:]]*:/) { + if (!inserted && before_regex != "" && $0 ~ before_regex) { flush_prev() - print " \"apps\": " apps_object "," + print " \"" object_key "\": " object_json "," inserted = 1 } @@ -70,7 +81,7 @@ persist_stack_metadata_apps_object() { print prev prev = "" } - print " \"apps\": " apps_object + print " \"" object_key "\": " object_json inserted = 1 print $0 next @@ -98,94 +109,16 @@ persist_stack_metadata_apps_object() { return 0 } +persist_stack_metadata_apps_object() { + local stack_dir="${1}" + local apps_json_object="${2}" + + persist_stack_metadata_top_level_object "${stack_dir}" "apps" "${apps_json_object}" "wizard" +} + persist_stack_metadata_wizard_object() { local stack_dir="${1}" local wizard_json_object="${2}" - local metadata_path="" - local metadata_tmp_path="" - metadata_path="${stack_dir}/metadata.json" - metadata_tmp_path="${metadata_path}.tmp" - if [ ! -f "${metadata_path}" ]; then - return 1 - fi - - if [ -z "${wizard_json_object}" ]; then - return 1 - fi - - if ! awk -v wizard_object="${wizard_json_object}" ' - BEGIN { - in_top_level_wizard = 0 - wizard_depth = 0 - inserted = 0 - prev = "" - } - function flush_prev() { - if (prev != "") { - print prev - prev = "" - } - } - { - if (!in_top_level_wizard && $0 ~ /^ "wizard"[[:space:]]*:/) { - flush_prev() - print " \"wizard\": " wizard_object - in_top_level_wizard = 1 - inserted = 1 - if ($0 ~ /{/) { - wizard_depth += gsub(/{/, "{", $0) - wizard_depth -= gsub(/}/, "}", $0) - } else { - wizard_depth = 0 - } - if (wizard_depth <= 0) { - in_top_level_wizard = 0 - } - next - } - - if (in_top_level_wizard) { - wizard_depth += gsub(/{/, "{", $0) - wizard_depth -= gsub(/}/, "}", $0) - if (wizard_depth <= 0) { - in_top_level_wizard = 0 - } - next - } - - if (!inserted && $0 ~ /^}/) { - if (prev != "") { - if (prev !~ /,[[:space:]]*$/) { - prev = prev "," - } - print prev - prev = "" - } - print " \"wizard\": " wizard_object - inserted = 1 - print $0 - next - } - - flush_prev() - prev = $0 - } - END { - flush_prev() - if (!inserted) { - exit 2 - } - } - ' "${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 + persist_stack_metadata_top_level_object "${stack_dir}" "wizard" "${wizard_json_object}" } diff --git a/scripts/easy-docker/lib/app/wizard/env/apps.sh b/scripts/easy-docker/lib/app/wizard/env/apps.sh index ea357578..4a0bbf47 100755 --- a/scripts/easy-docker/lib/app/wizard/env/apps.sh +++ b/scripts/easy-docker/lib/app/wizard/env/apps.sh @@ -84,12 +84,17 @@ build_predefined_apps_metadata_json_object() { local result_var="${1}" local predefined_csv="${2}" local branch_lines="${3}" + local custom_apps_lines="${4:-}" local app_id="" local app_branch="" + local custom_repo="" + local custom_branch="" local predefined_json_entries="" local branch_json_entries="" + local custom_json_entries="" local escaped_app_id="" local escaped_branch="" + local escaped_repo="" local entry_json="" local line="" local -a predefined_ids=() @@ -134,7 +139,30 @@ build_predefined_apps_metadata_json_object() { ${branch_lines} EOF - printf -v "${result_var}" '{\n "predefined": [\n%s\n ],\n "predefined_branches": {\n%s\n },\n "custom": [\n ]\n }' "${predefined_json_entries}" "${branch_json_entries}" + while IFS= read -r line; do + if [ -z "${line}" ]; then + continue + fi + + custom_repo="${line%%|*}" + custom_branch="${line#*|}" + if [ -z "${custom_repo}" ] || [ -z "${custom_branch}" ]; then + continue + fi + + escaped_repo="$(json_escape_string "${custom_repo}")" + escaped_branch="$(json_escape_string "${custom_branch}")" + entry_json="$(printf ' {\n "repo": "%s",\n "branch": "%s"\n }' "${escaped_repo}" "${escaped_branch}")" + if [ -z "${custom_json_entries}" ]; then + custom_json_entries="${entry_json}" + else + custom_json_entries="${custom_json_entries}"$',\n'"${entry_json}" + fi + done < ${EASY_DOCKER_BUILD_ERROR_DETAIL}" 6 - ;; - *) - show_warning_and_wait "Custom image build failed (${build_image_status})." 4 - ;; - esac - - return "${build_image_status}" -} From 77a0f9e17169d7a6b399c8f8d3a426bf1efc931b Mon Sep 17 00:00:00 2001 From: RocketQuack <202538874+Rocket-Quack@users.noreply.github.com> Date: Mon, 20 Apr 2026 23:26:57 +0200 Subject: [PATCH 45/51] feat(easy-docker): bootstrap jq dependency on startup --- scripts/easy-docker/config/jq-checksums.tsv | 10 ++ scripts/easy-docker/lib/checks/jq.sh | 6 + scripts/easy-docker/lib/core/json.sh | 26 ++++ scripts/easy-docker/lib/core/messages.sh | 10 ++ scripts/easy-docker/lib/install/jq/assets.sh | 144 ++++++++++++++++++ scripts/easy-docker/lib/install/jq/ensure.sh | 67 ++++++++ .../lib/install/jq/github_release.sh | 109 +++++++++++++ scripts/easy-docker/lib/install/jq/load.sh | 20 +++ .../lib/install/jq/package_manager.sh | 62 ++++++++ .../easy-docker/lib/install/jq/platform.sh | 52 +++++++ scripts/easy-docker/lib/load.sh | 4 + scripts/easy-docker/main.sh | 4 + tests/easy-docker/10_cli_smoke.bats | 20 +++ tests/easy-docker/45_jq_checks.bats | 134 ++++++++++++++++ 14 files changed, 668 insertions(+) create mode 100644 scripts/easy-docker/config/jq-checksums.tsv create mode 100755 scripts/easy-docker/lib/checks/jq.sh create mode 100755 scripts/easy-docker/lib/core/json.sh create mode 100755 scripts/easy-docker/lib/install/jq/assets.sh create mode 100755 scripts/easy-docker/lib/install/jq/ensure.sh create mode 100755 scripts/easy-docker/lib/install/jq/github_release.sh create mode 100755 scripts/easy-docker/lib/install/jq/load.sh create mode 100755 scripts/easy-docker/lib/install/jq/package_manager.sh create mode 100755 scripts/easy-docker/lib/install/jq/platform.sh create mode 100755 tests/easy-docker/45_jq_checks.bats diff --git a/scripts/easy-docker/config/jq-checksums.tsv b/scripts/easy-docker/config/jq-checksums.tsv new file mode 100644 index 00000000..89d34996 --- /dev/null +++ b/scripts/easy-docker/config/jq-checksums.tsv @@ -0,0 +1,10 @@ +# version asset_name sha256 +1.8.1 jq-linux-amd64 020468de7539ce70ef1bceaf7cde2e8c4f2ca6c3afb84642aabc5c97d9fc2a0d +1.8.1 jq-linux64 020468de7539ce70ef1bceaf7cde2e8c4f2ca6c3afb84642aabc5c97d9fc2a0d +1.8.1 jq-linux-arm64 6bc62f25981328edd3cfcfe6fe51b073f2d7e7710d7ef7fcdac28d4e384fc3d4 +1.8.1 jq-linux-armhf ac304e50cf7cd24933d83dc7d0e4f79892a71a92fb02336d4ecaffa8933760bd +1.8.1 jq-macos-amd64 e80dbe0d2a2597e3c11c404f03337b981d74b4a8504b70586c354b7697a7c27f +1.8.1 jq-osx-amd64 e80dbe0d2a2597e3c11c404f03337b981d74b4a8504b70586c354b7697a7c27f +1.8.1 jq-macos-arm64 a9fe3ea2f86dfc72f6728417521ec9067b343277152b114f4e98d8cb0e263603 +1.8.1 jq-win64.exe 23cb60a1354eed6bcc8d9b9735e8c7b388cd1fdcb75726b93bc299ef22dd9334 +1.8.1 jq-windows-amd64.exe 23cb60a1354eed6bcc8d9b9735e8c7b388cd1fdcb75726b93bc299ef22dd9334 diff --git a/scripts/easy-docker/lib/checks/jq.sh b/scripts/easy-docker/lib/checks/jq.sh new file mode 100755 index 00000000..e259eb43 --- /dev/null +++ b/scripts/easy-docker/lib/checks/jq.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash + +jq_check_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# shellcheck source=scripts/easy-docker/lib/install/jq/load.sh +source "${jq_check_dir}/../install/jq/load.sh" diff --git a/scripts/easy-docker/lib/core/json.sh b/scripts/easy-docker/lib/core/json.sh new file mode 100755 index 00000000..a053338f --- /dev/null +++ b/scripts/easy-docker/lib/core/json.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash + +get_easy_docker_jq_command() { + if command -v jq >/dev/null 2>&1; then + printf 'jq\n' + return 0 + fi + + if command -v jq.exe >/dev/null 2>&1; then + printf 'jq.exe\n' + return 0 + fi + + return 1 +} + +easy_docker_require_jq() { + get_easy_docker_jq_command >/dev/null 2>&1 +} + +easy_docker_run_jq() { + local jq_command="" + + jq_command="$(get_easy_docker_jq_command)" || return 127 + "${jq_command}" "$@" +} diff --git a/scripts/easy-docker/lib/core/messages.sh b/scripts/easy-docker/lib/core/messages.sh index a2fd3fde..fca2524a 100755 --- a/scripts/easy-docker/lib/core/messages.sh +++ b/scripts/easy-docker/lib/core/messages.sh @@ -27,3 +27,13 @@ print_docker_command_support_guidance() { echo "Update Docker to a recent version and ensure Compose v2 is available as 'docker compose'." echo "Standard 'docker' and 'docker compose' commands are required." } + +print_jq_install_guidance() { + print_manual_jq_install_guidance +} + +print_manual_jq_install_guidance() { + echo "Install jq first: https://jqlang.org/download/" + echo "On Windows, you can also use: winget install jqlang.jq" + echo "Ensure the 'jq' or 'jq.exe' command is available on PATH before running easy-docker." +} diff --git a/scripts/easy-docker/lib/install/jq/assets.sh b/scripts/easy-docker/lib/install/jq/assets.sh new file mode 100755 index 00000000..786ed3a6 --- /dev/null +++ b/scripts/easy-docker/lib/install/jq/assets.sh @@ -0,0 +1,144 @@ +#!/usr/bin/env bash + +get_jq_checksums_path() { + local jq_lib_dir="" + + jq_lib_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + printf '%s/../../../config/jq-checksums.tsv\n' "${jq_lib_dir}" +} + +get_pinned_jq_version() { + local checksums_path="" + local release_version="" + + checksums_path="$(get_jq_checksums_path)" + if [ ! -f "${checksums_path}" ]; then + return 1 + fi + + release_version="$( + awk -F '\t' ' + /^[[:space:]]*#/ { next } + NF < 3 { next } + { + print $1 + exit + } + ' "${checksums_path}" + )" + if [ -z "${release_version}" ]; then + return 1 + fi + + printf '%s\n' "${release_version}" +} + +get_pinned_jq_asset_checksum() { + local release_version="${1}" + local asset_name="${2}" + local checksums_path="" + local expected_checksum="" + + checksums_path="$(get_jq_checksums_path)" + if [ ! -f "${checksums_path}" ]; then + return 1 + fi + + expected_checksum="$( + awk -F '\t' -v release_version="${release_version}" -v asset_name="${asset_name}" ' + /^[[:space:]]*#/ { next } + NF < 3 { next } + $1 == release_version && $2 == asset_name { + print $3 + exit + } + ' "${checksums_path}" + )" + if [ -z "${expected_checksum}" ]; then + return 1 + fi + + printf '%s\n' "${expected_checksum}" +} + +sha256_verification_available_for_jq() { + command_exists sha256sum || + command_exists shasum || + command_exists openssl || + command_exists certutil +} + +compute_file_sha256_for_jq() { + local file_path="${1}" + local hash_input_path="${file_path}" + + if command_exists sha256sum; then + sha256sum "${file_path}" | awk '{print tolower($1)}' + return $? + fi + + if command_exists shasum; then + shasum -a 256 "${file_path}" | awk '{print tolower($1)}' + return $? + fi + + if command_exists openssl; then + openssl dgst -sha256 -r "${file_path}" | awk '{print tolower($1)}' + return $? + fi + + if command_exists certutil; then + if command_exists cygpath; then + hash_input_path="$(cygpath -w "${file_path}" 2>/dev/null || printf '%s' "${file_path}")" + fi + + certutil -hashfile "${hash_input_path}" SHA256 2>/dev/null | + awk 'NR == 2 { gsub(/ /, "", $0); print tolower($0); exit }' + return $? + fi + + return 1 +} + +verify_file_sha256_for_jq() { + local file_path="${1}" + local expected_checksum="${2}" + local actual_checksum="" + + actual_checksum="$(compute_file_sha256_for_jq "${file_path}" || true)" + if [ -z "${actual_checksum}" ]; then + return 1 + fi + + if [ "${actual_checksum}" != "$(printf '%s' "${expected_checksum}" | tr '[:upper:]' '[:lower:]')" ]; then + return 1 + fi + + return 0 +} + +get_jq_asset_candidates() { + local jq_os="${1}" + local jq_arch="${2}" + + case "${jq_os}:${jq_arch}" in + linux:amd64) + printf '%s\n%s\n' "jq-linux-amd64" "jq-linux64" + ;; + linux:arm64) + printf '%s\n' "jq-linux-arm64" + ;; + linux:armhf) + printf '%s\n' "jq-linux-armhf" + ;; + macos:amd64) + printf '%s\n%s\n' "jq-macos-amd64" "jq-osx-amd64" + ;; + macos:arm64) + printf '%s\n' "jq-macos-arm64" + ;; + windows:amd64) + printf '%s\n%s\n' "jq-windows-amd64.exe" "jq-win64.exe" + ;; + esac +} diff --git a/scripts/easy-docker/lib/install/jq/ensure.sh b/scripts/easy-docker/lib/install/jq/ensure.sh new file mode 100755 index 00000000..6033fcc8 --- /dev/null +++ b/scripts/easy-docker/lib/install/jq/ensure.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash + +should_use_jq_github_fallback() { + local answer="" + + if [ ! -t 0 ]; then + echo "GitHub fallback prompt requires an interactive terminal." + return 1 + fi + + printf "Use GitHub binary fallback for jq? [y/N]: " + read -r answer + + case "${answer}" in + y | Y | yes | YES) + return 0 + ;; + *) + return 1 + ;; + esac +} + +ensure_jq() { + local disable_installation_fallback="${1:-0}" + + if get_easy_docker_jq_command >/dev/null 2>&1; then + return 0 + fi + + echo "jq is not installed. Trying package manager installation..." + + if install_jq_with_package_manager; then + hash -r + fi + + if get_easy_docker_jq_command >/dev/null 2>&1; then + echo "jq was installed successfully." + return 0 + fi + + if [ "${disable_installation_fallback}" = "1" ]; then + echo "Installation fallback is disabled." + print_manual_jq_install_guidance + return 1 + fi + + if should_use_jq_github_fallback; then + echo "Trying pinned GitHub release fallback..." + if install_jq_from_github_release; then + hash -r + fi + else + echo "GitHub fallback was not selected." + print_manual_jq_install_guidance + return 1 + fi + + if get_easy_docker_jq_command >/dev/null 2>&1; then + echo "jq was installed successfully." + return 0 + fi + + echo "Automatic installation failed." + print_manual_jq_install_guidance + return 1 +} diff --git a/scripts/easy-docker/lib/install/jq/github_release.sh b/scripts/easy-docker/lib/install/jq/github_release.sh new file mode 100755 index 00000000..19dfab92 --- /dev/null +++ b/scripts/easy-docker/lib/install/jq/github_release.sh @@ -0,0 +1,109 @@ +#!/usr/bin/env bash + +cleanup_jq_tmp_dir() { + local tmp_dir="${1:-}" + + if [ -n "${tmp_dir}" ] && [ -d "${tmp_dir}" ]; then + rm -rf "${tmp_dir}" + fi +} + +install_jq_from_github_release() { + local release_version="" + local checksums_path="" + local jq_os="" + local jq_arch="" + local asset_name="" + local asset_path="" + local download_url="" + local tmp_dir="" + local target_dir="" + local target_binary_name="jq" + local expected_checksum="" + + if ! command_exists curl; then + echo "curl is required for the GitHub fallback." + return 1 + fi + + if ! read -r jq_os jq_arch < <(detect_jq_platform); then + echo "Unsupported platform for automatic GitHub fallback." + return 1 + fi + + release_version="$(get_pinned_jq_version || true)" + if [ -z "${release_version}" ]; then + echo "Could not determine the pinned jq release version." + return 1 + fi + + checksums_path="$(get_jq_checksums_path)" + if [ ! -f "${checksums_path}" ]; then + echo "Pinned jq checksum file is missing: ${checksums_path}" + return 1 + fi + + if ! sha256_verification_available_for_jq; then + echo "A SHA256 verification tool is required for the GitHub fallback." + return 1 + fi + + tmp_dir="$(mktemp -d 2>/dev/null || true)" + if [ -z "${tmp_dir}" ] || [ ! -d "${tmp_dir}" ]; then + echo "Failed to create temporary directory for jq installation." + return 1 + fi + + while IFS= read -r asset_name; do + expected_checksum="$(get_pinned_jq_asset_checksum "${release_version}" "${asset_name}" || true)" + if [ -z "${expected_checksum}" ]; then + continue + fi + + asset_path="${tmp_dir}/${asset_name}" + download_url="https://github.com/jqlang/jq/releases/download/jq-${release_version}/${asset_name}" + + if ! curl -fsSL "${download_url}" -o "${asset_path}"; then + continue + fi + + if ! verify_file_sha256_for_jq "${asset_path}" "${expected_checksum}"; then + echo "Checksum verification failed for ${asset_name}." + continue + fi + + if [[ "${asset_name}" == *.exe ]]; then + target_binary_name="jq.exe" + else + target_binary_name="jq" + fi + + if [ "${jq_os}" != "windows" ] && [ -w "/usr/local/bin" ]; then + target_dir="/usr/local/bin" + if copy_binary "${asset_path}" "${target_dir}/${target_binary_name}"; then + cleanup_jq_tmp_dir "${tmp_dir}" + return 0 + fi + fi + + if [ "${jq_os}" != "windows" ] && command_exists sudo; then + if copy_binary_with_privileges "${asset_path}" "/usr/local/bin/${target_binary_name}"; then + cleanup_jq_tmp_dir "${tmp_dir}" + return 0 + fi + fi + + if [ -n "${HOME:-}" ]; then + target_dir="${HOME}/.local/bin" + mkdir -p "${target_dir}" + if copy_binary "${asset_path}" "${target_dir}/${target_binary_name}"; then + cleanup_jq_tmp_dir "${tmp_dir}" + return 0 + fi + fi + done < <(get_jq_asset_candidates "${jq_os}" "${jq_arch}") + + cleanup_jq_tmp_dir "${tmp_dir}" + echo "No compatible, verified jq binary was installed from the pinned GitHub release." + return 1 +} diff --git a/scripts/easy-docker/lib/install/jq/load.sh b/scripts/easy-docker/lib/install/jq/load.sh new file mode 100755 index 00000000..b7c844d3 --- /dev/null +++ b/scripts/easy-docker/lib/install/jq/load.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash + +load_jq_install_modules() { + local jq_lib_dir="" + + jq_lib_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + + # shellcheck source=scripts/easy-docker/lib/install/jq/platform.sh + source "${jq_lib_dir}/platform.sh" + # shellcheck source=scripts/easy-docker/lib/install/jq/assets.sh + source "${jq_lib_dir}/assets.sh" + # shellcheck source=scripts/easy-docker/lib/install/jq/package_manager.sh + source "${jq_lib_dir}/package_manager.sh" + # shellcheck source=scripts/easy-docker/lib/install/jq/github_release.sh + source "${jq_lib_dir}/github_release.sh" + # shellcheck source=scripts/easy-docker/lib/install/jq/ensure.sh + source "${jq_lib_dir}/ensure.sh" +} + +load_jq_install_modules diff --git a/scripts/easy-docker/lib/install/jq/package_manager.sh b/scripts/easy-docker/lib/install/jq/package_manager.sh new file mode 100755 index 00000000..26716bec --- /dev/null +++ b/scripts/easy-docker/lib/install/jq/package_manager.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash + +install_jq_with_package_manager() { + local pm_attempted=0 + + if command_exists brew; then + pm_attempted=1 + if brew install jq; then + return 0 + fi + fi + + if command_exists apt-get; then + pm_attempted=1 + if run_with_privileges apt-get update && run_with_privileges apt-get install -y jq; then + return 0 + fi + fi + + if command_exists dnf; then + pm_attempted=1 + if run_with_privileges dnf install -y jq; then + return 0 + fi + fi + + if command_exists pacman; then + pm_attempted=1 + if run_with_privileges pacman -Sy --noconfirm jq; then + return 0 + fi + fi + + if command_exists zypper; then + pm_attempted=1 + if run_with_privileges zypper --non-interactive install jq; then + return 0 + fi + fi + + if command_exists winget; then + pm_attempted=1 + if winget install --id jqlang.jq -e --accept-source-agreements --accept-package-agreements; then + return 0 + fi + fi + + if command_exists choco; then + pm_attempted=1 + if choco install jq -y; then + return 0 + fi + fi + + if [ "${pm_attempted}" -eq 0 ]; then + echo "No supported package manager was found." + else + echo "Package manager installation did not succeed." + fi + + return 1 +} diff --git a/scripts/easy-docker/lib/install/jq/platform.sh b/scripts/easy-docker/lib/install/jq/platform.sh new file mode 100755 index 00000000..54426dc3 --- /dev/null +++ b/scripts/easy-docker/lib/install/jq/platform.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash + +detect_jq_platform() { + local raw_os="" + local raw_arch="" + local jq_os="" + local jq_arch="" + + raw_os="$(uname -s 2>/dev/null || echo unknown)" + raw_arch="$(uname -m 2>/dev/null || echo unknown)" + + case "${raw_os}" in + Linux*) + jq_os="linux" + ;; + Darwin*) + jq_os="macos" + ;; + MINGW* | MSYS* | CYGWIN* | Windows_NT) + jq_os="windows" + ;; + *) + return 1 + ;; + esac + + case "${raw_arch}" in + x86_64 | amd64) + jq_arch="amd64" + ;; + aarch64 | arm64) + jq_arch="arm64" + ;; + armv7l | armv7) + jq_arch="armhf" + ;; + *) + return 1 + ;; + esac + + if [ "${jq_os}" = "windows" ] && [ "${jq_arch}" != "amd64" ]; then + return 1 + fi + + if [ "${jq_os}" = "macos" ] && [ "${jq_arch}" = "armhf" ]; then + return 1 + fi + + printf '%s %s\n' "${jq_os}" "${jq_arch}" + return 0 +} diff --git a/scripts/easy-docker/lib/load.sh b/scripts/easy-docker/lib/load.sh index 3d76d77c..7a84d309 100755 --- a/scripts/easy-docker/lib/load.sh +++ b/scripts/easy-docker/lib/load.sh @@ -8,10 +8,14 @@ load_easy_docker_modules() { source "${lib_dir}/core/commands.sh" # shellcheck source=scripts/easy-docker/lib/core/messages.sh source "${lib_dir}/core/messages.sh" + # shellcheck source=scripts/easy-docker/lib/core/json.sh + source "${lib_dir}/core/json.sh" # shellcheck source=scripts/easy-docker/lib/install/gum/load.sh source "${lib_dir}/install/gum/load.sh" # shellcheck source=scripts/easy-docker/lib/checks/docker.sh source "${lib_dir}/checks/docker.sh" + # shellcheck source=scripts/easy-docker/lib/checks/jq.sh + source "${lib_dir}/checks/jq.sh" # shellcheck source=scripts/easy-docker/lib/ui/screens.sh source "${lib_dir}/ui/screens.sh" # shellcheck source=scripts/easy-docker/lib/app/screen.sh diff --git a/scripts/easy-docker/main.sh b/scripts/easy-docker/main.sh index 16f171f3..88633c61 100755 --- a/scripts/easy-docker/main.sh +++ b/scripts/easy-docker/main.sh @@ -25,6 +25,10 @@ if ! ensure_docker; then exit 1 fi +if ! ensure_jq "${disable_installation_fallback}"; then + exit 1 +fi + trap 'leave_alt_screen; exit 0' INT TERM trap 'leave_alt_screen' EXIT diff --git a/tests/easy-docker/10_cli_smoke.bats b/tests/easy-docker/10_cli_smoke.bats index 318c7c6c..5e18ace5 100755 --- a/tests/easy-docker/10_cli_smoke.bats +++ b/tests/easy-docker/10_cli_smoke.bats @@ -84,3 +84,23 @@ write_passthrough_stub() { [[ "${output}" == *"docker is not installed."* ]] [[ "${output}" == *"Install Docker first:"* ]] } + +@test "missing jq stops after gum and docker dependencies succeed" { + write_stub gum 'exit 0' + # shellcheck disable=SC2016 + write_stub docker \ + 'if [ "${1:-}" = "compose" ] && [ "${2:-}" = "version" ]; then exit 0; fi' \ + 'if [ "${1:-}" = "info" ]; then exit 0; fi' \ + 'case "$*" in' \ + ' "ps --help"| "exec --help"| "inspect --help"| "cp --help"| "build --help"| "compose config --help"| "compose up --help"| "compose down --help"| "compose logs --help"| "compose exec --help"| "compose pull --help"| "compose ps --help") exit 0 ;;' \ + 'esac' \ + 'exit 0' + + run "${SYSTEM_ENV}" "PATH=${STUB_BIN}" "${SYSTEM_BASH}" "${MAIN_SCRIPT}" --no-installation-fallback + + [ "${status}" -eq 1 ] + [[ "${output}" == *"jq is not installed. Trying package manager installation..."* ]] + [[ "${output}" == *"No supported package manager was found."* ]] + [[ "${output}" == *"Installation fallback is disabled."* ]] + [[ "${output}" == *"Install jq first:"* ]] +} diff --git a/tests/easy-docker/45_jq_checks.bats b/tests/easy-docker/45_jq_checks.bats new file mode 100755 index 00000000..a2780cf1 --- /dev/null +++ b/tests/easy-docker/45_jq_checks.bats @@ -0,0 +1,134 @@ +#!/usr/bin/env bats + +load 'test_helper.bash' + +setup() { + easy_docker_test_begin + easy_docker_test_source_jq_modules +} + +teardown() { + easy_docker_test_end +} + +@test "ensure_jq fails when jq is not installed" { + # shellcheck disable=SC2317 + get_easy_docker_jq_command() { + return 1 + } + + # shellcheck disable=SC2317 + install_jq_with_package_manager() { + echo "No supported package manager was found." + return 1 + } + + run ensure_jq 1 + + [ "${status}" -eq 1 ] + [[ "${output}" == *"jq is not installed. Trying package manager installation..."* ]] + [[ "${output}" == *"No supported package manager was found."* ]] + [[ "${output}" == *"Installation fallback is disabled."* ]] + [[ "${output}" == *"Install jq first:"* ]] +} + +@test "ensure_jq succeeds when jq is installed" { + # shellcheck disable=SC2317 + get_easy_docker_jq_command() { + printf '%s\n' "jq" + } + + run ensure_jq 0 + + [ "${status}" -eq 0 ] + [ -z "${output}" ] +} + +@test "ensure_jq succeeds when only jq.exe is installed" { + # shellcheck disable=SC2317 + get_easy_docker_jq_command() { + printf '%s\n' "jq.exe" + } + + run ensure_jq 0 + + [ "${status}" -eq 0 ] + [ -z "${output}" ] +} + +@test "should_use_jq_github_fallback rejects non-interactive terminals" { + local script_path="" + local repo_root="" + + repo_root="$(easy_docker_test_repo_root)" + script_path="${EASY_DOCKER_TEST_TMPDIR}/run-should-use-jq-github-fallback" + easy_docker_test_write_executable "${script_path}" \ + "source \"${repo_root}/scripts/easy-docker/lib/install/jq/ensure.sh\"" \ + 'should_use_jq_github_fallback' + + run "${script_path}" Date: Mon, 20 Apr 2026 23:27:37 +0200 Subject: [PATCH 46/51] refactor(easy-docker): migrate stack json processing to jq --- .../lib/app/wizard/common/apps/metadata.sh | 197 ++++++++----- .../lib/app/wizard/common/apps/persistence.sh | 219 +++++++++----- .../lib/app/wizard/common/compose/build.sh | 12 +- .../easy-docker/lib/app/wizard/common/core.sh | 32 +- .../app/wizard/common/site/metadata/read.sh | 59 +--- .../app/wizard/common/site/metadata/write.sh | 74 +---- .../lib/app/wizard/flows/manage/build.sh | 3 + tests/easy-docker/20_core_render.bats | 33 +++ tests/easy-docker/50_stack_metadata.bats | 1 + tests/easy-docker/56_site_metadata_read.bats | 124 ++++++++ .../60_compose_render_failures.bats | 1 + tests/easy-docker/65_apps_jq_migration.bats | 273 +++++++++++++++++ tests/easy-docker/test_helper.bash | 278 ++++++++++++++++++ 13 files changed, 1002 insertions(+), 304 deletions(-) create mode 100755 tests/easy-docker/56_site_metadata_read.bats create mode 100755 tests/easy-docker/65_apps_jq_migration.bats diff --git a/scripts/easy-docker/lib/app/wizard/common/apps/metadata.sh b/scripts/easy-docker/lib/app/wizard/common/apps/metadata.sh index 3dba35b4..fdea5902 100755 --- a/scripts/easy-docker/lib/app/wizard/common/apps/metadata.sh +++ b/scripts/easy-docker/lib/app/wizard/common/apps/metadata.sh @@ -29,33 +29,11 @@ get_metadata_apps_predefined_csv() { return 1 fi - awk ' - /"apps"[[:space:]]*:[[:space:]]*{/ { - in_apps = 1 - } - in_apps && /"predefined"[[:space:]]*:[[:space:]]*\[/ { - in_predefined = 1 - next - } - in_predefined && /\]/ { - in_predefined = 0 - next - } - in_predefined { - if (match($0, /"([^"]+)"/, parts)) { - if (csv == "") { - csv = parts[1] - } else { - csv = csv "," parts[1] - } - } - } - END { - if (csv != "") { - print csv - } - } - ' "${metadata_path}" + if ! easy_docker_require_jq; then + return 1 + fi + + easy_docker_run_jq -r '(.apps.predefined // []) | join(",")' "${metadata_path}" } get_metadata_apps_custom_lines() { @@ -65,34 +43,11 @@ get_metadata_apps_custom_lines() { return 1 fi - awk ' - /"apps"[[:space:]]*:[[:space:]]*{/ { - in_apps = 1 - } - in_apps && /"custom"[[:space:]]*:[[:space:]]*\[/ { - in_custom = 1 - next - } - in_custom && /\]/ { - in_custom = 0 - repo = "" - branch = "" - next - } - in_custom { - if (match($0, /"repo"[[:space:]]*:[[:space:]]*"([^"]+)"/, repo_parts)) { - repo = repo_parts[1] - } - if (match($0, /"branch"[[:space:]]*:[[:space:]]*"([^"]+)"/, branch_parts)) { - branch = branch_parts[1] - } - if (repo != "" && branch != "") { - print repo "|" branch - repo = "" - branch = "" - } - } - ' "${metadata_path}" + if ! easy_docker_require_jq; then + return 1 + fi + + easy_docker_run_jq -r '(.apps.custom // [])[]? | select(has("repo") and has("branch")) | "\(.repo)|\(.branch)"' "${metadata_path}" } get_metadata_apps_predefined_branch_lines() { @@ -102,32 +57,64 @@ get_metadata_apps_predefined_branch_lines() { return 1 fi - awk ' - /"apps"[[:space:]]*:[[:space:]]*{/ { - in_apps = 1 - } - in_apps && /"predefined_branches"[[:space:]]*:[[:space:]]*{/ { - in_predefined_branches = 1 - next - } - in_predefined_branches && /}/ { - in_predefined_branches = 0 - next - } - in_predefined_branches { - if (match($0, /"([^"]+)"[[:space:]]*:[[:space:]]*"([^"]+)"/, parts)) { - print parts[1] "|" parts[2] - } - } - ' "${metadata_path}" + if ! easy_docker_require_jq; then + return 1 + fi + + easy_docker_run_jq -r '(.apps.predefined_branches // {}) | to_entries[]? | "\(.key)|\(.value)"' "${metadata_path}" } get_metadata_apps_predefined_branch_for_id() { local metadata_path="${1}" local app_id_lookup="${2}" - local line="" + + if [ ! -f "${metadata_path}" ]; then + return 1 + fi + + if ! easy_docker_require_jq; then + return 1 + fi + + # shellcheck disable=SC2016 + easy_docker_run_jq -r --arg app_id "${app_id_lookup}" '.apps.predefined_branches[$app_id] // empty' "${metadata_path}" +} + +build_metadata_apps_json_object() { + local result_var="${1}" + local predefined_csv="${2}" + local branch_lines="${3}" + local custom_apps_lines="${4:-}" local app_id="" local app_branch="" + local custom_repo="" + local custom_branch="" + local predefined_json_entries="" + local branch_json_entries="" + local custom_json_entries="" + local escaped_app_id="" + local escaped_branch="" + local escaped_repo="" + local entry_json="" + local line="" + local -a predefined_ids=() + + if [ -n "${predefined_csv}" ]; then + IFS=',' read -r -a predefined_ids <<<"${predefined_csv}" + for app_id in "${predefined_ids[@]}"; do + if [ -z "${app_id}" ]; then + continue + fi + + escaped_app_id="$(json_escape_string "${app_id}")" + entry_json="$(printf ' "%s"' "${escaped_app_id}")" + if [ -z "${predefined_json_entries}" ]; then + predefined_json_entries="${entry_json}" + else + predefined_json_entries="${predefined_json_entries}"$',\n'"${entry_json}" + fi + done + fi while IFS= read -r line; do if [ -z "${line}" ]; then @@ -136,13 +123,61 @@ get_metadata_apps_predefined_branch_for_id() { app_id="${line%%|*}" app_branch="${line#*|}" - if [ "${app_id}" = "${app_id_lookup}" ] && [ -n "${app_branch}" ]; then - printf '%s\n' "${app_branch}" - return 0 + if [ -z "${app_id}" ] || [ -z "${app_branch}" ]; then + continue fi - done < <(get_metadata_apps_predefined_branch_lines "${metadata_path}" || true) - return 1 + escaped_app_id="$(json_escape_string "${app_id}")" + escaped_branch="$(json_escape_string "${app_branch}")" + entry_json="$(printf ' "%s": "%s"' "${escaped_app_id}" "${escaped_branch}")" + if [ -z "${branch_json_entries}" ]; then + branch_json_entries="${entry_json}" + else + branch_json_entries="${branch_json_entries}"$',\n'"${entry_json}" + fi + done </dev/null 2>&1; then + return 1 + fi + + printf -v "${result_var}" "%s" "${rendered_metadata}" +} + persist_stack_metadata_top_level_object() { local stack_dir="${1}" local object_key="${2}" @@ -7,6 +115,11 @@ persist_stack_metadata_top_level_object() { local insert_before_key="${4:-}" local metadata_path="" local metadata_tmp_path="" + local metadata_content="" + local existing_key="" + local inserted=0 + local -a existing_keys=() + local -a ordered_keys=() metadata_path="${stack_dir}/metadata.json" metadata_tmp_path="${metadata_path}.tmp" @@ -18,85 +131,41 @@ persist_stack_metadata_top_level_object() { return 1 fi - if ! awk -v object_key="${object_key}" -v object_json="${object_json}" -v insert_before_key="${insert_before_key}" ' - BEGIN { - target_regex = "^ \"" object_key "\"[[:space:]]*:" - before_regex = "" - if (insert_before_key != "") { - before_regex = "^ \"" insert_before_key "\"[[:space:]]*:" - } - in_target = 0 - target_depth = 0 - inserted = 0 - prev = "" - } - function flush_prev() { - if (prev != "") { - print prev - prev = "" - } - } - { - if (!in_target && $0 ~ target_regex) { - flush_prev() - if (object_key == "wizard") { - print " \"" object_key "\": " object_json - } else { - print " \"" object_key "\": " object_json "," - } - in_target = 1 - inserted = 1 - if ($0 ~ /{/) { - target_depth += gsub(/{/, "{", $0) - target_depth -= gsub(/}/, "}", $0) - } else { - target_depth = 0 - } - if (target_depth <= 0) { - in_target = 0 - } - next - } + if ! easy_docker_require_jq; then + return 1 + fi - if (in_target) { - target_depth += gsub(/{/, "{", $0) - target_depth -= gsub(/}/, "}", $0) - if (target_depth <= 0) { - in_target = 0 - } - next - } + if ! easy_docker_run_jq -e 'type == "object"' "${metadata_path}" >/dev/null 2>&1; then + return 1 + fi - if (!inserted && before_regex != "" && $0 ~ before_regex) { - flush_prev() - print " \"" object_key "\": " object_json "," - inserted = 1 - } + object_json="${object_json%$'\n'}" + if ! printf '%s\n' "${object_json}" | easy_docker_run_jq -e 'type == "object"' >/dev/null 2>&1; then + return 1 + fi - if (!inserted && $0 ~ /^}/) { - if (prev != "") { - if (prev !~ /,[[:space:]]*$/) { - prev = prev "," - } - print prev - prev = "" - } - print " \"" object_key "\": " object_json - inserted = 1 - print $0 - next - } + mapfile -t existing_keys < <(easy_docker_run_jq -r 'keys_unsorted[]' "${metadata_path}") || return 1 - flush_prev() - prev = $0 - } - END { - flush_prev() - if (!inserted) { - exit 2 - } - } - ' "${metadata_path}" >"${metadata_tmp_path}"; then + for existing_key in "${existing_keys[@]}"; do + if [ "${existing_key}" = "${object_key}" ]; then + continue + fi + + if [ "${inserted}" -eq 0 ] && [ -n "${insert_before_key}" ] && [ "${existing_key}" = "${insert_before_key}" ]; then + ordered_keys+=("${object_key}") + inserted=1 + fi + + ordered_keys+=("${existing_key}") + done + + if [ "${inserted}" -eq 0 ]; then + ordered_keys+=("${object_key}") + fi + + build_stack_metadata_top_level_object_content metadata_content "${metadata_path}" "${object_key}" "${object_json}" "${ordered_keys[@]}" || return 1 + + if ! printf '%s' "${metadata_content}" >"${metadata_tmp_path}"; then rm -f -- "${metadata_tmp_path}" >/dev/null 2>&1 || true return 1 fi diff --git a/scripts/easy-docker/lib/app/wizard/common/compose/build.sh b/scripts/easy-docker/lib/app/wizard/common/compose/build.sh index 8bab8930..803f3cd4 100755 --- a/scripts/easy-docker/lib/app/wizard/common/compose/build.sh +++ b/scripts/easy-docker/lib/app/wizard/common/compose/build.sh @@ -31,6 +31,9 @@ build_stack_custom_image() { if [ ! -f "${env_path}" ]; then return 12 fi + if ! easy_docker_require_jq; then + return 25 + fi custom_image="$(get_env_file_key_value "${env_path}" "CUSTOM_IMAGE" || true)" custom_tag="$(get_env_file_key_value "${env_path}" "CUSTOM_TAG" || true)" @@ -58,13 +61,8 @@ build_stack_custom_image() { fi apps_refs_lines="$( - awk ' - match($0, /"url"[[:space:]]*:[[:space:]]*"([^"]+)"/, url_parts) && - match($0, /"branch"[[:space:]]*:[[:space:]]*"([^"]+)"/, branch_parts) { - print url_parts[1] "|" branch_parts[1] - } - ' "${apps_json_path}" - )" + easy_docker_run_jq -r '.[]? | select((.url // "") != "" and (.branch // "") != "") | "\(.url)|\(.branch)"' "${apps_json_path}" + )" || return 23 if [ -z "${apps_refs_lines}" ]; then return 23 fi diff --git a/scripts/easy-docker/lib/app/wizard/common/core.sh b/scripts/easy-docker/lib/app/wizard/common/core.sh index 54711793..124f8336 100755 --- a/scripts/easy-docker/lib/app/wizard/common/core.sh +++ b/scripts/easy-docker/lib/app/wizard/common/core.sh @@ -119,12 +119,12 @@ get_metadata_string_field() { return 1 fi - awk -v field_name="${field_name}" ' - match($0, "\"" field_name "\"[[:space:]]*:[[:space:]]*\"([^\"]*)\"", parts) { - print parts[1] - exit - } - ' "${metadata_path}" + if ! easy_docker_require_jq; then + return 1 + fi + + # shellcheck disable=SC2016 + easy_docker_run_jq -r --arg field_name "${field_name}" '[.. | objects | .[$field_name]? | select(type == "string")][0] // empty' "${metadata_path}" } get_env_file_key_value() { @@ -364,19 +364,9 @@ get_metadata_compose_files_lines() { return 1 fi - awk ' - /"compose_files"[[:space:]]*:[[:space:]]*\[/ { - in_compose_files = 1 - next - } - in_compose_files && /\]/ { - in_compose_files = 0 - exit - } - in_compose_files { - if (match($0, /"([^"]+)"/, parts)) { - print parts[1] - } - } - ' "${metadata_path}" + if ! easy_docker_require_jq; then + return 1 + fi + + easy_docker_run_jq -r '([.. | objects | .compose_files? | select(type == "array")] | .[0] // [])[]?' "${metadata_path}" } diff --git a/scripts/easy-docker/lib/app/wizard/common/site/metadata/read.sh b/scripts/easy-docker/lib/app/wizard/common/site/metadata/read.sh index b0f072f4..c298ccfe 100755 --- a/scripts/easy-docker/lib/app/wizard/common/site/metadata/read.sh +++ b/scripts/easy-docker/lib/app/wizard/common/site/metadata/read.sh @@ -8,27 +8,12 @@ get_metadata_site_string_field() { 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 - } + if ! easy_docker_require_jq; then + return 1 + fi - line = $0 - open_count = gsub(/{/, "{", line) - close_count = gsub(/}/, "}", line) - site_depth += open_count - close_count - if (site_depth <= 0) { - exit - } - } - ' "${metadata_path}" + # shellcheck disable=SC2016 + easy_docker_run_jq -r --arg field_name "${field_name}" '.site[$field_name] // empty' "${metadata_path}" } get_metadata_site_apps_installed_lines() { @@ -38,35 +23,11 @@ get_metadata_site_apps_installed_lines() { 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}" + if ! easy_docker_require_jq; then + return 1 + fi + + easy_docker_run_jq -r '(.site.apps_installed // [])[]? | select(type == "string")' "${metadata_path}" } get_stack_site_name() { diff --git a/scripts/easy-docker/lib/app/wizard/common/site/metadata/write.sh b/scripts/easy-docker/lib/app/wizard/common/site/metadata/write.sh index dafc4393..333adbcd 100755 --- a/scripts/easy-docker/lib/app/wizard/common/site/metadata/write.sh +++ b/scripts/easy-docker/lib/app/wizard/common/site/metadata/write.sh @@ -68,11 +68,9 @@ persist_stack_site_metadata() { local updated_at="${9:-}" local last_backup_at="${10-__KEEP_CURRENT__}" 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 @@ -83,77 +81,7 @@ persist_stack_site_metadata() { build_stack_site_metadata_json_object site_json_object "${site_mode}" "${site_name}" "${apps_installed_lines}" "${last_action}" "${last_error}" "${error_log_path}" "${created_at}" "${updated_at}" "${last_backup_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 + if ! persist_stack_metadata_top_level_object "${stack_dir}" "site" "${site_json_object}"; then return 1 fi diff --git a/scripts/easy-docker/lib/app/wizard/flows/manage/build.sh b/scripts/easy-docker/lib/app/wizard/flows/manage/build.sh index 6ef17921..b88ab426 100755 --- a/scripts/easy-docker/lib/app/wizard/flows/manage/build.sh +++ b/scripts/easy-docker/lib/app/wizard/flows/manage/build.sh @@ -72,6 +72,9 @@ run_build_stack_custom_image_with_feedback() { 24) show_warning_and_wait "Custom image build failed: app branch precheck failed -> ${EASY_DOCKER_BUILD_ERROR_DETAIL}" 6 ;; + 25) + show_warning_and_wait "Custom image build failed: jq is required for stack metadata and apps.json processing." 4 + ;; *) show_warning_and_wait "Custom image build failed (${build_image_status})." 4 ;; diff --git a/tests/easy-docker/20_core_render.bats b/tests/easy-docker/20_core_render.bats index c0df3908..e4018584 100755 --- a/tests/easy-docker/20_core_render.bats +++ b/tests/easy-docker/20_core_render.bats @@ -5,6 +5,7 @@ load 'test_helper.bash' setup() { easy_docker_test_begin easy_docker_test_source_core_render_modules + easy_docker_test_install_jq_stub unset ERPNEXT_VERSION unset FRAPPE_BRANCH } @@ -106,6 +107,38 @@ EOF [ "${output}" = "${expected}" ] } +@test "get_metadata_compose_files_lines keeps the first compose_files array only" { + local sandbox_root="" + local stack_dir="" + local expected="" + + sandbox_root="$(easy_docker_test_create_repo_sandbox "compose-lines-first-array")" + easy_docker_test_override_repo_root "${sandbox_root}" + stack_dir="$(easy_docker_test_stack_dir "compose-lines-first-array")" + mkdir -p "${stack_dir}" + + cat >"${stack_dir}/metadata.json" <<'EOF' +{ + "stack_name": "compose-lines-first-array", + "compose_files": [ + "compose.yaml", + "overrides/compose.proxy.yaml" + ], + "wizard": { + "compose_files": [ + "should-not-appear.yaml" + ] + } +} +EOF + + expected=$'compose.yaml\noverrides/compose.proxy.yaml' + + run get_metadata_compose_files_lines "${stack_dir}/metadata.json" + [ "${status}" -eq 0 ] + [ "${output}" = "${expected}" ] +} + @test "render_stack_compose_from_metadata writes generated compose with stubbed docker config" { local sandbox_root="" local stack_dir="" diff --git a/tests/easy-docker/50_stack_metadata.bats b/tests/easy-docker/50_stack_metadata.bats index cc5e2314..37379535 100755 --- a/tests/easy-docker/50_stack_metadata.bats +++ b/tests/easy-docker/50_stack_metadata.bats @@ -5,6 +5,7 @@ load 'test_helper.bash' setup() { easy_docker_test_begin easy_docker_test_source_core_render_modules + easy_docker_test_install_jq_stub } teardown() { diff --git a/tests/easy-docker/56_site_metadata_read.bats b/tests/easy-docker/56_site_metadata_read.bats new file mode 100755 index 00000000..14715992 --- /dev/null +++ b/tests/easy-docker/56_site_metadata_read.bats @@ -0,0 +1,124 @@ +#!/usr/bin/env bats + +load 'test_helper.bash' + +setup() { + local repo_root="" + + easy_docker_test_begin + easy_docker_test_source_apps_modules + easy_docker_test_install_jq_stub + + repo_root="$(easy_docker_test_repo_root)" + # shellcheck source=scripts/easy-docker/lib/app/wizard/common/site/metadata.sh + source "${repo_root}/scripts/easy-docker/lib/app/wizard/common/site/metadata.sh" +} + +teardown() { + easy_docker_test_end +} + +@test "site metadata readers keep existing values independent of JSON layout" { + local sandbox_root="" + local stack_dir="" + local expected_apps="" + + sandbox_root="$(easy_docker_test_create_repo_sandbox "site-metadata-read")" + easy_docker_test_override_repo_root "${sandbox_root}" + stack_dir="$(easy_docker_test_stack_dir "site-metadata-read")" + mkdir -p "${stack_dir}" + + cat >"${stack_dir}/metadata.json" <<'EOF' +{ + "site": { + "name": "site-a.local", + "last_error": "", + "created_at": "2026-04-20T10:00:00Z", + "last_backup_at": "2026-04-20T12:00:00Z", + "apps_installed": [ + "erpnext", + "crm", + "my_custom_app" + ] + } +} +EOF + expected_apps=$'erpnext\ncrm\nmy_custom_app' + + run get_stack_site_name "${stack_dir}" + [ "${status}" -eq 0 ] + [ "${output}" = "site-a.local" ] + + run get_stack_site_created_at "${stack_dir}" + [ "${status}" -eq 0 ] + [ "${output}" = "2026-04-20T10:00:00Z" ] + + run get_stack_site_last_backup_at "${stack_dir}" + [ "${status}" -eq 0 ] + [ "${output}" = "2026-04-20T12:00:00Z" ] + + run get_stack_site_apps_installed_lines "${stack_dir}" + [ "${status}" -eq 0 ] + [ "${output}" = "${expected_apps}" ] +} + +@test "persist_stack_site_metadata keeps the canonical site layout and preserves top-level metadata order" { + local sandbox_root="" + local stack_dir="" + local expected_metadata="" + + sandbox_root="$(easy_docker_test_create_repo_sandbox "site-metadata-write")" + easy_docker_test_override_repo_root "${sandbox_root}" + stack_dir="$(easy_docker_test_stack_dir "site-metadata-write")" + mkdir -p "${stack_dir}" + + cat >"${stack_dir}/metadata.json" <<'EOF' +{ + "schema_version": 1, + "stack_name": "site-metadata-write", + "setup_type": "production", + "frappe_branch": "version-16", + "created_at": "2026-04-20T10:00:00Z", + "wizard": { + "topology": "single-host" + } +} +EOF + + if ! persist_stack_site_metadata "${stack_dir}" "single-site" "site-a.local" $'erpnext\ncrm' "create-site" "" "" "2026-04-20T10:00:00Z" "2026-04-20T12:00:00Z" ""; then + false + fi + + expected_metadata="$( + cat <<'EOF' +{ + "schema_version": 1, + "stack_name": "site-metadata-write", + "setup_type": "production", + "frappe_branch": "version-16", + "created_at": "2026-04-20T10:00:00Z", + "wizard": { + "topology": "single-host" + }, + "site": { + "mode": "single-site", + "name": "site-a.local", + "apps_installed": [ + "erpnext", + "crm" + ], + "last_action": "create-site", + "last_error": "", + "error_log_path": "", + "created_at": "2026-04-20T10:00:00Z", + "updated_at": "2026-04-20T12:00:00Z", + "last_backup_at": "" + } +} +EOF + )" + + run cat "${stack_dir}/metadata.json" + [ "${status}" -eq 0 ] + [ "${output}" = "${expected_metadata}" ] +} diff --git a/tests/easy-docker/60_compose_render_failures.bats b/tests/easy-docker/60_compose_render_failures.bats index 3845e041..49761fac 100755 --- a/tests/easy-docker/60_compose_render_failures.bats +++ b/tests/easy-docker/60_compose_render_failures.bats @@ -5,6 +5,7 @@ load 'test_helper.bash' setup() { easy_docker_test_begin easy_docker_test_source_core_render_modules + easy_docker_test_install_jq_stub } teardown() { diff --git a/tests/easy-docker/65_apps_jq_migration.bats b/tests/easy-docker/65_apps_jq_migration.bats new file mode 100755 index 00000000..6ab1b423 --- /dev/null +++ b/tests/easy-docker/65_apps_jq_migration.bats @@ -0,0 +1,273 @@ +#!/usr/bin/env bats + +load 'test_helper.bash' + +setup() { + easy_docker_test_begin + easy_docker_test_source_apps_modules + easy_docker_test_install_jq_stub +} + +teardown() { + easy_docker_test_end +} + +write_predefined_apps_catalog() { + local sandbox_root="${1}" + + mkdir -p "${sandbox_root}/scripts/easy-docker/config" + cat >"${sandbox_root}/scripts/easy-docker/config/apps.tsv" <<'EOF' +erpnext ERPNext https://github.com/frappe/erpnext version-16 version-16,version-15 +crm CRM https://github.com/frappe/crm main main,develop +EOF +} + +write_containerfile_fixture() { + local sandbox_root="${1}" + + mkdir -p "${sandbox_root}/images/layered" + cat >"${sandbox_root}/images/layered/Containerfile" <<'EOF' +FROM scratch +EOF +} + +write_stack_metadata_fixture() { + local stack_dir="${1}" + + cat >"${stack_dir}/metadata.json" <<'EOF' +{ + "schema_version": 1, + "stack_name": "my-production-stack", + "setup_type": "production", + "frappe_branch": "version-16", + "created_at": "2026-04-08T16:12:09Z", + "apps": { + "predefined": [ + "erpnext", + "crm" + ], + "predefined_branches": { + "erpnext": "version-16", + "crm": "main" + }, + "custom": [ + { + "repo": "https://github.com/example/custom-app", + "branch": "stable" + } + ] + }, + "wizard": { + "topology": "single-host", + "selection": { + "proxy_mode_id": "traefik-http" + }, + "env": { + "CUSTOM_IMAGE": "production_image", + "CUSTOM_TAG": "v1.0.0" + }, + "compose_files": [ + "compose.yaml", + "overrides/compose.proxy.yaml" + ], + "updated_at": "2026-04-08T16:19:02Z" + } +} +EOF +} + +@test "metadata app readers use jq and keep expected line formats" { + local sandbox_root="" + local stack_dir="" + local expected_custom="" + local expected_branches="" + + sandbox_root="$(easy_docker_test_create_repo_sandbox "apps-reader")" + easy_docker_test_override_repo_root "${sandbox_root}" + stack_dir="$(easy_docker_test_stack_dir "apps-reader")" + mkdir -p "${stack_dir}" + write_stack_metadata_fixture "${stack_dir}" + + expected_custom='https://github.com/example/custom-app|stable' + expected_branches=$'erpnext|version-16\ncrm|main' + + run get_metadata_apps_predefined_csv "${stack_dir}/metadata.json" + [ "${status}" -eq 0 ] + [ "${output}" = "erpnext,crm" ] + + run get_metadata_apps_custom_lines "${stack_dir}/metadata.json" + [ "${status}" -eq 0 ] + [ "${output}" = "${expected_custom}" ] + + run get_metadata_apps_predefined_branch_lines "${stack_dir}/metadata.json" + [ "${status}" -eq 0 ] + [ "${output}" = "${expected_branches}" ] +} + +@test "persist_stack_metadata_apps_object keeps apps before wizard with legacy formatting" { + local sandbox_root="" + local stack_dir="" + local metadata_path="" + local apps_json_object="" + local expected="" + + sandbox_root="$(easy_docker_test_create_repo_sandbox "apps-persist")" + easy_docker_test_override_repo_root "${sandbox_root}" + stack_dir="$(easy_docker_test_stack_dir "apps-persist")" + mkdir -p "${stack_dir}" + metadata_path="${stack_dir}/metadata.json" + + cat >"${metadata_path}" <<'EOF' +{ + "schema_version": 1, + "stack_name": "my-production-stack", + "wizard": { + "topology": "single-host" + } +} +EOF + + build_metadata_apps_json_object apps_json_object "erpnext,crm" $'erpnext|version-16\ncrm|main' "" + + run persist_stack_metadata_apps_object "${stack_dir}" "${apps_json_object}" + [ "${status}" -eq 0 ] + + expected=$'{\n "schema_version": 1,\n "stack_name": "my-production-stack",\n "apps": {\n "predefined": [\n "erpnext",\n "crm"\n ],\n "predefined_branches": {\n "erpnext": "version-16",\n "crm": "main"\n },\n "custom": [\n\n ]\n },\n "wizard": {\n "topology": "single-host"\n }\n}\n' + + run cat "${metadata_path}" + [ "${status}" -eq 0 ] + [ "${output}"$'\n' = "${expected}" ] +} + +@test "persist_stack_metadata_wizard_object preserves existing apps formatting" { + local sandbox_root="" + local stack_dir="" + local metadata_path="" + local wizard_json_object="" + local expected="" + + sandbox_root="$(easy_docker_test_create_repo_sandbox "wizard-persist")" + easy_docker_test_override_repo_root "${sandbox_root}" + stack_dir="$(easy_docker_test_stack_dir "wizard-persist")" + mkdir -p "${stack_dir}" + metadata_path="${stack_dir}/metadata.json" + write_stack_metadata_fixture "${stack_dir}" + + wizard_json_object=$'{\n "topology": "single-host",\n "selection": {\n "proxy_mode_id": "traefik-https"\n },\n "env": {\n "CUSTOM_IMAGE": "production_image",\n "CUSTOM_TAG": "v2.0.0"\n }\n }' + + run persist_stack_metadata_wizard_object "${stack_dir}" "${wizard_json_object}" + [ "${status}" -eq 0 ] + + expected=$'{\n "schema_version": 1,\n "stack_name": "my-production-stack",\n "setup_type": "production",\n "frappe_branch": "version-16",\n "created_at": "2026-04-08T16:12:09Z",\n "apps": {\n "predefined": [\n "erpnext",\n "crm"\n ],\n "predefined_branches": {\n "erpnext": "version-16",\n "crm": "main"\n },\n "custom": [\n {\n "repo": "https://github.com/example/custom-app",\n "branch": "stable"\n }\n ]\n },\n "wizard": {\n "topology": "single-host",\n "selection": {\n "proxy_mode_id": "traefik-https"\n },\n "env": {\n "CUSTOM_IMAGE": "production_image",\n "CUSTOM_TAG": "v2.0.0"\n }\n }\n}\n' + + run cat "${metadata_path}" + [ "${status}" -eq 0 ] + [ "${output}"$'\n' = "${expected}" ] +} + +@test "build_stack_apps_json_content_from_metadata_apps keeps apps.json output format" { + local sandbox_root="" + local stack_dir="" + local apps_json_content="" + local expected="" + + sandbox_root="$(easy_docker_test_create_repo_sandbox "apps-json")" + easy_docker_test_override_repo_root "${sandbox_root}" + write_predefined_apps_catalog "${sandbox_root}" + stack_dir="$(easy_docker_test_stack_dir "apps-json")" + mkdir -p "${stack_dir}" + write_stack_metadata_fixture "${stack_dir}" + + if ! build_stack_apps_json_content_from_metadata_apps apps_json_content "${stack_dir}"; then + false + fi + + expected=$'[\n {"url": "https://github.com/frappe/erpnext", "branch": "version-16"},\n {"url": "https://github.com/frappe/crm", "branch": "main"},\n {"url": "https://github.com/example/custom-app", "branch": "stable"}\n]\n' + + [ "${apps_json_content}" = "${expected}" ] +} + +@test "build_stack_custom_image fails clearly when jq is unavailable" { + local sandbox_root="" + local stack_dir="" + local env_path="" + + sandbox_root="$(easy_docker_test_create_repo_sandbox "build-no-jq")" + easy_docker_test_override_repo_root "${sandbox_root}" + write_predefined_apps_catalog "${sandbox_root}" + write_containerfile_fixture "${sandbox_root}" + stack_dir="$(easy_docker_test_stack_dir "build-no-jq")" + mkdir -p "${stack_dir}" + write_stack_metadata_fixture "${stack_dir}" + env_path="${stack_dir}/build-no-jq.env" + + cat >"${env_path}" <<'EOF' +CUSTOM_IMAGE=production_image +CUSTOM_TAG=v1.0.0 +EOF + + get_easy_docker_jq_command() { + return 1 + } + + run build_stack_custom_image "${stack_dir}" + [ "${status}" -eq 25 ] +} + +@test "build_stack_custom_image parses apps.json with jq before git branch checks" { + local sandbox_root="" + local stack_dir="" + local env_path="" + local git_log="" + local docker_log="" + + sandbox_root="$(easy_docker_test_create_repo_sandbox "build-with-jq")" + easy_docker_test_override_repo_root "${sandbox_root}" + write_predefined_apps_catalog "${sandbox_root}" + write_containerfile_fixture "${sandbox_root}" + stack_dir="$(easy_docker_test_stack_dir "build-with-jq")" + mkdir -p "${stack_dir}" + write_stack_metadata_fixture "${stack_dir}" + env_path="${stack_dir}/my-production-stack.env" + + cat >"${env_path}" <<'EOF' +CUSTOM_IMAGE=production_image +CUSTOM_TAG=v1.0.0 +EOF + + git_log="${EASY_DOCKER_TEST_TMPDIR}/git.log" + docker_log="${EASY_DOCKER_TEST_TMPDIR}/docker.log" + + # shellcheck disable=SC2016 + easy_docker_test_write_bin_command git \ + 'set -euo pipefail' \ + "printf '%s\n' \"git \$*\" >>\"${git_log}\"" \ + 'if [ "${1:-}" = "ls-remote" ]; then' \ + ' exit 0' \ + 'fi' \ + 'exit 64' + + easy_docker_test_write_bin_command docker \ + 'set -euo pipefail' \ + "printf '%s\n' \"docker \$*\" >>\"${docker_log}\"" \ + 'exit 0' + + easy_docker_test_write_bin_command base64 \ + 'set -euo pipefail' \ + 'printf "%s\n" "W3sidXJsIjogImh0dHBzOi8vZXhhbXBsZS5pbnZhbGlkL2FwcCIsICJicmFuY2giOiAidmVyc2lvbi0xNiJ9XQ=="' + + easy_docker_test_prepend_bin_dir + + run build_stack_custom_image "${stack_dir}" + [ "${status}" -eq 0 ] + + run cat "${git_log}" + [ "${status}" -eq 0 ] + [[ "${output}" == *'git ls-remote --exit-code --heads https://github.com/frappe/erpnext version-16'* ]] + [[ "${output}" == *'git ls-remote --exit-code --heads https://github.com/frappe/crm main'* ]] + [[ "${output}" == *'git ls-remote --exit-code --heads https://github.com/example/custom-app stable'* ]] + + run cat "${docker_log}" + [ "${status}" -eq 0 ] + [[ "${output}" == *'docker build -f '* ]] +} diff --git a/tests/easy-docker/test_helper.bash b/tests/easy-docker/test_helper.bash index 4371f2aa..66946327 100755 --- a/tests/easy-docker/test_helper.bash +++ b/tests/easy-docker/test_helper.bash @@ -61,6 +61,8 @@ easy_docker_test_source_common_modules() { source "${repo_root}/scripts/easy-docker/lib/core/commands.sh" # shellcheck source=scripts/easy-docker/lib/core/messages.sh source "${repo_root}/scripts/easy-docker/lib/core/messages.sh" + # shellcheck source=scripts/easy-docker/lib/core/json.sh + source "${repo_root}/scripts/easy-docker/lib/core/json.sh" } easy_docker_test_source_core_render_modules() { @@ -76,6 +78,27 @@ easy_docker_test_source_core_render_modules() { source "${repo_root}/scripts/easy-docker/lib/app/wizard/common/compose/render.sh" } +easy_docker_test_source_apps_modules() { + local repo_root="" + + repo_root="$(easy_docker_test_repo_root)" + + easy_docker_test_source_common_modules + + # shellcheck source=scripts/easy-docker/lib/app/wizard/common/core.sh + source "${repo_root}/scripts/easy-docker/lib/app/wizard/common/core.sh" + # shellcheck source=scripts/easy-docker/lib/app/wizard/common/helpers.sh + source "${repo_root}/scripts/easy-docker/lib/app/wizard/common/helpers.sh" + # shellcheck source=scripts/easy-docker/lib/app/wizard/common/apps/metadata.sh + source "${repo_root}/scripts/easy-docker/lib/app/wizard/common/apps/metadata.sh" + # shellcheck source=scripts/easy-docker/lib/app/wizard/common/apps/persistence.sh + source "${repo_root}/scripts/easy-docker/lib/app/wizard/common/apps/persistence.sh" + # shellcheck source=scripts/easy-docker/lib/app/wizard/common/apps/catalog.sh + source "${repo_root}/scripts/easy-docker/lib/app/wizard/common/apps/catalog.sh" + # shellcheck source=scripts/easy-docker/lib/app/wizard/common/compose/build.sh + source "${repo_root}/scripts/easy-docker/lib/app/wizard/common/compose/build.sh" +} + easy_docker_test_source_docker_modules() { local repo_root="" @@ -87,6 +110,17 @@ easy_docker_test_source_docker_modules() { source "${repo_root}/scripts/easy-docker/lib/checks/docker.sh" } +easy_docker_test_source_jq_modules() { + local repo_root="" + + repo_root="$(easy_docker_test_repo_root)" + + easy_docker_test_source_common_modules + + # shellcheck source=scripts/easy-docker/lib/checks/jq.sh + source "${repo_root}/scripts/easy-docker/lib/checks/jq.sh" +} + easy_docker_test_source_gum_modules() { local repo_root="" @@ -145,3 +179,247 @@ easy_docker_test_install_docker_stub() { easy_docker_test_prepend_bin_dir } + +easy_docker_test_install_jq_stub() { + # shellcheck disable=SC2016 + easy_docker_test_write_bin_command jq \ + 'set -euo pipefail' \ + 'raw_output=0' \ + 'exit_status=0' \ + 'filter_expr=""' \ + 'file_path=""' \ + 'arg_field_name=""' \ + 'arg_key=""' \ + 'arg_app_id=""' \ + 'while [ "$#" -gt 0 ]; do' \ + ' case "${1}" in' \ + ' -r)' \ + ' raw_output=1' \ + ' shift' \ + ' ;;' \ + ' -e)' \ + ' exit_status=1' \ + ' shift' \ + ' ;;' \ + ' --arg)' \ + ' case "${2}" in' \ + ' field_name) arg_field_name="${3}" ;;' \ + ' key) arg_key="${3}" ;;' \ + ' app_id) arg_app_id="${3}" ;;' \ + ' esac' \ + ' shift 3' \ + ' ;;' \ + ' --indent)' \ + ' shift 2' \ + ' ;;' \ + ' -*)' \ + ' shift' \ + ' ;;' \ + ' *)' \ + ' if [ -z "${filter_expr}" ]; then' \ + ' filter_expr="${1}"' \ + ' elif [ -z "${file_path}" ]; then' \ + ' file_path="${1}"' \ + ' else' \ + ' echo "unsupported jq stub arguments" >&2' \ + ' exit 2' \ + ' fi' \ + ' shift' \ + ' ;;' \ + ' esac' \ + 'done' \ + 'if [ -z "${filter_expr}" ]; then' \ + ' echo "missing jq filter" >&2' \ + ' exit 2' \ + 'fi' \ + 'cleanup_file=""' \ + 'if [ -n "${file_path}" ] && [ "${file_path}" != "-" ]; then' \ + ' payload_path="${file_path}"' \ + 'else' \ + ' payload_path="$(mktemp)"' \ + ' cleanup_file="${payload_path}"' \ + ' cat >"${payload_path}"' \ + 'fi' \ + 'jq_stub_cleanup() {' \ + ' if [ -n "${cleanup_file}" ] && [ -f "${cleanup_file}" ]; then' \ + ' rm -f "${cleanup_file}"' \ + ' fi' \ + '}' \ + 'trap jq_stub_cleanup EXIT' \ + 'jq_stub_is_object() {' \ + ' awk '"'"'BEGIN { found=0 } /^[[:space:]]*$/ { next } { if ($0 ~ /^[[:space:]]*{/) found=1; exit } END { exit(found ? 0 : 1) }'"'"' "${payload_path}"' \ + '}' \ + 'jq_stub_first_string_field() {' \ + ' local field_name="${1}"' \ + ' awk -v field_name="${field_name}" '"'"'match($0, "\"" field_name "\"[[:space:]]*:[[:space:]]*\"([^\"]*)\"", parts) { print parts[1]; exit }'"'"' "${payload_path}"' \ + '}' \ + 'jq_stub_array_strings() {' \ + ' local key="${1}"' \ + ' awk -v key="${key}" '"'"'' \ + ' function emit_matches(segment, parts) {' \ + ' while (match(segment, /"([^"]+)"/, parts)) {' \ + ' print parts[1]' \ + ' segment = substr(segment, RSTART + RLENGTH)' \ + ' }' \ + ' }' \ + ' $0 ~ "\"" key "\"[[:space:]]*:[[:space:]]*\\[" {' \ + ' segment = $0' \ + ' sub(/^.*\[[[:space:]]*/, "", segment)' \ + ' emit_matches(segment)' \ + ' if (segment ~ /\]/) {' \ + ' exit' \ + ' }' \ + ' in_array = 1' \ + ' next' \ + ' }' \ + ' in_array {' \ + ' emit_matches($0)' \ + ' if ($0 ~ /\]/) {' \ + ' exit' \ + ' }' \ + ' }' \ + ' '"'"' "${payload_path}"' \ + '}' \ + 'jq_stub_object_entries() {' \ + ' local key="${1}"' \ + ' awk -v key="${key}" '"'"'' \ + ' $0 ~ "\"" key "\"[[:space:]]*:[[:space:]]*\\{" { in_object = 1; next }' \ + ' in_object && /^[[:space:]]*}/ { exit }' \ + ' in_object && match($0, /"([^"]+)"[[:space:]]*:[[:space:]]*"([^"]+)"/, parts) { print parts[1] "|" parts[2] }' \ + ' '"'"' "${payload_path}"' \ + '}' \ + 'jq_stub_lookup_object_value() {' \ + ' local object_key="${1}"' \ + ' local lookup_key="${2}"' \ + ' awk -v object_key="${object_key}" -v lookup_key="${lookup_key}" '"'"'' \ + ' $0 ~ "\"" object_key "\"[[:space:]]*:[[:space:]]*\\{" { in_object = 1; next }' \ + ' in_object && /^[[:space:]]*}/ { exit }' \ + ' in_object && match($0, /"([^"]+)"[[:space:]]*:[[:space:]]*"([^"]+)"/, parts) {' \ + ' if (parts[1] == lookup_key) {' \ + ' print parts[2]' \ + ' exit' \ + ' }' \ + ' }' \ + ' '"'"' "${payload_path}"' \ + '}' \ + 'jq_stub_top_level_keys() {' \ + ' awk '"'"'match($0, /^ "([^"]+)":/, parts) { print parts[1] }'"'"' "${payload_path}"' \ + '}' \ + 'jq_stub_count_delta() {' \ + ' local line="${1}"' \ + ' local opens=0' \ + ' local closes=0' \ + ' local tmp=""' \ + ' tmp="${line//[^\{]/}"' \ + ' opens=$((opens + ${#tmp}))' \ + ' tmp="${line//[^\[]/}"' \ + ' opens=$((opens + ${#tmp}))' \ + ' tmp="${line//[^\}]/}"' \ + ' closes=$((closes + ${#tmp}))' \ + ' tmp="${line//[^\]]/}"' \ + ' closes=$((closes + ${#tmp}))' \ + ' printf "%s\n" "$((opens - closes))"' \ + '}' \ + 'jq_stub_top_level_value() {' \ + ' local key="${1}"' \ + ' local line=""' \ + ' local value=""' \ + ' local in_block=0' \ + ' local depth=0' \ + ' local delta=0' \ + ' while IFS= read -r line || [ -n "${line}" ]; do' \ + ' if [ "${in_block}" -eq 0 ]; then' \ + ' case "${line}" in' \ + ' " \"${key}\":"*)' \ + ' value="${line#*: }"' \ + ' if [[ "${value}" == \{* || "${value}" == \[* ]]; then' \ + ' printf "%s\n" "${value}"' \ + ' depth="$(jq_stub_count_delta "${value}")"' \ + ' if [ "${depth}" -le 0 ]; then' \ + ' return 0' \ + ' fi' \ + ' in_block=1' \ + ' else' \ + ' value="${value%,}"' \ + ' printf "%s\n" "${value}"' \ + ' return 0' \ + ' fi' \ + ' ;;' \ + ' esac' \ + ' else' \ + ' delta="$(jq_stub_count_delta "${line}")"' \ + ' if [ $((depth + delta)) -le 0 ]; then' \ + ' printf "%s\n" "${line%,}"' \ + ' return 0' \ + ' fi' \ + ' printf "%s\n" "${line}"' \ + ' depth=$((depth + delta))' \ + ' fi' \ + ' done <"${payload_path}"' \ + '}' \ + 'jq_stub_apps_custom_lines() {' \ + ' local repo=""' \ + ' local branch=""' \ + ' awk '"'"'' \ + ' /"custom"[[:space:]]*:[[:space:]]*\[/ { in_custom = 1; next }' \ + ' in_custom && /\]/ { exit }' \ + ' in_custom && match($0, /"repo"[[:space:]]*:[[:space:]]*"([^"]+)"/, parts) { repo = parts[1] }' \ + ' in_custom && match($0, /"branch"[[:space:]]*:[[:space:]]*"([^"]+)"/, parts) { branch = parts[1] }' \ + ' in_custom && repo != "" && branch != "" { print repo "|" branch; repo = ""; branch = "" }' \ + ' '"'"' "${payload_path}"' \ + '}' \ + 'jq_stub_apps_json_refs() {' \ + ' awk '"'"'match($0, /"url"[[:space:]]*:[[:space:]]*"([^"]+)".*"branch"[[:space:]]*:[[:space:]]*"([^"]+)"/, parts) { print parts[1] "|" parts[2] }'"'"' "${payload_path}"' \ + '}' \ + 'case "${filter_expr}" in' \ + ' "(.apps.predefined // []) | join(\",\")")' \ + ' output="$(jq_stub_array_strings "predefined" | paste -sd, -)"' \ + ' [ -n "${output}" ] && printf "%s\n" "${output}"' \ + ' ;;' \ + ' "(.apps.custom // [])[]? | select(has(\"repo\") and has(\"branch\")) | \"\\(.repo)|\\(.branch)\"")' \ + ' jq_stub_apps_custom_lines' \ + ' ;;' \ + ' "(.apps.predefined_branches // {}) | to_entries[]? | \"\\(.key)|\\(.value)\"")' \ + ' jq_stub_object_entries "predefined_branches"' \ + ' ;;' \ + ' ".apps.predefined_branches[\$app_id] // empty")' \ + ' jq_stub_lookup_object_value "predefined_branches" "${arg_app_id}"' \ + ' ;;' \ + ' "[.. | objects | .[\$field_name]? | select(type == \"string\")][0] // empty")' \ + ' jq_stub_first_string_field "${arg_field_name}"' \ + ' ;;' \ + ' "([.. | objects | .compose_files? | select(type == \"array\")] | .[0] // [])[]?")' \ + ' jq_stub_array_strings "compose_files"' \ + ' ;;' \ + ' ".site[\$field_name] // empty")' \ + ' jq_stub_first_string_field "${arg_field_name}"' \ + ' ;;' \ + ' "(.site.apps_installed // [])[]? | select(type == \"string\")")' \ + ' jq_stub_array_strings "apps_installed"' \ + ' ;;' \ + ' "type == \"object\"")' \ + ' if jq_stub_is_object; then' \ + ' [ "${exit_status}" -eq 0 ] && printf "true\n"' \ + ' exit 0' \ + ' fi' \ + ' [ "${exit_status}" -eq 1 ] && exit 1' \ + ' printf "false\n"' \ + ' exit 0' \ + ' ;;' \ + ' "keys_unsorted[]")' \ + ' jq_stub_top_level_keys' \ + ' ;;' \ + ' ".[\$key]")' \ + ' jq_stub_top_level_value "${arg_key}"' \ + ' ;;' \ + ' ".[]? | select((.url // \"\") != \"\" and (.branch // \"\") != \"\") | \"\\(.url)|\\(.branch)\"")' \ + ' jq_stub_apps_json_refs' \ + ' ;;' \ + ' *)' \ + ' echo "unsupported jq filter in stub: ${filter_expr}" >&2' \ + ' exit 2' \ + ' ;;' \ + 'esac' + + easy_docker_test_prepend_bin_dir +} From 6e061d8d17dfb5ea2f090d910b31ccc0ac1ce0dd Mon Sep 17 00:00:00 2001 From: RocketQuack <202538874+Rocket-Quack@users.noreply.github.com> Date: Mon, 20 Apr 2026 23:27:48 +0200 Subject: [PATCH 47/51] docs(easy-docker): document jq processing and fallback --- docs/01-getting-started/05-easy-docker.md | 16 ++++++++++++- docs/07-troubleshooting/01-troubleshoot.md | 28 ++++++++++++++++++++++ docs/10-easy-docker/01-overview.md | 25 ++++++++++++++++++- docs/10-easy-docker/02-workflows.md | 6 +++++ docs/10-easy-docker/03-updates.md | 6 +++++ docs/10-easy-docker/index.md | 5 ++++ scripts/easy-docker/README.md | 27 +++++++++++++++++---- 7 files changed, 107 insertions(+), 6 deletions(-) diff --git a/docs/01-getting-started/05-easy-docker.md b/docs/01-getting-started/05-easy-docker.md index e3d5041b..792c9935 100644 --- a/docs/01-getting-started/05-easy-docker.md +++ b/docs/01-getting-started/05-easy-docker.md @@ -21,9 +21,23 @@ Current status: The script entrypoint is: ```bash -bash easy-docker.sh +bash ./easy-docker.sh ``` +Before the wizard opens, `easy-docker` validates its startup dependencies. +Today that means: + +- `gum` +- `docker` +- `docker compose` +- Docker daemon availability +- `jq` + +If `gum` or `jq` is missing, `easy-docker` first tries package-manager +installation and can then fall back to a pinned GitHub binary when the setup is +interactive and fallback is not disabled. On Windows, use a real Bash +environment such as WSL or Git Bash and keep the script path in Bash syntax. + Minimal first use: 1. Start `easy-docker.sh` diff --git a/docs/07-troubleshooting/01-troubleshoot.md b/docs/07-troubleshooting/01-troubleshoot.md index d2254b8c..c7cab849 100644 --- a/docs/07-troubleshooting/01-troubleshoot.md +++ b/docs/07-troubleshooting/01-troubleshoot.md @@ -4,6 +4,7 @@ title: Troubleshoot - [Fixing MariaDB issues after rebuilding the container](#fixing-mariadb-issues-after-rebuilding-the-container) - [docker-compose does not recognize variables from `.env` file](#docker-compose-does-not-recognize-variables-from-env-file) +- [easy-docker dependency checks](#easy-docker-dependency-checks) - [Windows Based Installation](#windows-based-installation) - [Redo installation](#redo-installation) @@ -69,6 +70,33 @@ Note: For MariaDB 10.3 and older use `mysql.user` instead of `mysql.global_priv` If you are using old version of `docker-compose` the .env file needs to be located in directory from where the docker-compose command is executed. There may also be difference in official `docker-compose` and the one packaged by distro. Use `--env-file=.env` if available to explicitly specify the path to file. +### easy-docker dependency checks + +`easy-docker` now validates its startup dependencies before the TUI opens. + +The check order is: + +1. CLI options +2. `gum` +3. `docker` +4. `jq` + +If `gum` or `jq` is missing, the wizard first tries package-manager +installation. If that does not work and the session is interactive, it can then +offer a pinned GitHub binary fallback unless `--no-installation-fallback` is +set. + +If `jq` is still missing after those steps, startup stops with install guidance +instead of continuing into the menus. + +On Windows, pay attention to which Bash runtime you are actually using: + +- `bash` from PowerShell usually means WSL +- Git Bash is a separate runtime +- use Bash path syntax such as `bash ./easy-docker.sh` + +Windows-native Bash setups can use either `jq` or `jq.exe` on `PATH`. + ### Windows Based Installation - Set environment variable `COMPOSE_CONVERT_WINDOWS_PATHS` e.g. `set COMPOSE_CONVERT_WINDOWS_PATHS=1` diff --git a/docs/10-easy-docker/01-overview.md b/docs/10-easy-docker/01-overview.md index b1ee3bbf..1772857b 100644 --- a/docs/10-easy-docker/01-overview.md +++ b/docs/10-easy-docker/01-overview.md @@ -17,6 +17,11 @@ All stack data created by the wizard is written into the repository-local `.easy-docker` directory. That includes the generated stack environment files and the stack-specific metadata used by the workflow. +Internally, the stack JSON contract is now handled through `jq` instead of +line-based `awk` parsing. This is meant to improve robustness against harmless +JSON formatting differences without changing the generated layout of +`metadata.json` or `apps.json`. + This means `easy-docker` is not a closed system. After the setup has been created, you can still inspect the generated files, keep working with them manually, and continue outside the wizard if that fits your workflow better. @@ -63,6 +68,18 @@ To run `easy-docker`, the environment should have: - Docker Compose v2 through `docker compose` - a running Docker daemon - `gum` for the interactive terminal UI +- `jq` for stack JSON processing + +The startup checks happen in this order: + +1. CLI option parsing +2. `gum` +3. `docker` +4. `jq` +5. TUI startup + +That means `--help` exits before dependency checks, while missing dependencies +stop the workflow before the menus open. When `gum` is already installed, the wizard uses it directly. @@ -81,6 +98,12 @@ This means the usual setup flow is: The Docker requirements are also checked on startup so the workflow stops early with guidance instead of failing later in the middle of stack setup. +`jq` now follows the same install strategy as `gum`: `easy-docker` first checks +whether it is already available, then tries the system package manager, and can +finally offer a pinned GitHub binary fallback in interactive sessions. +Runtime resolution accepts either `jq` or `jq.exe`, which keeps Windows-native +Bash setups compatible as long as one of them is on `PATH`. + ## Main Areas ### Stack Creation @@ -210,5 +233,5 @@ the workflow stays consistent when you return to manage it later. Run the wizard from the repository root: ```bash -bash easy-docker.sh +bash ./easy-docker.sh ``` diff --git a/docs/10-easy-docker/02-workflows.md b/docs/10-easy-docker/02-workflows.md index 7b5b72ae..5a9a36fe 100644 --- a/docs/10-easy-docker/02-workflows.md +++ b/docs/10-easy-docker/02-workflows.md @@ -25,6 +25,12 @@ Site app management is intentionally scoped to apps that are already part of the stack image. The wizard does not try to install arbitrary apps that are not part of the selected stack configuration. +Internally, the stack app contract is now handled through `jq` instead of +line-based `awk` parsing. This is intended to keep app selection and branch +update behavior the same while making the JSON processing more robust in the +background. The generated `metadata.json` and `apps.json` files are still meant +to look the same to users. + For the split-services path, see [Split Services](./05-split-services.md). That page explains the intended flow in simple terms and shows where the proxy, application, database, and Redis diff --git a/docs/10-easy-docker/03-updates.md b/docs/10-easy-docker/03-updates.md index 2db70fef..67d22fc1 100644 --- a/docs/10-easy-docker/03-updates.md +++ b/docs/10-easy-docker/03-updates.md @@ -22,5 +22,11 @@ you can see the base version the stack is built against. value on the next start or restart, so the tag change becomes effective once the image has been rebuilt and the stack is restarted. +The update flow still rebuilds the image from a regenerated `apps.json`, and +`metadata.json` remains the source of truth for the stack state. The difference +is internal: stack metadata and generated app state are now processed through +`jq` instead of line-based `awk` parsing. No user-visible change in the file +layout is intended. + For now, this update flow focuses on app branch changes. A separate Frappe base version update flow can be added later without changing the overall model. diff --git a/docs/10-easy-docker/index.md b/docs/10-easy-docker/index.md index ff79c647..767a5091 100644 --- a/docs/10-easy-docker/index.md +++ b/docs/10-easy-docker/index.md @@ -16,6 +16,11 @@ This section documents the current behavior of the wizard: - stack, site, app, and update actions are handled through the wizard - the generated Compose output is available as a rendered snapshot +Before the wizard opens, `easy-docker` validates its startup dependencies. That +includes `gum`, `docker`, `docker compose`, a running Docker daemon, and `jq`. +`gum` and `jq` can both use package-manager installation and a pinned GitHub +binary fallback. `docker` still must already be present. + Start here: - [Overview](./01-overview.md) diff --git a/scripts/easy-docker/README.md b/scripts/easy-docker/README.md index 5b33bb9b..3972cc59 100644 --- a/scripts/easy-docker/README.md +++ b/scripts/easy-docker/README.md @@ -5,26 +5,45 @@ Easy installation script for Frappe Docker for development and production ## Run ```bash -bash easy-docker.sh +bash ./easy-docker.sh ``` +Run the entrypoint from a real Bash environment. + +- On Linux, use your normal shell session. +- On Windows, use WSL or Git Bash. +- If you start `bash` from PowerShell, that usually means WSL, so keep the path + in Bash form such as `bash ./easy-docker.sh`, not `bash .\easy-docker.sh`. + ## Dependencies - `gum` is used for the TUI and is installed automatically when possible - `docker` CLI is required and checked on startup - `docker compose` (Compose v2 command) is required and checked on startup +- `jq` is required for stack JSON handling and is checked on startup - Docker Desktop includes Compose v2 by default; on Linux Engine-only setups you may need the `docker-compose-plugin` package - Docker daemon must be running before the TUI starts - Required docker commands are validated (`docker ps/exec/inspect/cp` and `docker compose config/up/down/logs/exec/pull/ps`) -- If package manager installation for `gum` fails, the script can use a pinned GitHub binary fallback -- The GitHub fallback is pinned to `gum` `v0.17.0` and verifies SHA256 checksums from `scripts/easy-docker/config/gum-checksums.tsv` +- Startup validation order is: CLI options, `gum`, `docker`, then `jq` +- If package manager installation for `gum` or `jq` fails, the script can use a pinned GitHub binary fallback +- The `gum` fallback is pinned to `gum` `v0.17.0` and verifies SHA256 checksums from `scripts/easy-docker/config/gum-checksums.tsv` +- The `jq` fallback is pinned to `jq` `1.8.1` and verifies SHA256 checksums from `scripts/easy-docker/config/jq-checksums.tsv` +- `docker` still has no installation fallback path and must already be present +- Runtime `jq` resolution accepts either `jq` or `jq.exe`, so Windows-native setups with only `jq.exe` on `PATH` are supported + +## JSON Handling + +- `metadata.json` remains the source of truth for stack state +- `apps.json` is still generated from stack metadata and still used for the image build +- `easy-docker` now reads and writes stack JSON through `jq` instead of line-based `awk` parsing +- This is an internal robustness change only; the generated layout of `metadata.json` and `apps.json` is intended to stay the same for users ## Options - `-h`, `--help` - Shows usage and exits without starting the TUI - `--no-installation-fallback` - - Disables GitHub binary fallback for `gum` + - Disables GitHub binary fallback prompts for `gum` and `jq` - If package manager installation fails, the script exits with manual installation guidance ## Apps Catalog From 0a4b85f4018ff4922cb742521cfe3ca1e1705e61 Mon Sep 17 00:00:00 2001 From: RocketQuack <202538874+Rocket-Quack@users.noreply.github.com> Date: Mon, 20 Apr 2026 23:36:34 +0200 Subject: [PATCH 48/51] fix(easy-docker): pass apps.json as a build secret --- .../lib/app/wizard/common/compose/build.sh | 12 +----------- .../easy-docker/lib/app/wizard/flows/manage/build.sh | 6 ------ tests/easy-docker/65_apps_jq_migration.bats | 7 ++----- 3 files changed, 3 insertions(+), 22 deletions(-) diff --git a/scripts/easy-docker/lib/app/wizard/common/compose/build.sh b/scripts/easy-docker/lib/app/wizard/common/compose/build.sh index 803f3cd4..0fa2dd06 100755 --- a/scripts/easy-docker/lib/app/wizard/common/compose/build.sh +++ b/scripts/easy-docker/lib/app/wizard/common/compose/build.sh @@ -11,7 +11,6 @@ build_stack_custom_image() { local frappe_path="https://github.com/frappe/frappe" local repo_root="" local containerfile_path="" - local apps_json_base64="" local apps_refs_lines="" local app_ref_line="" local app_url="" @@ -89,15 +88,6 @@ build_stack_custom_image() { ${apps_refs_lines} EOF - if ! command_exists base64; then - return 18 - fi - - apps_json_base64="$(base64 "${apps_json_path}" | tr -d '\r\n')" - if [ -z "${apps_json_base64}" ]; then - return 19 - fi - repo_root="$(get_easy_docker_repo_root)" containerfile_path="${repo_root}/images/layered/Containerfile" if [ ! -f "${containerfile_path}" ]; then @@ -110,7 +100,7 @@ EOF -f "${containerfile_path}" \ --build-arg "FRAPPE_BRANCH=${frappe_branch}" \ --build-arg "FRAPPE_PATH=${frappe_path}" \ - --build-arg "APPS_JSON_BASE64=${apps_json_base64}" \ + --secret "id=apps_json,src=${apps_json_path}" \ -t "${image_ref}" \ "${repo_root}" || return 21 diff --git a/scripts/easy-docker/lib/app/wizard/flows/manage/build.sh b/scripts/easy-docker/lib/app/wizard/flows/manage/build.sh index b88ab426..3c424b41 100755 --- a/scripts/easy-docker/lib/app/wizard/flows/manage/build.sh +++ b/scripts/easy-docker/lib/app/wizard/flows/manage/build.sh @@ -51,12 +51,6 @@ run_build_stack_custom_image_with_feedback() { 17) show_warning_and_wait "Custom image build failed: apps.json not found after generation." 4 ;; - 18) - show_warning_and_wait "Custom image build failed: base64 command is not available in this environment." 4 - ;; - 19) - show_warning_and_wait "Custom image build failed: apps.json could not be base64-encoded." 4 - ;; 20) show_warning_and_wait "Custom image build failed: images/layered/Containerfile not found." 4 ;; diff --git a/tests/easy-docker/65_apps_jq_migration.bats b/tests/easy-docker/65_apps_jq_migration.bats index 6ab1b423..3a8205b0 100755 --- a/tests/easy-docker/65_apps_jq_migration.bats +++ b/tests/easy-docker/65_apps_jq_migration.bats @@ -214,7 +214,7 @@ EOF [ "${status}" -eq 25 ] } -@test "build_stack_custom_image parses apps.json with jq before git branch checks" { +@test "build_stack_custom_image parses apps.json with jq before git branch checks and passes apps.json as a build secret" { local sandbox_root="" local stack_dir="" local env_path="" @@ -252,10 +252,6 @@ EOF "printf '%s\n' \"docker \$*\" >>\"${docker_log}\"" \ 'exit 0' - easy_docker_test_write_bin_command base64 \ - 'set -euo pipefail' \ - 'printf "%s\n" "W3sidXJsIjogImh0dHBzOi8vZXhhbXBsZS5pbnZhbGlkL2FwcCIsICJicmFuY2giOiAidmVyc2lvbi0xNiJ9XQ=="' - easy_docker_test_prepend_bin_dir run build_stack_custom_image "${stack_dir}" @@ -270,4 +266,5 @@ EOF run cat "${docker_log}" [ "${status}" -eq 0 ] [[ "${output}" == *'docker build -f '* ]] + [[ "${output}" == *"--secret id=apps_json,src=${stack_dir}/apps.json"* ]] } From ae75ca56e713228a839214843d344e71c11fe289 Mon Sep 17 00:00:00 2001 From: RocketQuack <202538874+Rocket-Quack@users.noreply.github.com> Date: Fri, 24 Apr 2026 12:45:24 +0200 Subject: [PATCH 49/51] fix(easy-docker): handle missing terminal capabilities quietly --- scripts/easy-docker/lib/app/screen.sh | 40 ++++++++++++++---- tests/easy-docker/25_screen_terminal.bats | 49 +++++++++++++++++++++++ tests/easy-docker/test_helper.bash | 20 +++++++++ 3 files changed, 102 insertions(+), 7 deletions(-) create mode 100755 tests/easy-docker/25_screen_terminal.bats diff --git a/scripts/easy-docker/lib/app/screen.sh b/scripts/easy-docker/lib/app/screen.sh index ce293994..8a6d1344 100755 --- a/scripts/easy-docker/lib/app/screen.sh +++ b/scripts/easy-docker/lib/app/screen.sh @@ -1,19 +1,45 @@ #!/usr/bin/env bash ALT_SCREEN_ACTIVE=0 +CURSOR_HIDDEN=0 + +stdout_is_terminal() { + [ -t 1 ] +} + +run_tput_quietly() { + tput "$@" 2>/dev/null +} enter_alt_screen() { - if [ -t 1 ] && command_exists tput; then - tput smcup || true - tput civis || true + if ! stdout_is_terminal || ! command_exists tput; then + return 0 + fi + + if run_tput_quietly smcup; then ALT_SCREEN_ACTIVE=1 fi + + if run_tput_quietly civis; then + CURSOR_HIDDEN=1 + fi + + return 0 } leave_alt_screen() { - if [ "${ALT_SCREEN_ACTIVE}" = "1" ] && command_exists tput; then - tput cnorm || true - tput rmcup || true - ALT_SCREEN_ACTIVE=0 + if command_exists tput; then + if [ "${CURSOR_HIDDEN}" = "1" ]; then + run_tput_quietly cnorm || true + fi + + if [ "${ALT_SCREEN_ACTIVE}" = "1" ]; then + run_tput_quietly rmcup || true + fi fi + + CURSOR_HIDDEN=0 + ALT_SCREEN_ACTIVE=0 + + return 0 } diff --git a/tests/easy-docker/25_screen_terminal.bats b/tests/easy-docker/25_screen_terminal.bats new file mode 100755 index 00000000..57ce2775 --- /dev/null +++ b/tests/easy-docker/25_screen_terminal.bats @@ -0,0 +1,49 @@ +#!/usr/bin/env bats + +load 'test_helper.bash' + +setup() { + easy_docker_test_begin + easy_docker_test_source_screen_modules_with_tty_stdout +} + +teardown() { + easy_docker_test_end +} + +@test "enter_alt_screen suppresses tput stderr when terminfo is unavailable" { + easy_docker_test_write_bin_command tput \ + 'echo '"'"'tput: unknown terminal "xterm-256color"'"'"' >&2' \ + 'exit 1' + easy_docker_test_prepend_bin_dir + + run enter_alt_screen + [ "${status}" -eq 0 ] + [ -z "${output}" ] +} + +@test "enter_alt_screen and leave_alt_screen track successful terminal state" { + local log_file="" + local expected_log="" + + log_file="${EASY_DOCKER_TEST_TMPDIR}/tput.log" + + easy_docker_test_write_bin_command tput \ + "printf '%s\\n' \"\${1:-}\" >>\"${log_file}\"" \ + 'exit 0' + easy_docker_test_prepend_bin_dir + + enter_alt_screen + [ "${ALT_SCREEN_ACTIVE}" = "1" ] + [ "${CURSOR_HIDDEN}" = "1" ] + + leave_alt_screen + [ "${ALT_SCREEN_ACTIVE}" = "0" ] + [ "${CURSOR_HIDDEN}" = "0" ] + + expected_log=$'smcup\ncivis\ncnorm\nrmcup' + + run cat "${log_file}" + [ "${status}" -eq 0 ] + [ "${output}" = "${expected_log}" ] +} diff --git a/tests/easy-docker/test_helper.bash b/tests/easy-docker/test_helper.bash index 66946327..5d5cb6f5 100755 --- a/tests/easy-docker/test_helper.bash +++ b/tests/easy-docker/test_helper.bash @@ -136,6 +136,26 @@ easy_docker_test_source_gum_modules() { source "${repo_root}/scripts/easy-docker/lib/install/gum/ensure.sh" } +easy_docker_test_source_screen_modules() { + local repo_root="" + + repo_root="$(easy_docker_test_repo_root)" + + easy_docker_test_source_common_modules + + # shellcheck source=scripts/easy-docker/lib/app/screen.sh + source "${repo_root}/scripts/easy-docker/lib/app/screen.sh" +} + +easy_docker_test_source_screen_modules_with_tty_stdout() { + easy_docker_test_source_screen_modules + + # shellcheck disable=SC2317 + stdout_is_terminal() { + return 0 + } +} + easy_docker_test_create_repo_sandbox() { local sandbox_name="${1}" local sandbox_root="" From 9f36345fdcf87b11a7ccd14afe583cc869340504 Mon Sep 17 00:00:00 2001 From: RocketQuack <202538874+Rocket-Quack@users.noreply.github.com> Date: Fri, 24 Apr 2026 12:46:30 +0200 Subject: [PATCH 50/51] ci(easy-docker): test bats across ubuntu debian and fedora --- .github/workflows/easy-docker.yml | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/.github/workflows/easy-docker.yml b/.github/workflows/easy-docker.yml index 2d55a23f..d54b3248 100644 --- a/.github/workflows/easy-docker.yml +++ b/.github/workflows/easy-docker.yml @@ -20,10 +20,36 @@ on: jobs: bats: + name: Bats (${{ matrix.name }}) runs-on: ubuntu-latest timeout-minutes: 10 + strategy: + fail-fast: false + matrix: + include: + - name: Ubuntu 24.04 + image: ubuntu:24.04 + install_cmd: | + apt-get update + DEBIAN_FRONTEND=noninteractive apt-get install -y bash ca-certificates curl git gzip tar ncurses-bin + - name: Debian 12 + image: debian:12 + install_cmd: | + apt-get update + DEBIAN_FRONTEND=noninteractive apt-get install -y bash ca-certificates curl git gzip tar ncurses-bin + - name: Fedora 43 + image: fedora:43 + install_cmd: | + dnf install -y bash ca-certificates curl git gzip tar ncurses + container: + image: ${{ matrix.image }} + env: + TERM: xterm-256color steps: + - name: Install distro dependencies + run: ${{ matrix.install_cmd }} + - name: Checkout uses: actions/checkout@v6 @@ -32,7 +58,7 @@ jobs: BATS_VERSION="v1.11.1" curl -fsSL "https://github.com/bats-core/bats-core/archive/refs/tags/${BATS_VERSION}.tar.gz" -o bats-core.tar.gz tar -xzf bats-core.tar.gz - sudo "./bats-core-${BATS_VERSION#v}/install.sh" /usr/local + "./bats-core-${BATS_VERSION#v}/install.sh" /usr/local - name: Run easy-docker Bats tests run: bats --recursive tests/easy-docker From 15aab8bdaa85081d2856a8a47ff60454cb521b98 Mon Sep 17 00:00:00 2001 From: RocketQuack <202538874+Rocket-Quack@users.noreply.github.com> Date: Fri, 24 Apr 2026 12:57:39 +0200 Subject: [PATCH 51/51] ci(easy-docker): install gawk for cross-distro bats runs --- .github/workflows/easy-docker.yml | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/.github/workflows/easy-docker.yml b/.github/workflows/easy-docker.yml index d54b3248..c33a3ef1 100644 --- a/.github/workflows/easy-docker.yml +++ b/.github/workflows/easy-docker.yml @@ -31,16 +31,18 @@ jobs: image: ubuntu:24.04 install_cmd: | apt-get update - DEBIAN_FRONTEND=noninteractive apt-get install -y bash ca-certificates curl git gzip tar ncurses-bin + DEBIAN_FRONTEND=noninteractive apt-get install -y bash ca-certificates curl gawk git gzip tar ncurses-bin + update-alternatives --set awk /usr/bin/gawk - name: Debian 12 image: debian:12 install_cmd: | apt-get update - DEBIAN_FRONTEND=noninteractive apt-get install -y bash ca-certificates curl git gzip tar ncurses-bin + DEBIAN_FRONTEND=noninteractive apt-get install -y bash ca-certificates curl gawk git gzip tar ncurses-bin + update-alternatives --set awk /usr/bin/gawk - name: Fedora 43 image: fedora:43 install_cmd: | - dnf install -y bash ca-certificates curl git gzip tar ncurses + dnf install -y bash ca-certificates curl gawk git gzip tar ncurses container: image: ${{ matrix.image }} env: