diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f13c16ec..72337316 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -63,29 +63,42 @@ jobs: echo "python=$(echo "$DIFF" | grep -qE '^workspace/|^\.github/workflows/ci\.yml$' && echo true || echo false)" >> "$GITHUB_OUTPUT" echo "scripts=$(echo "$DIFF" | grep -qE '^tests/e2e/|^scripts/|^infra/scripts/|^\.github/workflows/ci\.yml$' && echo true || echo false)" >> "$GITHUB_OUTPUT" + # Platform (Go) is a required check on staging. Always-run + per-step + # gating (see Canvas (Next.js) for the rationale and the failure mode + # this avoids). platform-build: name: Platform (Go) needs: changes - if: needs.changes.outputs.platform == 'true' runs-on: ubuntu-latest defaults: run: working-directory: workspace-server steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 + - if: needs.changes.outputs.platform != 'true' + working-directory: . + run: echo "No platform/** changes — skipping real build steps; this job always runs to satisfy the required-check name on branch protection." + - if: needs.changes.outputs.platform == 'true' + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - if: needs.changes.outputs.platform == 'true' + uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 with: go-version: 'stable' - - run: go mod download - - run: go build ./cmd/server + - if: needs.changes.outputs.platform == 'true' + run: go mod download + - if: needs.changes.outputs.platform == 'true' + run: go build ./cmd/server # CLI (molecli) moved to standalone repo: github.com/Molecule-AI/molecule-cli - - run: go vet ./... || true - - name: Run golangci-lint + - if: needs.changes.outputs.platform == 'true' + run: go vet ./... || true + - if: needs.changes.outputs.platform == 'true' + name: Run golangci-lint run: golangci-lint run --timeout 3m ./... || true - - name: Run tests with race detection and coverage + - if: needs.changes.outputs.platform == 'true' + name: Run tests with race detection and coverage run: go test -race -coverprofile=coverage.out ./... - - name: Per-file coverage report + - if: needs.changes.outputs.platform == 'true' + name: Per-file coverage report # Advisory — lists every source file with its coverage so reviewers # can see at-a-glance where gaps are. Sorted ascending so the worst # offenders float to the top. Does NOT fail the build; the hard @@ -98,7 +111,8 @@ jobs: END {for (f in s) printf "%6.1f%% %s\n", s[f]/c[f], f}' \ | sort -n - - name: Check coverage thresholds + - if: needs.changes.outputs.platform == 'true' + name: Check coverage thresholds # Enforces two gates from #1823 Layer 1: # 1. Total floor (25% — ratchet plan in COVERAGE_FLOOR.md). # 2. Per-file floor — non-test .go files in security-critical @@ -178,42 +192,38 @@ jobs: exit 1 fi - # Path-filter no-op shadow for Canvas (Next.js). + # Canvas (Next.js) — required check, always runs. See platform-build + # comment above for the rationale. # - # Branch protection on staging requires a "Canvas (Next.js)" check. - # When a PR doesn't touch canvas/** paths, the real canvas-build job - # below is skipped via `if:`, and GitHub reports its conclusion as - # SKIPPED — which branch protection treats as not-passed → merge - # BLOCKED on every workspace-server-only or migration-only PR. - # - # Pattern (per durable feedback memory: branch_protection_check_name_parity): - # split into a real job + a no-op shadow that share the same `name:`. - # Exactly one runs per PR; both report the same check context, and at - # least one always reports SUCCESS, satisfying the required check. - canvas-build-noop: - name: Canvas (Next.js) - needs: changes - if: needs.changes.outputs.canvas != 'true' - runs-on: ubuntu-latest - steps: - - run: echo "No canvas/** changes in this PR — Canvas (Next.js) skip is intentional, satisfying required-check via this no-op." - + # Supersedes the canvas-build-noop pattern attempted in PR #2321: two + # jobs sharing `name:` doesn't actually satisfy branch protection + # because the SKIPPED check run sibling is treated as not-passed + # regardless of how many SUCCESS siblings it has. Verified empirically + # on PR #2314 — mergeStateStatus stayed BLOCKED until I collapsed to + # a single-job-with-conditional-steps shape. canvas-build: name: Canvas (Next.js) needs: changes - if: needs.changes.outputs.canvas == 'true' runs-on: ubuntu-latest defaults: run: working-directory: canvas steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + - if: needs.changes.outputs.canvas != 'true' + working-directory: . + run: echo "No canvas/** changes — skipping real build steps; this job always runs to satisfy the required-check name on branch protection." + - if: needs.changes.outputs.canvas == 'true' + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - if: needs.changes.outputs.canvas == 'true' + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: '22' - - run: rm -f package-lock.json && npm install - - run: npm run build - - name: Run tests with coverage + - if: needs.changes.outputs.canvas == 'true' + run: rm -f package-lock.json && npm install + - if: needs.changes.outputs.canvas == 'true' + run: npm run build + - if: needs.changes.outputs.canvas == 'true' + name: Run tests with coverage # Coverage instrumentation is configured in canvas/vitest.config.ts # (provider: v8, reporters: text + html + json-summary). Step 2 of # #1815 — wires coverage into CI so we get a baseline visible on @@ -224,7 +234,7 @@ jobs: # thresholds + a hard gate" — this PR ships the observability half. run: npx vitest run --coverage - name: Upload coverage summary as artifact - if: always() + if: needs.changes.outputs.canvas == 'true' && always() uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: canvas-coverage-${{ github.run_id }} @@ -240,14 +250,19 @@ jobs: # It now has workflow-level concurrency (cancel-in-progress: false) so # new pushes queue the E2E run rather than cancelling it at the run level. + # Shellcheck (E2E scripts) — required check, always runs. See + # platform-build for the rationale. shellcheck: name: Shellcheck (E2E scripts) needs: changes - if: needs.changes.outputs.scripts == 'true' runs-on: ubuntu-latest steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - - name: Run shellcheck on tests/e2e/*.sh and infra/scripts/*.sh + - if: needs.changes.outputs.scripts != 'true' + run: echo "No tests/e2e/ or infra/scripts/ changes — skipping real shellcheck; this job always runs to satisfy the required-check name on branch protection." + - if: needs.changes.outputs.scripts == 'true' + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - if: needs.changes.outputs.scripts == 'true' + name: Run shellcheck on tests/e2e/*.sh and infra/scripts/*.sh # shellcheck is pre-installed on ubuntu-latest runners (via apt). # infra/scripts/ is included because setup.sh + nuke.sh gate the # README quickstart — a shellcheck regression there silently breaks @@ -301,10 +316,11 @@ jobs: "repos/${{ github.repository }}/commits/${{ github.sha }}/comments" \ --field "body=@/tmp/deploy-reminder.md" + # Python Lint & Test — required check, always runs. See platform-build + # for the rationale. python-lint: name: Python Lint & Test needs: changes - if: needs.changes.outputs.python == 'true' runs-on: ubuntu-latest env: WORKSPACE_ID: test @@ -312,16 +328,23 @@ jobs: run: working-directory: workspace steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + - if: needs.changes.outputs.python != 'true' + working-directory: . + run: echo "No workspace/** changes — skipping real lint+test; this job always runs to satisfy the required-check name on branch protection." + - if: needs.changes.outputs.python == 'true' + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - if: needs.changes.outputs.python == 'true' + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version: '3.11' cache: pip cache-dependency-path: workspace/requirements.txt - - run: pip install -r requirements.txt pytest pytest-asyncio pytest-cov + - if: needs.changes.outputs.python == 'true' + run: pip install -r requirements.txt pytest pytest-asyncio pytest-cov # Coverage flags + fail-under floor moved into workspace/pytest.ini # (issue #1817) so local `pytest` and CI use identical config. - - run: python -m pytest --tb=short + - if: needs.changes.outputs.python == 'true' + run: python -m pytest --tb=short # SDK + plugin validation moved to standalone repo: # github.com/Molecule-AI/molecule-sdk-python diff --git a/tests/e2e/test_2307_peer_visibility_staging.sh b/tests/e2e/test_2307_peer_visibility_staging.sh new file mode 100755 index 00000000..b3d3d812 --- /dev/null +++ b/tests/e2e/test_2307_peer_visibility_staging.sh @@ -0,0 +1,275 @@ +#!/usr/bin/env bash +# Staging E2E for #2307 — create fresh tenant, test peer visibility, tear down. +# +# Mirrors tests/e2e/test_staging_full_saas.sh's pattern (org create via +# /cp/admin/orgs, EXIT-trap teardown via DELETE /cp/admin/tenants/:slug +# with required {"confirm":slug} body). +# +# Required: MOLECULE_ADMIN_TOKEN exported (CP admin bearer). +# Optional: +# MOLECULE_CP_URL default https://staging-api.moleculesai.app +# PARENT_RUNTIME default claude-code + +set -uo pipefail + +CP_URL="${MOLECULE_CP_URL:-https://staging-api.moleculesai.app}" +ADMIN_TOKEN="${MOLECULE_ADMIN_TOKEN:?MOLECULE_ADMIN_TOKEN required}" +PARENT_RUNTIME="${PARENT_RUNTIME:-claude-code}" + +RUN_ID=$(date +%s | tail -c 8) +SLUG="e2e-2307-$RUN_ID" +ORG_ID="" +TENANT_URL="" +TENANT_TOKEN="" +PARENT="" +CHILD="" +CTOK="" + +admin_call() { + local method="$1" path="$2" + shift 2 + curl -sS -X "$method" "$CP_URL$path" \ + -H "Authorization: Bearer $ADMIN_TOKEN" \ + -H "Content-Type: application/json" \ + "$@" +} + +tenant_call() { + local method="$1" path="$2" + shift 2 + curl -sS -X "$method" "$TENANT_URL$path" \ + -H "Authorization: Bearer $TENANT_TOKEN" \ + -H "X-Molecule-Org-Id: $ORG_ID" \ + -H "Content-Type: application/json" \ + "$@" +} + +teardown() { + local rc=$? + set +e + echo "" + echo "[teardown] DELETE /cp/admin/tenants/$SLUG ..." + admin_call DELETE "/cp/admin/tenants/$SLUG" \ + --max-time 120 \ + -d "{\"confirm\":\"$SLUG\"}" >/dev/null 2>&1 + # Poll up to 60s for purge + for j in $(seq 1 12); do + LIST=$(admin_call GET /cp/admin/orgs 2>/dev/null) + LEAK=$(echo "$LIST" | python3 -c " +import sys, json +try: + d = json.load(sys.stdin) +except Exception: + print(1); sys.exit(0) +orgs = d if isinstance(d, list) else d.get('orgs', []) +n = sum(1 for o in orgs if o.get('slug') == '$SLUG' and o.get('status') != 'purged') +print(n) +" 2>/dev/null || echo 1) + if [ "$LEAK" = "0" ]; then + echo " ✓ tenant purged (after ${j}x5s)" + exit $rc + fi + sleep 5 + done + echo " ⚠ LEAK: $SLUG still in /cp/admin/orgs after 60s — manual cleanup needed" + [ $rc -eq 0 ] && rc=4 + exit $rc +} +trap teardown EXIT INT TERM + +# ─── 1. Create the org ──────────────────────────────────────────────── +echo "[1/8] POST /cp/admin/orgs — slug=$SLUG" +CREATE=$(admin_call POST /cp/admin/orgs \ + -d "{\"slug\":\"$SLUG\",\"name\":\"E2E #2307 $SLUG\",\"owner_user_id\":\"e2e-runner:$SLUG\"}") +echo " resp: $(echo "$CREATE" | head -c 300)" +ORG_ID=$(echo "$CREATE" | python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))" 2>/dev/null) +[ -n "$ORG_ID" ] || { echo " ✗ org creation failed"; exit 1; } +echo " ✓ ORG_ID=$ORG_ID" + +# ─── 2. Wait for tenant ready ───────────────────────────────────────── +echo "[2/8] waiting for tenant to come up (cold-start ~5-10min)..." +for i in $(seq 1 180); do + STATUS=$(admin_call GET /cp/admin/orgs 2>/dev/null | python3 -c " +import sys, json +try: d = json.load(sys.stdin) +except Exception: sys.exit(0) +orgs = d if isinstance(d, list) else d.get('orgs', []) +for o in orgs: + if o.get('slug') == '$SLUG': + print(o.get('instance_status') or o.get('status') or 'unknown') + break +" 2>/dev/null) + [ $((i % 6)) -eq 1 ] && echo " attempt $i: status=$STATUS" + case "$STATUS" in running|online|ready) break ;; esac + sleep 5 +done +case "$STATUS" in running|online|ready) ;; + *) echo " ✗ tenant never came up (last=$STATUS)"; exit 2 ;; esac +echo " ✓ tenant status=$STATUS" + +# ─── 3. Per-tenant admin token ──────────────────────────────────────── +echo "[3/8] fetching per-tenant admin token..." +TT_RESP=$(admin_call GET "/cp/admin/orgs/$SLUG/admin-token") +TENANT_TOKEN=$(echo "$TT_RESP" | python3 -c "import sys,json; print(json.load(sys.stdin).get('admin_token',''))" 2>/dev/null) +[ -n "$TENANT_TOKEN" ] || { echo " ✗ tenant token fetch failed: $TT_RESP"; exit 2; } +echo " ✓ got tenant admin token (len ${#TENANT_TOKEN})" + +CP_HOST=$(echo "$CP_URL" | sed -E 's#^https?://##; s#/.*$##') +case "$CP_HOST" in + api.*) DERIVED_DOMAIN="${CP_HOST#api.}" ;; + staging-api.*) DERIVED_DOMAIN="staging.${CP_HOST#staging-api.}" ;; + *) DERIVED_DOMAIN="$CP_HOST" ;; +esac +TENANT_URL="https://${SLUG}.${DERIVED_DOMAIN}" +echo " tenant url: $TENANT_URL" + +# ─── 4. Wait for tenant TLS/DNS readiness ───────────────────────────── +echo "[4/8] waiting for tenant /health (TLS/DNS, up to 10min)..." +for i in $(seq 1 120); do + if curl -fsS "$TENANT_URL/health" -m 5 -k >/dev/null 2>&1; then + echo " ✓ /health ok (attempt $i)" + break + fi + sleep 5 +done + +# ─── 5. Provision parent CEO workspace ──────────────────────────────── +echo "[5/8] creating parent CEO ($PARENT_RUNTIME)..." +P_RESP=$(tenant_call POST /workspaces \ + -d "{\"name\":\"e2e-CEO\",\"runtime\":\"$PARENT_RUNTIME\",\"tier\":3}") +echo " parent resp: $(echo "$P_RESP" | head -c 300)" +PARENT=$(echo "$P_RESP" | python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))" 2>/dev/null) +PTOK=$(echo "$P_RESP" | python3 -c "import sys,json; print(json.load(sys.stdin).get('auth_token',''))" 2>/dev/null) +[ -n "$PARENT" ] || { echo " ✗ parent create failed"; exit 3; } +echo " ✓ PARENT=$PARENT (parent_token_returned=$([ -n "$PTOK" ] && echo yes || echo no))" + +# ─── 6. Wait for parent online ──────────────────────────────────────── +echo "[6/8] waiting for parent to come online (up to 12min)..." +for i in $(seq 1 144); do + WS_JSON=$(tenant_call GET "/workspaces/$PARENT" 2>/dev/null) + S=$(echo "$WS_JSON" | python3 -c " +import sys, json +try: d = json.load(sys.stdin) +except Exception: sys.exit(0) +w = d.get('workspace') if isinstance(d.get('workspace'), dict) else d +print(w.get('status') or '') +" 2>/dev/null) + [ $((i % 6)) -eq 1 ] && echo " attempt $i: parent status=$S" + [ "$S" = "online" ] && break + sleep 5 +done +[ "$S" = "online" ] || { echo " ✗ parent never online (last=$S)"; exit 3; } +echo " ✓ parent online" + +# ─── 7. Create external child + register URL ────────────────────────── +echo "[7/8] creating external child + registering..." +C_RESP=$(tenant_call POST /workspaces \ + -d "{\"name\":\"e2e-Reno-Server\",\"runtime\":\"external\",\"external\":true,\"tier\":2,\"parent_id\":\"$PARENT\"}") +echo " child resp: $(echo "$C_RESP" | head -c 400)" +CHILD=$(echo "$C_RESP" | python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))" 2>/dev/null) +# External-runtime token is nested under `connection.auth_token` (verified +# 2026-04-29 against staging response shape). Fall back to top-level for +# parity with older clients. +CTOK=$(echo "$C_RESP" | python3 -c " +import sys, json +d = json.load(sys.stdin) +print(d.get('connection', {}).get('auth_token') or d.get('auth_token') or '') +" 2>/dev/null) +[ -n "$CHILD" ] || { echo " ✗ child create failed"; exit 3; } +echo " ✓ CHILD=$CHILD (child_token_returned=$([ -n "$CTOK" ] && echo yes || echo no))" + +# Try register with child's own token (bootstrap path); fall back to tenant_call +if [ -n "$CTOK" ]; then + REG_RESP=$(curl -sS -X POST "$TENANT_URL/registry/register" \ + -H "Authorization: Bearer $CTOK" \ + -H "X-Molecule-Org-Id: $ORG_ID" \ + -H "Content-Type: application/json" \ + -d "{\"id\":\"$CHILD\",\"url\":\"https://example.com/molecule-test\",\"agent_card\":{\"name\":\"Reno Server\",\"description\":\"Mock\",\"version\":\"0.1.0\"}}") +else + REG_RESP=$(tenant_call POST /registry/register \ + -d "{\"id\":\"$CHILD\",\"url\":\"https://example.com/molecule-test\",\"agent_card\":{\"name\":\"Reno Server\",\"description\":\"Mock\",\"version\":\"0.1.0\"}}") +fi +echo " register resp: $(echo "$REG_RESP" | head -c 300)" + +# ─── 8. THE TEST — peer visibility ──────────────────────────────────── +echo "" +echo "[8/8] === Verdict — does parent see external child? ===" +echo "" +echo "(a) DB shape via admin: GET /cp/admin/orgs/$SLUG (workspaces listing if exposed)" + +# Check children listing — most direct DB-shape signal we can get from outside +LIST=$(tenant_call GET "/workspaces?parent_id=$PARENT") +echo " /workspaces?parent_id=$PARENT response: $(echo "$LIST" | head -c 500)" +echo "" + +CHILD_LISTED=$(echo "$LIST" | python3 -c " +import sys, json +try: + d = json.load(sys.stdin) +except Exception: + print('parse_error'); sys.exit(0) +ws = d if isinstance(d, list) else d.get('workspaces', d.get('items', [])) +print('yes' if any(w.get('id') == '$CHILD' for w in ws) else 'no') +" 2>/dev/null) +echo " child appears in parent's children listing: $CHILD_LISTED" + +# (b) /peers from PARENT side using PTOK if provided +if [ -n "$PTOK" ]; then + PEERS=$(curl -sS "$TENANT_URL/registry/$PARENT/peers" \ + -H "Authorization: Bearer $PTOK" \ + -H "X-Molecule-Org-Id: $ORG_ID") + echo "" + echo "(b) GET /registry/$PARENT/peers (parent's bearer):" + echo " $(echo "$PEERS" | head -c 600)" + if echo "$PEERS" | grep -q "$CHILD"; then + echo " ✓ child IS in parent's /peers" + VERDICT_B=ok + else + echo " ✗ child is NOT in parent's /peers — bug REPRODUCES at API layer" + VERDICT_B=fail + fi +else + echo "" + echo "(b) parent's auth_token not exposed by /workspaces create — skipping direct /peers check" + VERDICT_B=skipped +fi + +# (c) /peers from CHILD side using CTOK +if [ -n "$CTOK" ]; then + PEERS_C=$(curl -sS "$TENANT_URL/registry/$CHILD/peers" \ + -H "Authorization: Bearer $CTOK" \ + -H "X-Molecule-Org-Id: $ORG_ID") + echo "" + echo "(c) GET /registry/$CHILD/peers (child's bearer):" + echo " $(echo "$PEERS_C" | head -c 600)" + if echo "$PEERS_C" | grep -q "$PARENT"; then + echo " ✓ parent IS in child's /peers" + VERDICT_C=ok + else + echo " ✗ parent is NOT in child's /peers" + VERDICT_C=fail + fi +else + VERDICT_C=skipped +fi + +echo "" +echo "=== SUMMARY for #2307 staging E2E ===" +echo " child listed under parent: $CHILD_LISTED" +echo " /peers parent→child: $VERDICT_B" +echo " /peers child→parent: $VERDICT_C" + +# Exit code: 0 if everything visible, 10 if bug reproduces, 11 if inconclusive +if [ "$CHILD_LISTED" = "yes" ] && [ "$VERDICT_B" = "ok" ]; then + echo "" + echo "✓ STAGING: parent fully sees external child — bug is downstream (agent code, not platform API)" + exit 0 +elif [ "$VERDICT_B" = "fail" ] || [ "$CHILD_LISTED" = "no" ]; then + echo "" + echo "✗ STAGING: bug REPRODUCES at platform-API layer" + exit 10 +else + echo "" + echo "? STAGING: inconclusive (need parent token to call /peers definitively)" + exit 11 +fi