diff --git a/deploy.sh b/deploy.sh index 44a221a..a02aef6 100755 --- a/deploy.sh +++ b/deploy.sh @@ -17,14 +17,22 @@ # ./deploy.sh # interactive; auto-detect + prompts # ./deploy.sh --wsl | --prod # force host type (still prompts rest) # ./deploy.sh --host-ip 10.0.0.50 # preset HOST_IP (internal stack IP) -# ./deploy.sh --router-ip 10.0.0.9 --router-port 5000 # BMP-facing target +# ./deploy.sh --router-ip 10.0.0.9 --router-port 1790 # BMP-facing target # ./deploy.sh --auth local|authelia # ./deploy.sh --scope full-stack|remote|central-store # ./deploy.sh --central-kafka 10.0.0.50:9092 # for --scope remote # ./deploy.sh --reset # wipe data tree first (guarded on prod) +# ./deploy.sh --install-prereqs # allow installing docker-ce unattended # ./deploy.sh --prod --yes # unattended (needs enough presets) # REPO_DIR=/opt/obmp-docker ./deploy.sh # +# ./deploy.sh teardown # stop + remove EVERYTHING, leave it down +# --wipe-data # also wipe $OBMP_DATA_ROOT (guarded; +# # backups/ preserved by default) +# --purge-images # also remove stack images +# --purge-backups # do not preserve backups/ on wipe +# --yes # unattended (requires explicit flags) +# # SCOPE / DEPLOYMENT LEVELS (what each includes, excludes, ballpark sysreqs): # # core Collector core: zookeeper, kafka, psql, collector, psql-app, @@ -119,9 +127,19 @@ ARG_ROUTER_IP="" ARG_ROUTER_PORT="" ARG_AUTH="" # local | authelia ARG_SCOPE="" # full-stack | remote | central-store (aliases accepted) -ARG_CENTRAL_KAFKA="" # host:port for --scope standalone +ARG_CENTRAL_KAFKA="" # host:port for --scope remote ARG_RESET=0 ARG_YES=0 +ARG_INSTALL_PREREQS=0 +COMMAND="deploy" # deploy (default) | teardown +ARG_WIPE_DATA=0 # teardown: also wipe $OBMP_DATA_ROOT +ARG_PURGE_IMAGES=0 # teardown: also remove stack images +ARG_PURGE_BACKUPS=0 # teardown: do NOT preserve $OBMP_DATA_ROOT/backups + +# First positional arg may be a subcommand. +case "${1:-}" in + teardown) COMMAND="teardown"; shift ;; +esac while [ $# -gt 0 ]; do case "$1" in @@ -140,6 +158,10 @@ while [ $# -gt 0 ]; do --central-kafka) ARG_CENTRAL_KAFKA="${2:?--central-kafka needs host:port}"; shift ;; --central-kafka=*) ARG_CENTRAL_KAFKA="${1#*=}" ;; --reset) ARG_RESET=1 ;; + --install-prereqs) ARG_INSTALL_PREREQS=1 ;; + --wipe-data) ARG_WIPE_DATA=1 ;; + --purge-images) ARG_PURGE_IMAGES=1 ;; + --purge-backups) ARG_PURGE_BACKUPS=1 ;; --yes|-y) ARG_YES=1 ;; -h|--help) grep '^#' "$0" | sed 's/^#//'; exit 0 ;; *) echo "Unknown arg: $1" >&2; exit 1 ;; @@ -147,10 +169,9 @@ while [ $# -gt 0 ]; do shift done -log() { printf '\033[1;36m[deploy]\033[0m %s\n' "$*"; } -warn() { printf '\033[1;33m[deploy][warn]\033[0m %s\n' "$*" >&2; } -die() { printf '\033[1;31m[deploy][err]\033[0m %s\n' "$*" >&2; exit 1; } -hr() { printf '\033[2m%s\033[0m\n' "----------------------------------------------------------------"; } +# Shared output/prompt/.env helpers (log ok warn die hr banner step ask +# confirm get_env set_env is_ipv4) -- single definition for all deploy tooling. +. "$REPO_DIR/scripts/deploy-lib.sh" # Return the host-published TCP ports this deployment type will bind, so we can # check them for collisions BEFORE bring-up. Ports come from docker-compose.yml @@ -225,32 +246,71 @@ preflight_ports() { return $conflict } -# Prompt with an editable default. Honors --yes (accepts default silently). -ask() { - local __var="$1" __prompt="$2" __default="${3:-}" __reply="" - if [ "$ARG_YES" -eq 1 ]; then - printf -v "$__var" '%s' "$__default"; return - fi - if [ -n "$__default" ]; then - read -r -p "$__prompt [$__default]: " __reply - __reply="${__reply:-$__default}" - else - read -r -p "$__prompt: " __reply - fi - printf -v "$__var" '%s' "$__reply" -} - -confirm() { - local __prompt="$1" __reply="" - [ "$ARG_YES" -eq 1 ] && return 0 - read -r -p "$__prompt [y/N]: " __reply - [ "$__reply" = "y" ] || [ "$__reply" = "Y" ] -} - cd "$REPO_DIR" || die "REPO_DIR '$REPO_DIR' not found" [ -f docker-compose.yml ] || die "no docker-compose.yml in $REPO_DIR - set REPO_DIR" -command -v docker >/dev/null || die "docker not found" -docker compose version >/dev/null 2>&1 || die "docker compose v2 not available" + +# --- host prerequisites (fresh-WSL friendly) -------------------------------- +# A pristine WSL2 Ubuntu has neither docker-ce nor systemd enabled; check the +# whole chain and either remediate (--install-prereqs / interactive offer) or +# print the exact fix instead of dying with a bare message mid-run. +install_docker_ce() { + log "Installing docker-ce from the official Docker apt repo ..." + sudo apt-get update -qq + sudo apt-get install -y -qq ca-certificates curl + sudo install -m 0755 -d /etc/apt/keyrings + sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc + sudo chmod a+r /etc/apt/keyrings/docker.asc + echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo "$VERSION_CODENAME") stable" \ + | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null + sudo apt-get update -qq + sudo apt-get install -y -qq docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin + ok "docker-ce installed" +} + +ensure_systemd_wsl() { + # Native docker in WSL2 needs systemd (the docker service). Pristine + # distros ship with it off; flipping it requires a WSL restart, which we + # cannot do from inside -- set it up and stop with clear instructions. + [ -d /run/systemd/system ] && return 0 + grep -qiE 'microsoft|wsl' /proc/version 2>/dev/null || return 0 + warn "systemd is not running -- native docker cannot start without it." + if confirm "Enable systemd in /etc/wsl.conf now (needs a WSL restart afterwards)?"; then + if ! grep -q '^\[boot\]' /etc/wsl.conf 2>/dev/null; then + printf '\n[boot]\nsystemd=true\n' | sudo tee -a /etc/wsl.conf > /dev/null + elif ! grep -q '^systemd=true' /etc/wsl.conf; then + sudo sed -i '/^\[boot\]/a systemd=true' /etc/wsl.conf + fi + ok "systemd enabled in /etc/wsl.conf" + die "now run 'wsl --shutdown' from Windows, reopen this distro, and re-run ./deploy.sh" + fi + die "cannot continue without systemd on WSL (docker service will not start)" +} + +preflight_host() { + if ! command -v docker >/dev/null; then + warn "docker is not installed on this host." + if [ "$ARG_INSTALL_PREREQS" -eq 1 ] || confirm "Install docker-ce now (official Docker apt repo)?"; then + ensure_systemd_wsl + install_docker_ce + sudo systemctl enable --now docker 2>/dev/null || true + else + die "install docker-ce first (https://docs.docker.com/engine/install/ubuntu/) or re-run with --install-prereqs" + fi + fi + docker compose version >/dev/null 2>&1 \ + || die "docker compose v2 missing - install the docker-compose-plugin package" + if ! docker info >/dev/null 2>&1; then + # client exists but daemon unreachable: systemd off (WSL) or service down + ensure_systemd_wsl + warn "docker daemon is not running - trying to start it ..." + sudo systemctl enable --now docker 2>/dev/null || true + sleep 2 + docker info >/dev/null 2>&1 \ + || die "docker daemon still unreachable - check: systemctl status docker" + ok "docker daemon started" + fi +} +preflight_host # --- Docker Desktop vs native-daemon guard ---------------------------------- # On WSL, Docker Desktop runs the daemon in its OWN separate distro @@ -285,23 +345,97 @@ if detect_docker_desktop; then fi fi -# --- .env helpers ----------------------------------------------------------- +# --- teardown subcommand ----------------------------------------------------- +# Leave-it-down teardown: removes every container across ALL compose profiles, +# the named volumes, and the rendered gitignored configs. Data-tree wipe is +# opt-in (--wipe-data or interactive), preserving backups/ unless +# --purge-backups. Images are kept unless --purge-images. Prints the +# Windows-side cleanup it cannot reach from inside WSL. +ALL_PROFILES=(--profile test --profile auth --profile evpn-test --profile scale-out) +cmd_teardown() { + banner "OpenBMP stack teardown" + local dr; dr="$( [ -f .env ] && get_env OBMP_DATA_ROOT || true )"; dr="${dr:-/var/openbmp}" + echo + log "This will remove:" + echo " - all obmp containers (every profile: core, test, auth, evpn-test, scale-out)" + echo " - docker networks + named volumes (obmp_data-volume, obmp_ts-volume)" + echo " - rendered configs (gobgp/gobgpd.conf, gobgp-evpn/gobgpd.conf)" + [ "$ARG_PURGE_IMAGES" -eq 1 ] && echo " - stack images (--purge-images)" + if [ "$ARG_WIPE_DATA" -eq 1 ]; then + echo " - DATA TREE $dr (--wipe-data)$( [ "$ARG_PURGE_BACKUPS" -eq 1 ] && echo ' INCLUDING backups/' || echo ', preserving backups/')" + else + echo " - (data tree $dr is KEPT; add --wipe-data for a full wipe)" + fi + echo " - .env is always kept" + echo + confirm "Tear the stack down?" || die "aborted - nothing was removed" + + log "Stopping and removing containers (all profiles) ..." + if [ "$ARG_PURGE_IMAGES" -eq 1 ]; then + docker compose "${ALL_PROFILES[@]}" down -v --remove-orphans --rmi all 2>&1 | tail -3 || true + else + docker compose "${ALL_PROFILES[@]}" down -v --remove-orphans 2>&1 | tail -3 || true + fi + docker volume rm obmp_data-volume obmp_ts-volume 2>/dev/null || true + ok "containers, networks, volumes removed" + + rm -f gobgp/gobgpd.conf gobgp-evpn/gobgpd.conf 2>/dev/null || true + ok "rendered configs removed" + + if [ "$ARG_WIPE_DATA" -eq 1 ] || { [ "$ARG_YES" -eq 0 ] && confirm "ALSO wipe the data tree $dr (Postgres history, Kafka spool)?"; }; then + if [ -d "$dr" ]; then + if grep -qiE 'microsoft|wsl' /proc/version 2>/dev/null; then :; else + warn "Native host data wipe - this destroys all BMP history under $dr." + [ "$ARG_YES" -eq 1 ] && [ "$ARG_WIPE_DATA" -eq 0 ] && die "refusing implicit data wipe under --yes; pass --wipe-data explicitly" + if [ "$ARG_YES" -eq 0 ]; then + read -r -p "Type the data root path to confirm deletion: " __c + [ "$__c" = "$dr" ] || die "confirmation did not match - data tree untouched" + fi + fi + if [ "$ARG_PURGE_BACKUPS" -eq 1 ]; then + sudo rm -rf "${dr:?}"/* 2>/dev/null || true + ok "data tree wiped (including backups)" + else + sudo find "$dr" -mindepth 1 -maxdepth 1 ! -name backups -exec rm -rf {} + 2>/dev/null || true + ok "data tree wiped (backups/ preserved)" + fi + fi + fi + + echo + hr + log "Linux-side teardown complete. Windows-side leftovers (if this was a" + log "WSL deployment) need an ELEVATED PowerShell:" + echo + echo " powershell -ExecutionPolicy Bypass -File scripts\\wsl-portproxy.ps1 -Remove" + echo + log "Verify clean: docker ps -a | grep obmp (expect nothing)" + hr + exit 0 +} +[ "$COMMAND" = "teardown" ] && cmd_teardown + +banner "OpenBMP stack deploy" + +# --- .env bootstrap ---------------------------------------------------------- if [ ! -f .env ]; then [ -f .env.example ] || die "no .env or .env.example present" cp .env.example .env log "Created .env from .env.example" fi -get_env() { grep -E "^$1=" .env | head -1 | cut -d= -f2- || true; } -set_env() { - local key="$1" val="$2" - if grep -qE "^${key}=" .env; then - sed -i "s|^${key}=.*|${key}=${val}|" .env - else - printf '%s=%s\n' "$key" "$val" >> .env + +# Prime sudo early if filesystem setup will need it, so the password prompt +# happens HERE (visible, at the start) instead of blocking mid-provision. +_dr_probe="$(get_env OBMP_DATA_ROOT)"; _dr_probe="${_dr_probe:-/var/openbmp}" +if [ ! -w "$(dirname "$_dr_probe")" ] || { [ -d "$_dr_probe" ] && [ ! -w "$_dr_probe" ]; }; then + if [ "$(id -u)" -ne 0 ]; then + log "Filesystem setup under $_dr_probe needs sudo - authenticating now." + sudo -v || die "sudo authentication failed" fi -} +fi # --- 1. host type: auto-detect, then override ------------------------------- +step 1 6 "Host type" detected="prod" if grep -qiE 'microsoft|wsl' /proc/version 2>/dev/null; then detected="wsl"; fi if [ -n "$ARG_HOSTTYPE" ]; then @@ -309,6 +443,8 @@ if [ -n "$ARG_HOSTTYPE" ]; then log "Host type: $HOSTTYPE (from flag; auto-detect said '$detected')" else log "Auto-detected host type: $detected" + echo " wsl = this distro runs under WSL2 (routers reach the stack via the Windows portproxy)" + echo " prod = native Linux host (routers reach the stack directly)" ask HOSTTYPE "Host type (wsl/prod)" "$detected" fi case "$HOSTTYPE" in wsl|prod) ;; *) die "host type must be 'wsl' or 'prod' (got '$HOSTTYPE')";; esac @@ -355,6 +491,7 @@ normalize_type() { *) echo "" ;; esac } +step 2 6 "Deployment type" if [ -n "$ARG_SCOPE" ]; then DTYPE="$(normalize_type "$ARG_SCOPE")" [ -n "$DTYPE" ] || die "unknown --scope '$ARG_SCOPE' (use full-stack|remote|central-store)" @@ -371,6 +508,12 @@ log "Deployment type: $DTYPE" # --- 3. HOST_IP: smart default, editable, validated ------------------------- # HOST_IP is the address the INTERNAL stack advertises/binds (Kafka listener, # gobgp->collector). On a remote collector it is also this node's own address. +step 3 6 "Addresses (internal bind + what routers target)" +echo " Two different addresses are about to be asked for:" +echo " HOST_IP = where the stack binds INTERNALLY (Kafka, gobgp). On WSL" +echo " this is the WSL VM address." +echo " Router-facing = what routers put in 'bmp server host ...'. On WSL this" +echo " is the WINDOWS LAN IP (portproxy forwards it inward)." primary_ip="$(ip -o -4 addr show scope global 2>/dev/null | awk '{print $4}' | cut -d/ -f1 | head -1 || true)" wsl_ip="$(hostname -I 2>/dev/null | awk '{print $1}' || true)" current_ip="$(get_env HOST_IP)" @@ -461,6 +604,13 @@ BMP_STD_PORT=1790 default_router_port="${ARG_ROUTER_PORT:-$BMP_STD_PORT}" if [ -n "$ARG_ROUTER_IP" ]; then ROUTER_IP="$ARG_ROUTER_IP"; else ask ROUTER_IP "Router-facing IP (what 'bmp server' on the routers targets)" "$default_router_ip" + # A failed Windows-LAN-IP autodetect leaves a placeholder + # as the default; don't let a non-address sail into .env silently. + while ! is_ipv4 "$ROUTER_IP"; do + [ "$ARG_YES" -eq 1 ] && die "router-facing IP '$ROUTER_IP' is not an IPv4 address (autodetect failed?) - pass --router-ip" + warn "'$ROUTER_IP' is not an IPv4 address." + ask ROUTER_IP "Router-facing IP (dotted quad)" "" + done fi if [ -n "$ARG_ROUTER_PORT" ]; then ROUTER_PORT="$ARG_ROUTER_PORT"; else ask ROUTER_PORT "Router-facing BMP port" "$default_router_port" @@ -480,6 +630,7 @@ if [ "$DTYPE" = "remote" ]; then fi # --- 6. Authelia toggle (independent axis; N/A for remote - no local UI) ----- +step 4 6 "Grafana authentication" AUTH_MODE="local" OBMP_DOMAIN="$(get_env OBMP_DOMAIN)" OBMP_COOKIE_DOMAIN="$(get_env OBMP_COOKIE_DOMAIN)" @@ -526,6 +677,7 @@ type_resources() { central-store) echo "~16 vCPU / 48-64 GB / NVMe >=250 GB (carries all remotes' data)" ;; esac } +step 5 6 "Plan review" echo; hr; log "Deployment plan"; hr # Record what is being deployed - a greenfield deploy is only reproducible if # the checkout is pinned (branch + commit go in the plan and the terminal log). @@ -568,9 +720,11 @@ else fi fi -if ! confirm "Proceed with this plan?"; then - die "aborted before making changes - nothing was modified" +# NOTE: default answer is No -- hitting Enter here ABORTS (safe default). +if ! confirm "Proceed with this plan? (y = deploy, Enter/n = abort)"; then + die "aborted before making changes - nothing was modified (answer 'y' to deploy)" fi +step 6 6 "Provision + staged bring-up" # --- 8. write .env ---------------------------------------------------------- set_env HOST_IP "$HOST_IP" @@ -598,10 +752,13 @@ if [ "$ARG_RESET" -eq 1 ]; then else warn "Resetting data tree under $OBMP_DATA_ROOT (WSL test host)" fi - docker compose --profile test --profile auth down -v 2>/dev/null || true + docker compose "${ALL_PROFILES[@]}" down -v --remove-orphans 2>/dev/null || true docker volume rm obmp_data-volume obmp_ts-volume 2>/dev/null || true - sudo rm -rf "${OBMP_DATA_ROOT:?}/"* 2>/dev/null || true - log "Reset complete" + # Wipe the tree but keep backups/ -- pg-backup.sh dumps live there and a + # reset should not silently destroy them (use 'teardown --purge-backups' + # for that). + sudo find "${OBMP_DATA_ROOT:?}" -mindepth 1 -maxdepth 1 ! -name backups -exec rm -rf {} + 2>/dev/null || true + log "Reset complete (backups/ preserved if present)" fi # --- 10. run setup.sh ------------------------------------------------------- @@ -642,7 +799,7 @@ wait_kafka() { done [ "$(docker inspect obmp-kafka --format '{{.State.Status}}' 2>/dev/null)" = running ] \ || die "kafka did not stabilise - check: docker logs obmp-kafka" - log " kafka up" + ok "kafka up" } wait_postgres() { log " waiting for postgres ..." @@ -658,7 +815,7 @@ if [ "$DTYPE" = "remote" ]; then wait_kafka log "Stage 2/2 - collector (forwarding to $CENTRAL_KAFKA) ..." docker compose up -d collector - log " collector up" + ok "collector up" elif [ "$DTYPE" = "central-store" ]; then # Store only: Postgres/psql-app/Grafana/feeders, NO local collector. @@ -668,12 +825,12 @@ elif [ "$DTYPE" = "central-store" ]; then docker compose up -d psql wait_postgres docker compose up -d psql-app grafana whois - log " store up" + ok "store up" log "Stage 3/3 - feeders/consumers (--profile test) ..." docker compose --profile test up -d # ensure no local collector is running on a central-store node docker compose stop collector 2>/dev/null || true - log " feeders up (no local collector)" + ok "feeders up (no local collector)" else # full-stack: collector + store + feeders on one host. @@ -686,10 +843,10 @@ else docker compose up -d psql wait_postgres docker compose up -d collector psql-app grafana whois - log " core up" + ok "core up" log "Stage 3/3 - feeders${AUTH_MODE:+ + auth if authelia} (${profiles[*]}) ..." docker compose "${profiles[@]}" up -d - log " feeders up" + ok "feeders up" fi # --- 12. verify ------------------------------------------------------------- @@ -701,6 +858,13 @@ docker logs obmp-collector --tail 8 2>&1 | sed 's/^/ /' || true # --- 13. post-deploy notes -------------------------------------------------- echo printf '\033[1;32m[deploy] done\033[0m\n' +hr +log "Verify (any time):" +echo " docker compose ps # every service Up / healthy" +echo " docker exec -i obmp-psql psql -U openbmp -d openbmp -c 'SELECT name, state FROM routers;'" +echo " (DB schema is created automatically by psql-app on its first run)" +log "Tear down later: ./deploy.sh teardown (add --wipe-data for a full wipe)" +hr if [ "$DTYPE" = "remote" ]; then cat < $CENTRAL_KAFKA (central store). diff --git a/scripts/wsl-portproxy.ps1 b/scripts/wsl-portproxy.ps1 index 9faa1cd..848da5b 100644 --- a/scripts/wsl-portproxy.ps1 +++ b/scripts/wsl-portproxy.ps1 @@ -20,14 +20,19 @@ .PARAMETER WslIp WSL address to forward to. Default: auto-detected via 'wsl hostname -I'. +.PARAMETER Remove + Teardown mode: delete the portproxy + firewall rules this script creates + (pair with './deploy.sh teardown' on the Linux side). + .EXAMPLE powershell -ExecutionPolicy Bypass -File scripts\wsl-portproxy.ps1 - powershell -ExecutionPolicy Bypass -File scripts\wsl-portproxy.ps1 -RouterPort 5000 + powershell -ExecutionPolicy Bypass -File scripts\wsl-portproxy.ps1 -Remove #> param( [int]$RouterPort = 1790, [int]$KafkaPort = 9092, - [string]$WslIp = "" + [string]$WslIp = "", + [switch]$Remove ) $ErrorActionPreference = "Stop" @@ -39,6 +44,24 @@ if (-not (New-Object System.Security.Principal.WindowsPrincipal($id)).IsInRole( exit 1 } +# -Remove: teardown mode. Deletes the portproxy + firewall rules this script +# creates (used by './deploy.sh teardown' guidance). No WSL IP needed. +if ($Remove) { + foreach ($listen in @($RouterPort, $KafkaPort)) { + netsh interface portproxy delete v4tov4 listenport=$listen listenaddress=0.0.0.0 2>$null | Out-Null + Write-Host "portproxy rule removed: 0.0.0.0:$listen" + $ruleName = "OpenBMP $listen" + if (Get-NetFirewallRule -DisplayName $ruleName -ErrorAction SilentlyContinue) { + Remove-NetFirewallRule -DisplayName $ruleName + Write-Host "firewall rule removed: $ruleName" + } + } + Write-Host "" + Write-Host "Remaining portproxy rules:" + netsh interface portproxy show v4tov4 + exit 0 +} + if (-not $WslIp) { $WslIp = (wsl hostname -I).Trim().Split(" ")[0] if (-not $WslIp) { Write-Error "Could not auto-detect the WSL IP; pass -WslIp."; exit 1 } diff --git a/setup.sh b/setup.sh index e8213cf..1f7ca70 100755 --- a/setup.sh +++ b/setup.sh @@ -27,20 +27,10 @@ if [ ! -f .env ]; then exit 1 fi -# Read a single KEY=value from .env without sourcing it — .env contains keys -# with hyphens (PROX-CML_*) that a shell `source` would choke on. -get_env() { grep -E "^$1=" .env | head -1 | cut -d= -f2- || true; } - -# Set KEY=value in .env: replace the line if present, else append. -set_env() { - local key="$1" val="$2" - if grep -qE "^${key}=" .env; then - # `|` delimiter — values are hex, no `|`. - sed -i "s|^${key}=.*|${key}=${val}|" .env - else - printf '%s=%s\n' "$key" "$val" >> .env - fi -} +# Shared helpers (log ok warn die hr get_env set_env ...) — same styling as +# deploy.sh, defined once in scripts/deploy-lib.sh. +TAG=setup +. "$(dirname "$0")/scripts/deploy-lib.sh" OBMP_DATA_ROOT="$(get_env OBMP_DATA_ROOT)" OBMP_DATA_ROOT="${OBMP_DATA_ROOT:-/var/openbmp}"