From f9e43894557c399548889cb67635fdc38d6f2355 Mon Sep 17 00:00:00 2001 From: bonnyr-f5 Date: Mon, 10 Aug 2026 12:11:05 +1000 Subject: [PATCH 01/59] chore: integration branch snapshot (internal origin/staging) --- .dockerignore | 29 +- .github/workflows/ci.yml | 223 +- .github/workflows/release.yml | 97 +- .gitignore | 4 + .trivyignore | 12 + MIN_UPGRADE_FROM | 1 + Makefile | 51 +- backend/.dockerignore | 70 - backend/Dockerfile | 98 +- .../v2_138_add_container_registries.py | 32 +- .../v2_143_add_usecase_artifact_tables.py | 106 + .../versions/v2_144_bnk_deployable_release.py | 452 ++ ...5_kubernetes_cluster_deployable_release.py | 35 + .../v2_146_bare_metal_host_rshim_mac_base.py | 30 + .../v2_147_add_benchmark_run_is_baseline.py | 33 + .../alembic/versions/v2_148_release_source.py | 92 + ..._149_kubernetes_cluster_running_release.py | 35 + .../versions/v2_150_add_bnk_cluster_config.py | 95 + ...51_add_dpus_kubernetes_cluster_id_index.py | 35 + .../v2_152_heal_stamped_head_drift.py | 147 + backend/celery_app.py | 9 +- .../forge-blueprint.json | 4 +- backend/data/stack_templates.json | 15 +- backend/main.py | 10 +- backend/models/__init__.py | 27 +- backend/models/bare_metal.py | 66 +- backend/models/benchmark.py | 4 + backend/models/bnk_deployable_release.py | 91 + backend/models/dpu.py | 22 + backend/models/enums.py | 13 + backend/models/kubernetes.py | 91 +- backend/models/release_source.py | 47 + backend/models/usecase_artifact.py | 98 + backend/modules/__init__.py | 2 + .../modules/bare_metal/bnk_cert_manager.py | 13 +- backend/modules/bare_metal/bnk_cneinstance.py | 4 +- backend/modules/bare_metal/bnk_flo.py | 3 + backend/modules/bare_metal/bnk_license.py | 149 + .../modules/bare_metal/bnk_prerequisites.py | 22 +- backend/modules/bare_metal/bnk_ssh_base.py | 30 +- backend/modules/bare_metal/bnk_vlans.py | 2 +- backend/modules/bare_metal/flash_dpu.py | 258 +- .../bare_metal/setup_dpu_networking.py | 123 +- backend/openapi.json | 3227 ++++++++++++++- backend/requirements.txt | 11 +- .../routes/bare_metal_deployable_releases.py | 73 + backend/routes/bare_metal_deployments.py | 4 + backend/routes/bare_metal_version_profiles.py | 39 - backend/routes/benchmarks.py | 48 + backend/routes/blueprint_catalog.py | 35 + backend/routes/config_export.py | 77 +- backend/routes/dpus_websocket.py | 2 +- backend/routes/drift.py | 3 +- backend/routes/k8s/_shared.py | 64 +- backend/routes/k8s/clusters.py | 64 + backend/routes/module_sources.py | 39 + backend/routes/release_sources.py | 172 + backend/routes/stacks.py | 33 +- backend/routes/usecase_artifacts.py | 121 + backend/schemas/bare_metal.py | 36 +- backend/schemas/benchmarks.py | 43 + backend/schemas/catalog_prune.py | 67 + backend/schemas/dpu.py | 10 + backend/schemas/drift.py | 56 + backend/schemas/k8s.py | 49 + backend/schemas/release_source.py | 91 + backend/schemas/usecase_artifact.py | 72 + backend/services/backup_service.py | 2 +- backend/services/bare_metal/__init__.py | 4 +- .../bare_metal/deployable_release_refresh.py | 290 ++ backend/services/bare_metal/host_service.py | 2 + backend/services/bare_metal/orchestrator.py | 103 +- .../services/bare_metal/release_source_oci.py | 243 ++ .../services/bare_metal/version_profiles.py | 189 +- backend/services/benchmark_service.py | 158 + backend/services/bf_conf_renderer.py | 38 +- backend/services/bluefield_image_service.py | 45 + backend/services/bnk/helpers.py | 25 + backend/services/bnk/policy_associations.py | 141 +- backend/services/bnk/topology.py | 52 +- backend/services/bnk_cluster_service.py | 440 ++ backend/services/catalog_prune_service.py | 362 ++ .../services/cluster_management_service.py | 47 +- backend/services/config_export_service.py | 90 + backend/services/dpu_connectivity_service.py | 2 +- backend/services/dpu_os_probe_service.py | 2 +- backend/services/dpu_service.py | 2 +- backend/services/drift_service.py | 49 + .../services/execution/blueprint_context.py | 16 +- .../services/execution/container_engine.py | 5 + .../services/execution/container_runner.py | 528 ++- .../services/execution/variable_assembler.py | 313 +- backend/services/k8s_drift_service.py | 70 + backend/services/llm_observability_service.py | 12 +- backend/services/project_service.py | 30 +- backend/services/qkview_service.py | 33 +- backend/services/release_registry_service.py | 38 + backend/services/release_source_service.py | 379 ++ backend/services/rshim_service.py | 150 +- backend/services/scanner/__init__.py | 24 + backend/services/stack_service.py | 47 +- backend/services/tmfifo_ipam_service.py | 174 + backend/services/usecase_artifact_service.py | 262 ++ backend/startup_steps.py | 13 + backend/tasks/container_reaper.py | 137 + backend/tasks/container_tasks.py | 80 +- backend/tasks/ssh_tasks.py | 59 +- .../test_bnk_cluster_config_persistence.py | 250 ++ .../test_bnk_cluster_member_assignment.py | 806 ++++ .../tests/component/test_catalog_prune_db.py | 243 ++ .../test_cluster_management_service.py | 164 + .../component/test_config_export_service.py | 77 + .../test_container_dependency_wiring.py | 212 + .../tests/component/test_qkview_service.py | 82 + .../component/test_release_source_service.py | 490 +++ .../component/test_release_source_tags.py | 351 ++ .../test_running_release_discovery.py | 611 +++ backend/tests/component/test_ssh_tasks.py | 258 ++ backend/tests/component/test_stack_service.py | 93 + .../test_usecase_artifact_service.py | 254 ++ .../test_deployable_release_contracts.py | 121 + .../integration/test_routes_benchmarks.py | 164 +- .../tests/integration/test_routes_drift.py | 32 +- .../tests/integration/test_routes_projects.py | 87 + .../test_routes_usecase_artifacts.py | 295 ++ .../unit/test_benchmark_baseline_trends.py | 482 +++ backend/tests/unit/test_bf_conf_renderer.py | 47 + .../unit/test_bluefield_image_service.py | 139 + .../tests/unit/test_bnk_cluster_service.py | 103 + backend/tests/unit/test_bnk_license_module.py | 303 ++ .../unit/test_bnk_policy_associations.py | 192 +- .../unit/test_bnk_ssh_base_apply_retry.py | 144 + backend/tests/unit/test_bnk_topology.py | 97 +- backend/tests/unit/test_catalog_prune.py | 172 + backend/tests/unit/test_container_reaper.py | 101 + backend/tests/unit/test_container_runner.py | 615 ++- .../unit/test_dependency_output_wiring.py | 150 + .../tests/unit/test_flash_dpu_bfb_cache.py | 360 ++ backend/tests/unit/test_flash_dpu_mac_enum.py | 379 ++ backend/tests/unit/test_orchestrator.py | 130 +- backend/tests/unit/test_release_drift_b.py | 123 + backend/tests/unit/test_release_source_oci.py | 413 ++ backend/tests/unit/test_rshim_service.py | 363 ++ backend/tests/unit/test_schemas_bare_metal.py | 66 +- backend/tests/unit/test_ssh_modules.py | 119 + .../tests/unit/test_tmfifo_ipam_service.py | 99 + .../unit/test_usecase_artifact_service.py | 109 + backend/tests/unit/test_version_profiles.py | 340 +- bin/local/bnk-pods.sh | 97 + bin/local/forge-api.sh | 168 + bin/local/forge-db.sh | 121 + bin/local/wait-for.sh | 126 + docker-bake.hcl | 19 +- docker-compose.adr424.yml | 61 + docker-compose.dev.yml | 6 +- docker-compose.yml | 9 +- docs/API_REFERENCE.md | 2 + docs/DEPLOYMENT.md | 4 +- docs/DOCKER.md | 11 + docs/INSTALLATION.md | 30 + docs/ROADMAP.md | 2 + .../ADR-424-multi-host-dpu-single-cluster.md | 77 + docs/adr/ADR-478-bnk-release-selection.md | 69 + ...94-bnk-release-management-consolidation.md | 80 + .../D-034-portable-bnk-use-case-artifact.md | 254 ++ .../D-035-docker-netskope-tls-interception.md | 74 + docs/roadmap.html | 4 +- docs/roadmap.yaml | 13 + frontend-v2/package.json | 4 +- .../components/bare-metal/BareMetalPanel.tsx | 825 +++- .../__tests__/MultiHostDeployModal.test.tsx | 132 + .../components/catalog/BnkReleasesPanel.tsx | 1004 +++++ .../__tests__/BnkReleasesPanel.test.tsx | 480 +++ .../components/k8s/BnkClusterMemberDialog.tsx | 442 ++ .../components/k8s/DPFInfrastructurePanel.tsx | 4 +- .../src/components/k8s/DPUDeviceList.tsx | 27 +- .../src/components/k8s/F5BNKPolicyViewer.tsx | 584 ++- .../components/k8s/F5BNKTopologyViewer.tsx | 64 +- .../src/components/k8s/K8sClusterList.tsx | 31 + .../src/components/k8s/PolicyBuilder.tsx | 4 +- .../components/k8s/TrafficFlowOverview.tsx | 165 +- .../__tests__/BnkClusterMemberDialog.test.tsx | 400 ++ .../k8s/__tests__/F5BNKPolicyViewer.test.tsx | 184 +- .../__tests__/TrafficFlowOverview.test.tsx | 54 +- .../k8s/f5bnk-details/EgressDetail.tsx | 40 +- .../__tests__/f5bnk-details.test.tsx | 43 +- .../observability/TimeSeriesChart.tsx | 4 +- .../components/stacks/StackDetailDialog.tsx | 423 +- .../StackDetailDialog.multiHost.test.tsx | 293 ++ .../__tests__/StackDetailDialog.test.tsx | 179 + .../src/hooks/__tests__/useBareMetal.test.ts | 144 +- .../hooks/__tests__/useK8sClusters.test.ts | 112 + .../hooks/__tests__/useReleaseSources.test.ts | 285 ++ frontend-v2/src/hooks/useBareMetal.ts | 39 +- frontend-v2/src/hooks/useBenchmarks.ts | 40 + frontend-v2/src/hooks/useK8sClusters.ts | 41 +- frontend-v2/src/hooks/useLlmObservability.ts | 4 + frontend-v2/src/hooks/useReleaseSources.ts | 85 + .../lib/__tests__/dpu-device-status.test.ts | 61 + frontend-v2/src/lib/api/bare-metal.ts | 35 +- frontend-v2/src/lib/api/benchmarks.ts | 14 + frontend-v2/src/lib/api/kubernetes.ts | 11 + frontend-v2/src/lib/api/release-sources.ts | 68 + frontend-v2/src/lib/api/stacks.ts | 10 +- frontend-v2/src/lib/dpu-device-status.ts | 48 + frontend-v2/src/lib/queryKeys.ts | 16 +- frontend-v2/src/pages/BenchmarkCompareTab.tsx | 27 +- .../src/pages/BenchmarkOverviewTab.tsx | 308 ++ frontend-v2/src/pages/BenchmarkRunDetail.tsx | 45 +- frontend-v2/src/pages/BenchmarkRunsTab.tsx | 37 +- frontend-v2/src/pages/BenchmarkTrendsView.tsx | 223 + frontend-v2/src/pages/Benchmarks.tsx | 419 +- frontend-v2/src/pages/Catalog.tsx | 12 +- frontend-v2/src/pages/F5BNK.tsx | 12 +- frontend-v2/src/pages/RunBenchmarkWizard.tsx | 512 +++ .../__tests__/BenchmarkOverviewTab.test.tsx | 165 + .../__tests__/benchmark-runs-view.test.ts | 143 + .../pages/__tests__/benchmark-utils.test.ts | 89 + .../run-benchmark-wizard-logic.test.ts | 227 + frontend-v2/src/pages/benchmark-runs-view.ts | 115 + frontend-v2/src/pages/benchmark-utils.tsx | 74 + .../src/pages/observability/LlmDashboard.tsx | 1 + .../src/pages/run-benchmark-wizard-logic.ts | 106 + frontend-v2/src/test/mocks/handlers.ts | 37 + frontend-v2/src/types/api-generated.ts | 3647 ++++++++++++----- frontend-v2/src/types/bare-metal.ts | 39 +- frontend-v2/src/types/benchmarks.ts | 43 + frontend-v2/src/types/f5bnk.ts | 43 +- frontend-v2/src/types/index.ts | 4 +- frontend-v2/src/types/kubernetes.ts | 43 +- frontend-v2/src/types/stacks.ts | 10 + mcp-server/Dockerfile | 2 +- mcp-server/pyproject.toml | 7 +- scripts/check-migrations.py | 167 +- scripts/check-schema-parity.py | 267 ++ scripts/publish-signed-images.sh | 15 +- scripts/retry.sh | 62 + vm-bnk-forge/.gitignore | 10 + vm-bnk-forge/README.md | 327 ++ vm-bnk-forge/config.env.example | 43 + vm-bnk-forge/destroy-vm.sh | 31 + vm-bnk-forge/lib/render.sh | 99 + vm-bnk-forge/make-vm.sh | 110 + vm-bnk-forge/render-cloud-init.sh | 62 + vm-bnk-forge/templates/meta-data.tpl | 2 + vm-bnk-forge/templates/user-data.tpl | 158 + 246 files changed, 33917 insertions(+), 2960 deletions(-) create mode 100644 MIN_UPGRADE_FROM delete mode 100644 backend/.dockerignore create mode 100644 backend/alembic/versions/v2_143_add_usecase_artifact_tables.py create mode 100644 backend/alembic/versions/v2_144_bnk_deployable_release.py create mode 100644 backend/alembic/versions/v2_145_kubernetes_cluster_deployable_release.py create mode 100644 backend/alembic/versions/v2_146_bare_metal_host_rshim_mac_base.py create mode 100644 backend/alembic/versions/v2_147_add_benchmark_run_is_baseline.py create mode 100644 backend/alembic/versions/v2_148_release_source.py create mode 100644 backend/alembic/versions/v2_149_kubernetes_cluster_running_release.py create mode 100644 backend/alembic/versions/v2_150_add_bnk_cluster_config.py create mode 100644 backend/alembic/versions/v2_151_add_dpus_kubernetes_cluster_id_index.py create mode 100644 backend/alembic/versions/v2_152_heal_stamped_head_drift.py create mode 100644 backend/models/bnk_deployable_release.py create mode 100644 backend/models/release_source.py create mode 100644 backend/models/usecase_artifact.py create mode 100644 backend/modules/bare_metal/bnk_license.py create mode 100644 backend/routes/bare_metal_deployable_releases.py delete mode 100644 backend/routes/bare_metal_version_profiles.py create mode 100644 backend/routes/release_sources.py create mode 100644 backend/routes/usecase_artifacts.py create mode 100644 backend/schemas/catalog_prune.py create mode 100644 backend/schemas/release_source.py create mode 100644 backend/schemas/usecase_artifact.py create mode 100644 backend/services/bare_metal/deployable_release_refresh.py create mode 100644 backend/services/bare_metal/release_source_oci.py create mode 100644 backend/services/bnk_cluster_service.py create mode 100644 backend/services/catalog_prune_service.py create mode 100644 backend/services/release_source_service.py create mode 100644 backend/services/tmfifo_ipam_service.py create mode 100644 backend/services/usecase_artifact_service.py create mode 100644 backend/tasks/container_reaper.py create mode 100644 backend/tests/component/test_bnk_cluster_config_persistence.py create mode 100644 backend/tests/component/test_bnk_cluster_member_assignment.py create mode 100644 backend/tests/component/test_catalog_prune_db.py create mode 100644 backend/tests/component/test_container_dependency_wiring.py create mode 100644 backend/tests/component/test_release_source_service.py create mode 100644 backend/tests/component/test_release_source_tags.py create mode 100644 backend/tests/component/test_running_release_discovery.py create mode 100644 backend/tests/component/test_usecase_artifact_service.py create mode 100644 backend/tests/contract/test_deployable_release_contracts.py create mode 100644 backend/tests/integration/test_routes_usecase_artifacts.py create mode 100644 backend/tests/unit/test_benchmark_baseline_trends.py create mode 100644 backend/tests/unit/test_bnk_cluster_service.py create mode 100644 backend/tests/unit/test_bnk_license_module.py create mode 100644 backend/tests/unit/test_bnk_ssh_base_apply_retry.py create mode 100644 backend/tests/unit/test_catalog_prune.py create mode 100644 backend/tests/unit/test_container_reaper.py create mode 100644 backend/tests/unit/test_dependency_output_wiring.py create mode 100644 backend/tests/unit/test_flash_dpu_bfb_cache.py create mode 100644 backend/tests/unit/test_flash_dpu_mac_enum.py create mode 100644 backend/tests/unit/test_release_drift_b.py create mode 100644 backend/tests/unit/test_release_source_oci.py create mode 100644 backend/tests/unit/test_tmfifo_ipam_service.py create mode 100644 backend/tests/unit/test_usecase_artifact_service.py create mode 100755 bin/local/bnk-pods.sh create mode 100755 bin/local/forge-api.sh create mode 100755 bin/local/forge-db.sh create mode 100755 bin/local/wait-for.sh create mode 100644 docker-compose.adr424.yml create mode 100644 docs/adr/ADR-424-multi-host-dpu-single-cluster.md create mode 100644 docs/adr/ADR-478-bnk-release-selection.md create mode 100644 docs/adr/ADR-494-bnk-release-management-consolidation.md create mode 100644 docs/adr/D-034-portable-bnk-use-case-artifact.md create mode 100644 docs/adr/D-035-docker-netskope-tls-interception.md create mode 100644 frontend-v2/src/components/bare-metal/__tests__/MultiHostDeployModal.test.tsx create mode 100644 frontend-v2/src/components/catalog/BnkReleasesPanel.tsx create mode 100644 frontend-v2/src/components/catalog/__tests__/BnkReleasesPanel.test.tsx create mode 100644 frontend-v2/src/components/k8s/BnkClusterMemberDialog.tsx create mode 100644 frontend-v2/src/components/k8s/__tests__/BnkClusterMemberDialog.test.tsx create mode 100644 frontend-v2/src/components/stacks/__tests__/StackDetailDialog.multiHost.test.tsx create mode 100644 frontend-v2/src/hooks/__tests__/useReleaseSources.test.ts create mode 100644 frontend-v2/src/hooks/useReleaseSources.ts create mode 100644 frontend-v2/src/lib/__tests__/dpu-device-status.test.ts create mode 100644 frontend-v2/src/lib/api/release-sources.ts create mode 100644 frontend-v2/src/lib/dpu-device-status.ts create mode 100644 frontend-v2/src/pages/BenchmarkOverviewTab.tsx create mode 100644 frontend-v2/src/pages/BenchmarkTrendsView.tsx create mode 100644 frontend-v2/src/pages/RunBenchmarkWizard.tsx create mode 100644 frontend-v2/src/pages/__tests__/BenchmarkOverviewTab.test.tsx create mode 100644 frontend-v2/src/pages/__tests__/benchmark-runs-view.test.ts create mode 100644 frontend-v2/src/pages/__tests__/benchmark-utils.test.ts create mode 100644 frontend-v2/src/pages/__tests__/run-benchmark-wizard-logic.test.ts create mode 100644 frontend-v2/src/pages/benchmark-runs-view.ts create mode 100644 frontend-v2/src/pages/run-benchmark-wizard-logic.ts create mode 100755 scripts/check-schema-parity.py create mode 100755 scripts/retry.sh create mode 100644 vm-bnk-forge/.gitignore create mode 100644 vm-bnk-forge/README.md create mode 100644 vm-bnk-forge/config.env.example create mode 100755 vm-bnk-forge/destroy-vm.sh create mode 100644 vm-bnk-forge/lib/render.sh create mode 100755 vm-bnk-forge/make-vm.sh create mode 100755 vm-bnk-forge/render-cloud-init.sh create mode 100644 vm-bnk-forge/templates/meta-data.tpl create mode 100644 vm-bnk-forge/templates/user-data.tpl 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/.github/workflows/ci.yml b/.github/workflows/ci.yml index e8040a93..4dc2391d 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,173 @@ 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" + frontend-build: name: "P2 · Build · Frontend" needs: [changes, lint-frontend, typecheck-frontend, unit-tests-frontend] @@ -658,7 +841,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 +851,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 +861,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 +1043,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 +1076,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..d874da50 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 @@ -567,3 +570,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..68ee922d 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 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..b105cea1 --- /dev/null +++ b/MIN_UPGRADE_FROM @@ -0,0 +1 @@ +v3.0.1 diff --git a/Makefile b/Makefile index 38ee8631..13e8e6d1 100644 --- a/Makefile +++ b/Makefile @@ -87,7 +87,7 @@ AWSBNKCTL_STAMP := bin/.awsbnkctl-$(AWSBNKCTL_VERSION).stamp 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 \ @@ -257,18 +257,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 +463,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 @@ -746,11 +761,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 @@ -1149,7 +1166,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 +1213,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 +1302,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..67d46671 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,19 +95,26 @@ 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 @@ -119,7 +126,7 @@ RUN mkdir -p /tmp/bnk-forge-logs /tmp/bnk-forge-modules /app/state /app/helm_cha 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..407a2ec5 --- /dev/null +++ b/backend/alembic/versions/v2_152_heal_stamped_head_drift.py @@ -0,0 +1,147 @@ +"""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. Guarded: absent on a create_all + # database, present on a chain-built one. + op.drop_index( + "ix_container_registries_name", + table_name="container_registries", + if_exists=True, + ) + + +def downgrade() -> None: + # Intentionally empty. This revision only ADDS objects that other revisions + # already claim to own — v2_136 owns 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. + pass 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/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/main.py b/backend/main.py index e0ba84ee..3053f384 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__) @@ -132,6 +134,7 @@ async def lifespan(app: FastAPI): 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,6 +167,7 @@ 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), @@ -360,7 +364,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 +405,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..ce9596b1 100644 --- a/backend/models/kubernetes.py +++ b/backend/models/kubernetes.py @@ -1,6 +1,6 @@ """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, event from sqlalchemy.orm import relationship from sqlalchemy.sql import func @@ -56,6 +56,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 +70,91 @@ 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" + ) + + +@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..c31dda8b 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)" @@ -516,6 +603,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 +743,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 +755,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 +830,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()}") diff --git a/backend/modules/bare_metal/setup_dpu_networking.py b/backend/modules/bare_metal/setup_dpu_networking.py index 42fddf1f..1a42169a 100644 --- a/backend/modules/bare_metal/setup_dpu_networking.py +++ b/backend/modules/bare_metal/setup_dpu_networking.py @@ -264,18 +264,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 +347,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 +385,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/openapi.json b/backend/openapi.json index a80f1898..d0e0b77a 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" } } } @@ -14546,7 +15113,9 @@ "description": "Successful Response", "content": { "application/json": { - "schema": {} + "schema": { + "$ref": "#/components/schemas/ClusterDriftStatusResponse" + } } } }, @@ -18039,6 +18608,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 +19377,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 +20712,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 +20734,16 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeployStackRequest", + "default": {} + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -20087,7 +20772,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 +20794,16 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeployStackRequest", + "default": {} + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -23536,14 +24231,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 +24250,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 +24284,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 +24301,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 +24346,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 +24363,15 @@ "type": "integer", "title": "Cluster Id" } + }, + { + "name": "version_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Version Id" + } } ], "requestBody": { @@ -23642,7 +24379,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CreateUpgradePlanRequest" + "$ref": "#/components/schemas/UseCaseDriftRequest" } } } @@ -23652,7 +24389,9 @@ "description": "Successful Response", "content": { "application/json": { - "schema": {} + "schema": { + "$ref": "#/components/schemas/UseCaseDriftResponse" + } } } }, @@ -23669,14 +24408,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,14 +24425,46 @@ "type": "integer", "title": "Cluster 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/current": { + "get": { + "tags": [ + "bnk-upgrade" + ], + "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": "upgrade_id", + "name": "cluster_id", "in": "path", "required": true, "schema": { "type": "integer", - "title": "Upgrade Id" + "title": "Cluster Id" } } ], @@ -23719,14 +24490,14 @@ } } }, - "/api/k8s/clusters/{cluster_id}/bnk/upgrade/{upgrade_id}/rollback": { + "/api/k8s/clusters/{cluster_id}/bnk/upgrade/plan": { "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", + "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", @@ -23736,17 +24507,18 @@ "type": "integer", "title": "Cluster Id" } - }, - { - "name": "upgrade_id", - "in": "path", - "required": true, - "schema": { - "type": "integer", - "title": "Upgrade Id" - } } ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateUpgradePlanRequest" + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -23769,14 +24541,114 @@ } } }, - "/api/k8s/clusters/{cluster_id}/bnk/upgrade/{upgrade_id}/cancel": { + "/api/k8s/clusters/{cluster_id}/bnk/upgrade/{upgrade_id}/execute": { "post": { "tags": [ "bnk-upgrade" ], - "summary": "Cancel Upgrade", - "description": "Cancel a pending/ready upgrade plan.", - "operationId": "cancel_upgrade_api_k8s_clusters__cluster_id__bnk_upgrade__upgrade_id__cancel_post", + "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": { + "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}/cancel": { + "post": { + "tags": [ + "bnk-upgrade" + ], + "summary": "Cancel Upgrade", + "description": "Cancel a pending/ready upgrade plan.", + "operationId": "cancel_upgrade_api_k8s_clusters__cluster_id__bnk_upgrade__upgrade_id__cancel_post", "parameters": [ { "name": "cluster_id", @@ -26265,6 +27137,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 +27745,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 +30743,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 +31584,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 +31662,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 +32171,17 @@ } ], "title": "Bond Mode" + }, + "net_rshim_mac_base": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Net Rshim Mac Base" } }, "type": "object", @@ -31508,6 +32629,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 +32961,17 @@ } ], "title": "Bond Mode" + }, + "net_rshim_mac_base": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Net Rshim Mac Base" } }, "type": "object", @@ -32310,6 +33453,11 @@ "additionalProperties": true, "type": "object", "title": "Winners" + }, + "context_mismatch": { + "type": "boolean", + "title": "Context Mismatch", + "default": false } }, "type": "object", @@ -32349,6 +33497,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 +34302,17 @@ ], "title": "Proxy Deployment Id" }, + "scenario_key": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Scenario Key" + }, "status": { "type": "string", "title": "Status" @@ -33136,6 +34328,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 +34710,17 @@ ], "title": "Proxy Deployment Id" }, + "scenario_key": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Scenario Key" + }, "status": { "type": "string", "title": "Status" @@ -33506,6 +34736,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 +35510,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 +36101,13 @@ ], "title": "Notes" }, + "url_warnings": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Url Warnings" + }, "created_at": { "type": "string", "format": "date-time", @@ -35598,6 +37012,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 +37243,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 +38800,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 +38864,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 +39359,16 @@ ], "title": "Enabled Prerequisites" }, + "bnk_config": { + "anyOf": [ + { + "$ref": "#/components/schemas/BnkClusterConfigSummary" + }, + { + "type": "null" + } + ] + }, "node_count": { "anyOf": [ { @@ -37830,6 +39380,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": [ { @@ -40256,6 +41828,214 @@ "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" + }, "DeploymentPlanPreview": { "properties": { "topology": { @@ -42315,6 +44095,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 +45555,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 +48487,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": { @@ -51992,6 +53926,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 +54053,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 +55154,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 +55318,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 +59347,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 +60385,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..627924c1 100644 --- a/backend/routes/benchmarks.py +++ b/backend/routes/benchmarks.py @@ -55,6 +55,7 @@ BenchmarkTargetListResponse, BenchmarkTargetResponse, BenchmarkTargetUpdate, + BenchmarkTrendsResponse, DiscoverTargetsRequest, DiscoverTargetsResponse, ImportAwsJumphostRequest, @@ -341,6 +342,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 +718,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) # ============================================================================ 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/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..0975d2cb 100644 --- a/backend/routes/k8s/_shared.py +++ b/backend/routes/k8s/_shared.py @@ -22,10 +22,18 @@ # 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, +) -> 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). """ platform_context = PlatformContextService.serialize_cluster_context(cluster) @@ -55,12 +63,66 @@ 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 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/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/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..2e23fcdf --- /dev/null +++ b/backend/services/bnk_cluster_service.py @@ -0,0 +1,440 @@ +"""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_all_clusters / list_project_clusters: + 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_management_service.py b/backend/services/cluster_management_service.py index 0bc7342b..703e92d4 100644 --- a/backend/services/cluster_management_service.py +++ b/backend/services/cluster_management_service.py @@ -348,19 +348,47 @@ 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).""" + 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] + from services.bnk_cluster_service import BnkClusterService + + clusters = ( + self.db.query(KubernetesCluster) + .options(selectinload(KubernetesCluster.bnk_config)) + .all() + ) + # Bulk-fetch membership for all BNK clusters in 2 queries (not 2N). + # selectinload(bnk_config) already avoids the config N+1; this bulk + # call eliminates the host+DPU membership N+1 in _serialize_bnk_config. + 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, membership=membership_map.get(c.id)) + 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.""" + 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]: @@ -377,6 +405,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 @@ -484,6 +515,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/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/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/container_engine.py b/backend/services/execution/container_engine.py index 2a6c10ca..b4f65e95 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. @@ -384,6 +388,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 diff --git a/backend/services/execution/container_runner.py b/backend/services/execution/container_runner.py index 3c579db1..efb18b33 100644 --- a/backend/services/execution/container_runner.py +++ b/backend/services/execution/container_runner.py @@ -33,9 +33,11 @@ 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 @@ -57,6 +59,26 @@ # so this network keeps NAT egress while isolating them from other containers. DEFAULT_ARTIFACT_NETWORK = "bnk-forge-artifacts" +# 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 + # 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"} @@ -65,6 +87,22 @@ _ENV_KEY_RE = re.compile(r"^[A-Za-z_][A-Za-z0-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 +162,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 +221,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 +255,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 +348,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. @@ -365,11 +607,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 +635,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 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() + 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 + ) + + 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 +758,134 @@ 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 health_check(self) -> bool: """Return True when the docker CLI can reach the daemon via the proxy.""" try: diff --git a/backend/services/execution/variable_assembler.py b/backend/services/execution/variable_assembler.py index f5812e8e..472b57a7 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 @@ -550,97 +573,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 +736,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 +765,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 +837,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/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/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/project_service.py b/backend/services/project_service.py index cc785c49..5b734390 100644 --- a/backend/services/project_service.py +++ b/backend/services/project_service.py @@ -577,11 +577,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 +614,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 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/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_service.py b/backend/services/stack_service.py index dfa23bde..adb2d9cd 100644 --- a/backend/services/stack_service.py +++ b/backend/services/stack_service.py @@ -799,7 +799,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 +848,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 +890,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 +901,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: 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..ba822339 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 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..f9a2dfdb 100644 --- a/backend/tasks/container_tasks.py +++ b/backend/tasks/container_tasks.py @@ -119,7 +119,10 @@ 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, +) -> 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 +176,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,6 +251,7 @@ 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, @@ -373,7 +431,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 +505,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 +565,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 +639,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 +701,7 @@ 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) lines: list[str] = [] header = f"=== CONTAINER ENGINE ACTION '{action}' ===\nModule: {ctx.path}" result = engine.run_action( @@ -710,7 +776,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) diff --git a/backend/tasks/ssh_tasks.py b/backend/tasks/ssh_tasks.py index 524910b9..6b7ef252 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( 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..9369b980 --- /dev/null +++ b/backend/tests/component/test_bnk_cluster_member_assignment.py @@ -0,0 +1,806 @@ +"""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 + ): + """GET /api/k8s/clusters renders bnk_config.host_ids / dpu_ids correctly + for a cluster that has a BnkClusterConfig with members assigned. + + Verifies _serialize_bnk_config branch (bnk_config present) and that + host IDs do not leak into the dpu_ids bucket (B6). + + 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 + + # Fetch the cluster list — exercises bulk_cluster_membership + serialize_cluster. + resp = client.get("/api/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 for a BNK cluster" + + 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']}" + ) + + +# --------------------------------------------------------------------------- +# 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..d451a3af 100644 --- a/backend/tests/component/test_cluster_management_service.py +++ b/backend/tests/component/test_cluster_management_service.py @@ -273,6 +273,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 @@ -389,6 +440,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_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_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_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_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/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..a92224e8 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) 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_projects.py b/backend/tests/integration/test_routes_projects.py index fa098b5d..061a83e1 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( 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/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_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_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..0f99538c 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, @@ -160,46 +167,155 @@ def test_relative_mount_path_rejected(self): runner.build_run_argv(_spec(mount_path="state")) -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. +class _FakeDocker: + """Stand-in for every ``docker`` call one step makes. - 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. + 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} - return patch("subprocess.run", side_effect=_fake_run) + def _sleep(seconds): + now["t"] += seconds + + 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 @@ -218,38 +334,36 @@ def test_non_root_users_pass(self, user): 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 +377,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 +397,424 @@ 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" 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_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_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/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/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.yml b/docker-compose.yml index fac827eb..f24156c3 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 @@ -161,7 +162,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 +343,8 @@ services: celery-beat: build: - context: ./backend + context: . + dockerfile: backend/Dockerfile target: beat image: bnk-forge-beat:latest container_name: bnk-forge-celery-beat diff --git a/docs/API_REFERENCE.md b/docs/API_REFERENCE.md index c397f820..0960e6d7 100644 --- a/docs/API_REFERENCE.md +++ b/docs/API_REFERENCE.md @@ -271,6 +271,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/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.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/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} + + )} + +
    +