refactor(easy-docker): migrate stack json processing to jq

This commit is contained in:
RocketQuack 2026-04-20 23:27:37 +02:00
parent 77a0f9e171
commit 0753037544
13 changed files with 1002 additions and 304 deletions

View file

@ -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 <<EOF
${branch_lines}
EOF
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 <<EOF
${custom_apps_lines}
EOF
printf -v "${result_var}" '{\n "predefined": [\n%s\n ],\n "predefined_branches": {\n%s\n },\n "custom": [\n%s\n ]\n }' "${predefined_json_entries}" "${branch_json_entries}" "${custom_json_entries}"
}
render_metadata_apps_json_object_from_metadata() {
local result_var="${1}"
local metadata_path="${2}"
local predefined_csv=""
local branch_lines=""
local custom_lines=""
local apps_json_object=""
predefined_csv="$(get_metadata_apps_predefined_csv "${metadata_path}" || true)"
branch_lines="$(get_metadata_apps_predefined_branch_lines "${metadata_path}" || true)"
custom_lines="$(get_metadata_apps_custom_lines "${metadata_path}" || true)"
build_metadata_apps_json_object apps_json_object "${predefined_csv}" "${branch_lines}" "${custom_lines}"
printf -v "${result_var}" "%s" "${apps_json_object}"
}
build_stack_apps_json_content_from_metadata_apps() {
@ -170,6 +205,10 @@ build_stack_apps_json_content_from_metadata_apps() {
return 1
fi
if ! easy_docker_require_jq; 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)"

View file

@ -1,5 +1,113 @@
#!/usr/bin/env bash
render_stack_metadata_top_level_entry_from_json_file() {
local metadata_path="${1}"
local metadata_key="${2}"
local entry_value=""
local rendered_entry=""
local line=""
local delta=0
local depth=0
local started=0
while IFS= read -r line || [ -n "${line}" ]; do
if [ "${started}" -eq 0 ]; then
case "${line}" in
" \"${metadata_key}\":"*)
entry_value="${line# \""${metadata_key}"\": }"
entry_value="${entry_value%,}"
if [[ "${entry_value}" == \{* || "${entry_value}" == \[* ]]; then
rendered_entry=" \"${metadata_key}\": ${entry_value}"
started=1
delta="$(count_stack_metadata_json_structure_delta "${entry_value}")"
depth=$((depth + delta))
if [ "${depth}" -le 0 ]; then
printf '%s' "${rendered_entry}"
return 0
fi
else
printf ' "%s": %s' "${metadata_key}" "${entry_value}"
return 0
fi
;;
esac
continue
fi
delta="$(count_stack_metadata_json_structure_delta "${line}")"
if [ $((depth + delta)) -le 0 ]; then
rendered_entry="${rendered_entry}"$'\n'"${line%,}"
printf '%s' "${rendered_entry}"
return 0
fi
rendered_entry="${rendered_entry}"$'\n'"${line}"
depth=$((depth + delta))
done <"${metadata_path}"
return 1
}
count_stack_metadata_json_structure_delta() {
local line="${1}"
local opens=0
local closes=0
local matches=""
matches="${line//[^\{]/}"
opens=$((opens + ${#matches}))
matches="${line//[^\[]/}"
opens=$((opens + ${#matches}))
matches="${line//[^\}]/}"
closes=$((closes + ${#matches}))
matches="${line//[^\]]/}"
closes=$((closes + ${#matches}))
printf '%s\n' "$((opens - closes))"
}
build_stack_metadata_top_level_object_content() {
local result_var="${1}"
local metadata_path="${2}"
local object_key="${3}"
local object_json="${4}"
shift 4
local rendered_metadata=""
local entry_json=""
local metadata_key=""
local index=0
local total_keys=0
local -a ordered_keys=("$@")
total_keys="${#ordered_keys[@]}"
rendered_metadata="{"
if [ "${total_keys}" -gt 0 ]; then
rendered_metadata="${rendered_metadata}"$'\n'
fi
for index in "${!ordered_keys[@]}"; do
metadata_key="${ordered_keys[${index}]}"
if [ "${metadata_key}" = "${object_key}" ]; then
entry_json="$(printf ' "%s": %s' "${metadata_key}" "${object_json}")"
else
entry_json="$(render_stack_metadata_top_level_entry_from_json_file "${metadata_path}" "${metadata_key}")" || return 1
fi
rendered_metadata="${rendered_metadata}${entry_json}"
if [ "${index}" -lt $((total_keys - 1)) ]; then
rendered_metadata="${rendered_metadata},"
fi
rendered_metadata="${rendered_metadata}"$'\n'
done
rendered_metadata="${rendered_metadata}}"$'\n'
if ! printf '%s' "${rendered_metadata}" | easy_docker_run_jq -e 'type == "object"' >/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

View file

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

View file

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

View file

@ -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() {

View file

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

View file

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

View file

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

View file

@ -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() {

View file

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

View file

@ -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() {

View file

@ -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 '* ]]
}

View file

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