mirror of
https://github.com/trailofbits/buttercup
synced 2026-06-21 14:11:39 +00:00
c1856c4ae3
Three defects found while verifying the pipeline end-to-end:
1. Approval one-shot race: capture_line 'competition_patch_id=' ran once
right after the patch-generated milestone, but the scheduler logs that
id only minutes later (after it builds+verifies+submits the patch). The
capture always lost the race, so approval was always skipped and the
local stack never reached Patch passed / bundle. Replace with a
wait_capture() poll loop (mirrors wait_for) so approval actually fires.
2. Default --task-duration 1800 is self-defeating: build->POV->seed-gen->
patch exceeds 30 min on normal hardware, so the task expires mid-patch
("task expired/cancelled? Will discard") and never reaches patch/bundle.
Default to 7200 so the task outlives the pipeline.
3. Default --budget 3 cannot reach patch/bundle: a full run through patch
generation costs ~$10; $3 is exhausted around POV. Default to 10.
e2e.md updated to match (defaults, the cheap --budget 3 caveat, and the
poll-then-approve description).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
481 lines
18 KiB
Bash
Executable File
481 lines
18 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# scripts/e2e.sh — Run the full Buttercup pipeline against example-libpng using
|
|
# the dev docker-compose stack (no Kubernetes required).
|
|
#
|
|
# Uses the prebuilt component images published to GHCR (via the
|
|
# compose.prebuilt.yaml overlay) instead of building them locally, so a run
|
|
# does not depend on a working local image build.
|
|
#
|
|
# This mirrors the milestones checked by .github/workflows/system-integration.yml
|
|
# but reads docker-compose logs instead of `kubectl logs`.
|
|
|
|
set -u
|
|
set -o pipefail
|
|
|
|
###############################################################################
|
|
# Config & defaults
|
|
###############################################################################
|
|
|
|
# Resolve repo root from this script's location.
|
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
|
COMPOSE_DIR="${REPO_ROOT}/dev/docker-compose"
|
|
ENV_FILE="${COMPOSE_DIR}/.env"
|
|
|
|
# Defaults — overridable via flags or environment.
|
|
#
|
|
# BUDGET: a full run through patch generation costs ~$10 of LLM spend; $3 is
|
|
# exhausted during/just after POV, so anything past seed-gen would always time
|
|
# out. Default to 10 so the whole pipeline (incl. patch+bundle) is reachable.
|
|
#
|
|
# TASK_DURATION: the CRS discards a task's work once its deadline passes. On
|
|
# normal hardware build->POV->seed-gen->patch exceeds 30 min, so an 1800s task
|
|
# expires mid-patch ("task expired/cancelled? Will discard") and never reaches
|
|
# patch/bundle. Default to 7200 (2h) so the task outlives the pipeline.
|
|
BUDGET="${LITELLM_MAX_BUDGET:-10}"
|
|
TASK_DURATION="${E2E_TASK_DURATION:-7200}"
|
|
|
|
# Prebuilt GHCR images instead of local builds (compose.prebuilt.yaml overlay).
|
|
IMAGE_TAG="${BUTTERCUP_IMAGE_TAG:-main}"
|
|
|
|
DO_PULL=1
|
|
|
|
# Internal milestone timeouts (seconds). Bundle submission is quick; the rest
|
|
# (build, vuln, seed-gen, patch) can each take a while on a low-budget run.
|
|
MILESTONE_TIMEOUT=1800
|
|
BUNDLE_TIMEOUT=300
|
|
|
|
# Temp file for the trigger_task HTTP response; cleaned up on exit.
|
|
TASK_RESP=""
|
|
|
|
###############################################################################
|
|
# Logging
|
|
###############################################################################
|
|
|
|
if [[ -t 1 ]]; then
|
|
C_RST=$'\033[0m'; C_RED=$'\033[1;31m'; C_GRN=$'\033[1;32m'
|
|
C_YLW=$'\033[1;33m'; C_BLU=$'\033[1;36m'; C_DIM=$'\033[2m'
|
|
else
|
|
C_RST=""; C_RED=""; C_GRN=""; C_YLW=""; C_BLU=""; C_DIM=""
|
|
fi
|
|
|
|
log() { printf '%s[e2e]%s %s\n' "$C_BLU" "$C_RST" "$*"; }
|
|
ok() { printf '%s[e2e]%s %s\n' "$C_GRN" "$C_RST" "$*"; }
|
|
warn() { printf '%s[e2e]%s %s\n' "$C_YLW" "$C_RST" "$*" >&2; }
|
|
err() { printf '%s[e2e]%s %s\n' "$C_RED" "$C_RST" "$*" >&2; }
|
|
dim() { printf '%s[e2e]%s %s%s%s\n' "$C_BLU" "$C_RST" "$C_DIM" "$*" "$C_RST"; }
|
|
|
|
###############################################################################
|
|
# Usage
|
|
###############################################################################
|
|
|
|
usage() {
|
|
cat <<EOF
|
|
Usage: scripts/e2e.sh [options]
|
|
|
|
Runs an end-to-end smoke test of Buttercup against example-libpng using
|
|
docker-compose (no Kubernetes). Monitors scheduler/seed-gen logs for the
|
|
milestones tracked by .github/workflows/system-integration.yml.
|
|
|
|
Options:
|
|
--budget DOLLARS LiteLLM per-user max budget (default: $BUDGET)
|
|
--task-duration SECONDS How long the CRS should fuzz (default: $TASK_DURATION)
|
|
--image-tag TAG Prebuilt GHCR image tag to run (default: $IMAGE_TAG)
|
|
--no-pull Skip 'docker compose pull' (use already-pulled images)
|
|
-h, --help Print this help
|
|
|
|
Required environment (at least one provider key):
|
|
ANTHROPIC_API_KEY and/or OPENAI_API_KEY and/or GEMINI_API_KEY
|
|
|
|
Optional:
|
|
BUTTERCUP_IMAGE_TAG Prebuilt GHCR image tag (default: main; same as --image-tag)
|
|
LANGFUSE_HOST, LANGFUSE_PUBLIC_KEY, LANGFUSE_SECRET_KEY
|
|
|
|
The script writes ${ENV_FILE} from the values above each run.
|
|
EOF
|
|
}
|
|
|
|
###############################################################################
|
|
# Argument parsing
|
|
###############################################################################
|
|
|
|
while [[ $# -gt 0 ]]; do
|
|
case "$1" in
|
|
--budget) BUDGET="$2"; shift 2 ;;
|
|
--task-duration) TASK_DURATION="$2"; shift 2 ;;
|
|
--image-tag) IMAGE_TAG="$2"; shift 2 ;;
|
|
--no-pull) DO_PULL=0; shift ;;
|
|
-h|--help) usage; exit 0 ;;
|
|
*) err "Unknown argument: $1"; usage; exit 2 ;;
|
|
esac
|
|
done
|
|
|
|
###############################################################################
|
|
# Pre-flight checks
|
|
###############################################################################
|
|
|
|
if ! command -v docker >/dev/null 2>&1; then
|
|
err "docker is required but not installed."
|
|
exit 1
|
|
fi
|
|
if ! docker compose version >/dev/null 2>&1; then
|
|
err "'docker compose' v2 is required (not 'docker-compose')."
|
|
exit 1
|
|
fi
|
|
if ! command -v curl >/dev/null 2>&1; then
|
|
err "curl is required but not installed."
|
|
exit 1
|
|
fi
|
|
|
|
# Read a value already present in the existing .env. Used so that variables
|
|
# not provided via the environment (e.g. LANGFUSE_*) are preserved across runs
|
|
# instead of being clobbered, since this script regenerates .env from scratch
|
|
# on every run.
|
|
prev_env() {
|
|
[[ -f "$ENV_FILE" ]] || return 0
|
|
sed -n "s/^$1=//p" "$ENV_FILE" | head -n1
|
|
}
|
|
|
|
# 1) Prefer the environment; 2) fall back to whatever is already in .env.
|
|
: "${ANTHROPIC_API_KEY:=$(prev_env ANTHROPIC_API_KEY)}"
|
|
: "${OPENAI_API_KEY:=$(prev_env OPENAI_API_KEY)}"
|
|
: "${GEMINI_API_KEY:=$(prev_env GEMINI_API_KEY)}"
|
|
: "${AZURE_API_BASE:=$(prev_env AZURE_API_BASE)}"
|
|
: "${AZURE_API_KEY:=$(prev_env AZURE_API_KEY)}"
|
|
: "${LANGFUSE_HOST:=$(prev_env LANGFUSE_HOST)}"
|
|
: "${LANGFUSE_PUBLIC_KEY:=$(prev_env LANGFUSE_PUBLIC_KEY)}"
|
|
: "${LANGFUSE_SECRET_KEY:=$(prev_env LANGFUSE_SECRET_KEY)}"
|
|
|
|
# Require at least one usable provider key. Checked *after* the .env fallback
|
|
# above so a key saved to .env on a prior run still counts.
|
|
provider_keys_set=0
|
|
for v in ANTHROPIC_API_KEY OPENAI_API_KEY GEMINI_API_KEY; do
|
|
val="${!v:-}"
|
|
if [[ -n "$val" && "$val" != "<INSERT_KEY>" ]]; then
|
|
provider_keys_set=1
|
|
fi
|
|
done
|
|
if [[ "$provider_keys_set" -eq 0 ]]; then
|
|
err "No LLM provider key found. Set ANTHROPIC_API_KEY, OPENAI_API_KEY, or GEMINI_API_KEY."
|
|
err "Tip: 'export ANTHROPIC_API_KEY=...; scripts/e2e.sh' or add it to ${ENV_FILE} first."
|
|
exit 1
|
|
fi
|
|
|
|
# 3) Final placeholders if still unset after both env and .env. Keys left at
|
|
# the placeholder so litellm still loads its config (some models will fail at
|
|
# request time, others will succeed). LANGFUSE_* are intentionally left unset
|
|
# here: empty lines are NOT written to .env below, so a run without them set
|
|
# never clobbers LANGFUSE_* the user previously had in .env.
|
|
: "${ANTHROPIC_API_KEY:=<INSERT_KEY>}"
|
|
: "${OPENAI_API_KEY:=<INSERT_KEY>}"
|
|
: "${GEMINI_API_KEY:=<INSERT_KEY>}"
|
|
: "${AZURE_API_BASE:=<INSERT_HOST>}"
|
|
: "${AZURE_API_KEY:=<INSERT_KEY>}"
|
|
: "${LANGFUSE_HOST:=}"
|
|
: "${LANGFUSE_PUBLIC_KEY:=}"
|
|
: "${LANGFUSE_SECRET_KEY:=}"
|
|
|
|
###############################################################################
|
|
# .env generation
|
|
###############################################################################
|
|
|
|
log "Writing ${ENV_FILE} (LITELLM_MAX_BUDGET=\$${BUDGET})"
|
|
{
|
|
echo "# Generated by scripts/e2e.sh on $(date -Is)"
|
|
# litellm master key — internal to the local stack, not user-facing.
|
|
echo "BUTTERCUP_LITELLM_KEY=sk-1234"
|
|
echo "ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}"
|
|
echo "OPENAI_API_KEY=${OPENAI_API_KEY}"
|
|
echo "GEMINI_API_KEY=${GEMINI_API_KEY}"
|
|
echo "AZURE_API_BASE=${AZURE_API_BASE}"
|
|
echo "AZURE_API_KEY=${AZURE_API_KEY}"
|
|
echo "LITELLM_MAX_BUDGET=${BUDGET}"
|
|
# Only emit LANGFUSE_* when we actually have a value, so a run without
|
|
# them set leaves no empty LANGFUSE_HOST= behind to disable telemetry.
|
|
[[ -n "$LANGFUSE_HOST" ]] && echo "LANGFUSE_HOST=${LANGFUSE_HOST}"
|
|
[[ -n "$LANGFUSE_PUBLIC_KEY" ]] && echo "LANGFUSE_PUBLIC_KEY=${LANGFUSE_PUBLIC_KEY}"
|
|
[[ -n "$LANGFUSE_SECRET_KEY" ]] && echo "LANGFUSE_SECRET_KEY=${LANGFUSE_SECRET_KEY}"
|
|
true
|
|
} > "$ENV_FILE"
|
|
|
|
###############################################################################
|
|
# docker compose helpers
|
|
###############################################################################
|
|
|
|
# Always run compose from the compose dir so relative includes resolve.
|
|
# The compose.prebuilt.yaml overlay swaps every locally-built service for its
|
|
# prebuilt GHCR image, so nothing is built locally.
|
|
dc() {
|
|
(cd "$COMPOSE_DIR" \
|
|
&& BUTTERCUP_IMAGE_TAG="$IMAGE_TAG" \
|
|
docker compose -f compose.yaml -f compose.prebuilt.yaml "$@")
|
|
}
|
|
|
|
on_exit() {
|
|
rc=$?
|
|
[[ -n "$TASK_RESP" ]] && rm -f "$TASK_RESP"
|
|
log "Tearing the stack down (docker compose down -v)"
|
|
dc down -v --remove-orphans || true
|
|
if [[ $rc -ne 0 ]]; then
|
|
err "e2e run finished with exit code $rc"
|
|
fi
|
|
exit $rc
|
|
}
|
|
trap on_exit EXIT INT TERM
|
|
|
|
###############################################################################
|
|
# Bring the stack up
|
|
###############################################################################
|
|
|
|
if [[ "$DO_PULL" -eq 1 ]]; then
|
|
log "Pulling prebuilt component images from GHCR (tag: ${IMAGE_TAG})"
|
|
if ! dc pull; then
|
|
err "docker compose pull failed for tag '${IMAGE_TAG}'."
|
|
err "Check that the tag exists at ghcr.io/trailofbits/buttercup/* and that"
|
|
err "you can reach GHCR (private images need 'docker login ghcr.io')."
|
|
err "Override with --image-tag <branch-or-tag> or BUTTERCUP_IMAGE_TAG=..."
|
|
exit 1
|
|
fi
|
|
fi
|
|
|
|
log "Starting services"
|
|
if ! dc up -d; then
|
|
err "docker compose up failed. Check 'docker compose ps' / logs."
|
|
exit 1
|
|
fi
|
|
|
|
# Wait for the buttercup-ui task webhook to be reachable.
|
|
log "Waiting for buttercup-ui to accept connections on http://localhost:31323"
|
|
ui_up=0
|
|
for _ in $(seq 1 120); do
|
|
if curl -sf -o /dev/null -m 2 http://localhost:31323/v1/ping/ 2>/dev/null \
|
|
|| curl -sf -o /dev/null -m 2 http://localhost:31323/ 2>/dev/null; then
|
|
ui_up=1; break
|
|
fi
|
|
sleep 2
|
|
done
|
|
if [[ "$ui_up" -ne 1 ]]; then
|
|
err "buttercup-ui did not come up on port 31323. Check 'docker compose logs buttercup-ui'."
|
|
exit 1
|
|
fi
|
|
ok "buttercup-ui is up."
|
|
|
|
###############################################################################
|
|
# Submit the task
|
|
###############################################################################
|
|
|
|
TASK_JSON=$(cat <<EOF
|
|
{
|
|
"challenge_repo_url": "https://github.com/tob-challenges/example-libpng",
|
|
"challenge_repo_base_ref": "5bf8da2d7953974e5dfbd778429c3affd461f51a",
|
|
"challenge_repo_head_ref": "challenges/lp-delta-01",
|
|
"fuzz_tooling_url": "https://github.com/trail-of-forks/oss-fuzz",
|
|
"fuzz_tooling_ref": "fix-libpng",
|
|
"fuzz_tooling_project_name": "libpng",
|
|
"duration": ${TASK_DURATION}
|
|
}
|
|
EOF
|
|
)
|
|
|
|
log "Submitting task to buttercup-ui /webhook/trigger_task"
|
|
TASK_RESP="$(mktemp)"
|
|
http_code=$(curl -s -o "$TASK_RESP" -w '%{http_code}' \
|
|
-X POST 'http://127.0.0.1:31323/webhook/trigger_task' \
|
|
-H 'Content-Type: application/json' \
|
|
-d "$TASK_JSON")
|
|
resp_body=$(cat "$TASK_RESP" || true)
|
|
if [[ "$http_code" != "200" && "$http_code" != "201" ]]; then
|
|
err "trigger_task returned HTTP $http_code: $resp_body"
|
|
exit 1
|
|
fi
|
|
ok "Task accepted (HTTP $http_code). ${C_DIM}${resp_body}${C_RST}"
|
|
|
|
###############################################################################
|
|
# Milestone waiters
|
|
###############################################################################
|
|
|
|
# wait_for SERVICE PATTERN TIMEOUT_SEC LABEL
|
|
#
|
|
# Tails `docker compose logs <SERVICE>` until a line matching PATTERN appears
|
|
# or TIMEOUT_SEC elapses. Returns 0 on success, non-zero on timeout.
|
|
wait_for() {
|
|
local service="$1" pattern="$2" timeout="$3" label="$4"
|
|
local deadline=$(( $(date +%s) + timeout ))
|
|
log "Waiting for milestone: ${label} ${C_DIM}(service=${service}, timeout=${timeout}s)${C_RST}"
|
|
|
|
while [[ $(date +%s) -lt $deadline ]]; do
|
|
# --no-color so the grep matches plain text; --tail=all replays history.
|
|
# NOTE: capture into a var with `|| true` instead of `if cmd | grep`.
|
|
# Under `set -o pipefail`, `grep -m1` exits on the first match and the
|
|
# upstream `docker compose logs` then dies with SIGPIPE (rc 141), which
|
|
# would make the whole pipeline "fail" and the milestone never register
|
|
# for high-volume services whose match is early in the stream.
|
|
local match
|
|
match="$(dc logs --no-color --no-log-prefix --tail=all "$service" 2>/dev/null \
|
|
| grep -m1 -E "$pattern" || true)"
|
|
if [[ -n "$match" ]]; then
|
|
ok "Reached: ${label}"
|
|
return 0
|
|
fi
|
|
sleep 15
|
|
done
|
|
|
|
err "Timed out after ${timeout}s waiting for: ${label}"
|
|
err "Recent logs from ${service}:"
|
|
dc logs --no-color --tail=50 "$service" >&2 || true
|
|
return 1
|
|
}
|
|
|
|
# Capture a single matching log line (returns it on stdout, empty on miss).
|
|
capture_line() {
|
|
local service="$1" pattern="$2"
|
|
dc logs --no-color --no-log-prefix --tail=all "$service" 2>/dev/null \
|
|
| grep -E "$pattern" | head -n1 || true
|
|
}
|
|
|
|
# wait_capture SERVICE PATTERN TIMEOUT_SEC LABEL
|
|
#
|
|
# Like capture_line, but polls until the pattern appears or TIMEOUT_SEC
|
|
# elapses, echoing the first matching line on stdout (empty on timeout).
|
|
# Progress goes to stderr so stdout stays just the captured line.
|
|
#
|
|
# Needed because `competition_patch_id=` is logged by the scheduler only
|
|
# *after* it builds, verifies and submits the patch — minutes after the
|
|
# "Appending patch for task" milestone. A one-shot capture right after that
|
|
# milestone always races and loses, so approval would always be skipped.
|
|
wait_capture() {
|
|
local service="$1" pattern="$2" timeout="$3" label="$4"
|
|
local deadline=$(( $(date +%s) + timeout ))
|
|
log "Waiting to capture: ${label} ${C_DIM}(service=${service}, timeout=${timeout}s)${C_RST}" >&2
|
|
while [[ $(date +%s) -lt $deadline ]]; do
|
|
local match
|
|
match="$(dc logs --no-color --no-log-prefix --tail=all "$service" 2>/dev/null \
|
|
| grep -m1 -E "$pattern" || true)"
|
|
if [[ -n "$match" ]]; then
|
|
printf '%s\n' "$match"
|
|
return 0
|
|
fi
|
|
sleep 15
|
|
done
|
|
return 1
|
|
}
|
|
|
|
###############################################################################
|
|
# Walk through the pipeline
|
|
###############################################################################
|
|
|
|
declare -a SUMMARY=()
|
|
record() { SUMMARY+=("$1"); }
|
|
|
|
if wait_for scheduler \
|
|
"Processing build output for type FUZZER" \
|
|
"$MILESTONE_TIMEOUT" "fuzzer build processed"; then
|
|
record "fuzzer-build: ok"
|
|
else
|
|
record "fuzzer-build: TIMEOUT"
|
|
fi
|
|
|
|
# NOTE: match the structured summary line (`[i:task] pov_id=<id> ...`,
|
|
# logger.info), NOT the "POV submission response:" debug line whose payload is
|
|
# an API object repr that never contains a literal `pov_id=`.
|
|
if wait_for scheduler \
|
|
"pov_id=" \
|
|
"$MILESTONE_TIMEOUT" "vulnerability (POV) submitted"; then
|
|
record "pov-submit: ok"
|
|
else
|
|
record "pov-submit: TIMEOUT"
|
|
fi
|
|
|
|
if wait_for scheduler \
|
|
"Updated POV status. New status PASSED" \
|
|
"$MILESTONE_TIMEOUT" "POV accepted by competition API"; then
|
|
record "pov-passed: ok"
|
|
else
|
|
record "pov-passed: TIMEOUT"
|
|
fi
|
|
|
|
if wait_for seed-gen \
|
|
"Copied [1-9][0-9]* files to corpus" \
|
|
"$MILESTONE_TIMEOUT" "seed-gen produced seeds"; then
|
|
record "seed-gen: ok"
|
|
else
|
|
record "seed-gen: TIMEOUT"
|
|
fi
|
|
|
|
if wait_for scheduler \
|
|
"Appending patch for task" \
|
|
"$MILESTONE_TIMEOUT" "patch generated"; then
|
|
record "patch-generated: ok"
|
|
else
|
|
record "patch-generated: TIMEOUT"
|
|
fi
|
|
|
|
# Approve the patch (the local UI requires explicit approval, unlike scored
|
|
# rounds where it is automatic). competition_patch_id= only appears once the
|
|
# scheduler has built+verified+submitted the patch, well after the patch was
|
|
# generated, so poll for it rather than capturing once (which always races).
|
|
PATCH_LINE="$(wait_capture scheduler 'competition_patch_id=[0-9a-fA-F-]' \
|
|
"$MILESTONE_TIMEOUT" "competition_patch_id (for approval)" || true)"
|
|
if [[ -n "$PATCH_LINE" ]]; then
|
|
PATCH_ID=$(printf '%s' "$PATCH_LINE" | sed -n 's/.*competition_patch_id=\([^ ]*\).*/\1/p')
|
|
# Task id is inside the first [...] block, after the last ':'.
|
|
TASK_ID=$(printf '%s' "$PATCH_LINE" | sed -n 's/.*\[\([^]]*\)\].*/\1/p' | sed 's/^[^:]*://')
|
|
if [[ -n "$PATCH_ID" && -n "$TASK_ID" ]]; then
|
|
log "Approving patch ${C_DIM}task=${TASK_ID} patch=${PATCH_ID}${C_RST}"
|
|
if curl -fsS -X POST \
|
|
"http://127.0.0.1:31323/v1/task/${TASK_ID}/patch/${PATCH_ID}/approve" \
|
|
>/dev/null; then
|
|
record "patch-approve: ok"
|
|
else
|
|
record "patch-approve: HTTP fail"
|
|
fi
|
|
else
|
|
warn "Could not extract patch/task ids from: $PATCH_LINE"
|
|
record "patch-approve: skipped (parse fail)"
|
|
fi
|
|
else
|
|
warn "No competition_patch_id= line seen; skipping approval"
|
|
record "patch-approve: skipped (no patch line)"
|
|
fi
|
|
|
|
if wait_for scheduler \
|
|
"Patch passed" \
|
|
"$MILESTONE_TIMEOUT" "patch accepted by competition API"; then
|
|
record "patch-passed: ok"
|
|
else
|
|
record "patch-passed: TIMEOUT"
|
|
fi
|
|
|
|
# NOTE: same as POV above — match the structured summary `bundle_id=<id>`
|
|
# (logger.info), not the "Bundle submission response:" debug object repr.
|
|
if wait_for scheduler \
|
|
"bundle_id=" \
|
|
"$BUNDLE_TIMEOUT" "bundle submitted"; then
|
|
record "bundle-submit: ok"
|
|
else
|
|
record "bundle-submit: TIMEOUT"
|
|
fi
|
|
|
|
###############################################################################
|
|
# Summary
|
|
###############################################################################
|
|
|
|
printf '\n%s===================== e2e summary =====================%s\n' "$C_BLU" "$C_RST"
|
|
for line in "${SUMMARY[@]}"; do
|
|
if [[ "$line" == *": ok" ]]; then
|
|
printf ' %s✓%s %s\n' "$C_GRN" "$C_RST" "$line"
|
|
elif [[ "$line" == *": TIMEOUT" || "$line" == *"fail"* ]]; then
|
|
printf ' %s✗%s %s\n' "$C_RED" "$C_RST" "$line"
|
|
else
|
|
printf ' %s•%s %s\n' "$C_YLW" "$C_RST" "$line"
|
|
fi
|
|
done
|
|
printf '%s=======================================================%s\n' "$C_BLU" "$C_RST"
|
|
|
|
# Exit non-zero if any milestone failed.
|
|
for line in "${SUMMARY[@]}"; do
|
|
if [[ "$line" == *": TIMEOUT" || "$line" == *"fail"* ]]; then
|
|
exit 1
|
|
fi
|
|
done
|