Skip to content

Commit d199484

Browse files
authored
Merge pull request #50 from kagenti/docs/p1-fs-free-harness
P1: FS-free harness — inline /runs + Redis leaf-result + durable Sandbox CR
2 parents 2f27093 + 3eaa999 commit d199484

40 files changed

Lines changed: 2438 additions & 683 deletions

deploy/knative/leaf-async-smoke.sh

Lines changed: 57 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,52 +1,69 @@
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
810
set -euo pipefail
911
cd "$(dirname "$0")"
1012
source ./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"
1618
ITEMS="i1 i2 i3"; MODEL="${SH_MODEL:-claude-haiku-4-5}"
1719
declare -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.
1923
trap 'kubectl -n "$NS" exec "$SBOX" -- sh -c "rm -rf /workspace/$RUN" 2>/dev/null || true' EXIT
2024
claim() { echo ""; echo "--- Claim $1: $2 ---"; }
21-
oexec() { kubectl -n "$NS" exec "$ORCH" -- "$@"; }
2225
leaf_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+
3353
echo "=== Async leaf smoke (run=$RUN) ==="
3454
ensure_port_forward >/dev/null || true
35-
kubectl -n "$NS" wait --for=condition=Ready "pod/$ORCH" --timeout=90s >/dev/null
3655
kubectl -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"
4256
kubectl -n "$NS" exec "$SBOX" -- mkdir -p "$SBOX_REPO"
4357
kubectl -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
4660
claim 1 "Async accept: 202 + handle, returns fast"
4761
acc_ok=1
62+
declare -A ASYNC_SID=()
4863
for 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
5168
done
5269
[ "$acc_ok" = 1 ] && ok "all accepted" || ko "not all accepted"
@@ -57,16 +74,13 @@ maxp=0
5774
for _ 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"
6279
cov_ok=1
6380
for 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
7185
done
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).
7892
for _ 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
8195
killed=0
8296
for _ 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
86100
done
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"
94107
else
95-
ko "crash-resume: killed=$killed status=$res_ok"
108+
ko "crash-resume: killed=$killed status=$res_ok verdict=$r1_v"
96109
fi
97110

98111
# Claim 5: scale-to-zero when queue drains
@@ -101,15 +114,14 @@ zero=0
101114
for _ 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

114126
echo ""; echo "=== Results: $PASS passed, $FAIL failed ==="
115127
if [ "$FAIL" -gt 0 ]; then echo "ASYNC SMOKE FAIL"; exit 1; else echo "ASYNC SMOKE PASS"; exit 0; fi

deploy/knative/leaf-pvc.yaml

Lines changed: 0 additions & 16 deletions
This file was deleted.

deploy/knative/leaf-scaledjob.yaml

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -38,23 +38,20 @@ spec:
3838
value: "redis://redis.default.svc:6379"
3939
- name: SH_MODEL
4040
value: "claude-haiku-4-5"
41-
- name: KAGENTI_SANDBOX_POD
41+
- name: KAGENTI_SANDBOX_NAME
4242
value: "sandbox-0"
43+
- name: LEAF_RESULT_TTL_SECONDS
44+
value: "86400"
4345
- name: ANTHROPIC_API_KEY
4446
valueFrom: { secretKeyRef: { name: llm-credentials, key: api-key } }
4547
- name: ANTHROPIC_BASE_URL
4648
valueFrom: { secretKeyRef: { name: llm-credentials, key: base-url, optional: true } }
4749
- name: ANTHROPIC_AUTH_TOKEN
4850
valueFrom: { secretKeyRef: { name: llm-credentials, key: auth-token, optional: true } }
4951
volumeMounts:
50-
- name: work
51-
mountPath: /work
5252
- name: tmp
5353
mountPath: /tmp
5454
volumes:
55-
- name: work
56-
persistentVolumeClaim:
57-
claimName: leaf-work
5855
- name: tmp
5956
emptyDir: {}
6057
pollingInterval: 5

0 commit comments

Comments
 (0)