Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changes/11307.test.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add bai v2 CLI integration scenario suite covering vfolder, session, deployment, and RBAC flows
2 changes: 2 additions & 0 deletions scenarios/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
.state/
.tmp/
13 changes: 13 additions & 0 deletions scenarios/00_setup/inactive_keypair_access_keys.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
"""Print access_keys of inactive keypairs whose user_id == $TARGET_UID. Reads admin keypair search JSON from stdin."""
import json
import os
import sys

target = os.environ["TARGET_UID"]
try:
d = json.load(sys.stdin)
except Exception:
sys.exit(0)
for it in d.get("items", []):
if it.get("user_id") == target and not it.get("is_active"):
print(it["access_key"])
170 changes: 170 additions & 0 deletions scenarios/00_setup/run.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
#!/usr/bin/env bash
# Setup: idempotently create scenario users (A, B), projects (A, B), memberships,
# vfolder host grants, and a model-card fixture in model-store.

set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
cd "$SCRIPT_DIR/../.."
source scenarios/lib/env.sh
source scenarios/lib/common.sh

scenario_begin "00_setup — create scenario users + projects"

bai_config_session
bai_login_admin

# Pre-clear login throttle: prior failed runs may have locked test users out.
log_step "Pre-clear login throttle counters in Redis"
REDIS_CONTAINER="$(docker compose -f docker-compose.halfstack.current.yml ps -q backendai-half-redis 2>/dev/null || true)"
if [[ -n "$REDIS_CONTAINER" ]]; then
for email in "$TEST_USER_A_EMAIL" "$TEST_USER_B_EMAIL"; do
docker exec "$REDIS_CONTAINER" redis-cli del "login_history_${email}" >/dev/null 2>&1 || true
done
else
log_warn "halfstack redis container not found; skipping throttle clear"
fi

log_step "Verify domain '${TEST_DOMAIN}' exists"
./bai domain get "$TEST_DOMAIN" >/dev/null

ensure_project() {
local name="$1"
local existing
existing="$(lookup_project_id "$name" || true)"
if [[ -n "$existing" ]]; then
printf '%s' "$existing"; return 0
fi
log_step "Create project '${name}'"
local body
body=$(printf '{"name":"%s","domain_name":"%s","resource_policy":"%s","description":"scenario test project"}' \
"$name" "$TEST_DOMAIN" "$TEST_PROJECT_RESOURCE_POLICY")
./bai admin project create "$body" >/dev/null
sleep 0.2
local pid
pid="$(lookup_project_id "$name")"
[[ -n "$pid" ]] || { log_error "project create succeeded but lookup failed for '${name}'"; return 1; }
printf '%s' "$pid"
}

PROJECT_A_ID="$(ensure_project "$TEST_PROJECT_A_NAME")"
state_set project_a_id "$PROJECT_A_ID"
PROJECT_B_ID="$(ensure_project "$TEST_PROJECT_B_NAME")"
state_set project_b_id "$PROJECT_B_ID"
log_ok "projects: A=$PROJECT_A_ID B=$PROJECT_B_ID"

ensure_user() {
local email="$1" username="$2" password="$3"
local existing
existing="$(lookup_user_id "$email" || true)"
if [[ -n "$existing" ]]; then
# Reactivate (may be soft-deleted) + reset password + re-enable keypairs.
local body
body=$(printf '{"status":"active","password":"%s"}' "$password")
./bai user update "$existing" "$body" >/dev/null 2>&1 || true
./bai admin keypair search --limit 200 2>/dev/null | TARGET_UID="$existing" \
python3 "$SCRIPT_DIR/inactive_keypair_access_keys.py" \
| while read -r ak; do
[[ -z "$ak" ]] && continue
./bai admin keypair update "{\"access_key\":\"$ak\",\"is_active\":true}" >/dev/null 2>&1 || true
done
printf '%s' "$existing"; return 0
fi
log_step "Create user '${email}'"
./bai admin user create \
--email "$email" \
--username "$username" \
--password "$password" \
--domain-name "$TEST_DOMAIN" \
--status active \
--role user \
--resource-policy "$TEST_USER_RESOURCE_POLICY" >/dev/null
sleep 0.3
local uid
uid="$(lookup_user_id "$email")"
[[ -n "$uid" ]] || { log_error "user create succeeded but lookup failed for '${email}'"; return 1; }
printf '%s' "$uid"
}

USER_A_ID="$(ensure_user "$TEST_USER_A_EMAIL" "$TEST_USER_A_NAME" "$TEST_USER_A_PASSWORD")"
state_set user_a_id "$USER_A_ID"
USER_B_ID="$(ensure_user "$TEST_USER_B_EMAIL" "$TEST_USER_B_NAME" "$TEST_USER_B_PASSWORD")"
state_set user_b_id "$USER_B_ID"
log_ok "users: A=$USER_A_ID B=$USER_B_ID"

# CLI for membership add isn't exposed; use the legacy GraphQL mutation.
add_user_to_project() {
local uid="$1" pid="$2"
log_step "Add user $uid to project $pid"
local query="mutation { modify_group(gid: \"${pid}\", props: {user_update_mode: \"add\", user_uuids: [\"${uid}\"]}) { ok msg } }"
local out; out="$(./bai gql "$query" 2>&1)" || true
printf '%s' "$out" | python3 "$SCN_PY/modify_group_ok.py" || log_warn "modify_group response: $out"
}

add_user_to_project "$USER_A_ID" "$PROJECT_A_ID"
add_user_to_project "$USER_B_ID" "$PROJECT_B_ID"

# Projects need allowed_vfolder_hosts populated before vfolders can be created
# bound to that host. Default is empty `{}`.
grant_host_to_project() {
local pid="$1" host="$2"
local perms='[\"create-vfolder\",\"modify-vfolder\",\"delete-vfolder\",\"mount-in-session\",\"upload-file\",\"download-file\",\"invite-others\",\"set-user-specific-permission\"]'
local hosts="{\\\"${host}\\\":${perms}}"
log_step "Grant project ${pid} access to host '${host}'"
local query="mutation { modify_group(gid: \"${pid}\", props: {allowed_vfolder_hosts: \"${hosts}\"}) { ok msg } }"
local out; out="$(./bai gql "$query" 2>&1)" || true
printf '%s' "$out" | python3 "$SCN_PY/modify_group_ok.py" || log_warn "host grant response: $out"
}

grant_host_to_project "$PROJECT_A_ID" "$TEST_VFOLDER_HOST"
grant_host_to_project "$PROJECT_B_ID" "$TEST_VFOLDER_HOST"

log_step "Locate 'model-store' project"
MODEL_STORE_ID="$(lookup_project_id "model-store" || true)"
if [[ -z "$MODEL_STORE_ID" ]]; then
log_warn "no 'model-store' project — scenarios 03/14 will fail until provisioned"
else
log_ok "model-store: $MODEL_STORE_ID"
state_set model_store_id "$MODEL_STORE_ID"
grant_host_to_project "$MODEL_STORE_ID" "$TEST_VFOLDER_HOST"

FIXTURE_NAME="${SCENARIO_PREFIX}-model-card-fixture"

log_step "Ensure model fixture vfolder '${FIXTURE_NAME}' in model-store"
FIXTURE_VFOLDER_ID="$(lookup_admin_vfolder_id "$FIXTURE_NAME" || true)"
if [[ -z "$FIXTURE_VFOLDER_ID" ]]; then
./bai vfolder create \
--name "$FIXTURE_NAME" \
--usage-mode model \
--group "$MODEL_STORE_ID" \
--host "$TEST_VFOLDER_HOST" >/dev/null
sleep 0.3
FIXTURE_VFOLDER_ID="$(lookup_admin_vfolder_id "$FIXTURE_NAME" || true)"
[[ -n "$FIXTURE_VFOLDER_ID" ]] || { log_error "fixture vfolder create succeeded but lookup failed"; exit 1; }
fi
state_set model_fixture_vfolder_id "$FIXTURE_VFOLDER_ID"

log_step "Ensure model card '${FIXTURE_NAME}' registered"
FIXTURE_CARD_ID="$(lookup_card_id "$FIXTURE_NAME" || true)"
if [[ -z "$FIXTURE_CARD_ID" ]]; then
body=$(printf '{"name":"%s","vfolder_id":"%s","model_store_project_id":"%s"}' \
"$FIXTURE_NAME" "$FIXTURE_VFOLDER_ID" "$MODEL_STORE_ID")
OUT="$(./bai admin model-card create "$body" 2>&1)" || log_warn "model-card create: $OUT"
sleep 0.3
FIXTURE_CARD_ID="$(lookup_card_id "$FIXTURE_NAME" || true)"
[[ -n "$FIXTURE_CARD_ID" ]] || log_warn "model card not found after create — 03/14 may still fail"
fi
[[ -n "${FIXTURE_CARD_ID:-}" ]] && state_set model_fixture_card_id "$FIXTURE_CARD_ID"
log_ok "fixture vfolder=$FIXTURE_VFOLDER_ID card=${FIXTURE_CARD_ID:-<missing>}"
fi

log_step "Verify each test user can log in"
bai_login_user_a
./bai my session search --limit 1 >/dev/null
bai_login_user_b
./bai my session search --limit 1 >/dev/null

# Restore admin session for downstream interactive runs.
bai_login_admin

scenario_end_ok
52 changes: 52 additions & 0 deletions scenarios/01_vfolder_lifecycle/run.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
#!/usr/bin/env bash
# 01: VFolder lifecycle — create → mkdir → ls → mv → rm → delete → purge.

set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
cd "$SCRIPT_DIR/../.."
source scenarios/lib/env.sh
source scenarios/lib/common.sh

scenario_begin "01_vfolder_lifecycle"

bai_config_session
bai_login_admin
PROJECT_A_ID="$(state_get project_a_id || lookup_project_id "$TEST_PROJECT_A_NAME")"
[[ -n "$PROJECT_A_ID" ]] || { log_error "project A not found — run 00_setup first"; exit 1; }

bai_login_user_a

VF_NAME="${SCENARIO_PREFIX}-vf-lifecycle-$$"
log_step "Create user-owned vfolder '${VF_NAME}'"
./bai vfolder create --name "$VF_NAME" --host "$TEST_VFOLDER_HOST" --usage-mode general >/dev/null
VF_ID="$(lookup_my_vfolder_id "$VF_NAME")"
[[ -n "$VF_ID" ]] || { log_error "vfolder lookup failed after create"; exit 1; }
state_set vfolder_lifecycle_id "$VF_ID"
log_ok "vfolder: $VF_ID"

log_step "mkdir data/inputs, data/outputs"
./bai vfolder mkdir "$VF_ID" data --parents --exist-ok >/dev/null
./bai vfolder mkdir "$VF_ID" data/inputs --exist-ok >/dev/null
./bai vfolder mkdir "$VF_ID" data/outputs --exist-ok >/dev/null

log_step "ls /data must show inputs and outputs"
./bai vfolder ls "$VF_ID" data 2>&1 | python3 "$SCRIPT_DIR/verify_data_ls.py" \
|| { log_error "ls verification failed"; exit 1; }

log_step "mv data/inputs → data/in"
./bai vfolder mv "$VF_ID" data/inputs data/in >/dev/null

log_step "rm data/outputs"
./bai vfolder rm "$VF_ID" data/outputs --recursive >/dev/null

log_step "delete + purge"
./bai vfolder delete "$VF_ID" >/dev/null
./bai vfolder purge "$VF_ID" >/dev/null

log_step "Verify vfolder absent from my-search"
sleep 0.5
RESIDUAL="$(lookup_my_vfolder_id "$VF_NAME" || true)"
[[ -z "$RESIDUAL" ]] || { log_error "vfolder still present after purge: $RESIDUAL"; exit 1; }

scenario_end_ok
9 changes: 9 additions & 0 deletions scenarios/01_vfolder_lifecycle/verify_data_ls.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
"""Assert that 'inputs' and 'outputs' appear in a `bai vfolder ls` response. Reads JSON from stdin."""
import json
import sys

d = json.load(sys.stdin)
items = d.get("items") or d.get("files") or []
names = [it.get("name", it.get("path", "")) for it in items] if isinstance(items, list) else []
assert any("inputs" in (n or "") for n in names), f"inputs/ not found in {names}"
assert any("outputs" in (n or "") for n in names), f"outputs/ not found in {names}"
61 changes: 61 additions & 0 deletions scenarios/02_session_lifecycle/run.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
#!/usr/bin/env bash
# 02: Compute session lifecycle — vfolder + interactive session + terminate.

set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
cd "$SCRIPT_DIR/../.."
source scenarios/lib/env.sh
source scenarios/lib/common.sh

scenario_begin "02_session_lifecycle"

bai_config_session
bai_login_admin

PROJECT_A_ID="$(state_get project_a_id || lookup_project_id "$TEST_PROJECT_A_NAME")"
[[ -n "$PROJECT_A_ID" ]] || { log_error "project A missing"; exit 1; }
IMAGE_ID="$(lookup_image_id "$TEST_IMAGE_NAME")"
[[ -n "$IMAGE_ID" ]] || { log_error "image not found: $TEST_IMAGE_NAME"; exit 1; }

bai_login_user_a

VF_NAME="${SCENARIO_PREFIX}-vf-session-$$"
log_step "Create mount-target vfolder '${VF_NAME}'"
./bai vfolder create --name "$VF_NAME" --host "$TEST_VFOLDER_HOST" >/dev/null
VF_ID="$(lookup_my_vfolder_id "$VF_NAME")"
[[ -n "$VF_ID" ]] || { log_error "vfolder create lookup failed"; exit 1; }
state_set session_vf_id "$VF_ID"

SESSION_NAME="${SCENARIO_PREFIX}-sess-$$"
PAYLOAD_FILE="$SCENARIO_TMP_DIR/enqueue-${SESSION_NAME}.json"
session_payload "$SESSION_NAME" "$IMAGE_ID" "$PROJECT_A_ID" "$VF_ID" "/home/work/${VF_NAME}" > "$PAYLOAD_FILE"

log_step "Enqueue session '${SESSION_NAME}' (mounting ${VF_NAME})"
ENQ_OUT="$(./bai session enqueue "@$PAYLOAD_FILE" 2>&1)"
SESSION_ID="$(printf '%s' "$ENQ_OUT" | session_id_from)"
[[ -n "$SESSION_ID" ]] || { log_error "Failed to extract session id"; echo "$ENQ_OUT" | head -c 2000 >&2; exit 1; }
state_set session_id "$SESSION_ID"
log_ok "session: $SESSION_ID"

log_step "Wait for session to appear in my-search"
wait_session_status "$SESSION_ID" 20 1 \
PENDING PREPARING PREPARED RUNNING TERMINATED CANCELLED ERROR >/dev/null \
|| { log_error "session never appeared in my-search"; exit 1; }

log_step "Wait for session to settle (max 60s)"
FINAL_STATUS="$(wait_session_status "$SESSION_ID" 30 2 RUNNING TERMINATED CANCELLED ERROR || true)"
log_info "session settled at: ${FINAL_STATUS:-(unknown after timeout)}"

log_step "Terminate session"
terminate_session "$SESSION_ID"

log_step "Verify session terminated within 30s"
wait_session_status "$SESSION_ID" 15 2 TERMINATED CANCELLED NOT_FOUND >/dev/null \
|| log_warn "session did not reach TERMINATED within timeout"

log_step "Cleanup mount vfolder"
./bai vfolder delete "$VF_ID" >/dev/null || true
./bai vfolder purge "$VF_ID" >/dev/null || true

scenario_end_ok
52 changes: 52 additions & 0 deletions scenarios/03_model_card_deploy/run.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
#!/usr/bin/env bash
# 03: Model card → deployment via `model-card deploy` happy path.

set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
cd "$SCRIPT_DIR/../.."
source scenarios/lib/env.sh
source scenarios/lib/common.sh

scenario_begin "03_model_card_deploy"

bai_config_session
bai_login_admin

MODEL_STORE_ID="$(lookup_project_id "model-store")"
[[ -n "$MODEL_STORE_ID" ]] || { log_error "no 'model-store' project on this cluster"; exit 1; }

log_step "Search model cards in model-store"
CARD_ID="$(./bai model-card project-search "$MODEL_STORE_ID" --limit 5 2>&1 | python3 "$SCN_PY/pick_first_id.py")"
[[ -n "$CARD_ID" ]] || { log_error "no model cards available"; exit 1; }
log_ok "card: $CARD_ID"

./bai model-card get "$CARD_ID" >/dev/null

log_step "List available revision presets"
PRESET_ID="$(./bai model-card available-presets "$CARD_ID" 2>&1 | python3 "$SCN_PY/pick_first_id.py")"
[[ -n "$PRESET_ID" ]] || { log_error "card has no revision presets"; exit 1; }

PROJECT_A_ID="$(state_get project_a_id || lookup_project_id "$TEST_PROJECT_A_NAME")"

log_step "Deploy model card to project A"
DEPLOY_OUT="$(./bai model-card deploy "$CARD_ID" \
--project-id "$PROJECT_A_ID" \
--revision-preset-id "$PRESET_ID" \
--resource-group "$TEST_RESOURCE_GROUP" \
--replicas 1 2>&1)"
DEPLOYMENT_ID="$(printf '%s' "$DEPLOY_OUT" | deployment_id_from)"
[[ -n "$DEPLOYMENT_ID" ]] || { log_error "could not extract deployment id: $DEPLOY_OUT"; exit 1; }
state_set deployment_id "$DEPLOYMENT_ID"
log_ok "deployment: $DEPLOYMENT_ID"

log_step "Verify deployment via my deployment search"
sleep 1
TARGET="$DEPLOYMENT_ID" ./bai my deployment search --limit 50 2>&1 \
| TARGET="$DEPLOYMENT_ID" python3 "$SCN_PY/assert_id_in_search.py" \
|| { log_error "deployment not visible via my deployment search"; exit 1; }

log_step "Delete deployment"
./bai deployment delete "$DEPLOYMENT_ID" >/dev/null

scenario_end_ok
21 changes: 21 additions & 0 deletions scenarios/04_deployment_revision/check_terminal_status.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
"""Verify deployment $TARGET in project-search response is in a terminal state.

Exit 0 if status ∈ {STOPPED, DESTROYED, DELETED, TERMINATED, CANCELLED}, or if
the row is absent (also acceptable). Exit 1 otherwise. Reads search JSON from stdin.
"""
import json
import os
import sys

target = os.environ["TARGET"]
TERM = {"STOPPED", "DESTROYED", "DELETED", "TERMINATED", "CANCELLED"}
for it in json.load(sys.stdin).get("items", []):
if it.get("id") == target:
st = (it.get("lifecycle") or {}).get("status") or it.get("status") or ""
if st in TERM:
print(f"terminal: {st}")
sys.exit(0)
print(f"NOT TERMINAL: {st}")
sys.exit(1)
print("absent (also acceptable)")
sys.exit(0)
Loading
Loading