Compare commits

...

8 commits

Author SHA1 Message Date
Hernâni Marques
e46aeb408a
monitoring: soft-skip GOA-only landing assets off-GOA 2026-07-17 17:03:37 +02:00
Hernâni Marques
3d5f412af3
monitoring: stats.json freshness + display fields outside-in 2026-07-17 16:56:09 +02:00
Hernâni Marques
62d0e426f4
monitoring: e2e without run-until-done (wallet serve + poll) 2026-07-17 16:49:17 +02:00
Hernâni Marques
d2113e7362
monitoring: drop remaining run-until-done from GOA ladder 2026-07-17 16:49:17 +02:00
Hernâni Marques
b6c3225e97
landing-stats: make collector multi-stack reusable 2026-07-17 16:26:18 +02:00
Hernâni Marques
e1cc6d7141
monitoring: boxed badges + area.group test IDs 2026-07-17 15:55:50 +02:00
Hernâni Marques
f8affeb651
monitoring: badge backgrounds for ok/info/warn/error 2026-07-17 15:48:56 +02:00
Hernâni Marques
3d4e883cdb
monitoring: richer colour for ok/info/warn/error lines 2026-07-17 15:48:06 +02:00
19 changed files with 1204 additions and 465 deletions

View file

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

View file

@ -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" <<EOF
"timezone": $(json_str "$TZ"),
"generated_at": $(json_str "$GEN"),
"generated_at_human": $(json_str "$HUMAN"),
"generated_at_unix": ${GEN_UNIX},
"reserves": ${RESERVES:-0},
"wire_in_count": ${RESERVES_IN:-0},
"wire_in_amount": $(json_str "$WIRE_IN_AMT"),

View file

@ -1,12 +1,21 @@
# taler-landing — central stats (hernani user systemd)
# taler-landing — generic multi-stack landing stats
Public landing numbers on
One collector (`collect_bank_stats.py` + `collect-landing-stats.sh`) for any
currency / stack. **Defaults = GOA / hacktivism**; stage TESTPAYSAN uses a
profile file.
Public landing numbers (example GOA):
- https://bank.hacktivism.ch/intro/ → `stats.json`
- https://exchange.hacktivism.ch/intro/ → `stats.json`
- https://taler.hacktivism.ch/intro/ → `stats.json`
are produced by **one host process** as user **`hernani`**, not by three unrelated in-container crons.
Stage TESTPAYSAN (shared bank feed on all three hosts):
- https://stage.bank.lefrancpaysan.ch/intro/stats.json
(same file also under stage.exchange / stage.monnaie landings)
Produced by **one host process** (user systemd timer), not three crons.
## Why host / user systemd
@ -77,16 +86,37 @@ Preferred order:
Explorer password optional (shared-pool balance).
## Profiles
| File | Stack |
|------|--------|
| `profiles/goa.env` | hacktivism GOA (container publish) |
| `profiles/stage-testpaysan.env` | FrancPaysan stage; **shared** host `stats.json` |
```bash
# Stage (on stagepaysan):
STACK_PROFILE=stage-testpaysan ./scripts/taler-landing/install-landing-stats-host.sh
# or: francpaysan-admin-log/scripts/stagepaysan/install-landing-stats.sh
```
`~/.config/taler-landing/stack.env` is sourced on each run.
## Env overrides
| Env | Default | Meaning |
|-----|---------|---------|
| `BANK_URL` | `http://127.0.0.1:9012` | libeufin loopback |
| `BANK_CURRENCY` | *(auto)* | `GOA` / `TESTPAYSAN` / … |
| `SCAN_SKIP` | `exchange` | usernames excluded from flow |
| `COLLECT_BANK` / `_EXCHANGE` / `_MERCHANT` | `1` | phase switches |
| `COLLECT_RESOURCES` | `1` | container RSS/loadavg merge |
| `HOST_STATS_DIRS` | empty | space-separated host dirs for **shared** bank `stats.json` |
| `PUBLISH_PODMAN` | `1` | also `podman cp` into container landings |
| `TX_PAGE` | `500` | transactions page size |
| `MAX_TX_PAGES` | `0` | `0` = unlimited pages / account |
| `ACCOUNTS_DELTA` | `-10000` | account list window |
| `ADMIN_LOG` | `%h/src/koopa/koopa-admin-log` | refresh in-container scripts |
| `STATS_SOURCE_LABEL` | `host collect_bank_stats.py` | `stats.source` field |
## UI alt names

View file

@ -1,10 +1,14 @@
#!/usr/bin/env bash
# Central landing-stats collector for hernani@koopa (user systemd timer).
# Generic landing-stats orchestrator (any Taler stack).
#
# - Bank: full account+ledger scan via collect_bank_stats.py → bank container
# - Exchange / merchant: existing in-container scripts, then amount_alt enrich
# - All three: container RSS / loadavg / top procs via mem-snapshot (podman exec)
# - Never wipes a good stats.json on failure (writes stats-run.json only)
# Defaults = GOA / hacktivism on koopa. Override via env or EnvironmentFile
# (see profiles: goa | stage-testpaysan).
#
# - Bank: collect_bank_stats.py (currency-agnostic) → stats.json
# - Optional: exchange / merchant in-container generators
# - Optional: container RSS/loadavg merge
# - Publish: podman into container landings and/or host dirs (shared bank stats)
# - Never wipes a good stats.json on failure (stats-run.json only)
#
# Install: scripts/taler-landing/install-landing-stats-host.sh
set -euo pipefail
@ -12,6 +16,20 @@ set -euo pipefail
export TZ="${TZ:-Europe/Zurich}"
export PATH="${HOME}/.local/bin:/usr/local/bin:/usr/bin:/bin${PATH:+:$PATH}"
# Optional stack profile: ~/.config/taler-landing/stack.env or STACK_ENV_FILE
if [ -n "${STACK_ENV_FILE:-}" ] && [ -f "${STACK_ENV_FILE}" ]; then
# shellcheck disable=SC1090
set -a
# shellcheck source=/dev/null
. "${STACK_ENV_FILE}"
set +a
elif [ -f "${HOME}/.config/taler-landing/stack.env" ]; then
set -a
# shellcheck source=/dev/null
. "${HOME}/.config/taler-landing/stack.env"
set +a
fi
log() { printf '%s %s\n' "$(date -Iseconds)" "$*"; }
ROOT="$(cd "$(dirname "$0")" && pwd)"
@ -22,6 +40,8 @@ elif [ -f "${HOME}/.local/lib/taler-landing/collect_bank_stats.py" ]; then
LIB="${HOME}/.local/lib/taler-landing"
elif [ -f "${HOME}/src/koopa/koopa-admin-log/scripts/taler-landing/collect_bank_stats.py" ]; then
LIB="${HOME}/src/koopa/koopa-admin-log/scripts/taler-landing"
elif [ -f "${HOME}/taler/src/koopa-admin-log/scripts/taler-landing/collect_bank_stats.py" ]; then
LIB="${HOME}/taler/src/koopa-admin-log/scripts/taler-landing"
else
LIB="$ROOT"
fi
@ -29,6 +49,13 @@ fi
ADMIN_LOG="${ADMIN_LOG:-${HOME}/src/koopa/koopa-admin-log}"
SECRETS_BANK="${SECRETS_BANK:-${HOME}/src/koopa/koopa-admin-secrets/koopa/host-root/taler-bank}"
# Which phases to run (1/0)
COLLECT_BANK="${COLLECT_BANK:-1}"
COLLECT_EXCHANGE="${COLLECT_EXCHANGE:-1}"
COLLECT_MERCHANT="${COLLECT_MERCHANT:-1}"
COLLECT_RESOURCES="${COLLECT_RESOURCES:-1}"
# Containers (empty = skip podman publish for that role)
BANK_CTR="${BANK_CTR:-taler-hacktivism-bank}"
EX_CTR="${EX_CTR:-taler-hacktivism-exchange-ansible}"
MER_CTR="${MER_CTR:-taler-hacktivism}"
@ -36,10 +63,19 @@ MER_CTR="${MER_CTR:-taler-hacktivism}"
BANK_URL="${BANK_URL:-http://127.0.0.1:9012}"
BANK_PUBLIC_URL="${BANK_PUBLIC_URL:-https://bank.hacktivism.ch}"
EXCHANGE_CONFIG_URL="${EXCHANGE_CONFIG_URL:-https://exchange.hacktivism.ch/config}"
BANK_CURRENCY="${BANK_CURRENCY:-}" # empty → auto from bank/exchange /config
STATS_SOURCE_LABEL="${STATS_SOURCE_LABEL:-host collect_bank_stats.py}"
# In-container landing roots (podman publish)
BANK_LANDING_IN="${BANK_LANDING_IN:-/var/www/bank-landing}"
EX_LANDING_IN="${EX_LANDING_IN:-/var/www/exchange-landing}"
MER_LANDING_IN="${MER_LANDING_IN:-/var/www/merchant-landing}"
PUBLISH_PODMAN="${PUBLISH_PODMAN:-1}"
# Host dirs that should get the *same* bank stats.json (shared public feed).
# Colon-separated (systemd EnvironmentFile-safe). Example stage:
# HOST_STATS_DIRS=/var/www/lfp-stage/bank:/var/www/lfp-stage/exchange:/var/www/lfp-stage/merchant
HOST_STATS_DIRS="${HOST_STATS_DIRS:-}"
WORKDIR="${LANDING_STATS_WORKDIR:-${XDG_RUNTIME_DIR:-/tmp}/taler-landing-stats}"
mkdir -p "$WORKDIR"
@ -64,7 +100,9 @@ MEM_SRC="${MEM_SNAPSHOT_SRC:-$ADMIN_LOG/scripts/taler-shared/mem-snapshot.sh}"
merge_container_resources() {
local label="$1" ctr="$2" stats_file="$3"
[ "${COLLECT_RESOURCES}" = "1" ] || return 0
[ -f "$stats_file" ] || return 0
[ -n "$ctr" ] || return 0
if ! ctr_running "$ctr"; then
log "WARN: $label resources: $ctr not running"
return 1
@ -95,6 +133,36 @@ merge_container_resources() {
return 0
}
# Write stats.json into host directories (shared feed: same file everywhere).
publish_host_stats() {
local src_stats="$1" src_run="${2:-}"
[ -n "${HOST_STATS_DIRS:-}" ] || return 0
[ -f "$src_stats" ] || return 0
local d paths
# Accept colon or space separators
paths=$(printf '%s' "$HOST_STATS_DIRS" | tr ': ' '\n' | sed '/^$/d')
while IFS= read -r d; do
[ -n "$d" ] || continue
if [ -d "$d" ] || mkdir -p "$d" 2>/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,12 +175,26 @@ 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'
# 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) ==="
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

View file

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

View file

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

View file

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

View file

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

View file

@ -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" <<EOF
"timezone": $(json_str "$TZ"),
"generated_at": $(json_str "$GEN"),
"generated_at_human": $(json_str "$HUMAN"),
"generated_at_unix": ${GEN_UNIX},
"instances": ${INSTANCES:-0},
"orders": ${TOTAL_CONTRACTS:-0},
"paid": ${TOTAL_PAID:-0},

View file

@ -1,24 +1,26 @@
# taler-monitoring
Report for the **GOA** stack with clear severity tags and **per-area test IDs**:
Report for the **GOA** stack with boxed severity badges and **grouped test IDs**
`area.group-NN` (e.g. `www.exchange-01`, `e2e.pay-03`):
| Tag | Meaning |
|-----|---------|
| `[OK]` | check passed |
| `[INFO]` | inside status (container, ports, log noise) |
| `[WARN]` | degraded but maybe not fatal |
| `[ERROR]` | component problem |
| `[BLOCKER]` | **withdraw/pay path cannot complete** because of this |
| `┌ OK ┐` | check passed |
| `┌ INFO ┐` | status / note |
| `┌ WARN ┐` | degraded but maybe not fatal |
| `┌ ERROR ┐` | component problem |
| `┌ BLOCKER ┐` | **withdraw/pay path cannot complete** because of this |
IDs: **`www-001`**, **`inside-001`**, **`versions-001`**, **`sanity-001`**, **`server-001`**, **`e2e-001`** …
IDs reset per **group** inside an area — so “too many www tests” become
`www.exchange-*`, `www.bank-*`, `www.landing-*`, etc.
Catalog: **[TESTS.md](./TESTS.md)**.
```text
[OK] www-001 exchange /config https://exchange…/config
[BLOCKER] e2e-015 prereq: merchant HTTP 502
┌ OK ┐ www.exchange-01 exchange /config · HTTP 200
┌ BLOCKER┐ e2e.prereq-02 merchant HTTP 502
```
End of each phase: totals + list of **BLOCKERS** and **ERRORS**.
End of each phase: totals + list of **BLOCKERS** and **ERRORS** (ids included).
## Commands
@ -41,8 +43,27 @@ End of each phase: totals + list of **BLOCKERS** and **ERRORS**.
./taler-monitoring.sh --bank https://bank.demo.taler.net \
--exchange https://exchange.demo.taler.net \
--merchant https://backend.demo.taler.net --currency KUDOS urls
# Landing stats.json freshness (outside-in; age via generated_at_unix)
STATS_STALE_SECS=900 STATS_FAIL_SECS=3600 ./taler-monitoring.sh -d stage.lefrancpaysan.ch urls
```
### Landing `stats.json` (www.stats)
Public `GET …/intro/stats.json` must expose timestamps so monitoring can tell
whether the collector is still updating:
| Field | Role |
|-------|------|
| `generated_at_unix` | epoch seconds — **preferred** for age = now unix |
| `generated_at` | ISO-8601 (fallback if unix missing) |
| `generated_at_human` | UI “updated” / footer string |
Also checks display payloads (bank-shape `withdraws` / accounts, or exchange-db /
merchant-db counters). Soft WARN if age &gt; `STATS_STALE_SECS` (default 900);
ERROR if age &gt; `STATS_FAIL_SECS` (default 3600; set `0` to disable).
Stage shared feed: same `gen_unix` on bank + exchange + monnaies.
## Domain profiles (`domains.conf`)
Each monitored stack must say **what is bank, exchange, and merchant-backend**.

View file

@ -1,217 +1,132 @@
# taler-monitoring — test IDs by area
# taler-monitoring — grouped test IDs
Every check line is numbered **per area** as `AREA-NNN` (zero-padded):
| Area | Phase script | Meaning |
|------|--------------|---------|
| **www** | `check_urls.sh` | public HTTPS (outside-in) |
| **inside** | `check_inside.sh` | containers / processes on koopa |
| **versions** | `check_versions.sh` | deb.taler.net available + packages vs trixie |
| **sanity** | `check_sanity.sh` | public + server per component |
| **server** | `check_server.sh` | SSH host ports / processes |
| **e2e** | `check_e2e.sh` | withdraw + pay cycle |
Format in output:
Every check line is numbered **`area.group-NN`** (two-digit group counter):
```text
[OK] www-001 exchange /config https://exchange…/config
[ERROR] e2e-012 bank-auth: admin token failed
[BLOCKER] e2e-015 prereq: merchant HTTP 502
┌ OK ┐ www.exchange-01 exchange /config · HTTP 200
┌ ERROR ┐ e2e.pay-03 order create failed · HTTP 502
┌ BLOCKER┐ e2e.prereq-02 merchant secret missing
```
IDs are assigned **in run order** within the area (`set_area` resets the counter). Optional soft checks still consume a number when they WARN.
| Area | Phase script | Groups (examples) |
|------|--------------|-------------------|
| **www** | `check_urls.sh` | `exchange` `perf` `stats` `bank` `merchant` `paivana` `landing` |
| **inside** | `check_inside.sh` | `ssh` `bank` `exchange` `merchant` `caddy` `load` |
| **versions** | `check_versions.sh` | `outside` `inside` `compare` |
| **sanity** | `check_sanity.sh` | `bank` `exchange` `merchant` |
| **server** | `check_server.sh` | (flat `server-NN` or host groups) |
| **e2e** | `check_e2e.sh` | `prereq` `load` `bank` `wallet` `atm` `settle` `pay` `shop` `paivana` `dig` `report` |
| **ladder** | `check_goa_ladder.sh` | `plan` `load` `withdraw` `pay` `report` |
**Why groups:** flat `www-001``www-080` was hard to talk about.
`www.bank-04` / `e2e.atm-02` pin the failure to a logical block.
Usage in scripts:
```bash
set_area www
set_group exchange # → www.exchange-01, www.exchange-02, …
set_group bank # counter resets → www.bank-01
```
`set_group` also prints a small group chip (`┌ www.exchange ┐`) so logs show section boundaries.
Re-entering the same group later (e.g. exchange legal after perf) **continues** NN — `www.exchange-03`, not a second `-01`.
Without `set_group`, IDs stay flat: `area-01`, `area-02`, …
Optional soft checks still consume a number when they WARN.
Numbering follows **executed** checks (early skip may shift later NN inside the same group).
---
## www — public URLs (`./taler-monitoring.sh urls`)
| ID | Check |
|----|--------|
| www-… | exchange `/config`, currency, **alt_unit_names** |
| www-… | exchange `/keys` (+ alt_unit_names soft) |
| www-… | exchange `/intro/`, `/` (302→intro) |
| www-… | **exchange `/terms`** body (not empty / not API error) |
| www-… | **exchange `/privacy`** body |
| www-… | exchange `/terms/` (200 or redirect) |
| www-… | bank `/config`, currency, **alt_unit_names** |
| www-… | bank integration / webui / intro / `/` |
| www-… | **bank `/terms`** body |
| www-… | **bank `/privacy`** (or `/intro/privacy.html` fallback) |
| www-… | merchant `/config`, currency, currencies alt_unit_names |
| www-… | each merchant `exchanges[]` `/config` alt_unit_names |
| www-… | merchant `/intro/`, `/webui/`, `/` |
| www-… | **merchant `/terms`** body (dual-currency notice) |
| www-… | **merchant `/privacy`** body (must not be `not configured`) |
| www-… | merchant `/terms/` redirect |
| www-… | **landing (aggregated per site)**: one line for bank/merchant/exchange (`/intro` + assets + own/external link counts); failures listed as samples; soft external/shop assets compact |
| www-… | cross-links: one line (4 intros) on local stack |
| www-… | bank demo-withdraw + shop assets: one line (+ soft shop-pay if missing) |
| www-… | **bank `/intro/demo-withdraw.json`**`taler://withdraw/HOST:PORT/taler-integration/…` + integration op HTTP 200 |
| www-… | **landing exposed links** (bank / merchant / exchange): parse each `/intro/` HTML, probe every own-stack `https://` + root-relative `href`/`src`/`content`, soft-check external stores/docs |
| www-… | landing static: `qrcode.min.js`, `og-goa-shop.png`, `qr-logo.png`, shop-pay.js/css |
| www-… | cross-links between bank ↔ merchant ↔ exchange intros (local stack) |
| www-… | **bank `/intro/demo-withdraw.json`**`taler://withdraw/HOST/taler-integration/…` (no default `:443`/`:80`; non-default port OK) + integration op HTTP 200 |
| www-… | bank `/intro/auto-account.json` (earlier) → same withdraw shape, **no payto_uri**, login at `/webui/` |
| www-… | **performance** (outside-in RTT): bank/exchange/merchant `/config`, keys, webui, intro — ms; WARN ≥ `PERF_WARN_MS` (8000); ERROR ≥ `PERF_FAIL_MS` (20000) |
| www-… | **landing load stats**: `/intro/stats.json` → loadavg, container RSS, in-container probe ms (public; SSH container cat fallback); warn if stale/missing |
| Group | Checks (typical) |
|-------|------------------|
| **www.exchange-** | `/config`, currency, **alt_unit_names**; `/keys` (+ alt soft); `/intro/`, `/`; **`/terms`**, **`/privacy`**, `/terms/` |
| **www.perf-** | outside-in RTT: bank/exchange/merchant `/config`, keys, webui, intro — ms; WARN ≥ `PERF_WARN_MS` (8000); ERROR ≥ `PERF_FAIL_MS` (20000) |
| **www.stats-** | `/intro/stats.json` **reachable**; **timestamps** (`generated_at_unix` + human/ISO); **freshness** vs wall clock (`STATS_STALE_SECS` WARN, `STATS_FAIL_SECS` ERROR); **display fields** (bank-shape withdraws/accounts, or exchange-db / merchant-db keys); optional performance block; shared-feed equal `gen_unix` when bank collector is published to all three |
| **www.bank-** | `/config`, currency, alt_unit_names; integration/webui/intro; **auto-account.json** (required GOA/local; skip/soft off-GOA e.g. TESTPAYSAN); `/terms`, `/privacy` |
| **www.merchant-** | `/config` currency + currencies alt_unit_names; listed exchanges alt; webui/intro; **`/terms`**, **`/privacy`** |
| **www.paivana-** | local GOA paywall front (redirect to template) |
| **www.landing-** | own-stack intro links; static assets (`qrcode.min.js` hard; `og-goa-shop.png` hard only GOA/local); **demo-withdraw.json** GOA-only; shop-pay soft; cross-links local |
**Legal docs rule:** HTTP 200, non-empty body, not plain `not configured`, not merchant API JSON `code:21`. On local stack, optional content needle (terms/privacy/FADP/GOA…).
**Legal docs rule:** HTTP 200, non-empty body, not plain `not configured`, not merchant API JSON `code:21`. Local stack may require content needle (terms/privacy/FADP/GOA…).
**Performance rule:** Measured from the **monitoring runner** (public URLs via Caddy), not container loopback. HTTP must match expect (usually 200); **latency (ms) is on every perf OK/WARN/ERROR line**, plus a **perf summary** (n / min / p50 / avg / max). Slow ≥ `PERF_WARN_MS` (default 8000) → WARN; ≥ `PERF_FAIL_MS` (default 20000) → ERROR.
**Performance rule:** Measured from the **monitoring runner** (public via Caddy), not container loopback. Latency (ms) on every perf line + **perf summary** (n / min / p50 / avg / max).
**Landing links rule:** Own-stack (bank/exchange/taler.\* + page host) must be HTTP 200 (or redirect→200). External (App Store, Play, F-Droid, wallet.taler.net, docs/git.taler.net, …) soft WARN if down. Auto-account wallet link must be `taler://withdraw/HOST/taler-integration/…` (default ports stripped for mobile wallets), never payto.
**Landing links rule:** Own-stack must be HTTP 200 (or redirect→200). External stores/docs soft WARN. Auto-account wallet link must be `taler://withdraw/HOST/taler-integration/…` (default ports stripped), never payto.
**alt_unit_names rule:** wallet codec requires a non-empty map including scale key `"0"`. For multi-currency merchant, also follow every entry in `exchanges[]` and check that exchanges public `/config`.
(IDs after a failed early check may shift if later soft checks are skipped when body missing — numbering follows **executed** checks.)
**alt_unit_names rule:** non-empty map including scale key `"0"`. Multi-currency merchant: follow each `exchanges[]` public `/config`.
---
## inside — koopa SSH (`./taler-monitoring.sh inside`)
| ID | Check (typical order) |
|----|------------------------|
| inside-001 | ssh koopa (or **koopa-external** fallback) |
| inside-002+ | per-component emit: container, ports, libeufin/httpd, postgres, local `/config`/`/keys`, wirewatch, DNS pin, caddy |
| inside-… | **load / memory**: host loadavg + RAM; bank/exchange/merchant podman CPU/mem/block + process RSS by role + DB sizes |
| Group | Checks |
|-------|--------|
| **inside.ssh-** | ssh reachability / remote collect |
| **inside.bank-** | container, libeufin, postgres, local `/config`, nginx, DNS pins |
| **inside.exchange-** | container, httpd, wirewatch, aggregator, transfer, local keys, DNS |
| **inside.merchant-** | container, httpd, wirewatch, depositcheck, DNS |
| **inside.caddy-** | host reverse-proxy process |
| **inside.load-** | host loadavg + RAM; per-container RSS/CPU |
Remote lines `E|comp|LEVEL|key|detail` each become one numbered result.
Remote lines `E|comp|LEVEL|key|detail` each become one numbered result under that component group.
SSH: `KOOPA_SSH` (default `koopa`), then `KOOPA_SSH_FALLBACKS` (default `koopa-external`) when LAN is unreachable.
SSH: `KOOPA_SSH` (default `koopa`), then `KOOPA_SSH_FALLBACKS` when LAN is unreachable.
---
## sanity — bank · exchange · merchant (`./taler-monitoring.sh sanity`)
## e2e — withdraw + pay (`./taler-monitoring.sh e2e`)
| ID | Section |
|----|---------|
| sanity-001… | bank public + server |
| sanity-… | exchange public + server |
| sanity-… | merchant public + server |
| Group | Checks |
|-------|--------|
| **e2e.prereq-** | wallet-cli, currency, budgets, secrets, public reachability |
| **e2e.load-** | host/container load snapshots (before / after ATM / after pay) |
| **e2e.bank-** | account, credit, withdraw ops |
| **e2e.wallet-** | exchange + ToS, wallet setup |
| **e2e.atm-** | ATM withdraw ladder rungs |
| **e2e.settle-** | wallet settlement wait / spendable balance |
| **e2e.pay-** | variable payments |
| **e2e.shop-** | GOA shop product catalog pays |
| **e2e.paivana-** | template paywall pay |
| **e2e.dig-** | coins-missing dig (inside + withdrawal-operation) |
| **e2e.report-** | final tallies |
Sequential through the whole script (one `set_area sanity`).
When filing an issue, quote the full id + label, e.g.
`e2e.atm-03 ATM withdraw 5 GOA · bank confirmed, wallet empty`.
---
## versions — packages vs deb.taler.net (`./taler-monitoring.sh versions`)
## versions / sanity / ladder / server
### Outside (runner / public network — no SSH)
| ID (order) | Check |
|------------|--------|
| versions-… | DNS `deb.taler.net` |
| versions-… | HTTPS portal + apt base URL |
| versions-… | suite `InRelease` / `Release` |
| versions-… | suite `Packages` + `Packages.gz` |
| versions-… | sample pool `.deb` fetchable (Range 200/206) |
| versions-… | suite offers `taler-exchange`, `taler-merchant`, `libeufin-bank` |
| versions-… | optional `trixie-testing` Packages |
| versions-… | TLS verify (soft) |
### Inside (SSH koopa containers)
| ID | Check |
|----|--------|
| versions-… | ssh koopa |
| versions-… | each container → `InRelease` (pasta can reach apt repo) |
| versions-… | each container lists `deb.taler.net` in apt sources |
| versions-… | each installed `taler*` / `libeufin*` / `libtaler*` / `libdonau*` vs suite version |
| versions-… | core packages installed (`taler-exchange`, `libeufin-bank`, `taler-merchant`) |
Outside always runs. Inside skipped with `SKIP_SSH=1` (still reports outside results).
Compare rules:
- **match** suite → OK
- **ahead** of suite (often testing/dev) → INFO
- **behind** suite → ERROR for core packages, WARN otherwise (`TALER_PKG_BEHIND=error` forces ERROR)
Default suite: **trixie** (`TALER_APT_SUITE`, `TALER_APT_BASE=https://deb.taler.net/apt/debian`).
Without SSH (`SKIP_SSH=1` or remote domain): still runs outside-in repo checks; skips container install compare.
| Area.group | Meaning |
|------------|---------|
| **versions.outside-** | deb.taler.net suite index |
| **versions.inside-** | packages in containers |
| **versions.compare-** | installed vs suite |
| **sanity.bank-** / **.exchange-** / **.merchant-** | public + optional server-side per component |
| **ladder.plan-** / **.load-** / **.withdraw-** / **.pay-** / **.report-** | GOA amount ladder |
| **server-** | SSH host ports / processes (flat unless grouped later) |
---
## server — SSH ports (`./taler-monitoring.sh server`)
| ID | Check |
|----|--------|
| server-001 | ssh |
| server-002+ | containers, local pasta ports, processes, caddy |
---
## e2e — payment path (`./taler-monitoring.sh e2e`)
Local GOA also: **ATM includes GOA:4200**, then **paivana** (HTTP 302 on `PAIVANA_PUBLIC` + public template pay `paivana` / GOA:4200 on `goa-shop`). Disable with `E2E_PAIVANA=0`.
| ID | Step (approx.) |
|----|----------------|
| e2e-001 | budget info |
| e2e-002 | wallet-cli present |
| e2e-003 | mode / currency info |
| e2e-004… | secrets, reachability gates |
| e2e-… | account, credit, withdraw, confirm, coins, order, pay ladder |
| e2e-… | **load snapshots** at e2e-start, after-withdraw, after-pay, after-shop, e2e-end (host loadavg/RAM + container RSS/CPU/DB) |
| e2e-… | **coins** before/after each ATM withdraw, settle, pay, shop: count in circulation / spent, amount + denoms using **`alt_unit_names`** (e.g. `5 Kilo-GOA (GOA:5000)`), Δ vs previous snap |
| e2e-… | **GOA shop products** — full catalog list; **random pick of 2** (override `E2E_SHOP_PICK_N`) |
| e2e-… | balances, dig on failure, load delta + overall metrics |
Shop product pays use instance `goa-shop` (default) and catalog `E2E_SHOP_PRODUCTS`
(`id|Product name|amount` lines). Each e2e run **shuffles** the catalog and pays
only `E2E_SHOP_PICK_N` products (**default 2**). Flow matches the landing popup
(public POST `/templates/{id}`, not private orders). Report labels use product name.
Blockers keep the same ID prefix: `[BLOCKER] e2e-0NN step: message`.
---
## ladder — GOA withdraw ranges (`./taler-monitoring.sh ladder`)
| Step | What |
|------|------|
| ladder-001 | `GET /intro/auto-account.json` (personal account, GOA:0) |
| ladder-… | **load snapshot** before withdraws (host + bank/exchange/merchant mem/CPU) |
| ladder-… | **Phase A withdraw** (23): mint explorer pool — 0 → random → max1 → max into **one cumulative wallet** (mids × `LADDER_WITHDRAW_SCALE`, default 1.5) |
| ladder-… | wallet-cli accept-uri + explorer confirm when `selected`; settle; coins snap |
| ladder-… | **Phase B pay** (23): same shape 0 → random → max1 → max via merchant orders + `handle-uri --yes` |
| ladder-… | **load snapshot** after + delta + overall timing (withdraw + pay) |
| report | withdraw TSV + pay TSV + JSON |
Default amounts (`build_ladder_pair`, strictly increasing):
## Visual format (colour on)
```text
withdraw: [0] + random mids + [max1] + [max]
pay: [0] + (withdraw_mid / scale) + [max1] + [max] # always ≤ matching withdraw mid
╔══════════════════════════════╗
║ www · public URLs · … ║
╚══════════════════════════════╝
┌ www.exchange ┐
┌ OK ┐ www.exchange-01 exchange /config · HTTP 200
┌ www.bank ┐
┌ OK ┐ www.bank-01 bank /config · HTTP 200
┌ ERROR ┐ www.bank-04 bank /intro/auto-account.json · invalid withdraw
┌ ERRORS · failed checks ┐
• www.bank-04 bank /intro/auto-account.json · invalid withdraw
totals: 40 OK, 1 ERROR, 2 WARN, 5 INFO
```
TSV `range`: `pin:0` | `random` | `pin:max-1` | `pin:max`.
Soft: absolute **max** mint/pay may `CEILING_REJECT` / skip (WARN). **max-1** is a hard pin.
`LADDER_PAY=0` skips phase B.
| Env | Default | Meaning |
|-----|---------|---------|
| `LADDER_STEPS` | `23` | total rungs (0 + mids + max) |
| `LADDER_MAX_AMOUNT` | `4503599627370496` | absolute last pin |
| `LADDER_TIMEOUT_S` | `3600` | large rungs need time |
| `LADDER_MAX_RUNGS` | `99` | cap list length after build |
```bash
./taler-monitoring.sh ladder
LADDER_STEPS=10 ./taler-monitoring.sh ladder
LADDER_REPORT_DIR=/tmp/my-ladder ./taler-monitoring.sh ladder
```
---
## Run one area
```bash
./taler-monitoring.sh urls # www only
./taler-monitoring.sh inside # inside only
./taler-monitoring.sh versions # deb.taler.net + package drift
./taler-monitoring.sh e2e # e2e only
./taler-monitoring.sh ladder # GOA amount ladder + timings
./taler-monitoring.sh -d taler.net urls
```
`NO_COLOR=1` or `CLICOLOR=0` disables boxes/colours (ASCII `[ OK ]` + `-- group --` headers).
Always use `printf --` friendly plain headers when colour is off (leading `---` is not a printf option).

View file

@ -23,19 +23,37 @@ e2e_over() { [ "$(e2e_left)" -le 0 ]; }
# stdout only for JSON; logs go to stderr file so balance parse stays clean
wcli_bal_snap() {
local out="$1"
if [ -z "${WALLET_CLI:-}" ] || [ ! -f "${WDB:-}" ]; then
if [ -z "${WALLET_CLI:-}" ]; then
echo "(no wallet)" >"$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
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
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}"
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,12 +550,21 @@ wcli() {
cap=$(e2e_left)
[ "$cap" -gt "$maxc" ] && cap=$maxc
[ "$cap" -lt 3 ] && return 124
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
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
}
# Pay must not be starved: use reserved seconds even if global budget is tight
@ -486,16 +581,26 @@ wcli_pay() {
cap=12
fi
info "pay wallet cap" "${cap}s"
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
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
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
fi
curl -skS -m 8 -o "$SCRATCH/ord-pub-$tag.json" "$statusUrl" 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
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 ($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 \
|| 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)"
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=""
# 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
info "goa-shop" "SKIPPED (remote or non-GOA currency)"
E2E_SHOP_ENABLE=0
else
# Build catalog array from E2E_SHOP_PRODUCTS (id|name|amount lines)
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[|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"

View file

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

View file

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

View file

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

View file

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

View file

@ -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 515m)
# 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 <detail>
# 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)
# --- 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 "?"
# latency fields differ by site
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=%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))
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)
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"
;;
*)
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
# 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} $url"
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} ${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
fi
done
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)"
;;
*)

View file

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

View file

@ -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_<area>_<group>).
_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 ]
}