Files
openairinterface5g/ci-scripts/yaml_files/5g_rfsimulator/exp.sh
T
2025-10-19 20:03:22 +09:00

655 lines
21 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/bin/bash
# Examples:
# ./exp-35.sh
# UE_MIN=2 UE_MAX=12 UE_PEAK_HOUR=14 UE_NOISE_FRAC=0.15 ./exp-35.sh
# TEST_UE=oai-nr-ue1 TEST_PROFILE=browsing TEST_DURATION_SEC=120 ./exp-35.sh
set -euo pipefail
# ---------------------------[ Random Burst Mode ]----------------------------
# UE scaler mode: 'random' (rapid random targets) or 'diurnal' (legacy cosine)
UE_SCALER_MODE="${UE_SCALER_MODE:-random}"
# Interval (sec) for scaler ticks
UE_SCALER_INTERVAL_SEC="${UE_SCALER_INTERVAL_SEC:-30}"
# Rapid/random system-level bursts: every few minutes trigger a short, global burst
# that nudges all UEs to start/extend a session at once. UE-side script watches
# /tmp/system_burst and reacts (see traffic_runner --profile random).
RANDOM_BURST_MODE="${RANDOM_BURST_MODE:-1}" # 1=enabled, 0=disabled
BURST_MIN_GAP_SEC="${BURST_MIN_GAP_SEC:-180}" # 3 minutes
BURST_MAX_GAP_SEC="${BURST_MAX_GAP_SEC:-600}" # 10 minutes
BURST_MARK_FILE="${BURST_MARK_FILE:-/tmp/system_burst}"
# -------------------------------[ Config ]------------------------------------
ARTIFACT_DIR="/home/nakaolab-go/OAI/research-main/artifacts/"
mkdir -p "$ARTIFACT_DIR"
POWER_LOG_FILE="${POWER_LOG_FILE:-$ARTIFACT_DIR/power_usage.csv}"
CPU_PROXY_LOG_FILE="${CPU_PROXY_LOG_FILE:-$ARTIFACT_DIR/cpu_proxy.csv}"
RRC_SINCE_STATE="${RRC_SINCE_STATE:-$ARTIFACT_DIR/.rrc_since}"
# xApp (mac stats) container + path inside container
MAC_STATS_CONTAINER="${MAC_STATS_CONTAINER:-xapp-chatgpt}"
MAC_STATS_FILE_IN_CONTAINER="${MAC_STATS_FILE_IN_CONTAINER:-/tmp/mac_stats.log}"
# Intervals (seconds)
RRC_LOG_INTERVAL_SEC="${RRC_LOG_INTERVAL_SEC:-10}"
POWER_LOG_INTERVAL_SEC="${POWER_LOG_INTERVAL_SEC:-5}"
CPU_PROXY_INTERVAL_SEC="${CPU_PROXY_INTERVAL_SEC:-5}"
UE_SCALER_INTERVAL_SEC="${UE_SCALER_INTERVAL_SEC:-60}"
# Docker compose helper
DC_BIN=(docker compose)
dc() { command "${DC_BIN[@]}" "$@"; }
dc down -v
# Service names (override if your compose uses different ones)
AMF_SERVICE="${AMF_SERVICE:-oai-amf}"
UPF_SERVICE="${UPF_SERVICE:-oai-upf}"
GNB_SERVICE="${GNB_SERVICE:-oai-gnb}"
XAPP_SERVICE="${XAPP_SERVICE:-xapp-chatgpt}"
# UE service name pattern (for discovery)
UE_NAME_REGEX="${UE_NAME_REGEX:-^oai-nr-ue}"
# Test controls
TEST_UE="${TEST_UE:-}" # e.g. oai-nr-ue1
TEST_PROFILES="${TEST_PROFILES:-}" # profile name
TEST_DURATION_SEC="${TEST_DURATION_SEC:-0}" # 0 = run indefinitely
# Diurnal scaling controls (normal mode only; ignored if TEST_UE is set)
UE_MIN="${UE_MIN:-1}" # Minimum number of UEs to keep connected
# UE_MAX="${UE_MAX:-}" # Max UEs; default = total UEs in compose
UE_MAX=10
UE_PEAK_HOUR="${UE_PEAK_HOUR:-17}" # Local hour with peak load (0..23)
UE_NOISE_FRAC="${UE_NOISE_FRAC:-0.15}" # ±fractional noise applied to target
# -------------------------[ Power Model (mW) ]--------------------------------
declare -A PWR_MW_PROFILE=(
[idle]=60 # screen on, modem camped, no transfers
[iot_chatty]=150 # tiny bursts, long sleeps
[browsing]=380 # short transfers with think-time
[voip]=520 # steady uplink/downlink small packets
[video]=850 # continuous DL; 720–1080p
[gaming]=950 # bursty DL + UL keepalives
[bulk_download]=1100 # sustained UL/DL at MCS ceiling
)
# -------------------------------[ Utils ]-------------------------------------
ts_now() { date +%s; }
ts_iso() { date -Is; }
ts_tag() { date +%Y%m%d_%H%M%S; }
log() { echo "[$(ts_iso)] $*"; }
exec_safe() {
local svc="$1"; shift
dc exec -T "$svc" bash -lc "$*"
}
# -----------------------------[ Discovery ]-----------------------------------
list_all_services() { dc config --services; }
get_all_ue_services() { list_all_services | grep -E "${UE_NAME_REGEX}" || true; }
get_core_services() {
# everything except UEs, gNB, xApp
list_all_services | grep -Ev "(${UE_NAME_REGEX}|^${GNB_SERVICE}$|^${XAPP_SERVICE}$)" || true
}
get_running_ues() {
# Running UEs by service name (include TEST_UE name if custom)
if [[ -n "${TEST_UE:-}" ]]; then
dc ps --services --filter "status=running" \
| grep -E "${UE_NAME_REGEX}|^${TEST_UE}$" || true
else
dc ps --services --filter "status=running" \
| grep -E "${UE_NAME_REGEX}" || true
fi
}
# -------------------------[ Readiness helpers ]-------------------------------
svc_cid() { dc ps -q "$1"; } # container id for a service
wait_healthy() {
local svc="$1" timeout="${2:-60}"
local cid; cid="$(svc_cid "$svc")"
if [[ -z "$cid" ]]; then
log "wait_healthy: service '$svc' not running."
return 1
fi
local health
health="$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{end}}' "$cid" 2>/dev/null || true)"
if [[ -z "$health" ]]; then
log "wait_healthy: no healthcheck for '$svc'; skipping."
return 0
fi
local start; start="$(ts_now)"
while :; do
health="$(docker inspect -f '{{.State.Health.Status}}' "$cid" 2>/dev/null || echo "")"
if [[ "$health" == "healthy" ]]; then
log "$svc is healthy."
return 0
fi
if (( $(ts_now) - start > timeout )); then
log "wait_healthy timeout for '$svc' (last='$health')."
return 1
fi
sleep 1
done
}
wait_log() {
local svc="$1" pat="$2" timeout="${3:-60}"
local cid; cid="$(svc_cid "$svc")"
if [[ -z "$cid" ]]; then
log "wait_log: service '$svc' not running."
return 1
fi
local start; start="$(ts_now)"
while :; do
if dc logs "$svc" --since=2m 2>&1 | grep -Eiq -- "$pat"; then
log "wait_log: detected '$pat' in '$svc'."
return 0
fi
if (( $(ts_now) - start > timeout )); then
log "wait_log timeout for '$svc' waiting '$pat'."
return 1
fi
sleep 1
done
}
get_container_ip() {
local cid; cid="$(svc_cid "$1")"
[[ -z "$cid" ]] && return 1
docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$cid"
}
# --------------------------[ Staged bring-up ]--------------------------------
start_services_list() {
[[ $# -eq 0 ]] && return 0
dc up -d "$@"
}
start_ue_list() {
[[ $# -eq 0 ]] && return 0
for svc in "$@"; do
log "Starting: $svc"
dc up -d "$svc"
sleep 10
done
}
start_in_stages() {
# 1) CORE
mapfile -t core_svcs < <(get_core_services)
if [[ ${#core_svcs[@]} -gt 0 ]]; then
start_services_list "${core_svcs[@]}"
else
log "No CORE services to start."
exit 1
fi
# Ensure AMF is ready
wait_healthy "${AMF_SERVICE}" 15
wait_healthy "${UPF_SERVICE}" 15
exec_safe "${UPF_SERVICE}" 'bash -lc "
GW=192.168.72.129
DEV=\$(ip -4 route get \$GW 2>/dev/null | awk '\''/ dev /{for(i=1;i<=NF;i++) if(\$i==\"dev\"){print \$(i+1); exit}}'\''); DEV=\${DEV:-eth0}
ip -4 route replace \$GW/32 dev \$DEV
ip -4 route replace default via \$GW dev \$DEV onlink
"'
wait_healthy oai-flexric 15
# 2) gNB
start_services_list "${GNB_SERVICE}"
wait_log "${GNB_SERVICE}" "Received NGSetupResponse from AMF" 20
wait_log "${GNB_SERVICE}" "Transaction ID E2 SETUP-REQUEST 0 E2 SETUP-RESPONSE 0" 15
# 3) xApp
start_services_list "${XAPP_SERVICE}"
wait_healthy "${XAPP_SERVICE}" 15
# 4) UE(s)
# if [[ -n "${TEST_UE:-}" ]]; then
# # Allow multiple UEs in TEST_UE (comma and/or space separated)
# IFS=', ' read -r -a _ue_list <<< "${TEST_UE}"
# start_services_list "${_ue_list[@]}"
# for ue in "${_ue_list[@]}"; do
# [[ -z "$ue" ]] && continue
# if wait_oai_tun "$ue" 20; then
# exec_safe "$ue" 'ip route replace default dev oaitun_ue1' || true
# run_traffic_profile "$ue" "random" "0" || true
# else
# exit 1
# fi
# done
# else
# # Initial diurnal target, then enforce UE count
# local target; if [[ "${UE_SCALER_MODE}" == "diurnal" ]]; then target="$(calc_target_ue_count)"; else target="$(random_target_ue_count)"; fi
# log "Initial diurnal target UE count: ${target}"
# ensure_ue_count "$target"
# fi
}
# -------------------------[ Diurnal UE scaling ]------------------------------
calc_target_ue_count() {
local total_ues="${UE_MAX:-}"
if [[ -z "$total_ues" ]]; then
total_ues="$(get_all_ue_services | wc -l | tr -d ' ')"
fi
local min="${UE_MIN:-1}"
if (( total_ues < min )); then total_ues="$min"; fi
local H M
H="$(date +%H)"; M="$(date +%M)"
# Compute target via cosine wave (peak at UE_PEAK_HOUR), then add noise and clamp
awk -v min="$min" -v max="$total_ues" -v peak="${UE_PEAK_HOUR:-14}" \
-v noise="${UE_NOISE_FRAC:-0.15}" -v H="$H" -v M="$M" 'BEGIN{
# time-of-day in days [0,1)
t = (H*60.0 + M)/1440.0
ph = peak/24.0
norm = (cos(2*3.141592653589793*(t - ph)) + 1)/2 # 0..1
base = min + norm*(max-min)
srand(); u = (rand()*2.0 - 1.0) * noise # [-noise, +noise]
val = base * (1.0 + u)
if (val < min) val = min
if (val > max) val = max
# round to nearest integer
printf("%.0f\n", val)
}'
}
wait_oaitun() {
local ue="$1" timeout="${2:-20}"
for ((i=1; i<=timeout; i++)); do
if exec_safe "$ue" 'ip link show oaitun_ue1 >/dev/null 2>&1'; then
return 0
fi
sleep 1
done
return 1
}
ensure_ue_count() {
local target="$1"
# Build arrays
mapfile -t all_ues < <(get_all_ue_services)
mapfile -t running < <(get_running_ues)
# Current and desired counts
local cur="${#running[@]}"
if (( cur == target )); then
log "UE scaler: already at target (${target})."
return 0
fi
# Set helpers for membership tests
declare -A IS_RUNNING=()
for u in "${running[@]}"; do IS_RUNNING["$u"]=1; done
# Determine non-running UEs
declare -a not_running=()
for u in "${all_ues[@]}"; do
[[ -z "${IS_RUNNING[$u]:-}" ]] && not_running+=("$u")
done
# Start more UEs if needed
if (( cur < target )); then
local need=$(( target - cur ))
log "UE scaler: need +${need} UEs (cur=${cur}, target=${target})."
# Choose candidates (prefer random if shuf available)
local to_start=()
if command -v shuf >/dev/null 2>&1; then
mapfile -t to_start < <(printf '%s\n' "${not_running[@]}" | shuf -n "$need" || true)
else
to_start=("${not_running[@]:0:$need}")
fi
if (( ${#to_start[@]} > 0 )); then
start_ue_list "${to_start[@]}"
# kick traffic + (best-effort) readiness
for ue in "${to_start[@]}"; do
if wait_oaitun "$ue" 20; then
exec_safe "$ue" 'ip route replace default dev oaitun_ue1'
run_traffic_profile "$ue" "random" "0"
else
dc restart "$ue"
to_start+="$ue"
fi
done
fi
return 0
fi
# Otherwise stop some UEs to shrink
local drop=$(( cur - target ))
log "UE scaler: need -${drop} UEs (cur=${cur}, target=${target})."
local to_stop=()
if command -v shuf >/dev/null 2>&1; then
mapfile -t to_stop < <(printf '%s\n' "${running[@]}" | shuf -n "$drop" || true)
else
to_stop=("${running[@]:0:$drop}")
fi
for ue in "${to_stop[@]}"; do
log "Stopping UE ${ue}"
dc stop "$ue" || true
dc rm -f "$ue" || true
done
}
# --------------------------[ UE traffic helpers ]-----------------------------
pick_profile() {
local r=$((RANDOM % 100))
if (( r < 30 )); then echo browsing # 45%
elif (( r < 70 )); then echo video # 35%
elif (( r < 90 )); then echo bulk_download # 12%
else echo iot_chatty # 8%
fi
}
run_traffic_profile() {
local ue="$1" profile="$2" duration="${3:-0}"
exec_safe "$ue" "echo 'traffic runner ready for $ue. profile: $profile'"
exec_safe "$ue" "bash -lc '
if [ ! -f /tmp/traffic_profile.lock ]; then
prof=\$1; dur=\$2
/usr/local/bin/traffic_runner --profile \$prof --duration \$dur >/tmp/traffic_runner.log 2>&1 &
printf \"%s %s\n\" \"\$prof\" \"\$dur\" > /tmp/traffic_profile.lock
fi
' -- $profile $duration"
}
# --------------------------[ RRC / MAC scraping ]-----------------------------
rotate_mac_capture() {
local tag="${1:-}"
[[ -z "$tag" ]] && tag="$(ts_tag)"
if dc ps --services --filter "status=running" | grep -q "^${MAC_STATS_CONTAINER}$"; then
log "Rotating mac_stats from ${MAC_STATS_CONTAINER}"
dc cp "${MAC_STATS_CONTAINER}:${MAC_STATS_FILE_IN_CONTAINER}" \
"${ARTIFACT_DIR}/mac_stats_${tag}.log" || true
exec_safe "$MAC_STATS_CONTAINER" "> '${MAC_STATS_FILE_IN_CONTAINER}'"
else
log "mac_stats container ${MAC_STATS_CONTAINER} not running; skipping rotate"
fi
}
_since_arg_from_file() {
local f="$1"
local val
val="$(tr -d '[]\r\n' < "$f" 2>/dev/null || true)"
if [[ -n "$val" ]]; then
printf -- '--since=%s' "$val"
else
printf -- '--since=2m'
fi
}
dump_gnb_rrc_logs() {
local tag="$1"
dc logs "${GNB_SERVICE}" 2>&1 | grep -E "NR_RRC" \
> "${ARTIFACT_DIR}/gnb_rrc_${tag}.log" || true
}
# -----------------------------[ Power / CPU ]---------------------------------
init_power_logs() {
if [[ ! -s "$POWER_LOG_FILE" ]]; then
echo "epoch,ue,profile,power_mW,dt_s,energy_mJ" > "$POWER_LOG_FILE"
fi
if [[ ! -s "$CPU_PROXY_LOG_FILE" ]]; then
echo "epoch,container,cpu_percent,mem_usage" > "$CPU_PROXY_LOG_FILE"
fi
}
profile_total_mw() {
local profile="$1"
echo "${PWR_MW_PROFILE[$profile]:-0}"
}
sample_power_once() {
local epoch="$(ts_now)"
local dt_s="${POWER_LOG_INTERVAL_SEC}"
while IFS= read -r ue; do
[[ -z "$ue" ]] && continue
local lock
lock="$(exec_safe "$ue" 'bash -lc "cat /tmp/traffic_profile.lock 2>/dev/null || true"')"
local picked_profile picked_duration
picked_profile="$(awk "{print \$1}" <<<"$lock")"
picked_duration="$(awk "{print \$2}" <<<"$lock")"
if [[ -z "$picked_profile" ]]; then
picked_profile="${TEST_PROFILE:-idle}"
fi
while [[ "$picked_profile" == -* ]]; do
picked_profile="${picked_profile#-}"
done
picked_profile="${picked_profile,,}"
local p_mw
p_mw="$(profile_total_mw "$picked_profile")"
: "${p_mw:=0}"
local energy_mJ=$(( p_mw * dt_s ))
echo "${epoch},${ue},${picked_profile},${p_mw},${dt_s},${energy_mJ}" >> "$POWER_LOG_FILE"
done < <(get_running_ues)
}
# --- CPU proxy: query docker stats by container ID and map back to service name ---
cpu_proxy_once() {
local epoch="$(ts_now)"
# Build list of UE service names (same as container names)
declare -a SVC_LIST=()
while IFS= read -r svc; do
[[ -z "$svc" ]] && continue
SVC_LIST+=("$svc")
done < <(get_running_ues)
# Query docker stats by NAME and key 'seen' by NAME
local fmt='{{.Name}},{{.CPUPerc}},{{.MemUsage}}'
declare -A SEEN=()
while IFS=',' read -r name cpu mem; do
[[ -z "$name" ]] && continue
name="${name#/}"
SEEN["$name"]=1
cpu="${cpu%%%}" # drop trailing '%'
echo "${epoch},${name},${cpu},${mem}" >> "$CPU_PROXY_LOG_FILE"
done < <(docker stats --no-stream --format "$fmt" "${SVC_LIST[@]}" 2>/dev/null || true)
# Any container absent from docker stats output → record zeros once
local svc
for svc in "${SVC_LIST[@]}"; do
if [[ -z "${SEEN[$svc]:-}" ]]; then
echo "${epoch},${svc},0,0" >> "$CPU_PROXY_LOG_FILE"
fi
done
}
rotate_csv_if_any() {
local src="$1" prefix="$2" tag="${3:-}"
[[ -z "$tag" ]] && tag="$(ts_tag)"
if [[ -s "$src" ]]; then
mv "$src" "${ARTIFACT_DIR}/${prefix}_${tag}.csv" || true
case "$prefix" in
power_usage) echo "epoch,ue,profile,power_mW,dt_s,energy_mJ" > "$src" ;;
cpu_proxy) echo "epoch,container,cpu_percent,mem_usage" > "$src" ;;
esac
fi
}
finalize_and_rotate_artifacts() {
local tag; tag="$(ts_tag)"
rotate_mac_capture "$tag" || true
dump_gnb_rrc_logs "$tag" || true
rotate_csv_if_any "$POWER_LOG_FILE" "power_usage" "$tag"
rotate_csv_if_any "$CPU_PROXY_LOG_FILE" "cpu_proxy" "$tag"
}
# -------------------------------[ Signals ]-----------------------------------
__SHUTTING_DOWN=0
on_interrupt() {
if [[ "${__SHUTTING_DOWN}" -eq 1 ]]; then return; fi
__SHUTTING_DOWN=1
log "Signal received (INT/TERM); taking final samples and rotating artifacts…"
# One last sample tick
sample_power_once || true
cpu_proxy_once || true
# Rotate artifacts and clean up
finalize_and_rotate_artifacts
cleanup
# 130 is the conventional exit code for SIGINT
exit 130
}
# --------------------------------[ Cleanup ]----------------------------------
cleanup() {
# Default: stop only UEs; keep infra up for inspection
log "Cleanup: stopping UE containers only"
while IFS= read -r ue; do
[[ -z "$ue" ]] && continue
dc stop "$ue" || true
dc rm -f "$ue" || true
done < <(dc ps --services | grep -E "${UE_NAME_REGEX}" || true)
}
# ---------------------------------[ Main ]------------------------------------
trigger_system_burst() {
mapfile -t running < <(get_running_ues)
if (( ${#running[@]} == 0 )); then
return 0
fi
log "Triggering system-wide burst for ${#running[@]} UE(s)…"
for ue in "${running[@]}"; do
exec_safe "$ue" "bash -lc 'date +%s > ${BURST_MARK_FILE}'" || true
done
}
random_target_ue_count() {
local total_ues="${UE_MAX:-}"
if [[ -z "$total_ues" ]]; then
total_ues="$(get_all_ue_services | wc -l | tr -d ' ')"
fi
local min="${UE_MIN:-1}"
if (( total_ues < min )); then total_ues="$min"; fi
local val
if command -v shuf >/dev/null 2>&1; then
val="$(shuf -i "${min}-${total_ues}" -n 1)"
else
local span=$(( total_ues - min + 1 ))
val=$(( min + (RANDOM % span) ))
fi
echo "$val"
}
main() {
trap on_interrupt INT TERM
init_power_logs
# Staged bring-up with readiness checks for both normal and TEST modes
start_in_stages
# Initialize next system-burst time
if (( RANDOM_BURST_MODE )); then
local next_burst_ts
next_burst_ts="$(($(ts_now) + BURST_MIN_GAP_SEC + RANDOM % (BURST_MAX_GAP_SEC - BURST_MIN_GAP_SEC + 1)))"
fi
# Kick off traffic workload(s)
if [[ -n "${TEST_UE:-}" ]]; then
log "TEST UE(S) ENABLED"
# Support multiple UEs (comma/space/newline separated)
read -r -a _ue_list <<< "$(tr ',\n' ' ' <<< "${TEST_UE}")"
read -r -a _ue_profiles <<< "$(tr ',\n' ' ' <<< "${TEST_PROFILES}")"
start_ue_list "${_ue_list[@]}"
for i in "${!_ue_list[@]}"; do
ue="${_ue_list[$i]}"
prof="${_ue_profiles[$i]}"
[[ -z "$ue" ]] && continue
if wait_oaitun "$ue" 20; then
exec_safe "$ue" "ip route replace default dev oaitun_ue1"
run_traffic_profile "$ue" "$prof"
else
exit 1
fi
done
fi
# Optional finite duration for TEST mode
if [[ -n "${TEST_UE:-}" && "${TEST_DURATION_SEC:-0}" -gt 0 ]]; then
RUN_STOP_TS=$(( $(ts_now) + TEST_DURATION_SEC ))
fi
# Main sampling & scaling loop
local last_rrc="$(( $(ts_now) - RRC_LOG_INTERVAL_SEC ))"
local last_power="$(( $(ts_now) - POWER_LOG_INTERVAL_SEC ))"
local last_cpu="$(( $(ts_now) - CPU_PROXY_INTERVAL_SEC ))"
local last_scale="$(( $(ts_now) - UE_SCALER_INTERVAL_SEC ))"
log "Entering main loop (sampling + diurnal scaler)…"
while :; do
local now; now="$(ts_now)"
# System-wide random burst scheduler
if (( RANDOM_BURST_MODE )); then
if (( now >= next_burst_ts )); then
trigger_system_burst || true
next_burst_ts="$(( now + BURST_MIN_GAP_SEC + RANDOM % (BURST_MAX_GAP_SEC - BURST_MIN_GAP_SEC + 1) ))"
fi
fi
if (( now - last_power >= POWER_LOG_INTERVAL_SEC )); then
sample_power_once || true
last_power="$now"
fi
if (( now - last_cpu >= CPU_PROXY_INTERVAL_SEC )); then
cpu_proxy_once || true
last_cpu="$now"
fi
# Re-evaluate diurnal target (normal mode only)
if [[ -z "${TEST_UE:-}" ]] && (( now - last_scale >= UE_SCALER_INTERVAL_SEC )); then
local target; if [[ "${UE_SCALER_MODE}" == "diurnal" ]]; then target="$(calc_target_ue_count)"; else target="$(random_target_ue_count)"; fi
log "Diurnal scaler tick: target = ${target}"
ensure_ue_count "$target"
last_scale="$now"
fi
# TEST duration stop gate
if [[ -n "${RUN_STOP_TS:-}" && "$now" -ge "${RUN_STOP_TS}" ]]; then
log "TEST run reached duration (${TEST_DURATION_SEC}s); finalizing artifacts…"
finalize_and_rotate_artifacts
cleanup
exit 0
fi
sleep 1
done
}
main "$@"