Skip to content
149 changes: 123 additions & 26 deletions exgentic_a2a_runner/analyze-run.sh
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,23 @@ WINDOW="${WINDOW:-3h}"
MLFLOW_NAMESPACE="${MLFLOW_NAMESPACE:-}"
MLFLOW_SERVICE="${MLFLOW_SERVICE:-}"
MLFLOW_REMOTE_PORT="${MLFLOW_REMOTE_PORT:-}"
MLFLOW_LOCAL_PORT="${MLFLOW_LOCAL_PORT:-8080}"
# Local port for the MLflow port-forward. Must NOT be 8080: the kind ingress
# serves keycloak.localtest.me (and other *.localtest.me hosts) on 8080, and
# keycloak.localtest.me resolves to 127.0.0.1 — so binding the port-forward to
# localhost:8080 would shadow Keycloak, and the secret-mode token request (a
# password grant against Keycloak) would hit MLflow instead.
MLFLOW_LOCAL_PORT="${MLFLOW_LOCAL_PORT:-8085}"
MLFLOW_TLS="${MLFLOW_TLS:-}"
MLFLOW_WORKSPACE="${MLFLOW_WORKSPACE:-}"
AUTH_MODE="${AUTH_MODE:-}"
# secret-mode (kind) auth: a password grant against the mlflow Keycloak client as
# an MLflow user. MLflow's mlflow-oidc-auth authorizes reads from its own user DB
# (where "admin" is seeded as a global admin), NOT from the token's Keycloak group
# claim — so the user must be one MLflow knows. Defaults to admin; override with
# MLFLOW_USER. The password defaults to the rossoctl-test-user secret in the
# keycloak namespace (which holds admin's password); override with KEYCLOAK_PASSWORD.
MLFLOW_USER="${MLFLOW_USER:-admin}"
KEYCLOAK_PASSWORD="${KEYCLOAK_PASSWORD:-}"
KUBECTL_BIN="${KUBECTL_BIN:-kubectl}"
# `whoami -t` is an OpenShift (oc) extension, not a kubectl subcommand, so the
# token command is separate from KUBECTL_BIN. Override with OC_BIN if needed.
Expand All @@ -43,6 +56,8 @@ EXPERIMENT_FILTER=""
COMPARE_EXPERIMENTS=""
CLUSTER_MODE=""
INGRESS_DOMAIN=""
# Directory to save the raw downloaded traces JSON into. Empty = don't save.
SAVE_TRACES_DIR="${SAVE_TRACES_DIR:-}"

usage() {
cat << EOF
Expand All @@ -62,6 +77,7 @@ Options:
--mlflow-tls MLflow serves HTTPS on the forwarded port
--mlflow-workspace NAME Send x-mlflow-workspace header
--auth-mode MODE Token source: secret (rossoctl oauth secret) or oc-token (oc whoami -t)
--save-traces DIR Save the raw downloaded traces JSON into DIR (created if needed)
-h, --help Show this help message

The MLflow location, TLS, workspace, auth mode, and experiment id all DEFAULT
Expand Down Expand Up @@ -89,10 +105,15 @@ Examples:
$0 --openshift apps.mycluster.example.com
$0 --openshift apps.mycluster.example.com --experiment-id 3 --compare baseline,test1
$0 -u http://mlflow.localtest.me:8080 --window 2d
$0 --window 6h --save-traces ./traces
EOF
exit 1
}

# Capture the original invocation so the auth-failure hint can print the exact
# command to re-run (the arg loop below consumes "$@" via shift).
ORIGINAL_INVOCATION=("$0" "$@")

while [[ $# -gt 0 ]]; do
case $1 in
-u|--url) MLFLOW_URL="$2"; shift 2 ;;
Expand All @@ -106,6 +127,7 @@ while [[ $# -gt 0 ]]; do
--mlflow-tls) MLFLOW_TLS="true"; shift ;;
--mlflow-workspace) MLFLOW_WORKSPACE="$2"; shift 2 ;;
--auth-mode) AUTH_MODE="$2"; shift 2 ;;
--save-traces) SAVE_TRACES_DIR="$2"; shift 2 ;;
--kind) CLUSTER_MODE="kind"; shift ;;
--openshift)
CLUSTER_MODE="openshift"
Expand Down Expand Up @@ -207,6 +229,15 @@ if ! WINDOW_MS=$(parse_window_ms "$WINDOW"); then
exit 1
fi

# If --save-traces was given, make sure the target directory exists (create it
# if needed) so the downloader's output can be written there.
if [ -n "$SAVE_TRACES_DIR" ]; then
if ! mkdir -p "$SAVE_TRACES_DIR" 2>/dev/null; then
echo "Error: could not create traces directory '$SAVE_TRACES_DIR'"
exit 1
fi
fi

echo "=== MLflow Trace Analysis ==="
echo "Cluster mode: $CLUSTER_MODE"
if [ "$USE_PORT_FORWARD" = "true" ]; then
Expand All @@ -226,6 +257,9 @@ fi
if [ -n "$COMPARE_EXPERIMENTS" ]; then
echo "Comparing Experiments: $COMPARE_EXPERIMENTS"
fi
if [ -n "$SAVE_TRACES_DIR" ]; then
echo "Saving traces to: $SAVE_TRACES_DIR"
fi
echo ""

# --- Verify kubectl points at the cluster matching CLUSTER_MODE ---
Expand All @@ -238,6 +272,14 @@ source "$SCRIPT_DIR/libsh/check-kubectl-context.sh"
check_kubectl_context
echo ""

# urls.sh provides keycloak_api_url (CLUSTER_MODE must be exported, done above);
# keycloak-direct-access.sh provides enable_direct_access_grants. Both are used
# by the secret-mode token flow (password grant against the mlflow client).
# shellcheck source=libsh/urls.sh
source "$SCRIPT_DIR/libsh/urls.sh"
# shellcheck source=libsh/keycloak-direct-access.sh
source "$SCRIPT_DIR/libsh/keycloak-direct-access.sh"

# --- Helper functions ---

OAUTH_TOKEN=""
Expand Down Expand Up @@ -277,10 +319,21 @@ cleanup_port_forward() {
fi
}

# secret mode: rossoctl's client-credentials flow. Reads mlflow-oauth-secret and
# execs into the MLflow pod to exchange it for an access token.
# secret mode: password (direct-access) grant against the mlflow Keycloak client.
#
# Why not client_credentials (the previous approach): that mints a token for the
# mlflow *service account*, which mlflow-oidc-auth does not grant experiment reads
# to, so the traces API returns 403. mlflow-oidc-auth authorizes from its own user
# DB, where "admin" is seeded as a global admin — so we obtain a token for a real
# MLflow user (default: admin) instead. The mlflow client already carries a groups
# protocol mapper and is the confidential client MLflow trusts.
#
# The client id/secret come from mlflow-oauth-secret; the user password defaults to
# the rossoctl-test-user secret (admin's password). The token endpoint is built from
# keycloak_api_url (the OIDC_TOKEN_URL in the secret points at the in-cluster
# Keycloak service, which is not reachable from the laptop).
get_token_from_secret() {
echo "Obtaining OAuth token via mlflow-oauth-secret..."
echo "Obtaining OAuth token via password grant against the mlflow client..."

# Note: under `set -e`, a failing command substitution aborts the script
# before the following `if` can run. Capture status explicitly so the
Expand All @@ -293,36 +346,43 @@ get_token_from_secret() {
return 1
fi

local client_id client_secret token_url
local client_id client_secret
client_id=$(echo "$secret_json" | jq -r '.data["OIDC_CLIENT_ID"]' | base64 -d) || true
client_secret=$(echo "$secret_json" | jq -r '.data["OIDC_CLIENT_SECRET"]' | base64 -d) || true
token_url=$(echo "$secret_json" | jq -r '.data["OIDC_TOKEN_URL"]' | base64 -d) || true

if [ -z "$client_id" ] || [ -z "$client_secret" ] || [ -z "$token_url" ]; then
echo "Error: Could not extract OAuth credentials from secret"
if [ -z "$client_id" ] || [ -z "$client_secret" ]; then
echo "Error: Could not extract OIDC client id/secret from mlflow-oauth-secret"
return 1
fi

local mlflow_pod
mlflow_pod=$("$KUBECTL_BIN" get pod -n "$MLFLOW_NAMESPACE" -l app=mlflow -o jsonpath='{.items[0].metadata.name}' 2>/dev/null) || true
if [ -z "$mlflow_pod" ]; then
echo "Error: Could not find MLflow pod"
# Resolve the MLflow user's password: explicit KEYCLOAK_PASSWORD wins, else the
# rossoctl-test-user secret (holds admin's password) in the keycloak namespace.
local user_password="$KEYCLOAK_PASSWORD"
if [ -z "$user_password" ]; then
user_password=$("$KUBECTL_BIN" get secret rossoctl-test-user -n keycloak \
-o jsonpath='{.data.password}' 2>/dev/null | base64 -d 2>/dev/null || true)
fi
if [ -z "$user_password" ]; then
echo "Error: Could not resolve a password for MLflow user '$MLFLOW_USER'"
echo "Hint: set KEYCLOAK_PASSWORD, or confirm the rossoctl-test-user secret exists in the keycloak namespace"
return 1
fi

# The mlflow client needs Direct Access Grants enabled for the password grant.
local KEYCLOAK_API
KEYCLOAK_API="$(keycloak_api_url)"
export KEYCLOAK_API
enable_direct_access_grants "$client_id"

local token_url="$KEYCLOAK_API/realms/rossoctl/protocol/openid-connect/token"
echo "Requesting token for user '$MLFLOW_USER' (client '$client_id')..."
local token_response
token_response=$("$KUBECTL_BIN" exec -n "$MLFLOW_NAMESPACE" "$mlflow_pod" -- \
python3 -c "
import urllib.request, urllib.parse, json
data = urllib.parse.urlencode({
'grant_type': 'client_credentials',
'client_id': '${client_id}',
'client_secret': '${client_secret}'
}).encode()
req = urllib.request.Request('${token_url}', data=data, headers={'Content-Type': 'application/x-www-form-urlencoded'})
resp = urllib.request.urlopen(req)
print(resp.read().decode())
" 2>/dev/null) || true
token_response=$(curl -s -X POST "$token_url" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=password" \
-d "client_id=${client_id}" \
-d "client_secret=${client_secret}" \
-d "username=${MLFLOW_USER}" \
-d "password=${user_password}" 2>/dev/null) || true

OAUTH_TOKEN=$(echo "$token_response" | jq -r '.access_token' 2>/dev/null) || true
if [ -z "$OAUTH_TOKEN" ] || [ "$OAUTH_TOKEN" = "null" ]; then
Expand Down Expand Up @@ -425,4 +485,41 @@ if [ -n "$COMPARE_EXPERIMENTS" ]; then
PYTHON_ARGS="--compare"
fi

python3 "$SCRIPT_DIR/download_mlflow_traces.py" | python3 "$SCRIPT_DIR/analyze_traces.py" $PYTHON_ARGS
# download_mlflow_traces.py exits 75 when MLflow rejects the token (a valid
# token still gets 403 until the user has logged into the MLflow UI once, which
# is what populates mlflow-oidc-auth's permission DB). Capture the downloader's
# status via PIPESTATUS so we can print an actionable hint instead of a raw
# HTTP 403 traceback.
set +e
if [ -n "$SAVE_TRACES_DIR" ]; then
# tee the downloader's stdout (the raw traces JSON) into a timestamped file
# in SAVE_TRACES_DIR before it is piped to the analyzer, so both the saved
# copy and the analysis come from the same download.
SAVE_TRACES_FILE="$SAVE_TRACES_DIR/traces-$(date +%Y%m%d-%H%M%S).json"
python3 "$SCRIPT_DIR/download_mlflow_traces.py" \
| tee "$SAVE_TRACES_FILE" \
| python3 "$SCRIPT_DIR/analyze_traces.py" $PYTHON_ARGS
DOWNLOAD_STATUS=${PIPESTATUS[0]}
else
python3 "$SCRIPT_DIR/download_mlflow_traces.py" | python3 "$SCRIPT_DIR/analyze_traces.py" $PYTHON_ARGS
DOWNLOAD_STATUS=${PIPESTATUS[0]}
fi
set -e

if [ -n "$SAVE_TRACES_DIR" ] && [ "$DOWNLOAD_STATUS" -eq 0 ]; then
echo ""
echo "✓ Saved traces to $SAVE_TRACES_FILE"
fi

if [ "$DOWNLOAD_STATUS" -eq 75 ]; then
echo ""
echo "MLflow authentication succeeded but access was denied."
echo "Log into the MLflow UI once (this registers your user with MLflow's"
echo "permission system), then re-run the analysis:"
echo ""
printf ' '; printf '%q ' "${ORIGINAL_INVOCATION[@]}"; echo
echo ""
exit 75
fi

exit "$DOWNLOAD_STATUS"
6 changes: 6 additions & 0 deletions exgentic_a2a_runner/delete-all-deployments.sh
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,8 @@ export CLUSTER_MODE INGRESS_DOMAIN
source "$SCRIPT_DIR/libsh/urls.sh"
# shellcheck source=libsh/check-kubectl-context.sh
source "$SCRIPT_DIR/libsh/check-kubectl-context.sh"
# shellcheck source=libsh/keycloak-direct-access.sh
source "$SCRIPT_DIR/libsh/keycloak-direct-access.sh"
check_kubectl_context

ROSSOCTL_API="$(rossoctl_api_url)"
Expand All @@ -117,6 +119,10 @@ echo ""
# Step 1: Get Keycloak authentication token
echo "Step 1: Getting Keycloak authentication token..."

# The token requests below use grant_type=password against the rossoctl client,
# which requires Direct Access Grants to be enabled on that client.
enable_direct_access_grants

if [ "$KEYCLOAK_PASSWORD" = "unknown" ]; then
echo "Step 1.5: Attempting to fetch Keycloak password from cluster..."
ROSSOCTL_PASSWORD=$("$KUBECTL_BIN" get secret rossoctl-test-user -n keycloak -o jsonpath='{.data.password}' 2>/dev/null | base64 -d 2>/dev/null || echo "")
Expand Down
32 changes: 3 additions & 29 deletions exgentic_a2a_runner/deploy-agent.sh
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,8 @@ source "$SCRIPT_DIR/libsh/urls.sh"
KUBECTL_BIN="${KUBECTL_BIN:-kubectl}"
# shellcheck source=libsh/check-kubectl-context.sh
source "$SCRIPT_DIR/libsh/check-kubectl-context.sh"
# shellcheck source=libsh/keycloak-direct-access.sh
source "$SCRIPT_DIR/libsh/keycloak-direct-access.sh"
check_kubectl_context

ROSSOCTL_API="$(rossoctl_api_url)"
Expand Down Expand Up @@ -331,35 +333,7 @@ fi

# Step 2: Enable Direct Access Grants for rossoctl client if needed
echo "Step 2: Enabling Direct Access Grants for rossoctl client..."

# Get admin token first (use "admin" password for master realm)
ADMIN_TOKEN_RESPONSE=$(curl -s -X POST "$KEYCLOAK_API/realms/master/protocol/openid-connect/token" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "username=admin" \
-d "password=admin" \
-d "grant_type=password" \
-d "client_id=admin-cli" 2>/dev/null || echo "TOKEN_ERROR")

if [ "$ADMIN_TOKEN_RESPONSE" != "TOKEN_ERROR" ]; then
ADMIN_TOKEN=$(echo "$ADMIN_TOKEN_RESPONSE" | grep -o '"access_token":"[^"]*"' | sed 's/"access_token":"\([^"]*\)"/\1/')

if [ -n "$ADMIN_TOKEN" ]; then
# Get rossoctl client configuration
CLIENT_CONFIG=$(curl -s "$KEYCLOAK_API/admin/realms/rossoctl/clients?clientId=rossoctl" \
-H "Authorization: Bearer $ADMIN_TOKEN" 2>/dev/null)

CLIENT_ID=$(echo "$CLIENT_CONFIG" | grep -o '"id":"[^"]*"' | head -1 | sed 's/"id":"\([^"]*\)"/\1/')

if [ -n "$CLIENT_ID" ]; then
# Enable direct access grants
curl -s -X PUT "$KEYCLOAK_API/admin/realms/rossoctl/clients/$CLIENT_ID" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"directAccessGrantsEnabled": true}' >/dev/null 2>&1
echo "✓ Direct access grants enabled for rossoctl client"
fi
fi
fi
enable_direct_access_grants

echo ""

Expand Down
52 changes: 3 additions & 49 deletions exgentic_a2a_runner/deploy-benchmark.sh
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,8 @@ source "$SCRIPT_DIR_BENCH/libsh/urls.sh"
KUBECTL_BIN="${KUBECTL_BIN:-kubectl}"
# shellcheck source=libsh/check-kubectl-context.sh
source "$SCRIPT_DIR_BENCH/libsh/check-kubectl-context.sh"
# shellcheck source=libsh/keycloak-direct-access.sh
source "$SCRIPT_DIR_BENCH/libsh/keycloak-direct-access.sh"
check_kubectl_context

# Default to Exgentic registry, can be overridden with environment variable
Expand Down Expand Up @@ -201,55 +203,7 @@ fi

# Step 4: Enable Direct Access Grants for rossoctl client if needed
echo "Step 4: Enabling Direct Access Grants for rossoctl client..."

# Resolve master-realm admin credentials: prefer env vars, fall back to the
# keycloak-initial-admin secret (RHBK operator), then defaults.
KEYCLOAK_ADMIN_USERNAME="${KEYCLOAK_ADMIN_USERNAME:-}"
KEYCLOAK_ADMIN_PASSWORD="${KEYCLOAK_ADMIN_PASSWORD:-}"
if [ -z "$KEYCLOAK_ADMIN_USERNAME" ] || [ -z "$KEYCLOAK_ADMIN_PASSWORD" ]; then
KC_ADMIN_USERNAME=$(kubectl get secret keycloak-initial-admin -n keycloak \
-o jsonpath='{.data.username}' 2>/dev/null | base64 -d 2>/dev/null || true)
KC_ADMIN_PASSWORD=$(kubectl get secret keycloak-initial-admin -n keycloak \
-o jsonpath='{.data.password}' 2>/dev/null | base64 -d 2>/dev/null || true)
KEYCLOAK_ADMIN_USERNAME="${KEYCLOAK_ADMIN_USERNAME:-${KC_ADMIN_USERNAME:-admin}}"
KEYCLOAK_ADMIN_PASSWORD="${KEYCLOAK_ADMIN_PASSWORD:-${KC_ADMIN_PASSWORD:-admin}}"
fi

ADMIN_TOKEN_RESPONSE=$(curl -s -X POST "$KEYCLOAK_API/realms/master/protocol/openid-connect/token" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "username=${KEYCLOAK_ADMIN_USERNAME}" \
-d "password=${KEYCLOAK_ADMIN_PASSWORD}" \
-d "grant_type=password" \
-d "client_id=admin-cli" 2>/dev/null) || true

ADMIN_TOKEN=$(echo "$ADMIN_TOKEN_RESPONSE" | grep -o '"access_token":"[^"]*"' | sed 's/"access_token":"\([^"]*\)"/\1/')
if [ -z "$ADMIN_TOKEN" ]; then
echo "Error: Could not obtain master-realm admin token from Keycloak"
echo " Response: $ADMIN_TOKEN_RESPONSE"
echo " Set KEYCLOAK_ADMIN_PASSWORD in your .env if the master realm admin password is not 'admin'."
exit 1
fi

CLIENT_CONFIG=$(curl -s "$KEYCLOAK_API/admin/realms/rossoctl/clients?clientId=rossoctl" \
-H "Authorization: Bearer $ADMIN_TOKEN" 2>/dev/null)
CLIENT_ID=$(echo "$CLIENT_CONFIG" | grep -o '"id":"[^"]*"' | head -1 | sed 's/"id":"\([^"]*\)"/\1/')
if [ -z "$CLIENT_ID" ]; then
echo "Error: Could not find rossoctl client ID in Keycloak"
echo " Response: $CLIENT_CONFIG"
exit 1
fi

PUT_CODE=$(curl -s -o /tmp/kc_put_response.txt -w "%{http_code}" \
-X PUT "$KEYCLOAK_API/admin/realms/rossoctl/clients/$CLIENT_ID" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"directAccessGrantsEnabled": true}' 2>/dev/null) || PUT_CODE="000"
if [ "$PUT_CODE" != "204" ] && [ "$PUT_CODE" != "200" ]; then
echo "Error: Failed to enable direct access grants for rossoctl client (HTTP $PUT_CODE)"
echo " Response: $(cat /tmp/kc_put_response.txt 2>/dev/null)"
exit 1
fi
echo "✓ Direct access grants enabled for rossoctl client"
enable_direct_access_grants

echo ""

Expand Down
Loading