Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions lib/bash/app/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
85 changes: 73 additions & 12 deletions lib/bash/app/lib_app.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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=()
Expand Down Expand Up @@ -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__() {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand All @@ -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"
Expand All @@ -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"
Expand All @@ -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"
Expand All @@ -414,55 +430,100 @@ 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")
shift
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
}

Expand Down
73 changes: 73 additions & 0 deletions lib/bash/app/tests/lib_app.bats
Original file line number Diff line number Diff line change
Expand Up @@ -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" {
Expand Down Expand Up @@ -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
Expand Down
Loading