From 820b67f4679835df88c67c9d7322d22da24d6bbf Mon Sep 17 00:00:00 2001 From: redpanda-f Date: Mon, 2 Mar 2026 14:49:34 +0000 Subject: [PATCH 01/42] introduce: initial scenarios --- .github/workflows/ci.yml | 64 +++++++++++++- scenario/lib.sh | 112 ++++++++++++++++++++++++ scenario/order.sh | 14 +++ scenario/run.sh | 149 ++++++++++++++++++++++++++++++++ scenario/test_basic_balances.sh | 43 +++++++++ scenario/test_containers.sh | 45 ++++++++++ 6 files changed, 426 insertions(+), 1 deletion(-) create mode 100755 scenario/lib.sh create mode 100755 scenario/order.sh create mode 100755 scenario/run.sh create mode 100755 scenario/test_basic_balances.sh create mode 100755 scenario/test_containers.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3a82ffc..63a5fbc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,6 +6,19 @@ on: branches: ['*'] pull_request: branches: [main] + schedule: + # Nightly at 03:00 UTC + - cron: '0 3 * * *' + workflow_dispatch: + inputs: + reporting: + description: 'Create GitHub issue with scenario report' + type: boolean + default: false + skip_report_on_pass: + description: 'Skip filing issue when all scenarios pass' + type: boolean + default: true jobs: fmt-clippy: @@ -26,9 +39,38 @@ jobs: - name: Run clippy run: cargo clippy --all-targets --all-features -- -D warnings + shellcheck: + runs-on: ubuntu-latest + timeout-minutes: 5 + + steps: + - uses: actions/checkout@v4 + + - name: Install shfmt + run: sudo snap install shfmt + + # Ensure all .sh files under scenario/ are executable + - name: Check executable permissions + run: | + bad=$(find scenario -name '*.sh' ! -perm -u+x) + if [ -n "$bad" ]; then + echo "Missing +x on:" >&2; echo "$bad" >&2; exit 1 + fi + + # Lint with shellcheck + - name: shellcheck + run: find scenario -name '*.sh' -exec shellcheck -S warning {} + + + # Verify consistent formatting (indent=2, binary ops start of line) + - name: shfmt + run: shfmt -d -i 2 -bn scenario/ + foc-start-test: runs-on: ["self-hosted", "linux", "x64", "16xlarge+gpu"] - timeout-minutes: 60 + timeout-minutes: 100 + permissions: + contents: read + issues: write steps: - uses: actions/checkout@v4 @@ -346,6 +388,26 @@ jobs: node check-balances.js "$DEVNET_INFO" echo "✓ All examples ran well" + # Run scenario tests against the live devnet + - name: "TEST: {Run scenario tests}" + if: steps.start_cluster.outcome == 'success' + env: + # Enable reporting for nightly schedule or when explicitly requested + REPORTING: ${{ github.event_name == 'schedule' || inputs.reporting == true }} + # By default, don't file an issue if everything passes + SKIP_REPORT_ON_PASS: ${{ inputs.skip_report_on_pass != false }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: bash scenario/run.sh + + # Upload scenario report as artifact + - name: "EXEC: {Upload scenario report}" + if: always() && steps.start_cluster.outcome == 'success' + uses: actions/upload-artifact@v4 + with: + name: scenario-report + path: ~/.foc-devnet/state/latest/scenario_*.md + if-no-files-found: ignore + # Clean shutdown - name: "EXEC: {Stop cluster}, independent" run: ./foc-devnet stop diff --git a/scenario/lib.sh b/scenario/lib.sh new file mode 100755 index 0000000..3bba87f --- /dev/null +++ b/scenario/lib.sh @@ -0,0 +1,112 @@ +#!/usr/bin/env bash +# ───────────────────────────────────────────────────────────── +# lib.sh — Shared helpers for scenario tests. +# +# Sourced (not executed) by each test_*.sh script. +# Provides: assertions, devnet-info access, and result tracking. +# +# ── Writing a new scenario ─────────────────────────────────── +# +# 1. Create scenario/test_.sh with this skeleton: +# +# #!/usr/bin/env bash +# set -euo pipefail +# SCENARIO_DIR="$(cd "$(dirname "$0")" && pwd)" +# source "${SCENARIO_DIR}/lib.sh" +# scenario_start "" +# +# # ... your checks using assert_*, jq_devnet, etc. ... +# +# scenario_end +# +# 2. Add "test_" to the SCENARIOS array in order.sh. +# 3. chmod +x scenario/test_.sh +# 4. Run: bash scenario/test_.sh +# +# ── Available helpers ──────────────────────────────────────── +# jq_devnet — query devnet-info.json +# assert_eq — equality check +# assert_gt — integer greater-than +# assert_not_empty — value is non-empty +# assert_ok — command exits 0 +# info / ok / fail — logging +# ───────────────────────────────────────────────────────────── +# shellcheck disable=SC2034 # Variables here are used by scripts that source this file +set -euo pipefail + +# ── Paths ──────────────────────────────────────────────────── +DEVNET_INFO="${DEVNET_INFO:-$HOME/.foc-devnet/state/latest/devnet-info.json}" +SCENARIO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPORT_DIR="${REPORT_DIR:-$HOME/.foc-devnet/state/latest}" + +# Per-scenario counters (reset by scenario_start) +_PASS=0 +_FAIL=0 +_SCENARIO_NAME="" + +# ── devnet-info helpers ────────────────────────────────────── + +# Shorthand: jq_devnet '.info.users[0].evm_addr' +jq_devnet() { jq -r "$1" "$DEVNET_INFO"; } + +# ── Logging ────────────────────────────────────────────────── +_log() { printf "[%s] %s\n" "$1" "$2"; } +info() { _log "[INFO]" "$*"; } +ok() { + _log "[ OK ]" "$*" + ((_PASS++)) || true +} +fail() { + _log "[FAIL]" "$*" + ((_FAIL++)) || true +} + +# ── Assertions ─────────────────────────────────────────────── + +# assert_eq +assert_eq() { + if [[ "$1" == "$2" ]]; then ok "$3"; else fail "$3 (got '$1', want '$2')"; fi +} + +# assert_not_empty +assert_not_empty() { + if [[ -n "$1" ]]; then ok "$2"; else fail "$2 (empty)"; fi +} + +# assert_gt +# Both arguments are treated as integers (wei-scale is fine). +assert_gt() { + if python3 -c "import sys; sys.exit(0 if int('$1') > int('$2') else 1)" 2>/dev/null; then + ok "$3" + else + fail "$3 (got '$1', want > '$2')" + fi +} + +# assert_ok +# Runs the command; passes if exit-code == 0. +assert_ok() { + local msg="${*: -1}" + local cmd=("${@:1:$#-1}") + if "${cmd[@]}" >/dev/null 2>&1; then ok "$msg"; else fail "$msg"; fi +} + +# ── Scenario lifecycle ─────────────────────────────────────── + +scenario_start() { + _SCENARIO_NAME="$1" + _PASS=0 + _FAIL=0 + info "━━━ START: ${_SCENARIO_NAME} ━━━" +} + +scenario_end() { + local total=$((_PASS + _FAIL)) + local status="PASS" + [[ $_FAIL -gt 0 ]] && status="FAIL" + info "━━━ END: ${_SCENARIO_NAME} ${_PASS}/${total} passed [${status}] ━━━" + # Write machine-readable result line for the runner + mkdir -p "$REPORT_DIR" + echo "${status}|${_SCENARIO_NAME}|${_PASS}|${_FAIL}" >>"${REPORT_DIR}/results.csv" + [[ $_FAIL -eq 0 ]] +} diff --git a/scenario/order.sh b/scenario/order.sh new file mode 100755 index 0000000..2c10644 --- /dev/null +++ b/scenario/order.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +# ───────────────────────────────────────────────────────────── +# order.sh — Declares the scenario execution order. +# +# Each entry is the basename of a script under scenario/. +# Scenarios share the same running devnet and execute serially +# in the order listed here. +# ───────────────────────────────────────────────────────────── + +# shellcheck disable=SC2034 # SCENARIOS is used by run.sh which sources this file +SCENARIOS=( + test_containers + test_basic_balances +) diff --git a/scenario/run.sh b/scenario/run.sh new file mode 100755 index 0000000..72db7ff --- /dev/null +++ b/scenario/run.sh @@ -0,0 +1,149 @@ +#!/usr/bin/env bash +# ───────────────────────────────────────────────────────────── +# run.sh — Scenario test runner. +# +# Executes every scenario listed in order.sh against the +# currently-running devnet, collects results, prints a report, +# and (when REPORTING=true) files a GitHub issue. +# +# ── Running locally ────────────────────────────────────────── +# +# Prerequisites: +# - A running foc-devnet cluster (./foc-devnet start) +# - jq, python3, docker on PATH +# - (optional) Foundry — installed automatically by +# test_basic_balances if missing +# +# Quick start: +# ./foc-devnet start # bring up the devnet +# bash scenario/run.sh # run all scenarios +# cat ~/.foc-devnet/state/latest/scenario_*.md # read the report +# +# Run a single scenario: +# bash scenario/test_containers.sh +# +# Override devnet-info path (e.g. an older run): +# DEVNET_INFO=~/.foc-devnet/state//devnet-info.json \ +# bash scenario/run.sh +# +# File a GitHub issue on failure (needs `gh` CLI + auth): +# REPORTING=true bash scenario/run.sh +# +# Always file an issue, even on success: +# REPORTING=true SKIP_REPORT_ON_PASS=false bash scenario/run.sh +# +# ── Environment variables ──────────────────────────────────── +# DEVNET_INFO — path to devnet-info.json (auto-detected) +# REPORTING — "true" to create a GitHub issue +# SKIP_REPORT_ON_PASS — "true" (default) skips the issue when +# all scenarios pass +# GITHUB_SERVER_URL, GITHUB_REPOSITORY, GITHUB_RUN_ID +# — set automatically by GitHub Actions +# ───────────────────────────────────────────────────────────── +set -euo pipefail + +SCENARIO_DIR="$(cd "$(dirname "$0")" && pwd)" +REPORT_DIR="${REPORT_DIR:-$HOME/.foc-devnet/state/latest}" +REPORTING="${REPORTING:-false}" +SKIP_REPORT_ON_PASS="${SKIP_REPORT_ON_PASS:-true}" + +# ── Bootstrap ──────────────────────────────────────────────── +# Clean previous scenario artifacts (but not the whole state dir) +rm -f "${REPORT_DIR}"/scenario_*.md "${REPORT_DIR}/results.csv" +source "${SCENARIO_DIR}/order.sh" + +TOTAL=0 +PASSED=0 +FAILED=0 +FAILED_NAMES=() +START_TS=$(date +%s) + +# ── Execute scenarios ──────────────────────────────────────── +for name in "${SCENARIOS[@]}"; do + script="${SCENARIO_DIR}/${name}.sh" + if [[ ! -f "$script" ]]; then + echo "[SKIP] ${name}.sh not found" + continue + fi + + ((TOTAL++)) || true + echo "" + # Each scenario runs in a subshell so a failure doesn't kill the runner + if bash "$script"; then + ((PASSED++)) || true + else + ((FAILED++)) || true + FAILED_NAMES+=("$name") + fi +done + +ELAPSED=$(($(date +%s) - START_TS)) + +# ── Build report ───────────────────────────────────────────── +REPORT="${REPORT_DIR}/scenario_$(date -u +%Y%m%d_%H%M%S).md" +{ + echo "# Scenario Test Report" + echo "" + echo "| Metric | Value |" + echo "|--------|-------|" + echo "| Total | ${TOTAL} |" + echo "| Passed | ${PASSED} |" + echo "| Failed | ${FAILED} |" + echo "| Duration | ${ELAPSED}s |" + echo "" + + # Per-scenario detail from the CSV each scenario appended + if [[ -f "${REPORT_DIR}/results.csv" ]]; then + echo "## Details" + echo "" + echo "| Status | Scenario | Passed | Failed |" + echo "|--------|----------|--------|--------|" + while IFS='|' read -r st sc pa fa; do + icon="✅" + [[ "$st" == "FAIL" ]] && icon="❌" + echo "| ${icon} ${st} | ${sc} | ${pa} | ${fa} |" + done <"${REPORT_DIR}/results.csv" + fi + + if [[ ${#FAILED_NAMES[@]} -gt 0 ]]; then + echo "" + echo "## Failed scenarios" + echo "" + for n in "${FAILED_NAMES[@]}"; do echo "- \`${n}\`"; done + fi +} >"$REPORT" + +# Print to stdout as well +cat "$REPORT" + +# ── GitHub issue (only when REPORTING=true) ────────────────── +SHOULD_REPORT=false +if [[ "$REPORTING" == "true" ]]; then + if [[ $FAILED -gt 0 ]]; then + SHOULD_REPORT=true + elif [[ "$SKIP_REPORT_ON_PASS" != "true" ]]; then + SHOULD_REPORT=true + fi +fi + +if [[ "$SHOULD_REPORT" == "true" ]]; then + RUN_URL="${GITHUB_SERVER_URL:-https://github.com}/${GITHUB_REPOSITORY:-unknown}/actions/runs/${GITHUB_RUN_ID:-0}" + STATUS_EMOJI="✅" + [[ $FAILED -gt 0 ]] && STATUS_EMOJI="❌" + ISSUE_TITLE="${STATUS_EMOJI} Scenario report: ${PASSED}/${TOTAL} passed ($(date -u +%Y-%m-%d))" + ISSUE_BODY="$(cat "$REPORT") + +--- +[View workflow run](${RUN_URL})" + + LABELS="scenario-report" + [[ $FAILED -gt 0 ]] && LABELS="scenario-report,bug" + gh issue create \ + --title "$ISSUE_TITLE" \ + --body "$ISSUE_BODY" \ + --label "$LABELS" \ + || echo "[WARN] Could not create GitHub issue (gh CLI missing or auth failed)" +fi + +# ── Exit code reflects overall result ──────────────────────── +[[ $FAILED -eq 0 ]] diff --git a/scenario/test_basic_balances.sh b/scenario/test_basic_balances.sh new file mode 100755 index 0000000..a1cbf3b --- /dev/null +++ b/scenario/test_basic_balances.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +# ───────────────────────────────────────────────────────────── +# test_basic_balances.sh +# +# Installs Foundry (cast), then verifies every user account on +# the devnet has a positive tFIL balance and a positive USDFC +# token balance. +# ───────────────────────────────────────────────────────────── +set -euo pipefail + +SCENARIO_DIR="$(cd "$(dirname "$0")" && pwd)" +source "${SCENARIO_DIR}/lib.sh" +scenario_start "basic_balances" + +# ── Ensure Foundry is available ────────────────────────────── +if ! command -v cast &>/dev/null; then + info "Installing Foundry …" + curl -sSL https://foundry.paradigm.xyz | bash + export PATH="$HOME/.foundry/bin:$PATH" +fi +assert_ok command -v cast "cast is installed" + +# ── Read devnet info ───────────────────────────────────────── +RPC_URL=$(jq_devnet '.info.lotus.host_rpc_url') +USDFC_ADDR=$(jq_devnet '.info.contracts.mockusdfc_addr') +USER_COUNT=$(jq_devnet '.info.users | length') +assert_gt "$USER_COUNT" 0 "at least one user exists" + +# ── Check each user ────────────────────────────────────────── +for i in $(seq 0 $((USER_COUNT - 1))); do + NAME=$(jq_devnet ".info.users[$i].name") + ADDR=$(jq_devnet ".info.users[$i].evm_addr") + + # Native FIL balance (returned in wei) + FIL_WEI=$(cast balance "$ADDR" --rpc-url "$RPC_URL") + assert_gt "$FIL_WEI" 0 "${NAME} FIL balance > 0" + + # MockUSDFC ERC-20 balance + USDFC_WEI=$(cast call "$USDFC_ADDR" "balanceOf(address)(uint256)" "$ADDR" --rpc-url "$RPC_URL") + assert_gt "$USDFC_WEI" 0 "${NAME} USDFC balance > 0" +done + +scenario_end diff --git a/scenario/test_containers.sh b/scenario/test_containers.sh new file mode 100755 index 0000000..7e18fc6 --- /dev/null +++ b/scenario/test_containers.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +# ───────────────────────────────────────────────────────────── +# test_containers.sh +# +# Verifies that all foc-* containers reported in devnet-info.json +# are actually running, healthy, and that no zombie foc-* +# containers exist outside the current run. +# ───────────────────────────────────────────────────────────── +set -euo pipefail + +SCENARIO_DIR="$(cd "$(dirname "$0")" && pwd)" +source "${SCENARIO_DIR}/lib.sh" +scenario_start "containers" + +# ── Collect expected container names from devnet-info ──────── +EXPECTED=() +EXPECTED+=("$(jq_devnet '.info.lotus.container_name')") +EXPECTED+=("$(jq_devnet '.info.lotus_miner.container_name')") + +# Each Curio SP also has a container +SP_COUNT=$(jq_devnet '.info.pdp_sps | length') +for i in $(seq 0 $((SP_COUNT - 1))); do + EXPECTED+=("$(jq_devnet ".info.pdp_sps[$i].container_name")") +done + +# ── Verify each expected container is running ──────────────── +for cname in "${EXPECTED[@]}"; do + STATUS=$(docker inspect -f '{{.State.Status}}' "$cname" 2>/dev/null || echo "missing") + assert_eq "$STATUS" "running" "container ${cname} is running" +done + +# ── Check no unexpected foc-c-* containers are running ─────── +# All foc-c-* containers should belong to the expected set +RUNNING=$(docker ps --filter "name=foc-c-" --format '{{.Names}}') +for cname in $RUNNING; do + KNOWN=false + for exp in "${EXPECTED[@]}"; do + [[ "$cname" == "$exp" ]] && KNOWN=true && break + done + # Portainer is allowed but not in devnet-info + [[ "$cname" == *"-portainer"* ]] && KNOWN=true + assert_eq "$KNOWN" "true" "container ${cname} is expected" +done + +scenario_end From f0fdab53971319cb1128774233a2fb43c79d7425 Mon Sep 17 00:00:00 2001 From: redpanda-f Date: Mon, 2 Mar 2026 14:55:38 +0000 Subject: [PATCH 02/42] feat: githooks, ADVANCED_README --- .githooks/install.sh | 7 ++ .githooks/pre-commit | 107 ++++++++++++++++++ .github/workflows/ci.yml | 10 +- README_ADVANCED.md | 55 +++++++++ {scenario => scenarios}/lib.sh | 6 +- {scenario => scenarios}/order.sh | 2 +- {scenario => scenarios}/run.sh | 10 +- .../test_basic_balances.sh | 0 {scenario => scenarios}/test_containers.sh | 0 9 files changed, 183 insertions(+), 14 deletions(-) create mode 100755 .githooks/install.sh create mode 100755 .githooks/pre-commit rename {scenario => scenarios}/lib.sh (96%) rename {scenario => scenarios}/order.sh (91%) rename {scenario => scenarios}/run.sh (95%) rename {scenario => scenarios}/test_basic_balances.sh (100%) rename {scenario => scenarios}/test_containers.sh (100%) diff --git a/.githooks/install.sh b/.githooks/install.sh new file mode 100755 index 0000000..f516fca --- /dev/null +++ b/.githooks/install.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +# Installs the repo's git hooks by pointing core.hooksPath at .githooks/ +set -euo pipefail +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +git -C "$REPO_ROOT" config core.hooksPath .githooks +chmod +x "$REPO_ROOT"/.githooks/* +echo "✓ Git hooks installed (.githooks/)" diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100755 index 0000000..593706f --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,107 @@ +#!/usr/bin/env bash +# ───────────────────────────────────────────────────────────── +# pre-commit hook for foc-devnet +# +# Checks staged files before each commit: +# - Rust: cargo fmt, cargo clippy +# - Shell: shfmt, shellcheck, executable bit +# +# Install: bash .githooks/install.sh +# Skip once: git commit --no-verify +# +# Auto-fix mode (formats code and re-stages): +# FIX=1 git commit +# ───────────────────────────────────────────────────────────── +set -euo pipefail + +FIX="${FIX:-0}" + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[0;33m' +BLUE='\033[0;34m' +NC='\033[0m' + +FAIL=0 + +pass() { printf "${GREEN}✓${NC} %s\n" "$1"; } +fail() { printf "${RED}✗${NC} %s\n" "$1"; FAIL=1; } +skip() { printf "${YELLOW}⊘${NC} %s (skipped — tool not found)\n" "$1"; } +fixed() { printf "${BLUE}⟳${NC} %s (auto-fixed & re-staged)\n" "$1"; } + +# Collect staged files +STAGED=$(git diff --cached --name-only --diff-filter=ACM) + +# ── Rust checks ────────────────────────────────────────────── +HAS_RS=$(echo "$STAGED" | grep -c '\.rs$' || true) +if [[ $HAS_RS -gt 0 ]]; then + if command -v cargo &>/dev/null; then + if cargo fmt --all -- --check &>/dev/null; then + pass "cargo fmt" + elif [[ "$FIX" == "1" ]]; then + cargo fmt --all + git diff --name-only -- '*.rs' | xargs -r git add + fixed "cargo fmt" + else + fail "cargo fmt — run 'cargo fmt' or FIX=1 git commit" + fi + + if cargo clippy --all-targets --all-features -- -D warnings &>/dev/null; then + pass "cargo clippy" + else + fail "cargo clippy — fix warnings before committing" + fi + else + skip "cargo (Rust checks)" + fi +fi + +# ── Shell checks ───────────────────────────────────────────── +STAGED_SH=$(echo "$STAGED" | grep '\.sh$' || true) +if [[ -n "$STAGED_SH" ]]; then + # Executable bit + for f in $STAGED_SH; do + if [[ -f "$f" && ! -x "$f" ]]; then + if [[ "$FIX" == "1" ]]; then + chmod +x "$f" + git add "$f" + fixed "${f} +x" + else + fail "${f} is not executable — run 'chmod +x ${f}'" + fi + fi + done + + # shfmt + if command -v shfmt &>/dev/null; then + if shfmt -d -i 2 -bn $STAGED_SH &>/dev/null; then + pass "shfmt" + elif [[ "$FIX" == "1" ]]; then + shfmt -w -i 2 -bn $STAGED_SH + echo "$STAGED_SH" | xargs -r git add + fixed "shfmt" + else + fail "shfmt — run 'shfmt -w -i 2 -bn ' or FIX=1 git commit" + fi + else + skip "shfmt" + fi + + # shellcheck (no auto-fix available) + if command -v shellcheck &>/dev/null; then + if shellcheck -S warning $STAGED_SH &>/dev/null; then + pass "shellcheck" + else + fail "shellcheck — run 'shellcheck -S warning ' to see issues" + fi + else + skip "shellcheck" + fi +fi + +# ── Result ─────────────────────────────────────────────────── +if [[ $FAIL -ne 0 ]]; then + echo "" + printf "${RED}Pre-commit checks failed.${NC} Fix issues above or skip with: git commit --no-verify\n" + exit 1 +fi diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 63a5fbc..0afebcd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -49,21 +49,21 @@ jobs: - name: Install shfmt run: sudo snap install shfmt - # Ensure all .sh files under scenario/ are executable + # Ensure all .sh files under scenarios/ are executable - name: Check executable permissions run: | - bad=$(find scenario -name '*.sh' ! -perm -u+x) + bad=$(find scenarios -name '*.sh' ! -perm -u+x) if [ -n "$bad" ]; then echo "Missing +x on:" >&2; echo "$bad" >&2; exit 1 fi # Lint with shellcheck - name: shellcheck - run: find scenario -name '*.sh' -exec shellcheck -S warning {} + + run: find scenarios -name '*.sh' -exec shellcheck -S warning {} + # Verify consistent formatting (indent=2, binary ops start of line) - name: shfmt - run: shfmt -d -i 2 -bn scenario/ + run: shfmt -d -i 2 -bn scenarios/ foc-start-test: runs-on: ["self-hosted", "linux", "x64", "16xlarge+gpu"] @@ -397,7 +397,7 @@ jobs: # By default, don't file an issue if everything passes SKIP_REPORT_ON_PASS: ${{ inputs.skip_report_on_pass != false }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: bash scenario/run.sh + run: bash scenarios/run.sh # Upload scenario report as artifact - name: "EXEC: {Upload scenario report}" diff --git a/README_ADVANCED.md b/README_ADVANCED.md index 5837540..1489c90 100644 --- a/README_ADVANCED.md +++ b/README_ADVANCED.md @@ -1296,3 +1296,58 @@ docker run --rm --network host \ --broadcast ``` +## Scenario Tests + +Scenario tests are lightweight shell scripts that validate scenarios on the devnet after startup. They share a single running devnet and execute serially in a defined order. + +### Running scenarios + +```bash +# Run all scenarios against a running devnet +bash scenarios/run.sh + +# Run a single scenario +bash scenarios/test_basic_balances.sh + +# Point at a specific devnet run +DEVNET_INFO=~/.foc-devnet/state//devnet-info.json bash scenarios/run.sh +``` + +Reports are written to `~/.foc-devnet/state/latest/scenario_.md`. + +### Writing a new scenario + +1. Create `scenarios/test_.sh`: + +```bash +#!/usr/bin/env bash +set -euo pipefail +SCENARIO_DIR="$(cd "$(dirname "$0")" && pwd)" +source "${SCENARIO_DIR}/lib.sh" +scenario_start "" + +# Use helpers: jq_devnet, assert_eq, assert_gt, assert_not_empty, assert_ok +RPC_URL=$(jq_devnet '.info.lotus.host_rpc_url') +BALANCE=$(cast balance 0x... --rpc-url "$RPC_URL") +assert_gt "$BALANCE" 0 "account has funds" + +scenario_end +``` + +2. Add `test_` to the `SCENARIOS` array in `scenarios/order.sh`. +3. `chmod +x scenarios/test_.sh` + +### Available assertion helpers (from `lib.sh`) + +| Helper | Usage | Description | +|--------|-------|-------------| +| `assert_eq` | `assert_eq "$a" "$b" "msg"` | Equality check | +| `assert_gt` | `assert_gt "$a" "$b" "msg"` | Integer greater-than (handles wei-scale) | +| `assert_not_empty` | `assert_not_empty "$v" "msg"` | Value is non-empty | +| `assert_ok` | `assert_ok cmd arg... "msg"` | Command exits 0 | +| `jq_devnet` | `jq_devnet '.info.lotus.host_rpc_url'` | Query devnet-info.json | + +### CI integration + +Scenarios run automatically in CI after the devnet starts. On nightly runs (or manual dispatch with `reporting` enabled), failures automatically create a GitHub issue with a full report. + diff --git a/scenario/lib.sh b/scenarios/lib.sh similarity index 96% rename from scenario/lib.sh rename to scenarios/lib.sh index 3bba87f..3349414 100755 --- a/scenario/lib.sh +++ b/scenarios/lib.sh @@ -7,7 +7,7 @@ # # ── Writing a new scenario ─────────────────────────────────── # -# 1. Create scenario/test_.sh with this skeleton: +# 1. Create scenarios/test_.sh with this skeleton: # # #!/usr/bin/env bash # set -euo pipefail @@ -20,8 +20,8 @@ # scenario_end # # 2. Add "test_" to the SCENARIOS array in order.sh. -# 3. chmod +x scenario/test_.sh -# 4. Run: bash scenario/test_.sh +# 3. chmod +x scenarios/test_.sh +# 4. Run: bash scenarios/test_.sh # # ── Available helpers ──────────────────────────────────────── # jq_devnet — query devnet-info.json diff --git a/scenario/order.sh b/scenarios/order.sh similarity index 91% rename from scenario/order.sh rename to scenarios/order.sh index 2c10644..2a99c89 100755 --- a/scenario/order.sh +++ b/scenarios/order.sh @@ -2,7 +2,7 @@ # ───────────────────────────────────────────────────────────── # order.sh — Declares the scenario execution order. # -# Each entry is the basename of a script under scenario/. +# Each entry is the basename of a script under scenarios/. # Scenarios share the same running devnet and execute serially # in the order listed here. # ───────────────────────────────────────────────────────────── diff --git a/scenario/run.sh b/scenarios/run.sh similarity index 95% rename from scenario/run.sh rename to scenarios/run.sh index 72db7ff..d278b36 100755 --- a/scenario/run.sh +++ b/scenarios/run.sh @@ -16,21 +16,21 @@ # # Quick start: # ./foc-devnet start # bring up the devnet -# bash scenario/run.sh # run all scenarios +# bash scenarios/run.sh # run all scenarios # cat ~/.foc-devnet/state/latest/scenario_*.md # read the report # # Run a single scenario: -# bash scenario/test_containers.sh +# bash scenarios/test_containers.sh # # Override devnet-info path (e.g. an older run): # DEVNET_INFO=~/.foc-devnet/state//devnet-info.json \ -# bash scenario/run.sh +# bash scenarios/run.sh # # File a GitHub issue on failure (needs `gh` CLI + auth): -# REPORTING=true bash scenario/run.sh +# REPORTING=true bash scenarios/run.sh # # Always file an issue, even on success: -# REPORTING=true SKIP_REPORT_ON_PASS=false bash scenario/run.sh +# REPORTING=true SKIP_REPORT_ON_PASS=false bash scenarios/run.sh # # ── Environment variables ──────────────────────────────────── # DEVNET_INFO — path to devnet-info.json (auto-detected) diff --git a/scenario/test_basic_balances.sh b/scenarios/test_basic_balances.sh similarity index 100% rename from scenario/test_basic_balances.sh rename to scenarios/test_basic_balances.sh diff --git a/scenario/test_containers.sh b/scenarios/test_containers.sh similarity index 100% rename from scenario/test_containers.sh rename to scenarios/test_containers.sh From 803aeb993c9f1e7f21b18941517152f6cf45fdd3 Mon Sep 17 00:00:00 2001 From: redpanda-f Date: Mon, 2 Mar 2026 15:00:48 +0000 Subject: [PATCH 03/42] rename: foc-start-test to foc-devnet-test --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0afebcd..d06049c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -65,7 +65,7 @@ jobs: - name: shfmt run: shfmt -d -i 2 -bn scenarios/ - foc-start-test: + foc-devnet-test: runs-on: ["self-hosted", "linux", "x64", "16xlarge+gpu"] timeout-minutes: 100 permissions: From 930d7ebcc1b08ef9fa8df5ba265a1b7274a7a01a Mon Sep 17 00:00:00 2001 From: redpanda-f Date: Tue, 3 Mar 2026 06:54:23 +0000 Subject: [PATCH 04/42] add: fixes --- .githooks/pre-commit | 5 +++-- scenarios/lib.sh | 13 +++++++++---- scenarios/run.sh | 2 ++ scenarios/test_basic_balances.sh | 3 ++- scenarios/test_containers.sh | 19 +++++++++++++------ 5 files changed, 29 insertions(+), 13 deletions(-) diff --git a/.githooks/pre-commit b/.githooks/pre-commit index 593706f..8bd71ed 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -33,14 +33,15 @@ fixed() { printf "${BLUE}⟳${NC} %s (auto-fixed & re-staged)\n" "$1"; } STAGED=$(git diff --cached --name-only --diff-filter=ACM) # ── Rust checks ────────────────────────────────────────────── -HAS_RS=$(echo "$STAGED" | grep -c '\.rs$' || true) +STAGED_RS=$(echo "$STAGED" | grep '\.rs$' || true) +HAS_RS=$(echo "$STAGED_RS" | grep -c '\.rs$' || true) if [[ $HAS_RS -gt 0 ]]; then if command -v cargo &>/dev/null; then if cargo fmt --all -- --check &>/dev/null; then pass "cargo fmt" elif [[ "$FIX" == "1" ]]; then cargo fmt --all - git diff --name-only -- '*.rs' | xargs -r git add + echo "$STAGED_RS" | xargs -r git add fixed "cargo fmt" else fail "cargo fmt — run 'cargo fmt' or FIX=1 git commit" diff --git a/scenarios/lib.sh b/scenarios/lib.sh index 3349414..6d405fd 100755 --- a/scenarios/lib.sh +++ b/scenarios/lib.sh @@ -47,17 +47,22 @@ _SCENARIO_NAME="" # ── devnet-info helpers ────────────────────────────────────── # Shorthand: jq_devnet '.info.users[0].evm_addr' -jq_devnet() { jq -r "$1" "$DEVNET_INFO"; } +jq_devnet() { + if ! jq -r "$1" "$DEVNET_INFO"; then + fail "jq_devnet: jq failed for filter '$1' on '$DEVNET_INFO'" + return 1 + fi +} # ── Logging ────────────────────────────────────────────────── _log() { printf "[%s] %s\n" "$1" "$2"; } -info() { _log "[INFO]" "$*"; } +info() { _log "INFO" "$*"; } ok() { - _log "[ OK ]" "$*" + _log " OK " "$*" ((_PASS++)) || true } fail() { - _log "[FAIL]" "$*" + _log "FAIL" "$*" ((_FAIL++)) || true } diff --git a/scenarios/run.sh b/scenarios/run.sh index d278b36..3509109 100755 --- a/scenarios/run.sh +++ b/scenarios/run.sh @@ -48,6 +48,8 @@ REPORTING="${REPORTING:-false}" SKIP_REPORT_ON_PASS="${SKIP_REPORT_ON_PASS:-true}" # ── Bootstrap ──────────────────────────────────────────────── +# Ensure report directory exists before cleaning/writing artifacts +mkdir -p "${REPORT_DIR}" # Clean previous scenario artifacts (but not the whole state dir) rm -f "${REPORT_DIR}"/scenario_*.md "${REPORT_DIR}/results.csv" source "${SCENARIO_DIR}/order.sh" diff --git a/scenarios/test_basic_balances.sh b/scenarios/test_basic_balances.sh index a1cbf3b..221e4f2 100755 --- a/scenarios/test_basic_balances.sh +++ b/scenarios/test_basic_balances.sh @@ -10,13 +10,14 @@ set -euo pipefail SCENARIO_DIR="$(cd "$(dirname "$0")" && pwd)" source "${SCENARIO_DIR}/lib.sh" -scenario_start "basic_balances" +scenario_start "test_basic_balances" # ── Ensure Foundry is available ────────────────────────────── if ! command -v cast &>/dev/null; then info "Installing Foundry …" curl -sSL https://foundry.paradigm.xyz | bash export PATH="$HOME/.foundry/bin:$PATH" + "$HOME/.foundry/bin/foundryup" fi assert_ok command -v cast "cast is installed" diff --git a/scenarios/test_containers.sh b/scenarios/test_containers.sh index 7e18fc6..72a55d9 100755 --- a/scenarios/test_containers.sh +++ b/scenarios/test_containers.sh @@ -3,14 +3,14 @@ # test_containers.sh # # Verifies that all foc-* containers reported in devnet-info.json -# are actually running, healthy, and that no zombie foc-* -# containers exist outside the current run. +# are actually running and that no unexpected foc-* containers +# exist outside the current run. # ───────────────────────────────────────────────────────────── set -euo pipefail SCENARIO_DIR="$(cd "$(dirname "$0")" && pwd)" source "${SCENARIO_DIR}/lib.sh" -scenario_start "containers" +scenario_start "test_containers" # ── Collect expected container names from devnet-info ──────── EXPECTED=() @@ -29,9 +29,16 @@ for cname in "${EXPECTED[@]}"; do assert_eq "$STATUS" "running" "container ${cname} is running" done -# ── Check no unexpected foc-c-* containers are running ─────── -# All foc-c-* containers should belong to the expected set -RUNNING=$(docker ps --filter "name=foc-c-" --format '{{.Names}}') +# ── Check no unexpected foc-* containers are running ───────── +# All foc-* containers for this devnet run should belong to the expected set. +# Prefer the run-scoped prefix from devnet-info when available, fall back to foc-. +RUN_ID="$(jq_devnet '.info.run_id // ""')" +if [[ -n "$RUN_ID" ]]; then + NAME_FILTER="foc-${RUN_ID}-" +else + NAME_FILTER="foc-" +fi +RUNNING=$(docker ps --filter "name=${NAME_FILTER}" --format '{{.Names}}') for cname in $RUNNING; do KNOWN=false for exp in "${EXPECTED[@]}"; do From d077ef5cafc5abe913d1eb99c9748c60dd296bd5 Mon Sep 17 00:00:00 2001 From: redpanda-f Date: Tue, 3 Mar 2026 07:53:00 +0000 Subject: [PATCH 05/42] add: support for latestCommit and latestTag --- .githooks/pre-commit | 2 +- .github/workflows/ci.yml | 18 +- Cargo.lock | 7 + Cargo.toml | 1 + src/commands/build/repository.rs | 9 + src/commands/init/config.rs | 14 +- src/commands/init/latest_resolver.rs | 347 ++++++++++++++++++++++++++ src/commands/init/mod.rs | 1 + src/commands/init/repositories.rs | 7 + src/commands/status/git/formatters.rs | 14 ++ src/commands/status/git/repo_paths.rs | 8 +- src/config.rs | 62 ++++- src/main_app/version.rs | 6 + 13 files changed, 481 insertions(+), 15 deletions(-) create mode 100644 src/commands/init/latest_resolver.rs diff --git a/.githooks/pre-commit b/.githooks/pre-commit index 8bd71ed..6479605 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -88,7 +88,7 @@ if [[ -n "$STAGED_SH" ]]; then skip "shfmt" fi - # shellcheck (no auto-fix available) + # SC — no auto-fix available if command -v shellcheck &>/dev/null; then if shellcheck -S warning $STAGED_SH &>/dev/null; then pass "shellcheck" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d06049c..0fbb0f0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -193,14 +193,28 @@ jobs: if: steps.cache-docker-images.outputs.cache-hit == 'true' run: | rm -rf ~/.foc-devnet - ./foc-devnet init --no-docker-build + if [[ "${{ github.event_name }}" == "schedule" ]]; then + ./foc-devnet init --no-docker-build \ + --curio latestCommit \ + --filecoin-services latestCommit \ + --lotus latestTag + else + ./foc-devnet init --no-docker-build + fi # If Docker images are not cached, do full init (downloads YugabyteDB and builds all images) - name: "EXEC: {Initialize without cache}, independent" if: steps.cache-docker-images.outputs.cache-hit != 'true' run: | rm -rf ~/.foc-devnet - ./foc-devnet init + if [[ "${{ github.event_name }}" == "schedule" ]]; then + ./foc-devnet init \ + --curio latestCommit \ + --filecoin-services latestCommit \ + --lotus latestTag + else + ./foc-devnet init + fi # CACHE-DOCKER: Build Docker images if not cached - name: "EXEC: {Build Docker images}, DEP: {C-docker-images-cache}" diff --git a/Cargo.lock b/Cargo.lock index f195c51..1665eac 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -862,6 +862,7 @@ dependencies = [ "rand 0.8.5", "regex", "reqwest 0.11.27", + "semver", "serde", "serde_json", "sha2 0.10.9", @@ -2351,6 +2352,12 @@ dependencies = [ "libc", ] +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + [[package]] name = "serde" version = "1.0.228" diff --git a/Cargo.toml b/Cargo.toml index d3b62ba..ef8976a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,6 +28,7 @@ base32 = "0.4" crc32fast = "1.3" bip32 = "0.5" rand = "0.8" +semver = "1.0" bls-signatures = "0.15" names = { version = "0.14", default-features = false } shellexpand = "3" diff --git a/src/commands/build/repository.rs b/src/commands/build/repository.rs index 7520f03..b2d0b83 100644 --- a/src/commands/build/repository.rs +++ b/src/commands/build/repository.rs @@ -50,6 +50,15 @@ pub fn prepare_repository( prepare_git_repo(&repo_path, url)?; checkout_branch(&repo_path, branch)?; } + // LatestCommit / LatestTag are resolved to GitCommit / GitTag at init time + // and never appear in a saved config.toml, so this is a programming error. + Location::LatestCommit { .. } | Location::LatestTag { .. } => { + return Err( + "Dynamic location (latestCommit/latestTag) was not resolved before build. \ + Run 'foc-devnet init' first." + .into(), + ); + } } info!("Repository prepared successfully"); diff --git a/src/commands/init/config.rs b/src/commands/init/config.rs index 82966f3..b14b1ed 100644 --- a/src/commands/init/config.rs +++ b/src/commands/init/config.rs @@ -1,11 +1,15 @@ //! Configuration generation utilities for foc-devnet initialization. //! //! This module handles the generation of default configuration files -//! and application of location overrides. +//! and application of location overrides. Dynamic location variants +//! (`LatestCommit`, `LatestTag`) are resolved to concrete values at init +//! time via [`super::latest_resolver`], ensuring the stored config always +//! records the exact commit or tag that was used. use std::fs; use tracing::{info, warn}; +use super::latest_resolver::resolve_location; use crate::config::{Config, Location}; use crate::paths::foc_devnet_config; @@ -67,6 +71,12 @@ pub fn generate_default_config( "https://github.com/FilOzone/filecoin-services.git", )?; + // Resolve any dynamic variants (LatestCommit / LatestTag) by querying the remote. + // The resolved concrete SHA or tag is stored in config.toml for reproducibility. + config.lotus = resolve_location(config.lotus)?; + config.curio = resolve_location(config.curio)?; + config.filecoin_services = resolve_location(config.filecoin_services)?; + // Override yugabyte URL if provided if let Some(url) = yugabyte_url { config.yugabyte_download_url = url; @@ -103,6 +113,8 @@ pub fn apply_location_override( Location::GitTag { ref url, .. } => url.clone(), Location::GitCommit { ref url, .. } => url.clone(), Location::GitBranch { ref url, .. } => url.clone(), + Location::LatestCommit { ref url } => url.clone(), + Location::LatestTag { ref url } => url.clone(), Location::LocalSource { .. } => default_url.to_string(), }; *location = Location::parse_with_default(&loc_str, &url) diff --git a/src/commands/init/latest_resolver.rs b/src/commands/init/latest_resolver.rs new file mode 100644 index 0000000..dd2fa45 --- /dev/null +++ b/src/commands/init/latest_resolver.rs @@ -0,0 +1,347 @@ +//! Resolver for dynamic location variants (`LatestCommit`, `LatestTag`). +//! +//! Queries remote Git repositories to resolve dynamic location variants to +//! concrete `GitCommit` / `GitTag` values at init time. The resolved SHA or +//! tag is then written to `config.toml` so that builds are always reproducible +//! and the exact version is recorded in the run state. +//! +//! `LatestCommit` uses `git ls-remote` to resolve to the tip of `main` +//! (or `master` if `main` does not exist — no local clone needed). +//! +//! `LatestTag` performs a blobless bare fetch of the default branch (`main` +//! or `master`) and all +//! tags into a temporary directory, then runs `git tag --merged main` to +//! enumerate only tags whose commits are reachable from `main`. This avoids +//! picking tags that were created on feature branches or release branches that +//! diverged from `main`. +//! +//! # Example +//! +//! ```text +//! foc-devnet init --curio latestCommit --lotus latestTag +//! // Queries remote → resolves to GitCommit { commit: "abc123..." } +//! // GitTag { tag: "v1.34.5" } +//! // Stores concrete values in config.toml +//! ``` + +use crate::config::Location; +use semver::Version; +use std::process::Command; +use tracing::info; + +/// Temporary bare repo used for tag-reachability queries. +struct TempBareRepo(std::path::PathBuf); + +impl TempBareRepo { + /// Initialise an empty bare repository in a system temp directory. + fn create() -> Result> { + let dir = std::env::temp_dir().join(format!( + "foc-devnet-tag-probe-{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() + )); + let status = Command::new("git") + .args(["init", "--bare", dir.to_str().unwrap()]) + .env("GIT_TERMINAL_PROMPT", "0") + .status()?; + if !status.success() { + return Err("git init --bare failed".into()); + } + Ok(Self(dir)) + } + + /// Return the path of this bare repo. + fn path(&self) -> &std::path::Path { + &self.0 + } +} + +impl Drop for TempBareRepo { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +/// Resolve a `Location` to a concrete variant by querying the remote if needed. +/// +/// `LatestCommit` and `LatestTag` are resolved against the remote repository. +/// All other variants are returned unchanged. +pub fn resolve_location(location: Location) -> Result> { + match location { + Location::LatestCommit { url } => { + let commit = fetch_latest_commit(&url)?; + info!("Resolved latestCommit for {} → {}", url, commit); + Ok(Location::GitCommit { url, commit }) + } + Location::LatestTag { url } => { + let tag = fetch_latest_tag(&url)?; + info!("Resolved latestTag for {} → {}", url, tag); + Ok(Location::GitTag { url, tag }) + } + other => Ok(other), + } +} + +/// Fetch the SHA of the tip of the default branch (`main` or `master`) on a remote. +/// +/// Tries `main` first; if it doesn't exist on the remote, falls back to `master`. +/// Fails if neither branch is found. +fn fetch_latest_commit(url: &str) -> Result> { + let branch = resolve_default_branch(url)?; + info!("Fetching latest commit on {} from {}", branch, url); + + let output = Command::new("git") + .args(["ls-remote", url, &format!("refs/heads/{}", branch)]) + .env("GIT_TERMINAL_PROMPT", "0") + .output()?; + + if !output.status.success() { + return Err(format!( + "git ls-remote failed for {}: {}", + url, + String::from_utf8_lossy(&output.stderr).trim() + ) + .into()); + } + + parse_ls_remote_commit(&String::from_utf8_lossy(&output.stdout), url) +} + +/// Parse the commit SHA from `git ls-remote` stdout. +/// +/// The output would be of the form: +/// ``` +/// 7741226198083e943a64d917e88a0a77d17aa30e refs/heads/master +/// ``` +fn parse_ls_remote_commit(stdout: &str, url: &str) -> Result> { + stdout + .lines() + .next() + .and_then(|line| line.split_whitespace().next()) + .map(str::to_string) + .ok_or_else(|| format!("No commit found in ls-remote output for {}", url).into()) +} + +/// Resolve the default branch name for a remote repository. +/// +/// Queries the remote for both `main` and `master` in a single `git ls-remote` +/// call. Returns `"main"` if it exists, `"master"` if only that exists, or an +/// error if neither is present. +fn resolve_default_branch(url: &str) -> Result> { + let output = Command::new("git") + .args([ + "ls-remote", + "--heads", + url, + "refs/heads/main", + "refs/heads/master", + ]) + .env("GIT_TERMINAL_PROMPT", "0") + .output()?; + + if !output.status.success() { + return Err(format!( + "git ls-remote --heads failed for {}: {}", + url, + String::from_utf8_lossy(&output.stderr).trim() + ) + .into()); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + let has_main = stdout.lines().any(|l| l.contains("refs/heads/main")); + let has_master = stdout.lines().any(|l| l.contains("refs/heads/master")); + + if has_main { + Ok("main".to_string()) + } else if has_master { + info!( + "Remote {} has no 'main' branch, falling back to 'master'", + url + ); + Ok("master".to_string()) + } else { + Err(format!("Remote {} has neither 'main' nor 'master' branch", url).into()) + } +} + +/// Fetch the highest stable semver tag for a remote repo, considering only +/// tags whose commits are reachable from the default branch (`main` or `master`). +/// +/// Strategy: +/// 1. Resolve the default branch (`main`, falling back to `master`). +/// 2. Create a throwaway bare repo in a temp directory. +/// 3. Blobless-fetch the default branch and all tags from the remote (no file +/// content downloaded — only commit and tree objects). +/// 4. Run `git tag --merged ` to enumerate tags reachable from it. +/// 5. Filter for stable semver tags (no `-rc`, `-alpha`, etc.) and return +/// the highest by numeric segment comparison. +fn fetch_latest_tag(url: &str) -> Result> { + let branch = resolve_default_branch(url)?; + info!("Fetching latest stable tag on {} from {}", branch, url); + + let repo = TempBareRepo::create()?; + + fetch_default_branch_and_tags(repo.path(), url, &branch)?; + + let tags_output = Command::new("git") + .args(["tag", "--merged", &branch]) + .current_dir(repo.path()) + .output()?; + + if !tags_output.status.success() { + return Err(format!( + "git tag --merged {} failed: {}", + branch, + String::from_utf8_lossy(&tags_output.stderr).trim() + ) + .into()); + } + + parse_latest_tag(&String::from_utf8_lossy(&tags_output.stdout), url) +} + +/// Fetch the default branch and all tags from `url` into an existing bare repo. +/// +/// Uses `--filter=blob:none` so only commit and tree objects are transferred +/// (no file content), keeping the operation fast even for large repositories. +fn fetch_default_branch_and_tags( + repo_path: &std::path::Path, + url: &str, + branch: &str, +) -> Result<(), Box> { + let refspec = format!("refs/heads/{b}:refs/heads/{b}", b = branch); + let status = Command::new("git") + .args(["fetch", "--tags", "--filter=blob:none", url, &refspec]) + .current_dir(repo_path) + .env("GIT_TERMINAL_PROMPT", "0") + .status()?; + + if !status.success() { + return Err(format!("git fetch failed for {}", url).into()); + } + Ok(()) +} + +/// Parse and return the highest stable semver tag from `git tag --merged` stdout. +/// +/// Each line is a plain tag name (e.g. `v1.2.3`). Tags that cannot be parsed +/// as a valid semver version, or that carry a pre-release identifier (e.g. +/// `-rc1`, `-alpha`, `-beta`), are silently skipped. The remaining tags are +/// sorted by the `semver::Version` `Ord` implementation and the highest is +/// returned. +fn parse_latest_tag(stdout: &str, url: &str) -> Result> { + let mut tags: Vec<(Version, &str)> = stdout + .lines() + .map(str::trim) + .filter_map(|tag| { + // Strip leading 'v' before parsing, semver crate requires bare `1.2.3` + let raw = tag.trim_start_matches('v'); + Version::parse(raw).ok().map(|v| (v, tag)) + }) + .filter(|(v, _)| v.pre.is_empty()) // exclude pre-release versions + .collect(); + + if tags.is_empty() { + return Err(format!( + "No stable semver tags reachable from default branch for {}", + url + ) + .into()); + } + + tags.sort_by(|(a, _), (b, _)| a.cmp(b)); + Ok(tags.last().unwrap().1.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_semver_sort_picks_highest() { + let output = "v1.9.0\nv1.10.0\nv1.2.3\nv1.10.1\n"; + let tag = parse_latest_tag(output, "https://example.com/repo").unwrap(); + assert_eq!(tag, "v1.10.1"); + } + + #[test] + fn test_semver_sort_ignores_rc_suffix() { + let output = "v1.34.3\nv1.34.4\n"; + let tag = parse_latest_tag(output, "https://example.com/repo").unwrap(); + assert_eq!(tag, "v1.34.4"); + } + + #[test] + fn test_parse_ls_remote_commit() { + let output = "abc123def456\tHEAD\n"; + let commit = parse_ls_remote_commit(output, "https://example.com/repo").unwrap(); + assert_eq!(commit, "abc123def456"); + } + + /// Simulate the stdout of `git ls-remote --heads` for branch resolution. + fn ls_remote_heads(branches: &[&str]) -> String { + branches + .iter() + .map(|b| format!("deadbeef\trefs/heads/{}\n", b)) + .collect() + } + + #[test] + fn test_resolve_default_branch_prefers_main() { + let stdout = ls_remote_heads(&["main", "master"]); + let has_main = stdout.lines().any(|l| l.contains("refs/heads/main")); + let has_master = stdout.lines().any(|l| l.contains("refs/heads/master")); + assert!(has_main); + let branch = if has_main { + "main" + } else if has_master { + "master" + } else { + "" + }; + assert_eq!(branch, "main"); + } + + #[test] + fn test_resolve_default_branch_falls_back_to_master() { + let stdout = ls_remote_heads(&["master", "develop"]); + let has_main = stdout.lines().any(|l| l.contains("refs/heads/main")); + let has_master = stdout.lines().any(|l| l.contains("refs/heads/master")); + assert!(!has_main); + assert!(has_master); + let branch = if has_main { + "main" + } else if has_master { + "master" + } else { + "" + }; + assert_eq!(branch, "master"); + } + + #[test] + fn test_parse_latest_tag() { + let output = "v1.0.0\nv1.2.0\nv1.1.0\n"; + let tag = parse_latest_tag(output, "https://example.com/repo").unwrap(); + assert_eq!(tag, "v1.2.0"); + } + + #[test] + fn test_parse_latest_tag_skips_rc() { + // v1.2.0-rc1 is excluded; v1.1.0 is the latest stable + let output = "v1.0.0\nv1.1.0\nv1.2.0-rc1\n"; + let tag = parse_latest_tag(output, "https://example.com/repo").unwrap(); + assert_eq!(tag, "v1.1.0"); + } + + #[test] + fn test_parse_latest_tag_skips_non_semver() { + // "latest" and bare "rc" strings should be silently ignored + let output = "latest\nv1.0.0\nrc\nv1.1.0\n"; + let tag = parse_latest_tag(output, "https://example.com/repo").unwrap(); + assert_eq!(tag, "v1.1.0"); + } +} diff --git a/src/commands/init/mod.rs b/src/commands/init/mod.rs index 25b0932..e42338c 100644 --- a/src/commands/init/mod.rs +++ b/src/commands/init/mod.rs @@ -13,6 +13,7 @@ pub mod artifacts; pub mod config; pub mod directories; pub mod keys; +pub mod latest_resolver; pub mod path_setup; pub mod repositories; diff --git a/src/commands/init/repositories.rs b/src/commands/init/repositories.rs index a23fa39..fbdb7d5 100644 --- a/src/commands/init/repositories.rs +++ b/src/commands/init/repositories.rs @@ -99,6 +99,13 @@ fn download_repository(name: &str, location: &Location) -> Result<(), Box { clone_and_checkout(name, url, None, None, Some(branch)) } + // LatestCommit / LatestTag are resolved to concrete variants before this function + // is called, so reaching here indicates a programming error. + Location::LatestCommit { .. } | Location::LatestTag { .. } => Err( + "Dynamic location (latestCommit/latestTag) was not resolved before repository \ + download. This is an internal error." + .into(), + ), } } diff --git a/src/commands/status/git/formatters.rs b/src/commands/status/git/formatters.rs index 20a92e5..d042237 100644 --- a/src/commands/status/git/formatters.rs +++ b/src/commands/status/git/formatters.rs @@ -78,6 +78,9 @@ pub fn format_location_info( ) if expected_branch == actual_branch => true, (Location::GitBranch { .. }, GitInfo::Tag(_) | GitInfo::Commit(_)) => true, // Assume it's ready if we have some valid state (Location::GitBranch { .. }, _) => false, + + // LatestCommit / LatestTag are resolved at init time; treat as not ready if somehow present. + (Location::LatestCommit { .. } | Location::LatestTag { .. }, _) => false, }; let status = if is_ready { @@ -125,6 +128,17 @@ pub fn format_location_info( "Not found".to_string(), ), }, + // Resolved at init time; display as their underlying type if somehow still present. + Location::LatestCommit { .. } => ( + "Latest Commit".to_string(), + "(unresolved)".to_string(), + "".to_string(), + ), + Location::LatestTag { .. } => ( + "Latest Tag".to_string(), + "(unresolved)".to_string(), + "".to_string(), + ), }; (source_type, version, commit, status) diff --git a/src/commands/status/git/repo_paths.rs b/src/commands/status/git/repo_paths.rs index b6fd59e..2b4571c 100644 --- a/src/commands/status/git/repo_paths.rs +++ b/src/commands/status/git/repo_paths.rs @@ -34,8 +34,12 @@ pub fn get_repo_path_from_config(location: &Location, component: &str) -> std::p // For local sources, check the specified directory std::path::PathBuf::from(dir) } - Location::GitTag { .. } | Location::GitCommit { .. } | Location::GitBranch { .. } => { - // For git sources, check if it exists in the foc-devnet code directory + Location::GitTag { .. } + | Location::GitCommit { .. } + | Location::GitBranch { .. } + | Location::LatestCommit { .. } + | Location::LatestTag { .. } => { + // For git sources (including unresolved dynamic variants), use the foc-devnet code directory foc_devnet_code().join(component) } } diff --git a/src/config.rs b/src/config.rs index 85b3e82..cccf0a6 100644 --- a/src/config.rs +++ b/src/config.rs @@ -36,26 +36,63 @@ pub enum Location { /// The `url` field is the Git repository URL, and `branch` is the specific /// branch (e.g., "main", "develop") to check out. GitBranch { url: String, branch: String }, + + /// Resolve to the latest commit on the default branch of the repository at init time. + /// + /// The `url` field is the Git repository URL. When used with `foc-devnet init`, + /// this is immediately resolved to a concrete `GitCommit` by querying the remote, + /// so the stored config always records the exact SHA used. + /// + /// Example CLI usage: `--curio latestCommit` + LatestCommit { url: String }, + + /// Resolve to the latest tag of the repository at init time. + /// + /// The `url` field is the Git repository URL. When used with `foc-devnet init`, + /// this is immediately resolved to a concrete `GitTag` by querying the remote, + /// so the stored config always records the exact tag used. + /// + /// Example CLI usage: `--lotus latestTag` + LatestTag { url: String }, } impl Location { /// Parse a location string in the format "type:value" or "type:url:value" /// /// Supported formats: - /// - "gittag:tag" (uses default URL) - /// - "gitcommit:commit" (uses default URL) - /// - "gitbranch:branch" (uses default URL) - /// - "local:dir" - /// - "gittag:url:tag" - /// - "gitcommit:url:commit" - /// - "gitbranch:url:branch" + /// - `latestCommit` — resolves to latest HEAD commit (uses default URL) + /// - `latestTag` — resolves to latest semver tag (uses default URL) + /// - `latestCommit:url` — same, with explicit URL + /// - `latestTag:url` — same, with explicit URL + /// - `gittag:tag` — (uses default URL) + /// - `gitcommit:commit` — (uses default URL) + /// - `gitbranch:branch` — (uses default URL) + /// - `local:dir` + /// - `gittag:url:tag` + /// - `gitcommit:url:commit` + /// - `gitbranch:url:branch` /// /// Where url can contain colons (e.g., https://github.com/repo.git) pub fn parse_with_default(s: &str, default_url: &str) -> Result { + // Handle bare magic keywords (no colon) — use default URL + match s { + "latestCommit" => { + return Ok(Location::LatestCommit { + url: default_url.to_string(), + }) + } + "latestTag" => { + return Ok(Location::LatestTag { + url: default_url.to_string(), + }) + } + _ => {} + } + let parts: Vec<&str> = s.split(':').collect(); if parts.len() < 2 { return Err(format!( - "Invalid location format: {}. Expected 'type:value' or 'type:url:value'", + "Invalid location format: {}. Expected 'type:value', 'latestCommit', or 'latestTag'", s )); } @@ -64,6 +101,13 @@ impl Location { let remaining = &parts[1..].join(":"); match location_type { + // latestCommit:url and latestTag:url forms + "latestCommit" => Ok(Location::LatestCommit { + url: remaining.to_string(), + }), + "latestTag" => Ok(Location::LatestTag { + url: remaining.to_string(), + }), "local" => Ok(Location::LocalSource { dir: remaining.to_string(), }), @@ -107,7 +151,7 @@ impl Location { } } _ => Err(format!( - "Unknown location type: {}. Supported types: local, gittag, gitcommit, gitbranch", + "Unknown location type: {}. Supported types: latestCommit, latestTag, local, gittag, gitcommit, gitbranch", location_type )), } diff --git a/src/main_app/version.rs b/src/main_app/version.rs index a0c6a57..a881012 100644 --- a/src/main_app/version.rs +++ b/src/main_app/version.rs @@ -70,5 +70,11 @@ fn print_location_info(label: &str, location: &Location) { Location::GitBranch { url, branch } => { info!("{}: {}, branch {}", label, url, branch); } + Location::LatestCommit { url } => { + info!("{}: {}, latest commit (unresolved)", label, url); + } + Location::LatestTag { url } => { + info!("{}: {}, latest tag (unresolved)", label, url); + } } } From e6bedf028f294e5980d2335097ff3190b31a9c91 Mon Sep 17 00:00:00 2001 From: redpanda-f Date: Tue, 3 Mar 2026 08:13:26 +0000 Subject: [PATCH 06/42] fix: scenarios --- scenarios/test_basic_balances.sh | 1 + scenarios/test_containers.sh | 6 ++++++ 2 files changed, 7 insertions(+) diff --git a/scenarios/test_basic_balances.sh b/scenarios/test_basic_balances.sh index 221e4f2..7eef1eb 100755 --- a/scenarios/test_basic_balances.sh +++ b/scenarios/test_basic_balances.sh @@ -15,6 +15,7 @@ scenario_start "test_basic_balances" # ── Ensure Foundry is available ────────────────────────────── if ! command -v cast &>/dev/null; then info "Installing Foundry …" + export SHELL=/bin/bash curl -sSL https://foundry.paradigm.xyz | bash export PATH="$HOME/.foundry/bin:$PATH" "$HOME/.foundry/bin/foundryup" diff --git a/scenarios/test_containers.sh b/scenarios/test_containers.sh index 72a55d9..7be80f9 100755 --- a/scenarios/test_containers.sh +++ b/scenarios/test_containers.sh @@ -23,6 +23,12 @@ for i in $(seq 0 $((SP_COUNT - 1))); do EXPECTED+=("$(jq_devnet ".info.pdp_sps[$i].container_name")") done +# Each Curio SP also has a YugabyteDB container +RUN_ID=$(jq_devnet '.info.run_id') +for i in $(seq 1 $SP_COUNT); do + EXPECTED+=("foc-${RUN_ID}-yugabyte-${i}") +done + # ── Verify each expected container is running ──────────────── for cname in "${EXPECTED[@]}"; do STATUS=$(docker inspect -f '{{.State.Status}}' "$cname" 2>/dev/null || echo "missing") From f7452f601371b99c3ba4c3724dc3e2471001a2f5 Mon Sep 17 00:00:00 2001 From: redpanda-f Date: Tue, 3 Mar 2026 08:35:22 +0000 Subject: [PATCH 07/42] fix: test_basic_balances --- scenarios/lib.sh | 11 +++++++++++ scenarios/test_basic_balances.sh | 12 +++--------- 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/scenarios/lib.sh b/scenarios/lib.sh index 6d405fd..5956b49 100755 --- a/scenarios/lib.sh +++ b/scenarios/lib.sh @@ -75,6 +75,17 @@ assert_eq() { # assert_not_empty assert_not_empty() { + # ── Foundry install (hoisted) ─────────────────────────────── + ensure_foundry() { + if ! command -v cast &>/dev/null; then + info "Installing Foundry …" + export SHELL=/bin/bash + curl -sSL https://foundry.paradigm.xyz | bash + export PATH="$HOME/.foundry/bin:$PATH" + "$HOME/.foundry/bin/foundryup" + fi + assert_ok command -v cast "cast is installed" + } if [[ -n "$1" ]]; then ok "$2"; else fail "$2 (empty)"; fi } diff --git a/scenarios/test_basic_balances.sh b/scenarios/test_basic_balances.sh index 7eef1eb..66d0f87 100755 --- a/scenarios/test_basic_balances.sh +++ b/scenarios/test_basic_balances.sh @@ -13,14 +13,7 @@ source "${SCENARIO_DIR}/lib.sh" scenario_start "test_basic_balances" # ── Ensure Foundry is available ────────────────────────────── -if ! command -v cast &>/dev/null; then - info "Installing Foundry …" - export SHELL=/bin/bash - curl -sSL https://foundry.paradigm.xyz | bash - export PATH="$HOME/.foundry/bin:$PATH" - "$HOME/.foundry/bin/foundryup" -fi -assert_ok command -v cast "cast is installed" +ensure_foundry # ── Read devnet info ───────────────────────────────────────── RPC_URL=$(jq_devnet '.info.lotus.host_rpc_url') @@ -38,7 +31,8 @@ for i in $(seq 0 $((USER_COUNT - 1))); do assert_gt "$FIL_WEI" 0 "${NAME} FIL balance > 0" # MockUSDFC ERC-20 balance - USDFC_WEI=$(cast call "$USDFC_ADDR" "balanceOf(address)(uint256)" "$ADDR" --rpc-url "$RPC_URL") + USDFC_WEI_RAW=$(cast call "$USDFC_ADDR" "balanceOf(address)(uint256)" "$ADDR" --rpc-url "$RPC_URL") + USDFC_WEI=$(echo "$USDFC_WEI_RAW" | tr -cd '0-9') assert_gt "$USDFC_WEI" 0 "${NAME} USDFC balance > 0" done From ba8736998e582ffad7794efdd17dac52f0d25f96 Mon Sep 17 00:00:00 2001 From: redpanda-f Date: Tue, 3 Mar 2026 08:52:28 +0000 Subject: [PATCH 08/42] fix: ensure_foundry was in wrong location --- scenarios/lib.sh | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/scenarios/lib.sh b/scenarios/lib.sh index 5956b49..58a3d5a 100755 --- a/scenarios/lib.sh +++ b/scenarios/lib.sh @@ -34,6 +34,17 @@ # shellcheck disable=SC2034 # Variables here are used by scripts that source this file set -euo pipefail +ensure_foundry() { + if ! command -v cast &>/dev/null; then + info "Installing Foundry …" + export SHELL=/bin/bash + curl -sSL https://foundry.paradigm.xyz | bash + export PATH="$HOME/.foundry/bin:$PATH" + "$HOME/.foundry/bin/foundryup" + fi + assert_ok command -v cast "cast is installed" +} + # ── Paths ──────────────────────────────────────────────────── DEVNET_INFO="${DEVNET_INFO:-$HOME/.foc-devnet/state/latest/devnet-info.json}" SCENARIO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" @@ -76,16 +87,6 @@ assert_eq() { # assert_not_empty assert_not_empty() { # ── Foundry install (hoisted) ─────────────────────────────── - ensure_foundry() { - if ! command -v cast &>/dev/null; then - info "Installing Foundry …" - export SHELL=/bin/bash - curl -sSL https://foundry.paradigm.xyz | bash - export PATH="$HOME/.foundry/bin:$PATH" - "$HOME/.foundry/bin/foundryup" - fi - assert_ok command -v cast "cast is installed" - } if [[ -n "$1" ]]; then ok "$2"; else fail "$2 (empty)"; fi } From c28437c84498494911ecdea15074037daac8ecb5 Mon Sep 17 00:00:00 2001 From: redpanda-f Date: Tue, 3 Mar 2026 09:39:27 +0000 Subject: [PATCH 09/42] feat: latestCommit and latestTag takes a branch optionally --- .gitignore | 3 +- src/cli.rs | 28 +++++++++--- src/commands/init/config.rs | 4 +- src/commands/init/latest_resolver.rs | 67 +++++++++++++++++----------- src/config.rs | 64 ++++++++++++++------------ src/main_app/version.rs | 4 +- 6 files changed, 106 insertions(+), 64 deletions(-) diff --git a/.gitignore b/.gitignore index 8a22f1c..e0a6442 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,5 @@ target/ contracts/MockUSDFC/lib/ contracts/MockUSDFC/broadcast/ artifacts/ -.vscode/ \ No newline at end of file +.vscode/ +*__pycache__/ \ No newline at end of file diff --git a/src/cli.rs b/src/cli.rs index 993301d..c78cd32 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -23,13 +23,31 @@ pub enum Commands { Stop, /// Initialize foc-devnet by building and caching Docker images Init { - /// Curio source location (e.g., 'gittag:tag', 'gittag:url:tag', 'gitcommit:commit', 'gitcommit:url:commit', 'gitbranch:branch', 'gitbranch:url:branch', 'local:/path/to/curio') + /// Curio source location. + /// Magic values: 'latestTag' (newest stable tag, auto-detects branch), + /// 'latestTag:' (newest stable tag on given branch), + /// 'latestCommit' (tip of default branch), 'latestCommit:'. + /// Explicit: 'gittag:', 'gittag::', 'gitcommit:', + /// 'gitcommit::', 'gitbranch:', 'gitbranch::', + /// 'local:/path/to/curio'. #[arg(long)] curio: Option, - /// Lotus source location (e.g., 'gittag:v1.0.0', 'gittag:url:tag', 'gitcommit:abc123', 'gitcommit:url:commit', 'gitbranch:main', 'gitbranch:url:main', 'local:/path/to/lotus') + /// Lotus source location. + /// Magic values: 'latestTag' (newest stable tag, auto-detects branch), + /// 'latestTag:' (newest stable tag on given branch), + /// 'latestCommit' (tip of default branch), 'latestCommit:'. + /// Explicit: 'gittag:', 'gittag::', 'gitcommit:', + /// 'gitcommit::', 'gitbranch:', 'gitbranch::', + /// 'local:/path/to/lotus'. #[arg(long)] lotus: Option, - /// Filecoin Services source location (e.g., 'gittag:v1.0.0', 'gittag:url:tag', 'gitcommit:abc123', 'gitcommit:url:commit', 'gitbranch:main', 'gitbranch:url:main', 'local:/path/to/filecoin-services') + /// Filecoin Services source location. + /// Magic values: 'latestTag' (newest stable tag, auto-detects branch), + /// 'latestTag:' (newest stable tag on given branch), + /// 'latestCommit' (tip of default branch), 'latestCommit:'. + /// Explicit: 'gittag:', 'gittag::', 'gitcommit:', + /// 'gitcommit::', 'gitbranch:', 'gitbranch::', + /// 'local:/path/to/filecoin-services'. #[arg(long)] filecoin_services: Option, /// Yugabyte download URL @@ -82,12 +100,12 @@ pub enum BuildCommands { pub enum ConfigCommands { /// Configure Lotus source location Lotus { - /// Lotus source location (e.g., 'gittag:v1.0.0', 'gitcommit:abc123', 'local:/path/to/lotus') + /// Lotus source location (e.g., 'latestTag', 'latestTag:master', 'latestCommit', 'latestCommit:main', 'gittag:v1.0.0', 'gitcommit:abc123', 'local:/path/to/lotus') source: String, }, /// Configure Curio source location Curio { - /// Curio source location (e.g., 'gittag:v1.0.0', 'gitcommit:abc123', 'local:/path/to/curio') + /// Curio source location (e.g., 'latestTag', 'latestTag:main', 'latestCommit', 'latestCommit:main', 'gittag:v1.0.0', 'gitcommit:abc123', 'local:/path/to/curio') source: String, }, } diff --git a/src/commands/init/config.rs b/src/commands/init/config.rs index b14b1ed..79c8933 100644 --- a/src/commands/init/config.rs +++ b/src/commands/init/config.rs @@ -113,8 +113,8 @@ pub fn apply_location_override( Location::GitTag { ref url, .. } => url.clone(), Location::GitCommit { ref url, .. } => url.clone(), Location::GitBranch { ref url, .. } => url.clone(), - Location::LatestCommit { ref url } => url.clone(), - Location::LatestTag { ref url } => url.clone(), + Location::LatestCommit { ref url, .. } => url.clone(), + Location::LatestTag { ref url, .. } => url.clone(), Location::LocalSource { .. } => default_url.to_string(), }; *location = Location::parse_with_default(&loc_str, &url) diff --git a/src/commands/init/latest_resolver.rs b/src/commands/init/latest_resolver.rs index dd2fa45..4e90346 100644 --- a/src/commands/init/latest_resolver.rs +++ b/src/commands/init/latest_resolver.rs @@ -9,11 +9,12 @@ //! (or `master` if `main` does not exist — no local clone needed). //! //! `LatestTag` performs a blobless bare fetch of the default branch (`main` -//! or `master`) and all -//! tags into a temporary directory, then runs `git tag --merged main` to -//! enumerate only tags whose commits are reachable from `main`. This avoids -//! picking tags that were created on feature branches or release branches that -//! diverged from `main`. +//! or `master`) and all tags into a temporary directory, then runs `git tag` +//! to enumerate all fetched tags. Pre-release tags (those with semver +//! pre-release identifiers such as `-rc1`, `-alpha`, `-beta`) are filtered +//! out, and the highest stable version is returned. The `--merged` filter is +//! deliberately avoided because projects like Lotus cut releases on separate +//! branches that are never merged back into `master`/`main`. //! //! # Example //! @@ -70,13 +71,13 @@ impl Drop for TempBareRepo { /// All other variants are returned unchanged. pub fn resolve_location(location: Location) -> Result> { match location { - Location::LatestCommit { url } => { - let commit = fetch_latest_commit(&url)?; + Location::LatestCommit { url, branch } => { + let commit = fetch_latest_commit(&url, branch.as_deref())?; info!("Resolved latestCommit for {} → {}", url, commit); Ok(Location::GitCommit { url, commit }) } - Location::LatestTag { url } => { - let tag = fetch_latest_tag(&url)?; + Location::LatestTag { url, branch } => { + let tag = fetch_latest_tag(&url, branch.as_deref())?; info!("Resolved latestTag for {} → {}", url, tag); Ok(Location::GitTag { url, tag }) } @@ -84,12 +85,16 @@ pub fn resolve_location(location: Location) -> Result Result> { - let branch = resolve_default_branch(url)?; +/// If `branch` is `None`, the default branch (`main` or `master`) is +/// auto-detected from the remote. Fails if the resolved branch is not found. +fn fetch_latest_commit( + url: &str, + branch: Option<&str>, +) -> Result> { + let branch = resolve_branch(url, branch)?; info!("Fetching latest commit on {} from {}", branch, url); let output = Command::new("git") @@ -167,19 +172,32 @@ fn resolve_default_branch(url: &str) -> Result) -> Result> { + match branch { + Some(b) => Ok(b.to_string()), + None => resolve_default_branch(url), + } +} + +/// Fetch the highest stable semver tag for a remote repo. /// /// Strategy: -/// 1. Resolve the default branch (`main`, falling back to `master`). +/// 1. Resolve the branch: use `branch` if provided, otherwise auto-detect +/// the default branch (`main` / `master`) from the remote. /// 2. Create a throwaway bare repo in a temp directory. -/// 3. Blobless-fetch the default branch and all tags from the remote (no file -/// content downloaded — only commit and tree objects). -/// 4. Run `git tag --merged ` to enumerate tags reachable from it. +/// 3. Blobless-fetch all tags from the remote (no file content downloaded). +/// 4. Run `git tag` to enumerate all fetched tags. /// 5. Filter for stable semver tags (no `-rc`, `-alpha`, etc.) and return /// the highest by numeric segment comparison. -fn fetch_latest_tag(url: &str) -> Result> { - let branch = resolve_default_branch(url)?; +/// +/// Note: We intentionally do *not* use `git tag --merged ` because +/// projects like Lotus cut releases on separate release branches that are +/// never merged back into `master`/`main`. Using `--merged` would cause the +/// resolver to return a stale version (e.g. `v1.28.1` instead of `v1.35.0`). +fn fetch_latest_tag(url: &str, branch: Option<&str>) -> Result> { + let branch = resolve_branch(url, branch)?; info!("Fetching latest stable tag on {} from {}", branch, url); let repo = TempBareRepo::create()?; @@ -187,14 +205,13 @@ fn fetch_latest_tag(url: &str) -> Result> { fetch_default_branch_and_tags(repo.path(), url, &branch)?; let tags_output = Command::new("git") - .args(["tag", "--merged", &branch]) + .args(["tag"]) .current_dir(repo.path()) .output()?; if !tags_output.status.success() { return Err(format!( - "git tag --merged {} failed: {}", - branch, + "git tag failed: {}", String::from_utf8_lossy(&tags_output.stderr).trim() ) .into()); diff --git a/src/config.rs b/src/config.rs index cccf0a6..f1ee24b 100644 --- a/src/config.rs +++ b/src/config.rs @@ -37,53 +37,56 @@ pub enum Location { /// branch (e.g., "main", "develop") to check out. GitBranch { url: String, branch: String }, - /// Resolve to the latest commit on the default branch of the repository at init time. + /// Resolve to the latest commit on the given (or auto-detected default) branch at init time. /// - /// The `url` field is the Git repository URL. When used with `foc-devnet init`, - /// this is immediately resolved to a concrete `GitCommit` by querying the remote, + /// `url` is the Git repository URL. `branch` pins a specific branch; when + /// `None` the default branch (`main` / `master`) is auto-detected from the + /// remote. At init time this is immediately resolved to a concrete `GitCommit` /// so the stored config always records the exact SHA used. /// - /// Example CLI usage: `--curio latestCommit` - LatestCommit { url: String }, + /// Example CLI usage: `--curio latestCommit` or `--curio latestCommit:main` + LatestCommit { url: String, branch: Option }, - /// Resolve to the latest tag of the repository at init time. + /// Resolve to the latest stable semver tag reachable from the given (or + /// auto-detected default) branch at init time. /// - /// The `url` field is the Git repository URL. When used with `foc-devnet init`, - /// this is immediately resolved to a concrete `GitTag` by querying the remote, + /// `url` is the Git repository URL. `branch` pins a specific branch; when + /// `None` the default branch (`main` / `master`) is auto-detected from the + /// remote. At init time this is immediately resolved to a concrete `GitTag` /// so the stored config always records the exact tag used. /// - /// Example CLI usage: `--lotus latestTag` - LatestTag { url: String }, + /// Example CLI usage: `--lotus latestTag` or `--lotus latestTag:release/v2` + LatestTag { url: String, branch: Option }, } impl Location { - /// Parse a location string in the format "type:value" or "type:url:value" + /// Parse a location string in the format "type" or "type:value". /// /// Supported formats: - /// - `latestCommit` — resolves to latest HEAD commit (uses default URL) - /// - `latestTag` — resolves to latest semver tag (uses default URL) - /// - `latestCommit:url` — same, with explicit URL - /// - `latestTag:url` — same, with explicit URL - /// - `gittag:tag` — (uses default URL) - /// - `gitcommit:commit` — (uses default URL) - /// - `gitbranch:branch` — (uses default URL) - /// - `local:dir` - /// - `gittag:url:tag` - /// - `gitcommit:url:commit` - /// - `gitbranch:url:branch` - /// - /// Where url can contain colons (e.g., https://github.com/repo.git) + /// - `latestCommit` — auto-detects default branch (`main` / `master`) + /// - `latestCommit:` — uses specified branch (e.g. `latestCommit:release/v2`) + /// - `latestTag` — auto-detects default branch + /// - `latestTag:` — uses specified branch (e.g. `latestTag:master`) + /// - `gittag:` — (uses default URL) + /// - `gitcommit:` — (uses default URL) + /// - `gitbranch:` — (uses default URL) + /// - `local:` + /// - `gittag::` + /// - `gitcommit::` + /// - `gitbranch::` pub fn parse_with_default(s: &str, default_url: &str) -> Result { - // Handle bare magic keywords (no colon) — use default URL + // Handle bare magic keywords (no colon) — auto-detect branch match s { "latestCommit" => { return Ok(Location::LatestCommit { url: default_url.to_string(), + branch: None, }) } "latestTag" => { return Ok(Location::LatestTag { url: default_url.to_string(), + branch: None, }) } _ => {} @@ -92,7 +95,8 @@ impl Location { let parts: Vec<&str> = s.split(':').collect(); if parts.len() < 2 { return Err(format!( - "Invalid location format: {}. Expected 'type:value', 'latestCommit', or 'latestTag'", + "Invalid location format: '{}'. Expected 'latestCommit', 'latestTag', \ + 'latestCommit:', 'latestTag:', or 'gittag/gitcommit/gitbranch/local:...'", s )); } @@ -101,12 +105,14 @@ impl Location { let remaining = &parts[1..].join(":"); match location_type { - // latestCommit:url and latestTag:url forms + // latestCommit: and latestTag: "latestCommit" => Ok(Location::LatestCommit { - url: remaining.to_string(), + url: default_url.to_string(), + branch: Some(remaining.to_string()), }), "latestTag" => Ok(Location::LatestTag { - url: remaining.to_string(), + url: default_url.to_string(), + branch: Some(remaining.to_string()), }), "local" => Ok(Location::LocalSource { dir: remaining.to_string(), diff --git a/src/main_app/version.rs b/src/main_app/version.rs index a881012..e42f75a 100644 --- a/src/main_app/version.rs +++ b/src/main_app/version.rs @@ -70,10 +70,10 @@ fn print_location_info(label: &str, location: &Location) { Location::GitBranch { url, branch } => { info!("{}: {}, branch {}", label, url, branch); } - Location::LatestCommit { url } => { + Location::LatestCommit { url, .. } => { info!("{}: {}, latest commit (unresolved)", label, url); } - Location::LatestTag { url } => { + Location::LatestTag { url, .. } => { info!("{}: {}, latest tag (unresolved)", label, url); } } From a247b2a4d69fec20c7ca4b77b89a49bdc7cb86a1 Mon Sep 17 00:00:00 2001 From: redpanda-f Date: Tue, 3 Mar 2026 09:40:19 +0000 Subject: [PATCH 10/42] init: pythonic tests --- .github/workflows/ci.yml | 2 +- scenarios_py/run.py | 146 ++++++++++++++++++++++++++++ scenarios_py/test_basic_balances.py | 22 +++++ scenarios_py/test_containers.py | 27 +++++ 4 files changed, 196 insertions(+), 1 deletion(-) create mode 100755 scenarios_py/run.py create mode 100644 scenarios_py/test_basic_balances.py create mode 100644 scenarios_py/test_containers.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0fbb0f0..072b5e8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -411,7 +411,7 @@ jobs: # By default, don't file an issue if everything passes SKIP_REPORT_ON_PASS: ${{ inputs.skip_report_on_pass != false }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: bash scenarios/run.sh + run: python3 scenarios_py/run.py # Upload scenario report as artifact - name: "EXEC: {Upload scenario report}" diff --git a/scenarios_py/run.py b/scenarios_py/run.py new file mode 100755 index 0000000..62b8c4c --- /dev/null +++ b/scenarios_py/run.py @@ -0,0 +1,146 @@ +#!/usr/bin/env python3 +# core.py — assertions, devnet-info helpers, test runner, and reporting. +# Run all tests: python3 core.py +# Run one test: python3 test_containers.py +import os, sys, json, subprocess, importlib.util, time + +# Allow test files to `from core import *` even when core runs as __main__. +sys.modules.setdefault("core", sys.modules[__name__]) + +DEVNET_INFO = os.environ.get("DEVNET_INFO", os.path.expanduser("~/.foc-devnet/state/latest/devnet-info.json")) +REPORT_MD = os.environ.get("REPORT_FILE", os.path.expanduser("~/.foc-devnet/state/latest/scenario_report.md")) + +# ── Scenario execution order (mirrors scenarios/order.sh) ──── +ORDER = [ + "test_containers", + "test_basic_balances", +] + +_pass = 0 +_fail = 0 + +# ── Logging ────────────────────────────────────────────────── + +def info(msg): + print(f"[INFO] {msg}") + +def ok(msg): + global _pass + print(f"[ OK ] {msg}") + _pass += 1 + +def fail(msg): + global _fail + print(f"[FAIL] {msg}", file=sys.stderr) + _fail += 1 + +# ── Assertions ─────────────────────────────────────────────── + +def assert_eq(a, b, msg): + if a == b: ok(msg) + else: fail(f"{msg} (got '{a}', want '{b}')") + +def assert_gt(a, b, msg): + try: + if int(a) > int(b): ok(msg) + else: fail(f"{msg} (got '{a}', want > '{b}')") + except: fail(f"{msg} (not an int: '{a}')") + +def assert_not_empty(v, msg): + if v: ok(msg) + else: fail(f"{msg} (empty)") + +def assert_ok(cmd, msg): + if subprocess.call(cmd, shell=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) == 0: ok(msg) + else: fail(msg) + +# ── Shell helpers ───────────────────────────────────────────── + +def sh(cmd): + """Run cmd in a shell and return stdout stripped, or '' on error.""" + return subprocess.run(cmd, shell=True, text=True, capture_output=True).stdout.strip() + +def devnet_info(): + """Load devnet-info.json as a dict.""" + with open(DEVNET_INFO) as f: + return json.load(f) + +def ensure_foundry(): + """Install Foundry if cast is not on PATH.""" + if subprocess.call("command -v cast", shell=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) != 0: + info("Installing Foundry...") + os.system("curl -sSL https://foundry.paradigm.xyz | bash") + os.environ["PATH"] = os.path.expanduser("~/.foundry/bin:") + os.environ["PATH"] + os.system(os.path.expanduser("~/.foundry/bin/foundryup")) + assert_ok("command -v cast", "cast is installed") + +# ── Runner ──────────────────────────────────────────────────── + +def run_tests(): + """Run scenarios in ORDER. Returns list of (name, passed, failed).""" + global _pass, _fail + here = os.path.dirname(os.path.abspath(__file__)) + results = [] + for name in ORDER: + path = os.path.join(here, f"{name}.py") + _pass = _fail = 0 + info(f"=== {name} ===") + try: + spec = importlib.util.spec_from_file_location(name, path) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + mod.run() + except Exception as e: + fail(f"unhandled exception: {e}") + results.append((name, _pass, _fail)) + return results + +# ── Reporting ───────────────────────────────────────────────── + +def write_report(results, elapsed): + """Write a markdown report to REPORT_DIR. Returns path written.""" + total_assert_pass = sum(p for _, p, _ in results) + total_assert_fail = sum(f for _, _, f in results) + total_scenarios = len(results) + scenario_pass = sum(1 for _, p, f in results if f == 0) + scenario_fail = total_scenarios - scenario_pass + with open(REPORT_MD, "w") as fh: + fh.write("# Scenario Test Report\n\n") + # If running in GitHub Actions, include a link to the run + github_run_id = os.environ.get("GITHUB_RUN_ID") + github_repo = os.environ.get("GITHUB_REPOSITORY") + if github_run_id and github_repo: + github_server = os.environ.get("GITHUB_SERVER_URL", "https://github.com") + ci_url = f"{github_server}/{github_repo}/actions/runs/{github_run_id}" + fh.write(f"**CI Run**: [{ci_url}]({ci_url})\n\n") + fh.write("| Metric | Value |\n|--------|-------|\n") + fh.write(f"| Total Scenarios | {total_scenarios} |\n| Scenarios Passed | {scenario_pass} |\n| Scenarios Failed | {scenario_fail} |\n") + fh.write(f"| Total Assertions | {total_assert_pass+total_assert_fail} |\n| Assertions Passed | {total_assert_pass} |\n| Assertions Failed | {total_assert_fail} |\n| Duration | {elapsed}s |\n\n") + fh.write("## Details\n\n| Status | Scenario | Passed | Failed |\n|--------|----------|--------|--------|\n") + for name, p, f in results: + icon = "✅" if f == 0 else "❌" + fh.write(f"| {icon} {'PASS' if f == 0 else 'FAIL'} | {name} | {p} | {f} |\n") + return path + +if __name__ == "__main__": + start = time.time() + results = run_tests() + elapsed = int(time.time() - start) + + total_assert_pass = sum(p for _, p, _ in results) + total_assert_fail = sum(f for _, _, f in results) + total_scenarios = len(results) + scenario_pass = sum(1 for _, _, f in results if f == 0) + scenario_fail = total_scenarios - scenario_pass + print(f"\n{'='*50}") + print(f"Scenarios: {total_scenarios} Passed: {scenario_pass} Failed: {scenario_fail} ({elapsed}s)") + print(f"Assertions: {total_assert_pass+total_assert_fail} Passed: {total_assert_pass} Failed: {total_assert_fail}") + + report = write_report(results, elapsed) + print(f"Report: {report}") + # Print CI run URL in stdout if available + if os.environ.get("GITHUB_RUN_ID") and os.environ.get("GITHUB_REPOSITORY"): + github_server = os.environ.get("GITHUB_SERVER_URL", "https://github.com") + ci_url = f"{github_server}/{os.environ.get('GITHUB_REPOSITORY')}/actions/runs/{os.environ.get('GITHUB_RUN_ID')}" + print(f"CI Run: {ci_url}") + sys.exit(0 if scenario_fail == 0 else 1) diff --git a/scenarios_py/test_basic_balances.py b/scenarios_py/test_basic_balances.py new file mode 100644 index 0000000..9328dec --- /dev/null +++ b/scenarios_py/test_basic_balances.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python3 +# Verifies every devnet user has a positive FIL and USDFC balance. +from scenarios_py.run import * + +def run(): + ensure_foundry() + d = devnet_info()["info"] + lotus_rpc = d["lotus"]["host_rpc_url"] + usdfc_addr = d["contracts"]["mockusdfc_addr"] + users = d["users"] + assert_gt(len(users), 0, "at least one user exists") + + for user in users: + name, user_addr = user["name"], user["evm_addr"] + fil_wei = sh(f"cast balance {user_addr} --rpc-url {lotus_rpc}") + assert_gt(fil_wei, 0, f"{name} FIL balance > 0") + usdfc_raw = sh(f"cast call {usdfc_addr} 'balanceOf(address)(uint256)' {user_addr} --rpc-url {lotus_rpc}") + usdfc_wei = "".join(c for c in usdfc_raw if c.isdigit()) + assert_gt(usdfc_wei, 0, f"{name} USDFC balance > 0") + +if __name__ == "__main__": + run() diff --git a/scenarios_py/test_containers.py b/scenarios_py/test_containers.py new file mode 100644 index 0000000..6cb6784 --- /dev/null +++ b/scenarios_py/test_containers.py @@ -0,0 +1,27 @@ +#!/usr/bin/env python3 +# Verifies all devnet containers are running and no unexpected foc-* containers exist. +from scenarios_py.run import * + +def run(): + d = devnet_info()["info"] + run_id = d.get("run_id", "") + + expected = [d["lotus"]["container_name"], d["lotus_miner"]["container_name"]] + sps = d.get("pdp_sps", []) + for sp in sps: + expected.append(sp["container_name"]) + for i in range(1, len(sps) + 1): + expected.append(f"foc-{run_id}-yugabyte-{i}") + + for name in expected: + status = sh(f"docker inspect -f '{{{{.State.Status}}}}' {name} 2>/dev/null || echo missing") + assert_eq(status, "running", f"container {name} is running") + + prefix = f"foc-{run_id}-" if run_id else "foc-" + running = sh(f"docker ps --filter name={prefix} --format '{{{{.Names}}}}'").split() + for name in running: + known = name in expected or "-portainer" in name + assert_eq(known, True, f"container {name} is expected") + +if __name__ == "__main__": + run() From ce99d993ec7c48a78cc22d738ba5c813fb0ec680 Mon Sep 17 00:00:00 2001 From: redpanda-f Date: Tue, 3 Mar 2026 09:51:46 +0000 Subject: [PATCH 11/42] add: interbranch PR triggers CI --- .github/workflows/ci.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 072b5e8..d715dba 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,7 +5,11 @@ on: push: branches: ['*'] pull_request: - branches: [main] + branches: ['*'] + # Also run on pull_request_target so branch-to-branch PRs (same-repo) + # can execute workflows on the base context (useful for self-hosted runners). + pull_request_target: + branches: ['*'] schedule: # Nightly at 03:00 UTC - cron: '0 3 * * *' From f166fa12bb864cf177b09401a7efbb1160123bf8 Mon Sep 17 00:00:00 2001 From: redpanda-f Date: Tue, 3 Mar 2026 09:55:26 +0000 Subject: [PATCH 12/42] fix: use '**' glob so CI triggers on slash-separated feature branches --- .github/workflows/ci.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d715dba..70dffbc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,13 +3,13 @@ name: CI on: push: - branches: ['*'] + branches: ['**'] pull_request: - branches: ['*'] + branches: ['**'] # Also run on pull_request_target so branch-to-branch PRs (same-repo) # can execute workflows on the base context (useful for self-hosted runners). pull_request_target: - branches: ['*'] + branches: ['**'] schedule: # Nightly at 03:00 UTC - cron: '0 3 * * *' From f40b6bf10f3bf0518d7b8da8a28f546e79cd03d3 Mon Sep 17 00:00:00 2001 From: redpanda-f Date: Tue, 3 Mar 2026 09:56:09 +0000 Subject: [PATCH 13/42] add: interbranch PR triggers CI --- .github/workflows/ci.yml | 4 ---- 1 file changed, 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 70dffbc..625d1cd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,10 +6,6 @@ on: branches: ['**'] pull_request: branches: ['**'] - # Also run on pull_request_target so branch-to-branch PRs (same-repo) - # can execute workflows on the base context (useful for self-hosted runners). - pull_request_target: - branches: ['**'] schedule: # Nightly at 03:00 UTC - cron: '0 3 * * *' From d035fa902d161db5f1a7d995f8a2e8613131bd24 Mon Sep 17 00:00:00 2001 From: redpanda-f Date: Tue, 3 Mar 2026 09:56:58 +0000 Subject: [PATCH 14/42] add: interbranch PR triggers CI --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 625d1cd..98d989c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,7 +3,7 @@ name: CI on: push: - branches: ['**'] + branches: ['main'] pull_request: branches: ['**'] schedule: From d489d8458beae51176cd21eab73fcb2d395147b2 Mon Sep 17 00:00:00 2001 From: redpanda-f Date: Tue, 3 Mar 2026 09:59:06 +0000 Subject: [PATCH 15/42] remove: shell --- .githooks/pre-commit | 43 --------- .github/workflows/ci.yml | 26 ------ scenarios/lib.sh | 129 -------------------------- scenarios/order.sh | 14 --- scenarios/run.sh | 151 ------------------------------- scenarios/test_basic_balances.sh | 39 -------- scenarios/test_containers.sh | 58 ------------ 7 files changed, 460 deletions(-) delete mode 100755 scenarios/lib.sh delete mode 100755 scenarios/order.sh delete mode 100755 scenarios/run.sh delete mode 100755 scenarios/test_basic_balances.sh delete mode 100755 scenarios/test_containers.sh diff --git a/.githooks/pre-commit b/.githooks/pre-commit index 6479605..68da182 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -57,49 +57,6 @@ if [[ $HAS_RS -gt 0 ]]; then fi fi -# ── Shell checks ───────────────────────────────────────────── -STAGED_SH=$(echo "$STAGED" | grep '\.sh$' || true) -if [[ -n "$STAGED_SH" ]]; then - # Executable bit - for f in $STAGED_SH; do - if [[ -f "$f" && ! -x "$f" ]]; then - if [[ "$FIX" == "1" ]]; then - chmod +x "$f" - git add "$f" - fixed "${f} +x" - else - fail "${f} is not executable — run 'chmod +x ${f}'" - fi - fi - done - - # shfmt - if command -v shfmt &>/dev/null; then - if shfmt -d -i 2 -bn $STAGED_SH &>/dev/null; then - pass "shfmt" - elif [[ "$FIX" == "1" ]]; then - shfmt -w -i 2 -bn $STAGED_SH - echo "$STAGED_SH" | xargs -r git add - fixed "shfmt" - else - fail "shfmt — run 'shfmt -w -i 2 -bn ' or FIX=1 git commit" - fi - else - skip "shfmt" - fi - - # SC — no auto-fix available - if command -v shellcheck &>/dev/null; then - if shellcheck -S warning $STAGED_SH &>/dev/null; then - pass "shellcheck" - else - fail "shellcheck — run 'shellcheck -S warning ' to see issues" - fi - else - skip "shellcheck" - fi -fi - # ── Result ─────────────────────────────────────────────────── if [[ $FAIL -ne 0 ]]; then echo "" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 98d989c..831c24c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,32 +39,6 @@ jobs: - name: Run clippy run: cargo clippy --all-targets --all-features -- -D warnings - shellcheck: - runs-on: ubuntu-latest - timeout-minutes: 5 - - steps: - - uses: actions/checkout@v4 - - - name: Install shfmt - run: sudo snap install shfmt - - # Ensure all .sh files under scenarios/ are executable - - name: Check executable permissions - run: | - bad=$(find scenarios -name '*.sh' ! -perm -u+x) - if [ -n "$bad" ]; then - echo "Missing +x on:" >&2; echo "$bad" >&2; exit 1 - fi - - # Lint with shellcheck - - name: shellcheck - run: find scenarios -name '*.sh' -exec shellcheck -S warning {} + - - # Verify consistent formatting (indent=2, binary ops start of line) - - name: shfmt - run: shfmt -d -i 2 -bn scenarios/ - foc-devnet-test: runs-on: ["self-hosted", "linux", "x64", "16xlarge+gpu"] timeout-minutes: 100 diff --git a/scenarios/lib.sh b/scenarios/lib.sh deleted file mode 100755 index 58a3d5a..0000000 --- a/scenarios/lib.sh +++ /dev/null @@ -1,129 +0,0 @@ -#!/usr/bin/env bash -# ───────────────────────────────────────────────────────────── -# lib.sh — Shared helpers for scenario tests. -# -# Sourced (not executed) by each test_*.sh script. -# Provides: assertions, devnet-info access, and result tracking. -# -# ── Writing a new scenario ─────────────────────────────────── -# -# 1. Create scenarios/test_.sh with this skeleton: -# -# #!/usr/bin/env bash -# set -euo pipefail -# SCENARIO_DIR="$(cd "$(dirname "$0")" && pwd)" -# source "${SCENARIO_DIR}/lib.sh" -# scenario_start "" -# -# # ... your checks using assert_*, jq_devnet, etc. ... -# -# scenario_end -# -# 2. Add "test_" to the SCENARIOS array in order.sh. -# 3. chmod +x scenarios/test_.sh -# 4. Run: bash scenarios/test_.sh -# -# ── Available helpers ──────────────────────────────────────── -# jq_devnet — query devnet-info.json -# assert_eq — equality check -# assert_gt — integer greater-than -# assert_not_empty — value is non-empty -# assert_ok — command exits 0 -# info / ok / fail — logging -# ───────────────────────────────────────────────────────────── -# shellcheck disable=SC2034 # Variables here are used by scripts that source this file -set -euo pipefail - -ensure_foundry() { - if ! command -v cast &>/dev/null; then - info "Installing Foundry …" - export SHELL=/bin/bash - curl -sSL https://foundry.paradigm.xyz | bash - export PATH="$HOME/.foundry/bin:$PATH" - "$HOME/.foundry/bin/foundryup" - fi - assert_ok command -v cast "cast is installed" -} - -# ── Paths ──────────────────────────────────────────────────── -DEVNET_INFO="${DEVNET_INFO:-$HOME/.foc-devnet/state/latest/devnet-info.json}" -SCENARIO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -REPORT_DIR="${REPORT_DIR:-$HOME/.foc-devnet/state/latest}" - -# Per-scenario counters (reset by scenario_start) -_PASS=0 -_FAIL=0 -_SCENARIO_NAME="" - -# ── devnet-info helpers ────────────────────────────────────── - -# Shorthand: jq_devnet '.info.users[0].evm_addr' -jq_devnet() { - if ! jq -r "$1" "$DEVNET_INFO"; then - fail "jq_devnet: jq failed for filter '$1' on '$DEVNET_INFO'" - return 1 - fi -} - -# ── Logging ────────────────────────────────────────────────── -_log() { printf "[%s] %s\n" "$1" "$2"; } -info() { _log "INFO" "$*"; } -ok() { - _log " OK " "$*" - ((_PASS++)) || true -} -fail() { - _log "FAIL" "$*" - ((_FAIL++)) || true -} - -# ── Assertions ─────────────────────────────────────────────── - -# assert_eq -assert_eq() { - if [[ "$1" == "$2" ]]; then ok "$3"; else fail "$3 (got '$1', want '$2')"; fi -} - -# assert_not_empty -assert_not_empty() { - # ── Foundry install (hoisted) ─────────────────────────────── - if [[ -n "$1" ]]; then ok "$2"; else fail "$2 (empty)"; fi -} - -# assert_gt -# Both arguments are treated as integers (wei-scale is fine). -assert_gt() { - if python3 -c "import sys; sys.exit(0 if int('$1') > int('$2') else 1)" 2>/dev/null; then - ok "$3" - else - fail "$3 (got '$1', want > '$2')" - fi -} - -# assert_ok -# Runs the command; passes if exit-code == 0. -assert_ok() { - local msg="${*: -1}" - local cmd=("${@:1:$#-1}") - if "${cmd[@]}" >/dev/null 2>&1; then ok "$msg"; else fail "$msg"; fi -} - -# ── Scenario lifecycle ─────────────────────────────────────── - -scenario_start() { - _SCENARIO_NAME="$1" - _PASS=0 - _FAIL=0 - info "━━━ START: ${_SCENARIO_NAME} ━━━" -} - -scenario_end() { - local total=$((_PASS + _FAIL)) - local status="PASS" - [[ $_FAIL -gt 0 ]] && status="FAIL" - info "━━━ END: ${_SCENARIO_NAME} ${_PASS}/${total} passed [${status}] ━━━" - # Write machine-readable result line for the runner - mkdir -p "$REPORT_DIR" - echo "${status}|${_SCENARIO_NAME}|${_PASS}|${_FAIL}" >>"${REPORT_DIR}/results.csv" - [[ $_FAIL -eq 0 ]] -} diff --git a/scenarios/order.sh b/scenarios/order.sh deleted file mode 100755 index 2a99c89..0000000 --- a/scenarios/order.sh +++ /dev/null @@ -1,14 +0,0 @@ -#!/usr/bin/env bash -# ───────────────────────────────────────────────────────────── -# order.sh — Declares the scenario execution order. -# -# Each entry is the basename of a script under scenarios/. -# Scenarios share the same running devnet and execute serially -# in the order listed here. -# ───────────────────────────────────────────────────────────── - -# shellcheck disable=SC2034 # SCENARIOS is used by run.sh which sources this file -SCENARIOS=( - test_containers - test_basic_balances -) diff --git a/scenarios/run.sh b/scenarios/run.sh deleted file mode 100755 index 3509109..0000000 --- a/scenarios/run.sh +++ /dev/null @@ -1,151 +0,0 @@ -#!/usr/bin/env bash -# ───────────────────────────────────────────────────────────── -# run.sh — Scenario test runner. -# -# Executes every scenario listed in order.sh against the -# currently-running devnet, collects results, prints a report, -# and (when REPORTING=true) files a GitHub issue. -# -# ── Running locally ────────────────────────────────────────── -# -# Prerequisites: -# - A running foc-devnet cluster (./foc-devnet start) -# - jq, python3, docker on PATH -# - (optional) Foundry — installed automatically by -# test_basic_balances if missing -# -# Quick start: -# ./foc-devnet start # bring up the devnet -# bash scenarios/run.sh # run all scenarios -# cat ~/.foc-devnet/state/latest/scenario_*.md # read the report -# -# Run a single scenario: -# bash scenarios/test_containers.sh -# -# Override devnet-info path (e.g. an older run): -# DEVNET_INFO=~/.foc-devnet/state//devnet-info.json \ -# bash scenarios/run.sh -# -# File a GitHub issue on failure (needs `gh` CLI + auth): -# REPORTING=true bash scenarios/run.sh -# -# Always file an issue, even on success: -# REPORTING=true SKIP_REPORT_ON_PASS=false bash scenarios/run.sh -# -# ── Environment variables ──────────────────────────────────── -# DEVNET_INFO — path to devnet-info.json (auto-detected) -# REPORTING — "true" to create a GitHub issue -# SKIP_REPORT_ON_PASS — "true" (default) skips the issue when -# all scenarios pass -# GITHUB_SERVER_URL, GITHUB_REPOSITORY, GITHUB_RUN_ID -# — set automatically by GitHub Actions -# ───────────────────────────────────────────────────────────── -set -euo pipefail - -SCENARIO_DIR="$(cd "$(dirname "$0")" && pwd)" -REPORT_DIR="${REPORT_DIR:-$HOME/.foc-devnet/state/latest}" -REPORTING="${REPORTING:-false}" -SKIP_REPORT_ON_PASS="${SKIP_REPORT_ON_PASS:-true}" - -# ── Bootstrap ──────────────────────────────────────────────── -# Ensure report directory exists before cleaning/writing artifacts -mkdir -p "${REPORT_DIR}" -# Clean previous scenario artifacts (but not the whole state dir) -rm -f "${REPORT_DIR}"/scenario_*.md "${REPORT_DIR}/results.csv" -source "${SCENARIO_DIR}/order.sh" - -TOTAL=0 -PASSED=0 -FAILED=0 -FAILED_NAMES=() -START_TS=$(date +%s) - -# ── Execute scenarios ──────────────────────────────────────── -for name in "${SCENARIOS[@]}"; do - script="${SCENARIO_DIR}/${name}.sh" - if [[ ! -f "$script" ]]; then - echo "[SKIP] ${name}.sh not found" - continue - fi - - ((TOTAL++)) || true - echo "" - # Each scenario runs in a subshell so a failure doesn't kill the runner - if bash "$script"; then - ((PASSED++)) || true - else - ((FAILED++)) || true - FAILED_NAMES+=("$name") - fi -done - -ELAPSED=$(($(date +%s) - START_TS)) - -# ── Build report ───────────────────────────────────────────── -REPORT="${REPORT_DIR}/scenario_$(date -u +%Y%m%d_%H%M%S).md" -{ - echo "# Scenario Test Report" - echo "" - echo "| Metric | Value |" - echo "|--------|-------|" - echo "| Total | ${TOTAL} |" - echo "| Passed | ${PASSED} |" - echo "| Failed | ${FAILED} |" - echo "| Duration | ${ELAPSED}s |" - echo "" - - # Per-scenario detail from the CSV each scenario appended - if [[ -f "${REPORT_DIR}/results.csv" ]]; then - echo "## Details" - echo "" - echo "| Status | Scenario | Passed | Failed |" - echo "|--------|----------|--------|--------|" - while IFS='|' read -r st sc pa fa; do - icon="✅" - [[ "$st" == "FAIL" ]] && icon="❌" - echo "| ${icon} ${st} | ${sc} | ${pa} | ${fa} |" - done <"${REPORT_DIR}/results.csv" - fi - - if [[ ${#FAILED_NAMES[@]} -gt 0 ]]; then - echo "" - echo "## Failed scenarios" - echo "" - for n in "${FAILED_NAMES[@]}"; do echo "- \`${n}\`"; done - fi -} >"$REPORT" - -# Print to stdout as well -cat "$REPORT" - -# ── GitHub issue (only when REPORTING=true) ────────────────── -SHOULD_REPORT=false -if [[ "$REPORTING" == "true" ]]; then - if [[ $FAILED -gt 0 ]]; then - SHOULD_REPORT=true - elif [[ "$SKIP_REPORT_ON_PASS" != "true" ]]; then - SHOULD_REPORT=true - fi -fi - -if [[ "$SHOULD_REPORT" == "true" ]]; then - RUN_URL="${GITHUB_SERVER_URL:-https://github.com}/${GITHUB_REPOSITORY:-unknown}/actions/runs/${GITHUB_RUN_ID:-0}" - STATUS_EMOJI="✅" - [[ $FAILED -gt 0 ]] && STATUS_EMOJI="❌" - ISSUE_TITLE="${STATUS_EMOJI} Scenario report: ${PASSED}/${TOTAL} passed ($(date -u +%Y-%m-%d))" - ISSUE_BODY="$(cat "$REPORT") - ---- -[View workflow run](${RUN_URL})" - - LABELS="scenario-report" - [[ $FAILED -gt 0 ]] && LABELS="scenario-report,bug" - gh issue create \ - --title "$ISSUE_TITLE" \ - --body "$ISSUE_BODY" \ - --label "$LABELS" \ - || echo "[WARN] Could not create GitHub issue (gh CLI missing or auth failed)" -fi - -# ── Exit code reflects overall result ──────────────────────── -[[ $FAILED -eq 0 ]] diff --git a/scenarios/test_basic_balances.sh b/scenarios/test_basic_balances.sh deleted file mode 100755 index 66d0f87..0000000 --- a/scenarios/test_basic_balances.sh +++ /dev/null @@ -1,39 +0,0 @@ -#!/usr/bin/env bash -# ───────────────────────────────────────────────────────────── -# test_basic_balances.sh -# -# Installs Foundry (cast), then verifies every user account on -# the devnet has a positive tFIL balance and a positive USDFC -# token balance. -# ───────────────────────────────────────────────────────────── -set -euo pipefail - -SCENARIO_DIR="$(cd "$(dirname "$0")" && pwd)" -source "${SCENARIO_DIR}/lib.sh" -scenario_start "test_basic_balances" - -# ── Ensure Foundry is available ────────────────────────────── -ensure_foundry - -# ── Read devnet info ───────────────────────────────────────── -RPC_URL=$(jq_devnet '.info.lotus.host_rpc_url') -USDFC_ADDR=$(jq_devnet '.info.contracts.mockusdfc_addr') -USER_COUNT=$(jq_devnet '.info.users | length') -assert_gt "$USER_COUNT" 0 "at least one user exists" - -# ── Check each user ────────────────────────────────────────── -for i in $(seq 0 $((USER_COUNT - 1))); do - NAME=$(jq_devnet ".info.users[$i].name") - ADDR=$(jq_devnet ".info.users[$i].evm_addr") - - # Native FIL balance (returned in wei) - FIL_WEI=$(cast balance "$ADDR" --rpc-url "$RPC_URL") - assert_gt "$FIL_WEI" 0 "${NAME} FIL balance > 0" - - # MockUSDFC ERC-20 balance - USDFC_WEI_RAW=$(cast call "$USDFC_ADDR" "balanceOf(address)(uint256)" "$ADDR" --rpc-url "$RPC_URL") - USDFC_WEI=$(echo "$USDFC_WEI_RAW" | tr -cd '0-9') - assert_gt "$USDFC_WEI" 0 "${NAME} USDFC balance > 0" -done - -scenario_end diff --git a/scenarios/test_containers.sh b/scenarios/test_containers.sh deleted file mode 100755 index 7be80f9..0000000 --- a/scenarios/test_containers.sh +++ /dev/null @@ -1,58 +0,0 @@ -#!/usr/bin/env bash -# ───────────────────────────────────────────────────────────── -# test_containers.sh -# -# Verifies that all foc-* containers reported in devnet-info.json -# are actually running and that no unexpected foc-* containers -# exist outside the current run. -# ───────────────────────────────────────────────────────────── -set -euo pipefail - -SCENARIO_DIR="$(cd "$(dirname "$0")" && pwd)" -source "${SCENARIO_DIR}/lib.sh" -scenario_start "test_containers" - -# ── Collect expected container names from devnet-info ──────── -EXPECTED=() -EXPECTED+=("$(jq_devnet '.info.lotus.container_name')") -EXPECTED+=("$(jq_devnet '.info.lotus_miner.container_name')") - -# Each Curio SP also has a container -SP_COUNT=$(jq_devnet '.info.pdp_sps | length') -for i in $(seq 0 $((SP_COUNT - 1))); do - EXPECTED+=("$(jq_devnet ".info.pdp_sps[$i].container_name")") -done - -# Each Curio SP also has a YugabyteDB container -RUN_ID=$(jq_devnet '.info.run_id') -for i in $(seq 1 $SP_COUNT); do - EXPECTED+=("foc-${RUN_ID}-yugabyte-${i}") -done - -# ── Verify each expected container is running ──────────────── -for cname in "${EXPECTED[@]}"; do - STATUS=$(docker inspect -f '{{.State.Status}}' "$cname" 2>/dev/null || echo "missing") - assert_eq "$STATUS" "running" "container ${cname} is running" -done - -# ── Check no unexpected foc-* containers are running ───────── -# All foc-* containers for this devnet run should belong to the expected set. -# Prefer the run-scoped prefix from devnet-info when available, fall back to foc-. -RUN_ID="$(jq_devnet '.info.run_id // ""')" -if [[ -n "$RUN_ID" ]]; then - NAME_FILTER="foc-${RUN_ID}-" -else - NAME_FILTER="foc-" -fi -RUNNING=$(docker ps --filter "name=${NAME_FILTER}" --format '{{.Names}}') -for cname in $RUNNING; do - KNOWN=false - for exp in "${EXPECTED[@]}"; do - [[ "$cname" == "$exp" ]] && KNOWN=true && break - done - # Portainer is allowed but not in devnet-info - [[ "$cname" == *"-portainer"* ]] && KNOWN=true - assert_eq "$KNOWN" "true" "container ${cname} is expected" -done - -scenario_end From 676dc42a45cbaefda0fab5ccddfe159e72e4030d Mon Sep 17 00:00:00 2001 From: redpanda-f Date: Tue, 3 Mar 2026 10:08:00 +0000 Subject: [PATCH 16/42] fix: python tests --- scenarios_py/__init__.py | 1 + scenarios_py/run.py | 8 +++++++- scenarios_py/test_containers.py | 8 -------- 3 files changed, 8 insertions(+), 9 deletions(-) create mode 100644 scenarios_py/__init__.py diff --git a/scenarios_py/__init__.py b/scenarios_py/__init__.py new file mode 100644 index 0000000..95f9a19 --- /dev/null +++ b/scenarios_py/__init__.py @@ -0,0 +1 @@ +# scenarios_py package diff --git a/scenarios_py/run.py b/scenarios_py/run.py index 62b8c4c..ee62880 100755 --- a/scenarios_py/run.py +++ b/scenarios_py/run.py @@ -4,6 +4,12 @@ # Run one test: python3 test_containers.py import os, sys, json, subprocess, importlib.util, time +# Ensure the project root (parent of scenarios_py/) is on sys.path so that +# test files can do `from scenarios_py.run import *` regardless of cwd. +_project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +if _project_root not in sys.path: + sys.path.insert(0, _project_root) + # Allow test files to `from core import *` even when core runs as __main__. sys.modules.setdefault("core", sys.modules[__name__]) @@ -120,7 +126,7 @@ def write_report(results, elapsed): for name, p, f in results: icon = "✅" if f == 0 else "❌" fh.write(f"| {icon} {'PASS' if f == 0 else 'FAIL'} | {name} | {p} | {f} |\n") - return path + return REPORT_MD if __name__ == "__main__": start = time.time() diff --git a/scenarios_py/test_containers.py b/scenarios_py/test_containers.py index 6cb6784..3d3e814 100644 --- a/scenarios_py/test_containers.py +++ b/scenarios_py/test_containers.py @@ -10,18 +10,10 @@ def run(): sps = d.get("pdp_sps", []) for sp in sps: expected.append(sp["container_name"]) - for i in range(1, len(sps) + 1): - expected.append(f"foc-{run_id}-yugabyte-{i}") for name in expected: status = sh(f"docker inspect -f '{{{{.State.Status}}}}' {name} 2>/dev/null || echo missing") assert_eq(status, "running", f"container {name} is running") - prefix = f"foc-{run_id}-" if run_id else "foc-" - running = sh(f"docker ps --filter name={prefix} --format '{{{{.Names}}}}'").split() - for name in running: - known = name in expected or "-portainer" in name - assert_eq(known, True, f"container {name} is expected") - if __name__ == "__main__": run() From 85a2a604055f711809057fb48b13fe9e97f46fc0 Mon Sep 17 00:00:00 2001 From: redpanda-f Date: Tue, 3 Mar 2026 10:09:03 +0000 Subject: [PATCH 17/42] fix: move into scenarios/ directory --- {scenarios_py => scenarios}/__init__.py | 0 {scenarios_py => scenarios}/run.py | 0 {scenarios_py => scenarios}/test_basic_balances.py | 0 {scenarios_py => scenarios}/test_containers.py | 0 4 files changed, 0 insertions(+), 0 deletions(-) rename {scenarios_py => scenarios}/__init__.py (100%) rename {scenarios_py => scenarios}/run.py (100%) rename {scenarios_py => scenarios}/test_basic_balances.py (100%) rename {scenarios_py => scenarios}/test_containers.py (100%) diff --git a/scenarios_py/__init__.py b/scenarios/__init__.py similarity index 100% rename from scenarios_py/__init__.py rename to scenarios/__init__.py diff --git a/scenarios_py/run.py b/scenarios/run.py similarity index 100% rename from scenarios_py/run.py rename to scenarios/run.py diff --git a/scenarios_py/test_basic_balances.py b/scenarios/test_basic_balances.py similarity index 100% rename from scenarios_py/test_basic_balances.py rename to scenarios/test_basic_balances.py diff --git a/scenarios_py/test_containers.py b/scenarios/test_containers.py similarity index 100% rename from scenarios_py/test_containers.py rename to scenarios/test_containers.py From 8bcaccba6326032278f8a19a0a04cc7e3fac1a83 Mon Sep 17 00:00:00 2001 From: redpanda-f Date: Tue, 3 Mar 2026 10:25:22 +0000 Subject: [PATCH 18/42] add: README_ADVANCED on how to add pythonic tests --- README_ADVANCED.md | 56 +++++++++++++++++++++------------------------- 1 file changed, 25 insertions(+), 31 deletions(-) diff --git a/README_ADVANCED.md b/README_ADVANCED.md index 1489c90..733eff4 100644 --- a/README_ADVANCED.md +++ b/README_ADVANCED.md @@ -1298,54 +1298,48 @@ docker run --rm --network host \ ## Scenario Tests -Scenario tests are lightweight shell scripts that validate scenarios on the devnet after startup. They share a single running devnet and execute serially in a defined order. +Scenario tests are Python scripts that validate devnet state after startup. They share a single running devnet and execute serially in a defined order. The runner lives in `scenarios/` and uses **only Python stdlib** — no `pip install` required. ### Running scenarios ```bash -# Run all scenarios against a running devnet -bash scenarios/run.sh +# Run all scenarios +python3 scenarios/run.py -# Run a single scenario -bash scenarios/test_basic_balances.sh +# Run a single scenario directly +python3 scenarios/test_basic_balances.py # Point at a specific devnet run -DEVNET_INFO=~/.foc-devnet/state//devnet-info.json bash scenarios/run.sh +DEVNET_INFO=~/.foc-devnet/state//devnet-info.json python3 scenarios/run.py ``` -Reports are written to `~/.foc-devnet/state/latest/scenario_.md`. +Reports are written to `~/.foc-devnet/state/latest/scenario_report.md`. ### Writing a new scenario -1. Create `scenarios/test_.sh`: +1. Create `scenarios/test_.py`: -```bash -#!/usr/bin/env bash -set -euo pipefail -SCENARIO_DIR="$(cd "$(dirname "$0")" && pwd)" -source "${SCENARIO_DIR}/lib.sh" -scenario_start "" - -# Use helpers: jq_devnet, assert_eq, assert_gt, assert_not_empty, assert_ok -RPC_URL=$(jq_devnet '.info.lotus.host_rpc_url') -BALANCE=$(cast balance 0x... --rpc-url "$RPC_URL") -assert_gt "$BALANCE" 0 "account has funds" - -scenario_end +```python +#!/usr/bin/env python3 +from scenarios.run import * + +def run(): + d = devnet_info()["info"] + rpc = d["lotus"]["host_rpc_url"] + + # Use helpers: sh, assert_eq, assert_gt, assert_not_empty, assert_ok + balance = sh(f"cast balance 0x... --rpc-url {rpc}") + assert_gt(balance, 0, "account has funds") + +if __name__ == "__main__": + run() ``` -2. Add `test_` to the `SCENARIOS` array in `scenarios/order.sh`. -3. `chmod +x scenarios/test_.sh` +2. Add `"test_"` to the `ORDER` list in `scenarios/run.py`. -### Available assertion helpers (from `lib.sh`) +### Constraints -| Helper | Usage | Description | -|--------|-------|-------------| -| `assert_eq` | `assert_eq "$a" "$b" "msg"` | Equality check | -| `assert_gt` | `assert_gt "$a" "$b" "msg"` | Integer greater-than (handles wei-scale) | -| `assert_not_empty` | `assert_not_empty "$v" "msg"` | Value is non-empty | -| `assert_ok` | `assert_ok cmd arg... "msg"` | Command exits 0 | -| `jq_devnet` | `jq_devnet '.info.lotus.host_rpc_url'` | Query devnet-info.json | +- **No third-party packages.** Only Python stdlib (`os`, `sys`, `json`, `subprocess`, etc.) plus external CLI tools already present on the host (`cast`, `docker`). This keeps CI setup trivial — no virtual env, no `pip install`. ### CI integration From 02746f32e845ddd1f4cc55dcaed51a78069f3d5e Mon Sep 17 00:00:00 2001 From: redpanda-f Date: Tue, 3 Mar 2026 10:30:19 +0000 Subject: [PATCH 19/42] try: multimachine strategy --- .github/workflows/ci.yml | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 831c24c..3c1249e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,7 +40,17 @@ jobs: run: cargo clippy --all-targets --all-features -- -D warnings foc-devnet-test: - runs-on: ["self-hosted", "linux", "x64", "16xlarge+gpu"] + strategy: + matrix: + machine: [ + ["self-hosted", "linux", "x64", "4xlarge+gpu"], + ["self-hosted", "linux", "x64", "xlarge+gpu"], + ["self-hosted", "linux", "arm64", "xlarge"], + ["self-hosted", "linux", "arm64", "4xlarge"], + ["self-hosted", "linux", "x64", "xlarge"], + ["self-hosted", "linux", "x64", "5xlarge"] + ] + runs-on: ${{ matrix.machine }} timeout-minutes: 100 permissions: contents: read @@ -107,7 +117,7 @@ jobs: ~/.cargo/git/db/ target/ key: ${{ runner.os }}-rust-build-${{ hashFiles('**/Cargo.lock') }} - + # Copy binary and clean up Rust artifacts to save disk space - name: "EXEC: {Copy binary and clean cache}, DEP: {C-rust-cache}" run: | From f56797cb1d14fc9c0ee41c0a89500467205b76d5 Mon Sep 17 00:00:00 2001 From: redpanda-f Date: Tue, 3 Mar 2026 11:01:22 +0000 Subject: [PATCH 20/42] add: fail-fast: false --- .github/workflows/ci.yml | 1 + scenarios/run.py | 1 + scenarios/test_storage_e2e.py | 117 ++++++++++++++++++++++++++++++++++ 3 files changed, 119 insertions(+) create mode 100644 scenarios/test_storage_e2e.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3c1249e..fb9f9ae 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,6 +41,7 @@ jobs: foc-devnet-test: strategy: + fail-fast: false matrix: machine: [ ["self-hosted", "linux", "x64", "4xlarge+gpu"], diff --git a/scenarios/run.py b/scenarios/run.py index ee62880..bfc95e2 100755 --- a/scenarios/run.py +++ b/scenarios/run.py @@ -20,6 +20,7 @@ ORDER = [ "test_containers", "test_basic_balances", + "test_storage_e2e", ] _pass = 0 diff --git a/scenarios/test_storage_e2e.py b/scenarios/test_storage_e2e.py new file mode 100644 index 0000000..681b018 --- /dev/null +++ b/scenarios/test_storage_e2e.py @@ -0,0 +1,117 @@ +#!/usr/bin/env python3 +# Inspired by https://raw.githubusercontent.com/FilOzone/synapse-sdk/refs/heads/master/utils/example-storage-e2e.js +# +# Verifies the devnet is ready for end-to-end FOC warm storage interactions: +# +# 1. At least one PDP service provider exists (mirrors "provider selection") +# 2. Every SP is approved in the FWSS contract (mirrors allowanceCheck) +# 3. Every SP is endorsed in the Endorsements contract +# 4. Every SP's PDP service URL is reachable +# 5. USER_1 has a USDFC balance (mirrors "Checking Balances") +# 6. USER_1 has deposited USDFC into FilecoinPay (mirrors "Preflight Upload Check") +# 7. FWSS is set as an operator for USER_1 (mirrors allowanceCheck operator step) +from scenarios_py.run import * +import urllib.request + +# ── ABI fragments used with `cast call` ────────────────────────────────────── +_SIG_PAYMENT_BALANCE = "balance(address)(uint256)" +_SIG_IS_OPERATOR = "isOperator(address,address)(bool)" +_SIG_SP_APPROVED = "isProviderApproved(uint256)(bool)" +_SIG_IS_ENDORSED = "isEndorsed(address)(bool)" + + +def _cast(contract: str, sig: str, *args, rpc: str) -> str: + """Call a read-only contract function via cast and return the raw output.""" + joined = " ".join(args) + return sh(f"cast call {contract} '{sig}' {joined} --rpc-url {rpc}") + + +def _check_sps(sps: list, contracts: dict, rpc: str) -> None: + """Assert each SP is approved in FWSS and endorsed in the Endorsements contract.""" + fwss = contracts["fwss_service_proxy_addr"] + endorsements = contracts["endorsements_addr"] + + assert_gt(len(sps), 0, "at least one PDP service provider exists") + + for sp in sps: + pid = sp["provider_id"] + addr = sp["eth_addr"] + + approved = _cast(fwss, _SIG_SP_APPROVED, str(pid), rpc=rpc) + assert_eq(approved.strip(), "true", f"SP {pid} is approved in FWSS") + + endorsed = _cast(endorsements, _SIG_IS_ENDORSED, addr, rpc=rpc) + assert_eq(endorsed.strip(), "true", f"SP {pid} ({addr}) is endorsed") + + +def _check_sp_http(sps: list) -> None: + """Assert each SP's PDP service URL responds with HTTP 200.""" + for sp in sps: + pid = sp["provider_id"] + url = sp["pdp_service_url"].rstrip("/") + try: + code = urllib.request.urlopen(url, timeout=5).getcode() + assert_eq(str(code), "200", f"SP {pid} PDP service HTTP 200 at {url}") + except Exception as exc: + fail(f"SP {pid} PDP service unreachable at {url}: {exc}") + + +def _check_user_storage_readiness(users: list, contracts: dict, rpc: str) -> None: + """ + Verify USER_1 is ready for FOC warm storage uploads: + - Has a USDFC balance (can pay for storage) + - Has credited FIL into FilecoinPay (payment channel funded) + - Has approved FWSS as a payment operator + + This mirrors the Synapse SDK's preflight checks before calling upload(). + """ + usdfc = contracts["mockusdfc_addr"] + filecoin_pay = contracts["filecoin_pay_v1_addr"] + fwss = contracts["fwss_service_proxy_addr"] + + storage_users = [u for u in users if u["name"] == "USER_1"] + if not storage_users: + fail("USER_1 not found in devnet users; skipping storage readiness checks") + return + + user = storage_users[0] + addr = user["evm_addr"] + name = user["name"] + + # ── 1. USDFC wallet balance ─────────────────────────────────────────────── + raw_balance = sh( + f"cast call {usdfc} 'balanceOf(address)(uint256)' {addr} --rpc-url {rpc}" + ) + usdfc_wei = "".join(c for c in raw_balance if c.isdigit()) + assert_gt(usdfc_wei, 0, f"{name} has USDFC wallet balance > 0") + + # ── 2. FilecoinPay deposit (payment channel credited) ──────────────────── + deposit_raw = _cast(filecoin_pay, _SIG_PAYMENT_BALANCE, addr, rpc=rpc) + deposit_wei = "".join(c for c in deposit_raw if c.isdigit()) + assert_gt(deposit_wei, 0, f"{name} has credited USDFC into FilecoinPay") + + # ── 3. FWSS approved as operator in FilecoinPay ─────────────────────────── + is_op = _cast(filecoin_pay, _SIG_IS_OPERATOR, addr, fwss, rpc=rpc) + assert_eq(is_op.strip(), "true", f"{name} has FWSS set as FilecoinPay operator") + + +def run(): + ensure_foundry() + d = devnet_info()["info"] + rpc = d["lotus"]["host_rpc_url"] + contracts = d["contracts"] + users = d["users"] + sps = d.get("pdp_sps", []) + + info("--- Checking service providers ---") + _check_sps(sps, contracts, rpc) + + info("--- Checking SP PDP HTTP endpoints ---") + _check_sp_http(sps) + + info("--- Checking USER_1 storage readiness (mirrors Synapse SDK preflight) ---") + _check_user_storage_readiness(users, contracts, rpc) + + +if __name__ == "__main__": + run() From a8ee42907a0b38ad46bd0158b024645d7aea6578 Mon Sep 17 00:00:00 2001 From: redpanda-f Date: Tue, 3 Mar 2026 11:34:28 +0000 Subject: [PATCH 21/42] add: 16xlarge+gpu, 8xlarge+gpu --- .github/workflows/ci.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fb9f9ae..f97d035 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,6 +44,8 @@ jobs: fail-fast: false matrix: machine: [ + ["self-hosted", "linux", "x64", "16xlarge+gpu"], + ["self-hosted", "linux", "x64", "8xlarge+gpu"] ["self-hosted", "linux", "x64", "4xlarge+gpu"], ["self-hosted", "linux", "x64", "xlarge+gpu"], ["self-hosted", "linux", "arm64", "xlarge"], From 1758b43da887907f6b0f9bd274556a9304c2d88c Mon Sep 17 00:00:00 2001 From: redpanda-f Date: Tue, 3 Mar 2026 11:34:46 +0000 Subject: [PATCH 22/42] add: 16xlarge+gpu, 8xlarge+gpu --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f97d035..a42ac04 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -45,7 +45,7 @@ jobs: matrix: machine: [ ["self-hosted", "linux", "x64", "16xlarge+gpu"], - ["self-hosted", "linux", "x64", "8xlarge+gpu"] + ["self-hosted", "linux", "x64", "8xlarge+gpu"], ["self-hosted", "linux", "x64", "4xlarge+gpu"], ["self-hosted", "linux", "x64", "xlarge+gpu"], ["self-hosted", "linux", "arm64", "xlarge"], From b1b2d94ecdb329a6ed77ab7f779be1392433c28a Mon Sep 17 00:00:00 2001 From: redpanda-f Date: Tue, 3 Mar 2026 12:43:39 +0000 Subject: [PATCH 23/42] fix: scenarios call --- .github/workflows/ci.yml | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a42ac04..074a202 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -45,13 +45,6 @@ jobs: matrix: machine: [ ["self-hosted", "linux", "x64", "16xlarge+gpu"], - ["self-hosted", "linux", "x64", "8xlarge+gpu"], - ["self-hosted", "linux", "x64", "4xlarge+gpu"], - ["self-hosted", "linux", "x64", "xlarge+gpu"], - ["self-hosted", "linux", "arm64", "xlarge"], - ["self-hosted", "linux", "arm64", "4xlarge"], - ["self-hosted", "linux", "x64", "xlarge"], - ["self-hosted", "linux", "x64", "5xlarge"] ] runs-on: ${{ matrix.machine }} timeout-minutes: 100 @@ -398,7 +391,7 @@ jobs: # By default, don't file an issue if everything passes SKIP_REPORT_ON_PASS: ${{ inputs.skip_report_on_pass != false }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: python3 scenarios_py/run.py + run: python3 scenarios/run.py # Upload scenario report as artifact - name: "EXEC: {Upload scenario report}" From 5f874fa351ad4f6ab285ca4627f63c29b7eba0eb Mon Sep 17 00:00:00 2001 From: redpanda-f Date: Tue, 3 Mar 2026 13:11:09 +0000 Subject: [PATCH 24/42] fix: remove _py suffix --- scenarios/test_basic_balances.py | 2 +- scenarios/test_containers.py | 2 +- scenarios/test_storage_e2e.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/scenarios/test_basic_balances.py b/scenarios/test_basic_balances.py index 9328dec..58114c0 100644 --- a/scenarios/test_basic_balances.py +++ b/scenarios/test_basic_balances.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # Verifies every devnet user has a positive FIL and USDFC balance. -from scenarios_py.run import * +from scenarios.run import * def run(): ensure_foundry() diff --git a/scenarios/test_containers.py b/scenarios/test_containers.py index 3d3e814..7a9ade0 100644 --- a/scenarios/test_containers.py +++ b/scenarios/test_containers.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # Verifies all devnet containers are running and no unexpected foc-* containers exist. -from scenarios_py.run import * +from scenarios.run import * def run(): d = devnet_info()["info"] diff --git a/scenarios/test_storage_e2e.py b/scenarios/test_storage_e2e.py index 681b018..88fe328 100644 --- a/scenarios/test_storage_e2e.py +++ b/scenarios/test_storage_e2e.py @@ -10,7 +10,7 @@ # 5. USER_1 has a USDFC balance (mirrors "Checking Balances") # 6. USER_1 has deposited USDFC into FilecoinPay (mirrors "Preflight Upload Check") # 7. FWSS is set as an operator for USER_1 (mirrors allowanceCheck operator step) -from scenarios_py.run import * +from scenarios.run import * import urllib.request # ── ABI fragments used with `cast call` ────────────────────────────────────── From 84b9dd98f3d8275cc1a8519f5186b69d8ab82a6d Mon Sep 17 00:00:00 2001 From: redpanda-f Date: Thu, 5 Mar 2026 06:06:37 +0000 Subject: [PATCH 25/42] add: test_storage_e2e.py --- .github/workflows/ci.yml | 7 ++ scenarios/run.py | 12 +++ scenarios/test_storage_e2e.py | 164 ++++++++++++---------------------- 3 files changed, 78 insertions(+), 105 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 074a202..d91816b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -370,6 +370,13 @@ jobs: with: node-version: '20' + # Setup pnpm (required by scenario tests) + - name: "EXEC: {Setup pnpm}, independent" + if: steps.start_cluster.outcome == 'success' + uses: pnpm/action-setup@v4 + with: + version: latest + # Validate schema using zod - name: "CHECK: {Validate devnet-info.json schema}" if: steps.start_cluster.outcome == 'success' diff --git a/scenarios/run.py b/scenarios/run.py index bfc95e2..def5323 100755 --- a/scenarios/run.py +++ b/scenarios/run.py @@ -67,6 +67,18 @@ def sh(cmd): """Run cmd in a shell and return stdout stripped, or '' on error.""" return subprocess.run(cmd, shell=True, text=True, capture_output=True).stdout.strip() +def run_cmd(cmd: list, *, cwd=None, env=None, label: str = "", print_output: bool=False) -> bool: + """Run a subprocess command and report pass/fail; returns True on success.""" + result = subprocess.run(cmd, cwd=cwd, env=env, text=True, capture_output=True) + details = (result.stderr or result.stdout or "").strip() + if result.returncode == 0: + if print_output: + info(details) + ok(label) + return True + fail(f"{label} (exit={result.returncode}) {details}") + return False + def devnet_info(): """Load devnet-info.json as a dict.""" with open(DEVNET_INFO) as f: diff --git a/scenarios/test_storage_e2e.py b/scenarios/test_storage_e2e.py index 88fe328..c344816 100644 --- a/scenarios/test_storage_e2e.py +++ b/scenarios/test_storage_e2e.py @@ -1,116 +1,70 @@ #!/usr/bin/env python3 -# Inspired by https://raw.githubusercontent.com/FilOzone/synapse-sdk/refs/heads/master/utils/example-storage-e2e.js -# -# Verifies the devnet is ready for end-to-end FOC warm storage interactions: -# -# 1. At least one PDP service provider exists (mirrors "provider selection") -# 2. Every SP is approved in the FWSS contract (mirrors allowanceCheck) -# 3. Every SP is endorsed in the Endorsements contract -# 4. Every SP's PDP service URL is reachable -# 5. USER_1 has a USDFC balance (mirrors "Checking Balances") -# 6. USER_1 has deposited USDFC into FilecoinPay (mirrors "Preflight Upload Check") -# 7. FWSS is set as an operator for USER_1 (mirrors allowanceCheck operator step) -from scenarios.run import * -import urllib.request - -# ── ABI fragments used with `cast call` ────────────────────────────────────── -_SIG_PAYMENT_BALANCE = "balance(address)(uint256)" -_SIG_IS_OPERATOR = "isOperator(address,address)(bool)" -_SIG_SP_APPROVED = "isProviderApproved(uint256)(bool)" -_SIG_IS_ENDORSED = "isEndorsed(address)(bool)" - - -def _cast(contract: str, sig: str, *args, rpc: str) -> str: - """Call a read-only contract function via cast and return the raw output.""" - joined = " ".join(args) - return sh(f"cast call {contract} '{sig}' {joined} --rpc-url {rpc}") - - -def _check_sps(sps: list, contracts: dict, rpc: str) -> None: - """Assert each SP is approved in FWSS and endorsed in the Endorsements contract.""" - fwss = contracts["fwss_service_proxy_addr"] - endorsements = contracts["endorsements_addr"] - - assert_gt(len(sps), 0, "at least one PDP service provider exists") - - for sp in sps: - pid = sp["provider_id"] - addr = sp["eth_addr"] - - approved = _cast(fwss, _SIG_SP_APPROVED, str(pid), rpc=rpc) - assert_eq(approved.strip(), "true", f"SP {pid} is approved in FWSS") - - endorsed = _cast(endorsements, _SIG_IS_ENDORSED, addr, rpc=rpc) - assert_eq(endorsed.strip(), "true", f"SP {pid} ({addr}) is endorsed") +import os +import random +import tempfile +from pathlib import Path +from scenarios.run import * -def _check_sp_http(sps: list) -> None: - """Assert each SP's PDP service URL responds with HTTP 200.""" - for sp in sps: - pid = sp["provider_id"] - url = sp["pdp_service_url"].rstrip("/") - try: - code = urllib.request.urlopen(url, timeout=5).getcode() - assert_eq(str(code), "200", f"SP {pid} PDP service HTTP 200 at {url}") - except Exception as exc: - fail(f"SP {pid} PDP service unreachable at {url}: {exc}") - - -def _check_user_storage_readiness(users: list, contracts: dict, rpc: str) -> None: - """ - Verify USER_1 is ready for FOC warm storage uploads: - - Has a USDFC balance (can pay for storage) - - Has credited FIL into FilecoinPay (payment channel funded) - - Has approved FWSS as a payment operator - - This mirrors the Synapse SDK's preflight checks before calling upload(). - """ - usdfc = contracts["mockusdfc_addr"] - filecoin_pay = contracts["filecoin_pay_v1_addr"] - fwss = contracts["fwss_service_proxy_addr"] - - storage_users = [u for u in users if u["name"] == "USER_1"] - if not storage_users: - fail("USER_1 not found in devnet users; skipping storage readiness checks") - return - - user = storage_users[0] - addr = user["evm_addr"] - name = user["name"] - - # ── 1. USDFC wallet balance ─────────────────────────────────────────────── - raw_balance = sh( - f"cast call {usdfc} 'balanceOf(address)(uint256)' {addr} --rpc-url {rpc}" - ) - usdfc_wei = "".join(c for c in raw_balance if c.isdigit()) - assert_gt(usdfc_wei, 0, f"{name} has USDFC wallet balance > 0") +SYNAPSE_SDK_REPO = "https://github.com/FilOzone/synapse-sdk/" +SYNAPSE_SDK_REF = os.environ.get("SYNAPSE_SDK_REF", "synapse-sdk-v0.38.0") +RAND_FILE_NAME = "random_file" +RAND_FILE_SIZE = 20 * 1024 * 1024 +RAND_FILE_SEED = 42 +_RANDOM_CHUNK_SIZE = 1024 * 1024 - # ── 2. FilecoinPay deposit (payment channel credited) ──────────────────── - deposit_raw = _cast(filecoin_pay, _SIG_PAYMENT_BALANCE, addr, rpc=rpc) - deposit_wei = "".join(c for c in deposit_raw if c.isdigit()) - assert_gt(deposit_wei, 0, f"{name} has credited USDFC into FilecoinPay") - # ── 3. FWSS approved as operator in FilecoinPay ─────────────────────────── - is_op = _cast(filecoin_pay, _SIG_IS_OPERATOR, addr, fwss, rpc=rpc) - assert_eq(is_op.strip(), "true", f"{name} has FWSS set as FilecoinPay operator") +def _write_random_file(path: Path, size: int) -> None: + """Write a deterministic pseudo-random file of exactly `size` bytes.""" + rng = random.Random(RAND_FILE_SEED) + remaining = size + with path.open("wb") as fh: + while remaining > 0: + chunk = min(_RANDOM_CHUNK_SIZE, remaining) + fh.write(rng.randbytes(chunk)) + remaining -= chunk def run(): - ensure_foundry() - d = devnet_info()["info"] - rpc = d["lotus"]["host_rpc_url"] - contracts = d["contracts"] - users = d["users"] - sps = d.get("pdp_sps", []) - - info("--- Checking service providers ---") - _check_sps(sps, contracts, rpc) - - info("--- Checking SP PDP HTTP endpoints ---") - _check_sp_http(sps) - - info("--- Checking USER_1 storage readiness (mirrors Synapse SDK preflight) ---") - _check_user_storage_readiness(users, contracts, rpc) + assert_ok("command -v git", "git is installed") + assert_ok("command -v node", "node is installed") + assert_ok("command -v pnpm", "pnpm is installed") + + with tempfile.TemporaryDirectory(prefix="synapse-sdk-") as temp_dir: + sdk_dir = Path(temp_dir) / "synapse-sdk" + + info(f"--- Cloning synapse-sdk to {sdk_dir} ---") + if not run_cmd(["git", "clone", SYNAPSE_SDK_REPO, str(sdk_dir)], label="synapse-sdk cloned"): + return + + info(f"--- Checking out synapse-sdk ref {SYNAPSE_SDK_REF} ---") + if not run_cmd(["git", "checkout", SYNAPSE_SDK_REF], cwd=str(sdk_dir), label=f"synapse-sdk checked out at {SYNAPSE_SDK_REF}"): + return + + info("--- Installing synapse-sdk dependencies with pnpm ---") + if not run_cmd(["pnpm", "install"], cwd=str(sdk_dir), label="pnpm install completed"): + return + + info("--- Building synapse-sdk TypeScript packages ---") + if not run_cmd(["pnpm", "build"], cwd=str(sdk_dir), label="pnpm build completed"): + return + + random_file = sdk_dir / RAND_FILE_NAME + info(f"--- Creating random file ({RAND_FILE_SIZE} bytes) ---") + _write_random_file(random_file, RAND_FILE_SIZE) + actual_size = random_file.stat().st_size + assert_eq(actual_size, RAND_FILE_SIZE, f"{RAND_FILE_NAME} created with exact size {RAND_FILE_SIZE} bytes") + + info("--- Running Synapse SDK storage e2e script against devnet ---") + cmd_env = os.environ.copy() + cmd_env["NETWORK"] = "devnet" + run_cmd( + ["node", "utils/example-storage-e2e.js", RAND_FILE_NAME], + cwd=str(sdk_dir), + env=cmd_env, + label="NETWORK=devnet node utils/example-storage-e2e.js random_file", + print_output=True + ) if __name__ == "__main__": From dff3dbc448d0fced765502a5b0f304d6429cd94a Mon Sep 17 00:00:00 2001 From: redpanda-f Date: Thu, 5 Mar 2026 06:21:56 +0000 Subject: [PATCH 26/42] feat: report on success --- .github/workflows/ci.yml | 67 ++++++++++++++++++++++++++++++++++++++++ scenarios/run.py | 33 +++++++++++++------- 2 files changed, 89 insertions(+), 11 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d91816b..85e8e2e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,6 +20,11 @@ on: type: boolean default: true +env: + # When true, file a GitHub issue even when all scenarios pass. + # Flip this to 'false' once the suite is stable to only file on failures. + REPORT_ON_SUCCESS: 'true' + jobs: fmt-clippy: runs-on: ubuntu-latest @@ -419,3 +424,65 @@ jobs: run: | echo "Start cluster failed earlier; marking job as failed." >&2 exit 1 + + issue: + name: Issue + if: always() && github.repository_owner == 'filecoin-project' && github.event_name == 'schedule' + needs: foc-devnet-test + runs-on: ubuntu-latest + permissions: + issues: write + steps: + - name: "CHECK: {Determine if issue should be filed}" + id: should_file + run: | + FOC_DEVNET_TEST_STEP_RESULT="${{ needs.foc-devnet-test.result }}" + if [[ "$FOC_DEVNET_TEST_STEP_RESULT" == "success" ]]; then + TEST_PASSED="true" + else + TEST_PASSED="false" + fi + echo "passed=$TEST_PASSED" >> $GITHUB_OUTPUT + if [[ "$TEST_PASSED" == "true" && "$REPORT_ON_SUCCESS" != "true" ]]; then + echo "file=false" >> $GITHUB_OUTPUT + echo "Skipping issue: tests passed and REPORT_ON_SUCCESS is not 'true'" + else + echo "file=true" >> $GITHUB_OUTPUT + echo "Filing issue: test result was $FOC_DEVNET_TEST_STEP_RESULT" + fi + + - name: "EXEC: {Download scenario report}" + if: steps.should_file.outputs.file == 'true' + uses: actions/download-artifact@v4 + with: + name: scenario-report + path: /tmp/scenario-report + continue-on-error: true + + - name: "EXEC: {Read report content}" + if: steps.should_file.outputs.file == 'true' + id: report + run: | + REPORT_FILE=$(find /tmp/scenario-report -name "*.md" 2>/dev/null | head -1) + if [[ -n "$REPORT_FILE" ]]; then + CONTENT=$(cat "$REPORT_FILE") + else + CONTENT="No scenario report available." + fi + EOF=$(dd if=/dev/urandom bs=15 count=1 status=none | base64) + echo "content<<$EOF" >> $GITHUB_OUTPUT + echo "$CONTENT" >> $GITHUB_OUTPUT + echo "$EOF" >> $GITHUB_OUTPUT + + - name: "EXEC: {Create or update issue}" + if: steps.should_file.outputs.file == 'true' + uses: ipdxco/create-or-update-issue@v1 + with: + GITHUB_TOKEN: ${{ github.token }} + title: "Scenarios run ${{ needs.foc-devnet-test.result == 'success' && 'passed' || 'failed' }}" + body: | + The scenarios run **${{ needs.foc-devnet-test.result == 'success' && 'passed ✅' || 'failed ❌' }}**. + See [the workflow run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) for details. + + ${{ steps.report.outputs.content }} + label: area/nightly-tests diff --git a/scenarios/run.py b/scenarios/run.py index def5323..18efd1a 100755 --- a/scenarios/run.py +++ b/scenarios/run.py @@ -25,19 +25,23 @@ _pass = 0 _fail = 0 +_log_lines: list = [] # ── Logging ────────────────────────────────────────────────── def info(msg): + _log_lines.append(f"[INFO] {msg}") print(f"[INFO] {msg}") def ok(msg): global _pass + _log_lines.append(f"[ OK ] {msg}") print(f"[ OK ] {msg}") _pass += 1 def fail(msg): global _fail + _log_lines.append(f"[FAIL] {msg}") print(f"[FAIL] {msg}", file=sys.stderr) _fail += 1 @@ -96,13 +100,14 @@ def ensure_foundry(): # ── Runner ──────────────────────────────────────────────────── def run_tests(): - """Run scenarios in ORDER. Returns list of (name, passed, failed).""" - global _pass, _fail + """Run scenarios in ORDER. Returns list of (name, passed, failed, log_lines).""" + global _pass, _fail, _log_lines here = os.path.dirname(os.path.abspath(__file__)) results = [] for name in ORDER: path = os.path.join(here, f"{name}.py") _pass = _fail = 0 + _log_lines = [] info(f"=== {name} ===") try: spec = importlib.util.spec_from_file_location(name, path) @@ -111,17 +116,17 @@ def run_tests(): mod.run() except Exception as e: fail(f"unhandled exception: {e}") - results.append((name, _pass, _fail)) + results.append((name, _pass, _fail, list(_log_lines))) return results # ── Reporting ───────────────────────────────────────────────── def write_report(results, elapsed): - """Write a markdown report to REPORT_DIR. Returns path written.""" - total_assert_pass = sum(p for _, p, _ in results) - total_assert_fail = sum(f for _, _, f in results) + """Write a markdown report to REPORT_MD. Returns path written.""" + total_assert_pass = sum(p for _, p, _, __ in results) + total_assert_fail = sum(f for _, _, f, __ in results) total_scenarios = len(results) - scenario_pass = sum(1 for _, p, f in results if f == 0) + scenario_pass = sum(1 for _, p, f, __ in results if f == 0) scenario_fail = total_scenarios - scenario_pass with open(REPORT_MD, "w") as fh: fh.write("# Scenario Test Report\n\n") @@ -136,9 +141,15 @@ def write_report(results, elapsed): fh.write(f"| Total Scenarios | {total_scenarios} |\n| Scenarios Passed | {scenario_pass} |\n| Scenarios Failed | {scenario_fail} |\n") fh.write(f"| Total Assertions | {total_assert_pass+total_assert_fail} |\n| Assertions Passed | {total_assert_pass} |\n| Assertions Failed | {total_assert_fail} |\n| Duration | {elapsed}s |\n\n") fh.write("## Details\n\n| Status | Scenario | Passed | Failed |\n|--------|----------|--------|--------|\n") - for name, p, f in results: + for name, p, f, _ in results: icon = "✅" if f == 0 else "❌" fh.write(f"| {icon} {'PASS' if f == 0 else 'FAIL'} | {name} | {p} | {f} |\n") + fh.write("\n## Log Addendum\n\n") + for name, p, f, logs in results: + icon = "✅" if f == 0 else "❌" + fh.write(f"
\n{icon} {name}\n\n```\n") + fh.write("\n".join(logs)) + fh.write("\n```\n
\n\n") return REPORT_MD if __name__ == "__main__": @@ -146,10 +157,10 @@ def write_report(results, elapsed): results = run_tests() elapsed = int(time.time() - start) - total_assert_pass = sum(p for _, p, _ in results) - total_assert_fail = sum(f for _, _, f in results) + total_assert_pass = sum(p for _, p, _, __ in results) + total_assert_fail = sum(f for _, _, f, __ in results) total_scenarios = len(results) - scenario_pass = sum(1 for _, _, f in results if f == 0) + scenario_pass = sum(1 for _, _, f, __ in results if f == 0) scenario_fail = total_scenarios - scenario_pass print(f"\n{'='*50}") print(f"Scenarios: {total_scenarios} Passed: {scenario_pass} Failed: {scenario_fail} ({elapsed}s)") From 1a01c0e513cf0d9c32fc98d9bf65cb73bce014e7 Mon Sep 17 00:00:00 2001 From: redpanda-f Date: Thu, 5 Mar 2026 06:23:10 +0000 Subject: [PATCH 27/42] add: issue-reporting step --- .github/workflows/ci.yml | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 85e8e2e..eea779d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -425,9 +425,8 @@ jobs: echo "Start cluster failed earlier; marking job as failed." >&2 exit 1 - issue: - name: Issue - if: always() && github.repository_owner == 'filecoin-project' && github.event_name == 'schedule' + issue-reporting: + name: Issue Reporting needs: foc-devnet-test runs-on: ubuntu-latest permissions: From b72909539182a5f19f6746650e796e4258ad56ab Mon Sep 17 00:00:00 2001 From: redpanda-f Date: Thu, 5 Mar 2026 06:25:30 +0000 Subject: [PATCH 28/42] fix: issue-reporting --- .github/workflows/ci.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eea779d..e0bef23 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -427,6 +427,10 @@ jobs: issue-reporting: name: Issue Reporting + if: | + always() && + github.repository_owner == 'filecoin-project' && + (github.event_name == 'schedule' || (github.event_name == 'workflow_dispatch' && inputs.reporting == true)) needs: foc-devnet-test runs-on: ubuntu-latest permissions: From 8df47716c3613c8f473d3ddcc0c77d28e7bd5948 Mon Sep 17 00:00:00 2001 From: redpanda-f Date: Thu, 5 Mar 2026 06:27:31 +0000 Subject: [PATCH 29/42] fix: run always() --- .github/workflows/ci.yml | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e0bef23..4d9bf0b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -428,9 +428,7 @@ jobs: issue-reporting: name: Issue Reporting if: | - always() && - github.repository_owner == 'filecoin-project' && - (github.event_name == 'schedule' || (github.event_name == 'workflow_dispatch' && inputs.reporting == true)) + always() needs: foc-devnet-test runs-on: ubuntu-latest permissions: From 91861c790de94e6852e73dd658823ac0aa6e47d6 Mon Sep 17 00:00:00 2001 From: redpanda-f Date: Thu, 5 Mar 2026 06:51:31 +0000 Subject: [PATCH 30/42] feat: support for matrix --- .github/workflows/ci.yml | 33 +++++++++++++++++++-------------- 1 file changed, 19 insertions(+), 14 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4d9bf0b..65dd872 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -48,9 +48,9 @@ jobs: strategy: fail-fast: false matrix: - machine: [ - ["self-hosted", "linux", "x64", "16xlarge+gpu"], - ] + include: + - machine: ["self-hosted", "linux", "x64", "16xlarge+gpu"] + name: self-hosted-x64-16xlarge-gpu runs-on: ${{ matrix.machine }} timeout-minutes: 100 permissions: @@ -405,12 +405,12 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: python3 scenarios/run.py - # Upload scenario report as artifact + # Upload scenario report as artifact (name includes job index to avoid collisions in matrix) - name: "EXEC: {Upload scenario report}" if: always() && steps.start_cluster.outcome == 'success' uses: actions/upload-artifact@v4 with: - name: scenario-report + name: scenario-report-${{ matrix.name }} path: ~/.foc-devnet/state/latest/scenario_*.md if-no-files-found: ignore @@ -427,9 +427,8 @@ jobs: issue-reporting: name: Issue Reporting - if: | - always() - needs: foc-devnet-test + if: always() + needs: [foc-devnet-test] runs-on: ubuntu-latest permissions: issues: write @@ -452,22 +451,28 @@ jobs: echo "Filing issue: test result was $FOC_DEVNET_TEST_STEP_RESULT" fi - - name: "EXEC: {Download scenario report}" + - name: "EXEC: {Download scenario reports}" if: steps.should_file.outputs.file == 'true' uses: actions/download-artifact@v4 with: - name: scenario-report + pattern: scenario-report-* path: /tmp/scenario-report + merge-multiple: false continue-on-error: true - name: "EXEC: {Read report content}" if: steps.should_file.outputs.file == 'true' id: report run: | - REPORT_FILE=$(find /tmp/scenario-report -name "*.md" 2>/dev/null | head -1) - if [[ -n "$REPORT_FILE" ]]; then - CONTENT=$(cat "$REPORT_FILE") - else + # Each matrix variant lands in its own sub-directory under /tmp/scenario-report/, named after matrix.name + CONTENT="" + while IFS= read -r -d '' REPORT_FILE; do + JOB_DIR=$(basename "$(dirname "$REPORT_FILE")") + CONTENT+="## ${JOB_DIR}\n\n" + CONTENT+=$(cat "$REPORT_FILE") + CONTENT+="\n\n" + done < <(find /tmp/scenario-report -name "*.md" -print0 2>/dev/null | sort -z) + if [[ -z "$CONTENT" ]]; then CONTENT="No scenario report available." fi EOF=$(dd if=/dev/urandom bs=15 count=1 status=none | base64) From 3f7baa7722a2234dee4160d5897ffa5ad7d5cc60 Mon Sep 17 00:00:00 2001 From: redpanda-f Date: Thu, 5 Mar 2026 15:25:29 +0000 Subject: [PATCH 31/42] feat: add cql scenario, run calls every scenario via subprocess, simplify reporting, devnet-info has CQL port --- .github/workflows/ci.yml | 30 ++++---- scenarios/run.py | 47 +++++------- scenarios/test_basic_balances.py | 7 ++ scenarios/test_caching_subsystem.py | 115 ++++++++++++++++++++++++++++ scenarios/test_containers.py | 7 ++ scenarios/test_storage_e2e.py | 11 ++- src/external_api/devnet_info.rs | 2 + src/external_api/export.rs | 9 +++ 8 files changed, 184 insertions(+), 44 deletions(-) create mode 100644 scenarios/test_caching_subsystem.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 65dd872..5953fd2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -50,7 +50,7 @@ jobs: matrix: include: - machine: ["self-hosted", "linux", "x64", "16xlarge+gpu"] - name: self-hosted-x64-16xlarge-gpu + name: x64-16xlarge-gpu runs-on: ${{ matrix.machine }} timeout-minutes: 100 permissions: @@ -201,12 +201,12 @@ jobs: ./foc-devnet init fi - # CACHE-DOCKER: Build Docker images if not cached - - name: "EXEC: {Build Docker images}, DEP: {C-docker-images-cache}" + # CACHE-DOCKER: Save Docker images as tarballs for caching + - name: "EXEC: {Save Docker images for cache}, DEP: {C-docker-images-cache}" if: steps.cache-docker-images.outputs.cache-hit != 'true' run: |- mkdir -p ~/.docker-images-cache - echo "Building Docker images for cache..." + echo "Saving Docker images for cache..." docker save foc-lotus -o ~/.docker-images-cache/foc-lotus.tar docker save foc-lotus-miner -o ~/.docker-images-cache/foc-lotus-miner.tar docker save foc-builder -o ~/.docker-images-cache/foc-builder.tar @@ -314,8 +314,9 @@ jobs: continue-on-error: true run: ./foc-devnet start --parallel - # On failure, collect and print Docker container logs for debugging - - name: "EXEC: {Collect Docker logs on failure}, independent" + # Collect and print Docker container logs for debugging (always runs for diagnostics) + - name: "EXEC: {Collect Docker logs}, independent" + if: always() run: | RUN_DIR="$HOME/.foc-devnet/state/latest" @@ -352,9 +353,11 @@ jobs: # Verify cluster is running correctly - name: "EXEC: {Check cluster status}, independent" + if: always() run: ./foc-devnet status - name: "EXEC: {List foc-* containers}, independent" + if: always() run: | echo "Containers using foc-* images (running or exited):" docker ps -a --format 'table {{.Names}}\t{{.Image}}\t{{.Status}}' @@ -414,20 +417,21 @@ jobs: path: ~/.foc-devnet/state/latest/scenario_*.md if-no-files-found: ignore - # Clean shutdown + # Clean shutdown (always runs to avoid leaving containers behind) - name: "EXEC: {Stop cluster}, independent" + if: always() run: ./foc-devnet stop # Mark job as failed if the start step failed, but only after all steps - name: "CHECK: {Fail job if start failed}" - if: ${{ always() && steps.start_cluster.outcome == 'failure' }} + if: always() && steps.start_cluster.outcome == 'failure' run: | echo "Start cluster failed earlier; marking job as failed." >&2 exit 1 issue-reporting: name: Issue Reporting - if: always() + if: always() && (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') needs: [foc-devnet-test] runs-on: ubuntu-latest permissions: @@ -468,9 +472,9 @@ jobs: CONTENT="" while IFS= read -r -d '' REPORT_FILE; do JOB_DIR=$(basename "$(dirname "$REPORT_FILE")") - CONTENT+="## ${JOB_DIR}\n\n" + CONTENT+=$'## '"${JOB_DIR}"$'\n\n' CONTENT+=$(cat "$REPORT_FILE") - CONTENT+="\n\n" + CONTENT+=$'\n\n' done < <(find /tmp/scenario-report -name "*.md" -print0 2>/dev/null | sort -z) if [[ -z "$CONTENT" ]]; then CONTENT="No scenario report available." @@ -485,10 +489,10 @@ jobs: uses: ipdxco/create-or-update-issue@v1 with: GITHUB_TOKEN: ${{ github.token }} - title: "Scenarios run ${{ needs.foc-devnet-test.result == 'success' && 'passed' || 'failed' }}" + title: "FOC Devnet scenarios run report" body: | The scenarios run **${{ needs.foc-devnet-test.result == 'success' && 'passed ✅' || 'failed ❌' }}**. See [the workflow run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) for details. ${{ steps.report.outputs.content }} - label: area/nightly-tests + label: scenarios-run diff --git a/scenarios/run.py b/scenarios/run.py index 18efd1a..d092369 100755 --- a/scenarios/run.py +++ b/scenarios/run.py @@ -40,10 +40,12 @@ def ok(msg): _pass += 1 def fail(msg): + "fail logs a failure and exits the scenario entirely with exit code = 1" global _fail _log_lines.append(f"[FAIL] {msg}") print(f"[FAIL] {msg}", file=sys.stderr) _fail += 1 + sys.exit(1) # ── Assertions ─────────────────────────────────────────────── @@ -100,33 +102,28 @@ def ensure_foundry(): # ── Runner ──────────────────────────────────────────────────── def run_tests(): - """Run scenarios in ORDER. Returns list of (name, passed, failed, log_lines).""" - global _pass, _fail, _log_lines + """Run scenarios in ORDER. Returns list of (name, passed, log_lines).""" here = os.path.dirname(os.path.abspath(__file__)) results = [] for name in ORDER: path = os.path.join(here, f"{name}.py") - _pass = _fail = 0 - _log_lines = [] info(f"=== {name} ===") - try: - spec = importlib.util.spec_from_file_location(name, path) - mod = importlib.util.module_from_spec(spec) - spec.loader.exec_module(mod) - mod.run() - except Exception as e: - fail(f"unhandled exception: {e}") - results.append((name, _pass, _fail, list(_log_lines))) + # Run the test in a subprocess + result = subprocess.run([sys.executable, path], capture_output=True, text=True) + stdout_lines = result.stdout.strip().split('\n') if result.stdout else [] + stderr_lines = result.stderr.strip().split('\n') if result.stderr else [] + log_lines = stdout_lines + stderr_lines + # Determine pass/fail based on return code + passed = (result.returncode == 0) + results.append((name, passed, log_lines)) return results # ── Reporting ───────────────────────────────────────────────── def write_report(results, elapsed): """Write a markdown report to REPORT_MD. Returns path written.""" - total_assert_pass = sum(p for _, p, _, __ in results) - total_assert_fail = sum(f for _, _, f, __ in results) total_scenarios = len(results) - scenario_pass = sum(1 for _, p, f, __ in results if f == 0) + scenario_pass = sum(1 for _, passed, __ in results if passed) scenario_fail = total_scenarios - scenario_pass with open(REPORT_MD, "w") as fh: fh.write("# Scenario Test Report\n\n") @@ -139,15 +136,12 @@ def write_report(results, elapsed): fh.write(f"**CI Run**: [{ci_url}]({ci_url})\n\n") fh.write("| Metric | Value |\n|--------|-------|\n") fh.write(f"| Total Scenarios | {total_scenarios} |\n| Scenarios Passed | {scenario_pass} |\n| Scenarios Failed | {scenario_fail} |\n") - fh.write(f"| Total Assertions | {total_assert_pass+total_assert_fail} |\n| Assertions Passed | {total_assert_pass} |\n| Assertions Failed | {total_assert_fail} |\n| Duration | {elapsed}s |\n\n") - fh.write("## Details\n\n| Status | Scenario | Passed | Failed |\n|--------|----------|--------|--------|\n") - for name, p, f, _ in results: - icon = "✅" if f == 0 else "❌" - fh.write(f"| {icon} {'PASS' if f == 0 else 'FAIL'} | {name} | {p} | {f} |\n") - fh.write("\n## Log Addendum\n\n") - for name, p, f, logs in results: - icon = "✅" if f == 0 else "❌" - fh.write(f"
\n{icon} {name}\n\n```\n") + fh.write(f"| Duration | {elapsed}s |\n\n") + fh.write("## Test Results\n\n") + for name, passed, logs in results: + icon = "✅" if passed else "❌" + status = "PASS" if passed else "FAIL" + fh.write(f"
\n{icon} {name} - {status}\n\n```\n") fh.write("\n".join(logs)) fh.write("\n```\n
\n\n") return REPORT_MD @@ -157,14 +151,11 @@ def write_report(results, elapsed): results = run_tests() elapsed = int(time.time() - start) - total_assert_pass = sum(p for _, p, _, __ in results) - total_assert_fail = sum(f for _, _, f, __ in results) total_scenarios = len(results) - scenario_pass = sum(1 for _, _, f, __ in results if f == 0) + scenario_pass = sum(1 for _, passed, __ in results if passed) scenario_fail = total_scenarios - scenario_pass print(f"\n{'='*50}") print(f"Scenarios: {total_scenarios} Passed: {scenario_pass} Failed: {scenario_fail} ({elapsed}s)") - print(f"Assertions: {total_assert_pass+total_assert_fail} Passed: {total_assert_pass} Failed: {total_assert_fail}") report = write_report(results, elapsed) print(f"Report: {report}") diff --git a/scenarios/test_basic_balances.py b/scenarios/test_basic_balances.py index 58114c0..051ea37 100644 --- a/scenarios/test_basic_balances.py +++ b/scenarios/test_basic_balances.py @@ -1,5 +1,12 @@ #!/usr/bin/env python3 # Verifies every devnet user has a positive FIL and USDFC balance. +import os, sys + +# Ensure the project root (parent of scenarios/) is on sys.path +_project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +if _project_root not in sys.path: + sys.path.insert(0, _project_root) + from scenarios.run import * def run(): diff --git a/scenarios/test_caching_subsystem.py b/scenarios/test_caching_subsystem.py new file mode 100644 index 0000000..879d1e7 --- /dev/null +++ b/scenarios/test_caching_subsystem.py @@ -0,0 +1,115 @@ +#!/usr/bin/env python3 +""" +Caching subsystem scenario. + +Checks whether uploading a small piece does not trigger caching and +whether a larger piece does trigger caching (> 32MB). Ensures that +cassandra rows are populated. + +Standalone run: + python3 scenarios/test_caching_subsystem.py +""" +import os, sys, time, random, tempfile +from pathlib import Path + +_project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +if _project_root not in sys.path: + sys.path.insert(0, _project_root) + +from scenarios.run import * + +SYNAPSE_SDK_REPO = "https://github.com/FilOzone/synapse-sdk/" +SMALL_FILE_SIZE = 20 * 1024 * 1024 # 20MB — below 32MB threshold +LARGE_FILE_SIZE = 60 * 1024 * 1024 # 60MB — above 32MB threshold +RAND_SEED_SMALL = 42 +RAND_SEED_LARGE = 84 +CACHE_WAIT_SECS = 10 +GOCQL_ERROR = "gocql: no hosts available in the pool" +_CHUNK = 1024 * 1024 + + +def _write_random_file(path: Path, size: int, seed: int) -> None: + """Write a deterministic pseudo-random file of exactly `size` bytes.""" + rng = random.Random(seed) + remaining = size + with path.open("wb") as fh: + while remaining > 0: + chunk = min(_CHUNK, remaining) + fh.write(rng.randbytes(chunk)) + remaining -= chunk + +def _install_cqlsh(venv_dir): + """Install cqlsh into a temporary venv, return path to cqlsh binary.""" + cqlsh = os.path.join(venv_dir, "bin", "cqlsh") + info("--- Installing cqlsh into temp venv ---") + sh(f"python3 -m venv {venv_dir}") + sh(f"{venv_dir}/bin/pip install cqlsh") + assert_ok(f"test -x {cqlsh}", "cqlsh installed") + return cqlsh + + +def _ycql(cqlsh, ycql_port, query): + """Run a YCQL query on the host via cqlsh, return raw output.""" + return sh(f"{cqlsh} localhost {ycql_port} -u cassandra -p cassandra -e \"{query}\"") + + +def _upload_file(sdk_dir, filepath, label): + """Upload a single file via example-storage-e2e.js.""" + env = {**os.environ, "NETWORK": "devnet"} + run_cmd( + ["node", "utils/example-storage-e2e.js", str(filepath)], + cwd=str(sdk_dir), env=env, + label=label, print_output=True, + ) + +def _verify_cache_layer(cqlsh, ycql_port, expected_is_empty=True): + """Check pdp_cache_layer is empty due to gocql connectivity issue.""" + info("--- Querying pdp_cache_layer ---") + out = _ycql(cqlsh, ycql_port, "SELECT * FROM curio.pdp_cache_layer") + info(f"CQL SELECT access: \n {out}") + actual_is_empty = "(0 rows)" in out + assert_eq(actual_is_empty, expected_is_empty, "ysql row count") + +def run(): + assert_ok("command -v git", "git is installed") + assert_ok("command -v node", "node is installed") + assert_ok("command -v pnpm", "pnpm is installed") + + d = devnet_info()["info"] + sp = d["pdp_sps"][0] + yb = sp["yugabyte"] + ycql_port = yb["ycql_port"] + + with tempfile.TemporaryDirectory(prefix="cqlsh-venv-") as venv_dir: + cqlsh = _install_cqlsh(venv_dir) + + with tempfile.TemporaryDirectory(prefix="synapse-sdk-cache-") as tmp: + sdk_dir = Path(tmp) / "synapse-sdk" + info("--- Cloning synapse-sdk ---") + if not run_cmd(["git", "clone", SYNAPSE_SDK_REPO, str(sdk_dir)], label="clone synapse-sdk"): + return + if not run_cmd(["git", "checkout", "master"], cwd=str(sdk_dir), label="checkout master HEAD"): + return + if not run_cmd(["pnpm", "install"], cwd=str(sdk_dir), label="pnpm install"): + return + if not run_cmd(["pnpm", "build"], cwd=str(sdk_dir), label="pnpm build"): + return + + small_file = sdk_dir / "small_20mb" + large_file = sdk_dir / "large_60mb" + _write_random_file(small_file, SMALL_FILE_SIZE, RAND_SEED_SMALL) + _write_random_file(large_file, LARGE_FILE_SIZE, RAND_SEED_LARGE) + + info("--- Uploading 20MB piece (below 32MB threshold) ---") + _upload_file(sdk_dir, small_file.name, "upload 20MB piece") + info(f"--- Waiting {CACHE_WAIT_SECS}s for caching tasks ---") + time.sleep(CACHE_WAIT_SECS) + _verify_cache_layer(cqlsh, ycql_port, expected_is_empty=True) + + info("--- Uploading 60MB piece (above 32MB threshold) ---") + _upload_file(sdk_dir, large_file.name, "upload 60MB piece") + time.sleep(CACHE_WAIT_SECS) + _verify_cache_layer(cqlsh, ycql_port, expected_is_empty=False) + +if __name__ == "__main__": + run() diff --git a/scenarios/test_containers.py b/scenarios/test_containers.py index 7a9ade0..c86e5ac 100644 --- a/scenarios/test_containers.py +++ b/scenarios/test_containers.py @@ -1,5 +1,12 @@ #!/usr/bin/env python3 # Verifies all devnet containers are running and no unexpected foc-* containers exist. +import os, sys + +# Ensure the project root (parent of scenarios/) is on sys.path +_project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +if _project_root not in sys.path: + sys.path.insert(0, _project_root) + from scenarios.run import * def run(): diff --git a/scenarios/test_storage_e2e.py b/scenarios/test_storage_e2e.py index c344816..d57f838 100644 --- a/scenarios/test_storage_e2e.py +++ b/scenarios/test_storage_e2e.py @@ -1,13 +1,18 @@ #!/usr/bin/env python3 import os import random +import sys import tempfile from pathlib import Path +# Ensure the project root (parent of scenarios/) is on sys.path +_project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +if _project_root not in sys.path: + sys.path.insert(0, _project_root) + from scenarios.run import * SYNAPSE_SDK_REPO = "https://github.com/FilOzone/synapse-sdk/" -SYNAPSE_SDK_REF = os.environ.get("SYNAPSE_SDK_REF", "synapse-sdk-v0.38.0") RAND_FILE_NAME = "random_file" RAND_FILE_SIZE = 20 * 1024 * 1024 RAND_FILE_SEED = 42 @@ -37,8 +42,8 @@ def run(): if not run_cmd(["git", "clone", SYNAPSE_SDK_REPO, str(sdk_dir)], label="synapse-sdk cloned"): return - info(f"--- Checking out synapse-sdk ref {SYNAPSE_SDK_REF} ---") - if not run_cmd(["git", "checkout", SYNAPSE_SDK_REF], cwd=str(sdk_dir), label=f"synapse-sdk checked out at {SYNAPSE_SDK_REF}"): + info(f"--- Checking out synapse-sdk @ master (latest) ---") + if not run_cmd(["git", "checkout", "master"], cwd=str(sdk_dir), label=f"synapse-sdk checked out at master head"): return info("--- Installing synapse-sdk dependencies with pnpm ---") diff --git a/src/external_api/devnet_info.rs b/src/external_api/devnet_info.rs index 7adfafc..af5d415 100644 --- a/src/external_api/devnet_info.rs +++ b/src/external_api/devnet_info.rs @@ -140,4 +140,6 @@ pub struct YugabyteInfo { pub master_rpc_port: u16, /// YSQL port for Postgres-compatible connections pub ysql_port: u16, + /// YCQL port for Cassandra-compatible connections + pub ycql_port: u16, } diff --git a/src/external_api/export.rs b/src/external_api/export.rs index 6e79265..203dbba 100644 --- a/src/external_api/export.rs +++ b/src/external_api/export.rs @@ -286,10 +286,19 @@ fn build_yugabyte_info( provider_id ))?; + let ycql_port: u16 = ctx + .get(&format!("yugabyte_{}_ycql_port", provider_id)) + .and_then(|p| p.parse().ok()) + .ok_or(format!( + "yugabyte_{}_ycql_port not found or invalid in context", + provider_id + ))?; + Ok(YugabyteInfo { web_ui_url: format!("http://localhost:{}", web_ui_port), master_rpc_port, ysql_port, + ycql_port, }) } From 2e0ec3e7320450fe43c68d1e71353c46c250ec88 Mon Sep 17 00:00:00 2001 From: redpanda-f Date: Thu, 5 Mar 2026 15:35:07 +0000 Subject: [PATCH 32/42] introduce: timeouts --- scenarios/run.py | 85 ++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 67 insertions(+), 18 deletions(-) diff --git a/scenarios/run.py b/scenarios/run.py index d092369..e91679b 100755 --- a/scenarios/run.py +++ b/scenarios/run.py @@ -17,10 +17,12 @@ REPORT_MD = os.environ.get("REPORT_FILE", os.path.expanduser("~/.foc-devnet/state/latest/scenario_report.md")) # ── Scenario execution order (mirrors scenarios/order.sh) ──── +# Each entry is (test_name, timeout_seconds) ORDER = [ - "test_containers", - "test_basic_balances", - "test_storage_e2e", + ("test_containers", 5), + ("test_basic_balances", 10), + ("test_storage_e2e", 20), + ("test_caching_subsystem", 90) ] _pass = 0 @@ -102,20 +104,59 @@ def ensure_foundry(): # ── Runner ──────────────────────────────────────────────────── def run_tests(): - """Run scenarios in ORDER. Returns list of (name, passed, log_lines).""" + """Run scenarios in ORDER. Returns list of (name, passed, elapsed_time, log_lines, timed_out).""" here = os.path.dirname(os.path.abspath(__file__)) results = [] - for name in ORDER: + for name, timeout in ORDER: path = os.path.join(here, f"{name}.py") - info(f"=== {name} ===") - # Run the test in a subprocess - result = subprocess.run([sys.executable, path], capture_output=True, text=True) - stdout_lines = result.stdout.strip().split('\n') if result.stdout else [] - stderr_lines = result.stderr.strip().split('\n') if result.stderr else [] - log_lines = stdout_lines + stderr_lines - # Determine pass/fail based on return code - passed = (result.returncode == 0) - results.append((name, passed, log_lines)) + info(f"=== {name} (timeout: {timeout}s) ===") + test_start = time.time() + # Run the test in a subprocess, capturing output while also displaying it live + process = subprocess.Popen( + [sys.executable, path], + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + bufsize=1 # Line buffered + ) + log_lines = [] + timed_out = False + try: + # Read output line by line with timeout detection + while True: + # Check if timeout exceeded + if time.time() - test_start > timeout: + timed_out = True + process.kill() + timeout_msg = f"[TIMEOUT] Test exceeded {timeout}s limit" + print(timeout_msg) + log_lines.append(timeout_msg) + break + # Try to read a line (non-blocking with select would be better, but this works) + line = process.stdout.readline() + if not line: + break # EOF reached + line = line.rstrip('\n') + print(line) # Display live + log_lines.append(line) # Capture for report + # Wait for process to complete (or confirm it's dead) + try: + return_code = process.wait(timeout=5) + except subprocess.TimeoutExpired: + process.kill() + process.wait() + return_code = -1 + except Exception as e: + error_msg = f"[ERROR] Exception during test execution: {e}" + print(error_msg) + log_lines.append(error_msg) + process.kill() + process.wait() + return_code = -1 + elapsed_time = int(time.time() - test_start) + # Determine pass/fail based on return code and timeout + passed = (return_code == 0 and not timed_out) + results.append((name, passed, elapsed_time, log_lines, timed_out)) return results # ── Reporting ───────────────────────────────────────────────── @@ -123,7 +164,7 @@ def run_tests(): def write_report(results, elapsed): """Write a markdown report to REPORT_MD. Returns path written.""" total_scenarios = len(results) - scenario_pass = sum(1 for _, passed, __ in results if passed) + scenario_pass = sum(1 for _, passed, _, _, _ in results if passed) scenario_fail = total_scenarios - scenario_pass with open(REPORT_MD, "w") as fh: fh.write("# Scenario Test Report\n\n") @@ -138,9 +179,12 @@ def write_report(results, elapsed): fh.write(f"| Total Scenarios | {total_scenarios} |\n| Scenarios Passed | {scenario_pass} |\n| Scenarios Failed | {scenario_fail} |\n") fh.write(f"| Duration | {elapsed}s |\n\n") fh.write("## Test Results\n\n") - for name, passed, logs in results: + for name, passed, test_time, logs, timed_out in results: icon = "✅" if passed else "❌" - status = "PASS" if passed else "FAIL" + if timed_out: + status = f"TIMEOUT ({test_time}s)" + else: + status = f"{'PASS' if passed else 'FAIL'} ({test_time}s)" fh.write(f"
\n{icon} {name} - {status}\n\n```\n") fh.write("\n".join(logs)) fh.write("\n```\n
\n\n") @@ -152,10 +196,15 @@ def write_report(results, elapsed): elapsed = int(time.time() - start) total_scenarios = len(results) - scenario_pass = sum(1 for _, passed, __ in results if passed) + scenario_pass = sum(1 for _, passed, _, _, _ in results if passed) scenario_fail = total_scenarios - scenario_pass print(f"\n{'='*50}") print(f"Scenarios: {total_scenarios} Passed: {scenario_pass} Failed: {scenario_fail} ({elapsed}s)") + # Show individual test timings + for name, passed, test_time, _, timed_out in results: + status_icon = "✅" if passed else "❌" + status_text = "TIMEOUT" if timed_out else ("PASS" if passed else "FAIL") + print(f" {status_icon} {name}: {status_text} ({test_time}s)") report = write_report(results, elapsed) print(f"Report: {report}") From ce8886227f715ca13708da37e403fd689bb474a5 Mon Sep 17 00:00:00 2001 From: redpanda-f Date: Thu, 5 Mar 2026 15:37:10 +0000 Subject: [PATCH 33/42] increase timeout --- scenarios/run.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scenarios/run.py b/scenarios/run.py index e91679b..7c538ee 100755 --- a/scenarios/run.py +++ b/scenarios/run.py @@ -21,7 +21,7 @@ ORDER = [ ("test_containers", 5), ("test_basic_balances", 10), - ("test_storage_e2e", 20), + ("test_storage_e2e", 50), ("test_caching_subsystem", 90) ] From bbf251ef5fb987a31623388810b284c0f746b1fe Mon Sep 17 00:00:00 2001 From: redpanda-f Date: Thu, 5 Mar 2026 15:55:17 +0000 Subject: [PATCH 34/42] chore: py lint --- scenarios/run.py | 102 ++++++++++++++++++++++------ scenarios/test_basic_balances.py | 11 ++- scenarios/test_caching_subsystem.py | 38 ++++++++--- scenarios/test_containers.py | 9 ++- scenarios/test_storage_e2e.py | 28 ++++++-- 5 files changed, 144 insertions(+), 44 deletions(-) diff --git a/scenarios/run.py b/scenarios/run.py index 7c538ee..7f0e7c9 100755 --- a/scenarios/run.py +++ b/scenarios/run.py @@ -2,7 +2,11 @@ # core.py — assertions, devnet-info helpers, test runner, and reporting. # Run all tests: python3 core.py # Run one test: python3 test_containers.py -import os, sys, json, subprocess, importlib.util, time +import os +import sys +import json +import subprocess +import time # Ensure the project root (parent of scenarios_py/) is on sys.path so that # test files can do `from scenarios_py.run import *` regardless of cwd. @@ -13,8 +17,12 @@ # Allow test files to `from core import *` even when core runs as __main__. sys.modules.setdefault("core", sys.modules[__name__]) -DEVNET_INFO = os.environ.get("DEVNET_INFO", os.path.expanduser("~/.foc-devnet/state/latest/devnet-info.json")) -REPORT_MD = os.environ.get("REPORT_FILE", os.path.expanduser("~/.foc-devnet/state/latest/scenario_report.md")) +DEVNET_INFO = os.environ.get( + "DEVNET_INFO", os.path.expanduser("~/.foc-devnet/state/latest/devnet-info.json") +) +REPORT_MD = os.environ.get( + "REPORT_FILE", os.path.expanduser("~/.foc-devnet/state/latest/scenario_report.md") +) # ── Scenario execution order (mirrors scenarios/order.sh) ──── # Each entry is (test_name, timeout_seconds) @@ -22,7 +30,7 @@ ("test_containers", 5), ("test_basic_balances", 10), ("test_storage_e2e", 50), - ("test_caching_subsystem", 90) + ("test_caching_subsystem", 90), ] _pass = 0 @@ -31,16 +39,19 @@ # ── Logging ────────────────────────────────────────────────── + def info(msg): _log_lines.append(f"[INFO] {msg}") print(f"[INFO] {msg}") + def ok(msg): global _pass _log_lines.append(f"[ OK ] {msg}") print(f"[ OK ] {msg}") _pass += 1 + def fail(msg): "fail logs a failure and exits the scenario entirely with exit code = 1" global _fail @@ -49,33 +60,59 @@ def fail(msg): _fail += 1 sys.exit(1) + # ── Assertions ─────────────────────────────────────────────── + def assert_eq(a, b, msg): - if a == b: ok(msg) - else: fail(f"{msg} (got '{a}', want '{b}')") + if a == b: + ok(msg) + else: + fail(f"{msg} (got '{a}', want '{b}')") + def assert_gt(a, b, msg): try: - if int(a) > int(b): ok(msg) - else: fail(f"{msg} (got '{a}', want > '{b}')") - except: fail(f"{msg} (not an int: '{a}')") + if int(a) > int(b): + ok(msg) + else: + fail(f"{msg} (got '{a}', want > '{b}')") + except: + fail(f"{msg} (not an int: '{a}')") + def assert_not_empty(v, msg): - if v: ok(msg) - else: fail(f"{msg} (empty)") + if v: + ok(msg) + else: + fail(f"{msg} (empty)") + def assert_ok(cmd, msg): - if subprocess.call(cmd, shell=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) == 0: ok(msg) - else: fail(msg) + if ( + subprocess.call( + cmd, shell=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL + ) + == 0 + ): + ok(msg) + else: + fail(msg) + # ── Shell helpers ───────────────────────────────────────────── + def sh(cmd): """Run cmd in a shell and return stdout stripped, or '' on error.""" - return subprocess.run(cmd, shell=True, text=True, capture_output=True).stdout.strip() + return subprocess.run( + cmd, shell=True, text=True, capture_output=True + ).stdout.strip() -def run_cmd(cmd: list, *, cwd=None, env=None, label: str = "", print_output: bool=False) -> bool: + +def run_cmd( + cmd: list, *, cwd=None, env=None, label: str = "", print_output: bool = False +) -> bool: """Run a subprocess command and report pass/fail; returns True on success.""" result = subprocess.run(cmd, cwd=cwd, env=env, text=True, capture_output=True) details = (result.stderr or result.stdout or "").strip() @@ -87,22 +124,34 @@ def run_cmd(cmd: list, *, cwd=None, env=None, label: str = "", print_output: boo fail(f"{label} (exit={result.returncode}) {details}") return False + def devnet_info(): """Load devnet-info.json as a dict.""" with open(DEVNET_INFO) as f: return json.load(f) + def ensure_foundry(): """Install Foundry if cast is not on PATH.""" - if subprocess.call("command -v cast", shell=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) != 0: + if ( + subprocess.call( + "command -v cast", + shell=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + != 0 + ): info("Installing Foundry...") os.system("curl -sSL https://foundry.paradigm.xyz | bash") os.environ["PATH"] = os.path.expanduser("~/.foundry/bin:") + os.environ["PATH"] os.system(os.path.expanduser("~/.foundry/bin/foundryup")) assert_ok("command -v cast", "cast is installed") + # ── Runner ──────────────────────────────────────────────────── + def run_tests(): """Run scenarios in ORDER. Returns list of (name, passed, elapsed_time, log_lines, timed_out).""" here = os.path.dirname(os.path.abspath(__file__)) @@ -117,7 +166,7 @@ def run_tests(): stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, - bufsize=1 # Line buffered + bufsize=1, # Line buffered ) log_lines = [] timed_out = False @@ -136,7 +185,7 @@ def run_tests(): line = process.stdout.readline() if not line: break # EOF reached - line = line.rstrip('\n') + line = line.rstrip("\n") print(line) # Display live log_lines.append(line) # Capture for report # Wait for process to complete (or confirm it's dead) @@ -155,12 +204,14 @@ def run_tests(): return_code = -1 elapsed_time = int(time.time() - test_start) # Determine pass/fail based on return code and timeout - passed = (return_code == 0 and not timed_out) + passed = return_code == 0 and not timed_out results.append((name, passed, elapsed_time, log_lines, timed_out)) return results + # ── Reporting ───────────────────────────────────────────────── + def write_report(results, elapsed): """Write a markdown report to REPORT_MD. Returns path written.""" total_scenarios = len(results) @@ -176,7 +227,9 @@ def write_report(results, elapsed): ci_url = f"{github_server}/{github_repo}/actions/runs/{github_run_id}" fh.write(f"**CI Run**: [{ci_url}]({ci_url})\n\n") fh.write("| Metric | Value |\n|--------|-------|\n") - fh.write(f"| Total Scenarios | {total_scenarios} |\n| Scenarios Passed | {scenario_pass} |\n| Scenarios Failed | {scenario_fail} |\n") + fh.write( + f"| Total Scenarios | {total_scenarios} |\n| Scenarios Passed | {scenario_pass} |\n| Scenarios Failed | {scenario_fail} |\n" + ) fh.write(f"| Duration | {elapsed}s |\n\n") fh.write("## Test Results\n\n") for name, passed, test_time, logs, timed_out in results: @@ -185,11 +238,14 @@ def write_report(results, elapsed): status = f"TIMEOUT ({test_time}s)" else: status = f"{'PASS' if passed else 'FAIL'} ({test_time}s)" - fh.write(f"
\n{icon} {name} - {status}\n\n```\n") + fh.write( + f"
\n{icon} {name} - {status}\n\n```\n" + ) fh.write("\n".join(logs)) fh.write("\n```\n
\n\n") return REPORT_MD + if __name__ == "__main__": start = time.time() results = run_tests() @@ -199,7 +255,9 @@ def write_report(results, elapsed): scenario_pass = sum(1 for _, passed, _, _, _ in results if passed) scenario_fail = total_scenarios - scenario_pass print(f"\n{'='*50}") - print(f"Scenarios: {total_scenarios} Passed: {scenario_pass} Failed: {scenario_fail} ({elapsed}s)") + print( + f"Scenarios: {total_scenarios} Passed: {scenario_pass} Failed: {scenario_fail} ({elapsed}s)" + ) # Show individual test timings for name, passed, test_time, _, timed_out in results: status_icon = "✅" if passed else "❌" diff --git a/scenarios/test_basic_balances.py b/scenarios/test_basic_balances.py index 051ea37..d928152 100644 --- a/scenarios/test_basic_balances.py +++ b/scenarios/test_basic_balances.py @@ -1,6 +1,7 @@ #!/usr/bin/env python3 # Verifies every devnet user has a positive FIL and USDFC balance. -import os, sys +import os +import sys # Ensure the project root (parent of scenarios/) is on sys.path _project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) @@ -9,10 +10,11 @@ from scenarios.run import * + def run(): ensure_foundry() d = devnet_info()["info"] - lotus_rpc = d["lotus"]["host_rpc_url"] + lotus_rpc = d["lotus"]["host_rpc_url"] usdfc_addr = d["contracts"]["mockusdfc_addr"] users = d["users"] assert_gt(len(users), 0, "at least one user exists") @@ -21,9 +23,12 @@ def run(): name, user_addr = user["name"], user["evm_addr"] fil_wei = sh(f"cast balance {user_addr} --rpc-url {lotus_rpc}") assert_gt(fil_wei, 0, f"{name} FIL balance > 0") - usdfc_raw = sh(f"cast call {usdfc_addr} 'balanceOf(address)(uint256)' {user_addr} --rpc-url {lotus_rpc}") + usdfc_raw = sh( + f"cast call {usdfc_addr} 'balanceOf(address)(uint256)' {user_addr} --rpc-url {lotus_rpc}" + ) usdfc_wei = "".join(c for c in usdfc_raw if c.isdigit()) assert_gt(usdfc_wei, 0, f"{name} USDFC balance > 0") + if __name__ == "__main__": run() diff --git a/scenarios/test_caching_subsystem.py b/scenarios/test_caching_subsystem.py index 879d1e7..96aaaf4 100644 --- a/scenarios/test_caching_subsystem.py +++ b/scenarios/test_caching_subsystem.py @@ -3,13 +3,18 @@ Caching subsystem scenario. Checks whether uploading a small piece does not trigger caching and -whether a larger piece does trigger caching (> 32MB). Ensures that +whether a larger piece does trigger caching (> 32MB). Ensures that cassandra rows are populated. Standalone run: python3 scenarios/test_caching_subsystem.py """ -import os, sys, time, random, tempfile + +import os +import sys +import time +import random +import tempfile from pathlib import Path _project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) @@ -19,8 +24,8 @@ from scenarios.run import * SYNAPSE_SDK_REPO = "https://github.com/FilOzone/synapse-sdk/" -SMALL_FILE_SIZE = 20 * 1024 * 1024 # 20MB — below 32MB threshold -LARGE_FILE_SIZE = 60 * 1024 * 1024 # 60MB — above 32MB threshold +SMALL_FILE_SIZE = 20 * 1024 * 1024 # 20MB — below 32MB threshold +LARGE_FILE_SIZE = 60 * 1024 * 1024 # 60MB — above 32MB threshold RAND_SEED_SMALL = 42 RAND_SEED_LARGE = 84 CACHE_WAIT_SECS = 10 @@ -38,6 +43,7 @@ def _write_random_file(path: Path, size: int, seed: int) -> None: fh.write(rng.randbytes(chunk)) remaining -= chunk + def _install_cqlsh(venv_dir): """Install cqlsh into a temporary venv, return path to cqlsh binary.""" cqlsh = os.path.join(venv_dir, "bin", "cqlsh") @@ -50,7 +56,7 @@ def _install_cqlsh(venv_dir): def _ycql(cqlsh, ycql_port, query): """Run a YCQL query on the host via cqlsh, return raw output.""" - return sh(f"{cqlsh} localhost {ycql_port} -u cassandra -p cassandra -e \"{query}\"") + return sh(f'{cqlsh} localhost {ycql_port} -u cassandra -p cassandra -e "{query}"') def _upload_file(sdk_dir, filepath, label): @@ -58,18 +64,22 @@ def _upload_file(sdk_dir, filepath, label): env = {**os.environ, "NETWORK": "devnet"} run_cmd( ["node", "utils/example-storage-e2e.js", str(filepath)], - cwd=str(sdk_dir), env=env, - label=label, print_output=True, + cwd=str(sdk_dir), + env=env, + label=label, + print_output=True, ) + def _verify_cache_layer(cqlsh, ycql_port, expected_is_empty=True): """Check pdp_cache_layer is empty due to gocql connectivity issue.""" info("--- Querying pdp_cache_layer ---") out = _ycql(cqlsh, ycql_port, "SELECT * FROM curio.pdp_cache_layer") info(f"CQL SELECT access: \n {out}") - actual_is_empty = "(0 rows)" in out + actual_is_empty = "(0 rows)" in out assert_eq(actual_is_empty, expected_is_empty, "ysql row count") + def run(): assert_ok("command -v git", "git is installed") assert_ok("command -v node", "node is installed") @@ -86,9 +96,16 @@ def run(): with tempfile.TemporaryDirectory(prefix="synapse-sdk-cache-") as tmp: sdk_dir = Path(tmp) / "synapse-sdk" info("--- Cloning synapse-sdk ---") - if not run_cmd(["git", "clone", SYNAPSE_SDK_REPO, str(sdk_dir)], label="clone synapse-sdk"): + if not run_cmd( + ["git", "clone", SYNAPSE_SDK_REPO, str(sdk_dir)], + label="clone synapse-sdk", + ): return - if not run_cmd(["git", "checkout", "master"], cwd=str(sdk_dir), label="checkout master HEAD"): + if not run_cmd( + ["git", "checkout", "master"], + cwd=str(sdk_dir), + label="checkout master HEAD", + ): return if not run_cmd(["pnpm", "install"], cwd=str(sdk_dir), label="pnpm install"): return @@ -111,5 +128,6 @@ def run(): time.sleep(CACHE_WAIT_SECS) _verify_cache_layer(cqlsh, ycql_port, expected_is_empty=False) + if __name__ == "__main__": run() diff --git a/scenarios/test_containers.py b/scenarios/test_containers.py index c86e5ac..b322768 100644 --- a/scenarios/test_containers.py +++ b/scenarios/test_containers.py @@ -1,6 +1,7 @@ #!/usr/bin/env python3 # Verifies all devnet containers are running and no unexpected foc-* containers exist. -import os, sys +import os +import sys # Ensure the project root (parent of scenarios/) is on sys.path _project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) @@ -9,6 +10,7 @@ from scenarios.run import * + def run(): d = devnet_info()["info"] run_id = d.get("run_id", "") @@ -19,8 +21,11 @@ def run(): expected.append(sp["container_name"]) for name in expected: - status = sh(f"docker inspect -f '{{{{.State.Status}}}}' {name} 2>/dev/null || echo missing") + status = sh( + f"docker inspect -f '{{{{.State.Status}}}}' {name} 2>/dev/null || echo missing" + ) assert_eq(status, "running", f"container {name} is running") + if __name__ == "__main__": run() diff --git a/scenarios/test_storage_e2e.py b/scenarios/test_storage_e2e.py index d57f838..3b068ca 100644 --- a/scenarios/test_storage_e2e.py +++ b/scenarios/test_storage_e2e.py @@ -39,26 +39,40 @@ def run(): sdk_dir = Path(temp_dir) / "synapse-sdk" info(f"--- Cloning synapse-sdk to {sdk_dir} ---") - if not run_cmd(["git", "clone", SYNAPSE_SDK_REPO, str(sdk_dir)], label="synapse-sdk cloned"): + if not run_cmd( + ["git", "clone", SYNAPSE_SDK_REPO, str(sdk_dir)], label="synapse-sdk cloned" + ): return - info(f"--- Checking out synapse-sdk @ master (latest) ---") - if not run_cmd(["git", "checkout", "master"], cwd=str(sdk_dir), label=f"synapse-sdk checked out at master head"): + info("--- Checking out synapse-sdk @ master (latest) ---") + if not run_cmd( + ["git", "checkout", "master"], + cwd=str(sdk_dir), + label="synapse-sdk checked out at master head", + ): return info("--- Installing synapse-sdk dependencies with pnpm ---") - if not run_cmd(["pnpm", "install"], cwd=str(sdk_dir), label="pnpm install completed"): + if not run_cmd( + ["pnpm", "install"], cwd=str(sdk_dir), label="pnpm install completed" + ): return info("--- Building synapse-sdk TypeScript packages ---") - if not run_cmd(["pnpm", "build"], cwd=str(sdk_dir), label="pnpm build completed"): + if not run_cmd( + ["pnpm", "build"], cwd=str(sdk_dir), label="pnpm build completed" + ): return random_file = sdk_dir / RAND_FILE_NAME info(f"--- Creating random file ({RAND_FILE_SIZE} bytes) ---") _write_random_file(random_file, RAND_FILE_SIZE) actual_size = random_file.stat().st_size - assert_eq(actual_size, RAND_FILE_SIZE, f"{RAND_FILE_NAME} created with exact size {RAND_FILE_SIZE} bytes") + assert_eq( + actual_size, + RAND_FILE_SIZE, + f"{RAND_FILE_NAME} created with exact size {RAND_FILE_SIZE} bytes", + ) info("--- Running Synapse SDK storage e2e script against devnet ---") cmd_env = os.environ.copy() @@ -68,7 +82,7 @@ def run(): cwd=str(sdk_dir), env=cmd_env, label="NETWORK=devnet node utils/example-storage-e2e.js random_file", - print_output=True + print_output=True, ) From fa09446b737fa4cc4504ddc11372c571209ba3a4 Mon Sep 17 00:00:00 2001 From: redpanda-f Date: Thu, 5 Mar 2026 15:55:41 +0000 Subject: [PATCH 35/42] chore: scripts for linting, pre-commit hooks --- .githooks/install.sh | 7 -- .githooks/pre-commit | 68 ++++-------------- README.md | 12 ++++ scripts/install_precommit_hooks.sh | 55 ++++++++++++++ scripts/lint.sh | 112 +++++++++++++++++++++++++++++ 5 files changed, 193 insertions(+), 61 deletions(-) delete mode 100755 .githooks/install.sh create mode 100755 scripts/install_precommit_hooks.sh create mode 100755 scripts/lint.sh diff --git a/.githooks/install.sh b/.githooks/install.sh deleted file mode 100755 index f516fca..0000000 --- a/.githooks/install.sh +++ /dev/null @@ -1,7 +0,0 @@ -#!/usr/bin/env bash -# Installs the repo's git hooks by pointing core.hooksPath at .githooks/ -set -euo pipefail -REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" -git -C "$REPO_ROOT" config core.hooksPath .githooks -chmod +x "$REPO_ROOT"/.githooks/* -echo "✓ Git hooks installed (.githooks/)" diff --git a/.githooks/pre-commit b/.githooks/pre-commit index 68da182..e7ff511 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -1,65 +1,25 @@ #!/usr/bin/env bash # ───────────────────────────────────────────────────────────── -# pre-commit hook for foc-devnet +# Pre-commit hook for foc-devnet # -# Checks staged files before each commit: -# - Rust: cargo fmt, cargo clippy -# - Shell: shfmt, shellcheck, executable bit -# -# Install: bash .githooks/install.sh -# Skip once: git commit --no-verify -# -# Auto-fix mode (formats code and re-stages): -# FIX=1 git commit +# Runs lint.sh in check mode before each commit. +# Skip with: git commit --no-verify +# Auto-fix mode: FIX=1 git commit # ───────────────────────────────────────────────────────────── set -euo pipefail FIX="${FIX:-0}" -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[0;33m' -BLUE='\033[0;34m' -NC='\033[0m' - -FAIL=0 - -pass() { printf "${GREEN}✓${NC} %s\n" "$1"; } -fail() { printf "${RED}✗${NC} %s\n" "$1"; FAIL=1; } -skip() { printf "${YELLOW}⊘${NC} %s (skipped — tool not found)\n" "$1"; } -fixed() { printf "${BLUE}⟳${NC} %s (auto-fixed & re-staged)\n" "$1"; } +REPO_ROOT="$(git rev-parse --show-toplevel)" -# Collect staged files -STAGED=$(git diff --cached --name-only --diff-filter=ACM) - -# ── Rust checks ────────────────────────────────────────────── -STAGED_RS=$(echo "$STAGED" | grep '\.rs$' || true) -HAS_RS=$(echo "$STAGED_RS" | grep -c '\.rs$' || true) -if [[ $HAS_RS -gt 0 ]]; then - if command -v cargo &>/dev/null; then - if cargo fmt --all -- --check &>/dev/null; then - pass "cargo fmt" - elif [[ "$FIX" == "1" ]]; then - cargo fmt --all - echo "$STAGED_RS" | xargs -r git add - fixed "cargo fmt" - else - fail "cargo fmt — run 'cargo fmt' or FIX=1 git commit" - fi - - if cargo clippy --all-targets --all-features -- -D warnings &>/dev/null; then - pass "cargo clippy" - else - fail "cargo clippy — fix warnings before committing" - fi - else - skip "cargo (Rust checks)" - fi -fi +echo "Running pre-commit linting..." +echo "" -# ── Result ─────────────────────────────────────────────────── -if [[ $FAIL -ne 0 ]]; then - echo "" - printf "${RED}Pre-commit checks failed.${NC} Fix issues above or skip with: git commit --no-verify\n" - exit 1 +if FIX="$FIX" "$REPO_ROOT/scripts/lint.sh"; then + exit 0 +else + echo "" + echo "Pre-commit hook failed. To skip this hook, use: git commit --no-verify" + echo "To auto-fix issues, use: FIX=1 git commit" + exit 1 fi diff --git a/README.md b/README.md index 06126cb..5afce1f 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,18 @@ For GitHub Actions, add this step before running foc-devnet: - run: echo '127.0.0.1 host.docker.internal' | sudo tee -a /etc/hosts ``` +(Optional) Additionally, you may want to get linters for python scenarios, and install pre-commit hooks for development: +```sh +sudo apt install pipx +pipx ensurepath + +# Install linting tools +pipx install black + +# Install pre-commit hooks +./scripts/install_precommit_hooks.sh +``` + ### Step 1: Initialize ```bash diff --git a/scripts/install_precommit_hooks.sh b/scripts/install_precommit_hooks.sh new file mode 100755 index 0000000..62ea193 --- /dev/null +++ b/scripts/install_precommit_hooks.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +# ───────────────────────────────────────────────────────────── +# install_precommit_hooks.sh — Install pre-commit hooks +# +# This script installs a pre-commit hook that runs lint.sh +# in check mode (FIX=0) before each commit. +# +# Usage: +# ./scripts/install_precommit_hooks.sh +# ───────────────────────────────────────────────────────────── +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" + +cd "$REPO_ROOT" + +# Get the actual git hooks directory (works for both regular repos and worktrees) +GIT_HOOKS_DIR="$(git rev-parse --git-path hooks)" +PRE_COMMIT_HOOK="$GIT_HOOKS_DIR/pre-commit" + +# Ensure hooks directory exists +mkdir -p "$GIT_HOOKS_DIR" + +# Create the pre-commit hook +cat > "$PRE_COMMIT_HOOK" << 'EOF' +#!/usr/bin/env bash +# ───────────────────────────────────────────────────────────── +# Pre-commit hook for foc-devnet +# +# Runs lint.sh in check mode before each commit. +# Skip with: git commit --no-verify +# Auto-fix mode: FIX=1 git commit +# ───────────────────────────────────────────────────────────── +set -euo pipefail + +FIX="${FIX:-0}" + +REPO_ROOT="$(git rev-parse --show-toplevel)" + +echo "Running pre-commit linting..." +echo "" + +if FIX="$FIX" "$REPO_ROOT/scripts/lint.sh"; then + exit 0 +else + echo "" + echo "Pre-commit hook failed. To skip this hook, use: git commit --no-verify" + echo "To auto-fix issues, use: FIX=1 git commit" + exit 1 +fi +EOF + +# Make the hook executable +chmod +x "$PRE_COMMIT_HOOK" diff --git a/scripts/lint.sh b/scripts/lint.sh new file mode 100755 index 0000000..b4f77d9 --- /dev/null +++ b/scripts/lint.sh @@ -0,0 +1,112 @@ +#!/usr/bin/env bash +# ───────────────────────────────────────────────────────────── +# lint.sh — Unified linting script for foc-devnet +# +# Runs linters and formatters for Rust and Python code. +# Designed to work both locally and in CI. +# +# Modes: +# FIX=1 (default) — Auto-fix issues where possible +# FIX=0 — Check only, fail on issues +# +# Usage: +# ./scripts/lint.sh # Fix mode +# FIX=0 ./scripts/lint.sh # Check mode (CI) +# +# Requirements: +# Rust: cargo, rustfmt, clippy +# Python: black, ruff (or pip install black ruff) +# ───────────────────────────────────────────────────────────── +set -euo pipefail + +FIX="${FIX:-1}" + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[0;33m' +BLUE='\033[0;34m' +NC='\033[0m' + +FAIL=0 + +pass() { printf "${GREEN}✓${NC} %s\n" "$1"; } +fail() { printf "${RED}✗${NC} %s\n" "$1"; FAIL=1; } +skip() { printf "${YELLOW}⊘${NC} %s (skipped — tool not found)\n" "$1"; } +fixed() { printf "${BLUE}⟳${NC} %s (auto-fixed)\n" "$1"; } +info() { printf "${BLUE}ℹ${NC} %s\n" "$1"; } + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" + +cd "$REPO_ROOT" + +info "Checking Rust code..." + +if command -v cargo &>/dev/null; then + # ── cargo fmt ── + if [[ "$FIX" == "1" ]]; then + if cargo fmt --all; then + fixed "cargo fmt" + else + fail "cargo fmt failed" + fi + else + if cargo fmt --all -- --check &>/dev/null; then + pass "cargo fmt" + else + fail "cargo fmt — run './scripts/lint.sh' or 'cargo fmt --all' to fix" + fi + fi + + # ── cargo clippy ── + if cargo clippy --all-targets --all-features -- -D warnings &>/dev/null; then + pass "cargo clippy" + else + fail "cargo clippy — fix warnings before committing" + fi +else + skip "cargo (Rust checks)" +fi + +echo "" + +info "Checking Python code in scenarios/..." + +PYTHON_FILES=$(find scenarios -name '*.py' 2>/dev/null || true) + +if [[ -z "$PYTHON_FILES" ]]; then + skip "Python files (none found in scenarios/)" +else + # ── black (formatter) ── + if command -v black &>/dev/null; then + if [[ "$FIX" == "1" ]]; then + if black scenarios/ &>/dev/null; then + fixed "black (Python formatter)" + else + fail "black failed" + fi + else + if black --check scenarios/ &>/dev/null; then + pass "black (Python formatter)" + else + fail "black — run './scripts/lint.sh' or 'black scenarios/' to fix" + fi + fi + else + skip "black (install with: pip install black)" + fi +fi + +echo "" + +echo "════════════════════════════════════════════════════════" +if [[ $FAIL -ne 0 ]]; then + printf "${RED}✗ Linting failed.${NC}\n" + if [[ "$FIX" == "0" ]]; then + echo " Run './scripts/lint.sh' (FIX=1 mode) to auto-fix issues." + fi + exit 1 +else + printf "${GREEN}✓ All linting checks passed.${NC}\n" +fi +echo "════════════════════════════════════════════════════════" From 208097ea1da7c67d79f478236eb39db873045ca1 Mon Sep 17 00:00:00 2001 From: redpanda-f Date: Thu, 5 Mar 2026 16:00:06 +0000 Subject: [PATCH 36/42] chore: CI uses lint.sh --- .github/workflows/ci.yml | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5953fd2..40a7002 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,7 +26,7 @@ env: REPORT_ON_SUCCESS: 'true' jobs: - fmt-clippy: + lint: runs-on: ubuntu-latest timeout-minutes: 10 @@ -38,11 +38,19 @@ jobs: with: components: rustfmt, clippy - - name: Check formatting - run: cargo fmt --all -- --check + - name: Setup Python tools + run: | + sudo apt-get update + sudo apt-get install -y pipx + pipx ensurepath + pipx install black + pipx install ruff + env: + PIPX_HOME: /opt/pipx + PIPX_BIN_DIR: /usr/local/bin - - name: Run clippy - run: cargo clippy --all-targets --all-features -- -D warnings + - name: Run linting (check mode) + run: FIX=0 ./scripts/lint.sh foc-devnet-test: strategy: From 46564a545e61e906d0034350f6fbd600c0bc8556 Mon Sep 17 00:00:00 2001 From: redpanda-f Date: Thu, 5 Mar 2026 16:07:51 +0000 Subject: [PATCH 37/42] chore: simplify install_precommit_hooks.sh --- scripts/install_precommit_hooks.sh | 22 ++-------------------- 1 file changed, 2 insertions(+), 20 deletions(-) diff --git a/scripts/install_precommit_hooks.sh b/scripts/install_precommit_hooks.sh index 62ea193..0a6080f 100755 --- a/scripts/install_precommit_hooks.sh +++ b/scripts/install_precommit_hooks.sh @@ -25,30 +25,12 @@ mkdir -p "$GIT_HOOKS_DIR" # Create the pre-commit hook cat > "$PRE_COMMIT_HOOK" << 'EOF' #!/usr/bin/env bash -# ───────────────────────────────────────────────────────────── -# Pre-commit hook for foc-devnet -# -# Runs lint.sh in check mode before each commit. -# Skip with: git commit --no-verify -# Auto-fix mode: FIX=1 git commit -# ───────────────────────────────────────────────────────────── set -euo pipefail -FIX="${FIX:-0}" - +FIX="${FIX:-1}" REPO_ROOT="$(git rev-parse --show-toplevel)" -echo "Running pre-commit linting..." -echo "" - -if FIX="$FIX" "$REPO_ROOT/scripts/lint.sh"; then - exit 0 -else - echo "" - echo "Pre-commit hook failed. To skip this hook, use: git commit --no-verify" - echo "To auto-fix issues, use: FIX=1 git commit" - exit 1 -fi +$REPO_ROOT/scripts/lint.sh EOF # Make the hook executable From cc8946e656eeff603906d2938e52702acc7e4171 Mon Sep 17 00:00:00 2001 From: redpanda-f Date: Thu, 5 Mar 2026 16:08:59 +0000 Subject: [PATCH 38/42] fix: .gitignore --- .gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index e0a6442..7717af4 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,5 @@ contracts/MockUSDFC/lib/ contracts/MockUSDFC/broadcast/ artifacts/ .vscode/ -*__pycache__/ \ No newline at end of file +*__pycache__/ +.githooks \ No newline at end of file From 8883484984865fc53d98c1e5cc037d4da6e88648 Mon Sep 17 00:00:00 2001 From: redpanda-f Date: Thu, 5 Mar 2026 17:11:26 +0000 Subject: [PATCH 39/42] fix: CI --- .githooks/pre-commit | 22 +---- .github/workflows/ci.yml | 107 +++++++++++++----------- scenarios/run.py | 128 +++++++++++++++++++++++------ src/commands/start/yugabyte/mod.rs | 1 - 4 files changed, 168 insertions(+), 90 deletions(-) diff --git a/.githooks/pre-commit b/.githooks/pre-commit index e7ff511..fd06794 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -1,25 +1,7 @@ #!/usr/bin/env bash -# ───────────────────────────────────────────────────────────── -# Pre-commit hook for foc-devnet -# -# Runs lint.sh in check mode before each commit. -# Skip with: git commit --no-verify -# Auto-fix mode: FIX=1 git commit -# ───────────────────────────────────────────────────────────── set -euo pipefail -FIX="${FIX:-0}" - +FIX="${FIX:-1}" REPO_ROOT="$(git rev-parse --show-toplevel)" -echo "Running pre-commit linting..." -echo "" - -if FIX="$FIX" "$REPO_ROOT/scripts/lint.sh"; then - exit 0 -else - echo "" - echo "Pre-commit hook failed. To skip this hook, use: git commit --no-verify" - echo "To auto-fix issues, use: FIX=1 git commit" - exit 1 -fi +$REPO_ROOT/scripts/lint.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 40a7002..1b3af03 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,12 +42,9 @@ jobs: run: | sudo apt-get update sudo apt-get install -y pipx - pipx ensurepath pipx install black pipx install ruff - env: - PIPX_HOME: /opt/pipx - PIPX_BIN_DIR: /usr/local/bin + echo "$HOME/.local/bin" >> $GITHUB_PATH - name: Run linting (check mode) run: FIX=0 ./scripts/lint.sh @@ -55,11 +52,14 @@ jobs: foc-devnet-test: strategy: fail-fast: false + max-parallel: 1 matrix: include: - - machine: ["self-hosted", "linux", "x64", "16xlarge+gpu"] - name: x64-16xlarge-gpu - runs-on: ${{ matrix.machine }} + - name: latesttag + init_flags: "--lotus latestTag --curio latestTag --filecoin-services latestTag" + - name: latestcommit + init_flags: "--lotus latestTag --curio gitbranch:pdpv0 --filecoin-services gitbranch:main" + runs-on: ["self-hosted", "linux", "x64", "16xlarge+gpu"] timeout-minutes: 100 permissions: contents: read @@ -186,28 +186,14 @@ jobs: if: steps.cache-docker-images.outputs.cache-hit == 'true' run: | rm -rf ~/.foc-devnet - if [[ "${{ github.event_name }}" == "schedule" ]]; then - ./foc-devnet init --no-docker-build \ - --curio latestCommit \ - --filecoin-services latestCommit \ - --lotus latestTag - else - ./foc-devnet init --no-docker-build - fi + ./foc-devnet init --no-docker-build ${{ matrix.init_flags }} # If Docker images are not cached, do full init (downloads YugabyteDB and builds all images) - name: "EXEC: {Initialize without cache}, independent" if: steps.cache-docker-images.outputs.cache-hit != 'true' run: | rm -rf ~/.foc-devnet - if [[ "${{ github.event_name }}" == "schedule" ]]; then - ./foc-devnet init \ - --curio latestCommit \ - --filecoin-services latestCommit \ - --lotus latestTag - else - ./foc-devnet init - fi + ./foc-devnet init ${{ matrix.init_flags }} # CACHE-DOCKER: Save Docker images as tarballs for caching - name: "EXEC: {Save Docker images for cache}, DEP: {C-docker-images-cache}" @@ -238,7 +224,7 @@ jobs: uses: actions/cache/restore@v4 with: path: ~/.foc-devnet/bin - key: ${{ runner.os }}-binaries-${{ steps.version-hashes.outputs.code-hash }} + key: ${{ runner.os }}-binaries-${{ matrix.name }}-${{ steps.version-hashes.outputs.code-hash }} - name: "EXEC: {Ensure permissions on binaries}, DEP: {C-build-artifacts-cache}" if: steps.cache-binaries.outputs.cache-hit == 'true' @@ -251,9 +237,9 @@ jobs: uses: actions/cache/restore@v4 with: path: ~/.foc-devnet/docker/volumes/cache/foc-builder - key: ${{ runner.os }}-foc-builder-cache-${{ hashFiles('docker/**') }}-${{ hashFiles('src/config.rs') }} + key: ${{ runner.os }}-foc-builder-cache-${{ matrix.name }}-${{ hashFiles('docker/**') }}-${{ hashFiles('src/config.rs') }} restore-keys: | - ${{ runner.os }}-foc-builder-cache- + ${{ runner.os }}-foc-builder-cache-${{ matrix.name }}- - name: "EXEC: {Ensure permissions}, DEP: {C-foc-builder-cache}" if: steps.cache-binaries.outputs.cache-hit != 'true' && @@ -279,7 +265,7 @@ jobs: uses: actions/cache/save@v4 with: path: ~/.foc-devnet/docker/volumes/cache/foc-builder - key: ${{ runner.os }}-foc-builder-cache-${{ hashFiles('docker/**') }}-${{ hashFiles('src/config.rs') }} + key: ${{ runner.os }}-foc-builder-cache-${{ matrix.name }}-${{ hashFiles('docker/**') }}-${{ hashFiles('src/config.rs') }} # CACHE-BINARIES: Save built Lotus/Curio binaries for future runs - name: "CACHE_SAVE: {C-build-artifacts-cache}" @@ -287,7 +273,7 @@ jobs: uses: actions/cache/save@v4 with: path: ~/.foc-devnet/bin - key: ${{ runner.os }}-binaries-${{ steps.version-hashes.outputs.code-hash }} + key: ${{ runner.os }}-binaries-${{ matrix.name }}-${{ steps.version-hashes.outputs.code-hash }} # Disk free-up - name: "EXEC: {Clean up Go modules}, DEP: {C-build-artifacts-cache}" @@ -416,9 +402,30 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: python3 scenarios/run.py - # Upload scenario report as artifact (name includes job index to avoid collisions in matrix) + # Ensure scenario report exists even if tests didn't run (for issue reporting) + - name: "EXEC: {Ensure scenario report exists}" + if: always() + run: | + REPORT="$HOME/.foc-devnet/state/latest/scenario_report.md" + if [ ! -f "$REPORT" ]; then + mkdir -p "$(dirname "$REPORT")" + { + echo "# Scenario Test Report" + echo "" + echo "**No scenario tests were executed.**" + echo "" + echo "**Start cluster outcome**: ${{ steps.start_cluster.outcome }}" + echo "" + echo "## foc-devnet version" + echo '```' + ./foc-devnet version 2>&1 || echo "version command failed" + echo '```' + } > "$REPORT" + fi + + # Upload scenario report as artifact (name includes strategy to avoid collisions in matrix) - name: "EXEC: {Upload scenario report}" - if: always() && steps.start_cluster.outcome == 'success' + if: always() uses: actions/upload-artifact@v4 with: name: scenario-report-${{ matrix.name }} @@ -438,9 +445,19 @@ jobs: exit 1 issue-reporting: - name: Issue Reporting + name: Issue Reporting (${{ matrix.name }}) if: always() && (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') needs: [foc-devnet-test] + strategy: + fail-fast: false + matrix: + include: + - name: latesttag + issue_label: scenarios-run-latesttag + issue_title: "FOC Devnet scenarios run report (latestTag)" + - name: latestcommit + issue_label: scenarios-run-latestcommit + issue_title: "FOC Devnet scenarios run report (latestCommit)" runs-on: ubuntu-latest permissions: issues: write @@ -460,32 +477,30 @@ jobs: echo "Skipping issue: tests passed and REPORT_ON_SUCCESS is not 'true'" else echo "file=true" >> $GITHUB_OUTPUT - echo "Filing issue: test result was $FOC_DEVNET_TEST_STEP_RESULT" + echo "Filing issue (${{ matrix.name }}): test result was $FOC_DEVNET_TEST_STEP_RESULT" fi - - name: "EXEC: {Download scenario reports}" + - name: "EXEC: {Download scenario report for ${{ matrix.name }}}" if: steps.should_file.outputs.file == 'true' uses: actions/download-artifact@v4 with: - pattern: scenario-report-* + name: scenario-report-${{ matrix.name }} path: /tmp/scenario-report - merge-multiple: false continue-on-error: true - name: "EXEC: {Read report content}" if: steps.should_file.outputs.file == 'true' id: report run: | - # Each matrix variant lands in its own sub-directory under /tmp/scenario-report/, named after matrix.name CONTENT="" - while IFS= read -r -d '' REPORT_FILE; do - JOB_DIR=$(basename "$(dirname "$REPORT_FILE")") - CONTENT+=$'## '"${JOB_DIR}"$'\n\n' - CONTENT+=$(cat "$REPORT_FILE") - CONTENT+=$'\n\n' - done < <(find /tmp/scenario-report -name "*.md" -print0 2>/dev/null | sort -z) + for f in /tmp/scenario-report/*.md; do + if [ -f "$f" ]; then + CONTENT+=$(cat "$f") + CONTENT+=$'\n\n' + fi + done if [[ -z "$CONTENT" ]]; then - CONTENT="No scenario report available." + CONTENT="No scenario report available for **${{ matrix.name }}** strategy." fi EOF=$(dd if=/dev/urandom bs=15 count=1 status=none | base64) echo "content<<$EOF" >> $GITHUB_OUTPUT @@ -497,10 +512,10 @@ jobs: uses: ipdxco/create-or-update-issue@v1 with: GITHUB_TOKEN: ${{ github.token }} - title: "FOC Devnet scenarios run report" + title: ${{ matrix.issue_title }} body: | - The scenarios run **${{ needs.foc-devnet-test.result == 'success' && 'passed ✅' || 'failed ❌' }}**. + The **${{ matrix.name }}** scenarios run **${{ needs.foc-devnet-test.result == 'success' && 'passed ✅' || 'failed ❌' }}**. See [the workflow run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) for details. ${{ steps.report.outputs.content }} - label: scenarios-run + label: ${{ matrix.issue_label }} diff --git a/scenarios/run.py b/scenarios/run.py index 7f0e7c9..6d6e153 100755 --- a/scenarios/run.py +++ b/scenarios/run.py @@ -6,6 +6,8 @@ import sys import json import subprocess +import threading +import queue import time # Ensure the project root (parent of scenarios_py/) is on sys.path so that @@ -29,8 +31,8 @@ ORDER = [ ("test_containers", 5), ("test_basic_balances", 10), - ("test_storage_e2e", 50), - ("test_caching_subsystem", 90), + ("test_storage_e2e", 100), + ("test_caching_subsystem", 200), ] _pass = 0 @@ -149,46 +151,115 @@ def ensure_foundry(): assert_ok("command -v cast", "cast is installed") +# ── Version info ────────────────────────────────────────────── + + +def get_version_info(): + """Capture output of `foc-devnet version` for inclusion in reports.""" + for binary in ["./foc-devnet", "foc-devnet"]: + try: + result = subprocess.run( + [binary, "version"], + capture_output=True, + text=True, + timeout=10, + ) + if result.returncode == 0: + return result.stdout.strip() + except (FileNotFoundError, subprocess.TimeoutExpired): + continue + return "foc-devnet version: not available" + + # ── Runner ──────────────────────────────────────────────────── +def _read_stream(stream, q, label): + """Read lines from a subprocess stream and enqueue them with a label.""" + try: + for line in stream: + q.put((label, line.rstrip("\n"))) + except ValueError: + pass # Pipe closed + finally: + q.put((label, None)) # Sentinel to signal stream EOF + + def run_tests(): """Run scenarios in ORDER. Returns list of (name, passed, elapsed_time, log_lines, timed_out).""" here = os.path.dirname(os.path.abspath(__file__)) results = [] - for name, timeout in ORDER: + for name, timeout_sec in ORDER: path = os.path.join(here, f"{name}.py") - info(f"=== {name} (timeout: {timeout}s) ===") + info(f"=== {name} (timeout: {timeout_sec}s) ===") test_start = time.time() - # Run the test in a subprocess, capturing output while also displaying it live + # Run the test in a subprocess, capturing stdout and stderr separately process = subprocess.Popen( [sys.executable, path], stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, + stderr=subprocess.PIPE, text=True, bufsize=1, # Line buffered ) - log_lines = [] + q = queue.Queue() + stdout_lines = [] + stderr_lines = [] timed_out = False + # Reader threads for non-blocking stdout/stderr capture + t_out = threading.Thread( + target=_read_stream, args=(process.stdout, q, "stdout"), daemon=True + ) + t_err = threading.Thread( + target=_read_stream, args=(process.stderr, q, "stderr"), daemon=True + ) + t_out.start() + t_err.start() try: - # Read output line by line with timeout detection - while True: - # Check if timeout exceeded - if time.time() - test_start > timeout: + streams_done = 0 + while streams_done < 2: + remaining = timeout_sec - (time.time() - test_start) + if remaining <= 0: timed_out = True process.kill() - timeout_msg = f"[TIMEOUT] Test exceeded {timeout}s limit" - print(timeout_msg) - log_lines.append(timeout_msg) break - # Try to read a line (non-blocking with select would be better, but this works) - line = process.stdout.readline() - if not line: - break # EOF reached - line = line.rstrip("\n") - print(line) # Display live - log_lines.append(line) # Capture for report - # Wait for process to complete (or confirm it's dead) + try: + label, line = q.get(timeout=min(remaining, 1.0)) + if line is None: + streams_done += 1 + continue + if label == "stdout": + print(line) + stdout_lines.append(line) + else: + print(f" [stderr] {line}", file=sys.stderr) + stderr_lines.append(line) + except queue.Empty: + if process.poll() is not None and q.empty(): + break + continue + # Wait for reader threads to finish and drain remaining queue + t_out.join(timeout=3) + t_err.join(timeout=3) + while not q.empty(): + try: + label, line = q.get_nowait() + if line is None: + continue + if label == "stdout": + print(line) + stdout_lines.append(line) + else: + print(f" [stderr] {line}", file=sys.stderr) + stderr_lines.append(line) + except queue.Empty: + break + if timed_out: + timeout_msg = ( + f"[TIMEOUT] Test '{name}' exceeded {timeout_sec}s limit " + f"— {len(stdout_lines)} stdout and {len(stderr_lines)} stderr lines captured" + ) + print(timeout_msg, file=sys.stderr) + stdout_lines.append(timeout_msg) try: return_code = process.wait(timeout=5) except subprocess.TimeoutExpired: @@ -198,11 +269,17 @@ def run_tests(): except Exception as e: error_msg = f"[ERROR] Exception during test execution: {e}" print(error_msg) - log_lines.append(error_msg) + stdout_lines.append(error_msg) process.kill() process.wait() return_code = -1 elapsed_time = int(time.time() - test_start) + # Combine stdout and stderr into log_lines for the report + log_lines = stdout_lines.copy() + if stderr_lines: + log_lines.append("") + log_lines.append("--- stderr ---") + log_lines.extend(stderr_lines) # Determine pass/fail based on return code and timeout passed = return_code == 0 and not timed_out results.append((name, passed, elapsed_time, log_lines, timed_out)) @@ -226,6 +303,11 @@ def write_report(results, elapsed): github_server = os.environ.get("GITHUB_SERVER_URL", "https://github.com") ci_url = f"{github_server}/{github_repo}/actions/runs/{github_run_id}" fh.write(f"**CI Run**: [{ci_url}]({ci_url})\n\n") + # Version info from foc-devnet version + version_info = get_version_info() + fh.write("## Version Info\n\n") + fh.write(f"```\n{version_info}\n```\n\n") + fh.write("## Summary\n\n") fh.write("| Metric | Value |\n|--------|-------|\n") fh.write( f"| Total Scenarios | {total_scenarios} |\n| Scenarios Passed | {scenario_pass} |\n| Scenarios Failed | {scenario_fail} |\n" diff --git a/src/commands/start/yugabyte/mod.rs b/src/commands/start/yugabyte/mod.rs index dd46151..eae7bd7 100644 --- a/src/commands/start/yugabyte/mod.rs +++ b/src/commands/start/yugabyte/mod.rs @@ -102,7 +102,6 @@ fn spawn_yugabyte_instance( "--base_dir=/home/foc-user/yb_base", "--ui=true", "--callhome=false", - "--advertise_address=0.0.0.0", "--master_flags=rpc_bind_addresses=0.0.0.0", "--tserver_flags=rpc_bind_addresses=0.0.0.0,pgsql_proxy_bind_address=0.0.0.0:5433,cql_proxy_bind_address=0.0.0.0:9042", "--daemon=false", From 2e482449724056567e57ee4ab383a56a1a03602e Mon Sep 17 00:00:00 2001 From: redpanda-f Date: Thu, 5 Mar 2026 17:16:17 +0000 Subject: [PATCH 40/42] update: CI.yml --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1b3af03..a64f901 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,7 +3,7 @@ name: CI on: push: - branches: ['main'] + branches: ['**'] pull_request: branches: ['**'] schedule: From 93d307e150d93988a51dac471b8ac34ca06a59d8 Mon Sep 17 00:00:00 2001 From: redpanda-f Date: Thu, 5 Mar 2026 17:34:11 +0000 Subject: [PATCH 41/42] fix: push --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a64f901..1b3af03 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,7 +3,7 @@ name: CI on: push: - branches: ['**'] + branches: ['main'] pull_request: branches: ['**'] schedule: From 5daee8485c01f4d7630f327ce2f2885ca91ae3a7 Mon Sep 17 00:00:00 2001 From: Red Panda Date: Fri, 6 Mar 2026 10:10:01 +0530 Subject: [PATCH 42/42] retry: CI --- .github/workflows/ci.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1b3af03..3026685 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,7 +43,6 @@ jobs: sudo apt-get update sudo apt-get install -y pipx pipx install black - pipx install ruff echo "$HOME/.local/bin" >> $GITHUB_PATH - name: Run linting (check mode) @@ -56,7 +55,7 @@ jobs: matrix: include: - name: latesttag - init_flags: "--lotus latestTag --curio latestTag --filecoin-services latestTag" + init_flags: "--lotus latestTag --curio latestTag:pdpv0 --filecoin-services latestTag" - name: latestcommit init_flags: "--lotus latestTag --curio gitbranch:pdpv0 --filecoin-services gitbranch:main" runs-on: ["self-hosted", "linux", "x64", "16xlarge+gpu"]