11#! /usr/bin/env bash
22# deploy/knative/leaf-async-smoke.sh
33# Gated Kind smoke for async leaf completion (design §6.2). Proves: async accept, KEDA
4- # scale-out, completion via done-marker, crash-resume, scale-to-zero, deterministic failure.
4+ # scale-out, completion via Redis status polling, crash-resume, scale-to-zero, deterministic failure.
5+ # FS-free contract (P1 rearchitecture): inline item in POST body; verdict polled from
6+ # GET /runs/status?sessionId — no /work PVC, no done-marker files.
57# Prereq: setup-kind.sh (incl. KEDA) done; image rebuilt with leaf-job + async routes;
6- # leaf-pvc.yaml + leaf-orchestrator.yaml applied; leaf- scaledjob.yaml applied; sandbox-0 up.
8+ # leaf-scaledjob.yaml applied; sandbox-0 up.
79# Usage: ASYNC_LIVE_SMOKE=1 bash deploy/knative/leaf-async-smoke.sh
810set -euo pipefail
911cd " $( dirname " $0 " ) "
1012source ./lib.sh
1113
1214[ " ${ASYNC_LIVE_SMOKE:- 0} " = " 1" ] || { echo " SKIP (set ASYNC_LIVE_SMOKE=1)" ; exit 0; }
1315
14- ORCH=leaf-orchestrator ; SBOX=" ${KAGENTI_SANDBOX_POD:- sandbox-0} "
15- RUN=" arun-$$ " ; INPUTS= " /work/ $RUN /inputs " ; RES= " /work/ $RUN /results " ; SBOX_REPO=" /workspace/$RUN /repo"
16+ SBOX=" ${KAGENTI_SANDBOX_POD:- sandbox-0} "
17+ RUN=" arun-$$ " ; SBOX_REPO=" /workspace/$RUN /repo"
1618ITEMS=" i1 i2 i3" ; MODEL=" ${SH_MODEL:- claude-haiku-4-5} "
1719declare -A EXPECT=( [i1]=FLAGGED [i2]=CLEAR [i3]=CLEAR )
20+ declare -A ITEM_FILE=( [i1]=risky.py [i2]=safe.py [i3]=risky.py )
21+ declare -A ITEM_PAT=( [i1]=" eval(" [i2]=" eval(" [i3]=" subprocess" )
1822# Clean up sandbox workspace on ANY exit (incl. SIGTERM/OOM mid-run), not just the happy path.
1923trap ' kubectl -n "$NS" exec "$SBOX" -- sh -c "rm -rf /workspace/$RUN" 2>/dev/null || true' EXIT
2024claim () { echo " " ; echo " --- Claim $1 : $2 ---" ; }
21- oexec () { kubectl -n " $NS " exec " $ORCH " -- " $@ " ; }
2225leaf_job_pods () { kubectl get pods -n " $NS " -l scaledjob.keda.sh/name=leaf-worker --field-selector=status.phase=Running --no-headers 2> /dev/null | wc -l | tr -d ' ' ; }
2326
24- # async dispatch: POST {async:true} -> 202 handle
25- adispatch () {
26- local id=" $1 "
27- local model=" ${2:- $MODEL } " in=" ${3:- $INPUTS / $id .json} "
28- jq -nc --arg s " $RUN /$id " --arg m " $model " --arg in " $in " --arg out " $RES /$id .json" --arg ws " $SBOX_REPO " \
29- ' {sessionId:$s, model:$m, inputsRef:$in, resultRef:$out, workspaceRef:$ws, async:true}' \
27+ # adispatch_item <sessionId> <item_id> <file> <pattern> [model] -> echoes 202 response JSON
28+ adispatch_item () {
29+ local sid=" $1 " id=" $2 " file=" $3 " pat=" $4 " model=" ${5:- $MODEL } "
30+ jq -nc --arg s " $sid " --arg m " $model " --arg id " $id " --arg f " $file " --arg p " $pat " --arg ws " $SBOX_REPO " \
31+ ' {sessionId:$s, model:$m, workspaceRef:$ws, item:{item_id:$id, file:$f, pattern:$p}, async:true}' \
3032 | curl -s --max-time 30 -H " $HOST_HEADER " -H " Content-Type: application/json" -d @- " $BASE /runs"
3133}
3234
35+ # adispatch <item_id> [model] -> echoes 202 response JSON
36+ adispatch () {
37+ local id=" $1 " model=" ${2:- $MODEL } "
38+ adispatch_item " $RUN /$id " " $id " " ${ITEM_FILE[$id]} " " ${ITEM_PAT[$id]} " " $model "
39+ }
40+
41+ # poll_status <sessionId> -> echoes final status JSON (done|failed|aborted) or times out (exit 1)
42+ poll_status () {
43+ local sid=" $1 " i=0 resp status
44+ while [ " $i " -lt 60 ]; do
45+ resp=$( curl -s --max-time 10 -H " $HOST_HEADER " " $BASE /runs/status?sessionId=$( jq -rn --arg s " $sid " ' $s|@uri' ) " )
46+ status=$( echo " $resp " | jq -r ' .status' )
47+ case " $status " in done|failed|aborted) echo " $resp " ; return 0;; esac
48+ i=$(( i+ 1 )) ; sleep 2
49+ done
50+ echo " $resp " ; return 1
51+ }
52+
3353echo " === Async leaf smoke (run=$RUN ) ==="
3454ensure_port_forward > /dev/null || true
35- kubectl -n " $NS " wait --for=condition=Ready " pod/$ORCH " --timeout=90s > /dev/null
3655kubectl -n " $NS " wait --for=condition=Ready " pod/$SBOX " --timeout=90s > /dev/null
37- for _ in $( seq 1 30) ; do oexec sh -c ' command -v jq >/dev/null && command -v curl >/dev/null' && break ; sleep 2; done
38- seed_work_dirs " $INPUTS " " $RES " # world-writable for the non-root harness uid 65532 (issue #39)
39- # Trailing /. copies the directory CONTENTS into the (pre-created) dest; without it
40- # kubectl cp nests the source dir (…/inputs/inputs/i1.json) and runLeaf reads bad_inputs.
41- kubectl -n " $NS " cp ./fixtures/inputs/. " $ORCH :$INPUTS "
4256kubectl -n " $NS " exec " $SBOX " -- mkdir -p " $SBOX_REPO "
4357kubectl -n " $NS " cp ./fixtures/repo/. " $SBOX :$SBOX_REPO "
4458
45- # Claim 1: async accept (fast 202)
59+ # Claim 1: async accept (fast 202); capture sessionIds for Claim 3 polling
4660claim 1 " Async accept: 202 + handle, returns fast"
4761acc_ok=1
62+ declare -A ASYNC_SID=()
4863for id in $ITEMS ; do
49- st=$( adispatch " $id " | jq -r ' .status // "none"' ) ; echo " $id -> $st "
64+ resp1=$( adispatch " $id " )
65+ st=$( echo " $resp1 " | jq -r ' .status // "none"' ) ; echo " $id -> $st "
66+ ASYNC_SID[$id ]=$( echo " $resp1 " | jq -r ' .sessionId // empty' )
5067 [ " $st " = " accepted" ] || acc_ok=0
5168done
5269[ " $acc_ok " = 1 ] && ok " all accepted" || ko " not all accepted"
@@ -57,16 +74,13 @@ maxp=0
5774for _ in $( seq 1 24) ; do p=$( leaf_job_pods) ; [ " $p " -gt " $maxp " ] && maxp=$p ; [ " $p " -ge 2 ] && break ; sleep 5; done
5875[ " $maxp " -ge 2 ] && ok " observed $maxp concurrent leaf-job pods" || echo " NOTE: observed max $maxp (cap/scheduling may serialize)"
5976
60- # Claim 3: completion via done-marker + correct verdicts
61- claim 3 " Completion via done-marker ; verdicts correct"
77+ # Claim 3: completion via Redis status poll + correct verdicts (polls Claim 1 sessions)
78+ claim 3 " Completion via /runs/status poll ; verdicts correct"
6279cov_ok=1
6380for id in $ITEMS ; do
64- got=" "
65- for _ in $( seq 1 60) ; do
66- if oexec test -f " $RES /$id .json.status" ; then got=$( oexec sh -c " jq -r .status $RES /$id .json.status" ) ; break ; fi
67- sleep 5
68- done
69- v=$( oexec sh -c " jq -r .verdict $RES /$id .json 2>/dev/null" 2> /dev/null || echo " " )
81+ final=$( poll_status " ${ASYNC_SID[$id]:- $RUN / $id } " )
82+ got=$( echo " $final " | jq -r ' .status // "none"' )
83+ v=$( echo " $final " | jq -r ' .verdict.verdict // empty' 2> /dev/null || echo " " )
7084 if [ " $got " = " done" ] && [ " $v " = " ${EXPECT[$id]} " ]; then echo " $id done verdict=$v " ; else echo " $id status=$got verdict=$v (want ${EXPECT[$id]} )" ; cov_ok=0; fi
7185done
7286[ " $cov_ok " = 1 ] && ok " all async leaves completed with correct verdicts" || ko " missing/incorrect"
@@ -76,23 +90,22 @@ claim 4 "Crash mid-run → reclaimed → resumes → verdict"
7690# Quiesce: let Claim-3 leaf-job pods drain to zero so the pod we kill below provably belongs to r1
7791# (otherwise we might kill an i1/i2/i3 pod and r1's own un-killed run would complete → false pass).
7892for _ in $( seq 1 36) ; do [ " $( leaf_job_pods) " = " 0" ] && break ; sleep 5; done
79- adispatch " r1" " $MODEL " " $INPUTS /i1.json " > /dev/null
93+ r1_accept= $( adispatch_item " $RUN / r1" " i1 " " ${ITEM_FILE[i1]} " " ${ITEM_PAT[i1]} " )
8094# wait for a running leaf-job pod, then kill it
8195killed=0
8296for _ in $( seq 1 24) ; do
8397 pod=$( kubectl get pods -n " $NS " -l scaledjob.keda.sh/name=leaf-worker --field-selector=status.phase=Running --no-headers 2> /dev/null | awk ' {print $1}' | head -1)
8498 [ -n " $pod " ] && { kubectl delete pod -n " $NS " " $pod " --force --grace-period=0 > /dev/null 2>&1 ; killed=1; break ; }
8599 sleep 5
86100done
87- res_ok=" "
88- for _ in $( seq 1 72) ; do
89- if oexec test -f " $RES /r1.json.status" ; then res_ok=$( oexec sh -c " jq -r .status $RES /r1.json.status" ) ; break ; fi
90- sleep 5
91- done
92- if [ " $killed " = 1 ] && [ " $res_ok " = " done" ] && oexec sh -c " jq -e .verdict $RES /r1.json >/dev/null 2>&1" ; then
93- ok " killed a running leaf-job; reclaimed + resumed → verdict produced"
101+ r1_sid=$( echo " $r1_accept " | jq -r ' .sessionId // empty' )
102+ final_r1=$( poll_status " ${r1_sid:- $RUN / r1} " )
103+ res_ok=$( echo " $final_r1 " | jq -r ' .status // "none"' )
104+ r1_v=$( echo " $final_r1 " | jq -r ' .verdict.verdict // empty' 2> /dev/null || echo " " )
105+ if [ " $killed " = 1 ] && [ " $res_ok " = " done" ] && [ -n " $r1_v " ]; then
106+ ok " killed a running leaf-job; reclaimed + resumed → verdict=$r1_v produced"
94107else
95- ko " crash-resume: killed=$killed status=$res_ok "
108+ ko " crash-resume: killed=$killed status=$res_ok verdict= $r1_v "
96109fi
97110
98111# Claim 5: scale-to-zero when queue drains
@@ -101,15 +114,14 @@ zero=0
101114for _ in $( seq 1 36) ; do [ " $( leaf_job_pods) " = " 0" ] && { zero=1; break ; }; sleep 5; done
102115[ " $zero " = 1 ] && ok " leaf-worker scaled to zero" || ko " leaf-job pods still running"
103116
104- # Claim 6: deterministic failure → failed marker, no result, no infinite reprocess
105- claim 6 " Bad inputs → failed marker, no result_ref"
106- adispatch " ineg" " $MODEL " " $INPUTS /does-not-exist.json" > /dev/null
107- neg=" "
108- for _ in $( seq 1 36) ; do
109- if oexec test -f " $RES /ineg.json.status" ; then neg=$( oexec sh -c " jq -r .reason $RES /ineg.json.status" ) ; break ; fi
110- sleep 5
111- done
112- if [ " $neg " = " bad_inputs" ] && ! oexec test -f " $RES /ineg.json" ; then ok " failed (bad_inputs), no result_ref" ; else ko " reason=$neg " ; fi
117+ # Claim 6: input-validation path — a malformed async envelope (missing item) is rejected with 400.
118+ # In the inline contract a well-formed item always runs, so the async enqueue endpoint's
119+ # isLeafEnvelope/validateItem guard is the bad-input surface: no `item` must return HTTP 400
120+ # (not a 202 accept). The terminal "failed" verdict path itself is covered by the sync smoke.
121+ claim 6 " Malformed async envelope (missing item) is rejected with HTTP 400"
122+ neg_body=$( jq -nc --arg s " $RUN /ineg" ' {sessionId:$s, async:true}' )
123+ neg_code=$( curl -s -o /dev/null -w ' %{http_code}' --max-time 30 -H " $HOST_HEADER " -H " Content-Type: application/json" -d " $neg_body " " $BASE /runs" )
124+ if [ " $neg_code " = " 400" ]; then ok " malformed async envelope rejected (HTTP 400)" ; else ko " expected HTTP 400, got $neg_code " ; fi
113125
114126echo " " ; echo " === Results: $PASS passed, $FAIL failed ==="
115127if [ " $FAIL " -gt 0 ]; then echo " ASYNC SMOKE FAIL" ; exit 1; else echo " ASYNC SMOKE PASS" ; exit 0; fi
0 commit comments