diff --git a/configs/systemd/user/taler-landing-stats.service b/configs/systemd/user/taler-landing-stats.service index 6273a72..cc6bd88 100644 --- a/configs/systemd/user/taler-landing-stats.service +++ b/configs/systemd/user/taler-landing-stats.service @@ -14,12 +14,14 @@ TimeoutStartSec=480 # Do not keep "active" after run — timer may fire again cleanly RemainAfterExit=no Environment=TZ=Europe/Zurich +# Stack overrides (goa / stage-testpaysan / custom) — optional +EnvironmentFile=-%h/.config/taler-landing/stack.env +# GOA defaults if stack.env absent Environment=ADMIN_LOG=%h/src/koopa/koopa-admin-log Environment=SECRETS_BANK=%h/src/koopa/koopa-admin-secrets/koopa/host-root/taler-bank Environment=BANK_URL=http://127.0.0.1:9012 Environment=BANK_PUBLIC_URL=https://bank.hacktivism.ch Environment=EXCHANGE_CONFIG_URL=https://exchange.hacktivism.ch/config -# All accounts except exchange (double-count of withdraw credits on exchange ledger) Environment=SCAN_SKIP=exchange Environment=TX_PAGE=500 Environment=MAX_TX_PAGES=0 diff --git a/scripts/taler-exchange/landing-stats-exchange.sh b/scripts/taler-exchange/landing-stats-exchange.sh index 35452d4..987e631 100644 --- a/scripts/taler-exchange/landing-stats-exchange.sh +++ b/scripts/taler-exchange/landing-stats-exchange.sh @@ -238,6 +238,7 @@ fi GEN=$(now_iso) HUMAN=$(now_human) +GEN_UNIX=$(date +%s) num_or_null() { case "${1:-}" in ''|null) echo null ;; *) echo "$1" ;; esac; } MEM_JSON='"container_rss_human": "—"' @@ -257,6 +258,7 @@ cat >"$TMP" </dev/null; then + if cp -f "$src_stats" "${d}/stats.json" 2>/dev/null; then + chmod a+r "${d}/stats.json" 2>/dev/null || true + [ -n "$src_run" ] && [ -f "$src_run" ] && cp -f "$src_run" "${d}/stats-run.json" 2>/dev/null || true + log "host: published ${d}/stats.json" + continue + fi + fi + if command -v sudo >/dev/null 2>&1 && sudo -n true 2>/dev/null; then + sudo mkdir -p "$d" 2>/dev/null || true + sudo cp -f "$src_stats" "${d}/stats.json" + sudo chmod a+r "${d}/stats.json" 2>/dev/null || true + [ -n "$src_run" ] && [ -f "$src_run" ] && sudo cp -f "$src_run" "${d}/stats-run.json" 2>/dev/null || true + log "host: published ${d}/stats.json (sudo)" + else + log "WARN: cannot write ${d}/stats.json (need write or passwordless sudo)" + fi + done <<<"$paths" +} + read_pass() { local name="$1" f for f in \ @@ -107,11 +175,25 @@ read_pass() { return 0 fi done - # container (hernani can podman exec root files inside owned containers) - if podman inspect -f '{{.State.Running}}' "$BANK_CTR" 2>/dev/null | grep -qx true; then - if podman exec "$BANK_CTR" test -r "/root/${name}" 2>/dev/null; then - podman exec "$BANK_CTR" cat "/root/${name}" 2>/dev/null | tr -d '\n' - return 0 + # container secrets: /root/… (GOA) or /data/secrets/… (FrancPaysan stage) + if [ -n "${BANK_CTR:-}" ] && podman inspect -f '{{.State.Running}}' "$BANK_CTR" 2>/dev/null | grep -qx true; then + for path in "/root/${name}" "/data/secrets/${name}" "/data/secrets/bank-admin-password.txt"; do + case "$path" in + *bank-admin-password.txt) + [ "$name" = "bank-admin-password.txt" ] || continue + ;; + esac + if podman exec "$BANK_CTR" test -r "$path" 2>/dev/null; then + podman exec "$BANK_CTR" cat "$path" 2>/dev/null | tr -d '\n\r' + return 0 + fi + done + # stage explorer secret naming + if [ "$name" = "bank-explorer-password.txt" ]; then + if podman exec "$BANK_CTR" test -r /data/secrets/bank-explorer-password.txt 2>/dev/null; then + podman exec "$BANK_CTR" cat /data/secrets/bank-explorer-password.txt 2>/dev/null | tr -d '\n\r' + return 0 + fi fi fi return 1 @@ -123,6 +205,8 @@ ctr_running() { publish_json() { local ctr="$1" dest_dir="$2" src_stats="$3" src_run="$4" + [ "${PUBLISH_PODMAN}" = "1" ] || return 0 + [ -n "$ctr" ] || return 0 if ! ctr_running "$ctr"; then log "WARN: $ctr not running — skip publish $dest_dir" return 1 @@ -166,6 +250,7 @@ collect_bank() { BANK_URL="$BANK_URL" \ BANK_PUBLIC_URL="$BANK_PUBLIC_URL" \ EXCHANGE_CONFIG_URL="$EXCHANGE_CONFIG_URL" \ + BANK_CURRENCY="${BANK_CURRENCY:-}" \ BANK_ADMIN_PASS="$admin_pass" \ BANK_EXPLORER_PASS="$explorer_pass" \ DEMO_DIR="${demo_dir}" \ @@ -173,6 +258,7 @@ collect_bank() { TX_PAGE="${TX_PAGE:-500}" \ MAX_TX_PAGES="${MAX_TX_PAGES:-0}" \ ACCOUNTS_DELTA="${ACCOUNTS_DELTA:--10000}" \ + STATS_SOURCE_LABEL="$STATS_SOURCE_LABEL" \ "$PY" "$LIB/collect_bank_stats.py" \ --out "$WORKDIR/bank-stats.json" \ --run-out "$WORKDIR/bank-stats-run.json" \ @@ -181,11 +267,13 @@ collect_bank() { set -e if [ "$ec_bank" -eq 0 ] && [ -f "$WORKDIR/bank-stats.json" ]; then - # Always attach in-container RSS/loadavg (not host /proc) + # Optional: attach in-container RSS/loadavg (not host /proc) merge_container_resources bank "$BANK_CTR" "$WORKDIR/bank-stats.json" || true publish_json "$BANK_CTR" "$BANK_LANDING_IN" \ "$WORKDIR/bank-stats.json" "$WORKDIR/bank-stats-run.json" - log "bank: OK → ${BANK_CTR}:${BANK_LANDING_IN}/stats.json" + # Shared public feed: same bank stats on all HOST_STATS_DIRS (bank/ex/merchant landings) + publish_host_stats "$WORKDIR/bank-stats.json" "$WORKDIR/bank-stats-run.json" + log "bank: OK (podman=${BANK_CTR:-none} host_dirs=${HOST_STATS_DIRS:-none})" else log "ERROR: bank collect failed (ec=$ec_bank) — previous stats.json kept" [ -f "$WORKDIR/bank-stats-run.json" ] || \ @@ -272,13 +360,26 @@ collect_merchant() { # --------------------------------------------------------------------------- main() { - log "=== taler-landing-stats start (user=$(id -un) lib=$LIB) ===" - collect_bank || ec_bank=$? - collect_exchange || ec_ex=$? - collect_merchant || ec_mer=$? + log "=== taler-landing-stats start (user=$(id -un) lib=$LIB currency=${BANK_CURRENCY:-auto}) ===" + log "phases bank=${COLLECT_BANK} exchange=${COLLECT_EXCHANGE} merchant=${COLLECT_MERCHANT} resources=${COLLECT_RESOURCES}" + if [ "${COLLECT_BANK}" = "1" ]; then + collect_bank || ec_bank=$? + else + log "skip bank collect" + fi + if [ "${COLLECT_EXCHANGE}" = "1" ]; then + collect_exchange || ec_ex=$? + else + log "skip exchange collect" + fi + if [ "${COLLECT_MERCHANT}" = "1" ]; then + collect_merchant || ec_mer=$? + else + log "skip merchant collect" + fi log "=== done bank=$ec_bank exchange=$ec_ex merchant=$ec_mer ===" # non-zero if bank failed (primary public flow numbers); soft on ex/mer - if [ "$ec_bank" -ne 0 ]; then + if [ "${COLLECT_BANK}" = "1" ] && [ "$ec_bank" -ne 0 ]; then return 1 fi return 0 diff --git a/scripts/taler-landing/collect_bank_stats.py b/scripts/taler-landing/collect_bank_stats.py index 096eae7..b8d4398 100755 --- a/scripts/taler-landing/collect_bank_stats.py +++ b/scripts/taler-landing/collect_bank_stats.py @@ -1,23 +1,22 @@ #!/usr/bin/env python3 """Full-scan bank landing stats (all accounts, all ledger money). -Run on koopa as hernani (or anywhere with admin API access). Writes stats.json -compatible with bank.hacktivism.ch/intro/ plus amount_alt fields for compact UI. +Generic for any currency stack (GOA, TESTPAYSAN, KUDOS, …). Run on the ops host +(with admin API access). Writes stats.json for public landings + amount_alt. Env (selected): BANK_URL default http://127.0.0.1:9012 + BANK_CURRENCY optional; else from exchange /config or first ledger amount BANK_ADMIN_USER default admin BANK_ADMIN_PASS or BANK_ADMIN_PASS_FILE / pass via --admin-pass-file BANK_EXPLORER_USER default explorer BANK_EXPLORER_PASS optional (balance_explorer) - EXCHANGE_CONFIG_URL for alt_unit_names (default https://exchange.hacktivism.ch/config) + EXCHANGE_CONFIG_URL for alt_unit_names + currency + BANK_PUBLIC_URL public base for latency probes OUT output path (default stdout if -) SCAN_SKIP comma usernames excluded from flow (default: exchange) - TX_PAGE page size for transactions delta (default 500) - MAX_TX_PAGES 0 = unlimited pages per account (default 0) - ACCOUNTS_DELTA GET /accounts?delta= (default -10000) - RECENT_WD_N recent withdraws (default 10) - TZ default Europe/Zurich + TX_PAGE / MAX_TX_PAGES / ACCOUNTS_DELTA / RECENT_WD_N / TZ + STATS_SOURCE_LABEL optional string in stats.source field """ from __future__ import annotations @@ -343,6 +342,11 @@ def main() -> int: "--exchange-config", default=env("EXCHANGE_CONFIG_URL", "https://exchange.hacktivism.ch/config"), ) + ap.add_argument( + "--currency", + default=env("BANK_CURRENCY", ""), + help="ledger currency code (optional; else exchange /config or first amount)", + ) ap.add_argument("--out", default=env("OUT", "-")) ap.add_argument("--run-out", default=env("RUN_OUT", "")) ap.add_argument( @@ -364,6 +368,11 @@ def main() -> int: ) ap.add_argument("--recent", type=int, default=int(env("RECENT_WD_N", "10") or "10")) ap.add_argument("--public-base", default=env("BANK_PUBLIC_URL", "https://bank.hacktivism.ch")) + ap.add_argument( + "--source-label", + default=env("STATS_SOURCE_LABEL", "host collect_bank_stats.py"), + help="stats.source field (identify stack / collector)", + ) args = ap.parse_args() tz_name = env("TZ", "Europe/Zurich") or "Europe/Zurich" @@ -378,6 +387,7 @@ def main() -> int: bank = args.bank.rstrip("/") skip = {s.strip() for s in (args.skip or "").split(",") if s.strip()} + currency_hint = (args.currency or "").strip().upper() def write_run(ok: bool, err: Optional[str] = None) -> None: if not args.run_out: @@ -397,6 +407,24 @@ def main() -> int: raise RuntimeError("no admin password (BANK_ADMIN_PASS or --admin-pass-file)") token = get_token(bank, args.admin_user, admin_pass) alt = load_alt_from_config(args.exchange_config) + # Currency: explicit → exchange /config → first ledger amount → GOA + currency = currency_hint + if not currency: + try: + code_c, cfg_d, _ = http_json(args.exchange_config, timeout=12) + if code_c == 200 and isinstance(cfg_d, dict) and cfg_d.get("currency"): + currency = str(cfg_d["currency"]).strip().upper() + except Exception: + pass + if not currency: + try: + code_c, cfg_d, _ = http_json(f"{bank}/config", timeout=12) + if code_c == 200 and isinstance(cfg_d, dict) and cfg_d.get("currency"): + currency = str(cfg_d["currency"]).strip().upper() + except Exception: + pass + if not currency: + currency = "GOA" if not alt: alt = dict(DEFAULT_ALT) @@ -442,13 +470,24 @@ def main() -> int: except Exception: continue low = (subject or "").lower() + # Normalise bare numbers to stack currency + if ":" not in str(amount): + amount = f"{currency}:{amount}" + else: + # adopt ledger currency if we only had a hint + try: + cur0, _n0 = parse_amount(amount) + if cur0 and currency_hint == "" and cur0.upper() != "GOA": + currency = cur0.upper() + except Exception: + pass if direction == "credit": total_in += n n_incoming += 1 incomings.append( { "kind": "incoming", - "amount": amount if ":" in amount else f"GOA:{amount}", + "amount": amount, "at_unix": ts, "account": uname, "subject": subject, @@ -460,7 +499,7 @@ def main() -> int: withdraws.append( { "kind": "withdraw", - "amount": amount if ":" in amount else f"GOA:{amount}", + "amount": amount, "at_unix": ts, "account": uname, "subject": subject, @@ -503,7 +542,7 @@ def main() -> int: users_n = sum(1 for u in accounts if u not in ("admin", "exchange")) def pack_amt(d: Decimal) -> Dict[str, Any]: - f = format_amount_alt(f"GOA:{d}", alt) + f = format_amount_alt(f"{currency}:{d}", alt) return { "amount": f["amount"], "amount_alt": f["amount_alt"], @@ -567,10 +606,11 @@ def main() -> int: } ) - # explorer balance - balance = "GOA:0" - bal_alt = "GOA:0" - bal_full = "GOA:0" + # explorer / pool balance (shared demo account when present) + zero = f"{currency}:0" + balance = zero + bal_alt = zero + bal_full = zero if explorer_pass: try: etok = get_token(bank, args.explorer_user, explorer_pass) @@ -583,7 +623,7 @@ def main() -> int: balance = str( data.get("balance", {}).get("amount") if isinstance(data.get("balance"), dict) - else data.get("amount") or data.get("balance") or "GOA:0" + else data.get("amount") or data.get("balance") or zero ) # sometimes balance is object with amount if isinstance(data.get("balance"), dict) and data["balance"].get( @@ -608,7 +648,7 @@ def main() -> int: ) if code == 200 and isinstance(data, dict): if isinstance(data.get("balance"), dict): - balance = str(data["balance"].get("amount") or "GOA:0") + balance = str(data["balance"].get("amount") or zero) elif data.get("amount"): balance = str(data["amount"]) bf = format_amount_alt(balance, alt) @@ -637,12 +677,12 @@ def main() -> int: stats = { "ok": True, - "currency": "GOA", + "currency": currency, "timezone": tz_name, "generated_at": gen_iso, "generated_at_human": gen_human, "generated_at_unix": now_u, - "source": "host collect_bank_stats.py (hernani systemd)", + "source": str(args.source_label or "host collect_bank_stats.py"), "bank_url": bank, "scan": { "tx_page": args.tx_page, diff --git a/scripts/taler-landing/install-landing-stats-host.sh b/scripts/taler-landing/install-landing-stats-host.sh index 7c572f0..4a26217 100755 --- a/scripts/taler-landing/install-landing-stats-host.sh +++ b/scripts/taler-landing/install-landing-stats-host.sh @@ -1,35 +1,41 @@ #!/usr/bin/env bash -# Install central landing-stats collector as hernani user systemd timer. +# Install generic landing-stats collector as user systemd timer. # -# On koopa: +# GOA / koopa (default): # cd ~/src/koopa/koopa-admin-log # ./scripts/taler-landing/install-landing-stats-host.sh -# systemctl --user start taler-landing-stats.service # one-shot now -# systemctl --user status taler-landing-stats.timer # -# Requires: linger enabled for hernani (loginctl enable-linger hernani) -# so the timer runs without an interactive login. +# Stage TESTPAYSAN (stagepaysan on francpaysan host): +# STACK_PROFILE=stage-testpaysan ./scripts/taler-landing/install-landing-stats-host.sh +# # or from francpaysan-admin-log: ./scripts/stagepaysan/install-landing-stats.sh +# +# Optional: STACK_ENV_FILE=/path/to/profile.env +# Requires linger: sudo loginctl enable-linger $USER set -euo pipefail ROOT="$(cd "$(dirname "$0")/../.." && pwd)" ADMIN_LOG="${ADMIN_LOG:-$ROOT}" SRC="$ADMIN_LOG/scripts/taler-landing" UNIT_SRC="$ADMIN_LOG/configs/systemd/user" +PROFILE="${STACK_PROFILE:-goa}" BIN_DST="${HOME}/.local/bin" LIB_DST="${HOME}/.local/lib/taler-landing" UNIT_DST="${HOME}/.config/systemd/user" STATE_DST="${HOME}/.local/state/taler-landing-stats" +CFG_DST="${HOME}/.config/taler-landing" -mkdir -p "$BIN_DST" "$LIB_DST" "$UNIT_DST" "$STATE_DST" +mkdir -p "$BIN_DST" "$LIB_DST" "$UNIT_DST" "$STATE_DST" "$CFG_DST" install -m 0755 "$SRC/collect-landing-stats.sh" "$BIN_DST/collect-landing-stats.sh" install -m 0755 "$SRC/collect_bank_stats.py" "$LIB_DST/collect_bank_stats.py" install -m 0755 "$SRC/enrich_stats_alt.py" "$LIB_DST/enrich_stats_alt.py" install -m 0755 "$SRC/merge_resources.py" "$LIB_DST/merge_resources.py" install -m 0755 "$SRC/collect_container_resources.sh" "$LIB_DST/collect_container_resources.sh" -install -m 0755 "$SRC/test-landing-stats.sh" "$BIN_DST/test-landing-stats.sh" 2>/dev/null || \ - install -m 0755 "$SRC/test-landing-stats.sh" "$LIB_DST/test-landing-stats.sh" +if [ -f "$SRC/test-landing-stats.sh" ]; then + install -m 0755 "$SRC/test-landing-stats.sh" "$BIN_DST/test-landing-stats.sh" 2>/dev/null || \ + install -m 0755 "$SRC/test-landing-stats.sh" "$LIB_DST/test-landing-stats.sh" +fi install -m 0644 "$SRC/goa_amounts.py" "$LIB_DST/goa_amounts.py" # mem-snapshot helper lives in taler-shared (copied into containers at collect time) if [ -f "$ADMIN_LOG/scripts/taler-shared/mem-snapshot.sh" ]; then @@ -37,18 +43,34 @@ if [ -f "$ADMIN_LOG/scripts/taler-shared/mem-snapshot.sh" ]; then "$LIB_DST/mem-snapshot.sh" fi -# Wrapper always uses installed lib next to itself when LIB discovery works; -# also point ADMIN_LOG for in-container script refresh. +# Stack profile → ~/.config/taler-landing/stack.env (sourced by collector) +if [ -n "${STACK_ENV_FILE:-}" ] && [ -f "${STACK_ENV_FILE}" ]; then + install -m 0644 "${STACK_ENV_FILE}" "$CFG_DST/stack.env" +elif [ -f "$SRC/profiles/${PROFILE}.env" ]; then + install -m 0644 "$SRC/profiles/${PROFILE}.env" "$CFG_DST/stack.env" +else + echo "WARN: no profile ${PROFILE}.env — collector uses built-in GOA defaults" >&2 +fi + install -m 0644 "$UNIT_SRC/taler-landing-stats.service" "$UNIT_DST/" install -m 0644 "$UNIT_SRC/taler-landing-stats.timer" "$UNIT_DST/" -# Rewrite ExecStart to installed binary if unit uses %h path — units already do. +# Point unit EnvironmentFile at stack profile +if grep -q '^EnvironmentFile=' "$UNIT_DST/taler-landing-stats.service" 2>/dev/null; then + : +else + # inject after [Service] + sed -i '/^\[Service\]/a EnvironmentFile=-%h/.config/taler-landing/stack.env' \ + "$UNIT_DST/taler-landing-stats.service" +fi + systemctl --user daemon-reload systemctl --user enable --now taler-landing-stats.timer -echo "Installed (user=$(id -un)):" +echo "Installed (user=$(id -un) profile=${PROFILE}):" echo " $BIN_DST/collect-landing-stats.sh" echo " $LIB_DST/{collect_bank_stats,enrich_stats_alt,goa_amounts}.py" +echo " $CFG_DST/stack.env" echo " $UNIT_DST/taler-landing-stats.{service,timer} (timer enabled)" echo " logs: $STATE_DST/" echo @@ -61,8 +83,6 @@ echo "Run once now:" echo " systemctl --user start taler-landing-stats.service" echo " journalctl --user -u taler-landing-stats.service -n 40 --no-pager" echo -echo "Optional secrets (if not readable via podman exec bank /root/…):" -echo " mkdir -p ~/.config/taler-landing" -echo " cp …/bank-admin-password.txt ~/.config/taler-landing/" -echo " cp …/bank-explorer-password.txt ~/.config/taler-landing/" -echo " chmod 600 ~/.config/taler-landing/*" +echo "Secrets (if not via podman exec bank):" +echo " mkdir -p ~/.config/taler-landing && chmod 700 ~/.config/taler-landing" +echo " # bank-admin-password.txt bank-explorer-password.txt" diff --git a/scripts/taler-landing/profiles/goa.env b/scripts/taler-landing/profiles/goa.env new file mode 100644 index 0000000..08c357a --- /dev/null +++ b/scripts/taler-landing/profiles/goa.env @@ -0,0 +1,18 @@ +# Stack profile: GOA / hacktivism +# systemd EnvironmentFile: KEY=value only (no export) +BANK_CTR=taler-hacktivism-bank +EX_CTR=taler-hacktivism-exchange-ansible +MER_CTR=taler-hacktivism +BANK_URL=http://127.0.0.1:9012 +BANK_PUBLIC_URL=https://bank.hacktivism.ch +EXCHANGE_CONFIG_URL=https://exchange.hacktivism.ch/config +BANK_CURRENCY=GOA +BANK_LANDING_IN=/var/www/bank-landing +EX_LANDING_IN=/var/www/exchange-landing +MER_LANDING_IN=/var/www/merchant-landing +COLLECT_BANK=1 +COLLECT_EXCHANGE=1 +COLLECT_MERCHANT=1 +COLLECT_RESOURCES=1 +PUBLISH_PODMAN=1 +STATS_SOURCE_LABEL="host collect_bank_stats.py goa hacktivism" diff --git a/scripts/taler-landing/profiles/stage-testpaysan.env b/scripts/taler-landing/profiles/stage-testpaysan.env new file mode 100644 index 0000000..b63a58c --- /dev/null +++ b/scripts/taler-landing/profiles/stage-testpaysan.env @@ -0,0 +1,22 @@ +# Stack profile: FrancPaysan stage / TESTPAYSAN (ops user stagepaysan) +# systemd EnvironmentFile: KEY=value only (no export, no shell expansion) +BANK_CTR=stage-lfp-bank +EX_CTR=stage-lfp-exchange-ansible +MER_CTR=stage-lfp-merchant +BANK_URL=http://127.0.0.1:9032 +BANK_PUBLIC_URL=https://stage.bank.lefrancpaysan.ch +EXCHANGE_CONFIG_URL=https://stage.exchange.lefrancpaysan.ch/config +BANK_CURRENCY=TESTPAYSAN +SCAN_SKIP=exchange +BANK_LANDING_IN=/data/var/www +EX_LANDING_IN=/data/var/www +MER_LANDING_IN=/data/var/www +# Shared public feed: one bank scan → same stats.json on all three Caddy landings +# Colon-separated for systemd EnvironmentFile +HOST_STATS_DIRS=/var/www/lfp-stage/bank:/var/www/lfp-stage/exchange:/var/www/lfp-stage/merchant +COLLECT_BANK=1 +COLLECT_EXCHANGE=0 +COLLECT_MERCHANT=0 +COLLECT_RESOURCES=0 +PUBLISH_PODMAN=1 +STATS_SOURCE_LABEL="host collect_bank_stats.py stage TESTPAYSAN shared" diff --git a/scripts/taler-merchant/landing-stats-merchant.sh b/scripts/taler-merchant/landing-stats-merchant.sh index cee1bc7..15c5f9b 100644 --- a/scripts/taler-merchant/landing-stats-merchant.sh +++ b/scripts/taler-merchant/landing-stats-merchant.sh @@ -363,6 +363,7 @@ CUR_JSON="${CUR_JSON} GEN=$(now_iso) HUMAN=$(now_human) +GEN_UNIX=$(date +%s) # Live performance probes (exchange-style) measure_ms() { @@ -406,6 +407,7 @@ cat >"$TMP" <"$out" return 1 fi + # one-shot needs DB file; serve mode needs socket + if [ -z "${WSERVE_PID:-}" ] || [ ! -S "${WSOCK:-}" ]; then + if [ ! -f "${WDB:-}" ]; then + echo "(no wallet)" >"$out" + return 1 + fi + fi local err="${out}.err" if command -v perl >/dev/null 2>&1; then - perl -e 'alarm shift; exec @ARGV' 10 \ - node "$WALLET_CLI" --wallet-db="$WDB" --no-throttle --skip-defaults balance \ - >"$out" 2>"$err" || true + if [ -n "${WSERVE_PID:-}" ] && [ -S "${WSOCK:-}" ]; then + perl -e 'alarm shift; exec @ARGV' 10 \ + node "$WALLET_CLI" --wallet-connection="$WSOCK" --no-throttle balance \ + >"$out" 2>"$err" || true + else + perl -e 'alarm shift; exec @ARGV' 10 \ + node "$WALLET_CLI" --wallet-db="$WDB" --no-throttle --skip-defaults balance \ + >"$out" 2>"$err" || true + fi else - node "$WALLET_CLI" --wallet-db="$WDB" --no-throttle --skip-defaults balance \ - >"$out" 2>"$err" || true + if [ -n "${WSERVE_PID:-}" ] && [ -S "${WSOCK:-}" ]; then + node "$WALLET_CLI" --wallet-connection="$WSOCK" --no-throttle balance \ + >"$out" 2>"$err" || true + else + node "$WALLET_CLI" --wallet-db="$WDB" --no-throttle --skip-defaults balance \ + >"$out" 2>"$err" || true + fi fi [ -s "$out" ] } @@ -104,9 +122,9 @@ wait_wallet_balance() { local rounds="${2:-15}" local sleep_s="${3:-3}" local r av - info "wallet settle wait" "up to ${rounds}×${sleep_s}s for avail>${min_n} ${CUR} (wirewatch lag is common)" + # Poll balance only — never run-until-done (hangs; banned in monitoring). + info "wallet settle wait" "up to ${rounds}×${sleep_s}s for avail>${min_n} ${CUR} (poll balance only; no run-until-done)" for r in $(seq 1 "$rounds"); do - wcli run-until-done >"$SCRATCH/settle-run.out" 2>&1 || true wcli_bal_snap "$SCRATCH/bal-live.out" || wcli balance >"$SCRATCH/bal-live.out" 2>&1 || true av=$(wallet_avail_num "$SCRATCH/bal-live.out") if python3 -c "import sys; sys.exit(0 if float(sys.argv[1]) > float(sys.argv[2]) else 1)" "$av" "$min_n" 2>/dev/null; then @@ -182,6 +200,7 @@ e2e_skip_rest() { # Area e2e-### — withdraw + pay cycle set_area e2e +set_group prereq section "e2e · prerequisites" info "e2e budget" "${E2E_TIMEOUT}s (set E2E_TIMEOUT= to change)" @@ -220,12 +239,22 @@ except Exception: print("")' 2>/dev/null || true) if [ "$E2E_REMOTE" = "1" ]; then # remote: still ATM-shaped but smaller notes; keep cheap : "${E2E_WITHDRAW_VALUES:=10 20 50}" - : "${E2E_PAY_VALUES:=0.01 0.05 0.1 1}" + if [ "$CUR" = "TESTPAYSAN" ]; then + # match fixed public templates e2e-001 / e2e-005 / e2e-1 + : "${E2E_PAY_VALUES:=0.01 0.05 1}" + else + : "${E2E_PAY_VALUES:=0.01 0.05 0.1 1}" + fi else # local GOA: classic ATM notes + 4200 for paivana paywall template : "${E2E_WITHDRAW_VALUES:=20 50 100 200 4200}" : "${E2E_PAY_VALUES:=0.01 0.05 0.1 0.5 1 2 5 10}" fi +# Public template pays (landing/shop style) — default on for TESTPAYSAN stage +if [ -z "${E2E_USE_TEMPLATES:-}" ]; then + if [ "$CUR" = "TESTPAYSAN" ]; then E2E_USE_TEMPLATES=1; else E2E_USE_TEMPLATES=0; fi +fi +: "${E2E_USE_TEMPLATES:=0}" # Build CUR:amount lists build_amt_list() { @@ -299,12 +328,27 @@ BANK_HOST=$(python3 -c 'from urllib.parse import urlparse; print(urlparse("'"$BA SCRATCH=$(mktemp -d) WDB="$SCRATCH/wallet.sqlite3" +WSOCK="$SCRATCH/wallet.sock" +WSERVE_PID="" +# Client/server wallet (advanced serve): long-lived shepherd without run-until-done. +# Default on — wallet-cli only completes withdraw/pay while a serve process is up. +: "${E2E_WALLET_SERVE:=1}" METRICS_DIR="${METRICS_DIR:-$SCRATCH/metrics}" mkdir -p "$METRICS_DIR" export METRICS_DIR CUR="${CUR:-${EXPECT_CURRENCY:-GOA}}" WDB export PATH="${HOME}/.local/bin:${HOME}/taler/opt/taler-wallet-cli/usr/bin:/opt/homebrew/bin:/usr/local/bin:$PATH" + +stop_wallet_serve() { + if [ -n "${WSERVE_PID:-}" ] && kill -0 "$WSERVE_PID" 2>/dev/null; then + kill "$WSERVE_PID" 2>/dev/null || true + wait "$WSERVE_PID" 2>/dev/null || true + fi + WSERVE_PID="" + rm -f "${WSOCK:-}" 2>/dev/null || true +} + # Always dump balances on any exit (timeout, blocker, signal, success) -trap 'ec=$?; e2e_finish "$ec"; rm -rf "$SCRATCH"; exit "$ec"' EXIT +trap 'ec=$?; e2e_finish "$ec"; stop_wallet_serve; rm -rf "$SCRATCH"; exit "$ec"' EXIT # Abort cleanly on login / KYC / registration barriers (esp. remote domains) e2e_abort_auth() { @@ -382,10 +426,12 @@ else fi # Host + bank/exchange/merchant RAM/CPU/load (SSH koopa or koopa-external) +set_group load section "e2e · load snapshot (before withdraw/pay)" metrics_report_load "${METRICS_DIR}/load-before.json" "e2e-start" || true # Local stack: koopa secrets. Remote: only explicit env (never leak local passwords to foreign banks). +set_group prereq if [ "$E2E_REMOTE" = "1" ]; then ADMIN_PASS="${E2E_BANK_ADMIN_PASS:-}" MPW="${E2E_MERCHANT_TOKEN:-${MERCHANT_TOKEN:-}}" @@ -402,13 +448,29 @@ else exit 1 fi if [ -n "$MPW" ]; then + # Secrets often store full "secret-token:…"; strip before re-prefixing Bearer. + MPW=$(printf '%s' "$MPW" | tr -d '\n\r') + case "$MPW" in + secret-token:*) MPW_TOKEN="$MPW" ;; + *) MPW_TOKEN="secret-token:${MPW}" ;; + esac ok "merchant secret (instance ${MERCHANT_INSTANCE})" elif [ "$E2E_REMOTE" = "1" ]; then - warn "merchant secret" "missing — order create may fail (set E2E_MERCHANT_TOKEN)" + MPW_TOKEN="" + warn "merchant secret" "missing — private orders fail unless E2E_USE_TEMPLATES=1 (set E2E_MERCHANT_TOKEN)" else blocker "prereq" "merchant instance password missing" exit 1 fi +# Authorization header for merchant private API (never double secret-token:) +merchant_auth_header() { + local t="${1:-${MPW_TOKEN:-}}" + [ -n "$t" ] || return 1 + case "$t" in + secret-token:*) printf 'Authorization: Bearer %s' "$t" ;; + *) printf 'Authorization: Bearer secret-token:%s' "$t" ;; + esac +} # Public reachability gates (remote: soft-skip if bank/merchant absent) for pair in \ @@ -452,7 +514,31 @@ info "e2e user" "$USER withdraw=$WITHDRAW_AMT pay=$PAY_AMT" BANK="$BANK_PUBLIC" INST="$MERCHANT_INSTANCE" +start_wallet_serve() { + [ "${E2E_WALLET_SERVE}" = "1" ] || return 0 + [ -n "${WALLET_CLI:-}" ] && [ -f "${WALLET_CLI}" ] || return 1 + stop_wallet_serve + rm -f "$WSOCK" + # serve holds the DB open; clients use --wallet-connection only + node "$WALLET_CLI" --wallet-db="$WDB" --no-throttle --skip-defaults \ + advanced serve --unix-path "$WSOCK" \ + >"$SCRATCH/wallet-serve.log" 2>&1 & + WSERVE_PID=$! + local i + for i in $(seq 1 40); do + if [ -S "$WSOCK" ] && kill -0 "$WSERVE_PID" 2>/dev/null; then + ok "wallet serve" "pid=$WSERVE_PID sock=$WSOCK (no run-until-done)" + return 0 + fi + sleep 0.15 + done + warn "wallet serve" "socket not ready — falling back to one-shot CLI (coins may stay pending)" + stop_wallet_serve + return 1 +} + # $1 = max seconds for this call (optional); rest = wallet-cli args +# Prefer live serve socket so the shepherd stays up (alternative to run-until-done). wcli() { local maxc=12 if [[ "${1:-}" =~ ^[0-9]+$ ]]; then @@ -464,11 +550,20 @@ wcli() { cap=$(e2e_left) [ "$cap" -gt "$maxc" ] && cap=$maxc [ "$cap" -lt 3 ] && return 124 - if command -v perl >/dev/null 2>&1; then - perl -e 'alarm shift; exec @ARGV' "$cap" \ - node "$WALLET_CLI" --wallet-db="$WDB" --no-throttle --skip-defaults "$@" + if [ -n "${WSERVE_PID:-}" ] && [ -S "${WSOCK:-}" ]; then + if command -v perl >/dev/null 2>&1; then + perl -e 'alarm shift; exec @ARGV' "$cap" \ + node "$WALLET_CLI" --wallet-connection="$WSOCK" --no-throttle "$@" + else + node "$WALLET_CLI" --wallet-connection="$WSOCK" --no-throttle "$@" + fi else - node "$WALLET_CLI" --wallet-db="$WDB" --no-throttle --skip-defaults "$@" + if command -v perl >/dev/null 2>&1; then + perl -e 'alarm shift; exec @ARGV' "$cap" \ + node "$WALLET_CLI" --wallet-db="$WDB" --no-throttle --skip-defaults "$@" + else + node "$WALLET_CLI" --wallet-db="$WDB" --no-throttle --skip-defaults "$@" + fi fi } @@ -486,16 +581,26 @@ wcli_pay() { cap=12 fi info "pay wallet cap" "${cap}s" - if command -v perl >/dev/null 2>&1; then - perl -e 'alarm shift; exec @ARGV' "$cap" \ - node "$WALLET_CLI" --wallet-db="$WDB" --no-throttle --skip-defaults "$@" + if [ -n "${WSERVE_PID:-}" ] && [ -S "${WSOCK:-}" ]; then + if command -v perl >/dev/null 2>&1; then + perl -e 'alarm shift; exec @ARGV' "$cap" \ + node "$WALLET_CLI" --wallet-connection="$WSOCK" --no-throttle "$@" + else + node "$WALLET_CLI" --wallet-connection="$WSOCK" --no-throttle "$@" + fi else - node "$WALLET_CLI" --wallet-db="$WDB" --no-throttle --skip-defaults "$@" + if command -v perl >/dev/null 2>&1; then + perl -e 'alarm shift; exec @ARGV' "$cap" \ + node "$WALLET_CLI" --wallet-db="$WDB" --no-throttle --skip-defaults "$@" + else + node "$WALLET_CLI" --wallet-db="$WDB" --no-throttle --skip-defaults "$@" + fi fi } # When coins never become available: run inside + targeted bank/exchange dig dig_when_no_coins() { + set_group dig section "e2e · coins missing — inside dig" info "reason" "wallet empty after withdraw path (often wirewatch timing) — probing bank + exchange" if [ -n "${WID:-}" ]; then @@ -580,6 +685,7 @@ BAL_BEFORE=$(fmt_bal "$SCRATCH/bal-before.out") info "BALANCE before" "$BAL_BEFORE" # --------------------------------------------------------------------------- +set_group bank section "e2e · bank (account · credit · withdraw)" # --------------------------------------------------------------------------- AT="" @@ -678,8 +784,11 @@ else fi # --------------------------------------------------------------------------- +set_group wallet section "e2e · wallet setup (exchange + ToS once)" # --------------------------------------------------------------------------- +# Long-lived serve = client/server wallet (shepherd keeps running; no run-until-done). +start_wallet_serve || true if ! wcli exchanges add "${EXCHANGE_PUBLIC}/" >"$SCRATCH/ex-add.out" 2>&1; then if ! grep -qiE 'already|ok' "$SCRATCH/ex-add.out"; then blocker "wallet-exchange" "exchanges add failed — $(tail -c 160 "$SCRATCH/ex-add.out" | tr '\n' ' ')" @@ -774,12 +883,13 @@ e2e_one_withdraw() { fi cp "$SCRATCH/accept-$tag.out" "$SCRATCH/accept.out" + # Poll bank withdrawal status only (no run-until-done). force-select if stuck pending. st="" - for i in 1 2 3 4; do + for i in 1 2 3 4 5 6 8 10; do e2e_over && break - wcli run-until-done >"$SCRATCH/sel-$tag-$i.out" 2>&1 || true st=$(wd_status) case "$st" in selected|confirmed|aborted) break ;; esac + sleep 0.4 done if [ "$st" != "selected" ] && [ "$st" != "confirmed" ]; then wcli transactions >"$SCRATCH/tx-pre.json" 2>&1 || true @@ -824,10 +934,9 @@ else: fi fake_incoming_speedup - # Short per-ATM poll; full settle wait happens after the ladder (avoids false FAIL) - local ok_bal=0 r av - for r in 1 2 3 4 5 6; do - wcli run-until-done >"$SCRATCH/run-$tag-$r.out" 2>&1 || true + # Short per-ATM settle: poll balance + bank transfer_done only (never run-until-done) + local ok_bal=0 r av xfer + for r in 1 2 3 4 5 6 8 10; do wcli_bal_snap "$SCRATCH/bal-$tag.out" || wcli balance >"$SCRATCH/bal-$tag.out" 2>&1 || true av=$(wallet_avail_num "$SCRATCH/bal-$tag.out") if python3 -c "import sys; sys.exit(0 if float(sys.argv[1]) > 0 else 1)" "$av" 2>/dev/null; then @@ -836,6 +945,13 @@ else: info "balance" "$(fmt_bal "$SCRATCH/bal-$tag.out")" break fi + xfer=$(curl -sS -m 8 "$BANK/taler-integration/withdrawal-operation/${WID}" 2>/dev/null \ + | python3 -c 'import json,sys; d=json.load(sys.stdin); print(d.get("transfer_done"), d.get("status"))' 2>/dev/null || echo "?") + if echo "$xfer" | grep -qi True; then + ok "ATM $WITHDRAW_AMT bank transfer_done" "wallet avail=${CUR}:${av} ($xfer) — no run-until-done" + ok_bal=1 + break + fi sleep 2 done metrics_report_coins "after-ATM-${tag}" || true @@ -853,8 +969,28 @@ else: return 1 } +# Map CUR:amount → public template id (TESTPAYSAN stage e2e-* templates). +# Override with E2E_TEMPLATE_MAP="0.01=e2e-001 0.05=e2e-005 1=e2e-1" +e2e_template_id_for_amount() { + local amt="$1" + local val map_entry k v + val=$(printf '%s' "$amt" | awk -F: '{print $NF}') + : "${E2E_TEMPLATE_MAP:=0.01=e2e-001 0.05=e2e-005 1=e2e-1 1.0=e2e-1}" + for map_entry in $E2E_TEMPLATE_MAP; do + k="${map_entry%%=*}" + v="${map_entry#*=}" + if [ "$k" = "$val" ]; then + printf '%s' "$v" + return 0 + fi + done + return 1 +} + # One payment: e2e_one_pay AMOUNT [SUMMARY] [TAG] # SUMMARY defaults to "monitoring pay $AMOUNT"; TAG defaults from amount. +# When E2E_USE_TEMPLATES=1 and amount maps to a public template, uses +# POST /instances/{inst}/templates/{id} (same as landings/shops). e2e_one_pay() { PAY_AMT="$1" local PAY_SUM="${2:-monitoring pay ${PAY_AMT}}" @@ -867,11 +1003,28 @@ e2e_one_pay() { section "e2e · pay $PAY_AMT · $(format_amount_alt "$PAY_AMT") · $PAY_SUM" e2e_over && { warn "pay" "budget exhausted — skip $PAY_AMT ($PAY_SUM)"; return 1; } metrics_report_coins "before-pay-${tag}" || true - if [ -z "${MPW:-}" ]; then + + # Prefer public templates when enabled (stage TESTPAYSAN / shop-style orders) + local tpl_id="" + if [ "${E2E_USE_TEMPLATES:-0}" = "1" ]; then + tpl_id=$(e2e_template_id_for_amount "$PAY_AMT" 2>/dev/null || true) + if [ -n "$tpl_id" ]; then + info "pay $PAY_AMT" "via public template $tpl_id (E2E_USE_TEMPLATES=1)" + e2e_one_pay_public_template "$tpl_id" "$PAY_SUM" "$PAY_AMT" + return $? + fi + warn "pay $PAY_AMT" "no template map for amount — fall back to private order" + fi + + if [ -z "${MPW:-}" ] && [ -z "${MPW_TOKEN:-}" ]; then warn "pay $PAY_AMT" "no merchant token" return 1 fi - AUTH="Authorization: Bearer secret-token:${MPW}" + local AUTH + AUTH=$(merchant_auth_header) || { + warn "pay $PAY_AMT" "no merchant token" + return 1 + } # JSON-escape summary for curl -d local SUM_JSON SUM_JSON=$(python3 -c 'import json,sys; print(json.dumps(sys.argv[1]))' "$PAY_SUM" 2>/dev/null || printf '"%s"' "$PAY_SUM") @@ -881,8 +1034,9 @@ e2e_one_pay() { "${MERCHANT_PUBLIC}/instances/${INST}/private/orders" 2>"$SCRATCH/ord-$tag.err" || true if ! grep -q order_id "$SCRATCH/ord-$tag.json" 2>/dev/null; then if [ "$E2E_REMOTE" != "1" ] && koopa_ssh_ok; then + local mpw_raw="${MPW_TOKEN:-secret-token:${MPW}}" koopa_ssh_run 20 \ - "curl -skS -m 12 -X POST -H 'Authorization: Bearer secret-token:${MPW}' -H 'Content-Type: application/json' -d '{\"order\":{\"summary\":${SUM_JSON},\"amount\":\"${PAY_AMT}\",\"fulfillment_message\":\"ok\"},\"create_token\":true}' 'https://127.0.0.1:9010/instances/${INST}/private/orders'" \ + "curl -skS -m 12 -X POST -H 'Authorization: Bearer ${mpw_raw}' -H 'Content-Type: application/json' -d '{\"order\":{\"summary\":${SUM_JSON},\"amount\":\"${PAY_AMT}\",\"fulfillment_message\":\"ok\"},\"create_token\":true}' 'https://127.0.0.1:9010/instances/${INST}/private/orders'" \ >"$SCRATCH/ord-$tag.json" 2>"$SCRATCH/ord-$tag.err" || true fi fi @@ -926,23 +1080,37 @@ except Exception: print("") return 1 fi ok "wallet handle pay $PAY_AMT ($PAY_SUM)" + # Settle: poll merchant order + wallet transactions only (never run-until-done) local r - for r in 1 2 3; do - wcli_pay run-until-done >"$SCRATCH/pay-run-$tag.out" 2>&1 || true + for r in 1 2 3 4 5 6; do wcli_pay transactions >"$SCRATCH/tx-$tag.out" 2>&1 || true - curl -skS -m 8 -o "$SCRATCH/ord-paid-$tag.json" -H "$AUTH" \ - "${MERCHANT_PUBLIC}/instances/${INST}/private/orders/${OID}" 2>/dev/null || true + if [ -n "${AUTH:-}" ]; then + curl -skS -m 8 -o "$SCRATCH/ord-paid-$tag.json" -H "$AUTH" \ + "${MERCHANT_PUBLIC}/instances/${INST}/private/orders/${OID}" 2>/dev/null || true + fi + if [ -n "${OTOK:-}" ]; then + curl -skS -m 8 -o "$SCRATCH/ord-pub-$tag.json" \ + "${MERCHANT_PUBLIC}/instances/${INST}/orders/$(python3 -c 'import urllib.parse,sys; print(urllib.parse.quote(sys.argv[1], safe=""))' "$OID")?token=$(python3 -c 'import urllib.parse,sys; print(urllib.parse.quote(sys.argv[1], safe=""))' "$OTOK")" \ + 2>/dev/null || true + fi if grep -qiE 'payment|paid|Payment' "$SCRATCH/tx-$tag.out" 2>/dev/null \ || grep -qiE 'done|paid|success|Payment' "$SCRATCH/pay-$tag.out" 2>/dev/null \ || python3 -c 'import json,sys -d=json.load(open(sys.argv[1])) -sys.exit(0 if d.get("paid") is True or str(d.get("order_status","")).lower()=="paid" else 1) -' "$SCRATCH/ord-paid-$tag.json" 2>/dev/null; then +for p in sys.argv[1:]: + try: + d=json.load(open(p)) + if d.get("paid") is True or str(d.get("order_status","")).lower()=="paid": + sys.exit(0) + except Exception: + pass +sys.exit(1) +' "$SCRATCH/ord-paid-$tag.json" "$SCRATCH/ord-pub-$tag.json" 2>/dev/null; then ok "payment settled $PAY_AMT ($PAY_SUM · order $OID)" metrics_report_coins "after-pay-${tag}" || true metrics_record_flow spent "$PAY_AMT" || true return 0 fi + sleep 1 done metrics_report_coins "after-pay-fail-${tag}" || true warn "pay $PAY_AMT ($PAY_SUM)" "not settled for order $OID" @@ -1045,33 +1213,34 @@ except Exception: print("") fi ok "shop $pname" "wallet accepted pay URI" + # Settle: public order status + transactions only (never run-until-done) local r AUTH="" - if [ -n "${MPW:-}" ]; then - AUTH="Authorization: Bearer secret-token:${MPW}" - fi - for r in 1 2 3; do - wcli_pay run-until-done >"$SCRATCH/pay-run-$tag.out" 2>&1 || true + AUTH=$(merchant_auth_header 2>/dev/null) || AUTH="" + for r in 1 2 3 4 5 6; do wcli_pay transactions >"$SCRATCH/tx-$tag.out" 2>&1 || true if [ -n "$AUTH" ]; then curl -skS -m 8 -o "$SCRATCH/ord-paid-$tag.json" -H "$AUTH" \ "${MERCHANT_PUBLIC}/instances/${INST}/private/orders/${OID}" 2>/dev/null || true - if python3 -c 'import json,sys -d=json.load(open(sys.argv[1])) -sys.exit(0 if d.get("paid") is True or str(d.get("order_status","")).lower()=="paid" else 1) -' "$SCRATCH/ord-paid-$tag.json" 2>/dev/null; then - ok "shop $pname" "payment settled ($pamt · order $OID)" - metrics_report_coins "after-shop-${tag}" || true - metrics_record_flow spent "$pamt" || true - return 0 - fi fi - if grep -qiE 'payment|paid|Payment' "$SCRATCH/tx-$tag.out" 2>/dev/null \ + curl -skS -m 8 -o "$SCRATCH/ord-pub-$tag.json" "$statusUrl" 2>/dev/null || true + if python3 -c 'import json,sys +for p in sys.argv[1:]: + try: + d=json.load(open(p)) + if d.get("paid") is True or str(d.get("order_status","")).lower()=="paid": + sys.exit(0) + except Exception: + pass +sys.exit(1) +' "$SCRATCH/ord-paid-$tag.json" "$SCRATCH/ord-pub-$tag.json" 2>/dev/null \ + || grep -qiE 'payment|paid|Payment' "$SCRATCH/tx-$tag.out" 2>/dev/null \ || grep -qiE 'done|paid|success|Payment' "$SCRATCH/pay-$tag.out" 2>/dev/null; then - ok "shop $pname" "payment settled via wallet tx ($pamt · order $OID)" + ok "shop $pname" "payment settled ($pamt · order $OID)" metrics_report_coins "after-shop-${tag}" || true metrics_record_flow spent "$pamt" || true return 0 fi + sleep 1 done metrics_report_coins "after-shop-fail-${tag}" || true warn "shop $pname ($pid)" "not settled for order $OID" @@ -1079,6 +1248,7 @@ sys.exit(0 if d.get("paid") is True or str(d.get("order_status","")).lower()=="p } # --------------------------------------------------------------------------- +set_group atm section "e2e · ATM withdraw ladder" # --------------------------------------------------------------------------- WITHDRAW_OK=0 @@ -1109,12 +1279,14 @@ for WITHDRAW_AMT in $WITHDRAW_LIST; do esac done info "ATM withdraw summary" "$WITHDRAW_REPORT (ok=$WITHDRAW_OK_N lag=$WITHDRAW_LAG_N fail=$WITHDRAW_FAIL_N)" +set_group load section "e2e · load snapshot (after ATM withdraws)" metrics_report_load "${METRICS_DIR}/load-after-withdraw.json" "after-withdraw" || true cp -f "${METRICS_DIR}/load-after-withdraw.json" "${METRICS_DIR}/load-after.json" 2>/dev/null || true metrics_report_coins "after-ATM-ladder" || true # Settlement catch-up: bank may have confirmed while wallet was still empty +set_group settle section "e2e · wallet settlement (timing)" if ! wait_wallet_balance 0 "${E2E_SETTLE_ROUNDS}" "${E2E_SETTLE_SLEEP}"; then if [ "$WITHDRAW_LAG_N" -gt 0 ] || [ "$WITHDRAW_OK_N" -gt 0 ]; then @@ -1154,6 +1326,7 @@ else fi # --------------------------------------------------------------------------- +set_group pay section "e2e · variable payments (if balance allows)" # --------------------------------------------------------------------------- PAY_OK=0 @@ -1161,13 +1334,16 @@ PAY_OK_N=0 PAY_FAIL_N=0 PAY_SKIP_N=0 PAY_REPORT="" -if [ -z "${MPW:-}" ]; then +if [ -z "${MPW:-}" ] && [ "${E2E_USE_TEMPLATES:-0}" != "1" ]; then if [ "$E2E_REMOTE" = "1" ]; then - e2e_abort_auth "merchant-order" "no merchant token — set E2E_MERCHANT_TOKEN" + e2e_abort_auth "merchant-order" "no merchant token — set E2E_MERCHANT_TOKEN (or E2E_USE_TEMPLATES=1)" fi blocker "merchant-order" "no merchant token" exit 1 fi +if [ -z "${MPW:-}" ] && [ "${E2E_USE_TEMPLATES:-0}" = "1" ]; then + info "pay" "no merchant token — public templates only (E2E_USE_TEMPLATES=1)" +fi for PAY_AMT in $PAY_LIST; do e2e_over && { warn "pay ladder" "time budget low — stopping more payments"; break; } @@ -1193,6 +1369,7 @@ for PAY_AMT in $PAY_LIST; do fi done info "pay summary" "$PAY_REPORT (ok=$PAY_OK_N fail=$PAY_FAIL_N skip=$PAY_SKIP_N)" +set_group load section "e2e · load snapshot (after payments)" metrics_report_load "${METRICS_DIR}/load-after-pay.json" "after-pay" || true cp -f "${METRICS_DIR}/load-after-pay.json" "${METRICS_DIR}/load-after.json" 2>/dev/null || true @@ -1207,18 +1384,47 @@ if [ "$PAY_OK" != "1" ]; then fi # --------------------------------------------------------------------------- -section "e2e · GOA shop products (merchant landing catalog)" +set_group shop +section "e2e · shop products (public templates)" # --------------------------------------------------------------------------- -# Public template POST + wallet pay — full catalog list, random pick of N products. +# Public template POST + wallet pay — catalog random pick of N products. +# GOA: goa-shop on hacktivism. TESTPAYSAN: farmer shops (jardin / fermes). SHOP_OK=0 SHOP_OK_N=0 SHOP_FAIL_N=0 SHOP_SKIP_N=0 SHOP_REPORT="" -if [ "$E2E_REMOTE" = "1" ] || [ "${CUR:-}" != "GOA" ]; then - info "goa-shop" "SKIPPED (remote or non-GOA currency)" +# Auto catalog for stage TESTPAYSAN when not overridden +if [ "${CUR:-}" = "TESTPAYSAN" ] && [ -z "${E2E_SHOP_PRODUCTS_SET:-}" ] && [ -z "${E2E_SHOP_FORCE_GOA:-}" ]; then + # id|name|amount|instance — force stage catalog (GOA default is set earlier) + E2E_SHOP_PRODUCTS="oeufs-6|Œufs plein air × 6|TESTPAYSAN:5|jardin-du-creux +pain-seigle|Pain de seigle 800 g|TESTPAYSAN:4.5|jardin-du-creux +jus-pomme|Jus de pomme 1 L|TESTPAYSAN:6|jardin-du-creux +panier-legumes|Panier légumes|TESTPAYSAN:25|fermes-des-collines +fromage-chevre|Fromage de chèvre 200 g|TESTPAYSAN:8.5|fermes-des-collines +miel-printemps|Miel de printemps 250 g|TESTPAYSAN:12|fermes-des-collines" + # Force stage defaults (GOA goa-shop is already set earlier via :=) + if [ -z "${E2E_SHOP_INSTANCE_SET:-}" ]; then + E2E_SHOP_INSTANCE=jardin-du-creux + fi + if [ -z "${E2E_SHOP_PAYTO_SET:-}" ]; then + E2E_SHOP_PAYTO="payto://x-taler-bank/stage.bank.lefrancpaysan.ch/jardin-du-creux" + fi + E2E_SHOP_PICK_N="${E2E_SHOP_PICK_N:-2}" + E2E_SHOP_ENABLE=1 +fi +# GOA local only by default; remote non-GOA shops need E2E_SHOP_ENABLE=1 or TESTPAYSAN auto above +if [ -z "${E2E_SHOP_ENABLE:-}" ]; then + if [ "$E2E_REMOTE" = "1" ] || [ "${CUR:-}" != "GOA" ]; then + E2E_SHOP_ENABLE=0 + else + E2E_SHOP_ENABLE=1 + fi +fi +if [ "${E2E_SHOP_ENABLE}" != "1" ]; then + info "shop" "SKIPPED (set E2E_SHOP_ENABLE=1 or use TESTPAYSAN stage catalog)" else - # Build catalog array from E2E_SHOP_PRODUCTS (id|name|amount lines) + # Build catalog array from E2E_SHOP_PRODUCTS (id|name|amount[|instance] lines) SHOP_CATALOG=() while IFS= read -r line; do [ -z "$line" ] && continue @@ -1232,7 +1438,7 @@ EOF case "$PICK_N" in ''|*[!0-9]*) PICK_N=2 ;; esac if [ "$PICK_N" -lt 1 ]; then PICK_N=1; fi if [ "$SHOP_CAT_N" -eq 0 ]; then - warn "goa-shop" "empty E2E_SHOP_PRODUCTS catalog" + warn "shop" "empty E2E_SHOP_PRODUCTS catalog" else if [ "$PICK_N" -gt "$SHOP_CAT_N" ]; then PICK_N=$SHOP_CAT_N; fi # Shuffle catalog, take first PICK_N (portable: python) @@ -1245,24 +1451,26 @@ random.shuffle(lines) print('\n'.join(lines[:n])) " "$PICK_N" ) - info "goa-shop" "catalog ${SHOP_CAT_N} products · random pick ${PICK_N} · instance ${E2E_SHOP_INSTANCE}" - info "goa-shop payto" "$E2E_SHOP_PAYTO" - info "goa-shop pick" "$(printf '%s\n' "$SHOP_PICKED" | cut -d'|' -f1,2 | tr '\n' '; ' | sed 's/; $//')" - # e2e_one_pay_public_template uses global INST — temporarily pin shop instance + info "shop" "catalog ${SHOP_CAT_N} products · random pick ${PICK_N} · default instance ${E2E_SHOP_INSTANCE}" + info "shop payto" "$E2E_SHOP_PAYTO" + info "shop pick" "$(printf '%s\n' "$SHOP_PICKED" | cut -d'|' -f1,2 | tr '\n' '; ' | sed 's/; $//')" + # e2e_one_pay_public_template uses global INST — pin per product if 4th field set _E2E_INST_SAVE="$INST" INST="${E2E_SHOP_INSTANCE}" while IFS= read -r line; do [ -z "$line" ] && continue - e2e_over && { warn "goa-shop" "time budget low — stopping product pays"; break; } + e2e_over && { warn "shop" "time budget low — stopping product pays"; break; } pid=$(printf '%s' "$line" | cut -d'|' -f1) pname=$(printf '%s' "$line" | cut -d'|' -f2) pamt=$(printf '%s' "$line" | cut -d'|' -f3) + pinst=$(printf '%s' "$line" | cut -d'|' -f4) [ -z "$pid" ] || [ -z "$pamt" ] && continue [ -z "$pname" ] && pname="$pid" + INST="${pinst:-${E2E_SHOP_INSTANCE}}" av_now=$(wallet_avail_num) pay_n=$(python3 -c 'import sys; print(float(sys.argv[1].split(":",1)[-1]))' "$pamt" 2>/dev/null || echo 0) if ! python3 -c "import sys; sys.exit(0 if float(sys.argv[1]) + 1e-12 >= float(sys.argv[2]) else 1)" "$av_now" "$pay_n" 2>/dev/null; then - warn "goa-shop $pname ($pid)" "skip — insufficient avail ${CUR}:${av_now} (need ${pay_n})" + warn "shop $pname ($pid)" "skip — insufficient avail ${CUR}:${av_now} (need ${pay_n})" SHOP_SKIP_N=$((SHOP_SKIP_N + 1)) SHOP_REPORT="${SHOP_REPORT}${SHOP_REPORT:+ }${pname}=SKIP(bal)" continue @@ -1286,9 +1494,9 @@ $SHOP_PICKED EOF INST="$_E2E_INST_SAVE" unset _E2E_INST_SAVE - info "goa-shop summary" "$SHOP_REPORT (ok=$SHOP_OK_N fail=$SHOP_FAIL_N skip=$SHOP_SKIP_N · pick $PICK_N/$SHOP_CAT_N)" + info "shop summary" "$SHOP_REPORT (ok=$SHOP_OK_N fail=$SHOP_FAIL_N skip=$SHOP_SKIP_N · pick $PICK_N/$SHOP_CAT_N)" if [ "$SHOP_OK" != "1" ] && [ "$SHOP_FAIL_N" -gt 0 ]; then - warn "goa-shop" "no sample product payment succeeded ($SHOP_REPORT)" + warn "shop" "no sample product payment succeeded ($SHOP_REPORT)" fi section "e2e · load snapshot (after shop pays)" metrics_report_load "${METRICS_DIR}/load-after-shop.json" "after-shop" || true @@ -1297,6 +1505,7 @@ EOF fi # --------------------------------------------------------------------------- +set_group paivana section "e2e · paivana paywall (template GOA:4200)" # --------------------------------------------------------------------------- # Public paywall: https://paivana.hacktivism.ch → 302 to merchant template "paivana". @@ -1388,11 +1597,12 @@ else info "paivana summary" "${PAIVANA_REPORT:-?} · instance ${E2E_PAIVANA_INSTANCE}" fi +set_group report section "e2e · report" info "user" "$USER" info "ATM withdraws" "$WITHDRAW_REPORT" info "payments" "$PAY_REPORT" -info "goa-shop" "${SHOP_REPORT:-(n/a)}" +info "shop" "${SHOP_REPORT:-(n/a)}" info "paivana" "${PAIVANA_REPORT:-(n/a)}" info "final balance" "$(fmt_bal "$SCRATCH/bal-live.out" 2>/dev/null || echo "(n/a)")" info "scratch" "$SCRATCH" diff --git a/scripts/taler-monitoring/check_goa_ladder.sh b/scripts/taler-monitoring/check_goa_ladder.sh index 28d71ed..8b311ce 100755 --- a/scripts/taler-monitoring/check_goa_ladder.sh +++ b/scripts/taler-monitoring/check_goa_ladder.sh @@ -29,7 +29,10 @@ ROOT=$(cd "$(dirname "$0")" && pwd) # shellcheck source=lib.sh source "$ROOT/lib.sh" +# Area ladder.* — GOA withdraw/pay ladder +# Groups: ladder.plan / ladder.load / ladder.withdraw / ladder.pay / ladder.report set_area ladder +set_group plan SECTION_T0=$(date +%s) now_ms() { python3 -c 'import time; print(int(time.time()*1000))'; } elapsed_ms() { @@ -261,6 +264,7 @@ info "steps" "${LADDER_STEPS} (0 + random≥${LADDER_MIN_AMOUNT} + max-1=${CUR}: info "withdraw_scale" "${LADDER_WITHDRAW_SCALE}× pay mids (fund pay ladder)" info "pay_phase" "$([ "${LADDER_PAY}" = "1" ] && echo enabled || echo disabled)" +set_group load section "ladder · load snapshot (before withdraws)" metrics_report_load "$LOAD_BEFORE" "ladder-start" || true # Fresh wallet per rung — baseline empty (or last-rung DB if re-used later) @@ -344,6 +348,7 @@ STOP_REASON="" STOP_AMOUNT="" declare -a RUNG_JSON=() +set_group withdraw section "ladder · phase A · withdraw (${LADDER_N} rungs)" rung=0 for AMT in "$@"; do @@ -658,12 +663,7 @@ else: fi } - # Short bounded select assist (≤8s) — not a hang path; helps attach fresh reserve_pub - if command -v perl >/dev/null 2>&1 && [ -f "$CLI_JS" ]; then - perl -e 'alarm shift; exec @ARGV' 8 \ - node "$CLI_JS" --wallet-db="$WDB" --no-throttle run-until-done \ - >"$SCRATCH/select-$tag.out" 2>&1 || true - fi + # No run-until-done (hangs / banned). Read transactions only for reserve_pub candidates. wcli transactions >"$SCRATCH/tx-$tag.json" 2>&1 || true t0=$(now_ms) @@ -787,6 +787,7 @@ done PAY_OK_N=0 PAY_FAIL_N=0 if [ "${LADDER_PAY}" = "1" ] && [ -n "${PAY_LIST:-}" ] && [ "$FAIL_N_L" -eq 0 ]; then + set_group pay section "ladder · phase B · pay" metrics_report_coins "before-pay-ladder" || true # Merchant secret (same as e2e) @@ -959,19 +960,12 @@ except Exception: print("") ms_handle=$(elapsed_ms "$t0") ok "handle-uri $PAMT ${ms_handle}ms" - # Short settle polls (bounded — no infinite hang) + # Short settle polls: merchant order + wallet tx only — never run-until-done t0=$(now_ms) settled=0 r=0 while [ "$r" -lt "${LADDER_PAY_SETTLE_ROUNDS}" ]; do r=$((r + 1)) - if command -v perl >/dev/null 2>&1; then - perl -e 'alarm shift; exec @ARGV' 10 \ - "$CLI_JS" --wallet-db="$WDB" --no-throttle run-until-done \ - >"$SCRATCH/pay-run-$ptag.out" 2>&1 || true - else - wcli run-until-done >"$SCRATCH/pay-run-$ptag.out" 2>&1 || true - fi wcli transactions >"$SCRATCH/tx-$ptag.out" 2>&1 || true curl -skS -m 8 -o "$SCRATCH/ord-paid-$ptag.json" -H "$AUTH" \ "${MER}/instances/${INST}/private/orders/${OID}" 2>/dev/null || true @@ -983,6 +977,7 @@ sys.exit(0 if d.get("paid") is True or str(d.get("order_status","")).lower()=="p settled=1 break fi + sleep 0.5 done ms_psettle=$(elapsed_ms "$t0") ms_total=$(elapsed_ms "$t_pay") @@ -1024,6 +1019,7 @@ fi ms_phase=$(python3 -c 'import sys,time; print(int((time.time()-float(sys.argv[1]))*1000))' "$SECTION_T0") # --- report --- +set_group report section "ladder · report" info "auto-account" "$ACCT_USER" info "ok_rungs" "$OK_N" @@ -1128,6 +1124,7 @@ PY wcli balance 2>&1 | tee "$REPORT_DIR/balance-final.out" | tail -20 || true +set_group load section "ladder · load snapshot (after withdraws)" metrics_report_load "$LOAD_AFTER" "ladder-end" || true if [ -f "$LOAD_BEFORE" ] && [ -f "$LOAD_AFTER" ]; then diff --git a/scripts/taler-monitoring/check_inside.sh b/scripts/taler-monitoring/check_inside.sh index f49b4b6..2d012ed 100755 --- a/scripts/taler-monitoring/check_inside.sh +++ b/scripts/taler-monitoring/check_inside.sh @@ -7,8 +7,10 @@ source "$ROOT/lib.sh" # shellcheck source=metrics.sh source "$ROOT/metrics.sh" -# Area inside-### — container / process state on koopa (SSH) +# Area inside.* — container / process state on koopa (SSH) +# Groups: inside.ssh / inside.bank / inside.exchange / inside.merchant / inside.caddy set_area inside +set_group ssh section "inside · collect from koopa" if [ "${SKIP_SSH}" = "1" ]; then @@ -128,10 +130,20 @@ if [ -z "$RAW" ] || ! echo "$RAW" | grep -q '^E|'; then exit 1 fi +_last_inside_grp="" while IFS= read -r line; do case "$line" in E\|*) IFS='|' read -r _ comp level key detail <<<"$line" + # Group IDs by component so issues map cleanly: inside.bank-02, inside.exchange-04 + case "$comp" in + bank|exchange|merchant|caddy) _g="$comp" ;; + *) _g="ssh" ;; + esac + if [ "$_g" != "$_last_inside_grp" ]; then + set_group "$_g" + _last_inside_grp="$_g" + fi case "$level" in OK) ok "[$comp] $key${detail:+ ($detail)}" ;; ERROR) err "$comp" "$key" "$detail" ;; @@ -143,6 +155,7 @@ while IFS= read -r line; do done <<<"$RAW" # Host loadavg + RAM + per-container RSS/CPU (same probe as e2e/ladder) +set_group load section "inside · load / memory" METRICS_DIR="${METRICS_DIR:-$(mktemp -d)}" export METRICS_DIR diff --git a/scripts/taler-monitoring/check_sanity.sh b/scripts/taler-monitoring/check_sanity.sh index 1486e9b..1e3b922 100755 --- a/scripts/taler-monitoring/check_sanity.sh +++ b/scripts/taler-monitoring/check_sanity.sh @@ -23,10 +23,12 @@ json_currency() { python3 -c 'import json,sys; d=json.load(open(sys.argv[1])); print(d.get("currency") or "")' "$1" 2>/dev/null || true } -# Area sanity-### — public + optional server-side per component +# Area sanity.* — public + optional server-side per component +# Groups: sanity.bank / sanity.exchange / sanity.merchant set_area sanity # --------------------------------------------------------------------------- +set_group bank section "sanity · bank" # --------------------------------------------------------------------------- expect_code "bank public /config" 200 "$BANK_PUBLIC/config" @@ -98,6 +100,7 @@ else fi # --------------------------------------------------------------------------- +set_group exchange section "sanity · exchange" # --------------------------------------------------------------------------- expect_code "exchange public /config" 200 "$EXCHANGE_PUBLIC/config" @@ -193,6 +196,7 @@ else fi # --------------------------------------------------------------------------- +set_group merchant section "sanity · merchant" # --------------------------------------------------------------------------- expect_code "merchant public /config" 200 "$MERCHANT_PUBLIC/config" diff --git a/scripts/taler-monitoring/check_server.sh b/scripts/taler-monitoring/check_server.sh index 961b4c8..8412a12 100755 --- a/scripts/taler-monitoring/check_server.sh +++ b/scripts/taler-monitoring/check_server.sh @@ -5,8 +5,9 @@ ROOT=$(cd "$(dirname "$0")" && pwd) # shellcheck source=lib.sh source "$ROOT/lib.sh" -# Area server-### — host/container ports via SSH +# Area server.* — host/container ports via SSH set_area server +set_group ssh section "server · ssh ${KOOPA_SSH}" if ! koopa_ssh_ok; then diff --git a/scripts/taler-monitoring/check_urls.sh b/scripts/taler-monitoring/check_urls.sh index c5cd78d..55ed1b8 100755 --- a/scripts/taler-monitoring/check_urls.sh +++ b/scripts/taler-monitoring/check_urls.sh @@ -5,7 +5,9 @@ ROOT=$(cd "$(dirname "$0")" && pwd) # shellcheck source=lib.sh source "$ROOT/lib.sh" -# Area www-### — public HTTPS (outside-in) +# Area www.* — public HTTPS (outside-in) +# Groups: www.exchange / www.perf / www.stats / www.bank / www.merchant / +# www.paivana / www.landing set_area www section "www · public URLs · ${TALER_DOMAIN:-?} (outside-in, no SSH)" @@ -169,7 +171,8 @@ sys.exit(0) PY } -# --- exchange (core; always required) --- www-001 … +# --- exchange (core; always required) --- www.exchange-NN +set_group exchange check_url "exchange /config" 200 "$EXCHANGE_PUBLIC/config" code=$(http_body "$EXCHANGE_PUBLIC/config" "$tmp/ec.json") if [ "$code" = "200" ]; then @@ -219,6 +222,7 @@ fi # Performance — outside-in public HTTPS latency (this runner, not loopback) # Same spirit as bank landing-stats public probes; measured here from outside. # --------------------------------------------------------------------------- +set_group perf section "www · performance · public HTTPS latency (outside-in)" # Latency thresholds (ms), outside-in from this runner. @@ -290,11 +294,18 @@ fi info "perf note" "RTT measured from this host (outside-in)" # --------------------------------------------------------------------------- -# Landing load stats (stats.json written *inside* containers, usually public). -# Prefer HTTPS /intro/stats.json (no SSH). If missing and SSH works, cat from -# the container. Report loadavg + RSS + in-container probe ms — one line each. +# Landing stats.json (public outside-in) — freshness + display fields. +# Timestamps (required for external measurement): +# generated_at_unix — epoch seconds (preferred) +# generated_at — ISO-8601 +# generated_at_human — footer / “updated” UI string +# Bank collector always sets all three; exchange/merchant scripts set unix too. +# Env: +# STATS_STALE_SECS warn if age > this (default 900 = 15m; timer often 5–15m) +# STATS_FAIL_SECS hard fail if age > this (default 3600); 0 = never hard-fail on age # --------------------------------------------------------------------------- -: "${STATS_STALE_SECS:=900}" # warn if generated_at older than 15m +: "${STATS_STALE_SECS:=900}" +: "${STATS_FAIL_SECS:=3600}" _fetch_landing_stats_json() { # $1=name bank|exchange|merchant $2=base URL → writes $tmp/stats-$1.json, exit 0 if ok @@ -322,81 +333,265 @@ _fetch_landing_stats_json() { return 1 } -report_landing_load_stats() { +# Validate one stats.json for monitoring display + outside age measurement. +# Prints lines: OK|WARN|ERR|STALE|FAIL +# Sets $tmp/stats-meta-$name.txt with gen_unix|source for shared-feed compare. +check_landing_stats_json() { local name="$1" base="$2" local f="$tmp/stats-${name}.json" - local line age_s + local line hard_missing=0 if ! _fetch_landing_stats_json "$name" "$base"; then if [ "${CHECK_LANDING:-1}" = "1" ] || [ "${LOCAL_STACK:-0}" = "1" ]; then - warn "perf ${name} landing-stats" "stats.json not available (public or SSH)" + fail "stats ${name}" "HTTP/body missing · ${base}/intro/stats.json" + else + warn "stats ${name}" "stats.json not available" fi - return + return 1 fi + ok "stats ${name} reachable" "${base}/intro/stats.json" + + # Multi-line verdict from python (STATUS detail) + while IFS= read -r line; do + case "$line" in + META\ *) + printf '%s\n' "${line#META }" >"$tmp/stats-meta-${name}.txt" + ;; + OK\ *) + ok "stats ${name}" "${line#OK }" + ;; + WARN\ *) + warn "stats ${name}" "${line#WARN }" + ;; + STALE\ *) + warn "stats ${name} freshness" "stale · ${line#STALE }" + ;; + FAIL\ *) + fail "stats ${name}" "${line#FAIL }" + hard_missing=1 + ;; + ERR\ *) + if [ "${LOCAL_STACK:-0}" = "1" ] || [ "${CHECK_LANDING:-1}" = "1" ]; then + fail "stats ${name}" "${line#ERR }" + else + warn "stats ${name}" "${line#ERR }" + fi + hard_missing=1 + ;; + *) + [ -n "$line" ] && warn "stats ${name}" "unparsed · $line" + ;; + esac + done < <(python3 - "$f" "$name" "${STATS_STALE_SECS}" "${STATS_FAIL_SECS}" <<'PY' 2>/dev/null || echo "ERR python check failed" +import json, sys, time, re +from datetime import datetime, timezone + +path, role, stale_s, fail_s = sys.argv[1], sys.argv[2], int(sys.argv[3]), int(sys.argv[4]) +now = int(time.time()) + +def out(kind, msg): + print("%s %s" % (kind, msg)) - line=$(python3 - "$f" "${STATS_STALE_SECS}" <<'PY' 2>/dev/null || true -import json, sys, time -path, stale = sys.argv[1], int(sys.argv[2]) try: d = json.load(open(path)) except Exception as e: - print("ERR parse:" + str(e)[:80]) + out("ERR", "JSON parse: %s" % str(e)[:100]) sys.exit(0) -if not d.get("ok", True): - print("ERR ok=false " + str(d.get("error") or "")[:80]) + +if not isinstance(d, dict): + out("ERR", "body is not a JSON object") sys.exit(0) -p = d.get("performance") or {} -if not isinstance(p, dict) or not p: - print("ERR no performance block") + +if d.get("ok") is False: + out("ERR", "ok=false · %s" % str(d.get("error") or d.get("hint") or "")[:80]) sys.exit(0) -mem = p.get("memory") if isinstance(p.get("memory"), dict) else {} -rss = mem.get("container_rss_human") or mem.get("proc_sum_rss_human") or "?" -load = p.get("loadavg") or "?" -# latency fields differ by site -bits = [] -for k in ("config_ms", "integration_ms", "webui_ms", "keys_ms", "terms_ms"): - if k in p and p[k] is not None: - bits.append("%s=%sms" % (k.replace("_ms", ""), p[k])) -lat = " ".join(bits) if bits else "latency=?" -gen = d.get("generated_at_human") or d.get("generated_at") or "?" -# staleness -age = "" -try: - gu = d.get("generated_at_unix") - if gu is not None: - age_s = int(time.time()) - int(gu) - age = " age=%ss" % age_s - if age_s > stale: - print("STALE loadavg=%s RSS=%s %s gen=%s%s" % (load, rss, lat, gen, age)) - sys.exit(0) -except Exception: - pass -print("OK loadavg=%s RSS=%s %s gen=%s%s" % (load, rss, lat, gen, age)) + +# --- timestamps (outside-in age measurement) --- +gu = d.get("generated_at_unix") +giso = d.get("generated_at") or d.get("generated_at_iso") +ghum = d.get("generated_at_human") +age = None +src_u = None + +if gu is not None and str(gu).strip() != "": + try: + src_u = int(float(gu)) + # tolerate ms accidental timestamps + if src_u > 10_000_000_000: + src_u //= 1000 + age = now - src_u + except Exception: + out("ERR", "generated_at_unix not numeric: %r" % gu) + src_u = None +elif giso: + # parse ISO when unix missing (legacy exchange/merchant before unix field) + try: + s = str(giso).strip() + if s.endswith("Z"): + s = s[:-1] + "+00:00" + # allow +0200 without colon + s = re.sub(r"([+-]\d{2})(\d{2})$", r"\1:\2", s) + dt = datetime.fromisoformat(s) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + src_u = int(dt.timestamp()) + age = now - src_u + out("WARN", "no generated_at_unix — derived age from generated_at ISO (deploy collector with unix)") + except Exception as e: + out("ERR", "cannot parse generated_at=%r (%s)" % (giso, e)) +else: + out("ERR", "missing generated_at_unix and generated_at — cannot measure freshness outside") + +if not ghum and not giso: + out("ERR", "missing generated_at_human/generated_at — UI cannot show “updated” time") +elif ghum: + out("OK", "timestamp human=%s" % ghum) +else: + out("OK", "timestamp iso=%s" % giso) + +if src_u is not None: + out("META", "%s|%s|%s" % (src_u, d.get("source") or "", d.get("currency") or "")) + if age is not None and age < -120: + out("WARN", "clock skew · generated_at_unix in the future by %ss" % (-age)) + if age is not None: + out("OK", "generated_at_unix=%s age=%ss (now=%s)" % (src_u, age, now)) + if fail_s > 0 and age > fail_s: + out("FAIL", "age=%ss > STATS_FAIL_SECS=%s · collector not updating" % (age, fail_s)) + elif age > stale_s: + out("STALE", "age=%ss > STATS_STALE_SECS=%s · timer lag or stuck publish" % (age, stale_s)) + +# --- display fields for landings / monitoring --- +src = str(d.get("source") or "") +# Bank-shaped (incl. stage shared feed on all three landings) +bankish = ( + role == "bank" + or "collect_bank_stats" in src + or "shared" in src + or isinstance(d.get("withdraws"), dict) + or isinstance(d.get("bank_accounts"), dict) +) +if bankish: + missing = [] + if not isinstance(d.get("withdraws"), dict): + missing.append("withdraws") + ba = d.get("bank_accounts") + wl = d.get("wallets") + if not isinstance(ba, dict) and not isinstance(wl, dict): + missing.append("bank_accounts|wallets") + if d.get("currency") in (None, ""): + missing.append("currency") + if missing: + out("ERR", "bank-shaped display missing: %s" % ",".join(missing)) + else: + w = d.get("withdraws") or {} + ba = ba if isinstance(ba, dict) else {} + n_acc = ba.get("total") if ba.get("total") is not None else ba.get("users") + out( + "OK", + "display bank-shape currency=%s accounts=%s withdraws.count=%s source=%s" + % (d.get("currency"), n_acc, w.get("count"), (src or "?")[:48]), + ) +elif src == "exchange-db" or (role == "exchange" and "reserves" in d): + need = [] + for k in ("reserves", "wire_in_count", "known_coins"): + if k not in d and k.replace("known_coins", "coins_live") not in d: + if k == "known_coins" and ("coins_live" in d or "coins_remaining_amount" in d): + continue + need.append(k) + if need: + out("WARN", "exchange display keys missing: %s" % ",".join(need)) + else: + out( + "OK", + "display exchange-db reserves=%s wire_in=%s coins=%s" + % (d.get("reserves"), d.get("wire_in_count"), d.get("known_coins") or d.get("coins_live")), + ) +elif src == "merchant-db" or (role == "merchant" and "instances" in d): + if "instances" not in d and "orders" not in d: + out("ERR", "merchant display missing instances/orders") + else: + out( + "OK", + "display merchant-db instances=%s orders=%s paid=%s" + % (d.get("instances"), d.get("orders"), d.get("paid")), + ) +else: + out("WARN", "unknown stats schema role=%s source=%s keys=%s" % (role, src[:40], ",".join(list(d.keys())[:8]))) + +# performance block optional (stage shared bank feed may omit heavy probes) +p = d.get("performance") +if isinstance(p, dict) and p: + mem = p.get("memory") if isinstance(p.get("memory"), dict) else {} + rss = mem.get("container_rss_human") or mem.get("proc_sum_rss_human") or "—" + load = p.get("loadavg") or "—" + bits = [] + for k in ("config_ms", "integration_ms", "webui_ms", "keys_ms", "terms_ms"): + if k in p and p[k] is not None: + bits.append("%s=%s" % (k.replace("_ms", ""), p[k])) + lat = " ".join(bits) if bits else "latency=—" + out("OK", "performance loadavg=%s RSS=%s %s" % (load, rss, lat)) +else: + out("OK", "performance block optional/absent") PY ) - case "$line" in - OK\ *) - ok "perf ${name} landing-stats" "${line#OK }" - ;; - STALE\ *) - warn "perf ${name} landing-stats" "stale · ${line#STALE }" - ;; - ERR\ *) - warn "perf ${name} landing-stats" "${line#ERR }" - ;; - *) - warn "perf ${name} landing-stats" "unreadable stats.json" - ;; - esac + return 0 } if [ "${CHECK_LANDING:-1}" = "1" ] || [ "${LOCAL_STACK:-0}" = "1" ]; then - section "www · performance · landing load stats (stats.json · in-container probes)" - report_landing_load_stats "bank" "$BANK_PUBLIC" - report_landing_load_stats "exchange" "$EXCHANGE_PUBLIC" - report_landing_load_stats "merchant" "$MERCHANT_PUBLIC" - info "perf landing-stats note" "from /intro/stats.json (public); SSH container fallback if needed" + set_group stats + section "www · landing stats.json (freshness + display fields · outside-in)" + info "stats policy" "STALE≥${STATS_STALE_SECS}s WARN · FAIL≥${STATS_FAIL_SECS}s ERROR (0=disable fail) · need generated_at_unix" + check_landing_stats_json "bank" "$BANK_PUBLIC" || true + check_landing_stats_json "exchange" "$EXCHANGE_PUBLIC" || true + check_landing_stats_json "merchant" "$MERCHANT_PUBLIC" || true + + # Shared feed: stage-style publish copies same bank stats to all three URLs + if [ -f "$tmp/stats-meta-bank.txt" ] && [ -f "$tmp/stats-meta-exchange.txt" ] && [ -f "$tmp/stats-meta-merchant.txt" ]; then + shared_line=$(python3 - "$tmp/stats-meta-bank.txt" "$tmp/stats-meta-exchange.txt" "$tmp/stats-meta-merchant.txt" <<'PY' 2>/dev/null || true +import sys +def parse(p): + try: + t = open(p).read().strip().split("|", 2) + return t[0], (t[1] if len(t) > 1 else ""), (t[2] if len(t) > 2 else "") + except Exception: + return "", "", "" +b, bs, bc = parse(sys.argv[1]) +e, es, ec = parse(sys.argv[2]) +m, ms, mc = parse(sys.argv[3]) +if not b or not e or not m: + print("SKIP incomplete meta") +elif b == e == m: + print("SHARED gen_unix=%s bank_src=%s" % (b, (bs or "?")[:50])) +else: + # Shared feed only when all three claim the same bank-collector source + # (e.g. stage TESTPAYSAN "… shared"). GOA uses independent exchange-db / + # merchant-db collectors — different gen_unix is expected. + def bankish(s): + s = (s or "").lower() + return "shared" in s or "collect_bank_stats" in s + if bankish(bs) and bankish(es) and bankish(ms): + print("DRIFT bank=%s exchange=%s merchant=%s (expected equal for shared feed)" % (b, e, m)) + else: + print("INDEPENDENT bank=%s exchange=%s merchant=%s" % (b, e, m)) +PY +) + case "$shared_line" in + SHARED\ *) + ok "stats shared feed" "${shared_line#SHARED }" + ;; + DRIFT\ *) + warn "stats shared feed" "timestamps differ · ${shared_line#DRIFT }" + ;; + INDEPENDENT\ *) + info "stats feeds" "independent collectors · ${shared_line#INDEPENDENT }" + ;; + *) + info "stats feeds" "${shared_line:-n/a}" + ;; + esac + fi + info "stats note" "public GET /intro/stats.json; measure age via generated_at_unix vs wall clock" fi # Rollup + optional JSON for metrics_print_overall if [ -s "$PERF_TSV" ]; then @@ -470,7 +665,8 @@ fi info "perf note" "measured from this host (outside-in); thresholds PERF_WARN_MS=${PERF_WARN_MS} PERF_FAIL_MS=${PERF_FAIL_MS}" -# Terms + privacy (legal docs) +# Exchange terms + privacy (same www.exchange group — issues map to exchange) +set_group exchange check_legal_doc "exchange /terms" "$EXCHANGE_PUBLIC/terms" "terms|GOA|exploration|FADP|revDSG|privacy" check_legal_doc "exchange /privacy" "$EXCHANGE_PUBLIC/privacy" "privacy|FADP|revDSG|data|GOA|exploration" # trailing slash: 200 or redirect to bare path @@ -485,6 +681,7 @@ case "$code" in esac # --- bank --- +set_group bank if [ "${LOCAL_STACK:-1}" = "0" ]; then check_url_soft "bank /config" 200 "$BANK_PUBLIC/config" else @@ -508,7 +705,7 @@ if [ "$code" = "200" ]; then check_url_soft "bank /webui/" 200 "$BANK_PUBLIC/webui/" check_url_soft "bank /intro/" 200 "$BANK_PUBLIC/intro/" check_url_soft "bank /" 302,301,200 "$BANK_PUBLIC/" - # Auto-account: credentials + shared-pool taler://withdraw (like step 2) + # Auto-account: GOA shared-pool mint (hacktivism). Not used on TESTPAYSAN stage. aa_code=$(http_body "$BANK_PUBLIC/intro/auto-account.json" "$tmp/aa.json") case "$aa_code" in 200) @@ -518,11 +715,22 @@ if [ "$code" = "200" ]; then fail "bank /intro/auto-account.json" "invalid withdraw/login ($(tr '\n' ' ' <"$tmp/aa-val" | sed 's/[[:space:]]*$//'))" fi ;; - 405|501|404|502|503|000) - fail "bank /intro/auto-account.json" "HTTP $aa_code (want 200; 405/501 = broken)" + 404|405|501) + if [ "${EXPECT_CURRENCY:-}" = "GOA" ] || [ "${LOCAL_STACK:-0}" = "1" ]; then + fail "bank /intro/auto-account.json" "HTTP $aa_code (want 200; 405/501 = broken)" + else + info "bank /intro/auto-account.json" "HTTP $aa_code — skip (not a GOA auto-account stack)" + fi + ;; + 502|503|000) + fail "bank /intro/auto-account.json" "HTTP $aa_code want 200" ;; *) - fail "bank /intro/auto-account.json" "HTTP $aa_code want 200" + if [ "${EXPECT_CURRENCY:-}" = "GOA" ] || [ "${LOCAL_STACK:-0}" = "1" ]; then + fail "bank /intro/auto-account.json" "HTTP $aa_code want 200" + else + warn "bank /intro/auto-account.json" "HTTP $aa_code (optional off-GOA)" + fi ;; esac fi @@ -545,6 +753,7 @@ else fi # --- merchant --- +set_group merchant if [ "${LOCAL_STACK:-1}" = "0" ]; then check_url_soft "merchant /config" 200 "$MERCHANT_PUBLIC/config" else @@ -610,6 +819,7 @@ esac # Paivana paywall (local GOA stack) — public front only; pay path is e2e # --------------------------------------------------------------------------- if [ "${LOCAL_STACK:-1}" = "1" ] && [ "${E2E_PAIVANA:-1}" != "0" ]; then + set_group paivana section "www · paivana paywall" : "${PAIVANA_PUBLIC:=https://paivana.hacktivism.ch}" PAIVANA_PUBLIC="${PAIVANA_PUBLIC%/}" @@ -646,6 +856,7 @@ if [ "${CHECK_LANDING:-1}" != "1" ]; then exit 0 fi +set_group landing section "www · landing exposed links · bank / merchant / exchange" # Probe one URL: print code to stdout (200 after following redirects counts as 200). @@ -803,18 +1014,24 @@ check_one_landing() { return fi - # Required static assets (hard: qrcode + og; soft: qr-logo) - for url in \ - "${base}/intro/qrcode.min.js" \ - "${base}/intro/og-goa-shop.png" - do - if _landing_probe "$url"; then - a_ok=$((a_ok + 1)) - else + # Required static: qrcode.min.js. GOA og image is soft off-GOA (stage has no og-goa-shop.png). + if _landing_probe "${base}/intro/qrcode.min.js"; then + a_ok=$((a_ok + 1)) + else + a_fail=$((a_fail + 1)) + fail_sample="${fail_sample}${fail_sample:+; }HTTP ${_landing_code} ${base}/intro/qrcode.min.js" + fi + if _landing_probe "${base}/intro/og-goa-shop.png"; then + a_ok=$((a_ok + 1)) + else + if [ "${EXPECT_CURRENCY:-}" = "GOA" ] || [ "${LOCAL_STACK:-0}" = "1" ]; then a_fail=$((a_fail + 1)) - fail_sample="${fail_sample}${fail_sample:+; }HTTP ${_landing_code} $url" + fail_sample="${fail_sample}${fail_sample:+; }HTTP ${_landing_code} ${base}/intro/og-goa-shop.png" + else + a_soft=$((a_soft + 1)) + soft_sample="${soft_sample}${soft_sample:+; }HTTP ${_landing_code} og-goa-shop.png (GOA asset)" fi - done + fi if _landing_probe "${base}/intro/qr-logo.png"; then a_ok=$((a_ok + 1)) else @@ -942,7 +1159,15 @@ if [ "${LOCAL_STACK:-1}" = "1" ] || [ -n "${BANK_PUBLIC:-}" ]; then fail "bank /intro/demo-withdraw.json" "invalid taler://withdraw ($(tr '\n' ' ' <"$tmp/dw-val" | sed 's/[[:space:]]*$//'))" fi ;; - 405|501|404|502|503|000) + 404|405|501) + # GOA bank landing mints demo-withdraw.json; stage TESTPAYSAN uses ATM/wallet guide only + if [ "${EXPECT_CURRENCY:-}" = "GOA" ] || [ "${LOCAL_STACK:-0}" = "1" ]; then + fail "landing bank demo-withdraw" "HTTP $dw_code (want 200)" + else + info "landing bank demo-withdraw" "HTTP $dw_code — skip (not a GOA demo-withdraw stack)" + fi + ;; + 502|503|000) fail "landing bank demo-withdraw" "HTTP $dw_code (want 200)" ;; *) diff --git a/scripts/taler-monitoring/check_versions.sh b/scripts/taler-monitoring/check_versions.sh index eed9e5f..ea4df2f 100755 --- a/scripts/taler-monitoring/check_versions.sh +++ b/scripts/taler-monitoring/check_versions.sh @@ -19,6 +19,7 @@ ROOT=$(cd "$(dirname "$0")" && pwd) source "$ROOT/lib.sh" set_area versions +set_group outside SUITE="${TALER_APT_SUITE:-trixie}" APT_BASE="${TALER_APT_BASE:-https://deb.taler.net/apt/debian}" @@ -166,6 +167,7 @@ fi # --------------------------------------------------------------------------- # 2) INSIDE — containers can reach deb.taler.net (pasta / install path) # --------------------------------------------------------------------------- +set_group inside section "versions · inside · containers → deb.taler.net" if [ "${SKIP_SSH}" = "1" ]; then @@ -266,6 +268,7 @@ ok "collected installed packages" "${n_inst} rows" # --------------------------------------------------------------------------- # 3) compare installed vs trixie (and note testing) # --------------------------------------------------------------------------- +set_group compare section "versions · compare installed vs ${SUITE}" export BEHIND_MODE HAVE_TESTING diff --git a/scripts/taler-monitoring/lib.sh b/scripts/taler-monitoring/lib.sh index 5df0819..65c6005 100755 --- a/scripts/taler-monitoring/lib.sh +++ b/scripts/taler-monitoring/lib.sh @@ -193,6 +193,13 @@ apply_taler_domain() { PAY_AMT="${PAY_AMT:-${EXPECT_CURRENCY}:0.01}" CREDIT_AMT="${CREDIT_AMT:-${EXPECT_CURRENCY}:100}" ;; + TESTPAYSAN) + # Stage FrancPaysan: default instance + public templates (e2e-001/005/1) + MERCHANT_INSTANCE="${MERCHANT_INSTANCE:-default}" + WITHDRAW_AMT="${WITHDRAW_AMT:-TESTPAYSAN:20}" + PAY_AMT="${PAY_AMT:-TESTPAYSAN:0.01}" + CREDIT_AMT="${CREDIT_AMT:-TESTPAYSAN:100}" + ;; CHF) WITHDRAW_AMT="${WITHDRAW_AMT:-CHF:20}" PAY_AMT="${PAY_AMT:-CHF:0.01}" @@ -352,17 +359,30 @@ koopa_ssh_python() { with_timeout "$t" ssh "${SSH_BASE_OPTS[@]}" "${KOOPA_SSH}" python3 - } -# ANSI colours for tags (green OK / yellow WARN / red ERROR·BLOCKER / cyan INFO). -# Default: always on so pasted logs keep visible tags. Disable: NO_COLOR=1 or CLICOLOR=0. +# ANSI colours — boxed badges (fg+bg) + body (label bold, detail dim). +# Off: NO_COLOR=1 or CLICOLOR=0. if [ "${NO_COLOR:-0}" = "1" ] || [ "${CLICOLOR:-1}" = "0" ]; then - G= R= Y= C= N= B= + G= R= Y= C= M= D= W= B= N= + BG_OK= BG_INFO= BG_WARN= BG_ERR= BG_BLK= BG_SEC= + BOX=0 else - G=$'\e[1;32m' # bold green [OK] - R=$'\e[1;31m' # bold red [ERROR] [BLOCKER] - Y=$'\e[1;33m' # bold yellow [WARN] - C=$'\e[1;36m' # bold cyan [INFO] - B=$'\e[1m' - N=$'\e[0m' + G=$'\e[1;32m' # green text (totals) + R=$'\e[1;31m' # red text + Y=$'\e[1;33m' # yellow text + C=$'\e[1;36m' # cyan text + M=$'\e[1;35m' # magenta text + D=$'\e[2m' # dim tid / detail + W=$'\e[1;37m' # bold white label + B=$'\e[1m' # bold section + N=$'\e[0m' # reset + # Badge fill: bright text on solid background + BG_OK=$'\e[1;30;42m' # black on green + BG_INFO=$'\e[1;30;46m' # black on cyan + BG_WARN=$'\e[1;30;43m' # black on yellow + BG_ERR=$'\e[1;37;41m' # white on red + BG_BLK=$'\e[1;37;45m' # white on magenta + BG_SEC=$'\e[1;37;44m' # white on blue (section) + BOX=1 fi PASS_N=0 @@ -372,17 +392,68 @@ INFO_N=0 BLOCKERS=() # human-readable payment/withdraw blockers ERRORS=() # all ERROR lines (component scope) -# Test IDs by area: www-001, e2e-001, inside-001, … -# Usage: set_area www then each ok/fail/warn/info/blocker/err auto-numbers. +# Grouped test IDs: area.group-NN (e.g. www.exchange-01, e2e.pay-03) +# set_area www # phase: www | e2e | inside | versions | … +# set_group exchange # resets counter + prints group box → www.exchange-01 +# set_group bank # → www.bank-01 +# Without set_group: area-01, area-02, … (flat within area) TEST_AREA="" +TEST_GROUP="" TEST_N=0 -# Last issued id (www-001); set by _take_tid — not via $(…) so TEST_N persists. LAST_TID="" +# Per-group counters so re-entering www.exchange after perf continues NN +# (shell vars TEST_CNT__). +_tid_key() { + # $1=area $2=group → safe identifier + printf 'TEST_CNT_%s_%s' "$1" "$2" | tr -c 'A-Za-z0-9_' '_' +} +_tid_save() { + [ -z "${TEST_AREA:-}" ] || [ -z "${TEST_GROUP:-}" ] && return 0 + local k + k=$(_tid_key "$TEST_AREA" "$TEST_GROUP") + eval "$k=\$TEST_N" +} +_tid_load() { + local k + k=$(_tid_key "$TEST_AREA" "$1") + eval "TEST_N=\${$k:-0}" +} set_area() { + # leave previous group counter saved + _tid_save TEST_AREA="$1" + TEST_GROUP="" TEST_N=0 LAST_TID="" } +# Start a logical sub-group. Short stable names: +# exchange bank merchant perf stats landing paivana +# prereq wallet atm settle pay shop dig load report +# ssh caddy outside inside compare withdraw +# Counter continues if the same group is re-entered later in the area. +# Prints a compact group chip so log + issue text map cleanly (www.bank-03). +set_group() { + local g="$1" + # no-op if same group already active (e.g. inside emit loop) + if [ "$g" = "${TEST_GROUP:-}" ] && [ -n "$g" ]; then + return 0 + fi + _tid_save + TEST_GROUP="$g" + LAST_TID="" + if [ -z "${TEST_AREA:-}" ] || [ -z "$g" ]; then + TEST_N=0 + return 0 + fi + _tid_load "$g" + local tag="${TEST_AREA}.${g}" + if [ "${BOX:-0}" = "1" ]; then + # cyan-outline group chip: ┌ www.exchange ┐ + printf '%s%s┌ %s ┐%s\n' "$D" "$C" "$tag" "$N" + else + printf -- '-- %s --\n' "$tag" + fi +} # Assign next id into LAST_TID (must not run in a subshell). _take_tid() { LAST_TID="" @@ -390,49 +461,69 @@ _take_tid() { return fi TEST_N=$((TEST_N + 1)) - LAST_TID=$(printf '%s-%03d' "$TEST_AREA" "$TEST_N") + if [ -n "${TEST_GROUP:-}" ]; then + LAST_TID=$(printf '%s.%s-%02d' "$TEST_AREA" "$TEST_GROUP" "$TEST_N") + _tid_save + else + LAST_TID=$(printf '%s-%02d' "$TEST_AREA" "$TEST_N") + fi } +# Dim test id: "www.exchange-01 " or empty _fmt_tid() { - # prefix "www-001 " or empty if [ -n "${LAST_TID:-}" ]; then - printf '%s ' "$LAST_TID" + printf '%s%s%s ' "$D" "$LAST_TID" "$N" + fi +} +# Boxed badge cell: ┌ OK ┐ with filled bg (tag left-padded, width 7 for BLOCKER) +# $1=badge style $2=tag text +_fmt_badge() { + local badge="$1" tag="$2" inner + inner=$(printf -- '%-7s' "$tag") + if [ "${BOX:-0}" = "1" ]; then + printf '%s┌ %s┐%s' "$badge" "$inner" "$N" + else + printf '%s[ %s]%s' "$badge" "$inner" "$N" + fi +} +# One concrete line: ┌ OK ┐ tid label · detail +# $1=badge style $2=tag text $3=label colour $4=label $5=detail +_msg_line() { + local badge="$1" tag="$2" lcol="$3" label="$4" detail="${5:-}" + if [ -n "$detail" ]; then + printf -- '%s %s%s%s%s %s·%s %s%s%s\n' \ + "$(_fmt_badge "$badge" "$tag")" "$(_fmt_tid)" "$lcol" "$label" "$N" "$D" "$N" "$D" "$detail" "$N" + else + printf -- '%s %s%s%s%s\n' \ + "$(_fmt_badge "$badge" "$tag")" "$(_fmt_tid)" "$lcol" "$label" "$N" fi } ok() { - # Forms (same idea as info/warn): - # ok "what passed" - # ok "what passed" "detail / ms / bytes / …" + # ok "what is good" ["concrete evidence: HTTP 200 · 12ms · …"] local label="$1" detail="${2:-}" _take_tid - if [ -n "$detail" ]; then - printf '%s[OK]%s %s%s — %s\n' "$G" "$N" "$(_fmt_tid)" "$label" "$detail" - else - printf '%s[OK]%s %s%s\n' "$G" "$N" "$(_fmt_tid)" "$label" - fi + _msg_line "$BG_OK" "OK" "$W" "$label" "$detail" PASS_N=$((PASS_N + 1)) } -# component-scoped error: err bank "libeufin down" "detail" +# component-scoped error: err bank "what failed" "why / HTTP / path" err() { local comp="$1" msg="$2" detail="${3:-}" _take_tid - printf '%s[ERROR]%s %s%s: %s%s\n' "$R" "$N" "$(_fmt_tid)" "$comp" "$msg" "${detail:+ — $detail}" + _msg_line "$BG_ERR" "ERROR" "$W" "${comp}: ${msg}" "$detail" FAIL_N=$((FAIL_N + 1)) - ERRORS+=("${LAST_TID:+$LAST_TID }[$comp] $msg${detail:+ — $detail}") + ERRORS+=("${LAST_TID:+$LAST_TID }[$comp] $msg${detail:+ · $detail}") } -# legacy fail label ... +# fail "what failed" ["why / HTTP code / path"] fail() { local label="$1" detail="${2:-}" _take_tid - printf '%s[ERROR]%s %s%s%s\n' "$R" "$N" "$(_fmt_tid)" "$label" "${detail:+ — $detail}" + _msg_line "$BG_ERR" "ERROR" "$W" "$label" "$detail" FAIL_N=$((FAIL_N + 1)) - ERRORS+=("${LAST_TID:+$LAST_TID }$label${detail:+ — $detail}") + ERRORS+=("${LAST_TID:+$LAST_TID }$label${detail:+ · $detail}") } warn() { - # Forms (same idea as err; always try to state the problem): - # warn "problem" - # warn "problem" "why / context" - # warn component "problem" "why / context" + # warn "what is soft-bad" ["why still ok to continue"] + # warn component "what" "why" local a1="${1:-}" a2="${2:-}" a3="${3:-}" local head detail _take_tid @@ -446,56 +537,78 @@ warn() { head="$a1" detail="" fi - if [ -n "$detail" ]; then - printf '%s[WARN]%s %s%s — %s\n' "$Y" "$N" "$(_fmt_tid)" "$head" "$detail" - else - printf '%s[WARN]%s %s%s\n' "$Y" "$N" "$(_fmt_tid)" "$head" - fi + _msg_line "$BG_WARN" "WARN" "$W" "$head" "$detail" WARN_N=$((WARN_N + 1)) } info() { + # info "topic" ["concrete fact / value / next step"] local label="$1" detail="${2:-}" _take_tid - if [ -n "$detail" ]; then - printf '%s[INFO]%s %s%s — %s\n' "$C" "$N" "$(_fmt_tid)" "$label" "$detail" - else - printf '%s[INFO]%s %s%s\n' "$C" "$N" "$(_fmt_tid)" "$label" - fi + _msg_line "$BG_INFO" "INFO" "$W" "$label" "$detail" INFO_N=$((INFO_N + 1)) } blocker() { - # Payment/withdraw path cannot proceed because of this + # Hard stop on pay/withdraw path: blocker "step" "why it cannot continue" local step="$1" msg="$2" _take_tid - printf '%s[BLOCKER]%s %s%s: %s\n' "$R$B" "$N" "$(_fmt_tid)" "$step" "$msg" + _msg_line "$BG_BLK" "BLOCKER" "$W" "${step}" "$msg" BLOCKERS+=("${LAST_TID:+$LAST_TID }[$step] $msg") FAIL_N=$((FAIL_N + 1)) ERRORS+=("BLOCKER ${LAST_TID:+$LAST_TID }[$step] $msg") } -section() { printf '\n%s== %s ==%s\n' "$B" "$*" "$N"; } +section() { + # Boxed section header (3 lines when colour on) + local title="$*" + local w=${#title} + [ "$w" -lt 24 ] && w=24 + [ "$w" -gt 56 ] && w=56 + local pad line i + pad=$(printf -- "%-${w}s" "$title") + if [ "${BOX:-0}" = "1" ]; then + line="" + i=0 + while [ "$i" -lt $((w + 2)) ]; do + line="${line}═" + i=$((i + 1)) + done + printf -- '\n%s╔%s╗%s\n' "$BG_SEC" "$line" "$N" + printf -- '%s║%s %s%s%s %s║%s\n' "$BG_SEC" "$N" "$B" "$pad" "$N" "$BG_SEC" "$N" + printf -- '%s╚%s╝%s\n' "$BG_SEC" "$line" "$N" + else + printf -- '\n== %s ==\n' "$title" + fi +} summary() { echo "" if [ "${#BLOCKERS[@]}" -gt 0 ]; then - printf '%s--- BLOCKERS (fix/withdraw path) ---%s\n' "$R$B" "$N" + if [ "${BOX:-0}" = "1" ]; then + printf -- '%s┌ BLOCKERS · pay/withdraw cannot finish ┐%s\n' "$BG_BLK" "$N" + else + printf -- '--- BLOCKERS (pay/withdraw cannot finish) ---\n' + fi local b for b in "${BLOCKERS[@]}"; do - printf '%s • %s%s\n' "$R" "$b" "$N" + printf -- '%s •%s %s%s%s\n' "$M" "$N" "$W" "$b" "$N" done fi if [ "${#ERRORS[@]}" -gt 0 ] && [ "${#BLOCKERS[@]}" -lt "${#ERRORS[@]}" ]; then - printf '%s--- ERRORS ---%s\n' "$R" "$N" + if [ "${BOX:-0}" = "1" ]; then + printf -- '%s┌ ERRORS · failed checks ┐%s\n' "$BG_ERR" "$N" + else + printf -- '--- ERRORS (failed checks) ---\n' + fi local e for e in "${ERRORS[@]}"; do case "$e" in BLOCKER*) continue ;; esac - printf '%s • %s%s\n' "$R" "$e" "$N" + printf -- '%s •%s %s%s%s\n' "$R" "$N" "$W" "$e" "$N" done fi - printf 'totals: %s%d OK%s' "$G" "$PASS_N" "$N" - [ "$FAIL_N" -gt 0 ] && printf ', %s%d ERROR%s' "$R" "$FAIL_N" "$N" - [ "$WARN_N" -gt 0 ] && printf ', %s%d WARN%s' "$Y" "$WARN_N" "$N" - [ "$INFO_N" -gt 0 ] && printf ', %d INFO' "$INFO_N" - [ "${#BLOCKERS[@]}" -gt 0 ] && printf ', %s%d BLOCKER%s' "$R" "${#BLOCKERS[@]}" "$N" + printf -- '%stotals:%s %s%d OK%s' "$D" "$N" "$G" "$PASS_N" "$N" + [ "$FAIL_N" -gt 0 ] && printf -- ', %s%d ERROR%s' "$R" "$FAIL_N" "$N" + [ "$WARN_N" -gt 0 ] && printf -- ', %s%d WARN%s' "$Y" "$WARN_N" "$N" + [ "$INFO_N" -gt 0 ] && printf -- ', %s%d INFO%s' "$C" "$INFO_N" "$N" + [ "${#BLOCKERS[@]}" -gt 0 ] && printf -- ', %s%d BLOCKER%s' "$M" "${#BLOCKERS[@]}" "$N" printf '\n' [ "$FAIL_N" -eq 0 ] }