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 +}