The app-scoped Tasks tab never sorted. It rendered straight from tasksManager.tasks and relied on loadTasks() having ordered it, so any path that appends after the load — a task arriving from the event bus, a retry, a queue merge — put that task wherever it happened to land rather than at the top. Sort where the list is rendered instead of trusting it from three callers away. Honest note on the reported symptom: a list_users task appearing mid-list could not be reproduced from the stored records — replaying the sort over all 96 task files puts the newest tool tasks first. What is demonstrably wrong is the missing sort above, and a second latent fault it would mask: 8 of those 96 records carry a null createdAt (cron-created backups), and `new Date(null)` is the epoch, so they sort as if from 1970 rather than as unknown. Adds window.taskSortTime for that: createdAt when it parses, otherwise the timestamp already embedded in the task id — the WebUI mints task_<epoch_ms>_<rand> and the backend task_<epoch_s>_<hex>, distinguishable by digit count. All three sorts now use it, so the global list, the app list and the loader agree. The filter bar is client-side over the already-loaded per-app array, so it is instant and needs no reload: status chips (built from the statuses actually present, with counts, so a chip can never return zero) plus a search over the command and the task id — the id being what a deep link and a log URL both carry, so pasting one finds it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
308 lines
12 KiB
Bash
308 lines
12 KiB
Bash
#!/bin/bash
|
|
|
|
# Configuration validation.
|
|
# ---------------------------------------------------------------------------
|
|
# Every check here exists because the bug it catches shipped at least once, and
|
|
# none of them announced themselves at runtime — a wrong config key does not
|
|
# crash anything, it just quietly stops working:
|
|
#
|
|
# * two keys sharing one RANDOMIZED<n> placeholder produced the SAME secret
|
|
# for Gitea's metrics token and its admin password
|
|
# * an auth adapter writing CFG_<APP>_ADMIN_PASSWORD while the config declared
|
|
# ADMIN_PASSWORD_1 made every password reset a silent no-op
|
|
# * a compose annotation whose value did not match the line body meant the tag
|
|
# never substituted, and Mastodon shipped its placeholder as a live password
|
|
#
|
|
# So the rule is: fail loudly here, in a command someone can run, rather than
|
|
# discover it months later.
|
|
#
|
|
# Checks run against the deployed install where it exists and fall back to the
|
|
# install templates, because both can be wrong in different ways: a template
|
|
# carries placeholders, a deployed file carries the substituted values.
|
|
|
|
# Collected during a run.
|
|
_lpv_issues=0
|
|
_lpv_checked=0
|
|
|
|
_lpvFail() { isError " $*"; ((_lpv_issues++)); }
|
|
_lpvWarn() { isNotice " $*"; }
|
|
|
|
# Resolve an app's config / compose, preferring the deployed copy.
|
|
_lpvAppFiles()
|
|
{
|
|
local app="$1"
|
|
_lpv_cfg_live="${containers_dir}${app}/${app}.config"
|
|
_lpv_cfg_tmpl="${install_containers_dir}${app}/${app}.config"
|
|
_lpv_comp_live="${containers_dir}${app}/docker-compose.yml"
|
|
_lpv_comp_tmpl="${install_containers_dir}${app}/docker-compose.yml"
|
|
[[ -f "$_lpv_cfg_live" ]] || _lpv_cfg_live=""
|
|
[[ -f "$_lpv_cfg_tmpl" ]] || _lpv_cfg_tmpl=""
|
|
[[ -f "$_lpv_comp_live" ]] || _lpv_comp_live=""
|
|
[[ -f "$_lpv_comp_tmpl" ]] || _lpv_comp_tmpl=""
|
|
}
|
|
|
|
# --- config file: syntax, duplicates, prefix -------------------------------
|
|
_lpvCheckConfigFile()
|
|
{
|
|
local app="$1" file="$2" label="$3"
|
|
[[ -n "$file" ]] || return 0
|
|
local up="${app^^}"; up="${up//-/_}"
|
|
|
|
bash -n "$file" 2>/dev/null || _lpvFail "$app: $label does not parse as shell."
|
|
|
|
local dup
|
|
dup=$(grep -oE '^CFG_[A-Z0-9_]+=' "$file" | sort | uniq -d | tr -d '=')
|
|
[[ -n "$dup" ]] && while IFS= read -r k; do
|
|
_lpvFail "$app: $k is defined more than once in $label."
|
|
done <<< "$dup"
|
|
|
|
local wrong
|
|
wrong=$(grep -oE '^CFG_[A-Z0-9_]+=' "$file" | tr -d '=' | grep -v "^CFG_${up}_" || true)
|
|
[[ -n "$wrong" ]] && while IFS= read -r k; do
|
|
[[ -n "$k" ]] && _lpvFail "$app: $k in $label does not use this app's CFG_${up}_ prefix."
|
|
done <<< "$wrong"
|
|
}
|
|
|
|
# --- template config: placeholder hygiene ----------------------------------
|
|
# A generated value must carry a slot number, and no two keys may share one
|
|
# placeholder — the replacer mints one value per DISTINCT placeholder and
|
|
# substitutes every occurrence, so sharing means sharing the secret.
|
|
_lpvCheckPlaceholders()
|
|
{
|
|
local app="$1" file="$2"
|
|
[[ -n "$file" ]] || return 0
|
|
|
|
local k
|
|
while IFS= read -r k; do
|
|
[[ -n "$k" ]] || continue
|
|
[[ "$k" =~ _[0-9]+$ ]] || \
|
|
_lpvFail "$app: $k holds a generated value but has no slot number (expected ${k}_1)."
|
|
done < <(grep -oE '^CFG_[A-Z0-9_]+=RANDOMIZED' "$file" | sed 's/=RANDOMIZED//')
|
|
|
|
local ph
|
|
while IFS= read -r ph; do
|
|
[[ -n "$ph" ]] || continue
|
|
local keys
|
|
keys=$(grep -E "^CFG_[A-Z0-9_]+=${ph}$" "$file" | cut -d= -f1 | tr '\n' ' ')
|
|
_lpvFail "$app: $ph is shared by ${keys}— they would all receive the same secret."
|
|
done < <(grep -oE '=RANDOMIZED[A-Z]*[0-9]+$' "$file" | tr -d '=' | sort | uniq -d)
|
|
}
|
|
|
|
# --- deployed config: nothing left unfilled, no accidental twins ------------
|
|
_lpvCheckDeployedSecrets()
|
|
{
|
|
local app="$1" file="$2"
|
|
[[ -n "$file" ]] || return 0
|
|
|
|
local k
|
|
while IFS= read -r k; do
|
|
[[ -n "$k" ]] && _lpvFail "$app: $k still holds its RANDOMIZED placeholder — generation never ran."
|
|
done < <(grep -oE '^CFG_[A-Z0-9_]+="?RANDOMIZED' "$file" | sed 's/="\?RANDOMIZED//')
|
|
|
|
# Two different keys holding one value is what a shared placeholder looks
|
|
# like after substitution. Short values are skipped: "true", "admin" and
|
|
# friends repeat legitimately.
|
|
# The value arrives here unquoted (the sed/tr below strip them) while the
|
|
# file stores it quoted, so the key lookup has to put the quotes back —
|
|
# grepping for =<value> against ="<value>" matches nothing and the message
|
|
# names no keys, which is worse than useless in a failure report.
|
|
local v
|
|
while IFS= read -r v; do
|
|
[[ ${#v} -ge 12 ]] || continue
|
|
local keys
|
|
keys=$(grep -F "=\"$v\"" "$file" | cut -d= -f1 | tr '\n' ' ')
|
|
_lpvFail "$app: ${keys}share one value — a generated secret should never be reused."
|
|
done < <(grep -oE '^CFG_[A-Z0-9_]+="[^"]{12,}"$' "$file" \
|
|
| sed 's/^[^=]*=//' | tr -d '"' | sort | uniq -d)
|
|
}
|
|
|
|
# --- compose: annotations substitutable, tags backed -----------------------
|
|
_lpvCheckCompose()
|
|
{
|
|
local app="$1" file="$2" label="$3" cfg="$4"
|
|
[[ -n "$file" ]] || return 0
|
|
local up="${app^^}"; up="${up//-/_}"
|
|
|
|
# The annotation value is the literal the tag manager searches for on that
|
|
# line. If it does not appear in the line body, nothing can ever substitute.
|
|
# index() rather than split(): awk reads split's separator as a regex, and
|
|
# "#LIBREPORTAL|" ends in an alternation operator with nothing after it.
|
|
local bad
|
|
bad=$(awk '
|
|
{
|
|
marker = "#LIBREPORTAL|"
|
|
first = index($0, marker)
|
|
if (first == 0) next
|
|
body = substr($0, 1, first - 1)
|
|
rest = $0
|
|
while ((p = index(rest, marker)) > 0) {
|
|
rest = substr(rest, p + length(marker))
|
|
q = index(rest, "|")
|
|
if (q == 0) break
|
|
tag = substr(rest, 1, q - 1)
|
|
val = substr(rest, q + 1)
|
|
sub(/[ \t\r].*$/, "", val)
|
|
h = index(val, "#")
|
|
if (h > 0) val = substr(val, 1, h - 1)
|
|
if (val != "" && index(body, val) == 0)
|
|
printf "line %d: %s (value %s)\n", NR, tag, val
|
|
}
|
|
}' "$file")
|
|
[[ -n "$bad" ]] && while IFS= read -r l; do
|
|
_lpvFail "$app: $label $l never substitutes — the value is not in the line body."
|
|
done <<< "$bad"
|
|
|
|
# Every app-prefixed tag needs a filler: a CFG key of the same name, or a
|
|
# script that writes the tag directly (version bumps, per-app hooks).
|
|
# *_VERSION_TAG is exempt: the updater builds both the CFG name and the tag
|
|
# name from the app slug at runtime (CFG_${up}_VERSION), so neither the key
|
|
# nor the literal tag appears anywhere to be found.
|
|
local tag key
|
|
while IFS= read -r tag; do
|
|
[[ -n "$tag" ]] || continue
|
|
[[ "$tag" == *_VERSION_TAG ]] && continue
|
|
key="CFG_${tag%_TAG}"
|
|
[[ -n "$cfg" ]] && grep -q "^${key}=" "$cfg" 2>/dev/null && continue
|
|
grep -q "$tag" <<< "$_lpv_src" && continue
|
|
_lpvFail "$app: $label tag $tag has no ${key} and no script that fills it."
|
|
done < <(grep -oE "#LIBREPORTAL\|${up}_[A-Z0-9_]+_TAG\|" "$file" | tr -d '#|' | sed 's/LIBREPORTAL//' | sort -u)
|
|
}
|
|
|
|
# --- auth adapters: the key they write must exist --------------------------
|
|
# authPersistCfg resolves a bare name to its numbered slot, but if neither form
|
|
# is declared the write is a silent no-op and the WebUI keeps showing a password
|
|
# that no longer works.
|
|
_lpvCheckAuthAdapter()
|
|
{
|
|
local app="$1" cfg="$2"
|
|
local adapter="${install_containers_dir}${app}/scripts/${app}_auth.sh"
|
|
[[ -f "$adapter" && -n "$cfg" ]] || return 0
|
|
local up="${up_override:-${app^^}}"; up="${up//-/_}"
|
|
|
|
local key
|
|
while IFS= read -r key; do
|
|
[[ -n "$key" ]] || continue
|
|
grep -qE "^CFG_${up}_${key}=" "$cfg" && continue
|
|
grep -qE "^CFG_${up}_${key}_[0-9]+=" "$cfg" && continue
|
|
_lpvFail "$app: its auth adapter persists ${key}, but neither CFG_${up}_${key} nor a numbered slot is declared."
|
|
done < <(grep -oE "authPersistCfg ${app} [A-Z_]+" "$adapter" | awk '{print $3}' | sort -u)
|
|
}
|
|
|
|
# validateAppConfiguration <app> — every check for one app.
|
|
validateAppConfiguration()
|
|
{
|
|
local app="$1"
|
|
[[ -n "$app" ]] || { isError "No app name given."; return 1; }
|
|
_lpvAppFiles "$app"
|
|
if [[ -z "$_lpv_cfg_live" && -z "$_lpv_cfg_tmpl" ]]; then
|
|
isError "No config found for '$app'."
|
|
return 1
|
|
fi
|
|
|
|
# Called on its own rather than from the all-apps loop: own the counters and
|
|
# build the source index here. Without the index every tag filled by a hook
|
|
# instead of a CFG key reads as unbacked — `validation app matrix` reported
|
|
# RUN_UID/RUN_GID as failures that `validation all` correctly did not.
|
|
local standalone=""
|
|
[[ -z "$_lpv_in_all" ]] && { standalone=1; _lpv_issues=0; _lpv_checked=0; }
|
|
_lpvLoadSources
|
|
((_lpv_checked++))
|
|
local before=$_lpv_issues
|
|
|
|
_lpvCheckConfigFile "$app" "${_lpv_cfg_live:-$_lpv_cfg_tmpl}" "$( [[ -n "$_lpv_cfg_live" ]] && echo "deployed config" || echo "config template" )"
|
|
_lpvCheckPlaceholders "$app" "$_lpv_cfg_tmpl"
|
|
_lpvCheckDeployedSecrets "$app" "$_lpv_cfg_live"
|
|
_lpvCheckCompose "$app" "${_lpv_comp_live:-$_lpv_comp_tmpl}" \
|
|
"$( [[ -n "$_lpv_comp_live" ]] && echo "deployed compose" || echo "compose template" )" \
|
|
"${_lpv_cfg_live:-$_lpv_cfg_tmpl}"
|
|
_lpvCheckAuthAdapter "$app" "${_lpv_cfg_live:-$_lpv_cfg_tmpl}"
|
|
|
|
# A single-app run that says nothing is indistinguishable from one that did
|
|
# not run, so report either way. The all-apps loop keeps its own summary.
|
|
if [[ -n "$standalone" ]]; then
|
|
if [[ $_lpv_issues -eq $before ]]; then
|
|
isSuccessful "$app: no configuration problems found."
|
|
else
|
|
isError "$app: $((_lpv_issues - before)) configuration problem(s)."
|
|
return 1
|
|
fi
|
|
fi
|
|
return 0
|
|
}
|
|
|
|
# Load every .sh once so the "is this tag filled by a script?" test is a string
|
|
# search rather than a find+grep per tag.
|
|
_lpvLoadSources()
|
|
{
|
|
[[ -n "$_lpv_src" ]] && return 0
|
|
_lpv_src=$(cat \
|
|
$(find "${install_scripts_dir%/}" "${install_containers_dir%/}" \
|
|
-name '*.sh' ! -name 'function_manifest.sh' -type f 2>/dev/null) 2>/dev/null)
|
|
}
|
|
|
|
# validateSystemConfiguration — the shared config tree, not any one app.
|
|
validateSystemConfiguration()
|
|
{
|
|
isHeader "Validating system configuration"
|
|
local before=$_lpv_issues
|
|
local f
|
|
while IFS= read -r f; do
|
|
[[ -f "$f" ]] || continue
|
|
bash -n "$f" 2>/dev/null || _lpvFail "$(basename "$f") does not parse as shell."
|
|
local dup
|
|
dup=$(grep -oE '^CFG_[A-Z0-9_]+=' "$f" | sort | uniq -d | tr -d '=')
|
|
[[ -n "$dup" ]] && while IFS= read -r k; do
|
|
[[ -n "$k" ]] && _lpvFail "$k is defined more than once in $(basename "$f")."
|
|
done <<< "$dup"
|
|
done < <(find "${configs_dir%/}" -maxdepth 2 -type f ! -name '*.category' ! -name '.*' ! -name '*.bak' 2>/dev/null)
|
|
|
|
if [[ $_lpv_issues -eq $before ]]; then
|
|
isSuccessful "System configuration is consistent."
|
|
fi
|
|
return 0
|
|
}
|
|
|
|
# validateAllConfigurations — every app plus the system tree.
|
|
validateAllConfigurations()
|
|
{
|
|
_lpv_issues=0; _lpv_checked=0
|
|
_lpvLoadSources
|
|
|
|
isHeader "Validating application configuration"
|
|
local d app
|
|
for d in "${install_containers_dir%/}"/*/; do
|
|
app="$(basename "$d")"
|
|
[[ -f "$d$app.config" ]] || continue
|
|
validateAppConfiguration "$app"
|
|
done
|
|
|
|
validateSystemConfiguration
|
|
|
|
echo ""
|
|
if [[ $_lpv_issues -eq 0 ]]; then
|
|
isSuccessful "$_lpv_checked apps validated — no configuration problems found."
|
|
return 0
|
|
fi
|
|
isError "$_lpv_issues configuration problem(s) across $_lpv_checked apps."
|
|
isNotice "These do not surface at runtime: a mis-declared key silently stops working."
|
|
return 1
|
|
}
|
|
|
|
# tagsValidateShowValidationStatus — the summary `libreportal validation status`
|
|
# prints. Same checks, counts only.
|
|
tagsValidateShowValidationStatus()
|
|
{
|
|
validateAllConfigurations >/dev/null 2>&1
|
|
local rc=$?
|
|
isHeader "Configuration validation status"
|
|
echo " Apps checked : $_lpv_checked"
|
|
echo " Problems : $_lpv_issues"
|
|
echo ""
|
|
if [[ $_lpv_issues -eq 0 ]]; then
|
|
isSuccessful "No configuration problems found."
|
|
else
|
|
isNotice "Run 'libreportal validation all' for the detail."
|
|
fi
|
|
return $rc
|
|
}
|