Assistant Service — Integration Tests & Verification¶
Use this page after any deployment or config change to verify each workload and its dependencies. Tests are ordered from innermost (infra) to outermost (API).
flowchart TB
T0["0 · All pods Running"] --> T1["1 · DB — schema + tables"]
T1 --> T2["2 · Redis — RediSearch + ReJSON"]
T2 --> T3["3 · Hatchet worker registered"]
T3 --> T4["4 · core-service reachable"]
T4 --> T5["5 · Foundry LLM gateway"]
T5 --> T6["6 · KMS (tooling)"]
T6 --> T7["7 · End-to-end via ingress"]
style T0 fill:#1e3a5f,stroke:#4a90d9,color:#fff
style T7 fill:#1e5f3a,stroke:#4ad98a,color:#fff
0. Pre-flight — all pods Running¶
Expected: every workload shows Running with restart count 0 (or low after initial config fixes).
| Workload | Container | Port | Expected status |
|---|---|---|---|
assistant-service |
assistants API | 8002 | Running, probe ✅ |
assistant-service-intelligence |
RAG / agents | 8003 | Running, probe ✅ |
assistant-service-tooling |
MCP ToolBus | 8001 | Running, probe ✅ |
assistant-service-generic-worker |
Hatchet worker | — | Running |
assistant-service-migration (Job) |
alembic | — | Completed |
If any pod is CrashLoopBackOff or restarting, get its logs before continuing:
kubectl logs -n ask deploy/<workload-name> --since=3m | tail -60
kubectl logs -n ask deploy/<workload-name> -p 2>/dev/null | tail -60 # previous crash
1. Database — migration ran, schema exists¶
The assistant-service-migration Job runs alembic upgrade head at sync-wave 1, before the
deployments start.
# 1a. Confirm the migration job completed
kubectl get job -n ask | grep -i assistant
# expect: assistant-service-migration 1/1 <age>
# 1b. Verify the schema and a sample table exist
kubectl exec -n ask core-pg-1 -- \
psql -U postgres -d assistant-service -c '\dn'
# expect: schema "assistant_service"
kubectl exec -n ask core-pg-1 -- \
psql -U postgres -d assistant-service -c '\dt assistant_service.*' | head -20
# expect: tables like conversations, assistants, messages, ...
Fail path: migration job not found → check ArgoCD sync-wave ordering; the job is PreSync
wave -1 and the ConfigMaps are wave -2. If the ConfigMap assistant-service-postgres-init-script
is missing, the DB-init Job also didn't run (database not created) — re-sync the app.
2. Redis — connectivity and module check¶
assistant-service uses Redis for chat-session cache and MCP state. The bitnami 8.2.1 image
bundles RediSearch + ReJSON; the chart loads them via commonConfiguration.
# 2a. Confirm Redis pod is Running
kubectl get pod -n ask -l app.kubernetes.io/name=redis | grep assistant
# 2b. Ping from the main assistants pod
REDIS_PASS=$(kubectl get secret -n ask assistant-service-secret \
-o jsonpath='{.data.REDIS_PASSWORD}' | base64 -d)
kubectl exec -n ask deploy/assistant-service -- \
redis-cli -h assistant-service-redis-master.ask.svc.cluster.local \
-a "$REDIS_PASS" PING
# expect: PONG
# 2c. Confirm RediSearch and ReJSON modules are loaded
kubectl exec -n ask assistant-service-redis-master-0 -- \
redis-cli -a "$REDIS_PASS" MODULE LIST
# expect: entries for "search" (RediSearch) and "ReJSON"
Fail path — modules missing: the redis:8.2.1 image includes modules in /opt/bitnami/redis/lib/redis/modules/. If MODULE LIST returns empty, the commonConfiguration loadmodule lines aren't taking effect — check the redis StatefulSet config and restart the redis pod.
3. Hatchet — worker registered and running¶
The assistant-service-generic-worker connects to hatchet-engine.ask.svc.cluster.local:7070
(plaintext gRPC — requires SERVER_GRPC_INSECURE=t on the engine).
Don't grep for the startup banner on a long-running pod
The STARTING HATCHET / starting runner lines are logged once at boot. On a pod that's
been up for hours they've scrolled off, and a --since=5m + grep "hatchet|worker|runner"
returns nothing — which looks like a failure but isn't. The worker's runtime logs use a
🪓 emoji and the words rx, step run, finished (none match that grep). Validate against
steady-state runtime, not the boot banner.
# 3a (preferred). Steady-state — confirm the worker is RECEIVING and RUNNING dispatched work.
# This only happens over a live gRPC stream to the engine, so it proves registration + connectivity
# more strongly than the startup banner does.
kubectl logs -n ask deploy/assistant-service-generic-worker --tail=40 | \
grep -iE "step run|finished step|refreshed|🪓"
✅ Healthy output (the built-in refresh_usage_counts cron fires every 10 min):
🪓 -- rx: start step run: <id>/assistant_refresh_usage_counts:refresh_usage_counts
🪓 -- run: start step: assistant_refresh_usage_counts:refresh_usage_counts/<id>
assistant_service - INFO - Refreshed mv_assistant_usage_counts
🪓 -- finished step run: assistant_refresh_usage_counts:refresh_usage_counts/<id>
0 restarts, 1/1 Running.
# 3b (boot banner — only meaningful on a freshly (re)started pod, within ~5 min of start).
kubectl logs -n ask deploy/assistant-service-generic-worker | \
grep -iE "STARTING HATCHET|starting runner|legacy" | head
# expect (if caught early enough):
# STARTING HATCHET...
# Connected to an older Hatchet engine ... Falling back to legacy worker registration.
# starting runner...
❌ Fail patterns:
| Error | Cause | Fix |
|---|---|---|
Socket closed |
engine TLS mismatch (SERVER_GRPC_INSECURE=f) |
kubectl patch secret hatchet-shared-config -n ask --type=merge -p '{"stringData":{"SERVER_GRPC_INSECURE":"t"}}' && kubectl rollout restart deploy/hatchet-engine -n ask |
invalid JWT / pod crash on start |
HATCHET__CLIENT_TOKEN is a random placeholder, not a real JWT |
TOK=$(kubectl get secret -n ask hatchet-client-config -o jsonpath='{.data.HATCHET_CLIENT_TOKEN}' | base64 -d) && vault kv patch secret/ask/assistant-service/auh1-dev HATCHET__CLIENT_TOKEN="$TOK" |
could not resolve host: hatchet.ask... |
token embeds the external hostname, HATCHET_CLIENT_HOST_PORT override missing |
confirm HATCHET_CLIENT_HOST_PORT=hatchet-engine.ask.svc.cluster.local:7070 is set in envConf.extraEnv |
# 3c. Verify on the Hatchet dashboard (workers tab)
# Open: https://hatchet.ask.mod.auh1.dev.dir → Workers
# Expect: "assistant-service-generic-worker" shown as connected
# 3d. List registered workflows via Hatchet API
HATCHET_TOKEN=$(kubectl get secret -n ask hatchet-client-config \
-o jsonpath='{.data.HATCHET_CLIENT_TOKEN}' | base64 -d)
curl -sk https://hatchet.ask.mod.auh1.dev.dir/api/v1/workflows \
-H "Authorization: Bearer $HATCHET_TOKEN" | python3 -m json.tool | grep -i name | head -20
# expect: workflow definitions from assistant-service (e.g. usage-count, bulk ops)
4. core-service — reachability and auth¶
All three app workloads (assistants, intelligence, tooling) call core-service for auth/IAM.
AssistantsSettings.core_service_url (env var CORE_SERVICE_URL) must point to it.
# 4a. Confirm CORE_SERVICE_URL reached the pods
kubectl exec -n ask deploy/assistant-service -- env | grep CORE_SERVICE_URL
kubectl exec -n ask deploy/assistant-service-intelligence -- env | grep CORE_SERVICE_URL
# expect: CORE_SERVICE_URL=http://core-service.ask.svc.cluster.local/api/v1
# 4b. HTTP reachability from intelligence pod (app image has NO curl — use httpx)
kubectl exec -n ask deploy/assistant-service-intelligence -- python3 -c \
"import httpx; r=httpx.get('http://core-service.ask.svc.cluster.local/api/v1/health/ready', timeout=10); print(r.status_code, r.text[:200])"
# expect: 200 {"status":"ok"} or similar
The app images ship no curl
assistant-service, -intelligence, and -tooling are built on python:3.12-slim-trixie
with no curl/wget. Any in-pod HTTP check must use Python httpx (an app dependency, always
present), as above. curl from the bastion is fine — it only fails inside kubectl exec.
Fail path: Connection refused → core-service pod is down. connection timed out →
DNS resolution failure or network policy blocking the pod. Check
kubectl get pods -n ask -l app.kubernetes.io/name=core-service.
5. Foundry / LLM gateway — connectivity and model availability¶
intelligence calls Foundry for chat completions, embeddings, and reranking. For initial bring-up a dummy endpoint is used (AI calls fail lazily — pods start fine).
# 5a. Confirm the Foundry service is reachable from intelligence (httpx — no curl in image)
# NOTE: LiteLLM's /health REQUIRES the api key (401 "No api key passed in." if omitted) — that 401
# still PROVES reachability. For a clean no-auth reachability check use /health/liveliness.
kubectl exec -n ask deploy/assistant-service-intelligence -- python3 -c \
"import httpx; r=httpx.get('http://foundry-litellm.foundry.svc.cluster.local:4000/health/liveliness', timeout=10); print(r.status_code, r.text[:120])"
# expect: 200 (a 401 from the plain /health endpoint also means 'reachable, needs key' — not a failure)
# 5b. List models served by Foundry
FOUNDRY_KEY=$(kubectl get secret -n ask assistant-service-secret \
-o jsonpath='{.data.AI71_FOUNDRY_KEY}' | base64 -d)
kubectl exec -n ask deploy/assistant-service-intelligence -- env FK="$FOUNDRY_KEY" python3 -c \
"import os,httpx; r=httpx.get('http://foundry-litellm.foundry.svc.cluster.local:4000/v1/models', headers={'Authorization':'Bearer '+os.environ['FK']}, timeout=15); print(r.status_code); [print(m['id']) for m in r.json().get('data',[])]"
# expect: 200 then the model ids — qwen/qwen3-5-122b-a10b, Qwen/Qwen3-4B-Instruct-2507, z-ai/glm-5,
# qwen/qwen3-embedding-0-6b, nvidia/llama-nemotron-rerank-1b-v2
# (qwen/qwen3-5-4b no longer directly served — compat alias redirects to Qwen3-4B-Instruct-2507)
!!! danger "Inject the key from the Secret — don't read it from the pod env"
These tests pass the key via `env FK=$FOUNDRY_KEY` (read fresh from the Secret), **not**
`os.environ['AI71_FOUNDRY_KEY']`. The running pod's env is captured at start, so right after a
Vault key change it still holds the **old placeholder** until you `rollout restart`. Injecting
the fresh key makes the test correct regardless of restart state.
!!! bug "Use `python3 -c`, not a `python3 -` heredoc — stdin isn't forwarded"
`kubectl exec` only forwards stdin with `-i`. A `python3 - <<'EOF'` **without `-i`** reads an
empty program and exits printing **nothing** (silent, looks like a hang). Pass the code as a
`-c` argument instead (newlines inside the quoted string are fine), as below.
```bash
# 5c. Embedding smoke test — fresh key injected from the Secret
FOUNDRY_KEY=$(kubectl get secret -n ask assistant-service-secret \
-o jsonpath='{.data.AI71_FOUNDRY_KEY}' | base64 -d)
kubectl exec -n ask deploy/assistant-service-intelligence -- env FK="$FOUNDRY_KEY" python3 -c \
"import os,httpx
r=httpx.post('http://foundry-litellm.foundry.svc.cluster.local:4000/v1/embeddings', headers={'Authorization':'Bearer '+os.environ['FK']}, json={'model':'qwen/qwen3-embedding-0-6b','input':'hello world'}, timeout=30)
print('status:', r.status_code)
d=r.json()
print('dims:', len(d['data'][0]['embedding']) if 'data' in d else d)"
# expect: status 200, dims: <N> (404 → model id wrong; 401 → key stale/placeholder)
# 5d. Chat completion smoke test — fresh key injected from the Secret
FOUNDRY_KEY=$(kubectl get secret -n ask assistant-service-secret \
-o jsonpath='{.data.AI71_FOUNDRY_KEY}' | base64 -d)
kubectl exec -n ask deploy/assistant-service-intelligence -- env FK="$FOUNDRY_KEY" python3 -c \
"import os,httpx
r=httpx.post('http://foundry-litellm.foundry.svc.cluster.local:4000/v1/chat/completions', headers={'Authorization':'Bearer '+os.environ['FK']}, json={'model':'qwen/qwen3-5-122b-a10b','messages':[{'role':'user','content':'ping'}],'max_tokens':5}, timeout=60)
print('status:', r.status_code)
print(r.json()['choices'][0]['message']['content'] if r.status_code==200 else r.text[:300])"
Then roll the pods so the APP uses the real key
The tests above inject the key, but the app code still reads it from its captured env. After confirming Foundry works, restart so the real key is live for actual traffic:
# 5e. Reranker smoke test (Cohere-style /v1/rerank) — fresh key injected from the Secret
FOUNDRY_KEY=$(kubectl get secret -n ask assistant-service-secret \
-o jsonpath='{.data.AI71_FOUNDRY_KEY}' | base64 -d)
kubectl exec -n ask deploy/assistant-service-intelligence -- env FK="$FOUNDRY_KEY" python3 -c \
"import os,httpx
r=httpx.post('http://foundry-litellm.foundry.svc.cluster.local:4000/v1/rerank',
headers={'Authorization':'Bearer '+os.environ['FK']},
json={'model':'nvidia/llama-nemotron-rerank-1b-v2','query':'What is the capital of France?',
'documents':['Berlin is the capital of Germany.','Paris is the capital of France.','The Eiffel Tower is a famous landmark.'],'top_n':3},
timeout=60)
print('status',r.status_code)
print([(x['index'],round(x['relevance_score'],3)) for x in r.json()['results']] if r.status_code==200 else r.text[:300])"
# expect: 200, the Paris doc (index 1) ranked first with the highest score
Foundry is wired (2026-06-17)
The real in-cluster LiteLLM gateway and OSS models are configured — see
Foundry / LLM Endpoint. 404 model not found here means a model name drifted
back to a cloud default; 401 means AI71_FOUNDRY_KEY is missing/stale in the Secret.
Foundry gateway capability verified 2026-06-17: chat (5d), embeddings (5c), rerank (5e — model
is served and ranks correctly), VLM (KMS). Note: 5e tests the gateway; assistant-service code
does not actually call the rerank endpoint ([foundry.reranker] is unused — see
model-references Layer 3).
6. KMS — knowledge base reachability (tooling)¶
The assistant-service-tooling ToolBus calls KMS for knowledge-base search tools.
KMS may not be deployed yet — calls are lazy; the pod still starts.
# 6a. Confirm KMS_BASE_URL is set on the tooling pod
kubectl exec -n ask deploy/assistant-service-tooling -- env | grep KMS_BASE_URL
# expect: KMS_BASE_URL=http://knowledge-management-service.ask.svc.cluster.local
# 6b. Reachability check (httpx — no curl in image; only meaningful when KMS is deployed)
kubectl exec -n ask deploy/assistant-service-tooling -- python3 -c \
"import httpx,sys
try:
r=httpx.get('http://knowledge-management-service.ask.svc.cluster.local/health', timeout=8)
print('✅ KMS reachable', r.status_code)
except Exception as e:
print('⚠️ KMS not reachable (expected if not deployed):', type(e).__name__)"
7. End-to-end — API smoke test via ingress¶
# 7a. Health checks on all three HTTP services
for ep in \
"https://api.ask.mod.auh1.dev.dir/ask71/v2/astsvc/health/readiness" \
"https://api.ask.mod.auh1.dev.dir/ask71/v2/astsvc/health/liveness"; do
echo -n "$ep → "
curl -sk "$ep" | head -c 80
echo
done
# expect: 200 {"status":"ok"} or {"status":"healthy"}
How to get a CORE_SERVICE_JWT
The assistant-service API validates a user session JWT that core-service signs with the
shared CORE_SERVICE__JWT_SECRET (HS, jwt_ttl_seconds = 300). It represents a logged-in user
— there is no shortcut that bypasses the IAM chain. Two ways to obtain one:
A — Browser (easiest). Log into the ASK frontend https://ask.mod.auh1.dev.dir/ (Zitadel),
open DevTools → Network, click any call to api.ask.mod.auh1.dev.dir, and copy the
Authorization: Bearer <token> value. Use it within 5 min (300s TTL).
B — Mint via the IAM chain (scriptable). Replicates trigger_execution_service:
iam_client.auth.get_user_id_by_token(machine_token, impersonate_user_id=<user_id>) →
iam_client.jwt_service.create_jwt(user_details, impersonate_user_id=<user_id>). Needs the
Zitadel machine token (IAM_SERVICE__PAT in core-service-secret) and a real user_id /
org_id (default org 377526562817377089). More setup; use only for repeatable automation.
# 7b. Authenticated API smoke test — list assistants
CORE_SERVICE_JWT="<bearer-from-step-A-or-B>"
curl -sk https://api.ask.mod.auh1.dev.dir/ask71/v2/astsvc/api/v1/assistants \
-H "Authorization: Bearer $CORE_SERVICE_JWT" | python3 -m json.tool | head -30
# expect: 200 {"items": [...]} (may be empty list if no assistants created yet)
# 401 → token expired (>300s) or IAM/jwt_secret mismatch between core-service and assistant-service
# 7c. Direct service health checks (in-cluster service DNS, bypasses ingress; httpx — no curl)
kubectl exec -n ask deploy/assistant-service -- python3 -c \
"import httpx
for u in ['http://assistant-service.ask.svc.cluster.local:80/health/readiness',
'http://assistant-service-intelligence.ask.svc.cluster.local:8003/health/readiness',
'http://assistant-service-tooling.ask.svc.cluster.local:8001/health/mcp']:
try: print(httpx.get(u, timeout=8).status_code, u)
except Exception as e: print('ERR', type(e).__name__, u)"
8. Per-workload dependency matrix (quick reference)¶
| Workload | Hard deps (pod won't start without) | Lazy / per-request deps |
|---|---|---|
| migration | core-pg reachable, DATABASE__PASSWORD set, APP_ENV set |
— |
| assistant-service (main) | core-pg, redis, secret synced, CORE_SERVICE_URL env, APP_ENV |
core-service (auth), intelligence (per-req), tooling (per-req) |
| assistant-service-intelligence | core-pg, redis, secret synced, CORE_SERVICE_URL env, APP_ENV |
Foundry (LLM calls), tooling (tool exec), core-service |
| assistant-service-tooling | core-pg, redis, secret synced, AI71_FOUNDRY_ENDPOINT env, KMS_BASE_URL env, APP_ENV |
KMS (knowledge tools), Foundry (tool LLM), intelligence |
| assistant-service-generic-worker | real JWT HATCHET__CLIENT_TOKEN, HATCHET_CLIENT_HOST_PORT override, engine grpcInsecure=t, core-pg, redis |
— |
Config env var checklist — must be in envConf.extraEnv (applied to all workloads)
- APP_ENV: "mod-auh1-dev-ask" # Dynaconf env_switcher
- APP_ENVIRONMENT: "mod-auh1-dev-ask" # settings.py file selection
- DATABASE__PASSWORD # from assistant-service-db-password secretKeyRef
- AI71_FOUNDRY_ENDPOINT # ToolsSettings (tooling) — env only, not TOML
- KMS_BASE_URL # ToolsSettings (tooling) — env only, not TOML
- CORE_SERVICE_URL # AssistantsSettings (main + intelligence) — env only, not TOML
- HATCHET_CLIENT_HOST_PORT # override external broadcast address in JWT token
- REDIS_HOST # AssistantsSettings.redis_host — defaults "localhost"; breaks InterruptionListener
REDIS_HOST missing does NOT crash the pod (field has a default) but breaks chat stream interrupts silently.
Missing any others → the affected workload crashes at import (gunicorn exits code 3).
9. Common failure patterns (quick lookup)¶
| Symptom | Workload | Root cause | Fix |
|---|---|---|---|
AssistantsSettings — core_service_url — Field required |
main, intelligence | CORE_SERVICE_URL not in env |
add to envConf.extraEnv; kubectl set env deploy -n ask -l app.kubernetes.io/instance=assistant-service CORE_SERVICE_URL=... |
ToolsSettings — foundry_base_url — Field required |
tooling | AI71_FOUNDRY_ENDPOINT not in env |
add to envConf.extraEnv |
Socket closed (gRPC) |
generic-worker | Hatchet engine TLS mismatch | kubectl patch secret hatchet-shared-config -n ask --type=merge -p '{"stringData":{"SERVER_GRPC_INSECURE":"t"}}' + restart engine |
invalid JWT / crash at import |
generic-worker | HATCHET__CLIENT_TOKEN is a placeholder |
copy real JWT from hatchet-client-config → Vault → force-sync ExternalSecret |
Health probe timeout (context deadline exceeded) |
intelligence | pod still starting (slow ML imports) OR pydantic crash before HTTP starts | check logs; if pydantic crash, look for Field required in previous-container logs |
Error in interruption listener: Error 111 connecting to localhost:6379 |
main | REDIS_HOST not set — AssistantsSettings.redis_host defaults to "localhost" |
add REDIS_HOST=assistant-service-redis-master.ask.svc.cluster.local to envConf.extraEnv |
tiktoken ConnectTimeout openaipublic.blob.core.windows.net |
main | air-gap — can't download tokenizer vocab | harmless warning, falls back to approximate counts; ignore |
MODULE LIST empty on Redis |
redis | loadmodule config not applied |
check redis commonConfiguration; restart redis pod |
DB relation "assistant_service.*" does not exist |
main, intelligence | migration Job didn't run or failed | check migration job logs; manually run alembic upgrade head if needed |