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