diff --git a/lib/bash/app/README.md b/lib/bash/app/README.md index abaa14f..826fdb0 100644 --- a/lib/bash/app/README.md +++ b/lib/bash/app/README.md @@ -30,6 +30,11 @@ Backslashes, tabs, carriage returns, and newlines in fields are escaped as `\\`, `\\t`, `\\r`, and `\\n` so each record remains one safe line. `base_app_config_set_cli` is a programmatic equivalent of `--cli key=value`. +`base_app_config_load` validates the complete candidate configuration before +publishing it. If loading fails, the previously successful values and +provenance remain unchanged; a successful load atomically replaces the +model's complete effective snapshot. + Supported types are `string`, `path`, `bool`, `integer`, and `enum`. Optional `validator=FUNCTION` callbacks receive the candidate value and must return zero. No configuration value is evaluated as shell code. diff --git a/lib/bash/app/lib_app.sh b/lib/bash/app/lib_app.sh index 06f468b..3bc7b80 100644 --- a/lib/bash/app/lib_app.sh +++ b/lib/bash/app/lib_app.sh @@ -25,6 +25,8 @@ declare -gA __base_bash_libs_app_config=() declare -gA __base_bash_libs_app_hooks=() declare -gA __base_bash_libs_app_values=() declare -gA __base_bash_libs_app_provenance=() +declare -gA __base_bash_libs_app_staged_values=() +declare -gA __base_bash_libs_app_staged_provenance=() declare -gA __base_bash_libs_app_cli=() declare -ga __base_bash_libs_app_keys=() declare -ga __base_bash_libs_app_hook_names=() @@ -165,11 +167,16 @@ __base_bash_libs_app_validate_value__() { fi } +__base_bash_libs_app_clear_staged_config__() { + __base_bash_libs_app_staged_values=() + __base_bash_libs_app_staged_provenance=() +} + __base_bash_libs_app_set_value__() { local model="$1" key="$2" value="$3" source="$4" __base_bash_libs_app_validate_value__ "$model" "$key" "$value" || return $? - __base_bash_libs_app_values["$model|$key"]="$value" - __base_bash_libs_app_provenance["$model|$key"]="$source" + __base_bash_libs_app_staged_values["$model|$key"]="$value" + __base_bash_libs_app_staged_provenance["$model|$key"]="$source" } __base_bash_libs_app_set_file_values__() { @@ -273,6 +280,12 @@ base_app_init() { for key in "${!__base_bash_libs_app_provenance[@]}"; do [[ "$key" == "$model|"* ]] && unset "__base_bash_libs_app_provenance[$key]" done + for key in "${!__base_bash_libs_app_staged_values[@]}"; do + [[ "$key" == "$model|"* ]] && unset "__base_bash_libs_app_staged_values[$key]" + done + for key in "${!__base_bash_libs_app_staged_provenance[@]}"; do + [[ "$key" == "$model|"* ]] && unset "__base_bash_libs_app_staged_provenance[$key]" + done for key in "${!__base_bash_libs_app_cli[@]}"; do [[ "$key" == "$model|"* ]] && unset "__base_bash_libs_app_cli[$key]" done @@ -376,7 +389,7 @@ base_app_config_set_cli() { # base_app_config_load - Applies user, project, environment, and CLI values. # Precedence is CLI > environment > project > user > default. base_app_config_load() { - local model="${1-}" argument project_file="" user_file="" key value_key env_name value + local model="${1-}" argument project_file="" user_file="" key value_key env_name value status local -a cli_pairs=() local parse_options=1 @@ -385,6 +398,7 @@ base_app_config_load() { return 2 } __base_bash_libs_app_model_exists__ "$model" || return 1 + __base_bash_libs_app_clear_staged_config__ shift while (($#)); do argument="$1" @@ -396,6 +410,7 @@ base_app_config_load() { if ((parse_options)) && [[ "$argument" == --project || "$argument" == --config ]]; then (($# > 0)) || { __base_bash_libs_app_error__ "$argument requires a file." + __base_bash_libs_app_clear_staged_config__ return 2 } project_file="$1" @@ -405,6 +420,7 @@ base_app_config_load() { if ((parse_options)) && [[ "$argument" == --user ]]; then (($# > 0)) || { __base_bash_libs_app_error__ '--user requires a file.' + __base_bash_libs_app_clear_staged_config__ return 2 } user_file="$1" @@ -414,6 +430,7 @@ base_app_config_load() { if ((parse_options)) && [[ "$argument" == --cli ]]; then (($# > 0)) || { __base_bash_libs_app_error__ '--cli requires key=value.' + __base_bash_libs_app_clear_staged_config__ return 2 } cli_pairs+=("$1") @@ -421,48 +438,92 @@ base_app_config_load() { continue fi __base_bash_libs_app_error__ "unknown configuration load argument '$argument'." + __base_bash_libs_app_clear_staged_config__ return 2 done IFS=, read -r -a __base_bash_libs_app_keys <<< "${__base_bash_libs_app_models["$model|config-keys"]-}" for key in "${__base_bash_libs_app_keys[@]+${__base_bash_libs_app_keys[@]}}"; do - value_key="$model|$key" - unset "__base_bash_libs_app_values[$value_key]" "__base_bash_libs_app_provenance[$value_key]" if [[ -n "${__base_bash_libs_app_config["$model|$key|default"]+set}" ]]; then - __base_bash_libs_app_set_value__ "$model" "$key" "${__base_bash_libs_app_config["$model|$key|default"]}" default || return $? + __base_bash_libs_app_set_value__ "$model" "$key" "${__base_bash_libs_app_config["$model|$key|default"]}" default || { + status=$? + __base_bash_libs_app_clear_staged_config__ + return "$status" + } fi done - [[ -z "$user_file" ]] || __base_bash_libs_app_set_file_values__ "$model" "$user_file" user || return $? - [[ -z "$project_file" ]] || __base_bash_libs_app_set_file_values__ "$model" "$project_file" project || return $? + if [[ -n "$user_file" ]]; then + __base_bash_libs_app_set_file_values__ "$model" "$user_file" user || { + status=$? + __base_bash_libs_app_clear_staged_config__ + return "$status" + } + fi + if [[ -n "$project_file" ]]; then + __base_bash_libs_app_set_file_values__ "$model" "$project_file" project || { + status=$? + __base_bash_libs_app_clear_staged_config__ + return "$status" + } + fi for key in "${__base_bash_libs_app_keys[@]+${__base_bash_libs_app_keys[@]}}"; do env_name="${__base_bash_libs_app_config["$model|$key|env"]-}" if [[ -n "$env_name" && -n "${!env_name+x}" ]]; then - __base_bash_libs_app_set_value__ "$model" "$key" "${!env_name}" environment || return $? + __base_bash_libs_app_set_value__ "$model" "$key" "${!env_name}" environment || { + status=$? + __base_bash_libs_app_clear_staged_config__ + return "$status" + } fi done for argument in "${cli_pairs[@]+${cli_pairs[@]}}"; do [[ "$argument" == *=* ]] || { __base_bash_libs_app_error__ "CLI configuration '$argument' must use key=value syntax." + __base_bash_libs_app_clear_staged_config__ return 2 } key="${argument%%=*}" value="${argument#*=}" [[ -n "${__base_bash_libs_app_config["$model|$key|type"]+set}" ]] || { __base_bash_libs_app_error__ "CLI configuration contains unknown key '$key'." + __base_bash_libs_app_clear_staged_config__ return 2 } - __base_bash_libs_app_set_value__ "$model" "$key" "$value" cli || return $? + __base_bash_libs_app_set_value__ "$model" "$key" "$value" cli || { + status=$? + __base_bash_libs_app_clear_staged_config__ + return "$status" + } done for key in "${__base_bash_libs_app_keys[@]+${__base_bash_libs_app_keys[@]}}"; do if [[ -n "${__base_bash_libs_app_cli["$model|$key"]+set}" ]]; then - __base_bash_libs_app_set_value__ "$model" "$key" "${__base_bash_libs_app_cli["$model|$key"]}" cli || return $? + __base_bash_libs_app_set_value__ "$model" "$key" "${__base_bash_libs_app_cli["$model|$key"]}" cli || { + status=$? + __base_bash_libs_app_clear_staged_config__ + return "$status" + } fi - if [[ -z "${__base_bash_libs_app_provenance["$model|$key"]-}" ]] && + if [[ -z "${__base_bash_libs_app_staged_provenance["$model|$key"]-}" ]] && __base_bash_libs_app_bool_true__ "${__base_bash_libs_app_config["$model|$key|required"]-false}"; then __base_bash_libs_app_error__ "required configuration '$key' was not provided." + __base_bash_libs_app_clear_staged_config__ return 2 fi done + + for value_key in "${!__base_bash_libs_app_values[@]}"; do + [[ "$value_key" == "$model|"* ]] && unset "__base_bash_libs_app_values[$value_key]" + done + for value_key in "${!__base_bash_libs_app_provenance[@]}"; do + [[ "$value_key" == "$model|"* ]] && unset "__base_bash_libs_app_provenance[$value_key]" + done + for value_key in "${!__base_bash_libs_app_staged_values[@]}"; do + __base_bash_libs_app_values["$value_key"]="${__base_bash_libs_app_staged_values["$value_key"]}" + done + for value_key in "${!__base_bash_libs_app_staged_provenance[@]}"; do + __base_bash_libs_app_provenance["$value_key"]="${__base_bash_libs_app_staged_provenance["$value_key"]}" + done + __base_bash_libs_app_clear_staged_config__ return 0 } diff --git a/lib/bash/app/tests/lib_app.bats b/lib/bash/app/tests/lib_app.bats index 3674785..d6b3f10 100644 --- a/lib/bash/app/tests/lib_app.bats +++ b/lib/bash/app/tests/lib_app.bats @@ -10,10 +10,32 @@ setup() { unset APP_TEST_MODE APP_TEST_SECRET } +validate_test_label() { + [[ "$1" == valid ]] +} + declare_test_config() { base_app_init demo name=demo base_app_config_define demo mode enum enum=dev,prod default=dev env=APP_TEST_MODE base_app_config_define demo secret string required=true secret=true env=APP_TEST_SECRET + base_app_config_define demo label string default=valid validator=validate_test_label +} + +assert_demo_snapshot() { + local value source + + base_app_config_get demo mode value + [ "$value" = prod ] + base_app_config_provenance demo mode source + [ "$source" = environment ] + base_app_config_get demo secret value + [ "$value" = old-secret ] + base_app_config_provenance demo secret source + [ "$source" = environment ] + base_app_config_get demo label value + [ "$value" = valid ] + base_app_config_provenance demo label source + [ "$source" = default ] } @test "lib_app requires stdlib and loads cli from the package" { @@ -58,6 +80,57 @@ declare_test_config() { ! grep -Eq '(^|[[:space:];])eval([[:space:];]|$)' "$BASE_BASH_DIR/app/lib_app.sh" } +@test "failed configuration loads preserve the last successful snapshot" { + local user_file="$TEST_TMPDIR/user.conf" project_file="$TEST_TMPDIR/project.conf" + declare_test_config + export APP_TEST_MODE=prod APP_TEST_SECRET=old-secret + base_app_config_load demo + + printf 'mode=dev\nnot-a-record\n' >"$user_file" + bats_run base_app_config_load demo --user "$user_file" + [ "$status" -eq 2 ] + assert_demo_snapshot + + printf 'mode=dev\nunknown=value\n' >"$project_file" + bats_run base_app_config_load demo --project "$project_file" + [ "$status" -eq 2 ] + assert_demo_snapshot + + export APP_TEST_MODE=staging + bats_run base_app_config_load demo + [ "$status" -eq 2 ] + assert_demo_snapshot + + export APP_TEST_MODE=prod + bats_run base_app_config_load demo --cli mode=dev --cli unknown=value + [ "$status" -eq 2 ] + assert_demo_snapshot + + bats_run base_app_config_load demo --cli label=invalid + [ "$status" -eq 2 ] + assert_demo_snapshot + + unset APP_TEST_SECRET + bats_run base_app_config_load demo + [ "$status" -eq 2 ] + assert_demo_snapshot + + export APP_TEST_MODE=dev APP_TEST_SECRET=new-secret + base_app_config_load demo --cli label=valid + base_app_config_get demo mode value + [ "$value" = dev ] + base_app_config_provenance demo mode source + [ "$source" = environment ] + base_app_config_get demo secret value + [ "$value" = new-secret ] + base_app_config_provenance demo secret source + [ "$source" = environment ] + base_app_config_get demo label value + [ "$value" = valid ] + base_app_config_provenance demo label source + [ "$source" = cli ] +} + @test "missing required configuration and explicitly requested files fail clearly" { declare_test_config bats_run base_app_config_load demo