diff --git a/.github/workflows/garm-vm-pre-b4-static.yml b/.github/workflows/garm-vm-pre-b4-static.yml new file mode 100644 index 000000000..c947e9bfe --- /dev/null +++ b/.github/workflows/garm-vm-pre-b4-static.yml @@ -0,0 +1,71 @@ +name: GARM VM pre-B4 static consumer + +on: + pull_request: + paths: + - .github/workflows/garm-vm-pre-b4-static.yml + - scripts/truenas_garm_vm_pre_b4_fixture.py + - tests/test_truenas_garm_vm_pre_b4.py + - scripts/truenas_garm_vm_template_preflight.py + - tests/test_truenas_garm_vm_template_preflight.py + +permissions: + contents: read + +jobs: + vm-fixture-static: + runs-on: ubuntu-24.04 + timeout-minutes: 12 + steps: + - name: Checkout Agent Dispatch consumer + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + persist-credentials: false + - name: Compile and adversarial source tests + run: | + set -euo pipefail + python3 -m py_compile scripts/truenas_garm_vm_pre_b4_fixture.py tests/test_truenas_garm_vm_pre_b4.py scripts/truenas_garm_vm_template_preflight.py tests/test_truenas_garm_vm_template_preflight.py + python3 -m unittest -v tests/test_truenas_garm_vm_pre_b4.py tests/test_truenas_garm_vm_template_preflight.py + - name: Export exact public GARM VM source fixture + env: + PROVIDER_REF: 5944d1dc62b4da2a247dda9754cfe674caedf5cf + run: | + set -euo pipefail + mkdir -p "$RUNNER_TEMP/garm-vm-provider" "$RUNNER_TEMP/garm-vm-evidence" + git -C "$RUNNER_TEMP/garm-vm-provider" init -q + git -C "$RUNNER_TEMP/garm-vm-provider" fetch -q --depth=1 \ + https://github.com/SemperSupra/garm-provider-truenas.git "$PROVIDER_REF" + git -C "$RUNNER_TEMP/garm-vm-provider" checkout -q --detach FETCH_HEAD + test "$(git -C "$RUNNER_TEMP/garm-vm-provider" rev-parse HEAD)" = "$PROVIDER_REF" + ( + cd "$RUNNER_TEMP/garm-vm-provider" + GARM_VM_FIXTURE_OUT="$RUNNER_TEMP/garm-vm-evidence/garm-provider-vm-pre-b4-fixture.json" \ + GARM_VM_FIXTURE_SOURCE="$PROVIDER_REF" \ + go test ./internal/vmbackend -run '^TestExportNestedVMPreB4Fixture$' -count=1 + ) + - name: Independently validate exact exported fixture without runtime mutation + run: | + set -euo pipefail + python3 scripts/truenas_garm_vm_pre_b4_fixture.py \ + --fixture "$RUNNER_TEMP/garm-vm-evidence/garm-provider-vm-pre-b4-fixture.json" \ + --producer-commit 5944d1dc62b4da2a247dda9754cfe674caedf5cf \ + --out "$RUNNER_TEMP/garm-vm-evidence/consumer-static-receipt.json" + python3 - "$RUNNER_TEMP/garm-vm-evidence/consumer-static-receipt.json" <<'PY' + import json, sys + r = json.load(open(sys.argv[1])) + assert r["classification"] == "STATIC_CONTRACT_PASS" + assert r["source_only"] is True + assert r["vm_runtime_admitted"] is False + assert r["guest_boot_observed"] is False + assert r["bootstrap_consumption_observed"] is False + assert r["github_jit_exercised"] is False + assert r["zero_residue_runtime_proven"] is False + PY + - name: Retain only sanitized static consumer receipt + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: garm-vm-pre-b4-static-receipt + path: ${{ runner.temp }}/garm-vm-evidence/consumer-static-receipt.json + if-no-files-found: warn + retention-days: 7 diff --git a/.github/workflows/gha-kvm-system-rdte.yml b/.github/workflows/gha-kvm-system-rdte.yml new file mode 100644 index 000000000..e5a05d968 --- /dev/null +++ b/.github/workflows/gha-kvm-system-rdte.yml @@ -0,0 +1,927 @@ +name: Real-system KVM RDTE + +on: + push: + branches: + - experiment/gha-kvm-system-rdte + workflow_dispatch: + inputs: + target: + description: "Heavy target to exercise" + required: true + default: both + type: choice + options: + - both + - proxmox + - truenas + truenas_version: + description: "Exact TrueNAS target version" + required: true + default: "26.0.0-BETA.3" + type: choice + options: + - "26.0.0-BETA.3" + - "25.10.7" + - "25.04.2.6" + - "25.04.1" + truenas_rung: + description: "TrueNAS system qualification rung" + required: true + default: t6 + type: choice + options: + - t0 + - t1 + - t2 + - t3 + - t4 + - t5 + - t6 + truenas_t6_product: + description: "Exact T6 Foundry consumer" + required: true + default: litellm + type: choice + options: + - litellm + - wow-sidecar + - garm + - garm-controller-f0f5 + - garm-provider-g2 + - garm-provider-g3 + - garm-provider-g4 + - garm-provider-g5 + - garm-provider-container-pre-b4 + - official-catalog + - foliorelay + - minecraft-paper + - collage-native-paper + - minecraft-vanilla-263 + proxmox_compute_fixture: + description: "Optional native PVE REST compute fixture" + required: true + default: none + type: choice + options: + - none + - container-c0 + - vm-v0 + truenas_compute_fixture: + description: "Optional compute fixture layered on the selected system rung" + required: true + default: none + type: choice + options: + - none + - container-c0 + - vm-v0 + - vm-v1 + +permissions: + contents: read + +jobs: + changes: + runs-on: ubuntu-24.04 + timeout-minutes: 2 + outputs: + proxmox: ${{ steps.scope.outputs.proxmox }} + truenas: ${{ steps.scope.outputs.truenas }} + qemu_topology: ${{ steps.scope.outputs.qemu_topology }} + truenas_version: ${{ steps.scope.outputs.truenas_version }} + truenas_rung: ${{ steps.scope.outputs.truenas_rung }} + truenas_t6_product: ${{ steps.scope.outputs.truenas_t6_product }} + truenas_collage_readback: ${{ steps.scope.outputs.truenas_collage_readback }} + truenas_lantern_stage: ${{ steps.scope.outputs.truenas_lantern_stage }} + truenas_world_rollback: ${{ steps.scope.outputs.truenas_world_rollback }} + truenas_native_offline_player: ${{ steps.scope.outputs.truenas_native_offline_player }} + truenas_native_backend_region: ${{ steps.scope.outputs.truenas_native_backend_region }} + truenas_compute_fixture: ${{ steps.scope.outputs.truenas_compute_fixture }} + proxmox_compute_fixture: ${{ steps.scope.outputs.proxmox_compute_fixture }} + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + persist-credentials: false + fetch-depth: 0 + - id: scope + env: + EVENT_NAME: ${{ github.event_name }} + DISPATCH_TARGET: ${{ inputs.target }} + DISPATCH_TRUENAS_VERSION: ${{ inputs.truenas_version }} + DISPATCH_TRUENAS_RUNG: ${{ inputs.truenas_rung }} + DISPATCH_TRUENAS_T6_PRODUCT: ${{ inputs.truenas_t6_product }} + DISPATCH_TRUENAS_COMPUTE_FIXTURE: ${{ inputs.truenas_compute_fixture }} + DISPATCH_PROXMOX_COMPUTE_FIXTURE: ${{ inputs.proxmox_compute_fixture }} + BEFORE_SHA: ${{ github.event.before }} + AFTER_SHA: ${{ github.sha }} + RUN_REQUEST_PATH: config/truenas-rdte-run-request.json + PROXMOX_RUN_REQUEST_PATH: config/proxmox-rdte-run-request.json + run: | + set -euo pipefail + proxmox=false + truenas=false + qemu_topology=false + truenas_version="${DISPATCH_TRUENAS_VERSION:-26.0.0-BETA.3}" + truenas_rung="${DISPATCH_TRUENAS_RUNG:-t6}" + truenas_t6_product="${DISPATCH_TRUENAS_T6_PRODUCT:-litellm}" + truenas_collage_readback=false + truenas_lantern_stage=false + truenas_world_rollback=false + truenas_native_offline_player=false + truenas_native_backend_region=false + truenas_compute_fixture="${DISPATCH_TRUENAS_COMPUTE_FIXTURE:-none}" + proxmox_compute_fixture="${DISPATCH_PROXMOX_COMPUTE_FIXTURE:-none}" + if [[ "$EVENT_NAME" == "workflow_dispatch" ]]; then + case "$DISPATCH_TARGET" in + proxmox) proxmox=true ;; + truenas) truenas=true ;; + both) proxmox=true; truenas=true; qemu_topology=true ;; + *) echo "unsupported dispatch target: $DISPATCH_TARGET" >&2; exit 2 ;; + esac + elif [[ -n "$BEFORE_SHA" && -n "$AFTER_SHA" && ! "$BEFORE_SHA" =~ ^0+$ ]]; then + while IFS= read -r path; do + case "$path" in + config/truenas-rdte-run-request.json) + # Heavy TrueNAS work is request-edge driven. Harness/probe/workflow + # changes alone must never replay the previously committed request. + truenas=true + ;; + config/proxmox-rdte-run-request.json) + # Heavy Proxmox work is request-edge driven. Harness/probe/workflow + # changes alone must never launch or replay a heavyweight PVE rep. + proxmox=true + ;; + scripts/gha_qemu_t3_topology_probe.py|.github/workflows/gha-kvm-system-rdte.yml) + qemu_topology=true + ;; + esac + done < <(git diff --name-only "$BEFORE_SHA" "$AFTER_SHA") + else + proxmox=true + truenas=true + qemu_topology=true + fi + if [[ "$EVENT_NAME" != "workflow_dispatch" && "$proxmox" == "true" ]]; then + [[ -f "$PROXMOX_RUN_REQUEST_PATH" ]] || { echo "missing Proxmox run request" >&2; exit 2; } + jq -e '.schema == "gha-kvm-proxmox-run-request/v1"' "$PROXMOX_RUN_REQUEST_PATH" >/dev/null + current_proxmox_request_id="$(jq -er '.request_id | select(type == "string" and length > 0)' "$PROXMOX_RUN_REQUEST_PATH")" + previous_proxmox_request_id="" + if git cat-file -e "$BEFORE_SHA:$PROXMOX_RUN_REQUEST_PATH" 2>/dev/null; then + previous_proxmox_request_id="$(git show "$BEFORE_SHA:$PROXMOX_RUN_REQUEST_PATH" | jq -er '.request_id | select(type == "string" and length > 0)')" + fi + if [[ -n "$previous_proxmox_request_id" && "$current_proxmox_request_id" == "$previous_proxmox_request_id" ]]; then + echo "refusing stale Proxmox request replay: request_id did not change ($current_proxmox_request_id)" >&2 + exit 2 + fi + proxmox_version="$(jq -er '.version | select(type == "string" and length > 0)' "$PROXMOX_RUN_REQUEST_PATH")" + proxmox_target_json="$(python3 scripts/proxmox_rdte_target.py --registry config/proxmox-rdte-targets.json --version "$proxmox_version" --require-runtime-admitted)" + proxmox_target_authority="$(jq -er '.authority_issue' <<<"$proxmox_target_json")" + proxmox_request_authority="$(jq -er '.authority_issue' "$PROXMOX_RUN_REQUEST_PATH")" + [[ "$proxmox_request_authority" == "$proxmox_target_authority" ]] || { + echo "Proxmox request authority does not match exact target profile" >&2 + exit 2 + } + proxmox_compute_fixture="$(jq -er '(.compute_fixture // "none") | select(. == "none" or . == "container-c0" or . == "vm-v0")' "$PROXMOX_RUN_REQUEST_PATH")" + printf 'fresh Proxmox request edge: previous=%s current=%s\n' "${previous_proxmox_request_id:-}" "$current_proxmox_request_id" + fi + if [[ "$EVENT_NAME" != "workflow_dispatch" && "$truenas" == "true" ]]; then + jq -e '.schema == "gha-kvm-truenas-run-request/v1"' "$RUN_REQUEST_PATH" >/dev/null + current_request_id="$(jq -er '.request_id | select(type == "string" and length > 0)' "$RUN_REQUEST_PATH")" + previous_request_id="" + if git cat-file -e "$BEFORE_SHA:$RUN_REQUEST_PATH" 2>/dev/null; then + previous_request_id="$(git show "$BEFORE_SHA:$RUN_REQUEST_PATH" | jq -er '.request_id | select(type == "string" and length > 0)')" + fi + if [[ -n "$previous_request_id" && "$current_request_id" == "$previous_request_id" ]]; then + echo "refusing stale TrueNAS request replay: request_id did not change ($current_request_id)" >&2 + exit 2 + fi + truenas_version="$(jq -er '.version | select(type == "string" and length > 0)' "$RUN_REQUEST_PATH")" + truenas_rung="$(jq -er '.rung | select(. == "t0" or . == "t1" or . == "t2" or . == "t3" or . == "t4" or . == "t5" or . == "t6")' "$RUN_REQUEST_PATH")" + truenas_t6_product="$(jq -er '(.product // "litellm") | select(. == "litellm" or . == "wow-sidecar" or . == "garm" or . == "garm-controller-f0f5" or . == "garm-provider-g2" or . == "garm-provider-g3" or . == "garm-provider-g4" or . == "garm-provider-g5" or . == "garm-provider-container-pre-b4" or . == "official-catalog" or . == "foliorelay" or . == "minecraft-paper" or . == "collage-native-paper" or . == "minecraft-vanilla-263")' "$RUN_REQUEST_PATH")" + truenas_compute_fixture="$(jq -er '(.compute_fixture // "none") | select(. == "none" or . == "container-c0" or . == "vm-v0" or . == "vm-v1")' "$RUN_REQUEST_PATH")" + truenas_collage_readback="$(jq -er '(.collage_readback // false) | select(. == true or . == false)' "$RUN_REQUEST_PATH")" + truenas_lantern_stage="$(jq -er '(.lantern_stage // false) | select(. == true or . == false)' "$RUN_REQUEST_PATH")" + truenas_world_rollback="$(jq -er '(.native_world_rollback // false) | select(. == true or . == false)' "$RUN_REQUEST_PATH")" + truenas_native_offline_player="$(jq -er '(.native_offline_player // false) | select(. == true or . == false)' "$RUN_REQUEST_PATH")" + truenas_native_backend_region="$(jq -er '(.native_backend_region // false) | select(. == true or . == false)' "$RUN_REQUEST_PATH")" + if [[ "$truenas_native_backend_region" == "true" && + "$truenas_native_offline_player" != "true" ]]; then + echo "G4 region requires native offline player" >&2; exit 2 + fi + if [[ "$truenas_native_offline_player" == "true" ]]; then + [[ "$truenas_world_rollback" == "true" && "$truenas_lantern_stage" == "true" && + "$truenas_collage_readback" == "true" && "$truenas_version" == "25.04.2.6" && + "$truenas_rung" == "t6" && "$truenas_t6_product" == "minecraft-vanilla-263" ]] || { + echo "G4 only admitted in exact disposable native G2+G3 player profile" >&2; exit 2; + } + fi + if [[ "$truenas_world_rollback" == "true" ]]; then + [[ "$truenas_lantern_stage" == "true" && "$truenas_collage_readback" == "true" && + "$truenas_t6_product" == "minecraft-vanilla-263" && "$truenas_version" == "25.04.2.6" && "$truenas_rung" == "t6" ]] || { + echo "native rollback only allowed in one exact G2+Lantern disposable fixture" >&2; exit 2; + } + fi + if [[ "$truenas_lantern_stage" == "true" ]]; then + [[ "$truenas_collage_readback" == "true" && "$truenas_t6_product" == "minecraft-vanilla-263" && + "$truenas_version" == "25.04.2.6" && "$truenas_rung" == "t6" ]] || { + echo "native Lantern stage requires exact one disposable vanilla263 T6 + read-only COLLAGE" >&2; exit 2; + } + fi + if [[ "$truenas_collage_readback" == "true" ]]; then + [[ "$truenas_t6_product" == "minecraft-vanilla-263" && "$truenas_version" == "25.04.2.6" && "$truenas_rung" == "t6" ]] || { + echo "read-only COLLAGE observer requires exact native vanilla263/25.04.2.6/T6" >&2; exit 2; + } + fi + python3 scripts/truenas_rdte_target.py \ + --registry config/truenas-rdte-targets.json \ + --version "$truenas_version" >/dev/null + printf 'fresh TrueNAS request edge: previous=%s current=%s\n' "${previous_request_id:-}" "$current_request_id" + fi + if [[ "$truenas_compute_fixture" != "none" && "$truenas_rung" != "t3" ]]; then + echo "compute fixture $truenas_compute_fixture requires truenas rung t3" >&2 + exit 2 + fi + echo "proxmox=$proxmox" >>"$GITHUB_OUTPUT" + echo "truenas=$truenas" >>"$GITHUB_OUTPUT" + echo "qemu_topology=$qemu_topology" >>"$GITHUB_OUTPUT" + echo "truenas_version=$truenas_version" >>"$GITHUB_OUTPUT" + echo "truenas_rung=$truenas_rung" >>"$GITHUB_OUTPUT" + echo "truenas_t6_product=$truenas_t6_product" >>"$GITHUB_OUTPUT" + echo "truenas_collage_readback=$truenas_collage_readback" >>"$GITHUB_OUTPUT" + echo "truenas_lantern_stage=$truenas_lantern_stage" >>"$GITHUB_OUTPUT" + echo "truenas_world_rollback=$truenas_world_rollback" >>"$GITHUB_OUTPUT" + echo "truenas_native_offline_player=$truenas_native_offline_player" >>"$GITHUB_OUTPUT" + echo "truenas_native_backend_region=$truenas_native_backend_region" >>"$GITHUB_OUTPUT" + echo "truenas_compute_fixture=$truenas_compute_fixture" >>"$GITHUB_OUTPUT" + echo "proxmox_compute_fixture=$proxmox_compute_fixture" >>"$GITHUB_OUTPUT" + printf 'target scope: proxmox=%s proxmox_compute_fixture=%s truenas=%s qemu_topology=%s truenas_version=%s truenas_rung=%s t6_product=%s truenas_compute_fixture=%s\n' "$proxmox" "$proxmox_compute_fixture" "$truenas" "$qemu_topology" "$truenas_version" "$truenas_rung" "$truenas_t6_product" "$truenas_compute_fixture" + + contract: + runs-on: ubuntu-24.04 + timeout-minutes: 5 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + persist-credentials: false + - name: Validate public-safe contract + run: python3 -m unittest -v tests/test_gha_kvm_system_rdte.py tests/test_truenas_future_protocols.py tests/test_truenas_t6_litellm_control.py tests/test_truenas_t6_wow_sidecar_control.py tests/test_truenas_t6_garm_control.py tests/test_truenas_garm_provider_g2.py tests/test_truenas_garm_provider_g3.py tests/test_truenas_garm_provider_g4.py tests/test_truenas_garm_provider_g5.py tests/test_truenas_garm_container_pre_b4.py tests/test_truenas_garm_controller_f0_f5.py tests/test_truenas_t6_official_catalog_control.py tests/test_truenas_t6_foliorelay_control.py tests/test_truenas_rdte_target.py tests/test_truenas_session_runner.py tests/test_minecraft_t6_receipt_gate.py + + foundry-t6-control: + needs: [changes, contract] + if: needs.contract.result == 'success' && needs.changes.outputs.truenas == 'true' && needs.changes.outputs.truenas_rung == 't6' && needs.changes.outputs.truenas_t6_product == 'litellm' + uses: SemperSupra/truenas-app-foundry/.github/workflows/export-litellm-t6-control.yml@4ba12f4a870f9af8a667056f1e2cc32f80f8e2ba + with: + foundry_ref: 4ba12f4a870f9af8a667056f1e2cc32f80f8e2ba + permissions: + contents: read + + foundry-wow-t6-control: + needs: [changes, contract] + if: needs.contract.result == 'success' && needs.changes.outputs.truenas == 'true' && needs.changes.outputs.truenas_rung == 't6' && needs.changes.outputs.truenas_t6_product == 'wow-sidecar' + uses: SemperSupra/truenas-app-foundry/.github/workflows/export-wow-sidecar-t6-control.yml@9ac2eef97581caff5b0de740560c55a778fa3f1c + with: + foundry_ref: 9ac2eef97581caff5b0de740560c55a778fa3f1c + permissions: + contents: read + + foundry-garm-t6-control: + needs: [changes, contract] + if: needs.contract.result == 'success' && needs.changes.outputs.truenas == 'true' && needs.changes.outputs.truenas_rung == 't6' && needs.changes.outputs.truenas_t6_product == 'garm' + uses: SemperSupra/truenas-app-foundry/.github/workflows/export-garm-t6-control.yml@7fd0d2474248b61cf8e290fc80f40b761cea5e98 + with: + foundry_ref: 7fd0d2474248b61cf8e290fc80f40b761cea5e98 + permissions: + contents: read + + foundry-garm-f0-f5-control: + needs: [changes, contract] + if: needs.contract.result == 'success' && needs.changes.outputs.truenas == 'true' && needs.changes.outputs.truenas_rung == 't6' && needs.changes.outputs.truenas_t6_product == 'garm-controller-f0f5' + uses: SemperSupra/truenas-app-foundry/.github/workflows/export-garm-f0-f5-control.yml@bfbf2158c3f679c3422e6035bdb67e7874a5c07a + with: + foundry_ref: bfbf2158c3f679c3422e6035bdb67e7874a5c07a + target_version: ${{ needs.changes.outputs.truenas_version }} + permissions: + contents: read + + foundry-official-catalog-t6-control: + needs: [changes, contract] + if: needs.contract.result == 'success' && needs.changes.outputs.truenas == 'true' && needs.changes.outputs.truenas_rung == 't6' && needs.changes.outputs.truenas_t6_product == 'official-catalog' + uses: SemperSupra/truenas-app-foundry/.github/workflows/export-official-catalog-t6-control.yml@60d56cc1752bc825cb232d72b397ff17fe6d47c4 + with: + foundry_ref: 60d56cc1752bc825cb232d72b397ff17fe6d47c4 + control_id: ntfy + permissions: + contents: read + + foundry-foliorelay-t6-control: + needs: [changes, contract] + if: needs.contract.result == 'success' && needs.changes.outputs.truenas == 'true' && needs.changes.outputs.truenas_rung == 't6' && needs.changes.outputs.truenas_t6_product == 'foliorelay' + uses: SemperSupra/truenas-app-foundry/.github/workflows/export-foliorelay-t6-control.yml@81deb97185760975fd8d3162df42056c77b3c0fd + with: + foundry_ref: 81deb97185760975fd8d3162df42056c77b3c0fd + target_version: ${{ needs.changes.outputs.truenas_version }} + permissions: + contents: read + + foundry-minecraft-paper-t6-control: + needs: [changes, contract] + if: needs.contract.result == 'success' && needs.changes.outputs.truenas == 'true' && needs.changes.outputs.truenas_rung == 't6' && needs.changes.outputs.truenas_t6_product == 'minecraft-paper' + uses: SemperSupra/truenas-app-foundry/.github/workflows/export-minecraft-paper-t6-control.yml@2cc7e1fe3772f8c532e933b3d7832e6cf985db4e + with: + foundry_ref: 2cc7e1fe3772f8c532e933b3d7832e6cf985db4e + target_version: "25.04.1" + permissions: + contents: read + + foundry-minecraft-vanilla263-t6-control: + needs: [changes, contract] + if: needs.contract.result == 'success' && needs.changes.outputs.truenas == 'true' && needs.changes.outputs.truenas_rung == 't6' && needs.changes.outputs.truenas_t6_product == 'minecraft-vanilla-263' && needs.changes.outputs.truenas_version == '25.04.2.6' + uses: SemperSupra/truenas-app-foundry/.github/workflows/export-minecraft-vanilla-263-11531-t6-control.yml@5ee204df205edfd531304441fd1a6a24004557b1 + with: + foundry_ref: 5ee204df205edfd531304441fd1a6a24004557b1 + target_version: "25.04.2.6" + permissions: + contents: read + + foundry-collage-paper-11530-t6-control: + needs: [changes, contract] + if: needs.contract.result == 'success' && needs.changes.outputs.truenas == 'true' && needs.changes.outputs.truenas_rung == 't6' && needs.changes.outputs.truenas_t6_product == 'collage-native-paper' + uses: SemperSupra/truenas-app-foundry/.github/workflows/export-minecraft-paper-11530-t6-control.yml@403321031b6d4e66aa64a8299fd7621eee290b0d + with: + foundry_ref: 403321031b6d4e66aa64a8299fd7621eee290b0d + target_version: "25.04.1" + permissions: + contents: read + + provider-g2-fixtures: + needs: [changes, contract] + if: needs.contract.result == 'success' && needs.changes.outputs.truenas == 'true' && needs.changes.outputs.truenas_rung == 't6' && needs.changes.outputs.truenas_t6_product == 'garm-provider-g2' + uses: SemperSupra/garm-provider-truenas/.github/workflows/export-g2-nested-fixtures.yml@1667aa73e514c278e221082ac33f2f10a4141eef + with: + provider_ref: 1667aa73e514c278e221082ac33f2f10a4141eef + permissions: + contents: read + + provider-g3-fixture: + needs: [changes, contract] + if: needs.contract.result == 'success' && needs.changes.outputs.truenas == 'true' && needs.changes.outputs.truenas_rung == 't6' && needs.changes.outputs.truenas_t6_product == 'garm-provider-g3' + uses: SemperSupra/garm-provider-truenas/.github/workflows/export-g3-nested-fixture.yml@e92d4024e6e60c0e2f8bcf5301c11fccb14eaaab + with: + provider_ref: e92d4024e6e60c0e2f8bcf5301c11fccb14eaaab + permissions: + contents: read + + provider-g4-fixture: + needs: [changes, contract] + if: needs.contract.result == 'success' && needs.changes.outputs.truenas == 'true' && needs.changes.outputs.truenas_rung == 't6' && needs.changes.outputs.truenas_t6_product == 'garm-provider-g4' + uses: SemperSupra/garm-provider-truenas/.github/workflows/export-g4-nested-fixture.yml@e5fc7c50780de54f7579f64990c87b2e16482613 + with: + provider_ref: e5fc7c50780de54f7579f64990c87b2e16482613 + permissions: + contents: read + + provider-g5-runner-fixture: + needs: [changes, contract] + if: needs.contract.result == 'success' && needs.changes.outputs.truenas == 'true' && needs.changes.outputs.truenas_rung == 't6' && needs.changes.outputs.truenas_t6_product == 'garm-provider-g5' + uses: SemperSupra/garm-provider-truenas/.github/workflows/export-g3-nested-fixture.yml@e92d4024e6e60c0e2f8bcf5301c11fccb14eaaab + with: + provider_ref: e92d4024e6e60c0e2f8bcf5301c11fccb14eaaab + permissions: + contents: read + + provider-g5-version-matrix: + needs: [changes, contract] + if: needs.contract.result == 'success' && needs.changes.outputs.truenas == 'true' && needs.changes.outputs.truenas_rung == 't6' && needs.changes.outputs.truenas_t6_product == 'garm-provider-g5' + uses: SemperSupra/garm-provider-truenas/.github/workflows/export-g5-version-matrix.yml@f83a712e095c6962741f93cf86bea69479ce4d4a + with: + provider_ref: f83a712e095c6962741f93cf86bea69479ce4d4a + permissions: + contents: read + + provider-container-pre-b4-fixture: + needs: [changes, contract] + if: needs.contract.result == 'success' && needs.changes.outputs.truenas == 'true' && needs.changes.outputs.truenas_rung == 't6' && needs.changes.outputs.truenas_t6_product == 'garm-provider-container-pre-b4' + uses: SemperSupra/garm-provider-truenas/.github/workflows/export-container-b0-b5-fixture.yml@9f3c86169320b6a2330bbb345d25a41c736c73c2 + with: + provider_ref: 9f3c86169320b6a2330bbb345d25a41c736c73c2 + permissions: + contents: read + + qemu-t3-topology: + needs: [changes, contract] + if: needs.contract.result == 'success' && needs.changes.outputs.qemu_topology == 'true' + runs-on: ubuntu-26.04 + timeout-minutes: 8 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + persist-credentials: false + - name: Install QEMU topology prerequisites + run: | + set -euo pipefail + sudo apt-get update -qq + sudo DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ + qemu-system-x86 qemu-utils + - name: Exercise actual hosted-runner QEMU topology + run: | + set -euo pipefail + mkdir -p "$RUNNER_TEMP/gha-kvm-system-rdte" + python3 scripts/gha_qemu_t3_topology_probe.py \ + --out "$RUNNER_TEMP/gha-kvm-system-rdte/qemu-t3-topology.json" + - name: Upload QEMU topology evidence + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: gha-qemu-t3-topology-${{ github.run_attempt }} + path: ${{ runner.temp }}/gha-kvm-system-rdte/qemu-t3-topology.json + if-no-files-found: error + retention-days: 7 + + proxmox-9-2: + concurrency: + group: agent-dispatch-heavyweight-rdte + queue: max + cancel-in-progress: false + needs: [changes, contract] + if: needs.contract.result == 'success' && needs.changes.outputs.proxmox == 'true' + runs-on: ubuntu-26.04 + timeout-minutes: 35 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + persist-credentials: false + - name: Ensure bounded QEMU/KVM prerequisites + run: | + set -euo pipefail + sudo apt-get update -qq + sudo DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ + qemu-system-x86 qemu-utils xorriso sshpass + - name: Exercise pinned Proxmox real-system target + run: | + set -euo pipefail + mkdir -p "$RUNNER_TEMP/gha-kvm-system-rdte" + bash scripts/gha_kvm_proxmox_rdte.sh \ + --state-dir "$RUNNER_TEMP/gha-kvm-system-rdte/proxmox-state" \ + --compute-fixture "${{ needs.changes.outputs.proxmox_compute_fixture }}" \ + --out "$RUNNER_TEMP/gha-kvm-system-rdte/proxmox-receipt.json" + python3 -m json.tool "$RUNNER_TEMP/gha-kvm-system-rdte/proxmox-receipt.json" + - name: Preserve Proxmox harness failure evidence + if: always() + run: | + set -euo pipefail + receipt="$RUNNER_TEMP/gha-kvm-system-rdte/proxmox-receipt.json" + if [ ! -f "$receipt" ]; then + python3 - "$receipt" <<'PY' + import json, pathlib, sys + pathlib.Path(sys.argv[1]).write_text(json.dumps({ + "contract":"gha-kvm-system-lab/v1", + "target":{"product":"proxmox-ve","version":"9.2-1"}, + "classification":"HARNESS_FAILURE", + "oracleSatisfied":False, + "phase":"workflow-fallback", + "detail":"adapter exited before emitting its normal receipt" + }, indent=2, sort_keys=True)+"\n") + PY + fi + - name: Upload Proxmox evidence + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: gha-kvm-proxmox-9-2-${{ github.run_attempt }} + path: ${{ runner.temp }}/gha-kvm-system-rdte/proxmox-receipt.json + if-no-files-found: error + retention-days: 7 + + truenas-26-beta3: + concurrency: + group: agent-dispatch-heavyweight-rdte + queue: max + cancel-in-progress: false + needs: [changes, contract, proxmox-9-2, foundry-t6-control, foundry-wow-t6-control, foundry-garm-t6-control, foundry-garm-f0-f5-control, foundry-official-catalog-t6-control, foundry-foliorelay-t6-control, foundry-minecraft-paper-t6-control, foundry-collage-paper-11530-t6-control, foundry-minecraft-vanilla263-t6-control, provider-g2-fixtures, provider-g3-fixture, provider-g4-fixture, provider-g5-runner-fixture, provider-g5-version-matrix, provider-container-pre-b4-fixture] + if: >- + always() && + needs.contract.result == 'success' && + needs.changes.outputs.truenas == 'true' && + (needs['proxmox-9-2'].result == 'success' || needs['proxmox-9-2'].result == 'skipped') && + ( + needs.changes.outputs.truenas_rung != 't6' || + (needs.changes.outputs.truenas_t6_product == 'litellm' && needs['foundry-t6-control'].result == 'success') || + (needs.changes.outputs.truenas_t6_product == 'wow-sidecar' && needs['foundry-wow-t6-control'].result == 'success') || + (needs.changes.outputs.truenas_t6_product == 'garm' && needs['foundry-garm-t6-control'].result == 'success') || + (needs.changes.outputs.truenas_t6_product == 'garm-controller-f0f5' && needs['foundry-garm-f0-f5-control'].result == 'success') || + (needs.changes.outputs.truenas_t6_product == 'official-catalog' && needs['foundry-official-catalog-t6-control'].result == 'success') || + (needs.changes.outputs.truenas_t6_product == 'foliorelay' && needs['foundry-foliorelay-t6-control'].result == 'success') || + (needs.changes.outputs.truenas_t6_product == 'minecraft-paper' && needs['foundry-minecraft-paper-t6-control'].result == 'success') || + (needs.changes.outputs.truenas_t6_product == 'collage-native-paper' && needs['foundry-collage-paper-11530-t6-control'].result == 'success') || + (needs.changes.outputs.truenas_t6_product == 'minecraft-vanilla-263' && needs.changes.outputs.truenas_version == '25.04.2.6' && needs['foundry-minecraft-vanilla263-t6-control'].result == 'success') || + (needs.changes.outputs.truenas_t6_product == 'garm-provider-g2' && needs['provider-g2-fixtures'].result == 'success') || + (needs.changes.outputs.truenas_t6_product == 'garm-provider-g3' && needs['provider-g3-fixture'].result == 'success') || + (needs.changes.outputs.truenas_t6_product == 'garm-provider-g4' && needs['provider-g4-fixture'].result == 'success') || + (needs.changes.outputs.truenas_t6_product == 'garm-provider-g5' && needs['provider-g5-runner-fixture'].result == 'success' && needs['provider-g5-version-matrix'].result == 'success') || + (needs.changes.outputs.truenas_t6_product == 'garm-provider-container-pre-b4' && needs['provider-container-pre-b4-fixture'].result == 'success') + ) + runs-on: ubuntu-26.04 + timeout-minutes: 50 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + persist-credentials: false + - name: Download exact LiteLLM Foundry T6 control + if: needs.changes.outputs.truenas_rung == 't6' && needs.changes.outputs.truenas_t6_product == 'litellm' + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c + with: + name: litellm-truenas-t6-control + path: ${{ runner.temp }}/gha-kvm-system-rdte/foundry-control + - name: Download exact WOW Sidecar Foundry T6 control + if: needs.changes.outputs.truenas_rung == 't6' && needs.changes.outputs.truenas_t6_product == 'wow-sidecar' + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c + with: + name: wow-sidecar-truenas-t6-control + path: ${{ runner.temp }}/gha-kvm-system-rdte/foundry-control + - name: Download exact GARM Foundry T6 control + if: needs.changes.outputs.truenas_rung == 't6' && needs.changes.outputs.truenas_t6_product == 'garm' + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c + with: + name: garm-truenas-t6-control + path: ${{ runner.temp }}/gha-kvm-system-rdte/foundry-control + - name: Download exact GARM Foundry F0-F5 control + if: needs.changes.outputs.truenas_rung == 't6' && needs.changes.outputs.truenas_t6_product == 'garm-controller-f0f5' + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c + with: + name: garm-truenas-f0-f5-control + path: ${{ runner.temp }}/gha-kvm-system-rdte/foundry-control + - name: Download exact official-catalog Foundry T6 control + if: needs.changes.outputs.truenas_rung == 't6' && needs.changes.outputs.truenas_t6_product == 'official-catalog' + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c + with: + name: official-catalog-truenas-t6-control + path: ${{ runner.temp }}/gha-kvm-system-rdte/foundry-control + - name: Download exact FolioRelay Foundry T6 control + if: needs.changes.outputs.truenas_rung == 't6' && needs.changes.outputs.truenas_t6_product == 'foliorelay' + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c + with: + name: foliorelay-t6-control + path: ${{ runner.temp }}/gha-kvm-system-rdte/foundry-control + - name: Download exact Minecraft Paper Foundry control + if: needs.changes.outputs.truenas_rung == 't6' && needs.changes.outputs.truenas_t6_product == 'minecraft-paper' + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c + with: + name: minecraft-paper-truenas-t6-control + path: ${{ runner.temp }}/gha-kvm-system-rdte/foundry-control + - name: Download exact COLLAGE 1.15.30 native Paper Foundry control + if: needs.changes.outputs.truenas_rung == 't6' && needs.changes.outputs.truenas_t6_product == 'collage-native-paper' + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c + with: + name: minecraft-paper-11530-truenas-t6-candidate + path: ${{ runner.temp }}/gha-kvm-system-rdte/foundry-control + - name: Download exact vanilla26.3 25.04.2.6 merged-source native control + if: needs.changes.outputs.truenas_rung == 't6' && needs.changes.outputs.truenas_t6_product == 'minecraft-vanilla-263' + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c + with: + name: minecraft-vanilla263-truenas-250426-11531-signed-source-candidate + path: ${{ runner.temp }}/gha-kvm-system-rdte/foundry-control + - name: Independently verify immutable native vanilla control pre-KVM + if: needs.changes.outputs.truenas_rung == 't6' && needs.changes.outputs.truenas_t6_product == 'minecraft-vanilla-263' + run: | + set -euo pipefail + python3 scripts/admit_vanilla263_native_t6_control.py \ + --control "$RUNNER_TEMP/gha-kvm-system-rdte/foundry-control/control.json" \ + --out "$RUNNER_TEMP/vanilla263-pre-kvm-control.json" + + - name: Checkout exact Minecraft product status oracle + if: needs.changes.outputs.truenas_rung == 't6' && (needs.changes.outputs.truenas_t6_product == 'minecraft-paper' || needs.changes.outputs.truenas_t6_product == 'collage-native-paper') + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + repository: SupraCraft/mc-server-images + ref: 1b2227847d56007cdf8b668c00845326827e5fd5 + path: .deps/minecraft-product + persist-credentials: false + - name: Prove exact Minecraft product source identity + if: needs.changes.outputs.truenas_rung == 't6' && (needs.changes.outputs.truenas_t6_product == 'minecraft-paper' || needs.changes.outputs.truenas_t6_product == 'collage-native-paper') + run: | + test "$(git -C .deps/minecraft-product rev-parse HEAD)" = 1b2227847d56007cdf8b668c00845326827e5fd5 + python3 .deps/minecraft-product/tools/test_minecraft_java_status_oracle.py + - name: Checkout independently qualified vanilla26.3 protocol777 status oracle + if: needs.changes.outputs.truenas_rung == 't6' && needs.changes.outputs.truenas_t6_product == 'minecraft-vanilla-263' + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + repository: SupraCraft/mc-server-images + ref: 4df9fbbfea0ae0e824719db367bd551349a86378 + path: .deps/minecraft-product + persist-credentials: false + - name: Verify exact independent vanilla status oracle source + if: needs.changes.outputs.truenas_rung == 't6' && needs.changes.outputs.truenas_t6_product == 'minecraft-vanilla-263' + run: | + set -euo pipefail + test "$(git -C .deps/minecraft-product rev-parse HEAD)" = 4df9fbbfea0ae0e824719db367bd551349a86378 + python3 .deps/minecraft-product/tools/test_minecraft_java_status_oracle.py + + - name: Checkout exact accepted COLLAGE read-only source + if: needs.changes.outputs.truenas_rung == 't6' && needs.changes.outputs.truenas_t6_product == 'collage-native-paper' + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + repository: SupraCraft/collage + ref: fbda94f3fea0a216ddd9c35b38a09d325bed3640 + path: .deps/collage-source + persist-credentials: false + - name: Set up pinned Go toolchain for COLLAGE read-only probe + if: needs.changes.outputs.truenas_rung == 't6' && needs.changes.outputs.truenas_t6_product == 'collage-native-paper' + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 + with: + go-version-file: .deps/collage-source/tools/truenas-readonly-probe/go.mod + cache: false + - name: Prequalify exact COLLAGE source, Go probe and policy before KVM + if: needs.changes.outputs.truenas_rung == 't6' && needs.changes.outputs.truenas_t6_product == 'collage-native-paper' + run: | + set -euo pipefail + test "$(git -C .deps/collage-source rev-parse HEAD)" = fbda94f3fea0a216ddd9c35b38a09d325bed3640 + (cd .deps/collage-source && + python3 -m unittest discover -s tests -p 'test_collage_*.py' -v) + (cd .deps/collage-source/tools/truenas-readonly-probe && + go mod download && go mod verify && + go test ./... && go build -o /dev/null .) + - name: Checkout pinned COLLAGE 25.04.2.6 vanilla read-only observer + if: needs.changes.outputs.truenas_rung == 't6' && needs.changes.outputs.truenas_t6_product == 'minecraft-vanilla-263' && needs.changes.outputs.truenas_collage_readback == 'true' + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + repository: SupraCraft/collage + ref: b9256d6ec0dbac43be2f5a136103b6e3512ee2ec + path: .deps/collage-source + persist-credentials: false + - name: Prepare pinned scoped native vanilla COLLAGE Go read-only observer + if: needs.changes.outputs.truenas_rung == 't6' && needs.changes.outputs.truenas_t6_product == 'minecraft-vanilla-263' && needs.changes.outputs.truenas_collage_readback == 'true' + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 + with: + go-version-file: .deps/collage-source/tools/truenas-readonly-probe/go.mod + cache: false + - name: Verify actual COLLAGE read-only Go source and negative semantics before KVM + if: needs.changes.outputs.truenas_rung == 't6' && needs.changes.outputs.truenas_t6_product == 'minecraft-vanilla-263' && needs.changes.outputs.truenas_collage_readback == 'true' + run: | + set -euo pipefail + test "$(git -C .deps/collage-source rev-parse HEAD)" = b9256d6ec0dbac43be2f5a136103b6e3512ee2ec + (cd .deps/collage-source && + python3 -m unittest -v tests.test_collage_native_readback tests.test_consume_vanilla_datapack_source) + (cd .deps/collage-source/tools/truenas-readonly-probe && + go mod download && go mod verify && + go test ./... && go build -o /dev/null .) + + - name: Checkout immutable real vanilla 26.3 Lantern datapack producer + if: needs.changes.outputs.truenas_lantern_stage == 'true' + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + repository: SupraCraft/mc-server-images + ref: 7f888b5829f4e5576d5ce512d86327348c43f01e + path: .deps/lantern-producer + persist-credentials: false + - name: Verify exact real Lantern ZIP and native-target COLLAGE source plan before guest mutation + if: needs.changes.outputs.truenas_lantern_stage == 'true' + run: | + set -euo pipefail + test "$(git -C .deps/lantern-producer rev-parse HEAD)" = 7f888b5829f4e5576d5ce512d86327348c43f01e + mkdir -p "$RUNNER_TEMP/lantern-native" + python3 .deps/lantern-producer/tools/export_core_restoration_datapack_26_3.py \ + --out "$RUNNER_TEMP/lantern-native/lantern-restoration-26.3.zip" \ + --manifest "$RUNNER_TEMP/lantern-native/manifest.json" \ + --world-id lantern-rdte --release-id lantern-core-restore-26-3 \ + > /dev/null + python3 - "$RUNNER_TEMP/lantern-native" <<'PY' + from pathlib import Path + import sys + from scripts.qualify_vanilla263_native_lantern_stage import verify_exact_candidate,SHA + root=Path(sys.argv[1]) + content,proof=verify_exact_candidate(root/"lantern-restoration-26.3.zip",root/"manifest.json") + assert proof["sha256"]==SHA and proof["size"]==len(content) + PY + python3 .deps/collage-source/tools/consume_vanilla_datapack_source.py \ + --manifest "$RUNNER_TEMP/lantern-native/manifest.json" \ + --artifact "$RUNNER_TEMP/lantern-native/lantern-restoration-26.3.zip" \ + --artifact-root "$RUNNER_TEMP/lantern-native" \ + --native-target-profile vanilla263-250426 \ + > "$RUNNER_TEMP/lantern-native/collage-source-intent.json" + python3 - "$RUNNER_TEMP/lantern-native/collage-source-intent.json" <<'PY' + import json,sys + d=json.load(open(sys.argv[1])) + assert d["classification"]=="SOURCE_ARTIFACT_AND_INTENT_PARITY_VALIDATED" + assert d["expected_native_app"]=="rdte-t6-minecraft-vanilla-263" + assert set(d["audience_receipts"])=={"human","automation","agent"} + assert d["native_guest_with_this_artifact_qualified"] is False + assert d["mutation_authorized"] is False + PY + - name: Download exact GARM provider G2 fixtures + if: needs.changes.outputs.truenas_rung == 't6' && needs.changes.outputs.truenas_t6_product == 'garm-provider-g2' + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c + with: + name: garm-provider-truenas-g2-fixtures + path: ${{ runner.temp }}/gha-kvm-system-rdte/g2-fixtures + - name: Download exact GARM provider G3 fixture + if: needs.changes.outputs.truenas_rung == 't6' && needs.changes.outputs.truenas_t6_product == 'garm-provider-g3' + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c + with: + name: garm-provider-truenas-g3-fixture + path: ${{ runner.temp }}/gha-kvm-system-rdte/g3-fixture + - name: Download exact GARM provider G4 fixture + if: needs.changes.outputs.truenas_rung == 't6' && needs.changes.outputs.truenas_t6_product == 'garm-provider-g4' + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c + with: + name: garm-provider-truenas-g4-fixture + path: ${{ runner.temp }}/gha-kvm-system-rdte/g4-fixture + - name: Download exact GARM provider G5 runner fixture + if: needs.changes.outputs.truenas_rung == 't6' && needs.changes.outputs.truenas_t6_product == 'garm-provider-g5' + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c + with: + name: garm-provider-truenas-g3-fixture + path: ${{ runner.temp }}/gha-kvm-system-rdte/g3-fixture + - name: Download exact GARM provider G5 version matrix + if: needs.changes.outputs.truenas_rung == 't6' && needs.changes.outputs.truenas_t6_product == 'garm-provider-g5' + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c + with: + name: garm-provider-truenas-g5-version-matrix + path: ${{ runner.temp }}/gha-kvm-system-rdte/g5-matrix + - name: Download exact GARM Container pre-B4 fixture + if: needs.changes.outputs.truenas_rung == 't6' && needs.changes.outputs.truenas_t6_product == 'garm-provider-container-pre-b4' + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c + with: + name: garm-provider-truenas-container-pre-b4-fixture + path: ${{ runner.temp }}/gha-kvm-system-rdte/container-pre-b4-fixture + - name: Refuse floating historical COLLAGE Paper 1.15.30 catalog before KVM + if: needs.changes.outputs.truenas_rung == 't6' && needs.changes.outputs.truenas_t6_product == 'collage-native-paper' + run: | + set -euo pipefail + python3 -m unittest -v tests.test_collage_catalog_preflight + python3 scripts/check_collage_catalog_preflight.py --expected 1.15.30 + - name: Refuse retired native vanilla 1.15.30; require current pinned 1.15.31 + if: needs.changes.outputs.truenas_rung == 't6' && needs.changes.outputs.truenas_t6_product == 'minecraft-vanilla-263' + run: | + set -euo pipefail + python3 -m unittest -v tests.test_collage_catalog_preflight + python3 scripts/check_collage_catalog_preflight.py --expected 1.15.31 + + - name: Set up immutable 26.3 experimental client toolchain for native G4 + if: needs.changes.outputs.truenas_native_offline_player == 'true' + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + with: + node-version: '22' + - name: Build pre-qualified 26.3 Mineflayer client on public runner only + if: needs.changes.outputs.truenas_native_offline_player == 'true' + run: | + set -euo pipefail + test "$(git -C .deps/minecraft-product rev-parse HEAD)" = 4df9fbbfea0ae0e824719db367bd551349a86378 + bash .deps/minecraft-product/tools/install_experimental_prismarine_26_3.sh "$RUNNER_TEMP/native-prismarine" + test -d "$RUNNER_TEMP/native-prismarine/runtime/node_modules/mineflayer" + node --check scripts/qualify_native_vanilla263_mineflayer_causal.js + - name: Ensure bounded QEMU/KVM prerequisites + run: | + set -euo pipefail + sudo apt-get update -qq + sudo DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ + qemu-system-x86 qemu-utils xorriso cups-ipp-utils libcups2-dev build-essential + - name: Exercise pinned TrueNAS installer target + run: | + set -euo pipefail + mkdir -p "$RUNNER_TEMP/gha-kvm-system-rdte" + args=( + --state-dir "$RUNNER_TEMP/gha-kvm-system-rdte/truenas-state" + --target-version "${{ needs.changes.outputs.truenas_version }}" + --rung "${{ needs.changes.outputs.truenas_rung }}" + --compute-fixture "${{ needs.changes.outputs.truenas_compute_fixture }}" + --out "$RUNNER_TEMP/gha-kvm-system-rdte/truenas-receipt.json" + ) + if [[ "${{ needs.changes.outputs.truenas_rung }}" == "t6" ]]; then + if [[ "${{ needs.changes.outputs.truenas_t6_product }}" == "garm-provider-g2" ]]; then + args+=( + --t6-product "garm-provider-g2" + --g2-fixture-dir "$RUNNER_TEMP/gha-kvm-system-rdte/g2-fixtures" + --g2-fixture-producer "1667aa73e514c278e221082ac33f2f10a4141eef" + ) + elif [[ "${{ needs.changes.outputs.truenas_t6_product }}" == "garm-provider-g3" ]]; then + args+=( + --t6-product "garm-provider-g3" + --g3-fixture-dir "$RUNNER_TEMP/gha-kvm-system-rdte/g3-fixture" + --g3-fixture-producer "e92d4024e6e60c0e2f8bcf5301c11fccb14eaaab" + ) + elif [[ "${{ needs.changes.outputs.truenas_t6_product }}" == "garm-provider-g4" ]]; then + args+=( + --t6-product "garm-provider-g4" + --g4-fixture-dir "$RUNNER_TEMP/gha-kvm-system-rdte/g4-fixture" + --g4-fixture-producer "e5fc7c50780de54f7579f64990c87b2e16482613" + ) + elif [[ "${{ needs.changes.outputs.truenas_t6_product }}" == "garm-provider-container-pre-b4" ]]; then + args+=( + --t6-product "garm-provider-container-pre-b4" + --container-fixture-dir "$RUNNER_TEMP/gha-kvm-system-rdte/container-pre-b4-fixture" + --container-fixture-producer "9f3c86169320b6a2330bbb345d25a41c736c73c2" + ) + elif [[ "${{ needs.changes.outputs.truenas_t6_product }}" == "garm-provider-g5" ]]; then + args+=( + --t6-product "garm-provider-g5" + --g5-matrix-dir "$RUNNER_TEMP/gha-kvm-system-rdte/g5-matrix" + --g5-matrix-producer "f83a712e095c6962741f93cf86bea69479ce4d4a" + --g3-fixture-dir "$RUNNER_TEMP/gha-kvm-system-rdte/g3-fixture" + --g3-fixture-producer "e92d4024e6e60c0e2f8bcf5301c11fccb14eaaab" + ) + else + if [[ "${{ needs.changes.outputs.truenas_t6_product }}" == "garm-controller-f0f5" ]]; then + foundry_commit="bfbf2158c3f679c3422e6035bdb67e7874a5c07a" + elif [[ "${{ needs.changes.outputs.truenas_t6_product }}" == "garm" ]]; then + foundry_commit="7fd0d2474248b61cf8e290fc80f40b761cea5e98" + elif [[ "${{ needs.changes.outputs.truenas_t6_product }}" == "wow-sidecar" ]]; then + foundry_commit="9ac2eef97581caff5b0de740560c55a778fa3f1c" + elif [[ "${{ needs.changes.outputs.truenas_t6_product }}" == "official-catalog" ]]; then + foundry_commit="60d56cc1752bc825cb232d72b397ff17fe6d47c4" + elif [[ "${{ needs.changes.outputs.truenas_t6_product }}" == "foliorelay" ]]; then + foundry_commit="81deb97185760975fd8d3162df42056c77b3c0fd" + elif [[ "${{ needs.changes.outputs.truenas_t6_product }}" == "minecraft-paper" ]]; then + foundry_commit="2cc7e1fe3772f8c532e933b3d7832e6cf985db4e" + elif [[ "${{ needs.changes.outputs.truenas_t6_product }}" == "collage-native-paper" ]]; then + foundry_commit="403321031b6d4e66aa64a8299fd7621eee290b0d" + elif [[ "${{ needs.changes.outputs.truenas_t6_product }}" == "minecraft-vanilla-263" ]]; then + foundry_commit="5ee204df205edfd531304441fd1a6a24004557b1" + else + foundry_commit="4ba12f4a870f9af8a667056f1e2cc32f80f8e2ba" + fi + args+=( + --t6-product "${{ needs.changes.outputs.truenas_t6_product }}" + --foundry-control-dir "$RUNNER_TEMP/gha-kvm-system-rdte/foundry-control" + --foundry-commit "$foundry_commit" + ) + fi + fi + if [[ "${{ needs.changes.outputs.truenas_rung }}" == "t6" && "${{ needs.changes.outputs.truenas_t6_product }}" == "minecraft-paper" ]]; then + args+=(--session-manifest config/truenas-session-minecraft-paper-25-04-1.json) + fi + if [[ "${{ needs.changes.outputs.truenas_rung }}" == "t6" && "${{ needs.changes.outputs.truenas_t6_product }}" == "minecraft-vanilla-263" && "${{ needs.changes.outputs.truenas_collage_readback }}" == "true" ]]; then + args+=(--vanilla-collage-readback) + fi + if [[ "${{ needs.changes.outputs.truenas_rung }}" == "t6" && "${{ needs.changes.outputs.truenas_lantern_stage }}" == "true" ]]; then + args+=(--vanilla-lantern-zip "$RUNNER_TEMP/lantern-native/lantern-restoration-26.3.zip" + --vanilla-lantern-manifest "$RUNNER_TEMP/lantern-native/manifest.json") + fi + if [[ "${{ needs.changes.outputs.truenas_world_rollback }}" == "true" ]]; then + args+=(--vanilla-world-rollback) + fi + if [[ "${{ needs.changes.outputs.truenas_native_offline_player }}" == "true" ]]; then + args+=(--vanilla-native-player + --vanilla-player-node-path "$RUNNER_TEMP/native-prismarine/runtime/node_modules") + fi + if [[ "${{ needs.changes.outputs.truenas_native_backend_region }}" == "true" ]]; then + args+=(--vanilla-backend-region) + fi + bash scripts/gha_kvm_truenas_rdte.sh "${args[@]}" + python3 -m json.tool "$RUNNER_TEMP/gha-kvm-system-rdte/truenas-receipt.json" + # A green job is not a Minecraft product pass without full guest evidence. + # Failure still uploads the receipt through always() below. + if [[ "${{ needs.changes.outputs.truenas_rung }}" == "t6" && "${{ needs.changes.outputs.truenas_t6_product }}" == "minecraft-paper" || "${{ needs.changes.outputs.truenas_t6_product }}" == "collage-native-paper" ]]; then + python3 scripts/assert_minecraft_t6_receipt.py --receipt "$RUNNER_TEMP/gha-kvm-system-rdte/truenas-receipt.json" + fi + if [[ "${{ needs.changes.outputs.truenas_rung }}" == "t6" && "${{ needs.changes.outputs.truenas_t6_product }}" == "minecraft-vanilla-263" ]]; then + additional=() + if [[ "${{ needs.changes.outputs.truenas_collage_readback }}" == "true" ]]; then + additional+=(--require-collage-readback) + fi + if [[ "${{ needs.changes.outputs.truenas_lantern_stage }}" == "true" ]]; then + additional+=(--require-lantern-staged) + fi + if [[ "${{ needs.changes.outputs.truenas_world_rollback }}" == "true" ]]; then + additional+=(--require-native-rollback) + fi + if [[ "${{ needs.changes.outputs.truenas_native_offline_player }}" == "true" ]]; then + additional+=(--require-native-client-visibility) + fi + if [[ "${{ needs.changes.outputs.truenas_native_backend_region }}" == "true" ]]; then + additional+=(--require-native-backend-region) + fi + python3 scripts/assert_vanilla263_native_t6_receipt.py --receipt "$RUNNER_TEMP/gha-kvm-system-rdte/truenas-receipt.json" "${additional[@]}" + fi + if [[ "${{ needs.changes.outputs.truenas_rung }}" == "t6" && "${{ needs.changes.outputs.truenas_t6_product }}" == "collage-native-paper" ]]; then + python3 scripts/assert_collage_p2_receipt.py --receipt "$RUNNER_TEMP/gha-kvm-system-rdte/truenas-receipt.json" + fi + - name: Preserve TrueNAS harness failure evidence + if: always() + env: + TRUENAS_VERSION: ${{ needs.changes.outputs.truenas_version }} + TRUENAS_RUNG: ${{ needs.changes.outputs.truenas_rung }} + run: | + set -euo pipefail + receipt="$RUNNER_TEMP/gha-kvm-system-rdte/truenas-receipt.json" + if [ ! -f "$receipt" ]; then + python3 - "$receipt" <<'PY' + import json, os, pathlib, sys + pathlib.Path(sys.argv[1]).write_text(json.dumps({ + "contract":"gha-kvm-system-lab/v1", + "target":{"product":"truenas","version":os.environ["TRUENAS_VERSION"],"rung":os.environ["TRUENAS_RUNG"].upper()}, + "classification":"HARNESS_FAILURE", + "oracleSatisfied":False, + "phase":"workflow-fallback", + "detail":"adapter exited before emitting its normal receipt" + }, indent=2, sort_keys=True)+"\n") + PY + fi + - name: Upload TrueNAS evidence + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: gha-kvm-truenas-${{ needs.changes.outputs.truenas_version }}-${{ github.run_attempt }} + path: ${{ runner.temp }}/gha-kvm-system-rdte/truenas-receipt.json + if-no-files-found: error + retention-days: 7 diff --git a/.github/workflows/minecraft-t6-static.yml b/.github/workflows/minecraft-t6-static.yml new file mode 100644 index 000000000..ea1463e2e --- /dev/null +++ b/.github/workflows/minecraft-t6-static.yml @@ -0,0 +1,44 @@ +name: Minecraft T6 consumer cheap qualification + +on: + pull_request: + paths: + - ".github/workflows/minecraft-t6-static.yml" + - "scripts/truenas_middleware_minecraft_t6_probe.py" + - "tests/test_truenas_t6_minecraft_control.py" + - "scripts/gha_kvm_truenas_rdte.sh" + - ".github/workflows/gha-kvm-system-rdte.yml" + - "config/truenas-capsule-providers.json" + - "config/truenas-session-minecraft-paper-25-04-1.json" + workflow_dispatch: + +permissions: + contents: read + +jobs: + cheap: + runs-on: ubuntu-24.04 + timeout-minutes: 5 + steps: + - name: Checkout exact feature revision + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + - name: Compile product and middleware consumer + run: | + python3 -m py_compile scripts/truenas_middleware_minecraft_t6_probe.py + python3 -m py_compile scripts/truenas_middleware_ddp_probe.py + bash -n scripts/gha_kvm_truenas_rdte.sh + - name: Execute source and fail-closed negatives + run: | + python3 -m unittest -v tests/test_truenas_t6_minecraft_control.py + python3 -m unittest -v tests/test_truenas_existing_probe_capsule_executor.py + python3 -m unittest -v tests/test_truenas_session_manifest.py + - name: Verify session manifest exact contract + run: | + python3 scripts/truenas_session_manifest.py \ + --manifest config/truenas-session-minecraft-paper-25-04-1.json \ + --targets config/truenas-rdte-targets.json \ + --providers config/truenas-capsule-providers.json + python3 scripts/truenas_single_capsule_adapter.py \ + --manifest config/truenas-session-minecraft-paper-25-04-1.json \ + --targets config/truenas-rdte-targets.json \ + --providers config/truenas-capsule-providers.json diff --git a/.github/workflows/official-minecraft-catalog-preflight.yml b/.github/workflows/official-minecraft-catalog-preflight.yml new file mode 100644 index 000000000..d7d55d447 --- /dev/null +++ b/.github/workflows/official-minecraft-catalog-preflight.yml @@ -0,0 +1,43 @@ +name: Read-only official Minecraft native catalog preflight +on: + pull_request: + paths: + - 'scripts/check_collage_catalog_preflight.py' + - 'tests/test_collage_catalog_preflight.py' + - '.github/workflows/official-minecraft-catalog-preflight.yml' + workflow_dispatch: +permissions: + contents: read +jobs: + actual-official-catalog: + if: github.event.repository.private == false + runs-on: ubuntu-24.04 + timeout-minutes: 5 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + persist-credentials: false + - name: Cheap catalog guard and 404 regression tests + run: | + set -euo pipefail + python3 -m py_compile scripts/check_collage_catalog_preflight.py + python3 -m unittest -v tests.test_collage_catalog_preflight + - name: Verify actual upstream 1.15.31 native source availability; no Paper 1.15.30 runtime claim + run: | + set -euo pipefail + python3 scripts/check_collage_catalog_preflight.py --expected 1.15.31 \ + > "$RUNNER_TEMP/official-catalog-result.json" + python3 - "$RUNNER_TEMP/official-catalog-result.json" <<'PY' + import json,sys + v=json.load(open(sys.argv[1])) + assert v["classification"] == "PRE_KVM_CATALOG_VERSION_AVAILABLE" + assert v["expected_version"] == "1.15.31" + assert "1.15.31" in v["visible_versions"] + assert "1.15.30" not in v["visible_versions"] + assert v["guest_catalog_observed"] is False + assert v["live_minecraft_qualified"] is False + assert v["world_mutated"] is False + PY + - name: Report exact read-only external preflight boundary + if: always() + run: echo 'Only current native vanilla 1.15.31 source catalog availability, not retired Paper 1.15.30; no KVM, native TrueNAS App, game-world mutation or physical APPLY.' >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/prep-foliorelay-t6-consumer.yml b/.github/workflows/prep-foliorelay-t6-consumer.yml new file mode 100644 index 000000000..ed8ab50cf --- /dev/null +++ b/.github/workflows/prep-foliorelay-t6-consumer.yml @@ -0,0 +1,141 @@ +name: FolioRelay T6 consumer prep + +on: + pull_request: + branches: + - experiment/gha-kvm-system-rdte + paths: + - ".github/workflows/prep-foliorelay-t6-consumer.yml" + - ".github/workflows/gha-kvm-system-rdte.yml" + - "scripts/gha_kvm_truenas_rdte.sh" + - "scripts/truenas_middleware_foliorelay_t6_probe.py" + - "tests/test_truenas_t6_foliorelay_control.py" + - "tools/foliorelay-observer/**" + push: + branches: + - "prep/truenas-t6-foliorelay-v1" + paths: + - ".github/workflows/prep-foliorelay-t6-consumer.yml" + - ".github/workflows/gha-kvm-system-rdte.yml" + - "scripts/gha_kvm_truenas_rdte.sh" + - "scripts/truenas_middleware_foliorelay_t6_probe.py" + - "tests/test_truenas_t6_foliorelay_control.py" + - "tools/foliorelay-observer/**" + +permissions: + contents: read + +jobs: + foundry-control: + uses: SemperSupra/truenas-app-foundry/.github/workflows/export-foliorelay-t6-control.yml@81deb97185760975fd8d3162df42056c77b3c0fd + with: + foundry_ref: 81deb97185760975fd8d3162df42056c77b3c0fd + target_version: 25.10.7 + permissions: + contents: read + + consumer-contract: + needs: foundry-control + runs-on: ubuntu-26.04 + timeout-minutes: 8 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + persist-credentials: false + - name: Ensure fixture compiler dependencies + run: | + set -euo pipefail + sudo apt-get update -qq + sudo DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ + cups-ipp-utils libcups2-dev build-essential + - name: Download exact Foundry FolioRelay control + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c + with: + name: foliorelay-t6-control + path: ${{ runner.temp }}/foliorelay-control + - name: Smoke minimal static independent DNS-SD observer + shell: bash + run: | + set -euo pipefail + carrier='docker.io/library/hello-world@sha256:5e23090353324d887c48ad5e5c56d294eab81588df9605b07d1afe895f9cc8f8' + work="$RUNNER_TEMP/foliorelay-observer-smoke" + rm -rf "$work" + mkdir -p "$work" + command -v go >/dev/null + GO111MODULE=off go test ./tools/foliorelay-observer + GO111MODULE=off CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \ + go build -trimpath -ldflags='-s -w -buildid=' \ + -o "$work/foliorelay-observer" ./tools/foliorelay-observer + test -s "$work/foliorelay-observer" + test "$(stat -c%s "$work/foliorelay-observer")" -le 6291456 + readelf -l "$work/foliorelay-observer" >"$RUNNER_TEMP/foliorelay-observer-elf.txt" + if grep -q 'INTERP' "$RUNNER_TEMP/foliorelay-observer-elf.txt"; then + echo "observer binary unexpectedly has a dynamic interpreter" >&2 + exit 1 + fi + port="$(python3 - <<'PY' + import socket + s=socket.socket(); s.bind(("127.0.0.1",0)); print(s.getsockname()[1]); s.close() + PY + )" + docker pull "$carrier" >/dev/null + test "$(docker image inspect "$carrier" --format '{{.Size}}')" -le 1048576 + docker rm -f foliorelay-observer-smoke >/dev/null 2>&1 || true + trap 'docker rm -f foliorelay-observer-smoke >/dev/null 2>&1 || true' EXIT + docker run -d --name foliorelay-observer-smoke \ + --network host \ + --read-only \ + --user 65534:65534 \ + --cap-drop ALL \ + --security-opt no-new-privileges:true \ + --mount "type=bind,src=$work/foliorelay-observer,dst=/observer/foliorelay-observer,readonly" \ + --entrypoint /observer/foliorelay-observer \ + "$carrier" \ + --uuid urn:uuid:01234567-89ab-4def-8123-456789abcdef \ + --txt-uuid 01234567-89ab-4def-8123-456789abcdef \ + --expected-host intentionally-absent.local \ + --expected-ipp-port 8634 \ + --port "$port" \ + --seconds 1 >/dev/null + python3 - "$port" <<'PY' + import json, sys, time, urllib.request + url=f"http://127.0.0.1:{sys.argv[1]}/" + deadline=time.monotonic()+15 + last=None + while time.monotonic()/dev/null + jq -e ' + (.services | keys | sort) == ["control","cups","discovery"] and + .services.discovery.network_mode == "host" + ' "$root/compose.json" >/dev/null + python3 -m unittest -v tests/test_truenas_t6_foliorelay_control.py + bash -n scripts/gha_kvm_truenas_rdte.sh + python3 -m py_compile scripts/truenas_middleware_foliorelay_t6_probe.py diff --git a/.github/workflows/prep-rdte-static.yml b/.github/workflows/prep-rdte-static.yml new file mode 100644 index 000000000..2d7f3789e --- /dev/null +++ b/.github/workflows/prep-rdte-static.yml @@ -0,0 +1,266 @@ +name: Prep RDTE static gate + +on: + pull_request: + paths: + - '.github/workflows/prep-rdte-static.yml' + - '.github/workflows/gha-kvm-system-rdte.yml' + - 'config/truenas-rdte-targets.json' + - 'config/truenas-rdte-run-request.json' + - 'config/proxmox-rdte-run-request.json' + - 'config/proxmox-rdte-targets.json' + - 'scripts/proxmox_rdte_target.py' + - 'tests/test_proxmox_rdte_target.py' + - 'config/truenas-capsule-providers.json' + - 'config/truenas-session-*.json' + - 'scripts/gha_kvm_truenas_rdte.sh' + - 'scripts/gha_kvm_proxmox_rdte.sh' + - 'scripts/truenas_rdte_target.py' + - 'scripts/truenas_session_manifest.py' + - 'scripts/truenas_single_capsule_adapter.py' + - 'scripts/truenas_session_state.py' + - 'scripts/truenas_session_runner.py' + - 'scripts/truenas_existing_probe_capsule_executor.py' + - 'scripts/truenas_middleware_*.py' + - 'tests/test_gha_kvm_system_rdte.py' + - 'tests/test_truenas_future_protocols.py' + - 'tests/test_truenas_t6_*.py' + - 'tests/test_truenas_garm_provider_*.py' + - 'tests/test_truenas_rdte_target.py' + - 'tests/test_truenas_session_manifest.py' + - 'tests/test_truenas_single_capsule_adapter.py' + - 'tests/test_truenas_session_state.py' + - 'tests/test_truenas_session_runner.py' + - 'tests/test_truenas_existing_probe_capsule_executor.py' + workflow_dispatch: + +permissions: + contents: read + +jobs: + static: + runs-on: ubuntu-24.04 + timeout-minutes: 12 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + persist-credentials: false + fetch-depth: 0 + - name: Shell syntax + run: | + set -euo pipefail + bash -n scripts/gha_kvm_truenas_rdte.sh + bash -n scripts/gha_kvm_proxmox_rdte.sh + - name: Python compile + run: | + set -euo pipefail + python3 -m compileall -q scripts tests + - name: RDTE contract and protocol tests + run: | + set -euo pipefail + tests=( + tests/test_gha_kvm_system_rdte.py + tests/test_truenas_future_protocols.py + ) + if [[ -f tests/test_truenas_t6_litellm_control.py ]]; then + tests+=(tests/test_truenas_t6_litellm_control.py) + fi + if [[ -f tests/test_truenas_t6_wow_sidecar_control.py ]]; then + tests+=(tests/test_truenas_t6_wow_sidecar_control.py) + fi + if [[ -f tests/test_truenas_t6_garm_control.py ]]; then + tests+=(tests/test_truenas_t6_garm_control.py) + fi + if [[ -f tests/test_truenas_t6_official_catalog_control.py ]]; then + tests+=(tests/test_truenas_t6_official_catalog_control.py) + fi + if [[ -f tests/test_truenas_t6_foliorelay_control.py ]]; then + tests+=(tests/test_truenas_t6_foliorelay_control.py) + fi + if [[ -f tests/test_truenas_garm_provider_g2.py ]]; then + tests+=(tests/test_truenas_garm_provider_g2.py) + fi + if [[ -f tests/test_truenas_garm_provider_g3.py ]]; then + tests+=(tests/test_truenas_garm_provider_g3.py) + fi + if [[ -f tests/test_truenas_garm_provider_g4.py ]]; then + tests+=(tests/test_truenas_garm_provider_g4.py) + fi + if [[ -f tests/test_truenas_garm_provider_g5.py ]]; then + tests+=(tests/test_truenas_garm_provider_g5.py) + fi + if [[ -f tests/test_truenas_garm_container_pre_b4.py ]]; then + tests+=(tests/test_truenas_garm_container_pre_b4.py) + fi + if [[ -f tests/test_truenas_rdte_target.py ]]; then + tests+=(tests/test_truenas_rdte_target.py) + fi + if [[ -f tests/test_proxmox_rdte_target.py ]]; then + tests+=(tests/test_proxmox_rdte_target.py) + fi + if [[ -f tests/test_truenas_session_manifest.py ]]; then + tests+=(tests/test_truenas_session_manifest.py) + fi + if [[ -f tests/test_truenas_single_capsule_adapter.py ]]; then + tests+=(tests/test_truenas_single_capsule_adapter.py) + fi + if [[ -f tests/test_truenas_session_state.py ]]; then + tests+=(tests/test_truenas_session_state.py) + fi + if [[ -f tests/test_truenas_session_runner.py ]]; then + tests+=(tests/test_truenas_session_runner.py) + fi + if [[ -f tests/test_truenas_existing_probe_capsule_executor.py ]]; then + tests+=(tests/test_truenas_existing_probe_capsule_executor.py) + fi + python3 -m unittest -v "${tests[@]}" + + - name: Scope expensive provider smokes + id: expensive_scope + env: + EVENT_NAME: ${{ github.event_name }} + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: | + set -euo pipefail + provider_smoke=false + if [[ "$EVENT_NAME" == "workflow_dispatch" ]]; then + provider_smoke=true + else + while IFS= read -r path; do + case "$path" in + scripts/truenas_middleware_garm_provider_g3_probe.py|scripts/truenas_middleware_garm_provider_g4_probe.py|scripts/truenas_middleware_garm_provider_g5_probe.py|tests/test_truenas_garm_provider_g3.py|tests/test_truenas_garm_provider_g4.py|tests/test_truenas_garm_provider_g5.py) + provider_smoke=true + ;; + esac + done < <(git diff --name-only "$BASE_SHA" "$HEAD_SHA") + fi + echo "provider_smoke=$provider_smoke" >>"$GITHUB_OUTPUT" + printf 'expensive provider smoke scope: %s\n' "$provider_smoke" + + - name: G3 runner container profile smoke + if: ${{ steps.expensive_scope.outputs.provider_smoke == 'true' && hashFiles('scripts/truenas_middleware_garm_provider_g3_probe.py') != '' }} + run: | + set -euo pipefail + image='ghcr.io/actions/actions-runner:2.336.0@sha256:0cfdcc701ce933c6d243c6b0b2da767366dc9f2e99961d4c3754b0b78084cdda' + docker pull "$image" + docker run --rm \ + --user 1001:1001 \ + --cpus 4 \ + --memory 8g \ + --cap-drop ALL \ + --security-opt no-new-privileges:true \ + --tmpfs /run/garm-jit:rw,nosuid,nodev,noexec,uid=1001,gid=1001,mode=0700 \ + --entrypoint /bin/sh \ + "$image" -c ' + set -eu + test "$(id -u)" = "1001" + test "$(id -g)" = "1001" + test -w /home/runner + test -w /run/garm-jit + command -v curl >/dev/null + command -v tar >/dev/null + command -v sha256sum >/dev/null + printf profile-smoke > /home/runner/g3-profile-smoke + rm -f /home/runner/g3-profile-smoke + printf jit-smoke > /run/garm-jit/g3-profile-smoke + rm -f /run/garm-jit/g3-profile-smoke + curl --fail --silent --show-error --location \ + --connect-timeout 5 --max-time 20 --range 0-0 \ + https://github.com/actions/runner/releases/download/v2.336.0/actions-runner-linux-x64-2.336.0.tar.gz \ + -o /dev/null + ' + + - name: G4 exact exported pair pre-JIT concurrency smoke + if: ${{ steps.expensive_scope.outputs.provider_smoke == 'true' && hashFiles('scripts/truenas_middleware_garm_provider_g4_probe.py') != '' }} + run: | + set -euo pipefail + provider_ref='e5fc7c50780de54f7579f64990c87b2e16482613' + rm -rf /tmp/g4-provider /tmp/g4-fixture.json /tmp/g4-alpha.json /tmp/g4-beta.json + git init -q /tmp/g4-provider + git -C /tmp/g4-provider fetch -q --depth=1 \ + https://github.com/SemperSupra/garm-provider-truenas.git "$provider_ref" + git -C /tmp/g4-provider checkout -q --detach FETCH_HEAD + [[ "$(git -C /tmp/g4-provider rev-parse HEAD)" == "$provider_ref" ]] + + ( + cd /tmp/g4-provider + G4_FIXTURE_OUT=/tmp/g4-fixture.json \ + G4_PRODUCER_SOURCE="$provider_ref" \ + go test ./internal/truenasstore \ + -run '^TestExportNestedG4CapacityTwoFixtureBundle$' -count=1 + ) + test -s /tmp/g4-fixture.json + + for slot in alpha beta; do + callback="https://httpbin.org/anything/sempersupra-g4/$slot/status" + metadata="https://httpbin.org/drip?duration=1&numbytes=1&code=200&delay=60&path=$slot" + token="g4-public-static-$slot-token" + jq --arg slot "$slot" --arg callback "$callback" --arg metadata "$metadata" --arg token "$token" ' + first(.runners[] | select(.slot == $slot) | .expected_compose_template) + | .services.runner.environment.GARM_CALLBACK_URL = $callback + | .services.runner.environment.GARM_METADATA_URL = $metadata + | .services.runner.environment.GARM_INSTANCE_TOKEN = $token + ' /tmp/g4-fixture.json > "/tmp/g4-$slot.json" + done + + cleanup() { + docker compose -p g4-smoke-alpha -f /tmp/g4-alpha.json down -v --remove-orphans >/dev/null 2>&1 || true + docker compose -p g4-smoke-beta -f /tmp/g4-beta.json down -v --remove-orphans >/dev/null 2>&1 || true + } + trap cleanup EXIT + + docker compose -p g4-smoke-alpha -f /tmp/g4-alpha.json up -d & + alpha_up=$! + docker compose -p g4-smoke-beta -f /tmp/g4-beta.json up -d & + beta_up=$! + wait "$alpha_up" + wait "$beta_up" + + deadline=$((SECONDS + 140)) + while (( SECONDS < deadline )); do + ready=0 + for slot in alpha beta; do + cid="$(docker compose -p "g4-smoke-$slot" -f "/tmp/g4-$slot.json" ps -aq runner || true)" + [[ -n "$cid" ]] || continue + if [[ "$(docker inspect -f '{{.State.Running}}' "$cid")" != "true" ]]; then + docker inspect "$cid" || true + docker logs "$cid" || true + exit 1 + fi + if docker exec "$cid" test -x /home/runner/actions-runner/run.sh; then + ready=$((ready + 1)) + fi + done + [[ "$ready" == "2" ]] && break + sleep 2 + done + + for slot in alpha beta; do + cid="$(docker compose -p "g4-smoke-$slot" -f "/tmp/g4-$slot.json" ps -aq runner)" + [[ "$(docker inspect -f '{{.State.Running}}' "$cid")" == "true" ]] + docker exec "$cid" test -x /home/runner/actions-runner/bin/Runner.Listener + docker exec "$cid" test -w /run/garm-jit + done + + - name: G5 exact merged source matrix smoke + if: ${{ steps.expensive_scope.outputs.provider_smoke == 'true' && hashFiles('scripts/truenas_middleware_garm_provider_g5_probe.py') != '' }} + run: | + set -euo pipefail + provider_ref='f83a712e095c6962741f93cf86bea69479ce4d4a' + rm -rf /tmp/g5-provider + git init -q /tmp/g5-provider + git -C /tmp/g5-provider fetch -q --depth=1 \ + https://github.com/SemperSupra/garm-provider-truenas.git "$provider_ref" + git -C /tmp/g5-provider checkout -q --detach FETCH_HEAD + [[ "$(git -C /tmp/g5-provider rev-parse HEAD)" == "$provider_ref" ]] + ( + cd /tmp/g5-provider + go test ./internal/truenasstore -run '^TestG5VersionMatrixContract$' -count=1 + jq -e ' + .schema == "semper-supra.garm-provider-truenas-g5-version-matrix/1" and + .provider_product_source == "14535745dc3aa3c0b5466da7c704bca4d23dcec5" and + .runtime_inheritance_allowed == false and + ([.targets[].version] | sort) == (["25.04.1","25.04.2.6","25.10.7","26.0.0-BETA.3"] | sort) + ' internal/truenasstore/testdata/truenas-version-matrix.json >/dev/null + ) diff --git a/.github/workflows/qualify-collage-p2-source.yml b/.github/workflows/qualify-collage-p2-source.yml new file mode 100644 index 000000000..788e6f78e --- /dev/null +++ b/.github/workflows/qualify-collage-p2-source.yml @@ -0,0 +1,57 @@ +name: Qualify COLLAGE P2 nested TrueNAS source + +on: + pull_request: + branches: + - experiment/gha-kvm-system-rdte + paths: + - 'scripts/truenas_middleware_collage_readback_p2.py' + - 'scripts/truenas_middleware_minecraft_t6_probe.py' + - 'scripts/assert_collage_p2_receipt.py' + - 'scripts/gha_kvm_truenas_rdte.sh' + - 'tests/test_collage_p2_native.py' + - 'tests/test_collage_p2_new_native_version.py' + - 'tests/test_collage_p2_roles.py' + - 'tests/test_collage_p2_effective_roles.py' + - 'tests/test_collage_catalog_preflight.py' + - 'scripts/check_collage_catalog_preflight.py' + - 'tests/test_truenas_t6_minecraft_control.py' + - '.github/workflows/gha-kvm-system-rdte.yml' + - '.github/workflows/qualify-collage-p2-source.yml' + workflow_dispatch: + +permissions: + contents: read + +jobs: + cheap-source: + runs-on: ubuntu-24.04 + timeout-minutes: 8 + steps: + - name: Checkout exact candidate + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + persist-credentials: false + - name: Syntax, receipt, existing product and negative source checks + run: | + set -euo pipefail + python3 -m py_compile \ + scripts/truenas_middleware_collage_readback_p2.py \ + scripts/truenas_middleware_minecraft_t6_probe.py \ + scripts/assert_collage_p2_receipt.py \ + tests/test_collage_p2_native.py \ + tests/test_collage_p2_new_native_version.py \ + tests/test_collage_p2_roles.py \ + tests/test_collage_p2_effective_roles.py \ + tests/test_collage_catalog_preflight.py \ + scripts/check_collage_catalog_preflight.py + python3 -m unittest -v \ + tests.test_collage_p2_native \ + tests.test_collage_p2_new_native_version \ + tests.test_collage_p2_roles \ + tests.test_collage_p2_effective_roles \ + tests.test_collage_catalog_preflight \ + tests.test_truenas_t6_minecraft_control + - name: Declare no live-system result + run: | + echo 'SOURCE_ONLY: no TrueNAS guest boot or authenticated API readback. The new distinct request ID requires a separate accepted request-edge after this gate.' >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/qualify-native-263-offline-ops-image.yml b/.github/workflows/qualify-native-263-offline-ops-image.yml new file mode 100644 index 000000000..fa6172f74 --- /dev/null +++ b/.github/workflows/qualify-native-263-offline-ops-image.yml @@ -0,0 +1,32 @@ +name: Native Minecraft 26.3 offline UUID image helper preflight +on: + pull_request: + paths: + - 'scripts/qualify_native_vanilla263_offline_ops_image.sh' + - 'scripts/qualify_native_vanilla263_loopback_player_overlay.py' + - '.github/workflows/qualify-native-263-offline-ops-image.yml' + workflow_dispatch: +permissions: + contents: read +jobs: + pinned-image-offline-operator: + if: github.event.repository.private == false + runs-on: ubuntu-24.04 + timeout-minutes: 8 + steps: + - name: Exact checked-out source, no credentials persisted + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + persist-credentials: false + - name: Validate bounded offline username-to-UUID source + run: | + set -euo pipefail + bash -n scripts/qualify_native_vanilla263_offline_ops_image.sh + python3 -m unittest -v tests.test_native_vanilla263_loopback_player_overlay + - name: Use pinned actual public image helper, no Minecraft, no guest + run: | + set -euo pipefail + bash scripts/qualify_native_vanilla263_offline_ops_image.sh + - name: Preserve boundary + run: | + echo 'Pinned image offline UUID operator configuration only. No native TrueNAS, live player, backend world witness, principal mutation authorization or physical APPLY.' >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/qualify-native-loopback-gameplay-overlay.yml b/.github/workflows/qualify-native-loopback-gameplay-overlay.yml new file mode 100644 index 000000000..251a87468 --- /dev/null +++ b/.github/workflows/qualify-native-loopback-gameplay-overlay.yml @@ -0,0 +1,58 @@ +name: Native vanilla263 synthetic player loopback source qualification +on: + pull_request: + paths: + - 'scripts/qualify_native_vanilla263_loopback_player_overlay.py' + - 'tests/test_native_vanilla263_loopback_player_overlay.py' + - 'scripts/qualify_native_vanilla263_mineflayer_causal.py' + - 'scripts/qualify_native_vanilla263_mineflayer_causal.js' + - 'tests/test_native_vanilla263_mineflayer_causal.py' + - 'scripts/qualify_native_vanilla263_anvil_blocks.py' + - 'tests/test_native_vanilla263_anvil_blocks.py' + - 'scripts/qualify_native_vanilla263_anvil_guest_readback.py' + - 'tests/test_native_vanilla263_anvil_guest_readback.py' + - 'tests/test_native_vanilla263_backend_three_checkpoint_receipt.py' + - 'scripts/truenas_middleware_minecraft_t6_probe.py' + - 'tests/test_vanilla263_catalog_parser_preflight.py' + - 'scripts/assert_vanilla263_native_t6_receipt.py' + - 'scripts/gha_kvm_truenas_rdte.sh' + - '.github/workflows/gha-kvm-system-rdte.yml' + - '.github/workflows/qualify-native-loopback-gameplay-overlay.yml' + workflow_dispatch: +permissions: + contents: read +jobs: + explicit-offline-rdte-contract: + if: github.event.repository.private == false + runs-on: ubuntu-24.04 + timeout-minutes: 4 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + persist-credentials: false + - name: Compile and adversarially test the source-only loopback variant + run: | + set -euo pipefail + python3 -m py_compile \ + scripts/qualify_native_vanilla263_loopback_player_overlay.py \ + scripts/qualify_native_vanilla263_mineflayer_causal.py \ + tests/test_native_vanilla263_loopback_player_overlay.py \ + tests/test_native_vanilla263_mineflayer_causal.py \ + scripts/qualify_native_vanilla263_anvil_blocks.py \ + tests/test_native_vanilla263_anvil_blocks.py \ + scripts/qualify_native_vanilla263_anvil_guest_readback.py \ + tests/test_native_vanilla263_anvil_guest_readback.py \ + tests/test_native_vanilla263_backend_three_checkpoint_receipt.py \ + scripts/truenas_middleware_minecraft_t6_probe.py \ + tests/test_vanilla263_catalog_parser_preflight.py \ + scripts/assert_vanilla263_native_t6_receipt.py + python3 -m unittest -v tests.test_native_vanilla263_loopback_player_overlay + python3 -m unittest -v tests.test_native_vanilla263_mineflayer_causal + python3 -m unittest -v tests.test_native_vanilla263_anvil_blocks + python3 -m unittest -v tests.test_native_vanilla263_anvil_guest_readback + python3 -m unittest -v tests.test_native_vanilla263_backend_three_checkpoint_receipt + python3 -m unittest -v tests.test_vanilla263_catalog_parser_preflight + node --check scripts/qualify_native_vanilla263_mineflayer_causal.js + bash -n scripts/gha_kvm_truenas_rdte.sh + - name: Preserve operational auth and physical TrueNAS hold + run: echo 'Source-only variant; immutable operational online_mode=true, RCON disabled; no QEMU guest, player, world mutation, or physical TrueNAS APPLY.' >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/qualify-vanilla263-native-admission.yml b/.github/workflows/qualify-vanilla263-native-admission.yml new file mode 100644 index 000000000..6d4658523 --- /dev/null +++ b/.github/workflows/qualify-vanilla263-native-admission.yml @@ -0,0 +1,40 @@ +name: Source-only vanilla 26.3 current native TrueNAS guest admission guard +on: + pull_request: + branches: [experiment/gha-kvm-system-rdte] + paths: + - 'scripts/qualify_vanilla263_native_admission.py' + - 'config/truenas-vanilla263-native-admission-request.json' + - 'tests/test_vanilla263_native_admission.py' + - '.github/workflows/qualify-vanilla263-native-admission.yml' + workflow_dispatch: +permissions: + contents: read +jobs: + fail-closed-source-admission: + if: github.event.repository.private == false + runs-on: ubuntu-24.04 + timeout-minutes: 7 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + persist-credentials: false + - name: Source and disjoint authority checks without guest or credentials + run: | + set -euo pipefail + python3 -m py_compile scripts/qualify_vanilla263_native_admission.py + python3 -m unittest discover -s tests -p 'test_vanilla263_native_admission.py' -v + python3 - <<'PY' + import json + from pathlib import Path + req=json.loads(Path("config/truenas-vanilla263-native-admission-request.json").read_text()) + old=json.loads(Path("config/truenas-rdte-run-request.json").read_text()) + assert req["request_id"]!=old["request_id"] + assert req["product"]=="minecraft-vanilla-263" and old["product"]=="minecraft-vanilla-263" + assert old["request_id"]=="tn-25-04-2-6-t6-vanilla-263-op4i-016-backend-anvil-three-checkpoint" + assert req["consumer_authority_issue"]==627 + assert req["version"]=="25.04.2.6" + PY + - name: Exact nonclaims + run: | + echo 'SOURCE-ONLY qualification of new TrueNAS25.04.2.6 Minecraft vanilla26.3 control admission, no GHA request edge, no KVM, no app creation, no online player or physical APPLY. Real Foundry producer control must be downloaded and validated separately before dispatch.' >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/qualify-vanilla263-native-control-consumer.yml b/.github/workflows/qualify-vanilla263-native-control-consumer.yml new file mode 100644 index 000000000..a23e1878f --- /dev/null +++ b/.github/workflows/qualify-vanilla263-native-control-consumer.yml @@ -0,0 +1,98 @@ +name: Vanilla 26.3 native guest control producer-consumer admission (public read-only) +on: + pull_request: + branches: [experiment/gha-kvm-system-rdte] + paths: + - '.github/workflows/qualify-vanilla263-native-control-consumer.yml' + - 'scripts/qualify_vanilla263_native_admission.py' + - 'config/truenas-vanilla263-native-admission-request.json' + - 'tests/test_vanilla263_native_admission.py' + workflow_dispatch: +permissions: + contents: read +concurrency: + group: vanilla263-native-control-consumer-${{ github.ref }} + cancel-in-progress: false +jobs: + exact-source-guest-admission: + if: github.event.repository.private == false + runs-on: ubuntu-24.04 + timeout-minutes: 20 + steps: + - name: Checkout exact Agent Dispatch consumer + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + persist-credentials: false + - name: Checkout accepted exact merged Foundry producer, never floating main + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + repository: SemperSupra/truenas-app-foundry + ref: 5ee204df205edfd531304441fd1a6a24004557b1 + path: .deps/vanilla263-foundry + persist-credentials: false + - name: Source admission and exact producer-head negative gates + run: | + set -euo pipefail + test "$(git -C .deps/vanilla263-foundry rev-parse HEAD)" = 5ee204df205edfd531304441fd1a6a24004557b1 + python3 -m unittest discover -s tests -p 'test_vanilla263_native_admission.py' -v + python3 -m unittest discover -s .deps/vanilla263-foundry/tests \ + -p 'test_export_minecraft_vanilla_263_11531_t6_control.py' -v + python3 -m unittest discover -s .deps/vanilla263-foundry/tests \ + -p 'test_vanilla_263_native_source.py' -v + docker compose version + - name: Real upstream native source render from exact merged commit + run: | + set -euo pipefail + mkdir -p "$RUNNER_TEMP/vanilla263" + python3 .deps/vanilla263-foundry/tools/validate_minecraft_world_control.py \ + --product vanilla-263 --catalog-profile 1.15.31 \ + --target-version 25.04.2.6 \ + > "$RUNNER_TEMP/vanilla263/merged-source.json" + - name: Produce new signed-by-digest merged-commit control + run: | + set -euo pipefail + python3 .deps/vanilla263-foundry/tools/export_minecraft_vanilla_263_11531_t6_control.py \ + --foundry-ref 5ee204df205edfd531304441fd1a6a24004557b1 \ + --target-version 25.04.2.6 \ + --source-evidence "$RUNNER_TEMP/vanilla263/merged-source.json" \ + --out "$RUNNER_TEMP/vanilla263/control.json" + - name: Independently enforce Agent Dispatch exact tuple admission, no KVM + run: | + set -euo pipefail + python3 scripts/qualify_vanilla263_native_admission.py \ + --request config/truenas-vanilla263-native-admission-request.json \ + --control "$RUNNER_TEMP/vanilla263/control.json" \ + --registry config/truenas-rdte-targets.json \ + --out "$RUNNER_TEMP/vanilla263/admission.json" + python3 - "$RUNNER_TEMP/vanilla263/control.json" "$RUNNER_TEMP/vanilla263/admission.json" <<'PY' + import hashlib,json,sys + c=json.load(open(sys.argv[1])) + a=json.load(open(sys.argv[2])) + raw=dict(c) + claimed=raw.pop("control_sha256") + assert claimed == hashlib.sha256(json.dumps(raw,sort_keys=True,separators=(",",":")).encode()).hexdigest() + assert c["foundry_ref"] == "5ee204df205edfd531304441fd1a6a24004557b1" + assert a["classification"] == "SOURCE_CONTROL_READY_GUEST_NOT_DISPATCHED" + assert a["control_sha256"] == claimed + assert a["foundry_ref"] == c["foundry_ref"] + assert a["request_id"] == "tn-25-04-2-6-t6-vanilla-263-op4b-001" + assert a["target_version"] == "25.04.2.6" + assert a["guest_run_requested"] is False + assert a["native_app_created"] is False + assert a["provider_observed"] is False + assert a["physical_truenas_apply"] == "HOLD" + PY + - name: Retain signed merged-source control and guest admission receipt only + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: vanilla263-native-current-source-consumer-admission + path: | + ${{ runner.temp }}/vanilla263/control.json + ${{ runner.temp }}/vanilla263/admission.json + if-no-files-found: warn + retention-days: 14 + - name: Exact limits + if: always() + run: | + echo 'Actual merged Foundry producer source rerendered; new SHA-bound control verified by independent Agent Dispatch guest admission from distinct request. This is source-only, no KVM, no native App, no live provider observation, no authenticated user, no physical TrueNAS APPLY.' >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/validate-compute-guest-profile-contract.yml b/.github/workflows/validate-compute-guest-profile-contract.yml new file mode 100644 index 000000000..f26804885 --- /dev/null +++ b/.github/workflows/validate-compute-guest-profile-contract.yml @@ -0,0 +1,26 @@ +name: Validate compute guest profile contract +on: + pull_request: + paths: + - "config/compute-guest-profiles.json" + - "scripts/compute_guest_profile_contract.py" + - "scripts/compute_guest_seed.py" + - "scripts/truenas_vm_console_probe.py" + - "scripts/truenas_compute_vm_v1_probe.py" + - "scripts/windows_w1_native_plan.py" + - "tests/test_compute_guest_profile_contract.py" + - "tests/test_compute_guest_seed.py" + - "tests/test_truenas_vm_console_probe.py" + - "tests/test_truenas_compute_vm_v1_probe.py" + - "tests/test_windows_w1_native_plan.py" + - ".github/workflows/validate-compute-guest-profile-contract.yml" + workflow_dispatch: +permissions: + contents: read +jobs: + validate: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + - run: python3 -m unittest -v tests/test_compute_guest_profile_contract.py tests/test_compute_guest_seed.py tests/test_truenas_vm_console_probe.py tests/test_truenas_v2_console_probe.py tests/test_truenas_compute_vm_v1_probe.py tests/test_windows_w1_native_plan.py + - run: python3 scripts/compute_guest_profile_contract.py diff --git a/.github/workflows/validate-compute-materialization-contract.yml b/.github/workflows/validate-compute-materialization-contract.yml new file mode 100644 index 000000000..dd521f207 --- /dev/null +++ b/.github/workflows/validate-compute-materialization-contract.yml @@ -0,0 +1,30 @@ +name: Validate compute materialization contract + +on: + pull_request: + paths: + - "config/compute-materialization-targets.json" + - "scripts/compute_materialization_contract.py" + - "tests/test_compute_materialization_contract.py" + - "scripts/truenas_compute_container_probe.py" + - "tests/test_truenas_compute_container_probe.py" + - "scripts/truenas_compute_vm_probe.py" + - "tests/test_truenas_compute_vm_probe.py" + - "scripts/proxmox_rest_compute_probe.py" + - "tests/test_proxmox_rest_compute_probe.py" + - "docs/compute-materialization-contract.md" + - ".github/workflows/validate-compute-materialization-contract.yml" + workflow_dispatch: + +permissions: + contents: read + +jobs: + validate: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + - name: Contract tests + run: python3 -m unittest -v tests/test_compute_materialization_contract.py tests/test_truenas_compute_container_probe.py tests/test_truenas_compute_vm_probe.py tests/test_proxmox_rest_compute_probe.py + - name: Validate exact target profiles + run: python3 scripts/compute_materialization_contract.py --validate-only diff --git a/.github/workflows/vanilla263-native-guest-static.yml b/.github/workflows/vanilla263-native-guest-static.yml new file mode 100644 index 000000000..71a0a3350 --- /dev/null +++ b/.github/workflows/vanilla263-native-guest-static.yml @@ -0,0 +1,102 @@ +name: Vanilla 26.3 native guest observer and exact receipt cheap gates +on: + pull_request: + paths: + - 'scripts/truenas_middleware_minecraft_t6_probe.py' + - 'scripts/truenas_middleware_collage_readback_p2.py' + - 'tests/test_collage_p2_effective_roles.py' + - 'scripts/qualify_vanilla263_native_lantern_stage.py' + - 'scripts/qualify_vanilla263_native_world_rollback.py' + - 'tests/test_vanilla263_native_world_rollback.py' + - 'tests/test_vanilla263_native_rollback_receipt.py' + - 'scripts/qualify_native_vanilla263_mineflayer_causal.py' + - 'scripts/qualify_native_vanilla263_mineflayer_causal.js' + - 'tests/test_native_vanilla263_player_guest_receipt.py' + - 'tests/test_vanilla263_native_lantern_stage.py' + - 'scripts/assert_vanilla263_native_t6_receipt.py' + - 'tests/test_truenas_t6_vanilla263_guest.py' + - '.github/workflows/vanilla263-native-guest-static.yml' + - 'tests/test_vanilla263_t6_guest_routing.py' + - 'scripts/gha_kvm_truenas_rdte.sh' + - '.github/workflows/gha-kvm-system-rdte.yml' + workflow_dispatch: +permissions: + contents: read +jobs: + + real-artifact-source: + if: github.event.repository.private == false + runs-on: ubuntu-24.04 + timeout-minutes: 7 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + persist-credentials: false + - name: Checkout immutable already-qualified real Lantern producer + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + repository: SupraCraft/mc-server-images + ref: 7f888b5829f4e5576d5ce512d86327348c43f01e + path: .deps/lantern-producer + persist-credentials: false + - name: Rebuild and independently accept actual ZIP bytes before paying for guest + run: | + set -euo pipefail + test "$(git -C .deps/lantern-producer rev-parse HEAD)" = 7f888b5829f4e5576d5ce512d86327348c43f01e + mkdir -p "$RUNNER_TEMP/lantern-artifact" + python3 .deps/lantern-producer/tools/export_core_restoration_datapack_26_3.py \ + --out "$RUNNER_TEMP/lantern-artifact/lantern-restoration-26.3.zip" \ + --manifest "$RUNNER_TEMP/lantern-artifact/manifest.json" \ + --world-id lantern-rdte \ + --release-id lantern-core-restore-26-3 + python3 - "$RUNNER_TEMP/lantern-artifact" <<'PY' + from pathlib import Path + import sys + from scripts.qualify_vanilla263_native_lantern_stage import SHA, verify_exact_candidate + root=Path(sys.argv[1]) + content,evidence=verify_exact_candidate( + root/"lantern-restoration-26.3.zip", root/"manifest.json") + assert evidence["sha256"]==SHA and evidence["size"]==len(content) + assert evidence["world"]=="lantern-rdte" + assert evidence["target"]=="disposable_native_truenas_25.04.2.6" + PY + - name: Guest boundary + run: echo 'Actual immutable ZIP and manifest validated on hosted runner. No KVM, TrueNAS, stage, player gameplay, or physical APPLY.' >> "$GITHUB_STEP_SUMMARY" + adversarial-source: + if: github.event.repository.private == false + runs-on: ubuntu-24.04 + timeout-minutes: 6 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + persist-credentials: false + - name: Compile exact native observer and independent acceptance oracle + run: | + set -euo pipefail + python3 -m py_compile \ + scripts/truenas_middleware_minecraft_t6_probe.py \ + scripts/truenas_middleware_collage_readback_p2.py \ + scripts/qualify_vanilla263_native_lantern_stage.py \ + scripts/qualify_vanilla263_native_world_rollback.py \ + scripts/admit_vanilla263_native_t6_control.py \ + scripts/assert_vanilla263_native_t6_receipt.py + python3 -m py_compile tests/test_truenas_t6_vanilla263_guest.py + - name: Native vanilla real guest boundary source and red-blue negatives + run: | + set -euo pipefail + python3 -m unittest -v tests/test_vanilla263_native_lantern_stage.py + python3 -m unittest -v tests/test_vanilla263_native_world_rollback.py + python3 -m unittest -v tests/test_vanilla263_native_rollback_receipt.py + python3 -m unittest -v tests/test_native_vanilla263_player_guest_receipt.py + node --check scripts/qualify_native_vanilla263_mineflayer_causal.js + python3 -m unittest -v tests/test_truenas_t6_vanilla263_guest.py + python3 -m unittest -v tests/test_collage_p2_effective_roles.py + bash -n scripts/gha_kvm_truenas_rdte.sh + python3 -m unittest -v tests/test_vanilla263_t6_guest_routing.py + - name: Preserve legacy Paper 25.04.1 T6 regression + run: | + set -euo pipefail + python3 -m unittest -v tests/test_truenas_t6_minecraft_control.py + python3 -m unittest -v tests/test_minecraft_t6_receipt_gate.py + - name: No guest or physical promotion + run: echo 'Cheap exact vanilla26.3 native observer contract only. No KVM guest, no native App and physical TrueNAS APPLY HOLD.' >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/vanilla263-t6-source-admission.yml b/.github/workflows/vanilla263-t6-source-admission.yml new file mode 100644 index 000000000..e87d7a2f3 --- /dev/null +++ b/.github/workflows/vanilla263-t6-source-admission.yml @@ -0,0 +1,79 @@ +name: Vanilla 26.3 TrueNAS 25.04.2.6 signed-source guest-admission gate +on: + pull_request: + paths: + - 'scripts/admit_vanilla263_native_t6_control.py' + - 'tests/test_vanilla263_t6_source_admission.py' + - '.github/workflows/vanilla263-t6-source-admission.yml' + workflow_dispatch: +permissions: + contents: read +jobs: + exact-source: + if: github.event.repository.private == false + runs-on: ubuntu-24.04 + timeout-minutes: 25 + steps: + - name: Checkout exact Agent Dispatch candidate without mutating request edge + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + persist-credentials: false + - name: Checkout immutable accepted Foundry producer + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + repository: SemperSupra/truenas-app-foundry + ref: 5ee204df205edfd531304441fd1a6a24004557b1 + path: .deps/vanilla-foundry + persist-credentials: false + - name: Validate source and test fixtures before render + run: | + set -euo pipefail + test "$(git -C .deps/vanilla-foundry rev-parse HEAD)" = 5ee204df205edfd531304441fd1a6a24004557b1 + python3 -m pip install --disable-pip-version-check 'PyYAML==6.0.2' + python3 -m py_compile scripts/admit_vanilla263_native_t6_control.py tests/test_vanilla263_t6_source_admission.py + - name: Independently source render exact native community catalog + run: | + set -euo pipefail + python3 .deps/vanilla-foundry/tools/validate_minecraft_world_control.py \ + --product vanilla-263 --catalog-profile 1.15.31 \ + --target-version 25.04.2.6 > "$RUNNER_TEMP/vanilla-source.json" + - name: Produce signed source control at accepted merged Foundry ref + run: | + set -euo pipefail + mkdir -p "$RUNNER_TEMP/vanilla-control" + python3 .deps/vanilla-foundry/tools/export_minecraft_vanilla_263_11531_t6_control.py \ + --foundry-ref 5ee204df205edfd531304441fd1a6a24004557b1 \ + --target-version 25.04.2.6 \ + --source-evidence "$RUNNER_TEMP/vanilla-source.json" \ + --out "$RUNNER_TEMP/vanilla-control/control.json" + echo "VANILLA263_SOURCE_CONTROL=$RUNNER_TEMP/vanilla-control/control.json" >> "$GITHUB_ENV" + - name: Independent Agent Dispatch consumer and exhaustive cheap red-blue + run: | + set -euo pipefail + python3 -m unittest -v tests/test_vanilla263_t6_source_admission.py + python3 scripts/admit_vanilla263_native_t6_control.py \ + --control "$VANILLA263_SOURCE_CONTROL" \ + --out "$RUNNER_TEMP/vanilla-control/consumer-receipt.json" + python3 - "$RUNNER_TEMP/vanilla-control/consumer-receipt.json" <<'PY' + import json,sys + r=json.load(open(sys.argv[1])) + assert r["schema"]=="semper-supra.minecraft-vanilla263-t6-consumer-admission/1" + assert r["classification"]=="SOURCE_CONTROL_READY_GUEST_NOT_DISPATCHED" + assert r["control_sha256"]=="59516bbbcb1750af6f0cb1c624756f08113a070acc11c60affe94d2bbd8d8dd5" + assert r["minecraft_version"]=="26.3" and r["minecraft_protocol"]==777 + assert r["java_version"]==25 and r["truenas_version"]=="25.04.2.6" + assert r["native_guest_created"] is False and r["native_guest_qualified"] is False + assert r["physical_truenas_apply"]=="HOLD" + PY + - name: Publish bounded safe control admission receipt only + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: vanilla263-native-control-consumer-receipt + path: ${{ runner.temp }}/vanilla-control/consumer-receipt.json + if-no-files-found: warn + retention-days: 14 + - name: Honest guest gate + if: always() + run: | + echo 'This job never dispatches a KVM guest, creates a TrueNAS App, tests Java in a guest or authorizes physical TrueNAS APPLY. Selector and guest observer require separate implementation before a new request edge.' >> "$GITHUB_STEP_SUMMARY" diff --git a/config/compute-guest-profiles.json b/config/compute-guest-profiles.json new file mode 100644 index 000000000..5790104f7 --- /dev/null +++ b/config/compute-guest-profiles.json @@ -0,0 +1,236 @@ +{ + "schema": "semper-supra.compute-guest-profiles/v1", + "authority": "SemperSupra/agent-dispatch-private#426", + "dependencies": { + "firecracker": "SemperSupra/agent-dispatch-private#277", + "windows_embodiment": "mark-e-deyoung/windows-utilities#26", + "windows_media": "mark-e-deyoung/WinBot#70" + }, + "policy": { + "source_capability_is_not_runtime_qualification": true, + "nested_kvm_must_be_observed_in_guest": true, + "private_hypervisor_escape_hatches_prohibited": true, + "windows_media_must_be_legal_public_evaluation_or_user_supplied": true, + "windows_product_key_must_not_be_embedded": true, + "guest_oracle_required_before_backend_admission": true + }, + "truenas": { + "25.04.1": { + "vm_adapter": "truenas-virt-incus-vm", + "source_api_family": "v25_04_0", + "windows11": { + "source_status": "CANDIDATE", + "capabilities": { + "secure_boot": { + "surface": "virt.instance.create.secure_boot", + "source_proven": true + }, + "tpm": { + "surface": "virt device dev_type=TPM", + "source_proven": true + }, + "uefi_q35": { + "surface": "incus VM firmware/machine implementation", + "source_proven": false + } + }, + "runtime_status": "OPEN" + }, + "firecracker": { + "source_status": "RUNTIME_OBSERVE_ONLY", + "cpu_passthrough_control": null, + "nested_kvm_requirement": "observe /dev/kvm inside guest; do not inject raw.qemu", + "runtime_status": "OPEN" + } + }, + "25.04.2.6": { + "vm_adapter": "truenas-vm-libvirt", + "source_api_family": "v25_04_2", + "windows11": { + "source_status": "CANDIDATE", + "capabilities": { + "secure_boot": { + "surface": "vm.create.enable_secure_boot", + "source_proven": true + }, + "tpm": { + "surface": "vm.create.trusted_platform_module", + "source_proven": true + }, + "uefi_q35": { + "surface": "vm.create bootloader=UEFI + machine_type=q35 validation", + "source_proven": true + } + }, + "runtime_status": "OPEN" + }, + "firecracker": { + "source_status": "CANDIDATE", + "cpu_passthrough_control": "vm.create.cpu_mode=HOST-PASSTHROUGH", + "nested_kvm_requirement": "observe /dev/kvm inside guest before invoking Firecracker", + "runtime_status": "OPEN" + } + }, + "25.10.7": { + "vm_adapter": "truenas-vm-libvirt", + "source_api_family": "v25_10_0", + "windows11": { + "source_status": "CANDIDATE", + "capabilities": { + "secure_boot": { + "surface": "vm.create.enable_secure_boot", + "source_proven": true + }, + "tpm": { + "surface": "vm.create.trusted_platform_module", + "source_proven": true + }, + "uefi_q35": { + "surface": "vm.create bootloader=UEFI + machine_type=q35 validation", + "source_proven": true + } + }, + "runtime_status": "OPEN" + }, + "firecracker": { + "source_status": "CANDIDATE", + "cpu_passthrough_control": "vm.create.cpu_mode=HOST-PASSTHROUGH", + "nested_kvm_requirement": "observe /dev/kvm inside guest before invoking Firecracker", + "runtime_status": "OPEN" + } + }, + "26.0.0-BETA.3": { + "vm_adapter": "truenas-vm-libvirt", + "source_api_family": "v26_0_0", + "windows11": { + "source_status": "CANDIDATE", + "capabilities": { + "secure_boot": { + "surface": "vm.create.enable_secure_boot", + "source_proven": true + }, + "tpm": { + "surface": "vm.create.trusted_platform_module", + "source_proven": true + }, + "uefi_q35": { + "surface": "vm.create bootloader=UEFI + machine_type=q35 validation", + "source_proven": true + } + }, + "runtime_status": "OPEN", + "fixture": "windows-11-enterprise-evaluation-26h2-x64-en-us" + }, + "firecracker": { + "source_status": "CANDIDATE", + "cpu_passthrough_control": "vm.create.cpu_mode=HOST-PASSTHROUGH", + "nested_kvm_requirement": "observe /dev/kvm inside guest before invoking Firecracker", + "runtime_status": "OPEN" + }, + "linux_v1": { + "source_status": "CANDIDATE", + "runtime_status": "OPEN", + "fixture": "cirros-0.6.3-x86_64-nocloud-serial-nonce", + "public_surfaces": { + "boot_disk": "vm.device.create RAW exists=true boot=true", + "seed_media": "vm.device.create CDROM", + "console_discovery": "vm.get_console" + }, + "observation_requirement": "consume guest console through supported TrueNAS console surface; do not inject raw.qemu or invoke virsh directly" + }, + "linux_v2": { + "source_status": "OBSERVATION_CANDIDATE", + "runtime_status": "OPEN", + "prerequisite": "V1 accepted", + "fixture": "cirros-0.6.3-x86_64-nocloud-serial-nonce", + "vm_cpu_mode": "HOST-PASSTHROUGH", + "observation": { + "kvm_device_marker": "AGENT_DISPATCH_V2_KVM_PRESENT=", + "cpu_extension_marker": "AGENT_DISPATCH_V2_CPU_VMX_SVM=", + "transport": "supported TrueNAS VM console" + }, + "firecracker_gate": "execute #277 portable Firecracker contract only when /dev/kvm and vmx|svm are both observed inside the guest", + "negative_boundary": "nested-KVM absence is a nested-virtualization capability result and does not invalidate V0/V1 VM support" + } + } + }, + "proxmox": { + "9.2-1": { + "vm_adapter": "proxmox-vm-rest", + "windows11": { + "source_status": "CANDIDATE", + "runtime_status": "OPEN", + "capabilities": { + "secure_boot": { + "surface": "qemu config efidisk0 efitype=4m,pre-enrolled-keys=1", + "source_proven": true + }, + "tpm": { + "surface": "qemu config tpmstate0 version=v2.0", + "source_proven": true + }, + "uefi_q35": { + "surface": "qemu config bios=ovmf + machine=q35", + "source_proven": true + } + }, + "source_binding": { + "qemu_server_commit": "6785065b3f766f15f6f151af8ec27ec8bb5b07ab", + "package_version": "9.1.15" + }, + "fixture": "windows-11-enterprise-evaluation-26h2-x64-en-us" + }, + "firecracker": { + "source_status": "PROFILE_PENDING_REST_PAYLOAD_BINDING", + "runtime_status": "OPEN" + } + } + }, + "rungs": { + "V0": "native VM + exact-owned device CRUD/readback/cleanup", + "V1": "boot pinned minimal Linux guest + external deterministic guest nonce", + "V2": "observe nested KVM and, iff present, execute Firecracker portable contract from #277", + "W1": "Windows 11 evaluation/user media install + firmware/TPM/Secure Boot readback + bounded guest oracle + lifecycle/cleanup", + "WB": "WinBot backend candidacy only after W1; admission decision remains windows-utilities#26" + }, + "fixtures": { + "linux_v1": { + "id": "cirros-0.6.3-x86_64-nocloud-serial-nonce", + "release": "0.6.3", + "architecture": "x86_64", + "source": { + "url": "https://download.cirros-cloud.net/0.6.3/cirros-0.6.3-x86_64-disk.img", + "sha256": "7d6355852aeb6dbcd191bcda7cd74f1536cfe5cbf8a10495a7283a8396e4b75b", + "checksum_url": "https://download.cirros-cloud.net/0.6.3/SHA256SUMS" + }, + "datasource": "nocloud", + "user_data_contract": "executable-shell", + "oracle": { + "transport": "serial-console", + "nonce_prefix": "AGENT_DISPATCH_V1_NONCE=", + "must_match_injected_nonce": true + }, + "claim_boundary": "V1 proves pinned Linux guest boot plus exact externally observed per-run nonce only; nested KVM, Firecracker and Windows remain separate rungs" + }, + "windows_w1": { + "id": "windows-11-enterprise-evaluation-26h2-x64-en-us", + "product": "Windows 11 Enterprise Evaluation", + "version": "26H2", + "build": "26300.9457", + "edition": "Enterprise", + "architecture": "x64", + "language": "English", + "source": { + "host": "software-static.download.prss.microsoft.com", + "sha256": "bc3f24086ebadc94489066b5ad78089e2cf5c3491e90e790bb81a2b199c10e38", + "size_bytes": 8225329152, + "qualification_authority": "mark-e-deyoung/WinBot#70", + "qualification_run": 36695017130, + "accepted_artifact": 11087331509, + "accepted_artifact_digest": "sha256:9f94fea45f82fe97056a89ac656d7009885a9d8b846747cf6f5f57d0943451dd" + }, + "product_key_policy": "none-embedded", + "oracle_boundary": "bounded guest readiness/nonce plus lifecycle; no WinBot backend admission claim" + } + } +} diff --git a/config/compute-materialization-targets.json b/config/compute-materialization-targets.json new file mode 100644 index 000000000..8edaafc0b --- /dev/null +++ b/config/compute-materialization-targets.json @@ -0,0 +1,571 @@ +{ + "schema": "semper-supra.compute-materialization-targets/v1", + "authority": "SemperSupra/agent-dispatch-private#426", + "parent_system_authority": "SemperSupra/agent-dispatch-private#396", + "policy": { + "exact_target_required": true, + "observed_capabilities_required_before_apply": true, + "source_profile_required": true, + "cli_fallback_when_product_api_exists": false, + "ambiguous_operation_retry": "reobserve-first", + "workflow_green_not_acceptance": true + }, + "platforms": { + "truenas": { + "targets": [ + { + "version": "25.04.1", + "middleware_commit": "74ab5a2d373be4097dece257d00e1086376333ba", + "management_families": [ + "virt-incus" + ], + "source_fingerprint": { + "virt.instance": "87fc6bb365b3b09820331d90ed2c0138af41ce1b", + "virt.instance_device": "96e68a484762388563384921738d1c7e547f4c6b" + }, + "container_adapters": [ + { + "id": "truenas-virt-incus-container", + "instance_type": "CONTAINER", + "required_methods": [ + "virt.instance.query", + "virt.instance.create", + "virt.instance.update", + "virt.instance.delete", + "virt.instance.start", + "virt.instance.stop", + "virt.instance.restart", + "virt.instance.image_choices" + ], + "guest_oracle": "virt.instance.get_shell-or-instance-network", + "restart_semantics": "native", + "source_api_family": "v25_04_0", + "image_discovery": { + "method": "virt.instance.image_choices", + "request": { + "remote": "LINUX_CONTAINERS" + }, + "source": "https://images.linuxcontainers.org", + "selection": { + "alias": "debian/trixie", + "instance_type": "CONTAINER", + "arch": "amd64" + } + } + } + ], + "vm_adapters": [ + { + "id": "truenas-virt-incus-vm", + "instance_type": "VM", + "required_methods": [ + "virt.instance.query", + "virt.instance.create", + "virt.instance.update", + "virt.instance.delete", + "virt.instance.start", + "virt.instance.stop", + "virt.instance.restart", + "virt.instance.device_list", + "virt.instance.device_add", + "virt.instance.device_update", + "virt.instance.device_delete", + "virt.device.nic_choices" + ], + "guest_oracle": "network-or-vnc/serial-capability", + "restart_semantics": "native", + "hypervisor_family": "incus-qemu", + "source_api_family": "v25_04_0", + "v0_device_ownership": "named-observed-parent-macvlan-nic" + } + ], + "api_schema_fingerprint": { + "virt_instance": { + "path": "src/middlewared/middlewared/api/v25_04_0/virt_instance.py", + "blob_sha": "f5caf3f2f98f8ce43fafc19ecccbd1aee1c7c6e0" + }, + "virt_device": { + "path": "src/middlewared/middlewared/api/v25_04_0/virt_device.py", + "blob_sha": "d6d783d3b0184544f888fcc91de68d12a5a708ba" + } + } + }, + { + "version": "25.04.2.6", + "middleware_commit": "244b717370fe35fb9eefc3096acfbc25f12b57b6", + "management_families": [ + "virt-incus", + "vm-libvirt" + ], + "source_fingerprint": { + "virt.instance": "ac22db500d9d1c6932a4cc109716cd8a3bd46223", + "vm.vms": "993ed915c7058c8e8433d5d5592bb30f24cbfd1c", + "vm.lifecycle": "85c5049c8c90572746ea1acf0a357a88afc88cb5", + "vm.device": "b98466b2247ed2460436c7d9329780f2c88d16ec", + "virt.instance_device": "96e68a484762388563384921738d1c7e547f4c6b", + "vm.info": "f6a267da921101373c620faf602f39ee83b3f527" + }, + "container_adapters": [ + { + "id": "truenas-virt-incus-container", + "instance_type": "CONTAINER", + "required_methods": [ + "virt.instance.query", + "virt.instance.create", + "virt.instance.update", + "virt.instance.delete", + "virt.instance.start", + "virt.instance.stop", + "virt.instance.restart", + "virt.instance.image_choices" + ], + "guest_oracle": "virt.instance.get_shell-or-instance-network", + "restart_semantics": "native", + "source_api_family": "v25_04_2", + "image_discovery": { + "method": "virt.instance.image_choices", + "request": { + "remote": "LINUX_CONTAINERS" + }, + "source": "https://images.linuxcontainers.org", + "selection": { + "alias": "debian/trixie", + "instance_type": "CONTAINER", + "arch": "amd64" + } + } + } + ], + "vm_adapters": [ + { + "id": "truenas-vm-libvirt", + "preferred": true, + "required_methods": [ + "vm.query", + "vm.create", + "vm.update", + "vm.delete", + "vm.status", + "vm.start", + "vm.stop", + "vm.restart", + "vm.virtualization_details", + "pool.dataset.query" + ], + "guest_oracle": "network-or-console-capability", + "restart_semantics": "native", + "hypervisor_family": "libvirt-qemu", + "source_api_family": "v25_04_2", + "required_device_methods": [ + "vm.device.query", + "vm.device.create", + "vm.device.update", + "vm.device.delete" + ], + "v0_storage_ownership": "exact-owned-zvol-device", + "read_only_bootstrap_methods": [ + "vm.license_active" + ] + }, + { + "id": "truenas-virt-incus-vm", + "preferred": false, + "instance_type": "VM", + "required_methods": [ + "virt.instance.query", + "virt.instance.create", + "virt.instance.update", + "virt.instance.delete", + "virt.instance.start", + "virt.instance.stop", + "virt.instance.restart", + "virt.instance.device_list", + "virt.instance.device_add", + "virt.instance.device_update", + "virt.instance.device_delete", + "virt.device.nic_choices" + ], + "guest_oracle": "network-or-vnc/serial-capability", + "restart_semantics": "native", + "hypervisor_family": "incus-qemu", + "source_api_family": "v25_04_2", + "v0_device_ownership": "named-observed-parent-macvlan-nic" + } + ], + "api_schema_fingerprint": { + "virt_instance": { + "path": "src/middlewared/middlewared/api/v25_04_2/virt_instance.py", + "blob_sha": "6076bd3cbe3fb9c1ad4766831e2ffe86e5a1b605" + }, + "vm": { + "path": "src/middlewared/middlewared/api/v25_04_2/vm.py", + "blob_sha": "201721f7e42e663d11f3e26ff6f59ef2bbce0c5f" + }, + "vm_device": { + "path": "src/middlewared/middlewared/api/v25_04_2/vm_device.py", + "blob_sha": "f85240d8a804b5d8347bc9a51d76c9c93c19a813" + }, + "virt_device": { + "path": "src/middlewared/middlewared/api/v25_04_2/virt_device.py", + "blob_sha": "d6d783d3b0184544f888fcc91de68d12a5a708ba" + } + } + }, + { + "version": "25.10.7", + "middleware_commit": "8ede398839710e56893d88ce85088139d8fab18e", + "management_families": [ + "virt-incus", + "vm-libvirt" + ], + "source_fingerprint": { + "virt.instance": "8f3f21996b4d14385ba17405676704cb9b921bea", + "vm.vms": "2532f53d54d845e983c76e8479027587dec81df7", + "vm.lifecycle": "85c5049c8c90572746ea1acf0a357a88afc88cb5", + "vm.device": "7d9b0098692190015fc178d25d8483927b90d757", + "virt.instance_device": "3dca6a0cfe02ca6487dfb1f0118b149d5d3d8103", + "vm.info": "0769a88d67169175bad6868697a9bffabac30f97" + }, + "container_adapters": [ + { + "id": "truenas-virt-incus-container", + "instance_type": "CONTAINER", + "required_methods": [ + "virt.instance.query", + "virt.instance.create", + "virt.instance.update", + "virt.instance.delete", + "virt.instance.start", + "virt.instance.stop", + "virt.instance.restart", + "virt.instance.image_choices" + ], + "guest_oracle": "virt.instance.get_shell-or-instance-network", + "restart_semantics": "native", + "source_api_family": "v25_10_0", + "image_discovery": { + "method": "virt.instance.image_choices", + "request": { + "remote": "LINUX_CONTAINERS" + }, + "source": "https://images.linuxcontainers.org", + "selection": { + "alias": "debian/trixie", + "instance_type": "CONTAINER", + "arch": "amd64" + } + } + } + ], + "vm_adapters": [ + { + "id": "truenas-vm-libvirt", + "preferred": true, + "required_methods": [ + "vm.query", + "vm.create", + "vm.update", + "vm.delete", + "vm.status", + "vm.start", + "vm.stop", + "vm.restart", + "vm.virtualization_details", + "pool.dataset.query" + ], + "guest_oracle": "network-or-console-capability", + "restart_semantics": "native", + "hypervisor_family": "libvirt-qemu", + "source_api_family": "v25_10_0", + "required_device_methods": [ + "vm.device.query", + "vm.device.create", + "vm.device.update", + "vm.device.delete" + ], + "v0_storage_ownership": "exact-owned-zvol-device", + "read_only_bootstrap_methods": [ + "vm.license_active" + ] + }, + { + "id": "truenas-virt-incus-vm", + "preferred": false, + "instance_type": "VM", + "required_methods": [ + "virt.instance.query", + "virt.instance.create", + "virt.instance.update", + "virt.instance.delete", + "virt.instance.start", + "virt.instance.stop", + "virt.instance.restart", + "virt.instance.device_list", + "virt.instance.device_add", + "virt.instance.device_update", + "virt.instance.device_delete", + "virt.device.nic_choices" + ], + "guest_oracle": "network-or-vnc/serial-capability", + "restart_semantics": "native", + "hypervisor_family": "incus-qemu", + "source_api_family": "v25_10_0", + "v0_device_ownership": "named-observed-parent-macvlan-nic" + } + ], + "api_schema_fingerprint": { + "virt_instance": { + "path": "src/middlewared/middlewared/api/v25_10_0/virt_instance.py", + "blob_sha": "5a97786a1522de7fb6e84b516127b01b8fd439df" + }, + "vm": { + "path": "src/middlewared/middlewared/api/v25_10_0/vm.py", + "blob_sha": "2dad2a7e851d354bc9d6e1d201781b2809e77894" + }, + "vm_device": { + "path": "src/middlewared/middlewared/api/v25_10_0/vm_device.py", + "blob_sha": "a7b8d406e81618db21fb98f2b57b002a9f2d87b7" + }, + "virt_device": { + "path": "src/middlewared/middlewared/api/v25_10_0/virt_device.py", + "blob_sha": "c06d0bffe77854f6e60d8a17221b323804c456f6" + } + } + }, + { + "version": "26.0.0-BETA.3", + "middleware_commit": "81e1265a86083888ba94a2bdfc02ff5c9c5ef6a3", + "management_families": [ + "container-lxc", + "vm-libvirt" + ], + "source_fingerprint": { + "container.container": "54111667692fa7beea67893b50149ef605a8ce22", + "container.lifecycle": "1e29c7c3143d9adbd0cb8e03806a3d5f5ebe29e0", + "container.nsenter": "90b6e245e186441a863a701c0c4714671b691184", + "vm.vms": "43ed4b532b40da02ad885039d954c99a93ea22ed", + "vm.lifecycle": "b313e2eeb0baa0179ab9244fc244eee9b02e02e9", + "vm.device": "b0f313b060ffec4cf08ec2e44c706bafc5121830", + "vm.info": "7a3dd519a011ae0cceeaa2e32dca7f37210a8f22", + "container.image": "43f01e991ceaacd46ca2292cc8cc6d460dc3dc7b" + }, + "container_adapters": [ + { + "id": "truenas-container-lxc", + "required_methods": [ + "container.query", + "container.create", + "container.update", + "container.delete", + "container.start", + "container.stop", + "container.image.query_registry" + ], + "guest_oracle": "supported-shell/namespace-or-instance-network", + "restart_semantics": "compose-stop-start", + "implementation_family": "lxc", + "source_api_family": "v26_0_0", + "image_discovery": { + "method": "container.image.query_registry", + "source": "https://images.sys.truenas.net/streams", + "selection": { + "name": "ubuntu:noble:amd64:default", + "version_policy": "last-returned-exact" + } + } + } + ], + "vm_adapters": [ + { + "id": "truenas-vm-libvirt", + "preferred": true, + "required_methods": [ + "vm.query", + "vm.create", + "vm.update", + "vm.delete", + "vm.status", + "vm.start", + "vm.stop", + "vm.restart", + "vm.virtualization_details", + "pool.dataset.query", + "system.product_type", + "system.feature_enabled" + ], + "guest_oracle": "network-or-console-capability", + "restart_semantics": "native", + "hypervisor_family": "libvirt-qemu", + "source_api_family": "v26_0_0", + "required_device_methods": [ + "vm.device.query", + "vm.device.create", + "vm.device.update", + "vm.device.delete" + ], + "v0_storage_ownership": "exact-owned-zvol-device", + "public_entitlement": { + "product_type_method": "system.product_type", + "feature_method": "system.feature_enabled", + "enterprise_feature": "VMS" + } + } + ], + "api_schema_fingerprint": { + "container": { + "path": "src/middlewared/middlewared/api/v26_0_0/container.py", + "blob_sha": "6c1d11b9d42d5bfc6dd242d77c017b7fbe1a26aa" + }, + "vm": { + "path": "src/middlewared/middlewared/api/v26_0_0/vm.py", + "blob_sha": "0944382fedc0a8cbcfc55fb8a653a9e5758e88fd" + }, + "vm_device": { + "path": "src/middlewared/middlewared/api/v26_0_0/vm_device.py", + "blob_sha": "67603b6e2048da6235b802f970559b627857efc9" + }, + "container_image": { + "path": "src/middlewared/middlewared/api/v26_0_0/container_image.py", + "blob_sha": "a8eec804213c011b334ab0ddc9c9a37003a35a31" + } + } + } + ] + }, + "proxmox": { + "targets": [ + { + "version": "9.2-1", + "management_families": [ + "pve-rest-lxc", + "pve-rest-qemu" + ], + "source_fingerprint": { + "iso_sha256": "4e88fe416df9b527624a175f24c9aa07c714d3332afb1ee3dbf3879573ef2c6c", + "pve_installer_version": "9.2.5", + "pve_installer_commit": "32afcd4cd534d8e2f99ae76aa0234a0a5c697ba9", + "pve_container_commit": "5eb5574ee9158ac40a5230de2cf18d7d6345709f", + "pve_qemu_commit": "684796e835289dab11af8606fbf7358b93526dd6", + "pve_qemu_submodule_commit": "98b060da3a4f92b2a994ead5b16a87e783baf77c", + "expected_pve_container_package": "6.1.10", + "expected_pve_qemu_kvm_package": "11.0.0-3" + }, + "container_adapters": [ + { + "id": "proxmox-lxc-rest", + "required_api_templates": [ + "POST /nodes/{node}/lxc", + "GET /nodes/{node}/lxc/{vmid}/config", + "POST /nodes/{node}/lxc/{vmid}/status/start", + "POST /nodes/{node}/lxc/{vmid}/status/stop", + "DELETE /nodes/{node}/lxc/{vmid}" + ], + "guest_oracle": "agent-or-console/exec-boundary", + "implementation_family": "pve-lxc" + } + ], + "vm_adapters": [ + { + "id": "proxmox-vm-rest", + "required_api_templates": [ + "POST /nodes/{node}/qemu", + "GET /nodes/{node}/qemu/{vmid}/config", + "POST /nodes/{node}/qemu/{vmid}/status/start", + "POST /nodes/{node}/qemu/{vmid}/status/stop", + "POST /nodes/{node}/qemu/{vmid}/status/reboot", + "DELETE /nodes/{node}/qemu/{vmid}" + ], + "guest_oracle": "agent-or-network/console-boundary", + "hypervisor_family": "pve-qemu-kvm" + } + ], + "compute_materialization_qualified": false + } + ], + "candidate_targets": [ + { + "version": "9.1-1", + "iso_name": "proxmox-ve_9.1-1.iso", + "iso_sha256": "6d8f5afc78c0c66812d7272cde7c8b98be7eb54401ceb045400db05eb5ae6d22", + "vendor_index": "https://enterprise.proxmox.com/iso/", + "state": "source-profile-open", + "claim_boundary": "exact vendor ISO identity only; PVE API/package/source profile and runtime qualification remain open" + }, + { + "version": "9.0-1", + "iso_name": "proxmox-ve_9.0-1.iso", + "iso_sha256": "228f948ae696f2448460443f4b619157cab78ee69802acc0d06761ebd4f51c3e", + "vendor_index": "https://archive.proxmox.com/iso/", + "state": "source-profile-open", + "claim_boundary": "exact vendor ISO identity only; PVE API/package/source profile and runtime qualification remain open" + }, + { + "version": "8.4-1", + "iso_name": "proxmox-ve_8.4-1.iso", + "iso_sha256": "d237d70ca48a9f6eb47f95fd4fd337722c3f69f8106393844d027d28c26523d8", + "vendor_index": "https://enterprise.proxmox.com/iso/", + "state": "source-profile-open", + "claim_boundary": "exact vendor ISO identity only; PVE API/package/source profile and runtime qualification remain open" + } + ] + } + }, + "fixtures": { + "system_container": { + "semantic_id": "linux-system-container-nonce/v1", + "required_lifecycle": [ + "create", + "readback", + "start", + "guest-nonce", + "update-if-supported", + "restart", + "stop", + "delete", + "absence", + "noop" + ] + }, + "firecracker_vm": { + "semantic_id": "linux-firecracker-nested-vm/v1", + "required_lifecycle": [ + "create", + "readback", + "start", + "guest-nonce", + "nested-kvm-observe", + "firecracker-if-kvm", + "restart", + "delete", + "absence" + ], + "nested_kvm": "must-be-observed-not-assumed", + "qualification_rungs": { + "V0": "native VM + owned device CRUD/readback/cleanup; guest boot not claimed", + "V1": "boot minimal Linux guest + deterministic network/agent nonce", + "V2": "observe nested KVM; execute Firecracker microVM only if /dev/kvm is genuinely exposed" + } + }, + "windows11_vm": { + "semantic_id": "windows-11-evaluation-vm/v1", + "required_lifecycle": [ + "create", + "readback", + "start", + "unattended-install", + "guest-oracle", + "restart", + "stop", + "delete", + "absence" + ], + "source_policy": "legal-public-evaluation-or-user-provided-media; no embedded product key", + "firmware_policy": "observe UEFI/q35/TPM/Secure-Boot capabilities; fail closed where required", + "qualification_rungs": { + "V0": "native VM + owned device CRUD/readback/cleanup", + "W1": "Windows 11 evaluation boot/install + bounded guest oracle + restart/stop/delete" + } + } + }, + "claim_boundary": "Source/profile/planning contract only. No container/VM target or fixture is runtime-qualified by this registry." +} diff --git a/config/proxmox-rdte-run-request.json b/config/proxmox-rdte-run-request.json new file mode 100644 index 000000000..849fa7412 --- /dev/null +++ b/config/proxmox-rdte-run-request.json @@ -0,0 +1,9 @@ +{ + "schema": "gha-kvm-proxmox-run-request/v1", + "request_id": "pve-9-2-compute-vm-v0-001-source-bound-rest-restaged", + "version": "9.2-1", + "compute_fixture": "vm-v0", + "authority_issue": 396, + "consumer_authority_issue": 426, + "purpose": "Exercise the first bounded Proxmox VE 9.2-1 native REST VM V0 materialization rep after source/profile binding, merged #318 runtime support, and #320 request-contract repair. Require exact installed qemu-server 9.1.15 admission, REST VM create/readback/start/stop/delete/absence, provider-owned cleanup and zero residue. This rep proves only the native VM/config lifecycle shell (V0); no Linux guest boot/nonce, nested KVM, Firecracker, Windows, Hyper-V or WinBot provider claim." +} diff --git a/config/proxmox-rdte-targets.json b/config/proxmox-rdte-targets.json new file mode 100644 index 000000000..f6d64c4e4 --- /dev/null +++ b/config/proxmox-rdte-targets.json @@ -0,0 +1,64 @@ +{ + "schema": "gha-kvm-proxmox-targets/v1", + "policy": { + "exact_version_required": true, + "floating_target_prohibited": true, + "runtime_admission_is_explicit": true, + "evidence_inheritance_across_versions": false + }, + "targets": [ + { + "version": "9.2-1", + "runtime_admitted": true, + "authority_issue": 396, + "iso_name": "proxmox-ve_9.2-1.iso", + "iso_url": "https://enterprise.proxmox.com/iso/proxmox-ve_9.2-1.iso", + "iso_sha256": "4e88fe416df9b527624a175f24c9aa07c714d3332afb1ee3dbf3879573ef2c6c", + "installer_source_version": "9.2.5", + "installer_source_commit": "32afcd4cd534d8e2f99ae76aa0234a0a5c697ba9", + "pve_manager_source_commit": "b9984c6d90a4bd80ab72dc2088c1b9103fb167b1", + "pve_manager_package_version": "9.2.2", + "pve_manager_apt_api_blob": "9cb6e473436719f3024ac09fffaad8faf0d7160d", + "pve_access_control_source_commit": "5ccd07d9302562b73374d331b63d25b04b86766c", + "pve_container_source_commit": "5eb5574ee9158ac40a5230de2cf18d7d6345709f", + "pve_container_package_version": "6.1.10", + "pve_container_lxc_api_blob": "88067ddebf5a0bd540f91ffd1db11f8eec40cefb", + "pve_container_status_api_blob": "c95fc3c7732d637639ab29e496bc27eafb9d222d", + "pve_qemu_source_commit": "684796e835289dab11af8606fbf7358b93526dd6", + "pve_qemu_package_version": "11.0.0-3", + "pve_qemu_submodule_commit": "98b060da3a4f92b2a994ead5b16a87e783baf77c", + "container_fixture": { + "template_name": "debian-13-standard_13.1-2_amd64.tar.zst", + "template_url": "http://download.proxmox.com/images/system/debian-13-standard_13.1-2_amd64.tar.zst", + "catalog_source": { + "repository": "proxmox/pve-manager", + "commit": "b9984c6d90a4bd80ab72dc2088c1b9103fb167b1", + "path": "PVE/APLInfo.pm", + "blob_sha": "433a8ef4bf78d606508c6bae162c49e9ef0ecfd3", + "base_url": "http://download.proxmox.com/images" + }, + "template_sha512": "5aec4ab2ac5c16c7c8ecb87bfeeb10213abe96db6b85e2463585cea492fc861d7c390b3f9c95629bf690b95e9dfe1037207fc69c0912429605f208d5cb2621f8" + }, + "required_rest_surfaces": [ + "/version", + "/nodes", + "/nodes/{node}/apt/versions", + "/nodes/{node}/lxc", + "/nodes/{node}/lxc/{vmid}/config", + "/nodes/{node}/lxc/{vmid}/status/current", + "/nodes/{node}/lxc/{vmid}/status/start", + "/nodes/{node}/lxc/{vmid}/status/stop", + "/nodes/{node}/tasks/{upid}/status" + ], + "qemu_server_source": { + "repository": "proxmox/qemu-server", + "commit": "6785065b3f766f15f6f151af8ec27ec8bb5b07ab", + "package_version": "9.1.15", + "api2_qemu_blob": "e029a204d121f3c8b104457ef14eb6d5ce029464", + "qemu_server_blob": "118f26bc94d9ee8e8c4c39a3d710e67c14f61bc0", + "changelog_blob": "63ded16d9d06ae0dd114a67d19bb36e14c71c980" + }, + "vm_source_status": "SOURCE_BOUND_RUNTIME_NOT_YET_QUALIFIED" + } + ] +} diff --git a/config/truenas-capsule-providers.json b/config/truenas-capsule-providers.json new file mode 100644 index 000000000..58a5ef457 --- /dev/null +++ b/config/truenas-capsule-providers.json @@ -0,0 +1,57 @@ +{ + "schema": "truenas-capsule-providers/v1", + "authority": { + "repository": "SemperSupra/agent-dispatch-private", + "issue": 480 + }, + "providers": [ + { + "id": "litellm-t6", + "kind": "foundry-product", + "product": "litellm", + "probe": "scripts/truenas_middleware_litellm_t6_probe.py", + "control_artifact": "litellm-truenas-t6-control", + "existing_selector": "litellm" + }, + { + "id": "wow-sidecar-t6", + "kind": "foundry-product", + "product": "wow-sidecar", + "probe": "scripts/truenas_middleware_wow_sidecar_t6_probe.py", + "control_artifact": "wow-sidecar-truenas-t6-control", + "existing_selector": "wow-sidecar" + }, + { + "id": "garm-t6", + "kind": "foundry-product", + "product": "garm", + "probe": "scripts/truenas_middleware_garm_t6_probe.py", + "control_artifact": "garm-truenas-t6-control", + "existing_selector": "garm" + }, + { + "id": "official-catalog-t6", + "kind": "official-catalog-control", + "product": "official-catalog", + "probe": "scripts/truenas_middleware_official_catalog_t6_probe.py", + "control_artifact": "official-catalog-truenas-t6-control", + "existing_selector": "official-catalog" + }, + { + "id": "foliorelay-t6", + "kind": "foundry-product", + "product": "foliorelay", + "probe": "scripts/truenas_middleware_foliorelay_t6_probe.py", + "control_artifact": "foliorelay-t6-control", + "existing_selector": "foliorelay" + }, + { + "id": "minecraft-paper-t6", + "kind": "foundry-product", + "product": "minecraft-paper", + "probe": "scripts/truenas_middleware_minecraft_t6_probe.py", + "control_artifact": "minecraft-paper-truenas-t6-control", + "existing_selector": "minecraft-paper" + } + ] +} diff --git a/config/truenas-rdte-run-request.json b/config/truenas-rdte-run-request.json new file mode 100644 index 000000000..62299dfa4 --- /dev/null +++ b/config/truenas-rdte-run-request.json @@ -0,0 +1,15 @@ +{ + "schema": "gha-kvm-truenas-run-request/v1", + "request_id": "tn-25-04-2-6-t6-vanilla-263-op4m-020-namespaced-overworld-anvil-three-checkpoint", + "version": "25.04.2.6", + "rung": "t6", + "product": "minecraft-vanilla-263", + "authority_issue": 423, + "consumer_authority_issue": 627, + "purpose": "OP4M-020 unique one-use $0 public serialized disposable KVM TrueNAS25.04.2.6 T6 native community Minecraft App1.15.31/lib2.3.16 Java25 vanilla26.3/protocol777 independent three-checkpoint server-owned Anvil backend after OP4L-019 permanently CONSUMED on obsolete pre-26.1 root-level world/region path. OP4I-016 permanently consumed; OP4J-017 permanently consumed; OP4K-018 permanently CONSUMED; OP4L-019 permanently CONSUMED; NEVER REPLAY any historical one-use request. Mojang official Java26.1 Snapshot6 changed Overworld region path to world/dimensions/minecraft/overworld/region/r.0.0.mca; exact source repaired in Agent Dispatch experiment 725d2d9f5c7a9573d28283a5844ccc359d567722, and independent real Java25/26.3 stopped OCI/OP3C/OP4E physical byte presence (not TrueNAS G4) from SupraCraft/mc-server-images bed73818a39208a31d90ffc9d4aab0d57affc65a. Pinned COLLAGE b9256d6ec0dbac43be2f5a136103b6e3512ee2ec explicit vanilla263-11531 TLS APPS_READ-only six ownership markers; no observation mutations, no authenticated principal-authorized COLLAGE APPLY. Keep immutable Foundry source5ee204df205edfd531304441fd1a6a24004557b1 canonical control sha256 59516bbbcb1750af6f0cb1c624756f08113a070acc11c60affe94d2bbd8d8dd5 and truenas/apps@95b0cc78e75fcd4b128f4ef2f8428034d981f7c2, exact Java25 OCI digest sha256:5a2ab74ae179c11e2dbbce67f307cdcf2c1257b3abb0f654d3a310b3d8ea57da, immutable original 739-byte Lantern datapack sha256 da25005e8729e6f013f7108ac5b8869dd46323befc78efdae18221b26cafe5f8. Strict isolated world ZFS owned dataset and G3 exact owned snapshot/injected-fault rollback with stopped App, synthetic Mineflayer protocol777 127.0.0.1 QEMU loopback (no stock Mojang account/VIP, no external ingress, RCON disabled, signed operational online-mode unchanged), bounded source pin. Three STOPPED preaction/candidate/rollback fixed namespaced Overworld r.0.0.mca Anvil captures through independent read-only TrueNAS filesystem.stat/core.download, independent block-state and three distinct stage checksums, exact service/owner checks and independent receipt verifier with full App/world zero-residue and platform-health cleanup. Only one fresh run after independent public cheap red/blue CI, latest source/control/catalog and verified idle heavyweight runner queue. All prior 016-019 CONSUMED. No private GHA-hosted minutes, no physical TrueNAS APPLY, no release/world artifact promotion, no authenticated COLLAGE mutation, no G5, no complete Two Rivers MCWORLD or operational approval.", + "collage_readback": true, + "lantern_stage": true, + "native_world_rollback": true, + "native_offline_player": true, + "native_backend_region": true +} diff --git a/config/truenas-rdte-targets.json b/config/truenas-rdte-targets.json new file mode 100644 index 000000000..cccf012e2 --- /dev/null +++ b/config/truenas-rdte-targets.json @@ -0,0 +1,83 @@ +{ + "schema": "gha-kvm-truenas-targets/v1", + "policy": { + "exact_version_required": true, + "vendor_digest_sidecar_required": true, + "floating_target_prohibited": true, + "heavyweight_slot": "shared-one-at-a-time" + }, + "targets": [ + { + "version": "25.04.1", + "channel": "deployed-legacy", + "system_version": "TrueNAS-25.04.1", + "iso_name": "TrueNAS-SCALE-25.04.1.iso", + "iso_url": "https://download.sys.truenas.net/TrueNAS-SCALE-Fangtooth/25.04.1/TrueNAS-SCALE-25.04.1.iso", + "sha256_url": "https://download.sys.truenas.net/TrueNAS-SCALE-Fangtooth/25.04.1/TrueNAS-SCALE-25.04.1.iso.sha256", + "middleware_ref": "release/25.04.1", + "middleware_commit": "74ab5a2d373be4097dece257d00e1086376333ba", + "foundry_profile": ".foundry/truenas-compatibility/25.04.1-materialization.json", + "ha_apps_gate": "system.license:JAILS", + "authority_issue": 411, + "installer_rpc_path": "/", + "installer_rpc_guest_port": 80, + "installer_source_ref": "release/25.04.1", + "installer_main_blob_sha": "4845a665568fcdb8ed911f3f6a23f6373cbe0b8f", + "foundry_profile_blob": "800aca038ded29e6a91bfc4c1b1fe2d2199182e8" + }, + { + "version": "25.04.2.6", + "channel": "recent-maintenance", + "system_version": "TrueNAS-25.04.2.6", + "iso_name": "TrueNAS-SCALE-25.04.2.6.iso", + "iso_url": "https://download.truenas.com/TrueNAS-SCALE-Fangtooth/25.04.2.6/TrueNAS-SCALE-25.04.2.6.iso", + "sha256_url": "https://download.truenas.com/TrueNAS-SCALE-Fangtooth/25.04.2.6/TrueNAS-SCALE-25.04.2.6.iso.sha256", + "middleware_ref": "release/25.04.2.6", + "middleware_commit": "244b717370fe35fb9eefc3096acfbc25f12b57b6", + "foundry_profile": ".foundry/truenas-compatibility/25.04.2.6-materialization.json", + "ha_apps_gate": "system.license:JAILS", + "installer_rpc_path": "/", + "authority_issue": 423, + "installer_rpc_guest_port": 80, + "installer_source_ref": "release/25.04.2.6", + "installer_main_blob_sha": "4845a665568fcdb8ed911f3f6a23f6373cbe0b8f", + "foundry_profile_blob": "631467044411c96a0fe6d361688b99a794f07c80" + }, + { + "version": "25.10.7", + "channel": "stable", + "system_version": "TrueNAS-25.10.7", + "iso_name": "TrueNAS-SCALE-25.10.7.iso", + "iso_url": "https://download.sys.truenas.net/TrueNAS-SCALE-Goldeye/25.10.7/TrueNAS-SCALE-25.10.7.iso", + "sha256_url": "https://download.sys.truenas.net/TrueNAS-SCALE-Goldeye/25.10.7/TrueNAS-SCALE-25.10.7.iso.sha256", + "middleware_ref": "TS-25.10.7", + "middleware_commit": "8ede398839710e56893d88ce85088139d8fab18e", + "foundry_profile": ".foundry/truenas-compatibility/25.10.7-materialization.json", + "ha_apps_gate": "system.license:JAILS", + "authority_issue": 410, + "installer_rpc_path": "/ws", + "installer_rpc_guest_port": 8080, + "installer_source_ref": "release/25.10.7", + "installer_main_blob_sha": "9e4f8b2bd9db497210fe6737c053080137be94d8", + "foundry_profile_blob": "1320cc849a9cc1e7ff6bd78989fc0e4708397248" + }, + { + "version": "26.0.0-BETA.3", + "channel": "early-release", + "system_version": "TrueNAS-26.0.0-BETA.3", + "iso_name": "TrueNAS-26.0.0-BETA.3.iso", + "iso_url": "https://download.sys.truenas.net/TrueNAS-26-BETA/26.0.0-BETA.3/TrueNAS-26.0.0-BETA.3.iso", + "sha256_url": "https://download.sys.truenas.net/TrueNAS-26-BETA/26.0.0-BETA.3/TrueNAS-26.0.0-BETA.3.iso.sha256", + "middleware_ref": "TS-26.0.0-BETA.3", + "middleware_commit": "81e1265a86083888ba94a2bdfc02ff5c9c5ef6a3", + "foundry_profile": ".foundry/truenas-compatibility/26.0.0-BETA.3-materialization.json", + "ha_apps_gate": "system.feature_enabled:APPS", + "authority_issue": 396, + "installer_rpc_path": "/ws", + "installer_rpc_guest_port": 8080, + "installer_source_ref": "release/26.0.0-BETA.3", + "installer_main_blob_sha": "9e4f8b2bd9db497210fe6737c053080137be94d8", + "foundry_profile_blob": "a8ebc533dc84172520eba2237ffeeb52b102dc24" + } + ] +} diff --git a/config/truenas-session-foliorelay-single-equivalence.json b/config/truenas-session-foliorelay-single-equivalence.json new file mode 100644 index 000000000..adacd9158 --- /dev/null +++ b/config/truenas-session-foliorelay-single-equivalence.json @@ -0,0 +1,67 @@ +{ + "authority": { + "issue": 480, + "repository": "SemperSupra/agent-dispatch-private" + }, + "budget": { + "download_mib": 512, + "guest_memory_mib": 8192, + "timeout_minutes": 20 + }, + "capsules": [ + { + "authority": { + "issue": 29, + "repository": "SemperSupra/folio-relay" + }, + "cleanup_required": true, + "dependencies": [], + "exact": { + "compose_sha256": "d4fc297eccdd884bf1882f3cceb5f3c97c1302e5537528cb06633fa2b65e9c78", + "control_image": "ghcr.io/sempersupra/foliorelay-control@sha256:0ffabcc1ced0325c41c54d860c6fe248e4fc8afeea3994dcebb999d6a14ee1ce", + "cups_image": "ghcr.io/sempersupra/foliorelay-cups@sha256:b644b4b1e064a1d10c18fbbb9f9aa09a2835e7e9a48ccda5a67d44cbda006b4f", + "foundry_ref": "fb41afd3d112f361b8c490aeb5915a978b956b20", + "product_head": "88fd70c8891eaf7b4c886aa928b96e232ca041c6" + }, + "id": "foliorelay-beta3-single-equivalence", + "kind": "foundry-product", + "mutating": true, + "namespace": "rdte-foliorelay-equivalence", + "oracle": "existing FolioRelay T6 product oracle through session wrapper", + "phases": { + "apply": true, + "cleanup": true, + "setup": true, + "verify": true + }, + "ports": [ + 8634, + 18080, + 18081 + ], + "provider": "foliorelay-t6", + "receipt_contract": "truenas-capsule-execution/v1", + "requirements": [ + "apps", + "zfs-pool", + "host-network-dnssd", + "ipp" + ], + "resources": { + "download_mib": 256, + "estimated_minutes": 10, + "memory_mib": 2048 + }, + "retry_policy": "read-only-reconcile", + "schema": "truenas-capsule/v1" + } + ], + "manifest_sha256": "a59e7781ef64c5679426f1f0d1231d6b4a2e903db35129c4d3064da81543be8b", + "pool": { + "data_disks": 2, + "name": "rdtepool" + }, + "schema": "truenas-session/v1", + "session_id": "tn-26-beta3-foliorelay-single-equivalence-001", + "version": "26.0.0-BETA.3" +} diff --git a/config/truenas-session-minecraft-paper-25-04-1.json b/config/truenas-session-minecraft-paper-25-04-1.json new file mode 100644 index 000000000..4281b5da4 --- /dev/null +++ b/config/truenas-session-minecraft-paper-25-04-1.json @@ -0,0 +1,68 @@ +{ + "schema": "truenas-session/v1", + "session_id": "tn-25-04-1-minecraft-paper-t6-001", + "version": "25.04.1", + "authority": { + "repository": "SemperSupra/agent-dispatch-private", + "issue": 480 + }, + "budget": { + "guest_memory_mib": 8192, + "download_mib": 2048, + "timeout_minutes": 48 + }, + "pool": { + "name": "rdtepool", + "data_disks": 2 + }, + "capsules": [ + { + "schema": "truenas-capsule/v1", + "id": "minecraft-paper-world-retention-001", + "kind": "foundry-product", + "provider": "minecraft-paper-t6", + "authority": { + "repository": "SupraCraft/mc-server-images", + "issue": 53 + }, + "exact": { + "foundry_producer_ref": "2cc7e1fe3772f8c532e933b3d7832e6cf985db4e", + "foundry_artifact_sha256": "cd249c0a97069ff958475b65dbf2143769fe87534c8879b02912de9e146ab525", + "minecraft_product_oracle_ref": "d383c657b6accc11fe587f4e9bcd6e2c1a4aa7a8", + "minecraft_version": "1.21.11", + "native_catalog_candidate": "1.15.29", + "truenas_target_profile_blob": "800aca038ded29e6a91bfc4c1b1fe2d2199182e8", + "native_image_reference": "ghcr.io/itzg/minecraft-server:2026.9.2-java21@sha256:edf50d431ddc6f73929368e82f4334a405796874f7b0ae11f1edcbb267d670aa", + "upstream_apps_commit": "bb96ef6fd8c2708765012a4a3ca3739370a4f92c" + }, + "namespace": "rdte-minecraft-paper-25-04-1", + "ports": [ + 25580 + ], + "requirements": [ + "apps", + "zfs-pool", + "native-catalog", + "minecraft-java-status" + ], + "resources": { + "memory_mib": 4096, + "download_mib": 1024, + "estimated_minutes": 35 + }, + "phases": { + "setup": true, + "apply": true, + "verify": true, + "cleanup": true + }, + "oracle": "Minecraft Java status, level.dat world survival after App ixVolume delete and reinstall, zero owned residue", + "receipt_contract": "truenas-minecraft-paper-t6/v1", + "retry_policy": "read-only-reconcile", + "mutating": true, + "cleanup_required": true, + "dependencies": [] + } + ], + "manifest_sha256": "85a740366843255ff2cee80ee0da3550429c911614b91d27325badf58a9d3152" +} diff --git a/config/truenas-vanilla263-native-admission-request.json b/config/truenas-vanilla263-native-admission-request.json new file mode 100644 index 000000000..9e3704c84 --- /dev/null +++ b/config/truenas-vanilla263-native-admission-request.json @@ -0,0 +1,10 @@ +{ + "schema": "gha-kvm-truenas-run-request/v1", + "request_id": "tn-25-04-2-6-t6-vanilla-263-op4b-001", + "version": "25.04.2.6", + "rung": "t6", + "product": "minecraft-vanilla-263", + "authority_issue": 423, + "consumer_authority_issue": 627, + "purpose": "Source-only eligibility for a new isolated TrueNAS 25.04.2.6 vanilla26.3 Java25 native T6 product, with distinct exact Foundry control, pinned catalog image and independently owned world. This document is NOT wired to request-edge GHA, not a live guest admission, and never physical TrueNAS APPLY." +} diff --git a/docs/compute-materialization-contract.md b/docs/compute-materialization-contract.md new file mode 100644 index 000000000..40a2d03c1 --- /dev/null +++ b/docs/compute-materialization-contract.md @@ -0,0 +1,36 @@ +# Cross-platform compute materialization contract + +Private authority: `SemperSupra/agent-dispatch-private#426`. + +This contract describes how Agent Dispatch will select a native product adapter +for system containers and VMs without pretending the API is stable across +TrueNAS releases or that one Proxmox release proves another. + +## TrueNAS source-discovered transition + +- 25.04.1: `virt.instance` is Incus-backed and owns both CONTAINER and VM instances. +- 25.04.2.6 / 25.10.7: the Incus `virt.instance` family remains, while a + separate `vm.*` libvirt/QEMU family is also present. +- 26.0.0-BETA.3: the legacy `virt` plugin is gone; first-class + `container.*` uses the new LXC-oriented implementation and `vm.*` + remains the VM surface. + +The planner therefore chooses by exact profile **and observed methods**. Version +alone never authorizes apply. + +## Proxmox + +The current real-system harness admits only PVE 9.2-1. The compute contract +defines REST-family LXC and QEMU adapters for that exact profile. Additional PVE +versions must be added as exact source/package/ISO profiles before execution. + +## Fixtures + +Three semantic fixtures are tracked: +1. small Linux system container with lifecycle + nonce; +2. Linux VM with nested-KVM observation and Firecracker only when KVM is + actually available inside the VM; +3. Windows 11 evaluation VM with firmware/security capability checks. + +The registry and planner are cheap/source-level contracts. They perform no +mutation and grant no runtime support claim. diff --git a/docs/gha-kvm-system-rdte.md b/docs/gha-kvm-system-rdte.md new file mode 100644 index 000000000..f6e446dd4 --- /dev/null +++ b/docs/gha-kvm-system-rdte.md @@ -0,0 +1,284 @@ +# Real-system KVM RDTE on public GitHub Actions + +Private experiment authority: `SemperSupra/agent-dispatch-private#396`. + +This public experiment qualifies whether the already-proven x64 GitHub-hosted KVM +surface can run disposable **real TrueNAS and Proxmox systems** for development and +compatibility testing. It extends the accepted runner census from +`SemperSupra/agent-dispatch-private#223`; it does not reopen or reinterpret that census. + +## Responsibility boundary + +```text +Agent Dispatch / private experiment authority + | + v +public-safe system RDTE adapters + SemperSupra/agent-dispatch + | | + v v + TrueNAS ISO Proxmox ISO + | | + +---- QEMU/KVM -+ + | + v + sanitized qualification receipt + +TrueNAS App Foundry consumes a proven TrueNAS target profile later. +It does not own or duplicate the VM substrate. +``` + +The common contract is deliberately small: host/resource preflight, exact source +identity, KVM entry gate, bounded target shape, target oracle, evidence classification, +diagnostics, and teardown. Installer behavior stays target-local. + +## Initial target rungs + +### TrueNAS + +Initial target: pinned `26.0.0-BETA.3`. + +- **T0 (implemented here):** vendor SHA256 sidecar -> exact ISO verification -> serial-observable installer boot. +- **T1:** invoke the vendor's own installer machinery deterministically. +- **T2:** boot installed TrueNAS and prove middleware/API health. +- **T3:** attach disposable sparse data disks and create a real ZFS pool. +- **T4:** initialize Apps and run one synthetic public-safe custom app. +- **T5:** app lifecycle/configuration/restart/delete. +- **T6:** consume an exact public TrueNAS App Foundry materialization control. +- **T7:** only after T0-T6, add stable and moving prerelease/nightly profiles. + +The T0 ISO modification changes only GRUB console observability so the experiment has +a machine-readable serial oracle. It does not substitute a different installer. + +### Proxmox VE + +Initial target: pinned `9.2-1`. + +The adapter embeds a minimal answer file using the vendor automated-installer contract, +constructs an auto-installable ISO, patches only the automated boot entry to expose its +console on `ttyS0`, installs to a disposable sparse SCSI disk, boots the installed +system, and probes its HTTPS API. + +Current rungs: + +- **P0:** exact ISO + published digest. +- **P1:** official unattended installation. +- **P2:** installed management-plane/API response. +- **P4 observation:** if SSH is available, record whether nested KVM is exposed. +- **P3/P5:** LXC lifecycle and an actual L3 KVM VCPU nonce are follow-up gates; presence + of `/dev/kvm` is not sufficient evidence for P5. + +## Resource and safety guardrails + +- public/free GitHub-hosted x64 runners only; +- one heavyweight system VM at a time; +- no private repository payloads or credentials; +- no paid/larger runner assumptions; +- TrueNAS guest capped at 8 GiB for the first rung; +- sparse virtual disks only; +- host RAM/disk headroom checked before a multi-gigabyte download/VM launch; +- localhost port forwards instead of persistent host bridges/TAP configuration; +- no stress/load testing; +- experiment-owned state removed on exit; +- failures are receipts, not automatically failed CI jobs. + +A public GHA pass is RDTE evidence for this virtual target profile. It is not physical +hardware qualification and is not production hosting. + +## Evidence semantics + +Retain the existing ladder: + +```text +advertised -> observed -> installed -> callable -> exercised -> oracleSatisfied +``` + +and classifications including `SUPPORTED`, `INCONCLUSIVE`, +`HARNESS_FAILURE`, `ENVIRONMENT_FAILURE`, `ORACLE_FAILURE`, and +`SKIPPED_GUARDRAIL`. + +Do not promote placement or target support merely because the workflow is green. + +## Active-campaign workflow posture + +During the bounded experiment campaign the workflow is allowed to run on pushes to the +single experiment branch so harness corrections receive real hosted-runner evidence. +After the active qualification rungs complete, freeze it back to explicit +`workflow_dispatch` as done by the earlier runner-census campaign. + +The workflow installs only host-side packages required to supply the already-qualified +QEMU/KVM test mechanism (`qemu-system-x86`, `qemu-utils`, `xorriso`, and +`sshpass`). Target operating-system packages are supplied by their pinned vendor ISOs. + +## Promotion boundary + +Only after TrueNAS T4/T5 pass should TrueNAS App Foundry receive a runtime target-profile +adapter. Only public materializations are eligible for this public RDTE. Application +development remains in product repositories and private promotion/evaluator authority +remains outside this public repository. + + +## Active-campaign target scoping + +Heavy system jobs are demand-scoped during the experiment campaign. A push that +changes only the Proxmox adapter runs the Proxmox lane; a push that changes the +TrueNAS adapter or installer RPC probe runs the TrueNAS lane. Contract, test, or +documentation-only changes do not launch a system VM. Manual dispatch can request +`proxmox`, `truenas`, or `both`. + +When both heavy targets are requested they remain serialized. This keeps the public +free-tier RDTE useful without replaying multi-gigabyte installs that cannot change the +evidence under test. + + +### Proxmox P2 installed-guest witness + +P2 must distinguish the QEMU host-forward listener from the installed guest. The +diagnostic adapter uses the vendor automated installer's supported `[first-boot]` +hook with `source = "from-iso"`, but intentionally selects +`ordering = "network-online"`. + +The earlier `fully-up` witness was circular: upstream Proxmox first-boot packaging +orders the fully-up service after and wants the product-specific API proxy +(`pveproxy.service` on PVE). If that proxy is the failing P2 dependency, the witness +cannot run to explain it. The upstream `network-online` ordering instead runs after +networking and before that product-proxy dependency. + +The hook therefore emits a bounded serial witness from inside the installed guest, +records hostname/hosts/network state immediately, then waits at most 90 seconds for +`pveproxy`/TCP 8006 and records final service state, listeners, failed units, and +bounded `pveproxy`/`pve-cluster` journals. The prepared ISO is also read back before +launch and the exact `/proxmox-first-boot` content SHA-256 must match the source hook. + +The witness is diagnostic evidence only. P2 is satisfied only by a real HTTPS +`/api2/json/version` response. A host-side TCP accept is recorded only as +`*_hostfwd_accepted`; it is not guest-service reachability. Failure to complete SSH +leaves nested-KVM state `unknown`. P5 still requires an actual L3 KVM VCPU execution +nonce and cannot be inferred from `/dev/kvm` or CPU flags alone. + +### TrueNAS T1 service-oracle semantics + +QEMU user-mode `hostfwd` accepting a localhost TCP connection is not a TrueNAS +installer-service oracle. T1 records that condition only as +`installer_rpc_hostfwd_accepted` diagnostic evidence. T1 is satisfied only by a +completed WebSocket/JSON-RPC exchange with the pinned installer that successfully +returns the read-only discovery methods. + +The T0 serial marker remains the T0-only oracle. On T1, a successful response from +the vendor installer RPC is stronger evidence that the installer environment is +running, so lack of an earlier serial-label observation does not negate a completed +RPC oracle. This avoids both a premature TCP false positive and a weaker-oracle +dependency blocking a stronger observed interface. + + +### TrueNAS T2 install + installed middleware + +T2 is a single disposable-system execution with separately retained sub-oracles: + +1. T1 read-only installer JSON-RPC discovery must pass. +2. The mutating installer client refuses adopted state, requires exactly one + non-removable destination disk and one non-loopback NIC, configures + `truenas_admin` with an ephemeral non-repository password, enables DHCP, + and requires the vendor `install` RPC to return successfully. +3. Only after that response (the upstream installer has exported `boot-pool`) + is the installer VM terminated. +4. The same qcow2 is booted with a stable virtual NIC identity. +5. Installed middleware is qualified independently over its source-defined DDP + `/websocket` interface using `auth.login_ex` with `PASSWORD_PLAIN`, + followed by `system.version` and `system.info`. + +An installed-middleware failure therefore does not erase a successful installer +sub-oracle. Fresh TrueNAS installation installs both i386-pc GRUB and EFI GRUB, +so the disposable legacy-BIOS QEMU target is a valid post-install boot oracle. + + +### TrueNAS T3 disposable ZFS data pool + +T3 extends the accepted T2 system without changing the installer disk shape. The +installer still sees only the 24-GiB boot target. After vendor installation completes, +the installed-system boot adds exactly two experiment-owned 8-GiB sparse virtio disks. They are attached as explicit virtio-blk devices with deterministic serial identities `RDTE_DATA_0` and `RDTE_DATA_1`. The installed-system topology is explicit: the virtio NIC is PCI `0x3`, the TrueNAS boot virtio-blk device is PCI `0x4` with `bootindex=1`, and the data disks are PCI `0x5` and `0x6`. QEMU `-boot order=c` is not mixed with `bootindex`; the installed boot uses `-boot strict=on` so firmware follows the per-device boot priority. + +The T3 middleware client then: +- authenticates through the accepted installed-system DDP surface; +- records `boot.get_disks` and refuses any overlap with data candidates; +- requires exactly two disks from `disk.get_unused`; +- creates `rdtepool` as one two-disk `MIRROR`; +- waits for the returned `pool.create` job ID to reach `SUCCESS`; +- requires an independent `pool.query` oracle reporting `ONLINE` and healthy; +- requires `pool.get_disks` membership to match exactly the two selected data disks. + +QEMU disk attachment, middleware unused-disk classification, job acceptance, and pool +health are separate observations. T3 does not imply Apps support; T4 remains a distinct +gate. + + +### QEMU T3 topology oracle + +Before another full TrueNAS T3 installation is spent, a cheap Ubuntu 26.04 job +instantiates the proposed installed-guest hardware shape under the hosted runner's +actual QEMU package without booting an operating system. QMP must report: + +- virtio NIC at PCI slot 0x3; +- explicit TrueNAS boot virtio-blk device at slot 0x4 with `bootindex=1`; +- experiment data disks at slots 0x5 and 0x6; +- data-disk serials `RDTE_DATA_0` and `RDTE_DATA_1`. + +This oracle exists because QEMU auto-assigns PCI devices to the first available +non-reserved slot and virtio-blk exposes both `serial` and `bootindex` as device +properties. It validates QEMU realization separately from the expensive TrueNAS +installer/boot/pool oracle. + + +### TrueNAS T4 Apps runtime + +T4 builds directly on the accepted T3 disposable mirror. The BETA.3 source-defined +control path is used end-to-end: + +1. rely on the exact BETA.3 product gate exercised by `docker.update`: + private `docker.license_active` permits non-HA targets directly and consults + `system.feature_enabled("APPS")` for HA-capable systems; +2. run the real `docker.update({"pool":"rdtepool"})` job and independently require + `docker.config.pool == "rdtepool"` plus `docker.status == RUNNING`; +3. create the upstream-test-shaped custom app `rdte-t4-probe` using immutable image + `nginx@sha256:65645c7bb6a0661892a8b03b89d0743208a18dd2f3f17a54ef4b76fb8e2f2a10`; +4. wait for the `app.create` job to succeed, then independently poll `app.query`; +5. require app state `RUNNING`, `custom_app == true`, exactly one active container, + service name `web`, the exact digest-pinned image, and container state `running`. + +The failed run `36536731116` is an oracle/harness failure, not an Apps capability +negative: authentication and the T3 pool oracle succeeded, but the T4 client called a +later `truenas.entitlements.check` service that is absent from the tagged BETA.3 +middleware before reaching `docker.update`. The corrected rep must exercise the +tagged source path above. T4 still does not promote broader lifecycle semantics or App +Foundry materialization; those remain T5 and T6. + + +### TrueNAS BETA.3 source binding + +Runtime API contracts for the 26.0.0-BETA.3 lane are bound to the published +`TS-26.0.0-BETA.3` middleware tag, commit +`81e1265a86083888ba94a2bdfc02ff5c9c5ef6a3`. + +That tagged tree predates the later `truenas.entitlements.*` service. Its Apps gate +is `docker.license_active`: non-HA systems are permitted directly, while HA-capable +systems consult `system.feature_enabled("APPS")`. The T4 client therefore does not +call the later entitlement API; `docker.update` and Docker startup exercise the +actual BETA.3 product gate. + + +### TrueNAS T5 custom-App lifecycle + +T5 is eligible only after accepted T4 evidence. It reproduces the T4 pool and Apps +runtime first, including the exact immutable image +`nginx@sha256:65645c7bb6a0661892a8b03b89d0743208a18dd2f3f17a54ef4b76fb8e2f2a10`, then exercises the tagged BETA.3 public lifecycle: + +- `app.stop` -> independently queried `STOPPED`; +- `app.start` -> independently queried `RUNNING` with the expected digest-pinned workload; +- `app.update` sets `RDTE_GENERATION=2`; +- public `app.config` must read back both `RDTE_GENERATION=2` and the same immutable image; +- `app.redeploy` -> independently queried `RUNNING`; +- final stop -> `STOPPED`; +- `app.delete` -> final exact `app.query` is absent. + +Job success alone never satisfies T5. T5 does not establish Foundry deployment-artifact +realization; that remains the distinct T6 gate. diff --git a/docs/truenas-parallel-execution-plan.md b/docs/truenas-parallel-execution-plan.md new file mode 100644 index 000000000..4d23f9287 --- /dev/null +++ b/docs/truenas-parallel-execution-plan.md @@ -0,0 +1,83 @@ +# TrueNAS parallel execution and resource-governance plan + +Authority: `SemperSupra/agent-dispatch-private#480`. + +This plan separates conceptual parallelism from runner parallelism. Many read-only, +design, source-audit, matrix, and code lanes may proceed independently. Expensive CI +and real-system execution remain deliberately bounded. + +## Resource policy + +### Heavyweight + +- At most one active Real-system TrueNAS run. +- No automatic retry. +- No speculative matrix sweep. +- Every push-launched TrueNAS run requires a fresh run-request edge with a changed + `request_id`, an exact-head cheap gate, and reconciliation of the heavyweight + ownership authority before dispatch. + +### Cheap GitHub Actions + +- Prefer one orchestration-surface pull request at a time when it runs the shared RDTE + contract suite. +- A second independent cheap lane is acceptable when it does not duplicate the same + heavyweight provider smoke/download work. +- Prep branches use the pull-request gate as the authoritative exact-head gate; they do + not also pay for an identical push gate. +- G3/G4/G5 provider container smokes run only when their provider surfaces changed, or + by explicit manual dispatch. + +### Read-only work + +Repository inspection, receipt classification, source/API audits, issue/matrix +reconciliation, documentation, and manifest design may proceed in parallel without a +TrueNAS VM. + +## Parallel lanes + +A. Session runner / WP2: session envelope, capsule receipts, cleanup/platform membrane. + +B. FolioRelay causal qualification: isolate the current public-URI mismatch and prepare +only the bounded successor proven by that evidence. + +C. Foundry matrices: keep product inventory/readiness in Foundry and project only READY +exact cells to session manifests. + +D. Synthetic OCI/custom-app controls: small orthogonal fixtures that can first be proven +as ordinary containers before admission to TrueNAS capsules. + +E. Orchestration/resource governance: fresh-request routing, prep-CI cost control, +cancellation/evidence handling, and stale-replay regression coverage. + +F. VM/API source audit: read-only management-plane capability census; nested KVM remains +a separate optional late capsule. + +## Sequential gates + +1. Session/capsule contracts before a session runner gains mutation authority. +2. Contamination semantics before multi-capsule runtime execution. +3. Single-capsule runtime equivalence before multi-capsule proving. +4. Multi-capsule clean-continuation proof before intentional contamination reps. +5. Product-specific causal fix before its next heavyweight rep. +6. `SUPPORTED` plus `oracleSatisfied=true` on a product/version before advancing that + product to its next exact target. +7. BETA.3 session acceptance before exact-version session rollout. + +## Red-team invariants + +- Harness/probe/workflow changes must not replay a stale committed TrueNAS request. +- Session PASS never substitutes for capsule/product PASS. +- Failed cleanup or platform-health reconciliation stops later mutation. +- Resource or authority drift fails closed. +- Parallel branches that modify shared orchestration files are serialized at merge time. +- Existing accepted evidence is reused when semantically equivalent instead of rerun. +- VM/nested-virtualization experiments are excluded from the first multiplexed proving + session. + +## Blue-team expected effect + +This preserves independent causal evidence while reducing repeated ISO install/pool +initialization and duplicate cheap CI. Parallel work prepares the next bounded edge while +the single heavyweight lane executes the current one, so runner utilization increases +without creating a thundering herd. diff --git a/docs/truenas-qualification-sessions.md b/docs/truenas-qualification-sessions.md new file mode 100644 index 000000000..aa48586c0 --- /dev/null +++ b/docs/truenas-qualification-sessions.md @@ -0,0 +1,68 @@ +# TrueNAS exact-version qualification sessions + +Authority: `SemperSupra/agent-dispatch-private#480`. + +This layer amortizes a single expensive disposable TrueNAS installation across multiple +independently authoritative test capsules. It does **not** turn a session into a product +verdict and does not move product authority into Agent Dispatch. + +## Contracts + +- `truenas-session/v1` binds one exact TrueNAS target, one immutable manifest digest, + one disposable pool topology, one session authority, and one bounded resource budget. +- `truenas-capsule/v1` binds one independently owned test capsule to an exact provider, + producer/artifact identities, namespace/ports, resource ceiling, oracle, cleanup + policy, receipt contract, and retry policy. +- `config/truenas-capsule-providers.json` is only an execution adapter registry. It + points at existing probes; it is not a product inventory or support matrix. + +Foundry and product repositories remain authoritative for what should be tested and for +the meaning of product receipts. + +## Isolation membrane + +Every mutating capsule must eventually execute under this membrane: + +``` +observe owned namespace + -> prove clean precondition + -> setup/apply + -> independent verify + -> cleanup + -> prove zero unintended residue + -> re-observe platform health +``` + +A product/oracle failure may be followed by another independent capsule only if cleanup +and platform-health reconciliation both succeed. Cleanup ambiguity must stop subsequent +mutation and classify the session as contaminated. + +## Immutable launch + +The session manifest contains `manifest_sha256`, computed over canonical JSON with the +hash field excluded. A runtime request must bind that digest before the heavyweight job +starts. Editing both payload and digest creates a different proposed session; it cannot +change the semantic identity of an already-launched session. + +The validator also fails closed on: + +- unknown exact TrueNAS versions; +- unknown capsule providers; +- duplicate capsule ids, namespaces, or ports; +- dependency cycles or missing dependencies; +- mutating capsules without mandatory cleanup; +- resource/time budget overflow; +- the current 8-GiB guest and 50-minute heavyweight guardrails. + +## Migration sequence + +1. Prove this static contract. +2. Wrap current T6 probes without rewriting them. +3. Prove one-capsule receipt equivalence with the existing one-product execution path. +4. Add the session runner and cleanup/platform-health membrane. +5. Prove an intentionally failing multi-capsule BETA.3 session. +6. Only then migrate ordinary product requests to immutable session manifests. +7. Retain one-capsule manifests for high-risk/new experiments. + +The active FolioRelay BETA.3 run that predates this contract is intentionally not +reinterpreted as a session. diff --git a/scripts/admit_vanilla263_native_t6_control.py b/scripts/admit_vanilla263_native_t6_control.py new file mode 100644 index 000000000..50d0019df --- /dev/null +++ b/scripts/admit_vanilla263_native_t6_control.py @@ -0,0 +1,188 @@ +#!/usr/bin/env python3 +"""OP-4B independent immutable Foundry control admission for vanilla 26.3. + +This deliberately does NOT create a TrueNAS App or generalize old Paper T6. +Native guest selector must remain unavailable until an independent guest probe +and exact guest receipt gate exist. No physical TrueNAS APPLY is authorized. +""" +from __future__ import annotations +import argparse +import hashlib +import json +from pathlib import Path +import re +import sys + +SCHEMA = "semper-supra.minecraft-vanilla-263-truenas-t6-control/1" +PRODUCER = "5ee204df205edfd531304441fd1a6a24004557b1" +CONTROL_SHA = "59516bbbcb1750af6f0cb1c624756f08113a070acc11c60affe94d2bbd8d8dd5" +APPS_REF = "95b0cc78e75fcd4b128f4ef2f8428034d981f7c2" +OCI = ("ghcr.io/itzg/minecraft-server:2026.10.0-java25@sha256:" + "5a2ab74ae179c11e2dbbce67f307cdcf2c1257b3abb0f654d3a310b3d8ea57da") +TARGET = "25.04.2.6" +MIDDLEWARE = "244b717370fe35fb9eefc3096acfbc25f12b57b6" +PROFILE_BLOB = "631467044411c96a0fe6d361688b99a794f07c80" +APP = "rdte-t6-minecraft-vanilla-263" +WORLD = "rdtepool/minecraft-vanilla-263-t6-world" +OWNER = { + "SUPRACRAFT_COLLAGE_MANAGED": "true", + "SUPRACRAFT_COLLAGE_SCHEMA": "1", + "SUPRACRAFT_COLLAGE_MANAGER_ID": "manager-rdte", + "SUPRACRAFT_COLLAGE_FLEET_ID": "rdte", + "SUPRACRAFT_COLLAGE_SERVICE_ID": "11111111-1111-4111-8111-111111111111", + "SUPRACRAFT_COLLAGE_WORLD_ID": "lantern-rdte", +} +RECEIPT = "semper-supra.minecraft-vanilla263-t6-consumer-admission/1" + + +class Refused(ValueError): + pass + + +def require(condition: bool, reason: str) -> None: + if not condition: + raise Refused(reason) + + +def canonical_hash(doc: dict) -> str: + return hashlib.sha256(json.dumps(doc, sort_keys=True, + separators=(",", ":")).encode()).hexdigest() + + +def verify(control: dict, *, expected_producer: str = PRODUCER, + expected_digest: str = CONTROL_SHA) -> dict: + """Verify entire canonical signer/digest and critical materialization invariants.""" + require(type(control) is dict and control.get("schema") == SCHEMA, + "VANILLA_CONTROL_SCHEMA_DRIFT") + signed = dict(control) + signature = signed.pop("control_sha256", None) + require(re.fullmatch(r"[0-9a-f]{40}", expected_producer) is not None, + "FOUNDRY_IMMUTABLE_REF_REQUIRED") + require(re.fullmatch(r"[0-9a-f]{64}", expected_digest) is not None, + "CANONICAL_DIGEST_REQUIRED") + require(control.get("foundry_ref") == expected_producer == PRODUCER, + "FOUNDRY_PRODUCER_REF_DRIFT") + require(signature == canonical_hash(signed) == expected_digest == CONTROL_SHA, + "CANONICAL_CONTROL_SHA_DRIFT") + target = control.get("target") or {} + require(type(target) is dict and + target.get("truenas_version") == TARGET and + target.get("expected_system_version") == "TrueNAS-" + TARGET and + target.get("middleware_commit") == MIDDLEWARE and + target.get("profile_git_blob_sha") == PROFILE_BLOB, + "NATIVE_GUEST_TARGET_DRIFT") + source = control.get("source") or {} + require(type(source) is dict and + source.get("apps_commit") == APPS_REF and + source.get("catalog_app") == "community/minecraft" and + source.get("catalog_version") == "1.15.31" and + source.get("library_version") == "2.3.16" and + source.get("image_reference") == OCI and + isinstance(source.get("source_compose_sha256"), str) and + re.fullmatch("[0-9a-f]{64}", source["source_compose_sha256"]) and + isinstance(source.get("values_raw_sha256"), str) and + re.fullmatch("[0-9a-f]{64}", source["values_raw_sha256"]), + "NATIVE_CATALOG_SOURCE_DRIFT") + runtime = control.get("runtime") or {} + require(type(runtime) is dict and + runtime.get("app_name") == APP and + runtime.get("catalog_app") == "minecraft" and + runtime.get("train") == "community" and + runtime.get("catalog_version_candidate") == "1.15.31" and + runtime.get("minecraft_version") == "26.3" and + runtime.get("minecraft_protocol") == 777 and + runtime.get("java_version") == 25 and + runtime.get("image_selector") == "j25_image" and + runtime.get("required_independent_world_dataset") == WORLD and + runtime.get("status_oracle") == "minecraft-java-status-oracle/v1", + "NATIVE_VANILLA_PRODUCT_DRIFT") + values = runtime.get("values_template") or {} + require(type(values) is dict, "NATIVE_VALUES_SCHEMA_DRIFT") + minecraft = values.get("minecraft") or {} + require(type(minecraft) is dict and + minecraft.get("type") == "VANILLA" and + minecraft.get("version") == "26.3" and + minecraft.get("image_selector") == "j25_image" and + minecraft.get("eula") is True and + minecraft.get("online_mode") is True and + minecraft.get("enable_rcon") is False and + minecraft.get("rcon_password") == "", + "NATIVE_VANILLA_RUNTIME_INPUT_DRIFT") + envs = minecraft.get("additional_envs") + require(type(envs) is list and len(envs) == len(OWNER) and + all(type(v) is dict and set(v) == {"name", "value"} for v in envs) and + {v["name"]: v["value"] for v in envs} == OWNER, + "NATIVE_OWNERSHIP_DRIFT") + storage = values.get("storage") or {} + mounts = storage.get("additional_storage") + require(type(storage) is dict and + (storage.get("data") or {}).get("type") == "ix_volume" and + type(mounts) is list and len(mounts) == 1 and + type(mounts[0]) is dict and + mounts[0].get("mount_path") == "/data/world" and + mounts[0].get("type") == "host_path" and + mounts[0].get("read_only") is False and + (mounts[0].get("host_path_config") or {}).get("path") == + "/opt/tests/collage-vanilla-263-world", + "NATIVE_WORLD_OWNER_BOUNDARY_DRIFT") + network = values.get("network") or {} + require(type(network) is dict and network.get("host_network") is False and + (network.get("server_port") or {}).get("port_number") == 25565 and + (network.get("server_port") or {}).get("bind_mode") == "published", + "NATIVE_PORT_POLICY_DRIFT") + evidence = control.get("evidence") or {} + require(type(evidence) is dict and evidence.get("source_render_pass") is True and + all(evidence.get(field) is False for field in ( + "live_catalog_version_verified", "native_guest_created", + "gameplay_qualified_by_native_guest", + "external_world_retention_qualified_by_native_guest", + "collage_managed_deployment_qualified", + "physical_truenas_qualified")) and + control.get("operational_selection") is False and + control.get("physical_truenas_apply") == "HOLD", + "SOURCE_ONLY_GUEST_OR_PHYSICAL_PROMOTION_DRIFT") + return { + "schema": RECEIPT, + "classification": "SOURCE_CONTROL_READY_GUEST_NOT_DISPATCHED", + "product": "minecraft-vanilla-263", + "foundry_ref": expected_producer, + "control_sha256": expected_digest, + "truenas_version": TARGET, + "catalog_version": "1.15.31", + "minecraft_version": "26.3", + "minecraft_protocol": 777, + "java_version": 25, + "image_sha256": OCI.split("@sha256:", 1)[1], + "world_id": OWNER["SUPRACRAFT_COLLAGE_WORLD_ID"], + "native_guest_created": False, + "native_guest_qualified": False, + "physical_truenas_apply": "HOLD", + } + + +def main() -> int: + p = argparse.ArgumentParser() + p.add_argument("--control", type=Path, required=True) + p.add_argument("--out", type=Path, required=True) + a = p.parse_args() + try: + require(a.control.is_file() and a.control.stat().st_size <= 128 * 1024, + "CONTROL_MISSING_OR_OVERSIZED") + result = verify(json.loads(a.control.read_text(encoding="utf-8"))) + ok = True + except (OSError, ValueError, TypeError) as ex: + raw = str(ex) + reason = raw if re.fullmatch("[A-Z0-9_]{3,90}", raw) else "SOURCE_CONTROL_INVALID" + result = {"schema": RECEIPT, "classification": "REFUSED", + "failure_code": reason, "native_guest_qualified": False, + "physical_truenas_apply": "HOLD"} + ok = False + a.out.parent.mkdir(parents=True, exist_ok=True) + a.out.write_text(json.dumps(result, indent=2, sort_keys=True) + "\n") + print(json.dumps({"classification": result["classification"], + "failure_code": result.get("failure_code")}, sort_keys=True)) + return 0 if ok else 2 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/assert_collage_p2_receipt.py b/scripts/assert_collage_p2_receipt.py new file mode 100644 index 000000000..54bf1040b --- /dev/null +++ b/scripts/assert_collage_p2_receipt.py @@ -0,0 +1,97 @@ +#!/usr/bin/env python3 +"""Exact product receipt gate for real disposable COLLAGE native readback P2. + +Requires full native Paper lifecycle AND TLS-verified provider read-only match. +Cannot pass from a synthetic observation file or a green runner job alone. +""" +from __future__ import annotations + +import argparse +import json +import pathlib +import sys + +EXPECTED_SOURCE = "fbda94f3fea0a216ddd9c35b38a09d325bed3640" +EXPECTED_SERVICE = "11111111-1111-4111-8111-111111111111" +EXPECTED_APP = "rdte-t6-minecraft-paper" + + +def check(row: dict) -> None: + def need(cond, reason): + if not cond: + raise ValueError(reason) + need(type(row) is dict and row.get("contract") == "gha-kvm-system-lab/v1", + "root contract") + target = row.get("target") or {} + need(target.get("version") == "25.04.1" + and target.get("rung") == "T6" + and target.get("product") == "truenas", "wrong target") + need(row.get("classification") == "SUPPORTED" + and row.get("oracleSatisfied") is True, "root failed") + p = row.get("foundry_materialization") or {} + need(p.get("schema") == "truenas-minecraft-paper-t6/v1" + and p.get("classification") == "SUPPORTED" + and p.get("oracleSatisfied") is True, "Paper guest failed") + need(p.get("catalog_version_verified") is True + and p.get("world_retained_on_delete") is True + and p.get("world_rebound_after_reinstall") is True, + "native Paper lifecycle did not qualify") + catalog = p.get("catalog_discovery") or {} + need(p.get("foundry_ref") == "403321031b6d4e66aa64a8299fd7621eee290b0d" + and catalog.get("requested_version") == "1.15.30" + and catalog.get("upstream_apps_commit") == + "559b9fe85212c800bc6770f152a65211df137a44" + and p.get("image_digest_qualification") == "SOURCE_AND_GUEST_REF_MATCH", + "new immutable native catalog/source qualification missing") + qualified_image = "ghcr.io/itzg/minecraft-server:2026.10.0-java21@sha256:d4057e7019e9b19f25e039d4a204cc502e1a112d1a2305966a534f3d7079e644" + digest_only = "ghcr.io/itzg/minecraft-server@sha256:d4057e7019e9b19f25e039d4a204cc502e1a112d1a2305966a534f3d7079e644" + need(p.get("observed_image_reference") in {qualified_image, digest_only}, + "new native OCI image digest absent") + stages = p.get("readiness") or {} + for label in ("first", "restart", "redeploy", "reinstall"): + stage = stages.get(label) or {} + status = stage.get("protocol") or {} + level = stage.get("level") or {} + need(status.get("oracleSatisfied") is True + and status.get("server_protocol") == 774 + and status.get("server_implementation") == "paper" + and type(level.get("size")) is int and level["size"] > 0, + "exact Java status/world stage absent: " + label) + cleanup = p.get("cleanup") or {} + need(all(cleanup.get(k) is True for k in + ("zero_residue", "app_absent", "owned_world_dataset_absent")), + "native cleanup not exact") + observation = p.get("collage_readback") or {} + need(observation.get("schema") == "semper-supra.collage-native-readback-p2/1" + and observation.get("classification") == "PROVIDER_READBACK_MATCH" + and observation.get("source_sha") == EXPECTED_SOURCE + and observation.get("native_app") == EXPECTED_APP + and observation.get("service_id") == EXPECTED_SERVICE + and observation.get("ownership_fields_matched") == 6 + and observation.get("credential_scope") == "APPS_READ" + and observation.get("transport") == "isolated_guest_tls_verified" + and observation.get("observation_mutation_count") == 0 + and observation.get("world_activated") is False + and observation.get("cloudnet_adoption_executed") is False + and observation.get("physical_true_nas_mutated") is False, + "real guest read-only provider observation missing") + need(isinstance(observation.get("tls_cert_sha256"), str) + and len(observation["tls_cert_sha256"]) == 64, + "TLS certificate anchor receipt missing") + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--receipt", type=pathlib.Path, required=True) + args = parser.parse_args() + try: + check(json.loads(args.receipt.read_text(encoding="utf-8"))) + except (OSError, ValueError) as exc: + print("COLLAGE P2 native guest acceptance FAILED: " + str(exc), file=sys.stderr) + return 2 + print("COLLAGE P2 real guest acceptance SUPPORTED: Paper lifecycle + native TLS observation") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/assert_minecraft_t6_receipt.py b/scripts/assert_minecraft_t6_receipt.py new file mode 100644 index 000000000..958a61d3b --- /dev/null +++ b/scripts/assert_minecraft_t6_receipt.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python3 +"""Fail closed unless nested Minecraft T6 proved protocol, persistence and cleanup.""" +import argparse +import json +import pathlib +import sys + + +def check(r): + def required(ok, message): + if not ok: + raise ValueError(message) + required(isinstance(r, dict) and r.get("contract") == "gha-kvm-system-lab/v1", "root schema") + target = r.get("target") or {} + required(target.get("product") == "truenas" and target.get("version") == "25.04.1" + and target.get("rung") == "T6", "wrong exact guest") + required(r.get("classification") == "SUPPORTED" and r.get("oracleSatisfied") is True, + "guest/root oracle failure") + s = r.get("session_execution") or {} + required(s.get("schema") == "truenas-session-execution/v1" + and s.get("classification") == "SESSION_CLEAN" and s.get("session_clean") is True, + "unclean capsule session") + capsules = s.get("capsules") or [] + required(isinstance(capsules, list) and len(capsules) == 1, "capsule count") + c = capsules[0] + required(c.get("provider") == "minecraft-paper-t6" and c.get("verdict") == "SUPPORTED" + and c.get("cleanup_satisfied") is True, "provider verdict/cleanup") + p = c.get("provider_receipt") or {} + f = r.get("foundry_materialization") or {} + required(p.get("schema") == "truenas-minecraft-paper-t6/v1" + and p.get("classification") == "SUPPORTED" and p.get("oracleSatisfied") is True, + "product oracle failure") + required(f.get("schema") == p.get("schema") and + f.get("control_sha256") == p.get("control_sha256"), + "materialization/provenance mismatch") + required(p.get("catalog_version_verified") is True, "catalog not confirmed") + required(p.get("world_retained_on_delete") is True + and p.get("world_rebound_after_reinstall") is True, "world not persistent") + cleanup = p.get("cleanup") or {} + required(all(cleanup.get(k) is True for k in + ("zero_residue", "app_absent", "owned_world_dataset_absent")), "cleanup failed") + readiness = p.get("readiness") or {} + for phase in ("first", "restart", "redeploy", "reinstall"): + stage = readiness.get(phase) or {} + o = stage.get("protocol") or {} + level = stage.get("level") or {} + required(o.get("schema") == "minecraft-java-status-oracle/v1" + and o.get("oracleSatisfied") is True and o.get("java_version") == "1.21.11" + and o.get("status_packet_valid") is True, "status missing: " + phase) + required(isinstance(level.get("size"), int) and level["size"] > 0, + "world level.dat absent: " + phase) + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--receipt", type=pathlib.Path, required=True) + args = parser.parse_args() + try: + check(json.loads(args.receipt.read_text(encoding="utf-8"))) + except (OSError, ValueError) as exc: + print("Minecraft T6 acceptance FAILED: " + str(exc), file=sys.stderr) + return 2 + print("Minecraft T6 acceptance SUPPORTED: protocol/world/cleanup") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/assert_vanilla263_native_t6_receipt.py b/scripts/assert_vanilla263_native_t6_receipt.py new file mode 100644 index 000000000..309dc88a1 --- /dev/null +++ b/scripts/assert_vanilla263_native_t6_receipt.py @@ -0,0 +1,327 @@ +#!/usr/bin/env python3 +"""Independent EXACT TrueNAS 25.04.2.6 / vanilla26.3 native guest receipt oracle. + +Cannot accept Paper T6 evidence, source-only control, an OCI-only container, +offline Java protocol from another host, or a provider's mere RUNNING status. +""" +from __future__ import annotations +import argparse +import json +from pathlib import Path +import sys + +from admit_vanilla263_native_t6_control import ( + APP, CONTROL_SHA, MIDDLEWARE, OCI, PRODUCER, TARGET, WORLD, +) + +SCHEMA = "truenas-minecraft-vanilla263-t6/v1" +STATUS_SCHEMA = "minecraft-java-status-oracle/v1" +IMAGE_SHA = OCI.split("@sha256:", 1)[1] + + +def check(root: dict, *, require_collage: bool = False, + require_lantern_staged: bool = False, + require_native_rollback: bool = False, + require_native_client_visibility: bool = False, + require_native_backend_region: bool = False) -> None: + def need(condition: bool, reason: str) -> None: + if not condition: + raise ValueError(reason) + need(type(root) is dict and root.get("contract") == "gha-kvm-system-lab/v1", + "guest root receipt schema") + target = root.get("target") or {} + need(type(target) is dict and + target.get("product") == "truenas" and + target.get("version") == TARGET and + target.get("rung") == "T6", "wrong exact guest identity") + need(root.get("classification") == "SUPPORTED" and + root.get("oracleSatisfied") is True and + root.get("phase") == "foundry-materialization", + "guest/root unsupported") + source = root.get("source") or {} + need(type(source) is dict and + source.get("system_version_expected") == "TrueNAS-" + TARGET and + source.get("middleware_commit") == MIDDLEWARE and + source.get("foundry_profile_blob") == "631467044411c96a0fe6d361688b99a794f07c80", + "guest exact source/profile mismatch") + shape = (root.get("requested_shape") or {}).get("app") or {} + need(type(shape) is dict and + shape.get("name") == APP and + shape.get("catalog_app") == "minecraft" and + shape.get("catalog_version_candidate") == "1.15.31" and + shape.get("minecraft_version") == "26.3" and + shape.get("minecraft_protocol") == 777, + "wrong native workload shape") + p = root.get("foundry_materialization") or {} + need(type(p) is dict and + p.get("schema") == SCHEMA and + p.get("classification") == "SUPPORTED" and + p.get("oracleSatisfied") is True and + p.get("product") == "vanilla-26.3" and + p.get("target") == TARGET, + "wrong product guest oracle") + need(p.get("foundry_ref") == PRODUCER and + p.get("control_sha256") == CONTROL_SHA, + "guest product signer/control mismatch") + need(p.get("catalog_version_verified") is True, + "exact native catalog not verified by guest") + observed = p.get("observed_image_reference") + need(isinstance(observed, str) and + (observed == OCI or + observed == OCI.split(":2026.10.0-java25")[0] + "@sha256:" + IMAGE_SHA) and + p.get("image_digest_qualification") == "SOURCE_AND_GUEST_REF_MATCH", + "native running OCI image digest not verified") + need(p.get("world_retained_on_delete") is True and + p.get("world_rebound_after_reinstall") is True, + "independent world not retained") + readiness = p.get("readiness") or {} + need(type(readiness) is dict, "guest readiness stages absent") + for phase in ("first", "restart", "redeploy", "reinstall"): + stage = readiness.get(phase) or {} + protocol = stage.get("protocol") or {} + level = stage.get("level") or {} + need(type(protocol) is dict and + protocol.get("schema") == STATUS_SCHEMA and + protocol.get("classification") == "SUPPORTED" and + protocol.get("oracleSatisfied") is True and + protocol.get("java_version") == "26.3" and + protocol.get("server_implementation") == "vanilla" and + protocol.get("status_name_contract") == "vanilla_exact" and + protocol.get("server_protocol") == 777 and + protocol.get("status_packet_valid") is True and + protocol.get("players_counter_valid") is True, + "independent vanilla protocol not accepted at " + phase) + need(type(level) is dict and + type(level.get("size")) is int and level["size"] > 0, + "independent owned world level.dat not observed at " + phase) + cleanup = p.get("cleanup") or {} + need(type(cleanup) is dict and + all(cleanup.get(k) is True for k in ( + "attempted", "zero_residue", "app_absent", + "owned_world_dataset_absent")), + "native App/ZFS world cleanup left residue") + need(p.get("secret_values_captured") is False, + "guest receipt admits secrets") + need((root.get("foundry_materialization") or {}).get("target") == TARGET, + "guest receipt cross-target mismatch") + if require_lantern_staged: + stage = p.get("lantern_artifact_stage") or {} + need(type(stage) is dict and + stage.get("schema") == "supracraft.native-vanilla263-lantern-artifact-stage/1" and + stage.get("classification") == "REAL_NATIVE_GUEST_ARTIFACT_BYTES_STAGED" and + stage.get("artifact_sha256") == + "da25005e8729e6f013f7108ac5b8869dd46323befc78efdae18221b26cafe5f8" and + stage.get("download_readback_sha256") == + "da25005e8729e6f013f7108ac5b8869dd46323befc78efdae18221b26cafe5f8" and + stage.get("world_id") == "lantern-rdte" and + stage.get("native_app") == APP and + stage.get("native_truenas_version") == TARGET and + stage.get("zfs_world_owned") is True and + stage.get("app_quiesced_before_write") is True and + stage.get("persisted_on_restart") is True and + stage.get("persisted_on_reinstall") is True and + stage.get("artifact_gameplay_effect_verified") is False and + stage.get("collage_managed_deployment") is False and + stage.get("principal_authorized_to_apply") is False and + stage.get("physical_truenas_apply") == "HOLD", + "actual native Lantern ZIP byte stage/retention was not qualified") + if require_native_client_visibility: + need(require_native_rollback and require_lantern_staged and require_collage, + "native client requires exact G2 and G3") + profile = p.get("native_player_profile") or {} + gameplay = p.get("native_player_gameplay") or {} + need(type(profile) is dict and profile.get("schema") == + "minecraft-vanilla263-disposable-loopback-player/1" and + profile.get("classification") == "DISPOSABLE_OVERLAY_BOUND" and + profile.get("rcon_enabled") is False and + profile.get("client_online_account_qualified") is False and + profile.get("physical_truenas_apply") == "HOLD" and + type(gameplay) is dict and + gameplay.get("same_native_guest_client_causal_visibility") is True and + gameplay.get("backend_independent_world_oracle_pass") is False and + gameplay.get("collage_managed_deployment") is False and + gameplay.get("physical_true_nas_apply") == "HOLD", + "native offline client path not independently verified") + for name in ("candidate", "rollback"): + client = gameplay.get(name) or {} + need(type(client) is dict and client.get("schema") == + "supracraft.native-vanilla263-mineflayer-causal/1" and + client.get("classification") == "CLIENT_CAUSAL_VISIBILITY_ONLY" and + client.get("stage") == name and + client.get("client_protocol777_connected_and_spawned") is True and + client.get("offline_synthetic_player_caused_core_placement") is True and + client.get("client_saw_expected_datapack_effect") is (name == "candidate") and + client.get("client_saw_expected_no_datapack_effect") is (name == "rollback") and + client.get("native_guest_backend_independent_oracle_qualified") is False and + client.get("stock_mojang_client_qualified") is False and + client.get("online_account_qualified") is False and + client.get("collage_mutation_authorized") is False and + client.get("physical_true_nas_apply") == "HOLD", + "native offline client causal stage missing") + if require_native_backend_region: + need(require_native_client_visibility and require_native_rollback and + require_collage and require_lantern_staged, + "backend world region requires G2/G3 and same native client") + proof = p.get("native_backend_world") or {} + need(type(proof) is dict and + proof.get("schema") == "supracraft.native-vanilla263-live-backend-region/1" and + proof.get("classification") == "LIVE_NATIVE_BACKEND_THREE_CHECKPOINTS" and + proof.get("same_owned_guest_three_checkpoints") is True and + proof.get("backend_independent_world_oracle_pass") is True and + proof.get("collage_mutation_authorized") is False and + proof.get("physical_true_nas_apply") == "HOLD", + "live three checkpoint backend admission unproven") + prep = proof.get("preparation") or {} + need(type(prep) is dict and + prep.get("schema") == "supracraft.native-vanilla263-mineflayer-causal/1" and + prep.get("classification") == "FIXED_PLAYER_FIXTURE_PREPARATION_ONLY" and + prep.get("stage") == "candidate-prep" and + prep.get("client_protocol777_connected_and_spawned") is True and + prep.get("client_fixture_prepared_without_core_placement") is True and + prep.get("native_guest_backend_independent_oracle_qualified") is False and + prep.get("collage_mutation_authorized") is False and + prep.get("physical_true_nas_apply") == "HOLD", + "independent backend preaction preparation absent") + blocks = { + "preaction": ["minecraft:air", "minecraft:iron_bars", "minecraft:black_concrete"], + "candidate": ["minecraft:redstone_block", "minecraft:air", "minecraft:sea_lantern"], + "rollback": ["minecraft:redstone_block", "minecraft:iron_bars", "minecraft:black_concrete"], + } + hashes = set() + for stage, expected in blocks.items(): + observed = proof.get(stage) or {} + digest = observed.get("region_sha256") + need(type(observed) is dict and + observed.get("schema") == "supracraft.native-vanilla263-server-owned-anvil/1" and + observed.get("classification") == "LIVE_NATIVE_STOPPED_OWNER_ANVIL_READBACK" and + observed.get("stage") == stage and + observed.get("block_states") == expected and + observed.get("coordinates") == [[0,70,0],[2,70,0],[1,70,1]] and + observed.get("block_state_expectations_matched") is True and + observed.get("requested_native_app") == APP and + observed.get("requested_native_world") == + "rdtepool/minecraft-vanilla-263-t6-world" and + observed.get("requested_native_world_file_fixed") is True and + observed.get("read_only_middleware_calls") is True and + observed.get("live_guest_identity_independently_verified") is True and + observed.get("native_guest_server_owned_bytes_proven") is True and + observed.get("server_quiescence_proven") is True and + observed.get("raw_region_published") is False and + observed.get("collage_mutation_authorized") is False and + observed.get("physical_true_nas_apply") == "HOLD" and + type(observed.get("downloaded_file_size")) is int and + 8192 <= observed["downloaded_file_size"] <= 64*1024*1024 and + type(digest) is str and len(digest) == 64 and + all(c in "0123456789abcdef" for c in digest), + "native server-owned Anvil stage missing or corrupted: " + stage) + hashes.add(digest) + readiness = (p.get("readiness") or {}).get("g4-" + stage) or {} + need(type(readiness) is dict and + (readiness.get("protocol") or {}).get("oracleSatisfied") is True and + type((readiness.get("level") or {}).get("size")) is int and + readiness["level"]["size"] > 0, + "native backend stage restart/health missing: " + stage) + need(len(hashes) == 3, "backend world snapshot hash replay") + if require_native_rollback: + need(require_lantern_staged and require_collage, + "native rollback requires independently qualified G2 and artifact") + rb = p.get("native_world_rollback") or {} + need(type(rb) is dict and + rb.get("schema") == "supracraft.native-vanilla263-rdte-rollback-mechanics/1" and + rb.get("classification") == "G3_DISPOSABLE_MECHANICS_ONLY" and + rb.get("phase") == "injected_fault_rolled_back" and + rb.get("snapshot_owned") is True and + rb.get("snapshot_id_verified") is True and + rb.get("candidate_present_before_stage") is False and + rb.get("release_fault_injected") is True and + rb.get("candidate_absent_after_rollback") is True and + rb.get("native_app_running_after_rollback") is True and + rb.get("native_game_status_rechecked") is True and + rb.get("candidate_sha256") == + "da25005e8729e6f013f7108ac5b8869dd46323befc78efdae18221b26cafe5f8" and + rb.get("collage_managed_deployment") is False and + rb.get("world_activation_authorized_by_collage") is False and + rb.get("actual_player_gameplay_verified") is False and + rb.get("physical_true_nas_mutated") is False, + "native disposable rollback receipt missing") + restored = (p.get("readiness") or {}).get("g3-rollback") or {} + proto = restored.get("protocol") or {} + level = restored.get("level") or {} + need(type(restored) is dict and type(proto) is dict and + proto.get("oracleSatisfied") is True and + type(level) is dict and type(level.get("size")) is int and + level["size"] > 0, + "native rollback independently healthy world not observed") + if require_collage: + observer = p.get("collage_readback") or {} + need(type(observer) is dict and + observer.get("schema") == "semper-supra.collage-native-readback-p2/1" and + observer.get("classification") == "PROVIDER_READBACK_MATCH" and + observer.get("source_profile") == "vanilla263" and + observer.get("source_sha") == "2b6abe1141e65b8c6872eeba6c8ae39f5a9635f8" and + observer.get("native_app") == APP and + observer.get("truenas_version") == TARGET and + observer.get("world_id") == "lantern-rdte" and + observer.get("service_id") == "11111111-1111-4111-8111-111111111111" and + observer.get("ownership_fields_matched") == 6 and + observer.get("transport") == "isolated_guest_tls_verified" and + observer.get("credential_scope") == "APPS_READ" and + observer.get("credential_mutation_phase") == + "disposable_guest_setup_before_observation" and + observer.get("observation_mutation_count") == 0 and + observer.get("world_artifact_format_verified") is False and + observer.get("world_activated") is False and + observer.get("cloudnet_adoption_executed") is False and + observer.get("physical_true_nas_mutated") is False and + isinstance(observer.get("tls_cert_sha256"), str) and + len(observer["tls_cert_sha256"]) == 64, + "independent scoped native vanilla readback missing or unqualified") + + +def main() -> int: + p = argparse.ArgumentParser() + p.add_argument("--receipt", required=True, type=Path) + p.add_argument("--require-collage-readback", action="store_true") + p.add_argument("--require-lantern-staged", action="store_true") + p.add_argument("--require-native-rollback", action="store_true") + p.add_argument("--require-native-client-visibility", action="store_true") + p.add_argument("--require-native-backend-region", action="store_true") + a = p.parse_args() + try: + check(json.loads(a.receipt.read_text(encoding="utf-8")), + require_collage=a.require_collage_readback, + require_lantern_staged=a.require_lantern_staged, + require_native_rollback=a.require_native_rollback, + require_native_client_visibility=a.require_native_client_visibility, + require_native_backend_region=a.require_native_backend_region) + except (OSError, ValueError, TypeError) as ex: + # Error messages generated by check() are constant, never propagate + # arbitrary provider receipt fields, credentials or real addresses. + msg = str(ex) if str(ex) in { + "guest root receipt schema", "wrong exact guest identity", + "guest/root unsupported", "guest exact source/profile mismatch", + "wrong native workload shape", "wrong product guest oracle", + "guest product signer/control mismatch", + "exact native catalog not verified by guest", + "native running OCI image digest not verified", + "independent world not retained", "guest readiness stages absent", + "native App/ZFS world cleanup left residue", + "guest receipt admits secrets", "guest receipt cross-target mismatch", + "native rollback requires independently qualified G2 and artifact", + "native disposable rollback receipt missing", + "native rollback independently healthy world not observed", + "native client requires exact G2 and G3", + "native offline client path not independently verified", + "native offline client causal stage missing", + } or str(ex).startswith(( + "independent vanilla protocol not accepted at ", + "independent owned world level.dat not observed at ", + )) else "guest receipt unreadable/invalid" + print("Vanilla263 native T6 acceptance REFUSED: " + msg, file=sys.stderr) + return 2 + print("Vanilla263 native T6 SUPPORTED: exact guest/catalog/Java25/protocol777/world/cleanup") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/check_collage_catalog_preflight.py b/scripts/check_collage_catalog_preflight.py new file mode 100644 index 000000000..5cf13de22 --- /dev/null +++ b/scripts/check_collage_catalog_preflight.py @@ -0,0 +1,98 @@ +#!/usr/bin/env python3 +"""Cheap read-only pre-KVM guard for rotating official TrueNAS Minecraft catalog. + +Do not confuse this remote branch preflight with in-guest native catalog proof. +The official TrueNAS Apps catalog is independently updated and may retire old +versions despite a pinned Foundry source render. Query the vendor's published +master branch (not the nonexistent main branch); caller still requires exact +pinned catalog version in the response. No credentials or mutation. +""" +from __future__ import annotations + +import argparse +import json +import re +import sys +import urllib.error +import urllib.request + +URI = "https://api.github.com/repos/truenas/apps/contents/trains/community/minecraft?ref=master" +VERSION = re.compile(r"^[0-9]+\.[0-9]+\.[0-9]+$") + + +def version_inventory(rows: object) -> list[str]: + if type(rows) is not list or len(rows) > 1000: + raise ValueError("CATALOG_LIST_INVALID") + versions = [] + for row in rows: + if type(row) is not dict: + raise ValueError("CATALOG_LIST_INVALID") + name = row.get("name") + kind = row.get("type") + if not isinstance(name, str) or kind not in {"dir", "file"}: + raise ValueError("CATALOG_LIST_INVALID") + if kind == "dir" and VERSION.fullmatch(name): + versions.append(name) + if not versions or len(set(versions)) != len(versions): + raise ValueError("CATALOG_LIST_AMBIGUOUS") + return sorted(versions) + + +def evaluate(rows: object, expected: str) -> dict: + if not isinstance(expected, str) or not VERSION.fullmatch(expected): + raise ValueError("INVALID_PINNED_VERSION") + available = version_inventory(rows) + return { + "schema": "collage.official-catalog-preflight/1", + "classification": "PRE_KVM_CATALOG_VERSION_AVAILABLE" + if expected in available else "PRE_KVM_CATALOG_DRIFT", + "expected_version": expected, + "visible_versions": available, + "guest_catalog_observed": False, + "live_minecraft_qualified": False, + "world_mutated": False, + } + + +def fixed_catalog_failure_reason(exc: Exception) -> str: + """No remote error/body/URL leakage; inconclusive is never version drift.""" + if isinstance(exc, urllib.error.HTTPError): + if exc.code in (403, 429): + return "OFFICIAL_CATALOG_HTTP_THROTTLED_OR_FORBIDDEN" + if exc.code == 404: + return "OFFICIAL_CATALOG_HTTP_NOT_FOUND" + return "OFFICIAL_CATALOG_HTTP_OTHER" + if isinstance(exc, urllib.error.URLError) or isinstance(exc, OSError): + return "OFFICIAL_CATALOG_TRANSPORT_UNAVAILABLE" + return "OFFICIAL_CATALOG_RESPONSE_INVALID" + + +def main() -> int: + p = argparse.ArgumentParser(description=__doc__) + p.add_argument("--expected", required=True) + a = p.parse_args() + req = urllib.request.Request(URI, headers={ + "User-Agent": "SupraCraft-COLLAGE-immutable-catalog-preflight", + "Accept": "application/vnd.github+json", + }) + try: + with urllib.request.urlopen(req, timeout=20) as stream: + payload = stream.read(512 * 1024 + 1) + if len(payload) > 512 * 1024: + raise ValueError("CATALOG_LIST_OVERSIZED") + row = evaluate(json.loads(payload), a.expected) + status = 0 if row["classification"] == "PRE_KVM_CATALOG_VERSION_AVAILABLE" else 2 + except (OSError, ValueError, urllib.error.HTTPError) as exc: + row = { + "schema": "collage.official-catalog-preflight/1", + "classification": "PRE_KVM_CATALOG_INCONCLUSIVE", + "reason": fixed_catalog_failure_reason(exc), + "guest_catalog_observed": False, "world_mutated": False, + } + status = 3 + print(json.dumps(row, sort_keys=True)) + return status + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/compute_guest_profile_contract.py b/scripts/compute_guest_profile_contract.py new file mode 100644 index 000000000..2f04a79b8 --- /dev/null +++ b/scripts/compute_guest_profile_contract.py @@ -0,0 +1,170 @@ +#!/usr/bin/env python3 +from __future__ import annotations +import argparse, json, pathlib +from typing import Any + +class ProfileError(RuntimeError): + pass + +def load(path:pathlib.Path)->dict[str,Any]: + value=json.loads(path.read_text(encoding="utf-8")) + if not isinstance(value,dict): + raise ProfileError("profile must be object") + return value + +def validate(p:dict[str,Any])->dict[str,Any]: + if p.get("schema")!="semper-supra.compute-guest-profiles/v1": + raise ProfileError("unsupported schema") + if p.get("dependencies",{}).get("firecracker")!="SemperSupra/agent-dispatch-private#277": + raise ProfileError("Firecracker authority must remain #277") + if p.get("dependencies",{}).get("windows_embodiment")!="mark-e-deyoung/windows-utilities#26": + raise ProfileError("Windows embodiment authority must remain windows-utilities#26") + if p.get("dependencies",{}).get("windows_media")!="mark-e-deyoung/WinBot#70": + raise ProfileError("Windows media authority must remain WinBot#70") + policy=p.get("policy") + if not isinstance(policy,dict) or any(policy.get(k) is not True for k in ( + "source_capability_is_not_runtime_qualification", + "nested_kvm_must_be_observed_in_guest", + "private_hypervisor_escape_hatches_prohibited", + "windows_media_must_be_legal_public_evaluation_or_user_supplied", + "windows_product_key_must_not_be_embedded", + "guest_oracle_required_before_backend_admission", + )): + raise ProfileError("safety/claim policy incomplete") + fixtures=p.get("fixtures") + if not isinstance(fixtures,dict) or set(fixtures)!={"linux_v1","windows_w1"}: + raise ProfileError("exact guest fixture set missing") + v1=fixtures["linux_v1"] + if v1.get("id")!="cirros-0.6.3-x86_64-nocloud-serial-nonce": + raise ProfileError("linux_v1 fixture id drift") + if v1.get("release")!="0.6.3" or v1.get("architecture")!="x86_64": + raise ProfileError("linux_v1 release/architecture drift") + source=v1.get("source") + if not isinstance(source,dict) or source.get("url")!="https://download.cirros-cloud.net/0.6.3/cirros-0.6.3-x86_64-disk.img": + raise ProfileError("linux_v1 source URL drift") + if source.get("sha256")!="7d6355852aeb6dbcd191bcda7cd74f1536cfe5cbf8a10495a7283a8396e4b75b": + raise ProfileError("linux_v1 source digest drift") + if source.get("checksum_url")!="https://download.cirros-cloud.net/0.6.3/SHA256SUMS": + raise ProfileError("linux_v1 checksum authority drift") + if v1.get("datasource")!="nocloud" or v1.get("user_data_contract")!="executable-shell": + raise ProfileError("linux_v1 datasource contract drift") + oracle=v1.get("oracle") + if not isinstance(oracle,dict) or oracle.get("transport")!="serial-console" or oracle.get("nonce_prefix")!="AGENT_DISPATCH_V1_NONCE=" or oracle.get("must_match_injected_nonce") is not True: + raise ProfileError("linux_v1 guest nonce oracle incomplete") + w1=fixtures["windows_w1"] + if w1.get("id")!="windows-11-enterprise-evaluation-26h2-x64-en-us": + raise ProfileError("Windows W1 fixture id drift") + if any(w1.get(k)!=v for k,v in { + "product":"Windows 11 Enterprise Evaluation", + "version":"26H2", + "build":"26300.9457", + "edition":"Enterprise", + "architecture":"x64", + "language":"English", + "product_key_policy":"none-embedded", + }.items()): + raise ProfileError("Windows W1 product identity drift") + wsrc=w1.get("source") + if not isinstance(wsrc,dict) or wsrc.get("host")!="software-static.download.prss.microsoft.com": + raise ProfileError("Windows W1 source host drift") + if wsrc.get("sha256")!="bc3f24086ebadc94489066b5ad78089e2cf5c3491e90e790bb81a2b199c10e38" or wsrc.get("size_bytes")!=8225329152: + raise ProfileError("Windows W1 media identity drift") + if wsrc.get("qualification_authority")!="mark-e-deyoung/WinBot#70" or wsrc.get("qualification_run")!=36695017130: + raise ProfileError("Windows W1 qualification authority drift") + if wsrc.get("accepted_artifact")!=11087331509 or wsrc.get("accepted_artifact_digest")!="sha256:9f94fea45f82fe97056a89ac656d7009885a9d8b846747cf6f5f57d0943451dd": + raise ProfileError("Windows W1 accepted artifact identity drift") + tn=p.get("truenas") + expected={"25.04.1","25.04.2.6","25.10.7","26.0.0-BETA.3"} + if not isinstance(tn,dict) or set(tn)!=expected: + raise ProfileError("TrueNAS guest profile set must cover exact admitted matrix") + for version,row in tn.items(): + if row.get("windows11",{}).get("runtime_status")!="OPEN": + raise ProfileError(f"{version}: Windows runtime must remain OPEN before receipt") + if row.get("firecracker",{}).get("runtime_status")!="OPEN": + raise ProfileError(f"{version}: Firecracker runtime must remain OPEN before receipt") + if version=="25.04.1": + fc=row["firecracker"] + if fc.get("source_status")!="RUNTIME_OBSERVE_ONLY" or fc.get("cpu_passthrough_control") is not None: + raise ProfileError("25.04.1 Firecracker must remain observe-only without private raw.qemu") + else: + if row["firecracker"].get("cpu_passthrough_control")!="vm.create.cpu_mode=HOST-PASSTHROUGH": + raise ProfileError(f"{version}: native Firecracker candidate must bind HOST-PASSTHROUGH") + if version=="26.0.0-BETA.3": + linux2=row.get("linux_v2") + if not isinstance(linux2,dict) or linux2.get("source_status")!="OBSERVATION_CANDIDATE" or linux2.get("runtime_status")!="OPEN": + raise ProfileError("BETA.3 Linux V2 must remain observation candidate/runtime OPEN") + if linux2.get("prerequisite")!="V1 accepted" or linux2.get("vm_cpu_mode")!="HOST-PASSTHROUGH": + raise ProfileError("BETA.3 Linux V2 prerequisite/cpu mode drift") + obs=linux2.get("observation") + if obs != { + "kvm_device_marker":"AGENT_DISPATCH_V2_KVM_PRESENT=", + "cpu_extension_marker":"AGENT_DISPATCH_V2_CPU_VMX_SVM=", + "transport":"supported TrueNAS VM console", + }: + raise ProfileError("BETA.3 Linux V2 observation contract drift") + if "#277" not in linux2.get("firecracker_gate","") or "does not invalidate V0/V1" not in linux2.get("negative_boundary",""): + raise ProfileError("BETA.3 Linux V2 claim boundary incomplete") + if version=="26.0.0-BETA.3": + linux=row.get("linux_v1") + if not isinstance(linux,dict) or linux.get("source_status")!="CANDIDATE" or linux.get("runtime_status")!="OPEN": + raise ProfileError("BETA.3 Linux V1 must remain source candidate/runtime OPEN") + if linux.get("fixture")!="cirros-0.6.3-x86_64-nocloud-serial-nonce": + raise ProfileError("BETA.3 Linux V1 fixture binding drift") + surfaces=linux.get("public_surfaces") + if surfaces != { + "boot_disk":"vm.device.create RAW exists=true boot=true", + "seed_media":"vm.device.create CDROM", + "console_discovery":"vm.get_console", + }: + raise ProfileError("BETA.3 Linux V1 public surface binding drift") + observation=linux.get("observation_requirement","") + if "raw.qemu" not in observation or "virsh directly" not in observation: + raise ProfileError("BETA.3 Linux V1 must prohibit private hypervisor console escape") + if row.get("windows11",{}).get("fixture")!="windows-11-enterprise-evaluation-26h2-x64-en-us": + raise ProfileError("BETA.3 Windows W1 fixture binding drift") + win=row["windows11"] + if win.get("source_status")!="CANDIDATE": + raise ProfileError(f"{version}: Windows source status must remain CANDIDATE") + caps=win.get("capabilities") + if not isinstance(caps,dict) or set(caps)!={"secure_boot","tpm","uefi_q35"}: + raise ProfileError(f"{version}: Windows capability set incomplete") + prox=p.get("proxmox") + if not isinstance(prox,dict) or set(prox)!={"9.2-1"}: + raise ProfileError("only admitted Proxmox 9.2-1 may have guest profile") + for kind in ("windows11","firecracker"): + if prox["9.2-1"][kind].get("runtime_status")!="OPEN": + raise ProfileError(f"Proxmox {kind} runtime must remain OPEN") + pwin=prox["9.2-1"]["windows11"] + if pwin.get("source_status")!="CANDIDATE": + raise ProfileError("Proxmox Windows source profile must be CANDIDATE") + if pwin.get("fixture")!="windows-11-enterprise-evaluation-26h2-x64-en-us": + raise ProfileError("Proxmox Windows fixture binding drift") + if pwin.get("source_binding")!={"qemu_server_commit":"6785065b3f766f15f6f151af8ec27ec8bb5b07ab","package_version":"9.1.15"}: + raise ProfileError("Proxmox Windows qemu-server source binding drift") + pcaps=pwin.get("capabilities") + if not isinstance(pcaps,dict) or set(pcaps)!={"secure_boot","tpm","uefi_q35"} or any(pcaps[k].get("source_proven") is not True for k in pcaps): + raise ProfileError("Proxmox Windows source capability binding incomplete") + rungs=p.get("rungs") + if not isinstance(rungs,dict) or list(rungs)!=["V0","V1","V2","W1","WB"]: + raise ProfileError("guest rung order/coverage invalid") + return { + "schema":"semper-supra.compute-guest-profiles-validation/v1", + "status":"PASS", + "truenas_versions":sorted(tn), + "proxmox_versions":sorted(prox), + "runtime_claims":"OPEN", + } + +def main()->int: + ap=argparse.ArgumentParser() + ap.add_argument("--profile",type=pathlib.Path,default=pathlib.Path("config/compute-guest-profiles.json")) + a=ap.parse_args() + try: + result=validate(load(a.profile)) + except (ProfileError,OSError,json.JSONDecodeError) as exc: + print(json.dumps({"status":"ERROR","error":str(exc)},sort_keys=True)) + return 2 + print(json.dumps(result,indent=2,sort_keys=True)) + return 0 +if __name__=="__main__": + raise SystemExit(main()) diff --git a/scripts/compute_guest_seed.py b/scripts/compute_guest_seed.py new file mode 100644 index 000000000..fe152b720 --- /dev/null +++ b/scripts/compute_guest_seed.py @@ -0,0 +1,89 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import json +import pathlib +import re + +NONCE_RE = re.compile(r"^[A-Za-z0-9_-]{16,64}$") +INSTANCE_RE = re.compile(r"^[A-Za-z0-9_.-]{1,64}$") +PREFIX = "AGENT_DISPATCH_V1_NONCE=" + + +class SeedError(RuntimeError): + pass + + +def validate_token(value: str, pattern: re.Pattern[str], label: str) -> str: + if not pattern.fullmatch(value): + raise SeedError(f"{label} violates deterministic seed contract") + return value + + +def render_seed(nonce: str, instance_id: str) -> tuple[str, str]: + nonce = validate_token(nonce, NONCE_RE, "nonce") + instance_id = validate_token(instance_id, INSTANCE_RE, "instance-id") + meta = json.dumps( + {"instance-id": instance_id, "local-hostname": "rdtev1"}, + sort_keys=True, + separators=(",", ":"), + ) + "\n" + user = ( + "#!/bin/sh\n" + "set -eu\n" + f"nonce='{nonce}'\n" + f"printf '%s%s\\n' '{PREFIX}' \"$nonce\" >/dev/ttyS0\n" + f"printf '%s%s\\n' '{PREFIX}' \"$nonce\" >/dev/console 2>/dev/null || true\n" + ) + return meta, user + + +def render_v2_observation_seed(nonce: str, instance_id: str) -> tuple[str, str]: + nonce = validate_token(nonce, NONCE_RE, "nonce") + instance_id = validate_token(instance_id, INSTANCE_RE, "instance-id") + meta = json.dumps( + {"instance-id": instance_id, "local-hostname": "rdtev2"}, + sort_keys=True, + separators=(",", ":"), + ) + "\n" + user = ( + "#!/bin/sh\n" + "set -eu\n" + f"nonce='{nonce}'\n" + "kvm=0\n" + "cpu=0\n" + "[ -c /dev/kvm ] && kvm=1 || true\n" + "grep -Eq '(^|[[:space:]])(vmx|svm)([[:space:]]|$)' /proc/cpuinfo && cpu=1 || true\n" + f"printf '%s%s\\n' '{PREFIX}' \"$nonce\" >/dev/ttyS0\n" + "printf 'AGENT_DISPATCH_V2_KVM_PRESENT=%s\\n' \"$kvm\" >/dev/ttyS0\n" + "printf 'AGENT_DISPATCH_V2_CPU_VMX_SVM=%s\\n' \"$cpu\" >/dev/ttyS0\n" + ) + return meta, user + + +def write_seed(out_dir: pathlib.Path, nonce: str, instance_id: str) -> None: + meta, user = render_seed(nonce, instance_id) + out_dir.mkdir(parents=True, exist_ok=True) + (out_dir / "meta-data").write_text(meta, encoding="utf-8") + user_path = out_dir / "user-data" + user_path.write_text(user, encoding="utf-8") + user_path.chmod(0o755) + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--nonce", required=True) + ap.add_argument("--instance-id", required=True) + ap.add_argument("--out-dir", type=pathlib.Path, required=True) + args = ap.parse_args() + try: + write_seed(args.out_dir, args.nonce, args.instance_id) + except (SeedError, OSError) as exc: + print(f"ERROR: {exc}") + return 2 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/compute_materialization_contract.py b/scripts/compute_materialization_contract.py new file mode 100644 index 000000000..29befa423 --- /dev/null +++ b/scripts/compute_materialization_contract.py @@ -0,0 +1,229 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import json +import pathlib +from typing import Any + + +class ContractError(RuntimeError): + pass + + +def load(path: pathlib.Path) -> dict[str, Any]: + try: + value = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise ContractError(f"cannot read {path}: {exc}") from exc + if not isinstance(value, dict): + raise ContractError(f"{path} must contain an object") + return value + + +def platform_targets(registry: dict[str, Any], platform: str) -> list[dict[str, Any]]: + if registry.get("schema") != "semper-supra.compute-materialization-targets/v1": + raise ContractError("unsupported registry schema") + platforms = registry.get("platforms") + if not isinstance(platforms, dict) or platform not in platforms: + raise ContractError(f"unsupported platform: {platform}") + targets = platforms[platform].get("targets") + if not isinstance(targets, list) or not targets: + raise ContractError(f"{platform}: target list missing") + versions = [x.get("version") for x in targets if isinstance(x, dict)] + if len(versions) != len(set(versions)) or any(not isinstance(v, str) or not v for v in versions): + raise ContractError(f"{platform}: target versions must be unique non-empty strings") + return targets + + +def target(registry: dict[str, Any], platform: str, version: str) -> dict[str, Any]: + rows = [x for x in platform_targets(registry, platform) if x["version"] == version] + if len(rows) != 1: + raise ContractError(f"exact target not admitted: {platform}/{version}") + return rows[0] + + +def validate_adapter(adapter: dict[str, Any], platform: str, kind: str) -> None: + if not isinstance(adapter.get("id"), str) or not adapter["id"]: + raise ContractError(f"{platform}/{kind}: adapter id missing") + if platform == "truenas": + methods = adapter.get("required_methods") + if not isinstance(methods, list) or not methods or any(not isinstance(x, str) or "." not in x for x in methods): + raise ContractError(f"{adapter['id']}: required_methods invalid") + if kind == "container": + discovery = adapter.get("image_discovery") + if not isinstance(discovery, dict): + raise ContractError(f"{adapter['id']}: image_discovery missing") + method = discovery.get("method") + if not isinstance(method, str) or method not in methods: + raise ContractError(f"{adapter['id']}: image_discovery method must be required") + if not isinstance(discovery.get("source"), str) or not discovery["source"].startswith("https://"): + raise ContractError(f"{adapter['id']}: image_discovery source invalid") + if not isinstance(discovery.get("selection"), dict) or not discovery["selection"]: + raise ContractError(f"{adapter['id']}: image_discovery selection missing") + elif platform == "proxmox": + templates = adapter.get("required_api_templates") + if not isinstance(templates, list) or not templates or any(not isinstance(x, str) or " /" not in x for x in templates): + raise ContractError(f"{adapter['id']}: required_api_templates invalid") + + +def validate(registry: dict[str, Any]) -> dict[str, Any]: + seen_adapters: set[tuple[str, str, str]] = set() + summary: dict[str, Any] = {} + for platform in ("truenas", "proxmox"): + rows = platform_targets(registry, platform) + ps = [] + for row in rows: + if not isinstance(row.get("source_fingerprint"), dict) or not row["source_fingerprint"]: + raise ContractError(f"{platform}/{row['version']}: source_fingerprint missing") + if platform == "truenas": + api_fp = row.get("api_schema_fingerprint") + if not isinstance(api_fp, dict) or not api_fp: + raise ContractError(f"{platform}/{row['version']}: api_schema_fingerprint missing") + for kind in ("container", "vm"): + adapters = row.get(f"{kind}_adapters") + if not isinstance(adapters, list) or not adapters: + raise ContractError(f"{platform}/{row['version']}: {kind}_adapters missing") + for adapter in adapters: + validate_adapter(adapter, platform, kind) + if platform == "truenas": + family = adapter.get("source_api_family") + if not isinstance(family, str) or not family.startswith("v"): + raise ContractError(f"{adapter['id']}: source_api_family missing") + key = (platform, row["version"], adapter["id"]) + if key in seen_adapters: + raise ContractError(f"duplicate adapter in target: {key}") + seen_adapters.add(key) + ps.append({ + "version": row["version"], + "management_families": row.get("management_families", []), + "container_adapters": [a["id"] for a in row["container_adapters"]], + "vm_adapters": [a["id"] for a in row["vm_adapters"]], + }) + if platform == "proxmox": + candidates = registry["platforms"][platform].get("candidate_targets", []) + if not isinstance(candidates, list): + raise ContractError("proxmox: candidate_targets must be a list") + admitted = {x["version"] for x in rows} + seen_candidates = set() + for candidate in candidates: + if not isinstance(candidate, dict): + raise ContractError("proxmox: candidate target must be an object") + version = candidate.get("version") + digest = candidate.get("iso_sha256") + if not isinstance(version, str) or not version or version in admitted or version in seen_candidates: + raise ContractError(f"proxmox: invalid or duplicate candidate version {version!r}") + if not isinstance(digest, str) or len(digest) != 64: + raise ContractError(f"proxmox/{version}: candidate ISO SHA-256 invalid") + if candidate.get("state") != "source-profile-open": + raise ContractError(f"proxmox/{version}: candidate must remain source-profile-open") + seen_candidates.add(version) + summary["proxmox_candidates"] = [ + {"version": x["version"], "state": x["state"]} for x in candidates + ] + summary[platform] = ps + return { + "schema": "semper-supra.compute-materialization-targets-validation/v1", + "status": "PASS", + "platforms": summary, + "claim_boundary": "profile contract only; no runtime qualification implied", + } + + +def choose_adapter( + registry: dict[str, Any], + platform: str, + version: str, + kind: str, + observed_methods: set[str] | None, +) -> dict[str, Any]: + if kind not in {"container", "vm"}: + raise ContractError(f"unsupported kind: {kind}") + row = target(registry, platform, version) + adapters = row[f"{kind}_adapters"] + + if platform == "proxmox": + if len(adapters) != 1: + raise ContractError(f"{platform}/{version}/{kind}: ambiguous adapter set") + return adapters[0] + + if observed_methods is None: + raise ContractError("TrueNAS adapter selection requires observed method set") + + candidates = [] + for adapter in adapters: + required = set(adapter["required_methods"]) + if required <= observed_methods: + candidates.append(adapter) + if not candidates: + missing = { + a["id"]: sorted(set(a["required_methods"]) - observed_methods) + for a in adapters + } + raise ContractError(f"no capability-satisfied adapter; missing={missing}") + + preferred = [a for a in candidates if a.get("preferred") is True] + if len(preferred) == 1: + return preferred[0] + if len(preferred) > 1: + raise ContractError("multiple preferred adapters satisfied") + if len(candidates) != 1: + raise ContractError(f"multiple capability-satisfied adapters without preference: {[a['id'] for a in candidates]}") + return candidates[0] + + +def semantic_plan(kind: str, adapter: dict[str, Any]) -> list[str]: + if kind == "container": + restart = "restart" if adapter.get("restart_semantics") == "native" else "stop+start" + return ["observe", "create", "readback", "start", "guest-oracle", "update-if-supported", restart, "stop", "delete", "absence", "noop"] + return ["observe", "create", "readback", "start", "guest-oracle", "nested-kvm-observe-if-firecracker", "restart", "stop", "delete", "absence", "noop"] + + +def main() -> int: + p = argparse.ArgumentParser() + p.add_argument("--registry", type=pathlib.Path, default=pathlib.Path("config/compute-materialization-targets.json")) + p.add_argument("--platform", choices=["truenas", "proxmox"]) + p.add_argument("--version") + p.add_argument("--kind", choices=["container", "vm"]) + p.add_argument("--observed-methods", type=pathlib.Path) + p.add_argument("--validate-only", action="store_true") + args = p.parse_args() + + try: + registry = load(args.registry) + validation = validate(registry) + if args.validate_only: + print(json.dumps(validation, indent=2, sort_keys=True)) + return 0 + if not (args.platform and args.version and args.kind): + raise ContractError("--platform, --version and --kind are required unless --validate-only") + methods = None + if args.observed_methods: + raw = load(args.observed_methods) + values = raw.get("methods") + if not isinstance(values, list) or any(not isinstance(x, str) for x in values): + raise ContractError("observed methods file must contain string array 'methods'") + methods = set(values) + adapter = choose_adapter(registry, args.platform, args.version, args.kind, methods) + row = target(registry, args.platform, args.version) + result = { + "schema": "semper-supra.compute-materialization-plan/v1", + "platform": args.platform, + "version": args.version, + "kind": args.kind, + "adapter": adapter, + "source_fingerprint": row["source_fingerprint"], + "api_schema_fingerprint": row.get("api_schema_fingerprint"), + "semantic_plan": semantic_plan(args.kind, adapter), + "apply_authorized": False, + "claim_boundary": "plan only; runtime apply requires separate bounded executor/authority", + } + print(json.dumps(result, indent=2, sort_keys=True)) + return 0 + except ContractError as exc: + print(json.dumps({"status": "ERROR", "error": str(exc)}, sort_keys=True)) + return 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/gha_kvm_proxmox_rdte.sh b/scripts/gha_kvm_proxmox_rdte.sh new file mode 100755 index 000000000..3d6ca614e --- /dev/null +++ b/scripts/gha_kvm_proxmox_rdte.sh @@ -0,0 +1,1233 @@ +#!/usr/bin/env bash +set -euo pipefail + +ISO_NAME="proxmox-ve_9.2-1.iso" +ISO_URL="https://enterprise.proxmox.com/iso/$ISO_NAME" +ISO_SHA256="4e88fe416df9b527624a175f24c9aa07c714d3332afb1ee3dbf3879573ef2c6c" +PVE_INSTALLER_SOURCE_VERSION="9.2.5" +PVE_INSTALLER_SOURCE_COMMIT="32afcd4cd534d8e2f99ae76aa0234a0a5c697ba9" +P3_PVE_CONTAINER_SOURCE_COMMIT="5eb5574ee9158ac40a5230de2cf18d7d6345709f" +P3_TEMPLATE_NAME="debian-13-standard_13.1-2_amd64.tar.zst" +P3_TEMPLATE_URL="http://download.proxmox.com/images/system/$P3_TEMPLATE_NAME" +P3_TEMPLATE_SHA512="5aec4ab2ac5c16c7c8ecb87bfeeb10213abe96db6b85e2463585cea492fc861d7c390b3f9c95629bf690b95e9dfe1037207fc69c0912429605f208d5cb2621f8" +P3_VMID=9101 +RAM_MIB=4096 +VCPUS=2 +DISK_SIZE="40G" +MIN_HOST_MEM_KIB=$((6 * 1024 * 1024)) +MIN_HOST_FREE_KIB=$((16 * 1024 * 1024)) +ROOT_PASSWORD="rdte-proxmox-${RANDOM}-${RANDOM}-${RANDOM}" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +usage() { + echo "Usage: gha_kvm_proxmox_rdte.sh --out RECEIPT [--state-dir DIR] [--compute-fixture none|container-c0|vm-v0]" +} + +OUT="" +STATE_DIR="" +COMPUTE_FIXTURE="none" +while [[ $# -gt 0 ]]; do + case "$1" in + --out) OUT="$2"; shift 2 ;; + --state-dir) STATE_DIR="$2"; shift 2 ;; + --compute-fixture) COMPUTE_FIXTURE="$2"; shift 2 ;; + -h|--help) usage; exit 0 ;; + *) echo "unknown argument: $1" >&2; usage >&2; exit 2 ;; + esac +done +[[ -n "$OUT" ]] || { usage >&2; exit 2; } +[[ "$COMPUTE_FIXTURE" == "none" || "$COMPUTE_FIXTURE" == "container-c0" || "$COMPUTE_FIXTURE" == "vm-v0" ]] || { echo "unsupported compute fixture: $COMPUTE_FIXTURE" >&2; exit 2; } + +if [[ -z "$STATE_DIR" ]]; then STATE_DIR="$(mktemp -d -t gha-kvm-proxmox.XXXXXX)"; fi +mkdir -p "$STATE_DIR" "$(dirname "$OUT")" +STATE_DIR="$(realpath "$STATE_DIR")" +OUT="$(realpath -m "$OUT")" +[[ "$OUT" != "$STATE_DIR/"* ]] || { echo "receipt must be outside disposable state" >&2; exit 2; } + +QEMU_PID="" +OBSERVED_ISO_SHA="" +API_VERSION_JSON="" +HOSTFWD_API_VERSION_JSON="" +GUEST_LOCAL_API_VERSION_JSON="" +API_OBSERVATION_ROUTE="" +NESTED_KVM="unknown" +NESTED_KVM_INDICATORS="unknown" +NESTED_KVM_VCPU_JSON="" +P3_LXC_JSON="" +REST_API_CENSUS_JSON="" +REST_C0_JSON="" +REST_VM_V0_JSON="" +SSH_HOSTFWD_ACCEPTED="false" +API_HOSTFWD_ACCEPTED="false" +QEMU_ALIVE_AT_API_GATE="unknown" +GUEST_DIAGNOSTICS="" +FIRST_BOOT_WITNESS="" +FIRST_BOOT_WITNESS_OBSERVED="false" +INSTALL_SUCCESS_MARKER_OBSERVED="false" +INSTALLED_DISK_LAYOUT_OK="false" +ISO_FIRST_BOOT_PACKAGE="" +INSTALLED_DISK_PREBOOT="" +INSTALLED_DISK_POSTBOOT="" +cleanup() { + set +e + if [[ -n "$QEMU_PID" ]]; then + sudo -n kill "$QEMU_PID" >/dev/null 2>&1 || true + sleep 1 + sudo -n kill -9 "$QEMU_PID" >/dev/null 2>&1 || true + fi + rm -rf -- "$STATE_DIR" +} +trap cleanup EXIT INT TERM + +write_receipt() { + local classification="$1" oracle="$2" phase="$3" detail="$4" + local serial_tail="" + if [[ -f "$STATE_DIR/serial.log" ]]; then + serial_tail="$(tail -n 80 "$STATE_DIR/serial.log" | tr -d '\000' | sed -E 's/[^[:print:]\t]//g' | tail -c 12000)" + fi + export R_OUT="$OUT" R_CLASS="$classification" R_ORACLE="$oracle" R_PHASE="$phase" R_DETAIL="$detail" + export R_SERIAL="$serial_tail" R_ISO_SHA="$OBSERVED_ISO_SHA" R_API="$API_VERSION_JSON" R_NESTED="$NESTED_KVM" + export R_NESTED_INDICATORS="$NESTED_KVM_INDICATORS" R_NESTED_VCPU="$NESTED_KVM_VCPU_JSON" R_P3_LXC="$P3_LXC_JSON" R_REST_API_CENSUS="$REST_API_CENSUS_JSON" R_REST_C0="$REST_C0_JSON" R_REST_VM_V0="$REST_VM_V0_JSON" R_COMPUTE_FIXTURE="$COMPUTE_FIXTURE" + export R_HOSTFWD_API="$HOSTFWD_API_VERSION_JSON" R_GUEST_LOCAL_API="$GUEST_LOCAL_API_VERSION_JSON" R_API_ROUTE="$API_OBSERVATION_ROUTE" + export R_SSH_HOSTFWD_ACCEPTED="$SSH_HOSTFWD_ACCEPTED" R_API_HOSTFWD_ACCEPTED="$API_HOSTFWD_ACCEPTED" + export R_QEMU_ALIVE="$QEMU_ALIVE_AT_API_GATE" R_GUEST_DIAGNOSTICS="$GUEST_DIAGNOSTICS" + export R_FIRST_BOOT_WITNESS="$FIRST_BOOT_WITNESS" R_FIRST_BOOT_WITNESS_OBSERVED="$FIRST_BOOT_WITNESS_OBSERVED" + export R_INSTALL_SUCCESS_MARKER_OBSERVED="$INSTALL_SUCCESS_MARKER_OBSERVED" R_INSTALLED_DISK_LAYOUT_OK="$INSTALLED_DISK_LAYOUT_OK" + export R_ISO_FIRST_BOOT_PACKAGE="$ISO_FIRST_BOOT_PACKAGE" + export R_DISK_PREBOOT="$INSTALLED_DISK_PREBOOT" R_DISK_POSTBOOT="$INSTALLED_DISK_POSTBOOT" + python3 - <<'PY' +import json, os, pathlib +def load_json_env(name): + raw = os.environ.get(name, "") + if not raw: + return None + try: + return json.loads(raw) + except Exception as exc: + return {"inspection_ok": False, "parse_error": str(exc), "raw": raw[:2000]} + +nested_vcpu = load_json_env("R_NESTED_VCPU") +p3_lxc = load_json_env("R_P3_LXC") +rest_api_census = load_json_env("R_REST_API_CENSUS") +rest_c0 = load_json_env("R_REST_C0") +rest_vm_v0 = load_json_env("R_REST_VM_V0") + +payload = { + "contract": "gha-kvm-system-lab/v1", + "target": {"product": "proxmox-ve", "version": "9.2-1"}, + "classification": os.environ["R_CLASS"], + "oracleSatisfied": os.environ["R_ORACLE"].lower() == "true", + "phase": os.environ["R_PHASE"], + "detail": os.environ["R_DETAIL"], + "requested_shape": {"vcpus": 2, "ram_mib": 4096, "disk": "40G", "compute_fixture": os.environ.get("R_COMPUTE_FIXTURE", "none")}, + "source": { + "iso_name": "proxmox-ve_9.2-1.iso", + "iso_url": "https://enterprise.proxmox.com/iso/proxmox-ve_9.2-1.iso", + "expected_sha256": "4e88fe416df9b527624a175f24c9aa07c714d3332afb1ee3dbf3879573ef2c6c", + "observed_sha256": os.environ.get("R_ISO_SHA") or None, + "installer_source_version": "9.2.5", + "installer_source_commit": "32afcd4cd534d8e2f99ae76aa0234a0a5c697ba9", + "pve_manager_source_commit": "b9984c6d90a4bd80", + "pve_access_control_source_commit": "5ccd07d9302562b73374d331b63d25b04b86766c", + "pve_version_api_source": "PVE/API2.pm", + "pve_qemu_source_commit": "684796e835289dab11af8606fbf7358b93526dd6", + "pve_qemu_submodule_commit": "98b060da3a4f92b2a994ead5b16a87e783baf77c", + "pve_qemu_debugexit_source": "hw/misc/debugexit.c", + "pve_qemu_expected_package": "11.0.0-3", + "pve_container_source_commit": "5eb5574ee9158ac40a5230de2cf18d7d6345709f", + "p3_template": "debian-13-standard_13.1-2_amd64.tar.zst", + "p3_template_catalog_sha512": "5aec4ab2ac5c16c7c8ecb87bfeeb10213abe96db6b85e2463585cea492fc861d7c390b3f9c95629bf690b95e9dfe1037207fc69c0912429605f208d5cb2621f8", + "iso_first_boot_package": os.environ.get("R_ISO_FIRST_BOOT_PACKAGE") or None, + }, + "oracles": { + "vendor_iso_digest": os.environ.get("R_ISO_SHA") == "4e88fe416df9b527624a175f24c9aa07c714d3332afb1ee3dbf3879573ef2c6c", + "unattended_install_completed": os.environ.get("R_INSTALL_SUCCESS_MARKER_OBSERVED") == "true", + "installed_disk_layout": os.environ.get("R_INSTALLED_DISK_LAYOUT_OK") == "true", + "installed_https_api": bool(os.environ.get("R_API")), + "nested_kvm_indicators_via_ssh": os.environ.get("R_NESTED_INDICATORS") == "yes", + "nested_kvm_observed_via_ssh": os.environ.get("R_NESTED") == "yes", + "nested_kvm_vcpu_executed": bool(nested_vcpu and nested_vcpu.get("oracleSatisfied") is True), + "p3_lxc_lifecycle_exercised": bool(p3_lxc and p3_lxc.get("oracleSatisfied") is True), + "rest_api_census_observed": bool(rest_api_census and rest_api_census.get("oracleSatisfied") is True and rest_api_census.get("phase") == "observe-only"), + "rest_api_container_c0_exercised": bool(rest_c0 and rest_c0.get("oracleSatisfied") is True and rest_c0.get("api_materialization_oracle") is True), + "rest_api_vm_v0_exercised": bool(rest_vm_v0 and rest_vm_v0.get("oracleSatisfied") is True and rest_vm_v0.get("api_materialization_oracle") is True and rest_vm_v0.get("kind") == "vm"), + }, + "api_version": json.loads(os.environ["R_API"]) if os.environ.get("R_API") else None, + "nested_kvm": os.environ.get("R_NESTED"), + "p5_nested_kvm": nested_vcpu, + "p3_lxc": p3_lxc, + "rest_api_census": rest_api_census, + "rest_api_container_c0": rest_c0, + "rest_api_vm_v0": rest_vm_v0, + "diagnostics": { + "qemu_alive_at_api_gate": os.environ.get("R_QEMU_ALIVE"), + "ssh_hostfwd_accepted": os.environ.get("R_SSH_HOSTFWD_ACCEPTED") == "true", + "api_hostfwd_accepted": os.environ.get("R_API_HOSTFWD_ACCEPTED") == "true", + "api_observation_route": os.environ.get("R_API_ROUTE") or None, + "hostfwd_https_api": load_json_env("R_HOSTFWD_API"), + "guest_local_https_api": load_json_env("R_GUEST_LOCAL_API"), + "nested_kvm_indicators": os.environ.get("R_NESTED_INDICATORS"), + "guest": os.environ.get("R_GUEST_DIAGNOSTICS") or None, + "first_boot_witness_observed": os.environ.get("R_FIRST_BOOT_WITNESS_OBSERVED") == "true", + "first_boot_witness": os.environ.get("R_FIRST_BOOT_WITNESS") or None, + "installed_disk_preboot": load_json_env("R_DISK_PREBOOT"), + "installed_disk_postboot": load_json_env("R_DISK_POSTBOOT"), + }, + "serial_tail": os.environ.get("R_SERIAL", ""), + "limitations": [ + "Disposable virtual-hardware target profile; not physical-HBA/SMART/IPMI/HA qualification.", + "Nested KVM is a separate oracle from Proxmox management-plane support.", + "P5 nested KVM requires p5_nested_kvm.oracleSatisfied=true from an actual nested vCPU debug-exit; device/CPU flags alone are diagnostic.", + "P3 LXC is a separate lifecycle oracle using one exact public Debian template; P2/P5 are not inferred from it.", + "REST API census is read-only discovery evidence only; it does not claim LXC or QEMU materialization.", + "REST C0, when explicitly requested, proves only product-API LXC create/readback/start/stop/delete/absence; guest execution remains a separate C1 oracle.", + "REST VM V0, when explicitly requested, proves only source-bound product-API QEMU shell create/readback/start/stop/delete/absence; guest boot/workload and nested-KVM remain separate.", + ], +} +pathlib.Path(os.environ["R_OUT"]).write_text(json.dumps(payload, indent=2, sort_keys=True)+"\n", encoding="utf-8") +PY +} + +fail_evidence() { + local class="$1" phase="$2" detail="$3" + write_receipt "$class" false "$phase" "$detail" + echo "$class: $phase: $detail" >&2 + exit 0 +} + +for cmd in curl sha256sum qemu-img qemu-system-x86_64 xorriso python3 lsblk blkid mount umount readlink udevadm losetup mountpoint; do + command -v "$cmd" >/dev/null 2>&1 || fail_evidence ENVIRONMENT_FAILURE preflight "missing prerequisite: $cmd" +done +if ! command -v pvs >/dev/null 2>&1 || ! command -v lvs >/dev/null 2>&1 || ! command -v lvchange >/dev/null 2>&1; then + sudo -n env DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends lvm2 >/dev/null 2>&1 || + fail_evidence ENVIRONMENT_FAILURE preflight "could not install lvm2 diagnostic dependency" +fi +[[ "$(uname -s)" == Linux && "$(uname -m)" == x86_64 ]] || fail_evidence ENVIRONMENT_FAILURE preflight "requires Linux x86_64" +[[ -e /dev/kvm ]] || fail_evidence ENVIRONMENT_FAILURE preflight "/dev/kvm absent" +sudo -n test -r /dev/kvm && sudo -n test -w /dev/kvm || fail_evidence ENVIRONMENT_FAILURE preflight "passwordless sudo KVM boundary unavailable" + +MEM_AVAIL_KIB="$(awk '/MemAvailable:/ {print $2}' /proc/meminfo)" +FREE_KIB="$(df -Pk "$STATE_DIR" | awk 'NR==2 {print $4}')" +(( MEM_AVAIL_KIB >= MIN_HOST_MEM_KIB )) || fail_evidence SKIPPED_GUARDRAIL preflight "host memory headroom below 6 GiB" +(( FREE_KIB >= MIN_HOST_FREE_KIB )) || fail_evidence SKIPPED_GUARDRAIL preflight "host disk headroom below 16 GiB (observed ${FREE_KIB} KiB)" + +ISO="$STATE_DIR/$ISO_NAME" +curl --fail --location --retry 3 --silent --show-error "$ISO_URL" -o "$ISO" || fail_evidence ENVIRONMENT_FAILURE acquire "vendor ISO download failed" +OBSERVED_ISO_SHA="$(sha256sum "$ISO" | awk '{print $1}')" +[[ "$OBSERVED_ISO_SHA" == "$ISO_SHA256" ]] || fail_evidence ORACLE_FAILURE acquire "vendor ISO digest mismatch" + +ISO_FIRST_BOOT_PATHS="$(xorriso -indev "$ISO" -find /proxmox/packages -type f -name 'proxmox-first-boot_*.deb' -- 2>/dev/null)" || + fail_evidence HARNESS_FAILURE acquire "could not inspect first-boot package identity in vendor ISO" +ISO_FIRST_BOOT_PATH="$(printf '%s\n' "$ISO_FIRST_BOOT_PATHS" | sed -n '1p' | tr -d "'")" +ISO_FIRST_BOOT_PACKAGE="$(basename "$ISO_FIRST_BOOT_PATH")" +[[ "$ISO_FIRST_BOOT_PACKAGE" == proxmox-first-boot_${PVE_INSTALLER_SOURCE_VERSION}_*.deb ]] || + fail_evidence ORACLE_FAILURE acquire "ISO first-boot package does not match pinned source version ${PVE_INSTALLER_SOURCE_VERSION}: ${ISO_FIRST_BOOT_PACKAGE:-absent}" + +cat >"$STATE_DIR/answer.toml" <"$STATE_DIR/auto-installer-mode.toml" + +cat >"$STATE_DIR/proxmox-first-boot" <<'EOF' +#!/bin/sh +set +e +RDTE_FIRST_BOOT_LOG="/var/lib/proxmox-first-boot/rdte-first-boot.log" +exec >"$RDTE_FIRST_BOOT_LOG" 2>&1 +echo "PVE_RDTE_WITNESS_BEGIN" +if [ -c /dev/ttyS0 ]; then + echo "TTY_S0=character-device" +else + echo "TTY_S0=absent-or-not-character-device" +fi +printf 'ORDERING_ARG=%s\n' "${1:-unset}" +printf 'HOSTNAME='; hostname -f 2>/dev/null || hostname 2>/dev/null || true +printf 'KERNEL='; uname -a 2>/dev/null || true +echo "HOSTS_BEGIN" +cat /etc/hosts 2>&1 || true +echo "HOSTS_END" +echo "IP_ADDR_BEGIN" +ip -brief address 2>&1 || true +echo "IP_ADDR_END" +echo "IP_ROUTE_BEGIN" +ip route 2>&1 || true +echo "IP_ROUTE_END" +echo "NETWORK_INTERFACES_BEGIN" +cat /etc/network/interfaces 2>&1 || true +echo "NETWORK_INTERFACES_END" +echo "PVE_INITIAL_SERVICES_BEGIN" +for service in pve-cluster pvedaemon pvestatd pveproxy ssh networking systemd-networkd; do + printf '%s=' "$service" + systemctl is-active "$service" 2>/dev/null || true +done +echo "PVE_INITIAL_SERVICES_END" + +# network-online intentionally runs before the product proxy dependency used by +# the upstream "fully-up" first-boot unit. Poll without mutating so a failing +# pveproxy can still leave guest-originated evidence instead of deadlocking the oracle. +for _ in $(seq 1 45); do + if systemctl is-active --quiet pveproxy 2>/dev/null && ss -lnt 2>/dev/null | grep -Eq '[:.]8006[[:space:]]'; then + break + fi + sleep 2 +done + +echo "PVE_FINAL_SERVICES_BEGIN" +for service in pve-cluster pvedaemon pvestatd pveproxy ssh networking systemd-networkd; do + printf '%s=' "$service" + systemctl is-active "$service" 2>/dev/null || true +done +echo "PVE_FINAL_SERVICES_END" +echo "LISTENERS_BEGIN" +ss -lntp 2>&1 || true +echo "LISTENERS_END" +echo "FAILED_UNITS_BEGIN" +systemctl --failed --no-pager 2>&1 || true +echo "FAILED_UNITS_END" +echo "PVEPROXY_JOURNAL_BEGIN" +journalctl -u pveproxy -b --no-pager -n 80 2>&1 || true +echo "PVEPROXY_JOURNAL_END" +echo "PVECLUSTER_JOURNAL_BEGIN" +journalctl -u pve-cluster -b --no-pager -n 80 2>&1 || true +echo "PVECLUSTER_JOURNAL_END" +if [ -e /dev/kvm ]; then echo "KVM_DEVICE=present"; else echo "KVM_DEVICE=absent"; fi +if grep -Eq '(vmx|svm)' /proc/cpuinfo 2>/dev/null; then echo "CPU_VIRT_FLAG=present"; else echo "CPU_VIRT_FLAG=absent"; fi +echo "PVE_RDTE_WITNESS_END" +sync || true +if [ -c /dev/ttyS0 ]; then + cat "$RDTE_FIRST_BOOT_LOG" >/dev/ttyS0 2>&1 || true +fi +exit 0 +EOF +chmod 0755 "$STATE_DIR/proxmox-first-boot" + +xorriso -osirrox on -indev "$ISO" -extract /boot/grub/grub.cfg "$STATE_DIR/grub.cfg" >/dev/null 2>&1 || + fail_evidence HARNESS_FAILURE prepare "could not extract Proxmox GRUB config" +chmod u+w "$STATE_DIR/grub.cfg" +python3 - "$STATE_DIR/grub.cfg" <<'PY' +import pathlib, sys +p=pathlib.Path(sys.argv[1]) +s=p.read_text(encoding="utf-8") +lines=[] +changed=False +for line in s.splitlines(): + if "proxmox-start-auto-installer" in line and line.lstrip().startswith("linux") and "console=ttyS0" not in line: + line += " console=ttyS0,115200" + changed=True + lines.append(line) +if not changed: + raise SystemExit("automated installer kernel line not found") +p.write_text("\n".join(lines)+"\n", encoding="utf-8") +PY + +AUTO_ISO="$STATE_DIR/proxmox-auto.iso" +cp --reflink=auto "$ISO" "$AUTO_ISO" +chmod u+w "$AUTO_ISO" +xorriso -boot_image any keep -dev "$AUTO_ISO" \ + -map "$STATE_DIR/auto-installer-mode.toml" /auto-installer-mode.toml \ + -map "$STATE_DIR/answer.toml" /answer.toml \ + -map "$STATE_DIR/proxmox-first-boot" /proxmox-first-boot \ + -map "$STATE_DIR/grub.cfg" /boot/grub/grub.cfg \ + -commit >/dev/null 2>"$STATE_DIR/xorriso.log" || + fail_evidence HARNESS_FAILURE prepare "failed to construct unattended Proxmox ISO" + +# Prove that the same exact file name consumed by the upstream auto-installer +# (/cdrom/proxmox-first-boot) exists in the prepared ISO before spending a VM rep. +xorriso -osirrox on -indev "$AUTO_ISO" \ + -extract /proxmox-first-boot "$STATE_DIR/proxmox-first-boot.iso" >/dev/null 2>&1 || + fail_evidence HARNESS_FAILURE prepare "prepared ISO does not contain /proxmox-first-boot" +[[ "$(sha256sum "$STATE_DIR/proxmox-first-boot" | awk '{print $1}')" == \ + "$(sha256sum "$STATE_DIR/proxmox-first-boot.iso" | awk '{print $1}')" ]] || + fail_evidence HARNESS_FAILURE prepare "prepared ISO first-boot hook content mismatch" + +qemu-img create -q -f qcow2 "$STATE_DIR/system.qcow2" "$DISK_SIZE" +SSH_PORT="$(python3 - <<'PY' +import socket +s=socket.socket(); s.bind(("127.0.0.1",0)); print(s.getsockname()[1]); s.close() +PY +)" +WEB_PORT="$(python3 - <<'PY' +import socket +s=socket.socket(); s.bind(("127.0.0.1",0)); print(s.getsockname()[1]); s.close() +PY +)" + + + +port_open() { + local port="$1" + python3 - "$port" <<'PY' +import socket, sys +s=socket.socket() +s.settimeout(1.0) +try: + s.connect(("127.0.0.1", int(sys.argv[1]))) +except OSError: + raise SystemExit(1) +finally: + s.close() +PY +} + +guest_local_https_api() { + # Exact installed source contract: /access/ticket is user=world, while + # /version requires an authenticated user; pve-http-server consumes the + # returned ticket from the PVEAuthCookie cookie on the subsequent GET. + local response="" + for _ in 1 2 3; do + response="$( + { + printf '%s\n' "$ROOT_PASSWORD" + cat <<'PY' +import json +import os +import socket +import ssl +import sys +import urllib.parse + +host = "127.0.0.1" +port = 8006 +server_name = "pve-rdte.example.invalid" +password = os.environ["PVE_RDTE_PASSWORD"] +stage = "init" + +def https_request(method, path, headers=None, body=b""): + request_headers = { + "Host": server_name, + "Connection": "close", + } + if headers: + request_headers.update(headers) + if body: + request_headers["Content-Length"] = str(len(body)) + request = [f"{method} {path} HTTP/1.0"] + request.extend(f"{name}: {value}" for name, value in request_headers.items()) + wire = ("\r\n".join(request) + "\r\n\r\n").encode("ascii") + body + + context = ssl.create_default_context() + context.check_hostname = False + context.verify_mode = ssl.CERT_NONE + with socket.create_connection((host, port), timeout=5) as raw: + with context.wrap_socket(raw, server_hostname=server_name) as tls: + tls.settimeout(5) + tls.sendall(wire) + chunks = [] + while True: + block = tls.recv(65536) + if not block: + break + chunks.append(block) + + payload = b"".join(chunks) + header, sep, response_body = payload.partition(b"\r\n\r\n") + if not sep: + raise RuntimeError("HTTP header terminator absent") + status = header.split(b"\r\n", 1)[0].decode("ascii", "replace") + return status, response_body + +try: + stage = "ticket" + ticket_body = urllib.parse.urlencode({ + "username": "root@pam", + "password": password, + }).encode("ascii") + ticket_status, ticket_response = https_request( + "POST", + "/api2/json/access/ticket", + {"Content-Type": "application/x-www-form-urlencoded"}, + ticket_body, + ) + if " 200 " not in f" {ticket_status} ": + print(json.dumps({ + "probe_error": "http_status", + "stage": stage, + "status": ticket_status, + }, sort_keys=True)) + raise SystemExit(0) + + ticket_payload = json.loads(ticket_response.decode("utf-8")) + ticket = (ticket_payload.get("data") or {}).get("ticket") + if not ticket: + print(json.dumps({ + "probe_error": "ticket_absent", + "stage": stage, + }, sort_keys=True)) + raise SystemExit(0) + + stage = "version" + version_status, version_response = https_request( + "GET", + "/api2/json/version", + {"Cookie": f"PVEAuthCookie={ticket}"}, + ) + if " 200 " not in f" {version_status} ": + print(json.dumps({ + "probe_error": "http_status", + "stage": stage, + "status": version_status, + "body_preview": version_response[:1000].decode("utf-8", "replace"), + }, sort_keys=True)) + raise SystemExit(0) + + version_payload = json.loads(version_response.decode("utf-8")) + print(json.dumps(version_payload, separators=(",", ":"), sort_keys=True)) +except SystemExit: + raise +except Exception as exc: + print(json.dumps({ + "probe_error": type(exc).__name__, + "stage": stage, + "message": str(exc), + }, sort_keys=True)) +PY + } | sshpass -p "$ROOT_PASSWORD" ssh -p "$SSH_PORT" \ + -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ConnectTimeout=5 \ + root@127.0.0.1 'IFS= read -r PVE_RDTE_PASSWORD; export PVE_RDTE_PASSWORD; exec python3 -' \ + 2>/dev/null || true + )" + if R_RESPONSE="$response" python3 - <<'PY' +import json, os +try: + payload = json.loads(os.environ["R_RESPONSE"]) + data = payload.get("data") + ok = isinstance(data, dict) and all(data.get(k) for k in ("version", "release", "repoid")) +except Exception: + ok = False +raise SystemExit(0 if ok else 1) +PY + then + printf '%s' "$response" + return 0 + fi + sleep 2 + done + printf '%s' "$response" +} + +guest_diagnostics() { + command -v sshpass >/dev/null 2>&1 || return 0 + sshpass -p "$ROOT_PASSWORD" ssh -p "$SSH_PORT" -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ConnectTimeout=5 root@127.0.0.1 ' + echo "===PVEVERSION==="; + pveversion -v 2>&1 || true; + echo "===SYSTEMD_FAILED==="; + systemctl --failed --no-pager 2>&1 || true; + echo "===PVE_SERVICES==="; + for s in pveproxy pvedaemon pvestatd pve-cluster networking systemd-networkd; do + echo "--- $s ---"; + systemctl status "$s" --no-pager -l 2>&1 | tail -n 40 || true; + done; + echo "===LISTENERS==="; + ss -lntup 2>&1 || true; + echo "===IP_ADDR==="; + ip -brief addr 2>&1 || true; + echo "===IP_ROUTE==="; + ip route 2>&1 || true; + echo "===RESOLV==="; + cat /etc/resolv.conf 2>&1 || true; + echo "===PVE_PROXY_JOURNAL==="; + journalctl -u pveproxy -b --no-pager -n 100 2>&1 || true; + ' 2>&1 | tail -c 24000 +} + + + +P3_STAGE_CLASS="" +P3_STAGE_PHASE="" +P3_STAGE_DETAIL="" + +stage_exact_p3_template() { + local template_host="$STATE_DIR/$P3_TEMPLATE_NAME" + local template_guest="/var/lib/vz/template/cache/$P3_TEMPLATE_NAME" + local observed_sha="" + + P3_STAGE_CLASS="ENVIRONMENT_FAILURE" + P3_STAGE_PHASE="host-prerequisite" + P3_STAGE_DETAIL="sha512sum or scp unavailable on GHA host" + command -v sha512sum >/dev/null 2>&1 && command -v scp >/dev/null 2>&1 || return 1 + + P3_STAGE_PHASE="template-acquire" + P3_STAGE_DETAIL="exact public LXC template download failed" + curl --fail --location --retry 3 --silent --show-error "$P3_TEMPLATE_URL" -o "$template_host" || return 1 + + observed_sha="$(sha512sum "$template_host" | awk '{print $1}')" + if [[ "$observed_sha" != "$P3_TEMPLATE_SHA512" ]]; then + rm -f -- "$template_host" + P3_STAGE_CLASS="ORACLE_FAILURE" + P3_STAGE_PHASE="template-integrity" + P3_STAGE_DETAIL="downloaded template SHA-512 did not match exact admitted PVE catalog" + return 1 + fi + + P3_STAGE_CLASS="ENVIRONMENT_FAILURE" + P3_STAGE_PHASE="template-stage" + P3_STAGE_DETAIL="template destination already existed or guest cache could not be prepared" + if ! sshpass -p "$ROOT_PASSWORD" ssh -p "$SSH_PORT" -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ConnectTimeout=5 \ + root@127.0.0.1 "test ! -e '$template_guest' && install -d -m 0755 /var/lib/vz/template/cache" >/dev/null 2>&1; then + rm -f -- "$template_host" + return 1 + fi + P3_STAGE_DETAIL="exact template could not be copied into PVE" + if ! sshpass -p "$ROOT_PASSWORD" scp -q -P "$SSH_PORT" -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \ + "$template_host" "root@127.0.0.1:$template_guest"; then + rm -f -- "$template_host" + return 1 + fi + rm -f -- "$template_host" + return 0 +} + +stage_failure_json() { + R_CLASS="$P3_STAGE_CLASS" R_PHASE="$P3_STAGE_PHASE" R_DETAIL="$P3_STAGE_DETAIL" python3 - <<'PY' +import json, os +print(json.dumps({ + "classification": os.environ["R_CLASS"], + "oracleSatisfied": False, + "phase": os.environ["R_PHASE"], + "detail": os.environ["R_DETAIL"], +}, separators=(",", ":"))) +PY +} + +probe_rest_lxc_c0() { + local template_guest="/var/lib/vz/template/cache/$P3_TEMPLATE_NAME" + local password_file="$STATE_DIR/pve-rest-c0-password" + local probe_out="$STATE_DIR/proxmox-rest-c0.json" + local template_absent="false" + + if ! stage_exact_p3_template; then + R_STAGE="$(stage_failure_json)" python3 - <<'PY' +import json, os +p=json.loads(os.environ["R_STAGE"]) +p.update({"schema":"proxmox-rest-compute-c0-v1","kind":"container","api_materialization_oracle":False,"cleanup":{"attempted":False,"absent":False,"fixture_input_absent":False}}) +print(json.dumps(p,separators=(",",":"))) +PY + return 0 + fi + + printf '%s\n' "$ROOT_PASSWORD" >"$password_file" + chmod 0400 "$password_file" + rm -f -- "$probe_out" + python3 "$SCRIPT_DIR/proxmox_rest_compute_probe.py" \ + --base-url "https://127.0.0.1:$WEB_PORT" \ + --password-file "$password_file" \ + --kind container \ + --vmid "$P3_VMID" \ + --template "local:vztmpl/$P3_TEMPLATE_NAME" \ + --apply --out "$probe_out" >/dev/null 2>&1 || true + rm -f -- "$password_file" + + sshpass -p "$ROOT_PASSWORD" ssh -p "$SSH_PORT" \ + -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ConnectTimeout=5 \ + root@127.0.0.1 "rm -f -- '$template_guest'; test ! -e '$template_guest'" >/dev/null 2>&1 && template_absent="true" + + if [[ ! -s "$probe_out" ]]; then + printf '%s' '{"schema":"proxmox-rest-compute-c0-v1","kind":"container","classification":"HARNESS_FAILURE","oracleSatisfied":false,"api_materialization_oracle":false,"phase":"rest-api-lifecycle","detail":"REST materializer exited without a receipt","cleanup":{"attempted":true,"absent":false,"fixture_input_absent":false}}' + return 0 + fi + + R_PROBE_OUT="$probe_out" R_TEMPLATE_ABSENT="$template_absent" R_TEMPLATE_SHA="$P3_TEMPLATE_SHA512" python3 - <<'PY' +import json, os, pathlib +p=json.loads(pathlib.Path(os.environ["R_PROBE_OUT"]).read_text()) +cleanup=p.setdefault("cleanup",{}) +cleanup["fixture_input_absent"]=os.environ["R_TEMPLATE_ABSENT"]=="true" +p["fixture_input"]={ + "template":"debian-13-standard_13.1-2_amd64.tar.zst", + "catalog_sha512":os.environ["R_TEMPLATE_SHA"], + "transport":"bounded SSH staging only; all container lifecycle mutation uses PVE REST", +} +if not cleanup["fixture_input_absent"]: + p["classification"]="ORACLE_FAILURE" + p["oracleSatisfied"]=False + p["api_materialization_oracle"]=False + p["detail"]=(p.get("detail","") + "; exact staged template remained after fixture cleanup").lstrip("; ") +print(json.dumps(p,separators=(",",":"))) +PY +} + +probe_lxc_lifecycle() { + local response="" + if ! stage_exact_p3_template; then + R_STAGE="$(stage_failure_json)" python3 - <<'PY' +import json, os +p=json.loads(os.environ["R_STAGE"]) +p["contract"]="proxmox-lxc-lifecycle/v1" +print(json.dumps(p,separators=(",",":"))) +PY + return 0 + fi + + response="$(sshpass -p "$ROOT_PASSWORD" ssh -p "$SSH_PORT" -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ConnectTimeout=5 \ + root@127.0.0.1 'bash -s' 2>/dev/null <<'REMOTE' || true +set +e +VMID=9101 +TEMPLATE_NAME="debian-13-standard_13.1-2_amd64.tar.zst" +TEMPLATE="/var/lib/vz/template/cache/$TEMPLATE_NAME" +EXPECTED_SHA512="5aec4ab2ac5c16c7c8ecb87bfeeb10213abe96db6b85e2463585cea492fc861d7c390b3f9c95629bf690b95e9dfe1037207fc69c0912429605f208d5cb2621f8" +EXPECTED_PVE_CONTAINER="6.1.10" +CENSUS="$(mktemp)"; CREATE_LOG="$(mktemp)"; START_LOG="$(mktemp)"; STOP_LOG="$(mktemp)"; DESTROY_LOG="$(mktemp)"; CONFIG_SNAPSHOT="$(mktemp)" +cleanup() { + if pct status "$VMID" >/dev/null 2>&1; then + pct stop "$VMID" >/dev/null 2>&1 || true + pct destroy "$VMID" --purge 1 >/dev/null 2>&1 || true + fi + rm -f -- "$TEMPLATE" +} +trap 'cleanup >/dev/null 2>&1' EXIT + +OBS_SHA="$(sha512sum "$TEMPLATE" 2>/dev/null | awk '{print $1}')" +PKG="$(dpkg-query -W -f='${Version}' pve-container 2>/dev/null || true)" +STORAGE_STATUS="$(pvesm status --storage local-lvm 2>&1 || true)" +STORAGE_ACTIVE=false +printf '%s\n' "$STORAGE_STATUS" | awk 'NR>1 && $1=="local-lvm" && $3=="active"{ok=1} END{exit !ok}' && STORAGE_ACTIVE=true +PRECONDITION="" +[[ "$OBS_SHA" == "$EXPECTED_SHA512" ]] || PRECONDITION="template-sha512-mismatch" +[[ "$PKG" == "$EXPECTED_PVE_CONTAINER" ]] || [[ -n "$PRECONDITION" ]] || PRECONDITION="pve-container-version-mismatch" +[[ "$STORAGE_ACTIVE" == true ]] || [[ -n "$PRECONDITION" ]] || PRECONDITION="local-lvm-inactive" +if pct status "$VMID" >/dev/null 2>&1 || [[ -e "/etc/pve/lxc/$VMID.conf" ]]; then [[ -n "$PRECONDITION" ]] || PRECONDITION="vmid-preexisting"; fi + +CREATE_RC=125; START_RC=125; EXEC_RC=125; STOP_RC=125; DESTROY_RC=125; RUNNING=false; CLEANUP_OK=false +if [[ -z "$PRECONDITION" ]]; then + pct create "$VMID" "local:vztmpl/$TEMPLATE_NAME" --rootfs local-lvm:2 --memory 256 --cores 1 --unprivileged 1 \ + --hostname rdte-p3 --net0 "name=eth0,bridge=vmbr0,ip=10.0.2.16/24,type=veth" >"$CREATE_LOG" 2>&1 + CREATE_RC=$? + if [[ "$CREATE_RC" -eq 0 ]]; then + pct config "$VMID" >"$CONFIG_SNAPSHOT" 2>&1 || true + pct start "$VMID" >"$START_LOG" 2>&1; START_RC=$? + if [[ "$START_RC" -eq 0 ]]; then + for _ in $(seq 1 20); do if pct status "$VMID" 2>/dev/null | grep -Fq 'status: running'; then RUNNING=true; break; fi; sleep 1; done + if [[ "$RUNNING" == true ]]; then + pct exec "$VMID" -- sh -c ' + echo P3_CENSUS_BEGIN + printf "HOSTNAME="; hostname 2>&1 || true + echo UNAME_BEGIN; uname -a 2>&1 || true; echo UNAME_END + echo CPU_BEGIN; grep -E "^(processor|vendor_id|model name|flags|Features)" /proc/cpuinfo 2>&1 | head -n 40 || true; echo CPU_END + echo MEM_BEGIN; head -n 30 /proc/meminfo 2>&1 || true; echo MEM_END + echo CGROUP_BEGIN; cat /proc/self/cgroup 2>&1 || true; echo CGROUP_END + echo MOUNTS_BEGIN; head -n 80 /proc/mounts 2>&1 || true; echo MOUNTS_END + echo NET_BEGIN; ip -brief address 2>&1 || true; echo NET_END + echo ROUTE_BEGIN; ip route 2>&1 || true; echo ROUTE_END + echo DNS_BEGIN; cat /etc/resolv.conf 2>&1 || true; echo DNS_END + echo DEV_BEGIN; ls -la /dev 2>&1 | head -n 80 || true; echo DEV_END + echo P3_CENSUS_END + ' >"$CENSUS" 2>&1 + EXEC_RC=$? + fi + fi + fi +fi + +if pct status "$VMID" >/dev/null 2>&1; then + if pct status "$VMID" 2>/dev/null | grep -Fq 'status: running'; then pct stop "$VMID" >"$STOP_LOG" 2>&1; STOP_RC=$?; else STOP_RC=0; fi + pct destroy "$VMID" --purge 1 >"$DESTROY_LOG" 2>&1; DESTROY_RC=$? +else + STOP_RC=0; DESTROY_RC=0 +fi +rm -f -- "$TEMPLATE" +CONFIG_ABSENT=true; VOLUME_ABSENT=true; TEMPLATE_ABSENT=true +[[ ! -e "/etc/pve/lxc/$VMID.conf" ]] || CONFIG_ABSENT=false +if pvesm list local-lvm 2>/dev/null | grep -Eq "(vm|subvol)-$VMID-"; then VOLUME_ABSENT=false; fi +[[ ! -e "$TEMPLATE" ]] || TEMPLATE_ABSENT=false +[[ "$CONFIG_ABSENT" == true && "$VOLUME_ABSENT" == true && "$TEMPLATE_ABSENT" == true ]] && CLEANUP_OK=true + +CENSUS_TEXT="$(tail -c 12000 "$CENSUS" 2>/dev/null || true)" +CONFIG_TEXT="$(tail -c 6000 "$CONFIG_SNAPSHOT" 2>/dev/null || true)" +CREATE_TEXT="$(tail -c 4000 "$CREATE_LOG" 2>/dev/null || true)"; START_TEXT="$(tail -c 4000 "$START_LOG" 2>/dev/null || true)" +STOP_TEXT="$(tail -c 4000 "$STOP_LOG" 2>/dev/null || true)"; DESTROY_TEXT="$(tail -c 4000 "$DESTROY_LOG" 2>/dev/null || true)" +R_PRE="$PRECONDITION" R_SHA="$OBS_SHA" R_PKG="$PKG" R_STORAGE="$STORAGE_STATUS" R_CREATE="$CREATE_RC" R_START="$START_RC" R_EXEC="$EXEC_RC" \ +R_STOP="$STOP_RC" R_DESTROY="$DESTROY_RC" R_RUNNING="$RUNNING" R_CLEAN="$CLEANUP_OK" R_CFGABS="$CONFIG_ABSENT" R_VOLABS="$VOLUME_ABSENT" R_TPLABS="$TEMPLATE_ABSENT" \ +R_CENSUS="$CENSUS_TEXT" R_CONFIG="$CONFIG_TEXT" R_CREATE_LOG="$CREATE_TEXT" R_START_LOG="$START_TEXT" R_STOP_LOG="$STOP_TEXT" R_DESTROY_LOG="$DESTROY_TEXT" \ +python3 - <<'PY' +import json, os +pre=os.environ.get("R_PRE",""); create=int(os.environ["R_CREATE"]); start=int(os.environ["R_START"]); exe=int(os.environ["R_EXEC"]); stop=int(os.environ["R_STOP"]); destroy=int(os.environ["R_DESTROY"]) +running=os.environ["R_RUNNING"]=="true"; cleanup=os.environ["R_CLEAN"]=="true" +ok=(not pre and create==0 and start==0 and running and exe==0 and stop==0 and destroy==0 and cleanup) +classification="SUPPORTED" if ok else ("ENVIRONMENT_FAILURE" if pre else "ORACLE_FAILURE") +detail="run-owned unprivileged LXC completed create/start/exec/stop/destroy with zero experiment residue" if ok else (pre or "LXC lifecycle did not satisfy the bounded create/start/exec/stop/destroy/cleanup oracle") +print(json.dumps({ + "contract":"proxmox-lxc-lifecycle/v1","classification":classification,"oracleSatisfied":ok,"phase":"lxc-lifecycle","detail":detail,"vmid":9101, + "template":{"name":"debian-13-standard_13.1-2_amd64.tar.zst","expected_sha512":"5aec4ab2ac5c16c7c8ecb87bfeeb10213abe96db6b85e2463585cea492fc861d7c390b3f9c95629bf690b95e9dfe1037207fc69c0912429605f208d5cb2621f8","observed_sha512":os.environ.get("R_SHA") or None}, + "pve_container_version":os.environ.get("R_PKG") or None,"source":{"pve_container_commit":"5eb5574ee9158ac40a5230de2cf18d7d6345709f"},"storage_status":os.environ.get("R_STORAGE") or None, + "steps":{"create":create,"start":start,"running":running,"exec":exe,"stop":stop,"destroy":destroy}, + "cleanup":{"config_absent":os.environ["R_CFGABS"]=="true","root_volume_absent":os.environ["R_VOLABS"]=="true","template_absent":os.environ["R_TPLABS"]=="true","zero_residue":cleanup}, + "container_config":os.environ.get("R_CONFIG") or None,"census":os.environ.get("R_CENSUS") or None, + "logs":{"create":os.environ.get("R_CREATE_LOG") or None,"start":os.environ.get("R_START_LOG") or None,"stop":os.environ.get("R_STOP_LOG") or None,"destroy":os.environ.get("R_DESTROY_LOG") or None}, +},sort_keys=True,separators=(",",":"))) +PY +trap - EXIT +rm -f -- "$CENSUS" "$CREATE_LOG" "$START_LOG" "$STOP_LOG" "$DESTROY_LOG" "$CONFIG_SNAPSHOT" +REMOTE +)" + if [[ -n "$response" ]]; then printf '%s' "$response"; else printf '%s' '{"contract":"proxmox-lxc-lifecycle/v1","classification":"ENVIRONMENT_FAILURE","oracleSatisfied":false,"phase":"transport","detail":"guest SSH did not return an LXC receipt"}'; fi +} + +probe_nested_kvm_vcpu() { + local response="" + command -v sshpass >/dev/null 2>&1 || { + printf '%s' '{"contract":"proxmox-nested-kvm-vcpu/v1","classification":"ENVIRONMENT_FAILURE","oracleSatisfied":false,"phase":"transport","detail":"sshpass unavailable for guest observation"}' + return 0 + } + response="$(sshpass -p "$ROOT_PASSWORD" ssh -p "$SSH_PORT" \ + -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ConnectTimeout=5 \ + root@127.0.0.1 'bash -s' 2>/dev/null <<'REMOTE' || true +set +e +EXPECTED_EXIT=85 +EXPECTED_PACKAGE="11.0.0-3" +TMP_RDTE="$(mktemp -d)" +trap 'rm -rf "$TMP_RDTE"' EXIT +BOOT="$TMP_RDTE/p5-boot.img" +QERR="$TMP_RDTE/qemu.err" +python3 - "$BOOT" <<'PY' +import pathlib, sys +code = bytes.fromhex("fab02abaf400eef4ebfd") +image = code + bytes(510 - len(code)) + b"\x55\xaa" +pathlib.Path(sys.argv[1]).write_bytes(image) +PY +BOOT_SHA="$(sha256sum "$BOOT" 2>/dev/null | awk '{print $1}')" +PKG="$(dpkg-query -W -f='${Version}' pve-qemu-kvm 2>/dev/null || true)" +QVER="$(qemu-system-x86_64 --version 2>/dev/null | head -n 1 || true)" +KVM_DEVICE="absent" +CPU_VIRT_FLAG="absent" +PRECONDITION="" +[[ -c /dev/kvm ]] && KVM_DEVICE="present" || PRECONDITION="kvm-device-absent" +if grep -Eq '(vmx|svm)' /proc/cpuinfo 2>/dev/null; then CPU_VIRT_FLAG="present"; else [[ -n "$PRECONDITION" ]] || PRECONDITION="cpu-virt-flag-absent"; fi +command -v qemu-system-x86_64 >/dev/null 2>&1 || { [[ -n "$PRECONDITION" ]] || PRECONDITION="nested-qemu-absent"; } +RC=125 +if [[ -z "$PRECONDITION" ]]; then + timeout 15 qemu-system-x86_64 \ + -machine pc \ + -accel kvm \ + -cpu host \ + -m 64 \ + -smp 1 \ + -drive "file=$BOOT,format=raw,if=floppy,readonly=on" \ + -boot order=a,strict=on \ + -device isa-debug-exit,iobase=0xf4,iosize=0x4 \ + -display none \ + -serial none \ + -monitor none \ + -no-reboot \ + >"$TMP_RDTE/qemu.out" 2>"$QERR" + RC=$? +fi +ERR="$(tail -c 4000 "$QERR" 2>/dev/null || true)" +R_RC="$RC" R_EXPECTED_EXIT="$EXPECTED_EXIT" R_EXPECTED_PACKAGE="$EXPECTED_PACKAGE" \ +R_PKG="$PKG" R_QVER="$QVER" R_BOOT_SHA="$BOOT_SHA" R_ERR="$ERR" \ +R_KVM_DEVICE="$KVM_DEVICE" R_CPU_VIRT_FLAG="$CPU_VIRT_FLAG" R_PRECONDITION="$PRECONDITION" \ +python3 - <<'PY' +import json, os +rc = int(os.environ["R_RC"]) +expected_exit = int(os.environ["R_EXPECTED_EXIT"]) +pkg = os.environ.get("R_PKG", "") +expected_pkg = os.environ["R_EXPECTED_PACKAGE"] +precondition = os.environ.get("R_PRECONDITION", "") +ok = (not precondition and rc == expected_exit and pkg == expected_pkg and os.environ.get("R_KVM_DEVICE") == "present" and os.environ.get("R_CPU_VIRT_FLAG") == "present") +if ok: + classification = "SUPPORTED" + detail = "nested QEMU used KVM and the run-owned guest executed the expected isa-debug-exit instruction" +elif precondition or pkg != expected_pkg: + classification = "ENVIRONMENT_FAILURE" + detail = precondition or f"unexpected pve-qemu-kvm package {pkg!r}" +else: + classification = "ORACLE_FAILURE" + detail = f"nested QEMU exit code {rc} did not match expected guest debug-exit code {expected_exit}" +print(json.dumps({ + "contract": "proxmox-nested-kvm-vcpu/v1", + "classification": classification, + "oracleSatisfied": ok, + "phase": "nested-vcpu", + "detail": detail, + "expected_debug_value": 42, + "expected_exit_code": expected_exit, + "observed_exit_code": rc, + "pve_qemu_kvm_version": pkg or None, + "qemu_version": os.environ.get("R_QVER") or None, + "boot_image_sha256": os.environ.get("R_BOOT_SHA") or None, + "kvm_device": os.environ.get("R_KVM_DEVICE"), + "cpu_virt_flag": os.environ.get("R_CPU_VIRT_FLAG"), + "stderr_tail": os.environ.get("R_ERR") or None, + "source": { + "pve_qemu_commit": "684796e835289dab11af8606fbf7358b93526dd6", + "qemu_submodule_commit": "98b060da3a4f92b2a994ead5b16a87e783baf77c", + "debugexit_source": "hw/misc/debugexit.c", + "exit_code_rule": "(value << 1) | 1", + }, +}, sort_keys=True, separators=(",", ":"))) +PY +REMOTE +)" + if [[ -n "$response" ]]; then printf '%s' "$response"; else printf '%s' '{"contract":"proxmox-nested-kvm-vcpu/v1","classification":"ENVIRONMENT_FAILURE","oracleSatisfied":false,"phase":"transport","detail":"guest SSH did not return a nested-KVM receipt"}'; fi +} + +start_qemu() { + local with_iso="$1" + : >"$STATE_DIR/serial.log" + local args=( + -enable-kvm -cpu host -smp "$VCPUS" -m "$RAM_MIB" + -device virtio-scsi-pci,id=scsi0 + -drive "file=$STATE_DIR/system.qcow2,if=none,format=qcow2,id=system" + -device scsi-hd,drive=system + -netdev "user,id=net0,hostfwd=tcp:127.0.0.1:$SSH_PORT-:22,hostfwd=tcp:127.0.0.1:$WEB_PORT-:8006" + -device e1000,netdev=net0 + -display none -monitor none + -serial "file:$STATE_DIR/serial.log" + -daemonize -pidfile "$STATE_DIR/qemu.pid" + ) + if [[ "$with_iso" == yes ]]; then args+=( -cdrom "$AUTO_ISO" -boot order=d ); else args+=( -boot order=c ); fi + sudo -n qemu-system-x86_64 "${args[@]}" + QEMU_PID="$(sudo -n cat "$STATE_DIR/qemu.pid")" +} +stop_qemu() { + if [[ -n "$QEMU_PID" ]]; then + sudo -n kill "$QEMU_PID" >/dev/null 2>&1 || true + for _ in $(seq 1 30); do sudo -n kill -0 "$QEMU_PID" >/dev/null 2>&1 || break; sleep 0.25; done + sudo -n kill -9 "$QEMU_PID" >/dev/null 2>&1 || true + QEMU_PID="" + fi +} + +inspect_installed_disk() { + local phase="$1" + local raw="$STATE_DIR/system-${phase}.raw" + local loopdev="" pv="" vg="" rootdev="" mnt="$STATE_DIR/mnt-$phase" + local hook_present=false hook_exec=false hook_sha="" hook_matches=false pending=false + local unit_present=false alias_target="" wants_target="" log_present=false log_text="" package_version="" + + cleanup_inspection() { + set +e + if mountpoint -q "$mnt" 2>/dev/null; then sudo -n umount "$mnt" >/dev/null 2>&1 || true; fi + if [[ -n "$rootdev" ]]; then sudo -n lvchange -an "$rootdev" >/dev/null 2>&1 || true; fi + if [[ -n "$loopdev" ]]; then sudo -n losetup -d "$loopdev" >/dev/null 2>&1 || true; fi + rm -f -- "$raw" + set -e + } + + emit_inspection_error() { + local layout="" blkids="" image_info="" + image_info="$(qemu-img info --output=json "$STATE_DIR/system.qcow2" 2>&1 || true)" + if [[ -n "$loopdev" && -b "$loopdev" ]]; then + layout="$(sudo -n lsblk -lnpo NAME,TYPE,FSTYPE,PTTYPE,PARTTYPE,PARTLABEL,SIZE "$loopdev" 2>&1 || true)" + blkids="$(sudo -n blkid 2>&1 | grep -F "$loopdev" || true)" + fi + R_PHASE="$phase" R_ERROR="$1" R_LAYOUT="$layout" R_BLKIDS="$blkids" R_IMAGE_INFO="$image_info" python3 - <<'PY' +import json, os +print(json.dumps({ + "phase": os.environ["R_PHASE"], + "inspection_ok": False, + "error": os.environ["R_ERROR"], + "qemu_img_info": os.environ.get("R_IMAGE_INFO") or None, + "lsblk": os.environ.get("R_LAYOUT") or None, + "blkid": os.environ.get("R_BLKIDS") or None, +}, sort_keys=True)) +PY + cleanup_inspection + } + + # Keep the product disk immutable. Convert qcow2 to a sparse raw read-only + # inspection copy, then let the kernel expose its partition table via loop. + # This avoids making Proxmox evidence depend on the qemu-nbd daemon lifecycle. + qemu-img convert -f qcow2 -O raw -S 4k "$STATE_DIR/system.qcow2" "$raw" >/dev/null 2>&1 || + { emit_inspection_error "qcow2 sparse-raw conversion failed"; return 0; } + loopdev="$(sudo -n losetup --find --show --read-only --partscan "$raw" 2>/dev/null || true)" + [[ -n "$loopdev" ]] || { emit_inspection_error "read-only loop attach failed"; return 0; } + sudo -n udevadm settle >/dev/null 2>&1 || true + sleep 1 + + while read -r dev type; do + [[ "$type" == "part" ]] || continue + if [[ "$(sudo -n blkid -p -s TYPE -o value "$dev" 2>/dev/null || true)" == "LVM2_member" ]]; then + pv="$dev" + break + fi + done < <(sudo -n lsblk -lnpo NAME,TYPE "$loopdev" 2>/dev/null) + + [[ -n "$pv" ]] || { emit_inspection_error "installed LVM PV not found"; return 0; } + + sudo -n pvscan --cache "$pv" >/dev/null 2>&1 || true + vg="$(sudo -n pvs --noheadings -o vg_name "$pv" 2>/dev/null | xargs || true)" + [[ -n "$vg" ]] || { emit_inspection_error "installed VG not found"; return 0; } + + rootdev="$(sudo -n lvs --noheadings -o lv_path,lv_name "$vg" 2>/dev/null | awk '$2=="root" {print $1; exit}')" + if [[ -z "$rootdev" ]] || ! sudo -n lvchange -ay "$rootdev" >/dev/null 2>&1; then + emit_inspection_error "installed root LV not activatable" + return 0 + fi + + mkdir -p "$mnt" + if ! sudo -n mount -o ro,noload "$rootdev" "$mnt" >/dev/null 2>&1; then + emit_inspection_error "installed root LV mount failed" + return 0 + fi + + local hook="$mnt/var/lib/proxmox-first-boot/proxmox-first-boot" + local pending_path="$mnt/var/lib/proxmox-first-boot/pending-first-boot-setup" + local unit="$mnt/lib/systemd/system/proxmox-first-boot-network-online.service" + local alias="$mnt/etc/systemd/system/proxmox-first-boot.service" + local wants="$mnt/etc/systemd/system/multi-user.target.wants/proxmox-first-boot-network-online.service" + local log="$mnt/var/lib/proxmox-first-boot/rdte-first-boot.log" + + if sudo -n test -f "$hook"; then + hook_present=true + sudo -n test -x "$hook" && hook_exec=true + hook_sha="$(sudo -n sha256sum "$hook" 2>/dev/null | awk '{print $1}')" + [[ "$hook_sha" == "$(sha256sum "$STATE_DIR/proxmox-first-boot" | awk '{print $1}')" ]] && hook_matches=true + fi + sudo -n test -e "$pending_path" && pending=true + sudo -n test -f "$unit" && unit_present=true + alias_target="$(sudo -n readlink "$alias" 2>/dev/null || true)" + wants_target="$(sudo -n readlink "$wants" 2>/dev/null || true)" + if sudo -n test -f "$log"; then + log_present=true + log_text="$(sudo -n tail -c 16000 "$log" 2>/dev/null || true)" + fi + package_version="$(sudo -n sed -n '/^Package: proxmox-first-boot$/,/^$/p' "$mnt/var/lib/dpkg/status" 2>/dev/null | sed -n 's/^Version: //p' | head -n 1)" + + R_PHASE="$phase" R_ROOT="$rootdev" R_HOOK_PRESENT="$hook_present" R_HOOK_EXEC="$hook_exec" \ + R_HOOK_SHA="$hook_sha" R_HOOK_MATCH="$hook_matches" R_PENDING="$pending" R_UNIT="$unit_present" \ + R_ALIAS="$alias_target" R_WANTS="$wants_target" R_LOG_PRESENT="$log_present" R_LOG="$log_text" \ + R_PACKAGE_VERSION="$package_version" python3 - <<'PY' +import json, os +def b(name): return os.environ.get(name, "false").lower() == "true" +print(json.dumps({ + "phase": os.environ["R_PHASE"], + "inspection_ok": True, + "root_lv": os.environ.get("R_ROOT") or None, + "first_boot_package_version": os.environ.get("R_PACKAGE_VERSION") or None, + "first_boot_hook_present": b("R_HOOK_PRESENT"), + "first_boot_hook_executable": b("R_HOOK_EXEC"), + "first_boot_hook_sha256": os.environ.get("R_HOOK_SHA") or None, + "hook_matches_prepared_iso": b("R_HOOK_MATCH"), + "pending_flag_present": b("R_PENDING"), + "network_online_unit_present": b("R_UNIT"), + "alias_target": os.environ.get("R_ALIAS") or None, + "wanted_by_target": os.environ.get("R_WANTS") or None, + "first_boot_log_present": b("R_LOG_PRESENT"), + "first_boot_log": os.environ.get("R_LOG") or None, +}, sort_keys=True)) +PY + cleanup_inspection +} + +start_qemu yes +INSTALL_OK=false +for _ in $(seq 1 360); do + # Exact pve-installer 9.2.5 source emits this only after run_installation() + # returns Ok. Do not accept the earlier "Rebooting system after successful + # installation" setup log, which is emitted before installation starts. + if grep -Fq 'Installation done.' "$STATE_DIR/serial.log" 2>/dev/null; then + INSTALL_OK=true + INSTALL_SUCCESS_MARKER_OBSERVED="true" + break + fi + if grep -Fq 'Installation failed:' "$STATE_DIR/serial.log" 2>/dev/null; then break; fi + if ! sudo -n kill -0 "$QEMU_PID" >/dev/null 2>&1; then break; fi + sleep 5 +done +[[ "$INSTALL_OK" == true ]] || fail_evidence ORACLE_FAILURE install "exact post-run_installation success witness not observed within bounded window" +stop_qemu +INSTALLED_DISK_PREBOOT="$(inspect_installed_disk preboot)" +if python3 - "$INSTALLED_DISK_PREBOOT" <<'PY' +import json, sys +try: + payload = json.loads(sys.argv[1]) +except Exception: + raise SystemExit(1) +ok = ( + payload.get("inspection_ok") is True + and payload.get("root_lv") == "/dev/pve/root" +) +raise SystemExit(0 if ok else 1) +PY +then + INSTALLED_DISK_LAYOUT_OK="true" +else + fail_evidence ORACLE_FAILURE installed-disk "exact installer success marker observed but installed /dev/pve/root layout was not proven" +fi + +start_qemu no +API_VERSION_JSON="" +HOSTFWD_API_VERSION_JSON="" +GUEST_LOCAL_API_VERSION_JSON="" +API_OBSERVATION_ROUTE="" +SSH_HOSTFWD_ACCEPTED="false" +API_HOSTFWD_ACCEPTED="false" +QEMU_ALIVE_AT_API_GATE="unknown" +for _ in $(seq 1 120); do + if sudo -n kill -0 "$QEMU_PID" >/dev/null 2>&1; then + QEMU_ALIVE_AT_API_GATE="true" + else + QEMU_ALIVE_AT_API_GATE="false" + break + fi + if grep -q 'PVE_RDTE_WITNESS_END' "$STATE_DIR/serial.log" 2>/dev/null; then + FIRST_BOOT_WITNESS_OBSERVED="true" + FIRST_BOOT_WITNESS="$(awk '/PVE_RDTE_WITNESS_BEGIN/{capture=1} capture{print} /PVE_RDTE_WITNESS_END/{exit}' "$STATE_DIR/serial.log" | tr -d '\000' | sed -E 's/[^[:print:]\t]//g' | tail -c 16000)" + fi + if port_open "$SSH_PORT"; then SSH_HOSTFWD_ACCEPTED="true"; fi + if port_open "$WEB_PORT"; then API_HOSTFWD_ACCEPTED="true"; fi + if [[ "$API_HOSTFWD_ACCEPTED" == "true" ]]; then + HOSTFWD_API_VERSION_JSON="$(curl -sk --max-time 5 "https://127.0.0.1:$WEB_PORT/api2/json/version" 2>/dev/null || true)" + if [[ "$HOSTFWD_API_VERSION_JSON" == *'"data"'* ]]; then + API_VERSION_JSON="$HOSTFWD_API_VERSION_JSON" + API_OBSERVATION_ROUTE="qemu-hostfwd-https" + break + fi + fi + if [[ "$FIRST_BOOT_WITNESS_OBSERVED" == "true" && "$SSH_HOSTFWD_ACCEPTED" == "true" ]]; then + if sshpass -p "$ROOT_PASSWORD" ssh -p "$SSH_PORT" -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ConnectTimeout=5 root@127.0.0.1 'true' >/dev/null 2>&1; then + GUEST_LOCAL_API_VERSION_JSON="$(guest_local_https_api || true)" + if [[ "$GUEST_LOCAL_API_VERSION_JSON" == *'"data"'* ]]; then + API_VERSION_JSON="$GUEST_LOCAL_API_VERSION_JSON" + API_OBSERVATION_ROUTE="ssh-observed-guest-local-https" + break + fi + # The guest witness already proved pveproxy active/listening. A bounded + # guest-local TLS failure is now discriminating; do not spend the rest of + # the workflow retrying an observation route. + GUEST_DIAGNOSTICS="$(guest_diagnostics || true)" + break + fi + fi + sleep 3 +done +if [[ "$API_VERSION_JSON" != *'"data"'* ]]; then + if command -v sshpass >/dev/null 2>&1 && sshpass -p "$ROOT_PASSWORD" ssh -p "$SSH_PORT" \ + -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ConnectTimeout=5 \ + root@127.0.0.1 'true' >/dev/null 2>&1; then + GUEST_DIAGNOSTICS="$(guest_diagnostics || true)" + fi + stop_qemu + INSTALLED_DISK_POSTBOOT="$(inspect_installed_disk postboot)" + fail_evidence ORACLE_FAILURE installed-api "installed Proxmox HTTPS API did not answer via direct hostfwd or SSH-observed guest-local HTTPS (qemu_alive=$QEMU_ALIVE_AT_API_GATE first_boot_witness=$FIRST_BOOT_WITNESS_OBSERVED)" +fi + +# Read-only native REST census: capture exact installed PVE/package identity only +# after the installed HTTPS API is known-good. +PVE_PASSWORD_FILE="$STATE_DIR/pve-rest-password" +printf '%s\n' "$ROOT_PASSWORD" >"$PVE_PASSWORD_FILE" +chmod 0400 "$PVE_PASSWORD_FILE" +REST_API_CENSUS_JSON="$( + python3 "$SCRIPT_DIR/proxmox_rest_compute_probe.py" \ + --base-url "https://127.0.0.1:$WEB_PORT" \ + --password-file "$PVE_PASSWORD_FILE" \ + --kind vm --observe-only 2>/dev/null || true +)" +rm -f "$PVE_PASSWORD_FILE" + +if [[ "$COMPUTE_FIXTURE" == "vm-v0" ]]; then + REST_VM_V0_OUT="$STATE_DIR/proxmox-rest-vm-v0.json" + rm -f -- "$REST_VM_V0_OUT" + PVE_PASSWORD_FILE="$STATE_DIR/pve-rest-vm-v0-password" + printf '%s\n' "$ROOT_PASSWORD" >"$PVE_PASSWORD_FILE" + chmod 0400 "$PVE_PASSWORD_FILE" + python3 "$SCRIPT_DIR/proxmox_rest_compute_probe.py" \ + --base-url "https://127.0.0.1:$WEB_PORT" \ + --password-file "$PVE_PASSWORD_FILE" \ + --kind vm --vmid 9201 \ + --expected-qemu-server-version "9.1.15" \ + --apply --out "$REST_VM_V0_OUT" >/dev/null 2>&1 || true + rm -f "$PVE_PASSWORD_FILE" + if [[ -s "$REST_VM_V0_OUT" ]]; then + REST_VM_V0_JSON="$(cat "$REST_VM_V0_OUT")" + else + REST_VM_V0_JSON='{"schema":"proxmox-rest-compute-c0-v1","kind":"vm","classification":"HARNESS_FAILURE","oracleSatisfied":false,"api_materialization_oracle":false,"phase":"rest-api-lifecycle","detail":"REST VM V0 materializer exited without a receipt","cleanup":{"attempted":true,"absent":false}}' + fi + if ! R_REST_VM_V0="$REST_VM_V0_JSON" python3 - <<'PY' +import json, os +try: + p=json.loads(os.environ.get("R_REST_VM_V0","")) + ok=(p.get("kind")=="vm" and p.get("classification")=="SUPPORTED" and + p.get("oracleSatisfied") is True and p.get("api_materialization_oracle") is True and + p.get("cleanup",{}).get("absent") is True) +except Exception: + ok=False +raise SystemExit(0 if ok else 1) +PY + then + fail_evidence ORACLE_FAILURE compute-vm-v0 "native PVE REST VM V0 did not satisfy exact source/create/readback/start/stop/delete/absence cleanup oracle" + fi +fi + +if [[ "$COMPUTE_FIXTURE" == "container-c0" ]]; then + REST_C0_JSON="$(probe_rest_lxc_c0 || true)" + if ! R_REST_C0="$REST_C0_JSON" python3 - <<'PY' +import json, os +try: + p=json.loads(os.environ.get("R_REST_C0","")) + ok=(p.get("classification")=="SUPPORTED" and p.get("oracleSatisfied") is True and + p.get("api_materialization_oracle") is True and + p.get("cleanup",{}).get("absent") is True and + p.get("cleanup",{}).get("fixture_input_absent") is True) +except Exception: + ok=False +raise SystemExit(0 if ok else 1) +PY + then + REST_C0_CLASS="$(R_REST_C0="$REST_C0_JSON" python3 - <<'PY' +import json, os +try: + c=json.loads(os.environ.get("R_REST_C0","")).get("classification","ORACLE_FAILURE") +except Exception: + c="HARNESS_FAILURE" +print(c if c in {"HARNESS_FAILURE","ENVIRONMENT_FAILURE","ORACLE_FAILURE"} else "ORACLE_FAILURE") +PY +)" + fail_evidence "$REST_C0_CLASS" compute-container-c0 "native PVE REST container C0 did not satisfy create/readback/start/stop/delete/absence plus fixture-input cleanup" + fi +fi + +NESTED_KVM="unknown" +NESTED_KVM_INDICATORS="unknown" +if [[ "$COMPUTE_FIXTURE" == "container-c0" || "$COMPUTE_FIXTURE" == "vm-v0" ]]; then + NESTED_KVM_INDICATORS="skipped" + NESTED_KVM_VCPU_JSON='{"contract":"proxmox-nested-kvm-vcpu/v1","classification":"SKIPPED_GUARDRAIL","oracleSatisfied":false,"phase":"not-required-for-bounded-compute-fixture","detail":"nested KVM is outside the bounded native REST container C0 / VM V0 causal rep"}' +else + if command -v sshpass >/dev/null 2>&1; then + if sshpass -p "$ROOT_PASSWORD" ssh -p "$SSH_PORT" \ + -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ConnectTimeout=5 \ + root@127.0.0.1 'test -e /dev/kvm && grep -Eq "(vmx|svm)" /proc/cpuinfo' >/dev/null 2>&1; then + NESTED_KVM_INDICATORS="yes" + fi + fi + NESTED_KVM_VCPU_JSON="$(probe_nested_kvm_vcpu || true)" + if R_NESTED_VCPU="$NESTED_KVM_VCPU_JSON" python3 - <<'PY' +import json, os +try: + payload = json.loads(os.environ.get("R_NESTED_VCPU", "")) + ok = payload.get("oracleSatisfied") is True +except Exception: + ok = False +raise SystemExit(0 if ok else 1) +PY + then + NESTED_KVM="yes" + fi +fi + +if [[ "$COMPUTE_FIXTURE" == "container-c0" ]]; then + P3_LXC_JSON='{"contract":"proxmox-lxc-lifecycle/v1","classification":"SKIPPED_GUARDRAIL","oracleSatisfied":false,"phase":"superseded-by-rest-c0","detail":"CLI P3 lifecycle intentionally skipped because this rep assigns the exact container fixture to native PVE REST"}' +elif [[ "$COMPUTE_FIXTURE" == "vm-v0" ]]; then + P3_LXC_JSON='{"contract":"proxmox-lxc-lifecycle/v1","classification":"SKIPPED_GUARDRAIL","oracleSatisfied":false,"phase":"not-required-for-vm-v0","detail":"CLI P3 LXC lifecycle intentionally skipped because this rep is bounded to native PVE REST VM V0"}' +elif [[ "$NESTED_KVM" == "yes" ]]; then + P3_LXC_JSON="$(probe_lxc_lifecycle || true)" +else + P3_LXC_JSON='{"contract":"proxmox-lxc-lifecycle/v1","classification":"SKIPPED_GUARDRAIL","oracleSatisfied":false,"phase":"prerequisite","detail":"P5 nested-vCPU oracle was not satisfied in this rep"}' +fi + +write_receipt SUPPORTED true complete "real Proxmox VE unattended install completed and real HTTPS /api2/json/version answered via $API_OBSERVATION_ROUTE" diff --git a/scripts/gha_kvm_truenas_rdte.sh b/scripts/gha_kvm_truenas_rdte.sh new file mode 100644 index 000000000..724e1103c --- /dev/null +++ b/scripts/gha_kvm_truenas_rdte.sh @@ -0,0 +1,1332 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" + +TARGET_REGISTRY="$SCRIPT_DIR/../config/truenas-rdte-targets.json" +TARGET_VERSION="26.0.0-BETA.3" +RAM_MIB=8192 +VCPUS=2 +DISK_SIZE="24G" +DATA_DISK_SIZE="8G" +DATA_DISK_COUNT=2 +DATA_POOL_NAME="rdtepool" +DATA_SERIAL_PREFIX="RDTE_DATA_" +NIC_MAC="52:54:00:54:4e:26" +MIN_HOST_MEM_KIB=$((11 * 1024 * 1024)) +MIN_HOST_FREE_KIB=$((28 * 1024 * 1024)) + +usage() { + echo "Usage: gha_kvm_truenas_rdte.sh --out RECEIPT [--state-dir DIR] [--target-version VERSION] [--rung t0|t1|t2|t3|t4|t5|t6] [--compute-fixture none|container-c0|vm-v0|vm-v1] [--t6-product litellm|wow-sidecar|garm|garm-controller-f0f5|garm-provider-g2|garm-provider-g3|garm-provider-g4|garm-provider-g5|garm-provider-container-pre-b4|official-catalog|foliorelay|minecraft-paper|collage-native-paper|minecraft-vanilla-263] [--foundry-control-dir DIR] [--foundry-commit SHA] [--g2-fixture-dir DIR] [--g2-fixture-producer SHA] [--g3-fixture-dir DIR] [--g3-fixture-producer SHA] [--g4-fixture-dir DIR] [--g4-fixture-producer SHA] [--g5-matrix-dir DIR] [--g5-matrix-producer SHA] [--container-fixture-dir DIR] [--container-fixture-producer SHA] [--session-manifest FILE] [--vanilla-collage-readback] [--vanilla-lantern-zip FILE --vanilla-lantern-manifest FILE]" +} + +OUT="" +STATE_DIR="" +RUNG="t0" +COMPUTE_FIXTURE="none" +T6_PRODUCT="litellm" +FOUNDRY_CONTROL_DIR="" +FOUNDRY_COMMIT="" +G2_FIXTURE_DIR="" +G2_FIXTURE_PRODUCER="" +G3_FIXTURE_DIR="" +G3_FIXTURE_PRODUCER="" +G4_FIXTURE_DIR="" +G4_FIXTURE_PRODUCER="" +G5_MATRIX_DIR="" +G5_MATRIX_PRODUCER="" +CONTAINER_FIXTURE_DIR="" +CONTAINER_FIXTURE_PRODUCER="" +SESSION_MANIFEST="" +VANILLA_COLLAGE_READBACK=false +VANILLA_LANTERN_ZIP="" +VANILLA_LANTERN_MANIFEST="" +VANILLA_WORLD_ROLLBACK=false +VANILLA_NATIVE_PLAYER=false +VANILLA_BACKEND_REGION=false +VANILLA_PLAYER_NODE_PATH="" +while [[ $# -gt 0 ]]; do + case "$1" in + --out) OUT="$2"; shift 2 ;; + --state-dir) STATE_DIR="$2"; shift 2 ;; + --target-version) TARGET_VERSION="$2"; shift 2 ;; + --rung) RUNG="$2"; shift 2 ;; + --compute-fixture) COMPUTE_FIXTURE="$2"; shift 2 ;; + --t6-product) T6_PRODUCT="$2"; shift 2 ;; + --foundry-control-dir) FOUNDRY_CONTROL_DIR="$2"; shift 2 ;; + --foundry-commit) FOUNDRY_COMMIT="$2"; shift 2 ;; + --g2-fixture-dir) G2_FIXTURE_DIR="$2"; shift 2 ;; + --g2-fixture-producer) G2_FIXTURE_PRODUCER="$2"; shift 2 ;; + --g3-fixture-dir) G3_FIXTURE_DIR="$2"; shift 2 ;; + --g3-fixture-producer) G3_FIXTURE_PRODUCER="$2"; shift 2 ;; + --g4-fixture-dir) G4_FIXTURE_DIR="$2"; shift 2 ;; + --g4-fixture-producer) G4_FIXTURE_PRODUCER="$2"; shift 2 ;; + --g5-matrix-dir) G5_MATRIX_DIR="$2"; shift 2 ;; + --g5-matrix-producer) G5_MATRIX_PRODUCER="$2"; shift 2 ;; + --container-fixture-dir) CONTAINER_FIXTURE_DIR="$2"; shift 2 ;; + --container-fixture-producer) CONTAINER_FIXTURE_PRODUCER="$2"; shift 2 ;; + --session-manifest) SESSION_MANIFEST="$2"; shift 2 ;; + --vanilla-collage-readback) VANILLA_COLLAGE_READBACK=true; shift ;; + --vanilla-lantern-zip) VANILLA_LANTERN_ZIP="$2"; shift 2 ;; + --vanilla-lantern-manifest) VANILLA_LANTERN_MANIFEST="$2"; shift 2 ;; + --vanilla-world-rollback) VANILLA_WORLD_ROLLBACK=true; shift ;; + --vanilla-native-player) VANILLA_NATIVE_PLAYER=true; shift ;; + --vanilla-backend-region) VANILLA_BACKEND_REGION=true; shift ;; + --vanilla-player-node-path) VANILLA_PLAYER_NODE_PATH="$2"; shift 2 ;; + -h|--help) usage; exit 0 ;; + *) echo "unknown argument: $1" >&2; usage >&2; exit 2 ;; + esac +done +[[ -n "$OUT" ]] || { usage >&2; exit 2; } +[[ -f "$TARGET_REGISTRY" ]] || { echo "missing exact TrueNAS target registry: $TARGET_REGISTRY" >&2; exit 2; } +TARGET_ENV="$(python3 "$SCRIPT_DIR/truenas_rdte_target.py" --registry "$TARGET_REGISTRY" --version "$TARGET_VERSION" --shell)" || exit 2 +# truenas_rdte_target.py emits only shell-quoted values after strict registry validation. +eval "$TARGET_ENV" +[[ "$RUNG" == "t0" || "$RUNG" == "t1" || "$RUNG" == "t2" || "$RUNG" == "t3" || "$RUNG" == "t4" || "$RUNG" == "t5" || "$RUNG" == "t6" ]] || { echo "rung must be t0, t1, t2, t3, t4, t5, or t6" >&2; exit 2; } +[[ "$COMPUTE_FIXTURE" == "none" || "$COMPUTE_FIXTURE" == "container-c0" || "$COMPUTE_FIXTURE" == "vm-v0" || "$COMPUTE_FIXTURE" == "vm-v1" ]] || { echo "unsupported compute fixture: $COMPUTE_FIXTURE" >&2; exit 2; } +if [[ "$COMPUTE_FIXTURE" != "none" && "$RUNG" != "t3" ]]; then + echo "compute fixture $COMPUTE_FIXTURE requires rung t3" >&2 + exit 2 +fi +if [[ -n "$VANILLA_LANTERN_ZIP" || -n "$VANILLA_LANTERN_MANIFEST" ]]; then + [[ -f "$VANILLA_LANTERN_ZIP" && -f "$VANILLA_LANTERN_MANIFEST" && + "$VANILLA_COLLAGE_READBACK" == "true" && + "$T6_PRODUCT" == "minecraft-vanilla-263" && "$VERSION" == "25.04.2.6" ]] || + { echo "native Lantern ZIP stage requires exact disposable vanilla263 and scoped COLLAGE readback" >&2; exit 2; } +fi +if [[ "$VANILLA_NATIVE_PLAYER" == "true" ]]; then + [[ "$VANILLA_WORLD_ROLLBACK" == "true" && -d "$VANILLA_PLAYER_NODE_PATH" && + "$T6_PRODUCT" == "minecraft-vanilla-263" && "$VERSION" == "25.04.2.6" ]] || + { echo "G4 synthetic client requires exact disposable native G3 and pinned Node dependencies" >&2; exit 2; } +fi +if [[ "$VANILLA_BACKEND_REGION" == "true" && "$VANILLA_NATIVE_PLAYER" != "true" ]]; then + echo "G4 region requires native offline player and separate G2/G3" >&2; exit 2 +fi +if [[ "$VANILLA_NATIVE_PLAYER" != "true" && -n "$VANILLA_PLAYER_NODE_PATH" ]]; then + echo "G4 client dependencies without explicit disposable admission" >&2; exit 2 +fi +if [[ "$VANILLA_WORLD_ROLLBACK" == "true" ]]; then + [[ "$VANILLA_COLLAGE_READBACK" == "true" && "$RUNG" == "t6" && + "$T6_PRODUCT" == "minecraft-vanilla-263" && "$VERSION" == "25.04.2.6" && + -f "$VANILLA_LANTERN_ZIP" && -f "$VANILLA_LANTERN_MANIFEST" ]] || + { echo "native world rollback requires disposable vanilla263 G2 and exact Lantern ZIP" >&2; exit 2; } +fi +if [[ "$VANILLA_COLLAGE_READBACK" == "true" ]]; then + [[ "$RUNG" == "t6" && "$T6_PRODUCT" == "minecraft-vanilla-263" && "$VERSION" == "25.04.2.6" ]] || + { echo "vanilla scoped readback only admitted on exact 25.04.2.6/T6/vanilla263" >&2; exit 2; } +fi +if [[ "$RUNG" == "t6" ]]; then + if [[ "$T6_PRODUCT" != "official-catalog" && "$T6_PRODUCT" != "garm-provider-g5" && "$T6_PRODUCT" != "foliorelay" && "$T6_PRODUCT" != "minecraft-paper" && "$T6_PRODUCT" != "garm-controller-f0f5" && "$T6_PRODUCT" != "minecraft-vanilla-263" ]]; then + if [[ "$T6_PRODUCT" == "collage-native-paper" ]]; then + [[ "$VERSION" == "25.04.1" ]] || { echo "COLLAGE P2 admitted only on isolated TrueNAS 25.04.1" >&2; exit 2; } + else + [[ "$VERSION" == "26.0.0-BETA.3" ]] || { echo "product-specific T6 controls remain admitted only for exact TrueNAS 26.0.0-BETA.3" >&2; exit 2; } + fi + fi + [[ "$T6_PRODUCT" == "litellm" || "$T6_PRODUCT" == "wow-sidecar" || "$T6_PRODUCT" == "garm" || "$T6_PRODUCT" == "garm-controller-f0f5" || "$T6_PRODUCT" == "garm-provider-g2" || "$T6_PRODUCT" == "garm-provider-g3" || "$T6_PRODUCT" == "garm-provider-g4" || "$T6_PRODUCT" == "garm-provider-g5" || "$T6_PRODUCT" == "garm-provider-container-pre-b4" || "$T6_PRODUCT" == "official-catalog" || "$T6_PRODUCT" == "foliorelay" || "$T6_PRODUCT" == "minecraft-paper" || "$T6_PRODUCT" == "collage-native-paper" || "$T6_PRODUCT" == "minecraft-vanilla-263" ]] || { echo "unsupported T6 product: $T6_PRODUCT" >&2; exit 2; } + if [[ "$T6_PRODUCT" == "minecraft-vanilla-263" ]]; then + [[ "$VERSION" == "25.04.2.6" ]] || { echo "vanilla26.3 native product requires exact TrueNAS25.04.2.6" >&2; exit 2; } + fi + if [[ "$T6_PRODUCT" == "garm-provider-g2" ]]; then + [[ -n "$G2_FIXTURE_DIR" && -d "$G2_FIXTURE_DIR" ]] || { echo "garm-provider-g2 requires --g2-fixture-dir" >&2; exit 2; } + [[ "$G2_FIXTURE_PRODUCER" =~ ^[0-9a-f]{40}$ ]] || { echo "garm-provider-g2 requires exact --g2-fixture-producer SHA" >&2; exit 2; } + G2_FIXTURE_DIR="$(realpath "$G2_FIXTURE_DIR")" + elif [[ "$T6_PRODUCT" == "garm-provider-g3" ]]; then + [[ -n "$G3_FIXTURE_DIR" && -d "$G3_FIXTURE_DIR" ]] || { echo "garm-provider-g3 requires --g3-fixture-dir" >&2; exit 2; } + [[ "$G3_FIXTURE_PRODUCER" =~ ^[0-9a-f]{40}$ ]] || { echo "garm-provider-g3 requires exact --g3-fixture-producer SHA" >&2; exit 2; } + G3_FIXTURE_DIR="$(realpath "$G3_FIXTURE_DIR")" + elif [[ "$T6_PRODUCT" == "garm-provider-g4" ]]; then + [[ -n "$G4_FIXTURE_DIR" && -d "$G4_FIXTURE_DIR" ]] || { echo "garm-provider-g4 requires --g4-fixture-dir" >&2; exit 2; } + [[ "$G4_FIXTURE_PRODUCER" =~ ^[0-9a-f]{40}$ ]] || { echo "garm-provider-g4 requires exact --g4-fixture-producer SHA" >&2; exit 2; } + G4_FIXTURE_DIR="$(realpath "$G4_FIXTURE_DIR")" + elif [[ "$T6_PRODUCT" == "garm-provider-container-pre-b4" ]]; then + [[ -n "$CONTAINER_FIXTURE_DIR" && -d "$CONTAINER_FIXTURE_DIR" ]] || { echo "garm-provider-container-pre-b4 requires --container-fixture-dir" >&2; exit 2; } + [[ "$CONTAINER_FIXTURE_PRODUCER" =~ ^[0-9a-f]{40}$ ]] || { echo "garm-provider-container-pre-b4 requires exact --container-fixture-producer SHA" >&2; exit 2; } + CONTAINER_FIXTURE_DIR="$(realpath "$CONTAINER_FIXTURE_DIR")" + elif [[ "$T6_PRODUCT" == "garm-provider-g5" ]]; then + [[ -n "$G5_MATRIX_DIR" && -d "$G5_MATRIX_DIR" ]] || { echo "garm-provider-g5 requires --g5-matrix-dir" >&2; exit 2; } + [[ "$G5_MATRIX_PRODUCER" =~ ^[0-9a-f]{40}$ ]] || { echo "garm-provider-g5 requires exact --g5-matrix-producer SHA" >&2; exit 2; } + [[ -n "$G3_FIXTURE_DIR" && -d "$G3_FIXTURE_DIR" ]] || { echo "garm-provider-g5 requires --g3-fixture-dir" >&2; exit 2; } + [[ "$G3_FIXTURE_PRODUCER" =~ ^[0-9a-f]{40}$ ]] || { echo "garm-provider-g5 requires exact --g3-fixture-producer SHA" >&2; exit 2; } + G5_MATRIX_DIR="$(realpath "$G5_MATRIX_DIR")" + G3_FIXTURE_DIR="$(realpath "$G3_FIXTURE_DIR")" + else + [[ -n "$FOUNDRY_CONTROL_DIR" && -d "$FOUNDRY_CONTROL_DIR" ]] || { echo "t6 requires --foundry-control-dir" >&2; exit 2; } + [[ "$FOUNDRY_COMMIT" =~ ^[0-9a-f]{40}$ ]] || { echo "t6 requires exact --foundry-commit SHA" >&2; exit 2; } + FOUNDRY_CONTROL_DIR="$(realpath "$FOUNDRY_CONTROL_DIR")" + fi +fi + +if [[ "$RUNG" == "t6" && ( "$T6_PRODUCT" == "garm-provider-g3" || "$T6_PRODUCT" == "garm-provider-g4" || "$T6_PRODUCT" == "garm-provider-g5" ) ]]; then + VCPUS=4 +fi + +if [[ -n "$SESSION_MANIFEST" ]]; then + [[ "$RUNG" == "t6" ]] || { echo "--session-manifest requires rung t6" >&2; exit 2; } + [[ "$T6_PRODUCT" != garm-provider-* ]] || { echo "session equivalence currently wraps Foundry/control providers only" >&2; exit 2; } + [[ -f "$SESSION_MANIFEST" ]] || { echo "session manifest does not exist: $SESSION_MANIFEST" >&2; exit 2; } + SESSION_MANIFEST="$(realpath "$SESSION_MANIFEST")" + SESSION_LOWERED="$(python3 "$SCRIPT_DIR/truenas_single_capsule_adapter.py" --manifest "$SESSION_MANIFEST" --targets "$TARGET_REGISTRY" --providers "$SCRIPT_DIR/../config/truenas-capsule-providers.json")" || exit 2 + SESSION_SELECTOR="$(python3 -c 'import json,sys; print(json.load(sys.stdin)["existing_selector"])' <<<"$SESSION_LOWERED")" + SESSION_VERSION="$(python3 -c 'import json,sys; print(json.load(sys.stdin)["version"])' <<<"$SESSION_LOWERED")" + [[ "$SESSION_SELECTOR" == "$T6_PRODUCT" ]] || { echo "session capsule selector $SESSION_SELECTOR does not match --t6-product $T6_PRODUCT" >&2; exit 2; } + [[ "$SESSION_VERSION" == "$VERSION" ]] || { echo "session exact version $SESSION_VERSION does not match target $VERSION" >&2; exit 2; } +fi + +if [[ -z "$STATE_DIR" ]]; then STATE_DIR="$(mktemp -d -t gha-kvm-truenas.XXXXXX)"; fi +mkdir -p "$STATE_DIR" "$(dirname "$OUT")" +STATE_DIR="$(realpath "$STATE_DIR")" +OUT="$(realpath -m "$OUT")" +[[ "$OUT" != "$STATE_DIR/"* ]] || { echo "receipt must be outside disposable state" >&2; exit 2; } + +QEMU_PID="" +OBSERVED_ISO_SHA="" +EXPECTED_ISO_SHA="" +GRUB_PATH="" +T0_OBSERVED="false" +RPC_HOSTFWD_ACCEPTED="false" +RPC_DISCOVERY_OK="false" +RPC_DISCOVERY_JSON="" +QEMU_ALIVE_AT_GATE="unknown" +INSTALL_RESULT_JSON="" +MIDDLEWARE_RESULT_JSON="" +POOL_RESULT_JSON="" +COMPUTE_RESULT_JSON="" +APP_RESULT_JSON="" +LIFECYCLE_RESULT_JSON="" +FOUNDRY_RESULT_JSON="" +SESSION_RESULT_JSON="" +cleanup() { + set +e + if [[ -n "$QEMU_PID" ]]; then + sudo -n kill "$QEMU_PID" >/dev/null 2>&1 || true + sleep 1 + sudo -n kill -9 "$QEMU_PID" >/dev/null 2>&1 || true + fi + rm -rf -- "$STATE_DIR" +} +trap cleanup EXIT INT TERM + +write_receipt() { + local classification="$1" oracle="$2" phase="$3" detail="$4" + local serial_tail="" + if [[ -f "$STATE_DIR/serial.log" ]]; then + serial_tail="$(tail -n 120 "$STATE_DIR/serial.log" | tr -d '\000' | sed -E 's/[^[:print:]\t]//g' | tail -c 16000)" + fi + export R_OUT="$OUT" R_CLASS="$classification" R_ORACLE="$oracle" R_PHASE="$phase" R_DETAIL="$detail" + export R_SERIAL="$serial_tail" R_ISO_SHA="$OBSERVED_ISO_SHA" R_EXPECTED="$EXPECTED_ISO_SHA" R_GRUB="$GRUB_PATH" + export R_TARGET_VERSION="$VERSION" R_EXPECTED_SYSTEM_VERSION="$EXPECTED_SYSTEM_VERSION" + export R_ISO_NAME="$ISO_NAME" R_ISO_URL="$ISO_URL" R_SHA_URL="$SHA_URL" + export R_MIDDLEWARE_REF="$MIDDLEWARE_REF" R_MIDDLEWARE_COMMIT="$MIDDLEWARE_COMMIT" + export R_FOUNDRY_PROFILE="$FOUNDRY_PROFILE" R_FOUNDRY_PROFILE_BLOB="$FOUNDRY_PROFILE_BLOB" R_HA_APPS_GATE="$HA_APPS_GATE" R_INSTALLER_RPC_PATH="$INSTALLER_RPC_PATH" R_INSTALLER_RPC_GUEST_PORT="$INSTALLER_RPC_GUEST_PORT" R_INSTALLER_SOURCE_REF="$INSTALLER_SOURCE_REF" R_INSTALLER_MAIN_BLOB_SHA="$INSTALLER_MAIN_BLOB_SHA" R_AUTHORITY_ISSUE="$AUTHORITY_ISSUE" + export R_RUNG="$RUNG" R_T6_PRODUCT="$T6_PRODUCT" R_COMPUTE_FIXTURE="$COMPUTE_FIXTURE" R_T0="$T0_OBSERVED" R_RPC_HOSTFWD="$RPC_HOSTFWD_ACCEPTED" + export R_VCPUS="$VCPUS" R_RAM_MIB="$RAM_MIB" + export R_RPC_OK="$RPC_DISCOVERY_OK" R_RPC_DISCOVERY="$RPC_DISCOVERY_JSON" R_QEMU_ALIVE="$QEMU_ALIVE_AT_GATE" + export R_INSTALL_RESULT="$INSTALL_RESULT_JSON" R_MIDDLEWARE_RESULT="$MIDDLEWARE_RESULT_JSON" R_POOL_RESULT="$POOL_RESULT_JSON" R_COMPUTE_RESULT="$COMPUTE_RESULT_JSON" R_APP_RESULT="$APP_RESULT_JSON" R_LIFECYCLE_RESULT="$LIFECYCLE_RESULT_JSON" R_FOUNDRY_RESULT="$FOUNDRY_RESULT_JSON" R_SESSION_RESULT="$SESSION_RESULT_JSON" + export R_FOUNDRY_CONTROL_DIR="$FOUNDRY_CONTROL_DIR" + python3 - <<'PY' +import json, os, pathlib + +def exact_foliorelay_requested_images(): + if os.environ.get("R_RUNG") != "t6" or os.environ.get("R_T6_PRODUCT") != "foliorelay": + return None + control_dir = pathlib.Path(os.environ.get("R_FOUNDRY_CONTROL_DIR") or "") + try: + control = json.loads((control_dir / "control.json").read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return None + candidate = control.get("candidate") or {} + images = [candidate.get("control_image"), candidate.get("cups_image")] + return images if all(isinstance(v, str) and "@sha256:" in v for v in images) else None + +foliorelay_images = exact_foliorelay_requested_images() +payload = { + "contract": "gha-kvm-system-lab/v1", + "target": { + "product": "truenas", + "version": os.environ["R_TARGET_VERSION"], + "rung": os.environ.get("R_RUNG", "t0").upper(), + "compute_fixture": None if os.environ.get("R_COMPUTE_FIXTURE", "none") == "none" else os.environ["R_COMPUTE_FIXTURE"], + }, + "classification": os.environ["R_CLASS"], + "oracleSatisfied": os.environ["R_ORACLE"].lower() == "true", + "phase": os.environ["R_PHASE"], + "detail": os.environ["R_DETAIL"], + "requested_shape": { + "vcpus": int(os.environ["R_VCPUS"]), + "ram_mib": int(os.environ["R_RAM_MIB"]), + "boot_disk": "24G", + "data_disks": ["8G", "8G"] if os.environ.get("R_RUNG") in {"t3", "t4", "t5", "t6"} else [], + "data_pool": {"name": "rdtepool", "topology": "MIRROR"} if os.environ.get("R_RUNG") in {"t3", "t4", "t5", "t6"} else None, + "data_disk_serials": ["RDTE_DATA_0", "RDTE_DATA_1"] if os.environ.get("R_RUNG") in {"t3", "t4", "t5", "t6"} else [], + "app": ( + {"name": "garm-provider-g2-fixtures", "image": "ghcr.io/actions/actions-runner:2.336.0@sha256:0cfdcc701ce933c6d243c6b0b2da767366dc9f2e99961d4c3754b0b78084cdda"} + if os.environ.get("R_RUNG") == "t6" and os.environ.get("R_T6_PRODUCT") == "garm-provider-g2" + else {"name": "garm-provider-g3-runner", "image": "ghcr.io/actions/actions-runner:2.336.0@sha256:0cfdcc701ce933c6d243c6b0b2da767366dc9f2e99961d4c3754b0b78084cdda"} + if os.environ.get("R_RUNG") == "t6" and os.environ.get("R_T6_PRODUCT") == "garm-provider-g3" + else {"name": "garm-provider-g4-pair", "count": 2, "image": "ghcr.io/actions/actions-runner:2.336.0@sha256:0cfdcc701ce933c6d243c6b0b2da767366dc9f2e99961d4c3754b0b78084cdda"} + if os.environ.get("R_RUNG") == "t6" and os.environ.get("R_T6_PRODUCT") == "garm-provider-g4" + else {"name": "garm-provider-g5-version-row", "count": 1, "image": "ghcr.io/actions/actions-runner:2.336.0@sha256:0cfdcc701ce933c6d243c6b0b2da767366dc9f2e99961d4c3754b0b78084cdda"} + if os.environ.get("R_RUNG") == "t6" and os.environ.get("R_T6_PRODUCT") == "garm-provider-g5" + else {"name": "rdte-t6-garm", "image": "ghcr.io/sempersupra/garm-appliance@sha256:1af67841ddd4589e3798dcda8be49230565c849d07ab57fd05899432dcdabca9"} + if os.environ.get("R_RUNG") == "t6" and os.environ.get("R_T6_PRODUCT") in {"garm", "garm-controller-f0f5"} + else {"name": "rdte-t6-wow-sidecar", "image": "ghcr.io/sempersupra/wow-sidecar@sha256:6b700ce7ba5ae44116b240ccbb54fb3b60dc952a9b4072ca1314b6f311bc5376"} + if os.environ.get("R_RUNG") == "t6" and os.environ.get("R_T6_PRODUCT") == "wow-sidecar" + else {"name": "rdte-t6-catalog-ntfy", "catalog_app": "ntfy", "catalog_version": "1.1.21"} + if os.environ.get("R_RUNG") == "t6" and os.environ.get("R_T6_PRODUCT") == "official-catalog" + else {"name": "rdte-t6-foliorelay", "images": foliorelay_images} + if os.environ.get("R_RUNG") == "t6" and os.environ.get("R_T6_PRODUCT") == "foliorelay" + else {"name": "rdte-t6-minecraft-vanilla-263", "catalog_app": "minecraft", "catalog_version_candidate": "1.15.31", "minecraft_version": "26.3", "minecraft_protocol": 777} + if os.environ.get("R_RUNG") == "t6" and os.environ.get("R_T6_PRODUCT") == "minecraft-vanilla-263" + else {"name": "rdte-t6-minecraft-paper", "catalog_app": "minecraft", "catalog_version_candidate": "1.15.29", "paper_version": "1.21.11"} + if os.environ.get("R_RUNG") == "t6" and os.environ.get("R_T6_PRODUCT") in {"minecraft-paper", "collage-native-paper"} + else {"name": "rdte-t6-litellm", "image": "ghcr.io/sempersupra/litellm-appliance@sha256:225c899db85865929f6099d3e1fe27097cafaed5af823fa397e75e1eb6ec51ac"} + if os.environ.get("R_RUNG") == "t6" + else {"name": "rdte-t4-probe", "image": "nginx@sha256:65645c7bb6a0661892a8b03b89d0743208a18dd2f3f17a54ef4b76fb8e2f2a10"} + if os.environ.get("R_RUNG") in {"t4", "t5"} else None + ), + }, + "source": { + "iso_name": os.environ["R_ISO_NAME"], + "iso_url": os.environ["R_ISO_URL"], + "vendor_sha256_url": os.environ["R_SHA_URL"], + "expected_sha256": os.environ.get("R_EXPECTED") or None, + "observed_sha256": os.environ.get("R_ISO_SHA") or None, + "middleware_ref": os.environ["R_MIDDLEWARE_REF"], + "middleware_commit": os.environ["R_MIDDLEWARE_COMMIT"], + "foundry_profile": os.environ["R_FOUNDRY_PROFILE"], + "foundry_profile_blob": os.environ["R_FOUNDRY_PROFILE_BLOB"], + "ha_apps_gate": os.environ["R_HA_APPS_GATE"], + "installer_rpc_path": os.environ["R_INSTALLER_RPC_PATH"], + "installer_rpc_guest_port": int(os.environ["R_INSTALLER_RPC_GUEST_PORT"]), + "installer_source_ref": os.environ["R_INSTALLER_SOURCE_REF"], + "installer_main_blob_sha": os.environ["R_INSTALLER_MAIN_BLOB_SHA"], + "system_version_expected": os.environ["R_EXPECTED_SYSTEM_VERSION"], + "authority_issue": int(os.environ["R_AUTHORITY_ISSUE"]) if os.environ.get("R_AUTHORITY_ISSUE") else None, + }, + "installer_grub_path": os.environ.get("R_GRUB") or None, + "oracles": { + "vendor_iso_digest": bool(os.environ.get("R_ISO_SHA")) and os.environ.get("R_ISO_SHA") == os.environ.get("R_EXPECTED"), + "installer_environment_observed": os.environ.get("R_T0") == "true" or os.environ.get("R_RPC_OK") == "true", + "installer_serial_marker_observed": os.environ.get("R_T0") == "true", + "installer_rpc_hostfwd_accepted": os.environ.get("R_RPC_HOSTFWD") == "true", + "installer_rpc_readonly": os.environ.get("R_RPC_OK") == "true", + "installer_install_completed": bool(os.environ.get("R_INSTALL_RESULT")) and json.loads(os.environ["R_INSTALL_RESULT"]).get("oracleSatisfied") is True, + "installed_middleware_authenticated": bool(os.environ.get("R_MIDDLEWARE_RESULT")) and json.loads(os.environ["R_MIDDLEWARE_RESULT"]).get("oracleSatisfied") is True, + "data_pool_created": bool(os.environ.get("R_POOL_RESULT")) and json.loads(os.environ["R_POOL_RESULT"]).get("oracleSatisfied") is True, + "compute_materialization_exercised": bool(os.environ.get("R_COMPUTE_RESULT")) and json.loads(os.environ["R_COMPUTE_RESULT"]).get("oracleSatisfied") is True, + "apps_runtime_exercised": bool(os.environ.get("R_APP_RESULT")) and json.loads(os.environ["R_APP_RESULT"]).get("oracleSatisfied") is True, + "app_lifecycle_exercised": bool(os.environ.get("R_LIFECYCLE_RESULT")) and json.loads(os.environ["R_LIFECYCLE_RESULT"]).get("oracleSatisfied") is True, + "foundry_materialization_exercised": bool(os.environ.get("R_FOUNDRY_RESULT")) and json.loads(os.environ["R_FOUNDRY_RESULT"]).get("oracleSatisfied") is True, + }, + "rpc_discovery": json.loads(os.environ["R_RPC_DISCOVERY"]) if os.environ.get("R_RPC_DISCOVERY") else None, + "install_result": json.loads(os.environ["R_INSTALL_RESULT"]) if os.environ.get("R_INSTALL_RESULT") else None, + "installed_middleware": json.loads(os.environ["R_MIDDLEWARE_RESULT"]) if os.environ.get("R_MIDDLEWARE_RESULT") else None, + "data_pool": json.loads(os.environ["R_POOL_RESULT"]) if os.environ.get("R_POOL_RESULT") else None, + "compute_materialization": json.loads(os.environ["R_COMPUTE_RESULT"]) if os.environ.get("R_COMPUTE_RESULT") else None, + "apps_runtime": json.loads(os.environ["R_APP_RESULT"]) if os.environ.get("R_APP_RESULT") else None, + "app_lifecycle": json.loads(os.environ["R_LIFECYCLE_RESULT"]) if os.environ.get("R_LIFECYCLE_RESULT") else None, + "foundry_materialization": json.loads(os.environ["R_FOUNDRY_RESULT"]) if os.environ.get("R_FOUNDRY_RESULT") else None, + "session_execution": json.loads(os.environ["R_SESSION_RESULT"]) if os.environ.get("R_SESSION_RESULT") else None, + "qemu_alive_at_gate": os.environ.get("R_QEMU_ALIVE"), + "serial_tail": os.environ.get("R_SERIAL", ""), + "limitations": [ + "T0 proves pinned vendor media integrity and installer-environment boot under the disposable virtual target profile.", + "T1 is version-profiled read-only installer RPC discovery.", + "T2 adds vendor installation plus installed middleware authentication/health.", + "T3 adds two experiment-owned sparse data disks and a real middleware-created ZFS mirror pool.", + "Optional container-c0 on T3 proves only native product-API container lifecycle/read-back/update/restart semantics and zero-residue cleanup; C1 guest execution remains separate.", + "Optional vm-v0 on T3 proves only native VM/device lifecycle, source-bound preconditions and exact-owned storage cleanup; guest boot/V1, nested KVM/V2, Firecracker and Windows remain separate.", + "Optional vm-v1 on T3 proves only pinned Linux guest boot plus an exact externally observed nonce through the supported VM console; nested KVM/V2, Firecracker and Windows remain separate.", + "T4 initializes Apps on that pool and runs one synthetic public-safe custom Compose app.", + "T5 exercises stop/start, config mutation/read-back, redeploy, stop, and delete for that digest-pinned custom app.", + "T6 consumes one exact public TrueNAS App Foundry materialization control and verifies native Custom App realization/read-back.", + "The first T6 control remains registry-tag mutable; T6 does not claim immutable upstream container-image identity.", + "This does not qualify physical storage controllers, SMART, GPU, IPMI, or HA behavior.", + ], +} +pathlib.Path(os.environ["R_OUT"]).write_text(json.dumps(payload, indent=2, sort_keys=True)+"\n", encoding="utf-8") +PY +} + +fail_evidence() { + local class="$1" phase="$2" detail="$3" + write_receipt "$class" false "$phase" "$detail" + echo "$class: $phase: $detail" >&2 + exit 0 +} + +[[ -f "$SCRIPT_DIR/truenas_installer_rpc_probe.py" ]] || + fail_evidence HARNESS_FAILURE preflight "missing TrueNAS installer RPC probe" +if [[ "$RUNG" == "t2" || "$RUNG" == "t3" || "$RUNG" == "t4" || "$RUNG" == "t5" || "$RUNG" == "t6" ]]; then + [[ -f "$SCRIPT_DIR/truenas_installer_rpc_install.py" ]] || + fail_evidence HARNESS_FAILURE preflight "missing TrueNAS installer install client" + [[ -f "$SCRIPT_DIR/truenas_middleware_ddp_probe.py" ]] || + fail_evidence HARNESS_FAILURE preflight "missing TrueNAS middleware health client" + if [[ "$RUNG" == "t3" || "$RUNG" == "t4" || "$RUNG" == "t5" || "$RUNG" == "t6" ]]; then + [[ -f "$SCRIPT_DIR/truenas_middleware_pool_probe.py" ]] || + fail_evidence HARNESS_FAILURE preflight "missing TrueNAS T3/T4 pool client" + fi + if [[ "$COMPUTE_FIXTURE" == "container-c0" ]]; then + [[ -f "$SCRIPT_DIR/truenas_compute_container_probe.py" ]] || + fail_evidence HARNESS_FAILURE preflight "missing TrueNAS compute container C0 client" + elif [[ "$COMPUTE_FIXTURE" == "vm-v0" ]]; then + [[ -f "$SCRIPT_DIR/truenas_compute_vm_probe.py" ]] || + fail_evidence HARNESS_FAILURE preflight "missing TrueNAS compute VM V0 client" + elif [[ "$COMPUTE_FIXTURE" == "vm-v1" ]]; then + for required in truenas_compute_vm_v1_probe.py truenas_vm_console_probe.py compute_guest_seed.py; do + [[ -f "$SCRIPT_DIR/$required" ]] || + fail_evidence HARNESS_FAILURE preflight "missing TrueNAS compute VM V1 component: $required" + done + command -v qemu-img >/dev/null 2>&1 || + fail_evidence HARNESS_FAILURE preflight "qemu-img required for pinned V1 image conversion" + command -v xorriso >/dev/null 2>&1 || + fail_evidence HARNESS_FAILURE preflight "xorriso required for deterministic NoCloud seed media" + fi + if [[ "$COMPUTE_FIXTURE" != "none" ]]; then + [[ -f "$SCRIPT_DIR/../config/compute-materialization-targets.json" ]] || + fail_evidence HARNESS_FAILURE preflight "missing compute materialization target registry" + fi + if [[ "$RUNG" == "t4" || "$RUNG" == "t5" || "$RUNG" == "t6" ]]; then + [[ -f "$SCRIPT_DIR/truenas_middleware_app_probe.py" ]] || + fail_evidence HARNESS_FAILURE preflight "missing TrueNAS Apps client" + fi + if [[ "$RUNG" == "t5" || "$RUNG" == "t6" ]]; then + [[ -f "$SCRIPT_DIR/truenas_middleware_app_lifecycle_probe.py" ]] || + fail_evidence HARNESS_FAILURE preflight "missing TrueNAS T5 lifecycle client" + fi + if [[ "$RUNG" == "t6" ]]; then + if [[ "$T6_PRODUCT" == "official-catalog" ]]; then + [[ -f "$SCRIPT_DIR/truenas_middleware_official_catalog_t6_probe.py" ]] || + fail_evidence HARNESS_FAILURE preflight "missing TrueNAS T6 official-catalog control client" + elif [[ "$T6_PRODUCT" == "garm-provider-g2" ]]; then + [[ -f "$SCRIPT_DIR/truenas_middleware_garm_provider_g2_probe.py" ]] || + fail_evidence HARNESS_FAILURE preflight "missing TrueNAS GARM provider G2 client" + command -v docker >/dev/null 2>&1 || fail_evidence ENVIRONMENT_FAILURE preflight "missing prerequisite: docker" + command -v openssl >/dev/null 2>&1 || fail_evidence ENVIRONMENT_FAILURE preflight "missing prerequisite: openssl" + elif [[ "$T6_PRODUCT" == "garm-provider-g3" ]]; then + [[ -f "$SCRIPT_DIR/truenas_middleware_garm_provider_g3_probe.py" ]] || + fail_evidence HARNESS_FAILURE preflight "missing TrueNAS GARM provider G3 client" + command -v docker >/dev/null 2>&1 || fail_evidence ENVIRONMENT_FAILURE preflight "missing prerequisite: docker" + command -v openssl >/dev/null 2>&1 || fail_evidence ENVIRONMENT_FAILURE preflight "missing prerequisite: openssl" + elif [[ "$T6_PRODUCT" == "garm-provider-g4" ]]; then + [[ -f "$SCRIPT_DIR/truenas_middleware_garm_provider_g4_probe.py" ]] || + fail_evidence HARNESS_FAILURE preflight "missing TrueNAS GARM provider G4 client" + command -v docker >/dev/null 2>&1 || fail_evidence ENVIRONMENT_FAILURE preflight "missing prerequisite: docker" + command -v openssl >/dev/null 2>&1 || fail_evidence ENVIRONMENT_FAILURE preflight "missing prerequisite: openssl" + elif [[ "$T6_PRODUCT" == "garm-provider-container-pre-b4" ]]; then + [[ -f "$SCRIPT_DIR/truenas_middleware_garm_container_pre_b4_probe.py" ]] || + fail_evidence HARNESS_FAILURE preflight "missing TrueNAS GARM Container pre-B4 client" + elif [[ "$T6_PRODUCT" == "garm-provider-g5" ]]; then + [[ -f "$SCRIPT_DIR/truenas_middleware_garm_provider_g5_probe.py" ]] || + fail_evidence HARNESS_FAILURE preflight "missing TrueNAS GARM provider G5 client" + command -v docker >/dev/null 2>&1 || fail_evidence ENVIRONMENT_FAILURE preflight "missing prerequisite: docker" + command -v openssl >/dev/null 2>&1 || fail_evidence ENVIRONMENT_FAILURE preflight "missing prerequisite: openssl" + elif [[ "$T6_PRODUCT" == "garm" ]]; then + [[ -f "$SCRIPT_DIR/truenas_middleware_garm_t6_probe.py" ]] || + fail_evidence HARNESS_FAILURE preflight "missing TrueNAS T6 GARM control client" + elif [[ "$T6_PRODUCT" == "wow-sidecar" ]]; then + [[ -f "$SCRIPT_DIR/truenas_middleware_wow_sidecar_t6_probe.py" ]] || + fail_evidence HARNESS_FAILURE preflight "missing TrueNAS T6 WOW Sidecar control client" + elif [[ "$T6_PRODUCT" == "minecraft-paper" || "$T6_PRODUCT" == "collage-native-paper" || "$T6_PRODUCT" == "minecraft-vanilla-263" ]]; then + [[ -f "$SCRIPT_DIR/truenas_middleware_minecraft_t6_probe.py" ]] || + fail_evidence HARNESS_FAILURE preflight "missing Minecraft T6 product probe" + [[ -f "$REPO_ROOT/.deps/minecraft-product/tools/minecraft_java_status_oracle.py" ]] || + fail_evidence HARNESS_FAILURE preflight "missing pinned Minecraft product oracle" + elif [[ "$T6_PRODUCT" == "foliorelay" ]]; then + [[ -f "$SCRIPT_DIR/truenas_middleware_foliorelay_t6_probe.py" ]] || + fail_evidence HARNESS_FAILURE preflight "missing TrueNAS T6 FolioRelay control client" + [[ -f "$SCRIPT_DIR/../tools/foliorelay-observer/main.go" ]] || + fail_evidence HARNESS_FAILURE preflight "missing minimal FolioRelay observer source" + command -v ipptool >/dev/null 2>&1 || fail_evidence ENVIRONMENT_FAILURE preflight "missing prerequisite: ipptool" + command -v cc >/dev/null 2>&1 || fail_evidence ENVIRONMENT_FAILURE preflight "missing prerequisite: C compiler" + command -v go >/dev/null 2>&1 || fail_evidence ENVIRONMENT_FAILURE preflight "missing prerequisite: Go compiler" + command -v readelf >/dev/null 2>&1 || fail_evidence ENVIRONMENT_FAILURE preflight "missing prerequisite: readelf" + else + [[ -f "$SCRIPT_DIR/truenas_middleware_litellm_t6_probe.py" ]] || + fail_evidence HARNESS_FAILURE preflight "missing TrueNAS T6 LiteLLM control client" + fi + fi +fi +for cmd in curl sha256sum qemu-img qemu-system-x86_64 xorriso python3; do + command -v "$cmd" >/dev/null 2>&1 || fail_evidence ENVIRONMENT_FAILURE preflight "missing prerequisite: $cmd" +done +[[ "$(uname -s)" == Linux && "$(uname -m)" == x86_64 ]] || fail_evidence ENVIRONMENT_FAILURE preflight "requires Linux x86_64" +[[ -e /dev/kvm ]] || fail_evidence ENVIRONMENT_FAILURE preflight "/dev/kvm absent" +sudo -n test -r /dev/kvm && sudo -n test -w /dev/kvm || fail_evidence ENVIRONMENT_FAILURE preflight "passwordless sudo KVM boundary unavailable" + +MEM_AVAIL_KIB="$(awk '/MemAvailable:/ {print $2}' /proc/meminfo)" +FREE_KIB="$(df -Pk "$STATE_DIR" | awk 'NR==2 {print $4}')" +HOST_CPUS="$(nproc)" +(( HOST_CPUS >= VCPUS )) || fail_evidence SKIPPED_GUARDRAIL preflight "host CPU count $HOST_CPUS below requested guest vCPU count $VCPUS" +(( MEM_AVAIL_KIB >= MIN_HOST_MEM_KIB )) || fail_evidence SKIPPED_GUARDRAIL preflight "host memory headroom below 11 GiB required before allocating 8 GiB guest" +(( FREE_KIB >= MIN_HOST_FREE_KIB )) || fail_evidence SKIPPED_GUARDRAIL preflight "host disk headroom below 28 GiB" + +FOLIORELAY_OBSERVER_BINARY="" +if [[ "$RUNG" == "t6" && "$T6_PRODUCT" == "foliorelay" ]]; then + FOLIORELAY_OBSERVER_BINARY="$STATE_DIR/foliorelay-observer" + ( + cd "$SCRIPT_DIR/.." + GO111MODULE=off CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \ + go build -trimpath -ldflags='-s -w -buildid=' \ + -o "$FOLIORELAY_OBSERVER_BINARY" ./tools/foliorelay-observer + ) || + fail_evidence HARNESS_FAILURE preflight "minimal FolioRelay observer build failed" + [[ -s "$FOLIORELAY_OBSERVER_BINARY" ]] || + fail_evidence HARNESS_FAILURE preflight "minimal FolioRelay observer build produced an empty binary" + observer_size="$(stat -c%s "$FOLIORELAY_OBSERVER_BINARY")" + (( observer_size <= 6291456 )) || + fail_evidence HARNESS_FAILURE preflight "minimal FolioRelay observer exceeds 6 MiB budget" + if readelf -l "$FOLIORELAY_OBSERVER_BINARY" | grep -q 'INTERP'; then + fail_evidence HARNESS_FAILURE preflight "minimal FolioRelay observer unexpectedly has a dynamic interpreter" + fi +fi + +ISO="$STATE_DIR/$ISO_NAME" +curl --fail --location --retry 3 --silent --show-error "$SHA_URL" -o "$STATE_DIR/vendor.sha256" || + fail_evidence ENVIRONMENT_FAILURE acquire "vendor SHA256 sidecar download failed" +EXPECTED_ISO_SHA="$(grep -Eo '[0-9a-fA-F]{64}' "$STATE_DIR/vendor.sha256" | head -n1 | tr 'A-F' 'a-f')" +[[ "$EXPECTED_ISO_SHA" =~ ^[0-9a-f]{64}$ ]] || fail_evidence HARNESS_FAILURE acquire "vendor SHA256 sidecar did not contain a digest" +curl --fail --location --retry 3 --silent --show-error "$ISO_URL" -o "$ISO" || + fail_evidence ENVIRONMENT_FAILURE acquire "vendor ISO download failed" +OBSERVED_ISO_SHA="$(sha256sum "$ISO" | awk '{print $1}')" +[[ "$OBSERVED_ISO_SHA" == "$EXPECTED_ISO_SHA" ]] || fail_evidence ORACLE_FAILURE acquire "vendor ISO digest mismatch" + +GRUB_PATH="" +for candidate in /boot/grub/grub.cfg /EFI/BOOT/grub.cfg /efi/boot/grub.cfg; do + if xorriso -osirrox on -indev "$ISO" -extract "$candidate" "$STATE_DIR/grub.cfg" >/dev/null 2>&1; then + GRUB_PATH="$candidate" + break + fi +done +[[ -n "$GRUB_PATH" ]] || fail_evidence HARNESS_FAILURE prepare "could not locate installer GRUB config in pinned ISO" +chmod u+w "$STATE_DIR/grub.cfg" + +python3 - "$STATE_DIR/grub.cfg" <<'PY' +import pathlib, sys +p=pathlib.Path(sys.argv[1]) +s=p.read_text(encoding="utf-8", errors="replace") +prefix="""insmod serial +serial --unit=0 --speed=115200 --word=8 --parity=no --stop=1 +terminal_input serial +terminal_output serial +set timeout=0 +""" +lines=[] +changed=False +for line in s.splitlines(): + stripped=line.lstrip() + if (stripped.startswith("linux ") or stripped.startswith("linuxefi ")) and "console=ttyS0" not in line: + line += " console=ttyS0,115200" + changed=True + lines.append(line) +if not changed: + raise SystemExit("no Linux kernel command line found in GRUB config") +p.write_text(prefix + "\n".join(lines)+"\n", encoding="utf-8") +PY + +SERIAL_ISO="$STATE_DIR/truenas-serial.iso" +cp --reflink=auto "$ISO" "$SERIAL_ISO" +chmod u+w "$SERIAL_ISO" +xorriso -boot_image any keep -dev "$SERIAL_ISO" \ + -map "$STATE_DIR/grub.cfg" "$GRUB_PATH" -commit >/dev/null 2>"$STATE_DIR/xorriso.log" || + fail_evidence HARNESS_FAILURE prepare "failed to construct serial-observable TrueNAS ISO" + + +port_open() { + local port="$1" + python3 - "$port" <<'PY' +import socket, sys +s=socket.socket() +s.settimeout(1.0) +try: + s.connect(("127.0.0.1", int(sys.argv[1]))) +except OSError: + raise SystemExit(1) +finally: + s.close() +PY +} + +try_rpc_discovery() { + local rpc_out="$STATE_DIR/rpc-discovery.json" + rm -f "$rpc_out" + python3 "$SCRIPT_DIR/truenas_installer_rpc_probe.py" --host 127.0.0.1 --port "$RPC_PORT" --path "$INSTALLER_RPC_PATH" --out "$rpc_out" --timeout 3 >/dev/null 2>&1 || true + [[ -f "$rpc_out" ]] || return 1 + RPC_DISCOVERY_JSON="$(cat "$rpc_out")" + if python3 - "$rpc_out" <<'PY' +import json, pathlib, sys +try: + data = json.loads(pathlib.Path(sys.argv[1]).read_text()) +except Exception: + raise SystemExit(1) +raise SystemExit(0 if data.get("oracleSatisfied") is True else 1) +PY + then + RPC_DISCOVERY_OK="true" + return 0 + fi + return 1 +} + +qemu-img create -q -f qcow2 "$STATE_DIR/boot.qcow2" "$DISK_SIZE" +RPC_PORT="$(python3 - <<'PY' +import socket +s=socket.socket(); s.bind(("127.0.0.1",0)); print(s.getsockname()[1]); s.close() +PY +)" +: >"$STATE_DIR/serial.log" +sudo -n qemu-system-x86_64 \ + -enable-kvm -cpu host -smp "$VCPUS" -m "$RAM_MIB" \ + -drive "file=$STATE_DIR/boot.qcow2,if=virtio,format=qcow2" \ + -cdrom "$SERIAL_ISO" -boot order=d \ + -netdev "user,id=net0,hostfwd=tcp:127.0.0.1:$RPC_PORT-:$INSTALLER_RPC_GUEST_PORT" -device "virtio-net-pci,netdev=net0,mac=$NIC_MAC,addr=0x3" \ + -display none -monitor none \ + -serial "file:$STATE_DIR/serial.log" \ + -daemonize -pidfile "$STATE_DIR/qemu.pid" || + fail_evidence ENVIRONMENT_FAILURE boot "QEMU could not start TrueNAS guest" +QEMU_PID="$(sudo -n cat "$STATE_DIR/qemu.pid")" + +T0_OBSERVED="false" +RPC_HOSTFWD_ACCEPTED="false" +RPC_DISCOVERY_OK="false" +for attempt in $(seq 1 220); do + if grep -Eqi 'TrueNAS|truenas-installer|TrueNAS Installer|Install/Upgrade' "$STATE_DIR/serial.log" 2>/dev/null; then + T0_OBSERVED="true" + fi + if sudo -n kill -0 "$QEMU_PID" >/dev/null 2>&1; then + QEMU_ALIVE_AT_GATE="true" + else + QEMU_ALIVE_AT_GATE="false" + break + fi + if [[ "$RUNG" == "t0" && "$T0_OBSERVED" == "true" ]]; then + break + fi + # All post-T0 rungs share the installer RPC prerequisite; do not enumerate higher rungs here. + if [[ "$RUNG" != "t0" ]]; then + if port_open "$RPC_PORT"; then + RPC_HOSTFWD_ACCEPTED="true" + fi + # QEMU hostfwd accepting TCP is only a scheduling hint. The oracle is a + # completed vendor WebSocket/JSON-RPC discovery exchange. + if (( attempt % 2 == 0 )) && try_rpc_discovery; then + break + fi + fi + sleep 3 +done + +if [[ "$RUNG" == "t0" ]]; then + if [[ "$T0_OBSERVED" == "true" ]]; then + write_receipt SUPPORTED true installer-boot "pinned TrueNAS vendor ISO reached a TrueNAS-labelled installer environment over serial" + elif grep -Eqi 'Linux version|systemd|Starting' "$STATE_DIR/serial.log" 2>/dev/null; then + fail_evidence INCONCLUSIVE installer-boot "Linux boot was observed but a TrueNAS-specific installer marker was not reached" + else + fail_evidence ORACLE_FAILURE installer-boot "no bounded installer-environment boot oracle was observed" + fi + exit 0 +fi + +[[ "$RPC_DISCOVERY_OK" == "true" ]] || + fail_evidence ORACLE_FAILURE installer-rpc "TrueNAS installer did not complete the read-only WebSocket JSON-RPC discovery oracle" + +if [[ "$RUNG" == "t1" ]]; then + write_receipt SUPPORTED true installer-rpc "pinned TrueNAS installer answered read-only JSON-RPC discovery methods; stronger RPC evidence also establishes the installer environment for this rung" + exit 0 +fi + +PASSWORD_FILE="$STATE_DIR/install-password" +python3 - "$PASSWORD_FILE" <<'PY' +import pathlib, secrets, sys +path = pathlib.Path(sys.argv[1]) +path.write_text(secrets.token_urlsafe(24), encoding="utf-8") +path.chmod(0o600) +PY + +INSTALL_OUT="$STATE_DIR/install-result.json" +python3 "$SCRIPT_DIR/truenas_installer_rpc_install.py" \ + --host 127.0.0.1 --port "$RPC_PORT" --path "$INSTALLER_RPC_PATH" \ + --password-file "$PASSWORD_FILE" \ + --out "$INSTALL_OUT" --timeout 120 >/dev/null 2>&1 || true +[[ -f "$INSTALL_OUT" ]] || fail_evidence HARNESS_FAILURE installer-install "installer mutation client did not emit a receipt" +INSTALL_RESULT_JSON="$(cat "$INSTALL_OUT")" +INSTALL_OK="$(python3 - "$INSTALL_OUT" <<'PY' +import json, pathlib, sys +data = json.loads(pathlib.Path(sys.argv[1]).read_text()) +print("true" if data.get("oracleSatisfied") is True else "false") +PY +)" +[[ "$INSTALL_OK" == "true" ]] || + fail_evidence ORACLE_FAILURE installer-install "vendor installer RPC did not complete installation on the sole disposable disk" + +# The installer exports boot-pool before returning from install(). Terminate the +# disposable installer VM only after that completed response, then boot the same disk. +if [[ -n "$QEMU_PID" ]]; then + sudo -n kill "$QEMU_PID" >/dev/null 2>&1 || true + for _ in $(seq 1 40); do + sudo -n kill -0 "$QEMU_PID" >/dev/null 2>&1 || break + sleep 0.25 + done + sudo -n kill -9 "$QEMU_PID" >/dev/null 2>&1 || true + QEMU_PID="" +fi + +DATA_DRIVE_ARGS=() +if [[ "$RUNG" == "t3" || "$RUNG" == "t4" || "$RUNG" == "t5" || "$RUNG" == "t6" ]]; then + for index in $(seq 0 $((DATA_DISK_COUNT - 1))); do + data_disk="$STATE_DIR/data${index}.qcow2" + qemu-img create -q -f qcow2 "$data_disk" "$DATA_DISK_SIZE" || + fail_evidence HARNESS_FAILURE data-disk-prepare "failed to create sparse data disk $index" + drive_id="rdtedata${index}" + pci_slot=$((5 + index)) + DATA_DRIVE_ARGS+=( + -drive "file=$data_disk,if=none,id=$drive_id,format=qcow2" + -device "virtio-blk-pci,drive=$drive_id,serial=${DATA_SERIAL_PREFIX}${index},addr=0x${pci_slot}" + ) + done +fi + +HTTP_PORT="$(python3 - <<'PY' +import socket +s=socket.socket(); s.bind(("127.0.0.1",0)); print(s.getsockname()[1]); s.close() +PY +)" +HTTPS_PORT="$(python3 - <<'PY' +import socket +s=socket.socket(); s.bind(("127.0.0.1",0)); print(s.getsockname()[1]); s.close() +PY +)" +LITELLM_HOST_PORT="" +LITELLM_HOSTFWD="" +GARM_HOST_PORT="" +GARM_HOSTFWD="" +FOLIORELAY_CONTROL_HOST_PORT="" +FOLIORELAY_IPP_HOST_PORT="" +FOLIORELAY_OBSERVER_HOST_PORT="" +FOLIORELAY_HOSTFWD="" +MINECRAFT_HOST_PORT="" +MINECRAFT_HOSTFWD="" +if [[ "$RUNG" == "t6" && ( "$T6_PRODUCT" == "minecraft-paper" || "$T6_PRODUCT" == "collage-native-paper" || "$T6_PRODUCT" == "minecraft-vanilla-263" ) ]]; then + MINECRAFT_HOST_PORT="$(python3 - <<'PY' +import socket +s=socket.socket(); s.bind(("127.0.0.1",0)); print(s.getsockname()[1]); s.close() +PY +)" + MINECRAFT_HOSTFWD=",hostfwd=tcp:127.0.0.1:${MINECRAFT_HOST_PORT}-:25580" +fi +if [[ "$RUNG" == "t6" && "$T6_PRODUCT" == "litellm" ]]; then + LITELLM_HOST_PORT="$(python3 - <<'PY' +import socket +s=socket.socket(); s.bind(("127.0.0.1",0)); print(s.getsockname()[1]); s.close() +PY +)" + LITELLM_HOSTFWD=",hostfwd=tcp:127.0.0.1:${LITELLM_HOST_PORT}-:30401" +fi +if [[ "$RUNG" == "t6" && ( "$T6_PRODUCT" == "garm" || "$T6_PRODUCT" == "garm-controller-f0f5" ) ]]; then + GARM_HOST_PORT="$(python3 - <<'PY' +import socket +s=socket.socket(); s.bind(("127.0.0.1",0)); print(s.getsockname()[1]); s.close() +PY +)" + GARM_HOSTFWD=",hostfwd=tcp:127.0.0.1:${GARM_HOST_PORT}-:30880" +fi +if [[ "$RUNG" == "t6" && "$T6_PRODUCT" == "foliorelay" ]]; then + read -r FOLIORELAY_CONTROL_HOST_PORT FOLIORELAY_IPP_HOST_PORT FOLIORELAY_OBSERVER_HOST_PORT < <(python3 - <<'PY' +import socket +ports=[] +for _ in range(3): + s=socket.socket(); s.bind(("127.0.0.1",0)); ports.append(str(s.getsockname()[1])); s.close() +print(" ".join(ports)) +PY +) + FOLIORELAY_HOSTFWD=",hostfwd=tcp:127.0.0.1:${FOLIORELAY_CONTROL_HOST_PORT}-:18443,hostfwd=tcp:127.0.0.1:${FOLIORELAY_IPP_HOST_PORT}-:8634,hostfwd=tcp:127.0.0.1:${FOLIORELAY_OBSERVER_HOST_PORT}-:18081" +fi +: >"$STATE_DIR/serial.log" +sudo -n qemu-system-x86_64 \ + -enable-kvm -cpu host -smp "$VCPUS" -m "$RAM_MIB" \ + -drive "file=$STATE_DIR/boot.qcow2,if=none,id=rdteboot,format=qcow2" \ + -device "virtio-blk-pci,drive=rdteboot,id=rdte-boot,addr=0x4,bootindex=1" \ + "${DATA_DRIVE_ARGS[@]}" \ + -boot strict=on \ + -netdev "user,id=net0,hostfwd=tcp:127.0.0.1:$HTTP_PORT-:80,hostfwd=tcp:127.0.0.1:$HTTPS_PORT-:443${LITELLM_HOSTFWD}${GARM_HOSTFWD}${FOLIORELAY_HOSTFWD}${MINECRAFT_HOSTFWD}" \ + -device "virtio-net-pci,netdev=net0,mac=$NIC_MAC,addr=0x3" \ + -display none -monitor none \ + -serial "file:$STATE_DIR/serial.log" \ + -daemonize -pidfile "$STATE_DIR/qemu.pid" || + fail_evidence ENVIRONMENT_FAILURE installed-boot "QEMU could not start installed TrueNAS guest" +QEMU_PID="$(sudo -n cat "$STATE_DIR/qemu.pid")" + +MIDDLEWARE_OUT="$STATE_DIR/middleware-health.json" +MIDDLEWARE_OK="false" +MIDDLEWARE_PORT="" +MIDDLEWARE_TLS_ARG=() +for attempt in $(seq 1 180); do + if ! sudo -n kill -0 "$QEMU_PID" >/dev/null 2>&1; then + QEMU_ALIVE_AT_GATE="false" + break + fi + QEMU_ALIVE_AT_GATE="true" + + if (( attempt % 2 == 0 )); then + rm -f "$MIDDLEWARE_OUT" + python3 "$SCRIPT_DIR/truenas_middleware_ddp_probe.py" \ + --host 127.0.0.1 --port "$HTTP_PORT" \ + --password-file "$PASSWORD_FILE" --out "$MIDDLEWARE_OUT" --timeout 4 \ + >/dev/null 2>&1 || true + if [[ -f "$MIDDLEWARE_OUT" ]] && python3 - "$MIDDLEWARE_OUT" <<'PY' +import json, pathlib, sys +data = json.loads(pathlib.Path(sys.argv[1]).read_text()) +raise SystemExit(0 if data.get("oracleSatisfied") is True else 1) +PY + then + MIDDLEWARE_OK="true" + MIDDLEWARE_PORT="$HTTP_PORT" + MIDDLEWARE_TLS_ARG=() + break + fi + + rm -f "$MIDDLEWARE_OUT" + python3 "$SCRIPT_DIR/truenas_middleware_ddp_probe.py" \ + --host 127.0.0.1 --port "$HTTPS_PORT" --tls \ + --password-file "$PASSWORD_FILE" --out "$MIDDLEWARE_OUT" --timeout 4 \ + >/dev/null 2>&1 || true + if [[ -f "$MIDDLEWARE_OUT" ]] && python3 - "$MIDDLEWARE_OUT" <<'PY' +import json, pathlib, sys +data = json.loads(pathlib.Path(sys.argv[1]).read_text()) +raise SystemExit(0 if data.get("oracleSatisfied") is True else 1) +PY + then + MIDDLEWARE_OK="true" + MIDDLEWARE_PORT="$HTTPS_PORT" + MIDDLEWARE_TLS_ARG=(--tls) + break + fi + fi + sleep 3 +done + +if [[ -f "$MIDDLEWARE_OUT" ]]; then + MIDDLEWARE_RESULT_JSON="$(cat "$MIDDLEWARE_OUT")" +fi +[[ "$MIDDLEWARE_OK" == "true" ]] || + fail_evidence ORACLE_FAILURE installed-middleware "installed TrueNAS did not authenticate and answer system.version/system.info within the bounded boot window" + +if [[ "$RUNG" == "t2" ]]; then + write_receipt SUPPORTED true installed-middleware "vendor installer completed and installed TrueNAS middleware authenticated and answered health methods" + exit 0 +fi + +POOL_OUT="$STATE_DIR/data-pool.json" +python3 "$SCRIPT_DIR/truenas_middleware_pool_probe.py" \ + --host 127.0.0.1 --port "$MIDDLEWARE_PORT" \ + "${MIDDLEWARE_TLS_ARG[@]}" \ + --password-file "$PASSWORD_FILE" \ + --out "$POOL_OUT" --pool-name "$DATA_POOL_NAME" \ + --expected-data-disks "$DATA_DISK_COUNT" --data-serial-prefix "$DATA_SERIAL_PREFIX" \ + --timeout 15 --job-timeout 180 >/dev/null 2>&1 || true +[[ -f "$POOL_OUT" ]] || fail_evidence HARNESS_FAILURE data-pool "T3/T4 pool client did not emit a receipt" +POOL_RESULT_JSON="$(cat "$POOL_OUT")" +POOL_OK="$(python3 - "$POOL_OUT" <<'PY' +import json, pathlib, sys +data = json.loads(pathlib.Path(sys.argv[1]).read_text()) +print("true" if data.get("oracleSatisfied") is True else "false") +PY +)" +[[ "$POOL_OK" == "true" ]] || + fail_evidence ORACLE_FAILURE data-pool "installed TrueNAS did not create and independently verify the disposable ZFS mirror pool" + +if [[ "$RUNG" == "t3" ]]; then + if [[ "$COMPUTE_FIXTURE" == "container-c0" ]]; then + COMPUTE_OUT="$STATE_DIR/compute-container-c0.json" + python3 "$SCRIPT_DIR/truenas_compute_container_probe.py" \ + --registry "$SCRIPT_DIR/../config/compute-materialization-targets.json" \ + --target-version "$VERSION" \ + --pool "$DATA_POOL_NAME" \ + --host 127.0.0.1 --port "$MIDDLEWARE_PORT" \ + "${MIDDLEWARE_TLS_ARG[@]}" \ + --password-file "$PASSWORD_FILE" \ + --out "$COMPUTE_OUT" --timeout 8 --job-timeout 600 --state-timeout 240 --apply \ + >/dev/null 2>&1 || true + [[ -f "$COMPUTE_OUT" ]] || + fail_evidence HARNESS_FAILURE compute-container-c0 "compute C0 client did not emit a receipt" + COMPUTE_RESULT_JSON="$(cat "$COMPUTE_OUT")" + COMPUTE_OK="$(python3 - "$COMPUTE_OUT" <<'PY' +import json, pathlib, sys +data=json.loads(pathlib.Path(sys.argv[1]).read_text()) +ok=( + data.get("classification")=="SUPPORTED" + and data.get("oracleSatisfied") is True + and data.get("c0_oracle_satisfied") is True + and data.get("c1_guest_oracle_satisfied") is False + and (data.get("cleanup") or {}).get("absent") is True + and isinstance(data.get("image_resolution"), dict) +) +print("true" if ok else "false") +PY +)" + [[ "$COMPUTE_OK" == "true" ]] || + fail_evidence ORACLE_FAILURE compute-container-c0 "native TrueNAS container C0 lifecycle did not satisfy exact API/image/cleanup oracles" + write_receipt SUPPORTED true compute-container-c0 "native TrueNAS container API completed exact image discovery, create/read-back/start/stop/update/restart-or-stop+start/delete/absence with zero residue; guest-level C1 remains unclaimed" + elif [[ "$COMPUTE_FIXTURE" == "vm-v0" ]]; then + COMPUTE_OUT="$STATE_DIR/compute-vm-v0.json" + python3 "$SCRIPT_DIR/truenas_compute_vm_probe.py" \ + --registry "$SCRIPT_DIR/../config/compute-materialization-targets.json" \ + --target-version "$VERSION" \ + --name "rdtecomputevmv0" \ + --zvol-name "$DATA_POOL_NAME/rdte-compute-vm-v0" \ + --host 127.0.0.1 --port "$MIDDLEWARE_PORT" \ + "${MIDDLEWARE_TLS_ARG[@]}" \ + --password-file "$PASSWORD_FILE" \ + --out "$COMPUTE_OUT" --timeout 8 --job-timeout 600 --apply \ + >/dev/null 2>&1 || true + [[ -f "$COMPUTE_OUT" ]] || + fail_evidence HARNESS_FAILURE compute-vm-v0 "compute VM V0 client did not emit a receipt" + COMPUTE_RESULT_JSON="$(cat "$COMPUTE_OUT")" + COMPUTE_OK="$(python3 - "$COMPUTE_OUT" <<'PY' +import json, pathlib, sys +data=json.loads(pathlib.Path(sys.argv[1]).read_text()) +cleanup=data.get("cleanup") or {} +ok=( + data.get("classification")=="SUPPORTED" + and data.get("oracleSatisfied") is True + and data.get("v0_oracle_satisfied") is True + and data.get("v1_guest_oracle_satisfied") is False + and data.get("v2_firecracker_oracle_satisfied") is False + and data.get("windows_w1_oracle_satisfied") is False + and cleanup.get("vm_absent") is True + and cleanup.get("device_absent") is True + and cleanup.get("zvol_absent") is True +) +print("true" if ok else "false") +PY +)" + [[ "$COMPUTE_OK" == "true" ]] || + fail_evidence ORACLE_FAILURE compute-vm-v0 "native TrueNAS VM V0 lifecycle did not satisfy exact ownership/precondition/cleanup oracles" + write_receipt SUPPORTED true compute-vm-v0 "native TrueNAS VM/device V0 completed create/read-back/update/owned-ZVOL device CRUD and zero-residue cleanup; guest boot, nested KVM, Firecracker and Windows remain unclaimed" + elif [[ "$COMPUTE_FIXTURE" == "vm-v1" ]]; then + V1_DIR="$STATE_DIR/compute-vm-v1" + V1_SOURCE="$V1_DIR/cirros-0.6.3-x86_64-disk.img" + V1_RAW="$V1_DIR/cirros-0.6.3-x86_64.raw" + V1_SEED_DIR="$V1_DIR/seed" + V1_SEED_ISO="$V1_DIR/seed.iso" + V1_SOURCE_URL="https://download.cirros-cloud.net/0.6.3/cirros-0.6.3-x86_64-disk.img" + V1_SOURCE_SHA256="7d6355852aeb6dbcd191bcda7cd74f1536cfe5cbf8a10495a7283a8396e4b75b" + V1_NONCE="v1${GITHUB_RUN_ID:-00000000000}nonce" + V1_INSTANCE_ID="rdte-v1-${GITHUB_RUN_ID:-local}" + mkdir -p "$V1_SEED_DIR" + curl --fail --location --retry 3 --silent --show-error "$V1_SOURCE_URL" -o "$V1_SOURCE" || + fail_evidence ENVIRONMENT_FAILURE compute-vm-v1-source "failed to acquire pinned CirrOS V1 guest image" + V1_OBSERVED_SHA256="$(sha256sum "$V1_SOURCE" | awk '{print $1}')" + [[ "$V1_OBSERVED_SHA256" == "$V1_SOURCE_SHA256" ]] || + fail_evidence ORACLE_FAILURE compute-vm-v1-source "CirrOS V1 guest image digest did not match pinned release" + qemu-img convert -f qcow2 -O raw "$V1_SOURCE" "$V1_RAW" || + fail_evidence HARNESS_FAILURE compute-vm-v1-media "failed to convert pinned CirrOS qcow2 image to RAW" + python3 "$SCRIPT_DIR/compute_guest_seed.py" --nonce "$V1_NONCE" --instance-id "$V1_INSTANCE_ID" --out-dir "$V1_SEED_DIR" || + fail_evidence HARNESS_FAILURE compute-vm-v1-media "failed to create deterministic NoCloud nonce seed" + ( + cd "$V1_SEED_DIR" + xorriso -as mkisofs -quiet -output "$V1_SEED_ISO" -volid cidata -joliet -rock user-data meta-data + ) || fail_evidence HARNESS_FAILURE compute-vm-v1-media "failed to package deterministic NoCloud seed ISO" + + COMPUTE_OUT="$STATE_DIR/compute-vm-v1.json" + V1_CLIENT_STDERR="$V1_DIR/client.stderr.log" + ( + cd "$REPO_ROOT" + python3 -m scripts.truenas_compute_vm_v1_probe \ + --target-version "$VERSION" \ + --pool "$DATA_POOL_NAME" \ + --name "rdtecomputevmv1" \ + --nonce "$V1_NONCE" \ + --source-image "$V1_SOURCE" \ + --raw-image "$V1_RAW" \ + --seed-iso "$V1_SEED_ISO" \ + --host 127.0.0.1 --port "$MIDDLEWARE_PORT" \ + "${MIDDLEWARE_TLS_ARG[@]}" \ + --password-file "$PASSWORD_FILE" \ + --out "$COMPUTE_OUT" --timeout 8 --job-timeout 600 --guest-timeout 180 --apply + ) >/dev/null 2>"$V1_CLIENT_STDERR" || true + if [[ ! -f "$COMPUTE_OUT" ]]; then + V1_CLIENT_ERROR="$(tail -c 2000 "$V1_CLIENT_STDERR" 2>/dev/null | tr '\n\r' ' ' || true)" + fail_evidence HARNESS_FAILURE compute-vm-v1 "compute VM V1 client did not emit a receipt; client stderr: ${V1_CLIENT_ERROR:-}" + fi + COMPUTE_RESULT_JSON="$(cat "$COMPUTE_OUT")" + COMPUTE_OK="$(python3 - "$COMPUTE_OUT" <<'PY' +import json, pathlib, sys +data=json.loads(pathlib.Path(sys.argv[1]).read_text()) +cleanup=data.get("cleanup") or {} +guest=data.get("guest_oracle") or {} +ok=( + data.get("classification")=="SUPPORTED" + and data.get("oracleSatisfied") is True + and data.get("v1_oracle_satisfied") is True + and guest.get("classification")=="SUPPORTED" + and guest.get("oracleSatisfied") is True + and cleanup.get("vm_absent") is True + and cleanup.get("staging_dataset_absent") is True +) +print("true" if ok else "false") +PY +)" + [[ "$COMPUTE_OK" == "true" ]] || + fail_evidence ORACLE_FAILURE compute-vm-v1 "native TrueNAS VM V1 did not satisfy pinned guest boot/nonce/cleanup oracles" + write_receipt SUPPORTED true compute-vm-v1 "pinned CirrOS guest booted and emitted the exact injected nonce through the supported TrueNAS VM console with zero owned VM/media residue; nested KVM, Firecracker and Windows remain unclaimed" + else + write_receipt SUPPORTED true data-pool "installed TrueNAS created an ONLINE healthy two-disk mirror containing exactly the selected disposable data disks" + fi + exit 0 +fi + +APP_OUT="$STATE_DIR/apps-runtime.json" +python3 "$SCRIPT_DIR/truenas_middleware_app_probe.py" \ + --host 127.0.0.1 --port "$MIDDLEWARE_PORT" \ + "${MIDDLEWARE_TLS_ARG[@]}" \ + --password-file "$PASSWORD_FILE" \ + --out "$APP_OUT" --pool-name "$DATA_POOL_NAME" \ + --expected-version "$EXPECTED_SYSTEM_VERSION" \ + --middleware-ref "$MIDDLEWARE_REF" --middleware-commit "$MIDDLEWARE_COMMIT" \ + --ha-apps-gate "$HA_APPS_GATE" \ + --timeout 8 --job-timeout 300 --state-timeout 180 >/dev/null 2>&1 || true +[[ -f "$APP_OUT" ]] || fail_evidence HARNESS_FAILURE apps-runtime "T4 Apps client did not emit a receipt" +APP_RESULT_JSON="$(cat "$APP_OUT")" +APP_OK="$(python3 - "$APP_OUT" <<'PY' +import json, pathlib, sys +data = json.loads(pathlib.Path(sys.argv[1]).read_text()) +print("true" if data.get("oracleSatisfied") is True else "false") +PY +)" +[[ "$APP_OK" == "true" ]] || + fail_evidence ORACLE_FAILURE apps-runtime "TrueNAS Apps did not initialize and run the bounded custom app oracle" + +if [[ "$RUNG" == "t4" ]]; then + write_receipt SUPPORTED true apps-runtime "installed TrueNAS initialized Apps on rdtepool and ran the bounded digest-pinned custom nginx Compose app" + exit 0 +fi + +LIFECYCLE_OUT="$STATE_DIR/app-lifecycle.json" +python3 "$SCRIPT_DIR/truenas_middleware_app_lifecycle_probe.py" \ + --host 127.0.0.1 --port "$MIDDLEWARE_PORT" \ + "${MIDDLEWARE_TLS_ARG[@]}" \ + --password-file "$PASSWORD_FILE" \ + --out "$LIFECYCLE_OUT" --expected-version "$EXPECTED_SYSTEM_VERSION" \ + --timeout 8 --job-timeout 300 --state-timeout 180 >/dev/null 2>&1 || true +[[ -f "$LIFECYCLE_OUT" ]] || fail_evidence HARNESS_FAILURE app-lifecycle "T5 lifecycle client did not emit a receipt" +LIFECYCLE_RESULT_JSON="$(cat "$LIFECYCLE_OUT")" +LIFECYCLE_OK="$(python3 - "$LIFECYCLE_OUT" <<'PY' +import json, pathlib, sys +data = json.loads(pathlib.Path(sys.argv[1]).read_text()) +print("true" if data.get("oracleSatisfied") is True else "false") +PY +)" +[[ "$LIFECYCLE_OK" == "true" ]] || + fail_evidence ORACLE_FAILURE app-lifecycle "custom app did not complete the bounded T5 lifecycle" + +if [[ "$RUNG" == "t5" ]]; then + write_receipt SUPPORTED true app-lifecycle "digest-pinned custom app completed stop/start, config mutation with public read-back, redeploy, stop, and delete" + exit 0 +fi + +FOUNDRY_OUT="$STATE_DIR/foundry-control.json" +if [[ -n "$SESSION_MANIFEST" ]]; then + SESSION_CONTEXT="$STATE_DIR/session-context.json" + SESSION_OUT="$STATE_DIR/session-execution.json" + SESSION_RECEIPTS="$STATE_DIR/session-capsules" + export SCTX_PRODUCT="$T6_PRODUCT" SCTX_MIDDLEWARE_PORT="$MIDDLEWARE_PORT" + export SCTX_PASSWORD_FILE="$PASSWORD_FILE" SCTX_CONTROL_DIR="$FOUNDRY_CONTROL_DIR" SCTX_FOUNDRY_COMMIT="$FOUNDRY_COMMIT" + export SCTX_LITELLM_PORT="$LITELLM_HOST_PORT" SCTX_GARM_PORT="$GARM_HOST_PORT" + export SCTX_FOLIO_CONTROL="$FOLIORELAY_CONTROL_HOST_PORT" SCTX_FOLIO_IPP="$FOLIORELAY_IPP_HOST_PORT" SCTX_FOLIO_OBSERVER="$FOLIORELAY_OBSERVER_HOST_PORT" + export SCTX_MINECRAFT_STATUS_PORT="$MINECRAFT_HOST_PORT" SCTX_MINECRAFT_ORACLE="$REPO_ROOT/.deps/minecraft-product/tools/minecraft_java_status_oracle.py" SCTX_TARGET_SYSTEM="$EXPECTED_SYSTEM_VERSION" + if (( ${#MIDDLEWARE_TLS_ARG[@]} )); then SCTX_TLS=true; else SCTX_TLS=false; fi + export SCTX_TLS + python3 - "$SESSION_CONTEXT" <<'PY' +import json, os, pathlib, sys +selector=os.environ["SCTX_PRODUCT"] +provider_id={ + "litellm":"litellm-t6", + "wow-sidecar":"wow-sidecar-t6", + "garm":"garm-t6", + "official-catalog":"official-catalog-t6", + "foliorelay":"foliorelay-t6", + "minecraft-paper":"minecraft-paper-t6", +}[selector] +ports={} +if selector=="litellm": ports["service"]=int(os.environ["SCTX_LITELLM_PORT"]) +elif selector=="garm": ports["service"]=int(os.environ["SCTX_GARM_PORT"]) +elif selector=="minecraft-paper": ports["status"]=int(os.environ["SCTX_MINECRAFT_STATUS_PORT"]) +elif selector=="foliorelay": + ports={ + "control":int(os.environ["SCTX_FOLIO_CONTROL"]), + "ipp":int(os.environ["SCTX_FOLIO_IPP"]), + "observer":int(os.environ["SCTX_FOLIO_OBSERVER"]), + } +payload={ + "schema":"truenas-capsule-context/v1", + "host":"127.0.0.1", + "middleware_port":int(os.environ["SCTX_MIDDLEWARE_PORT"]), + "password_file":os.environ["SCTX_PASSWORD_FILE"], + "tls":os.environ["SCTX_TLS"].lower()=="true", + "providers":{ + provider_id:{ + "control_dir":os.environ["SCTX_CONTROL_DIR"], + "foundry_commit":os.environ["SCTX_FOUNDRY_COMMIT"], + "ports":ports, + **({"status_oracle":os.environ["SCTX_MINECRAFT_ORACLE"], + "expected_system_version":os.environ["SCTX_TARGET_SYSTEM"]} if selector=="minecraft-paper" else {}), + } + }, +} +pathlib.Path(sys.argv[1]).write_text(json.dumps(payload,indent=2,sort_keys=True)+"\n",encoding="utf-8") +PY + python3 "$SCRIPT_DIR/truenas_session_runner.py" \ + --manifest "$SESSION_MANIFEST" \ + --targets "$TARGET_REGISTRY" \ + --providers "$SCRIPT_DIR/../config/truenas-capsule-providers.json" \ + --executor "$SCRIPT_DIR/truenas_existing_probe_capsule_executor.py" \ + --context "$SESSION_CONTEXT" \ + --out-dir "$SESSION_RECEIPTS" \ + --out "$SESSION_OUT" >/dev/null 2>&1 || true + [[ -f "$SESSION_OUT" ]] || fail_evidence HARNESS_FAILURE foundry-materialization "single-capsule session runner did not emit a receipt" + SESSION_RESULT_JSON="$(cat "$SESSION_OUT")" + SESSION_CAPSULE_VERDICT="$(python3 - "$SESSION_OUT" <<'PY' +import json, pathlib, sys +session=json.loads(pathlib.Path(sys.argv[1]).read_text()) +caps=session.get("capsules") or [] +print(caps[0].get("verdict","HARNESS_FAILURE") if len(caps)==1 else "HARNESS_FAILURE") +PY +)" + if [[ "$SESSION_CAPSULE_VERDICT" != "SUPPORTED" ]]; then + case "$SESSION_CAPSULE_VERDICT" in + ORACLE_FAILURE|HARNESS_FAILURE|ENVIRONMENT_FAILURE|UNSUPPORTED) + fail_evidence "$SESSION_CAPSULE_VERDICT" foundry-materialization "single-capsule session provider returned $SESSION_CAPSULE_VERDICT" + ;; + *) + fail_evidence HARNESS_FAILURE foundry-materialization "single-capsule session returned unexpected verdict $SESSION_CAPSULE_VERDICT" + ;; + esac + fi + SESSION_OK="$(python3 - "$SESSION_OUT" <<'PY' +import json, pathlib, sys +session=json.loads(pathlib.Path(sys.argv[1]).read_text()) +caps=session.get("capsules") or [] +ok=(session.get("classification")=="SESSION_CLEAN" and len(caps)==1 and caps[0].get("verdict")=="SUPPORTED") +print("true" if ok else "false") +PY +)" + [[ "$SESSION_OK" == "true" ]] || fail_evidence HARNESS_FAILURE foundry-materialization "single-capsule session envelope was not clean" + python3 - "$SESSION_OUT" "$FOUNDRY_OUT" <<'PY' +import json, pathlib, sys +session=json.loads(pathlib.Path(sys.argv[1]).read_text()) +provider=session["capsules"][0].get("provider_receipt") +if not isinstance(provider,dict) or provider.get("classification")!="SUPPORTED" or provider.get("oracleSatisfied") is not True: + raise SystemExit("accepted capsule did not retain accepted provider receipt") +pathlib.Path(sys.argv[2]).write_text(json.dumps(provider,indent=2,sort_keys=True)+"\n",encoding="utf-8") +PY +else +if [[ "$T6_PRODUCT" == "official-catalog" ]]; then + python3 "$SCRIPT_DIR/truenas_middleware_official_catalog_t6_probe.py" \ + --host 127.0.0.1 --port "$MIDDLEWARE_PORT" \ + "${MIDDLEWARE_TLS_ARG[@]}" \ + --password-file "$PASSWORD_FILE" \ + --control-dir "$FOUNDRY_CONTROL_DIR" \ + --foundry-commit "$FOUNDRY_COMMIT" \ + --out "$FOUNDRY_OUT" --timeout 8 --job-timeout 300 --state-timeout 240 >/dev/null 2>&1 || true +elif [[ "$T6_PRODUCT" == "garm-provider-g2" ]]; then + python3 "$SCRIPT_DIR/truenas_middleware_garm_provider_g2_probe.py" \ + --host 127.0.0.1 --http-port "$HTTP_PORT" --https-port "$HTTPS_PORT" \ + --password-file "$PASSWORD_FILE" \ + --fixture-dir "$G2_FIXTURE_DIR" \ + --fixture-producer-commit "$G2_FIXTURE_PRODUCER" \ + --out "$FOUNDRY_OUT" --timeout 8 --job-timeout 300 >/dev/null 2>&1 || true +elif [[ "$T6_PRODUCT" == "garm-provider-g3" ]]; then + python3 "$SCRIPT_DIR/truenas_middleware_garm_provider_g3_probe.py" \ + --host 127.0.0.1 --http-port "$HTTP_PORT" --https-port "$HTTPS_PORT" \ + --password-file "$PASSWORD_FILE" \ + --fixture-dir "$G3_FIXTURE_DIR" \ + --fixture-producer-commit "$G3_FIXTURE_PRODUCER" \ + --out "$FOUNDRY_OUT" --timeout 8 --job-timeout 300 >/dev/null 2>&1 || true +elif [[ "$T6_PRODUCT" == "garm-provider-g4" ]]; then + python3 "$SCRIPT_DIR/truenas_middleware_garm_provider_g4_probe.py" \ + --host 127.0.0.1 --http-port "$HTTP_PORT" --https-port "$HTTPS_PORT" \ + --password-file "$PASSWORD_FILE" \ + --fixture-dir "$G4_FIXTURE_DIR" \ + --fixture-producer-commit "$G4_FIXTURE_PRODUCER" \ + --out "$FOUNDRY_OUT" --timeout 8 --job-timeout 300 >/dev/null 2>&1 || true +elif [[ "$T6_PRODUCT" == "garm-provider-container-pre-b4" ]]; then + python3 "$SCRIPT_DIR/truenas_middleware_garm_container_pre_b4_probe.py" \ + --host 127.0.0.1 --http-port "$HTTP_PORT" --middleware-port "$MIDDLEWARE_PORT" \ + "${MIDDLEWARE_TLS_ARG[@]}" \ + --password-file "$PASSWORD_FILE" --pool "$DATA_POOL_NAME" \ + --fixture-dir "$CONTAINER_FIXTURE_DIR" \ + --fixture-producer-commit "$CONTAINER_FIXTURE_PRODUCER" \ + --out "$FOUNDRY_OUT" --timeout 8 --job-timeout 600 --state-timeout 180 >/dev/null 2>&1 || true +elif [[ "$T6_PRODUCT" == "garm-provider-g5" ]]; then + python3 "$SCRIPT_DIR/truenas_middleware_garm_provider_g5_probe.py" \ + --host 127.0.0.1 --http-port "$HTTP_PORT" --https-port "$HTTPS_PORT" \ + --password-file "$PASSWORD_FILE" \ + --matrix-dir "$G5_MATRIX_DIR" \ + --matrix-producer-commit "$G5_MATRIX_PRODUCER" \ + --fixture-dir "$G3_FIXTURE_DIR" \ + --fixture-producer-commit "$G3_FIXTURE_PRODUCER" \ + --target-version "$VERSION" \ + --expected-middleware-commit "$MIDDLEWARE_COMMIT" \ + --out "$FOUNDRY_OUT" --timeout 8 --job-timeout 300 >/dev/null 2>&1 || true +elif [[ "$T6_PRODUCT" == "garm-controller-f0f5" ]]; then + python3 "$SCRIPT_DIR/truenas_middleware_garm_f0_f5_probe.py" \ + --host 127.0.0.1 --port "$MIDDLEWARE_PORT" --service-port "$GARM_HOST_PORT" \ + "${MIDDLEWARE_TLS_ARG[@]}" \ + --password-file "$PASSWORD_FILE" \ + --control-dir "$FOUNDRY_CONTROL_DIR" \ + --foundry-commit "$FOUNDRY_COMMIT" \ + --target-version "$VERSION" \ + --expected-system-version "$EXPECTED_SYSTEM_VERSION" \ + --expected-middleware-commit "$MIDDLEWARE_COMMIT" \ + --expected-profile-path "$FOUNDRY_PROFILE" \ + --expected-profile-blob "$FOUNDRY_PROFILE_BLOB" \ + --out "$FOUNDRY_OUT" --timeout 8 --job-timeout 300 --state-timeout 240 >/dev/null 2>&1 || true +elif [[ "$T6_PRODUCT" == "garm" ]]; then + python3 "$SCRIPT_DIR/truenas_middleware_garm_t6_probe.py" \ + --host 127.0.0.1 --port "$MIDDLEWARE_PORT" --service-port "$GARM_HOST_PORT" \ + "${MIDDLEWARE_TLS_ARG[@]}" \ + --password-file "$PASSWORD_FILE" \ + --control-dir "$FOUNDRY_CONTROL_DIR" \ + --foundry-commit "$FOUNDRY_COMMIT" \ + --out "$FOUNDRY_OUT" --timeout 8 --job-timeout 300 --state-timeout 240 >/dev/null 2>&1 || true +elif [[ "$T6_PRODUCT" == "wow-sidecar" ]]; then + python3 "$SCRIPT_DIR/truenas_middleware_wow_sidecar_t6_probe.py" \ + --host 127.0.0.1 --port "$MIDDLEWARE_PORT" \ + "${MIDDLEWARE_TLS_ARG[@]}" \ + --password-file "$PASSWORD_FILE" \ + --control-dir "$FOUNDRY_CONTROL_DIR" \ + --foundry-commit "$FOUNDRY_COMMIT" \ + --out "$FOUNDRY_OUT" --timeout 8 --job-timeout 300 --state-timeout 240 >/dev/null 2>&1 || true +elif [[ "$T6_PRODUCT" == "minecraft-paper" || "$T6_PRODUCT" == "collage-native-paper" ]]; then + COLLAGE_P2_ARGS=() + if [[ "$T6_PRODUCT" == "collage-native-paper" ]]; then + [[ -d "$REPO_ROOT/.deps/collage-source" ]] || + fail_evidence HARNESS_FAILURE foundry-materialization "pinned COLLAGE P2 checkout missing" + COLLAGE_P2_ARGS=( + --collage-source-dir "$REPO_ROOT/.deps/collage-source" + --collage-https-port "$HTTPS_PORT" + --collage-source-ref "fbda94f3fea0a216ddd9c35b38a09d325bed3640" + --native-catalog-profile "1.15.30" + ) + fi + python3 "$SCRIPT_DIR/truenas_middleware_minecraft_t6_probe.py" \ + --host 127.0.0.1 --port "$MIDDLEWARE_PORT" \ + --status-port "$MINECRAFT_HOST_PORT" \ + --status-oracle "$REPO_ROOT/.deps/minecraft-product/tools/minecraft_java_status_oracle.py" \ + "${MIDDLEWARE_TLS_ARG[@]}" \ + --password-file "$PASSWORD_FILE" --control-dir "$FOUNDRY_CONTROL_DIR" \ + --foundry-commit "$FOUNDRY_COMMIT" --expected-system-version "$EXPECTED_SYSTEM_VERSION" \ + "${COLLAGE_P2_ARGS[@]}" \ + --out "$FOUNDRY_OUT" --timeout 8 --job-timeout 600 --state-timeout 480 >/dev/null 2>&1 || true +elif [[ "$T6_PRODUCT" == "minecraft-vanilla-263" ]]; then + [[ "$VERSION" == "25.04.2.6" ]] || fail_evidence HARNESS_FAILURE foundry-materialization "native vanilla guest exact version drift" + NATIVE_READBACK_ARGS=() + if [[ "$VANILLA_COLLAGE_READBACK" == "true" ]]; then + [[ -d "$REPO_ROOT/.deps/collage-source" ]] || + fail_evidence HARNESS_FAILURE foundry-materialization "exact current COLLAGE read-only checkout absent" + NATIVE_READBACK_ARGS=( + --collage-source-dir "$REPO_ROOT/.deps/collage-source" + --collage-https-port "$HTTPS_PORT" + --collage-source-ref "b9256d6ec0dbac43be2f5a136103b6e3512ee2ec" + ) + fi + NATIVE_LANTERN_ARGS=() + if [[ -n "$VANILLA_LANTERN_ZIP" ]]; then + NATIVE_LANTERN_ARGS=( + --lantern-zip "$VANILLA_LANTERN_ZIP" + --lantern-manifest "$VANILLA_LANTERN_MANIFEST" + ) + fi + NATIVE_ROLLBACK_ARGS=() + if [[ "$VANILLA_WORLD_ROLLBACK" == "true" ]]; then + NATIVE_ROLLBACK_ARGS=(--native-world-rollback) + fi + NATIVE_PLAYER_ARGS=() + if [[ "$VANILLA_NATIVE_PLAYER" == "true" ]]; then + [[ "$MINECRAFT_HOSTFWD" == ,hostfwd=tcp:127.0.0.1:*"-:25580" ]] || + { echo "G4 missing runner-owned QEMU localhost-only Minecraft forward" >&2; exit 2; } + NATIVE_PLAYER_ARGS=(--native-offline-player + --native-player-forward "${MINECRAFT_HOSTFWD#,hostfwd=}" + --native-player-node-path "$VANILLA_PLAYER_NODE_PATH") + fi + if [[ "$VANILLA_BACKEND_REGION" == "true" ]]; then + NATIVE_PLAYER_ARGS+=(--native-backend-region) + fi + python3 "$SCRIPT_DIR/truenas_middleware_minecraft_t6_probe.py" \ + --product-profile vanilla263 --native-catalog-profile 1.15.31 \ + --host 127.0.0.1 --port "$MIDDLEWARE_PORT" \ + --status-port "$MINECRAFT_HOST_PORT" \ + --status-oracle "$REPO_ROOT/.deps/minecraft-product/tools/minecraft_java_status_oracle.py" \ + "${MIDDLEWARE_TLS_ARG[@]}" \ + --password-file "$PASSWORD_FILE" --control-dir "$FOUNDRY_CONTROL_DIR" \ + --foundry-commit "$FOUNDRY_COMMIT" --expected-system-version "$EXPECTED_SYSTEM_VERSION" \ + "${NATIVE_READBACK_ARGS[@]}" \ + "${NATIVE_LANTERN_ARGS[@]}" \ + "${NATIVE_ROLLBACK_ARGS[@]}" \ + "${NATIVE_PLAYER_ARGS[@]}" \ + --out "$FOUNDRY_OUT" --timeout 8 --job-timeout 600 --state-timeout 480 >/dev/null 2>&1 || true +elif [[ "$T6_PRODUCT" == "foliorelay" ]]; then + python3 "$SCRIPT_DIR/truenas_middleware_foliorelay_t6_probe.py" \ + --host 127.0.0.1 --port "$MIDDLEWARE_PORT" \ + --control-port "$FOLIORELAY_CONTROL_HOST_PORT" \ + --ipp-port "$FOLIORELAY_IPP_HOST_PORT" \ + --observer-port "$FOLIORELAY_OBSERVER_HOST_PORT" \ + --observer-binary "$FOLIORELAY_OBSERVER_BINARY" \ + "${MIDDLEWARE_TLS_ARG[@]}" \ + --password-file "$PASSWORD_FILE" \ + --control-dir "$FOUNDRY_CONTROL_DIR" \ + --foundry-commit "$FOUNDRY_COMMIT" \ + --target-version "$VERSION" \ + --expected-system-version "$EXPECTED_SYSTEM_VERSION" \ + --out "$FOUNDRY_OUT" --timeout 8 --job-timeout 300 --state-timeout 300 >/dev/null 2>&1 || true +else + python3 "$SCRIPT_DIR/truenas_middleware_litellm_t6_probe.py" \ + --host 127.0.0.1 --port "$MIDDLEWARE_PORT" --service-port "$LITELLM_HOST_PORT" \ + "${MIDDLEWARE_TLS_ARG[@]}" \ + --password-file "$PASSWORD_FILE" \ + --control-dir "$FOUNDRY_CONTROL_DIR" \ + --foundry-commit "$FOUNDRY_COMMIT" \ + --out "$FOUNDRY_OUT" --timeout 8 --job-timeout 300 --state-timeout 240 >/dev/null 2>&1 || true +fi +fi +[[ -f "$FOUNDRY_OUT" ]] || fail_evidence HARNESS_FAILURE foundry-materialization "T6 Foundry control client did not emit a receipt" +FOUNDRY_RESULT_JSON="$(cat "$FOUNDRY_OUT")" +FOUNDRY_OK="$(python3 - "$FOUNDRY_OUT" <<'PY' +import json, pathlib, sys +data = json.loads(pathlib.Path(sys.argv[1]).read_text()) +print("true" if data.get("oracleSatisfied") is True else "false") +PY +)" +[[ "$FOUNDRY_OK" == "true" ]] || + fail_evidence ORACLE_FAILURE foundry-materialization "exact Foundry-exported $T6_PRODUCT control did not realize and verify on TrueNAS" + +if [[ "$T6_PRODUCT" == "official-catalog" ]]; then + write_receipt SUPPORTED true foundry-materialization "exact Foundry-exported native catalog control passed observe/discover-plan-apply-verify convergence, stop/start, config update/read-back, redeploy, conditional native-upgrade receipt, retain-data delete/reinstall, NOOP convergence, and final cleanup" +elif [[ "$T6_PRODUCT" == "garm-provider-g2" ]]; then + write_receipt SUPPORTED true foundry-materialization "exact packaged GARM TrueNAS provider passed verified WSS/API-key transport, valid local adoption, foreign exclusion, managed-drift fail-closed behavior, fresh-process readoption, and zero-residue cleanup; provider create/delete and GitHub/JIT intentionally not exercised" +elif [[ "$T6_PRODUCT" == "garm-provider-g3" ]]; then + write_receipt SUPPORTED true foundry-materialization "exact packaged GARM TrueNAS provider CreateInstance realized the source-derived fixed runner App profile, exact Compose read-back and provider Get/List reconciliation passed, supported middleware moved the experiment App inactive, provider DeleteInstance retired it, and GitHub/JIT/private workload/physical/capacity claims remained unexercised" +elif [[ "$T6_PRODUCT" == "garm-provider-g4" ]]; then + write_receipt SUPPORTED true foundry-materialization "exact packaged GARM TrueNAS provider realized two source-derived runner Apps concurrently, exact Compose and fresh-process Get/List reconciliation passed for both, active deletion failed closed, both retirement orders reached empty inventory with zero residue, and GitHub/JIT/private workload/physical/capacity claims remained unexercised" +elif [[ "$T6_PRODUCT" == "garm-provider-container-pre-b4" ]]; then + write_receipt SUPPORTED true foundry-materialization "exact BETA.3 GARM Container pre-B4 fixture completed supported container.* creation, rootfs staging, bootstrap execution markers, one-shot credential-file consumption, desired init/env scrub, stop/delete, and zero-residue absence; callback/JIT/runtime admission remain separate gates" +elif [[ "$T6_PRODUCT" == "garm-provider-g5" ]]; then + write_receipt SUPPORTED true foundry-materialization "exact G5 TrueNAS source-matrix row matched the RDTE target and the fixed packaged provider completed the capacity-one Create/read-back/Get/List/inactive/Delete/absence/empty-inventory/zero-residue lifecycle without runtime support inheritance or GitHub/JIT/private/physical/capacity claims" +elif [[ "$T6_PRODUCT" == "garm-controller-f0f5" ]]; then + write_receipt SUPPORTED true foundry-materialization "exact-target Foundry GARM controller completed F0-F5: exact profile/materialization identity, create/read-back, product health and persistent state, stop/start/update/redeploy, second-plan NOOP with WAIT/fail-closed reconciliation, retain-data delete/reinstall/post-reinstall NOOP, and final zero-residue cleanup" +elif [[ "$T6_PRODUCT" == "garm" ]]; then + write_receipt SUPPORTED true foundry-materialization "exact Foundry-exported GARM controller control passed exact appliance and secret-normalized config read-back, persistent state, external HTTPS, restart persistence, and zero-residue cleanup; GitHub/JIT registration intentionally not exercised" +elif [[ "$T6_PRODUCT" == "wow-sidecar" ]]; then + write_receipt SUPPORTED true foundry-materialization "exact Foundry-exported WOW Sidecar control passed immutable image/config read-back, permissions/seed metadata, public-fixture worker runtime, restart persistence, and zero-residue cleanup" +elif [[ "$T6_PRODUCT" == "minecraft-vanilla-263" ]]; then + write_receipt SUPPORTED true foundry-materialization "exact TrueNAS25.04.2.6 native vanilla26.3 Java25/protocol777, independent owned world across native App lifecycle, status and zero residue; source signer/control bound, no physical APPLY" +elif [[ "$T6_PRODUCT" == "minecraft-paper" || "$T6_PRODUCT" == "collage-native-paper" ]]; then + write_receipt SUPPORTED true foundry-materialization "exact native TrueNAS Minecraft Paper server passed protocol status + world level.dat persistence across App stop/start, redeploy, retain-world delete/reinstall and owned cleanup; no vanilla 26.3 or physical TrueNAS claim" +elif [[ "$T6_PRODUCT" == "foliorelay" ]]; then + write_receipt SUPPORTED true foundry-materialization "exact Foundry-exported FolioRelay three-service control passed exact image/config read-back, portal and IPP reachability, canonical identity, PDF/URF exact-source Inbox preservation, independent in-guest DNS-SD observation, restart persistence, and zero-residue cleanup" +else + write_receipt SUPPORTED true foundry-materialization "exact Foundry-exported LiteLLM control passed S1 projection, exact image/config read-back, health, restart persistence, and delete/absence" +fi diff --git a/scripts/gha_qemu_t3_topology_probe.py b/scripts/gha_qemu_t3_topology_probe.py new file mode 100755 index 000000000..c33dedb4b --- /dev/null +++ b/scripts/gha_qemu_t3_topology_probe.py @@ -0,0 +1,209 @@ +#!/usr/bin/env python3 +"""Cheap QEMU 10.x topology oracle for the TrueNAS T3 installed-boot shape.""" +from __future__ import annotations + +import argparse +import json +import pathlib +import socket +import subprocess +import tempfile +import time + + +NIC_SLOT = 3 +BOOT_SLOT = 4 +DATA_SLOTS = [5, 6] +DATA_SERIALS = ["RDTE_DATA_0", "RDTE_DATA_1"] + + +class QMP: + def __init__(self, path: pathlib.Path, timeout: float = 5.0): + deadline = time.monotonic() + timeout + self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + while True: + try: + self.sock.connect(str(path)) + break + except (FileNotFoundError, ConnectionRefusedError): + if time.monotonic() >= deadline: + raise + time.sleep(0.05) + self.file = self.sock.makefile("rwb", buffering=0) + greeting = self._recv() + if "QMP" not in greeting: + raise RuntimeError(f"missing QMP greeting: {greeting!r}") + self.execute("qmp_capabilities") + + def _recv(self): + line = self.file.readline() + if not line: + raise EOFError("QMP closed") + return json.loads(line) + + def execute(self, command, arguments=None): + request = {"execute": command} + if arguments is not None: + request["arguments"] = arguments + self.file.write((json.dumps(request, separators=(",", ":")) + "\n").encode()) + while True: + reply = self._recv() + if "event" in reply: + continue + if "error" in reply: + raise RuntimeError(f"{command}: {reply['error']!r}") + return reply.get("return") + + def close(self): + try: + self.execute("quit") + except Exception: + pass + try: + self.file.close() + finally: + self.sock.close() + + +def flatten_pci(buses): + rows = [] + def walk(devices): + for dev in devices: + rows.append({ + "bus": dev["bus"], + "slot": dev["slot"], + "function": dev["function"], + "qdev_id": dev.get("qdev_id", ""), + "vendor": dev.get("id", {}).get("vendor"), + "device": dev.get("id", {}).get("device"), + }) + bridge = dev.get("pci_bridge") + if bridge: + walk(bridge.get("devices", [])) + for bus in buses: + walk(bus.get("devices", [])) + return rows + + +def main(): + p = argparse.ArgumentParser() + p.add_argument("--out", required=True) + p.add_argument("--qemu", default="qemu-system-x86_64") + a = p.parse_args() + + out = pathlib.Path(a.out) + payload = { + "schema": "qemu-truenas-t3-topology/v1", + "classification": "ORACLE_FAILURE", + "oracleSatisfied": False, + "expected": { + "nic_slot": NIC_SLOT, + "boot_slot": BOOT_SLOT, + "data_slots": DATA_SLOTS, + "bootindex": 1, + "data_serials": DATA_SERIALS, + }, + } + + qemu = None + try: + version = subprocess.check_output([a.qemu, "--version"], text=True).splitlines()[0] + payload["qemu_version"] = version + + with tempfile.TemporaryDirectory(prefix="rdte-qemu-topology-") as td: + root = pathlib.Path(td) + for name in ("boot", "data0", "data1"): + subprocess.run( + ["qemu-img", "create", "-q", "-f", "qcow2", str(root / f"{name}.qcow2"), "64M"], + check=True, + ) + qmp_path = root / "qmp.sock" + pidfile = root / "qemu.pid" + args = [ + a.qemu, + "-S", + "-display", "none", + "-serial", "none", + "-monitor", "none", + "-m", "256", + "-smp", "1", + "-drive", f"file={root / 'boot.qcow2'},if=none,id=rdteboot,format=qcow2", + "-device", "virtio-blk-pci,drive=rdteboot,id=rdte-boot,addr=0x4,bootindex=1", + "-drive", f"file={root / 'data0.qcow2'},if=none,id=rdtedata0,format=qcow2", + "-device", "virtio-blk-pci,drive=rdtedata0,id=rdte-data0,serial=RDTE_DATA_0,addr=0x5", + "-drive", f"file={root / 'data1.qcow2'},if=none,id=rdtedata1,format=qcow2", + "-device", "virtio-blk-pci,drive=rdtedata1,id=rdte-data1,serial=RDTE_DATA_1,addr=0x6", + "-netdev", "user,id=net0", + "-device", "virtio-net-pci,netdev=net0,id=rdte-nic,mac=52:54:00:54:4e:26,addr=0x3", + "-boot", "strict=on", + "-qmp", f"unix:{qmp_path},server=on,wait=off", + "-daemonize", + "-pidfile", str(pidfile), + ] + payload["command_shape"] = [ + arg.replace(str(root), "$TMP") for arg in args[1:] + ] + subprocess.run(args, check=True) + qemu = int(pidfile.read_text().strip()) + + qmp = QMP(qmp_path) + try: + pci = flatten_pci(qmp.execute("query-pci")) + payload["pci"] = pci + by_id = {row["qdev_id"]: row for row in pci if row["qdev_id"]} + payload["bootindex"] = qmp.execute( + "qom-get", + {"path": "/machine/peripheral/rdte-boot", "property": "bootindex"}, + ) + payload["data_serials"] = [ + qmp.execute( + "qom-get", + {"path": f"/machine/peripheral/rdte-data{i}", "property": "serial"}, + ) + for i in range(2) + ] + payload["qtree"] = qmp.execute( + "human-monitor-command", + {"command-line": "info qtree"}, + ) + + checks = { + "nic_slot": by_id.get("rdte-nic", {}).get("slot") == NIC_SLOT, + "boot_slot": by_id.get("rdte-boot", {}).get("slot") == BOOT_SLOT, + "data0_slot": by_id.get("rdte-data0", {}).get("slot") == DATA_SLOTS[0], + "data1_slot": by_id.get("rdte-data1", {}).get("slot") == DATA_SLOTS[1], + "all_functions_zero": all( + by_id.get(name, {}).get("function") == 0 + for name in ("rdte-nic", "rdte-boot", "rdte-data0", "rdte-data1") + ), + "bootindex": payload["bootindex"] == 1, + "data_serials": payload["data_serials"] == DATA_SERIALS, + } + payload["checks"] = checks + if not all(checks.values()): + raise RuntimeError(f"topology checks failed: {checks!r}") + finally: + qmp.close() + + payload["classification"] = "SUPPORTED" + payload["oracleSatisfied"] = True + payload["detail"] = ( + "actual hosted-runner QEMU realized NIC@0x3, boot@0x4 bootindex=1, " + "and uniquely serialized data disks @0x5/0x6" + ) + except Exception as exc: + payload["detail"] = f"{type(exc).__name__}: {exc}" + finally: + if qemu: + try: + subprocess.run(["kill", str(qemu)], check=False) + except Exception: + pass + out.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8") + print(json.dumps(payload, indent=2, sort_keys=True)) + + return 0 if payload["oracleSatisfied"] else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/proxmox_rdte_target.py b/scripts/proxmox_rdte_target.py new file mode 100644 index 000000000..bd77c346d --- /dev/null +++ b/scripts/proxmox_rdte_target.py @@ -0,0 +1,85 @@ +#!/usr/bin/env python3 +from __future__ import annotations +import argparse, json, pathlib, re, sys + +SHA40=re.compile(r"^[0-9a-f]{40}$") +SHA256=re.compile(r"^[0-9a-f]{64}$") +SHA512=re.compile(r"^[0-9a-f]{128}$") + +class TargetError(RuntimeError): + pass + +def load_registry(path:pathlib.Path): + try: + doc=json.loads(path.read_text(encoding="utf-8")) + except (OSError,json.JSONDecodeError) as exc: + raise TargetError(f"cannot read target registry: {exc}") from exc + if not isinstance(doc,dict) or doc.get("schema")!="gha-kvm-proxmox-targets/v1": + raise TargetError("unsupported target registry schema") + targets=doc.get("targets") + if not isinstance(targets,list) or not targets: + raise TargetError("target registry has no targets") + result={} + for i,t in enumerate(targets): + if not isinstance(t,dict): + raise TargetError(f"targets[{i}] must be an object") + version=t.get("version") + if not isinstance(version,str) or not version or version in result: + raise TargetError(f"invalid or duplicate target version at index {i}") + if not isinstance(t.get("runtime_admitted"),bool): + raise TargetError(f"{version}: runtime_admitted must be boolean") + if not isinstance(t.get("authority_issue"),int): + raise TargetError(f"{version}: authority_issue must be integer") + for key in ( + "iso_name","iso_url","installer_source_version","vm_source_status", + "pve_manager_package_version","pve_container_package_version","pve_qemu_package_version", + ): + if not isinstance(t.get(key),str) or not t[key]: + raise TargetError(f"{version}: missing {key}") + for key in ( + "installer_source_commit","pve_manager_source_commit","pve_manager_apt_api_blob", + "pve_access_control_source_commit","pve_container_source_commit","pve_container_lxc_api_blob", + "pve_container_status_api_blob","pve_qemu_source_commit","pve_qemu_submodule_commit", + ): + if not isinstance(t.get(key),str) or not SHA40.fullmatch(t[key]): + raise TargetError(f"{version}: {key} must be exact 40-hex") + if not isinstance(t.get("iso_sha256"),str) or not SHA256.fullmatch(t["iso_sha256"]): + raise TargetError(f"{version}: iso_sha256 must be exact 64-hex") + fixture=t.get("container_fixture") + if not isinstance(fixture,dict): + raise TargetError(f"{version}: container_fixture missing") + for key in ("template_name","template_url"): + if not isinstance(fixture.get(key),str) or not fixture[key]: + raise TargetError(f"{version}: container_fixture.{key} missing") + if not isinstance(fixture.get("template_sha512"),str) or not SHA512.fullmatch(fixture["template_sha512"]): + raise TargetError(f"{version}: container_fixture.template_sha512 must be exact 128-hex") + for url in (t["iso_url"],fixture["template_url"]): + if any(token in url.lower() for token in ("latest","nightly","master+")): + raise TargetError(f"{version}: floating URL prohibited") + surfaces=t.get("required_rest_surfaces") + if not isinstance(surfaces,list) or not surfaces or not all(isinstance(x,str) and x.startswith("/") for x in surfaces): + raise TargetError(f"{version}: required_rest_surfaces invalid") + result[version]=t + return result + +def main(): + p=argparse.ArgumentParser() + p.add_argument("--registry",type=pathlib.Path,required=True) + p.add_argument("--version",required=True) + p.add_argument("--require-runtime-admitted",action="store_true") + a=p.parse_args() + try: + targets=load_registry(a.registry) + if a.version not in targets: + raise TargetError(f"exact Proxmox target is not registered: {a.version}") + target=targets[a.version] + if a.require_runtime_admitted and target["runtime_admitted"] is not True: + raise TargetError(f"Proxmox target is source-profiled but not runtime-admitted: {a.version}") + print(json.dumps(target,indent=2,sort_keys=True)) + return 0 + except TargetError as exc: + print(f"ERROR: {exc}",file=sys.stderr) + return 2 + +if __name__=="__main__": + raise SystemExit(main()) diff --git a/scripts/proxmox_rest_compute_probe.py b/scripts/proxmox_rest_compute_probe.py new file mode 100644 index 000000000..77fe29237 --- /dev/null +++ b/scripts/proxmox_rest_compute_probe.py @@ -0,0 +1,374 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import json +import pathlib +import re +import ssl +import time +import urllib.error +import urllib.parse +import urllib.request +from dataclasses import dataclass +from typing import Any + + +class ProxmoxProbeError(RuntimeError): + pass + +PVE_CONTAINER_SOURCE = { + "repository": "proxmox/pve-container", + "commit": "5eb5574ee9158ac40a5230de2cf18d7d6345709f", + "lxc_api_blob": "88067ddebf5a0bd540f91ffd1db11f8eec40cefb", + "lxc_status_api_blob": "c95fc3c7732d637639ab29e496bc27eafb9d222d", + "package_version": "6.1.10", +} +PVE_QEMU_PACKAGE_SOURCE = { + "repository": "proxmox/pve-qemu", + "commit": "684796e835289dab11af8606fbf7358b93526dd6", + "package_version": "11.0.0-3", +} +PVE_QEMU_SERVER_SOURCE = { + "repository": "proxmox/qemu-server", + "commit": "6785065b3f766f15f6f151af8ec27ec8bb5b07ab", + "package_version": "9.1.15", + "api2_qemu_blob": "e029a204d121f3c8b104457ef14eb6d5ce029464", + "qemu_server_blob": "118f26bc94d9ee8e8c4c39a3d710e67c14f61bc0", + "changelog_blob": "63ded16d9d06ae0dd114a67d19bb36e14c71c980", +} +PVE_MANAGER_API_SOURCE = { + "repository": "proxmox/pve-manager", + "commit": "b9984c6d90a4bd80ab72dc2088c1b9103fb167b1", + "apt_api_blob": "9cb6e473436719f3024ac09fffaad8faf0d7160d", + "manager_version": "9.2.2", +} + + + +@dataclass +class Response: + status: int + data: Any + + +class PveApi: + def __init__(self, base_url: str, username: str, password: str, verify_tls: bool = False, timeout: float = 10.0): + self.base_url = base_url.rstrip("/") + self.username = username + self.password = password + self.timeout = timeout + self.ctx = ssl.create_default_context() + if not verify_tls: + self.ctx.check_hostname = False + self.ctx.verify_mode = ssl.CERT_NONE + self.ticket: str | None = None + self.csrf: str | None = None + + def _request(self, method: str, path: str, fields: dict[str, Any] | None = None) -> Response: + url = self.base_url + "/api2/json" + path + body = None + headers = {"Accept": "application/json"} + if fields is not None: + body = urllib.parse.urlencode({k: str(v) for k, v in fields.items()}).encode() + headers["Content-Type"] = "application/x-www-form-urlencoded" + if self.ticket: + headers["Cookie"] = f"PVEAuthCookie={self.ticket}" + if self.csrf and method not in {"GET", "HEAD"}: + headers["CSRFPreventionToken"] = self.csrf + req = urllib.request.Request(url, data=body, method=method, headers=headers) + try: + with urllib.request.urlopen(req, context=self.ctx, timeout=self.timeout) as r: + raw = r.read() + payload = json.loads(raw.decode()) if raw else {} + return Response(r.status, payload.get("data")) + except urllib.error.HTTPError as exc: + raw = exc.read().decode(errors="replace") + raise ProxmoxProbeError(f"{method} {path}: HTTP {exc.code}: {raw}") from exc + + def login(self) -> None: + r = self._request("POST", "/access/ticket", {"username": self.username, "password": self.password}) + if not isinstance(r.data, dict) or not r.data.get("ticket") or not r.data.get("CSRFPreventionToken"): + raise ProxmoxProbeError("ticket response missing ticket/CSRF token") + self.ticket = r.data["ticket"] + self.csrf = r.data["CSRFPreventionToken"] + + def get(self, path: str) -> Any: + return self._request("GET", path).data + + def post(self, path: str, fields: dict[str, Any] | None = None) -> Any: + return self._request("POST", path, fields or {}).data + + def put(self, path: str, fields: dict[str, Any] | None = None) -> Any: + return self._request("PUT", path, fields or {}).data + + def delete(self, path: str, fields: dict[str, Any] | None = None) -> Any: + # PVE rejects request content for DELETE. DELETE options are query parameters. + if fields: + query = urllib.parse.urlencode({k: str(v) for k, v in fields.items()}) + path = f"{path}?{query}" + return self._request("DELETE", path).data + + +def wait_task(api: PveApi, node: str, upid: str, timeout: float = 180.0) -> dict[str, Any]: + deadline = time.monotonic() + timeout + encoded = urllib.parse.quote(upid, safe="") + last: dict[str, Any] = {} + while time.monotonic() < deadline: + data = api.get(f"/nodes/{node}/tasks/{encoded}/status") + if isinstance(data, dict): + last = data + if data.get("status") == "stopped": + exitstatus = data.get("exitstatus") + # PVE::UPID::status_is_error() treats OK and WARNINGS: as non-errors. + if exitstatus == "OK" or ( + isinstance(exitstatus, str) + and re.fullmatch(r"WARNINGS: \d+", exitstatus) + ): + return data + raise ProxmoxProbeError(f"task failed: {data!r}") + time.sleep(1) + raise ProxmoxProbeError(f"task timeout: {last!r}") + + +def node_name(api: PveApi) -> str: + rows = api.get("/nodes") + if not isinstance(rows, list) or len(rows) != 1 or not isinstance(rows[0].get("node"), str): + raise ProxmoxProbeError(f"expected one disposable PVE node: {rows!r}") + return rows[0]["node"] + + +def lxc_create_fields(vmid: int, template: str) -> dict[str, Any]: + return { + "vmid": vmid, + "hostname": f"rdte-lxc-{vmid}", + "ostemplate": template, + "rootfs": "local-lvm:2", + "memory": 256, + "cores": 1, + "unprivileged": 1, + "start": 0, + "description": "Agent Dispatch disposable REST C0 container", + } + + +def vm_create_fields(vmid: int, *, memory: int = 1024) -> dict[str, Any]: + return { + "vmid": vmid, + "name": f"rdte-vm-{vmid}", + "memory": memory, + "cores": 1, + "sockets": 1, + "cpu": "host", + "bios": "ovmf", + "machine": "q35", + "scsihw": "virtio-scsi-pci", + "net0": "virtio,bridge=vmbr0", + "ostype": "l26", + "start": 0, + "description": "Agent Dispatch disposable REST V0 VM shell", + } + + +def plan_only(kind: str, vmid: int, template: str | None) -> dict[str, Any]: + if kind == "container": + if not template: + raise ProxmoxProbeError("--template is required for container plan") + create = lxc_create_fields(vmid, template) + path = f"/nodes/{{node}}/lxc" + elif kind == "vm": + create = vm_create_fields(vmid) + path = f"/nodes/{{node}}/qemu" + else: + raise ProxmoxProbeError(f"unsupported kind {kind!r}") + return { + "schema": "proxmox-rest-compute-plan/v1", + "kind": kind, + "create_path": path, + "create_fields": create, + "lifecycle": ["observe-absent", "create", "readback", "start", "stop", "delete", "absence"], + "claim_boundary": "plan only; guest oracle and runtime qualification are separate", + } + + +def main() -> int: + p=argparse.ArgumentParser() + p.add_argument("--base-url", default="https://127.0.0.1:8006") + p.add_argument("--username", default="root@pam") + p.add_argument("--password-file", type=pathlib.Path) + p.add_argument("--verify-tls", action="store_true") + p.add_argument("--kind", choices=["container","vm"], required=True) + p.add_argument("--vmid", type=int, default=9101) + p.add_argument("--template") + p.add_argument("--observe-only", action="store_true") + p.add_argument("--expected-qemu-server-version") + p.add_argument("--apply", action="store_true") + p.add_argument("--out", type=pathlib.Path) + a=p.parse_args() + + receipt: dict[str,Any] = { + "schema":"proxmox-rest-compute-c0-v1", + "kind":a.kind, + "vmid":a.vmid, + "classification":"ORACLE_FAILURE", + "oracleSatisfied":False, + "api_materialization_oracle":False, + "guest_oracle":False, + "claim_boundary":"C0/V0 product REST lifecycle only; guest workload oracle remains separate", + "cleanup":{"attempted":False,"absent":False}, + "tasks":{}, + "source_contract":{ + "pve_container":PVE_CONTAINER_SOURCE, + "pve_qemu_package":PVE_QEMU_PACKAGE_SOURCE, + "qemu_server":PVE_QEMU_SERVER_SOURCE, + "pve_manager_api":PVE_MANAGER_API_SOURCE, + "qemu_server_api":"SOURCE_BOUND: exact installed qemu-server 9.1.15 is admitted for V0 product-REST shell lifecycle; runtime VM qualification remains separate", + }, + } + def emit(code:int)->int: + payload=json.dumps(receipt,indent=2,sort_keys=True)+"\n" + if a.out: a.out.write_text(payload,encoding="utf-8") + print(payload,end="") + return code + + api: PveApi | None = None + node: str | None = None + base: str | None = None + ownership_claimed = False + + try: + plan=plan_only(a.kind,a.vmid,a.template) + receipt["plan"]=plan + if not a.apply and not a.observe_only: + receipt.update({"classification":"SUPPORTED","oracleSatisfied":True,"phase":"plan-only"}) + return emit(0) + if not a.password_file: + raise ProxmoxProbeError("--apply/--observe-only requires --password-file") + password=a.password_file.read_text(encoding="utf-8").strip() + if not password: + raise ProxmoxProbeError("password file empty") + api=PveApi(a.base_url,a.username,password,a.verify_tls) + api.login() + version=api.get("/version") + receipt["pve_version"]=version + node=node_name(api) + receipt["node"]=node + try: + package_rows=api.get(f"/nodes/{node}/apt/versions") + except ProxmoxProbeError as package_exc: + package_rows=[] + receipt["package_census_error"]=str(package_exc) + receipt["package_census"]=[ + x for x in (package_rows or []) + if isinstance(x,dict) and x.get("Package") in {"pve-container","pve-qemu-kvm","qemu-server","pve-manager"} + ] + if a.observe_only: + receipt.update({ + "classification":"SUPPORTED", + "oracleSatisfied":True, + "phase":"observe-only", + "detail":"authenticated PVE API/version/package census completed without compute mutation", + }) + return emit(0) + if a.kind=="vm" and not a.expected_qemu_server_version: + raise ProxmoxProbeError( + "VM apply blocked: --expected-qemu-server-version must explicitly select the source-bound installed package" + ) + if a.kind=="vm": + observed_qemu=[ + x for x in receipt["package_census"] + if x.get("Package")=="qemu-server" + ] + installed_versions={ + str(x.get("OldVersion")) for x in observed_qemu + if x.get("CurrentState")=="Installed" and x.get("OldVersion") + } + available_versions={str(x.get("Version")) for x in observed_qemu if x.get("Version")} + receipt["qemu_server_package_observation"]={ + "installed_versions":sorted(installed_versions), + "available_versions":sorted(available_versions), + } + if a.expected_qemu_server_version != PVE_QEMU_SERVER_SOURCE["package_version"]: + raise ProxmoxProbeError( + f"VM apply blocked: requested qemu-server source version {a.expected_qemu_server_version!r} " + f"is not the admitted {PVE_QEMU_SERVER_SOURCE['package_version']!r}" + ) + if a.expected_qemu_server_version not in installed_versions: + raise ProxmoxProbeError( + f"VM apply blocked: expected installed qemu-server {a.expected_qemu_server_version!r}, " + f"observed installed {sorted(installed_versions)!r}; available={sorted(available_versions)!r}" + ) + base=f"/nodes/{node}/{'lxc' if a.kind=='container' else 'qemu'}" + rows=api.get(base) + if any(int(x.get("vmid",-1))==a.vmid for x in (rows or [])): + raise ProxmoxProbeError("preexisting vmid blocks ownership-safe apply") + fields=plan["create_fields"] + upid=api.post(base,fields) + if not isinstance(upid,str): raise ProxmoxProbeError(f"create did not return UPID: {upid!r}") + ownership_claimed=True + receipt["tasks"]["create"]=wait_task(api,node,upid) + cfg=api.get(f"{base}/{a.vmid}/config") + if not isinstance(cfg,dict): raise ProxmoxProbeError("config readback missing") + receipt["config_readback"]=cfg + upid=api.post(f"{base}/{a.vmid}/status/start") + if not isinstance(upid,str): raise ProxmoxProbeError("start did not return UPID") + receipt["tasks"]["start"]=wait_task(api,node,upid) + status=api.get(f"{base}/{a.vmid}/status/current") + if not isinstance(status,dict) or status.get("status")!="running": + raise ProxmoxProbeError(f"instance not running after start: {status!r}") + upid=api.post(f"{base}/{a.vmid}/status/stop") + if not isinstance(upid,str): raise ProxmoxProbeError("stop did not return UPID") + receipt["tasks"]["stop"]=wait_task(api,node,upid) + receipt["cleanup"]["attempted"]=True + upid=api.delete(f"{base}/{a.vmid}",{"purge":1}) + if not isinstance(upid,str): raise ProxmoxProbeError("delete did not return UPID") + receipt["tasks"]["delete"]=wait_task(api,node,upid) + rows=api.get(base) + absent=not any(int(x.get("vmid",-1))==a.vmid for x in (rows or [])) + receipt["cleanup"]["absent"]=absent + if not absent: raise ProxmoxProbeError("instance remained after REST delete") + receipt.update({ + "classification":"SUPPORTED", + "oracleSatisfied":True, + "api_materialization_oracle":True, + "phase":"rest-api-lifecycle", + "detail":"PVE REST create/readback/start/stop/delete/absence passed", + }) + return emit(0) + except (OSError,ValueError,ProxmoxProbeError) as exc: + receipt["detail"]=f"{type(exc).__name__}: {exc}" + if a.apply and ownership_claimed and api is not None and node is not None and base is not None: + receipt["cleanup"]["attempted"]=True + cleanup_errors=[] + try: + rows=api.get(base) + present=any(int(x.get("vmid",-1))==a.vmid for x in (rows or [])) + if present: + try: + status=api.get(f"{base}/{a.vmid}/status/current") + except ProxmoxProbeError: + status={} + if isinstance(status,dict) and status.get("status")=="running": + stop_upid=api.post(f"{base}/{a.vmid}/status/stop") + if isinstance(stop_upid,str): + receipt["tasks"]["failure_cleanup_stop"]=wait_task(api,node,stop_upid,timeout=60.0) + else: + cleanup_errors.append(f"failure cleanup stop did not return UPID: {stop_upid!r}") + delete_upid=api.delete(f"{base}/{a.vmid}",{"purge":1}) + if isinstance(delete_upid,str): + receipt["tasks"]["failure_cleanup_delete"]=wait_task(api,node,delete_upid,timeout=60.0) + else: + cleanup_errors.append(f"failure cleanup delete did not return UPID: {delete_upid!r}") + rows=api.get(base) + receipt["cleanup"]["absent"]=not any( + int(x.get("vmid",-1))==a.vmid for x in (rows or []) + ) + except (OSError,ValueError,ProxmoxProbeError) as cleanup_exc: + cleanup_errors.append(f"{type(cleanup_exc).__name__}: {cleanup_exc}") + if cleanup_errors: + receipt["cleanup"]["errors"]=cleanup_errors + return emit(2) + + +if __name__=="__main__": + raise SystemExit(main()) diff --git a/scripts/qualify_native_vanilla263_anvil_blocks.py b/scripts/qualify_native_vanilla263_anvil_blocks.py new file mode 100644 index 000000000..271e88750 --- /dev/null +++ b/scripts/qualify_native_vanilla263_anvil_blocks.py @@ -0,0 +1,209 @@ +#!/usr/bin/env python3 +"""Bounded independent Minecraft Anvil region *source* decoder, G4 candidate. + +Accepts raw region bytes only. Admission of bytes as live SERVER-OWNED world +requires a separate authenticated fixed-path native TrueNAS read at explicit +quiesced checkpoints. This module alone NEVER asserts backend world proof, +COLLAGE APPLY, authenticated clients or a successful guest. +""" +from __future__ import annotations + +import hashlib +import math +import struct +import zlib + +SCHEMA = "supracraft.native-vanilla263-server-owned-anvil/1" +MAX_REGION = 64 * 1024 * 1024 +MAX_CHUNK = 16 * 1024 * 1024 +MAX_ITEMS = 1_000_000 +COORDINATES = ((0, 70, 0), (2, 70, 0), (1, 70, 1)) +EXPECTED = { + "preaction": ("minecraft:air", "minecraft:iron_bars", "minecraft:black_concrete"), + "candidate": ("minecraft:redstone_block", "minecraft:air", "minecraft:sea_lantern"), + "rollback": ("minecraft:redstone_block", "minecraft:iron_bars", "minecraft:black_concrete"), +} + + +class Refused(ValueError): + pass + + +def require(ok, code): + if not ok: + raise Refused(code) + + +class NBT: + def __init__(self, data: bytes): + require(type(data) is bytes and 0 < len(data) <= MAX_CHUNK, "NBT_SIZE") + self.data, self.i = data, 0 + + def raw(self, n): + require(type(n) is int and 0 <= n <= len(self.data) - self.i, + "NBT_TRUNCATED") + result = self.data[self.i:self.i+n] + self.i += n + return result + + def num(self, fmt): + return struct.unpack(">" + fmt, self.raw(struct.calcsize(">" + fmt)))[0] + + def string(self): + n = self.num("H") + require(n <= 4096, "NBT_STRING_TOO_LONG") + try: + return self.raw(n).decode("utf-8", "strict") + except UnicodeDecodeError: + raise Refused("NBT_UTF8_INVALID") from None + + def payload(self, tag, depth=0): + require(depth <= 24, "NBT_RECURSION") + if tag == 1: return self.num("b") + if tag == 2: return self.num("h") + if tag == 3: return self.num("i") + if tag == 4: return self.num("q") + if tag == 5: return self.num("f") + if tag == 6: return self.num("d") + if tag == 8: return self.string() + if tag in (7, 11, 12): + n = self.num("i") + width, fmt = {7:(1,"b"), 11:(4,"i"), 12:(8,"q")}[tag] + require(0 <= n <= MAX_ITEMS and n*width <= len(self.data)-self.i, + "NBT_ARRAY_BOUNDS") + return [self.num(fmt) for _ in range(n)] + if tag == 9: + nested = self.num("B") + n = self.num("i") + require(1 <= nested <= 12 and 0 <= n <= MAX_ITEMS, + "NBT_LIST_BOUNDS") + return [self.payload(nested, depth+1) for _ in range(n)] + if tag == 10: + value = {} + for _ in range(MAX_ITEMS): + nested = self.num("B") + if nested == 0: return value + require(1 <= nested <= 12, "NBT_TAG_UNSUPPORTED") + name = self.string() + require(name not in value, "NBT_DUPLICATE_NAME") + value[name] = self.payload(nested, depth+1) + raise Refused("NBT_COMPOUND_TOO_LARGE") + raise Refused("NBT_TAG_UNSUPPORTED") + + def document(self): + require(self.num("B") == 10, "NBT_ROOT_NOT_COMPOUND") + self.string() + result = self.payload(10) + require(self.i == len(self.data), "NBT_TRAILING_BYTES") + return result + + +def chunk_from_region(blob: bytes): + require(type(blob) is bytes and 8192 <= len(blob) <= MAX_REGION, + "REGION_FILE_BOUNDS") + # Fixed region r.0.0.mca, chunk (0,0); no path or caller-controlled index. + entry = int.from_bytes(blob[0:4], "big") + sector, count = entry >> 8, entry & 255 + require(sector >= 2 and count > 0 and sector*4096 < len(blob) and + (sector+count)*4096 <= len(blob), "REGION_CHUNK_NOT_OWNED") + start = sector*4096 + n = int.from_bytes(blob[start:start+4], "big") + require(1 < n <= count*4096-4 and n <= MAX_CHUNK, + "REGION_CHUNK_LENGTH_INVALID") + # Only inline zlib Anvil: refuse external sidecars, other compressors, + # header overlap, malformed stream and zip bombs. + require(blob[start+4] == 2, "REGION_COMPRESSION_UNSUPPORTED") + packed = blob[start+5:start+4+n] + require(len(packed) == n-1, "REGION_TRUNCATED") + try: + inflater = zlib.decompressobj() + decoded = inflater.decompress(packed, MAX_CHUNK+1) + decoded += inflater.flush(MAX_CHUNK+1-len(decoded)) + require(inflater.eof and not inflater.unused_data and + not inflater.unconsumed_tail and 0 < len(decoded) <= MAX_CHUNK, + "REGION_DECOMPRESS_BOUNDS") + except (zlib.error, ValueError): + raise Refused("REGION_DECOMPRESS_BOUNDS") from None + root = NBT(decoded).document() + require(type(root) is dict and root.get("xPos") == 0 and root.get("zPos") == 0, + "REGION_WRONG_CHUNK") + return root + + +def block_at(root: dict, xyz: tuple[int, int, int]): + x, y, z = xyz + require(0 <= x <= 15 and 0 <= z <= 15 and y == 70, + "FIXTURE_COORDINATE_DRIFT") + sections = root.get("sections") + require(type(sections) is list and len(sections) <= 32, "SECTION_LIST_INVALID") + section_y = y // 16 + selected = [v for v in sections if type(v) is dict and v.get("Y") == section_y] + require(len(selected) == 1, "SECTION_ID_AMBIGUOUS") + states = selected[0].get("block_states") + require(type(states) is dict and type(states.get("palette")) is list, + "BLOCK_PALETTE_MISSING") + palette = states["palette"] + require(1 <= len(palette) <= 4096 and all(type(p) is dict and + type(p.get("Name")) is str and + p["Name"].startswith("minecraft:") for p in palette), + "BLOCK_PALETTE_INVALID") + if len(palette) == 1: + # A uniform section has no palette data. Nonempty data for uniform + # sections is unusual, but must still contain valid zero indices. + data = states.get("data", []) + else: + data = states.get("data") + require(type(data) is list and data, "BLOCK_DATA_REQUIRED") + require(type(data) is list and len(data) <= 1024 and + all(type(v) is int and -(1<<63) <= v < (1<<63) for v in data), + "BLOCK_DATA_INVALID") + if not data: + require(len(palette) == 1, "BLOCK_DATA_REQUIRED") + return palette[0]["Name"] + bits = max(4, math.ceil(math.log2(len(palette)))) + per_long = 64 // bits + idx = ((y & 15) << 8) | (z << 4) | x + position, shift = divmod(idx, per_long) + require(position < len(data), "BLOCK_DATA_TRUNCATED") + number = ((data[position] & ((1<<64)-1)) >> (shift*bits)) & ((1< dict: + require(stage in EXPECTED, "STAGE_NOT_ADMITTED") + root = chunk_from_region(region_bytes) + actual = tuple(block_at(root, c) for c in COORDINATES) + require(actual == EXPECTED[stage], "SERVER_BLOCK_CONSEQUENCE_ABSENT") + return { + "schema": SCHEMA, "classification": "UNTRUSTED_REGION_BYTES_DECODED_ONLY", + "stage": stage, "region_sha256": hashlib.sha256(region_bytes).hexdigest(), + "coordinates": [list(c) for c in COORDINATES], + "block_state_expectations_matched": True, + "block_states": list(actual), + "native_guest_server_owned_bytes_proven": False, + "server_quiescence_proven": False, + "backend_independent_world_oracle_pass": False, + "collage_mutation_authorized": False, + "physical_true_nas_apply": "HOLD", + } + + +def compare(candidate: bytes, rollback: bytes) -> dict: + require(type(candidate) is bytes and type(rollback) is bytes and + 8192 <= len(candidate) <= MAX_REGION and + 8192 <= len(rollback) <= MAX_REGION, "REGION_FILE_BOUNDS") + require(candidate != rollback, "CANDIDATE_ROLLBACK_SAME_REGION_REPLAY") + c, r = inspect(candidate, "candidate"), inspect(rollback, "rollback") + return { + "schema": SCHEMA, + "classification": "SOURCE_ONLY_DUAL_ANVIL_BLOCK_DECODER", + "candidate_region_sha256": c["region_sha256"], + "rollback_region_sha256": r["region_sha256"], + "block_states_differ_exactly_as_expected": True, + "server_origin_authenticated": False, + "world_quiescence_observed": False, + "backend_independent_world_oracle_pass": False, + "next_gate": "LIVE_QUIESCED_GUEST_FIXED_REGION_DOWNLOAD_AND_PREACTION_CONTROL", + "physical_true_nas_apply": "HOLD", + } diff --git a/scripts/qualify_native_vanilla263_anvil_guest_readback.py b/scripts/qualify_native_vanilla263_anvil_guest_readback.py new file mode 100644 index 000000000..6754c0168 --- /dev/null +++ b/scripts/qualify_native_vanilla263_anvil_guest_readback.py @@ -0,0 +1,157 @@ +#!/usr/bin/env python3 +"""Bounded G4 quiesced native world Anvil file readback seam (source-only). + +This is a fixed-path read-only adapter for the existing disposable native T6 +DDP session and its already admitted owned App/world. It has no network client, +credential issuance, provider mutation or disk export. Neither a synthetic +test nor a caller-provided bytes callback is an independent live guest receipt. +""" +from __future__ import annotations + +import re + +try: + from .qualify_native_vanilla263_anvil_blocks import ( + MAX_REGION, SCHEMA, Refused, inspect, require) + from .qualify_vanilla263_native_world_rollback import ( + APP, WORLD, app_state) +except ImportError: + from qualify_native_vanilla263_anvil_blocks import ( + MAX_REGION, SCHEMA, Refused, inspect, require) + from qualify_vanilla263_native_world_rollback import ( + APP, WORLD, app_state) + +# Minecraft Java 26.1 Snapshot 6 migrated the Overworld (including region) +# into dimensions/minecraft/overworld. The root region/ is legacy 1.21.11; +# never fall back there for the exact 26.3 signed source target. +REGION_DIRECTORY = WORLD + "/dimensions/minecraft/overworld/region" +PATH = REGION_DIRECTORY + "/r.0.0.mca" +REGION_BASENAME = "r.0.0.mca" +MAX_OWNED_REGION_ENTRIES = 4096 +STAGES = ("preaction", "candidate", "rollback") +# Only these literal, non-sensitive phases may escape as a public diagnostic. +PHASES = ( + "app_state_query", "app_stopped_verified", + "file_stat", "file_stat_verified", + "file_stat_listing_absent", "file_stat_listing_present", + "file_stat_listing_inconclusive", + "download_request", "download_route_verified", + "download_job", "download_job_completed", + "bytes_fetch", "bytes_verified", "anvil_decode", "anvil_verified", +) + + +def record_phase(stage: str, phase: str, reporter=None) -> None: + require(stage in STAGES and phase in PHASES, "BACKEND_PHASE_CODE_INVALID") + if reporter is not None: + reporter(stage + "." + phase) + + + +def classify_failed_stat(*, call, stage: str, on_phase=None) -> None: + """One extra fixed-path, read-only census after a failed native stat. + + This is a diagnostic, never a fallback source of world bytes or authority. + A missing directory, provider error or malformed list is INCONCLUSIVE. + No raw paths, listing names, provider responses or errors reach the caller. + """ + try: + rows = call("filesystem.listdir", [REGION_DIRECTORY]) + except Exception: + record_phase(stage, "file_stat_listing_inconclusive", on_phase) + raise Refused("BACKEND_REGION_STAT_CENSUS_INCONCLUSIVE") from None + if (type(rows) is not list or len(rows) > MAX_OWNED_REGION_ENTRIES + or any(type(row) is not dict + or type(row.get("name")) is not str + or type(row.get("type")) is not str + for row in rows)): + record_phase(stage, "file_stat_listing_inconclusive", on_phase) + raise Refused("BACKEND_REGION_STAT_CENSUS_INVALID") from None + matched = [row for row in rows if row["name"] == REGION_BASENAME] + if not matched: + record_phase(stage, "file_stat_listing_absent", on_phase) + raise Refused("BACKEND_REGION_NOT_LISTED_AFTER_STOP") from None + if len(matched) != 1 or matched[0]["type"] != "FILE": + record_phase(stage, "file_stat_listing_inconclusive", on_phase) + raise Refused("BACKEND_REGION_CENSUS_ENTRY_UNSAFE") from None + record_phase(stage, "file_stat_listing_present", on_phase) + raise Refused("BACKEND_REGION_STAT_REFUSED_BUT_FILE_LISTED") from None + + + +def validate_single_use_download(row: object) -> tuple[int, str]: + """Accept only the exact job-bound TrueNAS single-use download URL. + + Pinned truenas/middleware@244b717 service/core_service.py issues + /_download/{job.id}?auth_token={token}. file_app.py validates both + token and job ID. Never print or copy the bearer URL into a receipt. + This adapter cannot grant a token or broaden the underlying credential. + """ + require(type(row) is list and len(row) == 2 and + type(row[0]) is int and 0 < row[0] < 10**12 and + type(row[1]) is str and len(row[1]) < 400, + "BACKEND_DOWNLOAD_ROUTE_INVALID") + job, route = row + # One fixed-origin relative path; exactly one job-bound query parameter. + # No foreign authority, fragment, traversal, percent escapes or extra args. + require(re.fullmatch( + rf"/_download/{job}\?auth_token=[A-Za-z0-9_-]{{16,256}}", route + ) is not None, "BACKEND_DOWNLOAD_ROUTE_INVALID") + return job, route + + +def capture(*, call, wait_job, fetch_bytes, stage: str, on_phase=None) -> dict: + """Only after existing *actual* guest runner verified owned App then stop. + + The runner MUST independently bind the current authenticated DDP session, + exact native service/world, player action, signed request, and App stop. + It must not publish the raw Anvil region, signed session or download URL. + """ + require(stage in STAGES, "BACKEND_STAGE_NOT_ADMITTED") + record_phase(stage, "app_state_query", on_phase) + require(app_state(call) == "STOPPED", "BACKEND_QUIESCENCE_NOT_OBSERVED") + record_phase(stage, "app_stopped_verified", on_phase) + record_phase(stage, "file_stat", on_phase) + try: + stat = call("filesystem.stat", [PATH]) + except Exception: + # The 019 real guest died here with only RuntimeError. Distinguish a + # missing fixed region entry from a provider/stat refusal, without + # reflecting private middleware error strings or accepting a fallback. + classify_failed_stat(call=call, stage=stage, on_phase=on_phase) + raise AssertionError("unreachable") + require(type(stat) is dict and stat.get("type") == "FILE" and + type(stat.get("size")) is int and + 8192 <= stat["size"] <= MAX_REGION, "BACKEND_REGION_FILE_NOT_EXACT") + record_phase(stage, "file_stat_verified", on_phase) + record_phase(stage, "download_request", on_phase) + row = call("core.download", [ + "filesystem.get", [PATH], "g4-owned-region-" + stage + ".mca", True]) + job_id, single_use_route = validate_single_use_download(row) + record_phase(stage, "download_route_verified", on_phase) + record_phase(stage, "download_job", on_phase) + wait_job(job_id, "G4 read-only owned Anvil file download") + record_phase(stage, "download_job_completed", on_phase) + record_phase(stage, "bytes_fetch", on_phase) + blob = fetch_bytes(single_use_route) + require(type(blob) is bytes and len(blob) == stat["size"], + "BACKEND_DOWNLOADED_FILE_DRIFT") + record_phase(stage, "bytes_verified", on_phase) + record_phase(stage, "anvil_decode", on_phase) + result = inspect(blob, stage) + record_phase(stage, "anvil_verified", on_phase) + # The host transfer API is separate from independent receipt admission: + # this module cannot prove the caller ran under authorized guest identity. + result.update({ + "classification": "SOURCE_ONLY_OWNED_ANVIL_DOWNLOAD_SEAM", + "downloaded_file_size": len(blob), + "requested_native_app": APP, + "requested_native_world_file_fixed": True, + "requested_native_world": "rdtepool/minecraft-vanilla-263-t6-world", + "read_only_middleware_calls": True, + "live_guest_identity_independently_verified": False, + "backend_independent_world_oracle_pass": False, + "raw_region_published": False, + "next_gate": "EXACT_LIVE_GUEST_RUNNER_AND_INDEPENDENT_RECEIPT_BINDING", + }) + return result diff --git a/scripts/qualify_native_vanilla263_loopback_player_overlay.py b/scripts/qualify_native_vanilla263_loopback_player_overlay.py new file mode 100644 index 000000000..88bef894c --- /dev/null +++ b/scripts/qualify_native_vanilla263_loopback_player_overlay.py @@ -0,0 +1,157 @@ +#!/usr/bin/env python3 +"""Source-only derived offline player profile for one disposable native TrueNAS guest. + +The signed Foundry control is immutable. This is a separately named, +allowlisted RDTE overlay for live synthetic Mineflayer action tests, not a +general rendering service and never an operational configuration. +""" +from __future__ import annotations +import copy +import hashlib +import json +import re +import uuid +from typing import Any + +# Native guest imports from the scripts directory as top-level modules; +# repo-root cheap tests may also import the same module as scripts.*. +try: + from .admit_vanilla263_native_t6_control import ( + APP, CONTROL_SHA, OWNER, Refused, TARGET, verify, + ) +except ImportError: + from admit_vanilla263_native_t6_control import ( + APP, CONTROL_SHA, OWNER, Refused, TARGET, verify, + ) + +PROFILE = "minecraft-vanilla263-disposable-loopback-player/1" +BOT_NAME = "SupraCraftProbe" +# Minecraft offline UUIDv3 from MD5("OfflinePlayer:" + synthetic name). +# itzg/minecraft-server:2026.10.0 runs `mc-image-helper manage-users` +# before JVM boot; offline-mode OPS must be a UUID, not a non-Mojang name. +BOT_OFFLINE_UUID = "f4469b77-cc14-3c07-95f3-0b0c3f151bd2" +WORLD = "/mnt/rdtepool/minecraft-vanilla-263-t6-world" +HOST_FORWARD = "tcp:127.0.0.1:25580-:25580" + +def runner_owned_loopback_forward(value: object) -> int: + """Accept QEMU's allocated ephemeral host port, never broad guest ingress. + + The existing native T6 harness independently allocates an unused host port, + forwarded to the *guest* :25580 on QEMU user networking. This parser + checks only source shape; the live harness must still verify the actual + localhost listener belongs to the newly spawned disposable guest. + """ + require(type(value) is str and len(value) < 64, + "PLAYER_FORWARD_NOT_ISOLATED_LOOPBACK") + match = re.fullmatch(r"tcp:127[.]0[.]0[.]1:([1-9][0-9]{3,4})-:25580", value) + require(match is not None, "PLAYER_FORWARD_NOT_ISOLATED_LOOPBACK") + port = int(match.group(1)) + require(1024 < port < 65536 and port not in {25565, 443, 80}, + "PLAYER_FORWARD_NOT_ISOLATED_LOOPBACK") + return port + +def require(cond: bool, code: str) -> None: + if not cond: + raise Refused(code) + +def redacted_native_startup_census(app: object) -> dict[str, Any]: + """Bounded state/count only; never copy App metadata, paths, ports or logs. + + Frozen TrueNAS25.04.2.6 app.query reports container state through + active_workloads.container_details. An unhealthy App can remain DEPLOYING + (healthcheck STARTING) without ever raising a terminal job failure. + """ + allowed = {"RUNNING", "DEPLOYING", "STOPPED", "STOPPING", "CRASHED"} + container_states = {"created", "starting", "running", "exited", "crashed"} + state = app.get("state") if type(app) is dict else None + state = state if type(state) is str and state in allowed else "UNKNOWN" + active = app.get("active_workloads") if type(app) is dict else None + details = active.get("container_details") if type(active) is dict else None + if type(details) is not list: + details = [] + totals = {k: 0 for k in sorted(container_states)} + for item in details[:16]: + value = item.get("state") if type(item) is dict else None + if type(value) is str and value in totals: + totals[value] += 1 + return { + "native_app_state": state, + "container_states": totals, + "container_census_truncated": len(details) > 16, + } + + +def derive(control: dict, admission: dict) -> dict[str, Any]: + """Prepare a synthetic-world profile only; no RPC, filesystem, or guest.""" + verify(control) + require(type(admission) is dict and + admission.get("schema") == PROFILE and + admission.get("rdte_disposable") is True and + admission.get("experiment") == "offline-mineflayer-player-causal" and + admission.get("physical_truenas_apply") == "HOLD" and + admission.get("operational_selection") is False and + admission.get("external_ingress") is False and + admission.get("qemu_network") == "user" and + runner_owned_loopback_forward(admission.get("host_forward")) > 0 and + admission.get("guest_target") == "TrueNAS-" + TARGET and + admission.get("authorized_bot") == BOT_NAME and + set(admission) == { + "schema", "rdte_disposable", "experiment", + "physical_truenas_apply", "operational_selection", + "external_ingress", "qemu_network", "host_forward", + "guest_target", "authorized_bot", + }, "NOT_DISPOSABLE_LOOPBACK_PLAYER_ADMISSION") + values = copy.deepcopy(control["runtime"]["values_template"]) + mc = values["minecraft"] + require(mc["online_mode"] is True and + mc["enable_rcon"] is False and mc["rcon_password"] == "" and + mc["ops"] == [] and mc["whitelist"] == [], + "EXACT_BASELINE_AUTH_POLICY_DRIFT") + require(values["network"]["host_network"] is False and + values["network"]["server_port"]["bind_mode"] == "published" and + values["network"]["server_port"]["port_number"] == 25565, + "EXACT_BASELINE_NETWORK_POLICY_DRIFT") + require(values["storage"]["additional_storage"][0]["mount_path"] == "/data/world" and + {x["name"]: x["value"] for x in mc["additional_envs"]} == OWNER, + "EXACT_BASELINE_WORLD_OWNER_DRIFT") + # Only changes to gameplay authentication are the locally isolated + # synthetic client identity and granting that bot in-game commands. + # No RCON or remote management listener introduced, and all six + # COLLAGE owner labels remain exact. + require(str(uuid.UUID(bytes=hashlib.md5( + ("OfflinePlayer:" + BOT_NAME).encode("utf-8")).digest(), version=3)) + == BOT_OFFLINE_UUID, "DISPOSABLE_PLAYER_OFFLINE_UUID_DRIFT") + mc["online_mode"] = False + mc["ops"] = [BOT_OFFLINE_UUID] + # Identical site bindings as accepted guest native app probe. + values["storage"]["additional_storage"][0]["host_path_config"]["path"] = WORLD + values["network"]["server_port"]["port_number"] = 25580 + mc.pop("seed", None) if mc.get("seed") is None else None + require(mc["enable_rcon"] is False and mc["rcon_password"] == "" and + mc["whitelist"] == [] and mc["ops"] == [BOT_OFFLINE_UUID] and + mc["online_mode"] is False and + {x["name"]: x["value"] for x in mc["additional_envs"]} == OWNER, + "DISPOSABLE_PLAYER_OVERLAY_ESCAPED_ALLOWLIST") + digest=hashlib.sha256(json.dumps(values,sort_keys=True, + separators=(",",":")).encode()).hexdigest() + return { + "schema": PROFILE, + "classification": "SOURCE_ONLY_DISPOSABLE_PLAYER_VARIANT", + "immutable_base_control_sha256": CONTROL_SHA, + "app": APP, + "truenas_version": TARGET, + "minecraft_version": "26.3", + "protocol": 777, + "network": "QEMU_USER_LOOPBACK_ONLY_SOURCE_REQUIREMENT", + "host_forward": admission["host_forward"], + "host_port": runner_owned_loopback_forward(admission["host_forward"]), + "bot": BOT_NAME, + "derived_runtime_values_sha256": digest, + "values": values, + "online_account_auth_qualified": False, + "in_guest_bot_connected": False, + "datapack_effect_in_native_guest_qualified": False, + "collage_managed_deployment": False, + "physical_truenas_apply": "HOLD", + "operational_selection": False, + } diff --git a/scripts/qualify_native_vanilla263_mineflayer_causal.js b/scripts/qualify_native_vanilla263_mineflayer_causal.js new file mode 100644 index 000000000..cd0b61ee5 --- /dev/null +++ b/scripts/qualify_native_vanilla263_mineflayer_causal.js @@ -0,0 +1,123 @@ +'use strict' +// G4 source-only player worker for one isolated runner-owned QEMU native App. +// No Microsoft account, stock client, RCON, broader ingress, or TrueNAS API. +const fs = require('node:fs') +const mineflayer = require('mineflayer') +const { Vec3 } = require('vec3') + +const stage = process.env.SUPRACRAFT_NATIVE_STAGE +const prepare = stage === 'candidate-prep' +const port = Number(process.env.MC_PORT) +const output = process.env.BOT_RESULT_FILE +const admitted = ['candidate', 'rollback', 'candidate-prep'].includes(stage) && + Number.isSafeInteger(port) && port > 1024 && port < 65536 && + typeof output === 'string' && output.length > 0 +if (!admitted) process.exit(2) +const result = { + schema: 'supracraft.native-vanilla263-mineflayer-causal/1', + classification: 'CLIENT_NOT_QUALIFIED', + stage, requested_version: '26.3', + protocol: 777, offline_mode: true, + synthetic_bot: 'SupraCraftProbe', + operation: prepare ? 'mineflayer.chat.fixedFixture' : 'mineflayer.placeBlock', + rcon_used: false, + backend_independent_oracle_qualified: false, + true_nas_physical_apply: false, + collage_mutation_authorized: false, + in_guest_server_world: false, + fixture_via_bounded_bot_commands: false, + placed: false, visible_effect_correct: false +} +let done = false +let bot +function save () { fs.writeFileSync(output, JSON.stringify(result, null, 2) + '\n') } +function finish (ok, code) { + if (done) return + done = true + result.classification = ok ? (prepare ? 'CLIENT_FIXTURE_PREPARED' : 'CLIENT_CAUSAL_VISIBILITY') : 'CLIENT_REFUSED' + if (!ok) result.reason_code = code + save() + try { bot?.quit('synthetic native world test complete') } catch {} + setTimeout(() => process.exit(ok ? 0 : 1), 200) +} +function read (x, y, z) { + const block = bot.blockAt(new Vec3(x, y, z)) + return block ? block.name : null +} +try { + bot = mineflayer.createBot({ + host: '127.0.0.1', port, username: 'SupraCraftProbe', + auth: 'offline', version: '26.3' + }) +} catch (_) { finish(false, 'CLIENT_CONSTRUCT_REFUSED') } +bot?.on('error', () => finish(false, 'CLIENT_ERROR')) +bot?.on('kicked', () => finish(false, 'CLIENT_KICKED')) +bot?.on('end', () => { if (!done) finish(false, 'CLIENT_ENDED') }) +bot?.once('spawn', async () => { + try { + result.connected_and_spawned = true + result.negotiated_version = bot.version + result.negotiated_protocol = bot.protocolVersion + if (bot.version !== '26.3' || bot.protocolVersion !== 777) { + return finish(false, 'CLIENT_VERSION_DRIFT') + } + const commands = [ + '/forceload add 0 0', + '/fill -2 69 -2 4 69 3 minecraft:stone', + '/fill -2 70 -2 4 72 3 minecraft:air', + '/setblock 0 70 0 minecraft:air', + '/setblock 2 70 0 minecraft:iron_bars', + '/setblock 1 70 1 minecraft:black_concrete', + '/gamemode survival SupraCraftProbe', + '/give SupraCraftProbe minecraft:redstone_block 1', + '/tp SupraCraftProbe 0.5 70.0 2.5 180 0' + ] + // This single synthetic offline bot must be an OP under the exact + // source-admitted QEMU-only overlay, never under the normal online App. + for (const command of commands) { + bot.chat(command) + await bot.waitForTicks(8) + } + result.fixture_via_bounded_bot_commands = true + await bot.waitForTicks(20) + const before = read(0, 70, 0) + const support = read(0, 69, 0) + if (before !== 'air' || support !== 'stone') { + return finish(false, 'CLIENT_FIXTURE_NOT_PRESENT') + } + if (prepare) { + result.expected_datapack_present = true + result.visible_effect_correct = before === 'air' && support === 'stone' && + read(2, 70, 0) === 'iron_bars' && + read(1, 70, 1) === 'black_concrete' + return finish(result.visible_effect_correct, + 'CLIENT_FIXTURE_PREPARATION_FAILED') + } + const deadline = Date.now() + 20000 + let core + while (Date.now() < deadline) { + core = bot.inventory.items().find(item => item.name === 'redstone_block') + if (core) break + await bot.waitForTicks(2) + } + if (!core) return finish(false, 'CLIENT_ITEM_NOT_GRANTED') + await bot.equip(core, 'hand') + await bot.waitForTicks(6) + await bot.placeBlock(bot.blockAt(new Vec3(0, 69, 0)), new Vec3(0, 1, 0)) + await bot.waitForTicks(25) + const after = read(0, 70, 0) + const access = read(2, 70, 0) + const feedback = read(1, 70, 1) + result.placed = after === 'redstone_block' + result.visible_effect_correct = result.placed && + (stage === 'candidate' + ? access === 'air' && feedback === 'sea_lantern' + : access === 'iron_bars' && feedback === 'black_concrete') + result.expected_datapack_present = stage === 'candidate' + if (!result.visible_effect_correct) { + return finish(false, 'CLIENT_CAUSAL_EFFECT_ABSENT') + } + finish(true) + } catch (_) { finish(false, 'CLIENT_EXECUTION_REFUSED') } +}) +setTimeout(() => finish(false, 'CLIENT_TIMEOUT'), 120000) diff --git a/scripts/qualify_native_vanilla263_mineflayer_causal.py b/scripts/qualify_native_vanilla263_mineflayer_causal.py new file mode 100644 index 000000000..16a31cd4a --- /dev/null +++ b/scripts/qualify_native_vanilla263_mineflayer_causal.py @@ -0,0 +1,138 @@ +#!/usr/bin/env python3 +"""Protocol777 client-side causal action validator for disposable native TrueNAS. + +Source and narrow runner-local worker seam only. A Mineflayer packet is NOT +an independent server world oracle, authenticated production client or COLLAGE +deployment authority. Actual guest admission and backend proof remain separate. +""" +from __future__ import annotations + +import json +import os +from pathlib import Path +import subprocess + +try: + from .qualify_native_vanilla263_loopback_player_overlay import runner_owned_loopback_forward +except ImportError: + from qualify_native_vanilla263_loopback_player_overlay import runner_owned_loopback_forward + +SCHEMA = "supracraft.native-vanilla263-mineflayer-causal/1" +STAGES = ("candidate", "rollback", "candidate-prep") +WORKER = Path(__file__).with_name("qualify_native_vanilla263_mineflayer_causal.js") + + +class Refused(RuntimeError): + pass + + +def require(ok: bool, reason: str) -> None: + if not ok: + raise Refused(reason) + + +def validate(receipt: object, stage: str) -> dict: + if stage == "candidate-prep": + require(type(receipt) is dict and + receipt.get("schema") == SCHEMA and + receipt.get("classification") == "CLIENT_FIXTURE_PREPARED" and + receipt.get("stage") == stage and + receipt.get("requested_version") == "26.3" and + receipt.get("negotiated_version") == "26.3" and + type(receipt.get("protocol")) is int and receipt["protocol"] == 777 and + type(receipt.get("negotiated_protocol")) is int and + receipt["negotiated_protocol"] == 777 and + receipt.get("offline_mode") is True and + receipt.get("synthetic_bot") == "SupraCraftProbe" and + receipt.get("operation") == "mineflayer.chat.fixedFixture" and + receipt.get("connected_and_spawned") is True and + receipt.get("fixture_via_bounded_bot_commands") is True and + receipt.get("placed") is False and + receipt.get("visible_effect_correct") is True and + receipt.get("expected_datapack_present") is True and + receipt.get("rcon_used") is False and + receipt.get("backend_independent_oracle_qualified") is False and + receipt.get("true_nas_physical_apply") is False and + receipt.get("collage_mutation_authorized") is False and + receipt.get("in_guest_server_world") is False and + "error" not in receipt and "reason_code" not in receipt, + "G4_PREACTION_FIXTURE_UNQUALIFIED") + return { + "schema": SCHEMA, "classification": "FIXED_PLAYER_FIXTURE_PREPARATION_ONLY", + "stage": stage, "client_protocol777_connected_and_spawned": True, + "client_fixture_prepared_without_core_placement": True, + "native_guest_backend_independent_oracle_qualified": False, + "collage_mutation_authorized": False, + "physical_true_nas_apply": "HOLD", + } + require(stage in STAGES and type(receipt) is dict and + receipt.get("schema") == SCHEMA and + receipt.get("classification") == "CLIENT_CAUSAL_VISIBILITY" and + receipt.get("stage") == stage and + receipt.get("requested_version") == "26.3" and + receipt.get("negotiated_version") == "26.3" and + type(receipt.get("protocol")) is int and receipt["protocol"] == 777 and + type(receipt.get("negotiated_protocol")) is int and + receipt["negotiated_protocol"] == 777 and + receipt.get("offline_mode") is True and + receipt.get("synthetic_bot") == "SupraCraftProbe" and + receipt.get("operation") == "mineflayer.placeBlock" and + receipt.get("connected_and_spawned") is True and + receipt.get("fixture_via_bounded_bot_commands") is True and + receipt.get("placed") is True and + receipt.get("visible_effect_correct") is True and + receipt.get("expected_datapack_present") is (stage == "candidate") and + receipt.get("rcon_used") is False and + receipt.get("backend_independent_oracle_qualified") is False and + receipt.get("true_nas_physical_apply") is False and + receipt.get("collage_mutation_authorized") is False and + receipt.get("in_guest_server_world") is False and + "error" not in receipt and "reason_code" not in receipt, + "NATIVE_OFFLINE_PLAYER_CLIENT_CAUSAL_UNQUALIFIED") + return { + "schema": SCHEMA, + "classification": "CLIENT_CAUSAL_VISIBILITY_ONLY", + "stage": stage, + "client_protocol777_connected_and_spawned": True, + "offline_synthetic_player_caused_core_placement": True, + "client_saw_expected_datapack_effect": stage == "candidate", + "client_saw_expected_no_datapack_effect": stage == "rollback", + "native_guest_backend_independent_oracle_qualified": False, + "stock_mojang_client_qualified": False, + "online_account_qualified": False, + "collage_mutation_authorized": False, + "physical_true_nas_apply": "HOLD", + } + + +def run(*, stage: str, forward: str, state_dir: Path, node_path: Path) -> dict: + require(stage in STAGES, "UNSUPPORTED_CLIENT_STAGE") + host_port = runner_owned_loopback_forward(forward) + require(isinstance(state_dir, Path) and state_dir.is_dir() and not state_dir.is_symlink(), + "PLAYER_STATE_OWNER_MISSING") + require(isinstance(node_path, Path) and node_path.is_dir() and + not node_path.is_symlink(), "PINNED_CLIENT_DEPENDENCIES_MISSING") + require(WORKER.is_file() and not WORKER.is_symlink(), + "PINNED_CLIENT_WORKER_MISSING") + output = state_dir / ("native-mineflayer-" + stage + ".json") + require(not output.exists() and not output.is_symlink(), + "PLAYER_RECEIPT_REPLAY_REFUSED") + env = dict(os.environ, MC_PORT=str(host_port), + SUPRACRAFT_NATIVE_STAGE=stage, BOT_RESULT_FILE=str(output), + NODE_PATH=str(node_path)) + try: + result = subprocess.run( + ["node", str(WORKER)], cwd=str(WORKER.parent), env=env, + capture_output=True, text=True, timeout=145, check=False, + ) + except (OSError, subprocess.TimeoutExpired): + raise Refused("NATIVE_CLIENT_RUNTIME_UNAVAILABLE") from None + # Never expose the worker's stdout/stderr, packets, kicks or provider errors. + require(result.returncode == 0 and output.is_file() and + not output.is_symlink() and 0 < output.stat().st_size < 8192, + "NATIVE_CLIENT_RUNTIME_REFUSED") + try: + doc = json.loads(output.read_text(encoding="utf-8")) + except (ValueError, UnicodeError, OSError): + raise Refused("NATIVE_CLIENT_RECEIPT_UNREADABLE") from None + return validate(doc, stage) diff --git a/scripts/qualify_native_vanilla263_offline_ops_image.sh b/scripts/qualify_native_vanilla263_offline_ops_image.sh new file mode 100644 index 000000000..8197f81e0 --- /dev/null +++ b/scripts/qualify_native_vanilla263_offline_ops_image.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +# Source-level preflight of the exact disposable G4 synthetic OP conversion. +# Does not launch Minecraft, a TrueNAS guest, or an operational server. +set -euo pipefail +IMAGE='ghcr.io/itzg/minecraft-server:2026.10.0-java25@sha256:5a2ab74ae179c11e2dbbce67f307cdcf2c1257b3abb0f654d3a310b3d8ea57da' +UUID='f4469b77-cc14-3c07-95f3-0b0c3f151bd2' +NAME='SupraCraftProbe' +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +command -v docker >/dev/null || { echo 'PRECHECK_DOCKER_MISSING'; exit 2; } +command -v python3 >/dev/null || { echo 'PRECHECK_PYTHON_MISSING'; exit 2; } +tmp="$(mktemp -d)" +trap 'rm -rf "$tmp"' EXIT +mkdir -p "$tmp/out" +python3 - "$ROOT" "$UUID" "$NAME" <<'PY' +import hashlib, pathlib, sys, uuid +root,expected,name=sys.argv[1:] +value=str(uuid.UUID(bytes=hashlib.md5(("OfflinePlayer:"+name).encode()).digest(),version=3)) +assert value==expected, "synthetic offline identity changed" +src=(pathlib.Path(root)/"scripts/qualify_native_vanilla263_loopback_player_overlay.py").read_text() +assert f'BOT_OFFLINE_UUID = "{expected}"' in src, "disposable overlay drift" +assert 'mc["ops"] = [BOT_OFFLINE_UUID]' in src, "unresolved username used as OPS" +PY +# No platform or provider credentials; no raw container startup logs/identities. +# Bounded actual pinned-image helper: do not substitute a source-only JSON mock. +timeout 240s docker pull "$IMAGE" >/dev/null 2>&1 || { + echo 'IMAGE_PULL_NOT_QUALIFIED'; exit 2; +} +timeout 90s docker run --rm --network none --user 0:0 \ + -e ONLINE_MODE=FALSE -v "$tmp/out:/out" \ + --entrypoint mc-image-helper "$IMAGE" \ + manage-users --type=JAVA_OPS --version=26.3 \ + --existing=SYNCHRONIZE --output-directory=/out "$UUID" \ + >/dev/null 2>&1 || { + echo 'PINNED_OFFLINE_UUID_IMAGE_HELPER_REFUSED'; exit 2; +} +python3 - "$tmp/out/ops.json" "$UUID" <<'PY' +import json, pathlib, sys, uuid +p=pathlib.Path(sys.argv[1]) +assert p.is_file() and 0 < p.stat().st_size < 8192, "no bounded operators file" +doc=json.loads(p.read_text()) +assert type(doc) is list and len(doc)==1, "not exactly one synthetic OP" +entry=doc[0] +assert type(entry) is dict and uuid.UUID(entry["uuid"])==uuid.UUID(sys.argv[2]), "wrong offline identity" +assert entry["level"]==4 and entry["bypassesPlayerLimit"] is False, "unexpected operator privileges" +PY +echo 'PINNED_OFFLINE_UUID_IMAGE_HELPER_SOURCE_QUALIFIED' diff --git a/scripts/qualify_vanilla263_native_admission.py b/scripts/qualify_vanilla263_native_admission.py new file mode 100644 index 000000000..f45795061 --- /dev/null +++ b/scripts/qualify_vanilla263_native_admission.py @@ -0,0 +1,169 @@ +#!/usr/bin/env python3 +"""Fail-closed read-only admission edge for distinct vanilla 26.3 native guest. + +Only evaluates immutable Foundry control and exact public RDTE target registry. +It neither dispatches a workflow nor contacts or mutates TrueNAS. +""" +from __future__ import annotations +import argparse +import hashlib +import json +from pathlib import Path +import re + +SCHEMA="semper-supra.minecraft-vanilla-263-truenas-t6-control/1" +REQUEST="gha-kvm-truenas-run-request/v1" +VERSION="25.04.2.6" +IMAGE=("ghcr.io/itzg/minecraft-server:2026.10.0-java25@sha256:" + "5a2ab74ae179c11e2dbbce67f307cdcf2c1257b3abb0f654d3a310b3d8ea57da") +APPS="95b0cc78e75fcd4b128f4ef2f8428034d981f7c2" +BLOB="631467044411c96a0fe6d361688b99a794f07c80" +MIDDLEWARE="244b717370fe35fb9eefc3096acfbc25f12b57b6" +ISSUE=627 +OWNER={"SUPRACRAFT_COLLAGE_MANAGED":"true", + "SUPRACRAFT_COLLAGE_SCHEMA":"1", + "SUPRACRAFT_COLLAGE_MANAGER_ID":"manager-rdte", + "SUPRACRAFT_COLLAGE_FLEET_ID":"rdte", + "SUPRACRAFT_COLLAGE_SERVICE_ID":"11111111-1111-4111-8111-111111111111", + "SUPRACRAFT_COLLAGE_WORLD_ID":"lantern-rdte"} + + +class Refused(RuntimeError): + pass + + +def require(ok:bool, code:str): + if not ok: raise Refused(code) + + +def sha(doc): + return hashlib.sha256(json.dumps(doc,sort_keys=True,separators=(",",":")).encode()).hexdigest() + + +def check(request:dict, control:dict, registry:dict)->dict: + require(type(request) is dict and type(control) is dict and type(registry) is dict, + "INVALID_DOCUMENT") + require(set(request)=={"schema","request_id","version","rung","product", + "authority_issue","consumer_authority_issue","purpose"}, + "REQUEST_SHAPE_DRIFT") + require(request.get("schema")==REQUEST and request.get("version")==VERSION + and request.get("rung")=="t6" and + request.get("product")=="minecraft-vanilla-263" and + request.get("authority_issue")==423 and + request.get("consumer_authority_issue")==ISSUE and + isinstance(request.get("purpose"),str) and len(request["purpose"])>=20, + "WRONG_PRODUCT_TARGET_OR_AUTHORITY") + ident=request.get("request_id") + require(type(ident) is str and re.fullmatch("[a-z0-9][a-z0-9-]{9,99}",ident) + and ident.startswith("tn-25-04-2-6-t6-vanilla-263-") + and not any(x in ident for x in ("p2-005","paper","replay")), + "REUSED_OR_AMBIGUOUS_REQUEST_ID") + require(control.get("schema")==SCHEMA and + type(control.get("foundry_ref")) is str and + re.fullmatch("[a-f0-9]{40}",control["foundry_ref"]), + "FOUNDRY_CONTROL_AUTHORITY_UNVERIFIED") + c=dict(control) + stated=c.pop("control_sha256",None) + require(type(stated) is str and re.fullmatch("[a-f0-9]{64}",stated) and + stated==sha(c),"FOUNDRY_CONTROL_CANONICAL_SHA_MISMATCH") + t=control.get("target") or {} + s=control.get("source") or {} + runtime=control.get("runtime") or {} + evidence=control.get("evidence") or {} + require(t.get("truenas_version")==VERSION and + t.get("expected_system_version")=="TrueNAS-"+VERSION and + t.get("middleware_commit")==MIDDLEWARE and + t.get("profile_git_blob_sha")==BLOB, + "TRUENAS_TARGET_PROFILE_DRIFT") + require(s.get("apps_commit")==APPS and + s.get("catalog_app")=="community/minecraft" and + s.get("catalog_version")=="1.15.31" and + s.get("image_reference")==IMAGE and + type(s.get("source_compose_sha256")) is str and + re.fullmatch("[a-f0-9]{64}",s["source_compose_sha256"]), + "UPSTREAM_CATALOG_IMAGE_OR_SOURCE_DRIFT") + require(runtime.get("minecraft_version")=="26.3" and + runtime.get("minecraft_protocol")==777 and + runtime.get("java_version")==25 and + runtime.get("image_selector")=="j25_image" and + runtime.get("catalog_version_candidate")=="1.15.31" and + runtime.get("app_name")=="rdte-t6-minecraft-vanilla-263" and + runtime.get("required_independent_world_dataset")== + "rdtepool/minecraft-vanilla-263-t6-world", + "NATIVE_RUNTIME_OR_WORLD_IDENTITY_DRIFT") + v=runtime.get("values_template") or {} + game=v.get("minecraft") or {} + extra=game.get("additional_envs") + require(type(extra) is list and len(extra)==len(OWNER) and + all(type(x) is dict and set(x)=={"name","value"} for x in extra) and + {x["name"]:x["value"] for x in extra}==OWNER, + "OWNER_SERVICE_OR_REALM_DRIFT") + require(game.get("version")=="26.3" and game.get("type")=="VANILLA" and + game.get("image_selector")=="j25_image" and + game.get("online_mode") is True and game.get("enable_rcon") is False, + "NATIVE_APP_VALUES_DRIFT") + mounts=v.get("storage",{}).get("additional_storage",[]) + require(type(mounts) is list and len(mounts)==1 and + mounts[0].get("mount_path")=="/data/world" and + mounts[0].get("type")=="host_path" and + mounts[0].get("read_only") is False and + mounts[0].get("host_path_config",{}).get("path")== + "/opt/tests/collage-vanilla-263-world", + "EXTERNAL_WORLD_DATASET_DRIFT") + require(v.get("network",{}).get("host_network") is False, + "UNSAFE_NETWORK_BOUNDARY") + require(evidence.get("source_render_pass") is True and + all(evidence.get(x) is False for x in ( + "live_catalog_version_verified","native_guest_created", + "gameplay_qualified_by_native_guest", + "external_world_retention_qualified_by_native_guest", + "collage_managed_deployment_qualified", + "physical_truenas_qualified")) and + control.get("operational_selection") is False and + control.get("physical_truenas_apply")=="HOLD", + "UNSUPPORTED_GUEST_OR_PHYSICAL_CLAIM") + target=next((x for x in registry.get("targets",[]) if type(x) is dict and + x.get("version")==VERSION),None) + require(type(target) is dict and + target.get("system_version")=="TrueNAS-"+VERSION and + target.get("middleware_commit")==MIDDLEWARE and + target.get("foundry_profile_blob")==BLOB and + target.get("authority_issue")==423 and + registry.get("policy",{}).get("heavyweight_slot")=="shared-one-at-a-time" and + registry.get("policy",{}).get("vendor_digest_sidecar_required") is True, + "PUBLIC_RUNNER_TARGET_REGISTRY_DRIFT") + return {"schema":"supracraft.minecraft-vanilla263-native-t6-admission/1", + "classification":"SOURCE_CONTROL_READY_GUEST_NOT_DISPATCHED", + "request_id":ident,"consumer_authority_issue":ISSUE, + "target_version":VERSION,"foundry_ref":control["foundry_ref"], + "control_sha256":stated,"oci_image":IMAGE, + "native_app_created":False,"guest_run_requested":False, + "provider_observed":False,"realm_admission_qualified":False, + "physical_truenas_apply":"HOLD", + "next_gate":"EXACT_NATIVE_GUEST_CONSUMER_SOURCE_AND_NEW_REQUEST_EDGE"} + + +def main(): + p=argparse.ArgumentParser() + for arg in ("request","control","registry","out"): + p.add_argument("--"+arg,required=True,type=Path) + a=p.parse_args() + try: + ret=check(json.loads(a.request.read_text()), + json.loads(a.control.read_text()), + json.loads(a.registry.read_text())) + code=0 + except (ValueError,OSError,Refused,TypeError) as exc: + ret={"schema":"supracraft.minecraft-vanilla263-native-t6-admission/1", + "classification":"REFUSED","reason":str(exc)[:150], + "guest_run_requested":False,"physical_truenas_apply":"HOLD"} + code=2 + a.out.parent.mkdir(parents=True,exist_ok=True) + a.out.write_text(json.dumps(ret,sort_keys=True,indent=2)+"\n") + print(json.dumps({"classification":ret["classification"], + "reason":ret.get("reason")},sort_keys=True)) + return code + + +if __name__=="__main__": + raise SystemExit(main()) diff --git a/scripts/qualify_vanilla263_native_lantern_stage.py b/scripts/qualify_vanilla263_native_lantern_stage.py new file mode 100644 index 000000000..811a4f7b9 --- /dev/null +++ b/scripts/qualify_vanilla263_native_lantern_stage.py @@ -0,0 +1,139 @@ +#!/usr/bin/env python3 +"""Bounded actual Lantern ZIP stage into ONE disposable TrueNAS vanilla26.3 ZFS world. + +No untrusted archive extraction, no general file manager or world controller, +no player semantics and no physical TrueNAS authority. The native T6 owner +probe supplies authenticated guest-only middleware callbacks. +""" +from __future__ import annotations + +import hashlib +import json +from pathlib import Path, PurePosixPath +import zipfile + +SCHEMA = "supracraft.native-vanilla263-lantern-artifact-stage/1" +PRODUCER = "SupraCraft/mc-server-images@7f888b5829f4e5576d5ce512d86327348c43f01e" +WORLD = "/mnt/rdtepool/minecraft-vanilla-263-t6-world" +NAME = "lantern-restoration-26.3.zip" +SHA = "da25005e8729e6f013f7108ac5b8869dd46323befc78efdae18221b26cafe5f8" +TARGET = WORLD + "/datapacks/" + NAME +MEMBERS = { + "pack.mcmeta", + "data/minecraft/tags/function/tick.json", + "data/supracraft_core_restore/function/tick.mcfunction", +} +MAX_ZIP = 1024 * 1024 + +class Refused(RuntimeError): + pass + +def require(test: bool, code: str) -> None: + if not test: + raise Refused(code) + +def verify_exact_candidate(blob_path: Path, manifest_path: Path) -> tuple[bytes,dict]: + require(blob_path.is_file() and not blob_path.is_symlink() + and manifest_path.is_file() and not manifest_path.is_symlink(), + "PINNED_REAL_ARTIFACT_MISSING") + require(0 < blob_path.stat().st_size < MAX_ZIP + and 0 < manifest_path.stat().st_size < 65536, + "PINNED_REAL_ARTIFACT_SIZE_OUT_OF_BOUNDS") + m = json.loads(manifest_path.read_text(encoding="utf-8")) + require(type(m) is dict and + m.get("schema") == "supracraft-vanilla-datapack-candidate/1" and + m.get("classification") == "SOURCE_ARTIFACT_EXPORTED" and + m.get("producer") == PRODUCER and + m.get("kind") == "datapack" and m.get("backend") == "vanilla" and + m.get("minecraft_version") == "26.3" and + m.get("world_id") == "lantern-rdte" and + m.get("release_id") == "lantern-core-restore-26-3" and + m.get("pack_format") == [121, 0] and + m.get("relative_path") == NAME and + m.get("world_save_included") is False and + m.get("collage_deployed") is False, + "PINNED_REAL_ARTIFACT_CONTRACT_DRIFT") + content = blob_path.read_bytes() + require(hashlib.sha256(content).hexdigest() == SHA and + m.get("sha256") == SHA and + m.get("archive_size") == len(content), + "PINNED_REAL_ARTIFACT_SHA_DRIFT") + require(type(m.get("members")) is list and len(m["members"]) == 3, + "PINNED_REAL_ARTIFACT_MEMBERS_INVALID") + rows = m["members"] + require(all(type(r) is dict and set(r) == {"path","sha256","bytes"} and + type(r.get("path")) is str and r["path"] in MEMBERS and + isinstance(r.get("sha256"),str) and + type(r.get("bytes")) is int for r in rows) and + {r["path"] for r in rows} == MEMBERS, + "PINNED_REAL_ARTIFACT_MEMBER_DECLARATION_DRIFT") + expected = {r["path"]: r for r in rows} + with zipfile.ZipFile(blob_path) as z: + info = z.infolist() + require(len(info) == len(MEMBERS) and + {f.filename for f in info} == MEMBERS, + "PINNED_REAL_ZIP_MEMBER_SET_DRIFT") + for row in info: + require(not row.is_dir() and not (row.flag_bits & 1) and + (row.external_attr >> 16) & 0o170000 == 0o100000 and + row.file_size <= 128*1024 and + not row.filename.startswith("/") and + ".." not in PurePosixPath(row.filename).parts, + "PINNED_REAL_ZIP_MEMBER_UNSAFE") + member = z.read(row) + want = expected[row.filename] + require(len(member) == want["bytes"] and + hashlib.sha256(member).hexdigest() == want["sha256"], + "PINNED_REAL_ZIP_MEMBER_SHA_DRIFT") + metadata = json.loads(z.read("pack.mcmeta")) + require(metadata.get("pack",{}).get("min_format") == [121,0] and + metadata.get("pack",{}).get("max_format") == [121,0], + "PINNED_REAL_ZIP_PACK_FORMAT_DRIFT") + return content, {"schema":SCHEMA, "producer":PRODUCER, "sha256":SHA, + "size":len(content), "world":"lantern-rdte", + "release":"lantern-core-restore-26-3", + "target": "disposable_native_truenas_25.04.2.6"} + +def stage(call, wait_job, multipart_upload, http_bytes, *, + host: str, port: int, tls: bool, username: str, password: str, + timeout: float, blob: bytes, evidence: dict) -> dict: + """Stage solely while exact native App is STOPPED. No filesystem shell.""" + require(type(blob) is bytes and hashlib.sha256(blob).hexdigest() == SHA and + evidence.get("sha256") == SHA, "UNVERIFIED_ARTIFACT_BYTES") + rows = call("app.query", [[["id", "=", "rdte-t6-minecraft-vanilla-263"]]]) + require(type(rows) is list and len(rows) == 1 and + rows[0].get("state") == "STOPPED", + "NATIVE_APP_MUST_BE_QUIESCED_BEFORE_STAGE") + parent = call("filesystem.stat", [WORLD + "/datapacks"]) + require(type(parent) is dict and parent.get("type") == "DIRECTORY", + "OWNED_WORLD_DATAPACK_DIRECTORY_ABSENT") + try: + current = call("filesystem.stat", [TARGET]) + except Exception: + current = None + require(current is None, "REFUSE_TO_OVERWRITE_EXISTING_NATIVE_WORLD_ARTIFACT") + upload_job = multipart_upload(host,port,tls,username,password,TARGET,blob,0o644,timeout) + wait_job(upload_job,"Lantern datapack exact native ZFS world file stage") + observed = readback(call,wait_job,http_bytes,host=host,port=port,tls=tls,timeout=timeout) + require(observed == SHA, "NATIVE_WORLD_ARTIFACT_UPLOAD_READBACK_SHA_DRIFT") + return {"schema":SCHEMA,"classification":"REAL_NATIVE_GUEST_ARTIFACT_BYTES_STAGED", + "artifact_sha256":SHA,"artifact_size":len(blob), + "world_id":"lantern-rdte","release_id":"lantern-core-restore-26-3", + "native_truenas_version":"25.04.2.6", + "native_app":"rdte-t6-minecraft-vanilla-263", + "zfs_world_owned":True,"app_quiesced_before_write":True, + "download_readback_sha256":observed, + "artifact_gameplay_effect_verified":False, + "collage_managed_deployment":False,"principal_authorized_to_apply":False, + "physical_truenas_apply":"HOLD"} + +def readback(call,wait_job,http_bytes, *,host:str,port:int,tls:bool,timeout:float)->str: + row = call("core.download",["filesystem.get",[TARGET],"lantern-stage-sha-readback.zip",True]) + require(type(row) is list and len(row) == 2 and type(row[0]) is int and + isinstance(row[1],str) and row[1].startswith("/_download/") and + ".." not in row[1], "NATIVE_WORLD_ARTIFACT_DOWNLOAD_REF_INVALID") + wait_job(row[0],"native Lantern artifact byte readback") + actual = http_bytes(host,port,tls,row[1],timeout=timeout) + require(type(actual) is bytes and len(actual) <= MAX_ZIP, + "NATIVE_WORLD_ARTIFACT_READBACK_OVERSIZED") + return hashlib.sha256(actual).hexdigest() diff --git a/scripts/qualify_vanilla263_native_world_rollback.py b/scripts/qualify_vanilla263_native_world_rollback.py new file mode 100644 index 000000000..d8d424fda --- /dev/null +++ b/scripts/qualify_vanilla263_native_world_rollback.py @@ -0,0 +1,125 @@ +#!/usr/bin/env python3 +"""Bounded G3 native-world rollback mechanics for an isolated TrueNAS RDTE guest. + +This is NOT a COLLAGE-authorized APPLY, online player authentication, generic +snapshot manager, production NAS operator, or evidence of a successful guest. +The existing native T6 runner owns the authenticated session, exact App/world, +signed artifact, stop/start calls, lifecycle receipts and final owned cleanup. +This module supplies only fixed identity/snapshot/rollback preconditions. +""" +from __future__ import annotations + +# Native guest executes scripts as top-level modules; public unit tests import +# them through the scripts package. Share the same pinned artifact constants. +try: + from .qualify_vanilla263_native_lantern_stage import WORLD, TARGET, SHA, NAME +except ImportError: + from qualify_vanilla263_native_lantern_stage import WORLD, TARGET, SHA, NAME + +APP = "rdte-t6-minecraft-vanilla-263" +DATASET = "rdtepool/minecraft-vanilla-263-t6-world" +SNAPSHOT = DATASET + "@op4g-pre-lantern" +SCHEMA = "supracraft.native-vanilla263-rdte-rollback-mechanics/1" + + +class Refused(RuntimeError): + pass + + +def require(value, code): + if not value: + raise Refused(code) + + +def app_state(call): + rows = call("app.query", [[["id", "=", APP]]]) + require(type(rows) is list and len(rows) == 1 and + type(rows[0]) is dict and rows[0].get("name", APP) == APP, + "G3_APP_OWNER_NOT_EXACT") + state = rows[0].get("state") + require(state in {"STOPPED", "RUNNING"}, "G3_APP_UNSAFE_STATE") + return state + + +def _snapshot_rows(call): + rows = call("zfs.snapshot.query", [[["id", "=", SNAPSHOT]]]) + require(type(rows) is list and len(rows) <= 1 and + all(type(row) is dict and row.get("id") == SNAPSHOT for row in rows), + "G3_SNAPSHOT_QUERY_AMBIGUOUS") + return rows + + +def _artifact_present(call): + rows = call("filesystem.listdir", [WORLD + "/datapacks"]) + require(type(rows) is list and len(rows) <= 4096 and all( + type(row) is dict and type(row.get("name")) is str and + len(row["name"]) < 260 for row in rows), + "G3_DATA_PACK_INVENTORY_UNSAFE") + # Compare only exact basename; no traversal, glob or deletion. + return any(row["name"] == NAME for row in rows) + + +def take_owned_pre_stage_snapshot(call): + """Called only after existing T6 owner App is verified STOPPED. + + Must precede the actual signed 739-byte ZIP stage. Refuse preexisting + snapshots and world release conflicts; don't rename foreign snapshots. + """ + require(app_state(call) == "STOPPED", "G3_QUIESCE_REQUIRED") + require(not _artifact_present(call), "G3_CANDIDATE_ALREADY_PRESENT") + require(_snapshot_rows(call) == [], "G3_SNAPSHOT_REPLAY_REFUSED") + row = call("zfs.snapshot.create", [{ + "dataset": DATASET, "name": SNAPSHOT.rsplit("@", 1)[1], + "recursive": False, "exclude": [], "suspend_vms": False, + "vmware_sync": False, + }]) + require(type(row) is dict and row.get("id") == SNAPSHOT, + "G3_SNAPSHOT_RESULT_NOT_EXACT") + require(len(_snapshot_rows(call)) == 1, "G3_SNAPSHOT_NOT_DURABLE") + return { + "schema": SCHEMA, "phase": "baseline_snapshotted", + "classification": "G3_DISPOSABLE_MECHANICS_ONLY", + "snapshot_owned": True, "snapshot_id_verified": True, + "candidate_sha256": SHA, "candidate_present_before_stage": False, + "world_activation_authorized_by_collage": False, + "actual_player_gameplay_verified": False, + "physical_true_nas_mutated": False, + } + + +def restore_owned_baseline_after_injected_fault(call, wait_job, wait_app, wait_status): + """Fixed rollback after independent native byte stage and app re-install. + + Caller independently verifies exact ZIP bytes and healthy running App. + This function injects an explicit failed release decision: never promote. + Stop -> rollback exact owned snapshot -> restart -> health recheck. + """ + require(app_state(call) == "RUNNING", "G3_PREFAULT_APP_NOT_RUNNING") + require(_artifact_present(call), "G3_CANDIDATE_MISSING_PREFAULT") + require(len(_snapshot_rows(call)) == 1, "G3_SNAPSHOT_NOT_DURABLE") + wait_job(call("app.stop", [APP]), "G3 pre-rollback quiesce") + require(app_state(call) == "STOPPED", "G3_STOP_UNCONFIRMED") + response = call("zfs.snapshot.rollback", [SNAPSHOT, { + "recursive": False, "recursive_clones": False, "force": False, + "recursive_rollback": False, + }]) + require(response is None or response is True, "G3_ROLLBACK_REFUSED") + require(not _artifact_present(call), "G3_ROLLBACK_LEFT_CANDIDATE") + wait_job(call("app.start", [APP]), "G3 after-rollback resume") + wait_app() + wait_status("g3-rollback") + require(app_state(call) == "RUNNING", "G3_RESTORED_APP_NOT_RUNNING") + require(not _artifact_present(call), "G3_RESTART_REINTRODUCED_CANDIDATE") + require(len(_snapshot_rows(call)) == 1, "G3_ROLLBACK_SNAPSHOT_NOT_DURABLE") + return { + "schema": SCHEMA, "phase": "injected_fault_rolled_back", + "classification": "G3_DISPOSABLE_MECHANICS_ONLY", + "snapshot_id_verified": True, "release_fault_injected": True, + "candidate_absent_after_rollback": True, + "native_app_running_after_rollback": True, + "native_game_status_rechecked": True, + "candidate_sha256": SHA, "collage_managed_deployment": False, + "world_activation_authorized_by_collage": False, + "actual_player_gameplay_verified": False, + "physical_true_nas_mutated": False, + } diff --git a/scripts/truenas_compute_container_probe.py b/scripts/truenas_compute_container_probe.py new file mode 100644 index 000000000..30a15770d --- /dev/null +++ b/scripts/truenas_compute_container_probe.py @@ -0,0 +1,468 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import json +import pathlib +import time +from typing import Any + +try: + from scripts.compute_materialization_contract import ContractError, choose_adapter, load, target, validate + from scripts.truenas_middleware_ddp_probe import WebSocket, ddp_call, wait_for +except ModuleNotFoundError: + from compute_materialization_contract import ContractError, choose_adapter, load, target, validate + from truenas_middleware_ddp_probe import WebSocket, ddp_call, wait_for + + +class ProbeError(RuntimeError): + pass + + +def normalize_system_version(value: Any) -> str: + if not isinstance(value, str) or not value: + raise ProbeError(f"invalid system.version: {value!r}") + return value.removeprefix("TrueNAS-") + + +def observed_state(row: dict[str, Any] | None) -> str: + if not row: + return "ABSENT" + value = row.get("status") + if isinstance(value, str): + return value + if isinstance(value, dict) and isinstance(value.get("state"), str): + return value["state"] + return "UNKNOWN" + + +def build_create_payload( + adapter_id: str, + name: str, + nonce: str, + *, + legacy_image: str | None = None, + image_name: str | None = None, + image_version: str | None = None, + pool: str | None = None, +) -> dict[str, Any]: + if adapter_id == "truenas-virt-incus-container": + if not legacy_image: + raise ProbeError("legacy Incus adapter requires --legacy-image") + payload: dict[str, Any] = { + "name": name, + "source_type": "IMAGE", + "image": legacy_image, + "remote": "LINUX_CONTAINERS", + "instance_type": "CONTAINER", + "autostart": False, + "environment": {"RDTE_NONCE": nonce, "RDTE_GENERATION": "1"}, + "memory": 256 * 1024 * 1024, + } + if pool: + payload["storage_pool"] = pool + return payload + if adapter_id == "truenas-container-lxc": + if not image_name or not image_version: + raise ProbeError("LXC adapter requires --image-name and --image-version") + payload = { + "name": name, + "description": "Agent Dispatch disposable C0 compute-materialization fixture", + "autostart": False, + "time": "UTC", + "initenv": {"RDTE_NONCE": nonce, "RDTE_GENERATION": "1"}, + "idmap": {"type": "DEFAULT"}, + "capabilities_policy": "DEFAULT", + "image": {"name": image_name, "version": image_version}, + } + if pool: + payload["pool"] = pool + return payload + raise ProbeError(f"unsupported container adapter: {adapter_id}") + + +def resolve_image_identity( + adapter: dict[str, Any], + call, + *, + legacy_image: str | None = None, + image_name: str | None = None, + image_version: str | None = None, +) -> tuple[dict[str, str], dict[str, Any]]: + adapter_id = adapter.get("id") + discovery = adapter.get("image_discovery") + if not isinstance(discovery, dict): + raise ProbeError(f"{adapter_id}: image discovery contract missing") + method = discovery.get("method") + selection = discovery.get("selection") + if not isinstance(method, str) or not isinstance(selection, dict): + raise ProbeError(f"{adapter_id}: invalid image discovery contract") + + if adapter_id == "truenas-virt-incus-container": + request = discovery.get("request") + if not isinstance(request, dict): + raise ProbeError("legacy image discovery request missing") + choices = call(method, [request]) + if not isinstance(choices, dict): + raise ProbeError(f"legacy image choices invalid: {choices!r}") + alias = selection.get("alias") + if not isinstance(alias, str) or not alias: + raise ProbeError("legacy image alias contract missing") + if legacy_image is not None and legacy_image != alias: + raise ProbeError(f"explicit legacy image {legacy_image!r} does not match admitted alias {alias!r}") + observed = choices.get(alias) + if not isinstance(observed, dict): + raise ProbeError(f"admitted legacy image alias unavailable: {alias!r}") + if selection.get("instance_type") not in set(observed.get("instance_types") or []): + raise ProbeError(f"legacy image lacks required instance type: {observed!r}") + if selection.get("arch") not in set(observed.get("archs") or []): + raise ProbeError(f"legacy image lacks required architecture: {observed!r}") + return {"legacy_image": alias}, { + "method": method, + "source": discovery.get("source"), + "selected": {"alias": alias}, + "observed": observed, + } + + if adapter_id == "truenas-container-lxc": + rows = call(method, []) + if not isinstance(rows, list): + raise ProbeError(f"container image registry response invalid: {rows!r}") + name = selection.get("name") + if not isinstance(name, str) or not name: + raise ProbeError("LXC image name contract missing") + if image_name is not None and image_name != name: + raise ProbeError(f"explicit image name {image_name!r} does not match admitted name {name!r}") + observed = next((x for x in rows if isinstance(x, dict) and x.get("name") == name), None) + if not isinstance(observed, dict): + raise ProbeError(f"admitted LXC image name unavailable: {name!r}") + versions = [ + x.get("version") for x in (observed.get("versions") or []) + if isinstance(x, dict) and isinstance(x.get("version"), str) and x.get("version") + ] + if not versions: + raise ProbeError(f"admitted LXC image has no exact versions: {observed!r}") + chosen = image_version or versions[-1] + if chosen not in versions: + raise ProbeError(f"requested LXC image version {chosen!r} not returned by registry") + return {"image_name": name, "image_version": chosen}, { + "method": method, + "source": discovery.get("source"), + "selected": {"name": name, "version": chosen}, + "available_versions": versions, + } + + raise ProbeError(f"unsupported container adapter for image discovery: {adapter_id}") + + +def update_payload(adapter_id: str, nonce: str) -> dict[str, Any]: + if adapter_id == "truenas-virt-incus-container": + return {"environment": {"RDTE_NONCE": nonce, "RDTE_GENERATION": "2"}} + if adapter_id == "truenas-container-lxc": + return { + "description": "Agent Dispatch disposable C0 compute-materialization fixture generation 2", + "initenv": {"RDTE_NONCE": nonce, "RDTE_GENERATION": "2"}, + } + raise ProbeError(f"unsupported container adapter: {adapter_id}") + + +def method_shapes(adapter_id: str, name: str, row_id: Any | None = None) -> dict[str, tuple[str, list[Any], bool]]: + if adapter_id == "truenas-virt-incus-container": + return { + "query": ("virt.instance.query", [[["id", "=", name]]], False), + "create": ("virt.instance.create", [], True), + "update": ("virt.instance.update", [name], True), + "start": ("virt.instance.start", [name], True), + "stop": ("virt.instance.stop", [name, {"timeout": 30, "force": True}], True), + "restart": ("virt.instance.restart", [name, {"timeout": 30, "force": True}], True), + "delete": ("virt.instance.delete", [name], True), + } + if adapter_id == "truenas-container-lxc": + if row_id is None: + query = ("container.query", [[["name", "=", name]]], False) + return {"query": query} + return { + "query": ("container.query", [[["name", "=", name]]], False), + "create": ("container.create", [], True), + "update": ("container.update", [row_id], False), + "start": ("container.start", [row_id], False), + "stop": ("container.stop", [row_id, {"force_after_timeout": True}], True), + "delete": ("container.delete", [row_id], False), + } + raise ProbeError(f"unsupported container adapter: {adapter_id}") + + +def main() -> int: + p = argparse.ArgumentParser() + p.add_argument("--registry", type=pathlib.Path, default=pathlib.Path("config/compute-materialization-targets.json")) + p.add_argument("--target-version", required=True) + p.add_argument("--name", default="rdte-compute-container-c0") + p.add_argument("--nonce", default="agent-dispatch-container-c0-v1") + p.add_argument("--legacy-image") + p.add_argument("--image-name") + p.add_argument("--image-version") + p.add_argument("--pool") + p.add_argument("--host", default="127.0.0.1") + p.add_argument("--port", type=int) + p.add_argument("--tls", action="store_true") + p.add_argument("--password-file", type=pathlib.Path) + p.add_argument("--timeout", type=float, default=8.0) + p.add_argument("--job-timeout", type=float, default=600.0) + p.add_argument("--state-timeout", type=float, default=180.0) + p.add_argument("--apply", action="store_true") + p.add_argument("--out", type=pathlib.Path) + a = p.parse_args() + + receipt: dict[str, Any] = { + "schema": "truenas-compute-container-c0/v1", + "classification": "ORACLE_FAILURE", + "oracleSatisfied": False, + "c0_oracle_satisfied": False, + "c1_guest_oracle_satisfied": False, + "target_version_requested": a.target_version, + "name": a.name, + "nonce": a.nonce, + "states": [], + "jobs": {}, + "cleanup": {"attempted": False, "absent": False}, + "claim_boundary": "C0 native API lifecycle only; guest-level C1 nonce/service oracle remains separately required", + } + ws = None + call = None + adapter_id = None + row_id = None + + def emit() -> int: + payload = json.dumps(receipt, indent=2, sort_keys=True) + "\n" + if a.out: + a.out.write_text(payload, encoding="utf-8") + print(payload, end="") + return 0 if receipt.get("oracleSatisfied") else 2 + + try: + registry = load(a.registry) + validate(registry) + row = target(registry, "truenas", a.target_version) + receipt["source_fingerprint"] = row["source_fingerprint"] + receipt["api_schema_fingerprint"] = row["api_schema_fingerprint"] + + if not a.apply: + receipt.update({ + "classification": "SUPPORTED", + "oracleSatisfied": True, + "phase": "plan-only", + "detail": "C0 probe contract validated; apply was not requested", + "apply_authorized": False, + }) + return emit() + + if not a.port or not a.password_file: + raise ProbeError("--apply requires --port and --password-file") + password = a.password_file.read_text(encoding="utf-8").strip() + if not password: + raise ProbeError("password file was empty") + + ws = WebSocket(a.host, a.port, timeout=a.timeout, tls=a.tls) + ws.send_json({"msg": "connect", "version": "1", "support": ["1"]}) + connected = wait_for(ws, lambda m: m.get("msg") in {"connected", "failed"}) + if connected.get("msg") != "connected": + raise ProbeError(f"DDP connection failed: {connected!r}") + request_id = 1 + + def _call(method: str, params: list[Any]): + nonlocal request_id + result = ddp_call(ws, str(request_id), method, params) + request_id += 1 + return result + call = _call + + auth = call("auth.login_ex", [{ + "mechanism": "PASSWORD_PLAIN", + "username": "truenas_admin", + "password": password, + }]) + if not isinstance(auth, dict) or auth.get("response_type") != "SUCCESS": + raise ProbeError("authentication did not return SUCCESS") + + system_version = call("system.version", []) + receipt["system_version"] = system_version + exact = normalize_system_version(system_version) + if exact != a.target_version: + raise ProbeError(f"target mismatch: expected {a.target_version}, observed {exact}") + + method_map = call("core.get_methods", []) + if not isinstance(method_map, dict): + raise ProbeError("core.get_methods did not return a method map") + adapter = choose_adapter(registry, "truenas", exact, "container", set(method_map)) + adapter_id = adapter["id"] + receipt["adapter"] = adapter + + resolved_image, image_resolution = resolve_image_identity( + adapter, call, + legacy_image=a.legacy_image, image_name=a.image_name, image_version=a.image_version, + ) + receipt["image_resolution"] = image_resolution + create_payload = build_create_payload( + adapter_id, a.name, a.nonce, + pool=a.pool, **resolved_image, + ) + receipt["desired_create"] = create_payload + + def query_one(): + shapes = method_shapes(adapter_id, a.name, row_id) + method, params, _ = shapes["query"] + rows = call(method, params) + return rows[0] if isinstance(rows, list) and rows else None + + def wait_job(job_id: Any, label: str): + if isinstance(job_id, bool) or not isinstance(job_id, int): + raise ProbeError(f"{label} did not return job id: {job_id!r}") + deadline = time.monotonic() + a.job_timeout + while time.monotonic() < deadline: + job = call("core.get_jobs", [[["id", "=", job_id]], {"get": True}]) + if job: + state = job.get("state") + if state == "SUCCESS": + receipt["jobs"][label] = {"id": job_id, "state": state} + return + if state in {"FAILED", "ABORTED"}: + raise ProbeError(f"{label} job {state}: {job.get('error') or job.get('exception')}") + time.sleep(1) + raise ProbeError(f"{label} job timeout") + + def invoke(label: str, params_tail: list[Any] | None = None): + shapes = method_shapes(adapter_id, a.name, row_id) + method, base, job_backed = shapes[label] + params = list(base) + if params_tail: + params.extend(params_tail) + result = call(method, params) + if job_backed: + wait_job(result, label) + return result + + def wait_state(expected: str, label: str): + deadline = time.monotonic() + a.state_timeout + last = None + while time.monotonic() < deadline: + last = query_one() + if observed_state(last) == expected: + receipt["states"].append({"label": label, "state": expected}) + return last + time.sleep(1) + raise ProbeError(f"{label} did not reach {expected}: {last!r}") + + if query_one() is not None: + raise ProbeError(f"preexisting instance {a.name!r} blocks ownership-safe apply") + + shapes = method_shapes(adapter_id, a.name) + create_method = shapes.get("create", (adapter["required_methods"][1], [], True))[0] + create_result = call(create_method, [create_payload]) + wait_job(create_result, "create") + current = query_one() + if not current: + raise ProbeError("created container not observable") + if adapter_id == "truenas-container-lxc": + row_id = current.get("id") + if not isinstance(row_id, int): + raise ProbeError(f"26.x container id missing: {row_id!r}") + receipt["states"].append({"label": "created", "state": observed_state(current)}) + + invoke("start") + wait_state("RUNNING", "start") + + invoke("stop") + wait_state("STOPPED", "stop-before-update") + + invoke("update", [update_payload(adapter_id, a.nonce)]) + updated = query_one() + if adapter_id == "truenas-virt-incus-container": + if (updated or {}).get("environment", {}).get("RDTE_GENERATION") != "2": + raise ProbeError("legacy environment update did not read back generation 2") + else: + if (updated or {}).get("initenv", {}).get("RDTE_GENERATION") != "2": + raise ProbeError("LXC initenv update did not read back generation 2") + receipt["update_readback"] = "RDTE_GENERATION=2" + + invoke("start") + wait_state("RUNNING", "post-update-start") + + if adapter_id == "truenas-virt-incus-container": + invoke("restart") + wait_state("RUNNING", "restart") + else: + invoke("stop") + wait_state("STOPPED", "restart-stop") + invoke("start") + wait_state("RUNNING", "restart-start") + + invoke("stop") + wait_state("STOPPED", "final-stop") + invoke("delete") + deadline = time.monotonic() + a.state_timeout + while time.monotonic() < deadline and query_one() is not None: + time.sleep(1) + receipt["cleanup"] = {"attempted": True, "absent": query_one() is None} + if not receipt["cleanup"]["absent"]: + raise ProbeError("container remained after delete") + + receipt.update({ + "classification": "SUPPORTED", + "oracleSatisfied": True, + "c0_oracle_satisfied": True, + "phase": "c0-api-lifecycle", + "detail": "native TrueNAS container API lifecycle, mutation readback and zero-residue cleanup passed", + }) + return emit() + except (ProbeError, ContractError, OSError, RuntimeError, ValueError) as exc: + receipt["detail"] = f"{type(exc).__name__}: {exc}" + if a.apply and call is not None and adapter_id: + receipt["cleanup"]["attempted"] = True + try: + current = None + shapes = method_shapes(adapter_id, a.name, row_id) + qmethod, qparams, _ = shapes["query"] + rows = call(qmethod, qparams) + current = rows[0] if isinstance(rows, list) and rows else None + if current: + if observed_state(current) == "RUNNING": + method, params, job_backed = shapes["stop"] + result = call(method, params) + if job_backed and isinstance(result, int) and not isinstance(result, bool): + # Bounded cleanup does not wait forever; the main evidence already records failure. + deadline = time.monotonic() + min(a.job_timeout, 60) + while time.monotonic() < deadline: + job = call("core.get_jobs", [[["id", "=", result]], {"get": True}]) + if job and job.get("state") in {"SUCCESS", "FAILED", "ABORTED"}: + break + time.sleep(1) + rows = call(qmethod, qparams) + current = rows[0] if isinstance(rows, list) and rows else None + if current: + if adapter_id == "truenas-container-lxc": + row_id = current.get("id") + shapes = method_shapes(adapter_id, a.name, row_id) + method, params, job_backed = shapes["delete"] + result = call(method, params) + if job_backed and isinstance(result, int) and not isinstance(result, bool): + deadline = time.monotonic() + min(a.job_timeout, 60) + while time.monotonic() < deadline: + job = call("core.get_jobs", [[["id", "=", result]], {"get": True}]) + if job and job.get("state") in {"SUCCESS", "FAILED", "ABORTED"}: + break + time.sleep(1) + rows = call(qmethod, qparams) + receipt["cleanup"]["absent"] = not rows + except Exception as cleanup_exc: + receipt["cleanup"]["error"] = f"{type(cleanup_exc).__name__}: {cleanup_exc}" + return emit() + finally: + if ws is not None: + try: + ws.close() + except Exception: + pass + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/truenas_compute_vm_probe.py b/scripts/truenas_compute_vm_probe.py new file mode 100644 index 000000000..4ad6f6a3d --- /dev/null +++ b/scripts/truenas_compute_vm_probe.py @@ -0,0 +1,471 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import json +import pathlib +import re +import time +from typing import Any + +try: + from scripts.compute_materialization_contract import ContractError, choose_adapter, load, target, validate + from scripts.truenas_middleware_ddp_probe import WebSocket, ddp_call, wait_for +except ModuleNotFoundError: + from compute_materialization_contract import ContractError, choose_adapter, load, target, validate + from truenas_middleware_ddp_probe import WebSocket, ddp_call, wait_for + + +class ProbeError(RuntimeError): + pass + + +def normalize_system_version(value: Any) -> str: + if not isinstance(value, str) or not value: + raise ProbeError(f"invalid system.version: {value!r}") + return value.removeprefix("TrueNAS-") + + +def native_vm_create_payload(name: str, generation: int = 1) -> dict[str, Any]: + # TrueNAS vm.* validates names against ^[a-zA-Z_0-9]+$ before create. + if not re.fullmatch(r"[A-Za-z0-9_]+", name): + raise ProbeError("native TrueNAS VM name must contain only letters, digits, or underscore") + return { + "name": name, + "description": f"Agent Dispatch disposable VM V0 fixture generation {generation}", + "vcpus": 1, + "cores": 1, + "threads": 1, + "memory": 512, + "cpu_mode": "HOST-MODEL", + "autostart": False, + "ensure_display_device": False, + "time": "UTC", + "bootloader": "UEFI", + "trusted_platform_module": False, + "hyperv_enlightenments": False, + "enable_secure_boot": False, + "shutdown_timeout": 30, + } + + +def legacy_vm_create_payload(name: str, image: str, generation: int = 1) -> dict[str, Any]: + if not image: + raise ProbeError("legacy Incus VM adapter requires --legacy-image") + return { + "name": name, + "source_type": "IMAGE", + "image": image, + "remote": "LINUX_CONTAINERS", + "instance_type": "VM", + "autostart": False, + "environment": {"RDTE_GENERATION": str(generation)}, + "memory": 512 * 1024 * 1024, + "root_disk_size": 5, + "root_disk_io_bus": "VIRTIO-BLK", + "secure_boot": False, + "enable_vnc": False, + } + + +def owned_zvol_device(vm_id: int, zvol_name: str, size_bytes: int) -> dict[str, Any]: + if not zvol_name or "/" not in zvol_name: + raise ProbeError("owned ZVOL name must include a pool/dataset prefix") + return { + "vm": vm_id, + "attributes": { + "dtype": "DISK", + "path": None, + "type": "VIRTIO", + "create_zvol": True, + "zvol_name": zvol_name, + "zvol_volsize": size_bytes, + }, + "order": 1000, + } + + +def zvol_device_path(zvol_name: str) -> str: + if not zvol_name or "/" not in zvol_name: + raise ProbeError("owned ZVOL name must include a pool/dataset prefix") + return "/dev/zvol/" + zvol_name.replace(" ", "+") + + +def legacy_owned_nic(name: str, parent: str) -> dict[str, Any]: + if not name or not parent: + raise ProbeError("legacy owned NIC requires name and observed parent") + return { + "name": name, + "description": "Agent Dispatch disposable VM V0 owned NIC", + "readonly": False, + "dev_type": "NIC", + "network": None, + "nic_type": "MACVLAN", + "parent": parent, + } + + +def validate_modern_preconditions(details: Any, licensed: Any) -> dict[str, Any]: + if not isinstance(details, dict) or not isinstance(details.get("supported"), bool): + raise ProbeError(f"vm.virtualization_details invalid: {details!r}") + if not isinstance(licensed, bool): + raise ProbeError(f"vm.license_active invalid: {licensed!r}") + if not details["supported"]: + raise ProbeError(f"VM virtualization unavailable: {details.get('error')}") + if not licensed: + raise ProbeError("system is not entitled to use native vm.*") + return {"virtualization_details": details, "license_active": licensed} + + +def validate_public_modern_preconditions( + details: Any, product_type: Any, enterprise_feature_enabled: Any +) -> dict[str, Any]: + if not isinstance(details, dict) or not isinstance(details.get("supported"), bool): + raise ProbeError(f"vm.virtualization_details invalid: {details!r}") + if not details["supported"]: + raise ProbeError(f"VM virtualization unavailable: {details.get('error')}") + if product_type not in {"COMMUNITY_EDITION", "ENTERPRISE"}: + raise ProbeError(f"system.product_type invalid: {product_type!r}") + if product_type == "ENTERPRISE": + if not isinstance(enterprise_feature_enabled, bool): + raise ProbeError( + f"system.feature_enabled enterprise result invalid: {enterprise_feature_enabled!r}" + ) + if not enterprise_feature_enabled: + raise ProbeError("enterprise system is not entitled to use native vm.*") + elif enterprise_feature_enabled is not None: + raise ProbeError("community edition must not depend on enterprise feature licensing") + return { + "virtualization_details": details, + "product_type": product_type, + "enterprise_vm_feature_enabled": enterprise_feature_enabled, + } + + +def main() -> int: + p=argparse.ArgumentParser() + p.add_argument("--registry",type=pathlib.Path,default=pathlib.Path("config/compute-materialization-targets.json")) + p.add_argument("--target-version",required=True) + p.add_argument("--name",default="rdtecomputevmv0") + p.add_argument("--legacy-image") + p.add_argument("--zvol-name") + p.add_argument("--zvol-size-bytes",type=int,default=2*1024*1024*1024) + p.add_argument("--host",default="127.0.0.1") + p.add_argument("--port",type=int) + p.add_argument("--tls",action="store_true") + p.add_argument("--password-file",type=pathlib.Path) + p.add_argument("--timeout",type=float,default=8.0) + p.add_argument("--job-timeout",type=float,default=600.0) + p.add_argument("--apply",action="store_true") + p.add_argument("--out",type=pathlib.Path) + a=p.parse_args() + + receipt={ + "schema":"truenas-compute-vm-v0/v1", + "classification":"ORACLE_FAILURE", + "oracleSatisfied":False, + "v0_oracle_satisfied":False, + "v1_guest_oracle_satisfied":False, + "v2_firecracker_oracle_satisfied":False, + "windows_w1_oracle_satisfied":False, + "target_version_requested":a.target_version, + "name":a.name, + "cleanup":{"attempted":False,"vm_absent":False,"device_absent":False,"zvol_absent":False}, + "preconditions":{}, + "claim_boundary":"V0 native VM/device lifecycle only; Linux guest, Firecracker and Windows 11 remain separate rungs", + } + ws=None + call=None + vm_id=None + device_id=None + adapter_id=None + + def emit()->int: + payload=json.dumps(receipt,indent=2,sort_keys=True)+"\n" + if a.out: + a.out.write_text(payload,encoding="utf-8") + print(payload,end="") + return 0 if receipt.get("oracleSatisfied") else 2 + + try: + registry=load(a.registry) + validate(registry) + row=target(registry,"truenas",a.target_version) + receipt["source_fingerprint"]=row["source_fingerprint"] + receipt["api_schema_fingerprint"]=row["api_schema_fingerprint"] + + if not a.apply: + receipt.update({ + "classification":"SUPPORTED", + "oracleSatisfied":True, + "phase":"plan-only", + "detail":"V0 probe contract validated; apply was not requested", + "apply_authorized":False, + }) + return emit() + + if not a.port or not a.password_file: + raise ProbeError("--apply requires --port and --password-file") + password=a.password_file.read_text(encoding="utf-8").strip() + if not password: + raise ProbeError("password file was empty") + + ws=WebSocket(a.host,a.port,timeout=a.timeout,tls=a.tls) + ws.send_json({"msg":"connect","version":"1","support":["1"]}) + connected=wait_for(ws,lambda m:m.get("msg") in {"connected","failed"}) + if connected.get("msg")!="connected": + raise ProbeError(f"DDP connection failed: {connected!r}") + request_id=1 + + def _call(method:str,params:list[Any]): + nonlocal request_id + result=ddp_call(ws,str(request_id),method,params) + request_id+=1 + return result + call=_call + + def wait_job(job_id: Any, label: str, timeout: float | None = None): + if not isinstance(job_id, int) or isinstance(job_id, bool): + raise ProbeError(f"{label} did not return job id: {job_id!r}") + deadline=time.monotonic()+(timeout if timeout is not None else a.job_timeout) + while time.monotonic() str: + h = hashlib.sha256() + with path.open("rb") as f: + for chunk in iter(lambda: f.read(1024 * 1024), b""): + h.update(chunk) + return h.hexdigest() + + +def stage_paths(pool: str, stem: str = "rdtev1") -> dict[str, str]: + if not pool or "/" in pool or not pool.replace("_", "").isalnum(): + raise V1ProbeError("pool name violates V1 staging contract") + dataset = f"{pool}/{stem}stage" + mount = f"/mnt/{dataset}" + boot_zvol = f"{pool}/{stem}boot" + return { + "dataset": dataset, + "raw_image": f"{mount}/cirros-0.6.3-x86_64.raw", + "seed_iso": f"{mount}/seed.iso", + "boot_zvol": boot_zvol, + "boot_zvol_path": f"/dev/zvol/{boot_zvol}", + } + + +def zvol_boot_device(vm_id: int, zvol_name: str, size_bytes: int = 1024 * 1024 * 1024) -> dict[str, Any]: + if not zvol_name or "/" not in zvol_name: + raise V1ProbeError("V1 boot ZVOL name must include pool prefix") + return { + "vm": vm_id, + "attributes": { + "dtype": "DISK", + "path": None, + "type": "VIRTIO", + "create_zvol": True, + "zvol_name": zvol_name, + "zvol_volsize": size_bytes, + }, + "order": 100, + } + + +def seed_cdrom_device(vm_id: int, path: str) -> dict[str, Any]: + return { + "vm": vm_id, + "attributes": {"dtype": "CDROM", "path": path}, + "order": 1000, + } + + +def multipart_upload_body(remote_path: str, filename: str, content: bytes, boundary: str) -> bytes: + request = json.dumps( + {"method": "filesystem.put", "params": [remote_path]}, + separators=(",", ":"), + ).encode() + parts = [ + b"--" + boundary.encode() + b"\r\n" + b'Content-Disposition: form-data; name="data"\r\n' + b"Content-Type: application/json\r\n\r\n" + request + b"\r\n", + b"--" + boundary.encode() + b"\r\n" + + f'Content-Disposition: form-data; name="file"; filename="{filename}"\r\n'.encode() + + b"Content-Type: application/octet-stream\r\n\r\n" + + content + b"\r\n", + b"--" + boundary.encode() + b"--\r\n", + ] + return b"".join(parts) + + +def upload_file(host: str, port: int, username: str, password: str, local: pathlib.Path, + remote: str, tls: bool, timeout: float) -> int: + boundary = "agentdispatchv1upload" + body = multipart_upload_body(remote, local.name, local.read_bytes(), boundary) + auth = base64.b64encode(f"{username}:{password}".encode()).decode() + if tls: + context = ssl.create_default_context() + context.check_hostname = False + context.verify_mode = ssl.CERT_NONE + conn = http.client.HTTPSConnection(host, port, timeout=timeout, context=context) + else: + conn = http.client.HTTPConnection(host, port, timeout=timeout) + try: + conn.request( + "POST", + "/_upload/", + body=body, + headers={ + "Authorization": f"Basic {auth}", + "Content-Type": f"multipart/form-data; boundary={boundary}", + "Content-Length": str(len(body)), + }, + ) + response = conn.getresponse() + payload = response.read() + if response.status // 100 != 2: + raise V1ProbeError(f"filesystem.put upload HTTP {response.status}: {payload[:1000]!r}") + decoded = json.loads(payload.decode()) + job_id = decoded.get("job_id") + if not isinstance(job_id, int) or isinstance(job_id, bool): + raise V1ProbeError(f"filesystem.put upload returned invalid job id: {decoded!r}") + return job_id + finally: + conn.close() + + +def wait_job(ws: WebSocket, job_id: int, timeout: float, label: str) -> Any: + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + row = ddp_call(ws, f"job-{label}-{job_id}", "core.get_jobs", [[["id", "=", job_id]], {"get": True}]) + if row and row.get("state") == "SUCCESS": + return row.get("result") + if row and row.get("state") in {"FAILED", "ABORTED"}: + raise V1ProbeError(f"{label} job {row.get('state')}: {row.get('error') or row.get('exception')}") + time.sleep(1) + raise V1ProbeError(f"{label} job timed out") + + +def plan(pool: str, name: str, nonce: str) -> dict[str, Any]: + validate_token(nonce, NONCE_RE, "nonce") + paths = stage_paths(pool) + return { + "schema": "truenas-compute-vm-v1-plan/v1", + "name": name, + "staging": paths, + "vm_create": native_vm_create_payload(name), + "devices": [ + zvol_boot_device(0, paths["boot_zvol"]), + seed_cdrom_device(0, paths["seed_iso"]), + ], + "media_lowering": { + "source": paths["raw_image"], + "destination": paths["boot_zvol_path"], + "method": "vm.device.convert", + }, + "oracle": { + "surface": "/websocket/shell", + "nonce": f"AGENT_DISPATCH_V1_NONCE={nonce}", + }, + "claim_boundary": "pinned Linux guest boot + exact external nonce only", + } + + +def run_apply(a: argparse.Namespace) -> dict[str, Any]: + validate_token(a.nonce, NONCE_RE, "nonce") + source_sha = sha256_file(a.source_image) + if source_sha != PINNED_CIRROS_SHA256: + raise V1ProbeError(f"source CirrOS digest mismatch: {source_sha}") + raw_sha = sha256_file(a.raw_image) + seed_sha = sha256_file(a.seed_iso) + password = a.password_file.read_text(encoding="utf-8").strip() + paths = stage_paths(a.pool) + receipt: dict[str, Any] = { + "schema": "truenas-compute-vm-v1/v1", + "classification": "ORACLE_FAILURE", + "oracleSatisfied": False, + "v1_oracle_satisfied": False, + "source": { + "cirros_release": "0.6.3", + "cirros_sha256": source_sha, + "converted_raw_sha256": raw_sha, + "seed_iso_sha256": seed_sha, + }, + "staging": paths, + "cleanup": { + "attempted": False, + "vm_absent": False, + "boot_zvol_absent": False, + "staging_dataset_absent": False, + }, + "claim_boundary": "V1 Linux guest boot + exact external nonce only; nested KVM, Firecracker and Windows remain separate", + } + ws: WebSocket | None = None + vm_id: int | None = None + boot_device_id: int | None = None + stage_owned = False + try: + ws = WebSocket(a.host, a.port, timeout=a.timeout, tls=a.tls) + ws.send_json({"msg": "connect", "version": "1", "support": ["1"]}) + connected = wait_for(ws, lambda m: m.get("msg") in {"connected", "failed"}) + if connected.get("msg") != "connected": + raise V1ProbeError(f"DDP connection failed: {connected!r}") + auth = ddp_call(ws, "auth", "auth.login_ex", [{ + "mechanism": "PASSWORD_PLAIN", + "username": a.username, + "password": password, + }]) + if not isinstance(auth, dict) or auth.get("response_type") != "SUCCESS": + raise V1ProbeError(f"authentication failed: {auth!r}") + version = normalize_system_version(ddp_call(ws, "version", "system.version", [])) + if version != a.target_version: + raise V1ProbeError(f"target version mismatch: observed={version} expected={a.target_version}") + + if ddp_call(ws, "pre-vm", "vm.query", [[["name", "=", a.name]]]): + raise V1ProbeError(f"preexisting VM {a.name!r} blocks ownership-safe V1") + if ddp_call(ws, "pre-stage", "pool.dataset.query", [[["id", "=", paths["dataset"]]]]): + raise V1ProbeError(f"preexisting staging dataset {paths['dataset']!r} blocks ownership-safe V1") + if ddp_call(ws, "pre-boot-zvol", "pool.dataset.query", [[["id", "=", paths["boot_zvol"]]]]): + raise V1ProbeError(f"preexisting boot ZVOL {paths['boot_zvol']!r} blocks ownership-safe V1") + + ddp_call(ws, "mk-stage", "pool.dataset.create", [{ + "name": paths["dataset"], "type": "FILESYSTEM", "exec": "OFF", "share_type": "GENERIC", + }]) + stage_owned = True + + upload_job = upload_file(a.host, a.port, a.username, password, a.raw_image, paths["raw_image"], a.tls, a.timeout) + wait_job(ws, upload_job, a.job_timeout, "upload-raw") + upload_job = upload_file(a.host, a.port, a.username, password, a.seed_iso, paths["seed_iso"], a.tls, a.timeout) + wait_job(ws, upload_job, a.job_timeout, "upload-seed") + for label, path in (("raw", paths["raw_image"]), ("seed", paths["seed_iso"])): + stat = ddp_call(ws, f"stat-{label}", "filesystem.stat", [path]) + if not isinstance(stat, dict) or stat.get("type") != "FILE": + raise V1ProbeError(f"uploaded {label} media not observable through filesystem.stat: {stat!r}") + + vm = ddp_call(ws, "vm-create", "vm.create", [native_vm_create_payload(a.name)]) + if not isinstance(vm, dict) or not isinstance(vm.get("id"), int): + raise V1ProbeError(f"vm.create returned invalid VM: {vm!r}") + vm_id = vm["id"] + boot = zvol_boot_device(vm_id, paths["boot_zvol"]) + seed = seed_cdrom_device(vm_id, paths["seed_iso"]) + created_boot = ddp_call(ws, "boot-device", "vm.device.create", [boot]) + if not isinstance(created_boot, dict) or not isinstance(created_boot.get("id"), int): + raise V1ProbeError(f"boot DISK/ZVOL create returned invalid device: {created_boot!r}") + boot_device_id = created_boot["id"] + if created_boot.get("attributes", {}).get("path") != paths["boot_zvol_path"]: + raise V1ProbeError( + f"boot DISK/ZVOL path mismatch: {created_boot.get('attributes', {}).get('path')!r}" + ) + if not ddp_call(ws, "observe-boot-zvol", "pool.dataset.query", [[["id", "=", paths["boot_zvol"]]]]): + raise V1ProbeError("owned boot ZVOL was not independently observable after device create") + convert_job = ddp_call(ws, "convert-boot", "vm.device.convert", [{ + "source": paths["raw_image"], + "destination": paths["boot_zvol_path"], + }]) + wait_job(ws, convert_job, a.job_timeout, "convert-boot") + ddp_call(ws, "seed-device", "vm.device.create", [seed]) + receipt["desired_devices"] = [boot, seed] + receipt["media_lowering"] = { + "source": paths["raw_image"], + "destination": paths["boot_zvol_path"], + "method": "vm.device.convert", + "classification": "SUPPORTED", + } + + ddp_call(ws, "vm-start", "vm.start", [vm_id, {"overcommit": False}]) + console = observe_console_nonce( + a.host, a.port, password, vm_id, a.nonce, a.guest_timeout, a.tls, + ) + receipt["guest_oracle"] = console + if console.get("classification") != "SUPPORTED" or console.get("oracleSatisfied") is not True: + raise V1ProbeError(f"guest nonce oracle failed: {console.get('detail')}") + + receipt.update({ + "classification": "SUPPORTED", + "oracleSatisfied": True, + "v1_oracle_satisfied": True, + "detail": "pinned CirrOS guest booted and exact injected nonce was observed through supported TrueNAS VM console", + }) + return receipt + except Exception as exc: + receipt["detail"] = f"{type(exc).__name__}: {exc}" + return receipt + finally: + receipt["cleanup"]["attempted"] = True + if ws is not None: + if vm_id is not None: + try: + ddp_call(ws, "cleanup-stop", "vm.stop", [vm_id, {"force": True, "force_after_timeout": True}]) + except Exception: + pass + if boot_device_id is not None: + try: + boot_rows = ddp_call( + ws, "cleanup-query-boot-device", "vm.device.query", [[["id", "=", boot_device_id]]] + ) + if boot_rows: + ddp_call( + ws, + "cleanup-delete-boot-device", + "vm.device.delete", + [boot_device_id, {"force": True, "zvol": True, "raw_file": False}], + ) + except Exception: + pass + try: + ddp_call(ws, "cleanup-delete-vm", "vm.delete", [vm_id, {"zvols": False, "force": True}]) + except Exception: + pass + try: + receipt["cleanup"]["vm_absent"] = not bool( + ddp_call(ws, "cleanup-query-vm", "vm.query", [[["id", "=", vm_id]]]) + ) + except Exception: + pass + else: + receipt["cleanup"]["vm_absent"] = True + + try: + zvol_rows = ddp_call( + ws, "cleanup-query-boot-zvol", "pool.dataset.query", [[["id", "=", paths["boot_zvol"]]]] + ) + if zvol_rows: + ddp_call( + ws, + "cleanup-delete-boot-zvol", + "pool.dataset.delete", + [paths["boot_zvol"], {"recursive": True, "force": True}], + ) + receipt["cleanup"]["boot_zvol_absent"] = not bool( + ddp_call( + ws, + "cleanup-confirm-boot-zvol", + "pool.dataset.query", + [[["id", "=", paths["boot_zvol"]]]], + ) + ) + except Exception: + pass + + if stage_owned: + try: + ddp_call(ws, "cleanup-stage", "pool.dataset.delete", [ + paths["dataset"], {"recursive": True, "force": True} + ]) + except Exception: + pass + try: + receipt["cleanup"]["staging_dataset_absent"] = not bool( + ddp_call(ws, "cleanup-query-stage", "pool.dataset.query", [[["id", "=", paths["dataset"]]]]) + ) + except Exception: + pass + else: + receipt["cleanup"]["staging_dataset_absent"] = True + ws.close() + + +def main() -> int: + p = argparse.ArgumentParser() + p.add_argument("--host", default="127.0.0.1") + p.add_argument("--port", type=int) + p.add_argument("--tls", action="store_true") + p.add_argument("--timeout", type=float, default=8) + p.add_argument("--job-timeout", type=float, default=300) + p.add_argument("--guest-timeout", type=float, default=120) + p.add_argument("--username", default="truenas_admin") + p.add_argument("--password-file", type=pathlib.Path) + p.add_argument("--target-version", default="26.0.0-BETA.3") + p.add_argument("--pool", default="rdtepool") + p.add_argument("--name", default="rdtecomputevmv1") + p.add_argument("--nonce", required=True) + p.add_argument("--source-image", type=pathlib.Path) + p.add_argument("--raw-image", type=pathlib.Path) + p.add_argument("--seed-iso", type=pathlib.Path) + p.add_argument("--out", type=pathlib.Path, required=True) + p.add_argument("--apply", action="store_true") + a = p.parse_args() + + try: + if a.apply: + if not all((a.port, a.password_file, a.source_image, a.raw_image, a.seed_iso)): + raise V1ProbeError("apply requires port/password-file/source-image/raw-image/seed-iso") + result = run_apply(a) + else: + result = plan(a.pool, a.name, a.nonce) + except (V1ProbeError, SeedError, OSError) as exc: + result = { + "schema": "truenas-compute-vm-v1/v1", + "classification": "HARNESS_FAILURE", + "oracleSatisfied": False, + "v1_oracle_satisfied": False, + "detail": f"{type(exc).__name__}: {exc}", + } + a.out.write_text(json.dumps(result, indent=2, sort_keys=True) + "\n", encoding="utf-8") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/truenas_existing_probe_capsule_executor.py b/scripts/truenas_existing_probe_capsule_executor.py new file mode 100644 index 000000000..d27f2a0cb --- /dev/null +++ b/scripts/truenas_existing_probe_capsule_executor.py @@ -0,0 +1,244 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import json +import pathlib +import subprocess +import sys +from typing import Any + +from truenas_session_manifest import SessionError + +SCHEMA = "truenas-capsule-context/v1" +DISPATCH_SCHEMA = "truenas-capsule-dispatch/v1" +RECEIPT_SCHEMA = "truenas-capsule-execution/v1" + +PROVIDER_PORT_ARGS = { + "litellm-t6": {"service": "--service-port"}, + "wow-sidecar-t6": {}, + "garm-t6": {"service": "--service-port"}, + "official-catalog-t6": {}, + "minecraft-paper-t6": {"status": "--status-port"}, + "foliorelay-t6": { + "control": "--control-port", + "ipp": "--ipp-port", + "observer": "--observer-port", + }, +} + +STATE_TIMEOUT = { + "litellm-t6": 240, + "wow-sidecar-t6": 240, + "garm-t6": 240, + "official-catalog-t6": 240, + "minecraft-paper-t6": 480, + "foliorelay-t6": 300, +} + +KNOWN_FAILURES = { + "ORACLE_FAILURE", + "HARNESS_FAILURE", + "ENVIRONMENT_FAILURE", + "UNSUPPORTED", +} + + +def load_json(path: pathlib.Path) -> dict[str, Any]: + try: + value = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise SessionError(f"cannot read {path}: {exc}") from exc + if not isinstance(value, dict): + raise SessionError(f"{path}: top-level value must be an object") + return value + + +def nonempty(value: Any, label: str) -> str: + if not isinstance(value, str) or not value: + raise SessionError(f"{label} must be a non-empty string") + return value + + +def positive_port(value: Any, label: str) -> int: + if not isinstance(value, int) or isinstance(value, bool) or not 1 <= value <= 65535: + raise SessionError(f"{label} must be a TCP port") + return value + + +def provider_context(context: dict[str, Any], provider_id: str) -> dict[str, Any]: + rows = context.get("providers") + if not isinstance(rows, dict): + raise SessionError("context.providers must be an object") + row = rows.get(provider_id) + if not isinstance(row, dict): + raise SessionError(f"context has no provider binding for {provider_id}") + return row + + +def build_probe_command( + descriptor: dict[str, Any], + context: dict[str, Any], + provider_receipt: pathlib.Path, +) -> list[str]: + if descriptor.get("schema") != DISPATCH_SCHEMA: + raise SessionError("unsupported capsule dispatch schema") + capsule = descriptor.get("capsule") + provider = descriptor.get("provider") + if not isinstance(capsule, dict) or not isinstance(provider, dict): + raise SessionError("dispatch requires capsule and provider objects") + + provider_id = nonempty(capsule.get("provider"), "capsule.provider") + if provider_id != provider.get("id"): + raise SessionError("capsule/provider identity mismatch") + if provider_id not in PROVIDER_PORT_ARGS: + raise SessionError(f"provider is not admitted by existing-probe executor: {provider_id}") + + probe = pathlib.Path(nonempty(provider.get("probe"), "provider.probe")) + if not probe.is_file(): + raise SessionError(f"provider probe does not exist: {probe}") + + if context.get("schema") != SCHEMA: + raise SessionError("unsupported capsule context schema") + host = nonempty(context.get("host"), "context.host") + middleware_port = positive_port(context.get("middleware_port"), "context.middleware_port") + password_file = pathlib.Path(nonempty(context.get("password_file"), "context.password_file")) + if not password_file.is_file(): + raise SessionError("context.password_file does not exist") + tls = context.get("tls") + if not isinstance(tls, bool): + raise SessionError("context.tls must be boolean") + + binding = provider_context(context, provider_id) + control_dir = pathlib.Path(nonempty(binding.get("control_dir"), f"{provider_id}.control_dir")) + if not control_dir.is_dir(): + raise SessionError(f"{provider_id}.control_dir does not exist") + foundry_commit = nonempty(binding.get("foundry_commit"), f"{provider_id}.foundry_commit") + if len(foundry_commit) != 40 or any(ch not in "0123456789abcdef" for ch in foundry_commit): + raise SessionError(f"{provider_id}.foundry_commit must be exact lowercase SHA") + + command = [ + sys.executable, + str(probe), + "--host", host, + "--port", str(middleware_port), + ] + if tls: + command.append("--tls") + command += [ + "--password-file", str(password_file), + "--control-dir", str(control_dir), + "--foundry-commit", foundry_commit, + ] + + ports = binding.get("ports", {}) + if not isinstance(ports, dict): + raise SessionError(f"{provider_id}.ports must be an object") + for name, flag in PROVIDER_PORT_ARGS[provider_id].items(): + command += [flag, str(positive_port(ports.get(name), f"{provider_id}.ports.{name}"))] + if provider_id == "minecraft-paper-t6": + status_oracle = pathlib.Path(nonempty(binding.get("status_oracle"), "minecraft.status_oracle")) + if not status_oracle.is_file() or status_oracle.name != "minecraft_java_status_oracle.py": + raise SessionError("pinned Minecraft product oracle file is absent") + expected_system_version = nonempty(binding.get("expected_system_version"), "minecraft.expected_system_version") + if expected_system_version != "TrueNAS-25.04.1": + raise SessionError("Minecraft native product target version drift") + command += [ + "--status-oracle", str(status_oracle), + "--expected-system-version", expected_system_version, + ] + + command += [ + "--out", str(provider_receipt), + "--timeout", "8", + "--job-timeout", "600" if provider_id == "minecraft-paper-t6" else "300", + "--state-timeout", str(STATE_TIMEOUT[provider_id]), + ] + return command + + +def wrap_receipt( + descriptor: dict[str, Any], + provider_receipt: dict[str, Any] | None, + returncode: int, + stderr: str, +) -> dict[str, Any]: + capsule = descriptor["capsule"] + provider = descriptor["provider"] + supported = bool( + provider_receipt + and provider_receipt.get("classification") == "SUPPORTED" + and provider_receipt.get("oracleSatisfied") is True + ) + if supported: + verdict = "SUPPORTED" + cleanup_satisfied = True + platform_healthy = True + reason = "existing provider probe completed its accepted oracle and cleanup contract" + else: + observed = provider_receipt.get("classification") if provider_receipt else None + verdict = observed if observed in KNOWN_FAILURES else "HARNESS_FAILURE" + cleanup_satisfied = False + platform_healthy = False + # Minecraft T6 provides independent, post-failure read-only cleanup + # and platform witnesses. Do not promote the failed product oracle. + if provider["id"] == "minecraft-paper-t6" and provider_receipt: + evidence = provider_receipt.get("cleanup") or {} + cleanup_satisfied = (evidence.get("attempted") is True + and evidence.get("zero_residue") is True + and evidence.get("app_absent") is True + and evidence.get("owned_world_dataset_absent") is True) + platform_healthy = (cleanup_satisfied + and provider_receipt.get("post_failure_platform_healthy") is True) + reason = ( + (provider_receipt or {}).get("detail") + or stderr.strip() + or f"provider probe exited {returncode} without accepted receipt" + ) + return { + "schema": RECEIPT_SCHEMA, + "capsule_id": capsule["id"], + "provider": capsule["provider"], + "provider_probe": provider["probe"], + "manifest_sha256": descriptor["manifest_sha256"], + "verdict": verdict, + "mutating": capsule["mutating"], + "cleanup_required": capsule["cleanup_required"], + "cleanup_satisfied": cleanup_satisfied, + "platform_healthy": platform_healthy, + "authority_satisfied": True, + "resource_guardrail_satisfied": True, + "provider_returncode": returncode, + "provider_receipt": provider_receipt, + "reason": reason[:2000], + } + + +def main() -> int: + p = argparse.ArgumentParser() + p.add_argument("--capsule", type=pathlib.Path, required=True) + p.add_argument("--context", type=pathlib.Path, required=True) + p.add_argument("--out", type=pathlib.Path, required=True) + p.add_argument("--dry-run", action="store_true") + a = p.parse_args() + try: + descriptor = load_json(a.capsule) + context = load_json(a.context) + provider_receipt = a.out.with_suffix(".provider.json") + command = build_probe_command(descriptor, context, provider_receipt) + if a.dry_run: + print(json.dumps({"schema":"truenas-capsule-command/v1","argv":command}, indent=2)) + return 0 + cp = subprocess.run(command, text=True, capture_output=True, check=False) + source = load_json(provider_receipt) if provider_receipt.is_file() else None + receipt = wrap_receipt(descriptor, source, cp.returncode, cp.stderr) + a.out.write_text(json.dumps(receipt, indent=2, sort_keys=True)+"\n", encoding="utf-8") + print(json.dumps(receipt, indent=2, sort_keys=True)) + return 0 + except (SessionError, OSError) as exc: + print(f"ERROR: {exc}", file=sys.stderr) + return 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/truenas_garm_vm_pre_b4_fixture.py b/scripts/truenas_garm_vm_pre_b4_fixture.py new file mode 100644 index 000000000..7967c911b --- /dev/null +++ b/scripts/truenas_garm_vm_pre_b4_fixture.py @@ -0,0 +1,188 @@ +#!/usr/bin/env python3 +"""Fail-closed, source-only consumer for the exact TrueNAS BETA.3 GARM VM fixture. + +This module does not contact TrueNAS, create a guest, or admit runtime support. +""" +from __future__ import annotations + +import argparse +import json +import pathlib +import re + +SCHEMA = "semper-supra.garm-provider-truenas-vm-pre-b4-fixture/1" +TARGET = "TrueNAS-26.0.0-BETA.3" +MIDDLEWARE = "81e1265a86083888ba94a2bdfc02ff5c9c5ef6a3" +TEMPLATE_NAME = "garm_tpl_ubuntu_2404_20260926_amd64" +IMAGE_URL = "https://cloud-images.ubuntu.com/releases/noble/release-20260926/ubuntu-24.04-server-cloudimg-amd64.img" +IMAGE_SHA256 = "6a81c37564db9b1ee84e141922625e1d7c5b389b99bb3c572e0243607d5bb4d2" +TOKEN_PLACEHOLDER = "__RUN_LOCAL_INSTANCE_TOKEN__" +MARKER_PREFIX = "GARM_VM_BOOTSTRAP_CONSUMED_V1:" +REQUIRED_METHODS = { + "system.version", "vm.query", "vm.create", "vm.update", + "vm.clone", "vm.delete", "vm.start", "vm.stop", + "vm.poweroff", "vm.status", "vm.device.query", + "vm.device.create", "vm.device.delete", +} +REQUIRED_TRUE = { + "exact_version_required", + "25_04_1_not_admitted", + "foreign_ownership_rejected", + "classic_vm_name_safe", + "fixed_cpu_memory_profile_required", + "autostart_forbidden", + "zvol_template_clone_required", + "supported_filesystem_put_required", + "stock_template_source_exact", + "self_contained_nocloud_bootstrap", + "unprivileged_runner_required", + "owned_seed_dataset_required", + "owned_cdrom_required", + "active_seed_detach_forbidden", + "bootstrap_consumption_signal_required", + "console_consumption_marker_required", + "seed_device_absence_required", + "seed_dataset_absence_required", + "final_vm_absence_required", +} +REQUIRED_FALSE = { + "runtime_admission_claimed", + "guest_boot_claimed", + "bootstrap_consumption_claimed", + "github_jit_boundary_claimed", + "docker_or_container_actions_claimed", + "windows_or_gpu_claimed", +} + + +class FixtureError(ValueError): + pass + + +def require(ok: bool, reason: str) -> None: + if not ok: + raise FixtureError(reason) + + +def validate_fixture(doc: dict, producer: str) -> dict: + """Return only non-secret source/static evidence. Never log user-data.""" + require(re.fullmatch(r"[0-9a-f]{40}", producer) is not None, "invalid producer SHA") + require(isinstance(doc, dict), "fixture must be an object") + require(doc.get("schema") == SCHEMA, "fixture schema drift") + require(doc.get("producer_source") == producer, "producer ref drift") + require(doc.get("authority") == "SemperSupra/garm-provider-truenas-private#43", "owner authority drift") + + target = doc.get("target") or {} + require(target.get("version") == "26.0.0-BETA.3", "version drift") + require(target.get("system_version") == TARGET, "system version drift") + require(target.get("middleware_commit") == MIDDLEWARE, "middleware source drift") + require(target.get("driver") == "vm-v1", "driver drift") + require(target.get("control_surface") == "vm.*", "VM API drift") + require(target.get("status") == "OPEN", "unearned runtime admission") + require(REQUIRED_METHODS.issubset(set(target.get("required_methods") or [])), "missing VM API methods") + + require(doc.get("profile") == "truenas-vm-linux-general", "profile drift") + require(doc.get("template_family") == "ubuntu-24.04-amd64-template", "template family drift") + require(doc.get("template_version") == "ubuntu-24.04-release-20260926-amd64", "template version drift") + require(doc.get("template_runtime_name") == TEMPLATE_NAME, "runtime template name drift") + require(doc.get("template_source_url") == IMAGE_URL, "stock image URL drift") + require(doc.get("template_source_sha256") == IMAGE_SHA256, "stock image digest drift") + + name = doc.get("expected_name") + require(isinstance(name, str) and re.fullmatch(r"[a-zA-Z0-9_]{1,150}", name) is not None, + "classic VM name illegal") + owner = doc.get("expected_ownership") or {} + require(owner.get("schema") == "semper-supra.garm-vm-owner/1", "ownership schema drift") + require(owner.get("managed_by") == "garm-provider-truenas", "foreign management") + require(owner.get("profile") == doc["profile"], "ownership profile drift") + for key in ("controller_id", "pool_id", "runner_name"): + require(isinstance(owner.get(key), str) and bool(owner[key]), "missing ownership field " + key) + + clone = doc.get("clone") or {} + require(clone.get("vcpus") == 4 and type(clone.get("vcpus")) is int, "CPU profile drift") + require(clone.get("memory_bytes") == 8 * 1024**3 and type(clone.get("memory_bytes")) is int, + "memory profile drift") + require(clone.get("autostart") is False, "autostart forbidden") + require(isinstance(clone.get("description"), str) and owner["managed_by"] in clone["description"], + "ownership not encoded in description") + + seed = doc.get("seed") or {} + require(seed.get("run_local_token_placeholder") == TOKEN_PLACEHOLDER, "token placeholder drift") + require(seed.get("transport") == "provider-owned child dataset + public filesystem.put input pipe", + "seed transport drift") + require(seed.get("attach_device") == "owned vm.device CDROM", "seed attachment drift") + require(seed.get("retain_while_active") is True and seed.get("delete_only_when_stopped") is True, + "unsafe active seed retirement") + require(seed.get("consumption_signal_required") is True, "missing consumption signal") + marker = MARKER_PREFIX + name + require(seed.get("consumption_marker") == marker, "console consumption marker drift") + user_data = seed.get("user_data") + require(isinstance(user_data, str) and TOKEN_PLACEHOLDER in user_data and marker in user_data, + "NoCloud bootstrap material incomplete") + for needle in ("path: /usr/local/libexec/garm-runner-bootstrap", + "path: /usr/local/libexec/garm-bootstrap", + "name: garm-runner", "/usr/sbin/runuser -u garm-runner", + 'rm -f "$env_file"', "/dev/console"): + require(needle in user_data, "NoCloud self-contained/bootstrap invariant absent") + require(TOKEN_PLACEHOLDER not in str(seed.get("meta_data") or ""), "token leaked to meta-data") + require(TOKEN_PLACEHOLDER not in clone["description"], "token leaked to ownership") + + flags = doc.get("source_oracles") or {} + for flag in REQUIRED_TRUE: + require(flags.get(flag) is True, "unproven source invariant: " + flag) + for flag in REQUIRED_FALSE: + require(flags.get(flag) is False, "forbidden support claim: " + flag) + retirement = doc.get("retirement") or [] + require(isinstance(retirement, list) and len(retirement) >= 7, "missing retirement steps") + require(any("vm.stop" in s for s in retirement), "VM stop absent") + require(any("seed dataset absence" in s for s in retirement), "seed cleanup absent") + require(any("final VM absence" in s for s in retirement), "final VM cleanup absent") + + runner = doc.get("runner") or {} + require(re.fullmatch(r"[0-9a-f]{64}", str(runner.get("tool_sha256", ""))) is not None, + "unpinned runner tool") + require(str(runner.get("tool_url", "")).startswith("https://github.com/actions/runner/releases/download/"), + "unexpected runner tool origin") + return { + "schema": "truenas-garm-vm-pre-b4-consumer-static/v1", + "classification": "STATIC_CONTRACT_PASS", + "oracleSatisfied": True, + "source_only": True, + "producer_source": producer, + "target": TARGET, + "driver": "vm-v1", + "template_runtime_name": TEMPLATE_NAME, + "expected_name": name, + "vm_runtime_admitted": False, + "guest_boot_observed": False, + "bootstrap_consumption_observed": False, + "github_jit_exercised": False, + "zero_residue_runtime_proven": False, + } + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--fixture", type=pathlib.Path, required=True) + parser.add_argument("--producer-commit", required=True) + parser.add_argument("--out", type=pathlib.Path, required=True) + args = parser.parse_args() + try: + doc = json.loads(args.fixture.read_text(encoding="utf-8")) + receipt = validate_fixture(doc, args.producer_commit) + except (OSError, ValueError, TypeError, KeyError) as exc: + receipt = { + "schema": "truenas-garm-vm-pre-b4-consumer-static/v1", + "classification": "STATIC_CONTRACT_FAILURE", + "oracleSatisfied": False, + "source_only": True, + "reason": str(exc)[:180], + "vm_runtime_admitted": False, + } + args.out.write_text(json.dumps(receipt, sort_keys=True, indent=2) + "\n", encoding="utf-8") + print(json.dumps({k: v for k, v in receipt.items() if k != "reason"}, sort_keys=True)) + return 0 if receipt["oracleSatisfied"] else 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/truenas_garm_vm_template_preflight.py b/scripts/truenas_garm_vm_template_preflight.py new file mode 100644 index 000000000..e9623e39a --- /dev/null +++ b/scripts/truenas_garm_vm_template_preflight.py @@ -0,0 +1,197 @@ +#!/usr/bin/env python3 +"""Bounded, read-only VM template preflight for the exact GARM TrueNAS BETA.3 cell. + +Never creates/clones/starts/deletes VM, media, or datasets; never claims runtime +admission. VM name/readback is not proof of stock-image SHA or Foundry provenance. +""" +from __future__ import annotations + +import argparse +import json +import pathlib +from typing import Any, Callable + +from truenas_garm_vm_pre_b4_fixture import ( + TARGET, REQUIRED_METHODS, validate_fixture, FixtureError, +) + +SNAPSHOT_SCHEMA = "truenas-garm-vm-template-readonly-snapshot/v1" +RECEIPT_SCHEMA = "truenas-garm-vm-template-readonly-preflight/v1" +READ_METHODS = frozenset({ + "system.version", "core.get_methods", "vm.query", "vm.status", "vm.device.query" +}) + + +class PreflightError(ValueError): + pass + + +def _base_receipt() -> dict[str, Any]: + return { + "schema": RECEIPT_SCHEMA, + "classification": "BLOCKED", + "oracleSatisfied": False, + "read_only": True, + "runtime_mutation_performed": False, + "runtime_support_admitted": False, + "template_source_provenance_verified": False, + "guest_boot_observed": False, + "github_jit_exercised": False, + "zero_residue_runtime_proven": False, + } + + +def collect_snapshot(call: Callable[[str, list], Any], template_name: str) -> dict[str, Any]: + """Issue only explicit supported middleware reads, returning a narrow snapshot. + + Authentication is performed outside this function. Raw VM descriptions, API + errors, credentials and guest console text are never retained as evidence. + """ + if not isinstance(template_name, str) or not template_name.startswith("garm_tpl_"): + raise PreflightError("unexpected template identity") + observed_methods: list[str] = [] + + def read(method: str, args: list) -> Any: + if method not in READ_METHODS: + raise PreflightError("non-read method forbidden") + observed_methods.append(method) + return call(method, args) + + version = read("system.version", []) + methods = read("core.get_methods", []) + if not isinstance(methods, dict): + raise PreflightError("method census not a map") + rows = read("vm.query", [[["name", "=", template_name]]]) + if not isinstance(rows, list): + raise PreflightError("vm.query did not return a list") + + template_rows: list[dict[str, Any]] = [] + status = None + devices: list[dict[str, Any]] = [] + for row in rows: + if not isinstance(row, dict): + raise PreflightError("invalid vm.query row") + if row.get("name") != template_name: + raise PreflightError("vm.query returned an unrelated VM") + ident = row.get("id") + if not isinstance(ident, int) or isinstance(ident, bool) or ident <= 0: + raise PreflightError("missing VM ID") + template_rows.append({"name": row["name"], "id": ident}) + + if len(template_rows) == 1: + ident = template_rows[0]["id"] + status_raw = read("vm.status", [ident]) + if not isinstance(status_raw, dict): + raise PreflightError("vm.status response not a map") + status = status_raw.get("state") + device_rows = read("vm.device.query", [[["vm", "=", ident]]]) + if not isinstance(device_rows, list): + raise PreflightError("vm.device.query did not return a list") + for row in device_rows: + if not isinstance(row, dict): + raise PreflightError("VM device row not a map") + attrs = row.get("attributes") or {} + if not isinstance(attrs, dict): + raise PreflightError("VM device attributes not a map") + # No opaque device configuration or sensitive paths in evidence. + path = attrs.get("path") + devices.append({ + "dtype": attrs.get("dtype"), + "zvol_backed": isinstance(path, str) and path.startswith("/dev/zvol/"), + "raw_backed": attrs.get("dtype") == "RAW", + }) + + return { + "schema": SNAPSHOT_SCHEMA, + "version": version, + "available_methods": sorted(methods.keys()), + "template_rows": template_rows, + "template_state": status, + "template_devices": devices, + "read_methods_used": observed_methods, + "source_provenance_receipt": None, # deliberately not fabricated + } + + +def evaluate(fixture: dict[str, Any], producer: str, snapshot: dict[str, Any]) -> dict[str, Any]: + """Evaluate an independently collected observation; never infer template SHA.""" + static = validate_fixture(fixture, producer) + out = _base_receipt() + out["producer_source"] = producer + out["target"] = TARGET + out["template_runtime_name"] = static["template_runtime_name"] + + def block(reason: str) -> dict[str, Any]: + out["reason_code"] = reason + return out + + if not isinstance(snapshot, dict) or snapshot.get("schema") != SNAPSHOT_SCHEMA: + return block("INVALID_SNAPSHOT_SCHEMA") + if snapshot.get("version") != TARGET: + return block("EXACT_TARGET_MISMATCH") + methods = snapshot.get("available_methods") + if not isinstance(methods, list) or not all(isinstance(m, str) for m in methods): + return block("INVALID_METHOD_CENSUS") + if not REQUIRED_METHODS.issubset(set(methods)): + return block("VM_METHODS_ABSENT") + sequence = snapshot.get("read_methods_used") + if not isinstance(sequence, list) or any(m not in READ_METHODS for m in sequence): + return block("UNTRUSTED_OBSERVER") + if sequence[:3] != ["system.version", "core.get_methods", "vm.query"]: + return block("INCOMPLETE_OBSERVER") + rows = snapshot.get("template_rows") + if not isinstance(rows, list): + return block("INVALID_VM_QUERY") + if len(rows) == 0: + return block("TEMPLATE_MISSING") + if len(rows) != 1: + return block("TEMPLATE_AMBIGUOUS") + row = rows[0] + if not isinstance(row, dict) or row.get("name") != static["template_runtime_name"]: + return block("TEMPLATE_NAME_DRIFT") + if not isinstance(row.get("id"), int) or isinstance(row["id"], bool) or row["id"] <= 0: + return block("INVALID_VM_ID") + if sequence[-2:] != ["vm.status", "vm.device.query"]: + return block("INCOMPLETE_TEMPLATE_READBACK") + if snapshot.get("template_state") != "STOPPED": + return block("TEMPLATE_NOT_STOPPED") + devices = snapshot.get("template_devices") + if not isinstance(devices, list): + return block("INVALID_DEVICE_READBACK") + if any(not isinstance(dev, dict) for dev in devices): + return block("INVALID_DEVICE_ROW") + if any(d.get("raw_backed") is True or d.get("dtype") == "RAW" for d in devices): + return block("RAW_DISK_NOT_CLONEABLE") + boot = [d for d in devices if d.get("dtype") == "DISK"] + if len(boot) != 1 or boot[0].get("zvol_backed") is not True: + return block("ZVOL_BOOT_DISK_NOT_EXACT") + # This is a *preflight observation*, not an immutable-image verification. + # vm.query/name/status/device data alone cannot establish a specific + # downloaded cloud image SHA, boot-disk content or bootstrap consumption. + out["classification"] = "TEMPLATE_OBSERVED_SOURCE_UNVERIFIED" + out["oracleSatisfied"] = True + out["reason_code"] = "STOCK_IMAGE_PROVENANCE_SEPARATELY_REQUIRED" + return out + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--fixture", type=pathlib.Path, required=True) + ap.add_argument("--producer-commit", required=True) + ap.add_argument("--snapshot", type=pathlib.Path, required=True) + ap.add_argument("--out", type=pathlib.Path, required=True) + args = ap.parse_args() + try: + fixture = json.loads(args.fixture.read_text(encoding="utf-8")) + snapshot = json.loads(args.snapshot.read_text(encoding="utf-8")) + result = evaluate(fixture, args.producer_commit, snapshot) + except (OSError, json.JSONDecodeError, FixtureError, PreflightError, TypeError, KeyError): + result = _base_receipt() + result["reason_code"] = "INPUT_OR_FIXTURE_VALIDATION_FAILED" + args.out.write_text(json.dumps(result, indent=2, sort_keys=True) + "\n", encoding="utf-8") + print(json.dumps(result, sort_keys=True)) + return 0 if result.get("oracleSatisfied") is True else 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/truenas_installer_rpc_install.py b/scripts/truenas_installer_rpc_install.py new file mode 100755 index 000000000..6ceede879 --- /dev/null +++ b/scripts/truenas_installer_rpc_install.py @@ -0,0 +1,125 @@ +#!/usr/bin/env python3 +"""Bounded mutating client for the TrueNAS installer JSON-RPC API.""" +from __future__ import annotations + +import argparse +import json +import pathlib +import time + +from truenas_installer_rpc_probe import WebSocket + + +def rpc_call(ws, method, request_id, params=None, progress=None): + request = {"jsonrpc": "2.0", "id": request_id, "method": method} + if params is not None: + request["params"] = params + ws.send_json(request) + while True: + message = json.loads(ws.recv_text()) + if message.get("method") == "installation_progress": + values = message.get("params") or [] + if progress is not None and values and isinstance(values[0], dict): + progress.append(values[0]) + continue + if message.get("id") != request_id: + continue + if message.get("error") is not None: + raise RuntimeError(f"{method}: {json.dumps(message['error'], sort_keys=True)}") + return message.get("result") + + +def main(): + p = argparse.ArgumentParser() + p.add_argument("--host", default="127.0.0.1") + p.add_argument("--port", type=int, required=True) + p.add_argument("--path", default="/ws") + p.add_argument("--password-file", required=True) + p.add_argument("--out", required=True) + p.add_argument("--timeout", type=float, default=15.0) + a = p.parse_args() + + password = pathlib.Path(a.password_file).read_text(encoding="utf-8").strip() + if len(password) < 12: + raise SystemExit("ephemeral install password must be at least 12 characters") + + payload = { + "schema": "truenas-installer-rpc-install/v1", + "oracleSatisfied": False, + "classification": "ORACLE_FAILURE", + "selected_disk": None, + "selected_interface": None, + "installer_rpc_path": a.path, + "progress": [], + } + started = time.time() + ws = None + try: + ws = WebSocket(a.host, a.port, path=a.path, timeout=a.timeout) + adopted = rpc_call(ws, "is_adopted", 1) + payload["is_adopted"] = adopted + if adopted: + raise RuntimeError("fresh disposable installer unexpectedly reports adopted=true") + + disks = rpc_call(ws, "list_disks", 2) + payload["discovered_disks"] = disks + candidates = [disk for disk in disks if not disk.get("removable", False)] + if len(candidates) != 1: + raise RuntimeError( + f"refusing install: expected exactly one non-removable disk, found {len(candidates)}" + ) + disk = candidates[0] + payload["selected_disk"] = disk["name"] + + interfaces = rpc_call(ws, "list_network_interfaces", 3) + payload["discovered_interfaces"] = interfaces + if len(interfaces) != 1: + raise RuntimeError( + f"refusing install: expected exactly one non-loopback interface, found {len(interfaces)}" + ) + interface = interfaces[0] + payload["selected_interface"] = interface["name"] + + install_params = { + "disks": [disk["name"]], + "set_pmbr": True, + "authentication": { + "username": "truenas_admin", + "password": password, + }, + "post_install": { + "network_interfaces": [ + { + "name": interface["name"], + "ipv4_dhcp": True, + "ipv6_auto": True, + } + ] + }, + } + rpc_call(ws, "install", 4, [install_params], progress=payload["progress"]) + payload["oracleSatisfied"] = True + payload["classification"] = "SUPPORTED" + payload["detail"] = "vendor installer completed on the sole disposable disk" + payload["install_contract"] = { + "disk_count": 1, + "set_pmbr": True, + "authentication_username": "truenas_admin", + "password_configured": True, + "ipv4_dhcp": True, + "ipv6_auto": True, + } + except Exception as exc: + payload["detail"] = f"{type(exc).__name__}: {exc}" + finally: + if ws is not None: + ws.close() + + payload["elapsed_seconds"] = round(time.time() - started, 3) + pathlib.Path(a.out).write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8") + print(json.dumps(payload, indent=2, sort_keys=True)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/truenas_installer_rpc_probe.py b/scripts/truenas_installer_rpc_probe.py new file mode 100644 index 000000000..2da88925c --- /dev/null +++ b/scripts/truenas_installer_rpc_probe.py @@ -0,0 +1,167 @@ +#!/usr/bin/env python3 +"""Minimal dependency-free JSON-RPC 2.0 client for the TrueNAS installer WebSocket API.""" +from __future__ import annotations +import argparse, base64, hashlib, json, os, pathlib, socket, struct, time + + +class WebSocket: + def __init__(self, host: str, port: int, path: str = "/ws", timeout: float = 5.0): + self.sock = socket.create_connection((host, port), timeout=timeout) + self.sock.settimeout(timeout) + key = base64.b64encode(os.urandom(16)).decode() + request = ( + f"GET {path} HTTP/1.1\r\n" + f"Host: {host}:{port}\r\n" + "Upgrade: websocket\r\n" + "Connection: Upgrade\r\n" + f"Sec-WebSocket-Key: {key}\r\n" + "Sec-WebSocket-Version: 13\r\n\r\n" + ).encode() + self.sock.sendall(request) + head = self._read_until(b"\r\n\r\n", 16384) + status = head.split(b"\r\n", 1)[0] + if b" 101 " not in status: + raise RuntimeError(f"websocket handshake failed: {status.decode(errors='replace')}") + expected = base64.b64encode( + hashlib.sha1((key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11").encode()).digest() + ).decode() + headers = {} + for line in head.decode(errors="replace").split("\r\n")[1:]: + if ":" in line: + k, v = line.split(":", 1) + headers[k.lower().strip()] = v.strip() + if headers.get("sec-websocket-accept") != expected: + raise RuntimeError("websocket accept digest mismatch") + + def _read_exact(self, n: int) -> bytes: + chunks = [] + remaining = n + while remaining: + part = self.sock.recv(remaining) + if not part: + raise EOFError("websocket closed") + chunks.append(part) + remaining -= len(part) + return b"".join(chunks) + + def _read_until(self, marker: bytes, limit: int) -> bytes: + data = bytearray() + while marker not in data: + part = self.sock.recv(4096) + if not part: + raise EOFError("socket closed during handshake") + data.extend(part) + if len(data) > limit: + raise RuntimeError("handshake exceeded limit") + return bytes(data) + + def _send_frame(self, opcode: int, payload: bytes): + first = 0x80 | opcode + mask = os.urandom(4) + n = len(payload) + if n < 126: + header = bytes([first, 0x80 | n]) + elif n <= 0xFFFF: + header = bytes([first, 0x80 | 126]) + struct.pack("!H", n) + else: + header = bytes([first, 0x80 | 127]) + struct.pack("!Q", n) + masked = bytes(b ^ mask[i % 4] for i, b in enumerate(payload)) + self.sock.sendall(header + mask + masked) + + def send_json(self, obj): + self._send_frame(0x1, json.dumps(obj, separators=(",", ":")).encode()) + + def recv_text(self) -> str: + fragments = [] + while True: + b1, b2 = self._read_exact(2) + fin = bool(b1 & 0x80) + opcode = b1 & 0x0F + masked = bool(b2 & 0x80) + n = b2 & 0x7F + if n == 126: + n = struct.unpack("!H", self._read_exact(2))[0] + elif n == 127: + n = struct.unpack("!Q", self._read_exact(8))[0] + mask = self._read_exact(4) if masked else b"" + payload = self._read_exact(n) + if masked: + payload = bytes(b ^ mask[i % 4] for i, b in enumerate(payload)) + if opcode == 0x8: + raise EOFError("websocket close frame") + if opcode == 0x9: + self._send_frame(0xA, payload) + continue + if opcode in (0x1, 0x0): + fragments.append(payload) + if fin: + return b"".join(fragments).decode() + elif opcode == 0xA: + continue + else: + raise RuntimeError(f"unexpected websocket opcode {opcode}") + + def call(self, method: str, request_id: int): + self.send_json({"jsonrpc": "2.0", "id": request_id, "method": method}) + while True: + response = json.loads(self.recv_text()) + if response.get("id") != request_id: + continue + if response.get("error") is not None: + raise RuntimeError(f"{method}: {json.dumps(response['error'], sort_keys=True)}") + return response.get("result") + + def close(self): + try: + self._send_frame(0x8, b"") + except Exception: + pass + self.sock.close() + + +def main(): + p = argparse.ArgumentParser() + p.add_argument("--host", default="127.0.0.1") + p.add_argument("--port", type=int, required=True) + p.add_argument("--path", default="/ws") + p.add_argument("--out", required=True) + p.add_argument("--timeout", type=float, default=6.0) + a = p.parse_args() + started = time.time() + payload = { + "schema": "truenas-installer-rpc-discovery/v1", + "endpoint": {"host": a.host, "port": a.port, "path": a.path}, + "methods": {}, + "oracleSatisfied": False, + } + ws = None + payload["stage"] = "connect-or-handshake" + try: + ws = WebSocket(a.host, a.port, path=a.path, timeout=a.timeout) + payload["stage"] = "rpc-calls" + adopted = ws.call("is_adopted", 1) + payload["methods"]["is_adopted"] = adopted + if adopted: + raise RuntimeError("fresh installer unexpectedly reports adopted=true") + for i, method in enumerate( + ("system_info", "list_disks", "list_network_interfaces"), start=2 + ): + payload["methods"][method] = ws.call(method, i) + payload["oracleSatisfied"] = True + payload["classification"] = "SUPPORTED" + payload["detail"] = "read-only TrueNAS installer JSON-RPC discovery methods answered" + payload["stage"] = "complete" + except Exception as exc: + payload["classification"] = "ORACLE_FAILURE" + payload["detail"] = f"{type(exc).__name__}: {exc}" + finally: + if ws is not None: + ws.close() + payload["elapsed_seconds"] = round(time.time() - started, 3) + pathlib.Path(a.out).write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n") + print(json.dumps(payload, indent=2, sort_keys=True)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/truenas_middleware_app_lifecycle_probe.py b/scripts/truenas_middleware_app_lifecycle_probe.py new file mode 100755 index 000000000..53dd7f391 --- /dev/null +++ b/scripts/truenas_middleware_app_lifecycle_probe.py @@ -0,0 +1,244 @@ +#!/usr/bin/env python3 +"""Bounded exact-version TrueNAS T5 custom-app lifecycle oracle.""" +from __future__ import annotations + +import argparse +import json +import pathlib +import time + +from truenas_middleware_ddp_probe import WebSocket, ddp_call, wait_for + + +APP_NAME = "rdte-t4-probe" +APP_IMAGE = "nginx@sha256:65645c7bb6a0661892a8b03b89d0743208a18dd2f3f17a54ef4b76fb8e2f2a10" + + +def main() -> int: + p = argparse.ArgumentParser() + p.add_argument("--host", default="127.0.0.1") + p.add_argument("--port", type=int, required=True) + p.add_argument("--password-file", required=True) + p.add_argument("--out", required=True) + p.add_argument("--expected-version", required=True) + p.add_argument("--tls", action="store_true") + p.add_argument("--timeout", type=float, default=8.0) + p.add_argument("--job-timeout", type=float, default=300.0) + p.add_argument("--state-timeout", type=float, default=180.0) + a = p.parse_args() + + password = pathlib.Path(a.password_file).read_text(encoding="utf-8").strip() + payload = { + "schema": "truenas-app-lifecycle-t5/v2", + "classification": "ORACLE_FAILURE", + "oracleSatisfied": False, + "expected_version": a.expected_version, + "app_name": APP_NAME, + "app_image": APP_IMAGE, + "transport": "wss" if a.tls else "ws", + "jobs": {}, + "states": [], + } + ws = None + request_id = 1 + started = time.time() + + try: + ws = WebSocket(a.host, a.port, timeout=a.timeout, tls=a.tls) + ws.send_json({"msg": "connect", "version": "1", "support": ["1"]}) + connected = wait_for(ws, lambda m: m.get("msg") in {"connected", "failed"}) + if connected.get("msg") != "connected": + raise RuntimeError(f"DDP connection failed: {connected!r}") + + def call(method, params): + nonlocal request_id + result = ddp_call(ws, str(request_id), method, params) + request_id += 1 + return result + + def wait_job(job_id, label): + deadline = time.monotonic() + a.job_timeout + last = None + while time.monotonic() < deadline: + last = call("core.get_jobs", [[["id", "=", job_id]], {"get": True}]) + if last: + state = last.get("state") + if state == "SUCCESS": + payload["jobs"][label] = { + "id": job_id, + "state": state, + "progress": last.get("progress"), + "result": last.get("result"), + } + return last + if state in {"FAILED", "ABORTED"}: + raise RuntimeError( + f"{label} job {state}: {last.get('error') or last.get('exception')}" + ) + time.sleep(1) + raise RuntimeError(f"{label} job did not reach SUCCESS: {last!r}") + + def app_query(get=True): + params = [[["id", "=", APP_NAME]]] + if get: + params.append({"get": True}) + return call("app.query", params) + + def running(app): + workloads = app.get("active_workloads") or {} + details = workloads.get("container_details") or [] + return ( + app.get("state") == "RUNNING" + and app.get("custom_app") is True + and workloads.get("containers") == 1 + and len(details) == 1 + and details[0].get("service_name") == "web" + and details[0].get("image") == APP_IMAGE + and details[0].get("state") == "running" + ) + + def wait_state(expected, require_running=False): + deadline = time.monotonic() + a.state_timeout + last = None + while time.monotonic() < deadline: + last = app_query() + if require_running: + if running(last): + payload["states"].append(last.get("state")) + return last + elif last.get("state") == expected: + payload["states"].append(last.get("state")) + return last + if last.get("state") in {"CRASHED", "ERROR"}: + raise RuntimeError( + f"app entered failure state while waiting for {expected}: {last!r}" + ) + time.sleep(1) + raise RuntimeError(f"app did not reach {expected}: {last!r}") + + auth = call( + "auth.login_ex", + [{ + "mechanism": "PASSWORD_PLAIN", + "username": "truenas_admin", + "password": password, + }], + ) + payload["auth_response_type"] = ( + auth.get("response_type") if isinstance(auth, dict) else None + ) + if payload["auth_response_type"] != "SUCCESS": + raise RuntimeError(f"authentication did not return SUCCESS: {auth!r}") + + version = call("system.version", []) + payload["system_version"] = version + if version != a.expected_version: + raise RuntimeError( + f"target version changed: expected {a.expected_version!r}, got {version!r}" + ) + + initial = app_query() + if not running(initial): + raise RuntimeError(f"T5 requires the exact T4 app already RUNNING: {initial!r}") + payload["states"].append(initial["state"]) + + job_id = call("app.stop", [APP_NAME]) + if not isinstance(job_id, int): + raise RuntimeError(f"app.stop did not return a job id: {job_id!r}") + wait_job(job_id, "stop_1") + wait_state("STOPPED") + + job_id = call("app.start", [APP_NAME]) + if not isinstance(job_id, int): + raise RuntimeError(f"app.start did not return a job id: {job_id!r}") + wait_job(job_id, "start") + wait_state("RUNNING", require_running=True) + + updated_compose = { + "services": { + "web": { + "image": APP_IMAGE, + "restart": "unless-stopped", + "environment": {"RDTE_GENERATION": "2"}, + } + } + } + job_id = call( + "app.update", + [APP_NAME, {"custom_compose_config": updated_compose}], + ) + if not isinstance(job_id, int): + raise RuntimeError(f"app.update did not return a job id: {job_id!r}") + wait_job(job_id, "update") + + config = call("app.config", [APP_NAME]) + payload["config_after_update"] = config + generation = ( + config.get("services", {}) + .get("web", {}) + .get("environment", {}) + .get("RDTE_GENERATION") + ) + if str(generation) != "2": + raise RuntimeError( + f"app.config did not read back RDTE_GENERATION=2: {config!r}" + ) + config_image = config.get("services", {}).get("web", {}).get("image") + if config_image != APP_IMAGE: + raise RuntimeError( + f"app.config image drifted from immutable T4 digest: {config_image!r}" + ) + wait_state("RUNNING", require_running=True) + + job_id = call("app.redeploy", [APP_NAME]) + if not isinstance(job_id, int): + raise RuntimeError(f"app.redeploy did not return a job id: {job_id!r}") + wait_job(job_id, "redeploy") + wait_state("RUNNING", require_running=True) + + job_id = call("app.stop", [APP_NAME]) + if not isinstance(job_id, int): + raise RuntimeError(f"second app.stop did not return a job id: {job_id!r}") + wait_job(job_id, "stop_2") + wait_state("STOPPED") + + job_id = call( + "app.delete", + [APP_NAME, { + "remove_images": True, + "remove_ix_volumes": True, + "force_remove_custom_app": False, + }], + ) + if not isinstance(job_id, int): + raise RuntimeError(f"app.delete did not return a job id: {job_id!r}") + wait_job(job_id, "delete") + + remaining = app_query(get=False) + payload["post_delete_query"] = remaining + if remaining: + raise RuntimeError(f"app still exists after delete: {remaining!r}") + + payload["classification"] = "SUPPORTED" + payload["oracleSatisfied"] = True + payload["detail"] = ( + "digest-pinned custom app completed stop/start, config mutation with " + "public read-back, redeploy, stop, and delete" + ) + except Exception as exc: + payload["detail"] = f"{type(exc).__name__}: {exc}" + finally: + if ws is not None: + ws.close() + + payload["elapsed_seconds"] = round(time.time() - started, 3) + pathlib.Path(a.out).write_text( + json.dumps(payload, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + print(json.dumps(payload, indent=2, sort_keys=True)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/truenas_middleware_app_probe.py b/scripts/truenas_middleware_app_probe.py new file mode 100755 index 000000000..9f70f41b7 --- /dev/null +++ b/scripts/truenas_middleware_app_probe.py @@ -0,0 +1,213 @@ +#!/usr/bin/env python3 +"""Bounded exact-version TrueNAS Apps T4 oracle using source-defined middleware APIs.""" +from __future__ import annotations + +import argparse +import json +import pathlib +import time + +from truenas_middleware_ddp_probe import WebSocket, ddp_call, wait_for + + +APP_NAME = "rdte-t4-probe" +APP_IMAGE = "nginx@sha256:65645c7bb6a0661892a8b03b89d0743208a18dd2f3f17a54ef4b76fb8e2f2a10" + + +def main(): + p = argparse.ArgumentParser() + p.add_argument("--host", default="127.0.0.1") + p.add_argument("--port", type=int, required=True) + p.add_argument("--password-file", required=True) + p.add_argument("--out", required=True) + p.add_argument("--pool-name", default="rdtepool") + p.add_argument("--expected-version", required=True) + p.add_argument("--middleware-ref", required=True) + p.add_argument("--middleware-commit", required=True) + p.add_argument("--ha-apps-gate", required=True) + p.add_argument("--tls", action="store_true") + p.add_argument("--timeout", type=float, default=8.0) + p.add_argument("--job-timeout", type=float, default=300.0) + p.add_argument("--state-timeout", type=float, default=180.0) + a = p.parse_args() + + password = pathlib.Path(a.password_file).read_text(encoding="utf-8").strip() + payload = { + "schema": "truenas-middleware-app-t4/v1", + "source_contract": { + "middleware_ref": a.middleware_ref, + "middleware_commit": a.middleware_commit, + "apps_gate": "docker.license_active", + "ha_apps_gate": a.ha_apps_gate, + "expected_version": a.expected_version, + }, + "oracleSatisfied": False, + "classification": "ORACLE_FAILURE", + "pool_name": a.pool_name, + "app_name": APP_NAME, + "app_image": APP_IMAGE, + "transport": "wss" if a.tls else "ws", + } + started = time.time() + ws = None + request_id = 1 + + try: + ws = WebSocket(a.host, a.port, timeout=a.timeout, tls=a.tls) + ws.send_json({"msg": "connect", "version": "1", "support": ["1"]}) + connected = wait_for(ws, lambda m: m.get("msg") in {"connected", "failed"}) + if connected.get("msg") != "connected": + raise RuntimeError(f"DDP connection failed: {connected!r}") + + def call(method, params): + nonlocal request_id + result = ddp_call(ws, str(request_id), method, params) + request_id += 1 + return result + + def wait_job(job_id, label): + deadline = time.monotonic() + a.job_timeout + last = None + while time.monotonic() < deadline: + last = call("core.get_jobs", [[["id", "=", job_id]], {"get": True}]) + if last: + state = last.get("state") + if state == "SUCCESS": + return last + if state in {"FAILED", "ABORTED"}: + raise RuntimeError( + f"{label} job {state}: {last.get('error') or last.get('exception')}" + ) + time.sleep(1) + raise RuntimeError(f"{label} job did not reach SUCCESS within timeout: {last!r}") + + auth = call( + "auth.login_ex", + [{ + "mechanism": "PASSWORD_PLAIN", + "username": "truenas_admin", + "password": password, + }], + ) + payload["auth_response_type"] = auth.get("response_type") if isinstance(auth, dict) else None + if payload["auth_response_type"] != "SUCCESS": + raise RuntimeError(f"authentication did not return SUCCESS: {auth!r}") + + payload["system_version"] = call("system.version", []) + if payload["system_version"] != a.expected_version: + raise RuntimeError( + f"target version changed: expected {a.expected_version!r}, got {payload['system_version']!r}" + ) + payload["docker_before"] = call("docker.config", []) + existing = call("app.query", [[[ "id", "=", APP_NAME ]]]) + if existing: + raise RuntimeError(f"refusing mutation: app {APP_NAME!r} already exists") + + docker_job_id = call("docker.update", [{"pool": a.pool_name}]) + if not isinstance(docker_job_id, int): + raise RuntimeError(f"docker.update did not return a job id: {docker_job_id!r}") + payload["docker_update_job_id"] = docker_job_id + docker_job = wait_job(docker_job_id, "docker.update") + payload["docker_update_job"] = { + "id": docker_job.get("id"), + "state": docker_job.get("state"), + "progress": docker_job.get("progress"), + } + payload["docker_update_result"] = docker_job.get("result") + + deadline = time.monotonic() + a.state_timeout + docker_status = None + while time.monotonic() < deadline: + docker_status = call("docker.status", []) + if isinstance(docker_status, dict) and docker_status.get("status") == "RUNNING": + break + if isinstance(docker_status, dict) and docker_status.get("status") in {"FAILED", "MIGRATION_FAILED"}: + raise RuntimeError(f"Docker entered failure state: {docker_status!r}") + time.sleep(1) + else: + raise RuntimeError(f"Docker did not reach RUNNING: {docker_status!r}") + payload["docker_status"] = docker_status + + docker_config = call("docker.config", []) + payload["docker_config"] = docker_config + if docker_config.get("pool") != a.pool_name: + raise RuntimeError( + f"Docker pool mismatch: expected {a.pool_name!r}, got {docker_config.get('pool')!r}" + ) + + compose = { + "services": { + "web": { + "image": APP_IMAGE, + "restart": "unless-stopped", + } + } + } + payload["create_contract"] = { + "app_name": APP_NAME, + "custom_app": True, + "custom_compose_config": compose, + } + app_job_id = call("app.create", [{ + "app_name": APP_NAME, + "custom_app": True, + "custom_compose_config": compose, + }]) + if not isinstance(app_job_id, int): + raise RuntimeError(f"app.create did not return a job id: {app_job_id!r}") + payload["app_create_job_id"] = app_job_id + app_job = wait_job(app_job_id, "app.create") + payload["app_create_job"] = { + "id": app_job.get("id"), + "state": app_job.get("state"), + "progress": app_job.get("progress"), + } + payload["app_create_result"] = app_job.get("result") + + deadline = time.monotonic() + a.state_timeout + app = None + while time.monotonic() < deadline: + app = call("app.query", [[[ "id", "=", APP_NAME ]], {"get": True}]) + if app: + workloads = app.get("active_workloads") or {} + details = workloads.get("container_details") or [] + matching = [ + c for c in details + if c.get("service_name") == "web" + and c.get("image") == APP_IMAGE + and c.get("state") == "running" + ] + if ( + app.get("state") == "RUNNING" + and app.get("custom_app") is True + and workloads.get("containers") == 1 + and len(matching) == 1 + ): + break + if app.get("state") == "CRASHED": + raise RuntimeError(f"custom app entered CRASHED state: {app!r}") + time.sleep(1) + else: + raise RuntimeError(f"custom app did not reach the running oracle: {app!r}") + + payload["app"] = app + payload["oracleSatisfied"] = True + payload["classification"] = "SUPPORTED" + payload["detail"] = ( + "Apps initialized on the real disposable ZFS pool and a source-shaped custom Compose app " + "reached RUNNING with exactly one expected nginx container" + ) + except Exception as exc: + payload["detail"] = f"{type(exc).__name__}: {exc}" + finally: + if ws is not None: + ws.close() + + payload["elapsed_seconds"] = round(time.time() - started, 3) + pathlib.Path(a.out).write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8") + print(json.dumps(payload, indent=2, sort_keys=True)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/truenas_middleware_collage_readback_p2.py b/scripts/truenas_middleware_collage_readback_p2.py new file mode 100644 index 000000000..423bb5c46 --- /dev/null +++ b/scripts/truenas_middleware_collage_readback_p2.py @@ -0,0 +1,496 @@ +#!/usr/bin/env python3 +"""COLLAGE P2: independent native TrueNAS App readback in one disposable guest. + +This helper only runs after the existing Paper T6 proof has installed a native +App. Credential/certificate setup mutates the *disposable guest* before the +observation phase. Only the Go observer reads provider state. Never use on a +physical TrueNAS host, with an existing site's credentials, or in operations. +""" +from __future__ import annotations + +import datetime as dt +import hashlib +import json +import os +import pathlib +import re +import ssl +import subprocess +import sys +import tempfile + +SCHEMA = "semper-supra.collage-native-readback-p2/1" +REALM = "d68a14b9-2a1c-4e83-a452-e3b569cdf93e" +SERVICE_UUID = "11111111-1111-4111-8111-111111111111" +NAME = "collage_p2_observer" +ROLE_NAME = "collage-p2-apps-read" +OWNER = "rdte-t6-minecraft-paper" +WORLD_ID = "world-rdte-001" +VANILLA263_OWNER = "rdte-t6-minecraft-vanilla-263" +VANILLA263_WORLD = "lantern-rdte" +VANILLA263_SOURCE_SHA = "b9256d6ec0dbac43be2f5a136103b6e3512ee2ec" +DEFAULT_SOURCE_SHA = "fbda94f3fea0a216ddd9c35b38a09d325bed3640" + + +class ObservationError(RuntimeError): + pass + + +def require(value: bool, code: str) -> None: + if not value: + raise ObservationError(code) + + +def available_role_names(roles: object) -> set[str]: + """TrueNAS 25.04 privilege.roles -> list of role descriptors, not a map. + + Reject malformed or ambiguous inventories before any guest mutation. Only + report whether the exact role is offered; never infer broader privilege. + """ + require(isinstance(roles, list), "PRIVILEGE_ROLES_SHAPE_DRIFT") + names: set[str] = set() + for item in roles: + require(isinstance(item, dict) and isinstance(item.get("name"), str) + and isinstance(item.get("includes"), list) + and all(isinstance(x, str) for x in item["includes"]), + "PRIVILEGE_ROLES_SHAPE_DRIFT") + name = item["name"] + require(name not in names, "DUPLICATE_PRIVILEGE_ROLE") + names.add(name) + return names + + + +def scoped_key_expiry_ejson(now: dt.datetime | None = None) -> dict[str, int]: + """One-hour UTC expiry in pinned TrueNAS legacy WebSocket EJSON format. + + TrueNAS api_client release/25.04.2.6/ejson.py serializes datetime as + {"$date": UTC milliseconds}. The pinned middleware BaseModel has + strict=True: a plain datetime.isoformat() string is rejected as type + datetime by the 25.04.2.6 api_key.create input validator. Do not bypass + expiration or change role/transport to avoid this type mismatch. + """ + if now is None: + now = dt.datetime.now(dt.timezone.utc) + require(type(now) is dt.datetime and now.tzinfo is not None and + now.utcoffset() is not None, + "SCOPED_KEY_EXPIRY_REQUIRES_AWARE_DATETIME") + future = now.astimezone(dt.timezone.utc) + dt.timedelta(hours=1) + # Exactly the UTC-second canonicalization in the frozen EJSON encoder. + return {"$date": int(future.timestamp()) * 1000} + + +def provision_guest_readonly(call) -> str: + """Called on *already isolated* disposable TrueNAS 25.04.1 via admin DDP. + + Explicit provisioning is *outside* the zero-mutation observation phase. + No API keys, returned user/group/privilege records, or raw errors in logs. + """ + require(call("user.query", [[["username", "=", NAME]]]) == [], + "PREEXISTING_READONLY_USER_REFUSED") + require(call("privilege.query", [[["name", "=", ROLE_NAME]]]) == [], + "PREEXISTING_PRIVILEGE_REFUSED") + roles = available_role_names(call("privilege.roles", [])) + require("APPS_READ" in roles, "APPS_READ_ROLE_NOT_AVAILABLE") + user = call("user.create", [{ + "username": NAME, "full_name": "Disposable COLLAGE P2 observer", + "group_create": True, "password_disabled": True, + "ssh_password_enabled": False, "smb": False, + }]) + require(isinstance(user, dict) and user.get("username") == NAME, + "READONLY_ACCOUNT_CREATION_REFUSED") + group = user.get("group") + # TrueNAS user.create returns a populated group reference (datastore ID) + # but does not guarantee that it includes the Unix GID. Fetch the exact + # group by its immutable internal ID before creating a privilege. Never + # fall back to treating the database ID as POSIX GID. + require(isinstance(group, dict) and type(group.get("id")) is int, + "READONLY_ACCOUNT_PRIMARY_GROUP_ID_UNAVAILABLE") + gid_record = call("group.query", [[["id", "=", group["id"]]], {"get": True}]) + require(type(gid_record) is dict and gid_record.get("id") == group["id"] + and type(gid_record.get("gid")) is int and gid_record["gid"] >= 0 + and gid_record.get("local") is True, + "READONLY_ACCOUNT_PRIMARY_GROUP_GID_UNAVAILABLE") + group_gid = gid_record["gid"] + # Exact TrueNAS 25.04.2.6 middleware privilege.create.local_groups + # requires POSIX GIDs, NOT middleware account.group datastore IDs. + # group.query(id=...) and user.query(group.id) still use datastore IDs. + privilege = call("privilege.create", [{ + "name": ROLE_NAME, "local_groups": [group_gid], + "roles": ["APPS_READ"], "web_shell": False, + }]) + require(isinstance(privilege, dict) and privilege.get("name") == ROLE_NAME, + "APPS_READ_ASSIGNMENT_UNVERIFIED") + require(privilege.get("roles") == ["APPS_READ"], + "CREATED_PRIVILEGE_ROLES_NOT_EXACT") + members = privilege.get("local_groups") + require(type(members) is list and len(members) == 1 + and type(members[0]) is dict + and members[0].get("gid") == group_gid, + "CREATED_PRIVILEGE_GID_LINK_NOT_EXACT") + # Distinguish role/primary-group propagation problems from extra authority, + # without emitting public role names, user metadata or sensitive API fields. + primary = call("group.query", [[["id", "=", group["id"]]], {"get": True}]) + require(isinstance(primary, dict) and primary.get("id") == group["id"], + "OBSERVER_PRIMARY_GROUP_NOT_FOUND") + require(primary.get("roles") == ["APPS_READ"], + "OBSERVER_PRIMARY_GROUP_ROLE_NOT_EXACT") + user_roles = call("user.query", [[["username", "=", NAME]], {"get": True}]) + require(isinstance(user_roles, dict) and user_roles.get("username") == NAME, + "OBSERVER_USER_READBACK_NOT_FOUND") + user_primary = user_roles.get("group") + require(isinstance(user_primary, dict) + and user_primary.get("id") == group["id"], + "OBSERVER_USER_PRIMARY_GROUP_DRIFT") + effective = user_roles.get("roles") + require(isinstance(effective, list) + and all(isinstance(role, str) for role in effective), + "OBSERVER_USER_ROLES_SHAPE_DRIFT") + require("APPS_READ" in effective, "OBSERVER_USER_REQUIRED_ROLE_ABSENT") + require(effective == ["APPS_READ"], "OBSERVER_USER_EXTRA_ROLES_REFUSED") + # The 25.04.2.6 BaseModel is strict: a bare ISO string is NOT a + # datetime in Python-mode validation. Legacy DDP uses extended JSON + # {"$date": epoch_ms}; the pinned TrueNAS decoder turns it into a + # timezone-aware datetime before API validation. Keep the one-hour TTL. + expiry = scoped_key_expiry_ejson() + key = call("api_key.create", [{ + "name": "disposable-collage-p2", "username": NAME, + "expires_at": expiry, + }]) + require(isinstance(key, dict) and isinstance(key.get("key"), str) + and len(key["key"]) > 20, + "DISPOSABLE_READONLY_KEY_NOT_ISSUED") + return key["key"] + + +def discover_tls_anchor(port: int, out: pathlib.Path) -> tuple[str, str]: + """Pin self-signed guest HTTPS cert for private ephemeral RDTE loopback. + + TOFU is limited to the fresh, runner-owned, 127.0.0.1 QEMU forwarded port. + The subsequent Go client MUST verify this pinned cert. No insecure Go TLS. + Operational deployments must instead pin their explicit trusted CA. + """ + require(0 < port < 65536, "INVALID_GUEST_HTTPS_PORT") + try: + pem = ssl.get_server_certificate(("127.0.0.1", port), timeout=12) + der = ssl.PEM_cert_to_DER_cert(pem) + except (OSError, ssl.SSLError): + raise ObservationError("DISPOSABLE_GUEST_TLS_UNAVAILABLE") from None + out.write_text(pem, encoding="ascii") + p = subprocess.run(["openssl", "x509", "-in", str(out), "-noout", + "-ext", "subjectAltName"], text=True, + capture_output=True, timeout=12, check=False) + require(p.returncode == 0, "TLS_CERTIFICATE_SAN_UNAVAILABLE") + sans = p.stdout + if "IP Address:127.0.0.1" in sans: + hostname = "127.0.0.1" + elif "DNS:localhost" in sans: + hostname = "localhost" + else: + # Never override an arbitrary hostname in runner DNS. + names = re.findall(r"DNS:(truenas(?:\.[a-z0-9.-]+)?)", sans, re.I) + require(bool(names), "TLS_CERTIFICATE_HOST_IDENTITY_UNSUPPORTED") + hostname = names[0].lower() + add = subprocess.run(["sudo", "-n", "sh", "-c", + "printf '127.0.0.1 %s\\n' \"$1\" >> /etc/hosts", + "sh", hostname], capture_output=True, timeout=8) + require(add.returncode == 0, "EPHEMERAL_GUEST_HOSTNAME_BINDING_FAILED") + return hostname, hashlib.sha256(der).hexdigest() + + +def exact_source_readback_request(plan: dict, *, profile: str) -> tuple[dict, str, str]: + """Build only a read-only source intent; never establish artifact format. + + Use the already admitted COLLAGE source-only fixture for the provider + ownership probe. This fixture is NOT a playable datapack and is never + unpacked, applied or counted as real-world artifact deployment. + """ + require(profile in {"paper", "vanilla263"}, "COLLAGE_READBACK_PROFILE_UNSUPPORTED") + request = json.loads(json.dumps(plan)) + native = profile == "vanilla263" + owner = VANILLA263_OWNER if native else OWNER + world = VANILLA263_WORLD if native else WORLD_ID + request["request_id"] = "collage-native-vanilla263-readonly" if native else "collage-p2-native-paper" + request["target"].update({ + "deployment_id": owner, "service_id": SERVICE_UUID, + "world_id": world, "minecraft_version": "26.3" if native else "1.21.11", + "backend": "vanilla" if native else "paper", "purpose": "generic", + }) + request["artifact"].update({ + "world_id": world, "minecraft_version": "26.3" if native else "1.21.11", + }) + if native: + # The source artifact remains an intentionally unplayable fixture. + # This is permissible only for read-only provider observation, where + # artifact_format_verified must remain false; not actual deployment. + request["artifact"]["kind"] = "datapack" + return request, owner, world + + +# Fixed, non-secret RPC names and call order. No untrusted exception message +# or credential ever crosses into public job evidence. The prior real 005 guest +# failed with generic RuntimeError, masking WHICH guest RBAC API rejected. +RBAC_CALL_SEQUENCE = ( + ("user.query", "precheck_user"), + ("privilege.query", "precheck_privilege"), + ("privilege.roles", "discover_roles"), + ("user.create", "create_user"), + ("group.query", "resolve_group_gid"), + ("privilege.create", "grant_apps_read"), + ("group.query", "check_group_roles"), + ("user.query", "check_user_roles"), + ("api_key.create", "issue_scoped_key"), +) + + + +# Fixed allowlist only: TrueNAS 25.04 serializes ValidationErrors as +# errno 11 with type=VALIDATION and extra [attribute, message, errno] tuples. +# Attribute strings not explicitly listed here may never appear in receipts. +_VALIDATION_FIELDS = { + "api_key_create": "GENERAL", + "api_key_create.name": "NAME", + "api_key_create.username": "USERNAME", + "api_key_create.expires_at": "EXPIRES_AT", +} + + +def scoped_validation_field_suffix(obj: dict) -> str: + """Classify only a recognized validation field, never its message/value.""" + if obj.get("type") != "VALIDATION": + return "" + extra = obj.get("extra") + if type(extra) is not list or not (1 <= len(extra) <= 12): + return "_VALIDATION_FIELD_UNKNOWN" + categories = set() + for item in extra: + if type(item) is not list or len(item) != 3: + return "_VALIDATION_FIELD_UNKNOWN" + field = item[0] + if type(field) is not str or field not in _VALIDATION_FIELDS: + return "_VALIDATION_FIELD_UNKNOWN" + categories.add(_VALIDATION_FIELDS[field]) + return ("_VALIDATION_" + next(iter(categories))) if len(categories) == 1 else "_VALIDATION_MULTI" + + +def scoped_key_record_count_suffix(call) -> str: + """Read-only exact owned key count once; no listing, retries or disclosure.""" + try: + count = call("api_key.query", [ + [["username", "=", NAME], ["name", "=", "disposable-collage-p2"]], + {"count": True}, + ]) + except Exception: + return "_KEY_COUNT_UNAVAILABLE" + if type(count) is not int or count < 0: + return "_KEY_COUNT_INVALID" + if count == 0: + return "_KEY_COUNT0" + if count == 1: + return "_KEY_COUNT1" + return "_KEY_COUNT_MULTIPLE" + + +def classify_scoped_key_ddp_failure(exc: Exception) -> str: + """Only fixed failure categories or allowlisted DDP codes leave the guest. + + In addition to provider rejection, this RPC can time out while TrueNAS + performs password hash/PAM generation. Timeouts are indeterminate: the + key might already exist, so the caller must never retry issuance blindly. + Error messages, principal metadata and secret values remain private. + """ + if isinstance(exc, TimeoutError): + return "_TRANSPORT_TIMEOUT_INDETERMINATE" + if isinstance(exc, (ConnectionError, EOFError)): + return "_TRANSPORT_DISCONNECTED" + if isinstance(exc, OSError): + return "_TRANSPORT_IO_FAILURE" + if type(exc) is not RuntimeError or not exc.args or type(exc.args[0]) is not str: + return "_LOCAL_UNEXPECTED_FAILURE" + data = exc.args[0] + prefix = "api_key.create: " + if not data.startswith(prefix): + return "_DDP_UNSTRUCTURED_ERROR" + # Native 008 returned an oversized *structured* error after 119 s + # overall successful world staging. Keep the parser size-bounded, but + # inspect an error object of realistic traceback size in memory so the + # fixed allowlist can classify the DDP numeric code. Never publish the + # remote reason/trace/details, even when malicious or unusually long. + if len(data) > 262144: + return "_DDP_OVERSIZE_ERROR" + try: + obj = json.loads(data[len(prefix):]) + except (ValueError, RecursionError): + return "_DDP_MALFORMED_ERROR" + if type(obj) is not dict: + return "_DDP_NONOBJECT_ERROR" + code = obj.get("error") + if type(code) is int and code in {1, 2, 5, 11, 13, 17, 22, 38, 400, 401, 403, 404, 409, 422, 500, 503}: + return "_E" + str(code) + scoped_validation_field_suffix(obj) + if type(code) is str and code in {"EINVAL","EPERM","EACCES","VALIDATION_ERRORS","METHOD_NOT_FOUND"}: + return "_" + code + if type(code) is dict: + # Middleware may nest its code/error/errno fields beneath error. + # At most one level, with a hardcoded numeric-only allowlist; never + # derive a public string from untrusted remote metadata. + nested = code.get("errno") + if type(nested) is not int: + nested = code.get("error") + if type(nested) is int and nested in {1, 2, 5, 11, 13, 17, 22, 38, 400, 401, 403, 404, 409, 422, 500, 503}: + return "_E" + str(nested) + return "_DDP_NESTED_ERROR" + if type(code) is list: + return "_DDP_VALIDATION_LIST" + return "_DDP_UNRECOGNIZED_CODE" + + + +def bounded_rbac_call(call): + position = 0 + + def invoke(method, params): + nonlocal position + require(position < len(RBAC_CALL_SEQUENCE) and + method == RBAC_CALL_SEQUENCE[position][0], + "GUEST_RBAC_API_SEQUENCE_DRIFT") + operation = RBAC_CALL_SEQUENCE[position][1] + position += 1 + try: + return call(method, params) + except Exception as exc: + suffix = (classify_scoped_key_ddp_failure(exc) + if operation == "issue_scoped_key" else "") + if operation == "issue_scoped_key" and ( + suffix.startswith("_E11") or + suffix == "_TRANSPORT_TIMEOUT_INDETERMINATE"): + # A failed issuance may be indeterminate. One exact, read-only + # count only; never retry api_key.create or fetch key records. + suffix += scoped_key_record_count_suffix(call) + raise ObservationError("GUEST_RBAC_" + operation.upper() + + "_API_FAILED" + suffix) from None + return invoke + + +# Only fixed public-source refusal codes may cross from the child observer. +# Never concatenate untrusted JSON reason/error strings or stderr into receipts. +_ALLOWED_NATIVE_REFUSALS = frozenset({ + "NATIVE_READONLY_PROBE_FAILED", "PROBE_OUTPUT_NOT_JSON", + "PROBE_OUTPUT_OVERSIZED", "INVALID_PROVIDER_REPORT", + "NATIVE_APP_READBACK_INVALID", "NATIVE_APP_NOT_RUNNING", + "PROVIDER_OWNERSHIP_MISMATCH", "ARTIFACT_TARGET_DRIFT", + "SERVICE_ID_REQUIRED_FOR_PROVIDER_READBACK", + "REALM_NOT_IN_ADMITTED_SITE_BOUNDARY", + "NATIVE_VANILLA263_TARGET_PROFILE_DRIFT", + "NATIVE_VANILLA263_GUEST_PROFILE_DRIFT", + "NATIVE_VANILLA263_CATALOG_PROFILE_REFUSED", + "NATIVE_CATALOG_PROFILE_WRONG_PRODUCT", + "MISSING_BOUNDED_API_AUTHORITY", +}) + + +def validated_native_join_verdict(code: int, stdout: str, *, owner: str, + world: str) -> dict: + """Distinguish bounded failure planes while preserving read-only proof.""" + if type(stdout) is not str or len(stdout.encode("utf-8")) >= 32768: + raise ObservationError("NATIVE_READONLY_GUEST_JOIN_OUTPUT_INVALID") + try: + v = json.loads(stdout) + except ValueError: + raise ObservationError("NATIVE_READONLY_GUEST_JOIN_NONJSON") from None + if type(v) is not dict or v.get("schema") != "collage.native-readback/1": + raise ObservationError("NATIVE_READONLY_GUEST_JOIN_SCHEMA_INVALID") + if code != 0: + if code == 2 and v.get("classification") == "REFUSED" and ( + v.get("mutation_authorized") is False): + why = v.get("reason") + suffix = why if type(why) is str and why in _ALLOWED_NATIVE_REFUSALS else "OTHER" + raise ObservationError("NATIVE_READONLY_GUEST_JOIN_REFUSED_" + suffix) + if code == 3 and v.get("classification") == "INCONCLUSIVE" and ( + v.get("mutation_authorized") is False): + raise ObservationError("NATIVE_READONLY_GUEST_JOIN_CLIENT_INCONCLUSIVE") + raise ObservationError("NATIVE_READONLY_GUEST_JOIN_CLIENT_EXIT") + if v.get("classification") != "PROVIDER_READBACK_MATCH": + raise ObservationError("NATIVE_READONLY_GUEST_JOIN_CLASSIFICATION_INVALID") + if v.get("mutation_authorized") is not False or ( + v.get("ownership_fields_matched") != 6 or + v.get("service_id") != SERVICE_UUID): + raise ObservationError("NATIVE_READONLY_GUEST_JOIN_OWNER_FIELDS_INVALID") + if v.get("native_app") != owner or v.get("world_id") != world: + raise ObservationError("NATIVE_READONLY_GUEST_JOIN_TARGET_INVALID") + digest = v.get("source_plan_sha256") + if type(digest) is not str or not re.fullmatch(r"[0-9a-f]{64}", digest): + raise ObservationError("NATIVE_READONLY_GUEST_JOIN_SOURCE_DIGEST_INVALID") + if owner == VANILLA263_OWNER and ( + v.get("native_app_version") != "1.15.31" or + v.get("truenas_version") != "25.04.2.6"): + raise ObservationError("NATIVE_READONLY_GUEST_JOIN_EXACT_CATALOG_INVALID") + return v + + +def run_real_readback(call, *, collage: pathlib.Path, https_port: int, + state_dir: pathlib.Path, expected_source_sha: str + = DEFAULT_SOURCE_SHA, profile: str = "paper") -> dict: + """Return ONLY allowlisted, non-secret independent readback evidence.""" + require(collage.is_dir(), "PINNED_COLLAGE_CHECKOUT_MISSING") + git = subprocess.run(["git", "-C", str(collage), "rev-parse", "HEAD"], + text=True, capture_output=True, timeout=5) + require(git.returncode == 0 and git.stdout.strip() == expected_source_sha, + "PINNED_COLLAGE_COMMIT_DRIFT") + source_plan = collage / "examples" / "source-only-rdte.json" + fixture = collage / "examples" / "contract-only-world.fixture" + require(source_plan.is_file() and fixture.is_file(), + "SOURCE_ONLY_FIXTURE_UNAVAILABLE") + request, expected_owner, expected_world = exact_source_readback_request( + json.loads(source_plan.read_text(encoding="utf-8")), profile=profile) + if profile == "vanilla263": + require(expected_source_sha == VANILLA263_SOURCE_SHA, + "VANILLA263_COLLAGE_SOURCE_REF_DRIFT") + source = state_dir / "collage-p2-request.json" + source.write_text(json.dumps(request, indent=2, sort_keys=True) + "\n") + key = provision_guest_readonly(bounded_rbac_call(call)) + trust = state_dir / "collage-p2-guest-cert.pem" + hostname, cert_sha = discover_tls_anchor(https_port, trust) + env = os.environ.copy() + env["TRUENAS_API_KEY"] = key + env["TRUENAS_PORT"] = str(https_port) + env["SSL_CERT_FILE"] = str(trust) + cmd = [sys.executable, str(collage / "tools" / "collage_native_readback.py"), + "--request", str(source), "--artifact-root", str(collage), + "--host", hostname, "--username", NAME, + "--trusted-realm-id", REALM, + "--manager-id", "manager-rdte", "--fleet-id", "rdte"] + if profile == "vanilla263": + # Caller is already bound to exact immutable 1.15.31 Foundry control. + # Explicitly select the qualified current consumer, never float catalog. + cmd += ["--native-catalog-profile", "vanilla263-11531"] + try: + p = subprocess.run(cmd, env=env, text=True, capture_output=True, + timeout=180, check=False) + except (OSError, subprocess.TimeoutExpired): + raise ObservationError("NATIVE_READONLY_CLIENT_UNAVAILABLE") from None + # Raw stdout/stderr and arbitrary child/provider data stay private. + # The fixed-code validator does not report secrets or controller data. + verdict = validated_native_join_verdict( + p.returncode, p.stdout, owner=expected_owner, world=expected_world) + return { + "schema": SCHEMA, + "classification": "PROVIDER_READBACK_MATCH", + "source_sha": expected_source_sha, + "source_plan_sha256": verdict["source_plan_sha256"], + "native_app": expected_owner, + "truenas_version": verdict["truenas_version"], + "service_id": SERVICE_UUID, + "world_id": expected_world, + "ownership_fields_matched": 6, + "tls_cert_sha256": cert_sha, + "transport": "isolated_guest_tls_verified", + "credential_scope": "APPS_READ", + "source_profile": profile, + "credential_mutation_phase": "disposable_guest_setup_before_observation", + "observation_mutation_count": 0, + "world_artifact_format_verified": False, + "two_rivers_gameplay_qualified": False, + "world_activated": False, + "cloudnet_adoption_executed": False, + "physical_true_nas_mutated": False, + } diff --git a/scripts/truenas_middleware_ddp_probe.py b/scripts/truenas_middleware_ddp_probe.py new file mode 100755 index 000000000..4266563ce --- /dev/null +++ b/scripts/truenas_middleware_ddp_probe.py @@ -0,0 +1,199 @@ +#!/usr/bin/env python3 +"""Dependency-free installed TrueNAS middleware DDP health probe.""" +from __future__ import annotations + +import argparse +import base64 +import hashlib +import json +import os +import pathlib +import socket +import ssl +import struct +import time + + +class WebSocket: + def __init__(self, host, port, path="/websocket", timeout=6.0, tls=False): + raw = socket.create_connection((host, port), timeout=timeout) + if tls: + context = ssl.create_default_context() + context.check_hostname = False + context.verify_mode = ssl.CERT_NONE + raw = context.wrap_socket(raw, server_hostname=host) + self.sock = raw + self.sock.settimeout(timeout) + key = base64.b64encode(os.urandom(16)).decode() + request = ( + f"GET {path} HTTP/1.1\r\n" + f"Host: {host}:{port}\r\n" + "Upgrade: websocket\r\n" + "Connection: Upgrade\r\n" + f"Sec-WebSocket-Key: {key}\r\n" + "Sec-WebSocket-Version: 13\r\n\r\n" + ).encode() + self.sock.sendall(request) + head = self._read_until(b"\r\n\r\n", 16384) + status = head.split(b"\r\n", 1)[0] + if b" 101 " not in status: + raise RuntimeError(f"websocket handshake failed: {status.decode(errors='replace')}") + expected = base64.b64encode( + hashlib.sha1((key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11").encode()).digest() + ).decode() + headers = {} + for line in head.decode(errors="replace").split("\r\n")[1:]: + if ":" in line: + k, v = line.split(":", 1) + headers[k.lower().strip()] = v.strip() + if headers.get("sec-websocket-accept") != expected: + raise RuntimeError("websocket accept digest mismatch") + + def _read_exact(self, n): + parts = [] + while n: + part = self.sock.recv(n) + if not part: + raise EOFError("websocket closed") + parts.append(part) + n -= len(part) + return b"".join(parts) + + def _read_until(self, marker, limit): + data = bytearray() + while marker not in data: + part = self.sock.recv(4096) + if not part: + raise EOFError("socket closed during handshake") + data.extend(part) + if len(data) > limit: + raise RuntimeError("handshake exceeded limit") + return bytes(data) + + def _send_frame(self, opcode, payload): + mask = os.urandom(4) + first = 0x80 | opcode + n = len(payload) + if n < 126: + header = bytes([first, 0x80 | n]) + elif n <= 0xFFFF: + header = bytes([first, 0x80 | 126]) + struct.pack("!H", n) + else: + header = bytes([first, 0x80 | 127]) + struct.pack("!Q", n) + masked = bytes(b ^ mask[i % 4] for i, b in enumerate(payload)) + self.sock.sendall(header + mask + masked) + + def send_json(self, obj): + self._send_frame(0x1, json.dumps(obj, separators=(",", ":")).encode()) + + def recv_json(self): + while True: + b1, b2 = self._read_exact(2) + opcode = b1 & 0x0F + n = b2 & 0x7F + if n == 126: + n = struct.unpack("!H", self._read_exact(2))[0] + elif n == 127: + n = struct.unpack("!Q", self._read_exact(8))[0] + masked = bool(b2 & 0x80) + mask = self._read_exact(4) if masked else b"" + payload = self._read_exact(n) + if masked: + payload = bytes(b ^ mask[i % 4] for i, b in enumerate(payload)) + if opcode == 0x8: + raise EOFError("websocket close frame") + if opcode == 0x9: + self._send_frame(0xA, payload) + continue + if opcode != 0x1: + continue + return json.loads(payload.decode()) + + def close(self): + try: + self._send_frame(0x8, b"") + except Exception: + pass + self.sock.close() + + +def wait_for(ws, predicate): + while True: + message = ws.recv_json() + if message.get("msg") == "ping": + response = {"msg": "pong"} + if "id" in message: + response["id"] = message["id"] + ws.send_json(response) + continue + if predicate(message): + return message + + +def ddp_call(ws, request_id, method, params): + ws.send_json({"id": request_id, "msg": "method", "method": method, "params": params}) + message = wait_for(ws, lambda m: m.get("msg") == "result" and m.get("id") == request_id) + if message.get("error") is not None: + raise RuntimeError(f"{method}: {json.dumps(message['error'], sort_keys=True)}") + return message.get("result") + + +def main(): + p = argparse.ArgumentParser() + p.add_argument("--host", default="127.0.0.1") + p.add_argument("--port", type=int, required=True) + p.add_argument("--password-file", required=True) + p.add_argument("--out", required=True) + p.add_argument("--tls", action="store_true") + p.add_argument("--timeout", type=float, default=6.0) + a = p.parse_args() + + password = pathlib.Path(a.password_file).read_text(encoding="utf-8").strip() + payload = { + "schema": "truenas-middleware-ddp-health/v1", + "oracleSatisfied": False, + "classification": "ORACLE_FAILURE", + "transport": "wss" if a.tls else "ws", + } + started = time.time() + ws = None + try: + ws = WebSocket(a.host, a.port, timeout=a.timeout, tls=a.tls) + ws.send_json({"msg": "connect", "version": "1", "support": ["1"]}) + connected = wait_for(ws, lambda m: m.get("msg") in {"connected", "failed"}) + if connected.get("msg") != "connected": + raise RuntimeError(f"DDP connection failed: {connected!r}") + + auth = ddp_call( + ws, + "1", + "auth.login_ex", + [{ + "mechanism": "PASSWORD_PLAIN", + "username": "truenas_admin", + "password": password, + }], + ) + payload["auth_response_type"] = auth.get("response_type") if isinstance(auth, dict) else None + if payload["auth_response_type"] != "SUCCESS": + raise RuntimeError(f"authentication did not return SUCCESS: {auth!r}") + + payload["system_version"] = ddp_call(ws, "2", "system.version", []) + payload["system_info"] = ddp_call(ws, "3", "system.info", []) + payload["oracleSatisfied"] = True + payload["classification"] = "SUPPORTED" + payload["detail"] = "installed TrueNAS middleware authenticated and answered system.version/system.info" + except Exception as exc: + payload["detail"] = f"{type(exc).__name__}: {exc}" + finally: + if ws is not None: + ws.close() + + payload["elapsed_seconds"] = round(time.time() - started, 3) + pathlib.Path(a.out).write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8") + print(json.dumps(payload, indent=2, sort_keys=True)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/truenas_middleware_foliorelay_t6_probe.py b/scripts/truenas_middleware_foliorelay_t6_probe.py new file mode 100644 index 000000000..0cb8525aa --- /dev/null +++ b/scripts/truenas_middleware_foliorelay_t6_probe.py @@ -0,0 +1,959 @@ +#!/usr/bin/env python3 +"""TrueNAS T6 oracle for the exact Foundry-exported FolioRelay control.""" +from __future__ import annotations + +import argparse +import base64 +import hashlib +import http.client +import json +import pathlib +import re +import socket +import ssl +import struct +import subprocess +import tempfile +import time +import urllib.error +import urllib.request + +from truenas_middleware_ddp_probe import WebSocket, ddp_call, wait_for + +EXPECTED_SCHEMA = "semper-supra.foliorelay-truenas-t6-control/1" +EXPECTED_APP_NAME = "rdte-t6-foliorelay" +DBUS_SOCKET = "/run/dbus/system_bus_socket" +AVAHI_TARGETS = {"25.04.1","25.04.2.6","25.10.7"} +DIRECT_TARGETS = {"26.0.0-BETA.3"} +AVAHI_DISCOVERY_USER = "65534:10001" +AVAHI_DISCOVERY_COMMAND = [ + "-identity-file","/var/lib/foliorelay-control/config/printer.json", + "-backend","avahi", + "-dbus-address","unix:path=/run/dbus/system_bus_socket", +] +OBSERVER_APP_NAME = "rdte-t6-foliorelay-observer" +EXPECTED_CONTROL = "ghcr.io/sempersupra/foliorelay-control@sha256:d0ba6d1efbed0d9f84b20d374eeb44ee28ab0874a683396e628850f159193cf5" +EXPECTED_CUPS = "ghcr.io/sempersupra/foliorelay-cups@sha256:0997ad2054ca5e57f34291372aed55f549eee9ff201f171b430436b0655c0814" +OBSERVER_IMAGE = "docker.io/library/hello-world@sha256:5e23090353324d887c48ad5e5c56d294eab81588df9605b07d1afe895f9cc8f8" +DATASET = "rdtepool/foliorelay-t6" +ROOT = "/mnt/rdtepool/foliorelay-t6" +TOKEN_PATH = ROOT + "/secrets/control.token" +OBSERVER_DIR = ROOT + "/observer" +TOKEN = b"foliorelay-t6-public-fixture-token-0001\n" +PUBLIC_HOST = "foliorelay-t6.local" +PUBLIC_IPP_PORT = 8634 +PUBLIC_RESOURCE_PATH = "/printers/FolioRelay" +PUBLIC_URI = f"ipp://{PUBLIC_HOST}:{PUBLIC_IPP_PORT}{PUBLIC_RESOURCE_PATH}" +OBSERVER_LOG_TAIL_LINES = 200 +OBSERVER_LOG_MAX_EVENTS = 200 +OBSERVER_LOG_CAPTURE_SECONDS = 2.0 + +def canonical_sha256(value): + return hashlib.sha256(json.dumps(value, sort_keys=True, separators=(",", ":")).encode()).hexdigest() + +def reconciliation_action(app_state, live_compose, desired_compose): + if app_state is None: + return "CREATE" + if app_state in {"DEPLOYING","STOPPING"}: + return "WAIT" + if app_state not in {"RUNNING","STOPPED"}: + return "FAIL_CLOSED" + if not isinstance(live_compose, dict): + return "FAIL_CLOSED" + return "NOOP" if canonical_sha256(live_compose)==canonical_sha256(desired_compose) else "UPDATE" + +def sha256_bytes(value: bytes) -> str: + return hashlib.sha256(value).hexdigest() + +def load_json(path: pathlib.Path): + value = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(value, dict): + raise RuntimeError(f"{path.name} must be an object") + return value + +def validate_discovery_materialization(services: dict, target_version: str): + discovery=services.get("discovery") or {} + dbus_mounts=[] + for service_name,service in services.items(): + for mount in (service or {}).get("volumes") or []: + if not isinstance(mount,dict): + continue + paths=[mount.get("source"),mount.get("target")] + if any(isinstance(x,str) and (x=="/run/dbus" or x.startswith("/run/dbus/") or x=="/var/run/dbus" or x.startswith("/var/run/dbus/")) for x in paths): + dbus_mounts.append((service_name,mount)) + if target_version in AVAHI_TARGETS: + if discovery.get("command")!=AVAHI_DISCOVERY_COMMAND: + raise RuntimeError("25.x discovery command drifted from exact Avahi contract") + if discovery.get("user")!=AVAHI_DISCOVERY_USER: + raise RuntimeError("25.x discovery user drifted from exact Avahi contract") + if len(dbus_mounts)!=1: + raise RuntimeError("25.x requires exactly one host D-Bus mount") + service_name,mount=dbus_mounts[0] + if not ( + service_name=="discovery" + and mount.get("type")=="bind" + and mount.get("source")==DBUS_SOCKET + and mount.get("target")==DBUS_SOCKET + and mount.get("read_only") is True + ): + raise RuntimeError("25.x host D-Bus coupling exceeds exact read-only Avahi socket") + return "avahi" + if target_version in DIRECT_TARGETS: + if dbus_mounts: + raise RuntimeError("direct discovery target must not couple to host D-Bus") + command=discovery.get("command") or [] + if "-backend" in command or "-dbus-address" in command or "user" in discovery: + raise RuntimeError("direct discovery target drifted into Avahi materialization") + return "direct" + raise RuntimeError(f"unsupported FolioRelay target version {target_version!r}") + +def validate_host_path_requirements(control: dict, target_version: str): + runtime=control.get("runtime") or {} + requirements=runtime.get("host_path_requirements") + if not isinstance(requirements,list): + raise RuntimeError("Foundry control missing host_path_requirements") + expected={ + ROOT+"/control":("directory","0710" if target_version in AVAHI_TARGETS else "0700"), + ROOT+"/artifacts":("directory","0700"), + ROOT+"/cups-state":("directory","0755"), + ROOT+"/cups-spool":("directory","0755"), + ROOT+"/secrets":("directory","0700"), + TOKEN_PATH:("file","0400"), + } + scheme=(runtime.get("management_scheme") or "http").lower() + if scheme=="https": + tls_root=runtime.get("management_tls_root") + if tls_root!=ROOT+"/tls": + raise RuntimeError("HTTPS management TLS root drifted") + expected[tls_root]=("directory","0700") + elif scheme!="http": + raise RuntimeError(f"unsupported management scheme {scheme!r}") + by_path={} + for item in requirements: + if not isinstance(item,dict): + raise RuntimeError("host_path_requirements entries must be objects") + path=item.get("path") + if not isinstance(path,str) or path in by_path: + raise RuntimeError("host_path_requirements path missing or duplicated") + by_path[path]=item + if set(by_path)!=set(expected): + raise RuntimeError("Foundry host path set drifted") + for path,(kind,mode) in expected.items(): + item=by_path[path] + if item.get("kind")!=kind or item.get("mode")!=mode: + raise RuntimeError(f"Foundry host path kind/mode drifted: {path}") + if item.get("uid")!=10001 or item.get("gid")!=10001: + raise RuntimeError(f"Foundry host path ownership drifted: {path}") + return [by_path[item["path"]] for item in requirements] + +def middleware_mode(mode: str) -> str: + if not isinstance(mode,str) or not re.fullmatch(r"0?[0-7]{3}",mode): + raise RuntimeError(f"invalid host path mode {mode!r}") + return mode[-3:] + +def load_control(root: pathlib.Path, foundry_ref: str, target_version: str): + control = load_json(root / "control.json") + compose = load_json(root / "compose.json") + if control.get("schema") != EXPECTED_SCHEMA: + raise RuntimeError("unexpected FolioRelay T6 control schema") + if control.get("foundry_ref") != foundry_ref: + raise RuntimeError("Foundry source ref drifted") + if control.get("secrets_captured") is not False: + raise RuntimeError("control bundle does not assert secrets_captured=false") + candidate = control.get("candidate") or {} + if candidate.get("truenas_version") != target_version: + raise RuntimeError("TrueNAS target drifted") + if candidate.get("control_image") != EXPECTED_CONTROL: + raise RuntimeError("control image drifted") + if candidate.get("cups_image") != EXPECTED_CUPS: + raise RuntimeError("CUPS image drifted") + if canonical_sha256(compose) != (control.get("artifacts") or {}).get("compose_canonical_sha256"): + raise RuntimeError("Compose identity does not match control") + services = compose.get("services") or {} + if set(services) != {"control", "cups", "discovery"}: + raise RuntimeError("expected exact control/cups/discovery service set") + if services["control"].get("image") != EXPECTED_CONTROL or services["discovery"].get("image") != EXPECTED_CONTROL: + raise RuntimeError("control/discovery exact image identity drifted") + if services["cups"].get("image") != EXPECTED_CUPS: + raise RuntimeError("CUPS exact image identity drifted") + discovery_backend=validate_discovery_materialization(services,target_version) + if candidate.get("discovery_backend") not in (None,discovery_backend): + raise RuntimeError("control discovery backend disagrees with rendered Compose") + required = set(control.get("required_oracles") or []) + expected = { + "app-create-running", "config-readback-exact-compose", + "management-endpoint-ready", "truenas-webui-portal-advertised", + "ipp-get-printer-attributes", "canonical-uri-coherence", + "control-cups-dnssd-uuid-coherence", "dnssd-universal-visible", + "pdf-exact-source-inbox", "urf-exact-source-inbox", + "restart-preserves-identity-and-inbox", "delete-zero-residue", + } + if not expected.issubset(required): + raise RuntimeError("required FolioRelay oracle contract drifted") + validate_host_path_requirements(control,target_version) + validate_management_transport(control, services) + validate_management_portal_contract(control, compose) + return control, compose + +def validate_management_portal_contract(control: dict, compose: dict) -> str: + runtime=control.get("runtime") or {} + portal=runtime.get("management_portal") + expected={ + "name":"Web UI", + "scheme":"https", + "host":PUBLIC_HOST, + "port":runtime.get("management_port"), + "path":"/", + } + if portal!=expected: + raise RuntimeError("TrueNAS Web UI portal control metadata drifted") + if compose.get("x-portals")!=[expected]: + raise RuntimeError("TrueNAS Web UI portal Compose metadata drifted") + required=set(control.get("required_oracles") or []) + for oracle in ("management-endpoint-ready","truenas-webui-portal-advertised"): + if oracle not in required: + raise RuntimeError(f"portal control missing required oracle {oracle}") + if "portal-ready" in required: + raise RuntimeError("ambiguous legacy portal-ready oracle must not remain") + return f"https://{PUBLIC_HOST}:{expected['port']}{expected['path']}" + +def validate_management_transport(control: dict, services: dict) -> str: + runtime=control.get("runtime") or {} + scheme=(runtime.get("management_scheme") or "http").lower() + if scheme=="http": + return scheme + if scheme!="https": + raise RuntimeError(f"unsupported management scheme {scheme!r}") + if runtime.get("management_tls_root")!=ROOT+"/tls": + raise RuntimeError("HTTPS management TLS root drifted") + if runtime.get("management_tls_state")!="/var/lib/foliorelay-tls": + raise RuntimeError("HTTPS management TLS state target drifted") + required=set(control.get("required_oracles") or []) + for oracle in ("management-tls-ready","management-tls-identity-persistent"): + if oracle not in required: + raise RuntimeError(f"HTTPS control missing required oracle {oracle}") + tls_root=runtime["management_tls_root"] + tls_target=runtime["management_tls_state"] + control_service=services.get("control") or {} + mounts=[m for m in (control_service.get("volumes") or []) if isinstance(m,dict)] + if not any(m.get("source")==tls_root and m.get("target")==tls_target and m.get("read_only") is not True for m in mounts): + raise RuntimeError("HTTPS control missing writable dedicated TLS mount") + for service_name in ("cups","discovery"): + for mount in (services.get(service_name) or {}).get("volumes") or []: + if isinstance(mount,dict) and (mount.get("source")==tls_root or mount.get("target")==tls_target): + raise RuntimeError(f"{service_name} must not receive management TLS state") + return scheme + +def multipart_upload(host, port, tls, username, password, remote_path, content, mode, timeout): + boundary = "----semper-supra-foliorelay-t6" + data = json.dumps({"method":"filesystem.put","params":[remote_path,{"append":False,"mode":mode}]}, separators=(",",":")) + chunks = [ + f"--{boundary}\r\n".encode(), b'Content-Disposition: form-data; name="data"\r\n\r\n', + data.encode(), b"\r\n", f"--{boundary}\r\n".encode(), + b'Content-Disposition: form-data; name="file"; filename="payload"\r\n', + b"Content-Type: application/octet-stream\r\n\r\n", content, b"\r\n", + f"--{boundary}--\r\n".encode(), + ] + body = b"".join(chunks) + token = base64.b64encode(f"{username}:{password}".encode()).decode() + headers = {"Authorization":f"Basic {token}","Content-Type":f"multipart/form-data; boundary={boundary}","Content-Length":str(len(body))} + if tls: + ctx=ssl.create_default_context(); ctx.check_hostname=False; ctx.verify_mode=ssl.CERT_NONE + conn=http.client.HTTPSConnection(host,port,timeout=timeout,context=ctx) + else: + conn=http.client.HTTPConnection(host,port,timeout=timeout) + try: + conn.request("POST","/_upload/",body=body,headers=headers) + resp=conn.getresponse(); payload=resp.read() + finally: + conn.close() + if not (200 <= resp.status < 300): + raise RuntimeError(f"filesystem.put HTTP {resp.status}") + job_id=json.loads(payload.decode()).get("job_id") + if not isinstance(job_id,int): + raise RuntimeError("filesystem.put did not return job_id") + return job_id + +def http_bytes(host, port, path, token=None, method="GET", timeout=8.0, tls=False): + scheme="https" if tls else "http" + req=urllib.request.Request(f"{scheme}://{host}:{port}{path}", method=method) + if token: + req.add_header("Authorization", f"Bearer {token}") + if tls: + ctx=ssl.create_default_context() + ctx.check_hostname=False + ctx.verify_mode=ssl.CERT_NONE + with urllib.request.urlopen(req, timeout=timeout, context=ctx) as r: + return r.read() + with urllib.request.urlopen(req, timeout=timeout) as r: + return r.read() + +def management_tls_fingerprint(host: str, port: int, server_name: str, timeout: float) -> str: + ctx=ssl.create_default_context() + ctx.check_hostname=False + ctx.verify_mode=ssl.CERT_NONE + with socket.create_connection((host,port),timeout=timeout) as raw: + with ctx.wrap_socket(raw,server_hostname=server_name) as conn: + der=conn.getpeercert(binary_form=True) + if not der: + raise RuntimeError("management TLS peer returned no certificate") + pem=ssl.DER_cert_to_PEM_cert(der) + cert_path=None + try: + with tempfile.NamedTemporaryFile("w",encoding="utf-8",delete=False) as handle: + handle.write(pem) + cert_path=handle.name + checked=subprocess.run( + ["openssl","x509","-in",cert_path,"-noout","-checkhost",server_name], + text=True,capture_output=True, + ) + if checked.returncode!=0: + detail=(checked.stdout+"\n"+checked.stderr).strip() + raise RuntimeError(f"management TLS certificate host mismatch: {detail}") + finally: + if cert_path: + pathlib.Path(cert_path).unlink(missing_ok=True) + return hashlib.sha256(der).hexdigest() + +def middleware_http_bytes(host, port, tls, path, timeout=8.0): + if tls: + ctx=ssl.create_default_context(); ctx.check_hostname=False; ctx.verify_mode=ssl.CERT_NONE + conn=http.client.HTTPSConnection(host,port,timeout=timeout,context=ctx) + else: + conn=http.client.HTTPConnection(host,port,timeout=timeout) + try: + conn.request("GET",path) + resp=conn.getresponse(); payload=resp.read() + finally: + conn.close() + if not (200 <= resp.status < 300): + raise RuntimeError(f"middleware download HTTP {resp.status}") + return payload + +def wait_http(host, port, path, timeout_s, tls=False): + deadline=time.monotonic()+timeout_s + while time.monotonic()>endobj\n" + b"2 0 obj<< /Type /Pages /Kids [3 0 R] /Count 1 >>endobj\n" + b"3 0 obj<< /Type /Page /Parent 2 0 R /MediaBox [0 0 72 72] >>endobj\n" + b"trailer<< /Root 1 0 R >>\n%%EOF\n") + path.write_bytes(data) + +URF_C = r""" +#include +#include +#include +#include +#include +#include +int main(int argc,char **argv){ + cups_page_header2_t h; + cups_raster_t *r; + pwg_media_t *m; + unsigned char *line; + unsigned y; + int fd; + + if(argc!=2) return 64; + m=pwgMediaForPWG("iso_a4_210x297mm"); + if(!m) return 1; + if(!cupsRasterInitPWGHeader(&h,m,"sgray_8",300,300,"one-sided",NULL)) return 1; + h.cupsInteger[CUPS_RASTER_PWG_TotalPageCount]=1; + + fd=open(argv[1],O_CREAT|O_TRUNC|O_WRONLY,0600); + if(fd<0) return 1; + r=cupsRasterOpen(fd,CUPS_RASTER_WRITE_APPLE); + if(!r) return 1; + if(!cupsRasterWriteHeader2(r,&h)) return 1; + + line=malloc(h.cupsBytesPerLine); + if(!line) return 1; + memset(line,0xff,h.cupsBytesPerLine); + for(y=0;y str: + prefix="urn:uuid:" + if not isinstance(printer_uuid,str) or not printer_uuid.lower().startswith(prefix): + raise RuntimeError("canonical printer UUID is not urn:uuid form") + value=printer_uuid[len(prefix):] + if not value: + raise RuntimeError("canonical printer UUID is empty") + return value + +def forwarded_ipp_uri_has_product_path(uri: str | None) -> bool: + if not uri or not uri.startswith("ipp://"): + return False + tail = uri.split("://", 1)[1] + slash = tail.find("/") + return slash >= 0 and tail[slash:] == PUBLIC_RESOURCE_PATH + +def ipptool_print(port:int, media:str, source:pathlib.Path, label:str, root:pathlib.Path): + t=root/f"print-{label}.test" + t.write_text("""{ + NAME "FolioRelay T6" + OPERATION Print-Job + GROUP operation-attributes-tag + ATTR charset attributes-charset utf-8 + ATTR language attributes-natural-language en + ATTR uri printer-uri %s + ATTR name requesting-user-name rdte + ATTR name job-name "FolioRelay T6 %s" + ATTR mimeMediaType document-format %s + FILE "%s" + STATUS successful-ok +} +""" % (PUBLIC_URI,label,media,str(source)),encoding="utf-8") + p=subprocess.run(["ipptool","-tv",f"ipp://127.0.0.1:{port}/printers/FolioRelay",str(t)],text=True,capture_output=True) + (root/f"print-{label}.txt").write_text(p.stdout+"\n"+p.stderr,encoding="utf-8") + if p.returncode: raise RuntimeError(f"{label} Print-Job failed") + + +class JobFailure(RuntimeError): + def __init__(self, label: str, job: dict): + self.label = label + self.job = job + detail = job.get("error") or job.get("exception") or job.get("exc_info") or "no job diagnostic" + super().__init__(f"{label} job {job.get('state')}: {detail}") + +def bounded_text(value, limit=8000): + if value is None: + return None + text = str(value) + return text if len(text) <= limit else text[:limit] + "..." + +def bounded_head_tail_text(value, limit=8000): + if value is None: + return None + text = str(value) + if len(text) <= limit: + return text + marker = "\n......\n" + if limit <= len(marker): + return text[-limit:] + available = limit - len(marker) + head = max(1, available // 3) + tail = available - head + return text[:head] + marker + text[-tail:] + +def bounded_observer_lifecycle_excerpt(value, app_name=OBSERVER_APP_NAME, before=2, after=24, limit=8000): + if value is None: + return None + lines=str(value).splitlines() + needles=(app_name, f"ix-{app_name}") + hits=[i for i,line in enumerate(lines) if any(needle in line for needle in needles)] + if not hits: + return None + selected=set() + for i in hits: + selected.update(range(max(0,i-before), min(len(lines),i+after+1))) + excerpt="\n".join(lines[i] for i in sorted(selected)) + excerpt=re.sub(r"(auth_token=)[^&\s]+", r"\1", excerpt) + return bounded_head_tail_text(excerpt,limit) + +def bounded_job_snapshot(job): + if not isinstance(job, dict): + return None + return { + key: (bounded_text(job.get(key)) if key in {"error", "exception", "exc_info", "logs_excerpt"} else job.get(key)) + for key in ("id", "state", "progress", "error", "exception", "exc_info", "logs_excerpt") + if key in job + } + +def bounded_app_snapshot(app): + if not isinstance(app, dict): + return None + workloads = app.get("active_workloads") or {} + details = workloads.get("container_details") or [] + containers = [] + for item in details[:8]: + if not isinstance(item, dict): + continue + containers.append({ + key: item.get(key) + for key in ("id", "container_id", "service_name", "image", "state", "health") + if item.get(key) is not None + }) + return { + "id": app.get("id"), + "name": app.get("name"), + "state": app.get("state"), + "error_reason": bounded_text(app.get("error_reason")), + "containers": containers, + } + +def capture_container_log_tail(ws, app_name, container_id, tail_lines=OBSERVER_LOG_TAIL_LINES): + params = {"app_name": app_name, "container_id": container_id, "tail_lines": tail_lines} + subscription = "app.container_log_follow:" + json.dumps(params, sort_keys=True, separators=(",", ":")) + sub_id = "observer-log-" + hashlib.sha256(container_id.encode()).hexdigest()[:12] + old_timeout = ws.sock.gettimeout() + events = [] + try: + ws.sock.settimeout(min(float(old_timeout or 2.0), OBSERVER_LOG_CAPTURE_SECONDS)) + ws.send_json({"msg": "sub", "id": sub_id, "name": subscription, "params": []}) + deadline = time.monotonic() + OBSERVER_LOG_CAPTURE_SECONDS + while len(events) < OBSERVER_LOG_MAX_EVENTS and time.monotonic() < deadline: + try: + message = ws.recv_json() + except (socket.timeout, TimeoutError): + break + if message.get("msg") == "ping": + pong = {"msg": "pong"} + if "id" in message: + pong["id"] = message["id"] + ws.send_json(pong) + continue + if message.get("msg") == "added": + fields = message.get("fields") or {} + data = fields.get("data") + if data is not None: + events.append({ + "timestamp": fields.get("timestamp"), + "data": bounded_text(data, 4000), + }) + if message.get("msg") == "nosub" and message.get("id") == sub_id: + break + finally: + try: + ws.send_json({"msg": "unsub", "id": sub_id}) + except Exception: + pass + ws.sock.settimeout(old_timeout) + return { + "app_name": app_name, + "container_id": container_id, + "tail_lines": tail_lines, + "events": events, + } + + +def main(): + p=argparse.ArgumentParser() + p.add_argument("--host",default="127.0.0.1"); p.add_argument("--port",type=int,required=True) + p.add_argument("--control-port",type=int,required=True); p.add_argument("--ipp-port",type=int,required=True); p.add_argument("--observer-port",type=int,required=True) + p.add_argument("--observer-binary",type=pathlib.Path,required=True) + p.add_argument("--password-file",required=True); p.add_argument("--control-dir",type=pathlib.Path,required=True); p.add_argument("--foundry-commit",required=True) + p.add_argument("--target-version",required=True); p.add_argument("--expected-system-version",required=True) + p.add_argument("--out",required=True); p.add_argument("--tls",action="store_true"); p.add_argument("--timeout",type=float,default=8); p.add_argument("--job-timeout",type=float,default=300); p.add_argument("--state-timeout",type=float,default=300) + a=p.parse_args() + if not a.observer_binary.is_file(): + raise RuntimeError("minimal observer binary is missing") + observer_bytes=a.observer_binary.read_bytes() + if not observer_bytes: + raise RuntimeError("minimal observer binary is empty") + if len(observer_bytes) > 6291456: + raise RuntimeError("minimal observer binary exceeds 6 MiB budget") + started=time.time(); ws=None; dataset_owned=False; app_created=False; observer_created=False + payload={"schema":"truenas-foliorelay-foundry-t6/v1","classification":"ORACLE_FAILURE","oracleSatisfied":False,"expected_version":a.expected_system_version,"target_version":a.target_version,"foundry_commit":a.foundry_commit,"app_name":EXPECTED_APP_NAME,"secret_values_captured":False} + payload["observer_fixture"]={ + "implementation":"go-static", + "carrier_image":OBSERVER_IMAGE, + "binary_sha256":sha256_bytes(observer_bytes), + "binary_size_bytes":len(observer_bytes), + "run_as":"65534:65534", + } + try: + control,compose=load_control(a.control_dir,a.foundry_commit,a.target_version) + management_scheme=validate_management_transport(control,compose.get("services") or {}) + management_tls=management_scheme=="https" + management_tls_initial=None + management_portal_url=validate_management_portal_contract(control,compose) + payload["materialization"]={"schema":control["schema"],"foundry_ref":control["foundry_ref"],"compose_canonical_sha256":canonical_sha256(compose),"control_image":EXPECTED_CONTROL,"cups_image":EXPECTED_CUPS,"management_scheme":management_scheme,"management_portal_url":management_portal_url} + password=pathlib.Path(a.password_file).read_text().strip() + ws=WebSocket(a.host,a.port,timeout=a.timeout,tls=a.tls); ws.send_json({"msg":"connect","version":"1","support":["1"]}) + if wait_for(ws,lambda m:m.get("msg") in {"connected","failed"}).get("msg")!="connected": raise RuntimeError("DDP connection failed") + rid=1 + def call(method,params): + nonlocal rid + v=ddp_call(ws,str(rid),method,params); rid+=1; return v + def query_optional(method,filters): + rows=call(method,[filters]) + if not isinstance(rows,list): + raise RuntimeError(f"{method} optional query did not return list") + if len(rows)>1: + raise RuntimeError(f"{method} optional query returned multiple rows") + return rows[0] if rows else None + def wait_job(j,label): + deadline=time.monotonic()+a.job_timeout + while time.monotonic() bool: + text = str(exc).lower() + return '"error": 2' in text or "enoent" in text or "not found" in text + + +def load_fixture(directory: pathlib.Path, producer: str) -> dict: + p = directory / "garm-provider-container-pre-b4-fixture.json" + doc = json.loads(p.read_text(encoding="utf-8")) + if doc.get("schema") != EXPECTED_SCHEMA: + raise ProbeError("unexpected Container fixture schema") + if doc.get("producer_source") != producer: + raise ProbeError("Container fixture producer drifted") + target = doc.get("target") or {} + if target.get("system_version") != EXPECTED_VERSION: + raise ProbeError("fixture target drifted") + if target.get("driver") != "container-v1" or target.get("control_surface") != "container.*": + raise ProbeError("fixture driver drifted") + if target.get("status") != "OPEN": + raise ProbeError("fixture must remain non-admitted before runtime evidence") + source_oracles = doc.get("source_oracles") or {} + if source_oracles.get("runtime_admission_claimed") is not False: + raise ProbeError("fixture illegally claims runtime admission") + if source_oracles.get("source_derived_rootfs_projection_required") is not True: + raise ProbeError("fixture does not require source-derived rootfs projection") + required = set(target.get("required_methods") or []) + if "pool.dataset.query" in required: + raise ProbeError("fixture illegally reintroduced hidden-dataset query lowering") + projection = doc.get("rootfs_projection") or {} + if projection.get("dataset_template") != EXPECTED_DATASET_TEMPLATE: + raise ProbeError("fixture dataset projection drifted") + if projection.get("mountpoint_template") != EXPECTED_MOUNTPOINT_TEMPLATE: + raise ProbeError("fixture mountpoint projection drifted") + if projection.get("source_path") != EXPECTED_ROOTFS_SOURCE: + raise ProbeError("fixture rootfs source path drifted") + return doc + + +def derive_rootfs_mountpoint(fixture: dict, pool: str, name: str, dataset: str) -> str: + projection = fixture.get("rootfs_projection") or {} + expected_dataset = projection["dataset_template"].replace("{pool}", pool).replace("{name}", name) + if dataset != expected_dataset: + raise ProbeError( + f"container dataset drifted from source projection: got {dataset!r}, expected {expected_dataset!r}" + ) + mountpoint = projection["mountpoint_template"].replace("{pool}", pool).replace("{name}", name) + if not mountpoint.startswith("/.truenas_containers/"): + raise ProbeError(f"unsafe source-derived container mountpoint: {mountpoint!r}") + return mountpoint + + +def multipart_upload(host: str, port: int, password: str, remote_path: str, content: bytes, mode: int) -> int: + boundary = "----SemperSupra" + secrets.token_hex(12) + data = json.dumps({ + "method": "filesystem.put", + "params": [remote_path, {"append": False, "mode": mode}], + }).encode() + body = bytearray() + def add(name: str, value: bytes, filename: str | None = None, content_type: str | None = None): + body.extend(f"--{boundary}\r\n".encode()) + disp = f'Content-Disposition: form-data; name="{name}"' + if filename is not None: + disp += f'; filename="{filename}"' + body.extend((disp + "\r\n").encode()) + if content_type: + body.extend((f"Content-Type: {content_type}\r\n").encode()) + body.extend(b"\r\n") + body.extend(value) + body.extend(b"\r\n") + add("data", data) + add("file", content, filename="payload", content_type="application/octet-stream") + body.extend(f"--{boundary}--\r\n".encode()) + auth = base64.b64encode(f"truenas_admin:{password}".encode()).decode() + req = urllib.request.Request( + f"http://{host}:{port}/_upload/", + data=bytes(body), + method="POST", + headers={ + "Authorization": f"Basic {auth}", + "Content-Type": f"multipart/form-data; boundary={boundary}", + "Accept": "application/json", + "User-Agent": "SemperSupra-GARM-Container-RDTE/1", + }, + ) + with urllib.request.urlopen(req, timeout=60) as resp: + result = json.loads(resp.read().decode()) + job_id = result.get("job_id") + if isinstance(job_id, bool) or not isinstance(job_id, int): + raise ProbeError(f"filesystem.put upload did not return job id: {result!r}") + return job_id + + +def main() -> int: + p = argparse.ArgumentParser() + p.add_argument("--host", default="127.0.0.1") + p.add_argument("--http-port", type=int, required=True) + p.add_argument("--middleware-port", type=int, required=True) + p.add_argument("--tls", action="store_true") + p.add_argument("--password-file", type=pathlib.Path, required=True) + p.add_argument("--fixture-dir", type=pathlib.Path, required=True) + p.add_argument("--fixture-producer-commit", required=True) + p.add_argument("--pool", required=True) + p.add_argument("--out", type=pathlib.Path, required=True) + p.add_argument("--timeout", type=float, default=8.0) + p.add_argument("--job-timeout", type=float, default=600.0) + p.add_argument("--state-timeout", type=float, default=180.0) + a = p.parse_args() + + started = time.time() + receipt = { + "schema": "truenas-garm-container-pre-b4-runtime/v1", + "classification": "ORACLE_FAILURE", + "oracleSatisfied": False, + "b0_b3_b5_pre_b4_complete": False, + "bootstrap_execution_observed": False, + "github_jit_boundary_claimed": False, + "github_registration_exercised": False, + "private_repository_execution": False, + "physical_truenas_mutation": False, + "per_runner_memory_isolation_claimed": False, + "cleanup": {"attempted": False, "absent": False}, + } + ws = None + container_id = None + container_name = None + token = f"container-public-{secrets.token_hex(16)}" + + def emit(code: int) -> int: + receipt["elapsed_seconds"] = round(time.time() - started, 3) + a.out.write_text(json.dumps(receipt, indent=2, sort_keys=True) + "\n", encoding="utf-8") + print(json.dumps(receipt, indent=2, sort_keys=True)) + return code + + try: + fixture = load_fixture(a.fixture_dir, a.fixture_producer_commit) + receipt["fixture_producer_source"] = fixture["producer_source"] + receipt["fixture_schema"] = fixture["schema"] + receipt["target"] = fixture["target"] + receipt["profile"] = fixture["profile"] + container_name = fixture["expected_name"] + + password = a.password_file.read_text(encoding="utf-8").strip() + if not password: + raise ProbeError("password file is empty") + + ws = WebSocket(a.host, a.middleware_port, timeout=a.timeout, tls=a.tls) + ws.send_json({"msg": "connect", "version": "1", "support": ["1"]}) + connected = wait_for(ws, lambda m: m.get("msg") in {"connected", "failed"}) + if connected.get("msg") != "connected": + raise ProbeError(f"DDP connection failed: {connected!r}") + request_id = 1 + def call(method: str, params: list): + nonlocal request_id + result = ddp_call(ws, str(request_id), method, params) + request_id += 1 + return result + + auth = call("auth.login_ex", [{ + "mechanism": "PASSWORD_PLAIN", + "username": "truenas_admin", + "password": password, + }]) + if not isinstance(auth, dict) or auth.get("response_type") != "SUCCESS": + raise ProbeError("authentication did not return SUCCESS") + + version = call("system.version", []) + receipt["system_version"] = version + if version != EXPECTED_VERSION: + raise ProbeError(f"exact version mismatch: {version!r}") + + methods = call("core.get_methods", []) + required = set((fixture.get("target") or {}).get("required_methods") or []) + missing = sorted(m for m in required if m not in methods) + if missing: + raise ProbeError(f"required middleware methods missing: {missing}") + receipt["required_methods_present"] = True + + existing = call("container.query", [[["name", "=", container_name]]]) + if existing: + raise ProbeError("preexisting named container blocks ownership-safe apply") + + images = call("container.image.query_registry", []) + family = fixture["image_family"] + image_row = next((x for x in images if isinstance(x, dict) and x.get("name") == family), None) + if not image_row: + raise ProbeError(f"image family unavailable: {family}") + versions = [x.get("version") for x in (image_row.get("versions") or []) if isinstance(x, dict) and x.get("version")] + if not versions: + raise ProbeError("image family returned no exact versions") + image_version = versions[-1] + receipt["image"] = {"name": family, "version": image_version} + + desired = fixture["desired_create"] + create_payload = { + "name": container_name, + "description": desired["description"], + "autostart": False, + "idmap": {"type": desired["idmap_type"]}, + "capabilities_policy": desired["capabilities_policy"], + "init": desired["init"], + "initenv": dict(desired["initenv"]), + "pool": a.pool, + "image": {"name": family, "version": image_version}, + } + if token in json.dumps(create_payload): + raise ProbeError("runtime credential leaked into container.create payload") + + create_job = call("container.create", [create_payload]) + if isinstance(create_job, bool) or not isinstance(create_job, int): + raise ProbeError(f"container.create did not return job id: {create_job!r}") + + def wait_job(job_id: int, label: str): + deadline = time.monotonic() + a.job_timeout + while time.monotonic() < deadline: + job = call("core.get_jobs", [[["id", "=", job_id]], {"get": True}]) + if job: + state = job.get("state") + if state == "SUCCESS": + return job + if state in {"FAILED", "ABORTED"}: + raise ProbeError(f"{label} {state}: {job.get('error') or job.get('exception')}") + time.sleep(1) + raise ProbeError(f"{label} timed out") + + wait_job(create_job, "container.create") + rows = call("container.query", [[["name", "=", container_name]]]) + if len(rows) != 1: + raise ProbeError("created container did not read back exactly once") + row = rows[0] + container_id = row.get("id") + if not isinstance(container_id, int): + raise ProbeError("created container has no integer id") + if (row.get("status") or {}).get("state") != "STOPPED": + raise ProbeError("container was not STOPPED before staging") + if row.get("init") != desired["init"] or row.get("initenv") != desired["initenv"]: + raise ProbeError("bootstrap desired state did not read back exactly") + + dataset = row.get("dataset") + if not isinstance(dataset, str) or not dataset: + raise ProbeError("container dataset identity missing") + mountpoint = derive_rootfs_mountpoint(fixture, a.pool, container_name, dataset) + receipt["dataset"] = dataset + receipt["mountpoint"] = mountpoint + receipt["rootfs_projection_source"] = fixture["rootfs_projection"]["source_path"] + + staged = [] + for spec in fixture["staged_files"]: + rel = spec["path"] + remote = mountpoint.rstrip("/") + rel + contains_secret = bool(spec["contains_secret"]) + content = token.encode() if contains_secret else spec["content"].encode() + if not contains_secret: + got_sha = hashlib.sha256(content).hexdigest() + if got_sha != spec["sha256"]: + raise ProbeError(f"fixture staged-file digest drift: {rel}") + job_id = multipart_upload(a.host, a.http_port, password, remote, content, int(spec["mode"])) + wait_job(job_id, f"filesystem.put {rel}") + stat = call("filesystem.stat", [remote]) + if stat.get("size") != len(content) or (int(stat.get("mode", 0)) & 0o777) != int(spec["mode"]): + raise ProbeError(f"staged-file readback drift: {rel}") + staged.append({"path": rel, "mode": int(spec["mode"]), "contains_secret": contains_secret}) + receipt["staged_files"] = staged + + call("container.start", [container_id]) + deadline = time.monotonic() + a.state_timeout + while time.monotonic() < deadline: + rows = call("container.query", [[["id", "=", container_id]]]) + if rows and (rows[0].get("status") or {}).get("state") == "RUNNING": + break + time.sleep(1) + else: + raise ProbeError("container did not reach RUNNING") + + marker_stats = {} + for marker in fixture["execution_markers"]: + remote = mountpoint.rstrip("/") + marker + deadline = time.monotonic() + 45 + stat = None + while time.monotonic() < deadline: + try: + stat = call("filesystem.stat", [remote]) + break + except Exception: + time.sleep(1) + if not stat: + raise ProbeError(f"execution marker not observed: {marker}") + marker_stats[marker] = {"size": stat.get("size"), "mode": stat.get("mode")} + receipt["execution_markers"] = marker_stats + receipt["bootstrap_execution_observed"] = True + + token_path = mountpoint.rstrip("/") + "/var/lib/garm-container/bootstrap-instance-token" + try: + call("filesystem.stat", [token_path]) + except RuntimeError as exc: + if not is_explicit_not_found(exc): + raise ProbeError(f"credential-file absence could not be proven: {exc}") from exc + else: + raise ProbeError("one-shot credential file remained after wrapper consumption") + receipt["credential_file_deleted"] = True + + expected_post = fixture["expected_post_start"] + updated = call("container.update", [container_id, { + "init": expected_post["init"], + "initenv": expected_post["initenv"], + }]) + if not isinstance(updated, dict): + rows = call("container.query", [[["id", "=", container_id]]]) + updated = rows[0] if rows else None + if not isinstance(updated, dict) or updated.get("init") != expected_post["init"] or updated.get("initenv") != expected_post["initenv"]: + raise ProbeError("post-start desired-state scrub did not read back") + receipt["desired_state_scrubbed"] = True + + stop_job = call("container.stop", [container_id, {"force_after_timeout": True}]) + if isinstance(stop_job, int) and not isinstance(stop_job, bool): + wait_job(stop_job, "container.stop") + call("container.delete", [container_id]) + deadline = time.monotonic() + a.state_timeout + while time.monotonic() < deadline: + rows = call("container.query", [[["id", "=", container_id]]]) + if not rows: + break + time.sleep(1) + rows = call("container.query", [[["name", "=", container_name]]]) + if rows: + raise ProbeError("container remains after delete") + receipt["cleanup"] = {"attempted": True, "absent": True} + + receipt.update({ + "classification": "SUPPORTED", + "oracleSatisfied": True, + "b0_b3_b5_pre_b4_complete": True, + "detail": ( + "exact BETA.3 container.* candidate completed source/method validation, " + "stopped creation, supported rootfs staging, wrapper/bootstrap-child execution " + "markers, one-shot credential-file consumption, desired init/env scrub, " + "stop/delete and zero-residue absence; callback/JIT remains a separate B4 gate" + ), + }) + return emit(0) + except Exception as exc: + receipt["detail"] = f"{type(exc).__name__}: {str(exc).replace(token, '')}" + if ws is not None and container_id is not None: + receipt["cleanup"]["attempted"] = True + try: + request_id = locals().get("request_id", 1) + def cleanup_call(method: str, params: list): + nonlocal request_id + result = ddp_call(ws, str(request_id), method, params) + request_id += 1 + return result + rows = cleanup_call("container.query", [[["id", "=", container_id]]]) + if rows and (rows[0].get("status") or {}).get("state") == "RUNNING": + result = cleanup_call("container.stop", [container_id, {"force": True}]) + if isinstance(result, int) and not isinstance(result, bool): + deadline = time.monotonic() + 60 + while time.monotonic() < deadline: + job = cleanup_call("core.get_jobs", [[["id", "=", result]], {"get": True}]) + if job and job.get("state") in {"SUCCESS", "FAILED", "ABORTED"}: + break + time.sleep(1) + cleanup_call("container.delete", [container_id]) + rows = cleanup_call("container.query", [[["name", "=", container_name]]]) + receipt["cleanup"]["absent"] = not rows + except Exception as cleanup_exc: + receipt["cleanup"]["error"] = f"{type(cleanup_exc).__name__}: {cleanup_exc}" + return emit(2) + finally: + if ws is not None: + try: + ws.close() + except Exception: + pass + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/truenas_middleware_garm_f0_f5_probe.py b/scripts/truenas_middleware_garm_f0_f5_probe.py new file mode 100644 index 000000000..5b7d1b218 --- /dev/null +++ b/scripts/truenas_middleware_garm_f0_f5_probe.py @@ -0,0 +1,670 @@ +#!/usr/bin/env python3 +"""Exact-target GARM controller Foundry F0-F5 oracle on disposable TrueNAS.""" +from __future__ import annotations + +import argparse +import copy +import hashlib +import http.client +import json +import pathlib +import secrets +import ssl +import string +import subprocess +import tempfile +import time + +from truenas_middleware_ddp_probe import WebSocket, ddp_call, wait_for + +SCHEMA = "semper-supra.garm-truenas-f0-f5-control/1" +EXPECTED_APP_NAME = "rdte-t6-garm" +EXPECTED_IMAGE = "ghcr.io/sempersupra/garm-appliance@sha256:1af67841ddd4589e3798dcda8be49230565c849d07ab57fd05899432dcdabca9" +EXPECTED_CONTROLLER_SOURCE = "8a72cb23deacc712786945ac5e7ee63f8b4976d6" +EXPECTED_PROVIDER_SOURCE = "14535745dc3aa3c0b5466da7c704bca4d23dcec5" +EXPECTED_FIXTURE_DATASET = "rdtepool/garm-t6" +EXPECTED_FIXTURE_ROOT = "/mnt/rdtepool/garm-t6" +EXPECTED_CONFIG_DIR = EXPECTED_FIXTURE_ROOT + "/config" +EXPECTED_CONFIG_PATH = EXPECTED_CONFIG_DIR + "/config.toml" +EXPECTED_DB_PATH = EXPECTED_CONFIG_DIR + "/garm.db" +BOOTSTRAP_CONFIG = "garm-initial-config" +TLS_CERT_CONFIG = "garm-tls-certificate" +TLS_KEY_CONFIG = "garm-tls-private-key" +BOOTSTRAP_PLACEHOLDER = "__EPHEMERAL_NESTED_GARM_CONFIG__" +TLS_PREFIX = "__EPHEMERAL_NESTED_TLS__:" +REQUIRED_METHODS = { + "system.version", + "app.query", "app.config", "app.create", "app.update", "app.redeploy", + "app.stop", "app.start", "app.delete", + "pool.dataset.query", "pool.dataset.create", "pool.dataset.delete", + "filesystem.mkdir", "filesystem.stat", +} + + +def canonical_sha256(value: object) -> str: + raw = json.dumps(value, sort_keys=True, separators=(",", ":")).encode("utf-8") + return hashlib.sha256(raw).hexdigest() + + +def reconciliation_action(app_state, live_compose, desired_compose): + if app_state is None: + return "CREATE" + if app_state in {"DEPLOYING", "STOPPING"}: + return "WAIT" + if app_state not in {"RUNNING", "STOPPED"}: + return "FAIL_CLOSED" + if not isinstance(live_compose, dict): + return "FAIL_CLOSED" + return ( + "NOOP" + if canonical_sha256(live_compose) == canonical_sha256(desired_compose) + else "UPDATE" + ) + + +def load_json(path: pathlib.Path) -> dict: + value = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(value, dict): + raise RuntimeError(f"{path} must contain an object") + return value + + +def normalize_secret_configs(compose: dict) -> dict: + normalized = copy.deepcopy(compose) + configs = normalized.get("configs") or {} + if ( + BOOTSTRAP_CONFIG not in configs + or TLS_CERT_CONFIG not in configs + or TLS_KEY_CONFIG not in configs + ): + raise RuntimeError("GARM secret-bearing config inventory drifted") + configs[BOOTSTRAP_CONFIG]["content"] = BOOTSTRAP_PLACEHOLDER + configs[TLS_CERT_CONFIG]["content"] = TLS_PREFIX + TLS_CERT_CONFIG + configs[TLS_KEY_CONFIG]["content"] = TLS_PREFIX + TLS_KEY_CONFIG + return normalized + + +def load_control( + directory: pathlib.Path, + foundry_commit: str, + target_version: str, + expected_system_version: str, + expected_middleware_commit: str, + expected_profile_path: str, + expected_profile_blob: str, +) -> tuple[dict, dict]: + control = load_json(directory / "control.json") + compose = load_json(directory / "compose.json") + deployment = load_json(directory / "deployment.json") + + if control.get("schema") != SCHEMA: + raise RuntimeError("unexpected GARM F0-F5 control schema") + if control.get("foundry_ref") != foundry_commit: + raise RuntimeError("Foundry source ref drifted") + target = control.get("target") or {} + expected_target = { + "truenas_version": target_version, + "expected_system_version": expected_system_version, + "profile_path": expected_profile_path, + "profile_git_blob_sha": expected_profile_blob, + "middleware_commit": expected_middleware_commit, + } + for key, expected in expected_target.items(): + if target.get(key) != expected: + raise RuntimeError( + f"GARM exact target profile drifted for {key}: " + f"{target.get(key)!r} != {expected!r}" + ) + if control.get("appliance_reference") != EXPECTED_IMAGE: + raise RuntimeError("GARM appliance identity drifted") + if control.get("controller_source") != EXPECTED_CONTROLLER_SOURCE: + raise RuntimeError("GARM controller source identity drifted") + if control.get("provider_source") != EXPECTED_PROVIDER_SOURCE: + raise RuntimeError("TrueNAS provider source identity drifted") + if control.get("private_secrets_captured") is not False: + raise RuntimeError("control does not assert private_secrets_captured=false") + if control.get("fixture_secret_values_retained") is not False: + raise RuntimeError("control retained fixture secret values") + if control.get("github_credentials_present") is not False: + raise RuntimeError("control claims GitHub credentials are present") + + runtime = control.get("runtime") or {} + if runtime.get("app_name") != EXPECTED_APP_NAME: + raise RuntimeError("GARM app name drifted") + if runtime.get("config_root") != EXPECTED_CONFIG_DIR: + raise RuntimeError("GARM config root drifted") + if int(runtime.get("host_port") or 0) != 30880: + raise RuntimeError("GARM guest service port drifted") + + if control.get("runtime_compose_sha256") != canonical_sha256(compose): + raise RuntimeError("GARM runtime Compose identity does not match control") + if deployment.get("artifact_sha256") != control.get("deployment_artifact_sha256"): + raise RuntimeError("GARM deployment artifact identity drifted") + if deployment.get("materialization_identity") != control.get("materialization_identity"): + raise RuntimeError("GARM materialization identity drifted") + + services = compose.get("services") or {} + if set(services) != {"garm", "garm-config-seed"}: + raise RuntimeError("unexpected GARM service inventory") + for name in ("garm", "garm-config-seed"): + service = services[name] + if service.get("image") != EXPECTED_IMAGE: + raise RuntimeError(f"{name} image identity drifted") + if service.get("privileged") is True: + raise RuntimeError(f"{name} became privileged") + if "ALL" not in {str(x).upper() for x in service.get("cap_drop") or []}: + raise RuntimeError(f"{name} lost cap_drop ALL") + opts = {str(x).lower().replace(":", "=") for x in service.get("security_opt") or []} + if not any(x.startswith("no-new-privileges=true") for x in opts): + raise RuntimeError(f"{name} lost no-new-privileges") + + serialized = json.dumps(compose, sort_keys=True) + for forbidden in ( + "BEGIN PRIVATE KEY", + "PRIVATE-QUALIFICATION-FIXTURE", + "PUBLIC-QUALIFICATION-FIXTURE", + "ghp_", + "github_pat_", + ): + if forbidden in serialized: + raise RuntimeError(f"secret-like retained control content: {forbidden}") + if BOOTSTRAP_PLACEHOLDER not in serialized or TLS_PREFIX not in serialized: + raise RuntimeError("run-local secret placeholders are absent") + return control, compose + + +def build_ephemeral_runtime_compose(compose: dict) -> tuple[dict, dict[str, str]]: + runtime = copy.deepcopy(compose) + jwt_secret = secrets.token_urlsafe(36) + alphabet = string.ascii_letters + string.digits + db_passphrase = "".join(secrets.choice(alphabet) for _ in range(32)) + with tempfile.TemporaryDirectory(prefix="garm-f0-f5-tls-") as td: + root = pathlib.Path(td) + key_path = root / "tls.key" + cert_path = root / "tls.crt" + subprocess.run( + [ + "openssl", "req", "-x509", "-newkey", "rsa:2048", "-nodes", "-days", "1", + "-subj", "/CN=garm-t6.invalid", + "-addext", "subjectAltName=DNS:garm-t6.invalid,IP:127.0.0.1", + "-keyout", str(key_path), "-out", str(cert_path), + ], + check=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + tls_key = key_path.read_text(encoding="utf-8") + tls_cert = cert_path.read_text(encoding="utf-8") + + bootstrap = f"""[default] +enable_webhook_management = false + +[logging] +log_level = "info" + +[metrics] +disable_auth = false + +[jwt_auth] +secret = "{jwt_secret}" +time_to_live = "1h" + +[apiserver] +bind = "0.0.0.0" +port = 8080 +use_tls = true + +[apiserver.tls] +certificate = "/garm-tls.crt" +key = "/garm-tls.key" + +[apiserver.webui] +enable = true + +[database] +backend = "sqlite3" +passphrase = "{db_passphrase}" + +[database.sqlite3] +db_file = "/etc/garm/garm.db" +""" + configs = runtime["configs"] + configs[BOOTSTRAP_CONFIG]["content"] = bootstrap + configs[TLS_CERT_CONFIG]["content"] = tls_cert + configs[TLS_KEY_CONFIG]["content"] = tls_key + return runtime, { + "jwt_secret_sha256": hashlib.sha256(jwt_secret.encode()).hexdigest(), + "database_passphrase_sha256": hashlib.sha256(db_passphrase.encode()).hexdigest(), + "certificate_sha256": hashlib.sha256(tls_cert.encode()).hexdigest(), + } + + +def https_ui_probe(port: int, timeout: float = 8.0) -> int: + context = ssl._create_unverified_context() + conn = http.client.HTTPSConnection("127.0.0.1", port, timeout=timeout, context=context) + try: + conn.request("GET", "/ui/") + response = conn.getresponse() + response.read(4096) + return int(response.status) + finally: + conn.close() + + +def main() -> int: + p = argparse.ArgumentParser() + p.add_argument("--host", default="127.0.0.1") + p.add_argument("--port", type=int, required=True) + p.add_argument("--service-port", type=int, required=True) + p.add_argument("--password-file", required=True) + p.add_argument("--control-dir", type=pathlib.Path, required=True) + p.add_argument("--foundry-commit", required=True) + p.add_argument("--target-version", required=True) + p.add_argument("--expected-system-version", required=True) + p.add_argument("--expected-middleware-commit", required=True) + p.add_argument("--expected-profile-path", required=True) + p.add_argument("--expected-profile-blob", required=True) + p.add_argument("--out", required=True) + p.add_argument("--tls", action="store_true") + p.add_argument("--timeout", type=float, default=8.0) + p.add_argument("--job-timeout", type=float, default=300.0) + p.add_argument("--state-timeout", type=float, default=240.0) + a = p.parse_args() + + started = time.time() + payload = { + "schema": "truenas-garm-controller-f0-f5/v1", + "classification": "ORACLE_FAILURE", + "oracleSatisfied": False, + "f0_f5_complete": False, + "target_version": a.target_version, + "expected_version": a.expected_system_version, + "foundry_commit": a.foundry_commit, + "app_name": EXPECTED_APP_NAME, + "appliance": EXPECTED_IMAGE, + "controller_source": EXPECTED_CONTROLLER_SOURCE, + "provider_source": EXPECTED_PROVIDER_SOURCE, + "github_credentials_present": False, + "github_jit_registration_exercised": False, + "physical_truenas_mutation": False, + "capacity_promotion": False, + "secret_values_recorded": False, + } + ws = None + request_id = 1 + app_owned = False + dataset_owned = False + + try: + control, retained_compose = load_control( + a.control_dir, + a.foundry_commit, + a.target_version, + a.expected_system_version, + a.expected_middleware_commit, + a.expected_profile_path, + a.expected_profile_blob, + ) + runtime_compose, fingerprints = build_ephemeral_runtime_compose(retained_compose) + retained_sha = canonical_sha256(retained_compose) + payload["f0"] = { + "profile_id": control["target"]["profile_id"], + "profile_path": control["target"]["profile_path"], + "profile_git_blob_sha": control["target"]["profile_git_blob_sha"], + "middleware_commit": control["target"]["middleware_commit"], + "foundry_ref": control["foundry_ref"], + "appliance": control["appliance_reference"], + } + payload["f1"] = { + "retained_compose_sha256": retained_sha, + "deployment_artifact_sha256": control["deployment_artifact_sha256"], + "materialization_identity": control["materialization_identity"], + "run_local_secret_fingerprints": fingerprints, + } + + password = pathlib.Path(a.password_file).read_text(encoding="utf-8").strip() + ws = WebSocket(a.host, a.port, timeout=a.timeout, tls=a.tls) + ws.send_json({"msg": "connect", "version": "1", "support": ["1"]}) + connected = wait_for(ws, lambda m: m.get("msg") in {"connected", "failed"}) + if connected.get("msg") != "connected": + raise RuntimeError("DDP connection failed") + + def call(method, params): + nonlocal request_id + result = ddp_call(ws, str(request_id), method, params) + request_id += 1 + return result + + def wait_job(job_id, label): + if not isinstance(job_id, int): + raise RuntimeError(f"{label} did not return a job id") + deadline = time.monotonic() + a.job_timeout + last = None + while time.monotonic() < deadline: + last = call("core.get_jobs", [[["id", "=", job_id]], {"get": True}]) + if last and last.get("state") == "SUCCESS": + return last + if last and last.get("state") in {"FAILED", "ABORTED"}: + raise RuntimeError( + f"{label} job {last.get('state')}: " + f"{last.get('error') or last.get('exception')}" + ) + time.sleep(1) + raise RuntimeError(f"{label} job did not reach SUCCESS: {last!r}") + + def query_optional(method, filters): + values = call(method, [filters]) + if not values: + return None + if not isinstance(values, list) or len(values) != 1: + raise RuntimeError(f"{method} cardinality was not exactly one") + return values[0] + + def app_query(): + return query_optional("app.query", [["id", "=", EXPECTED_APP_NAME]]) + + def wait_state(expected): + deadline = time.monotonic() + a.state_timeout + last = None + while time.monotonic() < deadline: + last = app_query() + if last and last.get("state") == expected: + if expected != "RUNNING": + return last + details = (last.get("active_workloads") or {}).get("container_details") or [] + exact = [ + item for item in details + if item.get("service_name") == "garm" + and item.get("image") == EXPECTED_IMAGE + and item.get("state") == "running" + ] + if len(exact) == 1: + return last + if last and last.get("state") in {"CRASHED", "ERROR"}: + raise RuntimeError(f"GARM app entered {last.get('state')}") + time.sleep(1) + raise RuntimeError(f"GARM app did not reach {expected}: {last!r}") + + def assert_persistent_state(label): + config_meta = call("filesystem.stat", [EXPECTED_CONFIG_PATH]) + db_meta = call("filesystem.stat", [EXPECTED_DB_PATH]) + state = { + "config_toml_size_positive": int(config_meta.get("size") or 0) > 0, + "garm_db_size_positive": int(db_meta.get("size") or 0) > 0, + } + if not all(state.values()): + raise RuntimeError(f"{label}: persistent config/db state was empty") + return state + + def normalized_live_compose(): + return normalize_secret_configs(call("app.config", [EXPECTED_APP_NAME])) + + auth = call("auth.login_ex", [{ + "mechanism": "PASSWORD_PLAIN", + "username": "truenas_admin", + "password": password, + }]) + if not isinstance(auth, dict) or auth.get("response_type") != "SUCCESS": + raise RuntimeError("authentication did not return SUCCESS") + + observed_version = call("system.version", []) + if observed_version != a.expected_system_version: + raise RuntimeError( + f"target version drifted: {observed_version!r} != {a.expected_system_version!r}" + ) + methods = call("core.get_methods", []) + method_names = set(methods if isinstance(methods, list) else methods.keys()) + missing = sorted(REQUIRED_METHODS - method_names) + if missing: + raise RuntimeError(f"required middleware methods missing: {missing}") + payload["f0"]["system_version"] = observed_version + payload["f0"]["required_methods_present"] = True + + if app_query() is not None: + raise RuntimeError("refusing adopted GARM app state") + if query_optional("pool.dataset.query", [["id", "=", EXPECTED_FIXTURE_DATASET]]) is not None: + raise RuntimeError("refusing adopted GARM fixture dataset") + + dataset = call("pool.dataset.create", [{ + "name": EXPECTED_FIXTURE_DATASET, + "type": "FILESYSTEM", + "share_type": "GENERIC", + "comments": "SemperSupra disposable GARM F0-F5 fixture", + }]) + if not isinstance(dataset, dict) or dataset.get("id") != EXPECTED_FIXTURE_DATASET: + raise RuntimeError("pool.dataset.create did not return exact fixture dataset") + dataset_owned = True + + mkdir = call("filesystem.mkdir", [{ + "path": EXPECTED_CONFIG_DIR, + "options": {"mode": "750", "raise_chmod_error": True}, + }]) + if not isinstance(mkdir, dict) or mkdir.get("path") != EXPECTED_CONFIG_DIR: + raise RuntimeError("filesystem.mkdir did not create exact config directory") + + create_id = call("app.create", [{ + "app_name": EXPECTED_APP_NAME, + "custom_app": True, + "custom_compose_config": runtime_compose, + }]) + wait_job(create_id, "app.create") + app_owned = True + app = wait_state("RUNNING") + if app.get("custom_app") is not True: + raise RuntimeError("GARM runtime did not materialize as a custom app") + if canonical_sha256(normalized_live_compose()) != retained_sha: + raise RuntimeError("F2 secret-normalized app.config does not match retained Compose") + f2_state = assert_persistent_state("F2") + ui_status = https_ui_probe(a.service_port, timeout=a.timeout) + if not 200 <= ui_status < 400: + raise RuntimeError(f"F2 GARM UI returned HTTP {ui_status}") + payload["f2"] = { + "app_running": True, + "compose_readback_exact": True, + "persistent_state": f2_state, + "https_ui_status": ui_status, + } + + wait_job(call("app.stop", [EXPECTED_APP_NAME]), "app.stop") + wait_state("STOPPED") + wait_job(call("app.start", [EXPECTED_APP_NAME]), "app.start") + wait_state("RUNNING") + restart_sha = canonical_sha256(normalized_live_compose()) + if restart_sha != retained_sha: + raise RuntimeError("F3 Compose identity drifted after restart") + restart_state = assert_persistent_state("F3 restart") + + update_id = call( + "app.update", + [EXPECTED_APP_NAME, {"custom_compose_config": runtime_compose}], + ) + wait_job(update_id, "app.update") + wait_state("RUNNING") + wait_job(call("app.redeploy", [EXPECTED_APP_NAME]), "app.redeploy") + wait_state("RUNNING") + if canonical_sha256(normalized_live_compose()) != retained_sha: + raise RuntimeError("F3 Compose identity drifted after update/redeploy") + redeploy_state = assert_persistent_state("F3 update/redeploy") + redeploy_status = https_ui_probe(a.service_port, timeout=a.timeout) + if not 200 <= redeploy_status < 400: + raise RuntimeError(f"F3 GARM UI returned HTTP {redeploy_status}") + payload["f3"] = { + "stop_start_pass": True, + "update_redeploy_pass": True, + "compose_identity_preserved": True, + "restart_persistent_state": restart_state, + "redeploy_persistent_state": redeploy_state, + "https_ui_status": redeploy_status, + } + + second_app = app_query() + second_action = reconciliation_action( + (second_app or {}).get("state"), + normalized_live_compose() if second_app else None, + retained_compose, + ) + if second_action != "NOOP": + raise RuntimeError(f"F4 second plan was {second_action}, expected NOOP") + payload["f4"] = { + "second_plan_action": second_action, + "inflight_policy": "WAIT", + "ambiguous_policy": "FAIL_CLOSED", + } + + retained_before = query_optional( + "pool.dataset.query", [["id", "=", EXPECTED_FIXTURE_DATASET]] + ) + if retained_before is None: + raise RuntimeError("F5 fixture dataset absent before retain-data delete") + wait_job(call("app.delete", [EXPECTED_APP_NAME, { + "remove_images": False, + "remove_ix_volumes": False, + "force_remove_custom_app": False, + }]), "retain-data app.delete") + app_owned = False + if app_query() is not None: + raise RuntimeError("F5 app remained after retain-data delete") + if query_optional( + "pool.dataset.query", [["id", "=", EXPECTED_FIXTURE_DATASET]] + ) is None: + raise RuntimeError("F5 external fixture dataset was not retained") + assert_persistent_state("F5 retained-data") + + wait_job(call("app.create", [{ + "app_name": EXPECTED_APP_NAME, + "custom_app": True, + "custom_compose_config": runtime_compose, + }]), "reinstall app.create") + app_owned = True + reinstall_app = wait_state("RUNNING") + if canonical_sha256(normalized_live_compose()) != retained_sha: + raise RuntimeError("F5 reinstall app.config drifted") + reinstall_state = assert_persistent_state("F5 reinstall") + reinstall_status = https_ui_probe(a.service_port, timeout=a.timeout) + if not 200 <= reinstall_status < 400: + raise RuntimeError(f"F5 reinstalled GARM UI returned HTTP {reinstall_status}") + post_reinstall_action = reconciliation_action( + reinstall_app.get("state"), + normalized_live_compose(), + retained_compose, + ) + if post_reinstall_action != "NOOP": + raise RuntimeError( + f"F5 post-reinstall plan was {post_reinstall_action}, expected NOOP" + ) + payload["f5"] = { + "external_dataset_retained_across_app_delete": True, + "persistent_state_after_reinstall": reinstall_state, + "https_ui_status": reinstall_status, + "post_reinstall_action": post_reinstall_action, + } + + wait_job(call("app.delete", [EXPECTED_APP_NAME, { + "remove_images": False, + "remove_ix_volumes": False, + "force_remove_custom_app": False, + }]), "final app.delete") + app_owned = False + deleted = call( + "pool.dataset.delete", + [EXPECTED_FIXTURE_DATASET, {"recursive": True, "force": False}], + ) + if deleted is not True: + raise RuntimeError("final pool.dataset.delete did not return true") + dataset_owned = False + if app_query() is not None: + raise RuntimeError("final GARM app residue remains") + if query_optional( + "pool.dataset.query", [["id", "=", EXPECTED_FIXTURE_DATASET]] + ) is not None: + raise RuntimeError("final GARM fixture dataset residue remains") + mountpoint_absent = False + try: + call("filesystem.stat", [EXPECTED_FIXTURE_ROOT]) + except RuntimeError: + mountpoint_absent = True + if not mountpoint_absent: + raise RuntimeError("final GARM fixture mountpoint residue remains") + + payload["cleanup"] = { + "app_absent": True, + "fixture_dataset_absent": True, + "fixture_mountpoint_absent": True, + "zero_residue": True, + } + payload["classification"] = "SUPPORTED" + payload["oracleSatisfied"] = True + payload["f0_f5_complete"] = True + payload["detail"] = ( + "exact-target Foundry GARM controller completed F0-F5: exact " + "profile/materialization identity, create/readback, product health and persistent " + "state, stop/start/update/redeploy, second-plan NOOP with fail-closed reconciliation, " + "retain-data delete/reinstall/post-reinstall NOOP, and final zero-residue cleanup" + ) + except Exception as exc: + payload["detail"] = f"{type(exc).__name__}: {exc}" + cleanup = {"attempted": bool(app_owned or dataset_owned), "errors": []} + if ws is not None: + if app_owned: + try: + current = query_optional("app.query", [["id", "=", EXPECTED_APP_NAME]]) + if current is not None: + wait_job(call("app.delete", [EXPECTED_APP_NAME, { + "remove_images": False, + "remove_ix_volumes": False, + "force_remove_custom_app": False, + }]), "failure cleanup app.delete") + cleanup["app_absent"] = ( + query_optional("app.query", [["id", "=", EXPECTED_APP_NAME]]) + is None + ) + except Exception as cleanup_exc: + cleanup["errors"].append( + f"app: {type(cleanup_exc).__name__}: {cleanup_exc}" + ) + if dataset_owned: + try: + current = query_optional( + "pool.dataset.query", [["id", "=", EXPECTED_FIXTURE_DATASET]] + ) + if current is not None: + deleted = call( + "pool.dataset.delete", + [EXPECTED_FIXTURE_DATASET, {"recursive": True, "force": False}], + ) + if deleted is not True: + raise RuntimeError("cleanup dataset delete did not return true") + cleanup["fixture_dataset_absent"] = ( + query_optional( + "pool.dataset.query", + [["id", "=", EXPECTED_FIXTURE_DATASET]], + ) + is None + ) + except Exception as cleanup_exc: + cleanup["errors"].append( + f"dataset: {type(cleanup_exc).__name__}: {cleanup_exc}" + ) + cleanup["zero_residue"] = ( + cleanup["attempted"] + and not cleanup["errors"] + and (cleanup.get("app_absent", not app_owned) is True) + and (cleanup.get("fixture_dataset_absent", not dataset_owned) is True) + ) + payload["cleanup"] = cleanup + payload["cleanup_needed"] = cleanup["attempted"] and not cleanup["zero_residue"] + finally: + if ws is not None: + ws.close() + + payload["elapsed_seconds"] = round(time.time() - started, 3) + pathlib.Path(a.out).write_text( + json.dumps(payload, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + print(json.dumps(payload, indent=2, sort_keys=True)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/truenas_middleware_garm_provider_g2_probe.py b/scripts/truenas_middleware_garm_provider_g2_probe.py new file mode 100644 index 000000000..2013e8bac --- /dev/null +++ b/scripts/truenas_middleware_garm_provider_g2_probe.py @@ -0,0 +1,680 @@ +#!/usr/bin/env python3 +"""G2 oracle: exact GARM TrueNAS provider transport/adoption on disposable TrueNAS.""" +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import pathlib +import secrets +import shutil +import socket +import ssl +import subprocess +import tempfile +import time + +from truenas_middleware_ddp_probe import WebSocket, ddp_call, wait_for + + +EXPECTED_VERSION = "TrueNAS-26.0.0-BETA.3" +EXPECTED_APPLIANCE = ( + "ghcr.io/sempersupra/garm-appliance@" + "sha256:1af67841ddd4589e3798dcda8be49230565c849d07ab57fd05899432dcdabca9" +) +EXPECTED_PROVIDER_PRODUCT_SOURCE = "14535745dc3aa3c0b5466da7c704bca4d23dcec5" +EXPECTED_PROVIDER_BINARY_SHA256 = "911f076ba9421f7bb2a1ea72acc927327d799a5ad6265bffe2d869b2cbf5ecc2" +EXPECTED_TRUENAS_MIDDLEWARE_SOURCE = "81e1265a86083888ba94a2bdfc02ff5c9c5ef6a3" +EXPECTED_FIXTURE_SCHEMA = "semper-supra.garm-provider-truenas-g2-fixtures/1" +EXPECTED_CONTROLLER_ID = "g2-controller" +EXPECTED_POOL_ID = "g2-pool" +PROVIDER_PATH_IN_IMAGE = "/opt/garm/providers.d/garm-provider-truenas" + + +class AdminSession: + def __init__(self, host: str, port: int, password: str, timeout: float): + self.host = host + self.port = port + self.password = password + self.timeout = timeout + self.ws: WebSocket | None = None + self.request_id = 1 + + def connect(self) -> None: + self.close() + self.ws = WebSocket(self.host, self.port, timeout=self.timeout, tls=False) + self.ws.send_json({"msg": "connect", "version": "1", "support": ["1"]}) + connected = wait_for(self.ws, lambda m: m.get("msg") in {"connected", "failed"}) + if connected.get("msg") != "connected": + raise RuntimeError("DDP connection failed") + auth = self.call("auth.login_ex", [{ + "mechanism": "PASSWORD_PLAIN", + "username": "truenas_admin", + "password": self.password, + }]) + if not isinstance(auth, dict) or auth.get("response_type") != "SUCCESS": + raise RuntimeError("password authentication did not return SUCCESS") + + def call(self, method: str, params): + if self.ws is None: + raise RuntimeError("admin session is not connected") + result = ddp_call(self.ws, str(self.request_id), method, params) + self.request_id += 1 + return result + + def wait_job(self, job_id: int, label: str, timeout: float) -> dict: + deadline = time.monotonic() + timeout + last = None + while time.monotonic() < deadline: + last = self.call("core.get_jobs", [[["id", "=", job_id]], {"get": True}]) + if last and last.get("state") == "SUCCESS": + return last + if last and last.get("state") in {"FAILED", "ABORTED"}: + raise RuntimeError(f"{label} job {last.get('state')}: {last.get('error')}") + time.sleep(1) + raise RuntimeError(f"{label} job did not reach SUCCESS: {last!r}") + + def close(self) -> None: + if self.ws is not None: + try: + self.ws.close() + finally: + self.ws = None + + +def load_bundle(directory: pathlib.Path, expected_producer: str) -> dict: + path = directory / "garm-provider-g2-fixtures.json" + bundle = json.loads(path.read_text(encoding="utf-8")) + if bundle.get("schema") != EXPECTED_FIXTURE_SCHEMA: + raise RuntimeError("unexpected G2 fixture schema") + if bundle.get("provider_product_source") != EXPECTED_PROVIDER_PRODUCT_SOURCE: + raise RuntimeError("G2 fixture product source drifted") + if bundle.get("producer_source") != expected_producer: + raise RuntimeError("G2 fixture producer source drifted") + if bundle.get("controller_id") != EXPECTED_CONTROLLER_ID: + raise RuntimeError("G2 fixture controller identity drifted") + if bundle.get("pool_id") != EXPECTED_POOL_ID: + raise RuntimeError("G2 fixture pool identity drifted") + fixtures = bundle.get("fixtures") + if not isinstance(fixtures, dict) or set(fixtures) != { + "valid_local", "valid_foreign", "managed_drift" + }: + raise RuntimeError("G2 fixture inventory drifted") + return bundle + + +def sha256_file(path: pathlib.Path) -> str: + h = hashlib.sha256() + with path.open("rb") as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b""): + h.update(chunk) + return h.hexdigest() + + +def extract_provider_binary(root: pathlib.Path) -> pathlib.Path: + subprocess.run( + ["docker", "pull", EXPECTED_APPLIANCE], + check=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + cp = subprocess.run( + ["docker", "create", EXPECTED_APPLIANCE], + check=True, + capture_output=True, + text=True, + ) + container_id = cp.stdout.strip() + if not container_id: + raise RuntimeError("docker create returned no container id") + destination = root / "garm-provider-truenas" + try: + subprocess.run( + ["docker", "cp", f"{container_id}:{PROVIDER_PATH_IN_IMAGE}", str(destination)], + check=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + finally: + subprocess.run( + ["docker", "rm", "-f", container_id], + check=False, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + digest = sha256_file(destination) + if digest != EXPECTED_PROVIDER_BINARY_SHA256: + raise RuntimeError(f"provider binary digest drifted: {digest}") + destination.chmod(0o755) + return destination + + +def generate_tls(root: pathlib.Path) -> tuple[pathlib.Path, pathlib.Path]: + key = root / "g2-ui.key" + cert = root / "g2-ui.crt" + subprocess.run( + [ + "openssl", "req", "-x509", "-newkey", "rsa:2048", "-nodes", "-days", "1", + "-subj", "/CN=127.0.0.1", + "-addext", "subjectAltName=IP:127.0.0.1", + "-addext", "basicConstraints=critical,CA:FALSE", + "-addext", "keyUsage=critical,digitalSignature,keyEncipherment", + "-addext", "extendedKeyUsage=serverAuth", + "-keyout", str(key), "-out", str(cert), + ], + check=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + key.chmod(0o600) + return cert, key + + +def ui_cert_id(value) -> int | None: + if value is None: + return None + if isinstance(value, int): + return value + if isinstance(value, dict) and isinstance(value.get("id"), int): + return int(value["id"]) + raise RuntimeError(f"unexpected ui_certificate shape: {type(value).__name__}") + + +def wait_verified_https(host: str, port: int, cafile: pathlib.Path, timeout: float = 60.0) -> None: + deadline = time.monotonic() + timeout + last = None + while time.monotonic() < deadline: + try: + context = ssl.create_default_context(cafile=str(cafile)) + with socket.create_connection((host, port), timeout=4) as raw: + with context.wrap_socket(raw, server_hostname=host) as wrapped: + cert = wrapped.getpeercert() + if cert: + return + except Exception as exc: + last = exc + time.sleep(1) + raise RuntimeError(f"verified HTTPS did not become ready: {last}") + + +def canonical_json_sha256(value: object) -> str: + raw = json.dumps(value, sort_keys=True, separators=(",", ":")).encode("utf-8") + return hashlib.sha256(raw).hexdigest() + + +def create_app_stopped_with_exact_config( + session: AdminSession, + app_name: str, + compose: dict, + job_timeout: float, +) -> dict: + """Materialize exact fixture config without ever starting its runner bootstrap. + + TrueNAS 26.0.0-BETA.3 app.update deliberately skips compose_action when the + existing App is STOPPED. Seed a harmless container, stop it, then replace + the stored custom Compose with the source-generated provider fixture and + prove exact app.config read-back while state remains STOPPED. + """ + services = compose.get("services") + if not isinstance(services, dict): + raise RuntimeError("fixture compose has no services object") + runner = services.get("runner") + if not isinstance(runner, dict) or not isinstance(runner.get("image"), str): + raise RuntimeError("fixture compose has no runner image") + + seed_compose = { + "services": { + "seed": { + "image": runner["image"], + "restart": "no", + "entrypoint": [ + "/bin/sh", + "-c", + "trap 'exit 0' TERM INT HUP; while :; do sleep 60; done", + ], + }, + }, + } + create_id = session.call("app.create", [{ + "app_name": app_name, + "custom_app": True, + "custom_compose_config": seed_compose, + }]) + if not isinstance(create_id, int): + raise RuntimeError(f"app.create({app_name}) did not return a job id") + session.wait_job(create_id, f"app.create seed {app_name}", job_timeout) + + app = session.call("app.query", [[["id", "=", app_name]], {"get": True}]) + if not app: + raise RuntimeError(f"seed app {app_name} absent immediately after create") + if app.get("state") != "STOPPED": + stop_id = session.call("app.stop", [app_name]) + if not isinstance(stop_id, int): + raise RuntimeError(f"app.stop({app_name}) did not return a job id") + session.wait_job(stop_id, f"app.stop seed {app_name}", job_timeout) + app = session.call("app.query", [[["id", "=", app_name]], {"get": True}]) + if not app or app.get("state") != "STOPPED": + raise RuntimeError(f"seed app {app_name} did not reach STOPPED") + + expected_sha = canonical_json_sha256(compose) + update_id = session.call("app.update", [ + app_name, + {"custom_compose_config": compose}, + ]) + if not isinstance(update_id, int): + raise RuntimeError(f"app.update({app_name}) did not return a job id") + session.wait_job(update_id, f"app.update exact fixture {app_name}", job_timeout) + + app = session.call("app.query", [[["id", "=", app_name]], {"get": True}]) + if not app or app.get("state") != "STOPPED": + raise RuntimeError(f"exact fixture app {app_name} did not remain STOPPED") + + readback = session.call("app.config", [app_name]) + if not isinstance(readback, dict): + raise RuntimeError(f"app.config({app_name}) did not return an object") + if canonical_json_sha256(readback) != expected_sha: + raise RuntimeError(f"app.config({app_name}) did not preserve exact fixture compose") + return app + + +def delete_app_if_present(session: AdminSession, app_name: str, job_timeout: float) -> None: + matches = session.call("app.query", [[["id", "=", app_name]]]) + if not matches: + return + app = matches[0] + if app.get("state") not in {"STOPPED", "CRASHED"}: + stop_id = session.call("app.stop", [app_name]) + if isinstance(stop_id, int): + session.wait_job(stop_id, f"cleanup app.stop {app_name}", job_timeout) + delete_id = session.call("app.delete", [app_name, { + "remove_images": False, + "remove_ix_volumes": False, + "force_remove_custom_app": False, + }]) + if not isinstance(delete_id, int): + raise RuntimeError(f"cleanup app.delete({app_name}) did not return a job id") + session.wait_job(delete_id, f"cleanup app.delete {app_name}", job_timeout) + + +def provider_env( + provider_binary: pathlib.Path, + config_path: pathlib.Path, + api_key: str, + ca_path: pathlib.Path, + command: str, + instance_id: str | None = None, +) -> tuple[list[str], dict[str, str]]: + env = os.environ.copy() + env.update({ + "GARM_INTERFACE_VERSION": "v0.1.1", + "GARM_COMMAND": command, + "GARM_CONTROLLER_ID": EXPECTED_CONTROLLER_ID, + "GARM_POOL_ID": EXPECTED_POOL_ID, + "GARM_PROVIDER_CONFIG_FILE": str(config_path), + "TRUENAS_API_KEY": api_key, + "SSL_CERT_FILE": str(ca_path), + }) + if instance_id is not None: + env["GARM_INSTANCE_ID"] = instance_id + else: + env.pop("GARM_INSTANCE_ID", None) + return [str(provider_binary)], env + + +def invoke_provider( + provider_binary: pathlib.Path, + config_path: pathlib.Path, + api_key: str, + ca_path: pathlib.Path, + command: str, + instance_id: str | None = None, +) -> subprocess.CompletedProcess[str]: + argv, env = provider_env( + provider_binary, config_path, api_key, ca_path, command, instance_id + ) + return subprocess.run( + argv, + env=env, + text=True, + capture_output=True, + check=False, + timeout=45, + ) + + +def parse_json_stdout(cp: subprocess.CompletedProcess[str], label: str): + if cp.returncode != 0: + raise RuntimeError(f"{label} failed rc={cp.returncode}: {cp.stderr[-1000:]}") + text = cp.stdout.strip() + if not text: + raise RuntimeError(f"{label} returned empty stdout") + try: + return json.loads(text) + except json.JSONDecodeError as exc: + raise RuntimeError(f"{label} stdout was not JSON: {text[-1000:]}") from exc + + +def main() -> int: + p = argparse.ArgumentParser() + p.add_argument("--host", default="127.0.0.1") + p.add_argument("--http-port", type=int, required=True) + p.add_argument("--https-port", type=int, required=True) + p.add_argument("--password-file", required=True) + p.add_argument("--fixture-dir", type=pathlib.Path, required=True) + p.add_argument("--fixture-producer-commit", required=True) + p.add_argument("--out", required=True) + p.add_argument("--timeout", type=float, default=8.0) + p.add_argument("--job-timeout", type=float, default=300.0) + a = p.parse_args() + + started = time.time() + payload = { + "schema": "truenas-garm-provider-g2/v1", + "classification": "ORACLE_FAILURE", + "oracleSatisfied": False, + "expected_version": EXPECTED_VERSION, + "provider_product_source": EXPECTED_PROVIDER_PRODUCT_SOURCE, + "provider_binary_sha256": EXPECTED_PROVIDER_BINARY_SHA256, + "truenas_middleware_source": EXPECTED_TRUENAS_MIDDLEWARE_SOURCE, + "stopped_update_contract": "app.update skips compose action when existing App state is STOPPED", + "appliance": EXPECTED_APPLIANCE, + "controller_id": EXPECTED_CONTROLLER_ID, + "pool_id": EXPECTED_POOL_ID, + "provider_create_exercised": False, + "provider_delete_exercised": False, + "github_credentials_present": False, + "github_jit_registration_exercised": False, + "physical_truenas_mutation": False, + "secret_values_recorded": False, + } + + password = pathlib.Path(a.password_file).read_text(encoding="utf-8").strip() + bundle = None + fixture_names: list[str] = [] + api_key_id: int | None = None + api_key_value: str | None = None + imported_cert_id: int | None = None + original_ui_cert_id: int | None = None + session = AdminSession(a.host, a.http_port, password, a.timeout) + cleanup_errors: list[str] = [] + + with tempfile.TemporaryDirectory(prefix="garm-provider-g2-") as td: + root = pathlib.Path(td) + try: + bundle = load_bundle(a.fixture_dir, a.fixture_producer_commit) + payload["fixture_producer_source"] = bundle["producer_source"] + payload["fixture_bundle_sha256"] = sha256_file( + a.fixture_dir / "garm-provider-g2-fixtures.json" + ) + + provider_binary = extract_provider_binary(root) + payload["provider_binary_from_appliance"] = True + + cert_path, key_path = generate_tls(root) + payload["transport"] = { + "scheme": "wss", + "host": a.host, + "port": a.https_port, + "insecure_skip_verify": False, + "certificate_sha256": sha256_file(cert_path), + "private_key_recorded": False, + "api_key_recorded": False, + } + + session.connect() + version = session.call("system.version", []) + payload["system_version"] = version + if version != EXPECTED_VERSION: + raise RuntimeError(f"target version drifted: {version}") + + general = session.call("system.general.config", []) + original_ui_cert_id = ui_cert_id(general.get("ui_certificate")) + + api_key = session.call("api_key.create", [{ + "name": f"g2-provider-{secrets.token_hex(5)}", + "username": "truenas_admin", + }]) + if not isinstance(api_key, dict) or not isinstance(api_key.get("id"), int): + raise RuntimeError("api_key.create did not return an id") + api_key_id = int(api_key["id"]) + api_key_value = api_key.get("key") + if not isinstance(api_key_value, str) or not api_key_value: + raise RuntimeError("api_key.create did not return a key") + + create_cert_id = session.call("certificate.create", [{ + "name": f"g2_provider_{secrets.token_hex(4)}", + "create_type": "CERTIFICATE_CREATE_IMPORTED", + "add_to_trusted_store": False, + "certificate": cert_path.read_text(encoding="utf-8"), + "privatekey": key_path.read_text(encoding="utf-8"), + }]) + if not isinstance(create_cert_id, int): + raise RuntimeError("certificate.create did not return a job id") + cert_job = session.wait_job(create_cert_id, "certificate.create", a.job_timeout) + cert_result = cert_job.get("result") + if not isinstance(cert_result, dict) or not isinstance(cert_result.get("id"), int): + raise RuntimeError("certificate.create job did not return certificate id") + imported_cert_id = int(cert_result["id"]) + + session.call("system.general.update", [{"ui_certificate": imported_cert_id}]) + try: + session.call("system.general.ui_restart", [0]) + except Exception: + # Applying UI settings intentionally aborts the HTTP/WebSocket connection. + pass + session.close() + wait_verified_https(a.host, a.https_port, cert_path) + + provider_config = { + "mode": "truenas", + "truenas": { + "host": a.host, + "username": "truenas_admin", + "api_key_env": "TRUENAS_API_KEY", + "port": a.https_port, + "insecure_skip_verify": False, + "callback_host_gateway": False, + }, + } + config_path = root / "provider.json" + config_path.write_text( + json.dumps(provider_config, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + config_path.chmod(0o600) + + session.connect() + fixtures = bundle["fixtures"] + for key in ("valid_local", "valid_foreign"): + fixture = fixtures[key] + name = fixture["app_name"] + fixture_names.append(name) + create_app_stopped_with_exact_config( + session, name, fixture["compose"], a.job_timeout + ) + + local_name = fixtures["valid_local"]["app_name"] + foreign_name = fixtures["valid_foreign"]["app_name"] + + list_cp = invoke_provider( + provider_binary, config_path, api_key_value, cert_path, "ListInstances" + ) + listed = parse_json_stdout(list_cp, "ListInstances valid/foreign") + if not isinstance(listed, list): + raise RuntimeError("ListInstances did not return an array") + provider_ids = sorted( + str(item.get("provider_id")) + for item in listed + if isinstance(item, dict) + ) + if provider_ids != [local_name]: + raise RuntimeError(f"ListInstances ownership filter drifted: {provider_ids}") + + get_local_cp = invoke_provider( + provider_binary, config_path, api_key_value, cert_path, + "GetInstance", local_name, + ) + got_local = parse_json_stdout(get_local_cp, "GetInstance local") + if not isinstance(got_local, dict) or got_local.get("provider_id") != local_name: + raise RuntimeError("GetInstance local returned wrong provider id") + + foreign_cp = invoke_provider( + provider_binary, config_path, api_key_value, cert_path, + "GetInstance", foreign_name, + ) + if foreign_cp.returncode == 0: + raise RuntimeError("GetInstance foreign unexpectedly succeeded") + + reconnect_cp = invoke_provider( + provider_binary, config_path, api_key_value, cert_path, + "GetInstance", local_name, + ) + reconnect_local = parse_json_stdout(reconnect_cp, "GetInstance reconnect") + if reconnect_local.get("provider_id") != local_name: + raise RuntimeError("fresh provider process did not readopt local fixture") + + drift = fixtures["managed_drift"] + drift_name = drift["app_name"] + fixture_names.append(drift_name) + create_app_stopped_with_exact_config( + session, drift_name, drift["compose"], a.job_timeout + ) + + drift_get = invoke_provider( + provider_binary, config_path, api_key_value, cert_path, + "GetInstance", drift_name, + ) + if drift_get.returncode == 0: + raise RuntimeError("GetInstance managed drift unexpectedly succeeded") + + drift_list = invoke_provider( + provider_binary, config_path, api_key_value, cert_path, + "ListInstances", + ) + if drift_list.returncode == 0: + raise RuntimeError("ListInstances with managed drift unexpectedly succeeded") + + payload["oracles"] = { + "verified_tls_transport": True, + "api_key_auth_transport": True, + "list_valid_owned_only": True, + "get_valid_owned": True, + "foreign_not_adopted": True, + "fresh_process_readoption": True, + "managed_drift_get_fail_closed": True, + "managed_drift_list_fail_closed": True, + "exact_fixture_applied_while_stopped": True, + "runner_fixture_bootstrap_not_started": True, + "provider_delete_not_invoked": True, + "provider_create_not_invoked": True, + } + payload["classification"] = "SUPPORTED" + payload["oracleSatisfied"] = True + payload["detail"] = ( + "exact packaged provider used verified WSS plus run-local API-key auth against " + "real nested TrueNAS; exact source-generated local fixture was listed/readopted, " + "foreign ownership was excluded, and managed realized-profile drift failed closed; " + "source-generated fixture Compose was applied only after each seed App was STOPPED, " + "so runner bootstrap/JIT did not execute; provider create/delete and GitHub/JIT paths " + "were intentionally not exercised" + ) + except Exception as exc: + payload["detail"] = f"{type(exc).__name__}: {exc}" + finally: + # Cleanup through supported TrueNAS middleware only. + try: + session.connect() + for name in reversed(fixture_names): + try: + delete_app_if_present(session, name, a.job_timeout) + except Exception as exc: + cleanup_errors.append(f"delete {name}: {type(exc).__name__}: {exc}") + + for name in fixture_names: + try: + if session.call("app.query", [[["id", "=", name]]]): + cleanup_errors.append(f"fixture app remains after delete: {name}") + except Exception as exc: + cleanup_errors.append( + f"verify fixture absence {name}: {type(exc).__name__}: {exc}" + ) + + if api_key_id is not None: + try: + deleted = session.call("api_key.delete", [api_key_id]) + if deleted is not True: + cleanup_errors.append("api_key.delete did not return true") + except Exception as exc: + cleanup_errors.append(f"api_key.delete: {type(exc).__name__}: {exc}") + api_key_value = None + + if original_ui_cert_id is not None and imported_cert_id is not None: + try: + session.call( + "system.general.update", + [{"ui_certificate": original_ui_cert_id}], + ) + try: + session.call("system.general.ui_restart", [0]) + except Exception: + pass + except Exception as exc: + cleanup_errors.append( + f"restore ui certificate: {type(exc).__name__}: {exc}" + ) + session.close() + time.sleep(5) + try: + session.connect() + except Exception as exc: + cleanup_errors.append( + f"reconnect after ui restore: {type(exc).__name__}: {exc}" + ) + + if imported_cert_id is not None and session.ws is not None: + try: + delete_result = session.call( + "certificate.delete", [imported_cert_id, False] + ) + if isinstance(delete_result, int): + session.wait_job( + delete_result, "certificate.delete", a.job_timeout + ) + elif delete_result is not True: + cleanup_errors.append( + f"certificate.delete unexpected result {delete_result!r}" + ) + except Exception as exc: + cleanup_errors.append( + f"certificate.delete: {type(exc).__name__}: {exc}" + ) + except Exception as exc: + cleanup_errors.append(f"cleanup session: {type(exc).__name__}: {exc}") + finally: + session.close() + + payload["cleanup"] = { + "attempted": True, + "errors": cleanup_errors, + "zero_unintended_residue": not cleanup_errors, + "secret_values_recorded": False, + } + if cleanup_errors: + payload["oracleSatisfied"] = False + payload["classification"] = "ORACLE_FAILURE" + payload["detail"] = ( + payload.get("detail", "") + "; cleanup failed: " + "; ".join(cleanup_errors) + ) + + payload["elapsed_seconds"] = round(time.time() - started, 3) + pathlib.Path(a.out).write_text( + json.dumps(payload, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + print(json.dumps(payload, indent=2, sort_keys=True)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/truenas_middleware_garm_provider_g3_probe.py b/scripts/truenas_middleware_garm_provider_g3_probe.py new file mode 100644 index 000000000..54b7f63a5 --- /dev/null +++ b/scripts/truenas_middleware_garm_provider_g3_probe.py @@ -0,0 +1,765 @@ +#!/usr/bin/env python3 +"""G3 oracle: exact provider-created runner App to the synthetic GitHub credential boundary.""" +from __future__ import annotations + +import argparse +import copy +import json +import os +import pathlib +import secrets +import ssl +import subprocess +import tempfile +import time +import urllib.error +import urllib.request + +import truenas_middleware_garm_provider_g2_probe as g2 + + +EXPECTED_VERSION = "TrueNAS-26.0.0-BETA.3" +EXPECTED_APPLIANCE = ( + "ghcr.io/sempersupra/garm-appliance@" + "sha256:1af67841ddd4589e3798dcda8be49230565c849d07ab57fd05899432dcdabca9" +) +EXPECTED_PROVIDER_PRODUCT_SOURCE = "14535745dc3aa3c0b5466da7c704bca4d23dcec5" +EXPECTED_PROVIDER_BINARY_SHA256 = "911f076ba9421f7bb2a1ea72acc927327d799a5ad6265bffe2d869b2cbf5ecc2" +EXPECTED_FIXTURE_SCHEMA = "semper-supra.garm-provider-truenas-g3-create-fixture/1" +EXPECTED_CONTROLLER_ID = "g3-controller" +EXPECTED_POOL_ID = "g3-pool" +DEFAULT_CALLBACK_URL = "https://httpbin.org/anything/sempersupra-g3/status" +DEFAULT_METADATA_URL = ( + "https://httpbin.org/drip?duration=1&numbytes=1&code=200&delay=60&path=" +) +EXPECTED_SUBSTITUTIONS = [ + "bootstrap_template.callback-url", + "bootstrap_template.metadata-url", + "bootstrap_template.instance-token", + "expected_compose_template.services.runner.environment.GARM_CALLBACK_URL", + "expected_compose_template.services.runner.environment.GARM_METADATA_URL", + "expected_compose_template.services.runner.environment.GARM_INSTANCE_TOKEN", +] + + +class FixtureEnvironmentError(RuntimeError): + pass + + +def preflight_public_fixture(callback_url: str, metadata_url: str, timeout: float) -> None: + payload = b'{"status":"probe","message":"public synthetic G3 fixture preflight"}' + request = urllib.request.Request( + callback_url, + data=payload, + method="POST", + headers={ + "Accept": "application/json", + "Content-Type": "application/json", + "User-Agent": "SemperSupra-G3-RDTE/1", + }, + ) + try: + with urllib.request.urlopen(request, timeout=timeout) as response: + if not (200 <= response.status < 300): + raise FixtureEnvironmentError( + f"synthetic callback preflight returned HTTP {response.status}" + ) + response.read(4096) + + metadata_probe = metadata_url.rstrip("/") + "/credentials/runner" + request = urllib.request.Request( + metadata_probe, + method="GET", + headers={ + "Accept": "application/json", + "User-Agent": "SemperSupra-G3-RDTE/1", + }, + ) + try: + with urllib.request.urlopen(request, timeout=timeout) as response: + response.read(4096) + except TimeoutError: + pass + except urllib.error.URLError as exc: + if not isinstance(exc.reason, TimeoutError): + raise + else: + raise FixtureEnvironmentError( + "synthetic metadata hold-open returned before the preflight timeout" + ) + except FixtureEnvironmentError: + raise + except Exception as exc: + raise FixtureEnvironmentError( + f"public synthetic callback/metadata fixture unavailable: {type(exc).__name__}: {exc}" + ) from exc + + + +def load_bundle(directory: pathlib.Path, expected_producer: str) -> dict: + path = directory / "garm-provider-g3-fixture.json" + bundle = json.loads(path.read_text(encoding="utf-8")) + if bundle.get("schema") != EXPECTED_FIXTURE_SCHEMA: + raise RuntimeError("unexpected G3 fixture schema") + if bundle.get("provider_product_source") != EXPECTED_PROVIDER_PRODUCT_SOURCE: + raise RuntimeError("G3 fixture product source drifted") + if bundle.get("producer_source") != expected_producer: + raise RuntimeError("G3 fixture producer source drifted") + if bundle.get("controller_id") != EXPECTED_CONTROLLER_ID: + raise RuntimeError("G3 fixture controller identity drifted") + if bundle.get("pool_id") != EXPECTED_POOL_ID: + raise RuntimeError("G3 fixture pool identity drifted") + if bundle.get("run_local_substitutions") != EXPECTED_SUBSTITUTIONS: + raise RuntimeError("G3 run-local substitution allowlist drifted") + if not isinstance(bundle.get("bootstrap_template"), dict): + raise RuntimeError("G3 fixture has no bootstrap template") + if not isinstance(bundle.get("expected_compose_template"), dict): + raise RuntimeError("G3 fixture has no expected Compose template") + if not isinstance(bundle.get("expected_app_name"), str) or not bundle["expected_app_name"]: + raise RuntimeError("G3 fixture has no expected App name") + return bundle + + +def set_path(root: dict, dotted: str, value: str) -> None: + parts = dotted.split(".") + cur = root + for part in parts[:-1]: + nxt = cur.get(part) + if not isinstance(nxt, dict): + raise RuntimeError(f"G3 substitution path is not an object: {dotted}") + cur = nxt + if parts[-1] not in cur: + raise RuntimeError(f"G3 substitution target absent: {dotted}") + cur[parts[-1]] = value + + +def lower_fixture(bundle: dict, callback_url: str, metadata_url: str, token: str) -> tuple[dict, dict]: + bootstrap = copy.deepcopy(bundle["bootstrap_template"]) + expected = copy.deepcopy(bundle["expected_compose_template"]) + values = { + "bootstrap_template.callback-url": callback_url, + "bootstrap_template.metadata-url": metadata_url, + "bootstrap_template.instance-token": token, + "expected_compose_template.services.runner.environment.GARM_CALLBACK_URL": callback_url, + "expected_compose_template.services.runner.environment.GARM_METADATA_URL": metadata_url, + "expected_compose_template.services.runner.environment.GARM_INSTANCE_TOKEN": token, + } + for dotted in EXPECTED_SUBSTITUTIONS: + target = bootstrap if dotted.startswith("bootstrap_template.") else expected + relative = dotted.split(".", 1)[1] + set_path(target, relative, values[dotted]) + return bootstrap, expected + + +def provider_env( + config_path: pathlib.Path, + api_key: str, + ca_path: pathlib.Path, + command: str, + instance_id: str | None = None, +) -> dict[str, str]: + env = os.environ.copy() + env.update({ + "GARM_INTERFACE_VERSION": "v0.1.1", + "GARM_COMMAND": command, + "GARM_CONTROLLER_ID": EXPECTED_CONTROLLER_ID, + "GARM_POOL_ID": EXPECTED_POOL_ID, + "GARM_PROVIDER_CONFIG_FILE": str(config_path), + "TRUENAS_API_KEY": api_key, + "SSL_CERT_FILE": str(ca_path), + }) + if instance_id: + env["GARM_INSTANCE_ID"] = instance_id + else: + env.pop("GARM_INSTANCE_ID", None) + return env + + +def invoke_provider( + provider_binary: pathlib.Path, + config_path: pathlib.Path, + api_key: str, + ca_path: pathlib.Path, + command: str, + *, + instance_id: str | None = None, + stdin_object: dict | None = None, +) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [str(provider_binary)], + env=provider_env(config_path, api_key, ca_path, command, instance_id), + input=(json.dumps(stdin_object) if stdin_object is not None else None), + text=True, + capture_output=True, + check=False, + timeout=120, + ) + + +def parse_provider_json(cp: subprocess.CompletedProcess[str], label: str, token: str): + if cp.returncode != 0: + detail = (cp.stderr or cp.stdout or "")[-1200:].replace(token, "") + raise RuntimeError(f"{label} failed rc={cp.returncode}: {detail}") + text = cp.stdout.strip() + if not text: + raise RuntimeError(f"{label} returned empty stdout") + try: + return json.loads(text) + except json.JSONDecodeError as exc: + raise RuntimeError(f"{label} stdout was not JSON") from exc + + +def capture_create_failure_job( + session: g2.AdminSession, app_name: str, token: str +) -> dict: + jobs = session.call( + "core.get_jobs", + [[["method", "=", "app.create"]], {"order_by": ["-id"], "limit": 20}], + ) + if not isinstance(jobs, list): + raise RuntimeError("core.get_jobs did not return an array") + selected = None + for job in jobs: + if not isinstance(job, dict): + continue + arguments = job.get("arguments") + try: + rendered = json.dumps(arguments, sort_keys=True) + except TypeError: + rendered = repr(arguments) + if app_name in rendered: + selected = job + break + if selected is None: + return {"found": False} + + def scrub(value): + if isinstance(value, str): + return value.replace(token, "") + if isinstance(value, dict): + return {str(k): scrub(v) for k, v in value.items()} + if isinstance(value, list): + return [scrub(v) for v in value] + return value + + return { + "found": True, + "id": selected.get("id"), + "state": selected.get("state"), + "error": scrub(selected.get("error")), + "exception": scrub(selected.get("exception")), + "progress": scrub(selected.get("progress")), + "logs_excerpt": scrub(selected.get("logs_excerpt")), + "logs_available": bool(selected.get("logs_path")), + } + + +def select_sanitized_log_excerpt( + text: str, + app_name: str, + sensitive_values: list[str], + *, + context_lines: int = 80, + max_chars: int = 24000, +) -> dict: + scrubbed = text + for value in sensitive_values: + if value: + scrubbed = scrubbed.replace(value, "") + lines = scrubbed.splitlines() + hits = [i for i, line in enumerate(lines) if app_name in line] + if hits: + center = hits[-1] + start = max(0, center - context_lines) + end = min(len(lines), center + context_lines + 1) + else: + start = max(0, len(lines) - (context_lines * 2 + 1)) + end = len(lines) + excerpt = "\n".join(lines[start:end]) + if len(excerpt) > max_chars: + excerpt = excerpt[-max_chars:] + return { + "app_name_observed": bool(hits), + "line_count": len(lines), + "excerpt": excerpt, + } + + +def capture_app_lifecycle_log( + session: g2.AdminSession, + host: str, + https_port: int, + ca_path: pathlib.Path, + app_name: str, + sensitive_values: list[str], + timeout: float, +) -> dict: + result = session.call( + "core.download", + ["filesystem.get", ["/var/log/app_lifecycle.log"], "app_lifecycle.log", True], + ) + if not isinstance(result, (list, tuple)) or len(result) != 2: + raise RuntimeError(f"core.download returned unexpected result: {type(result).__name__}") + job_id, relative_url = result + if not isinstance(job_id, int) or not isinstance(relative_url, str): + raise RuntimeError("core.download did not return job id and URL") + if not relative_url.startswith("/_download/"): + raise RuntimeError("core.download returned unexpected download path") + + context = ssl.create_default_context(cafile=str(ca_path)) + request = urllib.request.Request( + f"https://{host}:{https_port}{relative_url}", + method="GET", + headers={"User-Agent": "SemperSupra-G3-RDTE/1"}, + ) + max_bytes = 2 * 1024 * 1024 + with urllib.request.urlopen( + request, timeout=max(timeout, 30.0), context=context + ) as response: + raw = response.read(max_bytes + 1) + truncated = len(raw) > max_bytes + raw = raw[:max_bytes] + text = raw.decode("utf-8", errors="replace") + selected = select_sanitized_log_excerpt( + text, app_name, sensitive_values + ) + selected.update({ + "download_job_id": job_id, + "bytes_read": len(raw), + "truncated": truncated, + "source": "/var/log/app_lifecycle.log", + }) + return selected + + +def query_app(session: g2.AdminSession, name: str) -> dict | None: + matches = session.call("app.query", [[["id", "=", name]]]) + if not matches: + return None + return matches[0] + + +def ensure_inactive(session: g2.AdminSession, name: str, job_timeout: float) -> str: + app = query_app(session, name) + if app is None: + raise RuntimeError("provider-created App disappeared before retirement") + state = str(app.get("state") or "") + if state in {"STOPPED", "CRASHED"}: + return state + stop_id = session.call("app.stop", [name]) + if not isinstance(stop_id, int): + raise RuntimeError("app.stop did not return a job id") + session.wait_job(stop_id, f"app.stop {name}", job_timeout) + app = query_app(session, name) + if app is None: + raise RuntimeError("provider-created App disappeared after stop") + state = str(app.get("state") or "") + if state not in {"STOPPED", "CRASHED"}: + raise RuntimeError(f"provider-created App did not become inactive: {state}") + return state + + +def main() -> int: + p = argparse.ArgumentParser() + p.add_argument("--host", default="127.0.0.1") + p.add_argument("--http-port", type=int, required=True) + p.add_argument("--https-port", type=int, required=True) + p.add_argument("--password-file", required=True) + p.add_argument("--fixture-dir", type=pathlib.Path, required=True) + p.add_argument("--fixture-producer-commit", required=True) + p.add_argument("--expected-version", default=EXPECTED_VERSION) + p.add_argument("--callback-url", default=DEFAULT_CALLBACK_URL) + p.add_argument("--metadata-url", default=DEFAULT_METADATA_URL) + p.add_argument("--out", required=True) + p.add_argument("--timeout", type=float, default=8.0) + p.add_argument("--job-timeout", type=float, default=300.0) + a = p.parse_args() + + started = time.time() + payload = { + "schema": "truenas-garm-provider-g3/v1", + "classification": "ORACLE_FAILURE", + "oracleSatisfied": False, + "expected_version": a.expected_version, + "provider_product_source": EXPECTED_PROVIDER_PRODUCT_SOURCE, + "provider_binary_sha256": EXPECTED_PROVIDER_BINARY_SHA256, + "appliance": EXPECTED_APPLIANCE, + "controller_id": EXPECTED_CONTROLLER_ID, + "pool_id": EXPECTED_POOL_ID, + "provider_create_exercised": False, + "provider_delete_exercised": False, + "github_credentials_present": False, + "github_jit_registration_exercised": False, + "private_repository_execution": False, + "physical_truenas_mutation": False, + "capacity_promotion": False, + "secret_values_recorded": False, + "synthetic_token_recorded": False, + "callback_fixture": { + "callback_url": a.callback_url, + "metadata_url": a.metadata_url, + "authority": "public-synthetic-non-github", + "metadata_behavior": "intentional-hold-open-no-jit-credentials", + }, + } + + if g2.EXPECTED_APPLIANCE != EXPECTED_APPLIANCE: + raise RuntimeError("G2 helper appliance identity drifted") + if g2.EXPECTED_PROVIDER_BINARY_SHA256 != EXPECTED_PROVIDER_BINARY_SHA256: + raise RuntimeError("G2 helper provider binary identity drifted") + + password = pathlib.Path(a.password_file).read_text(encoding="utf-8").strip() + session = g2.AdminSession(a.host, a.http_port, password, a.timeout) + api_key_id: int | None = None + api_key_value: str | None = None + imported_cert_id: int | None = None + original_ui_cert_id: int | None = None + app_name: str | None = None + provider_delete_succeeded = False + fallback_cleanup_used = False + cleanup_errors: list[str] = [] + + with tempfile.TemporaryDirectory(prefix="garm-provider-g3-") as td: + root = pathlib.Path(td) + token = f"g3-public-{secrets.token_hex(12)}" + try: + bundle = load_bundle(a.fixture_dir, a.fixture_producer_commit) + payload["fixture_producer_source"] = bundle["producer_source"] + preflight_public_fixture(a.callback_url, a.metadata_url, a.timeout) + payload["synthetic_fixture_preflight"] = True + payload["fixture_bundle_sha256"] = g2.sha256_file( + a.fixture_dir / "garm-provider-g3-fixture.json" + ) + payload["expected_app_name"] = bundle["expected_app_name"] + payload["run_local_substitutions"] = EXPECTED_SUBSTITUTIONS + app_name = bundle["expected_app_name"] + + bootstrap, expected_compose = lower_fixture( + bundle, a.callback_url, a.metadata_url, token + ) + expected_compose_sha = g2.canonical_json_sha256(expected_compose) + + provider_binary = g2.extract_provider_binary(root) + payload["provider_binary_from_appliance"] = True + + cert_path, key_path = g2.generate_tls(root) + payload["transport"] = { + "scheme": "wss", + "host": a.host, + "port": a.https_port, + "insecure_skip_verify": False, + "certificate_sha256": g2.sha256_file(cert_path), + "private_key_recorded": False, + "api_key_recorded": False, + } + + session.connect() + version = session.call("system.version", []) + payload["system_version"] = version + if version != a.expected_version: + raise RuntimeError(f"target version drifted: {version}") + + general = session.call("system.general.config", []) + original_ui_cert_id = g2.ui_cert_id(general.get("ui_certificate")) + + api_key = session.call("api_key.create", [{ + "name": f"g3-provider-{secrets.token_hex(5)}", + "username": "truenas_admin", + }]) + if not isinstance(api_key, dict) or not isinstance(api_key.get("id"), int): + raise RuntimeError("api_key.create did not return an id") + api_key_id = int(api_key["id"]) + api_key_value = api_key.get("key") + if not isinstance(api_key_value, str) or not api_key_value: + raise RuntimeError("api_key.create did not return a key") + + create_cert_id = session.call("certificate.create", [{ + "name": f"g3_provider_{secrets.token_hex(4)}", + "create_type": "CERTIFICATE_CREATE_IMPORTED", + "add_to_trusted_store": False, + "certificate": cert_path.read_text(encoding="utf-8"), + "privatekey": key_path.read_text(encoding="utf-8"), + }]) + if not isinstance(create_cert_id, int): + raise RuntimeError("certificate.create did not return a job id") + cert_job = session.wait_job(create_cert_id, "certificate.create", a.job_timeout) + cert_result = cert_job.get("result") + if not isinstance(cert_result, dict) or not isinstance(cert_result.get("id"), int): + raise RuntimeError("certificate.create job did not return certificate id") + imported_cert_id = int(cert_result["id"]) + + session.call("system.general.update", [{"ui_certificate": imported_cert_id}]) + try: + session.call("system.general.ui_restart", [0]) + except Exception: + pass + session.close() + g2.wait_verified_https(a.host, a.https_port, cert_path) + + provider_config = { + "mode": "truenas", + "truenas": { + "host": a.host, + "username": "truenas_admin", + "api_key_env": "TRUENAS_API_KEY", + "port": a.https_port, + "insecure_skip_verify": False, + "callback_host_gateway": False, + }, + } + config_path = root / "provider.json" + config_path.write_text( + json.dumps(provider_config, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + config_path.chmod(0o600) + + session.connect() + payload["provider_create_exercised"] = True + create_cp = invoke_provider( + provider_binary, config_path, api_key_value, cert_path, + "CreateInstance", stdin_object=bootstrap, + ) + try: + created = parse_provider_json(create_cp, "CreateInstance", token) + except Exception: + try: + payload["create_failure_job"] = capture_create_failure_job( + session, app_name, token + ) + except Exception as capture_exc: + payload["create_failure_job"] = { + "found": False, + "capture_error": ( + f"{type(capture_exc).__name__}: {capture_exc}" + ).replace(token, ""), + } + try: + payload["app_lifecycle_log"] = capture_app_lifecycle_log( + session, + a.host, + a.https_port, + cert_path, + app_name, + [token, api_key_value or ""], + a.timeout, + ) + except Exception as capture_exc: + detail = ( + f"{type(capture_exc).__name__}: {capture_exc}" + ).replace(token, "") + if api_key_value: + detail = detail.replace(api_key_value, "") + payload["app_lifecycle_log"] = { + "captured": False, + "capture_error": detail, + "source": "/var/log/app_lifecycle.log", + } + raise + if not isinstance(created, dict): + raise RuntimeError("CreateInstance did not return an object") + created_app_name = str(created.get("provider_id") or "") + if created_app_name != app_name: + raise RuntimeError( + f"provider-created App identity drifted: {created_app_name!r}" + ) + + config = session.call("app.config", [app_name]) + if not isinstance(config, dict): + raise RuntimeError("app.config did not return an object") + realized_sha = g2.canonical_json_sha256(config) + if realized_sha != expected_compose_sha: + raise RuntimeError("provider-created exact Compose read-back drifted") + + get_cp = invoke_provider( + provider_binary, config_path, api_key_value, cert_path, + "GetInstance", instance_id=app_name, + ) + got = parse_provider_json(get_cp, "GetInstance created", token) + if not isinstance(got, dict) or got.get("provider_id") != app_name: + raise RuntimeError("GetInstance did not read back created provider identity") + + list_cp = invoke_provider( + provider_binary, config_path, api_key_value, cert_path, + "ListInstances", + ) + listed = parse_provider_json(list_cp, "ListInstances created", token) + if not isinstance(listed, list): + raise RuntimeError("ListInstances did not return an array") + ids = sorted( + str(item.get("provider_id")) + for item in listed + if isinstance(item, dict) + ) + if ids != [app_name]: + raise RuntimeError(f"ListInstances created inventory drifted: {ids}") + + inactive_state = ensure_inactive(session, app_name, a.job_timeout) + + payload["provider_delete_exercised"] = True + delete_cp = invoke_provider( + provider_binary, config_path, api_key_value, cert_path, + "DeleteInstance", instance_id=app_name, + ) + if delete_cp.returncode != 0: + detail = (delete_cp.stderr or delete_cp.stdout or "")[-1200:].replace( + token, "" + ) + raise RuntimeError( + f"DeleteInstance failed rc={delete_cp.returncode}: {detail}" + ) + provider_delete_succeeded = True + + if query_app(session, app_name) is not None: + raise RuntimeError("provider-created App remains after DeleteInstance") + + list_after_cp = invoke_provider( + provider_binary, config_path, api_key_value, cert_path, + "ListInstances", + ) + listed_after = parse_provider_json( + list_after_cp, "ListInstances post-delete", token + ) + if listed_after != []: + raise RuntimeError( + f"provider inventory not empty after delete: {listed_after!r}" + ) + + payload["oracles"] = { + "verified_tls_transport": True, + "api_key_auth_transport": True, + "create_instance_returned_exact_owned_identity": True, + "exact_realized_compose_readback": True, + "get_created_instance": True, + "list_created_instance_only": True, + "inactive_transition_via_supported_middleware": True, + "provider_delete_after_inactive": True, + "post_delete_app_absent": True, + "post_delete_provider_inventory_empty": True, + "github_registration_not_exercised": True, + } + payload["retirement_inactive_state"] = inactive_state + payload["classification"] = "SUPPORTED" + payload["oracleSatisfied"] = True + payload["detail"] = ( + "exact packaged provider CreateInstance realized the source-derived fixed " + "runner App profile on real nested TrueNAS; exact Compose was read back, " + "Get/List reconciled the created identity, the experiment App was moved " + "inactive through supported middleware, provider DeleteInstance retired it, " + "and no GitHub credential, JIT registration, private workload, physical " + "TrueNAS, or capacity promotion was exercised" + ) + except FixtureEnvironmentError as exc: + payload["classification"] = "ENVIRONMENT_FAILURE" + payload["detail"] = str(exc).replace(token, "") + except Exception as exc: + payload["detail"] = str(exc).replace(token, "") + finally: + try: + session.connect() + if app_name and query_app(session, app_name) is not None: + fallback_cleanup_used = True + try: + g2.delete_app_if_present(session, app_name, a.job_timeout) + except Exception as exc: + cleanup_errors.append( + f"fallback delete {app_name}: {type(exc).__name__}: {exc}" + ) + if app_name: + try: + if query_app(session, app_name) is not None: + cleanup_errors.append( + f"provider-created App remains after cleanup: {app_name}" + ) + except Exception as exc: + cleanup_errors.append( + f"verify App absence {app_name}: {type(exc).__name__}: {exc}" + ) + + if api_key_id is not None: + try: + deleted = session.call("api_key.delete", [api_key_id]) + if deleted is not True: + cleanup_errors.append("api_key.delete did not return true") + except Exception as exc: + cleanup_errors.append( + f"api_key.delete: {type(exc).__name__}: {exc}" + ) + api_key_value = None + + if original_ui_cert_id is not None and imported_cert_id is not None: + try: + session.call( + "system.general.update", + [{"ui_certificate": original_ui_cert_id}], + ) + try: + session.call("system.general.ui_restart", [0]) + except Exception: + pass + except Exception as exc: + cleanup_errors.append( + f"restore ui certificate: {type(exc).__name__}: {exc}" + ) + session.close() + time.sleep(5) + try: + session.connect() + except Exception as exc: + cleanup_errors.append( + f"reconnect after ui restore: {type(exc).__name__}: {exc}" + ) + + if imported_cert_id is not None and session.ws is not None: + try: + delete_result = session.call( + "certificate.delete", [imported_cert_id, False] + ) + if isinstance(delete_result, int): + session.wait_job( + delete_result, "certificate.delete", a.job_timeout + ) + elif delete_result is not True: + cleanup_errors.append( + f"certificate.delete unexpected result {delete_result!r}" + ) + except Exception as exc: + cleanup_errors.append( + f"certificate.delete: {type(exc).__name__}: {exc}" + ) + except Exception as exc: + cleanup_errors.append( + f"cleanup session: {type(exc).__name__}: {exc}" + ) + finally: + session.close() + + payload["cleanup"] = { + "attempted": True, + "provider_delete_succeeded": provider_delete_succeeded, + "fallback_cleanup_used": fallback_cleanup_used, + "errors": cleanup_errors, + "zero_unintended_residue": not cleanup_errors, + "secret_values_recorded": False, + "synthetic_token_recorded": False, + } + if cleanup_errors or fallback_cleanup_used: + payload["oracleSatisfied"] = False + payload["classification"] = "ORACLE_FAILURE" + reasons = [] + if fallback_cleanup_used: + reasons.append("provider retirement required fallback direct cleanup") + reasons.extend(cleanup_errors) + payload["detail"] = payload.get("detail", "") + "; cleanup: " + "; ".join(reasons) + + payload["elapsed_seconds"] = round(time.time() - started, 3) + pathlib.Path(a.out).write_text( + json.dumps(payload, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + print(json.dumps(payload, indent=2, sort_keys=True)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/truenas_middleware_garm_provider_g4_probe.py b/scripts/truenas_middleware_garm_provider_g4_probe.py new file mode 100644 index 000000000..afafeb23a --- /dev/null +++ b/scripts/truenas_middleware_garm_provider_g4_probe.py @@ -0,0 +1,801 @@ +#!/usr/bin/env python3 +"""G4 oracle: exact provider capacity-two concurrency/reconnect/retirement on nested TrueNAS.""" +from __future__ import annotations + +import argparse +import concurrent.futures +import copy +import json +import os +import pathlib +import secrets +import subprocess +import tempfile +import time + +import truenas_middleware_garm_provider_g2_probe as g2 +import truenas_middleware_garm_provider_g3_probe as g3 + + +EXPECTED_VERSION = "TrueNAS-26.0.0-BETA.3" +EXPECTED_APPLIANCE = g3.EXPECTED_APPLIANCE +EXPECTED_PROVIDER_PRODUCT_SOURCE = g3.EXPECTED_PROVIDER_PRODUCT_SOURCE +EXPECTED_PROVIDER_BINARY_SHA256 = g3.EXPECTED_PROVIDER_BINARY_SHA256 +EXPECTED_FIXTURE_SCHEMA = "semper-supra.garm-provider-truenas-g4-capacity-two-fixture/1" +EXPECTED_CONTROLLER_ID = "g4-controller" +EXPECTED_POOL_ID = "g4-pool" +EXPECTED_RUNNER_IMAGE = ( + "ghcr.io/actions/actions-runner:2.336.0@" + "sha256:0cfdcc701ce933c6d243c6b0b2da767366dc9f2e99961d4c3754b0b78084cdda" +) +EXPECTED_CPU = 4 +EXPECTED_MEMORY_BYTES = 8 * 1024 * 1024 * 1024 +IX_APPS_PATH = "/mnt/.ix-apps" +SLOTS = ("alpha", "beta") +EXPECTED_SUBSTITUTIONS = [ + "runners[0].bootstrap_template.callback-url", + "runners[0].bootstrap_template.metadata-url", + "runners[0].bootstrap_template.instance-token", + "runners[0].expected_compose_template.services.runner.environment.GARM_CALLBACK_URL", + "runners[0].expected_compose_template.services.runner.environment.GARM_METADATA_URL", + "runners[0].expected_compose_template.services.runner.environment.GARM_INSTANCE_TOKEN", + "runners[1].bootstrap_template.callback-url", + "runners[1].bootstrap_template.metadata-url", + "runners[1].bootstrap_template.instance-token", + "runners[1].expected_compose_template.services.runner.environment.GARM_CALLBACK_URL", + "runners[1].expected_compose_template.services.runner.environment.GARM_METADATA_URL", + "runners[1].expected_compose_template.services.runner.environment.GARM_INSTANCE_TOKEN", +] + + +def fixture_urls(slot: str) -> tuple[str, str]: + callback = f"https://httpbin.org/anything/sempersupra-g4/{slot}/status" + metadata = ( + "https://httpbin.org/drip?duration=1&numbytes=1&code=200&delay=60&path=" + + slot + ) + return callback, metadata + + +def load_bundle(directory: pathlib.Path, expected_producer: str) -> dict: + path = directory / "garm-provider-g4-fixture.json" + bundle = json.loads(path.read_text(encoding="utf-8")) + if bundle.get("schema") != EXPECTED_FIXTURE_SCHEMA: + raise RuntimeError("unexpected G4 fixture schema") + if bundle.get("provider_product_source") != EXPECTED_PROVIDER_PRODUCT_SOURCE: + raise RuntimeError("G4 fixture product source drifted") + if bundle.get("producer_source") != expected_producer: + raise RuntimeError("G4 fixture producer source drifted") + if bundle.get("controller_id") != EXPECTED_CONTROLLER_ID: + raise RuntimeError("G4 fixture controller identity drifted") + if bundle.get("pool_id") != EXPECTED_POOL_ID: + raise RuntimeError("G4 fixture pool identity drifted") + if bundle.get("run_local_substitutions") != EXPECTED_SUBSTITUTIONS: + raise RuntimeError("G4 run-local substitution allowlist drifted") + runners = bundle.get("runners") + if not isinstance(runners, list) or len(runners) != 2: + raise RuntimeError("G4 fixture must contain exactly two runners") + if [r.get("slot") for r in runners if isinstance(r, dict)] != list(SLOTS): + raise RuntimeError("G4 fixture slot identities drifted") + names = [r.get("expected_app_name") for r in runners] + if not all(isinstance(name, str) and name for name in names) or len(set(names)) != 2: + raise RuntimeError("G4 fixture App identities are absent or aliased") + runner = bundle.get("runner") + if not isinstance(runner, dict): + raise RuntimeError("G4 fixture runner contract absent") + if runner.get("image") != EXPECTED_RUNNER_IMAGE: + raise RuntimeError("G4 runner image drifted") + if runner.get("cpu") != EXPECTED_CPU or runner.get("memory_bytes") != EXPECTED_MEMORY_BYTES: + raise RuntimeError("G4 fixed resource profile drifted") + if bundle.get("retirement_orders") != [["alpha", "beta"], ["beta", "alpha"]]: + raise RuntimeError("G4 retirement-order contract drifted") + return bundle + + +def lower_runner(runner: dict, callback: str, metadata: str, token: str) -> tuple[dict, dict]: + bootstrap = copy.deepcopy(runner["bootstrap_template"]) + expected = copy.deepcopy(runner["expected_compose_template"]) + bootstrap["callback-url"] = callback + bootstrap["metadata-url"] = metadata + bootstrap["instance-token"] = token + env = expected["services"]["runner"]["environment"] + env["GARM_CALLBACK_URL"] = callback + env["GARM_METADATA_URL"] = metadata + env["GARM_INSTANCE_TOKEN"] = token + return bootstrap, expected + + +def provider_env( + config_path: pathlib.Path, + api_key: str, + ca_path: pathlib.Path, + command: str, + instance_id: str | None = None, +) -> dict[str, str]: + env = os.environ.copy() + env.update({ + "GARM_INTERFACE_VERSION": "v0.1.1", + "GARM_COMMAND": command, + "GARM_CONTROLLER_ID": EXPECTED_CONTROLLER_ID, + "GARM_POOL_ID": EXPECTED_POOL_ID, + "GARM_PROVIDER_CONFIG_FILE": str(config_path), + "TRUENAS_API_KEY": api_key, + "SSL_CERT_FILE": str(ca_path), + }) + if instance_id: + env["GARM_INSTANCE_ID"] = instance_id + else: + env.pop("GARM_INSTANCE_ID", None) + return env + + +def invoke_provider( + provider_binary: pathlib.Path, + config_path: pathlib.Path, + api_key: str, + ca_path: pathlib.Path, + command: str, + *, + instance_id: str | None = None, + stdin_object: dict | None = None, +) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [str(provider_binary)], + env=provider_env(config_path, api_key, ca_path, command, instance_id), + input=(json.dumps(stdin_object) if stdin_object is not None else None), + text=True, + capture_output=True, + check=False, + timeout=180, + ) + + +def scrub_text(text: str, sensitive: list[str]) -> str: + out = text + for value in sensitive: + if value: + out = out.replace(value, "") + return out + + +def parse_provider_json( + cp: subprocess.CompletedProcess[str], + label: str, + sensitive: list[str], +): + if cp.returncode != 0: + detail = scrub_text((cp.stderr or cp.stdout or "")[-1600:], sensitive) + raise RuntimeError(f"{label} failed rc={cp.returncode}: {detail}") + text = cp.stdout.strip() + if not text: + raise RuntimeError(f"{label} returned empty stdout") + try: + return json.loads(text) + except json.JSONDecodeError as exc: + raise RuntimeError(f"{label} stdout was not JSON") from exc + + +def list_ids( + provider_binary: pathlib.Path, + config_path: pathlib.Path, + api_key: str, + ca_path: pathlib.Path, + sensitive: list[str], +) -> list[str]: + cp = invoke_provider( + provider_binary, config_path, api_key, ca_path, "ListInstances" + ) + listed = parse_provider_json(cp, "ListInstances", sensitive) + if not isinstance(listed, list): + raise RuntimeError("ListInstances did not return an array") + return sorted( + str(item.get("provider_id")) + for item in listed + if isinstance(item, dict) + ) + + +def get_instance( + provider_binary: pathlib.Path, + config_path: pathlib.Path, + api_key: str, + ca_path: pathlib.Path, + app_name: str, + sensitive: list[str], +) -> dict: + cp = invoke_provider( + provider_binary, + config_path, + api_key, + ca_path, + "GetInstance", + instance_id=app_name, + ) + got = parse_provider_json(cp, f"GetInstance {app_name}", sensitive) + if not isinstance(got, dict) or got.get("provider_id") != app_name: + raise RuntimeError(f"GetInstance identity drifted for {app_name}") + return got + + +def delete_instance( + provider_binary: pathlib.Path, + config_path: pathlib.Path, + api_key: str, + ca_path: pathlib.Path, + app_name: str, + sensitive: list[str], +) -> None: + cp = invoke_provider( + provider_binary, + config_path, + api_key, + ca_path, + "DeleteInstance", + instance_id=app_name, + ) + if cp.returncode != 0: + detail = scrub_text((cp.stderr or cp.stdout or "")[-1600:], sensitive) + raise RuntimeError(f"DeleteInstance {app_name} failed rc={cp.returncode}: {detail}") + + +def require_active_delete_refusal( + provider_binary: pathlib.Path, + config_path: pathlib.Path, + api_key: str, + ca_path: pathlib.Path, + app_name: str, + sensitive: list[str], + session: g2.AdminSession, +) -> None: + cp = invoke_provider( + provider_binary, + config_path, + api_key, + ca_path, + "DeleteInstance", + instance_id=app_name, + ) + if cp.returncode == 0: + raise RuntimeError("provider unexpectedly deleted an active runner") + if g3.query_app(session, app_name) is None: + raise RuntimeError("active-delete refusal lost the target App") + + +def statfs_available(session: g2.AdminSession) -> int: + data = session.call("filesystem.statfs", [IX_APPS_PATH]) + if not isinstance(data, dict) or not isinstance(data.get("avail_bytes"), int): + raise RuntimeError("filesystem.statfs did not return avail_bytes") + return int(data["avail_bytes"]) + + +def verify_realized_pair( + session: g2.AdminSession, + provider_binary: pathlib.Path, + config_path: pathlib.Path, + api_key: str, + cert_path: pathlib.Path, + lowered: dict[str, dict], + sensitive: list[str], +) -> None: + expected_ids = sorted(v["app_name"] for v in lowered.values()) + for slot in SLOTS: + item = lowered[slot] + config = session.call("app.config", [item["app_name"]]) + if not isinstance(config, dict): + raise RuntimeError(f"app.config did not return an object for {slot}") + realized_sha = g2.canonical_json_sha256(config) + expected_sha = g2.canonical_json_sha256(item["expected_compose"]) + if realized_sha != expected_sha: + raise RuntimeError(f"exact Compose read-back drifted for {slot}") + get_instance( + provider_binary, + config_path, + api_key, + cert_path, + item["app_name"], + sensitive, + ) + ids = list_ids( + provider_binary, config_path, api_key, cert_path, sensitive + ) + if ids != expected_ids: + raise RuntimeError(f"provider pair inventory drifted: {ids} != {expected_ids}") + + +def create_pair( + provider_binary: pathlib.Path, + config_path: pathlib.Path, + api_key: str, + cert_path: pathlib.Path, + lowered: dict[str, dict], + sensitive: list[str], +) -> None: + def create(slot: str): + item = lowered[slot] + cp = invoke_provider( + provider_binary, + config_path, + api_key, + cert_path, + "CreateInstance", + stdin_object=item["bootstrap"], + ) + return slot, parse_provider_json(cp, f"CreateInstance {slot}", sensitive) + + with concurrent.futures.ThreadPoolExecutor(max_workers=2) as pool: + results = list(pool.map(create, SLOTS)) + for slot, created in results: + if not isinstance(created, dict): + raise RuntimeError(f"CreateInstance {slot} did not return an object") + if created.get("provider_id") != lowered[slot]["app_name"]: + raise RuntimeError(f"CreateInstance identity drifted for {slot}") + + +def retire_order( + order: tuple[str, str], + session: g2.AdminSession, + provider_binary: pathlib.Path, + config_path: pathlib.Path, + api_key: str, + cert_path: pathlib.Path, + lowered: dict[str, dict], + sensitive: list[str], +) -> dict: + first, second = order + first_name = lowered[first]["app_name"] + second_name = lowered[second]["app_name"] + + first_state = g3.ensure_inactive(session, first_name, 300.0) + delete_instance( + provider_binary, config_path, api_key, cert_path, first_name, sensitive + ) + if g3.query_app(session, first_name) is not None: + raise RuntimeError(f"{first} App remains after provider DeleteInstance") + peer = g3.query_app(session, second_name) + if peer is None: + raise RuntimeError(f"retiring {first} corrupted peer {second}") + peer_state = str(peer.get("state") or "") + ids = list_ids( + provider_binary, config_path, api_key, cert_path, sensitive + ) + if ids != [second_name]: + raise RuntimeError(f"peer inventory drifted after retiring {first}: {ids}") + + second_state = g3.ensure_inactive(session, second_name, 300.0) + delete_instance( + provider_binary, config_path, api_key, cert_path, second_name, sensitive + ) + if g3.query_app(session, second_name) is not None: + raise RuntimeError(f"{second} App remains after provider DeleteInstance") + if list_ids(provider_binary, config_path, api_key, cert_path, sensitive) != []: + raise RuntimeError("provider inventory not empty after pair retirement") + return { + "order": [first, second], + "first_inactive_state": first_state, + "peer_state_after_first_retirement": peer_state, + "second_inactive_state": second_state, + } + + +def main() -> int: + p = argparse.ArgumentParser() + p.add_argument("--host", default="127.0.0.1") + p.add_argument("--http-port", type=int, required=True) + p.add_argument("--https-port", type=int, required=True) + p.add_argument("--password-file", required=True) + p.add_argument("--fixture-dir", type=pathlib.Path, required=True) + p.add_argument("--fixture-producer-commit", required=True) + p.add_argument("--out", required=True) + p.add_argument("--timeout", type=float, default=8.0) + p.add_argument("--job-timeout", type=float, default=300.0) + a = p.parse_args() + + started = time.time() + payload = { + "schema": "truenas-garm-provider-g4/v1", + "classification": "ORACLE_FAILURE", + "oracleSatisfied": False, + "expected_version": EXPECTED_VERSION, + "provider_product_source": EXPECTED_PROVIDER_PRODUCT_SOURCE, + "provider_binary_sha256": EXPECTED_PROVIDER_BINARY_SHA256, + "appliance": EXPECTED_APPLIANCE, + "controller_id": EXPECTED_CONTROLLER_ID, + "pool_id": EXPECTED_POOL_ID, + "runner_count": 2, + "github_credentials_present": False, + "github_jit_registration_exercised": False, + "private_repository_execution": False, + "physical_truenas_mutation": False, + "capacity_promotion": False, + "secret_values_recorded": False, + "synthetic_tokens_recorded": False, + } + + if g2.EXPECTED_APPLIANCE != EXPECTED_APPLIANCE: + raise RuntimeError("G2 helper appliance identity drifted") + if g2.EXPECTED_PROVIDER_BINARY_SHA256 != EXPECTED_PROVIDER_BINARY_SHA256: + raise RuntimeError("G2 helper provider binary identity drifted") + + password = pathlib.Path(a.password_file).read_text(encoding="utf-8").strip() + session = g2.AdminSession(a.host, a.http_port, password, a.timeout) + api_key_id: int | None = None + api_key_value: str | None = None + imported_cert_id: int | None = None + original_ui_cert_id: int | None = None + app_names: list[str] = [] + fallback_cleanup_used = False + cleanup_errors: list[str] = [] + sensitive: list[str] = [] + + with tempfile.TemporaryDirectory(prefix="garm-provider-g4-") as td: + root = pathlib.Path(td) + try: + bundle = load_bundle(a.fixture_dir, a.fixture_producer_commit) + payload["fixture_producer_source"] = bundle["producer_source"] + payload["fixture_bundle_sha256"] = g2.sha256_file( + a.fixture_dir / "garm-provider-g4-fixture.json" + ) + payload["source_oracles"] = bundle.get("source_oracles", {}) + payload["retirement_orders"] = bundle["retirement_orders"] + payload["configured_resource_envelope"] = { + "per_runner_cpu_limit": EXPECTED_CPU, + "per_runner_memory_bytes": EXPECTED_MEMORY_BYTES, + "runner_count": 2, + "aggregate_cpu_limit_units": EXPECTED_CPU * 2, + "aggregate_memory_limit_bytes": EXPECTED_MEMORY_BYTES * 2, + "limits_are_not_reservations": True, + } + + lowered: dict[str, dict] = {} + for runner in bundle["runners"]: + slot = runner["slot"] + callback, metadata = fixture_urls(slot) + g3.preflight_public_fixture(callback, metadata, a.timeout) + token = f"g4-{slot}-{secrets.token_hex(12)}" + sensitive.append(token) + bootstrap, expected = lower_runner( + runner, callback, metadata, token + ) + lowered[slot] = { + "app_name": runner["expected_app_name"], + "callback_url": callback, + "metadata_url": metadata, + "bootstrap": bootstrap, + "expected_compose": expected, + } + app_names.append(runner["expected_app_name"]) + payload["synthetic_fixture_preflight"] = True + payload["expected_app_names"] = sorted(app_names) + payload["callback_metadata_isolation"] = { + slot: { + "callback_url": lowered[slot]["callback_url"], + "metadata_url": lowered[slot]["metadata_url"], + } + for slot in SLOTS + } + + provider_binary = g2.extract_provider_binary(root) + payload["provider_binary_from_appliance"] = True + + cert_path, key_path = g2.generate_tls(root) + payload["transport"] = { + "scheme": "wss", + "host": a.host, + "port": a.https_port, + "insecure_skip_verify": False, + "certificate_sha256": g2.sha256_file(cert_path), + "private_key_recorded": False, + "api_key_recorded": False, + } + + session.connect() + version = session.call("system.version", []) + payload["system_version"] = version + if version != EXPECTED_VERSION: + raise RuntimeError(f"target version drifted: {version}") + + general = session.call("system.general.config", []) + original_ui_cert_id = g2.ui_cert_id(general.get("ui_certificate")) + api_key = session.call("api_key.create", [{ + "name": f"g4-provider-{secrets.token_hex(5)}", + "username": "truenas_admin", + }]) + if not isinstance(api_key, dict) or not isinstance(api_key.get("id"), int): + raise RuntimeError("api_key.create did not return an id") + api_key_id = int(api_key["id"]) + api_key_value = api_key.get("key") + if not isinstance(api_key_value, str) or not api_key_value: + raise RuntimeError("api_key.create did not return a key") + sensitive.append(api_key_value) + + create_cert_id = session.call("certificate.create", [{ + "name": f"g4_provider_{secrets.token_hex(4)}", + "create_type": "CERTIFICATE_CREATE_IMPORTED", + "add_to_trusted_store": False, + "certificate": cert_path.read_text(encoding="utf-8"), + "privatekey": key_path.read_text(encoding="utf-8"), + }]) + if not isinstance(create_cert_id, int): + raise RuntimeError("certificate.create did not return a job id") + cert_job = session.wait_job( + create_cert_id, "certificate.create", a.job_timeout + ) + cert_result = cert_job.get("result") + if not isinstance(cert_result, dict) or not isinstance(cert_result.get("id"), int): + raise RuntimeError("certificate.create job did not return certificate id") + imported_cert_id = int(cert_result["id"]) + + session.call("system.general.update", [{"ui_certificate": imported_cert_id}]) + try: + session.call("system.general.ui_restart", [0]) + except Exception: + pass + session.close() + g2.wait_verified_https(a.host, a.https_port, cert_path) + + provider_config = { + "mode": "truenas", + "truenas": { + "host": a.host, + "username": "truenas_admin", + "api_key_env": "TRUENAS_API_KEY", + "port": a.https_port, + "insecure_skip_verify": False, + "callback_host_gateway": False, + }, + } + config_path = root / "provider.json" + config_path.write_text( + json.dumps(provider_config, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + config_path.chmod(0o600) + + session.connect() + storage_before = statfs_available(session) + + cycle_receipts = [] + for cycle, order in enumerate((("alpha", "beta"), ("beta", "alpha")), start=1): + cycle_tokens = [] + cycle_lowered = {} + for slot in SLOTS: + callback, metadata = fixture_urls(slot) + token = f"g4-cycle{cycle}-{slot}-{secrets.token_hex(12)}" + sensitive.append(token) + cycle_tokens.append(token) + source_runner = next( + item for item in bundle["runners"] if item["slot"] == slot + ) + bootstrap, expected = lower_runner( + source_runner, callback, metadata, token + ) + cycle_lowered[slot] = { + "app_name": source_runner["expected_app_name"], + "bootstrap": bootstrap, + "expected_compose": expected, + } + + create_pair( + provider_binary, + config_path, + api_key_value, + cert_path, + cycle_lowered, + sensitive, + ) + verify_realized_pair( + session, + provider_binary, + config_path, + api_key_value, + cert_path, + cycle_lowered, + sensitive, + ) + storage_two_apps = statfs_available(session) + + # Each command is a fresh provider process. This List/Get pass is + # the explicit reconnect/adoption observation with both Apps present. + reconnect_ids = list_ids( + provider_binary, + config_path, + api_key_value, + cert_path, + sensitive, + ) + if reconnect_ids != sorted(app_names): + raise RuntimeError( + f"fresh-process reconnect inventory drifted: {reconnect_ids}" + ) + for name in app_names: + get_instance( + provider_binary, + config_path, + api_key_value, + cert_path, + name, + sensitive, + ) + + if cycle == 1: + require_active_delete_refusal( + provider_binary, + config_path, + api_key_value, + cert_path, + cycle_lowered["alpha"]["app_name"], + sensitive, + session, + ) + if g3.query_app(session, cycle_lowered["beta"]["app_name"]) is None: + raise RuntimeError("active-delete refusal corrupted peer App") + + retirement = retire_order( + order, + session, + provider_binary, + config_path, + api_key_value, + cert_path, + cycle_lowered, + sensitive, + ) + storage_after = statfs_available(session) + cycle_receipts.append({ + "cycle": cycle, + "retirement": retirement, + "storage": { + "ix_apps_avail_bytes_before_pair": storage_before, + "ix_apps_avail_bytes_with_two_apps": storage_two_apps, + "ix_apps_avail_bytes_after_retirement": storage_after, + "consumed_bytes_at_two_apps": max( + 0, storage_before - storage_two_apps + ), + "residual_consumed_bytes_after_retirement": max( + 0, storage_before - storage_after + ), + }, + }) + storage_before = storage_after + + payload["cycles"] = cycle_receipts + payload["oracles"] = { + "verified_tls_transport": True, + "api_key_auth_transport": True, + "two_concurrent_create_calls_returned_distinct_owned_identities": True, + "exact_realized_compose_readback_both": True, + "fresh_process_get_list_exact_pair": True, + "callback_metadata_config_isolated": True, + "active_delete_refused_without_peer_corruption": True, + "retirement_alpha_then_beta": True, + "retirement_beta_then_alpha": True, + "post_retirement_apps_absent": True, + "post_retirement_provider_inventory_empty": True, + "storage_delta_measured": True, + "github_registration_not_exercised": True, + } + payload["classification"] = "SUPPORTED" + payload["oracleSatisfied"] = True + payload["detail"] = ( + "exact packaged provider realized two concurrent source-derived runner Apps " + "on nested TrueNAS; both exact Compose profiles, isolated bootstrap configuration, " + "fresh-process Get/List adoption, active-delete refusal, both retirement orders, " + "empty final inventory, App absence, and bounded ix-apps storage deltas were " + "verified without GitHub/JIT/private/physical/capacity authority" + ) + except g3.FixtureEnvironmentError as exc: + payload["classification"] = "ENVIRONMENT_FAILURE" + payload["detail"] = scrub_text(str(exc), sensitive) + except Exception as exc: + payload["detail"] = scrub_text( + f"{type(exc).__name__}: {exc}", sensitive + ) + finally: + try: + session.connect() + for name in sorted(set(app_names)): + if g3.query_app(session, name) is not None: + fallback_cleanup_used = True + try: + g2.delete_app_if_present(session, name, a.job_timeout) + except Exception as exc: + cleanup_errors.append( + f"fallback delete {name}: {type(exc).__name__}: {exc}" + ) + for name in sorted(set(app_names)): + try: + if g3.query_app(session, name) is not None: + cleanup_errors.append( + f"provider-created App remains after cleanup: {name}" + ) + except Exception as exc: + cleanup_errors.append( + f"verify App absence {name}: {type(exc).__name__}: {exc}" + ) + + if api_key_id is not None: + try: + deleted = session.call("api_key.delete", [api_key_id]) + if deleted is not True: + cleanup_errors.append("api_key.delete did not return true") + except Exception as exc: + cleanup_errors.append( + f"api_key.delete: {type(exc).__name__}: {exc}" + ) + api_key_value = None + + if original_ui_cert_id is not None and imported_cert_id is not None: + try: + session.call( + "system.general.update", + [{"ui_certificate": original_ui_cert_id}], + ) + try: + session.call("system.general.ui_restart", [0]) + except Exception: + pass + except Exception as exc: + cleanup_errors.append( + f"restore ui certificate: {type(exc).__name__}: {exc}" + ) + session.close() + time.sleep(5) + try: + session.connect() + except Exception as exc: + cleanup_errors.append( + f"reconnect after ui restore: {type(exc).__name__}: {exc}" + ) + + if imported_cert_id is not None and session.ws is not None: + try: + delete_result = session.call( + "certificate.delete", [imported_cert_id, False] + ) + if isinstance(delete_result, int): + session.wait_job( + delete_result, "certificate.delete", a.job_timeout + ) + elif delete_result is not True: + cleanup_errors.append( + f"certificate.delete unexpected result {delete_result!r}" + ) + except Exception as exc: + cleanup_errors.append( + f"certificate.delete: {type(exc).__name__}: {exc}" + ) + except Exception as exc: + cleanup_errors.append( + f"cleanup session: {type(exc).__name__}: {exc}" + ) + finally: + session.close() + + payload["cleanup"] = { + "attempted": True, + "fallback_cleanup_used": fallback_cleanup_used, + "errors": cleanup_errors, + "zero_unintended_residue": not cleanup_errors, + "secret_values_recorded": False, + "synthetic_tokens_recorded": False, + } + if cleanup_errors or fallback_cleanup_used: + payload["oracleSatisfied"] = False + payload["classification"] = "ORACLE_FAILURE" + reasons = [] + if fallback_cleanup_used: + reasons.append("provider retirement required fallback direct cleanup") + reasons.extend(cleanup_errors) + payload["detail"] = payload.get("detail", "") + "; cleanup: " + "; ".join(reasons) + + payload["elapsed_seconds"] = round(time.time() - started, 3) + pathlib.Path(a.out).write_text( + json.dumps(payload, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + print(json.dumps(payload, indent=2, sort_keys=True)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/truenas_middleware_garm_provider_g5_probe.py b/scripts/truenas_middleware_garm_provider_g5_probe.py new file mode 100644 index 000000000..a4f13cb49 --- /dev/null +++ b/scripts/truenas_middleware_garm_provider_g5_probe.py @@ -0,0 +1,194 @@ +#!/usr/bin/env python3 +"""G5 oracle: bind one exact TrueNAS source-matrix row to the proven provider lifecycle oracle.""" +from __future__ import annotations + +import argparse +import json +import pathlib +import subprocess +import sys +import tempfile +import time + +import truenas_middleware_garm_provider_g3_probe as g3 + + +EXPECTED_MATRIX_SCHEMA = "semper-supra.garm-provider-truenas-g5-version-matrix/1" +EXPECTED_PROVIDER_PRODUCT_SOURCE = g3.EXPECTED_PROVIDER_PRODUCT_SOURCE + + +def load_matrix(directory: pathlib.Path, expected_producer: str) -> dict: + path = directory / "garm-provider-truenas-g5-version-matrix.json" + matrix = json.loads(path.read_text(encoding="utf-8")) + if matrix.get("schema") != EXPECTED_MATRIX_SCHEMA: + raise RuntimeError("unexpected G5 version-matrix schema") + if matrix.get("provider_product_source") != EXPECTED_PROVIDER_PRODUCT_SOURCE: + raise RuntimeError("G5 matrix provider product source drifted") + if matrix.get("producer_source") != expected_producer: + raise RuntimeError("G5 matrix producer source drifted") + if matrix.get("runtime_inheritance_allowed") is not False: + raise RuntimeError("G5 matrix must prohibit runtime support inheritance") + oracles = matrix.get("source_oracles") + if not isinstance(oracles, dict) or not oracles or not all(value is True for value in oracles.values()): + raise RuntimeError("G5 matrix source oracles are not all true") + targets = matrix.get("targets") + if not isinstance(targets, list) or not targets: + raise RuntimeError("G5 matrix has no targets") + return matrix + + +def select_row(matrix: dict, target_version: str, expected_middleware_commit: str) -> dict: + matches = [ + row for row in matrix["targets"] + if isinstance(row, dict) and row.get("version") == target_version + ] + if len(matches) != 1: + raise RuntimeError(f"G5 matrix target cardinality drifted for {target_version}: {len(matches)}") + row = matches[0] + expected_system_version = f"TrueNAS-{target_version}" + if row.get("system_version") != expected_system_version: + raise RuntimeError( + f"G5 matrix system_version drifted: {row.get('system_version')!r} != {expected_system_version!r}" + ) + if row.get("middleware_commit") != expected_middleware_commit: + raise RuntimeError( + "G5 matrix middleware commit drifted: " + f"{row.get('middleware_commit')!r} != {expected_middleware_commit!r}" + ) + blobs = row.get("source_blobs") + if not isinstance(blobs, dict) or len(blobs) < 7: + raise RuntimeError("G5 matrix row has incomplete source blob evidence") + if not isinstance(row.get("source_equivalence_group"), str) or not row["source_equivalence_group"]: + raise RuntimeError("G5 matrix row has no source equivalence group") + return row + + +def run_underlying_g3( + args: argparse.Namespace, + expected_system_version: str, + out: pathlib.Path, +) -> subprocess.CompletedProcess[str]: + script = pathlib.Path(__file__).with_name("truenas_middleware_garm_provider_g3_probe.py") + cmd = [ + sys.executable, + str(script), + "--host", args.host, + "--http-port", str(args.http_port), + "--https-port", str(args.https_port), + "--password-file", args.password_file, + "--fixture-dir", str(args.fixture_dir), + "--fixture-producer-commit", args.fixture_producer_commit, + "--expected-version", expected_system_version, + "--out", str(out), + "--timeout", str(args.timeout), + "--job-timeout", str(args.job_timeout), + ] + return subprocess.run(cmd, text=True, capture_output=True, check=False, timeout=900) + + +def main() -> int: + p = argparse.ArgumentParser() + p.add_argument("--host", default="127.0.0.1") + p.add_argument("--http-port", type=int, required=True) + p.add_argument("--https-port", type=int, required=True) + p.add_argument("--password-file", required=True) + p.add_argument("--matrix-dir", type=pathlib.Path, required=True) + p.add_argument("--matrix-producer-commit", required=True) + p.add_argument("--fixture-dir", type=pathlib.Path, required=True) + p.add_argument("--fixture-producer-commit", required=True) + p.add_argument("--target-version", required=True) + p.add_argument("--expected-middleware-commit", required=True) + p.add_argument("--out", required=True) + p.add_argument("--timeout", type=float, default=8.0) + p.add_argument("--job-timeout", type=float, default=300.0) + a = p.parse_args() + + started = time.time() + payload = { + "schema": "truenas-garm-provider-g5-version-row/v1", + "classification": "ORACLE_FAILURE", + "oracleSatisfied": False, + "target_version": a.target_version, + "matrix_producer_source": a.matrix_producer_commit, + "fixture_producer_source": a.fixture_producer_commit, + "provider_product_source": EXPECTED_PROVIDER_PRODUCT_SOURCE, + "runtime_inheritance_allowed": False, + "github_credentials_present": False, + "github_jit_registration_exercised": False, + "private_repository_execution": False, + "physical_truenas_mutation": False, + "capacity_promotion": False, + } + + try: + matrix = load_matrix(a.matrix_dir, a.matrix_producer_commit) + row = select_row(matrix, a.target_version, a.expected_middleware_commit) + payload["matrix_row"] = row + payload["matrix_source_oracles"] = matrix["source_oracles"] + payload["source_equivalence_group"] = row["source_equivalence_group"] + payload["expected_system_version"] = row["system_version"] + + with tempfile.TemporaryDirectory(prefix="garm-provider-g5-") as td: + underlying_path = pathlib.Path(td) / "provider-lifecycle.json" + cp = run_underlying_g3(a, row["system_version"], underlying_path) + if not underlying_path.exists(): + detail = (cp.stderr or cp.stdout or "")[-1600:] + raise RuntimeError( + f"underlying provider lifecycle emitted no receipt rc={cp.returncode}: {detail}" + ) + underlying = json.loads(underlying_path.read_text(encoding="utf-8")) + + payload["underlying_provider_lifecycle"] = underlying + if underlying.get("provider_product_source") != EXPECTED_PROVIDER_PRODUCT_SOURCE: + raise RuntimeError("underlying provider product source drifted") + if underlying.get("provider_binary_sha256") != g3.EXPECTED_PROVIDER_BINARY_SHA256: + raise RuntimeError("underlying provider binary identity drifted") + if underlying.get("system_version") != row["system_version"]: + raise RuntimeError( + f"runtime system version drifted: {underlying.get('system_version')!r}" + ) + cleanup = underlying.get("cleanup") + if not isinstance(cleanup, dict): + raise RuntimeError("underlying lifecycle has no cleanup receipt") + if cleanup.get("fallback_cleanup_used") is not False: + raise RuntimeError("underlying lifecycle required fallback cleanup") + if cleanup.get("zero_unintended_residue") is not True: + raise RuntimeError("underlying lifecycle did not prove zero unintended residue") + if underlying.get("oracleSatisfied") is not True or underlying.get("classification") != "SUPPORTED": + raise RuntimeError( + "underlying exact-version provider lifecycle did not satisfy its oracle: " + + str(underlying.get("detail") or underlying.get("classification")) + ) + + payload["oracles"] = { + "exact_source_matrix_row_bound": True, + "exact_middleware_commit_bound": True, + "exact_runtime_system_version": True, + "exact_provider_identity": True, + "provider_lifecycle_supported": True, + "zero_unintended_residue": True, + "runtime_inheritance_prohibited": True, + "github_registration_not_exercised": True, + } + payload["classification"] = "SUPPORTED" + payload["oracleSatisfied"] = True + payload["detail"] = ( + f"exact TrueNAS {a.target_version} source-matrix row matched the RDTE target and " + "the fixed packaged provider completed CreateInstance, exact Compose read-back, " + "Get/List, supported inactive transition, DeleteInstance, App absence, empty " + "inventory, and zero-residue cleanup without GitHub/JIT/private/physical/capacity authority" + ) + except Exception as exc: + payload["detail"] = f"{type(exc).__name__}: {exc}" + + payload["elapsed_seconds"] = round(time.time() - started, 3) + pathlib.Path(a.out).write_text( + json.dumps(payload, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + print(json.dumps(payload, indent=2, sort_keys=True)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/truenas_middleware_garm_t6_probe.py b/scripts/truenas_middleware_garm_t6_probe.py new file mode 100644 index 000000000..c0d54202c --- /dev/null +++ b/scripts/truenas_middleware_garm_t6_probe.py @@ -0,0 +1,490 @@ +#!/usr/bin/env python3 +"""Bounded GARM controller Foundry T6 oracle on disposable TrueNAS.""" +from __future__ import annotations + +import argparse +import copy +import hashlib +import http.client +import json +import pathlib +import secrets +import ssl +import string +import subprocess +import tempfile +import time + +from truenas_middleware_ddp_probe import WebSocket, ddp_call, wait_for + +SCHEMA = "semper-supra.garm-truenas-t6-control/1" +EXPECTED_VERSION = "TrueNAS-26.0.0-BETA.3" +EXPECTED_APP_NAME = "rdte-t6-garm" +EXPECTED_IMAGE = "ghcr.io/sempersupra/garm-appliance@sha256:1af67841ddd4589e3798dcda8be49230565c849d07ab57fd05899432dcdabca9" +EXPECTED_CONTROLLER_SOURCE = "8a72cb23deacc712786945ac5e7ee63f8b4976d6" +EXPECTED_PROVIDER_SOURCE = "14535745dc3aa3c0b5466da7c704bca4d23dcec5" +EXPECTED_FIXTURE_DATASET = "rdtepool/garm-t6" +EXPECTED_FIXTURE_ROOT = "/mnt/rdtepool/garm-t6" +EXPECTED_CONFIG_DIR = EXPECTED_FIXTURE_ROOT + "/config" +EXPECTED_CONFIG_PATH = EXPECTED_CONFIG_DIR + "/config.toml" +EXPECTED_DB_PATH = EXPECTED_CONFIG_DIR + "/garm.db" +BOOTSTRAP_CONFIG = "garm-initial-config" +TLS_CERT_CONFIG = "garm-tls-certificate" +TLS_KEY_CONFIG = "garm-tls-private-key" +BOOTSTRAP_PLACEHOLDER = "__EPHEMERAL_NESTED_GARM_CONFIG__" +TLS_PREFIX = "__EPHEMERAL_NESTED_TLS__:" + + +def canonical_sha256(value: object) -> str: + raw = json.dumps(value, sort_keys=True, separators=(",", ":")).encode("utf-8") + return hashlib.sha256(raw).hexdigest() + + +def load_json(path: pathlib.Path) -> dict: + value = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(value, dict): + raise RuntimeError(f"{path} must contain an object") + return value + + +def normalize_secret_configs(compose: dict) -> dict: + normalized = copy.deepcopy(compose) + configs = normalized.get("configs") or {} + if BOOTSTRAP_CONFIG not in configs or TLS_CERT_CONFIG not in configs or TLS_KEY_CONFIG not in configs: + raise RuntimeError("GARM secret-bearing config inventory drifted") + configs[BOOTSTRAP_CONFIG]["content"] = BOOTSTRAP_PLACEHOLDER + configs[TLS_CERT_CONFIG]["content"] = TLS_PREFIX + TLS_CERT_CONFIG + configs[TLS_KEY_CONFIG]["content"] = TLS_PREFIX + TLS_KEY_CONFIG + return normalized + + +def load_control(directory: pathlib.Path, foundry_commit: str) -> tuple[dict, dict]: + control = load_json(directory / "control.json") + compose = load_json(directory / "compose.json") + deployment = load_json(directory / "deployment.json") + + if control.get("schema") != SCHEMA: + raise RuntimeError("unexpected GARM T6 control schema") + if control.get("foundry_ref") != foundry_commit: + raise RuntimeError("Foundry source ref drifted") + if control.get("appliance_reference") != EXPECTED_IMAGE: + raise RuntimeError("GARM appliance identity drifted") + if control.get("controller_source") != EXPECTED_CONTROLLER_SOURCE: + raise RuntimeError("GARM controller source identity drifted") + if control.get("provider_source") != EXPECTED_PROVIDER_SOURCE: + raise RuntimeError("TrueNAS provider source identity drifted") + if control.get("private_secrets_captured") is not False: + raise RuntimeError("control does not assert private_secrets_captured=false") + if control.get("fixture_secret_values_retained") is not False: + raise RuntimeError("control retained fixture secret values") + if control.get("github_credentials_present") is not False: + raise RuntimeError("control claims GitHub credentials are present") + + runtime = control.get("runtime") or {} + if runtime.get("app_name") != EXPECTED_APP_NAME: + raise RuntimeError("GARM T6 app name drifted") + if runtime.get("config_root") != EXPECTED_CONFIG_DIR: + raise RuntimeError("GARM T6 config root drifted") + if int(runtime.get("host_port") or 0) != 30880: + raise RuntimeError("GARM T6 guest service port drifted") + if runtime.get("bootstrap_replacement") != BOOTSTRAP_CONFIG: + raise RuntimeError("GARM bootstrap replacement contract drifted") + + if control.get("runtime_compose_sha256") != canonical_sha256(compose): + raise RuntimeError("GARM runtime Compose identity does not match control") + if deployment.get("artifact_sha256") != control.get("deployment_artifact_sha256"): + raise RuntimeError("GARM deployment artifact identity drifted") + if deployment.get("materialization_identity") != control.get("materialization_identity"): + raise RuntimeError("GARM materialization identity drifted") + + services = compose.get("services") or {} + if set(services) != {"garm", "garm-config-seed"}: + raise RuntimeError("unexpected GARM T6 service inventory") + for name in ("garm", "garm-config-seed"): + service = services[name] + if service.get("image") != EXPECTED_IMAGE: + raise RuntimeError(f"{name} image identity drifted") + if service.get("privileged") is True: + raise RuntimeError(f"{name} became privileged") + if "ALL" not in {str(x).upper() for x in service.get("cap_drop") or []}: + raise RuntimeError(f"{name} lost cap_drop ALL") + options = {str(x).lower().replace(":", "=") for x in service.get("security_opt") or []} + if not any(x.startswith("no-new-privileges=true") for x in options): + raise RuntimeError(f"{name} lost no-new-privileges") + + serialized = json.dumps(compose, sort_keys=True) + for forbidden in ( + "BEGIN PRIVATE KEY", + "PRIVATE-QUALIFICATION-FIXTURE", + "PUBLIC-QUALIFICATION-FIXTURE", + "ghp_", + "github_pat_", + ): + if forbidden in serialized: + raise RuntimeError(f"secret-like retained control content: {forbidden}") + if BOOTSTRAP_PLACEHOLDER not in serialized or TLS_PREFIX not in serialized: + raise RuntimeError("run-local secret placeholders are absent") + return control, compose + + +def build_ephemeral_runtime_compose(compose: dict) -> tuple[dict, dict[str, str]]: + runtime = copy.deepcopy(compose) + jwt_secret = secrets.token_urlsafe(36) + alphabet = string.ascii_letters + string.digits + db_passphrase = "".join(secrets.choice(alphabet) for _ in range(32)) + + with tempfile.TemporaryDirectory(prefix="garm-t6-tls-") as td: + root = pathlib.Path(td) + key_path = root / "tls.key" + cert_path = root / "tls.crt" + subprocess.run( + [ + "openssl", "req", "-x509", "-newkey", "rsa:2048", "-nodes", "-days", "1", + "-subj", "/CN=garm-t6.invalid", + "-addext", "subjectAltName=DNS:garm-t6.invalid,IP:127.0.0.1", + "-keyout", str(key_path), "-out", str(cert_path), + ], + check=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + tls_key = key_path.read_text(encoding="utf-8") + tls_cert = cert_path.read_text(encoding="utf-8") + + bootstrap = f"""[default] +enable_webhook_management = false + +[logging] +log_level = "info" + +[metrics] +disable_auth = false + +[jwt_auth] +secret = "{jwt_secret}" +time_to_live = "1h" + +[apiserver] +bind = "0.0.0.0" +port = 8080 +use_tls = true + +[apiserver.tls] +certificate = "/garm-tls.crt" +key = "/garm-tls.key" + +[apiserver.webui] +enable = true + +[database] +backend = "sqlite3" +passphrase = "{db_passphrase}" + +[database.sqlite3] +db_file = "/etc/garm/garm.db" +""" + configs = runtime["configs"] + configs[BOOTSTRAP_CONFIG]["content"] = bootstrap + configs[TLS_CERT_CONFIG]["content"] = tls_cert + configs[TLS_KEY_CONFIG]["content"] = tls_key + return runtime, { + "jwt_secret_sha256": hashlib.sha256(jwt_secret.encode()).hexdigest(), + "database_passphrase_sha256": hashlib.sha256(db_passphrase.encode()).hexdigest(), + "certificate_sha256": hashlib.sha256(tls_cert.encode()).hexdigest(), + } + + +def https_ui_probe(port: int, timeout: float = 8.0) -> int: + context = ssl._create_unverified_context() + conn = http.client.HTTPSConnection("127.0.0.1", port, timeout=timeout, context=context) + try: + conn.request("GET", "/ui/") + response = conn.getresponse() + response.read(4096) + return int(response.status) + finally: + conn.close() + + +def main() -> int: + p = argparse.ArgumentParser() + p.add_argument("--host", default="127.0.0.1") + p.add_argument("--port", type=int, required=True) + p.add_argument("--service-port", type=int, required=True) + p.add_argument("--password-file", required=True) + p.add_argument("--control-dir", type=pathlib.Path, required=True) + p.add_argument("--foundry-commit", required=True) + p.add_argument("--out", required=True) + p.add_argument("--tls", action="store_true") + p.add_argument("--timeout", type=float, default=8.0) + p.add_argument("--job-timeout", type=float, default=300.0) + p.add_argument("--state-timeout", type=float, default=240.0) + a = p.parse_args() + + started = time.time() + payload = { + "schema": "truenas-garm-foundry-t6/v1", + "classification": "ORACLE_FAILURE", + "oracleSatisfied": False, + "expected_version": EXPECTED_VERSION, + "foundry_commit": a.foundry_commit, + "app_name": EXPECTED_APP_NAME, + "appliance": EXPECTED_IMAGE, + "controller_source": EXPECTED_CONTROLLER_SOURCE, + "provider_source": EXPECTED_PROVIDER_SOURCE, + "github_credentials_present": False, + "github_jit_registration_exercised": False, + "physical_truenas_mutation": False, + "capacity_promotion": False, + "secret_values_recorded": False, + } + ws = None + request_id = 1 + created_app = False + created_dataset = False + + try: + control, retained_compose = load_control(a.control_dir, a.foundry_commit) + runtime_compose, secret_fingerprints = build_ephemeral_runtime_compose(retained_compose) + retained_sha = canonical_sha256(retained_compose) + payload["materialization"] = { + "schema": control["schema"], + "foundry_ref": control["foundry_ref"], + "retained_compose_sha256": retained_sha, + "deployment_artifact_sha256": control["deployment_artifact_sha256"], + "materialization_identity": control["materialization_identity"], + "run_local_secret_fingerprints": secret_fingerprints, + "secret_values_recorded": False, + } + + password = pathlib.Path(a.password_file).read_text(encoding="utf-8").strip() + ws = WebSocket(a.host, a.port, timeout=a.timeout, tls=a.tls) + ws.send_json({"msg": "connect", "version": "1", "support": ["1"]}) + connected = wait_for(ws, lambda m: m.get("msg") in {"connected", "failed"}) + if connected.get("msg") != "connected": + raise RuntimeError("DDP connection failed") + + def call(method, params): + nonlocal request_id + result = ddp_call(ws, str(request_id), method, params) + request_id += 1 + return result + + def wait_job(job_id, label): + deadline = time.monotonic() + a.job_timeout + last = None + while time.monotonic() < deadline: + last = call("core.get_jobs", [[["id", "=", job_id]], {"get": True}]) + if last and last.get("state") == "SUCCESS": + return last + if last and last.get("state") in {"FAILED", "ABORTED"}: + raise RuntimeError(f"{label} job {last.get('state')}") + time.sleep(1) + raise RuntimeError(f"{label} job did not reach SUCCESS") + + def app_query(): + return call("app.query", [[["id", "=", EXPECTED_APP_NAME]], {"get": True}]) + + def wait_running(): + deadline = time.monotonic() + a.state_timeout + last = None + while time.monotonic() < deadline: + last = app_query() + if last: + if last.get("state") in {"CRASHED", "ERROR"}: + raise RuntimeError(f"GARM app entered {last.get('state')}") + details = (last.get("active_workloads") or {}).get("container_details") or [] + controllers = [ + item for item in details + if item.get("service_name") == "garm" + and item.get("image") == EXPECTED_IMAGE + and item.get("state") == "running" + ] + if last.get("state") == "RUNNING" and len(controllers) == 1: + return last + time.sleep(1) + raise RuntimeError(f"GARM controller did not reach RUNNING: {last!r}") + + auth = call("auth.login_ex", [{ + "mechanism": "PASSWORD_PLAIN", + "username": "truenas_admin", + "password": password, + }]) + if not isinstance(auth, dict) or auth.get("response_type") != "SUCCESS": + raise RuntimeError("authentication did not return SUCCESS") + + payload["system_version"] = call("system.version", []) + if payload["system_version"] != EXPECTED_VERSION: + raise RuntimeError("target version drifted") + + if call("app.query", [[["id", "=", EXPECTED_APP_NAME]]]): + raise RuntimeError("refusing adopted GARM app state") + if call("pool.dataset.query", [[["id", "=", EXPECTED_FIXTURE_DATASET]]]): + raise RuntimeError("refusing adopted GARM fixture dataset") + + dataset = call("pool.dataset.create", [{ + "name": EXPECTED_FIXTURE_DATASET, + "type": "FILESYSTEM", + "share_type": "GENERIC", + "comments": "SemperSupra disposable GARM T6 fixture", + }]) + if not isinstance(dataset, dict) or dataset.get("id") != EXPECTED_FIXTURE_DATASET: + raise RuntimeError("pool.dataset.create did not return exact GARM fixture dataset") + created_dataset = True + + mkdir = call("filesystem.mkdir", [{ + "path": EXPECTED_CONFIG_DIR, + "options": {"mode": "750", "raise_chmod_error": True}, + }]) + if not isinstance(mkdir, dict) or mkdir.get("path") != EXPECTED_CONFIG_DIR: + raise RuntimeError("filesystem.mkdir did not create exact GARM config directory") + payload["fixture_storage"] = { + "dataset": EXPECTED_FIXTURE_DATASET, + "config_dir": EXPECTED_CONFIG_DIR, + "content_recorded": False, + } + + create_id = call("app.create", [{ + "app_name": EXPECTED_APP_NAME, + "custom_app": True, + "custom_compose_config": runtime_compose, + }]) + if not isinstance(create_id, int): + raise RuntimeError("app.create did not return a job id") + wait_job(create_id, "app.create") + created_app = True + + app = wait_running() + details = (app.get("active_workloads") or {}).get("container_details") or [] + payload["runtime"] = { + "state": app.get("state"), + "custom_app": app.get("custom_app"), + "controller_running_exact_image": any( + item.get("service_name") == "garm" + and item.get("image") == EXPECTED_IMAGE + and item.get("state") == "running" + for item in details + ), + "container_states": sorted({ + f"{item.get('service_name')}:{item.get('state')}" + for item in details + if isinstance(item, dict) + }), + } + if app.get("custom_app") is not True: + raise RuntimeError("GARM runtime did not materialize as a custom app") + + readback = call("app.config", [EXPECTED_APP_NAME]) + normalized_readback = normalize_secret_configs(readback) + readback_sha = canonical_sha256(normalized_readback) + if readback_sha != retained_sha: + raise RuntimeError("secret-normalized app.config read-back does not match retained Foundry Compose") + payload["runtime"]["secret_normalized_compose_readback_sha256"] = readback_sha + + config_meta = call("filesystem.stat", [EXPECTED_CONFIG_PATH]) + db_meta = call("filesystem.stat", [EXPECTED_DB_PATH]) + payload["persistent_state"] = { + "config_toml": { + "path": EXPECTED_CONFIG_PATH, + "type": config_meta.get("type"), + "size_positive": int(config_meta.get("size") or 0) > 0, + }, + "garm_db": { + "path": EXPECTED_DB_PATH, + "type": db_meta.get("type"), + "size_positive": int(db_meta.get("size") or 0) > 0, + }, + "raw_content_recorded": False, + } + if not payload["persistent_state"]["config_toml"]["size_positive"]: + raise RuntimeError("persistent config.toml is empty") + if not payload["persistent_state"]["garm_db"]["size_positive"]: + raise RuntimeError("persistent garm.db is empty") + + status = https_ui_probe(a.service_port, timeout=a.timeout) + if status < 200 or status >= 400: + raise RuntimeError(f"GARM external /ui/ probe returned HTTP {status}") + payload["runtime"]["external_https_ui_status"] = status + + stop_id = call("app.stop", [EXPECTED_APP_NAME]) + wait_job(stop_id, "app.stop") + stopped = app_query() + if not stopped or stopped.get("state") != "STOPPED": + raise RuntimeError("GARM app did not reach STOPPED") + + start_id = call("app.start", [EXPECTED_APP_NAME]) + wait_job(start_id, "app.start") + wait_running() + + restart_readback = normalize_secret_configs(call("app.config", [EXPECTED_APP_NAME])) + if canonical_sha256(restart_readback) != retained_sha: + raise RuntimeError("GARM secret-normalized Compose identity drifted after restart") + restart_status = https_ui_probe(a.service_port, timeout=a.timeout) + if restart_status < 200 or restart_status >= 400: + raise RuntimeError(f"GARM external /ui/ restart probe returned HTTP {restart_status}") + payload["runtime"]["restart_compose_identity_preserved"] = True + payload["runtime"]["restart_https_ui_status"] = restart_status + + delete_id = call("app.delete", [EXPECTED_APP_NAME, { + "remove_images": False, + "remove_ix_volumes": False, + "force_remove_custom_app": False, + }]) + wait_job(delete_id, "app.delete") + created_app = False + if call("app.query", [[["id", "=", EXPECTED_APP_NAME]]]): + raise RuntimeError("GARM app remains after delete") + + deleted = call("pool.dataset.delete", [ + EXPECTED_FIXTURE_DATASET, + {"recursive": True, "force": False}, + ]) + if deleted is not True: + raise RuntimeError("pool.dataset.delete did not return true") + created_dataset = False + if call("pool.dataset.query", [[["id", "=", EXPECTED_FIXTURE_DATASET]]]): + raise RuntimeError("GARM fixture dataset remains after delete") + + mountpoint_absent = False + try: + call("filesystem.stat", [EXPECTED_FIXTURE_ROOT]) + except RuntimeError: + mountpoint_absent = True + if not mountpoint_absent: + raise RuntimeError("GARM fixture mountpoint remains after dataset delete") + + payload["cleanup"] = { + "app_absent": True, + "fixture_dataset_absent": True, + "fixture_mountpoint_absent": True, + "zero_residue": True, + } + payload["classification"] = "SUPPORTED" + payload["oracleSatisfied"] = True + payload["detail"] = ( + "exact Foundry-exported GARM controller App realized on real disposable TrueNAS; " + "exact appliance and secret-normalized config read-back, persistent controller state, " + "external HTTPS UI, restart persistence, and delete/zero-residue all passed; " + "GitHub credentials/JIT registration intentionally not exercised" + ) + except Exception as exc: + payload["detail"] = f"{type(exc).__name__}: {exc}" + payload["cleanup_needed"] = { + "app": created_app, + "fixture_dataset": created_dataset, + } + finally: + if ws is not None: + ws.close() + + payload["elapsed_seconds"] = round(time.time() - started, 3) + pathlib.Path(a.out).write_text( + json.dumps(payload, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + print(json.dumps(payload, indent=2, sort_keys=True)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/truenas_middleware_litellm_t6_probe.py b/scripts/truenas_middleware_litellm_t6_probe.py new file mode 100644 index 000000000..af6065bf4 --- /dev/null +++ b/scripts/truenas_middleware_litellm_t6_probe.py @@ -0,0 +1,522 @@ +#!/usr/bin/env python3 +"""TrueNAS T6 oracle for the exact Foundry-exported LiteLLM control.""" +from __future__ import annotations + +import argparse +import base64 +import hashlib +import http.client +import json +import pathlib +import ssl +import time +import urllib.error +import urllib.request + +from truenas_middleware_ddp_probe import WebSocket, ddp_call, wait_for + + +EXPECTED_VERSION = "TrueNAS-26.0.0-BETA.3" +EXPECTED_FOUNDRY_REF = "4ba12f4a870f9af8a667056f1e2cc32f80f8e2ba" +EXPECTED_SCHEMA = "semper-supra.litellm-truenas-t6-control/1" +EXPECTED_APPLIANCE = ( + "ghcr.io/sempersupra/litellm-appliance@" + "sha256:225c899db85865929f6099d3e1fe27097cafaed5af823fa397e75e1eb6ec51ac" +) +EXPECTED_APPLIANCE_DIGEST = ( + "sha256:225c899db85865929f6099d3e1fe27097cafaed5af823fa397e75e1eb6ec51ac" +) +EXPECTED_TRUENAS_APPS_COMMIT = "4df7498747aad81b1049135539c65c383e42df95" +EXPECTED_LIBRARY_VERSION = "2.3.11" +EXPECTED_LIBRARY_HASH = "874636814efb275e5276ea9d709b7cd665fed42bb1d50328e853d9253a2e1229" +EXPECTED_APP_NAME = "rdte-t6-litellm" +EXPECTED_GUEST_PORT = 30401 +EXPECTED_FIXTURE_DATASET = "rdtepool/litellm-t6" +EXPECTED_FIXTURE_ROOT = "/mnt/rdtepool/litellm-t6" +EXPECTED_CONFIG_DIR = "/mnt/rdtepool/litellm-t6/config" +EXPECTED_SECRET_DIR = "/mnt/rdtepool/litellm-t6/secrets" +EXPECTED_CONFIG_FILE = "proxy_server_config.yaml" +EXPECTED_FIXTURE_SECRET_NAMES = ["TEST_PROVIDER_KEY"] +FIXTURE_SECRET_VALUE = b"rdte-fixture-provider-token\n" + + +def canonical_sha256(value): + return hashlib.sha256( + json.dumps(value, sort_keys=True, separators=(",", ":")).encode("utf-8") + ).hexdigest() + + +def sha256_bytes(value: bytes) -> str: + return hashlib.sha256(value).hexdigest() + + +def _load_json(path: pathlib.Path) -> dict: + value = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(value, dict): + raise RuntimeError(f"{path.name} must contain a JSON object") + return value + + +def _one_service(compose: dict) -> tuple[str, dict]: + services = compose.get("services") + if not isinstance(services, dict) or len(services) != 1: + raise RuntimeError("expected exactly one rendered LiteLLM service") + name, service = next(iter(services.items())) + if not isinstance(service, dict): + raise RuntimeError("rendered LiteLLM service must be an object") + return str(name), service + + +def _one_mount(service: dict, target: str) -> dict: + matches = [ + value + for value in service.get("volumes", []) + if isinstance(value, dict) and value.get("target") == target + ] + if len(matches) != 1: + raise RuntimeError(f"expected exactly one mount for {target}") + return matches[0] + + +def load_control(directory: pathlib.Path): + control = _load_json(directory / "control.json") + compose = _load_json(directory / "compose.json") + config_bytes = (directory / EXPECTED_CONFIG_FILE).read_bytes() + + if control.get("schema") != EXPECTED_SCHEMA: + raise RuntimeError("unexpected LiteLLM T6 control schema") + if control.get("foundry_ref") != EXPECTED_FOUNDRY_REF: + raise RuntimeError("Foundry source ref drifted") + if control.get("secrets_captured") is not False: + raise RuntimeError("control bundle does not assert secrets_captured=false") + + candidate = control.get("candidate") or {} + if candidate.get("appliance_reference") != EXPECTED_APPLIANCE: + raise RuntimeError("admitted appliance reference drifted") + if candidate.get("appliance_digest") != EXPECTED_APPLIANCE_DIGEST: + raise RuntimeError("admitted appliance digest drifted") + if candidate.get("truenas_apps_commit") != EXPECTED_TRUENAS_APPS_COMMIT: + raise RuntimeError("TrueNAS Apps source commit drifted") + if candidate.get("truenas_lib_version") != EXPECTED_LIBRARY_VERSION: + raise RuntimeError("TrueNAS library version drifted") + if candidate.get("truenas_lib_hash") != EXPECTED_LIBRARY_HASH: + raise RuntimeError("TrueNAS library hash drifted") + + runtime = control.get("runtime") or {} + expected_runtime = { + "app_name": EXPECTED_APP_NAME, + "guest_port": EXPECTED_GUEST_PORT, + "config_dir": EXPECTED_CONFIG_DIR, + "secret_dir": EXPECTED_SECRET_DIR, + "config_file": EXPECTED_CONFIG_FILE, + "fixture_secret_names": EXPECTED_FIXTURE_SECRET_NAMES, + } + for key, expected in expected_runtime.items(): + if runtime.get(key) != expected: + raise RuntimeError(f"runtime {key} drifted") + + artifacts = control.get("artifacts") or {} + if artifacts.get("compose_canonical_sha256") != canonical_sha256(compose): + raise RuntimeError("rendered Compose identity does not match control") + if artifacts.get("config_sha256") != sha256_bytes(config_bytes): + raise RuntimeError("fixture config identity does not match control") + + service_name, service = _one_service(compose) + if service.get("image") != EXPECTED_APPLIANCE: + raise RuntimeError("rendered service image drifted") + config_mount = _one_mount(service, "/config") + secret_mount = _one_mount(service, "/run/secrets/semper-env") + for label, mount, source in ( + ("config", config_mount, EXPECTED_CONFIG_DIR), + ("provider-secret", secret_mount, EXPECTED_SECRET_DIR), + ): + if mount.get("source") != source or mount.get("read_only") is not True: + raise RuntimeError(f"{label} mount drifted") + if (service.get("environment") or {}).get("SEMPER_SECRET_DIR") != "/run/secrets/semper-env": + raise RuntimeError("SEMPER_SECRET_DIR drifted") + if not any( + isinstance(port, dict) + and int(port.get("target", -1)) == 4000 + and str(port.get("published")) == str(EXPECTED_GUEST_PORT) + for port in service.get("ports", []) + ): + raise RuntimeError("LiteLLM guest port mapping drifted") + + rendered = json.dumps(compose, sort_keys=True) + for forbidden in ("OPENROUTER_MANAGEMENT_KEY", "sk-or-mgmt-", ":latest", ":main-latest"): + if forbidden in rendered: + raise RuntimeError(f"forbidden rendered content: {forbidden}") + + return control, compose, config_bytes, service_name + + +def multipart_upload( + host: str, + port: int, + tls: bool, + username: str, + password: str, + remote_path: str, + content: bytes, + mode: int, + timeout: float, +) -> int: + boundary = "----semper-supra-litellm-t6-boundary" + data = json.dumps( + {"method": "filesystem.put", "params": [remote_path, {"append": False, "mode": mode}]}, + separators=(",", ":"), + ) + chunks = [ + f"--{boundary}\r\n".encode(), + b'Content-Disposition: form-data; name="data"\r\n\r\n', + data.encode(), + b"\r\n", + f"--{boundary}\r\n".encode(), + b'Content-Disposition: form-data; name="file"; filename="payload"\r\n', + b"Content-Type: application/octet-stream\r\n\r\n", + content, + b"\r\n", + f"--{boundary}--\r\n".encode(), + ] + body = b"".join(chunks) + token = base64.b64encode(f"{username}:{password}".encode()).decode() + headers = { + "Authorization": f"Basic {token}", + "Content-Type": f"multipart/form-data; boundary={boundary}", + "Content-Length": str(len(body)), + } + if tls: + context = ssl.create_default_context() + context.check_hostname = False + context.verify_mode = ssl.CERT_NONE + conn = http.client.HTTPSConnection(host, port, timeout=timeout, context=context) + else: + conn = http.client.HTTPConnection(host, port, timeout=timeout) + try: + conn.request("POST", "/_upload/", body=body, headers=headers) + response = conn.getresponse() + payload = response.read() + finally: + conn.close() + if response.status < 200 or response.status >= 300: + raise RuntimeError(f"filesystem.put upload returned HTTP {response.status}") + result = json.loads(payload.decode("utf-8")) + job_id = result.get("job_id") + if not isinstance(job_id, int): + raise RuntimeError("filesystem.put upload did not return a job_id") + return job_id + + +def health_probe(host: str, port: int, path: str, timeout: float) -> bool: + try: + with urllib.request.urlopen( + f"http://{host}:{port}{path}", + timeout=timeout, + ) as response: + return 200 <= response.status < 300 + except (urllib.error.URLError, TimeoutError, OSError): + return False + + +def main() -> int: + p = argparse.ArgumentParser() + p.add_argument("--host", default="127.0.0.1") + p.add_argument("--port", type=int, required=True, help="TrueNAS middleware HTTP(S) host-forward port") + p.add_argument("--service-port", type=int, required=True, help="host-forward port for guest LiteLLM port 30401") + p.add_argument("--password-file", required=True) + p.add_argument("--control-dir", type=pathlib.Path, required=True) + p.add_argument("--foundry-commit", required=True) + p.add_argument("--out", required=True) + p.add_argument("--tls", action="store_true") + p.add_argument("--timeout", type=float, default=8.0) + p.add_argument("--job-timeout", type=float, default=300.0) + p.add_argument("--state-timeout", type=float, default=240.0) + a = p.parse_args() + + started = time.time() + payload = { + "schema": "truenas-litellm-foundry-t6/v1", + "classification": "ORACLE_FAILURE", + "oracleSatisfied": False, + "expected_version": EXPECTED_VERSION, + "foundry_commit": a.foundry_commit, + "app_name": EXPECTED_APP_NAME, + "appliance_reference": EXPECTED_APPLIANCE, + "fixture_secret_names": EXPECTED_FIXTURE_SECRET_NAMES, + "secret_values_captured": False, + "management_credentials_present": False, + } + ws = None + created_app = False + request_id = 1 + try: + if a.foundry_commit != EXPECTED_FOUNDRY_REF: + raise RuntimeError("requested Foundry commit does not match qualified export") + control, compose, config_bytes, service_name = load_control(a.control_dir) + compose_sha = canonical_sha256(compose) + payload["materialization"] = { + "schema": control["schema"], + "foundry_ref": control["foundry_ref"], + "compose_canonical_sha256": compose_sha, + "config_sha256": sha256_bytes(config_bytes), + "service_name": service_name, + "truenas_apps_commit": EXPECTED_TRUENAS_APPS_COMMIT, + "truenas_lib_version": EXPECTED_LIBRARY_VERSION, + "truenas_lib_hash": EXPECTED_LIBRARY_HASH, + } + + password = pathlib.Path(a.password_file).read_text(encoding="utf-8").strip() + ws = WebSocket(a.host, a.port, timeout=a.timeout, tls=a.tls) + ws.send_json({"msg": "connect", "version": "1", "support": ["1"]}) + connected = wait_for(ws, lambda m: m.get("msg") in {"connected", "failed"}) + if connected.get("msg") != "connected": + raise RuntimeError("DDP connection failed") + + def call(method, params): + nonlocal request_id + result = ddp_call(ws, str(request_id), method, params) + request_id += 1 + return result + + def wait_job(job_id, label): + deadline = time.monotonic() + a.job_timeout + last = None + while time.monotonic() < deadline: + last = call("core.get_jobs", [[["id", "=", job_id]], {"get": True}]) + if last and last.get("state") == "SUCCESS": + return last + if last and last.get("state") in {"FAILED", "ABORTED"}: + raise RuntimeError(f"{label} job {last.get('state')}") + time.sleep(1) + raise RuntimeError(f"{label} job did not reach SUCCESS") + + def wait_app_state(expected): + deadline = time.monotonic() + a.state_timeout + last = None + while time.monotonic() < deadline: + last = call("app.query", [[["id", "=", EXPECTED_APP_NAME]], {"get": True}]) + if last and last.get("state") == expected: + return last + if last and last.get("state") in {"CRASHED", "ERROR"}: + raise RuntimeError(f"LiteLLM entered {last.get('state')}") + time.sleep(1) + raise RuntimeError(f"LiteLLM did not reach {expected}") + + auth = call("auth.login_ex", [{ + "mechanism": "PASSWORD_PLAIN", + "username": "truenas_admin", + "password": password, + }]) + if not isinstance(auth, dict) or auth.get("response_type") != "SUCCESS": + raise RuntimeError("authentication did not return SUCCESS") + payload["system_version"] = call("system.version", []) + if payload["system_version"] != EXPECTED_VERSION: + raise RuntimeError("target version drifted") + + existing = call("app.query", [[["id", "=", EXPECTED_APP_NAME]]]) + if existing: + raise RuntimeError("refusing adopted LiteLLM app state") + + existing_dataset = call("pool.dataset.query", [[["id", "=", EXPECTED_FIXTURE_DATASET]]]) + if existing_dataset: + raise RuntimeError("refusing adopted LiteLLM T6 fixture dataset") + + dataset = call("pool.dataset.create", [{ + "name": EXPECTED_FIXTURE_DATASET, + "type": "FILESYSTEM", + "share_type": "GENERIC", + "comments": "SemperSupra disposable LiteLLM T6 fixture", + }]) + if not isinstance(dataset, dict) or dataset.get("id") != EXPECTED_FIXTURE_DATASET: + raise RuntimeError("pool.dataset.create did not return exact T6 dataset identity") + + fixture_dirs = [ + (EXPECTED_CONFIG_DIR, "750"), + (EXPECTED_SECRET_DIR, "700"), + ] + created_dirs = [] + for path, mode in fixture_dirs: + result = call("filesystem.mkdir", [{ + "path": path, + "options": {"mode": mode, "raise_chmod_error": True}, + }]) + if not isinstance(result, dict) or result.get("path") != path: + raise RuntimeError(f"filesystem.mkdir did not create exact path {path}") + observed_mode = int(result.get("mode", 0)) & 0o777 + if observed_mode != int(mode, 8): + raise RuntimeError(f"filesystem.mkdir mode mismatch for {path}") + created_dirs.append({ + "path": path, + "mode": oct(observed_mode), + }) + payload["fixture_storage"] = { + "dataset": EXPECTED_FIXTURE_DATASET, + "root": EXPECTED_FIXTURE_ROOT, + "directories": created_dirs, + "values_recorded": False, + } + + config_remote = f"{EXPECTED_CONFIG_DIR}/{EXPECTED_CONFIG_FILE}" + secret_remote = f"{EXPECTED_SECRET_DIR}/TEST_PROVIDER_KEY" + config_job = multipart_upload( + a.host, a.port, a.tls, "truenas_admin", password, + config_remote, config_bytes, 0o640, a.timeout, + ) + wait_job(config_job, "config filesystem.put") + secret_job = multipart_upload( + a.host, a.port, a.tls, "truenas_admin", password, + secret_remote, FIXTURE_SECRET_VALUE, 0o600, a.timeout, + ) + wait_job(secret_job, "fixture secret filesystem.put") + + config_stat = call("filesystem.stat", [config_remote]) + secret_stat = call("filesystem.stat", [secret_remote]) + payload["fixture_projection"] = { + "config_path": config_remote, + "config_sha256": sha256_bytes(config_bytes), + "config_mode": oct(int(config_stat.get("mode", 0)) & 0o777), + "secret_path": secret_remote, + "secret_names": EXPECTED_FIXTURE_SECRET_NAMES, + "secret_mode": oct(int(secret_stat.get("mode", 0)) & 0o777), + "secret_value_recorded": False, + } + if (int(secret_stat.get("mode", 0)) & 0o777) != 0o600: + raise RuntimeError("fixture S1 file mode is not 0600") + + create_id = call("app.create", [{ + "app_name": EXPECTED_APP_NAME, + "custom_app": True, + "custom_compose_config": compose, + }]) + if not isinstance(create_id, int): + raise RuntimeError("app.create did not return a job id") + wait_job(create_id, "app.create") + created_app = True + + app = wait_app_state("RUNNING") + details = (app.get("active_workloads") or {}).get("container_details") or [] + matching = [ + item for item in details + if item.get("service_name") == service_name + and item.get("image") == EXPECTED_APPLIANCE + and item.get("state") == "running" + ] + if len(matching) != 1: + raise RuntimeError("running container image/service identity mismatch") + + config_readback = call("app.config", [EXPECTED_APP_NAME]) + config_readback_sha = canonical_sha256(config_readback) + if config_readback_sha != compose_sha: + raise RuntimeError("app.config read-back does not match exact Foundry Compose") + + live = ready = False + deadline = time.monotonic() + a.state_timeout + while time.monotonic() < deadline: + live = health_probe(a.host, a.service_port, "/health/liveliness", a.timeout) + ready = health_probe(a.host, a.service_port, "/health/readiness", a.timeout) + if live and ready: + break + time.sleep(1) + if not (live and ready): + raise RuntimeError("LiteLLM health endpoints did not pass") + + stop_id = call("app.stop", [EXPECTED_APP_NAME]) + wait_job(stop_id, "app.stop") + wait_app_state("STOPPED") + start_id = call("app.start", [EXPECTED_APP_NAME]) + wait_job(start_id, "app.start") + wait_app_state("RUNNING") + + deadline = time.monotonic() + a.state_timeout + restart_live = restart_ready = False + while time.monotonic() < deadline: + restart_live = health_probe(a.host, a.service_port, "/health/liveliness", a.timeout) + restart_ready = health_probe(a.host, a.service_port, "/health/readiness", a.timeout) + if restart_live and restart_ready: + break + time.sleep(1) + if not (restart_live and restart_ready): + raise RuntimeError("LiteLLM health did not recover after stop/start") + + second_config_sha = canonical_sha256(call("app.config", [EXPECTED_APP_NAME])) + if second_config_sha != compose_sha: + raise RuntimeError("Foundry Compose identity drifted after restart") + + delete_id = call("app.delete", [EXPECTED_APP_NAME, { + "remove_images": False, + "remove_ix_volumes": False, + "force_remove_custom_app": False, + }]) + wait_job(delete_id, "app.delete") + created_app = False + remaining = call("app.query", [[["id", "=", EXPECTED_APP_NAME]]]) + if remaining: + raise RuntimeError("LiteLLM app remains after delete") + + deleted_dataset = call("pool.dataset.delete", [ + EXPECTED_FIXTURE_DATASET, + {"recursive": True, "force": False}, + ]) + if deleted_dataset is not True: + raise RuntimeError("pool.dataset.delete did not return true") + + remaining_dataset = call( + "pool.dataset.query", + [[["id", "=", EXPECTED_FIXTURE_DATASET]]], + ) + if remaining_dataset: + raise RuntimeError("LiteLLM T6 fixture dataset remains after delete") + + root_absent = False + try: + call("filesystem.stat", [EXPECTED_FIXTURE_ROOT]) + except RuntimeError: + root_absent = True + if not root_absent: + raise RuntimeError("LiteLLM T6 fixture mountpoint remains after dataset delete") + + payload["cleanup"] = { + "app_absent": True, + "fixture_dataset_absent": True, + "fixture_mountpoint_absent": True, + "zero_residue": True, + } + + payload["runtime"] = { + "state": "RUNNING", + "custom_app": True, + "container_image_exact": True, + "compose_readback_sha256": config_readback_sha, + "liveliness": live, + "readiness": ready, + "restart_liveliness": restart_live, + "restart_readiness": restart_ready, + "restart_compose_identity_preserved": True, + "delete_absent": True, + "zero_residue": True, + } + payload["classification"] = "SUPPORTED" + payload["oracleSatisfied"] = True + payload["detail"] = ( + "exact Foundry-exported LiteLLM appliance consumed on real TrueNAS software; " + "S1 fixture projection, exact image/config read-back, health, restart persistence, " + "and delete/zero-residue all passed" + ) + except Exception as exc: + payload["detail"] = f"{type(exc).__name__}: {exc}" + payload["cleanup_needed"] = created_app + finally: + if ws is not None: + ws.close() + + payload["elapsed_seconds"] = round(time.time() - started, 3) + pathlib.Path(a.out).write_text( + json.dumps(payload, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + print(json.dumps(payload, indent=2, sort_keys=True)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/truenas_middleware_minecraft_t6_probe.py b/scripts/truenas_middleware_minecraft_t6_probe.py new file mode 100644 index 000000000..3b70803ee --- /dev/null +++ b/scripts/truenas_middleware_minecraft_t6_probe.py @@ -0,0 +1,1019 @@ +#!/usr/bin/env python3 +"""Native Minecraft Paper T6 on one disposable TrueNAS RDTE target. + +This is a product oracle over the existing TrueNAS Apps management API, not +another app-controller or a physical TrueNAS deployment operator. +""" +from __future__ import annotations + +import argparse +import copy +import hashlib +import json +import pathlib +import re +import subprocess +import sys +import time + +from truenas_middleware_ddp_probe import WebSocket, ddp_call, wait_for +from truenas_middleware_foliorelay_t6_probe import middleware_http_bytes, multipart_upload + +# Only the disposable scoped credential-issuance RPC gets a longer DDP +# response deadline. The TrueNAS nested CPU/PAM workload may exceed the +# ordinary 8-second socket deadline. A timeout is NOT proof the RPC failed or +# that the key was not created: never retry this non-idempotent operation. +SCOPED_KEY_CREATE_TIMEOUT_SECONDS = 60.0 + + +def scoped_ddp_call(ws, request_id, method, params, *, transport=ddp_call): + """One bounded scoped exception to ordinary DDP transport timeouts. + + No role changes, TLS downgrades, credential retries or raw error logging. + Existing request IDs must be unique before this function is called. + """ + if method != "api_key.create": + return transport(ws, request_id, method, params) + prior = ws.sock.gettimeout() + try: + ws.sock.settimeout(max(prior or 0.0, SCOPED_KEY_CREATE_TIMEOUT_SECONDS)) + return transport(ws, request_id, method, params) + finally: + ws.sock.settimeout(prior) + + +def sequenced_guest_ddp_call(ws, *, transport=scoped_ddp_call): + """Unique DDP correlation IDs, including after indeterminate timeouts.""" + next_id = 0 + + def call(method, params): + nonlocal next_id + next_id += 1 + return transport(ws, str(next_id), method, params) + + return call + + +SCHEMA = "semper-supra.minecraft-paper-truenas-t6-control/1" +EXPECTED_TARGET = "25.04.1" +EXPECTED_PROFILE = "800aca038ded29e6a91bfc4c1b1fe2d2199182e8" +EXPECTED_APPS_COMMIT = "bb96ef6fd8c2708765012a4a3ca3739370a4f92c" +EXPECTED_IMAGE_FRAGMENT = "ghcr.io/itzg/minecraft-server:2026.9.2-java21@sha256:edf50d431ddc6f73929368e82f4334a405796874f7b0ae11f1edcbb267d670aa" +CATALOG_PROFILES = { + "1.15.29": { + "apps_commit": EXPECTED_APPS_COMMIT, + "image": EXPECTED_IMAGE_FRAGMENT, + }, + "1.15.30": { + "apps_commit": "559b9fe85212c800bc6770f152a65211df137a44", + "image": "ghcr.io/itzg/minecraft-server:2026.10.0-java21@sha256:d4057e7019e9b19f25e039d4a204cc502e1a112d1a2305966a534f3d7079e644", + }, +} +EXPECTED_MIDDLEWARE = "74ab5a2d373be4097dece257d00e1086376333ba" +APP = "rdte-t6-minecraft-paper" +DATASET = "rdtepool/minecraft-paper-t6-world" +WORLD = "/mnt/rdtepool/minecraft-paper-t6-world" +PORT = 25580 +MC_TYPE = "PAPER" +MC_VERSION = "1.21.11" +MC_PROTOCOL = 774 +MC_IMPLEMENTATION = "paper" +MC_STATUS_NAME_CONTRACT = "paper_brand_exact" +VANILLA263 = False +WORLD_LEVEL = WORLD + "/level.dat" +SENTINEL_PATH = WORLD + "/.supracraft-paper-t6-sentinel" +REQUIRED_OWNER = { + "SUPRACRAFT_COLLAGE_MANAGED": "true", + "SUPRACRAFT_COLLAGE_SCHEMA": "1", + "SUPRACRAFT_COLLAGE_MANAGER_ID": "manager-rdte", + "SUPRACRAFT_COLLAGE_FLEET_ID": "rdte", + "SUPRACRAFT_COLLAGE_SERVICE_ID": "11111111-1111-4111-8111-111111111111", + "SUPRACRAFT_COLLAGE_WORLD_ID": "world-rdte-001", +} + + +def configure_exact_vanilla263() -> None: + """Explicit per-process T6 product profile, not a Paper acceptance alias. + + The old Paper behavior remains the default and all targets are exact. + This process handles exactly one guest/product. The new source consumer + independently checks the signed merged Foundry control before mutation. + """ + global SCHEMA, EXPECTED_TARGET, EXPECTED_PROFILE, EXPECTED_APPS_COMMIT + global EXPECTED_IMAGE_FRAGMENT, EXPECTED_MIDDLEWARE, APP, DATASET + global WORLD, PORT, WORLD_LEVEL, SENTINEL_PATH, REQUIRED_OWNER + global MC_TYPE, MC_VERSION, MC_PROTOCOL, MC_IMPLEMENTATION + global MC_STATUS_NAME_CONTRACT, VANILLA263, CATALOG_PROFILES + from admit_vanilla263_native_t6_control import ( + APP as NATIVE_APP, APPS_REF, OCI, OWNER, TARGET, + MIDDLEWARE, PROFILE_BLOB, WORLD as NATIVE_DATASET, + ) + SCHEMA = "semper-supra.minecraft-vanilla-263-truenas-t6-control/1" + EXPECTED_TARGET = TARGET + EXPECTED_PROFILE = PROFILE_BLOB + EXPECTED_APPS_COMMIT = APPS_REF + EXPECTED_IMAGE_FRAGMENT = OCI + EXPECTED_MIDDLEWARE = MIDDLEWARE + APP = NATIVE_APP + DATASET = NATIVE_DATASET + WORLD = "/mnt/" + NATIVE_DATASET + PORT = 25580 + WORLD_LEVEL = WORLD + "/level.dat" + SENTINEL_PATH = WORLD + "/.supracraft-vanilla263-t6-sentinel" + REQUIRED_OWNER = OWNER + MC_TYPE, MC_VERSION, MC_PROTOCOL = "VANILLA", "26.3", 777 + MC_IMPLEMENTATION, MC_STATUS_NAME_CONTRACT = "vanilla", "vanilla_exact" + VANILLA263 = True + CATALOG_PROFILES = dict(CATALOG_PROFILES) + CATALOG_PROFILES["1.15.31"] = {"apps_commit": APPS_REF, "image": OCI} + + +class MinecraftT6Error(RuntimeError): + pass + + +def qualified_native_image(app: dict, image_fragment: str = EXPECTED_IMAGE_FRAGMENT) -> str | None: + """Accept only the digest-pinned OCI reference, with optional tag normalization.""" + work = app.get("active_workloads") or {} + candidates = list(work.get("images") or []) + for item in work.get("container_details") or []: + if isinstance(item, dict) and isinstance(item.get("image"), str): + candidates.append(item["image"]) + repository_tag, digest = image_fragment.split("@sha256:", 1) + repository = repository_tag.rsplit(":", 1)[0] + valid = {image_fragment, repository + "@sha256:" + digest} + return next((v for v in candidates if isinstance(v, str) and v in valid), None) + + +def safe_job_error(state: dict, password: str) -> dict: + """Bounded public-safe error evidence. Never emit traceback or unredacted secrets.""" + raw = state.get("error") + raw = raw if isinstance(raw, str) else "" + if password: + raw = raw.replace(password, "[redacted]") + raw = re.sub( + r"(?i)(password|passphrase|token|secret|authorization|credential|api[_-]?key)\s*[:=]\s*[^,;\s]+", + r"\1=[redacted]", raw, + ) + raw = re.sub(r"[A-Za-z0-9_+/=-]{48,}", "[opaque]", raw) + raw = "".join(c if 32 <= ord(c) <= 126 else " " for c in raw) + info = state.get("exc_info") or {} + kind = info.get("type") if isinstance(info, dict) else None + return { + "job_state": state.get("state") if state.get("state") in ("FAILED", "ABORTED") else "UNKNOWN", + "error_excerpt": raw[:480], + "error_present": bool(raw), + "exception_kind": str(kind)[:100] if isinstance(kind, str) else None, + "truncated": len(raw) > 480, + } + + +def canonical_sha256(doc: dict) -> str: + return hashlib.sha256(json.dumps(doc, sort_keys=True, separators=(",", ":")).encode()).hexdigest() + + +def load_control(path: pathlib.Path, expected_commit: str, catalog_version: str = "1.15.29") -> dict: + if VANILLA263: + from admit_vanilla263_native_t6_control import verify + if catalog_version != "1.15.31": + raise MinecraftT6Error("vanilla263 exact native catalog version refused") + try: + control = json.loads((path / "control.json").read_text(encoding="utf-8")) + verify(control, expected_producer=expected_commit) + return control + except (OSError, ValueError, TypeError) as exc: + raise MinecraftT6Error("exact vanilla263 signed source control refused") from None + if catalog_version not in CATALOG_PROFILES: + raise MinecraftT6Error("unadmitted native catalog version profile") + profile = CATALOG_PROFILES[catalog_version] + control = json.loads((path / "control.json").read_text(encoding="utf-8")) + if not isinstance(control, dict) or control.get("schema") != SCHEMA: + raise MinecraftT6Error("Minecraft Foundry control schema drift") + if not re.fullmatch("[0-9a-f]{40}", expected_commit): + raise MinecraftT6Error("producer SHA is not immutable") + if control.get("foundry_ref") != expected_commit: + raise MinecraftT6Error("Foundry producer identity drift") + signed = control.copy() + claimed = signed.pop("control_sha256", None) + if claimed != canonical_sha256(signed): + raise MinecraftT6Error("Foundry control canonical digest drift") + target = control.get("target") or {} + if (target.get("truenas_version") != EXPECTED_TARGET or + target.get("expected_system_version") != "TrueNAS-" + EXPECTED_TARGET or + target.get("profile_git_blob_sha") != EXPECTED_PROFILE or + target.get("middleware_commit") != EXPECTED_MIDDLEWARE): + raise MinecraftT6Error("exact TrueNAS target/profile drift") + source = control.get("source") or {} + if (source.get("apps_commit") != profile["apps_commit"] or + source.get("app") != "community/minecraft" or + source.get("image_reference") != profile["image"] or + source.get("library_version") != "2.3.4" or + not re.fullmatch("[0-9a-f]{64}", str(source.get("source_compose_sha256", ""))) or + not re.fullmatch("[0-9a-f]{64}", str(source.get("values_raw_sha256", "")))): + raise MinecraftT6Error("pinned native Minecraft catalog source drift") + runtime = control.get("runtime") or {} + if (runtime.get("app_name") != APP or runtime.get("catalog_app") != "minecraft" or + runtime.get("train") != "community" or runtime.get("catalog_version_candidate") != catalog_version or + runtime.get("paper_version") != "1.21.11" or + runtime.get("required_owned_world_dataset") != DATASET or + runtime.get("status_oracle") != "minecraft-java-status-oracle/v1"): + raise MinecraftT6Error("Minecraft product/source identity drift") + values = runtime.get("values_template") + if not isinstance(values, dict): + raise MinecraftT6Error("missing native values template") + minecraft = values.get("minecraft") or {} + if (minecraft.get("type") != "PAPER" or minecraft.get("version") != "1.21.11" or + minecraft.get("eula") is not True or minecraft.get("online_mode") is not True or + minecraft.get("enable_rcon") is not False): + raise MinecraftT6Error("Paper/EULA/network mode contract drift") + owner = minecraft.get("additional_envs") or [] + if len(owner) != len(REQUIRED_OWNER) or { + i.get("name"): i.get("value") for i in owner if isinstance(i, dict) + } != REQUIRED_OWNER: + raise MinecraftT6Error("exact owner metadata drift") + storage = values.get("storage") or {} + if (storage.get("data") or {}).get("type") != "ix_volume": + raise MinecraftT6Error("base data must use independent App ixVolume") + mounts = storage.get("additional_storage") or [] + if (len(mounts) != 1 or mounts[0].get("type") != "host_path" or + mounts[0].get("mount_path") != "/data/world" or + mounts[0].get("read_only") is not False): + raise MinecraftT6Error("independent writable world mount contract drift") + if ((mounts[0].get("host_path_config") or {}).get("path") != "/opt/tests/collage-world-only"): + raise MinecraftT6Error("source host-path identity drift") + network = values.get("network") or {} + if (network.get("host_network") is not False or + (network.get("server_port") or {}).get("port_number") != 25565 or + (network.get("server_port") or {}).get("bind_mode") != "published"): + raise MinecraftT6Error("source published ingress contract drift") + if (not isinstance(control.get("evidence"), dict) or + control["evidence"].get("source_render_pass") is not True or + any(control["evidence"].get(flag) is not False for flag in ( + "live_catalog_version_verified", "live_minecraft_server_qualified", + "world_retention_qualified", "physical_truenas_qualified")) or + control.get("secrets_captured") is not False): + raise MinecraftT6Error("source candidate incorrectly promotes runtime or secrets") + return control + + +def runtime_values(control: dict) -> dict: + """Lower exact site bindings and remove only an absent optional native seed. + + Foundry source-render accepts seed: null; middleware app.create on + TrueNAS 25.04.1 rejects that field with [EINVAL] ...seed: null not allowed. + Omission means catalog default, not a synthesized world seed. + """ + values = copy.deepcopy(control["runtime"]["values_template"]) + values["storage"]["additional_storage"][0]["host_path_config"]["path"] = WORLD + values["network"]["server_port"]["port_number"] = PORT + if "seed" in values["minecraft"] and values["minecraft"]["seed"] is None: + del values["minecraft"]["seed"] + return values + + +def safe_status_oracle(script: pathlib.Path, port: int, out: pathlib.Path, + expected_version: str = "1.21.11", *, + implementation: str = "paper", + expected_protocol: int = 774) -> dict: + if implementation not in {"paper", "vanilla"} or ( + implementation == "paper" and expected_protocol != 774) or ( + implementation == "vanilla" and expected_protocol != 777): + raise MinecraftT6Error("exact product protocol/implementation profile refused") + if not script.is_file() or script.name != "minecraft_java_status_oracle.py": + raise MinecraftT6Error("exact Minecraft product oracle file absent") + cmd = [sys.executable, str(script), "--port", str(port), + "--expected-version", expected_version, + "--implementation", implementation, "--expected-protocol", str(expected_protocol), + "--timeout", "5", "--out", str(out)] + result = subprocess.run(cmd, capture_output=True, text=True, timeout=12) + if result.returncode != 0 or not out.is_file(): + raise MinecraftT6Error("Minecraft Java status oracle did not pass") + verdict = json.loads(out.read_text(encoding="utf-8")) + if (verdict.get("schema") != "minecraft-java-status-oracle/v1" or + verdict.get("classification") != "SUPPORTED" or + verdict.get("oracleSatisfied") is not True or + verdict.get("java_version") != expected_version or + verdict.get("server_implementation") != implementation or + verdict.get("status_name_contract") != ( + "paper_brand_exact" if implementation == "paper" else "vanilla_exact") or + verdict.get("server_protocol") != expected_protocol or + verdict.get("status_packet_valid") is not True): + raise MinecraftT6Error("Minecraft Java status receipt invalid") + return verdict + + +def classify_java_status_receipt(path: pathlib.Path) -> str: + """Map the trusted, bounded *local oracle result* to an allowlisted cause. + + Do not propagate raw Minecraft status payloads, MOTD, IPs, user names, + private paths or arbitrary exception strings into public receipts. + """ + try: + if not path.is_file() or path.stat().st_size > 4096: + return "missing_or_oversized_receipt" + row = json.loads(path.read_text(encoding="utf-8")) + except (OSError, ValueError): + return "unreadable_receipt" + if not isinstance(row, dict) or row.get("schema") != "minecraft-java-status-oracle/v1": + return "invalid_receipt" + if row.get("classification") == "SUPPORTED" and row.get("oracleSatisfied") is True: + return "status_supported" + if row.get("classification") != "ORACLE_FAILURE" or row.get("oracleSatisfied") is not False: + return "invalid_receipt" + reason = row.get("failure_reason") + if reason == "transport failure": + return "transport_failure" + if reason == "server version mismatch": + return "version_mismatch" + if reason == "server protocol is absent or invalid": + return "protocol_version_invalid" + if reason == "server protocol mismatch": + return "protocol_mismatch" + if reason in { + "truncated VarInt", "VarInt overflow", "VarInt too long", + "packet exceeds bound", "outbound packet exceeds bound", + "truncated packet", "status frame length invalid", + "wrong status packet id", "status JSON length mismatch", + "malformed status JSON", "status payload not an object", + "players status absent", "players counters invalid", + "player counter bounds invalid", + }: + return "invalid_or_truncated_status" + return "other_oracle_failure" + + +def native_server_port_matches(actual: object, desired: object) -> bool: + """Allow only the upstream 1.15.29 catalog's default empty host_ips addition.""" + if not isinstance(actual, dict) or not isinstance(desired, dict): + return False + if actual == desired: + return True + if "host_ips" in desired or set(actual) != set(desired) | {"host_ips"}: + return False + return actual.get("host_ips") == [] and all( + actual.get(key) == value for key, value in desired.items() + ) + + +def port_readback_diagnostics(actual: object, desired: object) -> dict[str, bool]: + """Only boolean comparison; no site network data or secrets in public receipts.""" + if not isinstance(actual, dict) or not isinstance(desired, dict): + return {"port_number": False, "bind_mode": False, + "host_ips_empty_or_absent": False, "only_expected_keys": False} + return { + "port_number": "port_number" in actual and actual.get("port_number") == desired.get("port_number"), + "bind_mode": "bind_mode" in actual and actual.get("bind_mode") == desired.get("bind_mode"), + "host_ips_empty_or_absent": "host_ips" not in actual or actual["host_ips"] == [], + "only_expected_keys": set(actual) <= set(desired) | {"host_ips"}, + } + + +def owner_readback_vector(config: dict, wanted: dict) -> dict[str, bool]: + """Only boolean equality witnesses. Never emit app.config values or owner envs.""" + minecraft = (config.get("minecraft") or {}) if isinstance(config, dict) else {} + env = minecraft.get("additional_envs") or [] if isinstance(minecraft, dict) else [] + found_owner = {v.get("name"): v.get("value") for v in env if isinstance(v, dict)} if isinstance(env, list) else {} + return { + "minecraft_type": minecraft.get("type") == MC_TYPE if isinstance(minecraft, dict) else False, + "minecraft_version": minecraft.get("version") == MC_VERSION if isinstance(minecraft, dict) else False, + "owner_environment": found_owner == REQUIRED_OWNER, + "additional_storage": (config.get("storage") or {}).get("additional_storage") == wanted["storage"]["additional_storage"] if isinstance(config, dict) and isinstance(config.get("storage") or {}, dict) else False, + "server_port": native_server_port_matches((config.get("network") or {}).get("server_port"), wanted["network"]["server_port"]) if isinstance(config, dict) and isinstance(config.get("network") or {}, dict) else False, + } + + +def owned_config(config: dict, wanted: dict) -> bool: + return all(owner_readback_vector(config, wanted).values()) + + +def product_catalog_admitted(product: str, version: str) -> bool: + """Bound CLI catalog claims before any DDP/session or App mutation.""" + return (product == "legacy-paper" and version in ("1.15.29", "1.15.30")) or ( + product == "vanilla263" and version == "1.15.31") + + +def parse_guest_args(argv=None) -> argparse.Namespace: + """Exact source-only parsing; vanilla catalog isn't registered until after parse.""" + p = argparse.ArgumentParser() + p.add_argument("--host", default="127.0.0.1") + p.add_argument("--port", required=True, type=int) + p.add_argument("--status-port", required=True, type=int) + p.add_argument("--status-oracle", required=True, type=pathlib.Path) + p.add_argument("--password-file", required=True, type=pathlib.Path) + p.add_argument("--control-dir", required=True, type=pathlib.Path) + p.add_argument("--foundry-commit", required=True) + p.add_argument("--expected-system-version", required=True) + p.add_argument("--out", required=True, type=pathlib.Path) + p.add_argument("--collage-source-dir", type=pathlib.Path) + p.add_argument("--collage-https-port", type=int) + p.add_argument("--collage-source-ref") + p.add_argument("--lantern-zip", type=pathlib.Path) + p.add_argument("--lantern-manifest", type=pathlib.Path) + p.add_argument("--native-world-rollback", action="store_true") + p.add_argument("--native-offline-player", action="store_true") + p.add_argument("--native-backend-region", action="store_true") + p.add_argument("--native-player-forward") + p.add_argument("--native-player-node-path", type=pathlib.Path) + p.add_argument("--native-catalog-profile", choices=("1.15.29", "1.15.30", "1.15.31"), + default="1.15.29") + p.add_argument("--product-profile", choices=("legacy-paper", "vanilla263"), + default="legacy-paper") + p.add_argument("--tls", action="store_true") + p.add_argument("--timeout", type=float, default=8) + p.add_argument("--job-timeout", type=float, default=600) + p.add_argument("--state-timeout", type=float, default=480) + a = p.parse_args(argv) + if not product_catalog_admitted(a.product_profile, a.native_catalog_profile): + p.error("PRODUCT_SCOPED_CATALOG_NOT_ADMITTED") + return a + + +def main() -> int: + a = parse_guest_args() + if a.product_profile == "vanilla263": + configure_exact_vanilla263() + started = time.monotonic() + payload = { + "schema": "truenas-minecraft-vanilla263-t6/v1" if VANILLA263 else "truenas-minecraft-paper-t6/v1", + "classification": "ORACLE_FAILURE", "oracleSatisfied": False, + "product": "vanilla-26.3" if VANILLA263 else "paper-upstream-1.21.11", "target": EXPECTED_TARGET, + "cleanup": {"attempted": False, "zero_residue": False}, + "secret_values_captured": False, + } + ws = None + dataset_created = False + app_created = False + try: + if (not VANILLA263 and a.native_catalog_profile == "1.15.30" and + a.collage_source_dir is None): + raise MinecraftT6Error("1.15.30 catalog profile is COLLAGE P2 opt-in only") + if (a.lantern_zip is None) != (a.lantern_manifest is None): + raise MinecraftT6Error("native Lantern source ZIP and manifest required as pair") + if a.lantern_zip is not None and (not VANILLA263 or + a.collage_source_dir is None): + raise MinecraftT6Error("native Lantern staging needs exact vanilla263 and read-only COLLAGE source") + if a.native_world_rollback and (not VANILLA263 or + a.lantern_zip is None or a.collage_source_dir is None): + raise MinecraftT6Error("G3 native rollback requires exact vanilla263 G2 and signed Lantern ZIP") + if a.native_offline_player and (not a.native_world_rollback or + not a.native_player_forward or a.native_player_node_path is None): + raise MinecraftT6Error("G4 client requires exact G3 rollback and owned loopback") + if a.native_backend_region and (not a.native_offline_player or + not a.native_world_rollback or not VANILLA263 or + a.collage_source_dir is None): + raise MinecraftT6Error("G4 backend region requires exact native G2/G3 and player") + if not a.native_offline_player and (a.native_player_forward or + a.native_player_node_path is not None): + raise MinecraftT6Error("G4 client arguments without explicit admission") + lantern_blob = None + lantern_evidence = None + if a.lantern_zip is not None: + from qualify_vanilla263_native_lantern_stage import ( + verify_exact_candidate, + ) + lantern_blob, lantern_evidence = verify_exact_candidate( + a.lantern_zip, a.lantern_manifest) + control = load_control(a.control_dir, a.foundry_commit, + catalog_version=a.native_catalog_profile) + if a.expected_system_version != "TrueNAS-" + EXPECTED_TARGET: + raise MinecraftT6Error("requested exact system version drift") + desired = runtime_values(control) + if a.native_offline_player: + from qualify_native_vanilla263_loopback_player_overlay import ( + BOT_NAME, BOT_OFFLINE_UUID, PROFILE, derive, + ) + admission = { + "schema": PROFILE, "rdte_disposable": True, + "experiment": "offline-mineflayer-player-causal", + "physical_truenas_apply": "HOLD", + "operational_selection": False, "external_ingress": False, + "qemu_network": "user", "host_forward": a.native_player_forward, + "guest_target": "TrueNAS-" + EXPECTED_TARGET, + "authorized_bot": BOT_NAME, + } + overlay = derive(control, admission) + if overlay["host_port"] != a.status_port: + raise MinecraftT6Error("G4 client and actual native QEMU port differ") + desired = overlay["values"] + payload["native_player_profile"] = { + "schema": PROFILE, "classification": "DISPOSABLE_OVERLAY_BOUND", + "operational_selection": False, + "physical_truenas_apply": "HOLD", + "client_online_account_qualified": False, + "rcon_enabled": False, + } + sentinel_bytes = (("SUPRACRAFT-VANILLA263-T6:" if VANILLA263 else "SUPRACRAFT-PAPER-T6:") + control["control_sha256"] + "\n").encode("ascii") + payload["foundry_ref"] = a.foundry_commit + payload["control_sha256"] = control["control_sha256"] + if not a.status_oracle.is_file(): + raise MinecraftT6Error("qualified status oracle source absent") + password = a.password_file.read_text(encoding="utf-8").strip() + ws = WebSocket(a.host, a.port, timeout=a.timeout, tls=a.tls) + ws.send_json({"msg": "connect", "version": "1", "support": ["1"]}) + if wait_for(ws, lambda m: m.get("msg") in {"connected", "failed"}).get("msg") != "connected": + raise MinecraftT6Error("DDP connection failed") + call = sequenced_guest_ddp_call(ws) + def one(method, filters): + rows = call(method, [filters]) + if not isinstance(rows, list) or len(rows) > 1: + raise MinecraftT6Error(method + " returned ambiguous query") + return rows[0] if rows else None + def wait_job(job, label): + if not isinstance(job, int): + raise MinecraftT6Error(label + " did not return job ID") + deadline = time.monotonic() + a.job_timeout + while time.monotonic() < deadline: + state = call("core.get_jobs", [[["id", "=", job]], {"get": True}]) + if isinstance(state, dict) and state.get("state") == "SUCCESS": + return + if isinstance(state, dict) and state.get("state") in {"FAILED", "ABORTED"}: + payload["failed_job"] = { + "phase": label, "job_id": job, **safe_job_error(state, password) + } + raise MinecraftT6Error(label + " returned terminal failed job") + time.sleep(2) + raise MinecraftT6Error(label + " job timeout") + def getapp(): + return one("app.query", [["id", "=", APP]]) + def wait_app(): + deadline = time.monotonic() + a.state_timeout + while time.monotonic() < deadline: + app = getapp() + if a.native_offline_player: + from qualify_native_vanilla263_loopback_player_overlay import ( + redacted_native_startup_census, + ) + witness = redacted_native_startup_census(app) + history = payload.setdefault("native_startup_states_seen", []) + if witness["native_app_state"] not in history: + history.append(witness["native_app_state"]) + payload["native_startup_last_census"] = witness + if isinstance(app, dict) and app.get("state") == "RUNNING": + matching_image = qualified_native_image(app, + image_fragment=CATALOG_PROFILES[a.native_catalog_profile]["image"]) + if matching_image is None: + raise MinecraftT6Error("native Minecraft App runtime image differs from digest-pinned source") + payload["observed_image_reference"] = matching_image + payload["image_digest_qualification"] = "SOURCE_AND_GUEST_REF_MATCH" + cfg = call("app.config", [APP]) + if a.native_offline_player: + # Never grant OP or disable online account auth in an + # unconfirmed App. Read exact isolated source overlay, + # no raw config/provider fields in public receipts. + cm = cfg.get("minecraft") if isinstance(cfg, dict) else None + if (not isinstance(cm, dict) or + cm.get("online_mode") is not False or + cm.get("ops") != [BOT_OFFLINE_UUID] or + cm.get("enable_rcon") is not False or + cm.get("rcon_password") != "" or + cm.get("whitelist") != []): + raise MinecraftT6Error( + "disposable native player auth/OP profile mismatch") + match_vector = owner_readback_vector(cfg, desired) + if all(match_vector.values()): + return app + # Emit only field-level match booleans; no actual user values, + # storage paths, environment variables or credentials. + payload["owner_readback_match"] = match_vector + network = (cfg.get("network") or {}) if isinstance(cfg, dict) else {} + payload["server_port_readback_match"] = port_readback_diagnostics( + network.get("server_port") if isinstance(network, dict) else None, + desired["network"]["server_port"], + ) + raise MinecraftT6Error("RUNNING App read-back did not match owner") + if isinstance(app, dict) and app.get("state") in {"CRASHED", "ERROR"}: + raise MinecraftT6Error("Minecraft App reported terminal runtime failure") + time.sleep(3) + raise MinecraftT6Error("Minecraft App never reached verified RUNNING") + def read_sentinel(): + row = call("core.download", ["filesystem.get", [SENTINEL_PATH], "paper-t6-sentinel.txt", True]) + if not isinstance(row, list) or len(row) != 2 or not isinstance(row[0], int): + raise MinecraftT6Error("sentinel download response invalid") + if not isinstance(row[1], str) or not row[1].startswith("/_download/") or ".." in row[1]: + raise MinecraftT6Error("sentinel download path is outside middleware") + wait_job(row[0], "world sentinel download") + content = middleware_http_bytes(a.host, a.port, a.tls, row[1], timeout=a.timeout) + if content != sentinel_bytes: + raise MinecraftT6Error("world sentinel bytes changed or disappeared") + return hashlib.sha256(content).hexdigest() + def stat_level(): + try: + item = call("filesystem.stat", [WORLD_LEVEL]) + except Exception: + return None + if isinstance(item, dict) and isinstance(item.get("size"), int) and item["size"] > 0: + return {"size": item["size"], "type": item.get("type")} + return None + def wait_status(label): + deadline = time.monotonic() + a.state_timeout + last = None + attempts = 0 + level_stat_attempted = False + level_ever_observed = False + status_ever_satisfied = False + status_cause = "not_probed" + while time.monotonic() < deadline: + attempts += 1 + receipt = a.out.with_name(a.out.stem + "-status-" + label + ".json") + try: + verdict = safe_status_oracle(a.status_oracle, a.status_port, receipt, + expected_version=MC_VERSION, + implementation=MC_IMPLEMENTATION, + expected_protocol=MC_PROTOCOL) + status_ever_satisfied = True + status_cause = "status_supported" + level_stat_attempted = True + level = stat_level() + level_ever_observed = level_ever_observed or (level is not None) + if level is not None: + payload.setdefault("readiness", {})[label] = {"protocol": verdict, "level": level} + return + except (OSError, ValueError, MinecraftT6Error, subprocess.TimeoutExpired) as exc: + last = type(exc).__name__ + # The oracle writes a bounded local failure receipt even on a + # failed connection. Only copy an allowlisted category. + status_cause = classify_java_status_receipt(receipt) + # Sample world independently of protocol success; a server may + # create level.dat while no TCP status listener is accessible. + # A negative stat is not a proof that the world is absent. + if attempts == 1 or attempts % 8 == 0: + level_stat_attempted = True + level_ever_observed = level_ever_observed or (stat_level() is not None) + time.sleep(4) + snapshot = { + "attempts": attempts, + "status_oracle_ever_satisfied": status_ever_satisfied, + "level_stat_attempted": level_stat_attempted, + "level_dat_ever_observed": level_ever_observed, + "last_status_cause": status_cause, + "last_exception_kind": last if last in { + "OSError", "ValueError", "MinecraftT6Error", "TimeoutExpired" + } else "none_or_other", + } + # Middleware reachability and App lifecycle state are separate + # evidence, never substitutes for the Java/status+level.dat oracle. + try: + app = getapp() + state = app.get("state") if isinstance(app, dict) else None + snapshot["middleware_app_query_succeeded"] = True + snapshot["app_state"] = state if state in { + "RUNNING", "DEPLOYING", "CRASHED", "STOPPED", "UNKNOWN" + } else "other_or_absent" + except Exception: + snapshot["middleware_app_query_succeeded"] = False + snapshot["app_state"] = "query_failed" + payload.setdefault("readiness_diagnostics", {})[label] = snapshot + raise MinecraftT6Error("Minecraft runtime status+world did not converge") + def delete_app(label): + nonlocal app_created + wait_job(call("app.delete", [APP, { + "remove_images": False, + "remove_ix_volumes": True, + "force_remove_custom_app": False, + }]), label) + app_created = False + if getapp() is not None: + raise MinecraftT6Error("App survived " + label) + + auth = call("auth.login_ex", [{"mechanism": "PASSWORD_PLAIN", + "username": "truenas_admin", "password": password}]) + if not isinstance(auth, dict) or auth.get("response_type") != "SUCCESS": + raise MinecraftT6Error("middleware authentication failed") + if call("system.version", []) != a.expected_system_version: + raise MinecraftT6Error("installed TrueNAS exact identity mismatch") + methods = call("core.get_methods", []) + required = {"app.query", "app.config", "app.create", "app.delete", "app.start", + "app.stop", "app.redeploy", "pool.dataset.query", "pool.dataset.create", + "pool.dataset.delete", "filesystem.stat", "filesystem.setperm"} + if a.native_world_rollback: + required.update({"zfs.snapshot.create", "zfs.snapshot.query", + "zfs.snapshot.rollback", "filesystem.listdir"}) + if not isinstance(methods, dict) or not required.issubset(methods): + raise MinecraftT6Error("required native TrueNAS methods missing") + if getapp() is not None or one("pool.dataset.query", [["id", "=", DATASET]]) is not None: + raise MinecraftT6Error("refusing to adopt pre-existing app or ZFS world dataset") + catalog = call("catalog.get_app_details", ["minecraft", {"train": "community"}]) + versions = (catalog or {}).get("versions") or {} + expected_catalog = control["runtime"]["catalog_version_candidate"] + # Record bounded public-safe discovered revision identities, not catalog secrets. + payload["catalog_discovery"] = { + "catalog_app": (catalog or {}).get("name"), + "latest_version": (catalog or {}).get("latest_version"), + "healthy": (catalog or {}).get("healthy"), + "visible_versions": sorted(str(v) for v in versions)[:32] if isinstance(versions, dict) else [], + "requested_version": expected_catalog, + "upstream_apps_commit": CATALOG_PROFILES[a.native_catalog_profile]["apps_commit"], + } + if not isinstance(versions, dict) or expected_catalog not in versions: + raise MinecraftT6Error("exact native Minecraft catalog version unavailable") + payload["catalog_version_verified"] = True + + dataset = call("pool.dataset.create", [{"name": DATASET, "type": "FILESYSTEM", + "share_type": "GENERIC", "comments": "disposable Minecraft Paper T6"}]) + if not isinstance(dataset, dict) or dataset.get("id") != DATASET: + raise MinecraftT6Error("created dataset identity mismatch") + dataset_created = True + perms = call("filesystem.setperm", [{"path": WORLD, "uid": 568, "gid": 568, + "mode": "775", "options": {"stripacl": True, "recursive": False, "traverse": False}}]) + if isinstance(perms, int) and not isinstance(perms, bool): + wait_job(perms, "world directory owner") + elif perms not in (None, True): + raise MinecraftT6Error("unexpected world permission result") + observed_perms = call("filesystem.stat", [WORLD]) + if (not isinstance(observed_perms, dict) or observed_perms.get("uid") != 568 + or observed_perms.get("gid") != 568): + raise MinecraftT6Error("writable world ownership readback mismatch") + upload = multipart_upload(a.host, a.port, a.tls, "truenas_admin", password, + SENTINEL_PATH, sentinel_bytes, 0o644, a.timeout) + wait_job(upload, "world sentinel write") + payload["sentinel_sha256"] = read_sentinel() + create_payload = {"app_name": APP, "catalog_app": "minecraft", "train": "community", + "version": control["runtime"]["catalog_version_candidate"], + "values": desired} + app_created = True # ownership was clean immediately before mutating request + wait_job(call("app.create", [create_payload]), "Minecraft first create") + wait_app() + wait_status("first") + read_sentinel() + def observe_collage_after_native_readiness(): + if (a.collage_https_port is None or a.collage_source_ref is None + or a.collage_https_port <= 0): + raise MinecraftT6Error("COLLAGE P2 readback admission incomplete") + from truenas_middleware_collage_readback_p2 import ( + ObservationError, run_real_readback, + ) + try: + payload["collage_readback"] = run_real_readback( + call, + collage=a.collage_source_dir, + https_port=a.collage_https_port, + state_dir=a.out.parent, + expected_source_sha=a.collage_source_ref, + profile="vanilla263" if VANILLA263 else "paper", + ) + except ObservationError as err: + # The helper emits only a fixed reason code, no raw auth, + # certificate, network or client traces. + raise MinecraftT6Error("COLLAGE P2: " + str(err)) from None + read_sentinel() + + # The exact Lantern ZIP stage/persistence was independently accepted + # in 006/007/008 despite terminal scoped-key failures. Do not repay + # three App restarts before attempting the still-unqualified G1/G2 + # read-only Go observer. Observe the verified first native App here; + # on success, retain this same App/world for real ZIP stage/reinstall. + # Legacy native-only and Paper P2 default paths remain unchanged. + if a.collage_source_dir is not None: + observe_collage_after_native_readiness() + + first_level = payload["readiness"]["first"]["level"]["size"] + + wait_job(call("app.stop", [APP]), "Minecraft stop") + if a.native_world_rollback: + from qualify_vanilla263_native_world_rollback import take_owned_pre_stage_snapshot + try: + payload["native_world_rollback"] = take_owned_pre_stage_snapshot(call) + except Exception as exc: + from qualify_vanilla263_native_world_rollback import Refused + if isinstance(exc, Refused): + raise MinecraftT6Error("G3 snapshot: " + str(exc)) from None + raise MinecraftT6Error("G3 snapshot: FIXED_NATIVE_SNAPSHOT_UNAVAILABLE") from None + if lantern_blob is not None: + from qualify_vanilla263_native_lantern_stage import stage + try: + payload["lantern_artifact_stage"] = stage( + call, wait_job, multipart_upload, middleware_http_bytes, + host=a.host, port=a.port, tls=a.tls, + username="truenas_admin", password=password, + timeout=a.timeout, blob=lantern_blob, evidence=lantern_evidence) + except Exception as exc: + # Report only the bounded code. Never emit credentials, + # middleware errors or raw artifacts into public GHA logs. + from qualify_vanilla263_native_lantern_stage import Refused + if isinstance(exc, Refused): + raise MinecraftT6Error("native Lantern stage: " + str(exc)) from None + raise MinecraftT6Error("native Lantern stage: GUEST_STAGE_UNAVAILABLE") from None + wait_job(call("app.start", [APP]), "Minecraft start") + wait_app() + wait_status("restart") + if lantern_blob is not None: + from qualify_vanilla263_native_lantern_stage import readback, SHA + require_stage = readback(call,wait_job,middleware_http_bytes, + host=a.host,port=a.port,tls=a.tls,timeout=a.timeout) + if require_stage != SHA: + raise MinecraftT6Error("native Lantern staged file lost during restart") + payload["lantern_artifact_stage"]["persisted_on_restart"] = True + wait_job(call("app.redeploy", [APP]), "Minecraft redeploy") + wait_app() + wait_status("redeploy") + delete_app("Minecraft retain-world delete") + if one("pool.dataset.query", [["id", "=", DATASET]]) is None: + raise MinecraftT6Error("world dataset destroyed by App delete") + if stat_level() is None: + raise MinecraftT6Error("world level.dat absent after App deletion") + read_sentinel() + payload["world_retained_on_delete"] = True + app_created = True + wait_job(call("app.create", [create_payload]), "Minecraft reinstall") + wait_app() + wait_status("reinstall") + if lantern_blob is not None: + from qualify_vanilla263_native_lantern_stage import readback, SHA + actual = readback(call,wait_job,middleware_http_bytes, + host=a.host,port=a.port,tls=a.tls,timeout=a.timeout) + if actual != SHA: + raise MinecraftT6Error("native Lantern staged file lost across reinstall") + payload["lantern_artifact_stage"]["persisted_on_reinstall"] = True + # The same exact App owner is rechecked by wait_app() above; + # pre-stage TLS Go observation remains a separate G2 receipt. + if a.native_backend_region: + from qualify_native_vanilla263_anvil_guest_readback import capture + from qualify_native_vanilla263_mineflayer_causal import run as run_player + payload["native_backend_world"] = { + "schema": "supracraft.native-vanilla263-live-backend-region/1", + "classification": "INCOMPLETE_NATIVE_BACKEND_REGION", + "backend_independent_world_oracle_pass": False, + "physical_true_nas_apply": "HOLD", + "collage_mutation_authorized": False, + } + # All emitted codes are fixed source literals, never DDP errors. + def g4_phase(code): + if code not in { + "fixture_preparation", "preaction.stop", "candidate.stop", + "rollback.stop", "preaction.stopped", "candidate.stopped", + "rollback.stopped", "preaction.owner_query", + "candidate.owner_query", "rollback.owner_query", + "preaction.owner_verified", "candidate.owner_verified", + "rollback.owner_verified", "preaction.version_query", + "candidate.version_query", "rollback.version_query", + "preaction.version_verified", "candidate.version_verified", + "rollback.version_verified", "preaction.restart", + "candidate.restart", "rollback.restart", + "preaction.restarted", "candidate.restarted", + "rollback.restarted", + } and code not in { + st + "." + ph + for st in ("preaction", "candidate", "rollback") + for ph in ( + "app_state_query", "app_stopped_verified", + "file_stat", "file_stat_verified", + "file_stat_listing_absent", "file_stat_listing_present", + "file_stat_listing_inconclusive", + "download_request", "download_route_verified", + "download_job", "download_job_completed", + "bytes_fetch", "bytes_verified", + "anvil_decode", "anvil_verified", + ) + }: + raise MinecraftT6Error("G4 phase code not admitted") + payload["native_backend_world"]["last_backend_phase"] = code + g4_phase("fixture_preparation") + payload["native_backend_world"]["preparation"] = run_player( + stage="candidate-prep", forward=a.native_player_forward, + state_dir=a.out.parent, node_path=a.native_player_node_path) + def sample_owned_backend(stage): + # A separate native App stop after the actual client action + # flushes server-owned region files. Never decode packets as + # if they were an authoritative backend world state. + g4_phase(stage + ".stop") + wait_job(call("app.stop", [APP]), "G4 backend quiesce " + stage) + g4_phase(stage + ".stopped") + g4_phase(stage + ".owner_query") + cfg = call("app.config", [APP]) + if not owned_config(cfg, desired): + raise MinecraftT6Error("G4 region owner drift") + g4_phase(stage + ".owner_verified") + g4_phase(stage + ".version_query") + if call("system.version", []) != a.expected_system_version: + raise MinecraftT6Error("G4 region target drift") + g4_phase(stage + ".version_verified") + obs = capture(call=call, wait_job=wait_job, + fetch_bytes=lambda p: middleware_http_bytes( + a.host, a.port, a.tls, p, timeout=a.timeout), + stage=stage, on_phase=g4_phase) + # Upgraded only in this signed, actual guest runner after + # real DDP owner/session, stop, bounded fixed-path download + # and independent Anvil block decoding all succeeded. + obs["classification"] = "LIVE_NATIVE_STOPPED_OWNER_ANVIL_READBACK" + obs["live_guest_identity_independently_verified"] = True + obs["server_quiescence_proven"] = True + obs["native_guest_server_owned_bytes_proven"] = True + g4_phase(stage + ".restart") + wait_job(call("app.start", [APP]), "G4 backend restart " + stage) + wait_app() + wait_status("g4-" + stage) + g4_phase(stage + ".restarted") + return obs + payload["native_backend_world"]["preaction"] = sample_owned_backend( + "preaction") + if a.native_offline_player: + from qualify_native_vanilla263_mineflayer_causal import run as run_player + payload["native_player_gameplay"] = { + "candidate": run_player(stage="candidate", + forward=a.native_player_forward, state_dir=a.out.parent, + node_path=a.native_player_node_path), + "same_native_guest_client_causal_visibility": False, + "backend_independent_world_oracle_pass": False, + "collage_managed_deployment": False, + "physical_true_nas_apply": "HOLD", + } + if a.native_backend_region: + payload["native_backend_world"]["candidate"] = sample_owned_backend( + "candidate") + if a.native_world_rollback: + from qualify_vanilla263_native_world_rollback import restore_owned_baseline_after_injected_fault + try: + restored = restore_owned_baseline_after_injected_fault( + call, wait_job, wait_app, wait_status) + except Exception as exc: + from qualify_vanilla263_native_world_rollback import Refused + if isinstance(exc, Refused): + raise MinecraftT6Error("G3 rollback: " + str(exc)) from None + raise MinecraftT6Error("G3 rollback: FIXED_NATIVE_RESTORE_UNAVAILABLE") from None + payload["native_world_rollback"].update(restored) + if a.native_offline_player: + from qualify_native_vanilla263_mineflayer_causal import run as run_player + payload["native_player_gameplay"]["rollback"] = run_player( + stage="rollback", forward=a.native_player_forward, + state_dir=a.out.parent, node_path=a.native_player_node_path) + payload["native_player_gameplay"]["same_native_guest_client_causal_visibility"] = True + if a.native_backend_region: + payload["native_backend_world"]["rollback"] = sample_owned_backend( + "rollback") + stages = payload["native_backend_world"] + p0, p1, p2 = (stages[name] for name in + ("preaction", "candidate", "rollback")) + if len({p0["region_sha256"], p1["region_sha256"], + p2["region_sha256"]}) != 3: + raise MinecraftT6Error("G4 region state replay or noncausal stage") + if not all(p.get("live_guest_identity_independently_verified") + is True and p.get("server_quiescence_proven") is True + and p.get("native_guest_server_owned_bytes_proven") is True + for p in (p0, p1, p2)): + raise MinecraftT6Error("G4 missing independent backend checkpoints") + stages["classification"] = "LIVE_NATIVE_BACKEND_THREE_CHECKPOINTS" + stages["same_owned_guest_three_checkpoints"] = True + stages["backend_independent_world_oracle_pass"] = True + read_sentinel() + if first_level <= 0: + raise MinecraftT6Error("world level.dat size invalid") + payload["world_rebound_after_reinstall"] = True + delete_app("Minecraft final delete") + if stat_level() is None: + raise MinecraftT6Error("world not retained before explicit owned cleanup") + read_sentinel() + result = call("pool.dataset.delete", [DATASET, {"recursive": True, "force": False}]) + if result is not True: + raise MinecraftT6Error("owned ZFS dataset delete was not confirmed") + dataset_created = False + if one("pool.dataset.query", [["id", "=", DATASET]]) is not None: + raise MinecraftT6Error("world dataset cleanup left residue") + payload["cleanup"] = {"attempted": True, "zero_residue": True, "app_absent": True, + "owned_world_dataset_absent": True} + payload["classification"] = "SUPPORTED" + payload["oracleSatisfied"] = True + payload["detail"] = ("native vanilla 26.3 status/world lifecycle verified in disposable TrueNAS; not physical site or managed COLLAGE" if VANILLA263 else "native Paper Minecraft status/world lifecycle verified in disposable TrueNAS; not vanilla 26.3 or physical site") + except Exception as exc: + payload["failure_type"] = type(exc).__name__ + payload["detail"] = str(exc) if isinstance(exc, MinecraftT6Error) else "runtime exception; inspect bounded guest diagnostics" + if ws is not None and (app_created or dataset_created): + cleanup = {"attempted": True, "zero_residue": False} + try: + if app_created and getapp() is not None: + delete_app("failure cleanup App") + cleanup["app_absent"] = getapp() is None + if dataset_created and cleanup["app_absent"] and one("pool.dataset.query", [["id", "=", DATASET]]) is not None: + # Destroy only own dataset, never any unknown/foreign dataset. + removed = call("pool.dataset.delete", [DATASET, {"recursive": True, "force": False}]) + if removed is not True: + raise MinecraftT6Error("failure cleanup dataset refused") + cleanup["owned_world_dataset_absent"] = one("pool.dataset.query", [["id", "=", DATASET]]) is None + cleanup["zero_residue"] = cleanup["app_absent"] and cleanup["owned_world_dataset_absent"] + except Exception as cexc: + cleanup["cleanup_error_type"] = type(cexc).__name__ + payload["cleanup"] = cleanup + # A failed product oracle need not mean contaminated guest or failed cleanup. + # Prove the guest middleware is still coherent using read-only endpoints. + if ws is not None and payload["cleanup"].get("zero_residue") is True: + try: + system_ok = call("system.version", []) == a.expected_system_version + docker = call("docker.config", []) + payload["post_failure_platform_healthy"] = bool( + system_ok and isinstance(docker, dict) + and getapp() is None + and one("pool.dataset.query", [["id", "=", DATASET]]) is None + ) + except Exception: + payload["post_failure_platform_healthy"] = False + finally: + if ws is not None: + ws.close() + payload["elapsed_seconds"] = round(time.monotonic() - started, 2) + a.out.parent.mkdir(parents=True, exist_ok=True) + a.out.write_text(json.dumps(payload, sort_keys=True, indent=2) + "\n", encoding="utf-8") + print(json.dumps(payload, sort_keys=True)) + return 0 if payload["oracleSatisfied"] else 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/truenas_middleware_official_catalog_t6_probe.py b/scripts/truenas_middleware_official_catalog_t6_probe.py new file mode 100644 index 000000000..5653fb67d --- /dev/null +++ b/scripts/truenas_middleware_official_catalog_t6_probe.py @@ -0,0 +1,465 @@ +#!/usr/bin/env python3 +"""Bounded native official-catalog lifecycle oracle for disposable TrueNAS targets.""" +from __future__ import annotations + +import argparse +import hashlib +import json +import pathlib +import time +from typing import Any + +from truenas_middleware_ddp_probe import WebSocket, ddp_call, wait_for + +EXPECTED_SCHEMA = "semper-supra.official-catalog-truenas-t6-control/1" +EXPECTED_FOUNDRY_REF = "60d56cc1752bc825cb232d72b397ff17fe6d47c4" +EXPECTED_CATALOG_COMMIT = "8d93ac087336ab642492209cb707aa1f9556d324" +EXPECTED_CONTROL_ID = "ntfy" +EXPECTED_CATALOG_VERSION = "1.1.21" +EXPECTED_APP_VERSION = "v2.28.0" +EXPECTED_LIB_VERSION = "2.3.4" +EXPECTED_LIB_VERSION_HASH = "2e3a8847308fb2eb0da046018f287c73822c094b5950a10377c3235794ff1242" +BOOTSTRAP_METHODS = {"core.get_methods"} + +REQUIRED_DISCOVERED_METHODS = { + "system.version", + "app.query", + "app.config", + "app.create", + "app.update", + "app.redeploy", + "app.stop", + "app.start", + "app.delete", + "app.upgrade", +} + + +def canonical_sha256(value: Any) -> str: + raw = json.dumps(value, sort_keys=True, separators=(",", ":")).encode() + return hashlib.sha256(raw).hexdigest() + + +def load_control(root: pathlib.Path, foundry_commit: str) -> dict[str, Any]: + path = root / "control.json" + control = json.loads(path.read_text(encoding="utf-8")) + if control.get("schema") != EXPECTED_SCHEMA: + raise RuntimeError("official-catalog control schema drifted") + if foundry_commit != EXPECTED_FOUNDRY_REF or control.get("foundry_ref") != EXPECTED_FOUNDRY_REF: + raise RuntimeError("Foundry source identity drifted") + source = control.get("catalog_source") or {} + if source.get("commit") != EXPECTED_CATALOG_COMMIT: + raise RuntimeError("catalog source commit drifted") + item = control.get("control") or {} + expected = { + "id": EXPECTED_CONTROL_ID, + "catalog_version": EXPECTED_CATALOG_VERSION, + "app_version": EXPECTED_APP_VERSION, + "lib_version": EXPECTED_LIB_VERSION, + "lib_version_hash": EXPECTED_LIB_VERSION_HASH, + } + for key, value in expected.items(): + if item.get(key) != value: + raise RuntimeError(f"control {key} drifted") + if control.get("secrets_captured") is not False: + raise RuntimeError("control unexpectedly captures secrets") + if control.get("universal_qualified") is not False: + raise RuntimeError("unqualified control falsely claims universal qualification") + claimed = control.get("control_sha256") + unsigned = dict(control) + unsigned.pop("control_sha256", None) + if claimed != canonical_sha256(unsigned): + raise RuntimeError("control canonical identity mismatch") + runtime = control.get("runtime") or {} + create = runtime.get("create_payload") or {} + if create.get("custom_app") is not False or create.get("catalog_app") != EXPECTED_CONTROL_ID: + raise RuntimeError("control is not native-catalog create") + if create.get("version") != EXPECTED_CATALOG_VERSION or create.get("train") != "community": + raise RuntimeError("native-catalog create identity drifted") + if (create.get("values") or {}).get("TZ") != "Etc/UTC": + raise RuntimeError("initial config oracle drifted") + update = runtime.get("config_update_payload") or {} + if (update.get("values") or {}).get("TZ") != "Europe/Berlin": + raise RuntimeError("update config oracle drifted") + return control + + +def normalize_system_version(value: Any) -> str: + if not isinstance(value, str) or not value: + raise RuntimeError("system.version did not return a non-empty string") + return value.removeprefix("TrueNAS-") + + +def owned(app: dict[str, Any], control: dict[str, Any]) -> bool: + item = control["control"] + metadata = app.get("metadata") or {} + return ( + app.get("custom_app") is False + and app.get("version") == item["catalog_version"] + and metadata.get("name") == item["id"] + and metadata.get("app_version") == item["app_version"] + and metadata.get("lib_version") == item["lib_version"] + and metadata.get("lib_version_hash") == item["lib_version_hash"] + ) + + +def native_lineage(app: dict[str, Any], control: dict[str, Any]) -> bool: + metadata = app.get("metadata") or {} + return app.get("custom_app") is False and metadata.get("name") == control["control"]["id"] + + +def native_runtime_healthy(app: dict[str, Any], control: dict[str, Any], *, exact_identity: bool = True) -> bool: + if (owned(app, control) if exact_identity else native_lineage(app, control)) is False: + return False + if app.get("state") != "RUNNING": + return False + workloads = app.get("active_workloads") or {} + details = workloads.get("container_details") or [] + containers = workloads.get("containers") + if not isinstance(containers, int) or containers < 1 or not details: + return False + primary = control["control"]["id"] + return any( + item.get("service_name") == primary and item.get("state") == "running" + for item in details + ) + + +def healthy_running(app: dict[str, Any], control: dict[str, Any]) -> bool: + return native_runtime_healthy(app, control, exact_identity=True) + + +def plan( + app: dict[str, Any] | None, + config: dict[str, Any] | None, + control: dict[str, Any], + *, + desired_present: bool = True, + desired_running: bool = True, + desired_tz: str = "Etc/UTC", +) -> str: + if app is None: + return "CREATE" if desired_present else "NOOP" + if not owned(app, control): + return "BLOCK_FOREIGN" + if not desired_present: + return "DELETE" + state = app.get("state") + if desired_running and state == "STOPPED": + return "START" + if not desired_running and state == "RUNNING": + return "STOP" + if state not in {"RUNNING", "STOPPED"}: + return "BLOCK_AMBIGUOUS" + if (config or {}).get("TZ") != desired_tz: + return "UPDATE" + return "NOOP" + + +def main() -> int: + p = argparse.ArgumentParser() + p.add_argument("--host", default="127.0.0.1") + p.add_argument("--port", type=int, required=True) + p.add_argument("--password-file", required=True) + p.add_argument("--control-dir", type=pathlib.Path, required=True) + p.add_argument("--foundry-commit", required=True) + p.add_argument("--out", required=True) + p.add_argument("--tls", action="store_true") + p.add_argument("--timeout", type=float, default=8.0) + p.add_argument("--job-timeout", type=float, default=300.0) + p.add_argument("--state-timeout", type=float, default=240.0) + a = p.parse_args() + + started = time.time() + payload: dict[str, Any] = { + "schema": "truenas-official-catalog-t6/v1", + "classification": "ORACLE_FAILURE", + "oracleSatisfied": False, + "foundry_commit": a.foundry_commit, + "catalog_commit": EXPECTED_CATALOG_COMMIT, + "control_id": EXPECTED_CONTROL_ID, + "jobs": {}, + "states": [], + "plans": [], + "upgrade": None, + "cleanup": {"attempted": False, "app_absent": False}, + "secret_values_captured": False, + } + ws = None + request_id = 1 + created = False + + try: + control = load_control(a.control_dir, a.foundry_commit) + app_name = control["runtime"]["app_name"] + payload["app_name"] = app_name + password = pathlib.Path(a.password_file).read_text(encoding="utf-8").strip() + + ws = WebSocket(a.host, a.port, timeout=a.timeout, tls=a.tls) + ws.send_json({"msg": "connect", "version": "1", "support": ["1"]}) + connected = wait_for(ws, lambda m: m.get("msg") in {"connected", "failed"}) + if connected.get("msg") != "connected": + raise RuntimeError(f"DDP connection failed: {connected!r}") + + def call(method, params): + nonlocal request_id + result = ddp_call(ws, str(request_id), method, params) + request_id += 1 + return result + + def wait_job(job_id: int, label: str): + deadline = time.monotonic() + a.job_timeout + last = None + while time.monotonic() < deadline: + last = call("core.get_jobs", [[["id", "=", job_id]], {"get": True}]) + if last: + state = last.get("state") + if state == "SUCCESS": + payload["jobs"][label] = {"id": job_id, "state": state} + return + if state in {"FAILED", "ABORTED"}: + raise RuntimeError( + f"{label} job {state}: {last.get('error') or last.get('exception')}" + ) + time.sleep(1) + raise RuntimeError(f"{label} job did not reach SUCCESS") + + def query(get: bool = True): + rows = call("app.query", [[["id", "=", app_name]]]) + if not rows: + return None if get else [] + return rows[0] if get else rows + + def config(): + return call("app.config", [app_name]) + + def wait_running(label: str): + deadline = time.monotonic() + a.state_timeout + last = None + while time.monotonic() < deadline: + last = query() + if last and healthy_running(last, control): + payload["states"].append({"label": label, "state": "RUNNING"}) + return last + if last and last.get("state") in {"CRASHED", "ERROR"}: + raise RuntimeError(f"{label} entered failure state: {last!r}") + time.sleep(1) + raise RuntimeError(f"{label} did not become healthy RUNNING: {last!r}") + + def wait_native_running(label: str): + deadline = time.monotonic() + a.state_timeout + last = None + while time.monotonic() < deadline: + last = query() + if last and native_runtime_healthy(last, control, exact_identity=False): + payload["states"].append({"label": label, "state": "RUNNING"}) + return last + time.sleep(1) + raise RuntimeError(f"{label} did not become native-lineage RUNNING: {last!r}") + + def wait_state(expected: str, label: str): + deadline = time.monotonic() + a.state_timeout + last = None + while time.monotonic() < deadline: + last = query() + if last and last.get("state") == expected and owned(last, control): + payload["states"].append({"label": label, "state": expected}) + return last + time.sleep(1) + raise RuntimeError(f"{label} did not reach {expected}: {last!r}") + + def submit(method: str, params: list[Any], label: str): + job_id = call(method, params) + if not isinstance(job_id, int): + raise RuntimeError(f"{method} did not return job id: {job_id!r}") + wait_job(job_id, label) + + def observe(desired_tz: str, **desired): + app = query() + cfg = config() if app else None + action = plan(app, cfg, control, desired_tz=desired_tz, **desired) + payload["plans"].append({ + "action": action, + "desired_present": desired.get("desired_present", True), + "desired_running": desired.get("desired_running", True), + "desired_tz": desired_tz, + "observed_state": app.get("state") if app else "ABSENT", + "observed_tz": cfg.get("TZ") if isinstance(cfg, dict) else None, + }) + return action, app, cfg + + auth = call("auth.login_ex", [{ + "mechanism": "PASSWORD_PLAIN", + "username": "truenas_admin", + "password": password, + }]) + if not isinstance(auth, dict) or auth.get("response_type") != "SUCCESS": + raise RuntimeError("authentication did not return SUCCESS") + + system_version = call("system.version", []) + payload["system_version"] = system_version + target_version = normalize_system_version(system_version) + payload["target_version"] = target_version + if target_version not in control.get("target_versions", []): + raise RuntimeError( + f"target version {system_version!r} ({target_version!r}) is outside control matrix" + ) + + methods = call("core.get_methods", []) + payload["bootstrap_probes"] = {"core.get_methods": True} + method_names = set(methods) if isinstance(methods, dict) else set() + missing = sorted(REQUIRED_DISCOVERED_METHODS - method_names) + payload["required_methods_present"] = not missing + payload["missing_methods"] = missing + if missing: + raise RuntimeError(f"required native catalog methods missing: {missing}") + + if query() is not None: + raise RuntimeError("refusing adopted official-catalog control state") + + action, _, _ = observe("Etc/UTC") + if action != "CREATE": + raise RuntimeError(f"initial plan was not CREATE: {action}") + submit("app.create", [control["runtime"]["create_payload"]], "create_1") + created = True + first = wait_running("create_1") + first_cfg = config() + if first_cfg.get("TZ") != "Etc/UTC": + raise RuntimeError("initial catalog config did not read back TZ=Etc/UTC") + payload["catalog_identity"] = { + "catalog_version": first.get("version"), + "app_version": (first.get("metadata") or {}).get("app_version"), + "lib_version": (first.get("metadata") or {}).get("lib_version"), + "lib_version_hash": (first.get("metadata") or {}).get("lib_version_hash"), + } + action, _, _ = observe("Etc/UTC") + if action != "NOOP": + raise RuntimeError(f"post-create replan was not NOOP: {action}") + + action, _, _ = observe("Etc/UTC", desired_running=False) + if action != "STOP": + raise RuntimeError(f"stop plan was not STOP: {action}") + submit("app.stop", [app_name], "stop") + wait_state("STOPPED", "stop") + + action, _, _ = observe("Etc/UTC", desired_running=True) + if action != "START": + raise RuntimeError(f"start plan was not START: {action}") + submit("app.start", [app_name], "start") + wait_running("start") + + action, _, _ = observe("Europe/Berlin") + if action != "UPDATE": + raise RuntimeError(f"config plan was not UPDATE: {action}") + submit("app.update", [app_name, control["runtime"]["config_update_payload"]], "update") + wait_running("update") + updated_cfg = config() + if updated_cfg.get("TZ") != "Europe/Berlin": + raise RuntimeError("app.config did not read back TZ=Europe/Berlin") + action, _, _ = observe("Europe/Berlin") + if action != "NOOP": + raise RuntimeError(f"post-update replan was not NOOP: {action}") + + submit("app.redeploy", [app_name], "redeploy") + wait_running("redeploy") + + action, _, _ = observe("Europe/Berlin", desired_present=False) + if action != "DELETE": + raise RuntimeError(f"retain-data delete plan was not DELETE: {action}") + submit("app.delete", [app_name, control["runtime"]["delete_options"]], "delete_retain") + created = False + if query() is not None: + raise RuntimeError("app still exists after retain-data delete") + payload["retain_data_delete"] = {"app_absent": True, **control["runtime"]["delete_options"]} + + action, _, _ = observe("Etc/UTC") + if action != "CREATE": + raise RuntimeError(f"reinstall plan was not CREATE: {action}") + submit("app.create", [control["runtime"]["create_payload"]], "create_2") + created = True + wait_running("create_2") + re_cfg = config() + if re_cfg.get("TZ") != "Etc/UTC": + raise RuntimeError("reinstalled config did not read back initial TZ") + action, _, _ = observe("Etc/UTC") + if action != "NOOP": + raise RuntimeError(f"post-reinstall replan was not NOOP: {action}") + + before_upgrade = query() + if before_upgrade.get("upgrade_available") is True: + before_version = before_upgrade.get("version") + submit("app.upgrade", [app_name, {}], "upgrade") + after_upgrade = wait_native_running("upgrade") + payload["upgrade"] = { + "status": "EXECUTED", + "from_version": before_version, + "to_version": after_upgrade.get("version"), + "discovered_identity": { + "catalog_version": after_upgrade.get("version"), + "app_version": (after_upgrade.get("metadata") or {}).get("app_version"), + "lib_version": (after_upgrade.get("metadata") or {}).get("lib_version"), + "lib_version_hash": (after_upgrade.get("metadata") or {}).get("lib_version_hash"), + }, + } + if after_upgrade.get("version") == before_version: + raise RuntimeError("native app.upgrade did not change catalog version") + else: + payload["upgrade"] = { + "status": "NOT_APPLICABLE", + "observed_upgrade_available": before_upgrade.get("upgrade_available"), + "receipt_required": True, + } + + destructive = { + "remove_images": False, + "remove_ix_volumes": True, + "force_remove_custom_app": False, + } + submit("app.delete", [app_name, destructive], "cleanup_delete") + created = False + payload["cleanup"]["attempted"] = True + payload["cleanup"]["app_absent"] = query() is None + payload["cleanup"]["remove_ix_volumes"] = True + if not payload["cleanup"]["app_absent"]: + raise RuntimeError("official-catalog app remained after final cleanup") + + payload["classification"] = "SUPPORTED" + payload["oracleSatisfied"] = True + payload["detail"] = ( + "exact native catalog control converged CREATE/NOOP, stop/start, config UPDATE/read-back/NOOP, " + "conditional native upgrade receipt, redeploy, retain-data delete, reinstall/NOOP, and final cleanup" + ) + except Exception as exc: + payload["detail"] = f"{type(exc).__name__}: {exc}" + if ws is not None and created: + payload["cleanup"]["attempted"] = True + try: + rows = call("app.query", [[["id", "=", payload.get("app_name")]]]) + if rows: + job_id = call("app.delete", [payload["app_name"], { + "remove_images": False, + "remove_ix_volumes": True, + "force_remove_custom_app": False, + }]) + if isinstance(job_id, int): + wait_job(job_id, "failure_cleanup") + payload["cleanup"]["app_absent"] = not call( + "app.query", [[["id", "=", payload.get("app_name")]]] + ) + except Exception as cleanup_exc: + payload["cleanup"]["error"] = f"{type(cleanup_exc).__name__}: {cleanup_exc}" + finally: + if ws is not None: + ws.close() + + payload["elapsed_seconds"] = round(time.time() - started, 3) + pathlib.Path(a.out).write_text( + json.dumps(payload, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + print(json.dumps(payload, indent=2, sort_keys=True)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/truenas_middleware_pool_probe.py b/scripts/truenas_middleware_pool_probe.py new file mode 100755 index 000000000..51a2c4393 --- /dev/null +++ b/scripts/truenas_middleware_pool_probe.py @@ -0,0 +1,195 @@ +#!/usr/bin/env python3 +"""Bounded TrueNAS middleware client for the T3 disposable ZFS-pool oracle.""" +from __future__ import annotations + +import argparse +import json +import pathlib +import time + +from truenas_middleware_ddp_probe import WebSocket, ddp_call, wait_for + + +def disk_name(disk): + return disk.get("devname") or disk.get("name") + + +def main(): + p = argparse.ArgumentParser() + p.add_argument("--host", default="127.0.0.1") + p.add_argument("--port", type=int, required=True) + p.add_argument("--password-file", required=True) + p.add_argument("--out", required=True) + p.add_argument("--pool-name", default="rdtepool") + p.add_argument("--expected-data-disks", type=int, default=2) + p.add_argument("--data-serial-prefix", default="RDTE_DATA_") + p.add_argument("--tls", action="store_true") + p.add_argument("--timeout", type=float, default=8.0) + p.add_argument("--job-timeout", type=float, default=180.0) + a = p.parse_args() + + password = pathlib.Path(a.password_file).read_text(encoding="utf-8").strip() + payload = { + "schema": "truenas-middleware-pool-t3/v1", + "oracleSatisfied": False, + "classification": "ORACLE_FAILURE", + "pool_name": a.pool_name, + "expected_data_disks": a.expected_data_disks, + "data_serial_prefix": a.data_serial_prefix, + "transport": "wss" if a.tls else "ws", + } + started = time.time() + ws = None + try: + payload["phase"] = "connect" + ws = WebSocket(a.host, a.port, timeout=a.timeout, tls=a.tls) + ws.send_json({"msg": "connect", "version": "1", "support": ["1"]}) + connected = wait_for(ws, lambda m: m.get("msg") in {"connected", "failed"}) + if connected.get("msg") != "connected": + raise RuntimeError(f"DDP connection failed: {connected!r}") + + payload["phase"] = "authenticate" + auth = ddp_call( + ws, "1", "auth.login_ex", + [{ + "mechanism": "PASSWORD_PLAIN", + "username": "truenas_admin", + "password": password, + }], + ) + payload["auth_response_type"] = auth.get("response_type") if isinstance(auth, dict) else None + if payload["auth_response_type"] != "SUCCESS": + raise RuntimeError(f"authentication did not return SUCCESS: {auth!r}") + + payload["phase"] = "discover-system" + payload["system_version"] = ddp_call(ws, "2", "system.version", []) + payload["phase"] = "discover-boot-disks" + boot_disks = ddp_call(ws, "3", "boot.get_disks", []) + payload["boot_disks"] = sorted(boot_disks) + payload["phase"] = "discover-disk-details" + disk_details = ddp_call(ws, "4", "disk.details", []) + payload["disk_details"] = disk_details + if not isinstance(disk_details, dict) or not isinstance(disk_details.get("unused"), list): + raise RuntimeError(f"disk.details did not return an unused-disk list: {disk_details!r}") + unused = disk_details["unused"] + payload["unused_disks"] = unused + + expected_serials = { + f"{a.data_serial_prefix}{index}" for index in range(a.expected_data_disks) + } + owned = [ + disk for disk in unused + if disk.get("serial") in expected_serials + ] + payload["owned_data_disks"] = owned + observed_serials = {disk.get("serial") for disk in owned} + if observed_serials != expected_serials or len(owned) != a.expected_data_disks: + raise RuntimeError( + "refusing pool mutation: expected exactly owned serials " + f"{sorted(expected_serials)!r}, found {sorted(s for s in observed_serials if s)!r}" + ) + selected = [disk_name(disk) for disk in owned] + if any(not name for name in selected): + raise RuntimeError("refusing pool mutation: owned data disk missing name/devname") + if len(set(selected)) != len(selected): + raise RuntimeError("refusing pool mutation: duplicate owned data-disk names") + if set(selected) & set(boot_disks): + raise RuntimeError("refusing pool mutation: an owned data disk overlaps the boot pool") + payload["selected_data_disks"] = sorted(selected) + + payload["phase"] = "precondition-pool-absence" + existing = ddp_call(ws, "5", "pool.query", [[["name", "=", a.pool_name]]]) + if existing: + raise RuntimeError(f"refusing pool mutation: pool {a.pool_name!r} already exists") + + create = { + "name": a.pool_name, + "encryption": False, + "allow_duplicate_serials": False, + "topology": { + "data": [{ + "type": "MIRROR", + "disks": selected, + }], + }, + } + payload["create_contract"] = { + "name": a.pool_name, + "encryption": False, + "allow_duplicate_serials": False, + "topology": {"data": [{"type": "MIRROR", "disks": sorted(selected)}]}, + } + payload["phase"] = "pool-create-submit" + job_id = ddp_call(ws, "6", "pool.create", [create]) + if not isinstance(job_id, int): + raise RuntimeError(f"pool.create did not return a legacy DDP job id: {job_id!r}") + payload["job_id"] = job_id + + payload["phase"] = "pool-create-wait" + deadline = time.monotonic() + a.job_timeout + job = None + poll = 7 + while time.monotonic() < deadline: + result = ddp_call( + ws, str(poll), "core.get_jobs", + [[["id", "=", job_id]], {"get": True}], + ) + poll += 1 + if result: + job = result + state = job.get("state") + payload["job_state"] = state + payload["job_progress"] = job.get("progress") + if state == "SUCCESS": + break + if state in {"FAILED", "ABORTED"}: + raise RuntimeError( + f"pool.create job {state}: {job.get('error') or job.get('exception')}" + ) + time.sleep(1) + else: + raise RuntimeError("pool.create job did not reach a terminal state within the bounded timeout") + + if not job or job.get("state") != "SUCCESS": + raise RuntimeError(f"pool.create did not reach SUCCESS: {job!r}") + + payload["phase"] = "verify-pool" + pool = ddp_call( + ws, str(poll), "pool.query", + [[["name", "=", a.pool_name]], {"get": True}], + ) + poll += 1 + payload["pool"] = pool + if pool.get("name") != a.pool_name: + raise RuntimeError(f"pool query returned wrong name: {pool.get('name')!r}") + if pool.get("status") != "ONLINE": + raise RuntimeError(f"created pool is not ONLINE: {pool.get('status')!r}") + if pool.get("healthy") is not True: + raise RuntimeError(f"created pool is not healthy: {pool.get('healthy')!r}") + + payload["phase"] = "verify-pool-disks" + pool_disks = ddp_call(ws, str(poll), "pool.get_disks", [pool["id"]]) + payload["pool_disks"] = sorted(pool_disks) + if set(pool_disks) != set(selected): + raise RuntimeError( + f"pool disk membership mismatch: selected={sorted(selected)!r} pool={sorted(pool_disks)!r}" + ) + + payload["phase"] = "complete" + payload["oracleSatisfied"] = True + payload["classification"] = "SUPPORTED" + payload["detail"] = "real two-disk mirror pool is ONLINE, healthy, and owns exactly the selected disposable disks" + except Exception as exc: + payload["detail"] = f"{type(exc).__name__}: {exc}" + finally: + if ws is not None: + ws.close() + + payload["elapsed_seconds"] = round(time.time() - started, 3) + pathlib.Path(a.out).write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8") + print(json.dumps(payload, indent=2, sort_keys=True)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/truenas_middleware_wow_sidecar_t6_probe.py b/scripts/truenas_middleware_wow_sidecar_t6_probe.py new file mode 100644 index 000000000..37bb3e3a2 --- /dev/null +++ b/scripts/truenas_middleware_wow_sidecar_t6_probe.py @@ -0,0 +1,370 @@ +#!/usr/bin/env python3 +"""Bounded WOW Sidecar Foundry T6 oracle on disposable TrueNAS.""" +from __future__ import annotations + +import argparse +import hashlib +import json +import pathlib +import re +import time + +from truenas_middleware_ddp_probe import WebSocket, ddp_call, wait_for + +SCHEMA = "semper-supra.wow-sidecar-truenas-t6-control/1" +EXPECTED_VERSION = "TrueNAS-26.0.0-BETA.3" +EXPECTED_APP_NAME = "rdte-t6-wow-sidecar" +EXPECTED_IMAGE = "ghcr.io/sempersupra/wow-sidecar@sha256:6b700ce7ba5ae44116b240ccbb54fb3b60dc952a9b4072ca1314b6f311bc5376" +EXPECTED_HELPER = "ixsystems/container-utils@sha256:46eba20714c1cc6784f60e245c32c33a2d9f616e47d804694a9854248c89a992" +EXPECTED_FIXTURE_DATASET = "rdtepool/wow-sidecar-t6" +EXPECTED_FIXTURE_ROOT = "/mnt/rdtepool/wow-sidecar-t6" +EXPECTED_CONFIG_DIR = EXPECTED_FIXTURE_ROOT + "/config" +EXPECTED_STATE_DIR = EXPECTED_FIXTURE_ROOT + "/state" +EXPECTED_KEY_PATH = EXPECTED_CONFIG_DIR + "/github-app.pem" +EXPECTED_PROFILE_PATH = EXPECTED_CONFIG_DIR + "/profiles/operator.json" +EXPECTED_MARKER_PATH = EXPECTED_CONFIG_DIR + "/.initialized-v1" +FIXTURE_MARKER = "PUBLIC-QUALIFICATION-FIXTURE" +PRIVATE_REPO_RE = re.compile(r"(?:https://github\\.com/)?SemperSupra/[A-Za-z0-9_.-]+-private(?![A-Za-z0-9_.-])", re.IGNORECASE) + + +def canonical_sha256(value: object) -> str: + raw = json.dumps(value, sort_keys=True, separators=(",", ":")).encode("utf-8") + return hashlib.sha256(raw).hexdigest() + + +def load_json(path: pathlib.Path) -> dict: + value = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(value, dict): + raise RuntimeError(f"{path} must contain an object") + return value + + +def load_control(directory: pathlib.Path, foundry_commit: str) -> tuple[dict, dict]: + control = load_json(directory / "control.json") + compose = load_json(directory / "compose.json") + if control.get("schema") != SCHEMA: + raise RuntimeError("unexpected WOW T6 control schema") + if control.get("foundry_ref") != foundry_commit: + raise RuntimeError("Foundry source ref drifted") + if control.get("secrets_captured") is not False: + raise RuntimeError("control bundle does not assert secrets_captured=false") + + candidate = control.get("candidate") or {} + if candidate.get("wow_image") != EXPECTED_IMAGE: + raise RuntimeError("WOW image identity drifted") + if candidate.get("permissions_helper") != EXPECTED_HELPER: + raise RuntimeError("permissions helper identity drifted") + runtime = control.get("runtime") or {} + if runtime.get("app_name") != EXPECTED_APP_NAME: + raise RuntimeError("WOW T6 app name drifted") + if runtime.get("production_credentials_present") is not False: + raise RuntimeError("public T6 control claims production credentials") + if runtime.get("fixture_key_marker") != FIXTURE_MARKER: + raise RuntimeError("public fixture key marker drifted") + if runtime.get("fixture_config_dir") != EXPECTED_CONFIG_DIR: + raise RuntimeError("fixture config directory drifted") + if runtime.get("fixture_state_dir") != EXPECTED_STATE_DIR: + raise RuntimeError("fixture state directory drifted") + + if control.get("artifacts", {}).get("compose_canonical_sha256") != canonical_sha256(compose): + raise RuntimeError("rendered Compose identity does not match control") + + services = compose.get("services") or {} + if set(services) != {"permissions", "wow-sidecar", "wow-sidecar-config-seed"}: + raise RuntimeError("unexpected WOW T6 service inventory") + if services["wow-sidecar"].get("image") != EXPECTED_IMAGE: + raise RuntimeError("worker image drifted") + if services["wow-sidecar-config-seed"].get("image") != EXPECTED_IMAGE: + raise RuntimeError("seed image drifted") + if services["permissions"].get("image") != EXPECTED_HELPER: + raise RuntimeError("permissions image drifted") + + serialized = json.dumps(compose, sort_keys=True) + if FIXTURE_MARKER not in serialized: + raise RuntimeError("public fixture marker is absent from exact control") + if PRIVATE_REPO_RE.search(serialized): + raise RuntimeError("private repository identity leaked into public control") + for forbidden in ("ghp_", "sk-"): + if forbidden in serialized: + raise RuntimeError(f"private credential-like content leaked into public control: {forbidden}") + return control, compose + + +def main() -> int: + p = argparse.ArgumentParser() + p.add_argument("--host", default="127.0.0.1") + p.add_argument("--port", type=int, required=True) + p.add_argument("--password-file", required=True) + p.add_argument("--control-dir", type=pathlib.Path, required=True) + p.add_argument("--foundry-commit", required=True) + p.add_argument("--out", required=True) + p.add_argument("--tls", action="store_true") + p.add_argument("--timeout", type=float, default=8.0) + p.add_argument("--job-timeout", type=float, default=300.0) + p.add_argument("--state-timeout", type=float, default=240.0) + a = p.parse_args() + + started = time.time() + payload = { + "schema": "truenas-wow-sidecar-foundry-t6/v1", + "classification": "ORACLE_FAILURE", + "oracleSatisfied": False, + "expected_version": EXPECTED_VERSION, + "foundry_commit": a.foundry_commit, + "app_name": EXPECTED_APP_NAME, + "wow_image": EXPECTED_IMAGE, + "permissions_helper": EXPECTED_HELPER, + "production_credentials_present": False, + "execution_control_mutation_authorized": False, + "credential_boundary": "public-fixture-only", + } + ws = None + request_id = 1 + created_app = False + created_dataset = False + + try: + control, compose = load_control(a.control_dir, a.foundry_commit) + compose_sha = canonical_sha256(compose) + payload["materialization"] = { + "schema": control["schema"], + "foundry_ref": control["foundry_ref"], + "compose_canonical_sha256": compose_sha, + "production_credentials_present": False, + } + + password = pathlib.Path(a.password_file).read_text(encoding="utf-8").strip() + ws = WebSocket(a.host, a.port, timeout=a.timeout, tls=a.tls) + ws.send_json({"msg": "connect", "version": "1", "support": ["1"]}) + connected = wait_for(ws, lambda m: m.get("msg") in {"connected", "failed"}) + if connected.get("msg") != "connected": + raise RuntimeError("DDP connection failed") + + def call(method, params): + nonlocal request_id + result = ddp_call(ws, str(request_id), method, params) + request_id += 1 + return result + + def wait_job(job_id, label): + deadline = time.monotonic() + a.job_timeout + last = None + while time.monotonic() < deadline: + last = call("core.get_jobs", [[["id", "=", job_id]], {"get": True}]) + if last and last.get("state") == "SUCCESS": + return last + if last and last.get("state") in {"FAILED", "ABORTED"}: + raise RuntimeError(f"{label} job {last.get('state')}") + time.sleep(1) + raise RuntimeError(f"{label} job did not reach SUCCESS") + + def app_query(get=True): + params = [[["id", "=", EXPECTED_APP_NAME]]] + if get: + params.append({"get": True}) + return call("app.query", params) + + def wait_running(): + deadline = time.monotonic() + a.state_timeout + last = None + while time.monotonic() < deadline: + last = app_query() + if last: + if last.get("state") in {"CRASHED", "ERROR"}: + raise RuntimeError(f"WOW app entered {last.get('state')}") + details = (last.get("active_workloads") or {}).get("container_details") or [] + workers = [ + item for item in details + if item.get("service_name") == "wow-sidecar" + and item.get("image") == EXPECTED_IMAGE + and item.get("state") == "running" + ] + if last.get("state") == "RUNNING" and len(workers) == 1: + return last + time.sleep(1) + raise RuntimeError(f"WOW worker did not reach RUNNING: {last!r}") + + auth = call("auth.login_ex", [{ + "mechanism": "PASSWORD_PLAIN", + "username": "truenas_admin", + "password": password, + }]) + if not isinstance(auth, dict) or auth.get("response_type") != "SUCCESS": + raise RuntimeError("authentication did not return SUCCESS") + + payload["system_version"] = call("system.version", []) + if payload["system_version"] != EXPECTED_VERSION: + raise RuntimeError("target version drifted") + + if call("app.query", [[["id", "=", EXPECTED_APP_NAME]]]): + raise RuntimeError("refusing adopted WOW app state") + if call("pool.dataset.query", [[["id", "=", EXPECTED_FIXTURE_DATASET]]]): + raise RuntimeError("refusing adopted WOW fixture dataset") + + dataset = call("pool.dataset.create", [{ + "name": EXPECTED_FIXTURE_DATASET, + "type": "FILESYSTEM", + "share_type": "GENERIC", + "comments": "SemperSupra disposable WOW Sidecar T6 fixture", + }]) + if not isinstance(dataset, dict) or dataset.get("id") != EXPECTED_FIXTURE_DATASET: + raise RuntimeError("pool.dataset.create did not return exact WOW fixture dataset") + created_dataset = True + + created_dirs = [] + for path in (EXPECTED_CONFIG_DIR, EXPECTED_STATE_DIR): + result = call("filesystem.mkdir", [{ + "path": path, + "options": {"mode": "750", "raise_chmod_error": True}, + }]) + if not isinstance(result, dict) or result.get("path") != path: + raise RuntimeError(f"filesystem.mkdir did not create exact path {path}") + created_dirs.append(path) + payload["fixture_storage"] = { + "dataset": EXPECTED_FIXTURE_DATASET, + "directories": created_dirs, + "content_recorded": False, + } + + create_id = call("app.create", [{ + "app_name": EXPECTED_APP_NAME, + "custom_app": True, + "custom_compose_config": compose, + }]) + if not isinstance(create_id, int): + raise RuntimeError("app.create did not return a job id") + wait_job(create_id, "app.create") + created_app = True + + app = wait_running() + details = (app.get("active_workloads") or {}).get("container_details") or [] + payload["runtime"] = { + "state": app.get("state"), + "custom_app": app.get("custom_app"), + "worker_running_exact_image": any( + item.get("service_name") == "wow-sidecar" + and item.get("image") == EXPECTED_IMAGE + and item.get("state") == "running" + for item in details + ), + "container_states": sorted({ + f"{item.get('service_name')}:{item.get('state')}" + for item in details + if isinstance(item, dict) + }), + } + if app.get("custom_app") is not True: + raise RuntimeError("WOW runtime did not materialize as a custom app") + + config_sha = canonical_sha256(call("app.config", [EXPECTED_APP_NAME])) + if config_sha != compose_sha: + raise RuntimeError("app.config read-back does not match exact Foundry Compose") + payload["runtime"]["compose_readback_sha256"] = config_sha + + stat_paths = { + "config_root": EXPECTED_CONFIG_DIR, + "state_root": EXPECTED_STATE_DIR, + "seed_marker": EXPECTED_MARKER_PATH, + "github_app_key": EXPECTED_KEY_PATH, + "operator_profile": EXPECTED_PROFILE_PATH, + } + metadata = {} + for label, path in stat_paths.items(): + st = call("filesystem.stat", [path]) + metadata[label] = { + "path": path, + "mode": oct(int(st.get("mode", 0)) & 0o777), + "uid": st.get("uid"), + "gid": st.get("gid"), + "type": st.get("type"), + } + if metadata["config_root"]["uid"] != 10001 or metadata["config_root"]["gid"] != 10001: + raise RuntimeError("config root ownership does not match WOW runtime identity") + if metadata["state_root"]["uid"] != 10001 or metadata["state_root"]["gid"] != 10001: + raise RuntimeError("state root ownership does not match WOW runtime identity") + if metadata["github_app_key"]["mode"] != "0o400": + raise RuntimeError("fixture GitHub App key mode is not 0400") + if metadata["operator_profile"]["mode"] != "0o400": + raise RuntimeError("fixture operator profile mode is not 0400") + if metadata["seed_marker"]["mode"] != "0o444": + raise RuntimeError("seed marker mode is not 0444") + payload["seed_metadata"] = metadata + payload["seed_metadata"]["raw_content_recorded"] = False + + stop_id = call("app.stop", [EXPECTED_APP_NAME]) + wait_job(stop_id, "app.stop") + stopped = app_query() + if not stopped or stopped.get("state") != "STOPPED": + raise RuntimeError("WOW app did not reach STOPPED") + + start_id = call("app.start", [EXPECTED_APP_NAME]) + wait_job(start_id, "app.start") + wait_running() + restart_sha = canonical_sha256(call("app.config", [EXPECTED_APP_NAME])) + if restart_sha != compose_sha: + raise RuntimeError("Foundry Compose identity drifted after restart") + payload["runtime"]["restart_compose_identity_preserved"] = True + + delete_id = call("app.delete", [EXPECTED_APP_NAME, { + "remove_images": False, + "remove_ix_volumes": False, + "force_remove_custom_app": False, + }]) + wait_job(delete_id, "app.delete") + created_app = False + if call("app.query", [[["id", "=", EXPECTED_APP_NAME]]]): + raise RuntimeError("WOW app remains after delete") + + deleted = call("pool.dataset.delete", [ + EXPECTED_FIXTURE_DATASET, + {"recursive": True, "force": False}, + ]) + if deleted is not True: + raise RuntimeError("pool.dataset.delete did not return true") + created_dataset = False + if call("pool.dataset.query", [[["id", "=", EXPECTED_FIXTURE_DATASET]]]): + raise RuntimeError("WOW fixture dataset remains after delete") + + mountpoint_absent = False + try: + call("filesystem.stat", [EXPECTED_FIXTURE_ROOT]) + except RuntimeError: + mountpoint_absent = True + if not mountpoint_absent: + raise RuntimeError("WOW fixture mountpoint remains after dataset delete") + + payload["cleanup"] = { + "app_absent": True, + "fixture_dataset_absent": True, + "fixture_mountpoint_absent": True, + "zero_residue": True, + } + payload["classification"] = "SUPPORTED" + payload["oracleSatisfied"] = True + payload["detail"] = ( + "exact Foundry-exported WOW App realized on real disposable TrueNAS; " + "immutable image/config read-back, permissions, create-once seed metadata, " + "worker RUNNING under public fixture credential, restart persistence, " + "and delete/zero-residue all passed" + ) + except Exception as exc: + payload["detail"] = f"{type(exc).__name__}: {exc}" + payload["cleanup_needed"] = { + "app": created_app, + "fixture_dataset": created_dataset, + } + finally: + if ws is not None: + ws.close() + + payload["elapsed_seconds"] = round(time.time() - started, 3) + pathlib.Path(a.out).write_text( + json.dumps(payload, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + print(json.dumps(payload, indent=2, sort_keys=True)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/truenas_rdte_target.py b/scripts/truenas_rdte_target.py new file mode 100644 index 000000000..363e0c860 --- /dev/null +++ b/scripts/truenas_rdte_target.py @@ -0,0 +1,87 @@ +#!/usr/bin/env python3 +from __future__ import annotations +import argparse, json, pathlib, re, shlex, sys + +SHA_RE = re.compile(r"^[0-9a-f]{40}$") + +class TargetError(RuntimeError): + pass + +def load_registry(path: pathlib.Path): + try: + doc=json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise TargetError(f"cannot read target registry: {exc}") from exc + if not isinstance(doc, dict) or doc.get("schema") != "gha-kvm-truenas-targets/v1": + raise TargetError("unsupported target registry schema") + targets=doc.get("targets") + if not isinstance(targets, list) or not targets: + raise TargetError("target registry has no targets") + seen=set() + result={} + for i,t in enumerate(targets): + if not isinstance(t, dict): + raise TargetError(f"targets[{i}] must be an object") + version=t.get("version") + if not isinstance(version,str) or not version or version in seen: + raise TargetError(f"invalid or duplicate target version at index {i}") + seen.add(version) + for key in ("system_version","iso_name","iso_url","sha256_url","middleware_ref","middleware_commit","foundry_profile","foundry_profile_blob","ha_apps_gate","installer_rpc_path","installer_source_ref","installer_main_blob_sha"): + if not isinstance(t.get(key),str) or not t[key]: + raise TargetError(f"{version}: missing {key}") + if not SHA_RE.fullmatch(t["middleware_commit"]): + raise TargetError(f"{version}: middleware_commit must be exact 40-hex") + if not SHA_RE.fullmatch(t["foundry_profile_blob"]): + raise TargetError(f"{version}: foundry_profile_blob must be exact 40-hex") + if t["installer_rpc_path"] not in {"/", "/ws"}: + raise TargetError(f"{version}: unsupported installer_rpc_path") + if not isinstance(t.get("installer_rpc_guest_port"), int) or not (1 <= t["installer_rpc_guest_port"] <= 65535): + raise TargetError(f"{version}: installer_rpc_guest_port must be an integer TCP port") + if not SHA_RE.fullmatch(t["installer_main_blob_sha"]): + raise TargetError(f"{version}: installer_main_blob_sha must be exact 40-hex") + if any(token in t["iso_url"].lower() for token in ("latest","nightly","master+")): + raise TargetError(f"{version}: supported target ISO must be exact, not floating") + if t["sha256_url"] != t["iso_url"] + ".sha256": + raise TargetError(f"{version}: sha256_url must be exact ISO sidecar") + result[version]=t + return result + +def shell(target): + fields={ + "VERSION":target["version"], + "EXPECTED_SYSTEM_VERSION":target["system_version"], + "ISO_NAME":target["iso_name"], + "ISO_URL":target["iso_url"], + "SHA_URL":target["sha256_url"], + "MIDDLEWARE_REF":target["middleware_ref"], + "MIDDLEWARE_COMMIT":target["middleware_commit"], + "FOUNDRY_PROFILE":target["foundry_profile"], + "FOUNDRY_PROFILE_BLOB":target["foundry_profile_blob"], + "HA_APPS_GATE":target["ha_apps_gate"], + "INSTALLER_RPC_PATH":target["installer_rpc_path"], + "INSTALLER_RPC_GUEST_PORT":str(target["installer_rpc_guest_port"]), + "INSTALLER_SOURCE_REF":target["installer_source_ref"], + "INSTALLER_MAIN_BLOB_SHA":target["installer_main_blob_sha"], + "AUTHORITY_ISSUE":str(target.get("authority_issue","")), + } + return "\n".join(f"{k}={shlex.quote(v)}" for k,v in fields.items()) + +def main(): + p=argparse.ArgumentParser() + p.add_argument("--registry", type=pathlib.Path, required=True) + p.add_argument("--version", required=True) + p.add_argument("--shell", action="store_true") + a=p.parse_args() + try: + targets=load_registry(a.registry) + if a.version not in targets: + raise TargetError(f"exact TrueNAS target is not registered: {a.version}") + target=targets[a.version] + print(shell(target) if a.shell else json.dumps(target, indent=2, sort_keys=True)) + return 0 + except TargetError as exc: + print(f"ERROR: {exc}", file=sys.stderr) + return 2 + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/truenas_session_manifest.py b/scripts/truenas_session_manifest.py new file mode 100644 index 000000000..94f2201da --- /dev/null +++ b/scripts/truenas_session_manifest.py @@ -0,0 +1,285 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import hashlib +import json +import pathlib +import re +import sys + +SCHEMA = "truenas-session/v1" +CAPSULE_SCHEMA = "truenas-capsule/v1" +ID_RE = re.compile(r"^[a-z0-9][a-z0-9._-]{2,79}$") +REPO_RE = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$") +SHA256_RE = re.compile(r"^[0-9a-f]{64}$") +KINDS = { + "substrate-sanity", "foundry-control", "official-catalog-control", + "foundry-product", "oci-custom-app-feature", "vm-management-plane", + "nested-virtualization", +} +RETRY_POLICIES = {"none", "read-only-reconcile"} +MAX_CAPSULES = 24 + +class SessionError(RuntimeError): + pass + +def _load_json(path: pathlib.Path) -> dict: + try: + value = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise SessionError(f"cannot read {path}: {exc}") from exc + if not isinstance(value, dict): + raise SessionError(f"{path}: top-level value must be an object") + return value + +def _positive_int(value, label: str) -> int: + if not isinstance(value, int) or isinstance(value, bool) or value <= 0: + raise SessionError(f"{label} must be a positive integer") + return value + +def _string(value, label: str) -> str: + if not isinstance(value, str) or not value.strip(): + raise SessionError(f"{label} must be a non-empty string") + return value + +def canonical_payload(doc: dict) -> bytes: + payload = dict(doc) + payload.pop("manifest_sha256", None) + return json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode() + +def manifest_digest(doc: dict) -> str: + return hashlib.sha256(canonical_payload(doc)).hexdigest() + +def load_target_versions(path: pathlib.Path) -> set[str]: + registry = _load_json(path) + if registry.get("schema") != "gha-kvm-truenas-targets/v1": + raise SessionError("unsupported TrueNAS target registry schema") + targets = registry.get("targets") + if not isinstance(targets, list) or not targets: + raise SessionError("TrueNAS target registry has no targets") + versions = set() + for index, target in enumerate(targets): + if not isinstance(target, dict): + raise SessionError(f"targets[{index}] must be an object") + version = _string(target.get("version"), f"targets[{index}].version") + if version in versions: + raise SessionError(f"duplicate TrueNAS target version: {version}") + versions.add(version) + return versions + +def load_provider_ids(path: pathlib.Path) -> set[str]: + registry = _load_json(path) + if registry.get("schema") != "truenas-capsule-providers/v1": + raise SessionError("unsupported capsule provider registry schema") + providers = registry.get("providers") + if not isinstance(providers, list): + raise SessionError("capsule provider registry providers must be a list") + ids = set() + for index, provider in enumerate(providers): + if not isinstance(provider, dict): + raise SessionError(f"providers[{index}] must be an object") + provider_id = _string(provider.get("id"), f"providers[{index}].id") + if provider_id in ids: + raise SessionError(f"duplicate capsule provider id: {provider_id}") + ids.add(provider_id) + return ids + +def validate_capsule(capsule: dict, index: int, provider_ids: set[str]) -> dict: + prefix = f"capsules[{index}]" + if not isinstance(capsule, dict): + raise SessionError(f"{prefix} must be an object") + if capsule.get("schema") != CAPSULE_SCHEMA: + raise SessionError(f"{prefix}: unsupported capsule schema") + capsule_id = _string(capsule.get("id"), f"{prefix}.id") + if not ID_RE.fullmatch(capsule_id): + raise SessionError(f"{prefix}.id has invalid syntax") + kind = _string(capsule.get("kind"), f"{prefix}.kind") + if kind not in KINDS: + raise SessionError(f"{prefix}.kind is not admitted: {kind}") + provider = _string(capsule.get("provider"), f"{prefix}.provider") + if provider not in provider_ids: + raise SessionError(f"{prefix}.provider is not registered: {provider}") + authority = capsule.get("authority") + if not isinstance(authority, dict): + raise SessionError(f"{prefix}.authority must be an object") + repo = _string(authority.get("repository"), f"{prefix}.authority.repository") + if not REPO_RE.fullmatch(repo): + raise SessionError(f"{prefix}.authority.repository must be owner/name") + issue = _positive_int(authority.get("issue"), f"{prefix}.authority.issue") + exact = capsule.get("exact") + if not isinstance(exact, dict) or not exact: + raise SessionError(f"{prefix}.exact must be a non-empty object") + for key, value in exact.items(): + _string(key, f"{prefix}.exact key") + _string(value, f"{prefix}.exact.{key}") + namespace = _string(capsule.get("namespace"), f"{prefix}.namespace") + if not ID_RE.fullmatch(namespace): + raise SessionError(f"{prefix}.namespace has invalid syntax") + ports = capsule.get("ports", []) + if not isinstance(ports, list): + raise SessionError(f"{prefix}.ports must be a list") + normalized_ports = [] + for port in ports: + if not isinstance(port, int) or isinstance(port, bool) or not 1 <= port <= 65535: + raise SessionError(f"{prefix}.ports contains an invalid port") + normalized_ports.append(port) + if len(normalized_ports) != len(set(normalized_ports)): + raise SessionError(f"{prefix}.ports contains duplicates") + requirements = capsule.get("requirements", []) + if not isinstance(requirements, list) or any(not isinstance(x, str) or not x for x in requirements): + raise SessionError(f"{prefix}.requirements must be a list of non-empty strings") + resources = capsule.get("resources") + if not isinstance(resources, dict): + raise SessionError(f"{prefix}.resources must be an object") + memory_mib = _positive_int(resources.get("memory_mib"), f"{prefix}.resources.memory_mib") + download_mib = _positive_int(resources.get("download_mib"), f"{prefix}.resources.download_mib") + estimated_minutes = _positive_int(resources.get("estimated_minutes"), f"{prefix}.resources.estimated_minutes") + phases = capsule.get("phases") + if not isinstance(phases, dict): + raise SessionError(f"{prefix}.phases must be an object") + for phase in ("setup", "apply", "verify", "cleanup"): + if phases.get(phase) is not True: + raise SessionError(f"{prefix}.phases.{phase} must be true") + oracle = _string(capsule.get("oracle"), f"{prefix}.oracle") + receipt_contract = _string(capsule.get("receipt_contract"), f"{prefix}.receipt_contract") + retry_policy = _string(capsule.get("retry_policy"), f"{prefix}.retry_policy") + if retry_policy not in RETRY_POLICIES: + raise SessionError(f"{prefix}.retry_policy is not admitted: {retry_policy}") + mutating = capsule.get("mutating") + if not isinstance(mutating, bool): + raise SessionError(f"{prefix}.mutating must be boolean") + cleanup_required = capsule.get("cleanup_required") + if not isinstance(cleanup_required, bool): + raise SessionError(f"{prefix}.cleanup_required must be boolean") + if mutating and not cleanup_required: + raise SessionError(f"{prefix}: mutating capsules must require cleanup") + dependencies = capsule.get("dependencies", []) + if not isinstance(dependencies, list) or any(not isinstance(x, str) or not x for x in dependencies): + raise SessionError(f"{prefix}.dependencies must be a list of capsule ids") + return { + "id": capsule_id, "kind": kind, "provider": provider, + "authority": {"repository": repo, "issue": issue}, + "namespace": namespace, "ports": normalized_ports, + "resources": {"memory_mib": memory_mib, "download_mib": download_mib, "estimated_minutes": estimated_minutes}, + "oracle": oracle, "receipt_contract": receipt_contract, + "retry_policy": retry_policy, "mutating": mutating, + "cleanup_required": cleanup_required, "dependencies": dependencies, + } + +def validate_manifest(manifest_path: pathlib.Path, target_registry_path: pathlib.Path, provider_registry_path: pathlib.Path) -> dict: + doc = _load_json(manifest_path) + if doc.get("schema") != SCHEMA: + raise SessionError("unsupported TrueNAS session schema") + session_id = _string(doc.get("session_id"), "session_id") + if not ID_RE.fullmatch(session_id): + raise SessionError("session_id has invalid syntax") + version = _string(doc.get("version"), "version") + if version not in load_target_versions(target_registry_path): + raise SessionError(f"exact TrueNAS target is not registered: {version}") + authority = doc.get("authority") + if not isinstance(authority, dict): + raise SessionError("authority must be an object") + authority_repo = _string(authority.get("repository"), "authority.repository") + if not REPO_RE.fullmatch(authority_repo): + raise SessionError("authority.repository must be owner/name") + authority_issue = _positive_int(authority.get("issue"), "authority.issue") + budget = doc.get("budget") + if not isinstance(budget, dict): + raise SessionError("budget must be an object") + guest_memory_mib = _positive_int(budget.get("guest_memory_mib"), "budget.guest_memory_mib") + download_mib = _positive_int(budget.get("download_mib"), "budget.download_mib") + timeout_minutes = _positive_int(budget.get("timeout_minutes"), "budget.timeout_minutes") + if guest_memory_mib > 8192: + raise SessionError("budget.guest_memory_mib exceeds the current 8-GiB guardrail") + if timeout_minutes > 50: + raise SessionError("budget.timeout_minutes exceeds the current 50-minute guardrail") + pool = doc.get("pool") + if not isinstance(pool, dict): + raise SessionError("pool must be an object") + pool_name = _string(pool.get("name"), "pool.name") + if not ID_RE.fullmatch(pool_name): + raise SessionError("pool.name has invalid syntax") + if _positive_int(pool.get("data_disks"), "pool.data_disks") != 2: + raise SessionError("pool.data_disks must preserve the qualified two-disk topology") + capsules = doc.get("capsules") + if not isinstance(capsules, list) or not capsules: + raise SessionError("capsules must be a non-empty list") + if len(capsules) > MAX_CAPSULES: + raise SessionError(f"capsules exceeds bounded limit {MAX_CAPSULES}") + provider_ids = load_provider_ids(provider_registry_path) + normalized = [validate_capsule(c, i, provider_ids) for i, c in enumerate(capsules)] + ids = [c["id"] for c in normalized] + if len(ids) != len(set(ids)): + raise SessionError("capsule ids must be unique") + namespaces = [c["namespace"] for c in normalized] + if len(namespaces) != len(set(namespaces)): + raise SessionError("capsule namespaces must be unique") + all_ports = [p for c in normalized for p in c["ports"]] + if len(all_ports) != len(set(all_ports)): + raise SessionError("capsule ports must be globally unique within a session") + ids_set = set(ids) + depmap = {c["id"]: c["dependencies"] for c in normalized} + for c in normalized: + if c["id"] in c["dependencies"]: + raise SessionError(f"{c['id']}: self-dependency is forbidden") + missing = sorted(set(c["dependencies"]) - ids_set) + if missing: + raise SessionError(f"{c['id']}: unknown dependencies: {', '.join(missing)}") + visiting, visited = set(), set() + def visit(node: str) -> None: + if node in visited: + return + if node in visiting: + raise SessionError("capsule dependency cycle detected") + visiting.add(node) + for dep in depmap[node]: + visit(dep) + visiting.remove(node) + visited.add(node) + for node in ids: + visit(node) + max_memory = max(c["resources"]["memory_mib"] for c in normalized) + total_download = sum(c["resources"]["download_mib"] for c in normalized) + total_minutes = sum(c["resources"]["estimated_minutes"] for c in normalized) + if max_memory > guest_memory_mib: + raise SessionError("capsule memory ceiling exceeds session guest-memory budget") + if total_download > download_mib: + raise SessionError("capsule download budgets exceed session download budget") + if total_minutes > timeout_minutes: + raise SessionError("capsule estimated time exceeds session timeout budget") + computed = manifest_digest(doc) + declared = doc.get("manifest_sha256") + if not isinstance(declared, str) or not SHA256_RE.fullmatch(declared): + raise SessionError("manifest_sha256 must be exact lowercase sha256") + if declared != computed: + raise SessionError(f"manifest_sha256 mismatch: declared {declared}, computed {computed}") + return { + "schema": SCHEMA, "session_id": session_id, "version": version, + "authority": {"repository": authority_repo, "issue": authority_issue}, + "manifest_sha256": computed, "capsule_count": len(normalized), + "mutating_capsule_count": sum(1 for c in normalized if c["mutating"]), + "max_capsule_memory_mib": max_memory, + "total_download_mib": total_download, + "total_estimated_minutes": total_minutes, + } + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--manifest", type=pathlib.Path, required=True) + parser.add_argument("--targets", type=pathlib.Path, default=pathlib.Path("config/truenas-rdte-targets.json")) + parser.add_argument("--providers", type=pathlib.Path, default=pathlib.Path("config/truenas-capsule-providers.json")) + parser.add_argument("--digest-only", action="store_true") + args = parser.parse_args() + try: + if args.digest_only: + print(manifest_digest(_load_json(args.manifest))) + return 0 + print(json.dumps(validate_manifest(args.manifest, args.targets, args.providers), indent=2, sort_keys=True)) + return 0 + except SessionError as exc: + print(f"ERROR: {exc}", file=sys.stderr) + return 2 + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/truenas_session_runner.py b/scripts/truenas_session_runner.py new file mode 100644 index 000000000..73124dff5 --- /dev/null +++ b/scripts/truenas_session_runner.py @@ -0,0 +1,322 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import json +import pathlib +import subprocess +import sys +import tempfile +from typing import Any + +from truenas_session_manifest import SessionError, validate_manifest +from truenas_session_state import ( + CAPSULE_VERDICTS, + CapsuleOutcome, + decide_after_capsule, + finalize_session, +) + +RECEIPT_SCHEMA = "truenas-capsule-execution/v1" +SESSION_SCHEMA = "truenas-session-execution/v1" + + +def load_json(path: pathlib.Path) -> dict[str, Any]: + try: + value = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise SessionError(f"cannot read {path}: {exc}") from exc + if not isinstance(value, dict): + raise SessionError(f"{path}: top-level value must be an object") + return value + + +def provider_map(path: pathlib.Path) -> dict[str, dict[str, Any]]: + doc = load_json(path) + if doc.get("schema") != "truenas-capsule-providers/v1": + raise SessionError("unsupported capsule provider registry schema") + rows = doc.get("providers") + if not isinstance(rows, list): + raise SessionError("provider registry requires providers list") + result: dict[str, dict[str, Any]] = {} + for row in rows: + if not isinstance(row, dict) or not isinstance(row.get("id"), str): + raise SessionError("provider registry contains invalid row") + if row["id"] in result: + raise SessionError(f"duplicate provider: {row['id']}") + result[row["id"]] = row + return result + + +def stable_topological_capsules(capsules: list[dict[str, Any]]) -> list[dict[str, Any]]: + remaining = list(capsules) + emitted: list[dict[str, Any]] = [] + emitted_ids: set[str] = set() + while remaining: + found = None + for index, capsule in enumerate(remaining): + deps = set(capsule.get("dependencies") or []) + if deps.issubset(emitted_ids): + found = index + break + if found is None: + raise SessionError("capsule dependency graph cannot be scheduled") + capsule = remaining.pop(found) + emitted.append(capsule) + emitted_ids.add(capsule["id"]) + return emitted + + +def validate_capsule_receipt( + receipt: dict[str, Any], + capsule: dict[str, Any], + provider: dict[str, Any], + manifest_sha256: str, +) -> CapsuleOutcome: + if receipt.get("schema") != RECEIPT_SCHEMA: + raise SessionError(f"{capsule['id']}: unsupported capsule receipt schema") + if receipt.get("capsule_id") != capsule["id"]: + raise SessionError(f"{capsule['id']}: receipt capsule identity mismatch") + if receipt.get("provider") != capsule["provider"]: + raise SessionError(f"{capsule['id']}: receipt provider identity mismatch") + if receipt.get("manifest_sha256") != manifest_sha256: + raise SessionError(f"{capsule['id']}: receipt manifest identity mismatch") + verdict = receipt.get("verdict") + if verdict not in CAPSULE_VERDICTS: + raise SessionError(f"{capsule['id']}: invalid receipt verdict {verdict!r}") + if receipt.get("mutating") is not capsule.get("mutating"): + raise SessionError(f"{capsule['id']}: mutating contract drift") + if receipt.get("cleanup_required") is not capsule.get("cleanup_required"): + raise SessionError(f"{capsule['id']}: cleanup contract drift") + for field in ( + "cleanup_satisfied", + "platform_healthy", + "authority_satisfied", + "resource_guardrail_satisfied", + ): + if not isinstance(receipt.get(field), bool): + raise SessionError(f"{capsule['id']}: receipt missing boolean {field}") + if receipt.get("provider_probe") != provider.get("probe"): + raise SessionError(f"{capsule['id']}: provider probe identity drift") + return CapsuleOutcome( + capsule_id=capsule["id"], + verdict=verdict, + mutating=capsule["mutating"], + cleanup_required=capsule["cleanup_required"], + cleanup_satisfied=receipt["cleanup_satisfied"], + platform_healthy=receipt["platform_healthy"], + authority_satisfied=receipt["authority_satisfied"], + resource_guardrail_satisfied=receipt["resource_guardrail_satisfied"], + ) + + +def not_executed_receipt( + capsule: dict[str, Any], + provider: dict[str, Any], + manifest_sha256: str, + reason: str, +) -> dict[str, Any]: + return { + "schema": RECEIPT_SCHEMA, + "capsule_id": capsule["id"], + "provider": capsule["provider"], + "provider_probe": provider.get("probe"), + "manifest_sha256": manifest_sha256, + "verdict": "NOT_EXECUTED", + "mutating": capsule["mutating"], + "cleanup_required": capsule["cleanup_required"], + "cleanup_satisfied": True, + "platform_healthy": True, + "authority_satisfied": True, + "resource_guardrail_satisfied": True, + "reason": reason, + } + + +def harness_failure_receipt( + capsule: dict[str, Any], + provider: dict[str, Any], + manifest_sha256: str, + detail: str, +) -> dict[str, Any]: + return { + "schema": RECEIPT_SCHEMA, + "capsule_id": capsule["id"], + "provider": capsule["provider"], + "provider_probe": provider.get("probe"), + "manifest_sha256": manifest_sha256, + "verdict": "HARNESS_FAILURE", + "mutating": capsule["mutating"], + "cleanup_required": capsule["cleanup_required"], + "cleanup_satisfied": not capsule["mutating"], + "platform_healthy": False, + "authority_satisfied": True, + "resource_guardrail_satisfied": True, + "reason": detail, + } + + +def execute_capsule( + executor: pathlib.Path, + capsule: dict[str, Any], + provider: dict[str, Any], + manifest_sha256: str, + context: pathlib.Path, + receipt_path: pathlib.Path, +) -> dict[str, Any]: + with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False) as handle: + descriptor_path = pathlib.Path(handle.name) + json.dump( + { + "schema": "truenas-capsule-dispatch/v1", + "manifest_sha256": manifest_sha256, + "capsule": capsule, + "provider": provider, + }, + handle, + indent=2, + sort_keys=True, + ) + handle.write("\n") + try: + cp = subprocess.run( + [ + *([sys.executable] if executor.suffix == ".py" else []), + str(executor), + "--capsule", + str(descriptor_path), + "--context", + str(context), + "--out", + str(receipt_path), + ], + text=True, + capture_output=True, + check=False, + ) + if receipt_path.is_file(): + receipt = load_json(receipt_path) + receipt.setdefault("executor_returncode", cp.returncode) + return receipt + return harness_failure_receipt( + capsule, + provider, + manifest_sha256, + f"executor exited {cp.returncode} without receipt", + ) + finally: + descriptor_path.unlink(missing_ok=True) + + +def run_session( + manifest_path: pathlib.Path, + targets_path: pathlib.Path, + providers_path: pathlib.Path, + executor: pathlib.Path, + context: pathlib.Path, + out_dir: pathlib.Path, +) -> dict[str, Any]: + validated = validate_manifest(manifest_path, targets_path, providers_path) + manifest = load_json(manifest_path) + providers = provider_map(providers_path) + ordered = stable_topological_capsules(manifest["capsules"]) + out_dir.mkdir(parents=True, exist_ok=True) + + receipts: list[dict[str, Any]] = [] + outcomes: dict[str, CapsuleOutcome] = {} + stop_reason: str | None = None + stop_classification: str | None = None + + for capsule in ordered: + provider = providers[capsule["provider"]] + receipt_path = out_dir / f"{capsule['id']}.json" + + if stop_reason is not None: + receipt = not_executed_receipt( + capsule, + provider, + validated["manifest_sha256"], + f"session stopped: {stop_classification}: {stop_reason}", + ) + receipt_path.write_text(json.dumps(receipt, indent=2, sort_keys=True) + "\n", encoding="utf-8") + receipts.append(receipt) + continue + + failed_dependencies = [ + dep + for dep in capsule.get("dependencies") or [] + if dep not in outcomes or outcomes[dep].verdict != "SUPPORTED" + ] + if failed_dependencies: + receipt = not_executed_receipt( + capsule, + provider, + validated["manifest_sha256"], + "unsatisfied dependencies: " + ", ".join(failed_dependencies), + ) + receipt_path.write_text(json.dumps(receipt, indent=2, sort_keys=True) + "\n", encoding="utf-8") + receipts.append(receipt) + outcomes[capsule["id"]] = validate_capsule_receipt( + receipt, capsule, provider, validated["manifest_sha256"] + ) + continue + + receipt = execute_capsule( + executor, + capsule, + provider, + validated["manifest_sha256"], + context, + receipt_path, + ) + receipt_path.write_text(json.dumps(receipt, indent=2, sort_keys=True) + "\n", encoding="utf-8") + outcome = validate_capsule_receipt( + receipt, capsule, provider, validated["manifest_sha256"] + ) + receipts.append(receipt) + outcomes[capsule["id"]] = outcome + decision = decide_after_capsule(outcome) + if not decision.continue_mutation: + stop_reason = decision.reason + stop_classification = decision.session_classification + + executed_outcomes = [ + outcomes[c["id"]] + for c in ordered + if c["id"] in outcomes and outcomes[c["id"]].verdict != "NOT_EXECUTED" + ] + classification = stop_classification or finalize_session(executed_outcomes) + return { + "schema": SESSION_SCHEMA, + "session_id": validated["session_id"], + "version": validated["version"], + "manifest_sha256": validated["manifest_sha256"], + "classification": classification, + "session_clean": classification == "SESSION_CLEAN", + "capsules": receipts, + "product_acceptance_inferred": False, + } + + +def main() -> int: + p = argparse.ArgumentParser() + p.add_argument("--manifest", type=pathlib.Path, required=True) + p.add_argument("--targets", type=pathlib.Path, default=pathlib.Path("config/truenas-rdte-targets.json")) + p.add_argument("--providers", type=pathlib.Path, default=pathlib.Path("config/truenas-capsule-providers.json")) + p.add_argument("--executor", type=pathlib.Path, required=True) + p.add_argument("--context", type=pathlib.Path, required=True) + p.add_argument("--out-dir", type=pathlib.Path, required=True) + p.add_argument("--out", type=pathlib.Path, required=True) + a = p.parse_args() + try: + result = run_session(a.manifest, a.targets, a.providers, a.executor, a.context, a.out_dir) + a.out.write_text(json.dumps(result, indent=2, sort_keys=True) + "\n", encoding="utf-8") + print(json.dumps(result, indent=2, sort_keys=True)) + return 0 + except (SessionError, OSError) as exc: + print(f"ERROR: {exc}", file=sys.stderr) + return 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/truenas_session_state.py b/scripts/truenas_session_state.py new file mode 100644 index 000000000..22f2a0120 --- /dev/null +++ b/scripts/truenas_session_state.py @@ -0,0 +1,71 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +from dataclasses import dataclass +from typing import Iterable + +TERMINAL_SESSION = { + "SESSION_CONTAMINATED", + "PLATFORM_UNHEALTHY", + "RESOURCE_GUARDRAIL", + "AUTHORITY_MISMATCH", +} +CAPSULE_VERDICTS = { + "SUPPORTED", + "ORACLE_FAILURE", + "HARNESS_FAILURE", + "ENVIRONMENT_FAILURE", + "UNSUPPORTED", + "DOCUMENTED_NEGATIVE", + "NOT_EXECUTED", +} + +@dataclass(frozen=True) +class CapsuleOutcome: + capsule_id: str + verdict: str + mutating: bool + cleanup_required: bool + cleanup_satisfied: bool + platform_healthy: bool + authority_satisfied: bool = True + resource_guardrail_satisfied: bool = True + +@dataclass(frozen=True) +class SessionDecision: + continue_mutation: bool + session_classification: str + next_state: str + reason: str + +def decide_after_capsule(outcome: CapsuleOutcome) -> SessionDecision: + if outcome.verdict not in CAPSULE_VERDICTS: + return SessionDecision(False, "HARNESS_FAILURE", "STOPPED", "unknown capsule verdict") + if not outcome.authority_satisfied: + return SessionDecision(False, "AUTHORITY_MISMATCH", "STOPPED", "capsule authority identity changed") + if not outcome.resource_guardrail_satisfied: + return SessionDecision(False, "RESOURCE_GUARDRAIL", "STOPPED", "resource floor/ceiling violated") + if outcome.mutating and outcome.cleanup_required and not outcome.cleanup_satisfied: + return SessionDecision(False, "SESSION_CONTAMINATED", "STOPPED", "mutating capsule cleanup was not proven") + if not outcome.platform_healthy: + return SessionDecision(False, "PLATFORM_UNHEALTHY", "STOPPED", "post-capsule platform health was not proven") + return SessionDecision(True, "IN_PROGRESS", "READY", "capsule failure domain reconciled; next independent capsule may run") + +def fold_session(outcomes: Iterable[CapsuleOutcome]) -> SessionDecision: + last=SessionDecision(True,"IN_PROGRESS","READY","no capsules executed") + for outcome in outcomes: + last=decide_after_capsule(outcome) + if not last.continue_mutation: + return last + return last + +def finalize_session(outcomes: Iterable[CapsuleOutcome]) -> str: + rows=list(outcomes) + decision=fold_session(rows) + if not decision.continue_mutation: + return decision.session_classification + if not rows: + return "NO_CAPSULES" + # A session can be structurally clean even when an individual product oracle failed. + # Per-capsule verdicts remain authoritative and must not be collapsed into product PASS. + return "SESSION_CLEAN" diff --git a/scripts/truenas_single_capsule_adapter.py b/scripts/truenas_single_capsule_adapter.py new file mode 100644 index 000000000..7eb087ee8 --- /dev/null +++ b/scripts/truenas_single_capsule_adapter.py @@ -0,0 +1,66 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import json +import pathlib +import sys + +from truenas_session_manifest import SessionError, validate_manifest + +def load_json(path: pathlib.Path): + value=json.loads(path.read_text(encoding="utf-8")) + if not isinstance(value,dict): + raise SessionError(f"{path}: top-level value must be an object") + return value + +def provider_map(path: pathlib.Path): + doc=load_json(path) + if doc.get("schema")!="truenas-capsule-providers/v1": + raise SessionError("unsupported capsule provider registry schema") + return {x["id"]:x for x in doc.get("providers",[])} + +def lower_single(manifest_path, targets_path, providers_path): + validated=validate_manifest(manifest_path,targets_path,providers_path) + manifest=load_json(manifest_path) + capsules=manifest["capsules"] + if len(capsules)!=1: + raise SessionError("single-capsule equivalence adapter requires exactly one capsule") + capsule=capsules[0] + provider=provider_map(providers_path).get(capsule["provider"]) + if not provider: + raise SessionError(f"unregistered provider: {capsule['provider']}") + selector=provider.get("existing_selector") + artifact=provider.get("control_artifact") + probe=provider.get("probe") + if not all(isinstance(x,str) and x for x in (selector,artifact,probe)): + raise SessionError("provider lacks existing execution binding") + return { + "schema":"truenas-single-capsule-lowering/v1", + "session_id":validated["session_id"], + "manifest_sha256":validated["manifest_sha256"], + "version":validated["version"], + "capsule_id":capsule["id"], + "provider":capsule["provider"], + "existing_selector":selector, + "control_artifact":artifact, + "probe":probe, + "authority":capsule["authority"], + "runtime_semantics_changed":False, + } + +def main(): + p=argparse.ArgumentParser() + p.add_argument("--manifest",type=pathlib.Path,required=True) + p.add_argument("--targets",type=pathlib.Path,default=pathlib.Path("config/truenas-rdte-targets.json")) + p.add_argument("--providers",type=pathlib.Path,default=pathlib.Path("config/truenas-capsule-providers.json")) + a=p.parse_args() + try: + print(json.dumps(lower_single(a.manifest,a.targets,a.providers),indent=2,sort_keys=True)) + return 0 + except (SessionError,OSError,json.JSONDecodeError) as exc: + print(f"ERROR: {exc}",file=sys.stderr) + return 2 + +if __name__=="__main__": + raise SystemExit(main()) diff --git a/scripts/truenas_v2_console_probe.py b/scripts/truenas_v2_console_probe.py new file mode 100644 index 000000000..b5aee0e71 --- /dev/null +++ b/scripts/truenas_v2_console_probe.py @@ -0,0 +1,139 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import json +import pathlib +import re +import time + +from scripts.compute_guest_seed import NONCE_RE, PREFIX, SeedError, validate_token +from scripts.truenas_middleware_ddp_probe import WebSocket, ddp_call, wait_for +from scripts.truenas_vm_console_probe import ShellWebSocket + +KVM_RE = re.compile(rb"AGENT_DISPATCH_V2_KVM_PRESENT=([01])") +CPU_RE = re.compile(rb"AGENT_DISPATCH_V2_CPU_VMX_SVM=([01])") + + +def parse_v2_observation(buffer: bytes, nonce: str) -> dict | None: + validate_token(nonce, NONCE_RE, "nonce") + nonce_marker = f"{PREFIX}{nonce}".encode("ascii") + if nonce_marker not in buffer: + return None + km = KVM_RE.search(buffer) + cm = CPU_RE.search(buffer) + if not km or not cm: + return None + kvm = km.group(1) == b"1" + cpu = cm.group(1) == b"1" + return { + "nonce_observed": True, + "kvm_device_present": kvm, + "cpu_vmx_or_svm_present": cpu, + "nested_kvm_observed": kvm and cpu, + "firecracker_eligible": kvm and cpu, + } + + +def observe_v2( + host: str, + port: int, + username: str, + password: str, + vm_id: int, + nonce: str, + timeout: float, + tls: bool, +) -> dict: + validate_token(nonce, NONCE_RE, "nonce") + receipt = { + "schema": "truenas-vm-v2-nested-kvm-observation/v1", + "classification": "ORACLE_FAILURE", + "oracleSatisfied": False, + "vm_id": vm_id, + "nested_kvm_observed": False, + "firecracker_eligible": False, + "claim_boundary": ( + "observes nested-KVM prerequisites only; Firecracker execution remains authority #277 " + "and is forbidden unless both /dev/kvm and vmx|svm are observed" + ), + } + ddp = shell = None + try: + ddp = WebSocket(host, port, timeout=min(timeout, 8), tls=tls) + ddp.send_json({"msg": "connect", "version": "1", "support": ["1"]}) + connected = wait_for(ddp, lambda m: m.get("msg") in {"connected", "failed"}) + if connected.get("msg") != "connected": + raise RuntimeError(f"DDP connect failed: {connected!r}") + auth = ddp_call(ddp, "1", "auth.login_ex", [{ + "mechanism": "PASSWORD_PLAIN", + "username": username, + "password": password, + }]) + if not isinstance(auth, dict) or auth.get("response_type") != "SUCCESS": + raise RuntimeError(f"authentication failed: {auth!r}") + token = ddp_call(ddp, "2", "auth.generate_token", [300, {}, False]) + if not isinstance(token, str) or not token: + raise RuntimeError(f"auth.generate_token returned invalid token: {token!r}") + + shell = ShellWebSocket(host, port, path="/websocket/shell", timeout=min(timeout, 8), tls=tls) + shell.send_json({"token": token, "options": {"vm_id": vm_id}}) + deadline = time.monotonic() + timeout + buffer = bytearray() + while time.monotonic() < deadline: + _, payload = shell.recv_payload() + buffer.extend(payload) + if len(buffer) > 65536: + del buffer[:-65536] + observed = parse_v2_observation(bytes(buffer), nonce) + if observed is not None: + receipt.update(observed) + receipt["classification"] = "SUPPORTED" + receipt["oracleSatisfied"] = True + receipt["detail"] = ( + "nested KVM prerequisites observed inside guest" + if observed["nested_kvm_observed"] + else "guest observation completed; nested KVM prerequisites are not both exposed" + ) + return receipt + raise TimeoutError("V2 nested-KVM markers not observed before deadline") + except Exception as exc: + receipt["detail"] = f"{type(exc).__name__}: {exc}" + return receipt + finally: + if shell is not None: + shell.close() + if ddp is not None: + ddp.close() + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--host", default="127.0.0.1") + ap.add_argument("--port", type=int, required=True) + ap.add_argument("--username", default="truenas_admin") + ap.add_argument("--password-file", type=pathlib.Path, required=True) + ap.add_argument("--vm-id", type=int, required=True) + ap.add_argument("--nonce", required=True) + ap.add_argument("--timeout", type=float, default=90) + ap.add_argument("--tls", action="store_true") + ap.add_argument("--out", type=pathlib.Path, required=True) + a = ap.parse_args() + try: + password = a.password_file.read_text(encoding="utf-8").strip() + result = observe_v2(a.host, a.port, a.username, password, a.vm_id, a.nonce, a.timeout, a.tls) + except (OSError, SeedError) as exc: + result = { + "schema": "truenas-vm-v2-nested-kvm-observation/v1", + "classification": "HARNESS_FAILURE", + "oracleSatisfied": False, + "nested_kvm_observed": False, + "firecracker_eligible": False, + "detail": f"{type(exc).__name__}: {exc}", + } + a.out.write_text(json.dumps(result, indent=2, sort_keys=True) + "\n", encoding="utf-8") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/truenas_vm_console_probe.py b/scripts/truenas_vm_console_probe.py new file mode 100644 index 000000000..4f1d9f010 --- /dev/null +++ b/scripts/truenas_vm_console_probe.py @@ -0,0 +1,185 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import json +import pathlib +import struct +import time + +from scripts.compute_guest_seed import NONCE_RE, PREFIX, SeedError, validate_token +from scripts.truenas_middleware_ddp_probe import WebSocket, ddp_call, wait_for + + +class ShellWebSocket(WebSocket): + def send_binary(self, payload: bytes) -> None: + self._send_frame(0x2, payload) + + def recv_payload(self) -> tuple[int, bytes]: + while True: + b1, b2 = self._read_exact(2) + opcode = b1 & 0x0F + n = b2 & 0x7F + if n == 126: + n = struct.unpack("!H", self._read_exact(2))[0] + elif n == 127: + n = struct.unpack("!Q", self._read_exact(8))[0] + masked = bool(b2 & 0x80) + mask = self._read_exact(4) if masked else b"" + payload = self._read_exact(n) + if masked: + payload = bytes(b ^ mask[i % 4] for i, b in enumerate(payload)) + if opcode == 0x8: + raise EOFError("websocket close frame") + if opcode == 0x9: + self._send_frame(0xA, payload) + continue + if opcode in {0x1, 0x2}: + return opcode, payload + + +def nonce_marker(nonce: str) -> bytes: + validate_token(nonce, NONCE_RE, "nonce") + return f"{PREFIX}{nonce}".encode("ascii") + + +def _observe_nonce_frames(shell: ShellWebSocket, marker: bytes, timeout: float) -> dict: + """Observe VM-console frames until the exact marker or the overall deadline. + + The websocket itself intentionally keeps a short socket timeout so a dead + transport remains responsive. A quiet guest console can legitimately be + idle longer than that while firmware/kernel/userspace boots, so per-read + TimeoutError is transient here; only the caller's overall deadline is an + oracle failure. + """ + buffer = bytearray() + deadline = time.monotonic() + timeout + connected_shell = False + idle_read_timeouts = 0 + while time.monotonic() < deadline: + try: + opcode, payload = shell.recv_payload() + except TimeoutError: + idle_read_timeouts += 1 + continue + + if opcode == 0x1: + try: + message = json.loads(payload.decode()) + except Exception: + message = None + if isinstance(message, dict) and message.get("msg") == "connected": + connected_shell = True + continue + + buffer.extend(payload) + if len(buffer) > 65536: + del buffer[:-65536] + if marker in buffer: + return { + "found": True, + "shell_connected": connected_shell, + "idle_read_timeouts": idle_read_timeouts, + "console_tail": bytes(buffer[-4096:]).decode("utf-8", "replace"), + } + + return { + "found": False, + "shell_connected": connected_shell, + "idle_read_timeouts": idle_read_timeouts, + "console_tail": bytes(buffer[-4096:]).decode("utf-8", "replace"), + } + + +def observe_console_nonce( + host: str, + port: int, + password: str, + vm_id: int, + nonce: str, + timeout: float, + tls: bool, +) -> dict: + marker = nonce_marker(nonce) + receipt = { + "schema": "truenas-vm-console-nonce/v1", + "classification": "ORACLE_FAILURE", + "oracleSatisfied": False, + "vm_id": vm_id, + "nonce_marker": marker.decode(), + "transport": "wss:/websocket/shell" if tls else "ws:/websocket/shell", + "claim_boundary": "external guest-console nonce only; no nested-KVM or guest capability claim", + } + ddp = shell = None + started = time.monotonic() + try: + ddp = WebSocket(host, port, timeout=min(timeout, 8), tls=tls) + ddp.send_json({"msg": "connect", "version": "1", "support": ["1"]}) + connected = wait_for(ddp, lambda m: m.get("msg") in {"connected", "failed"}) + if connected.get("msg") != "connected": + raise RuntimeError(f"DDP connect failed: {connected!r}") + auth = ddp_call(ddp, "1", "auth.login_ex", [{ + "mechanism": "PASSWORD_PLAIN", + "username": "truenas_admin", + "password": password, + }]) + if not isinstance(auth, dict) or auth.get("response_type") != "SUCCESS": + raise RuntimeError(f"authentication failed: {auth!r}") + token = ddp_call(ddp, "2", "auth.generate_token", [300, {}, False]) + if not isinstance(token, str) or not token: + raise RuntimeError(f"auth.generate_token returned invalid token: {token!r}") + + shell = ShellWebSocket(host, port, path="/websocket/shell", timeout=min(timeout, 8), tls=tls) + shell.send_json({"token": token, "options": {"vm_id": vm_id}}) + observed = _observe_nonce_frames(shell, marker, timeout) + receipt.update({ + "shell_connected": observed["shell_connected"], + "idle_read_timeouts": observed["idle_read_timeouts"], + "console_tail": observed["console_tail"], + }) + if observed["found"]: + receipt.update({ + "classification": "SUPPORTED", + "oracleSatisfied": True, + "elapsed_seconds": round(time.monotonic() - started, 3), + "detail": "exact injected V1 nonce observed through supported TrueNAS VM console websocket", + }) + return receipt + raise TimeoutError("exact V1 nonce not observed before console deadline") + except Exception as exc: + receipt["detail"] = f"{type(exc).__name__}: {exc}" + return receipt + finally: + if shell is not None: + shell.close() + if ddp is not None: + ddp.close() + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--host", default="127.0.0.1") + ap.add_argument("--port", type=int, required=True) + ap.add_argument("--password-file", type=pathlib.Path, required=True) + ap.add_argument("--vm-id", type=int, required=True) + ap.add_argument("--nonce", required=True) + ap.add_argument("--timeout", type=float, default=60) + ap.add_argument("--tls", action="store_true") + ap.add_argument("--out", type=pathlib.Path, required=True) + a = ap.parse_args() + try: + password = a.password_file.read_text(encoding="utf-8").strip() + result = observe_console_nonce(a.host, a.port, password, a.vm_id, a.nonce, a.timeout, a.tls) + except (OSError, SeedError) as exc: + result = { + "schema": "truenas-vm-console-nonce/v1", + "classification": "HARNESS_FAILURE", + "oracleSatisfied": False, + "detail": f"{type(exc).__name__}: {exc}", + } + a.out.write_text(json.dumps(result, indent=2, sort_keys=True) + "\n", encoding="utf-8") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/windows_w1_native_plan.py b/scripts/windows_w1_native_plan.py new file mode 100644 index 000000000..6a30e6d34 --- /dev/null +++ b/scripts/windows_w1_native_plan.py @@ -0,0 +1,324 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import json +import pathlib +import re +from typing import Any + +from scripts.compute_guest_profile_contract import load, validate + + +class WindowsW1PlanError(RuntimeError): + pass + + +FIXTURE_ID = "windows-11-enterprise-evaluation-26h2-x64-en-us" +MEDIA_SHA256 = "bc3f24086ebadc94489066b5ad78089e2cf5c3491e90e790bb81a2b199c10e38" +MEDIA_SIZE = 8225329152 +MEMORY_MIB = 4096 +VCPUS = 2 +SYSTEM_DISK_GIB = 64 +SYSTEM_DISK_BYTES = SYSTEM_DISK_GIB * 1024**3 + +TRUENAS_SOURCE = { + "middleware_commit": "81e1265a86083888ba94a2bdfc02ff5c9c5ef6a3", + "vm_api_blob": "0944382fedc0a8cbcfc55fb8a653a9e5758e88fd", + "vm_device_api_blob": "67603b6e2048da6235b802f970559b627857efc9", + "vm_service_blob": "43ed4b532b40da02ad885039d954c99a93ea22ed", +} +PROXMOX_SOURCE = { + "qemu_server_commit": "6785065b3f766f15f6f151af8ec27ec8bb5b07ab", + "qemu_server_blob": "118f26bc94d9ee8e8c4c39a3d710e67c14f61bc0", + "secure_boot_tpm_fixture_blob": "51e525b34ab3e670af887260e436973bdb2f1755", + "package_version": "9.1.15", +} + + +def _nonempty(value: str, label: str) -> str: + if not isinstance(value, str) or not value.strip(): + raise WindowsW1PlanError(f"{label} must be non-empty") + return value.strip() + + +def _safe_binding(value: str, label: str) -> str: + value = _nonempty(value, label) + if any(c in value for c in "\r\n\x00"): + raise WindowsW1PlanError(f"{label} contains invalid control characters") + return value + + +def _profile(path: pathlib.Path) -> dict[str, Any]: + profile = load(path) + validate(profile) + fixture = profile.get("fixtures", {}).get("windows_w1") + if not isinstance(fixture, dict) or fixture.get("id") != FIXTURE_ID: + raise WindowsW1PlanError("qualified Windows W1 fixture is absent") + source = fixture.get("source") + if not isinstance(source, dict): + raise WindowsW1PlanError("qualified Windows W1 source is absent") + if source.get("sha256") != MEDIA_SHA256 or source.get("size_bytes") != MEDIA_SIZE: + raise WindowsW1PlanError("Windows W1 media identity drift") + return profile + + +def portable_intent(profile: dict[str, Any]) -> dict[str, Any]: + fixture = profile["fixtures"]["windows_w1"] + return { + "schema": "windows-w1-portable-intent/v1", + "fixture": FIXTURE_ID, + "media": { + "product": fixture["product"], + "version": fixture["version"], + "build": fixture["build"], + "edition": fixture["edition"], + "architecture": fixture["architecture"], + "language": fixture["language"], + "sha256": MEDIA_SHA256, + "size_bytes": MEDIA_SIZE, + "qualification_authority": fixture["source"]["qualification_authority"], + "product_key_policy": fixture["product_key_policy"], + }, + "requirements": { + "virtual_processors": VCPUS, + "memory_mib": MEMORY_MIB, + "system_disk_gib": SYSTEM_DISK_GIB, + "firmware": "UEFI", + "secure_boot": True, + "tpm_version": "2.0", + "cpu_semantics": "host-passthrough", + "install_storage_semantics": "inbox-driver-compatible-ahci-sata", + "install_network_semantics": "inbox-driver-compatible-e1000", + }, + "lifecycle": [ + "observe-preconditions", + "create", + "readback", + "start-install", + "unattended-install", + "guest-readiness-oracle", + "restart", + "detach-install-media", + "system-disk-boot-oracle", + "stop", + "delete", + "absence", + ], + "claim_boundary": ( + "Windows W1 native VM install/readiness/restart/cleanup only; " + "nested virtualization, Hyper-V and WinBot-provider admission remain separate" + ), + } + + +def truenas_plan( + profile: dict[str, Any], + *, + pool: str, + iso_path: str, + bridge: str, + name: str = "rdtewindowsw1", +) -> dict[str, Any]: + row = profile["truenas"]["26.0.0-BETA.3"]["windows11"] + if row.get("fixture") != FIXTURE_ID or row.get("runtime_status") != "OPEN": + raise WindowsW1PlanError("TrueNAS BETA.3 Windows W1 profile is not source-bound OPEN") + if not re.fullmatch(r"[A-Za-z0-9_]+", name): + raise WindowsW1PlanError("TrueNAS VM name violates exact product name grammar") + pool = _safe_binding(pool, "pool") + if "/" in pool: + raise WindowsW1PlanError("TrueNAS pool binding must be a pool name, not a dataset path") + iso_path = _safe_binding(iso_path, "iso-path") + if not iso_path.startswith("/mnt/"): + raise WindowsW1PlanError("TrueNAS ISO binding must be a staged /mnt path") + bridge = _safe_binding(bridge, "bridge") + + zvol_name = f"{pool}/{name}system" + return { + "schema": "windows-w1-native-plan/v1", + "platform": "truenas", + "target": "26.0.0-BETA.3", + "portable_intent": portable_intent(profile), + "source_binding": TRUENAS_SOURCE, + "external_bindings": { + "install_media_path": iso_path, + "install_media_required_sha256": MEDIA_SHA256, + "network_attach": bridge, + "system_zvol": zvol_name, + }, + "vm_create": { + "name": name, + "description": "Agent Dispatch disposable Windows W1 fixture", + "vcpus": 1, + "cores": 2, + "threads": 1, + "memory": MEMORY_MIB, + "cpu_mode": "HOST-PASSTHROUGH", + "autostart": False, + "ensure_display_device": True, + "time": "LOCAL", + "bootloader": "UEFI", + "bootloader_ovmf": "OVMF_CODE_4M.secboot.fd", + "arch_type": "x86_64", + "machine_type": "pc-q35-6.2", + "trusted_platform_module": True, + "hyperv_enlightenments": True, + "enable_secure_boot": True, + "shutdown_timeout": 90, + }, + "device_templates": [ + { + "role": "install-media", + "vm_binding": "created_vm_id", + "order": 100, + "attributes": {"dtype": "CDROM", "path": iso_path}, + }, + { + "role": "system-disk", + "vm_binding": "created_vm_id", + "order": 200, + "attributes": { + "dtype": "DISK", + "path": None, + "type": "AHCI", + "create_zvol": True, + "zvol_name": zvol_name, + "zvol_volsize": SYSTEM_DISK_BYTES, + }, + }, + { + "role": "network", + "vm_binding": "created_vm_id", + "attributes": {"dtype": "NIC", "type": "E1000", "nic_attach": bridge}, + }, + ], + "post_install_transition": { + "action": "delete-owned-install-media-device", + "reason": "force restart/boot proof from the installed system disk without firmware boot ambiguity", + }, + "mutation_authorized": False, + } + + +def proxmox_plan( + profile: dict[str, Any], + *, + vmid: int, + storage: str, + iso_volume: str, + bridge: str, + name: str = "rdte-windows-w1", +) -> dict[str, Any]: + row = profile["proxmox"]["9.2-1"]["windows11"] + if ( + row.get("fixture") != FIXTURE_ID + or row.get("runtime_status") != "OPEN" + or row.get("source_status") != "CANDIDATE" + ): + raise WindowsW1PlanError("PVE 9.2-1 Windows W1 profile is not source-bound CANDIDATE/OPEN") + if not isinstance(vmid, int) or isinstance(vmid, bool) or vmid < 100: + raise WindowsW1PlanError("PVE vmid must be an integer >= 100") + storage = _safe_binding(storage, "storage") + if not re.fullmatch(r"[A-Za-z0-9_.-]+", storage): + raise WindowsW1PlanError("PVE storage binding contains unsupported characters") + iso_volume = _safe_binding(iso_volume, "iso-volume") + if ":" not in iso_volume: + raise WindowsW1PlanError("PVE ISO binding must be a storage volume id") + bridge = _safe_binding(bridge, "bridge") + if "," in bridge: + raise WindowsW1PlanError("PVE bridge binding must not contain commas") + name = _safe_binding(name, "name") + + return { + "schema": "windows-w1-native-plan/v1", + "platform": "proxmox", + "target": "9.2-1", + "portable_intent": portable_intent(profile), + "source_binding": PROXMOX_SOURCE, + "external_bindings": { + "install_media_volume": iso_volume, + "install_media_required_sha256": MEDIA_SHA256, + "vm_storage": storage, + "network_bridge": bridge, + }, + "create_path": "/nodes/{node}/qemu", + "create_fields": { + "vmid": vmid, + "name": name, + "memory": MEMORY_MIB, + "cores": 2, + "sockets": 1, + "cpu": "host", + "bios": "ovmf", + "machine": "q35", + "ostype": "win11", + "start": 0, + "net0": f"e1000,bridge={bridge}", + "sata0": f"{storage}:64", + "ide2": f"{iso_volume},media=cdrom", + "efidisk0": f"{storage}:1,efitype=4m,pre-enrolled-keys=1", + "tpmstate0": f"{storage}:1,version=v2.0", + "boot": "order=ide2;sata0", + "description": "Agent Dispatch disposable Windows W1 fixture", + }, + "post_install_transition": { + "method": "PUT", + "path": "/nodes/{node}/qemu/{vmid}/config", + "fields": {"delete": "ide2", "boot": "order=sata0"}, + "reason": "force restart/boot proof from the installed system disk without firmware boot ambiguity", + }, + "mutation_authorized": False, + } + + +def main() -> int: + p = argparse.ArgumentParser() + p.add_argument("--profile", type=pathlib.Path, default=pathlib.Path("config/compute-guest-profiles.json")) + p.add_argument("--platform", choices=["truenas", "proxmox"], required=True) + p.add_argument("--storage", required=True) + p.add_argument("--media-ref", required=True) + p.add_argument("--network", required=True) + p.add_argument("--name") + p.add_argument("--vmid", type=int, default=9301) + p.add_argument("--out", type=pathlib.Path) + a = p.parse_args() + try: + profile = _profile(a.profile) + if a.platform == "truenas": + result = truenas_plan( + profile, + pool=a.storage, + iso_path=a.media_ref, + bridge=a.network, + name=a.name or "rdtewindowsw1", + ) + else: + result = proxmox_plan( + profile, + vmid=a.vmid, + storage=a.storage, + iso_volume=a.media_ref, + bridge=a.network, + name=a.name or "rdte-windows-w1", + ) + except (OSError, ValueError, KeyError, WindowsW1PlanError) as exc: + result = { + "schema": "windows-w1-native-plan/v1", + "classification": "PLAN_FAILURE", + "mutation_authorized": False, + "detail": f"{type(exc).__name__}: {exc}", + } + code = 2 + else: + result["classification"] = "SUPPORTED" + result["oracleSatisfied"] = True + code = 0 + payload = json.dumps(result, indent=2, sort_keys=True) + "\n" + if a.out: + a.out.write_text(payload, encoding="utf-8") + print(payload, end="") + return code + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_collage_catalog_preflight.py b/tests/test_collage_catalog_preflight.py new file mode 100644 index 000000000..ef4901b3c --- /dev/null +++ b/tests/test_collage_catalog_preflight.py @@ -0,0 +1,96 @@ +"""Offline source tests: preflight must reject missing pins, not launch a VM.""" +import unittest +import pathlib +import urllib.error +from scripts.check_collage_catalog_preflight import (evaluate, version_inventory, + fixed_catalog_failure_reason, URI) + +class CatalogPreflightTests(unittest.TestCase): + def test_public_vendor_catalog_uses_real_master_not_nonexistent_main(self): + # 2026-10-14 causal failure of guest run 38488438084: + # official truenas/apps default branch = master; ?ref=main HTTP 404. + self.assertEqual( + URI, + "https://api.github.com/repos/truenas/apps/contents/trains/community/minecraft?ref=master", + ) + self.assertNotIn("?ref=main", URI) + + def test_available_exact_pin(self): + rows=[{"name":"1.15.29","type":"dir"},{"name":"item.yaml","type":"file"}] + v=evaluate(rows,"1.15.29") + self.assertEqual(v["classification"],"PRE_KVM_CATALOG_VERSION_AVAILABLE") + self.assertFalse(v["guest_catalog_observed"]) + self.assertFalse(v["world_mutated"]) + + def test_upstream_retires_exact_pin(self): + rows=[{"name":"1.15.30","type":"dir"},{"name":"item.yaml","type":"file"}] + v=evaluate(rows,"1.15.29") + self.assertEqual(v["classification"],"PRE_KVM_CATALOG_DRIFT") + self.assertEqual(v["visible_versions"],["1.15.30"]) + self.assertFalse(v["live_minecraft_qualified"]) + + def test_malformed_catalog_fails_closed(self): + for value in ({}, None, [], [{"name":"1.15.29"}], + [{"name":"1.15.29","type":"dir"}]*2): + with self.subTest(value=value),self.assertRaises(ValueError): + version_inventory(value) + + def test_causal_preflight_is_in_truenas_job_not_proxmox(self): + root=pathlib.Path(__file__).resolve().parents[1] + yml=(root/".github/workflows/gha-kvm-system-rdte.yml").read_text() + prox=yml.split(" proxmox-9-2:",1)[1].split(" truenas-26-beta3:",1)[0] + guest=yml.split(" truenas-26-beta3:",1)[1] + guard="python3 scripts/check_collage_catalog_preflight.py --expected 1.15.30" + self.assertNotIn(guard,prox) + self.assertIn(guard,guest) + self.assertLess(guest.index(guard), + guest.index(" - name: Ensure bounded QEMU/KVM prerequisites")) + + def test_live_native_source_contract_separate_from_retired_paper(self): + rows=[{"name":"1.15.31","type":"dir"},{"name":"item.yaml","type":"file"}] + native=evaluate(rows,"1.15.31") + paper=evaluate(rows,"1.15.30") + self.assertEqual(native["classification"],"PRE_KVM_CATALOG_VERSION_AVAILABLE") + self.assertEqual(paper["classification"],"PRE_KVM_CATALOG_DRIFT") + self.assertFalse(native["guest_catalog_observed"]) + self.assertFalse(native["live_minecraft_qualified"]) + workflow=(pathlib.Path(__file__).resolve().parents[1]/ + ".github/workflows/official-minecraft-catalog-preflight.yml").read_text() + self.assertIn("python3 scripts/check_collage_catalog_preflight.py --expected 1.15.31",workflow) + self.assertIn('assert "1.15.30" not in v["visible_versions"]',workflow) + + def test_catalog_http_and_transport_are_inconclusive_not_retired(self): + # The OP4K-018 standalone cheap CI returned exit 3 (not version drift). + # Classify without copying URLs, GitHub error bodies, tokens or traces. + cases = ( + (urllib.error.HTTPError("https://private.example/SECRET", 403, + "SECRET", None, None), + "OFFICIAL_CATALOG_HTTP_THROTTLED_OR_FORBIDDEN"), + (urllib.error.HTTPError("https://private.example/SECRET", 429, + "SECRET", None, None), + "OFFICIAL_CATALOG_HTTP_THROTTLED_OR_FORBIDDEN"), + (urllib.error.HTTPError("https://private.example/SECRET", 404, + "SECRET", None, None), + "OFFICIAL_CATALOG_HTTP_NOT_FOUND"), + (urllib.error.HTTPError("https://private.example/SECRET", 502, + "SECRET", None, None), + "OFFICIAL_CATALOG_HTTP_OTHER"), + (urllib.error.URLError("SECRET-transport"), + "OFFICIAL_CATALOG_TRANSPORT_UNAVAILABLE"), + (OSError("SECRET-network"), "OFFICIAL_CATALOG_TRANSPORT_UNAVAILABLE"), + (ValueError("SECRET-json"), "OFFICIAL_CATALOG_RESPONSE_INVALID"), + ) + for exc, code in cases: + with self.subTest(code=code): + category = fixed_catalog_failure_reason(exc) + self.assertEqual(category, code) + self.assertNotIn("SECRET", category) + + def test_no_float_latest_pin(self): + rows=[{"name":"1.15.30","type":"dir"}] + for version in ("latest","1.15","1.15.29\n",None): + with self.subTest(version=version),self.assertRaises(ValueError): + evaluate(rows,version) + +if __name__=="__main__": + unittest.main() diff --git a/tests/test_collage_p2_effective_roles.py b/tests/test_collage_p2_effective_roles.py new file mode 100644 index 000000000..a8cb4e7ea --- /dev/null +++ b/tests/test_collage_p2_effective_roles.py @@ -0,0 +1,413 @@ +"""Pure request/response policy checks; never claims a running TrueNAS guest.""" +import unittest +import json +import datetime as dt + +from scripts.truenas_middleware_collage_readback_p2 import ( + ObservationError, provision_guest_readonly, bounded_rbac_call, RBAC_CALL_SEQUENCE, + classify_scoped_key_ddp_failure, scoped_key_record_count_suffix, + scoped_key_expiry_ejson, validated_native_join_verdict, + SERVICE_UUID, VANILLA263_OWNER, VANILLA263_WORLD, +) + +ROLE = "APPS_READ" +USER = "collage_p2_observer" +PRIVILEGE = "collage-p2-apps-read" +GROUP_ID = 1013 +GROUP_GID = 32013 # POSIX GID, deliberately not middleware group datastore id + + +def authority_responses(*, primary_roles=None, user_roles=None, + user_group_id=GROUP_ID, privilege_roles=None, + group_gid=GROUP_GID, privilege_group_gid=GROUP_GID, + group_local=True, group_found=True): + primary_roles = [ROLE] if primary_roles is None else primary_roles + user_roles = [ROLE] if user_roles is None else user_roles + privilege_roles = [ROLE] if privilege_roles is None else privilege_roles + responses = [ + ("user.query", []), + ("privilege.query", []), + ("privilege.roles", [{"name": ROLE, "includes": []}]), + ("user.create", {"username": USER, "group": {"id": GROUP_ID}}), + ("group.query", {"id": GROUP_ID, "gid": group_gid, "local": group_local} + if group_found else None), + ("privilege.create", {"name": PRIVILEGE, "roles": privilege_roles, + "local_groups": [{"gid": privilege_group_gid}]}), + ("group.query", {"id": GROUP_ID, "roles": primary_roles}), + ("user.query", {"username": USER, "group": {"id": user_group_id}, + "roles": user_roles}), + ("api_key.create", {"key": "disposable-test-only-key-value-not-real"}), + ] + return responses + + +class RoleLinkageTests(unittest.TestCase): + def call_with(self, responses): + called = [] + + def call(method, params): + self.assertLess(len(called), len(responses), "unexpected API call") + want, result = responses[len(called)] + self.assertEqual(method, want, f"wrong operation at position {len(called)}") + if method == "api_key.create": + # Assert the exact 25.04.2.6 legacy DDP EJSON datetime shape, + # NOT the previously E11-refused ISO string or nonexpiring key. + expires = params[0]["expires_at"] + self.assertEqual(set(expires), {"$date"}) + self.assertIs(type(expires["$date"]), int) + seconds_ahead = expires["$date"] / 1000 - dt.datetime.now( + dt.timezone.utc).timestamp() + self.assertLess(seconds_ahead, 3605) + self.assertGreater(seconds_ahead, 3550) + if method == "privilege.create": + # Caller must send UNIX GID, not middleware group datastore id. + self.assertEqual(params[0]["local_groups"], [GROUP_GID]) + self.assertNotEqual(params[0]["local_groups"], [GROUP_ID]) + self.assertEqual(params[0]["roles"], ["APPS_READ"]) + if method == "group.query": + self.assertEqual(params[0][0][0], "id") + self.assertEqual(params[0][0][2], GROUP_ID) + called.append(method) + return result + return call, called + + def test_api_key_create_ddp_rejection_only_emits_allowlisted_error_number(self): + for code in (13,22,400,403,422,500): + err=RuntimeError('api_key.create: {"error": '+str(code)+ + ', "reason": "API-SECRET-NEVER-LOG-THIS"}') + self.assertEqual(classify_scoped_key_ddp_failure(err),"_E"+str(code)) + def remote(method, params): + if method=="api_key.create": + raise err + return next(x[1] for x in authority_responses() if x[0]==method) + # The actual guest call order is exercised separately below. + fn,seq=self.call_with(authority_responses()) + def failing(method,params): + if method=="api_key.create": raise err + return fn(method,params) + with self.assertRaises(ObservationError) as caught: + provision_guest_readonly(bounded_rbac_call(failing)) + self.assertEqual(str(caught.exception), + "GUEST_RBAC_ISSUE_SCOPED_KEY_API_FAILED_E"+str(code)) + self.assertNotIn("API-SECRET",str(caught.exception)) + malformed = ( + ('api_key.create: {"error": 987654,"reason":"SECRET"}', + "_DDP_UNRECOGNIZED_CODE"), + ('api_key.create: {"error":"CUSTOM_PRIVATE"}', + "_DDP_UNRECOGNIZED_CODE"), + ('api_key.create: {"error": {"error":403},"reason":"SECRET"}', + "_E403"), + ('api_key.create: {"error":[{"field":"PRIVATE"}]}', + "_DDP_VALIDATION_LIST"), + ('api_key.create: bad-json', "_DDP_MALFORMED_ERROR"), + ('other.method: {"error": 403}', "_DDP_UNSTRUCTURED_ERROR"), + ("api_key.create: "+("secret"*2000), "_DDP_MALFORMED_ERROR"), + ('api_key.create: {"error": 403, "reason": "' + ("SENSITIVE"*3000) + '"}', + "_E403"), + ('api_key.create: {"error": {"errno": 422, "reason": "PRIVATE"}}', + "_E422"), + ('api_key.create: {"error": {"errno": 99999, "reason": "PRIVATE"}}', + "_DDP_NESTED_ERROR"), + ('api_key.create: {"error": 500, "reason": "' + ("PRIVATE"*40000) + '"}', + "_DDP_OVERSIZE_ERROR"), + ) + for msg, category in malformed: + with self.subTest(category=category): + self.assertEqual(classify_scoped_key_ddp_failure(RuntimeError(msg)),category) + for exc, category in ( + (TimeoutError("PRIVATE-KEY-NEVER-LOG"), "_TRANSPORT_TIMEOUT_INDETERMINATE"), + (ConnectionError("PRIVATE-USER-NEVER-LOG"), "_TRANSPORT_DISCONNECTED"), + (OSError("PRIVATE-ENDPOINT-NEVER-LOG"), "_TRANSPORT_IO_FAILURE"), + (ValueError("PRIVATE-ROLE-NEVER-LOG"), "_LOCAL_UNEXPECTED_FAILURE"), + ): + self.assertEqual(classify_scoped_key_ddp_failure(exc),category) + self.assertNotIn("PRIVATE",classify_scoped_key_ddp_failure(exc)) + # Failures propagate through the actual nine-operation RBAC wrapper, + # but the caller sees neither raw DDP text nor a spurious success. + for exc,category in ( + (TimeoutError("DO-NOT-EMIT"),"_TRANSPORT_TIMEOUT_INDETERMINATE"), + (RuntimeError('api_key.create: {"error":[{"secret":"DO-NOT-EMIT"}]}'), + "_DDP_VALIDATION_LIST"), + ): + responses=authority_responses() + called=[] + def failure(method,params): + ordinal=len(called) + called.append(method) + if method=="api_key.create": + raise exc + return responses[ordinal][1] + with self.assertRaises(ObservationError) as caught: + provision_guest_readonly(bounded_rbac_call(failure)) + self.assertEqual(str(caught.exception), + "GUEST_RBAC_ISSUE_SCOPED_KEY_API_FAILED"+category+ + ("_KEY_COUNT_UNAVAILABLE" if category == + "_TRANSPORT_TIMEOUT_INDETERMINATE" else "")) + self.assertNotIn("DO-NOT-EMIT",str(caught.exception)) + + def test_scoped_expiry_is_timezone_aware_pinned_ejson_datetime_not_string(self): + # Frozen trueNAS_api_client release/25.04.2.6 ejson.JSONEncoder + # emits UTC milliseconds; object_hook restores timezone-aware datetime. + original = dt.datetime(2026, 10, 14, 15, 0, 0, 456000, + tzinfo=dt.timezone.utc) + value = scoped_key_expiry_ejson(original) + self.assertEqual(value, {"$date": 1791993600000}) + restored = dt.datetime.fromtimestamp(value["$date"] / 1000, + tz=dt.timezone.utc) + self.assertIs(type(restored), dt.datetime) + self.assertEqual(restored, dt.datetime(2026, 10, 14, 16, 0, + tzinfo=dt.timezone.utc)) + self.assertNotIsInstance(value, str) + different_zone = original.astimezone(dt.timezone(dt.timedelta(hours=7))) + self.assertEqual(scoped_key_expiry_ejson(different_zone), value) + with self.assertRaisesRegex(ObservationError, "AWARE_DATETIME"): + scoped_key_expiry_ejson(dt.datetime(2026, 10, 14, 15)) + for invalid in (None, "2026-10-14T16:00:00Z", 1791990000, {}): + # None uses the live clock by documented default; malformed + # non-null inputs must fail closed. + if invalid is None: + assert set(scoped_key_expiry_ejson()) == {"$date"} + else: + with self.assertRaises(ObservationError): + scoped_key_expiry_ejson(invalid) + + def test_validation_error_field_allowlist_and_no_provider_data_leakage(self): + # Frozen TrueNAS 25.04 websocket_app turns ValidationErrors into + # errno EAGAIN (11) with extra [field, message, errno] tuples. + cases = ( + ("api_key_create", "_E11_VALIDATION_GENERAL"), + ("api_key_create.name", "_E11_VALIDATION_NAME"), + ("api_key_create.username", "_E11_VALIDATION_USERNAME"), + ("api_key_create.expires_at", "_E11_VALIDATION_EXPIRES_AT"), + ("api_key_create.username.extra", "_E11_VALIDATION_FIELD_UNKNOWN"), + ("api_key_create.password", "_E11_VALIDATION_FIELD_UNKNOWN"), + ("evil.api_key_create.username", "_E11_VALIDATION_FIELD_UNKNOWN"), + ) + for field, expected in cases: + payload = {"error": 11, "type": "VALIDATION", + "extra": [[field, "SENSITIVE-KEY-DO-NOT-EMIT", 22]], + "reason": "SENSITIVE-USER-DO-NOT-EMIT", + "trace": {"formatted": "SENSITIVE-TRACE-DO-NOT-EMIT"}} + value = classify_scoped_key_ddp_failure( + RuntimeError("api_key.create: " + json.dumps(payload))) + with self.subTest(field=field): + self.assertEqual(value, expected) + self.assertNotIn("SENSITIVE", value) + self.assertNotIn(field, value) + extra_cases = ( + ([], "_E11_VALIDATION_FIELD_UNKNOWN"), + (["api_key_create.username"], "_E11_VALIDATION_FIELD_UNKNOWN"), + ([["api_key_create.username", "secret"]], "_E11_VALIDATION_FIELD_UNKNOWN"), + ([[True, "secret", 22]], "_E11_VALIDATION_FIELD_UNKNOWN"), + ([[["api_key_create.username"], "secret", 22]], "_E11_VALIDATION_FIELD_UNKNOWN"), + ([[ "api_key_create.username", "secret", 22]]*13, + "_E11_VALIDATION_FIELD_UNKNOWN"), + ([["api_key_create.username", "secret", 22], + ["api_key_create.name", "secret", 22]], "_E11_VALIDATION_MULTI"), + ) + for extra, expected in extra_cases: + payload = {"error": 11, "type": "VALIDATION", "extra": extra, + "trace": "DO-NOT-EMIT"*1000} + self.assertEqual(classify_scoped_key_ddp_failure( + RuntimeError("api_key.create: " + json.dumps(payload))), expected) + for bad_type in (None, False, "VALIDATION_WRONG", [], {}): + payload = {"error": 11, "type": bad_type, + "extra": [["api_key_create.name", "secret", 22]]} + self.assertEqual(classify_scoped_key_ddp_failure( + RuntimeError("api_key.create: " + json.dumps(payload))), "_E11") + + def test_failed_nonidempotent_key_creation_performs_only_one_exact_readonly_count(self): + for count, expected in ((0, "_KEY_COUNT0"), (1, "_KEY_COUNT1"), + (2, "_KEY_COUNT_MULTIPLE"), (False, "_KEY_COUNT_INVALID"), + (-1, "_KEY_COUNT_INVALID"), ([], "_KEY_COUNT_INVALID")): + calls = [] + def read_only(method, params): + calls.append((method, params)) + self.assertEqual(method, "api_key.query") + self.assertEqual(params, [ + [["username", "=", USER], ["name", "=", "disposable-collage-p2"]], + {"count": True}, + ]) + return count + with self.subTest(count=count): + self.assertEqual(scoped_key_record_count_suffix(read_only), expected) + self.assertEqual(len(calls), 1) + def fail_query(method, params): + raise RuntimeError("SECRET-NEVER-PUBLISH") + self.assertEqual(scoped_key_record_count_suffix(fail_query), + "_KEY_COUNT_UNAVAILABLE") + + responses = authority_responses() + called = [] + def failed_key(method, params): + called.append(method) + if method == "api_key.create": + raise RuntimeError("api_key.create: " + json.dumps({ + "error": 11, "type": "VALIDATION", + "extra": [["api_key_create.username", "SECRET", 22]], + "trace": "SECRET-NEVER-PUBLISH"})) + if method == "api_key.query": + self.assertEqual(params[1], {"count": True}) + return 0 + return responses[len(called)-1][1] + with self.assertRaises(ObservationError) as raised: + provision_guest_readonly(bounded_rbac_call(failed_key)) + self.assertEqual(str(raised.exception), + "GUEST_RBAC_ISSUE_SCOPED_KEY_API_FAILED" + "_E11_VALIDATION_USERNAME_KEY_COUNT0") + self.assertEqual(called[-2:], ["api_key.create", "api_key.query"]) + self.assertEqual(called.count("api_key.create"), 1) + self.assertNotIn("SECRET", str(raised.exception)) + + def test_source_contract_allows_only_exact_role_linkage(self): + fn, methods = self.call_with(authority_responses()) + self.assertEqual(provision_guest_readonly(fn), + "disposable-test-only-key-value-not-real") + self.assertEqual(methods[-1], "api_key.create") + self.assertEqual(methods.count("group.query"),2) + self.assertLess(methods.index("group.query"),methods.index("privilege.create")) + + def test_all_nine_real_guest_rbac_rpc_failure_phases_are_bounded_and_private(self): + sequence=authority_responses() + self.assertEqual([item[0] for item in sequence], + [item[0] for item in RBAC_CALL_SEQUENCE]) + for failing in range(len(RBAC_CALL_SEQUENCE)): + seen=[] + def remote(method, params): + number=len(seen) + seen.append(method) + self.assertEqual(method,sequence[number][0]) + if number==failing: + raise RuntimeError("API-SECRET-EXAMPLE MUST NEVER APPEAR") + return sequence[number][1] + with self.subTest(failing=failing): + label=RBAC_CALL_SEQUENCE[failing][1].upper() + with self.assertRaises(ObservationError) as caught: + provision_guest_readonly(bounded_rbac_call(remote)) + suffix = "_DDP_UNSTRUCTURED_ERROR" if failing == 8 else "" + self.assertEqual(str(caught.exception), + "GUEST_RBAC_"+label+"_API_FAILED"+suffix) + self.assertNotIn("API-SECRET",str(caught.exception)) + self.assertEqual(len(seen),failing+1) + if failing<8: + self.assertNotIn("api_key.create",seen) + fn,_=self.call_with(authority_responses()) + self.assertTrue(provision_guest_readonly(bounded_rbac_call(fn)).startswith( + "disposable-test-only-key-value")) + + def test_bounded_rbac_method_sequence_prevents_out_of_contract_rpc(self): + cb=bounded_rbac_call(lambda method,params: []) + with self.assertRaisesRegex(ObservationError,"GUEST_RBAC_API_SEQUENCE_DRIFT"): + cb("app.delete",[]) + + def test_unauthorized_roles_never_receive_api_key(self): + cases = [ + ({"user_roles": []}, "OBSERVER_USER_REQUIRED_ROLE_ABSENT"), + ({"user_roles": ["APPS_READ", "FULL_ADMIN"]}, + "OBSERVER_USER_EXTRA_ROLES_REFUSED"), + ({"user_roles": "APPS_READ"}, "OBSERVER_USER_ROLES_SHAPE_DRIFT"), + ({"user_group_id": GROUP_ID + 1}, + "OBSERVER_USER_PRIMARY_GROUP_DRIFT"), + ({"primary_roles": []}, "OBSERVER_PRIMARY_GROUP_ROLE_NOT_EXACT"), + ({"primary_roles": ["FULL_ADMIN"]}, + "OBSERVER_PRIMARY_GROUP_ROLE_NOT_EXACT"), + ({"privilege_roles": ["APPS_READ", "FULL_ADMIN"]}, + "CREATED_PRIVILEGE_ROLES_NOT_EXACT"), + ({"privilege_group_gid": GROUP_GID + 1}, + "CREATED_PRIVILEGE_GID_LINK_NOT_EXACT"), + ] + cases += [ + ({"group_gid": None}, "READONLY_ACCOUNT_PRIMARY_GROUP_GID_UNAVAILABLE"), + ({"group_local": False}, "READONLY_ACCOUNT_PRIMARY_GROUP_GID_UNAVAILABLE"), + ({"group_found": False}, "READONLY_ACCOUNT_PRIMARY_GROUP_GID_UNAVAILABLE"), + ] + for opts, reason in cases: + with self.subTest(opts=opts): + fn, methods = self.call_with(authority_responses(**opts)) + with self.assertRaisesRegex(ObservationError, reason): + provision_guest_readonly(fn) + self.assertNotIn("api_key.create", methods) + + +class NativeJoin018Tests(unittest.TestCase): + """Pure sanitized child-result classification; no guest or API calls.""" + + def document(self): + return { + "schema": "collage.native-readback/1", + "classification": "PROVIDER_READBACK_MATCH", + "mutation_authorized": False, + "ownership_fields_matched": 6, + "service_id": SERVICE_UUID, + "native_app": VANILLA263_OWNER, + "world_id": VANILLA263_WORLD, + "native_app_version": "1.15.31", + "truenas_version": "25.04.2.6", + "source_plan_sha256": "a" * 64, + } + + def adjudicate(self, code, result): + return validated_native_join_verdict( + code, json.dumps(result) if not isinstance(result, str) else result, + owner=VANILLA263_OWNER, world=VANILLA263_WORLD) + + def refused(self, code, result, expected): + with self.assertRaises(ObservationError) as caught: + self.adjudicate(code, result) + self.assertEqual(str(caught.exception), expected) + self.assertNotIn("SECRET-DO-NOT-EMIT", str(caught.exception)) + + def test_independent_exact_positive_requires_readback_and_sha(self): + result = self.adjudicate(0, self.document()) + self.assertEqual(result["ownership_fields_matched"], 6) + self.assertEqual(result["native_app_version"], "1.15.31") + + def test_version_owner_service_replay_and_shape_fail_closed(self): + base = self.document() + for field, bad, reason in ( + ("native_app_version", "1.15.30", "EXACT_CATALOG_INVALID"), + ("truenas_version", "25.04.2.7", "EXACT_CATALOG_INVALID"), + ("native_app", "foreign", "TARGET_INVALID"), + ("world_id", "foreign", "TARGET_INVALID"), + ("service_id", "foreign", "OWNER_FIELDS_INVALID"), + ("ownership_fields_matched", 5, "OWNER_FIELDS_INVALID"), + ("source_plan_sha256", "wrong", "SOURCE_DIGEST_INVALID"), + ("mutation_authorized", True, "OWNER_FIELDS_INVALID"), + ("classification", "REFUSED", "CLASSIFICATION_INVALID"), + ("schema", "foreign", "SCHEMA_INVALID"), + ): + d = dict(base) + d[field] = bad + with self.subTest(field=field): + self.refused(0, d, "NATIVE_READONLY_GUEST_JOIN_" + reason) + + def test_fixed_child_codes_only_no_raw_private_reflection(self): + error = {"schema": "collage.native-readback/1", + "classification": "REFUSED", + "mutation_authorized": False, + "reason": "NATIVE_VANILLA263_GUEST_PROFILE_DRIFT", + "private": "SECRET-DO-NOT-EMIT"} + self.refused(2, error, + "NATIVE_READONLY_GUEST_JOIN_REFUSED_NATIVE_VANILLA263_GUEST_PROFILE_DRIFT") + error["reason"] = "SENSITIVE_SECRET-DO-NOT-EMIT" + self.refused(2, error, "NATIVE_READONLY_GUEST_JOIN_REFUSED_OTHER") + error["reason"] = "NATIVE_READONLY_PROBE_FAILED" + self.refused(2, error, + "NATIVE_READONLY_GUEST_JOIN_REFUSED_NATIVE_READONLY_PROBE_FAILED") + error["mutation_authorized"] = True + self.refused(2, error, "NATIVE_READONLY_GUEST_JOIN_CLIENT_EXIT") + self.refused(1, error, "NATIVE_READONLY_GUEST_JOIN_CLIENT_EXIT") + self.refused(0, "SECRET-DO-NOT-EMIT not json", + "NATIVE_READONLY_GUEST_JOIN_NONJSON") + self.refused(0, "x" * 32768, + "NATIVE_READONLY_GUEST_JOIN_OUTPUT_INVALID") + inconclusive = {"schema": "collage.native-readback/1", + "classification": "INCONCLUSIVE", + "mutation_authorized": False, + "reason": "PROBE_EXECUTION_UNAVAILABLE"} + self.refused(3, inconclusive, + "NATIVE_READONLY_GUEST_JOIN_CLIENT_INCONCLUSIVE") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_collage_p2_native.py b/tests/test_collage_p2_native.py new file mode 100644 index 000000000..25eafe2a6 --- /dev/null +++ b/tests/test_collage_p2_native.py @@ -0,0 +1,126 @@ +"""Cheap source/contract tests for COLLAGE P2 disposable TrueNAS observer. + +These tests DO NOT impersonate a real TrueNAS guest or satisfy the live oracle. +""" +from __future__ import annotations + +import copy +import pathlib +import subprocess +import unittest + +from scripts.assert_collage_p2_receipt import check + +ROOT = pathlib.Path(__file__).resolve().parents[1] + + +class CollageP2SourceTests(unittest.TestCase): + def test_success_requires_real_native_guest_receipt(self): + with self.assertRaisesRegex(ValueError, "root contract"): + check({}) + with self.assertRaisesRegex(ValueError, "root failed"): + check({ + "contract": "gha-kvm-system-lab/v1", + "target": {"version": "25.04.1", "rung": "T6", "product": "truenas"}, + "classification": "HARNESS_FAILURE", "oracleSatisfied": False, + }) + + def test_paper_success_is_not_collage_readback_success(self): + native = { + "contract": "gha-kvm-system-lab/v1", + "target": {"version": "25.04.1", "rung": "T6", "product": "truenas"}, + "classification": "SUPPORTED", "oracleSatisfied": True, + "foundry_materialization": { + "schema": "truenas-minecraft-paper-t6/v1", + "classification": "SUPPORTED", "oracleSatisfied": True, + "catalog_version_verified": True, + "foundry_ref": "403321031b6d4e66aa64a8299fd7621eee290b0d", + "catalog_discovery": { + "requested_version": "1.15.30", + "upstream_apps_commit": "559b9fe85212c800bc6770f152a65211df137a44", + }, + "image_digest_qualification": "SOURCE_AND_GUEST_REF_MATCH", + "observed_image_reference": "ghcr.io/itzg/minecraft-server:2026.10.0-java21@sha256:d4057e7019e9b19f25e039d4a204cc502e1a112d1a2305966a534f3d7079e644", + "world_retained_on_delete": True, + "world_rebound_after_reinstall": True, + "cleanup": {"zero_residue": True, "app_absent": True, + "owned_world_dataset_absent": True}, + "readiness": {key: { + "protocol": {"oracleSatisfied": True, "server_protocol": 774, + "server_implementation": "paper"}, + "level": {"size": 123}, + } for key in ("first", "restart", "redeploy", "reinstall")}, + }, + } + with self.assertRaisesRegex(ValueError, "observation missing"): + check(native) + fake = copy.deepcopy(native) + fake["foundry_materialization"]["collage_readback"] = { + "schema": "semper-supra.collage-native-readback-p2/1", + "classification": "PROVIDER_READBACK_MATCH", + "source_sha": "fbda94f3fea0a216ddd9c35b38a09d325bed3640", + "native_app": "rdte-t6-minecraft-paper", + "service_id": "11111111-1111-4111-8111-111111111111", + "ownership_fields_matched": 6, + "credential_scope": "FULL_ADMIN", + "transport": "isolated_guest_tls_verified", + "observation_mutation_count": 0, + "world_activated": False, + "cloudnet_adoption_executed": False, + "physical_true_nas_mutated": False, + "tls_cert_sha256": "a" * 64, + } + with self.assertRaisesRegex(ValueError, "observation missing"): + check(fake) + fake["foundry_materialization"]["collage_readback"]["credential_scope"] = "APPS_READ" + check(fake) # Only pure receipt predicate; NEVER live product acceptance. + fake["foundry_materialization"]["collage_readback"]["observation_mutation_count"] = 1 + with self.assertRaisesRegex(ValueError, "observation missing"): + check(fake) + + def test_old_catalog_and_image_cannot_fake_new_collage_p2(self): + native = { + "contract": "gha-kvm-system-lab/v1", + "target": {"version": "25.04.1", "rung": "T6", "product": "truenas"}, + "classification": "SUPPORTED", "oracleSatisfied": True, + "foundry_materialization": { + "schema": "truenas-minecraft-paper-t6/v1", + "classification": "SUPPORTED", "oracleSatisfied": True, + "catalog_version_verified": True, + "foundry_ref": "2cc7e1fe3772f8c532e933b3d7832e6cf985db4e", + "catalog_discovery": {"requested_version": "1.15.29", + "upstream_apps_commit": + "bb96ef6fd8c2708765012a4a3ca3739370a4f92c"}, + "image_digest_qualification": "SOURCE_AND_GUEST_REF_MATCH", + "observed_image_reference": "ghcr.io/itzg/minecraft-server:2026.9.2-java21@sha256:edf50d431ddc6f73929368e82f4334a405796874f7b0ae11f1edcbb267d670aa", + "world_retained_on_delete": True, + "world_rebound_after_reinstall": True, + }, + } + with self.assertRaisesRegex(ValueError, "new immutable native catalog"): + check(native) + + def test_existing_paper_fixture_is_opt_in_unchanged(self): + paper = (ROOT / "scripts/truenas_middleware_minecraft_t6_probe.py").read_text() + shell = (ROOT / "scripts/gha_kvm_truenas_rdte.sh").read_text() + workflow = (ROOT / ".github/workflows/gha-kvm-system-rdte.yml").read_text() + helper = (ROOT / "scripts/truenas_middleware_collage_readback_p2.py").read_text() + self.assertIn("if a.collage_source_dir is not None:", paper) + self.assertIn('if [[ "$T6_PRODUCT" == "collage-native-paper" ]]; then', shell) + self.assertIn('--collage-source-ref "fbda94f3fea0a216ddd9c35b38a09d325bed3640"', shell) + self.assertIn("ref: fbda94f3fea0a216ddd9c35b38a09d325bed3640", workflow) + self.assertIn('"roles": ["APPS_READ"]', helper) + self.assertIn('"web_shell": False', helper) + self.assertIn('env["SSL_CERT_FILE"]', helper) + self.assertIn("observation_mutation_count", helper) + self.assertNotIn("InsecureSkipVerify: true", helper) + + def test_bash_static_syntax(self): + for path in ("scripts/gha_kvm_truenas_rdte.sh",): + p = subprocess.run(["bash", "-n", str(ROOT / path)], + capture_output=True, text=True) + self.assertEqual(p.returncode, 0, p.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_collage_p2_new_native_version.py b/tests/test_collage_p2_new_native_version.py new file mode 100644 index 000000000..f4cafbbb9 --- /dev/null +++ b/tests/test_collage_p2_new_native_version.py @@ -0,0 +1,48 @@ +"""Cheap negative and isolation tests for new opt-in COLLAGE catalog product.""" +import pathlib +import sys +import unittest + +ROOT=pathlib.Path(__file__).resolve().parents[1] +sys.path.insert(0,str(ROOT/"scripts")) +from scripts.truenas_middleware_minecraft_t6_probe import ( + CATALOG_PROFILES, EXPECTED_APPS_COMMIT, EXPECTED_IMAGE_FRAGMENT, + qualified_native_image, +) + +NEW_COMMIT="559b9fe85212c800bc6770f152a65211df137a44" +NEW_IMAGE=("ghcr.io/itzg/minecraft-server:2026.10.0-java21@sha256:" + "d4057e7019e9b19f25e039d4a204cc502e1a112d1a2305966a534f3d7079e644") +OLD_COMMIT="bb96ef6fd8c2708765012a4a3ca3739370a4f92c" + +class CatalogProfilesTests(unittest.TestCase): + def test_existing_legacy_default_does_not_migrate(self): + self.assertEqual(EXPECTED_APPS_COMMIT, OLD_COMMIT) + self.assertEqual(CATALOG_PROFILES["1.15.29"]["apps_commit"], OLD_COMMIT) + self.assertEqual(CATALOG_PROFILES["1.15.29"]["image"], EXPECTED_IMAGE_FRAGMENT) + self.assertNotEqual(CATALOG_PROFILES["1.15.29"]["image"], NEW_IMAGE) + + def test_new_exact_image_match_and_old_image_refusal(self): + self.assertEqual(CATALOG_PROFILES["1.15.30"], + {"apps_commit": NEW_COMMIT, "image": NEW_IMAGE}) + old=EXPECTED_IMAGE_FRAGMENT + self.assertEqual(qualified_native_image({"active_workloads": { + "images": [NEW_IMAGE]}}, image_fragment=NEW_IMAGE), NEW_IMAGE) + self.assertIsNone(qualified_native_image({"active_workloads": { + "images": [old]}}, image_fragment=NEW_IMAGE)) + + def test_runner_binds_real_new_producer_separately(self): + workflow=(ROOT/".github/workflows/gha-kvm-system-rdte.yml").read_text() + shell=(ROOT/"scripts/gha_kvm_truenas_rdte.sh").read_text() + self.assertIn("foundry-collage-paper-11530-t6-control:", workflow) + self.assertIn("export-minecraft-paper-11530-t6-control.yml@", workflow) + self.assertIn("403321031b6d4e66aa64a8299fd7621eee290b0d", workflow) + self.assertIn("minecraft-paper-11530-truenas-t6-candidate", workflow) + self.assertIn("--native-catalog-profile", shell) + self.assertIn('"1.15.30"', shell) + self.assertIn("python3 scripts/check_collage_catalog_preflight.py --expected 1.15.30", workflow) + self.assertIn("needs['foundry-collage-paper-11530-t6-control'].result == 'success'", workflow) + + +if __name__=="__main__": + unittest.main() diff --git a/tests/test_collage_p2_roles.py b/tests/test_collage_p2_roles.py new file mode 100644 index 000000000..30c84a5b7 --- /dev/null +++ b/tests/test_collage_p2_roles.py @@ -0,0 +1,70 @@ +"""Source-only checks for the TrueNAS 25.04 privilege.roles response contract.""" +import unittest + +from scripts.truenas_middleware_collage_readback_p2 import ( + ObservationError, available_role_names, provision_guest_readonly, + exact_source_readback_request, VANILLA263_OWNER, VANILLA263_WORLD, +) + + +class RolesTests(unittest.TestCase): + def test_native_vanilla263_source_readback_mode_exact_and_nonplayable(self): + source = { + "request_id": "original-source", + "target": {"provider": "truenas"}, + "artifact": {"kind": "world-save", "sha256": "a" * 64}, + } + native,app,world = exact_source_readback_request(source, profile="vanilla263") + self.assertEqual(app,VANILLA263_OWNER) + self.assertEqual(world,VANILLA263_WORLD) + self.assertEqual(native["target"]["deployment_id"], "rdte-t6-minecraft-vanilla-263") + self.assertEqual(native["target"]["backend"], "vanilla") + self.assertEqual(native["target"]["minecraft_version"], "26.3") + self.assertEqual(native["target"]["world_id"], "lantern-rdte") + self.assertEqual(native["target"]["service_id"], "11111111-1111-4111-8111-111111111111") + self.assertEqual(native["artifact"]["kind"], "datapack") + self.assertEqual(native["artifact"]["minecraft_version"], "26.3") + self.assertEqual(native["artifact"]["world_id"], "lantern-rdte") + self.assertEqual(source["target"], {"provider": "truenas"}) + self.assertEqual(source["artifact"]["kind"], "world-save") + paper,oldapp,oldworld = exact_source_readback_request(source, profile="paper") + self.assertEqual(oldapp,"rdte-t6-minecraft-paper") + self.assertEqual(oldworld,"world-rdte-001") + self.assertEqual(paper["artifact"]["kind"],"world-save") + with self.assertRaisesRegex(ObservationError,"COLLAGE_READBACK_PROFILE_UNSUPPORTED"): + exact_source_readback_request(source,profile="operations") + + def test_role_descriptors_are_an_array(self): + rows = [ + {"name": "APPS_READ", "includes": [], "builtin": False, "stig": None}, + {"name": "READONLY_ADMIN", "includes": ["APPS_READ"], + "builtin": True, "stig": None}, + ] + self.assertEqual(available_role_names(rows), {"APPS_READ", "READONLY_ADMIN"}) + + def test_reject_schema_drift(self): + for rows in ({"APPS_READ": {}}, None, [{"name": "APPS_READ"}], + [{"name": "APPS_READ", "includes": "string"}], + [{"name": "APPS_READ", "includes": []}, + {"name": "APPS_READ", "includes": []}]): + with self.subTest(rows=rows), self.assertRaises(ObservationError): + available_role_names(rows) + + def test_unknown_role_does_not_provision(self): + methods = [] + + def only_discovery(method, params): + methods.append(method) + if method == "privilege.roles": + return [{"name": "POOL_READ", "includes": []}] + if method in ("user.query", "privilege.query"): + return [] + raise AssertionError("unexpected method") + with self.assertRaisesRegex(ObservationError, "APPS_READ_ROLE_NOT_AVAILABLE"): + provision_guest_readonly(only_discovery) + self.assertEqual(methods, + ["user.query", "privilege.query", "privilege.roles"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_compute_guest_profile_contract.py b/tests/test_compute_guest_profile_contract.py new file mode 100644 index 000000000..1f6d5a59d --- /dev/null +++ b/tests/test_compute_guest_profile_contract.py @@ -0,0 +1,201 @@ +import copy, unittest +from scripts.compute_guest_profile_contract import ProfileError, validate + +def fixture(): + return { + "schema":"semper-supra.compute-guest-profiles/v1", + "dependencies":{ + "firecracker":"SemperSupra/agent-dispatch-private#277", + "windows_embodiment":"mark-e-deyoung/windows-utilities#26", + "windows_media":"mark-e-deyoung/WinBot#70", + }, + "policy":{ + "source_capability_is_not_runtime_qualification":True, + "nested_kvm_must_be_observed_in_guest":True, + "private_hypervisor_escape_hatches_prohibited":True, + "windows_media_must_be_legal_public_evaluation_or_user_supplied":True, + "windows_product_key_must_not_be_embedded":True, + "guest_oracle_required_before_backend_admission":True, + }, + "fixtures":{ + "linux_v1":{ + "id":"cirros-0.6.3-x86_64-nocloud-serial-nonce", + "release":"0.6.3", + "architecture":"x86_64", + "source":{ + "url":"https://download.cirros-cloud.net/0.6.3/cirros-0.6.3-x86_64-disk.img", + "sha256":"7d6355852aeb6dbcd191bcda7cd74f1536cfe5cbf8a10495a7283a8396e4b75b", + "checksum_url":"https://download.cirros-cloud.net/0.6.3/SHA256SUMS", + }, + "datasource":"nocloud", + "user_data_contract":"executable-shell", + "oracle":{ + "transport":"serial-console", + "nonce_prefix":"AGENT_DISPATCH_V1_NONCE=", + "must_match_injected_nonce":True, + }, + "claim_boundary":"x", + }, + "windows_w1":{ + "id":"windows-11-enterprise-evaluation-26h2-x64-en-us", + "product":"Windows 11 Enterprise Evaluation", + "version":"26H2", + "build":"26300.9457", + "edition":"Enterprise", + "architecture":"x64", + "language":"English", + "source":{ + "host":"software-static.download.prss.microsoft.com", + "sha256":"bc3f24086ebadc94489066b5ad78089e2cf5c3491e90e790bb81a2b199c10e38", + "size_bytes":8225329152, + "qualification_authority":"mark-e-deyoung/WinBot#70", + "qualification_run":36695017130, + "accepted_artifact":11087331509, + "accepted_artifact_digest":"sha256:9f94fea45f82fe97056a89ac656d7009885a9d8b846747cf6f5f57d0943451dd", + }, + "product_key_policy":"none-embedded", + "oracle_boundary":"x", + } + }, + "truenas":{}, + "proxmox":{ + "9.2-1":{ + "windows11":{ + "source_status":"CANDIDATE", + "runtime_status":"OPEN", + "fixture":"windows-11-enterprise-evaluation-26h2-x64-en-us", + "source_binding":{ + "qemu_server_commit":"6785065b3f766f15f6f151af8ec27ec8bb5b07ab", + "package_version":"9.1.15", + }, + "capabilities":{ + "secure_boot":{"source_proven":True}, + "tpm":{"source_proven":True}, + "uefi_q35":{"source_proven":True}, + }, + }, + "firecracker":{"runtime_status":"OPEN"}, + } + }, + "rungs":{"V0":"x","V1":"x","V2":"x","W1":"x","WB":"x"}, + } + +def tnrow(version): + legacy=version=="25.04.1" + row = { + "windows11":{ + "source_status":"CANDIDATE", + "runtime_status":"OPEN", + "capabilities":{ + "secure_boot":{"source_proven":True}, + "tpm":{"source_proven":True}, + "uefi_q35":{"source_proven":not legacy}, + }, + }, + "firecracker":{ + "source_status":"RUNTIME_OBSERVE_ONLY" if legacy else "CANDIDATE", + "cpu_passthrough_control":None if legacy else "vm.create.cpu_mode=HOST-PASSTHROUGH", + "runtime_status":"OPEN", + }, + } + if version=="26.0.0-BETA.3": + row["windows11"]["fixture"]="windows-11-enterprise-evaluation-26h2-x64-en-us" + row["linux_v2"]={ + "source_status":"OBSERVATION_CANDIDATE", + "runtime_status":"OPEN", + "prerequisite":"V1 accepted", + "fixture":"cirros-0.6.3-x86_64-nocloud-serial-nonce", + "vm_cpu_mode":"HOST-PASSTHROUGH", + "observation":{ + "kvm_device_marker":"AGENT_DISPATCH_V2_KVM_PRESENT=", + "cpu_extension_marker":"AGENT_DISPATCH_V2_CPU_VMX_SVM=", + "transport":"supported TrueNAS VM console", + }, + "firecracker_gate":"execute #277 portable Firecracker contract only when /dev/kvm and vmx|svm are both observed inside the guest", + "negative_boundary":"nested-KVM absence is a nested-virtualization capability result and does not invalidate V0/V1 VM support", + } + if version=="26.0.0-BETA.3": + row["linux_v1"]={ + "source_status":"CANDIDATE", + "runtime_status":"OPEN", + "fixture":"cirros-0.6.3-x86_64-nocloud-serial-nonce", + "public_surfaces":{ + "boot_disk":"vm.device.create RAW exists=true boot=true", + "seed_media":"vm.device.create CDROM", + "console_discovery":"vm.get_console", + }, + "observation_requirement":"consume guest console through supported TrueNAS console surface; do not inject raw.qemu or invoke virsh directly", + } + return row + +class Tests(unittest.TestCase): + def profile(self): + p=fixture() + for v in ("25.04.1","25.04.2.6","25.10.7","26.0.0-BETA.3"): + p["truenas"][v]=tnrow(v) + return p + def test_contract(self): + self.assertEqual(validate(self.profile())["status"],"PASS") + def test_legacy_nested_kvm_cannot_be_source_claimed(self): + p=self.profile() + p["truenas"]["25.04.1"]["firecracker"]["cpu_passthrough_control"]="raw.qemu=-cpu host" + with self.assertRaises(ProfileError): + validate(p) + def test_runtime_claim_fails_closed(self): + p=self.profile() + p["truenas"]["25.10.7"]["windows11"]["runtime_status"]="PASS" + with self.assertRaises(ProfileError): + validate(p) + def test_linux_v1_fixture_digest_fails_closed(self): + p=self.profile() + p["fixtures"]["linux_v1"]["source"]["sha256"]="0"*64 + with self.assertRaises(ProfileError): + validate(p) + def test_linux_v1_nonce_oracle_fails_closed(self): + p=self.profile() + p["fixtures"]["linux_v1"]["oracle"]["must_match_injected_nonce"]=False + with self.assertRaises(ProfileError): + validate(p) + def test_beta3_v1_console_must_remain_public_surface(self): + p=self.profile() + p["truenas"]["26.0.0-BETA.3"]["linux_v1"]["public_surfaces"]["console_discovery"]="virsh console" + with self.assertRaises(ProfileError): + validate(p) + def test_beta3_v1_runtime_claim_fails_closed(self): + p=self.profile() + p["truenas"]["26.0.0-BETA.3"]["linux_v1"]["runtime_status"]="PASS" + with self.assertRaises(ProfileError): + validate(p) + def test_beta3_v2_nested_kvm_negative_does_not_revoke_vm_support(self): + p=self.profile() + row=p["truenas"]["26.0.0-BETA.3"]["linux_v2"] + self.assertIn("does not invalidate V0/V1", row["negative_boundary"]) + self.assertEqual(row["runtime_status"], "OPEN") + def test_beta3_v2_firecracker_gate_remains_authority_bound(self): + p=self.profile() + p["truenas"]["26.0.0-BETA.3"]["linux_v2"]["firecracker_gate"]="run whatever" + with self.assertRaises(ProfileError): + validate(p) + def test_beta3_windows_fixture_binding_fails_closed(self): + p=self.profile() + p["truenas"]["26.0.0-BETA.3"]["windows11"]["fixture"]="different" + with self.assertRaises(ProfileError): + validate(p) + def test_windows_media_digest_fails_closed(self): + p=self.profile() + p["fixtures"]["windows_w1"]["source"]["sha256"]="0"*64 + with self.assertRaises(ProfileError): + validate(p) + def test_proxmox_windows_source_binding_fails_closed(self): + p=self.profile() + p["proxmox"]["9.2-1"]["windows11"]["source_binding"]["package_version"]="9.1.14" + with self.assertRaises(ProfileError): + validate(p) + def test_target_growth_fails_closed(self): + p=self.profile() + p["truenas"]["27.0.0"]=tnrow("27.0.0") + with self.assertRaises(ProfileError): + validate(p) + +if __name__=="__main__": + unittest.main() diff --git a/tests/test_compute_guest_seed.py b/tests/test_compute_guest_seed.py new file mode 100644 index 000000000..f2fa0fb97 --- /dev/null +++ b/tests/test_compute_guest_seed.py @@ -0,0 +1,54 @@ +import json +import pathlib +import tempfile +import unittest + +from scripts.compute_guest_seed import PREFIX, SeedError, render_seed, render_v2_observation_seed, write_seed + + +class ComputeGuestSeedTests(unittest.TestCase): + def test_seed_is_deterministic_and_serial_nonce_is_external(self): + nonce = "rep001nonceABCDEF12" + meta, user = render_seed(nonce, "rdte-v1-rep001") + self.assertEqual( + json.loads(meta), + {"instance-id": "rdte-v1-rep001", "local-hostname": "rdtev1"}, + ) + self.assertEqual(meta, '{"instance-id":"rdte-v1-rep001","local-hostname":"rdtev1"}\n') + self.assertTrue(user.startswith("#!/bin/sh\nset -eu\n")) + self.assertIn(PREFIX, user) + self.assertIn(">/dev/ttyS0", user) + self.assertIn(">/dev/console", user) + self.assertNotIn("curl ", user) + self.assertNotIn("wget ", user) + + def test_seed_rejects_shell_metacharacters(self): + for bad in ("short", "nonce;touch-pwned-1234", "$(id)-abcdefghijkl"): + with self.assertRaises(SeedError): + render_seed(bad, "rdte-v1-rep001") + + def test_v2_observation_seed_only_observes_nested_kvm_prerequisites(self): + meta, user = render_v2_observation_seed("rep002nonceABCDEF12", "rdte-v2-rep002") + self.assertEqual( + json.loads(meta), + {"instance-id": "rdte-v2-rep002", "local-hostname": "rdtev2"}, + ) + self.assertIn("[ -c /dev/kvm ]", user) + self.assertIn("(vmx|svm)", user) + self.assertIn("AGENT_DISPATCH_V2_KVM_PRESENT=", user) + self.assertIn("AGENT_DISPATCH_V2_CPU_VMX_SVM=", user) + self.assertNotIn("firecracker", user.lower()) + self.assertNotIn("modprobe", user.lower()) + self.assertNotIn("chmod", user.lower()) + + def test_write_seed_uses_expected_names_and_executable_userdata(self): + with tempfile.TemporaryDirectory() as td: + root = pathlib.Path(td) + write_seed(root, "rep002nonceABCDEF12", "rdte-v1-rep002") + self.assertTrue((root / "meta-data").is_file()) + self.assertTrue((root / "user-data").is_file()) + self.assertEqual((root / "user-data").stat().st_mode & 0o777, 0o755) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_compute_materialization_contract.py b/tests/test_compute_materialization_contract.py new file mode 100644 index 000000000..22843d52a --- /dev/null +++ b/tests/test_compute_materialization_contract.py @@ -0,0 +1,165 @@ +import unittest + +from scripts.compute_materialization_contract import ContractError, choose_adapter, semantic_plan, validate + + +def registry(): + return { + "schema": "semper-supra.compute-materialization-targets/v1", + "platforms": { + "truenas": { + "targets": [ + { + "version": "25.04.1", + "source_fingerprint": {"virt.instance": "a" * 40}, + "api_schema_fingerprint": {"virt_instance": {"path": "api/v25_04_0/virt_instance.py", "blob_sha": "d" * 40}}, + "container_adapters": [{ + "id": "truenas-virt-incus-container", + "source_api_family": "v25_04_0", + "required_methods": ["virt.instance.query", "virt.instance.create", "virt.instance.image_choices"], + "image_discovery": { + "method": "virt.instance.image_choices", + "source": "https://images.linuxcontainers.org", + "selection": {"alias": "debian/trixie"}, + }, + "restart_semantics": "native", + }], + "vm_adapters": [{ + "id": "truenas-virt-incus-vm", + "source_api_family": "v25_04_0", + "required_methods": ["virt.instance.query", "virt.instance.create"], + }], + }, + { + "version": "26.0.0-BETA.3", + "source_fingerprint": {"container.container": "b" * 40}, + "api_schema_fingerprint": {"container": {"path": "api/v26_0_0/container.py", "blob_sha": "e" * 40}, "vm": {"path": "api/v26_0_0/vm.py", "blob_sha": "f" * 40}}, + "container_adapters": [{ + "id": "truenas-container-lxc", + "source_api_family": "v26_0_0", + "required_methods": ["container.query", "container.create", "container.image.query_registry"], + "image_discovery": { + "method": "container.image.query_registry", + "source": "https://images.sys.truenas.net/streams", + "selection": {"name": "ubuntu:noble:amd64:default"}, + }, + "restart_semantics": "compose-stop-start", + }], + "vm_adapters": [{ + "id": "truenas-vm-libvirt", + "source_api_family": "v26_0_0", + "preferred": True, + "required_methods": [ + "vm.query", "vm.create", "vm.update", "vm.delete", "vm.status", + "vm.start", "vm.stop", "vm.restart", "vm.virtualization_details", + "pool.dataset.query", "system.product_type", "system.feature_enabled", + ], + "required_device_methods": [ + "vm.device.query", "vm.device.create", "vm.device.update", "vm.device.delete", + ], + "public_entitlement": { + "product_type_method": "system.product_type", + "feature_method": "system.feature_enabled", + "enterprise_feature": "VMS", + }, + }], + }, + ] + }, + "proxmox": { + "targets": [ + { + "version": "9.2-1", + "source_fingerprint": {"iso_sha256": "c" * 64}, + "container_adapters": [{ + "id": "proxmox-lxc-rest", + "required_api_templates": ["POST /nodes/{node}/lxc"], + }], + "vm_adapters": [{ + "id": "proxmox-vm-rest", + "required_api_templates": ["POST /nodes/{node}/qemu"], + }], + } + ], + "candidate_targets": [ + { + "version": "9.1-1", + "iso_sha256": "d" * 64, + "state": "source-profile-open", + } + ], + }, + }, + } + + +class ContractTests(unittest.TestCase): + def test_registry_contract(self): + self.assertEqual(validate(registry())["status"], "PASS") + + def test_missing_truenas_api_schema_fails_closed(self): + data = registry() + del data["platforms"]["truenas"]["targets"][0]["api_schema_fingerprint"] + with self.assertRaises(ContractError): + validate(data) + + def test_missing_container_image_discovery_fails_closed(self): + data = registry() + del data["platforms"]["truenas"]["targets"][0]["container_adapters"][0]["image_discovery"] + with self.assertRaises(ContractError): + validate(data) + + def test_2504_selects_legacy_incus_container(self): + a = choose_adapter( + registry(), "truenas", "25.04.1", "container", + {"virt.instance.query", "virt.instance.create", "virt.instance.image_choices"}, + ) + self.assertEqual(a["id"], "truenas-virt-incus-container") + + def test_beta3_vm_adapter_binds_public_entitlement_only(self): + adapter=choose_adapter( + registry(), "truenas", "26.0.0-BETA.3", "vm", + { + "vm.query","vm.create","vm.update","vm.delete","vm.status", + "vm.start","vm.stop","vm.restart","vm.virtualization_details", + "pool.dataset.query","system.product_type","system.feature_enabled", + "vm.device.query","vm.device.create","vm.device.update","vm.device.delete", + }, + ) + self.assertEqual(adapter["id"],"truenas-vm-libvirt") + self.assertEqual(adapter["public_entitlement"],{ + "product_type_method":"system.product_type", + "feature_method":"system.feature_enabled", + "enterprise_feature":"VMS", + }) + self.assertNotIn("read_only_bootstrap_methods",adapter) + self.assertIn("system.product_type",adapter["required_methods"]) + self.assertIn("system.feature_enabled",adapter["required_methods"]) + + def test_26_selects_lxc_container(self): + a = choose_adapter( + registry(), "truenas", "26.0.0-BETA.3", "container", + {"container.query", "container.create", "container.image.query_registry"}, + ) + self.assertEqual(a["id"], "truenas-container-lxc") + self.assertIn("stop+start", semantic_plan("container", a)) + + def test_missing_observed_method_fails_closed(self): + with self.assertRaises(ContractError): + choose_adapter(registry(), "truenas", "26.0.0-BETA.3", "vm", {"vm.query"}) + + def test_proxmox_selects_exact_profile_adapter(self): + a = choose_adapter(registry(), "proxmox", "9.2-1", "vm", None) + self.assertEqual(a["id"], "proxmox-vm-rest") + + def test_discovery_candidate_is_not_apply_admitted(self): + with self.assertRaises(ContractError): + choose_adapter(registry(), "proxmox", "9.1-1", "vm", None) + + def test_unknown_target_fails(self): + with self.assertRaises(ContractError): + choose_adapter(registry(), "proxmox", "8.4-1", "vm", None) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_gha_kvm_system_rdte.py b/tests/test_gha_kvm_system_rdte.py new file mode 100644 index 000000000..25db35980 --- /dev/null +++ b/tests/test_gha_kvm_system_rdte.py @@ -0,0 +1,945 @@ +import base64 +import hashlib +import json +import socket +import struct +import sys +import tempfile +import threading +import pathlib +import subprocess +import unittest + +ROOT = pathlib.Path(__file__).resolve().parents[1] +PVE = ROOT / "scripts" / "gha_kvm_proxmox_rdte.sh" +TRUENAS = ROOT / "scripts" / "gha_kvm_truenas_rdte.sh" +TRUENAS_RPC = ROOT / "scripts" / "truenas_installer_rpc_probe.py" +WORKFLOW = ROOT / ".github" / "workflows" / "gha-kvm-system-rdte.yml" +PREP_WORKFLOW = ROOT / ".github" / "workflows" / "prep-rdte-static.yml" +QEMU_TOPOLOGY = ROOT / "scripts" / "gha_qemu_t3_topology_probe.py" +TRUENAS_INSTALL = ROOT / "scripts" / "truenas_installer_rpc_install.py" +TRUENAS_MIDDLEWARE = ROOT / "scripts" / "truenas_middleware_ddp_probe.py" +TRUENAS_POOL = ROOT / "scripts" / "truenas_middleware_pool_probe.py" +TRUENAS_APP = ROOT / "scripts" / "truenas_middleware_app_probe.py" +TRUENAS_LIFECYCLE = ROOT / "scripts" / "truenas_middleware_app_lifecycle_probe.py" +FOLIORELAY_SESSION_EQUIV = ROOT / "config" / "truenas-session-foliorelay-single-equivalence.json" + + +class SystemRdteContractTests(unittest.TestCase): + def test_shell_syntax(self): + for path in (PVE, TRUENAS): + cp = subprocess.run(["bash", "-n", str(path)], text=True, capture_output=True) + self.assertEqual(cp.returncode, 0, f"{path}: {cp.stderr}") + + def test_common_evidence_contract(self): + for path in (PVE, TRUENAS): + text = path.read_text(encoding="utf-8") + self.assertIn("gha-kvm-system-lab/v1", text) + self.assertIn("oracleSatisfied", text) + self.assertIn("classification", text) + self.assertIn("passwordless sudo KVM boundary", text) + self.assertIn("SKIPPED_GUARDRAIL", text) + self.assertIn('rm -rf -- "$STATE_DIR"', text) + + def test_no_private_runner_or_secret_surface(self): + forbidden = [ + "self-hosted", + "garm-provider-truenas-private", + "agent-dispatch-private.git", + "ghp_", + "github_pat_", + "ACTIONS_RUNNER_INPUT_TOKEN", + ] + for path in (PVE, TRUENAS): + text = path.read_text(encoding="utf-8") + for needle in forbidden: + self.assertNotIn(needle, text) + + def test_proxmox_is_pinned_and_uses_vendor_auto_install_contract(self): + text = PVE.read_text(encoding="utf-8") + self.assertIn("proxmox-ve_9.2-1.iso", text) + self.assertIn( + "4e88fe416df9b527624a175f24c9aa07c714d3332afb1ee3dbf3879573ef2c6c", + text, + ) + self.assertIn('mode = "iso"', text) + self.assertIn('source = "from-dhcp"', text) + self.assertIn('filesystem = "ext4"', text) + self.assertIn('disk-list = ["sda"]', text) + self.assertIn("/api2/json/version", text) + self.assertIn('PVE_INSTALLER_SOURCE_VERSION="9.2.5"', text) + self.assertIn('PVE_INSTALLER_SOURCE_COMMIT="32afcd4cd534d8e2f99ae76aa0234a0a5c697ba9"', text) + self.assertIn("proxmox-first-boot_*.deb", text) + self.assertIn("ISO_FIRST_BOOT_PACKAGE", text) + self.assertNotIn("xdotool", text) + + def test_proxmox_first_boot_witness_uses_vendor_hook(self): + text = PVE.read_text(encoding="utf-8") + self.assertIn("[first-boot]", text) + self.assertIn('source = "from-iso"', text) + self.assertIn('ordering = "network-online"', text) + self.assertNotIn('ordering = "fully-up"', text) + self.assertIn("/proxmox-first-boot", text) + self.assertIn("proxmox-first-boot.iso", text) + self.assertIn("PVE_INITIAL_SERVICES_BEGIN", text) + self.assertIn("PVE_FINAL_SERVICES_BEGIN", text) + self.assertIn("PVEPROXY_JOURNAL_BEGIN", text) + self.assertIn("PVE_RDTE_WITNESS_BEGIN", text) + self.assertIn("PVE_RDTE_WITNESS_END", text) + self.assertIn("rdte-first-boot.log", text) + self.assertIn("TTY_S0=character-device", text) + self.assertIn("first_boot_witness_observed", text) + self.assertIn("guest_local_https_api", text) + self.assertIn("socket.create_connection((host, port), timeout=5)", text) + self.assertIn("ssl.create_default_context()", text) + self.assertIn('"probe_error": "http_status"', text) + self.assertIn('all(data.get(k) for k in ("version", "release", "repoid"))', text) + self.assertIn("/api2/json/access/ticket", text) + self.assertIn("urllib.parse.urlencode", text) + self.assertIn("PVEAuthCookie={ticket}", text) + self.assertIn('password = os.environ["PVE_RDTE_PASSWORD"]', text) + self.assertIn('"stage": stage', text) + self.assertNotIn("print(json.dumps(ticket_payload", text) + self.assertNotIn("CSRFPreventionToken:", text) + self.assertNotIn("openssl s_client", text) + self.assertIn('request = [f"{method} {path} HTTP/1.0"]', text) + self.assertIn(r'wire = ("\r\n".join(request) + "\r\n\r\n")', text) + self.assertNotIn(r'wire = ("\\r\\n".join(request)', text) + self.assertNotIn("for _ in $(seq 1 120); do\n if [[ \"$SSH_HOSTFWD_ACCEPTED\" == \"true\" ]]; then", text) + self.assertIn('API_OBSERVATION_ROUTE="ssh-observed-guest-local-https"', text) + self.assertIn('"api_observation_route"', text) + self.assertIn('"guest_local_https_api"', text) + self.assertIn('"pve_manager_source_commit": "b9984c6d90a4bd80"', text) + self.assertIn('"pve_access_control_source_commit": "5ccd07d9302562b73374d331b63d25b04b86766c"', text) + self.assertIn('ROOT_PASSWORD="rdte-proxmox-', text) + self.assertIn('root-password = "$ROOT_PASSWORD"', text) + self.assertIn('timezone = "UTC"', text) + self.assertNotIn('timezone = "Etc/UTC"', text) + self.assertIn('cat >"$STATE_DIR/answer.toml" <= VCPUS ))', text) + self.assertIn('R_VCPUS="$VCPUS"', text) + self.assertIn('"vcpus": int(os.environ["R_VCPUS"])', text) + self.assertIn('"ram_mib": int(os.environ["R_RAM_MIB"])', text) + self.assertIn('"garm-provider-g4-pair"', text) + self.assertIn('"garm-provider-g5-version-row"', text) + self.assertIn('"count": 2', text) + + def test_g4_routing_is_source_exact_and_public_safe(self): + workflow = (ROOT / ".github" / "workflows" / "gha-kvm-system-rdte.yml").read_text( + encoding="utf-8" + ) + text = TRUENAS.read_text(encoding="utf-8") + self.assertIn("garm-provider-g4", workflow) + self.assertIn( + "export-g4-nested-fixture.yml@e5fc7c50780de54f7579f64990c87b2e16482613", + workflow, + ) + self.assertIn( + '--g4-fixture-producer "e5fc7c50780de54f7579f64990c87b2e16482613"', + workflow, + ) + self.assertIn("truenas_middleware_garm_provider_g4_probe.py", text) + self.assertIn('G4_FIXTURE_PRODUCER', text) + self.assertNotIn("GITHUB_TOKEN", (ROOT / "scripts" / "truenas_middleware_garm_provider_g4_probe.py").read_text(encoding="utf-8")) + + def test_g5_routing_is_source_exact_cross_version_and_public_safe(self): + workflow = (ROOT / ".github" / "workflows" / "gha-kvm-system-rdte.yml").read_text( + encoding="utf-8" + ) + text = TRUENAS.read_text(encoding="utf-8") + self.assertIn("garm-provider-g5", workflow) + self.assertIn( + "export-g5-version-matrix.yml@f83a712e095c6962741f93cf86bea69479ce4d4a", + workflow, + ) + self.assertIn( + "export-g3-nested-fixture.yml@e92d4024e6e60c0e2f8bcf5301c11fccb14eaaab", + workflow, + ) + self.assertIn( + '--g5-matrix-producer "f83a712e095c6962741f93cf86bea69479ce4d4a"', + workflow, + ) + self.assertIn( + '--g3-fixture-producer "e92d4024e6e60c0e2f8bcf5301c11fccb14eaaab"', + workflow, + ) + self.assertIn("truenas_middleware_garm_provider_g5_probe.py", text) + self.assertIn('G5_MATRIX_PRODUCER', text) + self.assertIn( + 'if [[ "$T6_PRODUCT" != "official-catalog" && "$T6_PRODUCT" != "garm-provider-g5" && "$T6_PRODUCT" != "foliorelay" && "$T6_PRODUCT" != "minecraft-paper" && "$T6_PRODUCT" != "garm-controller-f0f5" && "$T6_PRODUCT" != "minecraft-vanilla-263" ]]; then', + text, + ) + g5_probe = (ROOT / "scripts" / "truenas_middleware_garm_provider_g5_probe.py").read_text( + encoding="utf-8" + ) + self.assertNotIn("GITHUB_TOKEN", g5_probe) + self.assertNotIn("GH_TOKEN", g5_probe) + + def test_garm_controller_f0_f5_route_is_exact_cross_version_and_public_safe(self): + workflow = WORKFLOW.read_text(encoding="utf-8") + script = TRUENAS.read_text(encoding="utf-8") + probe = (ROOT / "scripts" / "truenas_middleware_garm_f0_f5_probe.py").read_text( + encoding="utf-8" + ) + self.assertIn("garm-controller-f0f5", workflow) + self.assertIn( + "export-garm-f0-f5-control.yml@bfbf2158c3f679c3422e6035bdb67e7874a5c07a", + workflow, + ) + self.assertIn( + 'foundry_commit="bfbf2158c3f679c3422e6035bdb67e7874a5c07a"', + workflow, + ) + self.assertIn("garm-truenas-f0-f5-control", workflow) + self.assertIn("truenas_middleware_garm_f0_f5_probe.py", script) + self.assertIn('--expected-profile-blob "$FOUNDRY_PROFILE_BLOB"', script) + self.assertIn('"foundry_profile_blob": os.environ["R_FOUNDRY_PROFILE_BLOB"]', script) + self.assertNotIn("GITHUB_TOKEN", probe) + self.assertNotIn("GH_TOKEN", probe) + + def test_no_literal_escaped_shell_parameter_expansions(self): + needle = chr(92) + "$" + "{" + for path in (PVE, TRUENAS): + self.assertNotIn(needle, path.read_text(encoding="utf-8")) + + + def test_truenas_rpc_probe_is_dependency_free_and_read_only(self): + text = TRUENAS_RPC.read_text(encoding="utf-8") + for method in ("is_adopted", "system_info", "list_disks", "list_network_interfaces"): + self.assertIn(method, text) + self.assertNotIn('"install"', text) + self.assertIn('p.add_argument("--path", default="/ws")', text) + self.assertNotIn("pip install", text) + cp = subprocess.run( + ["python3", "-m", "py_compile", str(TRUENAS_RPC)], + text=True, + capture_output=True, + ) + self.assertEqual(cp.returncode, 0, cp.stderr) + + def test_proxmox_request_uses_exact_target_registry(self): + workflow = WORKFLOW.read_text(encoding="utf-8") + prep = (ROOT / ".github" / "workflows" / "prep-rdte-static.yml").read_text(encoding="utf-8") + self.assertIn("config/proxmox-rdte-targets.json", workflow) + self.assertIn("scripts/proxmox_rdte_target.py", workflow) + self.assertIn("--require-runtime-admitted", workflow) + self.assertIn("'config/proxmox-rdte-targets.json'", prep) + self.assertIn("'scripts/proxmox_rdte_target.py'", prep) + self.assertIn("'tests/test_proxmox_rdte_target.py'", prep) + + def test_prep_static_gate_watches_proxmox_request_edge(self): + prep = (ROOT / ".github" / "workflows" / "prep-rdte-static.yml").read_text(encoding="utf-8") + self.assertIn("'config/proxmox-rdte-run-request.json'", prep) + + def test_workflow_scopes_heavy_targets(self): + text = WORKFLOW.read_text(encoding="utf-8") + self.assertIn("fetch-depth: 0", text) + self.assertIn("needs.changes.outputs.proxmox", text) + self.assertIn("needs.changes.outputs.truenas", text) + self.assertIn("DISPATCH_TARGET", text) + self.assertIn("BEFORE_SHA:", text) + self.assertIn('git diff --name-only "$BEFORE_SHA" "$AFTER_SHA"', text) + self.assertIn("truenas_version:", text) + self.assertIn("truenas_rung:", text) + self.assertIn("needs.changes.outputs.truenas_version", text) + self.assertIn("needs.changes.outputs.truenas_rung", text) + self.assertIn("config/truenas-rdte-run-request.json", text) + self.assertIn("config/proxmox-rdte-run-request.json", text) + self.assertIn("gha-kvm-proxmox-run-request/v1", text) + self.assertIn("refusing stale Proxmox request replay", text) + self.assertIn( + 'if [[ "$EVENT_NAME" != "workflow_dispatch" && "$truenas" == "true" ]]; then', + text, + ) + self.assertIn("gha-kvm-truenas-run-request/v1", text) + self.assertIn("current_request_id=", text) + self.assertIn("previous_request_id=", text) + self.assertIn("refusing stale TrueNAS request replay", text) + self.assertIn('git show "$BEFORE_SHA:$RUN_REQUEST_PATH"', text) + self.assertIn("truenas_version=\"$(jq -er", text) + self.assertIn(".version | select(type == \"string\" and length > 0)", text) + self.assertIn("truenas_rung=\"$(jq -er", text) + self.assertIn('.rung | select(. == "t0" or . == "t1"', text) + + routing = text.split("while IFS= read -r path; do", 1)[1].split( + "done < <(git diff --name-only", 1 + )[0] + self.assertIn("config/truenas-rdte-run-request.json)", routing) + self.assertIn("config/proxmox-rdte-run-request.json)", routing) + self.assertNotIn("scripts/gha_kvm_proxmox_rdte.sh)", routing) + for stale_replay_source in ( + "scripts/gha_kvm_truenas_rdte.sh", + "scripts/truenas_installer_rpc_probe.py", + "scripts/truenas_middleware_foliorelay_t6_probe.py", + "config/truenas-rdte-targets.json", + ): + self.assertNotIn(stale_replay_source, routing) + + def test_prep_gate_avoids_duplicate_branch_and_pr_runs_and_scopes_expensive_smokes(self): + text = PREP_WORKFLOW.read_text(encoding="utf-8") + trigger = text.split("permissions:", 1)[0] + self.assertIn("pull_request:", trigger) + self.assertIn("workflow_dispatch:", trigger) + self.assertNotIn("\n push:", trigger) + self.assertIn("id: expensive_scope", text) + self.assertIn("provider_smoke=false", text) + self.assertIn("provider_smoke=true", text) + self.assertIn( + "steps.expensive_scope.outputs.provider_smoke == 'true'", + text, + ) + self.assertIn("fetch-depth: 0", text) + + def test_only_heavy_jobs_share_non_cancelling_concurrency_group(self): + text = WORKFLOW.read_text(encoding="utf-8") + header = text.split("jobs:", 1)[0] + self.assertNotIn("concurrency:", header) + for job in ("proxmox-9-2", "truenas-26-beta3"): + expected = ( + f" {job}:\n" + " concurrency:\n" + " group: agent-dispatch-heavyweight-rdte\n" + " queue: max\n" + " cancel-in-progress: false\n" + ) + self.assertIn(expected, text) + self.assertEqual(text.count("group: agent-dispatch-heavyweight-rdte"), 2) + self.assertEqual(text.count("queue: max"), 2) + self.assertEqual(text.count("cancel-in-progress: false"), 2) + self.assertNotIn("cancel-in-progress: true", text) + + def test_single_capsule_session_mode_is_opt_in_and_selector_bound(self): + text = TRUENAS.read_text(encoding="utf-8") + self.assertIn('--session-manifest', text) + self.assertIn('SESSION_MANIFEST=""', text) + self.assertIn("truenas_single_capsule_adapter.py", text) + self.assertIn('SESSION_SELECTOR', text) + self.assertIn('does not match --t6-product', text) + self.assertIn('truenas_session_runner.py', text) + self.assertIn('truenas_existing_probe_capsule_executor.py', text) + self.assertIn('"session_execution":', text) + self.assertIn('SESSION_CAPSULE_VERDICT=', text) + self.assertIn('fail_evidence "$SESSION_CAPSULE_VERDICT"', text) + self.assertIn('SESSION_OK=', text) + self.assertIn('session.get("classification")=="SESSION_CLEAN"', text) + self.assertIn('caps[0].get("verdict")=="SUPPORTED"', text) + self.assertIn('provider.get("classification")!="SUPPORTED"', text) + self.assertIn('provider.get("oracleSatisfied") is not True', text) + + def test_foliorelay_single_capsule_equivalence_manifest_is_exact_and_valid(self): + doc = json.loads(FOLIORELAY_SESSION_EQUIV.read_text(encoding="utf-8")) + self.assertEqual(doc["version"], "26.0.0-BETA.3") + self.assertEqual(len(doc["capsules"]), 1) + cap = doc["capsules"][0] + self.assertEqual(cap["provider"], "foliorelay-t6") + self.assertEqual(cap["authority"], {"repository":"SemperSupra/folio-relay","issue":29}) + self.assertEqual(cap["exact"]["foundry_ref"], "fb41afd3d112f361b8c490aeb5915a978b956b20") + self.assertEqual(cap["exact"]["product_head"], "88fd70c8891eaf7b4c886aa928b96e232ca041c6") + self.assertIn("@sha256:", cap["exact"]["control_image"]) + self.assertIn("@sha256:", cap["exact"]["cups_image"]) + cp = subprocess.run( + [ + sys.executable, + str(ROOT / "scripts" / "truenas_session_manifest.py"), + "--manifest", str(FOLIORELAY_SESSION_EQUIV), + "--targets", str(ROOT / "config" / "truenas-rdte-targets.json"), + "--providers", str(ROOT / "config" / "truenas-capsule-providers.json"), + ], + text=True, + capture_output=True, + ) + self.assertEqual(cp.returncode, 0, cp.stderr) + + def test_truenas_t1_requires_local_rpc_probe(self): + text = TRUENAS.read_text(encoding="utf-8") + self.assertIn("missing TrueNAS installer RPC probe", text) + + def test_truenas_rpc_probe_protocol_round_trip(self): + listener = socket.socket() + listener.bind(("127.0.0.1", 0)) + listener.listen(1) + port = listener.getsockname()[1] + errors = [] + request_lines = [] + + def read_exact(conn, count): + parts = [] + remaining = count + while remaining: + part = conn.recv(remaining) + if not part: + raise EOFError("synthetic websocket peer closed") + parts.append(part) + remaining -= len(part) + return b"".join(parts) + + def recv_json(conn): + b1, b2 = read_exact(conn, 2) + opcode = b1 & 0x0F + if opcode == 0x8: + return None + if opcode != 0x1: + raise AssertionError(f"unexpected client opcode {opcode}") + masked = bool(b2 & 0x80) + if not masked: + raise AssertionError("client websocket frame must be masked") + size = b2 & 0x7F + if size == 126: + size = struct.unpack("!H", read_exact(conn, 2))[0] + elif size == 127: + size = struct.unpack("!Q", read_exact(conn, 8))[0] + mask = read_exact(conn, 4) + payload = read_exact(conn, size) + payload = bytes(b ^ mask[i % 4] for i, b in enumerate(payload)) + return json.loads(payload.decode()) + + def send_json(conn, value): + payload = json.dumps(value, separators=(",", ":")).encode() + if len(payload) < 126: + header = bytes([0x81, len(payload)]) + else: + header = bytes([0x81, 126]) + struct.pack("!H", len(payload)) + conn.sendall(header + payload) + + def server(): + try: + conn, _ = listener.accept() + with conn: + request = bytearray() + while b"\r\n\r\n" not in request: + request.extend(conn.recv(4096)) + decoded_request = request.decode() + request_lines.append(decoded_request.split("\r\n", 1)[0]) + headers = {} + for line in decoded_request.split("\r\n")[1:]: + if ":" in line: + key, value = line.split(":", 1) + headers[key.lower().strip()] = value.strip() + key = headers["sec-websocket-key"] + accept = base64.b64encode( + hashlib.sha1( + (key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11").encode() + ).digest() + ).decode() + conn.sendall( + ( + "HTTP/1.1 101 Switching Protocols\r\n" + "Upgrade: websocket\r\n" + "Connection: Upgrade\r\n" + f"Sec-WebSocket-Accept: {accept}\r\n\r\n" + ).encode() + ) + answers = [ + ("is_adopted", False), + ("system_info", {"version": "synthetic", "installation_running": False}), + ("list_disks", [{"name": "sda", "size": 1024}]), + ("list_network_interfaces", [{"name": "eth0"}]), + ] + for method, result in answers: + request_obj = recv_json(conn) + if request_obj["method"] != method: + raise AssertionError( + f"expected {method}, got {request_obj['method']}" + ) + send_json( + conn, + { + "jsonrpc": "2.0", + "id": request_obj["id"], + "result": result, + }, + ) + except Exception as exc: + errors.append(exc) + finally: + listener.close() + + thread = threading.Thread(target=server, daemon=True) + thread.start() + with tempfile.TemporaryDirectory() as td: + out = pathlib.Path(td) / "rpc.json" + cp = subprocess.run( + [ + sys.executable, + str(TRUENAS_RPC), + "--port", + str(port), + "--path", + "/", + "--out", + str(out), + "--timeout", + "2", + ], + text=True, + capture_output=True, + timeout=10, + ) + self.assertEqual(cp.returncode, 0, cp.stderr) + payload = json.loads(out.read_text(encoding="utf-8")) + self.assertTrue(payload["oracleSatisfied"]) + self.assertEqual(payload["classification"], "SUPPORTED") + self.assertFalse(payload["methods"]["is_adopted"]) + self.assertEqual(payload["methods"]["system_info"]["version"], "synthetic") + self.assertEqual(payload["endpoint"]["path"], "/") + thread.join(timeout=5) + self.assertFalse(thread.is_alive()) + self.assertEqual(errors, []) + self.assertEqual(request_lines, ["GET / HTTP/1.1"]) + + def test_truenas_rpc_probe_preserves_connect_failure_receipt(self): + with tempfile.TemporaryDirectory() as td: + out = pathlib.Path(td) / "rpc-failure.json" + unused = socket.socket() + unused.bind(("127.0.0.1", 0)) + port = unused.getsockname()[1] + unused.close() + cp = subprocess.run( + [ + sys.executable, + str(TRUENAS_RPC), + "--port", + str(port), + "--path", + "/", + "--out", + str(out), + "--timeout", + "0.2", + ], + text=True, + capture_output=True, + timeout=10, + ) + self.assertEqual(cp.returncode, 0, cp.stderr) + self.assertTrue(out.is_file()) + payload = json.loads(out.read_text(encoding="utf-8")) + self.assertFalse(payload["oracleSatisfied"]) + self.assertEqual(payload["classification"], "ORACLE_FAILURE") + self.assertEqual(payload["stage"], "connect-or-handshake") + self.assertEqual(payload["endpoint"]["path"], "/") + self.assertIn("ConnectionRefusedError", payload["detail"]) + + def test_truenas_t1_requires_real_rpc_not_hostfwd_tcp(self): + text = TRUENAS.read_text(encoding="utf-8") + self.assertIn("try_rpc_discovery", text) + self.assertIn("RPC_DISCOVERY_OK", text) + self.assertIn("installer_rpc_hostfwd_accepted", text) + self.assertNotIn("RPC_REACHABLE", text) + self.assertIn("completed vendor WebSocket/JSON-RPC discovery exchange", text) + + def test_future_truenas_mutating_clients_compile_and_refuse_ambiguity(self): + install = TRUENAS_INSTALL.read_text(encoding="utf-8") + middleware = TRUENAS_MIDDLEWARE.read_text(encoding="utf-8") + self.assertIn("expected exactly one non-removable disk", install) + self.assertIn("expected exactly one non-loopback interface", install) + self.assertIn('p.add_argument("--path", default="/ws")', install) + self.assertIn("path=a.path", install) + self.assertIn('"truenas_admin"', install) + self.assertIn('"auth.login_ex"', middleware) + self.assertIn('"PASSWORD_PLAIN"', middleware) + self.assertIn('"system.version"', middleware) + self.assertIn('"system.info"', middleware) + for path in (TRUENAS_INSTALL, TRUENAS_MIDDLEWARE): + cp = subprocess.run( + ["python3", "-m", "py_compile", str(path)], + text=True, + capture_output=True, + ) + self.assertEqual(cp.returncode, 0, cp.stderr) + + def test_truenas_t2_keeps_install_and_middleware_oracles_separate(self): + text = TRUENAS.read_text(encoding="utf-8") + self.assertIn("t0|t1|t2", text) + self.assertIn("installer_install_completed", text) + self.assertIn("installed_middleware_authenticated", text) + self.assertIn("truenas_installer_rpc_install.py", text) + self.assertIn("truenas_middleware_ddp_probe.py", text) + self.assertIn('NIC_MAC="52:54:00:54:4e:26"', text) + + def test_truenas_install_rpc_uses_one_positional_object(self): + text = TRUENAS_INSTALL.read_text(encoding="utf-8") + self.assertIn('rpc_call(ws, "install", 4, [install_params]', text) + + def test_truenas_t3_real_harness_contract(self): + text = TRUENAS.read_text(encoding="utf-8") + workflow = WORKFLOW.read_text(encoding="utf-8") + self.assertIn("t0|t1|t2|t3|t4|t5|t6", text) + self.assertIn('DATA_DISK_SIZE="8G"', text) + self.assertIn("DATA_DISK_COUNT=2", text) + self.assertIn('DATA_POOL_NAME="rdtepool"', text) + self.assertIn('DATA_SERIAL_PREFIX="RDTE_DATA_"', text) + self.assertEqual(text.count("mac=$NIC_MAC,addr=0x3"), 2) + self.assertIn("if=none,id=rdteboot", text) + self.assertIn("id=rdte-boot,addr=0x4,bootindex=1", text) + self.assertIn("-boot strict=on", text) + self.assertNotIn("-boot order=c", text) + self.assertIn("if=none,id=$drive_id", text) + self.assertIn("pci_slot=$((5 + index))", text) + self.assertIn( + "virtio-blk-pci,drive=$drive_id,serial=${DATA_SERIAL_PREFIX}${index},addr=0x${pci_slot}", + text, + ) + self.assertIn('--data-serial-prefix "$DATA_SERIAL_PREFIX"', text) + self.assertIn("data${index}.qcow2", text) + self.assertIn('"${DATA_DRIVE_ARGS[@]}"', text) + self.assertIn("truenas_middleware_pool_probe.py", text) + self.assertIn('"data_pool_created"', text) + self.assertIn('"data_pool"', text) + self.assertIn("truenas_rung:", workflow) + self.assertIn("needs.changes.outputs.truenas_rung", workflow) + shell_check = subprocess.run(["bash", "-n", str(TRUENAS)], text=True, capture_output=True) + self.assertEqual(shell_check.returncode, 0, shell_check.stderr) + py_check = subprocess.run(["python3", "-m", "py_compile", str(TRUENAS_POOL)], text=True, capture_output=True) + self.assertEqual(py_check.returncode, 0, py_check.stderr) + + def test_truenas_t3_compute_c0_is_opt_in_api_native_and_c1_separate(self): + text = TRUENAS.read_text(encoding="utf-8") + workflow = WORKFLOW.read_text(encoding="utf-8") + self.assertIn('--compute-fixture', text) + self.assertIn('COMPUTE_FIXTURE="none"', text) + self.assertIn('container-c0', text) + self.assertIn('requires rung t3', text) + self.assertIn('truenas_compute_container_probe.py', text) + self.assertIn('config/compute-materialization-targets.json', text) + self.assertIn('"compute_materialization_exercised"', text) + self.assertIn('"compute_materialization"', text) + self.assertIn('"c0_oracle_satisfied"', text) + self.assertIn('"c1_guest_oracle_satisfied"', text) + self.assertIn('"image_resolution"', text) + self.assertIn('truenas_compute_fixture:', workflow) + self.assertIn('needs.changes.outputs.truenas_compute_fixture', workflow) + self.assertIn('(.compute_fixture // "none")', workflow) + self.assertNotIn('scripts/truenas_compute_container_probe.py)', workflow.split("while IFS= read -r path; do", 1)[1].split("done < <(git diff --name-only", 1)[0]) + shell_check = subprocess.run(["bash", "-n", str(TRUENAS)], text=True, capture_output=True) + self.assertEqual(shell_check.returncode, 0, shell_check.stderr) + + def test_truenas_t3_vm_v0_is_opt_in_and_strictly_pre_guest(self): + text = TRUENAS.read_text(encoding="utf-8") + workflow = WORKFLOW.read_text(encoding="utf-8") + self.assertIn('vm-v0', workflow) + self.assertIn('vm-v0', text) + self.assertIn('truenas_compute_vm_probe.py', text) + self.assertIn('--zvol-name "$DATA_POOL_NAME/rdte-compute-vm-v0"', text) + self.assertIn('"v0_oracle_satisfied"', text) + self.assertIn('"v1_guest_oracle_satisfied"', text) + self.assertIn('"v2_firecracker_oracle_satisfied"', text) + self.assertIn('"windows_w1_oracle_satisfied"', text) + self.assertIn('"vm_absent"', text) + self.assertIn('"device_absent"', text) + self.assertIn('"zvol_absent"', text) + self.assertIn('guest boot, nested KVM, Firecracker and Windows remain unclaimed', text) + shell_check = subprocess.run(["bash", "-n", str(TRUENAS)], text=True, capture_output=True) + self.assertEqual(shell_check.returncode, 0, shell_check.stderr) + + def test_truenas_t4_apps_contract(self): + text = TRUENAS.read_text(encoding="utf-8") + app = TRUENAS_APP.read_text(encoding="utf-8") + workflow = WORKFLOW.read_text(encoding="utf-8") + self.assertIn('APP_RESULT_JSON=""', text) + self.assertIn("apps_runtime_exercised", text) + self.assertIn("truenas_middleware_app_probe.py", text) + self.assertIn("docker.update", app) + self.assertIn("docker.status", app) + self.assertNotIn("truenas.entitlements.check", app) + self.assertIn('"apps_gate": "docker.license_active"', app) + self.assertIn('"ha_apps_gate": a.ha_apps_gate', app) + self.assertIn('"middleware_commit": a.middleware_commit', app) + self.assertIn('"middleware_ref": a.middleware_ref', app) + self.assertIn('--expected-version "$EXPECTED_SYSTEM_VERSION"', text) + self.assertIn('--middleware-commit "$MIDDLEWARE_COMMIT"', text) + self.assertIn('--ha-apps-gate "$HA_APPS_GATE"', text) + self.assertIn("app.create", app) + self.assertIn("app.query", app) + self.assertIn('payload["docker_update_result"]', app) + self.assertIn('payload["app_create_result"]', app) + self.assertIn('"nginx@sha256:65645c7bb6a0661892a8b03b89d0743208a18dd2f3f17a54ef4b76fb8e2f2a10"', app) + self.assertNotIn('"nginx:1.27-alpine"', app) + self.assertIn('"state") == "RUNNING"', app) + self.assertIn("truenas_rung:", workflow) + self.assertIn("needs.changes.outputs.truenas_rung", workflow) + py = subprocess.run( + ["python3", "-m", "py_compile", str(TRUENAS_APP)], + text=True, + capture_output=True, + ) + self.assertEqual(py.returncode, 0, py.stderr) + + + def test_truenas_t5_lifecycle_harness_contract(self): + text = TRUENAS.read_text(encoding="utf-8") + lifecycle = TRUENAS_LIFECYCLE.read_text(encoding="utf-8") + workflow = WORKFLOW.read_text(encoding="utf-8") + self.assertIn("t0|t1|t2|t3|t4|t5", text) + self.assertIn("truenas_middleware_app_lifecycle_probe.py", text) + self.assertIn('"app_lifecycle_exercised"', text) + self.assertIn('"app_lifecycle"', text) + self.assertIn("nginx@sha256:65645c7bb6a0661892a8b03b89d0743208a18dd2f3f17a54ef4b76fb8e2f2a10", text) + self.assertNotIn("EXPECTED_VERSION =", lifecycle) + self.assertIn('p.add_argument("--expected-version", required=True)', lifecycle) + self.assertIn('"expected_version": a.expected_version', lifecycle) + self.assertIn('--expected-version "$EXPECTED_SYSTEM_VERSION"', text) + self.assertIn('APP_IMAGE = "nginx@sha256:65645c7bb6a0661892a8b03b89d0743208a18dd2f3f17a54ef4b76fb8e2f2a10"', lifecycle) + self.assertIn("truenas_rung:", workflow) + self.assertIn("needs.changes.outputs.truenas_rung", workflow) + shell_check = subprocess.run( + ["bash", "-n", str(TRUENAS)], text=True, capture_output=True + ) + self.assertEqual(shell_check.returncode, 0, shell_check.stderr) + + + def test_truenas_t6_foundry_harness_contract(self): + text = TRUENAS.read_text(encoding="utf-8") + workflow = WORKFLOW.read_text(encoding="utf-8") + litellm = (ROOT / "scripts" / "truenas_middleware_litellm_t6_probe.py").read_text(encoding="utf-8") + self.assertIn("--foundry-control-dir", text) + self.assertIn("--foundry-commit", text) + self.assertIn("truenas_middleware_litellm_t6_probe.py", text) + self.assertNotIn('python3 "$SCRIPT_DIR/truenas_middleware_foundry_control_probe.py"', text) + self.assertIn('"foundry_materialization_exercised"', text) + self.assertIn('"foundry_materialization"', text) + self.assertIn("truenas_rung:", workflow) + self.assertIn("needs.changes.outputs.truenas_rung", workflow) + self.assertIn("4ba12f4a870f9af8a667056f1e2cc32f80f8e2ba", workflow) + self.assertIn("litellm-truenas-t6-control", workflow) + self.assertIn("export-litellm-t6-control.yml", workflow) + self.assertIn("semper-supra.litellm-truenas-t6-control/1", litellm) + self.assertIn("config_readback_sha", litellm) + self.assertIn('"secret_values_captured": False', litellm) + self.assertIn("--service-port", text) + self.assertIn("-:30401", text) + shell_check = subprocess.run( + ["bash", "-n", str(TRUENAS)], text=True, capture_output=True + ) + self.assertEqual(shell_check.returncode, 0, shell_check.stderr) + + def test_qemu_t3_topology_is_a_cheap_separate_oracle(self): + probe = QEMU_TOPOLOGY.read_text(encoding="utf-8") + workflow = WORKFLOW.read_text(encoding="utf-8") + self.assertIn('"rdte-nic"', probe) + self.assertIn('"rdte-boot"', probe) + self.assertIn('"bootindex": 1', probe) + self.assertIn('"RDTE_DATA_0"', probe) + self.assertIn('"RDTE_DATA_1"', probe) + self.assertIn('"query-pci"', probe) + self.assertIn('"qom-get"', probe) + self.assertIn("qemu-t3-topology:", workflow) + self.assertIn("needs.changes.outputs.qemu_topology", workflow) + self.assertNotIn( + "scripts/gha_qemu_t3_topology_probe.py)", + workflow.split("truenas=true", 1)[0], + ) + py = subprocess.run( + ["python3", "-m", "py_compile", str(QEMU_TOPOLOGY)], + text=True, + capture_output=True, + ) + self.assertEqual(py.returncode, 0, py.stderr) + + def test_all_post_t0_rungs_share_installer_rpc_gate(self): + text = TRUENAS.read_text(encoding="utf-8") + self.assertIn('if [[ "$RUNG" != "t0" ]]; then', text) + self.assertNotIn('if [[ "$RUNG" == "t1" || "$RUNG" == "t2" || "$RUNG" == "t3" ]]; then', text) + self.assertIn("try_rpc_discovery", text) + + def test_t4_uses_exact_target_profile_metadata(self): + text = (ROOT / "scripts" / "truenas_middleware_app_probe.py").read_text(encoding="utf-8") + self.assertNotIn("truenas.entitlements.check", text) + self.assertIn('p.add_argument("--middleware-ref", required=True)', text) + self.assertIn('p.add_argument("--middleware-commit", required=True)', text) + self.assertIn('p.add_argument("--ha-apps-gate", required=True)', text) + self.assertIn('"middleware_ref": a.middleware_ref', text) + self.assertIn('"middleware_commit": a.middleware_commit', text) + self.assertIn('"apps_gate": "docker.license_active"', text) + self.assertIn('"ha_apps_gate": a.ha_apps_gate', text) + + + +class PoolTransportBudgetTests(unittest.TestCase): + def test_pool_probe_has_wss_tolerant_read_budget_and_phase_receipts(self): + root = pathlib.Path(__file__).resolve().parents[1] + harness = (root / "scripts" / "gha_kvm_truenas_rdte.sh").read_text(encoding="utf-8") + probe = (root / "scripts" / "truenas_middleware_pool_probe.py").read_text(encoding="utf-8") + pool_call = harness.split('POOL_OUT="$STATE_DIR/data-pool.json"', 1)[1].split('[[ -f "$POOL_OUT" ]]', 1)[0] + self.assertIn("--timeout 15 --job-timeout 180", pool_call) + for phase in ( + "connect", "authenticate", "discover-system", "discover-boot-disks", + "discover-disk-details", "precondition-pool-absence", + "pool-create-submit", "pool-create-wait", "verify-pool", "verify-pool-disks", "complete", + ): + self.assertIn(f'payload["phase"] = "{phase}"', probe) + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_minecraft_t6_receipt_gate.py b/tests/test_minecraft_t6_receipt_gate.py new file mode 100644 index 000000000..840ee3419 --- /dev/null +++ b/tests/test_minecraft_t6_receipt_gate.py @@ -0,0 +1,54 @@ +#!/usr/bin/env python3 +import copy +import pathlib +import sys +import unittest +ROOT = pathlib.Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "scripts")) +from assert_minecraft_t6_receipt import check + + +def supported(): + o={"schema":"minecraft-java-status-oracle/v1","oracleSatisfied":True, + "java_version":"1.21.11","status_packet_valid":True} + p={"schema":"truenas-minecraft-paper-t6/v1","classification":"SUPPORTED", + "oracleSatisfied":True,"control_sha256":"a"*64,"catalog_version_verified":True, + "world_retained_on_delete":True,"world_rebound_after_reinstall":True, + "readiness":{k:{"protocol":o,"level":{"size":128}} for k in ("first","restart","redeploy","reinstall")}, + "cleanup":{"zero_residue":True,"app_absent":True,"owned_world_dataset_absent":True}} + c={"provider":"minecraft-paper-t6","verdict":"SUPPORTED","cleanup_satisfied":True,"provider_receipt":p} + return {"contract":"gha-kvm-system-lab/v1","classification":"SUPPORTED","oracleSatisfied":True, + "target":{"product":"truenas","version":"25.04.1","rung":"T6"}, + "session_execution":{"schema":"truenas-session-execution/v1","classification":"SESSION_CLEAN", + "session_clean":True,"capsules":[c]}, + "foundry_materialization":copy.deepcopy(p)} + + +class GateTests(unittest.TestCase): + def test_accepts_complete_synthetic(self): + check(supported()) + + def test_workflow_checks_exact_guest_receipt(self): + y=(ROOT/".github/workflows/gha-kvm-system-rdte.yml").read_text() + self.assertIn("python3 scripts/assert_minecraft_t6_receipt.py --receipt",y) + + def test_no_green_laundering(self): + for mutation in ( + lambda x:x.update({"classification":"HARNESS_FAILURE"}), + lambda x:x.update({"oracleSatisfied":False}), + lambda x:x.update({"session_execution":None}), + lambda x:x["session_execution"]["capsules"][0].update({"verdict":"ORACLE_FAILURE"}), + lambda x:x["session_execution"]["capsules"][0]["provider_receipt"].update({"catalog_version_verified":False}), + lambda x:x["session_execution"]["capsules"][0]["provider_receipt"].update({"world_rebound_after_reinstall":False}), + lambda x:x["session_execution"]["capsules"][0]["provider_receipt"]["cleanup"].update({"zero_residue":False}), + lambda x:x["session_execution"]["capsules"][0]["provider_receipt"]["readiness"].pop("reinstall"), + lambda x:x.update({"foundry_materialization":None}), + ): + d=supported() + mutation(d) + with self.assertRaises(ValueError): + check(d) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_native_vanilla263_anvil_blocks.py b/tests/test_native_vanilla263_anvil_blocks.py new file mode 100644 index 000000000..299f5a25b --- /dev/null +++ b/tests/test_native_vanilla263_anvil_blocks.py @@ -0,0 +1,148 @@ +"""Synthetic G4 Anvil block-state decoder tests; never a live guest proof.""" +from __future__ import annotations + +import math +import struct +import unittest +import zlib + +from scripts.qualify_native_vanilla263_anvil_blocks import Refused, compare, inspect + +PALETTE = ("minecraft:redstone_block", "minecraft:air", + "minecraft:sea_lantern", "minecraft:iron_bars", "minecraft:black_concrete") + + +def nbt_string(s): + b = s.encode() + return struct.pack(">H", len(b)) + b + + +def tag(number, name, payload): + return bytes((number,)) + nbt_string(name) + payload + + +def compound(*children): + return b"".join(children) + b"\x00" + + +def build(stage="candidate", *, x_pos=0, z_pos=0, overrides=None): + positions = {((70 & 15) << 8) | (z << 4) | x: value + for (x, z, value) in ( + (0, 0, 1 if stage == "preaction" else 0), (2, 0, 1 if stage == "candidate" else 3), + (1, 1, 2 if stage == "candidate" else 4))} + positions.update(overrides or {}) + bits, per_long = max(4, math.ceil(math.log2(len(PALETTE)))), 16 + # Default air; section 4 consists of exactly 4096 block indices. + idxs = [positions.get(i, 1) for i in range(4096)] + longs = [] + for block in range(0, 4096, per_long): + value = sum(idxs[block + i] << (bits*i) + for i in range(per_long)) + if value >= 1 << 63: + value -= 1 << 64 + longs.append(value) + pal = b"".join(compound(tag(8, "Name", nbt_string(n))) for n in PALETTE) + states = compound( + tag(9, "palette", b"\x0a" + struct.pack(">i", len(PALETTE)) + pal), + tag(12, "data", struct.pack(">i", len(longs)) + + b"".join(struct.pack(">q", n) for n in longs)), + ) + sections = compound( + tag(1, "Y", struct.pack(">b", 4)), + tag(10, "block_states", states), + ) + root = compound( + tag(3, "xPos", struct.pack(">i", x_pos)), + tag(3, "zPos", struct.pack(">i", z_pos)), + tag(9, "sections", b"\x0a" + struct.pack(">i", 1) + sections), + ) + doc = b"\x0a" + nbt_string("") + root + packed = zlib.compress(doc, 9) + count = max(1, math.ceil((len(packed) + 5) / 4096)) + region = bytearray((2 + count) * 4096) + region[0:4] = ((2 << 8) | count).to_bytes(4, "big") + region[2*4096:2*4096+4] = (1+len(packed)).to_bytes(4, "big") + region[2*4096+4] = 2 + region[2*4096+5:2*4096+5+len(packed)] = packed + return bytes(region) + + +class BlockRegionTests(unittest.TestCase): + def test_candidate_rollback_differ_source_only(self): + result = compare(build("candidate"), build("rollback")) + self.assertEqual(result["classification"], + "SOURCE_ONLY_DUAL_ANVIL_BLOCK_DECODER") + self.assertFalse(result["server_origin_authenticated"]) + self.assertFalse(result["backend_independent_world_oracle_pass"]) + self.assertEqual(result["physical_true_nas_apply"], "HOLD") + + def test_individual_stages_not_backend_proof(self): + for stage in ("preaction", "candidate", "rollback"): + result = inspect(build(stage), stage) + self.assertTrue(result["block_state_expectations_matched"]) + self.assertFalse(result["native_guest_server_owned_bytes_proven"]) + self.assertFalse(result["server_quiescence_proven"]) + self.assertFalse(result["backend_independent_world_oracle_pass"]) + + def reject(self, blob, stage, code): + with self.assertRaises(Refused) as ex: + inspect(blob, stage) + self.assertEqual(str(ex.exception), code) + + def test_clean_preaction_backend_fixture_must_be_absent_redstone(self): + control = inspect(build("preaction"), "preaction") + self.assertEqual(control["block_states"], [ + "minecraft:air", "minecraft:iron_bars", "minecraft:black_concrete"]) + core_idx = ((70 & 15) << 8) + self.reject(build("preaction", overrides={core_idx: 0}), "preaction", + "SERVER_BLOCK_CONSEQUENCE_ABSENT") + + def test_wrong_stage_and_replayed_same_region(self): + self.reject(build(), "rollback", "SERVER_BLOCK_CONSEQUENCE_ABSENT") + self.reject(build(), "unknown", "STAGE_NOT_ADMITTED") + with self.assertRaisesRegex(Refused, "SAME_REGION_REPLAY"): + compare(build(), build()) + + def test_wrong_or_stale_placed_core_negative(self): + idx = ((70 & 15) << 8) | (0 << 4) | 0 + self.reject(build(overrides={idx: 1}), "candidate", + "SERVER_BLOCK_CONSEQUENCE_ABSENT") + + def test_wrong_effect_negative(self): + access_idx = ((70 & 15) << 8) | (0 << 4) | 2 + self.reject(build(overrides={access_idx: 3}), "candidate", + "SERVER_BLOCK_CONSEQUENCE_ABSENT") + + def test_wrong_chunk_and_dimension_not_admitted(self): + self.reject(build(x_pos=1), "candidate", "REGION_WRONG_CHUNK") + self.reject(build(z_pos=-1), "candidate", "REGION_WRONG_CHUNK") + + def test_truncated_or_empty_region(self): + self.reject(b"", "candidate", "REGION_FILE_BOUNDS") + self.reject(build()[:8191], "candidate", "REGION_FILE_BOUNDS") + + def test_corrupted_header_and_external_compression(self): + blob = bytearray(build()) + blob[0:4] = b"\x00\x00\x00\x00" + self.reject(bytes(blob), "candidate", "REGION_CHUNK_NOT_OWNED") + blob = bytearray(build()) + blob[8192+4] = 0x82 + self.reject(bytes(blob), "candidate", "REGION_COMPRESSION_UNSUPPORTED") + blob = bytearray(build()) + blob[8192:8196] = (99999999).to_bytes(4, "big") + self.reject(bytes(blob), "candidate", "REGION_CHUNK_LENGTH_INVALID") + + def test_bad_decompression_rejected(self): + blob = bytearray(build()) + blob[8192+5:8192+9] = b"xxxx" + self.reject(bytes(blob), "candidate", "REGION_DECOMPRESS_BOUNDS") + + def test_reject_foreign_or_unqualified_blobs(self): + with self.assertRaisesRegex(Refused, "REGION_FILE_BOUNDS"): + compare(build(), b"caller-supplied-block-list") + self.assertNotIn("native_guest_backend_independent_oracle_qualified", + inspect(build(), "candidate")) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_native_vanilla263_anvil_guest_readback.py b/tests/test_native_vanilla263_anvil_guest_readback.py new file mode 100644 index 000000000..98e635571 --- /dev/null +++ b/tests/test_native_vanilla263_anvil_guest_readback.py @@ -0,0 +1,295 @@ +"""Stubbed middleware admission and negative surface tests; NOT a live guest.""" +from __future__ import annotations + +import unittest + +from scripts.qualify_native_vanilla263_anvil_guest_readback import (capture, PATH, REGION_DIRECTORY, record_phase, validate_single_use_download) +from scripts.qualify_vanilla263_native_world_rollback import WORLD +from scripts.qualify_native_vanilla263_anvil_blocks import Refused +from scripts.qualify_vanilla263_native_world_rollback import APP +from tests.test_native_vanilla263_anvil_blocks import build + + +class NativeReadbackTests(unittest.TestCase): + def setUp(self): + self.blob = build("candidate") + self.state = "STOPPED" + self.stat = {"type": "FILE", "size": len(self.blob)} + self.token = "/_download/1001?auth_token=" + ("A" * 64) + self.record = [] + self.downloaded = [] + + def call(self, name, args): + self.record.append((name, args)) + if name == "app.query": + self.assertEqual(args, [[["id", "=", APP]]]) + return [{"name": APP, "state": self.state}] + if name == "filesystem.stat": + self.assertEqual(args, [PATH]) + return self.stat + if name == "core.download": + self.assertEqual(args, ["filesystem.get", [PATH], + "g4-owned-region-candidate.mca", True]) + return [1001, self.token] + self.fail("unexpected middleware RPC: " + name) + + def wait(self, job, label): + self.downloaded.append((job, label)) + + def fetch(self, token): + self.assertEqual(token, self.token) + return self.blob + + def capture_stage(self, stage="candidate"): + return capture(call=self.call, wait_job=self.wait, + fetch_bytes=self.fetch, stage=stage) + + def reject(self, reason): + with self.assertRaises(Refused) as cm: + self.capture_stage() + self.assertEqual(str(cm.exception), reason) + + def test_java_26_3_namespaced_overworld_region_is_exclusive(self): + correct = WORLD + "/dimensions/minecraft/overworld/region" + legacy = WORLD + "/region" + self.assertEqual(REGION_DIRECTORY, correct) + self.assertEqual(PATH, correct + "/r.0.0.mca") + self.assertNotEqual(PATH, legacy + "/r.0.0.mca") + # Valid-looking bytes from the old 1.21.11 location are never read. + requested = [] + def strict(name, args): + requested.append((name, args)) + if name == "filesystem.stat" and args == [legacy + "/r.0.0.mca"]: + self.fail("legacy 1.21.11 overworld fallback attempted") + return self.call(name, args) + result = capture(call=strict, wait_job=self.wait, + fetch_bytes=self.fetch, stage="candidate") + self.assertFalse(result["native_guest_backend_independent_oracle_pass"] + if "native_guest_backend_independent_oracle_pass" in result + else result["backend_independent_world_oracle_pass"]) + self.assertIn(("filesystem.stat", [PATH]), requested) + self.assertNotIn(("filesystem.stat", [legacy + "/r.0.0.mca"]), requested) + + def test_fixed_native_quiesced_read_only_path_and_nonclaim(self): + result = self.capture_stage() + self.assertEqual(result["classification"], + "SOURCE_ONLY_OWNED_ANVIL_DOWNLOAD_SEAM") + self.assertEqual(result["downloaded_file_size"], len(self.blob)) + self.assertTrue(result["requested_native_world_file_fixed"]) + self.assertFalse(result["live_guest_identity_independently_verified"]) + self.assertFalse(result["backend_independent_world_oracle_pass"]) + self.assertFalse(result["raw_region_published"]) + self.assertEqual(result["physical_true_nas_apply"], "HOLD") + self.assertEqual([name for name, _ in self.record], + ["app.query", "filesystem.stat", "core.download"]) + self.assertEqual(self.downloaded, [(1001, "G4 read-only owned Anvil file download")]) + + def test_wrong_stage_has_zero_provider_calls(self): + with self.assertRaisesRegex(Refused, "BACKEND_STAGE_NOT_ADMITTED"): + self.capture_stage("other") + self.assertEqual(self.record, []) + + def test_refuse_running_app_before_file_read(self): + self.state = "RUNNING" + self.reject("BACKEND_QUIESCENCE_NOT_OBSERVED") + self.assertEqual([name for name, _ in self.record], ["app.query"]) + + def test_refuse_missing_or_unsafe_file_stat(self): + for attr in ({"type": "DIRECTORY", "size": len(self.blob)}, + {"type": "FILE", "size": 0}, {"type": "FILE", "size": True}, + None): + with self.subTest(stat=attr): + self.stat = attr + self.reject("BACKEND_REGION_FILE_NOT_EXACT") + + def test_vendor_true_nas_250426_download_shape_is_accepted(self): + # Actual pinned middleware core_service.py's return URL format. + # Token values are synthetic: no real host, credential or guest access. + route = "/_download/1001?auth_token=" + ("Qw7Nm3Kz" * 8) + job, selected = validate_single_use_download([1001, route]) + self.assertEqual((job, selected), (1001, route)) + self.token = route + self.assertEqual(self.capture_stage()["classification"], + "SOURCE_ONLY_OWNED_ANVIL_DOWNLOAD_SEAM") + self.assertEqual(self.downloaded, [(1001, "G4 read-only owned Anvil file download")]) + self.assertNotIn("auth_token", str(self.downloaded)) + + def test_fail_closed_foreign_redirect_token_job_replay_and_extra_query(self): + token = "SENSITIVE" * 8 + allowed = "/_download/1001?auth_token=" + token + candidates = ( + "https://foreign.invalid" + allowed, + "//foreign.invalid" + allowed, + "/_download/../1001?auth_token=" + token, + "/_download/1002?auth_token=" + token, + "/_download/01001?auth_token=" + token, + "/_download/1001", + "/_download/1001?x=" + token, + allowed + "&root=1", + allowed + "?extra=1", + allowed + "#fragment", + allowed.replace("auth_token=", "auth_token=%41"), + "/_download/1001?auth_token=a", + allowed + "/suffix", + ) + for candidate in candidates: + with self.subTest(route_kind=candidate.split("?")[0][:20]): + self.token = candidate + self.reject("BACKEND_DOWNLOAD_ROUTE_INVALID") + self.assertEqual(self.downloaded, []) + self.assertNotIn(token, str(self.downloaded)) + for row in ([True, allowed], ["1001", allowed], + [0, allowed], [1002, allowed], [1001, allowed, "other"], + {"job": 1001, "url": allowed}, [1001, None]): + with self.subTest(type_row=str(type(row))): + with self.assertRaisesRegex(Refused, + "BACKEND_DOWNLOAD_ROUTE_INVALID") as cm: + validate_single_use_download(row) + self.assertNotIn(token, str(cm.exception)) + + def test_old_non_query_synthetic_download_is_rejected(self): + self.token = "/_download/synthetic-test-opaque" + self.reject("BACKEND_DOWNLOAD_ROUTE_INVALID") + self.assertEqual(self.downloaded, []) + + def test_refuse_wrong_file_bytes(self): + self.blob = self.blob[:-1] + self.reject("BACKEND_DOWNLOADED_FILE_DRIFT") + + def test_refuse_wrong_effect_inside_download(self): + self.blob = build("rollback") + self.reject("SERVER_BLOCK_CONSEQUENCE_ABSENT") + + + def test_phase_census_is_literal_and_contains_no_provider_values(self): + seen = [] + got = capture(call=self.call, wait_job=self.wait, fetch_bytes=self.fetch, + stage="candidate", on_phase=seen.append) + self.assertTrue(got["block_state_expectations_matched"]) + self.assertEqual(seen, [ + "candidate.app_state_query", + "candidate.app_stopped_verified", + "candidate.file_stat", + "candidate.file_stat_verified", + "candidate.download_request", + "candidate.download_route_verified", + "candidate.download_job", + "candidate.download_job_completed", + "candidate.bytes_fetch", + "candidate.bytes_verified", + "candidate.anvil_decode", + "candidate.anvil_verified", + ]) + self.assertNotIn("/mnt/", str(seen)) + self.assertNotIn("/_download/", str(seen)) + self.assertNotIn("credential", str(seen)) + + def test_native_rpc_failure_identifies_operation_not_exception(self): + for fail_method, last_phase in ( + ("app.query", "candidate.app_state_query"), + ("filesystem.stat", "candidate.file_stat"), + ("core.download", "candidate.download_request"), + ): + with self.subTest(fail_method=fail_method): + previous = self.call + phases = [] + def injected(method, args): + if method == fail_method: + raise RuntimeError("secret-password provider-path /mnt/private") + return previous(method, args) + with self.assertRaises(Refused if fail_method == "filesystem.stat" + else RuntimeError) as raised: + capture(call=injected, wait_job=self.wait, fetch_bytes=self.fetch, + stage="candidate", on_phase=phases.append) + self.assertEqual(phases[-1], + "candidate.file_stat_listing_inconclusive" + if fail_method == "filesystem.stat" else last_phase) + if fail_method == "filesystem.stat": + self.assertEqual(str(raised.exception), + "BACKEND_REGION_STAT_CENSUS_INCONCLUSIVE") + self.assertNotIn("secret-password", str(phases)) + self.assertNotIn("/mnt/private", str(phases)) + + def test_019_stat_failure_has_one_readonly_fixed_path_census(self): + # Synthetic 019-shaped failure only, NOT evidence of real guest file. + cases = [ + ([], "BACKEND_REGION_NOT_LISTED_AFTER_STOP", "file_stat_listing_absent"), + ([{"name": "r.0.0.mca", "type": "FILE"}], + "BACKEND_REGION_STAT_REFUSED_BUT_FILE_LISTED", "file_stat_listing_present"), + ([{"name": "r.0.0.mca", "type": "DIRECTORY"}], + "BACKEND_REGION_CENSUS_ENTRY_UNSAFE", "file_stat_listing_inconclusive"), + ([{"name": "r.0.0.mca", "type": "FILE"}]*2, + "BACKEND_REGION_CENSUS_ENTRY_UNSAFE", "file_stat_listing_inconclusive"), + ({"secret": "private"}, "BACKEND_REGION_STAT_CENSUS_INVALID", + "file_stat_listing_inconclusive"), + ([{"name": "r.0.0.mca", "type": "FILE"}]*4097, + "BACKEND_REGION_STAT_CENSUS_INVALID", "file_stat_listing_inconclusive"), + ] + for entries, expected, last in cases: + with self.subTest(reason=expected): + calls, phases = [], [] + def injected(method, args): + calls.append(method) + if method == "filesystem.stat": + self.assertEqual(args, [PATH]) + raise RuntimeError("SECRET /mnt/foreign") + if method == "filesystem.listdir": + self.assertEqual(args, [PATH.rsplit("/", 1)[0]]) + return entries + return self.call(method, args) + with self.assertRaises(Refused) as raised: + capture(call=injected, wait_job=self.wait, + fetch_bytes=self.fetch, stage="candidate", + on_phase=phases.append) + self.assertEqual(str(raised.exception), expected) + self.assertEqual(calls, ["app.query", "filesystem.stat", + "filesystem.listdir"]) + self.assertEqual(phases[-1], "candidate." + last) + self.assertEqual(self.downloaded, []) + self.assertNotIn("SECRET", str(phases) + str(raised.exception)) + + def test_019_inventory_failure_is_inconclusive_never_retry_or_escalate(self): + calls, phases = [], [] + def injected(method, args): + calls.append(method) + if method in ("filesystem.stat", "filesystem.listdir"): + raise RuntimeError("SECRET provider error") + return self.call(method, args) + with self.assertRaises(Refused) as raised: + capture(call=injected, wait_job=self.wait, + fetch_bytes=self.fetch, stage="preaction", + on_phase=phases.append) + self.assertEqual(str(raised.exception), "BACKEND_REGION_STAT_CENSUS_INCONCLUSIVE") + self.assertEqual(calls, ["app.query", "filesystem.stat", "filesystem.listdir"]) + self.assertEqual(phases[-1], "preaction.file_stat_listing_inconclusive") + self.assertEqual(self.downloaded, []) + + def test_download_job_and_http_failures_are_distinct(self): + cases = ( + (lambda job, label: (_ for _ in ()).throw( + RuntimeError("untrusted job exception")), self.fetch, + "candidate.download_job"), + (self.wait, lambda token: (_ for _ in ()).throw( + RuntimeError("untrusted URL exception")), "candidate.bytes_fetch"), + ) + for wait, fetch, wanted in cases: + with self.subTest(wanted=wanted): + phases = [] + with self.assertRaises(RuntimeError): + capture(call=self.call, wait_job=wait, fetch_bytes=fetch, + stage="candidate", on_phase=phases.append) + self.assertEqual(phases[-1], wanted) + self.assertNotIn("untrusted", str(phases)) + + def test_unknown_phase_and_stage_not_emitted(self): + phases = [] + with self.assertRaisesRegex(Refused, "BACKEND_PHASE_CODE_INVALID"): + record_phase("candidate", "secret=PASSWORD", phases.append) + with self.assertRaisesRegex(Refused, "BACKEND_PHASE_CODE_INVALID"): + record_phase("foreign", "download_request", phases.append) + self.assertEqual(phases, []) + + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_native_vanilla263_backend_three_checkpoint_receipt.py b/tests/test_native_vanilla263_backend_three_checkpoint_receipt.py new file mode 100644 index 000000000..55a19f348 --- /dev/null +++ b/tests/test_native_vanilla263_backend_three_checkpoint_receipt.py @@ -0,0 +1,133 @@ +"""G4 synthetic JSON adjudication: cannot infer live native world from packets.""" +from __future__ import annotations +import copy +import unittest + +from tests.test_native_vanilla263_player_guest_receipt import guest +from scripts.assert_vanilla263_native_t6_receipt import check +from scripts.admit_vanilla263_native_t6_control import APP + +BLOCKS = { + "preaction": ["minecraft:air", "minecraft:iron_bars", "minecraft:black_concrete"], + "candidate": ["minecraft:redstone_block", "minecraft:air", "minecraft:sea_lantern"], + "rollback": ["minecraft:redstone_block", "minecraft:iron_bars", "minecraft:black_concrete"], +} + + +def backend_fixture(): + root = guest() + p = root["foundry_materialization"] + p["native_backend_world"] = { + "schema": "supracraft.native-vanilla263-live-backend-region/1", + "classification": "LIVE_NATIVE_BACKEND_THREE_CHECKPOINTS", + "same_owned_guest_three_checkpoints": True, + "backend_independent_world_oracle_pass": True, + "collage_mutation_authorized": False, + "physical_true_nas_apply": "HOLD", + "preparation": { + "schema": "supracraft.native-vanilla263-mineflayer-causal/1", + "classification": "FIXED_PLAYER_FIXTURE_PREPARATION_ONLY", + "stage": "candidate-prep", + "client_protocol777_connected_and_spawned": True, + "client_fixture_prepared_without_core_placement": True, + "native_guest_backend_independent_oracle_qualified": False, + "collage_mutation_authorized": False, + "physical_true_nas_apply": "HOLD", + }, + } + for i, (stage, block_states) in enumerate(BLOCKS.items()): + p["native_backend_world"][stage] = { + "schema": "supracraft.native-vanilla263-server-owned-anvil/1", + "classification": "LIVE_NATIVE_STOPPED_OWNER_ANVIL_READBACK", + "stage": stage, "region_sha256": str(i+1)*64, + "coordinates": [[0,70,0],[2,70,0],[1,70,1]], + "block_states": block_states, "block_state_expectations_matched": True, + "requested_native_app": APP, + "requested_native_world": "rdtepool/minecraft-vanilla-263-t6-world", + "requested_native_world_file_fixed": True, + "read_only_middleware_calls": True, + "live_guest_identity_independently_verified": True, + "native_guest_server_owned_bytes_proven": True, + "server_quiescence_proven": True, + "raw_region_published": False, + "collage_mutation_authorized": False, + "physical_true_nas_apply": "HOLD", + "downloaded_file_size": 12288, + } + p["readiness"]["g4-" + stage] = copy.deepcopy(p["readiness"]["reinstall"]) + return root + + +def admit(root, backend=True): + return check(root, require_collage=True, + require_lantern_staged=True, require_native_rollback=True, + require_native_client_visibility=True, + require_native_backend_region=backend) + + +class ThreeCheckpointReceiptTests(unittest.TestCase): + def test_synthetic_shape_and_falsely_upgraded_015_refused(self): + admit(backend_fixture()) # mocked JSON validation, NOT live acceptance + old = guest() + admit(old, backend=False) # 015 client-only acceptance preserved + with self.assertRaises(ValueError): + admit(old) + + def test_backend_requires_all_prior_gates(self): + with self.assertRaisesRegex(ValueError, "backend world region requires G2/G3"): + check(backend_fixture(), require_native_backend_region=True) + + def test_no_forged_guest_identity_or_server_owned_file(self): + for stage in BLOCKS: + for field, bad in ( + ("block_states", ["minecraft:redstone_block"]*3), + ("stage", "rollback" if stage != "rollback" else "candidate"), + ("region_sha256", "nothex"), + ("coordinates", [[100,70,0]]), + ("requested_native_world", "foreign-world"), + ("requested_native_app", "foreign"), + ("live_guest_identity_independently_verified", False), + ("native_guest_server_owned_bytes_proven", False), + ("server_quiescence_proven", False), + ("raw_region_published", True), + ("read_only_middleware_calls", False), + ("downloaded_file_size", 0), + ("collage_mutation_authorized", True), + ): + with self.subTest(stage=stage, field=field): + doc=backend_fixture() + doc["foundry_materialization"]["native_backend_world"][stage][field]=bad + with self.assertRaises(ValueError): + admit(doc) + + def test_replay_and_absent_clean_control_refused(self): + doc=backend_fixture() + proof=doc["foundry_materialization"]["native_backend_world"] + proof["rollback"]["region_sha256"]=proof["candidate"]["region_sha256"] + with self.assertRaisesRegex(ValueError, "hash replay"): + admit(doc) + doc=backend_fixture() + del doc["foundry_materialization"]["native_backend_world"]["preaction"] + with self.assertRaises(ValueError): admit(doc) + doc=backend_fixture() + del doc["foundry_materialization"]["readiness"]["g4-candidate"] + with self.assertRaisesRegex(ValueError,"restart/health missing"): admit(doc) + + def test_actor_authority_does_not_inherit_backend_pass(self): + doc=backend_fixture() + doc["foundry_materialization"]["native_backend_world"]["physical_true_nas_apply"]="READY" + with self.assertRaises(ValueError):admit(doc) + doc=backend_fixture() + doc["foundry_materialization"]["native_backend_world"]["collage_mutation_authorized"]=True + with self.assertRaises(ValueError):admit(doc) + + def test_preparation_must_have_no_claimed_backend_proof(self): + for field, bad in (("stage","rollback"),("client_fixture_prepared_without_core_placement",False), + ("native_guest_backend_independent_oracle_qualified", True)): + doc=backend_fixture() + doc["foundry_materialization"]["native_backend_world"]["preparation"][field]=bad + with self.assertRaises(ValueError):admit(doc) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_native_vanilla263_loopback_player_overlay.py b/tests/test_native_vanilla263_loopback_player_overlay.py new file mode 100644 index 000000000..c6ee03cdf --- /dev/null +++ b/tests/test_native_vanilla263_loopback_player_overlay.py @@ -0,0 +1,190 @@ +"""Exact-source semantics of a disposable offline synthetic player variant. + +Static tests intentionally do not claim actual guest isolation or gameplay. +The canonical Foundry signature is independently checked by existing tests; +these tests isolate the additional source-only constrained derivation. +""" +import copy +from pathlib import Path +import sys +import unittest +from unittest.mock import patch + +ROOT=Path(__file__).resolve().parents[1] +sys.path.insert(0,str(ROOT/"scripts")) +import qualify_native_vanilla263_loopback_player_overlay as overlay + +def baseline(): + return { + "runtime":{"values_template":{ + "minecraft":{ + "online_mode":True,"enable_rcon":False,"rcon_password":"", + "ops":[],"whitelist":[],"seed":None, + "additional_envs":[{"name":k,"value":v} + for k,v in overlay.OWNER.items()], + }, + "network":{"host_network":False, + "server_port":{"bind_mode":"published","port_number":25565}}, + "storage":{"additional_storage":[{"mount_path":"/data/world", + "host_path_config":{"path":"/opt/tests/collage-vanilla-263-world"}}]}, + }}, + } + +def admission(): + return { + "schema":overlay.PROFILE, + "rdte_disposable":True, + "experiment":"offline-mineflayer-player-causal", + "physical_truenas_apply":"HOLD", + "operational_selection":False, + "external_ingress":False, + "qemu_network":"user", + "host_forward":overlay.HOST_FORWARD, + "guest_target":"TrueNAS-"+overlay.TARGET, + "authorized_bot":"SupraCraftProbe", + } + +def derive(control=None, request=None): + ctl=control if control is not None else baseline() + req=request if request is not None else admission() + with patch.object(overlay,"verify",return_value={"classification":"SOURCE_CONTROL_READY_GUEST_NOT_DISPATCHED"}) as check: + result=overlay.derive(ctl,req) + check.assert_called_once_with(ctl) + return result + +class OverlaySourceTests(unittest.TestCase): + def test_synthetic_player_overlay_preserves_operational_control_and_is_source_only(self): + before=baseline() + frozen=copy.deepcopy(before) + result=derive(before,admission()) + self.assertEqual(before,frozen) + self.assertEqual(result["classification"],"SOURCE_ONLY_DISPOSABLE_PLAYER_VARIANT") + self.assertEqual(result["immutable_base_control_sha256"],overlay.CONTROL_SHA) + minecraft=result["values"]["minecraft"] + self.assertIs(minecraft["online_mode"],False) + self.assertEqual(minecraft["ops"],[overlay.BOT_OFFLINE_UUID]) + self.assertNotIn(overlay.BOT_NAME,minecraft["ops"]) + self.assertFalse(minecraft["enable_rcon"]) + self.assertEqual(minecraft["rcon_password"],"") + self.assertFalse(minecraft["whitelist"]) + self.assertNotIn("seed",minecraft) + self.assertEqual(result["values"]["network"]["server_port"]["port_number"],25580) + self.assertEqual(result["values"]["storage"]["additional_storage"][0]["host_path_config"]["path"],overlay.WORLD) + self.assertEqual({x["name"]:x["value"] for x in minecraft["additional_envs"]},overlay.OWNER) + for field in ("online_account_auth_qualified","in_guest_bot_connected", + "datapack_effect_in_native_guest_qualified", + "collage_managed_deployment","operational_selection"): + self.assertIs(result[field],False) + self.assertEqual(result["physical_truenas_apply"],"HOLD") + self.assertEqual(len(result["derived_runtime_values_sha256"]),64) + + def test_offline_uuid_v3_operator_is_not_an_unresolvable_mojang_name(self): + import hashlib, uuid + expected=str(uuid.UUID(bytes=hashlib.md5( + ("OfflinePlayer:" + overlay.BOT_NAME).encode()).digest(), version=3)) + self.assertEqual(expected,"f4469b77-cc14-3c07-95f3-0b0c3f151bd2") + self.assertEqual(expected,overlay.BOT_OFFLINE_UUID) + self.assertEqual(derive()["values"]["minecraft"]["ops"],[expected]) + self.assertEqual(derive()["bot"],overlay.BOT_NAME) + self.assertFalse(derive()["values"]["minecraft"]["online_mode"]) + self.assertFalse(derive()["values"]["minecraft"]["enable_rcon"]) + + def test_read_only_native_startup_census_never_rediscloses_metadata(self): + sample={ + "state":"DEPLOYING", + "name":"private-site", + "active_workloads":{"container_details":[ + {"state":"starting","id":"opaque-secret-container", + "image":"private.registry/password"}, + {"state":"created","port_config":[{"host_ip":"192.0.2.10"}]}, + {"state":"exited","volume_mounts":[{"source":"/secret/path"}]}, + ]}, + } + report=overlay.redacted_native_startup_census(sample) + self.assertEqual(report["native_app_state"],"DEPLOYING") + self.assertEqual(report["container_states"]["starting"],1) + self.assertEqual(report["container_states"]["created"],1) + self.assertEqual(report["container_states"]["exited"],1) + self.assertFalse(report["container_census_truncated"]) + self.assertNotIn("opaque-secret",str(report)) + self.assertNotIn("password",str(report)) + self.assertNotIn("192.0.2.10",str(report)) + self.assertNotIn("/secret",str(report)) + self.assertEqual(overlay.redacted_native_startup_census(None)["native_app_state"], + "UNKNOWN") + self.assertEqual(overlay.redacted_native_startup_census( + {"state":"UNKNOWN", "active_workloads":{"container_details":[]}} + )["native_app_state"],"UNKNOWN") + self.assertTrue(overlay.redacted_native_startup_census( + {"state":"STOPPED", "active_workloads":{"container_details":[ + {"state":"running"}]*17}} + )["container_census_truncated"]) + + def test_runner_owned_ephemeral_forward_matches_native_harness(self): + req=admission() + req["host_forward"]="tcp:127.0.0.1:45451-:25580" + result=derive(baseline(),req) + self.assertEqual(result["host_forward"],req["host_forward"]) + self.assertEqual(result["host_port"],45451) + self.assertEqual(result["network"],"QEMU_USER_LOOPBACK_ONLY_SOURCE_REQUIREMENT") + self.assertFalse(result["in_guest_bot_connected"]) + # A source parser does not prove QEMU listener ownership or a guest player. + for forbidden in ( + "tcp:0.0.0.0:45451-:25580", + "tcp:192.168.1.1:45451-:25580", + "tcp:[::1]:45451-:25580", + "tcp:127.0.0.1:45451-:25565", + "tcp:127.0.0.1:22-:25580", + "tcp:127.0.0.1:01025-:25580", + "tcp:127.0.0.1:65536-:25580", + "tcp:127.0.0.1:45451-:25580,hostfwd=other", + "tcp:127.0.0.1:45451-:25580\\nINJECT", + ): + bad=admission() + bad["host_forward"]=forbidden + with self.subTest(forbidden=forbidden),self.assertRaises(overlay.Refused): + derive(baseline(),bad) + + def test_all_unadmitted_exposure_and_target_variants_refused(self): + for key,bad in ( + ("rdte_disposable",False), + ("external_ingress",True), + ("qemu_network","tap"), + ("host_forward","tcp:0.0.0.0:25580-:25580"), + ("host_forward","tcp:127.0.0.1:25580-:25565"), + ("guest_target","TrueNAS-25.04.1"), + ("authorized_bot","human-owner"), + ("physical_truenas_apply","APPLY"), + ("operational_selection",True), + ("experiment","live-minecraft"), + ): + q=admission() + q[key]=bad + with self.subTest(key=key),self.assertRaises(overlay.Refused): + derive(baseline(),q) + q=admission() + q["allow_admin"]=True + with self.assertRaises(overlay.Refused): + derive(baseline(),q) + + def test_canonical_baseline_cannot_arrive_already_degraded(self): + for section,key,bad in ( + ("minecraft","online_mode",False), + ("minecraft","enable_rcon",True), + ("minecraft","ops",["OtherUser"]), + ("minecraft","whitelist",["Anybody"]), + ("network","host_network",True), + ): + c=baseline() + c["runtime"]["values_template"][section][key]=bad + with self.subTest(section=section,key=key),self.assertRaises(overlay.Refused): + derive(c,admission()) + + def test_real_canonical_signature_gate_called_not_bypassed(self): + original=baseline() + with patch.object(overlay,"verify",side_effect=overlay.Refused("CANONICAL_CONTROL_SHA_DRIFT")): + with self.assertRaisesRegex(overlay.Refused,"CANONICAL_CONTROL_SHA_DRIFT"): + overlay.derive(original,admission()) + +if __name__=="__main__": + unittest.main() diff --git a/tests/test_native_vanilla263_mineflayer_causal.py b/tests/test_native_vanilla263_mineflayer_causal.py new file mode 100644 index 000000000..fd096a3d0 --- /dev/null +++ b/tests/test_native_vanilla263_mineflayer_causal.py @@ -0,0 +1,134 @@ +"""OP4H source and adversarial only; no real native client or KVM admission.""" +from __future__ import annotations +import copy +from pathlib import Path +import subprocess +import tempfile +import unittest +from unittest.mock import patch + +from scripts import qualify_native_vanilla263_mineflayer_causal as target + + +def good(stage): + return { + "schema":target.SCHEMA, "classification":"CLIENT_CAUSAL_VISIBILITY", + "stage":stage,"requested_version":"26.3","negotiated_version":"26.3", + "protocol":777,"negotiated_protocol":777, + "offline_mode":True,"synthetic_bot":"SupraCraftProbe", + "operation":"mineflayer.placeBlock", + "connected_and_spawned":True, + "fixture_via_bounded_bot_commands":True, + "placed":True,"visible_effect_correct":True, + "expected_datapack_present":stage=="candidate", + "rcon_used":False,"backend_independent_oracle_qualified":False, + "true_nas_physical_apply":False, + "collage_mutation_authorized":False,"in_guest_server_world":False, + } + + +class ClientSourceTests(unittest.TestCase): + def test_candidate_and_rollback_effects_are_separate_and_no_authority(self): + for stage in ("candidate", "rollback"): + receipt=target.validate(good(stage),stage) + self.assertEqual(receipt["classification"],"CLIENT_CAUSAL_VISIBILITY_ONLY") + self.assertFalse(receipt["native_guest_backend_independent_oracle_qualified"]) + self.assertFalse(receipt["collage_mutation_authorized"]) + self.assertEqual(receipt["physical_true_nas_apply"],"HOLD") + self.assertEqual(receipt["client_saw_expected_datapack_effect"], + stage=="candidate") + self.assertEqual(receipt["client_saw_expected_no_datapack_effect"], + stage=="rollback") + + def test_preaction_stages_only_prepare_client_fixture(self): + pre=good("candidate-prep") + pre.update(classification="CLIENT_FIXTURE_PREPARED", + operation="mineflayer.chat.fixedFixture", + placed=False, expected_datapack_present=True) + result=target.validate(pre,"candidate-prep") + self.assertEqual(result["classification"],"FIXED_PLAYER_FIXTURE_PREPARATION_ONLY") + self.assertTrue(result["client_fixture_prepared_without_core_placement"]) + self.assertFalse(result["native_guest_backend_independent_oracle_qualified"]) + for field,value in (("placed",True),("operation","mineflayer.placeBlock"), + ("backend_independent_oracle_qualified",True), + ("expected_datapack_present",False),("rcon_used",True)): + bad=dict(pre, **{field:value}) + with self.subTest(field=field),self.assertRaises(target.Refused): + target.validate(bad,"candidate-prep") + + def test_forged_success_protocol_bot_gameplay_and_privilege_are_refused(self): + for field,value in ( + ("classification","SOURCE_ONLY"),("schema","untrusted"), + ("requested_version","26.2"),("negotiated_version","1.21.11"), + ("protocol",774),("negotiated_protocol",True), + ("offline_mode",False),("synthetic_bot","OtherPerson"), + ("operation","claim_without_packet"),("connected_and_spawned",False), + ("fixture_via_bounded_bot_commands",False), + ("placed",False),("visible_effect_correct",False), + ("expected_datapack_present",False), + ("rcon_used",True),("backend_independent_oracle_qualified",True), + ("true_nas_physical_apply",True), + ("collage_mutation_authorized",True),("in_guest_server_world",True), + ): + bad=good("candidate") + bad[field]=value + with self.subTest(field=field), self.assertRaises(target.Refused): + target.validate(bad,"candidate") + bad=good("candidate") + bad["reason_code"]="SECRET_OR_FALSE_POSITIVE" + with self.assertRaises(target.Refused): + target.validate(bad,"candidate") + with self.assertRaises(target.Refused): + target.validate(good("candidate"),"rollback") + + def test_requires_owned_loopback_and_no_preexisting_result(self): + with tempfile.TemporaryDirectory() as tmp: + base=Path(tmp) + runtime=base/"node_modules" + runtime.mkdir() + for bad in ( + "tcp:0.0.0.0:45451-:25580", + "tcp:127.0.0.1:45451-:25565", + "tcp:127.0.0.1:22-:25580", + ): + with self.subTest(bad=bad),self.assertRaises(Exception): + target.run(stage="candidate",forward=bad, + state_dir=base,node_path=runtime) + (base/"native-mineflayer-candidate.json").write_text("{}") + with self.assertRaisesRegex(target.Refused,"REPLAY_REFUSED"): + target.run(stage="candidate", + forward="tcp:127.0.0.1:45451-:25580", + state_dir=base,node_path=runtime) + + def test_runtime_drops_raw_output_and_never_uses_auth_or_network_arguments(self): + with tempfile.TemporaryDirectory() as tmp: + root=Path(tmp); deps=root/"node_modules"; deps.mkdir() + def fake(cmd,**kwargs): + self.assertEqual(cmd,["node",str(target.WORKER)]) + env=kwargs["env"] + self.assertEqual(env["MC_PORT"],"45451") + self.assertEqual(env["SUPRACRAFT_NATIVE_STAGE"],"rollback") + self.assertEqual(env["NODE_PATH"],str(deps)) + self.assertNotIn("secret",str(cmd)) + import json + Path(env["BOT_RESULT_FILE"]).write_text(json.dumps(good("rollback"))) + return subprocess.CompletedProcess(cmd,0,"SHOULD NEVER PUBLISH","SECRET") + with patch.object(target.subprocess,"run",side_effect=fake): + result=target.run(stage="rollback",node_path=deps,state_dir=root, + forward="tcp:127.0.0.1:45451-:25580") + self.assertNotIn("SECRET",str(result)) + self.assertEqual(result["stage"],"rollback") + self.assertFalse(result["stock_mojang_client_qualified"]) + + def test_absent_client_result_or_nonzero_exit_is_never_accepted(self): + with tempfile.TemporaryDirectory() as tmp: + root=Path(tmp); deps=root/"node_modules"; deps.mkdir() + with patch.object(target.subprocess,"run",return_value= + subprocess.CompletedProcess(["node"],1,"secret","trace")): + with self.assertRaisesRegex(target.Refused,"RUNTIME_REFUSED"): + target.run(stage="candidate",node_path=deps,state_dir=root, + forward="tcp:127.0.0.1:45451-:25580") + + +if __name__=="__main__": + unittest.main() diff --git a/tests/test_native_vanilla263_player_guest_receipt.py b/tests/test_native_vanilla263_player_guest_receipt.py new file mode 100644 index 000000000..eb2d8ba35 --- /dev/null +++ b/tests/test_native_vanilla263_player_guest_receipt.py @@ -0,0 +1,97 @@ +"""G4 source-only forged native-client visibility receipt negative tests. + +Never substitute this JSON unit fixture for live guest + backend + game proof. +""" +from __future__ import annotations +import copy +import unittest + +from tests.test_vanilla263_native_rollback_receipt import accepted_fixture +from scripts.assert_vanilla263_native_t6_receipt import check + +SCHEMA="supracraft.native-vanilla263-mineflayer-causal/1" + + +def player(stage): + return { + "schema":SCHEMA, + "classification":"CLIENT_CAUSAL_VISIBILITY_ONLY", + "stage":stage, + "client_protocol777_connected_and_spawned":True, + "offline_synthetic_player_caused_core_placement":True, + "client_saw_expected_datapack_effect":stage=="candidate", + "client_saw_expected_no_datapack_effect":stage=="rollback", + "native_guest_backend_independent_oracle_qualified":False, + "stock_mojang_client_qualified":False, + "online_account_qualified":False, + "collage_mutation_authorized":False, + "physical_true_nas_apply":"HOLD", + } + + +def guest(): + r=accepted_fixture() + p=r["foundry_materialization"] + p["native_player_profile"]={ + "schema":"minecraft-vanilla263-disposable-loopback-player/1", + "classification":"DISPOSABLE_OVERLAY_BOUND", + "rcon_enabled":False, + "client_online_account_qualified":False, + "physical_truenas_apply":"HOLD", + } + p["native_player_gameplay"]={ + "same_native_guest_client_causal_visibility":True, + "backend_independent_world_oracle_pass":False, + "collage_managed_deployment":False, + "physical_true_nas_apply":"HOLD", + "candidate":player("candidate"), + "rollback":player("rollback"), + } + return r + + +class PlayerGuestReceiptSourceTests(unittest.TestCase): + def admit(self, row): + return check(row,require_collage=True, + require_lantern_staged=True,require_native_rollback=True, + require_native_client_visibility=True) + + def test_synthetic_positive_preserves_false_claims(self): + self.admit(guest()) + + def test_client_cannot_skip_g2_g3(self): + with self.assertRaisesRegex(ValueError,"native client requires exact G2 and G3"): + check(guest(),require_collage=True,require_lantern_staged=True, + require_native_client_visibility=True) + + def test_missing_and_forged_client_result_refused(self): + for group,field,bad in ( + ("profile","rcon_enabled",True), + ("profile","client_online_account_qualified",True), + ("profile","physical_truenas_apply","APPLY"), + ("gameplay","same_native_guest_client_causal_visibility",False), + ("gameplay","backend_independent_world_oracle_pass",True), + ("gameplay","collage_managed_deployment",True), + ("client","stage","rollback"), + ("client","client_protocol777_connected_and_spawned",False), + ("client","offline_synthetic_player_caused_core_placement",False), + ("client","client_saw_expected_datapack_effect",False), + ("client","collage_mutation_authorized",True), + ("client","online_account_qualified",True), + ("client","native_guest_backend_independent_oracle_qualified",True), + ): + d=guest() + p=d["foundry_materialization"] + if group=="profile":p["native_player_profile"][field]=bad + elif group=="gameplay":p["native_player_gameplay"][field]=bad + else:p["native_player_gameplay"]["candidate"][field]=bad + with self.subTest(group=group,field=field): + with self.assertRaises(ValueError):self.admit(d) + d=guest() + del d["foundry_materialization"]["native_player_gameplay"]["rollback"] + with self.assertRaisesRegex(ValueError,"native offline client causal stage missing"): + self.admit(d) + + +if __name__=="__main__": + unittest.main() diff --git a/tests/test_proxmox_rdte_target.py b/tests/test_proxmox_rdte_target.py new file mode 100644 index 000000000..f111dfd32 --- /dev/null +++ b/tests/test_proxmox_rdte_target.py @@ -0,0 +1,52 @@ +#!/usr/bin/env python3 +import importlib.util, json, pathlib, unittest + +ROOT=pathlib.Path(__file__).resolve().parents[1] +SPEC=importlib.util.spec_from_file_location("proxmox_rdte_target",ROOT/"scripts"/"proxmox_rdte_target.py") +MOD=importlib.util.module_from_spec(SPEC); assert SPEC.loader; SPEC.loader.exec_module(MOD) +REG=ROOT/"config"/"proxmox-rdte-targets.json" +RUN_REQUEST=ROOT/"config"/"proxmox-rdte-run-request.json" + +class ProxmoxTargetRegistryTests(unittest.TestCase): + def setUp(self): + self.targets=MOD.load_registry(REG) + + def test_current_runtime_matrix_is_exactly_one_target(self): + self.assertEqual(set(self.targets),{"9.2-1"}) + self.assertTrue(self.targets["9.2-1"]["runtime_admitted"]) + + def test_exact_source_and_media_anchors(self): + t=self.targets["9.2-1"] + self.assertEqual(t["iso_sha256"],"4e88fe416df9b527624a175f24c9aa07c714d3332afb1ee3dbf3879573ef2c6c") + self.assertEqual(t["installer_source_commit"],"32afcd4cd534d8e2f99ae76aa0234a0a5c697ba9") + self.assertEqual(t["pve_manager_source_commit"],"b9984c6d90a4bd80ab72dc2088c1b9103fb167b1") + self.assertEqual(t["pve_container_source_commit"],"5eb5574ee9158ac40a5230de2cf18d7d6345709f") + self.assertEqual(t["pve_qemu_source_commit"],"684796e835289dab11af8606fbf7358b93526dd6") + self.assertEqual(t["container_fixture"]["template_sha512"],"5aec4ab2ac5c16c7c8ecb87bfeeb10213abe96db6b85e2463585cea492fc861d7c390b3f9c95629bf690b95e9dfe1037207fc69c0912429605f208d5cb2621f8") + self.assertEqual(t["container_fixture"]["template_url"],"http://download.proxmox.com/images/system/debian-13-standard_13.1-2_amd64.tar.zst") + self.assertEqual(t["container_fixture"]["catalog_source"]["blob_sha"],"433a8ef4bf78d606508c6bae162c49e9ef0ecfd3") + self.assertEqual(t["container_fixture"]["catalog_source"]["base_url"],"http://download.proxmox.com/images") + + def test_vm_source_profile_is_exact_but_runtime_unqualified(self): + t=self.targets["9.2-1"] + self.assertEqual(t["vm_source_status"],"SOURCE_BOUND_RUNTIME_NOT_YET_QUALIFIED") + self.assertEqual(t["qemu_server_source"]["package_version"],"9.1.15") + self.assertEqual(t["qemu_server_source"]["commit"],"6785065b3f766f15f6f151af8ec27ec8bb5b07ab") + self.assertEqual(t["qemu_server_source"]["api2_qemu_blob"],"e029a204d121f3c8b104457ef14eb6d5ce029464") + + def test_request_is_registered_runtime_target(self): + request=json.loads(RUN_REQUEST.read_text(encoding="utf-8")) + self.assertEqual(request["schema"],"gha-kvm-proxmox-run-request/v1") + self.assertIn(request["version"],self.targets) + target=self.targets[request["version"]] + self.assertTrue(target["runtime_admitted"]) + self.assertEqual(request["authority_issue"],target["authority_issue"]) + self.assertIn(request.get("compute_fixture","none"),{"none","container-c0","vm-v0"}) + + def test_unknown_target_fails_closed(self): + with self.assertRaises(MOD.TargetError): + if "9.3-1" not in self.targets: + raise MOD.TargetError("exact Proxmox target is not registered: 9.3-1") + +if __name__=="__main__": + unittest.main() diff --git a/tests/test_proxmox_rest_compute_probe.py b/tests/test_proxmox_rest_compute_probe.py new file mode 100644 index 000000000..ec801e40b --- /dev/null +++ b/tests/test_proxmox_rest_compute_probe.py @@ -0,0 +1,78 @@ +import pathlib +import unittest +from scripts.proxmox_rest_compute_probe import ( + PVE_CONTAINER_SOURCE, PVE_MANAGER_API_SOURCE, PVE_QEMU_PACKAGE_SOURCE, PVE_QEMU_SERVER_SOURCE, + ProxmoxProbeError, PveApi, Response, lxc_create_fields, plan_only, vm_create_fields, wait_task +) + +class ProxmoxRestComputeContractTests(unittest.TestCase): + def test_exact_container_source_is_bound(self): + self.assertEqual(PVE_CONTAINER_SOURCE["commit"],"5eb5574ee9158ac40a5230de2cf18d7d6345709f") + self.assertEqual(PVE_CONTAINER_SOURCE["package_version"],"6.1.10") + self.assertEqual(PVE_QEMU_PACKAGE_SOURCE["commit"],"684796e835289dab11af8606fbf7358b93526dd6") + self.assertEqual(PVE_MANAGER_API_SOURCE["commit"],"b9984c6d90a4bd80ab72dc2088c1b9103fb167b1") + self.assertEqual(PVE_QEMU_SERVER_SOURCE["commit"],"6785065b3f766f15f6f151af8ec27ec8bb5b07ab") + self.assertEqual(PVE_QEMU_SERVER_SOURCE["package_version"],"9.1.15") + self.assertEqual(PVE_QEMU_SERVER_SOURCE["api2_qemu_blob"],"e029a204d121f3c8b104457ef14eb6d5ce029464") + self.assertEqual(PVE_MANAGER_API_SOURCE["apt_api_blob"],"9cb6e473436719f3024ac09fffaad8faf0d7160d") + + def test_lxc_plan_uses_rest_and_unprivileged_container(self): + p=plan_only("container",9101,"local:vztmpl/debian.tar.zst") + self.assertEqual(p["create_path"],"/nodes/{node}/lxc") + self.assertEqual(p["create_fields"]["unprivileged"],1) + self.assertEqual(p["create_fields"]["start"],0) + self.assertNotIn("pct",str(p)) + + def test_vm_plan_uses_rest_and_native_vm_shape(self): + p=plan_only("vm",9201,None) + self.assertEqual(p["create_path"],"/nodes/{node}/qemu") + self.assertEqual(p["create_fields"]["bios"],"ovmf") + self.assertEqual(p["create_fields"]["machine"],"q35") + self.assertEqual(p["create_fields"]["cpu"],"host") + self.assertNotIn("qm ",str(p)) + + def test_container_requires_exact_template_identity(self): + with self.assertRaises(ProxmoxProbeError): + plan_only("container",9101,None) + + def test_vm_apply_source_gate_is_exact_package_bound(self): + text=pathlib.Path("scripts/proxmox_rest_compute_probe.py").read_text(encoding="utf-8") + self.assertIn('a.expected_qemu_server_version != PVE_QEMU_SERVER_SOURCE["package_version"]',text) + self.assertIn('"qemu_server":PVE_QEMU_SERVER_SOURCE',text) + + def test_vm_payload_does_not_smuggle_media_or_key(self): + f=vm_create_fields(9201) + self.assertNotIn("ide2",f) + self.assertNotIn("password",f) + self.assertNotIn("ciuser",f) + + def test_wait_task_accepts_proxmox_warning_terminal_state(self): + class FakeApi: + def get(self, path): + return {"status":"stopped","exitstatus":"WARNINGS: 1","upid":"UPID:test"} + result=wait_task(FakeApi(),"pve-rdte","UPID:test",timeout=0.1) + self.assertEqual(result["exitstatus"],"WARNINGS: 1") + + def test_delete_options_are_query_parameters_not_request_body(self): + api=PveApi("https://example.invalid","root@pam","unused") + captured={} + def fake_request(method,path,fields=None): + captured.update(method=method,path=path,fields=fields) + return Response(200,"UPID:test") + api._request=fake_request + result=api.delete("/nodes/pve/lxc/9101",{"purge":1}) + self.assertEqual(result,"UPID:test") + self.assertEqual(captured["method"],"DELETE") + self.assertEqual(captured["path"],"/nodes/pve/lxc/9101?purge=1") + self.assertIsNone(captured["fields"]) + + def test_apply_failure_path_has_owned_resource_cleanup(self): + text=pathlib.Path("scripts/proxmox_rest_compute_probe.py").read_text(encoding="utf-8") + self.assertIn("ownership_claimed=True",text) + self.assertIn('failure_cleanup_stop',text) + self.assertIn('failure_cleanup_delete',text) + self.assertIn('{"purge":1}',text) + self.assertIn('receipt["cleanup"]["absent"]',text) + +if __name__=="__main__": + unittest.main() diff --git a/tests/test_truenas_compute_container_probe.py b/tests/test_truenas_compute_container_probe.py new file mode 100644 index 000000000..ef132a3d7 --- /dev/null +++ b/tests/test_truenas_compute_container_probe.py @@ -0,0 +1,118 @@ +import unittest + +from scripts.truenas_compute_container_probe import ( + ProbeError, + build_create_payload, + method_shapes, + normalize_system_version, + observed_state, + resolve_image_identity, + update_payload, +) + + +class ContainerProbeContractTests(unittest.TestCase): + def test_version_normalization(self): + self.assertEqual(normalize_system_version("TrueNAS-25.04.1"), "25.04.1") + self.assertEqual(normalize_system_version("26.0.0-BETA.3"), "26.0.0-BETA.3") + + def test_legacy_create_is_native_incus_container(self): + payload = build_create_payload( + "truenas-virt-incus-container", + "rdte-c0", + "nonce", + legacy_image="ubuntu/24.04", + ) + self.assertEqual(payload["instance_type"], "CONTAINER") + self.assertEqual(payload["source_type"], "IMAGE") + self.assertFalse(payload["autostart"]) + self.assertEqual(payload["environment"]["RDTE_NONCE"], "nonce") + + def test_lxc_create_uses_public_image_tuple_and_safe_idmap(self): + payload = build_create_payload( + "truenas-container-lxc", + "rdte-c0", + "nonce", + image_name="ubuntu", + image_version="24.04", + ) + self.assertEqual(payload["image"], {"name": "ubuntu", "version": "24.04"}) + self.assertEqual(payload["idmap"], {"type": "DEFAULT"}) + self.assertEqual(payload["capabilities_policy"], "DEFAULT") + self.assertFalse(payload["autostart"]) + + def test_missing_image_identity_fails(self): + with self.assertRaises(ProbeError): + build_create_payload("truenas-virt-incus-container", "x", "n") + with self.assertRaises(ProbeError): + build_create_payload("truenas-container-lxc", "x", "n", image_name="ubuntu") + + def test_update_generation_is_adapter_specific(self): + self.assertEqual( + update_payload("truenas-virt-incus-container", "n")["environment"]["RDTE_GENERATION"], + "2", + ) + self.assertEqual( + update_payload("truenas-container-lxc", "n")["initenv"]["RDTE_GENERATION"], + "2", + ) + + def test_legacy_image_resolution_requires_exact_admitted_alias(self): + adapter = { + "id": "truenas-virt-incus-container", + "image_discovery": { + "method": "virt.instance.image_choices", + "request": {"remote": "LINUX_CONTAINERS"}, + "source": "https://images.linuxcontainers.org", + "selection": {"alias": "debian/trixie", "instance_type": "CONTAINER", "arch": "amd64"}, + }, + } + def call(method, params): + self.assertEqual(method, "virt.instance.image_choices") + self.assertEqual(params, [{"remote": "LINUX_CONTAINERS"}]) + return {"debian/trixie": {"instance_types": ["CONTAINER", "VM"], "archs": ["amd64"]}} + resolved, evidence = resolve_image_identity(adapter, call) + self.assertEqual(resolved, {"legacy_image": "debian/trixie"}) + self.assertEqual(evidence["selected"]["alias"], "debian/trixie") + + def test_lxc_image_resolution_retains_exact_returned_version(self): + adapter = { + "id": "truenas-container-lxc", + "image_discovery": { + "method": "container.image.query_registry", + "source": "https://images.sys.truenas.net/streams", + "selection": {"name": "ubuntu:noble:amd64:default", "version_policy": "last-returned-exact"}, + }, + } + def call(method, params): + self.assertEqual(method, "container.image.query_registry") + self.assertEqual(params, []) + return [{ + "name": "ubuntu:noble:amd64:default", + "versions": [{"version": "20261001"}, {"version": "20261005"}], + }] + resolved, evidence = resolve_image_identity(adapter, call) + self.assertEqual(resolved, { + "image_name": "ubuntu:noble:amd64:default", + "image_version": "20261005", + }) + self.assertEqual(evidence["selected"]["version"], "20261005") + + def test_state_normalizes_legacy_and_lxc(self): + self.assertEqual(observed_state({"status": "RUNNING"}), "RUNNING") + self.assertEqual(observed_state({"status": {"state": "RUNNING"}}), "RUNNING") + self.assertEqual(observed_state(None), "ABSENT") + + def test_method_shapes_never_use_private_shell(self): + legacy = method_shapes("truenas-virt-incus-container", "x") + self.assertEqual(legacy["query"][0], "virt.instance.query") + self.assertNotIn("get_shell", {v[0] for v in legacy.values()}) + lxc = method_shapes("truenas-container-lxc", "x", 7) + self.assertEqual(lxc["start"][0], "container.start") + self.assertEqual(lxc["stop"], ("container.stop", [7, {"force_after_timeout": True}], True)) + self.assertNotIn("timeout", lxc["stop"][1][1]) + self.assertNotIn("nsenter", {v[0] for v in lxc.values()}) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_truenas_compute_vm_probe.py b/tests/test_truenas_compute_vm_probe.py new file mode 100644 index 000000000..486635061 --- /dev/null +++ b/tests/test_truenas_compute_vm_probe.py @@ -0,0 +1,100 @@ +import unittest + +from scripts.truenas_compute_vm_probe import ( + ProbeError, + legacy_owned_nic, + legacy_vm_create_payload, + native_vm_create_payload, + normalize_system_version, + owned_zvol_device, + validate_modern_preconditions, + validate_public_modern_preconditions, + zvol_device_path, +) + + +class VmProbeContractTests(unittest.TestCase): + def test_version_normalization(self): + self.assertEqual(normalize_system_version("TrueNAS-25.10.7"),"25.10.7") + + def test_native_vm_defaults_are_stopped_safe_contract(self): + p=native_vm_create_payload("rdtecomputevmv0") + self.assertEqual(p["name"],"rdtecomputevmv0") + self.assertFalse(p["autostart"]) + self.assertFalse(p["ensure_display_device"]) + self.assertEqual(p["bootloader"],"UEFI") + self.assertFalse(p["trusted_platform_module"]) + self.assertFalse(p["enable_secure_boot"]) + + def test_native_vm_name_matches_exact_trueNAS_contract(self): + self.assertEqual(native_vm_create_payload("rdte_vm_v0")["name"], "rdte_vm_v0") + for invalid in ("rdte-v0", "rdte vm v0", "rdte/v0"): + with self.assertRaises(ProbeError): + native_vm_create_payload(invalid) + + def test_legacy_vm_uses_vm_instance_type(self): + p=legacy_vm_create_payload("rdte-v0","ubuntu/24.04") + self.assertEqual(p["instance_type"],"VM") + self.assertEqual(p["source_type"],"IMAGE") + self.assertFalse(p["autostart"]) + + def test_legacy_vm_requires_image(self): + with self.assertRaises(ProbeError): + legacy_vm_create_payload("rdte-v0","") + + def test_owned_zvol_device_is_explicit_and_nonbooting(self): + d=owned_zvol_device(7,"rdtepool/rdte-compute-vm-v0",2*1024*1024*1024) + self.assertEqual(d["vm"],7) + self.assertTrue(d["attributes"]["create_zvol"]) + self.assertEqual(d["attributes"]["zvol_name"],"rdtepool/rdte-compute-vm-v0") + self.assertNotIn("boot", d["attributes"]) + self.assertEqual(d["order"],1000) + + def test_zvol_requires_owned_parent_name(self): + with self.assertRaises(ProbeError): + owned_zvol_device(7,"bare-name",1024) + + def test_zvol_readback_path_is_normalized(self): + self.assertEqual( + zvol_device_path("rdtepool/rdte vm v0"), + "/dev/zvol/rdtepool/rdte+vm+v0", + ) + + def test_legacy_owned_nic_uses_observed_parent(self): + d=legacy_owned_nic("rdte-v0-nic","enp1s0") + self.assertEqual(d["name"],"rdte-v0-nic") + self.assertEqual(d["dev_type"],"NIC") + self.assertEqual(d["nic_type"],"MACVLAN") + self.assertEqual(d["parent"],"enp1s0") + + def test_modern_preconditions_require_kvm_and_entitlement(self): + ok=validate_modern_preconditions({"supported":True,"error":None},True) + self.assertTrue(ok["license_active"]) + with self.assertRaises(ProbeError): + validate_modern_preconditions({"supported":False,"error":"no kvm"},True) + with self.assertRaises(ProbeError): + validate_modern_preconditions({"supported":True,"error":None},False) + with self.assertRaises(ProbeError): + validate_modern_preconditions({},True) + + def test_beta3_public_entitlement_path_never_needs_private_license_method(self): + community=validate_public_modern_preconditions( + {"supported":True,"error":None},"COMMUNITY_EDITION",None + ) + self.assertEqual(community["product_type"],"COMMUNITY_EDITION") + enterprise=validate_public_modern_preconditions( + {"supported":True,"error":None},"ENTERPRISE",True + ) + self.assertTrue(enterprise["enterprise_vm_feature_enabled"]) + with self.assertRaises(ProbeError): + validate_public_modern_preconditions( + {"supported":True,"error":None},"ENTERPRISE",False + ) + with self.assertRaises(ProbeError): + validate_public_modern_preconditions( + {"supported":False,"error":"no kvm"},"COMMUNITY_EDITION",None + ) + + +if __name__=="__main__": + unittest.main() diff --git a/tests/test_truenas_compute_vm_v1_probe.py b/tests/test_truenas_compute_vm_v1_probe.py new file mode 100644 index 000000000..c993228f8 --- /dev/null +++ b/tests/test_truenas_compute_vm_v1_probe.py @@ -0,0 +1,67 @@ +import unittest + +from scripts.truenas_compute_vm_v1_probe import ( + PINNED_CIRROS_SHA256, + multipart_upload_body, + plan, + zvol_boot_device, + seed_cdrom_device, + stage_paths, +) + + +class TrueNASVmV1ProbeTests(unittest.TestCase): + def test_pinned_cirros_digest(self): + self.assertEqual( + PINNED_CIRROS_SHA256, + "7d6355852aeb6dbcd191bcda7cd74f1536cfe5cbf8a10495a7283a8396e4b75b", + ) + + def test_stage_paths_are_owned_under_pool(self): + paths = stage_paths("rdtepool") + self.assertEqual(paths["dataset"], "rdtepool/rdtev1stage") + self.assertTrue(paths["raw_image"].startswith("/mnt/rdtepool/rdtev1stage/")) + self.assertTrue(paths["seed_iso"].endswith("/seed.iso")) + self.assertEqual(paths["boot_zvol"], "rdtepool/rdtev1boot") + self.assertEqual(paths["boot_zvol_path"], "/dev/zvol/rdtepool/rdtev1boot") + + def test_devices_use_owned_zvol_disk_and_public_cdrom_shapes(self): + boot = zvol_boot_device(7, "rdtepool/rdtev1boot") + self.assertEqual(boot["attributes"]["dtype"], "DISK") + self.assertIsNone(boot["attributes"]["path"]) + self.assertTrue(boot["attributes"]["create_zvol"]) + self.assertEqual(boot["attributes"]["zvol_name"], "rdtepool/rdtev1boot") + self.assertEqual(boot["attributes"]["zvol_volsize"], 1024 * 1024 * 1024) + self.assertEqual(boot["attributes"]["type"], "VIRTIO") + self.assertEqual(boot["order"], 100) + cd = seed_cdrom_device(7, "/mnt/rdtepool/rdtev1stage/seed.iso") + self.assertEqual(cd["attributes"], {"dtype": "CDROM", "path": "/mnt/rdtepool/rdtev1stage/seed.iso"}) + self.assertEqual(cd["order"], 1000) + + def test_plan_keeps_v1_claim_narrow(self): + p = plan("rdtepool", "rdtecomputevmv1", "rep001nonceABCDEF12") + self.assertEqual(p["schema"], "truenas-compute-vm-v1-plan/v1") + self.assertEqual(p["oracle"]["surface"], "/websocket/shell") + self.assertEqual(p["devices"][0]["attributes"]["dtype"], "DISK") + self.assertEqual(p["media_lowering"]["method"], "vm.device.convert") + self.assertEqual(p["media_lowering"]["source"], p["staging"]["raw_image"]) + self.assertEqual(p["media_lowering"]["destination"], p["staging"]["boot_zvol_path"]) + self.assertNotIn('"dtype": "RAW"', str(p)) + self.assertNotIn("firecracker", str(p).lower()) + self.assertNotIn("nested-kvm", str(p).lower()) + + def test_upload_matches_trueNAS_filesystem_put_form(self): + body = multipart_upload_body( + "/mnt/rdtepool/rdtev1stage/seed.iso", + "seed.iso", + b"abc", + "boundary", + ) + self.assertIn(b'"method":"filesystem.put"', body) + self.assertIn(b'"params":["/mnt/rdtepool/rdtev1stage/seed.iso"]', body) + self.assertIn(b'name="file"; filename="seed.iso"', body) + self.assertTrue(body.endswith(b"--boundary--\r\n")) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_truenas_existing_probe_capsule_executor.py b/tests/test_truenas_existing_probe_capsule_executor.py new file mode 100644 index 000000000..dc9df691c --- /dev/null +++ b/tests/test_truenas_existing_probe_capsule_executor.py @@ -0,0 +1,303 @@ +#!/usr/bin/env python3 +import json +import pathlib +import sys +import stat +import tempfile +import unittest + +ROOT = pathlib.Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "scripts")) + +import truenas_existing_probe_capsule_executor as exe +import truenas_session_manifest as manifest_mod +import truenas_session_runner as session_runner +from truenas_session_manifest import SessionError + + +class ExistingProbeExecutorTests(unittest.TestCase): + def temp_root(self): + td = tempfile.TemporaryDirectory() + self.addCleanup(td.cleanup) + return pathlib.Path(td.name) + + def fixture(self, provider_id): + root = self.temp_root() + probe = root / "probe.py" + probe.write_text("#!/usr/bin/env python3\n", encoding="utf-8") + password = root / "password" + password.write_text("test\n", encoding="utf-8") + control = root / "control" + control.mkdir() + oracle = root / "minecraft_java_status_oracle.py" + oracle.write_text("# pinned synthetic oracle\n", encoding="utf-8") + ports = { + "litellm-t6": {"service": 18100}, + "wow-sidecar-t6": {}, + "garm-t6": {"service": 18101}, + "official-catalog-t6": {}, + "minecraft-paper-t6": {"status": 18105}, + "foliorelay-t6": {"control": 18102, "ipp": 18103, "observer": 18104}, + }[provider_id] + descriptor = { + "schema": "truenas-capsule-dispatch/v1", + "manifest_sha256": "a" * 64, + "capsule": { + "id": "capsule-one", + "provider": provider_id, + "mutating": True, + "cleanup_required": True, + }, + "provider": { + "id": provider_id, + "probe": str(probe), + }, + } + context = { + "schema": "truenas-capsule-context/v1", + "host": "127.0.0.1", + "middleware_port": 18000, + "password_file": str(password), + "tls": True, + "providers": { + provider_id: { + "control_dir": str(control), + "foundry_commit": "1" * 40, + "ports": ports, + **({"status_oracle": str(oracle), + "expected_system_version": "TrueNAS-25.04.1"} if provider_id == "minecraft-paper-t6" else {}), + } + }, + } + return root, descriptor, context + + def test_existing_provider_commands_preserve_current_probe_arguments(self): + expected = { + "litellm-t6": ["--service-port", "18100", "--state-timeout", "240"], + "wow-sidecar-t6": ["--state-timeout", "240"], + "garm-t6": ["--service-port", "18101", "--state-timeout", "240"], + "official-catalog-t6": ["--state-timeout", "240"], + "minecraft-paper-t6": [ + "--status-port", "18105", "--status-oracle", + "--expected-system-version", "TrueNAS-25.04.1", + "--job-timeout", "600", "--state-timeout", "480" + ], + "foliorelay-t6": [ + "--control-port", "18102", + "--ipp-port", "18103", + "--observer-port", "18104", + "--state-timeout", "300", + ], + } + for provider_id, required in expected.items(): + with self.subTest(provider=provider_id): + root, descriptor, context = self.fixture(provider_id) + command = exe.build_probe_command( + descriptor, context, root / "provider-receipt.json" + ) + self.assertEqual(command[0], sys.executable) + self.assertIn("--host", command) + self.assertIn("127.0.0.1", command) + self.assertIn("--port", command) + self.assertIn("18000", command) + self.assertIn("--tls", command) + self.assertIn("--password-file", command) + self.assertIn("--control-dir", command) + self.assertIn("--foundry-commit", command) + for token in required: + self.assertIn(token, command) + + def test_supported_provider_receipt_maps_to_clean_supported_capsule(self): + _, descriptor, _ = self.fixture("foliorelay-t6") + receipt = exe.wrap_receipt( + descriptor, + { + "classification": "SUPPORTED", + "oracleSatisfied": True, + "detail": "passed", + }, + 0, + "", + ) + self.assertEqual(receipt["verdict"], "SUPPORTED") + self.assertTrue(receipt["cleanup_satisfied"]) + self.assertTrue(receipt["platform_healthy"]) + self.assertTrue(receipt["authority_satisfied"]) + self.assertTrue(receipt["resource_guardrail_satisfied"]) + + def test_failed_provider_receipt_stops_fail_closed_until_membrane_exists(self): + _, descriptor, _ = self.fixture("foliorelay-t6") + receipt = exe.wrap_receipt( + descriptor, + { + "classification": "ORACLE_FAILURE", + "oracleSatisfied": False, + "detail": "product oracle failed", + }, + 0, + "", + ) + self.assertEqual(receipt["verdict"], "ORACLE_FAILURE") + self.assertFalse(receipt["cleanup_satisfied"]) + self.assertFalse(receipt["platform_healthy"]) + + def test_minecraft_oracle_failure_with_verified_cleanup_is_not_contamination(self): + _, descriptor, _ = self.fixture("minecraft-paper-t6") + receipt = exe.wrap_receipt(descriptor, { + "classification": "ORACLE_FAILURE", + "oracleSatisfied": False, + "detail": "app.create job failed", + "post_failure_platform_healthy": True, + "cleanup": {"attempted": True, "zero_residue": True, + "app_absent": True, "owned_world_dataset_absent": True} + }, 2, "") + self.assertEqual(receipt["verdict"], "ORACLE_FAILURE") + self.assertTrue(receipt["cleanup_satisfied"]) + self.assertTrue(receipt["platform_healthy"]) + + def test_minecraft_partial_cleanup_still_stops_mutation(self): + _, descriptor, _ = self.fixture("minecraft-paper-t6") + receipt = exe.wrap_receipt(descriptor, { + "classification": "ORACLE_FAILURE", "oracleSatisfied": False, + "post_failure_platform_healthy": True, + "cleanup": {"attempted": True, "zero_residue": False, + "app_absent": True, "owned_world_dataset_absent": False} + }, 2, "") + self.assertFalse(receipt["cleanup_satisfied"]) + self.assertFalse(receipt["platform_healthy"]) + + def test_missing_provider_receipt_is_harness_failure(self): + _, descriptor, _ = self.fixture("official-catalog-t6") + receipt = exe.wrap_receipt(descriptor, None, 7, "crashed") + self.assertEqual(receipt["verdict"], "HARNESS_FAILURE") + self.assertFalse(receipt["cleanup_satisfied"]) + self.assertFalse(receipt["platform_healthy"]) + self.assertIn("crashed", receipt["reason"]) + + def test_session_runner_executes_one_existing_probe_capsule_end_to_end(self): + root = self.temp_root() + probe = root / "fake_probe.py" + probe.write_text( + """#!/usr/bin/env python3 +import argparse, json, pathlib +p=argparse.ArgumentParser() +p.add_argument("--out", required=True) +p.add_argument("--host"); p.add_argument("--port"); p.add_argument("--password-file") +p.add_argument("--control-dir"); p.add_argument("--foundry-commit") +p.add_argument("--control-port"); p.add_argument("--ipp-port"); p.add_argument("--observer-port") +p.add_argument("--timeout"); p.add_argument("--job-timeout"); p.add_argument("--state-timeout") +p.add_argument("--tls", action="store_true") +a=p.parse_args() +pathlib.Path(a.out).write_text(json.dumps({ + "classification":"SUPPORTED", + "oracleSatisfied":True, + "detail":"synthetic accepted existing-probe receipt" +})+"\\n") +""", + encoding="utf-8", + ) + password = root / "password" + password.write_text("test\n", encoding="utf-8") + control = root / "control" + control.mkdir() + + targets = root / "targets.json" + targets.write_text(json.dumps({ + "schema":"gha-kvm-truenas-targets/v1", + "targets":[{"version":"26.0.0-BETA.3"}], + })+"\n", encoding="utf-8") + + providers = root / "providers.json" + providers.write_text(json.dumps({ + "schema":"truenas-capsule-providers/v1", + "providers":[{ + "id":"foliorelay-t6", + "kind":"foundry-product", + "product":"foliorelay", + "probe":str(probe), + "control_artifact":"foliorelay-t6-beta3-control", + "existing_selector":"foliorelay", + }], + })+"\n", encoding="utf-8") + + capsule = { + "schema":"truenas-capsule/v1", + "id":"foliorelay-one", + "kind":"foundry-product", + "provider":"foliorelay-t6", + "authority":{"repository":"SemperSupra/folio-relay","issue":29}, + "exact":{"source_ref":"1"*40}, + "namespace":"rdte-foliorelay-one", + "ports":[18102,18103,18104], + "requirements":["apps","zfs-pool"], + "resources":{"memory_mib":1024,"download_mib":64,"estimated_minutes":2}, + "phases":{"setup":True,"apply":True,"verify":True,"cleanup":True}, + "oracle":"existing FolioRelay T6 oracle", + "receipt_contract":"truenas-capsule-execution/v1", + "retry_policy":"read-only-reconcile", + "mutating":True, + "cleanup_required":True, + "dependencies":[], + } + manifest = { + "schema":"truenas-session/v1", + "session_id":"tn-26-beta3-single-equivalence-001", + "version":"26.0.0-BETA.3", + "authority":{"repository":"SemperSupra/agent-dispatch-private","issue":480}, + "budget":{"guest_memory_mib":8192,"download_mib":128,"timeout_minutes":10}, + "pool":{"name":"rdtepool","data_disks":2}, + "capsules":[capsule], + } + manifest["manifest_sha256"] = manifest_mod.manifest_digest(manifest) + manifest_path = root / "manifest.json" + manifest_path.write_text(json.dumps(manifest)+"\n", encoding="utf-8") + + context = root / "context.json" + context.write_text(json.dumps({ + "schema":"truenas-capsule-context/v1", + "host":"127.0.0.1", + "middleware_port":18000, + "password_file":str(password), + "tls":False, + "providers":{ + "foliorelay-t6":{ + "control_dir":str(control), + "foundry_commit":"1"*40, + "ports":{"control":18102,"ipp":18103,"observer":18104}, + } + }, + })+"\n", encoding="utf-8") + + wrapper = root / "executor" + executor_script = ROOT / "scripts" / "truenas_existing_probe_capsule_executor.py" + wrapper.write_text( + f"#!/bin/sh\nexec {sys.executable} {executor_script} \"$@\"\n", + encoding="utf-8", + ) + wrapper.chmod(wrapper.stat().st_mode | stat.S_IXUSR) + + result = session_runner.run_session( + manifest_path, + targets, + providers, + wrapper, + context, + root / "receipts", + ) + self.assertEqual(result["classification"], "SESSION_CLEAN") + self.assertTrue(result["session_clean"]) + self.assertFalse(result["product_acceptance_inferred"]) + self.assertEqual(len(result["capsules"]), 1) + self.assertEqual(result["capsules"][0]["verdict"], "SUPPORTED") + self.assertTrue(result["capsules"][0]["cleanup_satisfied"]) + self.assertTrue(result["capsules"][0]["platform_healthy"]) + + def test_context_requires_exact_provider_binding(self): + root, descriptor, context = self.fixture("garm-t6") + context["providers"] = {} + with self.assertRaisesRegex(SessionError, "no provider binding"): + exe.build_probe_command(descriptor, context, root / "receipt.json") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_truenas_future_protocols.py b/tests/test_truenas_future_protocols.py new file mode 100644 index 000000000..96f38dae4 --- /dev/null +++ b/tests/test_truenas_future_protocols.py @@ -0,0 +1,603 @@ +import base64 +import hashlib +import json +import pathlib +import socket +import struct +import subprocess +import sys +import tempfile +import threading +import unittest + +ROOT = pathlib.Path(__file__).resolve().parents[1] +INSTALL = ROOT / "scripts" / "truenas_installer_rpc_install.py" +MIDDLEWARE = ROOT / "scripts" / "truenas_middleware_ddp_probe.py" +POOL = ROOT / "scripts" / "truenas_middleware_pool_probe.py" +LIFECYCLE = ROOT / "scripts" / "truenas_middleware_app_lifecycle_probe.py" + + +class SyntheticWebSocketPeer: + def __init__(self): + self.listener = socket.socket() + self.listener.bind(("127.0.0.1", 0)) + self.listener.listen(1) + self.port = self.listener.getsockname()[1] + + @staticmethod + def read_exact(conn, count): + chunks = [] + while count: + part = conn.recv(count) + if not part: + raise EOFError("peer closed") + chunks.append(part) + count -= len(part) + return b"".join(chunks) + + @classmethod + def recv_json(cls, conn): + b1, b2 = cls.read_exact(conn, 2) + opcode = b1 & 0x0F + if opcode == 0x8: + return None + if opcode != 0x1: + raise AssertionError(f"unexpected opcode {opcode}") + if not (b2 & 0x80): + raise AssertionError("client frame must be masked") + size = b2 & 0x7F + if size == 126: + size = struct.unpack("!H", cls.read_exact(conn, 2))[0] + elif size == 127: + size = struct.unpack("!Q", cls.read_exact(conn, 8))[0] + mask = cls.read_exact(conn, 4) + payload = cls.read_exact(conn, size) + payload = bytes(b ^ mask[i % 4] for i, b in enumerate(payload)) + return json.loads(payload.decode()) + + @staticmethod + def send_json(conn, value): + payload = json.dumps(value, separators=(",", ":")).encode() + if len(payload) < 126: + header = bytes([0x81, len(payload)]) + else: + header = bytes([0x81, 126]) + struct.pack("!H", len(payload)) + conn.sendall(header + payload) + + def accept_websocket(self): + conn, _ = self.listener.accept() + request = bytearray() + while b"\r\n\r\n" not in request: + request.extend(conn.recv(4096)) + headers = {} + for line in request.decode().split("\r\n")[1:]: + if ":" in line: + key, value = line.split(":", 1) + headers[key.lower().strip()] = value.strip() + key = headers["sec-websocket-key"] + accept = base64.b64encode( + hashlib.sha1( + (key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11").encode() + ).digest() + ).decode() + conn.sendall( + ( + "HTTP/1.1 101 Switching Protocols\r\n" + "Upgrade: websocket\r\n" + "Connection: Upgrade\r\n" + f"Sec-WebSocket-Accept: {accept}\r\n\r\n" + ).encode() + ) + return conn + + +class FutureTrueNASProtocolTests(unittest.TestCase): + def test_installer_mutation_is_exact_and_progress_is_retained(self): + peer = SyntheticWebSocketPeer() + errors = [] + password = "synthetic-ephemeral-9264" + + def server(): + try: + with peer.accept_websocket() as conn: + expected = [ + ("is_adopted", False), + ("list_disks", [{ + "name": "vda", + "size": 24_000_000_000, + "model": "QEMU", + "label": "", + "removable": False, + }]), + ("list_network_interfaces", [{"name": "ens3"}]), + ] + for method, result in expected: + req = peer.recv_json(conn) + self.assertEqual(req["method"], method) + peer.send_json(conn, { + "jsonrpc": "2.0", + "id": req["id"], + "result": result, + }) + + req = peer.recv_json(conn) + self.assertEqual(req["method"], "install") + self.assertIsInstance(req["params"], list) + self.assertEqual(len(req["params"]), 1) + params = req["params"][0] + self.assertEqual(params["disks"], ["vda"]) + self.assertTrue(params["set_pmbr"]) + self.assertEqual(params["authentication"]["username"], "truenas_admin") + self.assertEqual(params["authentication"]["password"], password) + self.assertEqual( + params["post_install"]["network_interfaces"], + [{"name": "ens3", "ipv4_dhcp": True, "ipv6_auto": True}], + ) + peer.send_json(conn, { + "jsonrpc": "2.0", + "method": "installation_progress", + "params": [{"progress": 0.5, "message": "Installing"}], + }) + peer.send_json(conn, { + "jsonrpc": "2.0", + "id": req["id"], + "result": None, + }) + except Exception as exc: + errors.append(exc) + finally: + peer.listener.close() + + thread = threading.Thread(target=server, daemon=True) + thread.start() + with tempfile.TemporaryDirectory() as td: + td = pathlib.Path(td) + password_file = td / "password" + password_file.write_text(password, encoding="utf-8") + out = td / "install.json" + cp = subprocess.run( + [ + sys.executable, + str(INSTALL), + "--port", str(peer.port), + "--password-file", str(password_file), + "--out", str(out), + "--timeout", "2", + ], + text=True, + capture_output=True, + timeout=10, + ) + self.assertEqual(cp.returncode, 0, cp.stderr) + receipt = json.loads(out.read_text(encoding="utf-8")) + self.assertTrue(receipt["oracleSatisfied"]) + self.assertEqual(receipt["selected_disk"], "vda") + self.assertEqual(receipt["selected_interface"], "ens3") + self.assertEqual(receipt["progress"][0]["message"], "Installing") + self.assertNotIn(password, out.read_text(encoding="utf-8")) + thread.join(timeout=5) + self.assertFalse(thread.is_alive()) + self.assertEqual(errors, []) + + def test_installed_middleware_ddp_auth_and_health(self): + peer = SyntheticWebSocketPeer() + errors = [] + password = "synthetic-ephemeral-9264" + + def server(): + try: + with peer.accept_websocket() as conn: + msg = peer.recv_json(conn) + self.assertEqual(msg, {"msg": "connect", "version": "1", "support": ["1"]}) + peer.send_json(conn, {"msg": "connected", "session": "synthetic"}) + + msg = peer.recv_json(conn) + self.assertEqual(msg["method"], "auth.login_ex") + auth = msg["params"][0] + self.assertEqual(auth["mechanism"], "PASSWORD_PLAIN") + self.assertEqual(auth["username"], "truenas_admin") + self.assertEqual(auth["password"], password) + peer.send_json(conn, { + "id": msg["id"], + "msg": "result", + "result": {"response_type": "SUCCESS"}, + }) + + msg = peer.recv_json(conn) + self.assertEqual(msg["method"], "system.version") + peer.send_json(conn, { + "id": msg["id"], + "msg": "result", + "result": "TrueNAS-26.0.0-BETA.3", + }) + + msg = peer.recv_json(conn) + self.assertEqual(msg["method"], "system.info") + peer.send_json(conn, { + "id": msg["id"], + "msg": "result", + "result": {"version": "TrueNAS-26.0.0-BETA.3"}, + }) + except Exception as exc: + errors.append(exc) + finally: + peer.listener.close() + + thread = threading.Thread(target=server, daemon=True) + thread.start() + with tempfile.TemporaryDirectory() as td: + td = pathlib.Path(td) + password_file = td / "password" + password_file.write_text(password, encoding="utf-8") + out = td / "middleware.json" + cp = subprocess.run( + [ + sys.executable, + str(MIDDLEWARE), + "--port", str(peer.port), + "--password-file", str(password_file), + "--out", str(out), + "--timeout", "2", + ], + text=True, + capture_output=True, + timeout=10, + ) + self.assertEqual(cp.returncode, 0, cp.stderr) + receipt = json.loads(out.read_text(encoding="utf-8")) + self.assertTrue(receipt["oracleSatisfied"]) + self.assertEqual(receipt["auth_response_type"], "SUCCESS") + self.assertEqual(receipt["system_version"], "TrueNAS-26.0.0-BETA.3") + self.assertNotIn(password, out.read_text(encoding="utf-8")) + thread.join(timeout=5) + self.assertFalse(thread.is_alive()) + self.assertEqual(errors, []) + + def test_t3_pool_client_waits_for_job_and_proves_membership(self): + peer = SyntheticWebSocketPeer() + errors = [] + password = "synthetic-ephemeral-9264" + + def expect_call(conn, method): + msg = peer.recv_json(conn) + self.assertEqual(msg["msg"], "method") + self.assertEqual(msg["method"], method) + return msg + + def result(conn, msg, value): + peer.send_json(conn, {"id": msg["id"], "msg": "result", "result": value}) + + def server(): + try: + with peer.accept_websocket() as conn: + msg = peer.recv_json(conn) + self.assertEqual(msg, {"msg": "connect", "version": "1", "support": ["1"]}) + peer.send_json(conn, {"msg": "connected", "session": "synthetic"}) + + msg = expect_call(conn, "auth.login_ex") + self.assertEqual(msg["params"][0]["password"], password) + result(conn, msg, {"response_type": "SUCCESS"}) + + msg = expect_call(conn, "system.version") + result(conn, msg, "TrueNAS-26.0.0-BETA.3") + + msg = expect_call(conn, "boot.get_disks") + result(conn, msg, ["vda"]) + + msg = expect_call(conn, "disk.details") + self.assertEqual(msg["params"], []) + result(conn, msg, { + "used": [{"name": "vda", "devname": "vda"}], + "unused": [ + {"name": "vdb", "devname": "vdb", "serial": "RDTE_DATA_0", "size": 8_589_934_592}, + {"name": "vdc", "devname": "vdc", "serial": "RDTE_DATA_1", "size": 8_589_934_592}, + {"name": "fd0", "devname": "fd0", "serial": "", "size": 4096}, + ], + }) + + msg = expect_call(conn, "pool.query") + self.assertEqual(msg["params"], [[["name", "=", "rdtepool"]]]) + result(conn, msg, []) + + msg = expect_call(conn, "pool.create") + self.assertEqual(msg["params"], [{ + "name": "rdtepool", + "encryption": False, + "allow_duplicate_serials": False, + "topology": { + "data": [{ + "type": "MIRROR", + "disks": ["vdb", "vdc"], + }], + }, + }]) + result(conn, msg, 42) + + msg = expect_call(conn, "core.get_jobs") + self.assertEqual(msg["params"][0], [["id", "=", 42]]) + result(conn, msg, { + "id": 42, + "state": "RUNNING", + "progress": {"percent": 50, "description": "Creating pool"}, + }) + + msg = expect_call(conn, "core.get_jobs") + result(conn, msg, { + "id": 42, + "state": "SUCCESS", + "progress": {"percent": 100, "description": "Pool created"}, + "result": {"id": 7, "name": "rdtepool"}, + }) + + msg = expect_call(conn, "pool.query") + self.assertEqual(msg["params"], [ + [["name", "=", "rdtepool"]], + {"get": True}, + ]) + result(conn, msg, { + "id": 7, + "name": "rdtepool", + "guid": "synthetic", + "status": "ONLINE", + "healthy": True, + "path": "/mnt/rdtepool", + }) + + msg = expect_call(conn, "pool.get_disks") + self.assertEqual(msg["params"], [7]) + result(conn, msg, ["vdb", "vdc"]) + except Exception as exc: + errors.append(exc) + finally: + peer.listener.close() + + thread = threading.Thread(target=server, daemon=True) + thread.start() + with tempfile.TemporaryDirectory() as td: + td = pathlib.Path(td) + password_file = td / "password" + password_file.write_text(password, encoding="utf-8") + out = td / "pool.json" + cp = subprocess.run( + [ + sys.executable, + str(POOL), + "--port", str(peer.port), + "--password-file", str(password_file), + "--out", str(out), + "--timeout", "2", + "--job-timeout", "8", + ], + text=True, + capture_output=True, + timeout=15, + ) + self.assertEqual(cp.returncode, 0, cp.stderr) + receipt = json.loads(out.read_text(encoding="utf-8")) + self.assertTrue(receipt["oracleSatisfied"], receipt) + self.assertEqual(receipt["classification"], "SUPPORTED") + self.assertEqual(receipt["selected_data_disks"], ["vdb", "vdc"]) + self.assertEqual([d["serial"] for d in receipt["owned_data_disks"]], ["RDTE_DATA_0", "RDTE_DATA_1"]) + self.assertEqual(len(receipt["unused_disks"]), 3) + self.assertEqual(receipt["pool_disks"], ["vdb", "vdc"]) + self.assertEqual(receipt["job_state"], "SUCCESS") + self.assertFalse(receipt["create_contract"]["allow_duplicate_serials"]) + self.assertEqual(receipt["pool"]["status"], "ONLINE") + self.assertTrue(receipt["pool"]["healthy"]) + self.assertNotIn(password, out.read_text(encoding="utf-8")) + thread.join(timeout=5) + self.assertFalse(thread.is_alive()) + self.assertEqual(errors, []) + + def test_t3_uses_public_disk_details_not_private_get_unused(self): + text = POOL.read_text(encoding="utf-8") + self.assertIn('"disk.details"', text) + self.assertNotIn('"disk.get_unused"', text) + self.assertIn('disk_details["unused"]', text) + + + def test_t5_lifecycle_contract_is_exact_version_and_digest_pinned(self): + text = LIFECYCLE.read_text(encoding="utf-8") + self.assertNotIn("EXPECTED_VERSION =", text) + self.assertIn('p.add_argument("--expected-version", required=True)', text) + self.assertIn('"expected_version": a.expected_version', text) + self.assertIn('APP_NAME = "rdte-t4-probe"', text) + self.assertIn('APP_IMAGE = "nginx@sha256:65645c7bb6a0661892a8b03b89d0743208a18dd2f3f17a54ef4b76fb8e2f2a10"', text) + self.assertNotIn('APP_IMAGE = "nginx:1.27-alpine"', text) + for method in ( + "app.stop", + "app.start", + "app.update", + "app.config", + "app.redeploy", + "app.delete", + ): + self.assertIn(f'"{method}"', text) + self.assertIn('"RDTE_GENERATION": "2"', text) + self.assertIn('"remove_images": True', text) + self.assertIn('"remove_ix_volumes": True', text) + self.assertIn('"post_delete_query"', text) + cp = subprocess.run( + [sys.executable, "-m", "py_compile", str(LIFECYCLE)], + text=True, + capture_output=True, + ) + self.assertEqual(cp.returncode, 0, cp.stderr) + + def test_t5_lifecycle_round_trip(self): + peer = SyntheticWebSocketPeer() + errors = [] + password = "synthetic-ephemeral-9264" + + def expect_call(conn, method): + msg = peer.recv_json(conn) + self.assertEqual(msg["msg"], "method") + self.assertEqual(msg["method"], method) + return msg + + def result(conn, msg, value): + peer.send_json(conn, {"id": msg["id"], "msg": "result", "result": value}) + + def app(state): + running = state == "RUNNING" + return { + "id": "rdte-t4-probe", + "name": "rdte-t4-probe", + "state": state, + "custom_app": True, + "active_workloads": { + "containers": 1 if running else 0, + "container_details": [{ + "id": "synthetic-nginx", + "service_name": "web", + "image": "nginx@sha256:65645c7bb6a0661892a8b03b89d0743208a18dd2f3f17a54ef4b76fb8e2f2a10", + "state": "running", + }] if running else [], + }, + } + + def job_success(conn, job_id, result_value=None): + msg = expect_call(conn, "core.get_jobs") + self.assertEqual(msg["params"][0], [["id", "=", job_id]]) + result(conn, msg, { + "id": job_id, + "state": "SUCCESS", + "progress": {"percent": 100, "description": "done"}, + "result": result_value, + }) + + def server(): + try: + with peer.accept_websocket() as conn: + msg = peer.recv_json(conn) + self.assertEqual( + msg, {"msg": "connect", "version": "1", "support": ["1"]} + ) + peer.send_json(conn, {"msg": "connected", "session": "synthetic"}) + + msg = expect_call(conn, "auth.login_ex") + self.assertEqual(msg["params"][0]["password"], password) + result(conn, msg, {"response_type": "SUCCESS"}) + + msg = expect_call(conn, "system.version") + result(conn, msg, "TrueNAS-26.0.0-BETA.3") + + msg = expect_call(conn, "app.query") + result(conn, msg, app("RUNNING")) + + msg = expect_call(conn, "app.stop") + self.assertEqual(msg["params"], ["rdte-t4-probe"]) + result(conn, msg, 31) + job_success(conn, 31) + msg = expect_call(conn, "app.query") + result(conn, msg, app("STOPPED")) + + msg = expect_call(conn, "app.start") + self.assertEqual(msg["params"], ["rdte-t4-probe"]) + result(conn, msg, 32) + job_success(conn, 32) + msg = expect_call(conn, "app.query") + result(conn, msg, app("RUNNING")) + + msg = expect_call(conn, "app.update") + self.assertEqual(msg["params"][0], "rdte-t4-probe") + update = msg["params"][1] + self.assertEqual( + update["custom_compose_config"]["services"]["web"]["image"], + "nginx@sha256:65645c7bb6a0661892a8b03b89d0743208a18dd2f3f17a54ef4b76fb8e2f2a10", + ) + self.assertEqual( + update["custom_compose_config"]["services"]["web"]["environment"]["RDTE_GENERATION"], + "2", + ) + result(conn, msg, 33) + job_success(conn, 33) + + msg = expect_call(conn, "app.config") + result(conn, msg, update["custom_compose_config"]) + + msg = expect_call(conn, "app.query") + result(conn, msg, app("RUNNING")) + + msg = expect_call(conn, "app.redeploy") + self.assertEqual(msg["params"], ["rdte-t4-probe"]) + result(conn, msg, 34) + job_success(conn, 34) + msg = expect_call(conn, "app.query") + result(conn, msg, app("RUNNING")) + + msg = expect_call(conn, "app.stop") + result(conn, msg, 35) + job_success(conn, 35) + msg = expect_call(conn, "app.query") + result(conn, msg, app("STOPPED")) + + msg = expect_call(conn, "app.delete") + self.assertEqual(msg["params"][0], "rdte-t4-probe") + self.assertEqual(msg["params"][1], { + "remove_images": True, + "remove_ix_volumes": True, + "force_remove_custom_app": False, + }) + result(conn, msg, 36) + job_success(conn, 36, True) + + msg = expect_call(conn, "app.query") + self.assertEqual( + msg["params"], [[["id", "=", "rdte-t4-probe"]]] + ) + result(conn, msg, []) + except Exception as exc: + errors.append(exc) + finally: + peer.listener.close() + + thread = threading.Thread(target=server, daemon=True) + thread.start() + with tempfile.TemporaryDirectory() as td: + td = pathlib.Path(td) + password_file = td / "password" + password_file.write_text(password, encoding="utf-8") + out = td / "lifecycle.json" + cp = subprocess.run( + [ + sys.executable, + str(LIFECYCLE), + "--port", str(peer.port), + "--password-file", str(password_file), + "--out", str(out), + "--expected-version", "TrueNAS-26.0.0-BETA.3", + "--timeout", "2", + "--job-timeout", "8", + "--state-timeout", "8", + ], + text=True, + capture_output=True, + timeout=20, + ) + self.assertEqual(cp.returncode, 0, cp.stderr) + receipt = json.loads(out.read_text(encoding="utf-8")) + self.assertTrue(receipt["oracleSatisfied"], receipt) + self.assertEqual(receipt["classification"], "SUPPORTED") + self.assertEqual(receipt["system_version"], "TrueNAS-26.0.0-BETA.3") + self.assertEqual(receipt["app_image"], "nginx@sha256:65645c7bb6a0661892a8b03b89d0743208a18dd2f3f17a54ef4b76fb8e2f2a10") + self.assertEqual( + receipt["states"], + ["RUNNING", "STOPPED", "RUNNING", "RUNNING", "RUNNING", "STOPPED"], + ) + self.assertEqual( + receipt["config_after_update"]["services"]["web"]["environment"]["RDTE_GENERATION"], + "2", + ) + self.assertEqual(receipt["post_delete_query"], []) + self.assertNotIn(password, out.read_text(encoding="utf-8")) + thread.join(timeout=5) + self.assertFalse(thread.is_alive()) + self.assertEqual(errors, []) + + + + +if __name__ == "__main__": + unittest.main() + unittest.main() diff --git a/tests/test_truenas_garm_container_pre_b4.py b/tests/test_truenas_garm_container_pre_b4.py new file mode 100644 index 000000000..bdd4bb495 --- /dev/null +++ b/tests/test_truenas_garm_container_pre_b4.py @@ -0,0 +1,108 @@ +import json +import pathlib +import sys +import tempfile +import unittest + +ROOT = pathlib.Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "scripts")) + +import truenas_middleware_garm_container_pre_b4_probe as probe + + +class ContainerPreB4Tests(unittest.TestCase): + def test_probe_and_harness_propagate_tls(self): + script = (ROOT / "scripts" / "truenas_middleware_garm_container_pre_b4_probe.py").read_text(encoding="utf-8") + harness = (ROOT / "scripts" / "gha_kvm_truenas_rdte.sh").read_text(encoding="utf-8") + self.assertIn('p.add_argument("--tls", action="store_true")', script) + self.assertIn("tls=a.tls", script) + self.assertIn('"${MIDDLEWARE_TLS_ARG[@]}"', harness) + + def fixture(self, producer="a" * 40): + return { + "schema": probe.EXPECTED_SCHEMA, + "producer_source": producer, + "target": { + "system_version": probe.EXPECTED_VERSION, + "driver": "container-v1", + "control_surface": "container.*", + "status": "OPEN", + "required_methods": ["system.version", "container.query", "filesystem.put", "filesystem.stat"], + }, + "rootfs_projection": { + "dataset_template": probe.EXPECTED_DATASET_TEMPLATE, + "mountpoint_template": probe.EXPECTED_MOUNTPOINT_TEMPLATE, + "source_path": probe.EXPECTED_ROOTFS_SOURCE, + }, + "profile": "truenas-container-linux-general", + "image_family": "ubuntu:noble:amd64:default", + "expected_name": "garm-test", + "source_oracles": { + "runtime_admission_claimed": False, + "github_jit_boundary_claimed": False, + "source_derived_rootfs_projection_required": True, + }, + } + + def test_rootfs_projection_is_exact_and_fail_closed(self): + doc = self.fixture() + name = doc["expected_name"] + dataset = f"rdtepool/.truenas_containers/containers/{name}" + self.assertEqual( + probe.derive_rootfs_mountpoint(doc, "rdtepool", name, dataset), + f"/.truenas_containers/rdtepool/containers/{name}", + ) + with self.assertRaises(probe.ProbeError): + probe.derive_rootfs_mountpoint( + doc, "rdtepool", name, f"rdtepool/foreign_layout/{name}" + ) + + def test_load_rejects_hidden_dataset_query_reintroduction(self): + with tempfile.TemporaryDirectory() as td: + root = pathlib.Path(td) + doc = self.fixture() + doc["target"]["required_methods"].append("pool.dataset.query") + (root / "garm-provider-container-pre-b4-fixture.json").write_text( + json.dumps(doc), encoding="utf-8" + ) + with self.assertRaises(probe.ProbeError): + probe.load_fixture(root, "a" * 40) + + def test_not_found_classifier_is_fail_closed(self): + self.assertTrue(probe.is_explicit_not_found(RuntimeError('filesystem.stat: {"error": 2, "reason": "Path not found"}'))) + self.assertTrue(probe.is_explicit_not_found(RuntimeError("ENOENT"))) + self.assertFalse(probe.is_explicit_not_found(RuntimeError("authentication failed"))) + self.assertFalse(probe.is_explicit_not_found(RuntimeError("transport timeout"))) + + def test_load_accepts_exact_non_admitted_fixture(self): + with tempfile.TemporaryDirectory() as td: + root = pathlib.Path(td) + doc = self.fixture() + (root / "garm-provider-container-pre-b4-fixture.json").write_text( + json.dumps(doc), encoding="utf-8" + ) + self.assertEqual(probe.load_fixture(root, "a" * 40)["profile"], "truenas-container-linux-general") + + def test_load_rejects_runtime_admission_claim(self): + with tempfile.TemporaryDirectory() as td: + root = pathlib.Path(td) + doc = self.fixture() + doc["source_oracles"]["runtime_admission_claimed"] = True + (root / "garm-provider-container-pre-b4-fixture.json").write_text( + json.dumps(doc), encoding="utf-8" + ) + with self.assertRaises(probe.ProbeError): + probe.load_fixture(root, "a" * 40) + + def test_load_rejects_producer_drift(self): + with tempfile.TemporaryDirectory() as td: + root = pathlib.Path(td) + (root / "garm-provider-container-pre-b4-fixture.json").write_text( + json.dumps(self.fixture()), encoding="utf-8" + ) + with self.assertRaises(probe.ProbeError): + probe.load_fixture(root, "b" * 40) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_truenas_garm_controller_f0_f5.py b/tests/test_truenas_garm_controller_f0_f5.py new file mode 100644 index 000000000..5494438b6 --- /dev/null +++ b/tests/test_truenas_garm_controller_f0_f5.py @@ -0,0 +1,84 @@ +#!/usr/bin/env python3 +import importlib.util +import pathlib +import unittest + +ROOT = pathlib.Path(__file__).resolve().parents[1] +SPEC = importlib.util.spec_from_file_location( + "truenas_middleware_garm_f0_f5_probe", + ROOT / "scripts" / "truenas_middleware_garm_f0_f5_probe.py", +) +MOD = importlib.util.module_from_spec(SPEC) +assert SPEC.loader +SPEC.loader.exec_module(MOD) + + +class GarmControllerF0F5Tests(unittest.TestCase): + def test_reconciliation_actions_are_fail_closed(self): + desired = {"services": {"garm": {"image": "x"}}} + self.assertEqual(MOD.reconciliation_action(None, None, desired), "CREATE") + for state in ("DEPLOYING", "STOPPING"): + self.assertEqual( + MOD.reconciliation_action(state, desired, desired), "WAIT" + ) + for state in ("CRASHED", "ERROR", "UNKNOWN"): + self.assertEqual( + MOD.reconciliation_action(state, desired, desired), "FAIL_CLOSED" + ) + self.assertEqual( + MOD.reconciliation_action("RUNNING", None, desired), "FAIL_CLOSED" + ) + self.assertEqual( + MOD.reconciliation_action("RUNNING", desired, desired), "NOOP" + ) + self.assertEqual( + MOD.reconciliation_action( + "RUNNING", {"services": {"garm": {"image": "drift"}}}, desired + ), + "UPDATE", + ) + + def test_required_methods_cover_f0_f5_mutations_and_readback(self): + expected = { + "system.version", + "app.query", + "app.config", + "app.create", + "app.update", + "app.redeploy", + "app.stop", + "app.start", + "app.delete", + "pool.dataset.query", + "pool.dataset.create", + "pool.dataset.delete", + "filesystem.mkdir", + "filesystem.stat", + } + self.assertTrue(expected.issubset(MOD.REQUIRED_METHODS)) + + def test_secret_normalization_is_stable(self): + compose = { + "configs": { + MOD.BOOTSTRAP_CONFIG: {"content": "secret"}, + MOD.TLS_CERT_CONFIG: {"content": "cert"}, + MOD.TLS_KEY_CONFIG: {"content": "key"}, + } + } + normalized = MOD.normalize_secret_configs(compose) + self.assertEqual( + normalized["configs"][MOD.BOOTSTRAP_CONFIG]["content"], + MOD.BOOTSTRAP_PLACEHOLDER, + ) + self.assertEqual( + normalized["configs"][MOD.TLS_CERT_CONFIG]["content"], + MOD.TLS_PREFIX + MOD.TLS_CERT_CONFIG, + ) + self.assertEqual( + normalized["configs"][MOD.TLS_KEY_CONFIG]["content"], + MOD.TLS_PREFIX + MOD.TLS_KEY_CONFIG, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_truenas_garm_provider_g2.py b/tests/test_truenas_garm_provider_g2.py new file mode 100644 index 000000000..f8e506e47 --- /dev/null +++ b/tests/test_truenas_garm_provider_g2.py @@ -0,0 +1,166 @@ +from __future__ import annotations + +import importlib.util +import json +from pathlib import Path +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[1] +SCRIPT = ROOT / "scripts" / "truenas_middleware_garm_provider_g2_probe.py" +SPEC = importlib.util.spec_from_file_location("garm_provider_g2_probe", SCRIPT) +assert SPEC and SPEC.loader +MOD = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(MOD) + + +class GarmProviderG2Tests(unittest.TestCase): + def test_boundary_is_read_adoption_not_runner_creation(self): + text = SCRIPT.read_text(encoding="utf-8") + for required in ( + '"provider_create_exercised": False', + '"provider_delete_exercised": False', + '"github_jit_registration_exercised": False', + '"physical_truenas_mutation": False', + '"insecure_skip_verify": False', + '"GARM_COMMAND": command', + '"SSL_CERT_FILE": str(ca_path)', + '"TRUENAS_API_KEY": api_key', + '"ListInstances"', + '"GetInstance"', + ): + self.assertIn(required, text) + self.assertNotIn('"GARM_COMMAND": "CreateInstance"', text) + self.assertNotIn('"GARM_COMMAND": "DeleteInstance"', text) + + def test_fixture_bundle_binds_product_and_producer_sources(self): + bundle = { + "schema": MOD.EXPECTED_FIXTURE_SCHEMA, + "provider_product_source": MOD.EXPECTED_PROVIDER_PRODUCT_SOURCE, + "producer_source": "a" * 40, + "controller_id": MOD.EXPECTED_CONTROLLER_ID, + "pool_id": MOD.EXPECTED_POOL_ID, + "fixtures": { + "valid_local": {}, + "valid_foreign": {}, + "managed_drift": {}, + }, + } + with tempfile.TemporaryDirectory() as td: + root = Path(td) + (root / "garm-provider-g2-fixtures.json").write_text( + json.dumps(bundle), encoding="utf-8" + ) + loaded = MOD.load_bundle(root, "a" * 40) + self.assertEqual( + loaded["provider_product_source"], + MOD.EXPECTED_PROVIDER_PRODUCT_SOURCE, + ) + with self.assertRaisesRegex(RuntimeError, "producer source drifted"): + MOD.load_bundle(root, "b" * 40) + + def test_g2_fixture_download_is_true_nas_job_local(self): + workflow = ( + ROOT / ".github" / "workflows" / "gha-kvm-system-rdte.yml" + ).read_text(encoding="utf-8") + proxmox = workflow.split(" proxmox-9-2:", 1)[1].split( + " truenas-26-beta3:", 1 + )[0] + truenas = workflow.split(" truenas-26-beta3:", 1)[1] + marker = "Download exact GARM provider G2 fixtures" + self.assertNotIn(marker, proxmox) + self.assertIn(marker, truenas) + self.assertIn("garm-provider-truenas-g2-fixtures", truenas) + self.assertIn("--g2-fixture-dir", truenas) + + + def test_exact_fixture_is_applied_only_after_seed_is_stopped(self): + compose = { + "services": { + "runner": { + "image": "example.invalid/runner@sha256:" + ("0" * 64), + "restart": "no", + }, + }, + } + + class FakeSession: + def __init__(self): + self.calls = [] + self.query_count = 0 + self.waits = [] + + def call(self, method, params): + self.calls.append((method, params)) + if method == "app.create": + return 101 + if method == "app.stop": + return 102 + if method == "app.update": + return 103 + if method == "app.query": + self.query_count += 1 + return {"state": "RUNNING" if self.query_count == 1 else "STOPPED"} + if method == "app.config": + return compose + raise AssertionError(method) + + def wait_job(self, job_id, label, timeout): + self.waits.append((job_id, label, timeout)) + return {"state": "SUCCESS"} + + session = FakeSession() + result = MOD.create_app_stopped_with_exact_config( + session, "garm-fixture-runner-1", compose, 30.0 + ) + self.assertEqual(result["state"], "STOPPED") + + methods = [method for method, _ in session.calls] + self.assertLess(methods.index("app.stop"), methods.index("app.update")) + update = next(params for method, params in session.calls if method == "app.update") + self.assertEqual(update[1]["custom_compose_config"], compose) + self.assertIn("app.config", methods) + self.assertNotIn("app.start", methods) + + def test_fixture_cleanup_is_registered_before_materialization(self): + text = SCRIPT.read_text(encoding="utf-8") + valid = text.split('for key in ("valid_local", "valid_foreign"):', 1)[1].split( + 'local_name = fixtures["valid_local"]["app_name"]', 1 + )[0] + self.assertLess( + valid.index("fixture_names.append(name)"), + valid.index("create_app_stopped_with_exact_config"), + ) + self.assertIn("fixture app remains after delete", text) + + def test_stopped_update_contract_is_explicitly_documented(self): + text = SCRIPT.read_text(encoding="utf-8") + self.assertIn("existing App is STOPPED", text) + self.assertIn('"app.update"', text) + self.assertIn('"runner_fixture_bootstrap_not_started": True', text) + self.assertEqual( + MOD.EXPECTED_TRUENAS_MIDDLEWARE_SOURCE, + "81e1265a86083888ba94a2bdfc02ff5c9c5ef6a3", + ) + self.assertIn('"truenas_middleware_source": EXPECTED_TRUENAS_MIDDLEWARE_SOURCE', text) + self.assertIn('"stopped_update_contract":', text) + + def test_tls_path_is_verified_and_ephemeral(self): + text = SCRIPT.read_text(encoding="utf-8") + for required in ( + "ssl.create_default_context(cafile=str(cafile))", + "server_hostname=host", + '"api_key.create"', + '"certificate.create"', + '"system.general.update"', + '"system.general.ui_restart"', + '"api_key.delete"', + '"certificate.delete"', + '"private_key_recorded": False', + '"api_key_recorded": False', + ): + self.assertIn(required, text) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_truenas_garm_provider_g3.py b/tests/test_truenas_garm_provider_g3.py new file mode 100644 index 000000000..08de374a4 --- /dev/null +++ b/tests/test_truenas_garm_provider_g3.py @@ -0,0 +1,242 @@ +from __future__ import annotations + +import importlib.util +import json +from pathlib import Path +import tempfile +import unittest +import urllib.parse + +ROOT = Path(__file__).resolve().parents[1] +SCRIPT = ROOT / "scripts" / "truenas_middleware_garm_provider_g3_probe.py" +SPEC = importlib.util.spec_from_file_location("garm_provider_g3_probe", SCRIPT) +assert SPEC and SPEC.loader +MOD = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(MOD) + + +class GarmProviderG3Tests(unittest.TestCase): + def fixture_bundle(self) -> dict: + return { + "schema": MOD.EXPECTED_FIXTURE_SCHEMA, + "provider_product_source": MOD.EXPECTED_PROVIDER_PRODUCT_SOURCE, + "producer_source": "a" * 40, + "controller_id": MOD.EXPECTED_CONTROLLER_ID, + "pool_id": MOD.EXPECTED_POOL_ID, + "bootstrap_template": { + "callback-url": "https://fixture.invalid/status", + "metadata-url": "https://fixture.invalid/metadata", + "instance-token": "placeholder", + }, + "expected_app_name": "garm-g3-controlle-g3-synthetic-runner", + "expected_compose_template": { + "services": { + "runner": { + "environment": { + "GARM_CALLBACK_URL": "https://fixture.invalid/status", + "GARM_METADATA_URL": "https://fixture.invalid/metadata", + "GARM_INSTANCE_TOKEN": "placeholder", + } + } + } + }, + "run_local_substitutions": MOD.EXPECTED_SUBSTITUTIONS, + } + + def test_fixture_bundle_binds_product_producer_and_substitution_allowlist(self): + with tempfile.TemporaryDirectory() as td: + root = Path(td) + (root / "garm-provider-g3-fixture.json").write_text( + json.dumps(self.fixture_bundle()), encoding="utf-8" + ) + loaded = MOD.load_bundle(root, "a" * 40) + self.assertEqual( + loaded["provider_product_source"], + MOD.EXPECTED_PROVIDER_PRODUCT_SOURCE, + ) + + with self.assertRaisesRegex(RuntimeError, "producer source drifted"): + MOD.load_bundle(root, "b" * 40) + + bad = self.fixture_bundle() + bad["run_local_substitutions"] = bad["run_local_substitutions"][:-1] + (root / "garm-provider-g3-fixture.json").write_text( + json.dumps(bad), encoding="utf-8" + ) + with self.assertRaisesRegex(RuntimeError, "substitution allowlist drifted"): + MOD.load_bundle(root, "a" * 40) + + def test_lower_fixture_changes_only_callback_metadata_and_synthetic_token(self): + bundle = self.fixture_bundle() + callback = "https://example.invalid/anything/status" + metadata = "https://example.invalid/anything/metadata" + token = "synthetic-token" + bootstrap, expected = MOD.lower_fixture(bundle, callback, metadata, token) + + self.assertEqual(bootstrap["callback-url"], callback) + self.assertEqual(bootstrap["metadata-url"], metadata) + self.assertEqual(bootstrap["instance-token"], token) + env = expected["services"]["runner"]["environment"] + self.assertEqual(env["GARM_CALLBACK_URL"], callback) + self.assertEqual(env["GARM_METADATA_URL"], metadata) + self.assertEqual(env["GARM_INSTANCE_TOKEN"], token) + + original = self.fixture_bundle() + self.assertEqual( + original["bootstrap_template"]["callback-url"], + "https://fixture.invalid/status", + ) + + def test_boundary_is_create_readback_retire_not_github_execution(self): + text = SCRIPT.read_text(encoding="utf-8") + for required in ( + '"CreateInstance"', + '"GetInstance"', + '"ListInstances"', + '"DeleteInstance"', + '"provider_create_exercised": False', + '"provider_delete_exercised": False', + '"github_credentials_present": False', + '"github_jit_registration_exercised": False', + '"private_repository_execution": False', + '"physical_truenas_mutation": False', + '"capacity_promotion": False', + '"callback_host_gateway": False', + '"fallback_cleanup_used": fallback_cleanup_used', + '"provider_delete_succeeded": provider_delete_succeeded', + ): + self.assertIn(required, text) + self.assertNotIn("GITHUB_TOKEN", text) + self.assertNotIn("GH_TOKEN", text) + self.assertNotIn("PRIVATE_KEY", text) + + def test_exact_compose_is_compared_before_retirement(self): + text = SCRIPT.read_text(encoding="utf-8") + readback = text.index('session.call("app.config", [app_name])') + compare = text.index("provider-created exact Compose read-back drifted") + inactive = text.index("ensure_inactive(session, app_name") + delete = text.index('"DeleteInstance", instance_id=app_name') + self.assertLess(readback, compare) + self.assertLess(compare, inactive) + self.assertLess(inactive, delete) + + def test_synthetic_fixture_preflight_sends_no_provider_token(self): + text = SCRIPT.read_text(encoding="utf-8") + block = text.split("def preflight_public_fixture", 1)[1].split( + "def load_bundle", 1 + )[0] + self.assertIn('method="POST"', block) + self.assertIn('method="GET"', block) + self.assertIn("except TimeoutError", block) + self.assertIn("synthetic metadata hold-open returned before the preflight timeout", block) + self.assertNotIn("Authorization", block) + self.assertIn('"ENVIRONMENT_FAILURE"', text) + self.assertIn('"synthetic_fixture_preflight"', text) + + def test_default_metadata_fixture_holds_open_before_jit_credentials(self): + self.assertTrue(MOD.DEFAULT_METADATA_URL.startswith("https://httpbin.org/drip?")) + self.assertIn("delay=60", MOD.DEFAULT_METADATA_URL) + self.assertIn("path=", MOD.DEFAULT_METADATA_URL) + expanded = MOD.DEFAULT_METADATA_URL.rstrip("/") + "/credentials/runner" + parsed = urllib.parse.urlsplit(expanded) + self.assertEqual(parsed.path, "/drip") + query = urllib.parse.parse_qs(parsed.query) + self.assertEqual(query.get("path"), ["/credentials/runner"]) + self.assertEqual(query.get("delay"), ["60"]) + + def test_expected_app_name_is_bound_before_provider_create_for_cleanup(self): + text = SCRIPT.read_text(encoding="utf-8") + bind = text.index('app_name = bundle["expected_app_name"]') + create = text.index('"CreateInstance", stdin_object=bootstrap') + self.assertLess(bind, create) + + def test_create_failure_job_capture_is_sanitized_and_excludes_arguments(self): + token = "g3-secret-synthetic-token" + + class FakeSession: + def call(self, method, params): + self.method = method + self.params = params + return [ + { + "id": 77, + "method": "app.create", + "state": "FAILED", + "arguments": [{ + "app_name": "garm-g3-controlle-g3-synthetic-runner", + "custom_compose_config": { + "services": { + "runner": { + "environment": { + "GARM_INSTANCE_TOKEN": token, + } + } + } + }, + }], + "error": f"failed with {token}", + "exception": f"trace {token}", + "progress": {"description": f"cleanup {token}", "percent": 80}, + "logs_excerpt": f"container stderr {token}", + "logs_path": "/var/log/jobs/77.log", + } + ] + + session = FakeSession() + got = MOD.capture_create_failure_job( + session, "garm-g3-controlle-g3-synthetic-runner", token + ) + self.assertEqual(session.method, "core.get_jobs") + self.assertTrue(got["found"]) + self.assertEqual(got["id"], 77) + self.assertTrue(got["logs_available"]) + self.assertNotIn("arguments", got) + self.assertNotIn(token, json.dumps(got, sort_keys=True)) + self.assertIn("", got["logs_excerpt"]) + + def test_app_lifecycle_excerpt_is_bounded_and_redacts_sensitive_values(self): + app = "garm-g3-controlle-g3-synthetic-runner" + token = "g3-sensitive-token" + api_key = "api-sensitive-value" + lines = [f"noise-{i}" for i in range(220)] + lines += [ + f"compose up {app}", + f"stderr contains {token}", + f"environment accidentally contains {api_key}", + "fatal: exact compose failure", + ] + got = MOD.select_sanitized_log_excerpt( + "\n".join(lines), app, [token, api_key], context_lines=3, max_chars=1000 + ) + self.assertTrue(got["app_name_observed"]) + self.assertIn("fatal: exact compose failure", got["excerpt"]) + self.assertNotIn(token, got["excerpt"]) + self.assertNotIn(api_key, got["excerpt"]) + self.assertGreaterEqual(got["line_count"], 224) + + def test_appliance_log_capture_uses_single_specific_filesystem_source(self): + text = SCRIPT.read_text(encoding="utf-8") + block = text.split("def capture_app_lifecycle_log", 1)[1].split( + "def query_app", 1 + )[0] + self.assertIn('"core.download"', block) + self.assertIn('"filesystem.get"', block) + self.assertIn('"/var/log/app_lifecycle.log"', block) + self.assertIn("ssl.create_default_context", block) + self.assertNotIn("GITHUB_TOKEN", block) + self.assertNotIn("TRUENAS_API_KEY", block) + + def test_transport_reuses_verified_g2_helper_tuple(self): + text = SCRIPT.read_text(encoding="utf-8") + self.assertIn("g2.wait_verified_https", text) + self.assertIn('"SSL_CERT_FILE": str(ca_path)', text) + self.assertIn('"TRUENAS_API_KEY": api_key', text) + self.assertIn("g2.EXPECTED_APPLIANCE != EXPECTED_APPLIANCE", text) + self.assertIn( + "g2.EXPECTED_PROVIDER_BINARY_SHA256 != EXPECTED_PROVIDER_BINARY_SHA256", + text, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_truenas_garm_provider_g4.py b/tests/test_truenas_garm_provider_g4.py new file mode 100644 index 000000000..f69521d4c --- /dev/null +++ b/tests/test_truenas_garm_provider_g4.py @@ -0,0 +1,198 @@ +from __future__ import annotations + +import importlib.util +import json +from pathlib import Path +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[1] +SCRIPT = ROOT / "scripts" / "truenas_middleware_garm_provider_g4_probe.py" +SPEC = importlib.util.spec_from_file_location("garm_provider_g4_probe", SCRIPT) +assert SPEC and SPEC.loader +MOD = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(MOD) + + +class GarmProviderG4Tests(unittest.TestCase): + def fixture_bundle(self) -> dict: + def runner(slot: str, name: str) -> dict: + callback = f"https://fixture.invalid/{slot}/status" + metadata = f"https://fixture.invalid/{slot}/metadata" + token = f"{slot}-placeholder" + return { + "slot": slot, + "bootstrap_template": { + "callback-url": callback, + "metadata-url": metadata, + "instance-token": token, + }, + "expected_app_name": name, + "expected_compose_template": { + "services": { + "runner": { + "environment": { + "GARM_CALLBACK_URL": callback, + "GARM_METADATA_URL": metadata, + "GARM_INSTANCE_TOKEN": token, + } + } + } + }, + } + + return { + "schema": MOD.EXPECTED_FIXTURE_SCHEMA, + "provider_product_source": MOD.EXPECTED_PROVIDER_PRODUCT_SOURCE, + "producer_source": "a" * 40, + "controller_id": MOD.EXPECTED_CONTROLLER_ID, + "pool_id": MOD.EXPECTED_POOL_ID, + "runners": [ + runner("alpha", "garm-g4-controlle-g4-synthetic-c43b29d9"), + runner("beta", "garm-g4-controlle-g4-synthetic-9e19d328"), + ], + "runner": { + "image": MOD.EXPECTED_RUNNER_IMAGE, + "cpu": MOD.EXPECTED_CPU, + "memory_bytes": MOD.EXPECTED_MEMORY_BYTES, + }, + "retirement_orders": [["alpha", "beta"], ["beta", "alpha"]], + "run_local_substitutions": MOD.EXPECTED_SUBSTITUTIONS, + "source_oracles": { + "two_distinct_provider_owned_names": True, + "fresh_manager_adopts_exact_pair": True, + }, + } + + def test_bundle_binds_exact_source_pair_and_substitutions(self): + with tempfile.TemporaryDirectory() as td: + root = Path(td) + (root / "garm-provider-g4-fixture.json").write_text( + json.dumps(self.fixture_bundle()), encoding="utf-8" + ) + loaded = MOD.load_bundle(root, "a" * 40) + self.assertEqual(len(loaded["runners"]), 2) + self.assertNotEqual( + loaded["runners"][0]["expected_app_name"], + loaded["runners"][1]["expected_app_name"], + ) + + with self.assertRaisesRegex(RuntimeError, "producer source drifted"): + MOD.load_bundle(root, "b" * 40) + + bad = self.fixture_bundle() + bad["run_local_substitutions"] = bad["run_local_substitutions"][:-1] + (root / "garm-provider-g4-fixture.json").write_text( + json.dumps(bad), encoding="utf-8" + ) + with self.assertRaisesRegex(RuntimeError, "substitution allowlist drifted"): + MOD.load_bundle(root, "a" * 40) + + def test_lower_runner_changes_only_three_run_local_values(self): + runner = self.fixture_bundle()["runners"][0] + callback = "https://example.invalid/alpha/status" + metadata = "https://example.invalid/alpha/metadata" + token = "alpha-token" + bootstrap, expected = MOD.lower_runner(runner, callback, metadata, token) + self.assertEqual(bootstrap["callback-url"], callback) + self.assertEqual(bootstrap["metadata-url"], metadata) + self.assertEqual(bootstrap["instance-token"], token) + env = expected["services"]["runner"]["environment"] + self.assertEqual(env["GARM_CALLBACK_URL"], callback) + self.assertEqual(env["GARM_METADATA_URL"], metadata) + self.assertEqual(env["GARM_INSTANCE_TOKEN"], token) + + def test_fixture_urls_are_distinct_public_non_github_boundaries(self): + alpha = MOD.fixture_urls("alpha") + beta = MOD.fixture_urls("beta") + self.assertNotEqual(alpha, beta) + for callback, metadata in (alpha, beta): + self.assertTrue(callback.startswith("https://httpbin.org/")) + self.assertTrue(metadata.startswith("https://httpbin.org/drip?")) + self.assertIn("delay=60", metadata) + + def test_boundary_includes_concurrent_create_reconnect_and_both_retirements(self): + text = SCRIPT.read_text(encoding="utf-8") + required = ( + "ThreadPoolExecutor(max_workers=2)", + '"CreateInstance"', + '"GetInstance"', + '"ListInstances"', + '"DeleteInstance"', + "require_active_delete_refusal", + '("alpha", "beta")', + '("beta", "alpha")', + '"fresh_process_get_list_exact_pair": True', + '"active_delete_refused_without_peer_corruption": True', + '"retirement_alpha_then_beta": True', + '"retirement_beta_then_alpha": True', + '"post_retirement_provider_inventory_empty": True', + ) + for item in required: + self.assertIn(item, text) + + def test_public_boundary_and_cleanup_fail_closed(self): + text = SCRIPT.read_text(encoding="utf-8") + for required in ( + '"github_credentials_present": False', + '"github_jit_registration_exercised": False', + '"private_repository_execution": False', + '"physical_truenas_mutation": False', + '"capacity_promotion": False', + '"fallback_cleanup_used": fallback_cleanup_used', + ): + self.assertIn(required, text) + self.assertNotIn("GITHUB_TOKEN", text) + self.assertNotIn("GH_TOKEN", text) + self.assertNotIn("PRIVATE_KEY", text) + + def test_resource_observation_uses_exact_compose_and_ix_apps_statfs(self): + text = SCRIPT.read_text(encoding="utf-8") + self.assertIn('"aggregate_cpu_limit_units": EXPECTED_CPU * 2', text) + self.assertIn('"aggregate_memory_limit_bytes": EXPECTED_MEMORY_BYTES * 2', text) + self.assertIn('"filesystem.statfs"', text) + self.assertIn('IX_APPS_PATH = "/mnt/.ix-apps"', text) + self.assertIn('"storage_delta_measured": True', text) + + def test_active_delete_refusal_requires_target_remain_present(self): + class FakeSession: + pass + + original_invoke = MOD.invoke_provider + original_query = MOD.g3.query_app + try: + class CP: + returncode = 1 + stdout = "" + stderr = "active" + + MOD.invoke_provider = lambda *args, **kwargs: CP() + MOD.g3.query_app = lambda session, name: {"id": name, "state": "RUNNING"} + MOD.require_active_delete_refusal( + Path("/provider"), + Path("/config"), + "api-key", + Path("/ca"), + "garm-alpha", + ["token"], + FakeSession(), + ) + + MOD.g3.query_app = lambda session, name: None + with self.assertRaisesRegex(RuntimeError, "lost the target App"): + MOD.require_active_delete_refusal( + Path("/provider"), + Path("/config"), + "api-key", + Path("/ca"), + "garm-alpha", + ["token"], + FakeSession(), + ) + finally: + MOD.invoke_provider = original_invoke + MOD.g3.query_app = original_query + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_truenas_garm_provider_g5.py b/tests/test_truenas_garm_provider_g5.py new file mode 100644 index 000000000..26bef9041 --- /dev/null +++ b/tests/test_truenas_garm_provider_g5.py @@ -0,0 +1,106 @@ +from __future__ import annotations + +import importlib.util +import json +from pathlib import Path +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[1] +SCRIPT = ROOT / "scripts" / "truenas_middleware_garm_provider_g5_probe.py" +G3_SCRIPT = ROOT / "scripts" / "truenas_middleware_garm_provider_g3_probe.py" +SPEC = importlib.util.spec_from_file_location("garm_provider_g5_probe", SCRIPT) +assert SPEC and SPEC.loader +MOD = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(MOD) + + +class GarmProviderG5Tests(unittest.TestCase): + def matrix(self) -> dict: + return { + "schema": MOD.EXPECTED_MATRIX_SCHEMA, + "provider_product_source": MOD.EXPECTED_PROVIDER_PRODUCT_SOURCE, + "producer_source": "f" * 40, + "runtime_inheritance_allowed": False, + "source_oracles": { + "exact_middleware_commits": True, + "exact_git_blob_identities": True, + "provider_method_surface_scan": True, + "existing_25_04_1_contract_consistent": True, + "runtime_inheritance_prohibited": True, + }, + "targets": [ + { + "version": "25.10.7", + "system_version": "TrueNAS-25.10.7", + "middleware_ref": "TS-25.10.7", + "middleware_commit": "8ede398839710e56893d88ce85088139d8fab18e", + "source_equivalence_group": "goldeye-25.10.7-provider-control", + "source_blobs": {f"path-{i}": f"{i:040x}"[-40:] for i in range(7)}, + } + ], + } + + def test_matrix_binds_exact_row_and_prohibits_inheritance(self): + with tempfile.TemporaryDirectory() as td: + root = Path(td) + path = root / "garm-provider-truenas-g5-version-matrix.json" + path.write_text(json.dumps(self.matrix()), encoding="utf-8") + loaded = MOD.load_matrix(root, "f" * 40) + row = MOD.select_row( + loaded, + "25.10.7", + "8ede398839710e56893d88ce85088139d8fab18e", + ) + self.assertEqual(row["system_version"], "TrueNAS-25.10.7") + + with self.assertRaisesRegex(RuntimeError, "middleware commit drifted"): + MOD.select_row(loaded, "25.10.7", "0" * 40) + + bad = self.matrix() + bad["runtime_inheritance_allowed"] = True + path.write_text(json.dumps(bad), encoding="utf-8") + with self.assertRaisesRegex(RuntimeError, "prohibit runtime support inheritance"): + MOD.load_matrix(root, "f" * 40) + + def test_matrix_rejects_false_source_oracle(self): + with tempfile.TemporaryDirectory() as td: + root = Path(td) + bad = self.matrix() + bad["source_oracles"]["exact_git_blob_identities"] = False + (root / "garm-provider-truenas-g5-version-matrix.json").write_text( + json.dumps(bad), encoding="utf-8" + ) + with self.assertRaisesRegex(RuntimeError, "source oracles are not all true"): + MOD.load_matrix(root, "f" * 40) + + def test_g3_lifecycle_is_version_parameterized_without_changing_default(self): + text = G3_SCRIPT.read_text(encoding="utf-8") + self.assertIn('p.add_argument("--expected-version", default=EXPECTED_VERSION)', text) + self.assertIn('"expected_version": a.expected_version', text) + self.assertIn("if version != a.expected_version:", text) + + def test_wrapper_passes_exact_system_version_to_underlying_lifecycle(self): + text = SCRIPT.read_text(encoding="utf-8") + self.assertIn('"--expected-version", expected_system_version', text) + self.assertIn('"--fixture-producer-commit", args.fixture_producer_commit', text) + self.assertIn('"provider_lifecycle_supported": True', text) + self.assertIn('"runtime_inheritance_prohibited": True', text) + self.assertIn('"zero_unintended_residue": True', text) + + def test_public_boundary_remains_explicit(self): + text = SCRIPT.read_text(encoding="utf-8") + for required in ( + '"github_credentials_present": False', + '"github_jit_registration_exercised": False', + '"private_repository_execution": False', + '"physical_truenas_mutation": False', + '"capacity_promotion": False', + ): + self.assertIn(required, text) + self.assertNotIn("GITHUB_TOKEN", text) + self.assertNotIn("GH_TOKEN", text) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_truenas_garm_vm_pre_b4.py b/tests/test_truenas_garm_vm_pre_b4.py new file mode 100644 index 000000000..f22364984 --- /dev/null +++ b/tests/test_truenas_garm_vm_pre_b4.py @@ -0,0 +1,165 @@ +import copy +import pathlib +import sys +import unittest + +ROOT = pathlib.Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "scripts")) + +import truenas_garm_vm_pre_b4_fixture as vm + +PRODUCER = "5944d1dc62b4da2a247dda9754cfe674caedf5cf" + + +def fixture(): + name = "garm_nested_vm_01" + owner = { + "schema": "semper-supra.garm-vm-owner/1", + "managed_by": "garm-provider-truenas", + "controller_id": "controller", + "pool_id": "pool", + "runner_name": "runner", + "profile": "truenas-vm-linux-general", + } + marker = vm.MARKER_PREFIX + name + flags = {key: True for key in vm.REQUIRED_TRUE} + flags.update({key: False for key in vm.REQUIRED_FALSE}) + return { + "schema": vm.SCHEMA, + "producer_source": PRODUCER, + "authority": "SemperSupra/garm-provider-truenas-private#43", + "target": { + "version": "26.0.0-BETA.3", + "system_version": vm.TARGET, + "driver": "vm-v1", + "control_surface": "vm.*", + "middleware_commit": vm.MIDDLEWARE, + "required_methods": sorted(vm.REQUIRED_METHODS), + "status": "OPEN", + }, + "profile": "truenas-vm-linux-general", + "template_family": "ubuntu-24.04-amd64-template", + "template_version": "ubuntu-24.04-release-20260926-amd64", + "template_runtime_name": vm.TEMPLATE_NAME, + "template_source_url": vm.IMAGE_URL, + "template_source_sha256": vm.IMAGE_SHA256, + "expected_name": name, + "expected_ownership": owner, + "clone": { + "description": "garm-provider-truenas managed VM", + "vcpus": 4, + "memory_bytes": 8 * 1024**3, + "autostart": False, + }, + "seed": { + "run_local_token_placeholder": vm.TOKEN_PLACEHOLDER, + "transport": "provider-owned child dataset + public filesystem.put input pipe", + "attach_device": "owned vm.device CDROM", + "retain_while_active": True, + "delete_only_when_stopped": True, + "consumption_signal_required": True, + "consumption_marker": marker, + "meta_data": "instance-id: owned-1", + "user_data": "\n".join([ + vm.TOKEN_PLACEHOLDER, + marker, + "path: /usr/local/libexec/garm-runner-bootstrap", + "path: /usr/local/libexec/garm-bootstrap", + "name: garm-runner", + "/usr/sbin/runuser -u garm-runner", + 'rm -f "$env_file"', + "/dev/console", + ]), + }, + "source_oracles": flags, + "retirement": [ + "stop VM through vm.stop", "verify STOPPED", + "delete owned NoCloud CDROM through vm.device.delete", + "verify seed device absence", "delete provider-owned seed dataset", + "verify seed dataset absence", + "delete provider-owned VM and cloned boot ZVOL", + "verify final VM absence", + ], + "runner": { + "tool_url": "https://github.com/actions/runner/releases/download/v2.336.0/test.tar.gz", + "tool_sha256": "a" * 64, + }, + } + + +class VMStaticContractTests(unittest.TestCase): + def test_exact_fixture_is_static_only(self): + receipt = vm.validate_fixture(fixture(), PRODUCER) + self.assertEqual(receipt["classification"], "STATIC_CONTRACT_PASS") + self.assertTrue(receipt["source_only"]) + self.assertFalse(receipt["vm_runtime_admitted"]) + self.assertFalse(receipt["bootstrap_consumption_observed"]) + self.assertFalse(receipt["github_jit_exercised"]) + self.assertFalse(receipt["zero_residue_runtime_proven"]) + self.assertNotIn(vm.TOKEN_PLACEHOLDER, str(receipt)) + + def test_rejects_exact_version_and_producer_drift(self): + original = fixture() + for key, value in [ + ("system_version", "TrueNAS-25.10.7"), + ("middleware_commit", "0" * 40), + ("status", "PASS"), + ("driver", "virt-instance-vm"), + ]: + doc = copy.deepcopy(original) + doc["target"][key] = value + with self.subTest(key=key), self.assertRaises(vm.FixtureError): + vm.validate_fixture(doc, PRODUCER) + with self.assertRaises(vm.FixtureError): + vm.validate_fixture(original, "0" * 40) + + def test_rejects_template_and_vm_name_drift(self): + original = fixture() + for key, value in [ + ("template_runtime_name", "ubuntu_current"), + ("template_source_sha256", "0" * 64), + ("expected_name", "garm-runner-with-hyphens"), + ]: + doc = copy.deepcopy(original) + doc[key] = value + with self.subTest(key=key), self.assertRaises(vm.FixtureError): + vm.validate_fixture(doc, PRODUCER) + + def test_rejects_unearned_claims(self): + for flag in vm.REQUIRED_FALSE: + doc = fixture() + doc["source_oracles"][flag] = True + with self.subTest(flag=flag), self.assertRaises(vm.FixtureError): + vm.validate_fixture(doc, PRODUCER) + + def test_rejects_ownership_and_privilege_drift(self): + doc = fixture() + doc["expected_ownership"]["managed_by"] = "foreign-controller" + with self.assertRaises(vm.FixtureError): + vm.validate_fixture(doc, PRODUCER) + doc = fixture() + doc["clone"]["autostart"] = True + with self.assertRaises(vm.FixtureError): + vm.validate_fixture(doc, PRODUCER) + doc = fixture() + doc["seed"]["user_data"] = "missing bootstrap" + with self.assertRaises(vm.FixtureError): + vm.validate_fixture(doc, PRODUCER) + doc = fixture() + doc["seed"]["meta_data"] = vm.TOKEN_PLACEHOLDER + with self.assertRaises(vm.FixtureError): + vm.validate_fixture(doc, PRODUCER) + + def test_rejects_cleanup_and_missing_methods(self): + doc = fixture() + doc["target"]["required_methods"].remove("vm.device.delete") + with self.assertRaises(vm.FixtureError): + vm.validate_fixture(doc, PRODUCER) + doc = fixture() + doc["retirement"] = [] + with self.assertRaises(vm.FixtureError): + vm.validate_fixture(doc, PRODUCER) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_truenas_garm_vm_template_preflight.py b/tests/test_truenas_garm_vm_template_preflight.py new file mode 100644 index 000000000..1d8374d3f --- /dev/null +++ b/tests/test_truenas_garm_vm_template_preflight.py @@ -0,0 +1,94 @@ +import pathlib +import sys +import unittest + +ROOT = pathlib.Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "scripts")) +sys.path.insert(0, str(ROOT / "tests")) +import truenas_garm_vm_template_preflight as pre +from test_truenas_garm_vm_pre_b4 import PRODUCER, fixture + +def snapshot(): + return { + "schema": pre.SNAPSHOT_SCHEMA, + "version": pre.TARGET, + "available_methods": sorted(pre.REQUIRED_METHODS), + "template_rows": [{"name": pre.validate_fixture(fixture(), PRODUCER)["template_runtime_name"], "id": 42}], + "template_state": "STOPPED", + "template_devices": [ + {"dtype": "DISK", "zvol_backed": True, "raw_backed": False}, + {"dtype": "NIC", "zvol_backed": False, "raw_backed": False}, + ], + "read_methods_used": ["system.version", "core.get_methods", "vm.query", + "vm.status", "vm.device.query"], + } + +class VMReadOnlyPreflightTests(unittest.TestCase): + def test_suitable_observation_does_not_prove_source_or_support(self): + result = pre.evaluate(fixture(), PRODUCER, snapshot()) + self.assertEqual(result["classification"], "TEMPLATE_OBSERVED_SOURCE_UNVERIFIED") + self.assertTrue(result["oracleSatisfied"]) + for key in ("runtime_mutation_performed", "runtime_support_admitted", + "template_source_provenance_verified", "guest_boot_observed", + "zero_residue_runtime_proven"): + self.assertFalse(result[key]) + + def test_observer_uses_only_reads_and_redacts_opaque_fields(self): + name = pre.validate_fixture(fixture(), PRODUCER)["template_runtime_name"] + calls = [] + def rpc(method, params): + calls.append(method) + if method == "system.version": return pre.TARGET + if method == "core.get_methods": return {m: {} for m in pre.REQUIRED_METHODS} + if method == "vm.query": return [{"name": name, "id": 42, "opaque": "not-for-receipt"}] + if method == "vm.status": return {"state": "STOPPED"} + if method == "vm.device.query": + return [{"attributes": {"dtype": "DISK", "path": "/dev/zvol/rdtepool/base", "opaque": "not-for-receipt"}}] + raise AssertionError(method) + result = pre.collect_snapshot(rpc, name) + self.assertEqual(calls, ["system.version", "core.get_methods", "vm.query", + "vm.status", "vm.device.query"]) + self.assertNotIn("not-for-receipt", str(result)) + self.assertNotIn("/dev/zvol/rdtepool/base", str(result)) + self.assertEqual(pre.evaluate(fixture(), PRODUCER, result)["classification"], + "TEMPLATE_OBSERVED_SOURCE_UNVERIFIED") + + def test_absent_wrong_version_or_methods_fail_closed(self): + s = snapshot() + s["template_rows"] = [] + s["read_methods_used"] = s["read_methods_used"][:3] + self.assertEqual(pre.evaluate(fixture(), PRODUCER, s)["reason_code"], "TEMPLATE_MISSING") + s = snapshot() + s["version"] = "TrueNAS-25.10.7" + self.assertEqual(pre.evaluate(fixture(), PRODUCER, s)["reason_code"], "EXACT_TARGET_MISMATCH") + s = snapshot() + s["available_methods"].remove("vm.clone") + self.assertEqual(pre.evaluate(fixture(), PRODUCER, s)["reason_code"], "VM_METHODS_ABSENT") + + def test_state_disk_shape_and_ownership(self): + s = snapshot() + s["template_state"] = "RUNNING" + self.assertEqual(pre.evaluate(fixture(), PRODUCER, s)["reason_code"], "TEMPLATE_NOT_STOPPED") + s = snapshot() + s["template_devices"][0] = {"dtype": "RAW", "raw_backed": True} + self.assertEqual(pre.evaluate(fixture(), PRODUCER, s)["reason_code"], "RAW_DISK_NOT_CLONEABLE") + s = snapshot() + s["template_devices"][0]["zvol_backed"] = False + self.assertEqual(pre.evaluate(fixture(), PRODUCER, s)["reason_code"], "ZVOL_BOOT_DISK_NOT_EXACT") + s = snapshot() + s["template_rows"][0]["name"] = "foreign_vm" + self.assertEqual(pre.evaluate(fixture(), PRODUCER, s)["reason_code"], "TEMPLATE_NAME_DRIFT") + s = snapshot() + s["template_rows"].append(s["template_rows"][0].copy()) + self.assertEqual(pre.evaluate(fixture(), PRODUCER, s)["reason_code"], "TEMPLATE_AMBIGUOUS") + + def test_untrusted_observer_and_unsubstantiated_provenance(self): + s = snapshot() + s["read_methods_used"].append("vm.start") + self.assertEqual(pre.evaluate(fixture(), PRODUCER, s)["reason_code"], "UNTRUSTED_OBSERVER") + s = snapshot() + s["source_provenance_receipt"] = {"claimed": True} + self.assertFalse(pre.evaluate(fixture(), PRODUCER, s)["template_source_provenance_verified"]) + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_truenas_rdte_target.py b/tests/test_truenas_rdte_target.py new file mode 100644 index 000000000..76faad859 --- /dev/null +++ b/tests/test_truenas_rdte_target.py @@ -0,0 +1,124 @@ +#!/usr/bin/env python3 +import importlib.util, json, pathlib, unittest + +ROOT=pathlib.Path(__file__).resolve().parents[1] +SPEC=importlib.util.spec_from_file_location("truenas_rdte_target", ROOT/"scripts"/"truenas_rdte_target.py") +MOD=importlib.util.module_from_spec(SPEC); assert SPEC.loader; SPEC.loader.exec_module(MOD) +REG=ROOT/"config"/"truenas-rdte-targets.json" +RUN_REQUEST=ROOT/"config"/"truenas-rdte-run-request.json" + +class TargetRegistryTests(unittest.TestCase): + def setUp(self): + self.targets=MOD.load_registry(REG) + def test_exact_supported_matrix(self): + self.assertEqual(set(self.targets), {"25.04.1","25.04.2.6","25.10.7","26.0.0-BETA.3"}) + def test_exact_source_anchors(self): + self.assertEqual(self.targets["25.04.1"]["middleware_commit"], "74ab5a2d373be4097dece257d00e1086376333ba") + self.assertEqual(self.targets["25.04.2.6"]["middleware_commit"], "244b717370fe35fb9eefc3096acfbc25f12b57b6") + self.assertEqual(self.targets["25.10.7"]["middleware_commit"], "8ede398839710e56893d88ce85088139d8fab18e") + self.assertEqual(self.targets["26.0.0-BETA.3"]["middleware_commit"], "81e1265a86083888ba94a2bdfc02ff5c9c5ef6a3") + def test_exact_foundry_profile_blobs(self): + expected = {"25.04.1":"800aca038ded29e6a91bfc4c1b1fe2d2199182e8","25.04.2.6":"631467044411c96a0fe6d361688b99a794f07c80","25.10.7":"1320cc849a9cc1e7ff6bd78989fc0e4708397248","26.0.0-BETA.3":"a8ebc533dc84172520eba2237ffeeb52b102dc24"} + for version, blob in expected.items(): + self.assertEqual(self.targets[version]["foundry_profile_blob"], blob) + + def test_vendor_media_is_exact_and_has_digest_sidecar(self): + for version,target in self.targets.items(): + self.assertIn(version, target["iso_name"]) + self.assertEqual(target["sha256_url"], target["iso_url"]+".sha256") + lower=target["iso_url"].lower() + self.assertNotIn("latest", lower) + self.assertNotIn("nightly", lower) + self.assertNotIn("master+", lower) + def test_exact_installed_version_identity_for_stable_target(self): + self.assertEqual(self.targets["25.10.7"]["system_version"], "TrueNAS-25.10.7") + + def test_exact_installed_version_identity_for_legacy_target(self): + self.assertEqual(self.targets["25.04.1"]["system_version"], "TrueNAS-25.04.1") + self.assertEqual(self.targets["25.04.2.6"]["system_version"], "TrueNAS-25.04.2.6") + + def test_apps_gate_deltas_are_not_normalized_away(self): + self.assertEqual(self.targets["25.04.1"]["ha_apps_gate"], "system.license:JAILS") + self.assertEqual(self.targets["25.04.2.6"]["ha_apps_gate"], "system.license:JAILS") + self.assertEqual(self.targets["25.10.7"]["ha_apps_gate"], "system.license:JAILS") + self.assertEqual(self.targets["26.0.0-BETA.3"]["ha_apps_gate"], "system.feature_enabled:APPS") + def test_installer_rpc_endpoint_is_exact_version_profiled(self): + expected = { + "25.04.1": ("/", 80, "release/25.04.1", "4845a665568fcdb8ed911f3f6a23f6373cbe0b8f"), + "25.04.2.6": ("/", 80, "release/25.04.2.6", "4845a665568fcdb8ed911f3f6a23f6373cbe0b8f"), + "25.10.7": ("/ws", 8080, "release/25.10.7", "9e4f8b2bd9db497210fe6737c053080137be94d8"), + "26.0.0-BETA.3": ("/ws", 8080, "release/26.0.0-BETA.3", "9e4f8b2bd9db497210fe6737c053080137be94d8"), + } + for version, (path, port, source_ref, main_blob) in expected.items(): + target = self.targets[version] + self.assertEqual(target["installer_rpc_path"], path) + self.assertEqual(target["installer_rpc_guest_port"], port) + self.assertEqual(target["installer_source_ref"], source_ref) + self.assertEqual(target["installer_main_blob_sha"], main_blob) + + def test_foliorelay_requested_shape_is_source_bound_not_stale_literal(self): + harness=(ROOT/"scripts"/"gha_kvm_truenas_rdte.sh").read_text(encoding="utf-8") + self.assertNotIn("c8d5787162db919f84e9607d13f368995138861355f3fa269cbb10561f24d80d", harness) + self.assertIn('export R_FOUNDRY_CONTROL_DIR="$FOUNDRY_CONTROL_DIR"', harness) + self.assertIn('control = json.loads((control_dir / "control.json").read_text', harness) + self.assertIn('"images": foliorelay_images', harness) + + def test_unknown_exact_version_fails_closed(self): + with self.assertRaises(MOD.TargetError): + targets=self.targets + if "26.0.0-RC.1" not in targets: + raise MOD.TargetError("exact TrueNAS target is not registered: 26.0.0-RC.1") + + + def test_run_request_is_exact_registered_target(self): + request=json.loads(RUN_REQUEST.read_text(encoding="utf-8")) + self.assertEqual(request["schema"], "gha-kvm-truenas-run-request/v1") + self.assertIn(request["rung"], {"t0","t1","t2","t3","t4","t5","t6"}) + self.assertIn(request["version"], self.targets) + target=self.targets[request["version"]] + self.assertEqual(request["authority_issue"], target["authority_issue"]) + if request["rung"] == "t6": + self.assertIn(request.get("product", "litellm"), {"litellm","wow-sidecar","garm","garm-controller-f0f5","garm-provider-g2","garm-provider-g3","garm-provider-g4","garm-provider-g5","garm-provider-container-pre-b4","official-catalog","foliorelay","minecraft-paper","collage-native-paper","minecraft-vanilla-263"}) + if request.get("product") == "collage-native-paper": + self.assertEqual(request["rung"], "t6") + self.assertEqual(request["version"], "25.04.1") + # #614 owns the P2 read-only join; #625 is its independently + # source-locked Minecraft 1.15.30 qualification successor. + # This is a constrained allowlist, not arbitrary issue authority. + self.assertIn(request["consumer_authority_issue"], {614, 625}) + if request.get("product") == "minecraft-vanilla-263": + self.assertEqual(request["rung"], "t6") + self.assertEqual(request["version"], "25.04.2.6") + self.assertEqual(request["authority_issue"], 423) + self.assertEqual(request["consumer_authority_issue"], 627) + self.assertEqual(request["request_id"], + "tn-25-04-2-6-t6-vanilla-263-op4m-020-namespaced-overworld-anvil-three-checkpoint") + self.assertIs(request.get("collage_readback"), True) + self.assertIs(request.get("lantern_stage"), True) + self.assertIs(request.get("native_offline_player"), True) + self.assertIs(request.get("native_world_rollback"), True) + self.assertIs(request.get("native_backend_region"), True) + self.assertEqual(set(request), {"schema", "request_id", "version", "rung", "product", + "authority_issue", "consumer_authority_issue", + "purpose", "collage_readback", "lantern_stage", + "native_world_rollback", "native_offline_player", "native_backend_region"}) + if request.get("product") == "wow-sidecar": + self.assertEqual(request.get("consumer_authority_issue"), 276) + if request.get("product") == "garm": + self.assertEqual(request.get("consumer_authority_issue"), 277) + if request.get("product") == "garm-controller-f0f5": + self.assertEqual(request.get("consumer_authority_issue"), 282) + if request.get("product") == "garm-provider-g2": + self.assertEqual(request.get("consumer_authority_issue"), 39) + if request.get("product") == "garm-provider-g3": + self.assertEqual(request.get("consumer_authority_issue"), 40) + if request.get("product") == "garm-provider-container-pre-b4": + self.assertEqual(request.get("consumer_authority_issue"), 43) + if request.get("product") == "foliorelay": + self.assertEqual(request.get("consumer_authority_issue"), 273) + self.assertNotIn("latest", request["version"].lower()) + self.assertNotIn("nightly", request["version"].lower()) + + +if __name__=="__main__": + unittest.main() diff --git a/tests/test_truenas_session_manifest.py b/tests/test_truenas_session_manifest.py new file mode 100644 index 000000000..ed9209237 --- /dev/null +++ b/tests/test_truenas_session_manifest.py @@ -0,0 +1,170 @@ +#!/usr/bin/env python3 +import importlib.util +import json +import pathlib +import tempfile +import unittest + +ROOT = pathlib.Path(__file__).resolve().parents[1] +SPEC = importlib.util.spec_from_file_location( + "truenas_session_manifest", ROOT / "scripts" / "truenas_session_manifest.py" +) +MOD = importlib.util.module_from_spec(SPEC) +assert SPEC.loader +SPEC.loader.exec_module(MOD) +TARGETS = ROOT / "config" / "truenas-rdte-targets.json" +PROVIDERS = ROOT / "config" / "truenas-capsule-providers.json" + +def base_manifest(): + doc = { + "schema": "truenas-session/v1", + "session_id": "tn-26-beta3-session-contract-001", + "version": "26.0.0-BETA.3", + "authority": { + "repository": "SemperSupra/agent-dispatch-private", + "issue": 480 + }, + "budget": { + "guest_memory_mib": 8192, + "download_mib": 1536, + "timeout_minutes": 45 + }, + "pool": {"name": "rdtepool", "data_disks": 2}, + "capsules": [{ + "schema": "truenas-capsule/v1", + "id": "official-catalog-control-001", + "kind": "official-catalog-control", + "provider": "official-catalog-t6", + "authority": { + "repository": "SemperSupra/truenas-app-foundry-private", + "issue": 281 + }, + "exact": { + "source_ref": "60d56cc1752bc825cb232d72b397ff17fe6d47c4", + "artifact": "official-catalog-truenas-t6-control" + }, + "namespace": "rdte-official-catalog-001", + "ports": [18080], + "requirements": ["apps", "zfs-pool", "native-catalog"], + "resources": { + "memory_mib": 1024, + "download_mib": 512, + "estimated_minutes": 10 + }, + "phases": { + "setup": True, "apply": True, "verify": True, "cleanup": True + }, + "oracle": "native catalog lifecycle readback plus zero-residue cleanup", + "receipt_contract": "truenas-capsule-receipt/v1", + "retry_policy": "read-only-reconcile", + "mutating": True, + "cleanup_required": True, + "dependencies": [] + }] + } + doc["manifest_sha256"] = MOD.manifest_digest(doc) + return doc + +class SessionManifestTests(unittest.TestCase): + def write(self, doc): + tmp = tempfile.NamedTemporaryFile("w", suffix=".json", delete=False) + with tmp: + json.dump(doc, tmp, indent=2, sort_keys=True) + tmp.write("\n") + path = pathlib.Path(tmp.name) + self.addCleanup(lambda: path.unlink(missing_ok=True)) + return path + + def validate(self, doc): + doc["manifest_sha256"] = MOD.manifest_digest(doc) + return MOD.validate_manifest(self.write(doc), TARGETS, PROVIDERS) + + def test_valid_single_capsule_manifest(self): + result = self.validate(base_manifest()) + self.assertEqual(result["capsule_count"], 1) + self.assertEqual(result["mutating_capsule_count"], 1) + self.assertEqual(result["version"], "26.0.0-BETA.3") + + def test_manifest_hash_fails_closed_on_drift(self): + doc = base_manifest() + path = self.write(doc) + loaded = json.loads(path.read_text()) + loaded["capsules"][0]["namespace"] = "rdte-mutated-after-lock" + path.write_text(json.dumps(loaded), encoding="utf-8") + with self.assertRaisesRegex(MOD.SessionError, "manifest_sha256 mismatch"): + MOD.validate_manifest(path, TARGETS, PROVIDERS) + + def test_unknown_target_fails_closed(self): + doc = base_manifest() + doc["version"] = "26.0.0-RC.1" + with self.assertRaisesRegex(MOD.SessionError, "not registered"): + self.validate(doc) + + def test_unknown_provider_fails_closed(self): + doc = base_manifest() + doc["capsules"][0]["provider"] = "invented-provider" + with self.assertRaisesRegex(MOD.SessionError, "not registered"): + self.validate(doc) + + def test_mutating_capsule_requires_cleanup(self): + doc = base_manifest() + doc["capsules"][0]["cleanup_required"] = False + with self.assertRaisesRegex(MOD.SessionError, "must require cleanup"): + self.validate(doc) + + def test_duplicate_namespace_rejected(self): + doc = base_manifest() + second = json.loads(json.dumps(doc["capsules"][0])) + second["id"] = "official-catalog-control-002" + second["ports"] = [18081] + doc["capsules"].append(second) + with self.assertRaisesRegex(MOD.SessionError, "namespaces must be unique"): + self.validate(doc) + + def test_global_port_collision_rejected(self): + doc = base_manifest() + second = json.loads(json.dumps(doc["capsules"][0])) + second["id"] = "official-catalog-control-002" + second["namespace"] = "rdte-official-catalog-002" + doc["capsules"].append(second) + with self.assertRaisesRegex(MOD.SessionError, "ports must be globally unique"): + self.validate(doc) + + def test_resource_budget_rejected_before_runtime(self): + doc = base_manifest() + doc["capsules"][0]["resources"]["estimated_minutes"] = 46 + with self.assertRaisesRegex(MOD.SessionError, "estimated time exceeds"): + self.validate(doc) + + def test_dependency_cycle_rejected(self): + doc = base_manifest() + first = doc["capsules"][0] + second = json.loads(json.dumps(first)) + second["id"] = "official-catalog-control-002" + second["namespace"] = "rdte-official-catalog-002" + second["ports"] = [18081] + first["dependencies"] = [second["id"]] + second["dependencies"] = [first["id"]] + doc["capsules"].append(second) + with self.assertRaisesRegex(MOD.SessionError, "dependency cycle"): + self.validate(doc) + + def test_provider_registry_wraps_existing_probes(self): + registry = json.loads(PROVIDERS.read_text(encoding="utf-8")) + expected = { + "litellm-t6": "scripts/truenas_middleware_litellm_t6_probe.py", + "wow-sidecar-t6": "scripts/truenas_middleware_wow_sidecar_t6_probe.py", + "garm-t6": "scripts/truenas_middleware_garm_t6_probe.py", + "official-catalog-t6": "scripts/truenas_middleware_official_catalog_t6_probe.py", + "foliorelay-t6": "scripts/truenas_middleware_foliorelay_t6_probe.py", + "minecraft-paper-t6": "scripts/truenas_middleware_minecraft_t6_probe.py" + } + actual = {item["id"]: item["probe"] for item in registry["providers"]} + self.assertEqual(actual, expected) + folio = next(item for item in registry["providers"] if item["id"] == "foliorelay-t6") + self.assertEqual(folio["control_artifact"], "foliorelay-t6-control") + for probe in actual.values(): + self.assertTrue((ROOT / probe).is_file(), probe) + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_truenas_session_runner.py b/tests/test_truenas_session_runner.py new file mode 100644 index 000000000..80518a6fa --- /dev/null +++ b/tests/test_truenas_session_runner.py @@ -0,0 +1,204 @@ +#!/usr/bin/env python3 +import json +import pathlib +import stat +import sys +import tempfile +import unittest + +ROOT = pathlib.Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "scripts")) + +import truenas_session_manifest as manifest_mod +import truenas_session_runner as runner +from truenas_session_manifest import SessionError + +TARGETS = ROOT / "config" / "truenas-rdte-targets.json" +PROVIDERS = ROOT / "config" / "truenas-capsule-providers.json" + + +def capsule(cid, provider, port, dependencies=None): + return { + "schema": "truenas-capsule/v1", + "id": cid, + "kind": "official-catalog-control" if provider == "official-catalog-t6" else "foundry-product", + "provider": provider, + "authority": {"repository": "SemperSupra/truenas-app-foundry-private", "issue": 282}, + "exact": {"source_ref": "0123456789012345678901234567890123456789", "artifact": "fixture"}, + "namespace": f"rdte-{cid}", + "ports": [port], + "requirements": ["apps", "zfs-pool"], + "resources": {"memory_mib": 1024, "download_mib": 128, "estimated_minutes": 5}, + "phases": {"setup": True, "apply": True, "verify": True, "cleanup": True}, + "oracle": "existing provider oracle", + "receipt_contract": "truenas-capsule-execution/v1", + "retry_policy": "read-only-reconcile", + "mutating": True, + "cleanup_required": True, + "dependencies": dependencies or [], + } + + +def manifest(capsules): + doc = { + "schema": "truenas-session/v1", + "session_id": "tn-26-beta3-runner-static-001", + "version": "26.0.0-BETA.3", + "authority": {"repository": "SemperSupra/agent-dispatch-private", "issue": 480}, + "budget": {"guest_memory_mib": 8192, "download_mib": 1024, "timeout_minutes": 45}, + "pool": {"name": "rdtepool", "data_disks": 2}, + "capsules": capsules, + } + doc["manifest_sha256"] = manifest_mod.manifest_digest(doc) + return doc + + +FAKE_EXECUTOR = r"""#!/usr/bin/env python3 +import argparse, json, pathlib, sys +p=argparse.ArgumentParser() +p.add_argument("--capsule", required=True) +p.add_argument("--context", required=True) +p.add_argument("--out", required=True) +a=p.parse_args() +desc=json.loads(pathlib.Path(a.capsule).read_text()) +ctx=json.loads(pathlib.Path(a.context).read_text()) +cap=desc["capsule"]; provider=desc["provider"] +cfg=ctx[cap["id"]] +if cfg.get("emit") is False: + raise SystemExit(cfg.get("returncode", 3)) +receipt={ + "schema":"truenas-capsule-execution/v1", + "capsule_id":cap["id"], + "provider":cap["provider"], + "provider_probe":provider["probe"], + "manifest_sha256":desc["manifest_sha256"], + "verdict":cfg.get("verdict","SUPPORTED"), + "mutating":cap["mutating"], + "cleanup_required":cap["cleanup_required"], + "cleanup_satisfied":cfg.get("cleanup_satisfied",True), + "platform_healthy":cfg.get("platform_healthy",True), + "authority_satisfied":cfg.get("authority_satisfied",True), + "resource_guardrail_satisfied":cfg.get("resource_guardrail_satisfied",True) +} +if "manifest_sha256" in cfg: + receipt["manifest_sha256"]=cfg["manifest_sha256"] +pathlib.Path(a.out).write_text(json.dumps(receipt)) +raise SystemExit(cfg.get("returncode",0)) +""" + + +class SessionRunnerTests(unittest.TestCase): + def temp_json(self, value): + f = tempfile.NamedTemporaryFile("w", suffix=".json", delete=False) + with f: + json.dump(value, f, indent=2, sort_keys=True) + f.write("\n") + path = pathlib.Path(f.name) + self.addCleanup(lambda: path.unlink(missing_ok=True)) + return path + + def executor(self): + f = tempfile.NamedTemporaryFile("w", suffix=".py", delete=False) + with f: + f.write(FAKE_EXECUTOR) + path = pathlib.Path(f.name) + path.chmod(path.stat().st_mode | stat.S_IXUSR) + self.addCleanup(lambda: path.unlink(missing_ok=True)) + return path + + def execute_session(self, doc, context): + root = pathlib.Path(tempfile.mkdtemp()) + self.addCleanup(lambda: __import__("shutil").rmtree(root, ignore_errors=True)) + return runner.run_session( + self.temp_json(doc), + TARGETS, + PROVIDERS, + self.executor(), + self.temp_json(context), + root / "receipts", + ) + + def test_clean_session_preserves_independent_product_failure(self): + doc = manifest([ + capsule("catalog-control", "official-catalog-t6", 18080), + capsule("foliorelay", "foliorelay-t6", 18081), + ]) + result = self.execute_session(doc, { + "catalog-control": {"verdict": "SUPPORTED"}, + "foliorelay": {"verdict": "ORACLE_FAILURE"}, + }) + self.assertEqual(result["classification"], "SESSION_CLEAN") + self.assertTrue(result["session_clean"]) + self.assertFalse(result["product_acceptance_inferred"]) + self.assertEqual([x["verdict"] for x in result["capsules"]], ["SUPPORTED", "ORACLE_FAILURE"]) + + def test_cleanup_failure_stops_later_mutation(self): + doc = manifest([ + capsule("first", "official-catalog-t6", 18080), + capsule("second", "foliorelay-t6", 18081), + ]) + result = self.execute_session(doc, { + "first": {"verdict": "ORACLE_FAILURE", "cleanup_satisfied": False}, + "second": {"verdict": "SUPPORTED"}, + }) + self.assertEqual(result["classification"], "SESSION_CONTAMINATED") + self.assertEqual(result["capsules"][1]["verdict"], "NOT_EXECUTED") + self.assertIn("SESSION_CONTAMINATED", result["capsules"][1]["reason"]) + + def test_failed_dependency_skips_only_dependent_capsule(self): + doc = manifest([ + capsule("first", "official-catalog-t6", 18080), + capsule("dependent", "foliorelay-t6", 18081, ["first"]), + capsule("independent", "garm-t6", 18082), + ]) + result = self.execute_session(doc, { + "first": {"verdict": "ORACLE_FAILURE"}, + "dependent": {"verdict": "SUPPORTED"}, + "independent": {"verdict": "SUPPORTED"}, + }) + verdicts = {x["capsule_id"]: x["verdict"] for x in result["capsules"]} + self.assertEqual(verdicts["first"], "ORACLE_FAILURE") + self.assertEqual(verdicts["dependent"], "NOT_EXECUTED") + self.assertEqual(verdicts["independent"], "SUPPORTED") + self.assertEqual(result["classification"], "SESSION_CLEAN") + + def test_nonexecutable_python_adapter_uses_interpreter(self): + doc = manifest([capsule("first", "official-catalog-t6", 18080)]) + root = pathlib.Path(tempfile.mkdtemp()) + self.addCleanup(lambda: __import__("shutil").rmtree(root, ignore_errors=True)) + executable = self.executor() + executable.chmod(0o644) + self.assertFalse(executable.stat().st_mode & stat.S_IXUSR) + result = runner.run_session( + self.temp_json(doc), TARGETS, PROVIDERS, executable, + self.temp_json({"first": {"verdict": "SUPPORTED"}}), root / "receipts" + ) + self.assertEqual(result["capsules"][0]["verdict"], "SUPPORTED") + + def test_executor_without_receipt_fails_closed(self): + doc = manifest([ + capsule("first", "official-catalog-t6", 18080), + capsule("second", "foliorelay-t6", 18081), + ]) + result = self.execute_session(doc, { + "first": {"emit": False, "returncode": 7}, + "second": {"verdict": "SUPPORTED"}, + }) + self.assertEqual(result["classification"], "SESSION_CONTAMINATED") + self.assertEqual(result["capsules"][0]["verdict"], "HARNESS_FAILURE") + self.assertFalse(result["capsules"][0]["platform_healthy"]) + self.assertEqual(result["capsules"][1]["verdict"], "NOT_EXECUTED") + + def test_receipt_manifest_drift_fails_closed(self): + doc = manifest([capsule("first", "official-catalog-t6", 18080)]) + with self.assertRaisesRegex(SessionError, "receipt manifest identity mismatch"): + self.execute_session(doc, { + "first": { + "verdict": "SUPPORTED", + "manifest_sha256": "0" * 64, + } + }) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_truenas_session_state.py b/tests/test_truenas_session_state.py new file mode 100644 index 000000000..357bcb843 --- /dev/null +++ b/tests/test_truenas_session_state.py @@ -0,0 +1,73 @@ +#!/usr/bin/env python3 +import pathlib +import sys +import unittest + +ROOT=pathlib.Path(__file__).resolve().parents[1] +sys.path.insert(0,str(ROOT/"scripts")) +from truenas_session_state import CapsuleOutcome, decide_after_capsule, finalize_session + +def outcome(**overrides): + base=dict( + capsule_id="capsule-001", + verdict="SUPPORTED", + mutating=True, + cleanup_required=True, + cleanup_satisfied=True, + platform_healthy=True, + authority_satisfied=True, + resource_guardrail_satisfied=True, + ) + base.update(overrides) + return CapsuleOutcome(**base) + +class SessionStateTests(unittest.TestCase): + def test_supported_clean_capsule_allows_next(self): + d=decide_after_capsule(outcome()) + self.assertTrue(d.continue_mutation) + self.assertEqual(d.session_classification,"IN_PROGRESS") + + def test_product_oracle_failure_with_clean_membrane_allows_independent_next(self): + d=decide_after_capsule(outcome(verdict="ORACLE_FAILURE")) + self.assertTrue(d.continue_mutation) + self.assertEqual(d.next_state,"READY") + self.assertEqual(finalize_session([outcome(verdict="ORACLE_FAILURE")]),"SESSION_CLEAN") + + def test_cleanup_failure_contaminates_and_stops(self): + d=decide_after_capsule(outcome(verdict="ORACLE_FAILURE",cleanup_satisfied=False)) + self.assertFalse(d.continue_mutation) + self.assertEqual(d.session_classification,"SESSION_CONTAMINATED") + + def test_platform_health_failure_stops(self): + d=decide_after_capsule(outcome(platform_healthy=False)) + self.assertFalse(d.continue_mutation) + self.assertEqual(d.session_classification,"PLATFORM_UNHEALTHY") + + def test_authority_drift_stops_before_later_mutation(self): + d=decide_after_capsule(outcome(authority_satisfied=False)) + self.assertFalse(d.continue_mutation) + self.assertEqual(d.session_classification,"AUTHORITY_MISMATCH") + + def test_resource_guardrail_stops(self): + d=decide_after_capsule(outcome(resource_guardrail_satisfied=False)) + self.assertFalse(d.continue_mutation) + self.assertEqual(d.session_classification,"RESOURCE_GUARDRAIL") + + def test_read_only_failure_does_not_require_cleanup_if_platform_is_healthy(self): + d=decide_after_capsule(outcome( + verdict="ORACLE_FAILURE",mutating=False,cleanup_required=False,cleanup_satisfied=False + )) + self.assertTrue(d.continue_mutation) + + def test_unknown_verdict_fails_closed(self): + d=decide_after_capsule(outcome(verdict="MAGIC_PASS")) + self.assertFalse(d.continue_mutation) + self.assertEqual(d.session_classification,"HARNESS_FAILURE") + + def test_session_clean_does_not_mean_all_capsules_supported(self): + rows=[outcome(),outcome(capsule_id="capsule-002",verdict="DOCUMENTED_NEGATIVE")] + self.assertEqual(finalize_session(rows),"SESSION_CLEAN") + self.assertEqual(rows[1].verdict,"DOCUMENTED_NEGATIVE") + +if __name__=="__main__": + unittest.main() diff --git a/tests/test_truenas_single_capsule_adapter.py b/tests/test_truenas_single_capsule_adapter.py new file mode 100644 index 000000000..7e09e6d6d --- /dev/null +++ b/tests/test_truenas_single_capsule_adapter.py @@ -0,0 +1,84 @@ +#!/usr/bin/env python3 +import importlib.util +import json +import pathlib +import tempfile +import unittest + +ROOT=pathlib.Path(__file__).resolve().parents[1] +SCRIPTS=ROOT/"scripts" +import sys +sys.path.insert(0,str(SCRIPTS)) +import truenas_session_manifest as session +import truenas_single_capsule_adapter as adapter + +TARGETS=ROOT/"config"/"truenas-rdte-targets.json" +PROVIDERS=ROOT/"config"/"truenas-capsule-providers.json" + +EXPECTED={ + "litellm-t6":("litellm","litellm-truenas-t6-control","scripts/truenas_middleware_litellm_t6_probe.py"), + "wow-sidecar-t6":("wow-sidecar","wow-sidecar-truenas-t6-control","scripts/truenas_middleware_wow_sidecar_t6_probe.py"), + "garm-t6":("garm","garm-truenas-t6-control","scripts/truenas_middleware_garm_t6_probe.py"), + "official-catalog-t6":("official-catalog","official-catalog-truenas-t6-control","scripts/truenas_middleware_official_catalog_t6_probe.py"), + "foliorelay-t6":("foliorelay","foliorelay-t6-control","scripts/truenas_middleware_foliorelay_t6_probe.py"), +} + +def manifest(provider): + doc={ + "schema":"truenas-session/v1", + "session_id":"tn-26-beta3-single-equivalence-001", + "version":"26.0.0-BETA.3", + "authority":{"repository":"SemperSupra/agent-dispatch-private","issue":480}, + "budget":{"guest_memory_mib":8192,"download_mib":1024,"timeout_minutes":30}, + "pool":{"name":"rdtepool","data_disks":2}, + "capsules":[{ + "schema":"truenas-capsule/v1","id":"equivalence-capsule-001", + "kind":"official-catalog-control" if provider=="official-catalog-t6" else "foundry-product", + "provider":provider, + "authority":{"repository":"SemperSupra/truenas-app-foundry-private","issue":282}, + "exact":{"source_ref":"0123456789012345678901234567890123456789","artifact":"fixture"}, + "namespace":"rdte-equivalence-001","ports":[18080], + "requirements":["apps","zfs-pool"], + "resources":{"memory_mib":1024,"download_mib":256,"estimated_minutes":10}, + "phases":{"setup":True,"apply":True,"verify":True,"cleanup":True}, + "oracle":"existing product oracle unchanged", + "receipt_contract":"existing-product-receipt/unchanged", + "retry_policy":"read-only-reconcile","mutating":True,"cleanup_required":True, + "dependencies":[] + }] + } + doc["manifest_sha256"]=session.manifest_digest(doc) + return doc + +class SingleCapsuleEquivalenceTests(unittest.TestCase): + def write(self,doc): + f=tempfile.NamedTemporaryFile("w",suffix=".json",delete=False) + with f: + json.dump(doc,f,indent=2,sort_keys=True); f.write("\n") + p=pathlib.Path(f.name) + self.addCleanup(lambda:p.unlink(missing_ok=True)) + return p + + def test_every_existing_t6_provider_lowers_without_runtime_semantic_change(self): + for provider,(selector,artifact,probe) in EXPECTED.items(): + with self.subTest(provider=provider): + lowered=adapter.lower_single(self.write(manifest(provider)),TARGETS,PROVIDERS) + self.assertEqual(lowered["existing_selector"],selector) + self.assertEqual(lowered["control_artifact"],artifact) + self.assertEqual(lowered["probe"],probe) + self.assertFalse(lowered["runtime_semantics_changed"]) + self.assertEqual(lowered["manifest_sha256"],session.manifest_digest(manifest(provider))) + + def test_multi_capsule_manifest_is_rejected_by_equivalence_adapter(self): + doc=manifest("foliorelay-t6") + second=json.loads(json.dumps(doc["capsules"][0])) + second["id"]="equivalence-capsule-002" + second["namespace"]="rdte-equivalence-002" + second["ports"]=[18081] + doc["capsules"].append(second) + doc["manifest_sha256"]=session.manifest_digest(doc) + with self.assertRaisesRegex(session.SessionError,"exactly one capsule"): + adapter.lower_single(self.write(doc),TARGETS,PROVIDERS) + +if __name__=="__main__": + unittest.main() diff --git a/tests/test_truenas_t6_foliorelay_control.py b/tests/test_truenas_t6_foliorelay_control.py new file mode 100644 index 000000000..19e651cfd --- /dev/null +++ b/tests/test_truenas_t6_foliorelay_control.py @@ -0,0 +1,443 @@ +#!/usr/bin/env python3 +import importlib.util +import json +import pathlib +import subprocess +import tempfile +import sys +import unittest + +ROOT = pathlib.Path(__file__).resolve().parents[1] +SCRIPTS = ROOT / "scripts" +if str(SCRIPTS) not in sys.path: + sys.path.insert(0, str(SCRIPTS)) +SCRIPT = SCRIPTS / "truenas_middleware_foliorelay_t6_probe.py" +SPEC = importlib.util.spec_from_file_location("foliorelay_t6_probe", SCRIPT) +MOD = importlib.util.module_from_spec(SPEC) +assert SPEC.loader +SPEC.loader.exec_module(MOD) + +class FolioRelayT6ContractTests(unittest.TestCase): + def test_exact_product_and_foundry_identity_is_pinned(self): + self.assertIn("@sha256:", MOD.EXPECTED_CONTROL) + self.assertIn("@sha256:", MOD.EXPECTED_CUPS) + self.assertEqual(MOD.DATASET, "rdtepool/foliorelay-t6") + self.assertEqual(MOD.PUBLIC_URI, "ipp://foliorelay-t6.local:8634/printers/FolioRelay") + + def test_probe_preserves_product_oracles_and_zero_residue(self): + text = SCRIPT.read_text(encoding="utf-8") + for needle in ( + '"/api/v1/printer"', '"/api/v1/jobs"', 'application/pdf', 'image/urf', + 'ipptool', 'UNIRAST', '"pool.dataset.create"', '"pool.dataset.delete"', + '"app.create"', '"app.stop"', '"app.start"', '"app.delete"', + '"zero_residue":True', + 'dnssd_uuid_match', 'restart_preserved_identity_and_inbox', + 'app.update', 'app.redeploy', 'replan_action', '"NOOP"', + 'update_redeploy_preserved_identity_and_inbox', + 'second_plan_noop', 'retain_data_reinstall', + 'TrueNAS Web UI portal readback drifted', + 'TrueNAS Web UI portal drifted after update/redeploy', + 'TrueNAS Web UI portal drifted after retain-data reinstall', + '"truenas_webui_portal_advertised":True', + '"management_portal_persistent":True', + ): + self.assertIn(needle, text) + observer = (ROOT/"tools"/"foliorelay-observer"/"main.go").read_text(encoding="utf-8") + self.assertIn('_universal._sub._ipp._tcp.local', observer) + self.assertIn(MOD.OBSERVER_IMAGE, text) + self.assertNotIn("avahi-publish-service", text) + self.assertIn('DBUS_SOCKET = "/run/dbus/system_bus_socket"', text) + self.assertIn('25.x requires exactly one host D-Bus mount', text) + self.assertIn('direct discovery target must not couple to host D-Bus', text) + self.assertNotIn("/var/run/docker.sock", text) + + def test_discovery_materialization_is_exact_per_target(self): + base={ + "control":{"volumes":[]}, + "cups":{"volumes":[]}, + "discovery":{ + "volumes":[], + "command":["-identity-file","/var/lib/foliorelay-control/config/printer.json"], + }, + } + self.assertEqual(MOD.validate_discovery_materialization(base,"26.0.0-BETA.3"),"direct") + + avahi=json.loads(json.dumps(base)) + avahi["discovery"]["user"]="65534:10001" + avahi["discovery"]["command"]=list(MOD.AVAHI_DISCOVERY_COMMAND) + avahi["discovery"]["volumes"].append({ + "type":"bind", + "source":MOD.DBUS_SOCKET, + "target":MOD.DBUS_SOCKET, + "read_only":True, + }) + self.assertEqual(MOD.validate_discovery_materialization(avahi,"25.10.7"),"avahi") + + widened=json.loads(json.dumps(avahi)) + widened["control"]["volumes"].append({ + "type":"bind","source":"/run/dbus/other","target":"/run/dbus/other","read_only":True, + }) + with self.assertRaisesRegex(RuntimeError,"exactly one host D-Bus mount"): + MOD.validate_discovery_materialization(widened,"25.10.7") + + wrong_user=json.loads(json.dumps(avahi)) + wrong_user["discovery"]["user"]="10001:10001" + with self.assertRaisesRegex(RuntimeError,"discovery user"): + MOD.validate_discovery_materialization(wrong_user,"25.10.7") + + direct_dbus=json.loads(json.dumps(base)) + direct_dbus["discovery"]["volumes"].append({ + "type":"bind","source":MOD.DBUS_SOCKET,"target":MOD.DBUS_SOCKET,"read_only":True, + }) + with self.assertRaisesRegex(RuntimeError,"must not couple to host D-Bus"): + MOD.validate_discovery_materialization(direct_dbus,"26.0.0-BETA.3") + + def test_host_path_contract_drives_least_privilege_materialization(self): + common=[ + {"path":MOD.ROOT+"/artifacts","kind":"directory","uid":10001,"gid":10001,"mode":"0700"}, + {"path":MOD.ROOT+"/cups-state","kind":"directory","uid":10001,"gid":10001,"mode":"0755"}, + {"path":MOD.ROOT+"/cups-spool","kind":"directory","uid":10001,"gid":10001,"mode":"0755"}, + {"path":MOD.ROOT+"/secrets","kind":"directory","uid":10001,"gid":10001,"mode":"0700"}, + {"path":MOD.TOKEN_PATH,"kind":"file","uid":10001,"gid":10001,"mode":"0400"}, + ] + avahi={"runtime":{"host_path_requirements":[ + {"path":MOD.ROOT+"/control","kind":"directory","uid":10001,"gid":10001,"mode":"0710"}, + *common, + ]}} + direct={"runtime":{"host_path_requirements":[ + {"path":MOD.ROOT+"/control","kind":"directory","uid":10001,"gid":10001,"mode":"0700"}, + *common, + ]}} + got=MOD.validate_host_path_requirements(avahi,"25.10.7") + self.assertEqual(next(x for x in got if x["path"]==MOD.ROOT+"/control")["mode"],"0710") + self.assertEqual(MOD.middleware_mode("0710"),"710") + self.assertEqual(MOD.middleware_mode("0400"),"400") + self.assertEqual(next(x for x in MOD.validate_host_path_requirements(direct,"26.0.0-BETA.3") if x["path"]==MOD.ROOT+"/control")["mode"],"0700") + + widened=json.loads(json.dumps(avahi)) + widened["runtime"]["host_path_requirements"][0]["mode"]="0750" + with self.assertRaisesRegex(RuntimeError,"kind/mode drifted"): + MOD.validate_host_path_requirements(widened,"25.10.7") + + def test_true_nas_webui_portal_contract_is_exact(self): + portal={ + "name":"Web UI", + "scheme":"https", + "host":MOD.PUBLIC_HOST, + "port":18443, + "path":"/", + } + control={ + "runtime":{ + "management_port":18443, + "management_portal":portal, + }, + "required_oracles":[ + "management-endpoint-ready", + "truenas-webui-portal-advertised", + ], + } + compose={"x-portals":[portal]} + self.assertEqual( + MOD.validate_management_portal_contract(control,compose), + "https://foliorelay-t6.local:18443/", + ) + drift=json.loads(json.dumps(compose)) + drift["x-portals"][0]["host"]="127.0.0.1" + with self.assertRaisesRegex(RuntimeError,"Compose metadata drifted"): + MOD.validate_management_portal_contract(control,drift) + legacy=json.loads(json.dumps(control)) + legacy["required_oracles"].append("portal-ready") + with self.assertRaisesRegex(RuntimeError,"legacy portal-ready"): + MOD.validate_management_portal_contract(legacy,compose) + + def test_https_management_contract_is_control_only_and_backward_compatible(self): + self.assertEqual(MOD.validate_management_transport({"runtime":{}},{"control":{},"cups":{},"discovery":{}}),"http") + + tls_root=MOD.ROOT+"/tls" + tls_target="/var/lib/foliorelay-tls" + control={ + "runtime":{ + "management_scheme":"https", + "management_tls_root":tls_root, + "management_tls_state":tls_target, + }, + "required_oracles":["management-tls-ready","management-tls-identity-persistent"], + } + services={ + "control":{"volumes":[{"type":"bind","source":tls_root,"target":tls_target,"read_only":False}]}, + "cups":{"volumes":[]}, + "discovery":{"volumes":[]}, + } + self.assertEqual(MOD.validate_management_transport(control,services),"https") + + leaked=json.loads(json.dumps(services)) + leaked["cups"]["volumes"].append({"type":"bind","source":tls_root,"target":tls_target,"read_only":True}) + with self.assertRaisesRegex(RuntimeError,"cups must not receive"): + MOD.validate_management_transport(control,leaked) + + missing_oracle=json.loads(json.dumps(control)) + missing_oracle["required_oracles"]=["management-tls-ready"] + with self.assertRaisesRegex(RuntimeError,"management-tls-identity-persistent"): + MOD.validate_management_transport(missing_oracle,services) + + def test_https_host_path_contract_adds_private_tls_root(self): + tls_root=MOD.ROOT+"/tls" + requirements=[ + {"path":MOD.ROOT+"/control","kind":"directory","uid":10001,"gid":10001,"mode":"0710"}, + {"path":tls_root,"kind":"directory","uid":10001,"gid":10001,"mode":"0700"}, + {"path":MOD.ROOT+"/artifacts","kind":"directory","uid":10001,"gid":10001,"mode":"0700"}, + {"path":MOD.ROOT+"/cups-state","kind":"directory","uid":10001,"gid":10001,"mode":"0755"}, + {"path":MOD.ROOT+"/cups-spool","kind":"directory","uid":10001,"gid":10001,"mode":"0755"}, + {"path":MOD.ROOT+"/secrets","kind":"directory","uid":10001,"gid":10001,"mode":"0700"}, + {"path":MOD.TOKEN_PATH,"kind":"file","uid":10001,"gid":10001,"mode":"0400"}, + ] + got=MOD.validate_host_path_requirements({ + "runtime":{ + "management_scheme":"https", + "management_tls_root":tls_root, + "host_path_requirements":requirements, + } + },"25.10.7") + self.assertEqual(next(x for x in got if x["path"]==tls_root)["mode"],"0700") + + def test_f4_reconciliation_policy_and_f5_retention_contract(self): + exact={"services":{"control":{"image":"sha256:exact"}}} + drift={"services":{"control":{"image":"sha256:drift"}}} + self.assertEqual(MOD.reconciliation_action(None,None,exact),"CREATE") + self.assertEqual(MOD.reconciliation_action("DEPLOYING",None,exact),"WAIT") + self.assertEqual(MOD.reconciliation_action("STOPPING",None,exact),"WAIT") + self.assertEqual(MOD.reconciliation_action("CRASHED",exact,exact),"FAIL_CLOSED") + self.assertEqual(MOD.reconciliation_action("ERROR",exact,exact),"FAIL_CLOSED") + self.assertEqual(MOD.reconciliation_action("RUNNING",exact,exact),"NOOP") + self.assertEqual(MOD.reconciliation_action("STOPPED",exact,exact),"NOOP") + self.assertEqual(MOD.reconciliation_action("RUNNING",drift,exact),"UPDATE") + + text = SCRIPT.read_text(encoding="utf-8") + for needle in ( + 'second_plan_action=reconciliation_action', + 'second-plan reconciliation was', + '"inflight_policy":"WAIT"', + '"ambiguous_policy":"FAIL_CLOSED"', + '"retain_data_reinstall":True', + '"RETAIN_EXTERNAL_DATASET_ON_APP_DELETE_THEN_EXPLICIT_FIXTURE_CLEANUP"', + 'external fixture dataset was not retained across App delete', + 'identity or Inbox drifted after retain-data reinstall', + 'retained artifact bytes drifted after reinstall', + 'post-reinstall reconciliation was', + ): + self.assertIn(needle, text) + + def test_observer_is_separate_host_network_app(self): + text = SCRIPT.read_text(encoding="utf-8") + self.assertIn('OBSERVER_APP_NAME = "rdte-t6-foliorelay-observer"', text) + self.assertIn('"network_mode":"host"', text) + self.assertIn('"entrypoint":["/observer/foliorelay-observer"]', text) + self.assertIn('"user":"65534:65534"', text) + self.assertEqual( + MOD.OBSERVER_IMAGE, + "docker.io/library/hello-world@sha256:5e23090353324d887c48ad5e5c56d294eab81588df9605b07d1afe895f9cc8f8", + ) + self.assertNotIn('ghcr.io/truenas/apps_validation@sha256:', text) + self.assertIn('"distinct_observer_context":True', text) + + def test_observer_lifecycle_is_independent_from_mdns_oracle_result(self): + text = SCRIPT.read_text(encoding="utf-8") + observer = (ROOT/"tools"/"foliorelay-observer"/"main.go").read_text(encoding="utf-8") + self.assertIn('"status": "pending"', observer) + self.assertIn('r["status"] = "success"', observer) + self.assertIn('"status": "error"', observer) + self.assertIn('flag.Float64("seconds", 25', observer) + self.assertIn('DNS-SD observer oracle failed:', text) + self.assertIn('DNS-SD observer oracle remained pending', text) + self.assertNotIn("OBSERVER = r'''", text) + + + def test_product_crash_preserves_discovery_diagnostics_before_cleanup(self): + text = SCRIPT.read_text(encoding="utf-8") + for needle in ( + 'def capture_product_runtime_failure(name,app):', + 'payload["product_runtime_failure"]', + 'bounded_app_snapshot(app)', + 'bounded_observer_lifecycle_excerpt(lifecycle,app_name=name)', + 'service_name!="discovery"', + 'container_state not in {"crashed","exited","restarting"}', + 'capture_container_log_tail(ws,name,str(container_id))', + '"service_name":service_name', + '"container_state":container_state', + ): + self.assertIn(needle, text) + self.assertLess( + text.index('capture_product_runtime_failure(name,x)'), + text.index('raise RuntimeError(f"{name} entered {x.get(\'state\')}")'), + ) + + def test_failed_observer_create_preserves_diagnostics_and_cleans_owned_state(self): + text = SCRIPT.read_text(encoding="utf-8") + self.assertIn("class JobFailure", text) + self.assertIn('observer_created=True', text) + self.assertLess( + text.index('observer_created=True'), + text.index('wait_job(oj,"observer app.create")'), + ) + self.assertIn('payload["observer_create_failure"]', text) + self.assertIn('"app.container_log_follow:"', text) + self.assertIn('"tail_lines": tail_lines', text) + self.assertIn('"container_log_tails"', text) + self.assertIn('bounded_job_snapshot(observer_exc.job)', text) + self.assertIn('"logs_excerpt"', text) + self.assertIn('def query_optional(method,filters):', text) + self.assertIn('observer_app=query_optional("app.query"', text) + self.assertIn('before=query_optional("app.query"', text) + self.assertIn('query_optional("app.query",[["id","=",name]]) is None', text) + self.assertIn('before=query_optional("pool.dataset.query"', text) + self.assertIn('query_optional("pool.dataset.query",[["id","=",DATASET]]) is None', text) + self.assertIn('f"{name} cleanup delete"', text) + self.assertIn('"zero_residue"', text) + self.assertIn('payload["cleanup_needed"]', text) + + self.assertIn('call("core.download",["filesystem.get",["/var/log/app_lifecycle.log"]', text) + self.assertIn('"app_lifecycle_excerpt"', text) + self.assertIn('"app_lifecycle_capture_error"', text) + + def test_observer_lifecycle_excerpt_is_bounded_scoped_and_redacted(self): + sample = "\n".join([ + "unrelated-before", + "2026 app_lifecycle Failed 'up' action for 'rdte-t6-foliorelay-observer' app auth_token=secret-value", + "compose detail: failed to start container", + "compose detail: permission denied", + "unrelated-after", + ]) + excerpt = MOD.bounded_observer_lifecycle_excerpt(sample, before=0, after=2, limit=1000) + self.assertIn("rdte-t6-foliorelay-observer", excerpt) + self.assertIn("failed to start container", excerpt) + self.assertIn("permission denied", excerpt) + self.assertNotIn("unrelated-before", excerpt) + self.assertNotIn("secret-value", excerpt) + self.assertIn("auth_token=", excerpt) + + def test_observer_lifecycle_excerpt_preserves_terminal_error_when_long_line_is_bounded(self): + prefix = "pull progress " * 900 + terminal = "failed to register layer: write /var/lib/docker/overlay2: no space left on device" + sample = ( + "2026 app_lifecycle Failed 'up' action for 'rdte-t6-foliorelay-observer' app " + + prefix + terminal + ) + excerpt = MOD.bounded_observer_lifecycle_excerpt(sample, before=0, after=0, limit=1200) + self.assertLessEqual(len(excerpt), 1200) + self.assertIn("rdte-t6-foliorelay-observer", excerpt) + self.assertIn("......", excerpt) + self.assertIn(terminal, excerpt) + + def test_workflow_and_harness_route_exact_foliorelay_export(self): + workflow=(ROOT/".github"/"workflows"/"gha-kvm-system-rdte.yml").read_text(encoding="utf-8") + harness=(ROOT/"scripts"/"gha_kvm_truenas_rdte.sh").read_text(encoding="utf-8") + self.assertIn("truenas_t6_product == 'foliorelay'", workflow) + self.assertIn("export-foliorelay-t6-control.yml@81deb97185760975fd8d3162df42056c77b3c0fd", workflow) + self.assertIn("target_version: ${{ needs.changes.outputs.truenas_version }}", workflow) + self.assertIn("name: foliorelay-t6-control", workflow) + self.assertIn('foundry_commit="81deb97185760975fd8d3162df42056c77b3c0fd"', workflow) + self.assertIn("truenas_middleware_foliorelay_t6_probe.py", harness) + self.assertIn('FOLIORELAY_CONTROL_HOST_PORT', harness) + self.assertIn('hostfwd=tcp:127.0.0.1:${FOLIORELAY_CONTROL_HOST_PORT}-:18443', harness) + self.assertNotIn('hostfwd=tcp:127.0.0.1:${FOLIORELAY_CONTROL_HOST_PORT}-:18080', harness) + self.assertIn('FOLIORELAY_IPP_HOST_PORT', harness) + self.assertIn('FOLIORELAY_OBSERVER_HOST_PORT', harness) + self.assertIn('--observer-port "$FOLIORELAY_OBSERVER_HOST_PORT"', harness) + self.assertIn('--observer-binary "$FOLIORELAY_OBSERVER_BINARY"', harness) + self.assertIn('--target-version "$VERSION"', harness) + self.assertIn('--expected-system-version "$EXPECTED_SYSTEM_VERSION"', harness) + self.assertIn('"$T6_PRODUCT" != "foliorelay"', harness) + self.assertIn('CGO_ENABLED=0 GOOS=linux GOARCH=amd64', harness) + self.assertIn('cd "$SCRIPT_DIR/.."', harness) + self.assertIn('./tools/foliorelay-observer', harness) + self.assertNotIn('"$SCRIPT_DIR/../tools/foliorelay-observer" ||', harness) + self.assertIn('observer_size <= 6291456', harness) + + def test_probe_binds_runtime_target_and_foundry_identity_from_arguments(self): + text = SCRIPT.read_text(encoding="utf-8") + self.assertIn('control.get("foundry_ref") != foundry_ref', text) + self.assertIn('candidate.get("truenas_version") != target_version', text) + self.assertIn('call("system.version",[])!=a.expected_system_version', text) + self.assertIn('p.add_argument("--target-version",required=True)', text) + self.assertIn('p.add_argument("--expected-system-version",required=True)', text) + self.assertNotIn('EXPECTED_FOUNDRY_REF =', text) + self.assertNotIn('EXPECTED_VERSION =', text) + + def test_cups_uuid_oracle_preserves_canonical_urn_prefix(self): + attrs = """ + printer-uuid (uri) = urn:uuid:01234567-89ab-4def-8123-456789abcdef + printer-uri-supported (uri) = ipp://foliorelay-t6.local:8634/printers/FolioRelay + """ + self.assertEqual( + MOD.extract_printer_uuid(attrs), + "urn:uuid:01234567-89ab-4def-8123-456789abcdef", + ) + self.assertIsNone(MOD.extract_printer_uuid("printer-name = FolioRelay")) + + def test_dnssd_txt_uuid_projection_strips_only_canonical_urn_prefix(self): + canonical="urn:uuid:01234567-89ab-4def-8123-456789abcdef" + self.assertEqual(MOD.dnssd_txt_uuid(canonical), "01234567-89ab-4def-8123-456789abcdef") + with self.assertRaisesRegex(RuntimeError, "urn:uuid form"): + MOD.dnssd_txt_uuid("01234567-89ab-4def-8123-456789abcdef") + probe = SCRIPT.read_text(encoding="utf-8") + observer = (ROOT/"tools"/"foliorelay-observer"/"main.go").read_text(encoding="utf-8") + prep = (ROOT/".github"/"workflows"/"prep-foliorelay-t6-consumer.yml").read_text(encoding="utf-8") + self.assertIn('"--txt-uuid",dnssd_txt_uuid(uuid)', probe) + self.assertIn('flag.String("txt-uuid"', observer) + self.assertIn('"UUID="+expectedTxtUUID', observer) + self.assertIn('--txt-uuid 01234567-89ab-4def-8123-456789abcdef', prep) + + def test_forwarded_ipp_uri_preserves_product_resource_path_without_claiming_public_host(self): + attrs = """ + printer-uri-supported (uri) = ipp://127.0.0.1:48634/printers/FolioRelay + """ + uri = MOD.extract_printer_uri(attrs) + self.assertEqual(uri, "ipp://127.0.0.1:48634/printers/FolioRelay") + self.assertTrue(MOD.forwarded_ipp_uri_has_product_path(uri)) + self.assertFalse(MOD.forwarded_ipp_uri_has_product_path("ipp://127.0.0.1:48634/printers/Other")) + + def test_dnssd_observer_proves_public_host_port_and_resource_path(self): + text = SCRIPT.read_text(encoding="utf-8") + observer = (ROOT/"tools"/"foliorelay-observer"/"main.go").read_text(encoding="utf-8") + self.assertIn('"--expected-host",PUBLIC_HOST', text) + self.assertIn('"--expected-ipp-port",str(PUBLIC_IPP_PORT)', text) + self.assertIn('"srv_target"', observer) + self.assertIn('"srv_port"', observer) + self.assertIn('rp=printers/foliorelay', observer.lower()) + self.assertIn('pdl=application/pdf,image/urf', observer.lower()) + self.assertIn('"dnssd_public_uri_match":True', text) + self.assertIn('"service_instance"', observer) + self.assertIn('"service_instance":observed.get("service_instance")', text) + self.assertIn('matchObservation', observer) + self.assertIn('o.txtByOwner[owner]', observer) + self.assertIn('o.srvByOwner[owner]', observer) + self.assertNotIn('if PUBLIC_URI not in attrs', text) + + @unittest.skipUnless(pathlib.Path("/usr/include/cups/raster.h").is_file(), "CUPS development headers not installed") + def test_synthetic_urf_generator_builds_and_emits_unirast(self): + with tempfile.TemporaryDirectory() as td: + out = MOD.generate_urf(pathlib.Path(td)) + self.assertTrue(out.is_file()) + self.assertEqual(out.read_bytes()[:7], b"UNIRAST") + + def test_probe_cli_reaches_argparse_before_observer_validation(self): + cp=subprocess.run([sys.executable,str(SCRIPT),"--help"],capture_output=True,text=True) + self.assertEqual(cp.returncode,0,cp.stderr) + self.assertIn("--observer-binary",cp.stdout) + + def test_observer_fixture_identity_is_receipted(self): + text = SCRIPT.read_text(encoding="utf-8") + self.assertIn('payload["observer_fixture"]', text) + self.assertIn('"implementation":"go-static"', text) + self.assertIn('"carrier_image":OBSERVER_IMAGE', text) + self.assertIn('"binary_sha256":sha256_bytes(observer_bytes)', text) + self.assertIn('"binary_size_bytes":len(observer_bytes)', text) + self.assertIn('"run_as":"65534:65534"', text) + + def test_probe_compiles(self): + cp=subprocess.run([sys.executable,"-m","py_compile",str(SCRIPT)],capture_output=True,text=True) + self.assertEqual(cp.returncode,0,cp.stderr) + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_truenas_t6_garm_control.py b/tests/test_truenas_t6_garm_control.py new file mode 100644 index 000000000..0c735dd03 --- /dev/null +++ b/tests/test_truenas_t6_garm_control.py @@ -0,0 +1,141 @@ +from __future__ import annotations + +import importlib.util +import json +from pathlib import Path +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[1] +SCRIPT = ROOT / "scripts" / "truenas_middleware_garm_t6_probe.py" +SPEC = importlib.util.spec_from_file_location("garm_t6_probe", SCRIPT) +assert SPEC and SPEC.loader +MOD = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(MOD) + + +class GarmT6ControlTests(unittest.TestCase): + def sample_compose(self): + service = { + "image": MOD.EXPECTED_IMAGE, + "cap_drop": ["ALL"], + "security_opt": ["no-new-privileges=true"], + "privileged": False, + } + return { + "services": { + "garm": dict(service), + "garm-config-seed": dict(service), + }, + "configs": { + MOD.BOOTSTRAP_CONFIG: {"content": MOD.BOOTSTRAP_PLACEHOLDER}, + MOD.TLS_CERT_CONFIG: {"content": MOD.TLS_PREFIX + MOD.TLS_CERT_CONFIG}, + MOD.TLS_KEY_CONFIG: {"content": MOD.TLS_PREFIX + MOD.TLS_KEY_CONFIG}, + }, + } + + def test_probe_contract_keeps_github_and_physical_boundaries_explicit(self): + text = SCRIPT.read_text(encoding="utf-8") + for required in ( + '"github_credentials_present": False', + '"github_jit_registration_exercised": False', + '"physical_truenas_mutation": False', + '"capacity_promotion": False', + '"secret_values_recorded": False', + "EXPECTED_PROVIDER_SOURCE", + "EXPECTED_CONTROLLER_SOURCE", + ): + self.assertIn(required, text) + self.assertNotIn("GITHUB_APP_PRIVATE_KEY=", text) + self.assertNotIn("local-truenas-linux-general", text) + + def test_probe_binds_controller_persistence_restart_and_cleanup(self): + text = SCRIPT.read_text(encoding="utf-8") + for required in ( + '"app.create"', + '"app.stop"', + '"app.start"', + '"app.delete"', + '"pool.dataset.delete"', + "EXPECTED_CONFIG_PATH", + "EXPECTED_DB_PATH", + "https_ui_probe", + '"zero_residue": True', + "secret_normalized_compose_readback_sha256", + "restart_compose_identity_preserved", + ): + self.assertIn(required, text) + + def test_normalization_restores_retained_placeholders(self): + compose = self.sample_compose() + compose["configs"][MOD.BOOTSTRAP_CONFIG]["content"] = "runtime-bootstrap" + compose["configs"][MOD.TLS_CERT_CONFIG]["content"] = "runtime-cert" + compose["configs"][MOD.TLS_KEY_CONFIG]["content"] = "runtime-key" + normalized = MOD.normalize_secret_configs(compose) + self.assertEqual( + normalized["configs"][MOD.BOOTSTRAP_CONFIG]["content"], + MOD.BOOTSTRAP_PLACEHOLDER, + ) + self.assertEqual( + normalized["configs"][MOD.TLS_CERT_CONFIG]["content"], + MOD.TLS_PREFIX + MOD.TLS_CERT_CONFIG, + ) + self.assertEqual( + normalized["configs"][MOD.TLS_KEY_CONFIG]["content"], + MOD.TLS_PREFIX + MOD.TLS_KEY_CONFIG, + ) + + def test_workflow_routes_exact_garm_export_through_existing_t6_selector(self): + workflow = (ROOT / ".github" / "workflows" / "gha-kvm-system-rdte.yml").read_text(encoding="utf-8") + harness = (ROOT / "scripts" / "gha_kvm_truenas_rdte.sh").read_text(encoding="utf-8") + self.assertIn( + "export-garm-t6-control.yml@7fd0d2474248b61cf8e290fc80f40b761cea5e98", + workflow, + ) + self.assertIn("name: garm-truenas-t6-control", workflow) + self.assertIn("needs.changes.outputs.truenas_t6_product == 'garm'", workflow) + self.assertIn('T6_PRODUCT\" == \"garm', harness) + self.assertIn("truenas_middleware_garm_t6_probe.py", harness) + self.assertIn("GARM_HOSTFWD", harness) + self.assertIn("-:30880", harness) + proxmox = workflow.split(" proxmox-9-2:", 1)[1].split(" truenas-26-beta3:", 1)[0] + truenas = workflow.split(" truenas-26-beta3:", 1)[1] + self.assertNotIn("Download exact GARM Foundry T6 control", proxmox) + self.assertIn("Download exact GARM Foundry T6 control", truenas) + + def test_load_control_rejects_foundry_drift(self): + compose = self.sample_compose() + with tempfile.TemporaryDirectory() as td: + root = Path(td) + deployment = { + "artifact_sha256": "a" * 64, + "materialization_identity": "sha256:" + "b" * 64, + } + control = { + "schema": MOD.SCHEMA, + "foundry_ref": "a" * 40, + "appliance_reference": MOD.EXPECTED_IMAGE, + "controller_source": MOD.EXPECTED_CONTROLLER_SOURCE, + "provider_source": MOD.EXPECTED_PROVIDER_SOURCE, + "private_secrets_captured": False, + "fixture_secret_values_retained": False, + "github_credentials_present": False, + "runtime_compose_sha256": MOD.canonical_sha256(compose), + "deployment_artifact_sha256": deployment["artifact_sha256"], + "materialization_identity": deployment["materialization_identity"], + "runtime": { + "app_name": MOD.EXPECTED_APP_NAME, + "config_root": MOD.EXPECTED_CONFIG_DIR, + "host_port": 30880, + "bootstrap_replacement": MOD.BOOTSTRAP_CONFIG, + }, + } + (root / "compose.json").write_text(json.dumps(compose), encoding="utf-8") + (root / "deployment.json").write_text(json.dumps(deployment), encoding="utf-8") + (root / "control.json").write_text(json.dumps(control), encoding="utf-8") + with self.assertRaisesRegex(RuntimeError, "Foundry source ref drifted"): + MOD.load_control(root, "b" * 40) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_truenas_t6_litellm_control.py b/tests/test_truenas_t6_litellm_control.py new file mode 100644 index 000000000..0100e5646 --- /dev/null +++ b/tests/test_truenas_t6_litellm_control.py @@ -0,0 +1,162 @@ +#!/usr/bin/env python3 +import importlib.util +import json +import pathlib +import sys +import tempfile +import unittest + +HERE = pathlib.Path(__file__).resolve().parents[1] +SCRIPTS = HERE / "scripts" +if str(SCRIPTS) not in sys.path: + sys.path.insert(0, str(SCRIPTS)) +SCRIPT = SCRIPTS / "truenas_middleware_litellm_t6_probe.py" +SPEC = importlib.util.spec_from_file_location("litellm_t6_probe", SCRIPT) +MOD = importlib.util.module_from_spec(SPEC) +assert SPEC.loader +SPEC.loader.exec_module(MOD) + + +class LiteLlmT6ContractTests(unittest.TestCase): + def make_control(self, root, *, image=None, secret_names=None): + image = image or MOD.EXPECTED_APPLIANCE + secret_names = secret_names or ["TEST_PROVIDER_KEY"] + compose = { + "services": { + "litellm": { + "image": image, + "environment": {"SEMPER_SECRET_DIR": "/run/secrets/semper-env"}, + "ports": [{"target": 4000, "published": "30401", "protocol": "tcp"}], + "volumes": [ + {"type": "bind", "source": MOD.EXPECTED_CONFIG_DIR, "target": "/config", "read_only": True}, + {"type": "bind", "source": MOD.EXPECTED_SECRET_DIR, "target": "/run/secrets/semper-env", "read_only": True}, + ], + } + } + } + config = b"model_list: []\n" + control = { + "schema": MOD.EXPECTED_SCHEMA, + "foundry_ref": MOD.EXPECTED_FOUNDRY_REF, + "candidate": { + "appliance_reference": MOD.EXPECTED_APPLIANCE, + "appliance_digest": MOD.EXPECTED_APPLIANCE_DIGEST, + "truenas_apps_commit": MOD.EXPECTED_TRUENAS_APPS_COMMIT, + "truenas_lib_version": MOD.EXPECTED_LIBRARY_VERSION, + "truenas_lib_hash": MOD.EXPECTED_LIBRARY_HASH, + }, + "runtime": { + "app_name": MOD.EXPECTED_APP_NAME, + "guest_port": MOD.EXPECTED_GUEST_PORT, + "config_dir": MOD.EXPECTED_CONFIG_DIR, + "secret_dir": MOD.EXPECTED_SECRET_DIR, + "config_file": MOD.EXPECTED_CONFIG_FILE, + "fixture_secret_names": secret_names, + }, + "artifacts": { + "compose_canonical_sha256": MOD.canonical_sha256(compose), + "config_sha256": MOD.sha256_bytes(config), + }, + "secrets_captured": False, + } + root = pathlib.Path(root) + (root / "control.json").write_text(json.dumps(control), encoding="utf-8") + (root / "compose.json").write_text(json.dumps(compose), encoding="utf-8") + (root / MOD.EXPECTED_CONFIG_FILE).write_bytes(config) + return compose + + def test_loads_exact_litellm_control(self): + with tempfile.TemporaryDirectory() as td: + expected = self.make_control(td) + control, compose, config, service = MOD.load_control(pathlib.Path(td)) + self.assertEqual(compose, expected) + self.assertEqual(service, "litellm") + self.assertEqual(control["foundry_ref"], MOD.EXPECTED_FOUNDRY_REF) + self.assertEqual(config, b"model_list: []\n") + + def test_rejects_appliance_drift(self): + with tempfile.TemporaryDirectory() as td: + self.make_control(td, image="example.invalid/litellm:latest") + with self.assertRaises(RuntimeError): + MOD.load_control(pathlib.Path(td)) + + def test_rejects_secret_name_broadening(self): + with tempfile.TemporaryDirectory() as td: + self.make_control(td, secret_names=["TEST_PROVIDER_KEY", "OPENROUTER_MANAGEMENT_KEY"]) + with self.assertRaises(RuntimeError): + MOD.load_control(pathlib.Path(td)) + + def test_script_pins_supported_upload_and_lifecycle_boundaries(self): + text = SCRIPT.read_text(encoding="utf-8") + self.assertIn('"/_upload/"', text) + self.assertIn('"filesystem.put"', text) + self.assertIn('"pool.dataset.create"', text) + self.assertIn('"pool.dataset.delete"', text) + self.assertIn('EXPECTED_FIXTURE_DATASET = "rdtepool/litellm-t6"', text) + self.assertIn('"share_type": "GENERIC"', text) + self.assertIn('"filesystem.mkdir"', text) + self.assertNotIn('("/mnt/rdtepool/litellm-t6", "750")', text) + self.assertIn('(EXPECTED_CONFIG_DIR, "750")', text) + self.assertIn('(EXPECTED_SECRET_DIR, "700")', text) + self.assertIn('"raise_chmod_error": True', text) + self.assertIn('"recursive": True', text) + self.assertIn('"zero_residue": True', text) + self.assertIn('"fixture_dataset_absent": True', text) + self.assertIn('"fixture_mountpoint_absent": True', text) + self.assertIn('"app.create"', text) + self.assertIn('"app.stop"', text) + self.assertIn('"app.start"', text) + self.assertIn('"app.delete"', text) + self.assertIn('"/health/liveliness"', text) + self.assertIn('"/health/readiness"', text) + self.assertIn(MOD.EXPECTED_APPLIANCE_DIGEST, text) + self.assertNotIn("OPENROUTER_API_KEY", text) + + + def test_workflow_and_harness_are_bound_to_exact_litellm_export(self): + workflow = (HERE / ".github" / "workflows" / "gha-kvm-system-rdte.yml").read_text(encoding="utf-8") + harness = (SCRIPTS / "gha_kvm_truenas_rdte.sh").read_text(encoding="utf-8") + self.assertIn( + "export-litellm-t6-control.yml@" + MOD.EXPECTED_FOUNDRY_REF, + workflow, + ) + self.assertIn("foundry_ref: " + MOD.EXPECTED_FOUNDRY_REF, workflow) + self.assertIn("name: litellm-truenas-t6-control", workflow) + self.assertIn('foundry_commit="' + MOD.EXPECTED_FOUNDRY_REF + '"', workflow) + self.assertIn('--foundry-commit "$foundry_commit"', workflow) + self.assertIn("needs.changes.outputs.truenas_t6_product == 'litellm'", workflow) + self.assertNotIn("truenas-foundry-materialized-controls", workflow) + self.assertIn('LITELLM_HOSTFWD=",hostfwd=tcp:127.0.0.1:', harness) + self.assertIn('-:30401"', harness) + netdev_lines = [ + line.strip() + for line in harness.splitlines() + if '-netdev "user,id=net0' in line + ] + self.assertEqual(2, len(netdev_lines)) + self.assertNotIn("LITELLM_HOSTFWD", netdev_lines[0]) + self.assertIn("${LITELLM_HOSTFWD}", netdev_lines[1]) + self.assertIn("hostfwd=tcp:127.0.0.1:$HTTPS_PORT-:443", netdev_lines[1]) + self.assertIn( + 'python3 "$SCRIPT_DIR/truenas_middleware_litellm_t6_probe.py"', + harness, + ) + self.assertIn('--service-port "$LITELLM_HOST_PORT"', harness) + self.assertNotIn( + 'python3 "$SCRIPT_DIR/truenas_middleware_foundry_control_probe.py"', + harness, + ) + self.assertIn( + '[[ -f "$SCRIPT_DIR/truenas_middleware_litellm_t6_probe.py" ]] ||', + harness, + ) + self.assertIn("missing TrueNAS T6 LiteLLM control client", harness) + self.assertNotIn( + '[[ -f "$SCRIPT_DIR/truenas_middleware_foundry_control_probe.py" ]] ||', + harness, + ) + + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_truenas_t6_minecraft_control.py b/tests/test_truenas_t6_minecraft_control.py new file mode 100644 index 000000000..30d1b4bd9 --- /dev/null +++ b/tests/test_truenas_t6_minecraft_control.py @@ -0,0 +1,393 @@ +#!/usr/bin/env python3 +"""Cheap falsification of exact Minecraft/TrueNAS source/control semantics.""" +from __future__ import annotations + +import copy +import importlib.util +import json +import pathlib +import sys +import tempfile +import unittest + +ROOT = pathlib.Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "scripts")) +SPEC = importlib.util.spec_from_file_location( + "minecraft_t6", ROOT / "scripts/truenas_middleware_minecraft_t6_probe.py" +) +mc = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(mc) + +SOURCE_SHA = "a" * 40 + + +def fixture() -> dict: + values = { + "resources": {"limits": {"cpus": 2, "memory": 4096}}, + "minecraft": { + "type": "PAPER", "version": "1.21.11", "eula": True, "online_mode": True, + "enable_rcon": False, "seed": None, "additional_envs": [ + {"name": k, "value": v} for k, v in mc.REQUIRED_OWNER.items() + ], + }, + "storage": { + "data": {"type": "ix_volume"}, + "additional_storage": [{ + "type": "host_path", "mount_path": "/data/world", + "read_only": False, + "host_path_config": {"path": "/opt/tests/collage-world-only"}, + }], + }, + "network": {"host_network": False, + "server_port": {"port_number": 25565, "bind_mode": "published"}}, + } + return { + "schema": mc.SCHEMA, + "foundry_ref": SOURCE_SHA, + "target": { + "truenas_version": "25.04.1", + "expected_system_version": "TrueNAS-25.04.1", + "profile_git_blob_sha": mc.EXPECTED_PROFILE, + "middleware_commit": mc.EXPECTED_MIDDLEWARE, + }, + "source": { + "apps_commit": mc.EXPECTED_APPS_COMMIT, + "app": "community/minecraft", + "image_reference": mc.EXPECTED_IMAGE_FRAGMENT, + "library_version": "2.3.4", + "source_compose_sha256": "1"*64, + "values_raw_sha256": "2"*64, + }, + "runtime": { + "app_name": mc.APP, "catalog_app": "minecraft", + "train": "community", "catalog_version_candidate": "1.15.29", + "paper_version": "1.21.11", "required_owned_world_dataset": mc.DATASET, + "status_oracle": "minecraft-java-status-oracle/v1", + "values_template": values, + }, + "evidence": { + "source_render_pass": True, + "live_catalog_version_verified": False, + "live_minecraft_server_qualified": False, + "world_retention_qualified": False, + "physical_truenas_qualified": False, + }, + "secrets_captured": False, + } + + +def admit(doc, expected=SOURCE_SHA): + doc["control_sha256"] = mc.canonical_sha256(doc) + with tempfile.TemporaryDirectory() as td: + (pathlib.Path(td) / "control.json").write_text(json.dumps(doc), encoding="utf8") + return mc.load_control(pathlib.Path(td), expected) + + +class TestNativeMinecraftControl(unittest.TestCase): + def test_good_control_lowering_changes_site_bindings_and_omits_null_optional_seed(self): + control = admit(fixture()) + initial = copy.deepcopy(control["runtime"]["values_template"]) + lowered = mc.runtime_values(control) + self.assertIn("seed", initial["minecraft"]) + self.assertIsNone(initial["minecraft"]["seed"]) + self.assertNotIn("seed", lowered["minecraft"]) + self.assertIsNone(control["runtime"]["values_template"]["minecraft"]["seed"]) + self.assertEqual(lowered["storage"]["additional_storage"][0]["host_path_config"]["path"], mc.WORLD) + self.assertEqual(lowered["network"]["server_port"]["port_number"], mc.PORT) + self.assertEqual(initial["storage"]["additional_storage"][0]["host_path_config"]["path"], + "/opt/tests/collage-world-only") + self.assertEqual(initial["network"]["server_port"]["port_number"], 25565) + self.assertEqual(mc.WORLD, "/mnt/rdtepool/minecraft-paper-t6-world") + self.assertIn("@sha256:", mc.EXPECTED_IMAGE_FRAGMENT) + self.assertEqual(control["source"]["apps_commit"], mc.EXPECTED_APPS_COMMIT) + self.assertTrue(mc.owned_config(lowered, lowered)) + + def test_explicit_non_null_seed_is_not_implicitly_changed(self): + doc = fixture() + doc["runtime"]["values_template"]["minecraft"]["seed"] = "43-meaningful-seed" + lowered = mc.runtime_values(admit(doc)) + self.assertEqual(lowered["minecraft"]["seed"], "43-meaningful-seed") + + def test_unrelated_optional_nulls_do_not_get_stripped(self): + doc = fixture() + doc["runtime"]["values_template"]["minecraft"]["motd"] = None + lowered = mc.runtime_values(admit(doc)) + self.assertIsNone(lowered["minecraft"]["motd"]) + self.assertNotIn("seed", lowered["minecraft"]) + + def test_root_requested_shape_uses_same_native_catalog_revision(self): + harness = (ROOT / "scripts/gha_kvm_truenas_rdte.sh").read_text(encoding="utf-8") + self.assertIn('"catalog_version_candidate": "1.15.29", "paper_version": "1.21.11"', harness) + self.assertNotIn('"catalog_version_candidate": "1.15.24"', harness) + + def test_wrong_producer_rejected(self): + with self.assertRaisesRegex(mc.MinecraftT6Error, "producer identity"): + admit(fixture(), expected="b"*40) + + def test_invalid_producer_ref_rejected(self): + with self.assertRaisesRegex(mc.MinecraftT6Error, "not immutable"): + admit(fixture(), expected="main") + + def test_control_digest_tamper_rejected(self): + with tempfile.TemporaryDirectory() as td: + doc = fixture() + doc["control_sha256"] = "f"*64 + (pathlib.Path(td) / "control.json").write_text(json.dumps(doc)) + with self.assertRaisesRegex(mc.MinecraftT6Error, "canonical digest"): + mc.load_control(pathlib.Path(td), SOURCE_SHA) + + def test_wrong_target_rejected(self): + doc = fixture() + doc["target"]["truenas_version"] = "25.10.7" + with self.assertRaisesRegex(mc.MinecraftT6Error, "target/profile"): + admit(doc) + + def test_digest_pinned_minecraft_image_required(self): + doc = fixture() + doc["source"]["image_reference"] = "ghcr.io/itzg/minecraft-server:latest" + with self.assertRaisesRegex(mc.MinecraftT6Error, "catalog source"): + admit(doc) + + def test_live_catalog_version_mismatch_rejected(self): + doc = fixture() + doc["runtime"]["catalog_version_candidate"] = "1.15.24" + with self.assertRaisesRegex(mc.MinecraftT6Error, "product/source"): + admit(doc) + + def test_wrong_upstream_source_rejected(self): + doc = fixture() + doc["source"]["apps_commit"] = "b"*40 + with self.assertRaisesRegex(mc.MinecraftT6Error, "catalog source"): + admit(doc) + + def test_paper_is_not_vanilla(self): + doc = fixture() + doc["runtime"]["paper_version"] = "26.3" + with self.assertRaisesRegex(mc.MinecraftT6Error, "product/source"): + admit(doc) + + def test_preclaimed_live_pass_rejected(self): + doc = fixture() + doc["evidence"]["live_minecraft_server_qualified"] = True + with self.assertRaisesRegex(mc.MinecraftT6Error, "promotes runtime"): + admit(doc) + + def test_base_world_same_storage_rejected(self): + doc = fixture() + doc["runtime"]["values_template"]["storage"]["data"]["type"] = "host_path" + with self.assertRaisesRegex(mc.MinecraftT6Error, "App ixVolume"): + admit(doc) + + def test_readonly_external_world_rejected(self): + doc = fixture() + doc["runtime"]["values_template"]["storage"]["additional_storage"][0]["read_only"] = True + with self.assertRaisesRegex(mc.MinecraftT6Error, "writable world"): + admit(doc) + + def test_ownership_drift_rejected(self): + doc = fixture() + doc["runtime"]["values_template"]["minecraft"]["additional_envs"][0]["value"] = "false" + with self.assertRaisesRegex(mc.MinecraftT6Error, "owner metadata"): + admit(doc) + + def test_foreign_config_not_adopted(self): + control = admit(fixture()) + values = mc.runtime_values(control) + self.assertFalse(mc.owned_config({"minecraft": {"type": "PAPER", "version": "1.21.11"}}, values)) + bad = copy.deepcopy(values) + bad["minecraft"]["additional_envs"] = [] + self.assertFalse(mc.owned_config(bad, values)) + + def test_native_server_port_exact_or_documented_default_only(self): + desired = {"bind_mode": "published", "port_number": mc.PORT} + self.assertTrue(mc.native_server_port_matches(dict(desired), desired)) + self.assertTrue(mc.native_server_port_matches(dict(desired, host_ips=[]), desired)) + for forbidden in [ + dict(desired, host_ips=["127.0.0.1"]), + dict(desired, port_number=mc.PORT+1), + dict(desired, bind_mode="exposed"), + dict(desired, unknown="bad"), + {"port_number": mc.PORT, "host_ips": []}, + None, {}, + ]: + with self.subTest(value=str(forbidden)): + self.assertFalse(mc.native_server_port_matches(forbidden, desired)) + self.assertFalse(mc.native_server_port_matches(dict(desired, host_ips=[]), + dict(desired, host_ips=["0.0.0.0"]))) + + def test_native_port_default_preserves_full_ownership_gate(self): + desired = mc.runtime_values(admit(fixture())) + got = copy.deepcopy(desired) + got["network"]["server_port"]["host_ips"] = [] + self.assertTrue(mc.owned_config(got, desired)) + got["network"]["server_port"]["host_ips"] = ["0.0.0.0"] + self.assertFalse(mc.owned_config(got, desired)) + got["network"]["server_port"]["host_ips"] = [] + got["minecraft"]["additional_envs"].pop() + self.assertFalse(mc.owned_config(got, desired)) + + def test_port_readback_diagnostics_remain_boolean_and_private(self): + desired = {"bind_mode": "published", "port_number": mc.PORT} + diag = mc.port_readback_diagnostics( + dict(desired, host_ips=["192.0.2.8"], secret="redacted"), desired) + self.assertTrue(diag["port_number"]) + self.assertTrue(diag["bind_mode"]) + self.assertFalse(diag["host_ips_empty_or_absent"]) + self.assertFalse(diag["only_expected_keys"]) + self.assertTrue(all(type(v) is bool for v in diag.values())) + self.assertNotIn("192.0.2.8", json.dumps(diag)) + + def test_live_owner_mismatch_report_contains_only_boolean_witnesses(self): + wanted = mc.runtime_values(admit(fixture())) + exact = mc.owner_readback_vector(copy.deepcopy(wanted), wanted) + self.assertEqual(set(exact), {"minecraft_type", "minecraft_version", + "owner_environment", "additional_storage", "server_port"}) + self.assertTrue(all(exact.values())) + corrupted = copy.deepcopy(wanted) + corrupted["storage"]["additional_storage"][0]["host_path_config"]["path"] = "/private/secret-owner-path" + corrupted["network"]["server_port"]["port_number"] = 9999 + result = mc.owner_readback_vector(corrupted, wanted) + self.assertFalse(result["additional_storage"]) + self.assertFalse(result["server_port"]) + self.assertTrue(result["minecraft_type"]) + self.assertFalse(mc.owned_config(corrupted, wanted)) + serialized = json.dumps(result) + self.assertNotIn("private", serialized) + self.assertNotIn("/mnt/", serialized) + self.assertTrue(all(isinstance(value, bool) for value in result.values())) + self.assertTrue(all(not isinstance(value, str) for value in result.values())) + + def test_runtime_image_matches_digest_with_or_without_tag(self): + ref = mc.EXPECTED_IMAGE_FRAGMENT + app = {"active_workloads": {"images": [ref]}} + self.assertEqual(mc.qualified_native_image(app), ref) + repo, digest = ref.split("@sha256:", 1) + normalized = repo.rsplit(":", 1)[0] + "@sha256:" + digest + app = {"active_workloads": {"container_details": [{"image": normalized}]}} + self.assertEqual(mc.qualified_native_image(app), normalized) + app = {"active_workloads": {"images": [ref + ".evil"]}} + self.assertIsNone(mc.qualified_native_image(app)) + app = {"active_workloads": {"images": ["ghcr.io/itzg/minecraft-server:latest"]}} + self.assertIsNone(mc.qualified_native_image(app)) + + def test_job_error_receipt_redacts_secrets_and_bounds_detail(self): + state = {"state": "FAILED", + "error": "AppValidation: host_path invalid; password=my-secret-123; " + "TOKEN=supersecret0987; credential=abcd; literal=known-pass"} + cleaned = mc.safe_job_error(state, "known-pass") + self.assertEqual(cleaned["job_state"], "FAILED") + self.assertTrue(cleaned["error_present"]) + for forbidden in ("my-secret-123", "supersecret0987", "abcd", "known-pass"): + self.assertNotIn(forbidden, cleaned["error_excerpt"]) + self.assertIn("host_path invalid", cleaned["error_excerpt"]) + large = mc.safe_job_error({"state": "FAILED", "error": "x" * 700}, "secret") + self.assertLessEqual(len(large["error_excerpt"]), 480) + + def test_protocol_mismatch_is_an_allowlisted_status_cause(self): + with tempfile.TemporaryDirectory() as td: + p = pathlib.Path(td) / "oracle.json" + p.write_text(json.dumps({ + "schema": "minecraft-java-status-oracle/v1", + "classification": "ORACLE_FAILURE", "oracleSatisfied": False, + "failure_reason": "server protocol mismatch", + "raw_response": "do-not-copy-this-name" + })) + self.assertEqual(mc.classify_java_status_receipt(p), "protocol_mismatch") + + def test_status_diagnostic_receipt_only_emits_allowed_failure_category(self): + with tempfile.TemporaryDirectory() as td: + p = pathlib.Path(td) / "status.json" + variants = [ + ({"schema": "minecraft-java-status-oracle/v1", + "classification": "ORACLE_FAILURE", "oracleSatisfied": False, + "failure_type": "ConnectionRefusedError", + "failure_reason": "transport failure", + "sensitive": "192.0.2.20 aPrivateName"}, "transport_failure"), + ({"schema": "minecraft-java-status-oracle/v1", + "classification": "ORACLE_FAILURE", "oracleSatisfied": False, + "failure_type": "OracleFailure", + "failure_reason": "server version mismatch"}, "version_mismatch"), + ({"schema": "minecraft-java-status-oracle/v1", + "classification": "ORACLE_FAILURE", "oracleSatisfied": False, + "failure_reason": "malformed status JSON"}, "invalid_or_truncated_status"), + ({"schema": "minecraft-java-status-oracle/v1", + "classification": "ORACLE_FAILURE", "oracleSatisfied": False, + "failure_reason": "ATTACKER_PASSWORD=supersecret"}, "other_oracle_failure"), + ({"schema": "minecraft-java-status-oracle/v1", + "classification": "SUPPORTED", "oracleSatisfied": True}, "status_supported"), + ({"schema": "invalid", "failure_reason": "transport failure"}, "invalid_receipt"), + ] + for row, expected in variants: + with self.subTest(expected=expected): + p.write_text(json.dumps(row)) + result = mc.classify_java_status_receipt(p) + self.assertEqual(result, expected) + self.assertNotIn("192.0.2.20", result) + self.assertNotIn("supersecret", result) + p.write_bytes(b"x" * 5000) + self.assertEqual(mc.classify_java_status_receipt(p), "missing_or_oversized_receipt") + p.unlink() + self.assertEqual(mc.classify_java_status_receipt(p), "missing_or_oversized_receipt") + p.write_text("{broken") + self.assertEqual(mc.classify_java_status_receipt(p), "unreadable_receipt") + + def test_readiness_source_probes_level_even_when_protocol_fails(self): + source = (ROOT / "scripts/truenas_middleware_minecraft_t6_probe.py").read_text() + self.assertIn("level_ever_observed = level_ever_observed or (stat_level() is not None)", source) + self.assertIn('"status_oracle_ever_satisfied": status_ever_satisfied', source) + self.assertIn('snapshot["middleware_app_query_succeeded"] = True', source) + self.assertNotIn('payload["raw_status_json"]', source) + + def test_paper_status_invocation_and_receipt_gate_are_exact(self): + import unittest.mock as mock + control = mc.runtime_values(admit(fixture())) + with tempfile.TemporaryDirectory() as td: + script = pathlib.Path(td) / "minecraft_java_status_oracle.py" + script.write_text("# product oracle placeholder; subprocess instrumented") + out = pathlib.Path(td) / "status.json" + valid = { + "schema": "minecraft-java-status-oracle/v1", + "classification": "SUPPORTED", "oracleSatisfied": True, + "java_version": "1.21.11", "server_implementation": "paper", + "status_name_contract": "paper_brand_exact", + "server_protocol": 774, "status_packet_valid": True, + } + def mock_run(cmd, **_kwargs): + self.assertIn("--implementation", cmd) + self.assertEqual(cmd[cmd.index("--implementation") + 1], "paper") + self.assertEqual(cmd[cmd.index("--expected-protocol") + 1], "774") + out.write_text(json.dumps(valid)) + return type("Process", (), {"returncode": 0})() + with mock.patch.object(mc.subprocess, "run", side_effect=mock_run): + got = mc.safe_status_oracle(script, mc.PORT, out) + self.assertEqual(got["server_protocol"], 774) + for key, value in ( + ("server_protocol", 773), + ("server_implementation", "vanilla"), + ("status_name_contract", "vanilla_exact"), + ("java_version", "1.21.10"), + ): + invalid = dict(valid, **{key: value}) + def mock_bad(cmd, **_kwargs): + out.write_text(json.dumps(invalid)) + return type("Process", (), {"returncode": 0})() + with mock.patch.object(mc.subprocess, "run", side_effect=mock_bad): + with self.assertRaisesRegex(mc.MinecraftT6Error, "receipt invalid"): + mc.safe_status_oracle(script, mc.PORT, out) + + def test_source_pin_now_uses_approved_paper_oracle(self): + workflow = (ROOT / ".github/workflows/gha-kvm-system-rdte.yml").read_text() + pin = "1b2227847d56007cdf8b668c00845326827e5fd5" + self.assertEqual(workflow.count(pin), 2) + self.assertNotIn("d383c657b6accc11fe587f4e9bcd6e2c1a4aa7a8", workflow) + + def test_status_script_must_be_exact_named(self): + with tempfile.TemporaryDirectory() as td: + other = pathlib.Path(td) / "anything.py" + other.write_text("print('ok')", encoding="utf-8") + with self.assertRaisesRegex(mc.MinecraftT6Error, "oracle file absent"): + mc.safe_status_oracle(other, 25565, pathlib.Path(td) / "status.json") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_truenas_t6_official_catalog_control.py b/tests/test_truenas_t6_official_catalog_control.py new file mode 100644 index 000000000..6b40b9583 --- /dev/null +++ b/tests/test_truenas_t6_official_catalog_control.py @@ -0,0 +1,202 @@ +#!/usr/bin/env python3 +import copy +import importlib.util +import json +import pathlib +import sys +import tempfile +import unittest + +HERE = pathlib.Path(__file__).resolve().parents[1] +SCRIPTS = HERE / "scripts" +if str(SCRIPTS) not in sys.path: + sys.path.insert(0, str(SCRIPTS)) +SCRIPT = SCRIPTS / "truenas_middleware_official_catalog_t6_probe.py" +SPEC = importlib.util.spec_from_file_location("official_catalog_t6_probe", SCRIPT) +MOD = importlib.util.module_from_spec(SPEC) +assert SPEC.loader +SPEC.loader.exec_module(MOD) + + +def control(): + value = { + "schema": MOD.EXPECTED_SCHEMA, + "foundry_ref": MOD.EXPECTED_FOUNDRY_REF, + "catalog_source": { + "repository": "https://github.com/truenas/apps.git", + "commit": MOD.EXPECTED_CATALOG_COMMIT, + }, + "control": { + "id": MOD.EXPECTED_CONTROL_ID, + "role": "universal-candidate-primary", + "train": "community", + "catalog_version": MOD.EXPECTED_CATALOG_VERSION, + "app_version": MOD.EXPECTED_APP_VERSION, + "lib_version": MOD.EXPECTED_LIB_VERSION, + "lib_version_hash": MOD.EXPECTED_LIB_VERSION_HASH, + "source_path": "ix-dev/community/ntfy/app.yaml", + "source_blob_sha": "187931529f5171bb30f35076e97e16e11cb9fadf", + }, + "runtime": { + "app_name": "rdte-t6-catalog-ntfy", + "create_payload": { + "custom_app": False, + "catalog_app": "ntfy", + "app_name": "rdte-t6-catalog-ntfy", + "train": "community", + "version": "1.1.21", + "values": {"TZ": "Etc/UTC"}, + }, + "config_update_payload": {"values": {"TZ": "Europe/Berlin"}}, + "delete_options": {"remove_images": False, "remove_ix_volumes": False}, + "upgrade": {"not_applicable_requires_receipt": True}, + }, + "target_versions": ["25.04.1", "25.04.2.6", "25.10.7", "26.0.0-BETA.3"], + "runtime_qualified_targets": [], + "universal_qualified": False, + "secrets_captured": False, + } + value["control_sha256"] = MOD.canonical_sha256(value) + return value + + +def app(c, state="RUNNING", tz=None): + item = c["control"] + return { + "id": c["runtime"]["app_name"], + "state": state, + "custom_app": False, + "version": item["catalog_version"], + "metadata": { + "name": item["id"], + "app_version": item["app_version"], + "lib_version": item["lib_version"], + "lib_version_hash": item["lib_version_hash"], + }, + "active_workloads": { + "containers": 1, + "container_details": [{"state": "running", "service_name": "ntfy"}], + }, + } + + +class OfficialCatalogT6ContractTests(unittest.TestCase): + def test_system_version_normalization_matches_control_matrix_tokens(self): + self.assertEqual(MOD.normalize_system_version("TrueNAS-25.04.1"), "25.04.1") + self.assertEqual(MOD.normalize_system_version("TrueNAS-25.04.2.6"), "25.04.2.6") + self.assertEqual(MOD.normalize_system_version("TrueNAS-25.10.7"), "25.10.7") + self.assertEqual(MOD.normalize_system_version("TrueNAS-26.0.0-BETA.3"), "26.0.0-BETA.3") + with self.assertRaisesRegex(RuntimeError, "non-empty string"): + MOD.normalize_system_version(None) + + def test_control_loads_only_exact_native_catalog_identity(self): + c = control() + with tempfile.TemporaryDirectory() as td: + pathlib.Path(td, "control.json").write_text(json.dumps(c), encoding="utf-8") + got = MOD.load_control(pathlib.Path(td), MOD.EXPECTED_FOUNDRY_REF) + self.assertEqual(got["control"]["id"], "ntfy") + self.assertFalse(got["runtime"]["create_payload"]["custom_app"]) + + def test_control_rejects_catalog_fingerprint_drift(self): + c = control() + c["control"]["lib_version_hash"] = "0" * 64 + unsigned = dict(c) + unsigned.pop("control_sha256", None) + c["control_sha256"] = MOD.canonical_sha256(unsigned) + with tempfile.TemporaryDirectory() as td: + pathlib.Path(td, "control.json").write_text(json.dumps(c), encoding="utf-8") + with self.assertRaisesRegex(RuntimeError, "lib_version_hash drifted"): + MOD.load_control(pathlib.Path(td), MOD.EXPECTED_FOUNDRY_REF) + + def test_plan_is_observe_driven_and_converges_noop(self): + c = control() + self.assertEqual(MOD.plan(None, None, c), "CREATE") + a = app(c) + self.assertEqual(MOD.plan(a, {"TZ": "Etc/UTC"}, c), "NOOP") + self.assertEqual(MOD.plan(a, {"TZ": "Europe/Berlin"}, c), "UPDATE") + self.assertEqual(MOD.plan(a, {"TZ": "Etc/UTC"}, c, desired_running=False), "STOP") + a["state"] = "STOPPED" + self.assertEqual(MOD.plan(a, {"TZ": "Etc/UTC"}, c), "START") + self.assertEqual(MOD.plan(a, {"TZ": "Etc/UTC"}, c, desired_present=False), "DELETE") + + def test_health_allows_completed_permissions_helper(self): + c = control() + a = app(c) + a["active_workloads"] = { + "containers": 2, + "container_details": [ + {"state": "running", "service_name": "ntfy"}, + {"state": "exited", "service_name": "permissions"}, + ], + } + self.assertTrue(MOD.healthy_running(a, c)) + self.assertTrue(MOD.native_runtime_healthy(a, c, exact_identity=False)) + + def test_health_requires_primary_service_running(self): + c = control() + a = app(c) + a["active_workloads"] = { + "containers": 2, + "container_details": [ + {"state": "exited", "service_name": "ntfy"}, + {"state": "running", "service_name": "permissions"}, + ], + } + self.assertFalse(MOD.healthy_running(a, c)) + + def test_health_rejects_missing_primary_service(self): + c = control() + a = app(c) + a["active_workloads"] = { + "containers": 1, + "container_details": [{"state": "running", "service_name": "permissions"}], + } + self.assertFalse(MOD.healthy_running(a, c)) + + def test_foreign_catalog_identity_blocks_mutation(self): + c = control() + a = app(c) + a["metadata"]["lib_version_hash"] = "0" * 64 + self.assertEqual(MOD.plan(a, {"TZ": "Etc/UTC"}, c), "BLOCK_FOREIGN") + + def test_harness_binds_full_native_lifecycle_and_cleanup(self): + text = SCRIPT.read_text(encoding="utf-8") + for method in ( + '"app.create"', '"app.update"', '"app.stop"', '"app.start"', + '"app.redeploy"', '"app.delete"', '"app.upgrade"', + ): + self.assertIn(method, text) + self.assertIn('"NOOP"', text) + self.assertIn('"NOT_APPLICABLE"', text) + self.assertIn('"remove_ix_volumes": True', text) + self.assertNotIn("custom_compose_config", text) + + def test_workflow_and_harness_bind_exact_official_catalog_producer(self): + workflow = (HERE / ".github" / "workflows" / "gha-kvm-system-rdte.yml").read_text(encoding="utf-8") + harness = (SCRIPTS / "gha_kvm_truenas_rdte.sh").read_text(encoding="utf-8") + self.assertIn("export-official-catalog-t6-control.yml@" + MOD.EXPECTED_FOUNDRY_REF, workflow) + self.assertIn("foundry_ref: " + MOD.EXPECTED_FOUNDRY_REF, workflow) + self.assertIn("name: official-catalog-truenas-t6-control", workflow) + self.assertIn("needs.changes.outputs.truenas_t6_product == 'official-catalog'", workflow) + self.assertIn('foundry_commit="' + MOD.EXPECTED_FOUNDRY_REF + '"', workflow) + self.assertIn('"official-catalog"', harness) + self.assertIn("truenas_middleware_official_catalog_t6_probe.py", harness) + self.assertIn( + 'if [[ "$T6_PRODUCT" != "official-catalog" && "$T6_PRODUCT" != "garm-provider-g5" && "$T6_PRODUCT" != "foliorelay" && "$T6_PRODUCT" != "minecraft-paper" && "$T6_PRODUCT" != "garm-controller-f0f5" && "$T6_PRODUCT" != "minecraft-vanilla-263" ]]; then', + harness, + ) + + def test_probe_requires_observed_methods_and_noop_convergence(self): + text = SCRIPT.read_text(encoding="utf-8") + self.assertIn('BOOTSTRAP_METHODS = {"core.get_methods"}', text) + self.assertIn('call("core.get_methods", [])', text) + self.assertIn('payload["bootstrap_probes"] = {"core.get_methods": True}', text) + self.assertIn("REQUIRED_DISCOVERED_METHODS - method_names", text) + self.assertNotIn('"core.get_methods",\n "app.query"', text) + self.assertGreaterEqual(text.count('"NOOP"'), 3) + self.assertIn('"BLOCK_FOREIGN"', text) + self.assertIn("refusing adopted official-catalog control state", text) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_truenas_t6_vanilla263_guest.py b/tests/test_truenas_t6_vanilla263_guest.py new file mode 100644 index 000000000..0a52bd2db --- /dev/null +++ b/tests/test_truenas_t6_vanilla263_guest.py @@ -0,0 +1,320 @@ +"""Adversarial vanilla263 T6 guest and legacy-paper isolation source tests.""" +from __future__ import annotations +import copy +import json +from pathlib import Path +import sys +import tempfile +import unittest +from unittest.mock import patch + +ROOT=Path(__file__).resolve().parents[1] +sys.path.insert(0,str(ROOT/"scripts")) +import truenas_middleware_minecraft_t6_probe as mc +from assert_vanilla263_native_t6_receipt import check as check_guest +import admit_vanilla263_native_t6_control as native + + +def guest_fixture(): + status={ + "schema":"minecraft-java-status-oracle/v1", + "classification":"SUPPORTED","oracleSatisfied":True, + "java_version":"26.3","server_implementation":"vanilla", + "status_name_contract":"vanilla_exact","server_protocol":777, + "status_packet_valid":True,"players_counter_valid":True, + } + return { + "contract":"gha-kvm-system-lab/v1", + "target":{"product":"truenas","version":"25.04.2.6","rung":"T6"}, + "classification":"SUPPORTED","oracleSatisfied":True, + "phase":"foundry-materialization", + "source":{"system_version_expected":"TrueNAS-25.04.2.6", + "middleware_commit":native.MIDDLEWARE, + "foundry_profile_blob":native.PROFILE_BLOB}, + "requested_shape":{"app":{ + "name":native.APP,"catalog_app":"minecraft", + "catalog_version_candidate":"1.15.31", + "minecraft_version":"26.3","minecraft_protocol":777}}, + "foundry_materialization":{ + "schema":"truenas-minecraft-vanilla263-t6/v1", + "classification":"SUPPORTED","oracleSatisfied":True, + "product":"vanilla-26.3","target":"25.04.2.6", + "foundry_ref":native.PRODUCER, + "control_sha256":native.CONTROL_SHA, + "catalog_version_verified":True, + "observed_image_reference":native.OCI, + "image_digest_qualification":"SOURCE_AND_GUEST_REF_MATCH", + "world_retained_on_delete":True, + "world_rebound_after_reinstall":True, + "readiness":{stage:{"protocol":dict(status), + "level":{"size":2048}} + for stage in ("first","restart","redeploy","reinstall")}, + "cleanup":{"attempted":True,"zero_residue":True, + "app_absent":True,"owned_world_dataset_absent":True}, + "secret_values_captured":False, + }, + } + + +class TestNativeVanilla263Guest(unittest.TestCase): + def test_scoped_tls_go_readback_precedes_redundant_zip_lifecycle(self): + # Actual 006/007/008 native ZIP staging was independently qualified. + # Fail early on still-unqualified scoped key instead of repaying + # stop/restart/redeploy/reinstall just to encounter the same blocker. + import inspect + body=inspect.getsource(mc.main) + guard=body.index('if a.collage_source_dir is not None:') + callback=body.index(' observe_collage_after_native_readiness()',guard) + first=body.index('wait_status("first")') + stage=body.index('payload["lantern_artifact_stage"] = stage(') + final=body.index('payload["lantern_artifact_stage"]["persisted_on_reinstall"] = True') + self.assertLess(first,guard) + self.assertLess(guard,stage) + self.assertLess(guard,callback) + self.assertLess(callback,stage) + self.assertLess(stage,final) + self.assertEqual(body.count(' observe_collage_after_native_readiness()'),1) + self.assertIn('if lantern_blob is not None:',body) + self.assertIn('if a.collage_source_dir is not None:',body) + + def test_api_key_create_has_bounded_longer_deadline_and_restores_socket(self): + class Socket: + def __init__(self): self.value=8.0 + def gettimeout(self): return self.value + def settimeout(self, value): self.value=value + class WS: + def __init__(self): self.sock=Socket() + ws=WS() + seen=[] + def transport(sock, ident, method, params): + seen.append((ident,method,sock.sock.gettimeout())) + if method=="api_key.create": + raise TimeoutError("synthetic secret MUST NOT surface") + return "ok" + call=mc.sequenced_guest_ddp_call( + ws, transport=lambda w, i, m, p: + mc.scoped_ddp_call(w, i, m, p, transport=transport)) + self.assertEqual(call("app.query", []),"ok") + with self.assertRaises(TimeoutError): + call("api_key.create", [{"name":"synthetic-only"}]) + self.assertEqual(ws.sock.gettimeout(),8.0) + self.assertEqual(call("app.query", []),"ok") + self.assertEqual([item[0] for item in seen],["1","2","3"]) + self.assertEqual([item[2] for item in seen], + [8.0, mc.SCOPED_KEY_CREATE_TIMEOUT_SECONDS, 8.0]) + self.assertEqual(mc.SCOPED_KEY_CREATE_TIMEOUT_SECONDS,60.0) + + def test_scoped_key_success_also_restores_prior_socket_deadline(self): + class Socket: + value=11.0 + def gettimeout(self): return self.value + def settimeout(self,value): self.value=value + class WS: + sock=Socket() + ws=WS() + observed=[] + def transport(w, ident, method, params): + observed.append(w.sock.gettimeout()) + return {"key":"synthetic-test-only"} + result=mc.scoped_ddp_call(ws,"7","api_key.create",[], + transport=transport) + self.assertEqual(result,{"key":"synthetic-test-only"}) + self.assertEqual(observed,[60.0]) + self.assertEqual(ws.sock.gettimeout(),11.0) + + def test_full_independent_guest_oracle_fixture(self): + check_guest(guest_fixture()) + + def test_optional_native_collage_readback_requires_independent_tls_and_apps_read(self): + root=guest_fixture() + with self.assertRaisesRegex(ValueError,"independent scoped native vanilla readback"): + check_guest(root,require_collage=True) + proof={ + "schema":"semper-supra.collage-native-readback-p2/1", + "classification":"PROVIDER_READBACK_MATCH", + "source_profile":"vanilla263", + "source_sha":"2b6abe1141e65b8c6872eeba6c8ae39f5a9635f8", + "native_app":native.APP, "truenas_version":"25.04.2.6", + "world_id":"lantern-rdte", + "service_id":"11111111-1111-4111-8111-111111111111", + "ownership_fields_matched":6, + "transport":"isolated_guest_tls_verified", + "tls_cert_sha256":"a"*64, + "credential_scope":"APPS_READ", + "credential_mutation_phase":"disposable_guest_setup_before_observation", + "observation_mutation_count":0, + "world_artifact_format_verified":False, + "world_activated":False, + "cloudnet_adoption_executed":False, + "physical_true_nas_mutated":False, + } + root["foundry_materialization"]["collage_readback"]=proof + check_guest(root,require_collage=True) + for field,bad in ( + ("classification","SOURCE_VALIDATED"), + ("source_profile","paper"),("source_sha","f"*40), + ("native_app","rdte-t6-minecraft-paper"), + ("truenas_version","25.04.1"), + ("world_id","world-rdte-001"), + ("ownership_fields_matched",5), + ("transport","unverified-tls"), + ("credential_scope","FULL_ADMIN"), + ("observation_mutation_count",1), + ("world_activated",True), + ("tls_cert_sha256","invalid"), + ): + with self.subTest(field=field): + changed=copy.deepcopy(root) + changed["foundry_materialization"]["collage_readback"][field]=bad + with self.assertRaises(ValueError): + check_guest(changed,require_collage=True) + # Legacy native guest acceptance remains unchanged by absent readback. + check_guest(guest_fixture(),require_collage=False) + + def test_optional_real_native_lantern_file_stage_does_not_inherit_gameplay(self): + root=guest_fixture() + with self.assertRaisesRegex(ValueError,"actual native Lantern ZIP byte stage"): + check_guest(root,require_lantern_staged=True) + hashid="da25005e8729e6f013f7108ac5b8869dd46323befc78efdae18221b26cafe5f8" + stage={ + "schema":"supracraft.native-vanilla263-lantern-artifact-stage/1", + "classification":"REAL_NATIVE_GUEST_ARTIFACT_BYTES_STAGED", + "artifact_sha256":hashid,"download_readback_sha256":hashid, + "world_id":"lantern-rdte", "native_app":native.APP, + "native_truenas_version":"25.04.2.6", + "zfs_world_owned":True,"app_quiesced_before_write":True, + "persisted_on_restart":True,"persisted_on_reinstall":True, + "artifact_gameplay_effect_verified":False, + "collage_managed_deployment":False, + "principal_authorized_to_apply":False, + "physical_truenas_apply":"HOLD", + } + root["foundry_materialization"]["lantern_artifact_stage"]=stage + check_guest(root,require_lantern_staged=True) + for field,bad in ( + ("classification","SOURCE_VALIDATED"), + ("artifact_sha256","f"*64), + ("download_readback_sha256","f"*64), + ("native_app","foreign"), + ("app_quiesced_before_write",False), + ("persisted_on_restart",False), + ("persisted_on_reinstall",False), + ("artifact_gameplay_effect_verified",True), + ("collage_managed_deployment",True), + ("physical_truenas_apply","APPLY"), + ): + wrong=copy.deepcopy(root) + wrong["foundry_materialization"]["lantern_artifact_stage"][field]=bad + with self.subTest(field=field),self.assertRaises(ValueError): + check_guest(wrong,require_lantern_staged=True) + check_guest(guest_fixture(),require_lantern_staged=False) + + def test_never_promote_paper_legacy_or_source_only(self): + for change in ("legacy_version","legacy_paper","source_only","wrong_phase"): + r=guest_fixture() + if change=="legacy_version": + r["target"]["version"]="25.04.1" + if change=="legacy_paper": + r["foundry_materialization"]["schema"]="truenas-minecraft-paper-t6/v1" + if change=="source_only": + r["classification"]="SOURCE_CONTROL_READY_GUEST_NOT_DISPATCHED" + if change=="wrong_phase": + r["phase"]="source-admission" + with self.subTest(change=change),self.assertRaises(ValueError): + check_guest(r) + + def test_every_phase_real_vanilla_status_and_world_required(self): + baseline=guest_fixture() + for phase in ("first","restart","redeploy","reinstall"): + for field,val in (("java_version","1.21.11"), + ("server_implementation","paper"), + ("server_protocol",774), + ("status_name_contract","paper_brand_exact"), + ("status_packet_valid",False), + ("players_counter_valid",False)): + r=copy.deepcopy(baseline) + r["foundry_materialization"]["readiness"][phase]["protocol"][field]=val + with self.subTest(phase=phase,field=field),self.assertRaises(ValueError): + check_guest(r) + for size in (0,-1,None): + r=copy.deepcopy(baseline) + r["foundry_materialization"]["readiness"][phase]["level"]["size"]=size + with self.assertRaises(ValueError): + check_guest(r) + + def test_identity_integrity_and_cleanup_negative(self): + for path,val in [ + (("source","middleware_commit"),"f"*40), + (("requested_shape","app","name"),"foreign"), + (("foundry_materialization","control_sha256"),"a"*64), + (("foundry_materialization","observed_image_reference"),"ghcr.io/itzg/minecraft-server:latest"), + (("foundry_materialization","catalog_version_verified"),False), + (("foundry_materialization","world_retained_on_delete"),False), + (("foundry_materialization","world_rebound_after_reinstall"),False), + (("foundry_materialization","cleanup","zero_residue"),False), + (("foundry_materialization","cleanup","app_absent"),False), + (("foundry_materialization","cleanup","owned_world_dataset_absent"),False), + (("foundry_materialization","secret_values_captured"),True), + ]: + r=guest_fixture() + node=r + for key in path[:-1]: node=node[key] + node[path[-1]]=val + with self.subTest(path=path),self.assertRaises(ValueError): + check_guest(r) + + def test_00_legacy_paper_profile_remains_exact_default(self): + self.assertFalse(mc.VANILLA263) + self.assertEqual(mc.EXPECTED_TARGET,"25.04.1") + self.assertEqual(mc.MC_VERSION,"1.21.11") + self.assertEqual(mc.MC_PROTOCOL,774) + self.assertEqual(mc.APP,"rdte-t6-minecraft-paper") + + def test_explicit_vanilla_profile_is_distinct(self): + mc.configure_exact_vanilla263() + self.assertTrue(mc.VANILLA263) + self.assertEqual(mc.EXPECTED_TARGET,"25.04.2.6") + self.assertEqual(mc.EXPECTED_PROFILE,native.PROFILE_BLOB) + self.assertEqual(mc.APP,native.APP) + self.assertEqual(mc.DATASET,native.WORLD) + self.assertEqual(mc.EXPECTED_IMAGE_FRAGMENT,native.OCI) + self.assertEqual(mc.MC_TYPE,"VANILLA") + self.assertEqual(mc.MC_VERSION,"26.3") + self.assertEqual(mc.MC_PROTOCOL,777) + self.assertEqual(mc.CATALOG_PROFILES["1.15.31"]["image"],native.OCI) + + def test_vanilla_status_requires_exact_version_protocol_implementation(self): + mc.configure_exact_vanilla263() + with tempfile.TemporaryDirectory() as d: + script=Path(d)/"minecraft_java_status_oracle.py" + script.write_text("probe stub, subprocess mocked") + out=Path(d)/"status.json" + valid=guest_fixture()["foundry_materialization"]["readiness"]["first"]["protocol"] + def good(cmd,**kwargs): + self.assertEqual(cmd[cmd.index("--implementation")+1],"vanilla") + self.assertEqual(cmd[cmd.index("--expected-protocol")+1],"777") + self.assertEqual(cmd[cmd.index("--expected-version")+1],"26.3") + out.write_text(json.dumps(valid)) + return type("Proc",(),{"returncode":0})() + with patch.object(mc.subprocess,"run",side_effect=good): + self.assertEqual(mc.safe_status_oracle( + script,25580,out,expected_version="26.3", + implementation="vanilla",expected_protocol=777)["server_protocol"],777) + for field,val in (("server_protocol",774), + ("server_implementation","paper"), + ("java_version","1.21.11")): + forged=dict(valid,**{field:val}) + def bad(cmd,**kwargs): + out.write_text(json.dumps(forged)) + return type("Proc",(),{"returncode":0})() + with patch.object(mc.subprocess,"run",side_effect=bad): + with self.subTest(field=field),self.assertRaises(mc.MinecraftT6Error): + mc.safe_status_oracle(script,25580,out, + expected_version="26.3",implementation="vanilla", + expected_protocol=777) + with self.assertRaises(mc.MinecraftT6Error): + mc.safe_status_oracle(script,25580,out, + expected_version="26.3",implementation="paper",expected_protocol=777) + +if __name__=="__main__": + unittest.main() diff --git a/tests/test_truenas_t6_wow_sidecar_control.py b/tests/test_truenas_t6_wow_sidecar_control.py new file mode 100644 index 000000000..784a1ea55 --- /dev/null +++ b/tests/test_truenas_t6_wow_sidecar_control.py @@ -0,0 +1,112 @@ +from __future__ import annotations + +import importlib.util +import json +from pathlib import Path +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[1] +SCRIPT = ROOT / "scripts" / "truenas_middleware_wow_sidecar_t6_probe.py" +SPEC = importlib.util.spec_from_file_location("wow_t6_probe", SCRIPT) +assert SPEC and SPEC.loader +MOD = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(MOD) + + +class WowSidecarT6ControlTests(unittest.TestCase): + def test_probe_contract_is_public_fixture_only(self): + text = SCRIPT.read_text(encoding="utf-8") + self.assertIn("production_credentials_present", text) + self.assertIn('"public-fixture-only"', text) + self.assertIn(MOD.EXPECTED_IMAGE, text) + self.assertIn(MOD.EXPECTED_HELPER, text) + self.assertIn(MOD.EXPECTED_FIXTURE_DATASET, text) + self.assertIn('"execution_control_mutation_authorized": False', text) + self.assertNotIn("GITHUB_APP_PRIVATE_KEY=", text) + self.assertIn("PRIVATE_REPO_RE", text) + self.assertNotIn("SemperSupra/wow-sidecar-private", text) + self.assertNotIn("SemperSupra/agent-dispatch-private", text) + + def test_probe_binds_seed_permissions_restart_and_cleanup_oracles(self): + text = SCRIPT.read_text(encoding="utf-8") + for required in ( + '"filesystem.stat"', + "EXPECTED_KEY_PATH", + "EXPECTED_PROFILE_PATH", + "EXPECTED_MARKER_PATH", + '"0o400"', + '"0o444"', + '"app.create"', + '"app.stop"', + '"app.start"', + '"app.delete"', + '"pool.dataset.delete"', + '"zero_residue": True', + "compose_readback_sha256", + "restart_compose_identity_preserved", + ): + self.assertIn(required, text) + + def test_workflow_routes_exact_wow_export_without_reopening_litellm_default(self): + workflow = (ROOT / ".github" / "workflows" / "gha-kvm-system-rdte.yml").read_text(encoding="utf-8") + harness = (ROOT / "scripts" / "gha_kvm_truenas_rdte.sh").read_text(encoding="utf-8") + request = json.loads((ROOT / "config" / "truenas-rdte-run-request.json").read_text(encoding="utf-8")) + + self.assertIn( + "export-wow-sidecar-t6-control.yml@9ac2eef97581caff5b0de740560c55a778fa3f1c", + workflow, + ) + self.assertIn("name: wow-sidecar-truenas-t6-control", workflow) + self.assertIn("--t6-product", workflow) + self.assertIn('"wow-sidecar"', harness) + self.assertIn("truenas_middleware_wow_sidecar_t6_probe.py", harness) + self.assertIn('T6_PRODUCT="litellm"', harness) + if request["rung"] == "t6": + product = request.get("product", "litellm") + self.assertIn(product, {"litellm", "wow-sidecar", "garm", "garm-controller-f0f5", "garm-provider-g2", "garm-provider-g3", "garm-provider-g4", "garm-provider-g5", "garm-provider-container-pre-b4", "official-catalog", "foliorelay", "minecraft-paper", "collage-native-paper", "minecraft-vanilla-263"}) + if product == "wow-sidecar": + self.assertEqual("26.0.0-BETA.3", request["version"]) + self.assertEqual(396, request["authority_issue"]) + self.assertEqual(276, request["consumer_authority_issue"]) + else: + self.assertNotIn("product", request) + + def test_load_control_rejects_foundry_drift(self): + compose = { + "services": { + "permissions": {"image": MOD.EXPECTED_HELPER}, + "wow-sidecar": {"image": MOD.EXPECTED_IMAGE}, + "wow-sidecar-config-seed": {"image": MOD.EXPECTED_IMAGE}, + }, + "configs": { + "fixture": {"content": MOD.FIXTURE_MARKER}, + }, + } + with tempfile.TemporaryDirectory() as td: + root=Path(td) + (root/"compose.json").write_text(json.dumps(compose),encoding="utf-8") + control={ + "schema":MOD.SCHEMA, + "foundry_ref":"a"*40, + "secrets_captured":False, + "candidate":{ + "wow_image":MOD.EXPECTED_IMAGE, + "permissions_helper":MOD.EXPECTED_HELPER, + }, + "runtime":{ + "app_name":MOD.EXPECTED_APP_NAME, + "production_credentials_present":False, + "fixture_key_marker":MOD.FIXTURE_MARKER, + "fixture_config_dir":MOD.EXPECTED_CONFIG_DIR, + "fixture_state_dir":MOD.EXPECTED_STATE_DIR, + }, + "artifacts":{"compose_canonical_sha256":MOD.canonical_sha256(compose)}, + } + (root/"control.json").write_text(json.dumps(control),encoding="utf-8") + with self.assertRaisesRegex(RuntimeError,"Foundry source ref drifted"): + MOD.load_control(root,"b"*40) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_truenas_v2_console_probe.py b/tests/test_truenas_v2_console_probe.py new file mode 100644 index 000000000..3c595ed28 --- /dev/null +++ b/tests/test_truenas_v2_console_probe.py @@ -0,0 +1,34 @@ +import unittest + +from scripts.truenas_v2_console_probe import parse_v2_observation + + +class TrueNASV2ConsoleProbeTests(unittest.TestCase): + NONCE = "rep003nonceABCDEF12" + + def test_positive_nested_kvm_observation(self): + data = ( + b"boot\nAGENT_DISPATCH_V1_NONCE=" + self.NONCE.encode() + + b"\nAGENT_DISPATCH_V2_KVM_PRESENT=1\nAGENT_DISPATCH_V2_CPU_VMX_SVM=1\n" + ) + result = parse_v2_observation(data, self.NONCE) + self.assertTrue(result["nested_kvm_observed"]) + self.assertTrue(result["firecracker_eligible"]) + + def test_negative_is_valid_observation_not_firecracker_eligible(self): + data = ( + b"AGENT_DISPATCH_V1_NONCE=" + self.NONCE.encode() + + b"\nAGENT_DISPATCH_V2_KVM_PRESENT=0\nAGENT_DISPATCH_V2_CPU_VMX_SVM=1\n" + ) + result = parse_v2_observation(data, self.NONCE) + self.assertFalse(result["nested_kvm_observed"]) + self.assertFalse(result["firecracker_eligible"]) + self.assertTrue(result["nonce_observed"]) + + def test_incomplete_markers_do_not_pass(self): + data = b"AGENT_DISPATCH_V1_NONCE=" + self.NONCE.encode() + self.assertIsNone(parse_v2_observation(data, self.NONCE)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_truenas_vm_console_probe.py b/tests/test_truenas_vm_console_probe.py new file mode 100644 index 000000000..131ba0a44 --- /dev/null +++ b/tests/test_truenas_vm_console_probe.py @@ -0,0 +1,43 @@ +import unittest + +from scripts.compute_guest_seed import SeedError +from scripts.truenas_vm_console_probe import _observe_nonce_frames, nonce_marker + + +class TrueNASVmConsoleProbeTests(unittest.TestCase): + def test_nonce_marker_is_exact_and_ascii(self): + self.assertEqual( + nonce_marker("rep001nonceABCDEF12"), + b"AGENT_DISPATCH_V1_NONCE=rep001nonceABCDEF12", + ) + + def test_nonce_marker_rejects_shell_metacharacters(self): + with self.assertRaises(SeedError): + nonce_marker("nonce;touch-pwned-1234") + + def test_idle_socket_timeout_is_transient_until_overall_deadline(self): + marker = nonce_marker("rep003nonceABCDEF12") + + class FakeShell: + def __init__(self): + self.events = [ + TimeoutError("timed out"), + (0x1, b'{"msg":"connected"}'), + (0x2, b"booting...\nAGENT_DISPATCH_V1_NONCE=rep003nonceABCDEF12\n"), + ] + + def recv_payload(self): + event = self.events.pop(0) + if isinstance(event, BaseException): + raise event + return event + + observed = _observe_nonce_frames(FakeShell(), marker, 1.0) + self.assertTrue(observed["found"]) + self.assertTrue(observed["shell_connected"]) + self.assertEqual(observed["idle_read_timeouts"], 1) + self.assertIn(marker.decode(), observed["console_tail"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_vanilla263_catalog_parser_preflight.py b/tests/test_vanilla263_catalog_parser_preflight.py new file mode 100644 index 000000000..948c82305 --- /dev/null +++ b/tests/test_vanilla263_catalog_parser_preflight.py @@ -0,0 +1,96 @@ +"""Causal OP4J-017 harness failure reproduction; never opens a TrueNAS guest.""" +from __future__ import annotations +import contextlib +import io +import json +from pathlib import Path +import sys +import unittest + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "scripts")) +import truenas_middleware_minecraft_t6_probe as native +WORKFLOW = ROOT / ".github/workflows/gha-kvm-system-rdte.yml" +HARNESS = ROOT / "scripts/gha_kvm_truenas_rdte.sh" +REQUEST = ROOT / "config/truenas-rdte-run-request.json" + + +def cli(profile: str, catalog: str) -> list[str]: + return [ + "--host", "127.0.0.1", "--port", "10000", + "--status-port", "25580", "--status-oracle", "/never/open/oracle.py", + "--password-file", "/never/open/secret-file", + "--control-dir", "/never/open/control", + "--foundry-commit", "5ee204df205edfd531304441fd1a6a24004557b1", + "--expected-system-version", "TrueNAS-25.04.2.6", + "--out", "/never/write/probe.json", + "--product-profile", profile, + "--native-catalog-profile", catalog, + ] + + +class ProductScopedParserTests(unittest.TestCase): + def test_017_reproduced_profile_accepted_without_guest_io(self): + self.assertNotIn("1.15.31", native.CATALOG_PROFILES) + args = native.parse_guest_args(cli("vanilla263", "1.15.31")) + self.assertEqual(args.product_profile, "vanilla263") + self.assertEqual(args.native_catalog_profile, "1.15.31") + self.assertFalse(Path("/never/write/probe.json").exists()) + self.assertNotIn("1.15.31", native.CATALOG_PROFILES) + self.assertTrue(native.product_catalog_admitted("vanilla263", "1.15.31")) + + def test_old_paper_versions_remain_distinct_and_admitted(self): + for version in ("1.15.29", "1.15.30"): + with self.subTest(version=version): + args = native.parse_guest_args(cli("legacy-paper", version)) + self.assertEqual(args.native_catalog_profile, version) + self.assertTrue(native.product_catalog_admitted("legacy-paper", version)) + + def test_cross_product_and_unpinned_profiles_fail_before_guest(self): + rows = (("legacy-paper", "1.15.31"), + ("vanilla263", "1.15.29"), + ("vanilla263", "1.15.30"), + ("vanilla263", "1.15.32"), + ("legacy-paper", "1.15.32")) + for product, catalog in rows: + with self.subTest(product=product, catalog=catalog): + sink = io.StringIO() + with contextlib.redirect_stderr(sink), self.assertRaises(SystemExit) as raised: + native.parse_guest_args(cli(product, catalog)) + self.assertEqual(raised.exception.code, 2) + self.assertFalse(native.product_catalog_admitted(product, catalog)) + self.assertNotIn("secret-file", sink.getvalue()) + self.assertFalse(Path("/never/write/probe.json").exists()) + + def test_runner_uses_same_qualified_exact_catalog_without_modifying_request(self): + harness = HARNESS.read_text(encoding="utf-8") + workflow = WORKFLOW.read_text(encoding="utf-8") + request = json.loads(REQUEST.read_text(encoding="utf-8")) + self.assertIn("--product-profile vanilla263 --native-catalog-profile 1.15.31", harness) + self.assertIn('--native-catalog-profile "1.15.30"', harness) + self.assertIn("--expected 1.15.31", workflow) + self.assertEqual(request["request_id"], + "tn-25-04-2-6-t6-vanilla-263-op4m-020-namespaced-overworld-anvil-three-checkpoint") + self.assertNotIn("019", request["request_id"]) + self.assertIn("020", request["request_id"]) + self.assertEqual(request["consumer_authority_issue"], 627) + self.assertEqual(request["authority_issue"], 423) + self.assertEqual(set(request), { + "schema", "request_id", "version", "rung", "product", + "authority_issue", "consumer_authority_issue", "purpose", + "collage_readback", "lantern_stage", "native_world_rollback", + "native_offline_player", "native_backend_region" + }) + + def test_runtime_parse_always_precedes_mutating_configuration(self): + source = (ROOT/"scripts/truenas_middleware_minecraft_t6_probe.py").read_text() + main = source[source.index("def main() -> int:"):] + self.assertLess(main.index("a = parse_guest_args()"), + main.index("configure_exact_vanilla263()")) + self.assertLess(main.index("configure_exact_vanilla263()"), + main.index("started = time.monotonic()")) + self.assertIn("PRODUCT_SCOPED_CATALOG_NOT_ADMITTED", source) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_vanilla263_native_admission.py b/tests/test_vanilla263_native_admission.py new file mode 100644 index 000000000..d349859c7 --- /dev/null +++ b/tests/test_vanilla263_native_admission.py @@ -0,0 +1,119 @@ +"""Fail-closed source admission tests; never start a guest or ask for APPLY.""" +import copy, json, pathlib, sys, unittest +ROOT=pathlib.Path(__file__).resolve().parents[1] +sys.path.insert(0,str(ROOT/"scripts")) +from qualify_vanilla263_native_admission import ( + BLOB, IMAGE, MIDDLEWARE, OWNER, check, sha, Refused +) + +def tuple_(): + request=json.loads((ROOT/"config/truenas-vanilla263-native-admission-request.json").read_text()) + registry=json.loads((ROOT/"config/truenas-rdte-targets.json").read_text()) + extra=[{"name":k,"value":v} for k,v in OWNER.items()] + control={ + "schema":"semper-supra.minecraft-vanilla-263-truenas-t6-control/1", + "foundry_ref":"a"*40, + "target":{"truenas_version":"25.04.2.6", + "expected_system_version":"TrueNAS-25.04.2.6", + "middleware_commit":MIDDLEWARE, + "profile_git_blob_sha":BLOB}, + "source":{"apps_commit":"95b0cc78e75fcd4b128f4ef2f8428034d981f7c2", + "catalog_app":"community/minecraft", + "catalog_version":"1.15.31","image_reference":IMAGE, + "source_compose_sha256":"b"*64}, + "runtime":{"minecraft_version":"26.3","minecraft_protocol":777, + "java_version":25,"image_selector":"j25_image", + "catalog_version_candidate":"1.15.31", + "app_name":"rdte-t6-minecraft-vanilla-263", + "required_independent_world_dataset":"rdtepool/minecraft-vanilla-263-t6-world", + "values_template":{"minecraft":{ + "version":"26.3","type":"VANILLA","image_selector":"j25_image", + "online_mode":True,"enable_rcon":False, + "additional_envs":extra}, + "storage":{"additional_storage":[{ + "mount_path":"/data/world","type":"host_path","read_only":False, + "host_path_config":{"path":"/opt/tests/collage-vanilla-263-world"}}]}, + "network":{"host_network":False}}}, + "evidence":{"source_render_pass":True, + "live_catalog_version_verified":False, + "native_guest_created":False, + "gameplay_qualified_by_native_guest":False, + "external_world_retention_qualified_by_native_guest":False, + "collage_managed_deployment_qualified":False, + "physical_truenas_qualified":False}, + "operational_selection":False,"physical_truenas_apply":"HOLD", + } + control["control_sha256"]=sha(control) + return request,control,registry + + +def amend(t,i,path,value): + t=list(copy.deepcopy(t)) + target=t[i] + for part in path[:-1]: + target=target[part] + target[path[-1]]=value + if i==1: + t[1]["control_sha256"]=sha({k:v for k,v in t[1].items() if k!="control_sha256"}) + return tuple(t) + + +class GuestAdmissionTests(unittest.TestCase): + def test_accept_exact_synthetic_control_but_do_not_dispatch(self): + r=check(*tuple_()) + self.assertEqual(r["classification"],"SOURCE_CONTROL_READY_GUEST_NOT_DISPATCHED") + self.assertEqual(r["consumer_authority_issue"],627) + self.assertFalse(r["guest_run_requested"]) + self.assertFalse(r["native_app_created"]) + self.assertEqual(r["physical_truenas_apply"],"HOLD") + def test_reject_old_p2_request_and_wrong_target(self): + for key,value in (("product","collage-native-paper"), + ("version","25.04.1"), + ("consumer_authority_issue",625), + ("authority_issue",411), + ("request_id","tn-25-04-2-6-t6-vanilla-263-p2-005-replay")): + with self.subTest(key=key),self.assertRaises(Refused): + check(*amend(tuple_(),0,[key],value)) + def test_reject_control_digest_tampering(self): + t=list(tuple_());t[1]["control_sha256"]="0"*64 + with self.assertRaisesRegex(Refused,"CANONICAL"): + check(*t) + def test_reject_backend_version_image_target_identity(self): + for path,value in ( + (["runtime","minecraft_version"],"1.21.11"), + (["runtime","minecraft_protocol"],774), + (["runtime","java_version"],21), + (["source","image_reference"],"wrong-image"), + (["target","profile_git_blob_sha"],"c"*40), + (["runtime","values_template","minecraft","version"],26.3), + (["runtime","values_template","minecraft","type"],"PAPER"), + (["runtime","values_template","network","host_network"],True), + ): + with self.subTest(field=path),self.assertRaises(Refused): + check(*amend(tuple_(),1,path,value)) + def test_reject_forged_guest_qualification_and_physical_apply(self): + for path,value in ( + (["evidence","native_guest_created"],True), + (["evidence","physical_truenas_qualified"],True), + (["physical_truenas_apply"],"APPLY"), + (["operational_selection"],True), + ): + with self.subTest(field=path),self.assertRaises(Refused): + check(*amend(tuple_(),1,path,value)) + def test_reject_wrong_world_or_owner_identity(self): + for path,value in ( + (["runtime","values_template","storage","additional_storage"],[]), + (["runtime","required_independent_world_dataset"],"foreign-world"), + (["runtime","values_template","minecraft","additional_envs"],[]), + ): + with self.subTest(field=path),self.assertRaises(Refused): + check(*amend(tuple_(),1,path,value)) + def test_reject_wrong_or_missing_target_registry(self): + t=list(tuple_()) + t[2]["targets"]=[] + with self.assertRaisesRegex(Refused,"REGISTRY"): + check(*t) + + +if __name__=="__main__": + unittest.main() diff --git a/tests/test_vanilla263_native_lantern_stage.py b/tests/test_vanilla263_native_lantern_stage.py new file mode 100644 index 000000000..0e27cfd23 --- /dev/null +++ b/tests/test_vanilla263_native_lantern_stage.py @@ -0,0 +1,132 @@ +"""OP4D static, synthetic and negative source tests; NOT a guest-acceptance claim.""" +import hashlib +import json +from pathlib import Path +import tempfile +import unittest +from unittest.mock import patch +import zipfile + +from scripts import qualify_vanilla263_native_lantern_stage as s + + +class StageContract(unittest.TestCase): + def make(self): + self.td=tempfile.TemporaryDirectory() + self.addCleanup(self.td.cleanup) + root=Path(self.td.name) + path=root/s.NAME + payloads={ + "pack.mcmeta": json.dumps({"pack":{"min_format":[121,0], + "max_format":[121,0]}}).encode(), + "data/minecraft/tags/function/tick.json": b'{"values":[]}\n', + "data/supracraft_core_restore/function/tick.mcfunction": b"say synthetic\n", + } + with zipfile.ZipFile(path,"w") as z: + for name,data in sorted(payloads.items()): + row=zipfile.ZipInfo(name,(1980,1,1,0,0,0)) + row.create_system=3 + row.external_attr=0o100644<<16 + z.writestr(row,data) + blob=path.read_bytes() + sha=hashlib.sha256(blob).hexdigest() + m={ + "schema":"supracraft-vanilla-datapack-candidate/1", + "classification":"SOURCE_ARTIFACT_EXPORTED", + "producer":s.PRODUCER,"kind":"datapack","backend":"vanilla", + "minecraft_version":"26.3","world_id":"lantern-rdte", + "release_id":"lantern-core-restore-26-3","pack_format":[121,0], + "relative_path":s.NAME,"world_save_included":False, + "collage_deployed":False,"sha256":sha,"archive_size":len(blob), + "members":[{"path":name,"sha256":hashlib.sha256(data).hexdigest(), + "bytes":len(data)} for name,data in sorted(payloads.items())], + } + manifest=root/"manifest.json" + manifest.write_text(json.dumps(m)) + return path,manifest,blob,sha + + def test_real_manifest_shape_synthetic_zip_never_confused_with_immutable_bytes(self): + zip_file,manifest,blob,sha=self.make() + with self.assertRaisesRegex(s.Refused,"PINNED_REAL_ARTIFACT_SHA_DRIFT"): + s.verify_exact_candidate(zip_file,manifest) + with patch.object(s,"SHA",sha): + content,receipt=s.verify_exact_candidate(zip_file,manifest) + self.assertEqual(content,blob) + self.assertEqual(receipt["sha256"],sha) + for field,bad in (("minecraft_version","1.21.11"), + ("world_id","two-rivers-operations"), + ("collage_deployed",True), + ("producer","foreign/provenance@"+"0"*40), + ("pack_format",[80,0])): + m=json.loads(manifest.read_text()) + m[field]=bad + manifest.write_text(json.dumps(m)) + with patch.object(s,"SHA",sha),self.subTest(field=field): + with self.assertRaises(s.Refused): + s.verify_exact_candidate(zip_file,manifest) + m[field]=json.loads(json.dumps({ + "minecraft_version":"26.3","world_id":"lantern-rdte", + "collage_deployed":False,"producer":s.PRODUCER, + "pack_format":[121,0]}))[field] + manifest.write_text(json.dumps(m)) + + def test_disposable_app_must_be_stopped_before_native_file_upload(self): + _,_,blob,sha=self.make() + called=[] + def fake(method,args): + called.append(method) + if method=="app.query": + return [{"state":"RUNNING"}] + raise AssertionError("mutating step called before quiesce") + with patch.object(s,"SHA",sha): + with self.assertRaisesRegex(s.Refused,"NATIVE_APP_MUST_BE_QUIESCED"): + s.stage(fake,None,None,None,host="127.0.0.1",port=80,tls=False, + username="rdte",password="test-not-secret",timeout=8, + blob=blob,evidence={"sha256":sha}) + self.assertEqual(called,["app.query"]) + + def test_native_own_world_writes_and_two_separate_digest_reads(self): + _,_,blob,sha=self.make() + called=[] + def fake(method,args): + called.append(method) + if method=="app.query":return [{"state":"STOPPED"}] + if method=="filesystem.stat": + if args[0].endswith("/datapacks"): + return {"type":"DIRECTORY"} + raise FileNotFoundError() + if method=="core.download": + self.assertEqual(args[1][0],s.TARGET) + return [77,"/_download/exact-owned-artifact"] + raise AssertionError(method) + def upload(host,port,tls,user,password,target,content,mode,timeout): + self.assertEqual(target,s.TARGET) + self.assertEqual(content,blob) + self.assertEqual(mode,0o644) + called.append("filesystem.put") + return 76 + def jobs(job,label): + self.assertIn(job,(76,77)) + called.append("job_"+str(job)) + def http(host,port,tls,path,timeout): + called.append("download_bytes") + return blob + with patch.object(s,"SHA",sha): + r=s.stage(fake,jobs,upload,http,host="127.0.0.1",port=80, + tls=False,username="rdte",password="test-not-secret", + timeout=8,blob=blob,evidence={"sha256":sha}) + self.assertEqual(r["classification"],"REAL_NATIVE_GUEST_ARTIFACT_BYTES_STAGED") + self.assertFalse(r["artifact_gameplay_effect_verified"]) + self.assertFalse(r["collage_managed_deployment"]) + self.assertEqual(r["physical_truenas_apply"],"HOLD") + self.assertLess(called.index("app.query"),called.index("filesystem.put")) + self.assertEqual(called.count("download_bytes"),1) + + def test_unsafe_readback_contract_refused(self): + with self.assertRaisesRegex(s.Refused,"NATIVE_WORLD_ARTIFACT_DOWNLOAD_REF_INVALID"): + s.readback(lambda method,args:[4,"https://external-host/forbidden"],None,None, + host="127.0.0.1",port=80,tls=False,timeout=8) + + +if __name__=="__main__": + unittest.main() diff --git a/tests/test_vanilla263_native_rollback_receipt.py b/tests/test_vanilla263_native_rollback_receipt.py new file mode 100644 index 000000000..164c6ac16 --- /dev/null +++ b/tests/test_vanilla263_native_rollback_receipt.py @@ -0,0 +1,102 @@ +"""OP4G independent receipt gate: cannot upgrade staged ZIP/G2 to G3 mechanics.""" +from __future__ import annotations +import copy +import unittest +from tests.test_truenas_t6_vanilla263_guest import guest_fixture +from scripts.assert_vanilla263_native_t6_receipt import check +from scripts.admit_vanilla263_native_t6_control import APP + +SHA="da25005e8729e6f013f7108ac5b8869dd46323befc78efdae18221b26cafe5f8" + +def accepted_fixture(): + root=guest_fixture() + p=root["foundry_materialization"] + p["collage_readback"]={ + "schema":"semper-supra.collage-native-readback-p2/1", + "classification":"PROVIDER_READBACK_MATCH", + "source_profile":"vanilla263", + "source_sha":"2b6abe1141e65b8c6872eeba6c8ae39f5a9635f8", + "native_app":APP,"truenas_version":"25.04.2.6", + "world_id":"lantern-rdte", + "service_id":"11111111-1111-4111-8111-111111111111", + "ownership_fields_matched":6,"transport":"isolated_guest_tls_verified", + "credential_scope":"APPS_READ", + "credential_mutation_phase":"disposable_guest_setup_before_observation", + "observation_mutation_count":0,"world_artifact_format_verified":False, + "world_activated":False,"cloudnet_adoption_executed":False, + "physical_true_nas_mutated":False,"tls_cert_sha256":"a"*64, + } + p["lantern_artifact_stage"]={ + "schema":"supracraft.native-vanilla263-lantern-artifact-stage/1", + "classification":"REAL_NATIVE_GUEST_ARTIFACT_BYTES_STAGED", + "artifact_sha256":SHA,"download_readback_sha256":SHA, + "world_id":"lantern-rdte","native_app":APP, + "native_truenas_version":"25.04.2.6","zfs_world_owned":True, + "app_quiesced_before_write":True,"persisted_on_restart":True, + "persisted_on_reinstall":True, + "artifact_gameplay_effect_verified":False, + "collage_managed_deployment":False, + "principal_authorized_to_apply":False,"physical_truenas_apply":"HOLD", + } + p["native_world_rollback"]={ + "schema":"supracraft.native-vanilla263-rdte-rollback-mechanics/1", + "classification":"G3_DISPOSABLE_MECHANICS_ONLY", + "phase":"injected_fault_rolled_back", + "snapshot_owned":True,"snapshot_id_verified":True, + "candidate_present_before_stage":False, + "release_fault_injected":True, + "candidate_absent_after_rollback":True, + "native_app_running_after_rollback":True, + "native_game_status_rechecked":True, + "candidate_sha256":SHA,"collage_managed_deployment":False, + "world_activation_authorized_by_collage":False, + "actual_player_gameplay_verified":False, + "physical_true_nas_mutated":False, + } + p["readiness"]["g3-rollback"]=copy.deepcopy(p["readiness"]["reinstall"]) + return root + + +class NegativeReceiptTests(unittest.TestCase): + def test_source_fixture_positive_and_missing_rollback_refused(self): + root=accepted_fixture() + check(root,require_collage=True,require_lantern_staged=True, + require_native_rollback=True) + del root["foundry_materialization"]["native_world_rollback"] + with self.assertRaisesRegex(ValueError,"native disposable rollback receipt missing"): + check(root,require_collage=True,require_lantern_staged=True, + require_native_rollback=True) + + def test_cannot_promote_without_g2_or_real_artifact(self): + root=accepted_fixture() + for collage,stage in ((False,False),(True,False),(False,True)): + with self.subTest(collage=collage,stage=stage): + with self.assertRaisesRegex(ValueError,"native rollback requires"): + check(root,require_collage=collage, + require_lantern_staged=stage,require_native_rollback=True) + + def test_forged_cleanup_owner_replay_game_health_and_promotion_are_refused(self): + for parent,field,value in ( + ("native_world_rollback","snapshot_owned",False), + ("native_world_rollback","candidate_sha256","f"*64), + ("native_world_rollback","candidate_absent_after_rollback",False), + ("native_world_rollback","world_activation_authorized_by_collage",True), + ("native_world_rollback","collage_managed_deployment",True), + ("native_world_rollback","actual_player_gameplay_verified",True), + ("native_world_rollback","physical_true_nas_mutated",True), + ("native_world_rollback","native_app_running_after_rollback",False), + ("readiness","g3-rollback",{}), + ): + root=accepted_fixture() + p=root["foundry_materialization"] + if parent=="readiness": + p[parent][field]=value + else: + p[parent][field]=value + with self.subTest(field=field),self.assertRaises(ValueError): + check(root,require_collage=True, + require_lantern_staged=True,require_native_rollback=True) + + +if __name__=="__main__": + unittest.main() diff --git a/tests/test_vanilla263_native_world_rollback.py b/tests/test_vanilla263_native_world_rollback.py new file mode 100644 index 000000000..571e62b78 --- /dev/null +++ b/tests/test_vanilla263_native_world_rollback.py @@ -0,0 +1,166 @@ +"""OP4G G3 disposable world transaction cheap red/blue contract tests. + +Mock-only local transport for source validation. No native guest or AUTHORIZED +COLLAGE APPLY may be inferred from these unit tests. +""" +from __future__ import annotations +import unittest + +from scripts.qualify_vanilla263_native_world_rollback import ( + APP, DATASET, NAME, SNAPSHOT, WORLD, SCHEMA, Refused, + app_state, take_owned_pre_stage_snapshot, + restore_owned_baseline_after_injected_fault, +) + + +class SimulatedOwnedGuest: + def __init__(self): + self.state = "STOPPED" + self.pack = False + self.snapshot = False + self.call_log = [] + self.replayed = False + self.unknown_app = False + self.foreign_snapshot = False + self.forced_failure = None + + def __call__(self, method, args): + self.call_log.append((method, args)) + if method == self.forced_failure: + raise ConnectionError("UNTRUSTED SECRET MUST NEVER ESCAPE") + if method == "app.query": + return ([{"name": "foreign", "state": self.state}] if self.unknown_app + else [{"name": APP, "state": self.state}]) + if method == "filesystem.listdir": + assert args == [WORLD + "/datapacks"] + return [{"name": NAME, "type": "FILE"}] if self.pack else [] + if method == "zfs.snapshot.query": + assert args == [[["id", "=", SNAPSHOT]]] + if self.foreign_snapshot: + return [{"id": "foreign/snapshot"}] + return [{"id": SNAPSHOT}] if self.snapshot else [] + if method == "zfs.snapshot.create": + assert args[0]["dataset"] == DATASET + assert args[0]["name"] == "op4g-pre-lantern" + assert args[0]["recursive"] is False + assert args[0]["suspend_vms"] is False + assert not self.snapshot + self.snapshot = True + return {"id": SNAPSHOT} + if method == "app.stop": + assert args == [APP] + self.state = "STOPPED" + return 11 + if method == "zfs.snapshot.rollback": + assert args == [SNAPSHOT, {"recursive": False, + "recursive_clones": False, "force": False, + "recursive_rollback": False}] + assert self.state == "STOPPED" + assert self.snapshot + self.pack = False + return None + if method == "app.start": + assert args == [APP] + self.state = "RUNNING" + return 12 + raise AssertionError(method) + + +class NativeRollbackContract(unittest.TestCase): + def test_exact_stop_snapshot_stage_fault_restore_and_restart(self): + api = SimulatedOwnedGuest() + baseline = take_owned_pre_stage_snapshot(api) + self.assertEqual(baseline["schema"], SCHEMA) + self.assertFalse(baseline["world_activation_authorized_by_collage"]) + self.assertFalse(baseline["actual_player_gameplay_verified"]) + self.assertFalse(baseline["physical_true_nas_mutated"]) + api.pack = True # independent actual stage is separately admitted + api.state = "RUNNING" + events = [] + def job(i, label): + self.assertIn(i, (11, 12)) + events.append(("job", label)) + def wait_app(): + self.assertEqual(api.state, "RUNNING") + events.append(("app", "ready")) + def wait_status(which): + self.assertEqual(which, "g3-rollback") + events.append(("game", which)) + receipt = restore_owned_baseline_after_injected_fault( + api, job, wait_app, wait_status) + self.assertTrue(receipt["candidate_absent_after_rollback"]) + self.assertTrue(receipt["native_game_status_rechecked"]) + self.assertFalse(receipt["collage_managed_deployment"]) + self.assertFalse(receipt["actual_player_gameplay_verified"]) + order = [x[0] for x in api.call_log] + self.assertLess(order.index("zfs.snapshot.create"), + order.index("zfs.snapshot.rollback")) + self.assertLess(order.index("app.stop"), + order.index("zfs.snapshot.rollback")) + self.assertLess(order.index("zfs.snapshot.rollback"), + order.index("app.start")) + self.assertEqual(len([x for x in api.call_log if x[0] == + "zfs.snapshot.rollback"]), 1) + self.assertIn(("game", "g3-rollback"), events) + + def test_quiescence_owner_replay_and_existing_candidate_negatives(self): + for state, pack, snapshot, foreign, expected in ( + ("RUNNING", False, False, False, "G3_QUIESCE_REQUIRED"), + ("STOPPED", True, False, False, "G3_CANDIDATE_ALREADY_PRESENT"), + ("STOPPED", False, True, False, "G3_SNAPSHOT_REPLAY_REFUSED"), + ("STOPPED", False, False, True, "G3_APP_OWNER_NOT_EXACT"), + ): + api = SimulatedOwnedGuest() + api.state, api.pack, api.snapshot, api.unknown_app = ( + state, pack, snapshot, foreign) + with self.subTest(expected=expected): + with self.assertRaisesRegex(Refused, expected): + take_owned_pre_stage_snapshot(api) + self.assertFalse(any(x[0] == "zfs.snapshot.create" + for x in api.call_log)) + api = SimulatedOwnedGuest() + api.foreign_snapshot = True + with self.assertRaisesRegex(Refused, "G3_SNAPSHOT_QUERY_AMBIGUOUS"): + take_owned_pre_stage_snapshot(api) + + def test_rollback_refuses_absent_artifact_or_unowned_app(self): + for state, pack, snapshot, reason in ( + ("STOPPED", True, True, "G3_PREFAULT_APP_NOT_RUNNING"), + ("RUNNING", False, True, "G3_CANDIDATE_MISSING_PREFAULT"), + ("RUNNING", True, False, "G3_SNAPSHOT_NOT_DURABLE"), + ): + api = SimulatedOwnedGuest() + api.state, api.pack, api.snapshot = state, pack, snapshot + with self.subTest(reason=reason): + with self.assertRaisesRegex(Refused, reason): + restore_owned_baseline_after_injected_fault( + api, lambda *_: None, lambda: None, lambda *_: None) + self.assertNotIn("app.stop", [x[0] for x in api.call_log]) + + def test_snapshot_and_inventory_malformed_shapes_rejected_before_mutation(self): + class BadShape(SimulatedOwnedGuest): + def __init__(self, method, result): + super().__init__() + self.bad_method, self.bad_result = method, result + def __call__(self, method, args): + if method == self.bad_method: + self.call_log.append((method, args)) + return self.bad_result + return super().__call__(method, args) + for method, response in ( + ("filesystem.listdir", [{"name": "../../foreign"}] * 4097), + ("filesystem.listdir", [{"name": 5}]), + ("zfs.snapshot.query", [{"id": SNAPSHOT}, {"id": SNAPSHOT}]), + ("zfs.snapshot.query", {"id": SNAPSHOT}), + ("app.query", [{"name": APP, "state": "STOPPED"}] * 2), + ): + api = BadShape(method, response) + with self.subTest(method=method): + with self.assertRaises(Refused): + take_owned_pre_stage_snapshot(api) + self.assertNotIn("zfs.snapshot.create", + [x[0] for x in api.call_log]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_vanilla263_t6_guest_routing.py b/tests/test_vanilla263_t6_guest_routing.py new file mode 100644 index 000000000..d1f137be8 --- /dev/null +++ b/tests/test_vanilla263_t6_guest_routing.py @@ -0,0 +1,222 @@ +"""Static red/blue: distinct native guest selector; no request-edge replay.""" +from __future__ import annotations +import json +from pathlib import Path +import re +import unittest + +ROOT=Path(__file__).resolve().parents[1] +WORKFLOW=(ROOT/".github/workflows/gha-kvm-system-rdte.yml").read_text() +HARNESS=(ROOT/"scripts/gha_kvm_truenas_rdte.sh").read_text() + + +class TestVanilla263GuestRouting(unittest.TestCase): + def test_native_product_is_explicit_not_paper_alias(self): + self.assertIn("- minecraft-vanilla-263",WORKFLOW) + self.assertIn('or . == "minecraft-vanilla-263"',WORKFLOW) + self.assertIn('needs.changes.outputs.truenas_t6_product == \'minecraft-vanilla-263\'',WORKFLOW) + self.assertIn('needs.changes.outputs.truenas_version == \'25.04.2.6\'',WORKFLOW) + self.assertIn('[[ "$VERSION" == "25.04.2.6" ]]',HARNESS) + self.assertIn('|| "$T6_PRODUCT" == "minecraft-vanilla-263"',HARNESS) + + def test_producer_signer_and_fresh_exact_artifact_seam(self): + pin="5ee204df205edfd531304441fd1a6a24004557b1" + self.assertIn("foundry-minecraft-vanilla263-t6-control:",WORKFLOW) + self.assertIn( + "export-minecraft-vanilla-263-11531-t6-control.yml@"+pin,WORKFLOW) + self.assertIn("foundry_ref: "+pin,WORKFLOW) + self.assertIn( + "name: minecraft-vanilla263-truenas-250426-11531-signed-source-candidate",WORKFLOW) + self.assertIn("scripts/admit_vanilla263_native_t6_control.py",WORKFLOW) + self.assertIn('foundry_commit="'+pin+'"',WORKFLOW) + self.assertIn( + "needs['foundry-minecraft-vanilla263-t6-control'].result == 'success'", + WORKFLOW) + + def test_exact_oracle_not_legacy_paper_status(self): + self.assertIn("Checkout independently qualified vanilla26.3 protocol777 status oracle",WORKFLOW) + self.assertIn("4df9fbbfea0ae0e824719db367bd551349a86378",WORKFLOW) + self.assertIn("python3 scripts/assert_vanilla263_native_t6_receipt.py",WORKFLOW) + self.assertIn("--product-profile vanilla263 --native-catalog-profile 1.15.31",HARNESS) + self.assertIn('"minecraft_protocol": 777',HARNESS) + self.assertIn('"minecraft_version": "26.3"',HARNESS) + self.assertIn('"rdte-t6-minecraft-vanilla-263"',HARNESS) + self.assertIn("--status-port \"$MINECRAFT_HOST_PORT\"",HARNESS) + self.assertIn("MINECRAFT_HOSTFWD",HARNESS) + + def test_existing_request_edge_untouched_during_source_qualification(self): + current=json.loads((ROOT/"config/truenas-rdte-run-request.json").read_text()) + proposal=json.loads((ROOT/"config/truenas-vanilla263-native-admission-request.json").read_text()) + self.assertEqual(current.get("product"),"minecraft-vanilla-263") + self.assertEqual(current["version"],"25.04.2.6") + self.assertEqual(current["rung"],"t6") + self.assertEqual(current["authority_issue"],423) + self.assertEqual(current["consumer_authority_issue"],627) + self.assertEqual(current["request_id"],"tn-25-04-2-6-t6-vanilla-263-op4m-020-namespaced-overworld-anvil-three-checkpoint") + self.assertNotEqual(current["request_id"],"tn-25-04-2-6-t6-vanilla-263-op4i-016-backend-anvil-three-checkpoint") + self.assertNotEqual(current["request_id"],"tn-25-04-2-6-t6-vanilla-263-op4j-017-backend-phase-anvil-three-checkpoint") + self.assertNotEqual(current["request_id"],"tn-25-04-2-6-t6-vanilla-263-op4k-018-parsed-native-anvil-three-checkpoint") + self.assertNotEqual(current["request_id"],"tn-25-04-2-6-t6-vanilla-263-op4l-019-pinned-readonly-join-anvil-three-checkpoint") + self.assertIn("OP4L-019 permanently CONSUMED",current["purpose"]) + self.assertIn("dimensions/minecraft/overworld/region/r.0.0.mca",current["purpose"]) + self.assertIn("OP4K-018",current["purpose"]) + self.assertIn("permanently CONSUMED",current["purpose"]) + self.assertIn("OP4J-017", current["purpose"]) + self.assertIn("permanently consumed", current["purpose"]) + self.assertIn("vanilla263-11531", current["purpose"]) + self.assertIn("OP4I-016",current["purpose"]) + self.assertIn("permanently CONSUMED",current["purpose"]) + self.assertIn("App1.15.31",current["purpose"]) + self.assertEqual(current["consumer_authority_issue"],627) + self.assertTrue(all(current[k] is True for k in + ("collage_readback","lantern_stage","native_world_rollback", + "native_offline_player","native_backend_region"))) + + self.assertEqual(proposal["request_id"],"tn-25-04-2-6-t6-vanilla-263-op4b-001") + self.assertEqual(proposal["consumer_authority_issue"],627) + self.assertIn("NOT wired",proposal["purpose"]) + # Exactly one bounded heavyweight request edge is admitted in this PR. + # A replay of this request_id without a new distinct edge is forbidden. + self.assertIn("config/truenas-rdte-run-request.json)",WORKFLOW) + self.assertIn('"$current_request_id" == "$previous_request_id"',WORKFLOW) + + def test_scoped_native_collage_readback_explicit_only_not_default_guest(self): + self.assertIn('truenas_collage_readback=false',WORKFLOW) + self.assertIn('(.collage_readback // false)',WORKFLOW) + self.assertIn('truenas_collage_readback=$truenas_collage_readback',WORKFLOW) + self.assertIn('--vanilla-collage-readback',WORKFLOW) + self.assertIn('needs.changes.outputs.truenas_collage_readback',WORKFLOW) + self.assertIn('--require-collage-readback',WORKFLOW) + self.assertIn('ref: b9256d6ec0dbac43be2f5a136103b6e3512ee2ec',WORKFLOW) + self.assertIn('[[ "$truenas_collage_readback" == "true" ]]',WORKFLOW) + self.assertIn('"$T6_PRODUCT" == "minecraft-vanilla-263"',HARNESS) + self.assertIn('VANILLA_COLLAGE_READBACK=false',HARNESS) + self.assertIn('--collage-source-ref "b9256d6ec0dbac43be2f5a136103b6e3512ee2ec"',HARNESS) + self.assertIn('NATIVE_READBACK_ARGS=()',HARNESS) + self.assertIn('"${NATIVE_READBACK_ARGS[@]}"',HARNESS) + request=json.loads((ROOT/"config/truenas-rdte-run-request.json").read_text()) + self.assertIs(request.get("collage_readback"),True) + self.assertEqual(request["request_id"],"tn-25-04-2-6-t6-vanilla-263-op4m-020-namespaced-overworld-anvil-three-checkpoint") + self.assertEqual(request["product"],"minecraft-vanilla-263") + self.assertEqual(request["version"],"25.04.2.6") + self.assertNotIn("--vanilla-collage-readback", + str(request)) + # Synthetic source tests cannot count as a live TLS/Go observation. + probe=(ROOT/"scripts/truenas_middleware_collage_readback_p2.py").read_text() + self.assertIn('VANILLA263_SOURCE_SHA',probe) + self.assertIn('profile="vanilla263" if VANILLA263 else "paper"', + (ROOT/"scripts/truenas_middleware_minecraft_t6_probe.py").read_text()) + + def test_real_lantern_native_zfs_stage_never_autoruns_or_promotes_gameplay(self): + self.assertIn('truenas_lantern_stage=false', WORKFLOW) + self.assertIn('(.lantern_stage // false)', WORKFLOW) + self.assertIn('truenas_lantern_stage=$truenas_lantern_stage', WORKFLOW) + self.assertIn('--vanilla-lantern-zip',WORKFLOW) + self.assertIn('--vanilla-lantern-manifest',WORKFLOW) + self.assertIn('--require-lantern-staged',WORKFLOW) + self.assertIn('ref: 7f888b5829f4e5576d5ce512d86327348c43f01e',WORKFLOW) + self.assertIn('verify_exact_candidate',WORKFLOW) + self.assertIn('native-target-profile vanilla263-250426',WORKFLOW) + self.assertIn('VANILLA_LANTERN_ZIP=""',HARNESS) + self.assertIn('"$VANILLA_COLLAGE_READBACK" == "true"',HARNESS) + self.assertIn('NATIVE_LANTERN_ARGS=()',HARNESS) + current=json.loads((ROOT/"config/truenas-rdte-run-request.json").read_text()) + self.assertIs(current.get("lantern_stage"),True) + source=(ROOT/"scripts/truenas_middleware_minecraft_t6_probe.py").read_text() + self.assertIn('wait_job(call("app.stop", [APP])',source) + self.assertIn('stage(',source) + self.assertIn('persisted_on_reinstall',source) + self.assertIn('persisted_on_restart',source) + + def test_native_rollback_requires_exact_unique_disposable_request_and_g2(self): + request=json.loads((ROOT/"config/truenas-rdte-run-request.json").read_text()) + self.assertEqual(request["request_id"], + "tn-25-04-2-6-t6-vanilla-263-op4m-020-namespaced-overworld-anvil-three-checkpoint") + self.assertIs(request.get("native_world_rollback"),True) + self.assertIs(request.get("collage_readback"),True) + self.assertIs(request.get("lantern_stage"),True) + self.assertIn("truenas_world_rollback=false",WORKFLOW) + self.assertIn("(.native_world_rollback // false)",WORKFLOW) + self.assertIn('truenas_world_rollback=$truenas_world_rollback',WORKFLOW) + self.assertIn("--vanilla-world-rollback",WORKFLOW) + self.assertIn("--require-native-rollback",WORKFLOW) + self.assertIn("VANILLA_WORLD_ROLLBACK=false",HARNESS) + self.assertIn('"$VANILLA_COLLAGE_READBACK" == "true"',HARNESS) + probe=(ROOT/"scripts/truenas_middleware_minecraft_t6_probe.py").read_text() + self.assertIn('--native-world-rollback',probe) + self.assertIn('take_owned_pre_stage_snapshot(call)',probe) + self.assertIn('restore_owned_baseline_after_injected_fault(',probe) + + def test_native_synthetic_client_is_opt_in_and_bound_to_g3_owned_qemu_forward(self): + request=json.loads((ROOT/"config/truenas-rdte-run-request.json").read_text()) + # 012 is consumed; source qualification of G4 cannot replay it. + self.assertEqual(request["request_id"], + "tn-25-04-2-6-t6-vanilla-263-op4m-020-namespaced-overworld-anvil-three-checkpoint") + self.assertIs(request.get("native_offline_player"),True) + self.assertIn("truenas_native_offline_player=false",WORKFLOW) + self.assertIn("(.native_offline_player // false)",WORKFLOW) + self.assertIn("truenas_native_offline_player=$truenas_native_offline_player",WORKFLOW) + self.assertIn("--vanilla-native-player",WORKFLOW) + self.assertIn("--vanilla-player-node-path",WORKFLOW) + self.assertIn("--require-native-client-visibility",WORKFLOW) + self.assertIn("VANILLA_NATIVE_PLAYER=false",HARNESS) + self.assertIn("MINECRAFT_HOSTFWD",HARNESS) + self.assertIn('"${MINECRAFT_HOSTFWD#,hostfwd=}"',HARNESS) + probe=(ROOT/"scripts/truenas_middleware_minecraft_t6_probe.py").read_text() + self.assertIn("--native-offline-player",probe) + self.assertIn("overlay = derive(control, admission)",probe) + self.assertIn('stage="candidate"',probe) + self.assertIn('stage="rollback"',probe) + self.assertIn("if a.native_offline_player",probe) + assert_path=(ROOT/"scripts/assert_vanilla263_native_t6_receipt.py").read_text() + self.assertIn("native_guest_backend_independent_oracle_qualified",assert_path) + + def test_native_backend_three_checkpoint_is_opt_in_without_consuming_015(self): + request=json.loads((ROOT/"config/truenas-rdte-run-request.json").read_text()) + self.assertIs(request.get("native_backend_region"), True) + self.assertEqual(request["request_id"], + "tn-25-04-2-6-t6-vanilla-263-op4m-020-namespaced-overworld-anvil-three-checkpoint") + for needle in ("truenas_native_backend_region=false", + "(.native_backend_region // false)", + "truenas_native_backend_region=$truenas_native_backend_region", + "--vanilla-backend-region", "--require-native-backend-region"): + self.assertIn(needle, WORKFLOW) + for needle in ("VANILLA_BACKEND_REGION=false", + "--vanilla-backend-region", + '--native-backend-region', + '"$VANILLA_BACKEND_REGION" == "true"'): + self.assertTrue(needle in HARNESS or + needle in (ROOT/"scripts/truenas_middleware_minecraft_t6_probe.py").read_text(), + needle) + probe=(ROOT/"scripts/truenas_middleware_minecraft_t6_probe.py").read_text() + for stage in ("preaction","candidate","rollback"): + self.assertIn('native_backend_world"]["'+stage+'"]', probe) + self.assertIn("backend_independent_world_oracle_pass", probe) + self.assertIn("owned_config(cfg, desired)", probe) + self.assertIn("native_backend_region", str(request)) + + def test_player_dependencies_are_installed_in_native_guest_job(self): + top, native = WORKFLOW.split(" truenas-26-beta3:", 1) + setup = "Set up immutable 26.3 experimental client toolchain for native G4" + install = "Build pre-qualified 26.3 Mineflayer client on public runner only" + for name in (setup, install): + self.assertEqual(WORKFLOW.count(name), 1) + self.assertNotIn(name, top) + self.assertIn(name, native) + self.assertLess(native.index(install), + native.index("Ensure bounded QEMU/KVM prerequisites")) + self.assertLess(native.index("Ensure bounded QEMU/KVM prerequisites"), + native.index("Exercise pinned TrueNAS installer target")) + self.assertIn("--require-native-client-visibility", native) + + def test_no_fallback_guest_or_physical_promotion(self): + self.assertNotIn('product == "minecraft-vanilla-263" && needs[\'foundry-minecraft-paper',WORKFLOW) + self.assertIn("agent-dispatch-heavyweight-rdte",WORKFLOW) + self.assertIn("queue: max",WORKFLOW) + self.assertIn("cancel-in-progress: false",WORKFLOW) + # Exact accepted source consumer must refuse a wrong Foundry SHA + from scripts.admit_vanilla263_native_t6_control import CONTROL_SHA + self.assertEqual(CONTROL_SHA,"59516bbbcb1750af6f0cb1c624756f08113a070acc11c60affe94d2bbd8d8dd5") + +if __name__=="__main__": + unittest.main() diff --git a/tests/test_vanilla263_t6_source_admission.py b/tests/test_vanilla263_t6_source_admission.py new file mode 100644 index 000000000..4ee811e6e --- /dev/null +++ b/tests/test_vanilla263_t6_source_admission.py @@ -0,0 +1,156 @@ +"""Native vanilla26.3 admission red/blue tests with exact Foundry producer fixtures.""" +import copy +import importlib.util +import json +import os +from pathlib import Path +import sys +import tempfile +import unittest +from unittest.mock import patch + +ROOT=Path(__file__).resolve().parents[1] +sys.path.insert(0,str(ROOT/"scripts")) +import admit_vanilla263_native_t6_control as consumer + +FOUNDRY=ROOT/".deps"/"vanilla-foundry" +EXPORT=FOUNDRY/"tools"/"export_minecraft_vanilla_263_11531_t6_control.py" + + +def producer(): + if not EXPORT.is_file(): + raise AssertionError("EXACT_FOUNDRY_PRODUCER_NOT_CHECKED_OUT") + spec=importlib.util.spec_from_file_location("foundry_vanilla263_exact", EXPORT) + module=importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def source_document(): + f=producer() + import yaml + pin=json.loads(f.PIN.read_text()) + values=yaml.safe_load(f.VALUES.read_text()) + profile=json.loads(f.PROFILE.read_text()) + evidence={ + "result":"PASS","truenas_target":"25.04.2.6", + "runtime_qualification":False, + "upstream":{"ref":f.SOURCE,"app":"community/minecraft"}, + "server":{"catalog_version":"1.15.31","type":"VANILLA", + "minecraft_version":"26.3","image_selector":"j25_image", + "image_ref":f.IMAGE,"ownership_metadata":f.IDENTITY, + "world_mount":{"source":"/opt/tests/collage-vanilla-263-world"}}, + "compose_sha256":"a"*64, + } + return f.produce(consumer.PRODUCER,evidence,pin,values,profile,f.BLOB) + + +def readmit_local_fixture(doc): + # Fixture canonical hash differs from real rendered upstream compose hash. + # Relax only test EXPECTED digest while keeping every semantic gate exact. + source=copy.deepcopy(doc) + digest=source.pop("control_sha256") + assert digest==consumer.canonical_hash(source) + with patch.object(consumer,"CONTROL_SHA",digest): + return consumer.verify(doc,expected_digest=digest) + + +class TestNativeVanilla263SourceAdmission(unittest.TestCase): + def test_signed_source_is_not_a_guest(self): + row=readmit_local_fixture(source_document()) + self.assertEqual(row["classification"], + "SOURCE_CONTROL_READY_GUEST_NOT_DISPATCHED") + self.assertEqual(row["minecraft_protocol"],777) + self.assertFalse(row["native_guest_qualified"]) + self.assertEqual(row["physical_truenas_apply"],"HOLD") + + def test_real_rendered_control_from_exact_producer(self): + loc=os.environ.get("VANILLA263_SOURCE_CONTROL") + if not loc: + self.fail("VANILLA263_SOURCE_CONTROL_MISSING") + c=json.loads(Path(loc).read_text()) + got=consumer.verify(c) + self.assertEqual(got["control_sha256"],consumer.CONTROL_SHA) + self.assertEqual(got["foundry_ref"],consumer.PRODUCER) + + def test_signed_digest_forgery_fails_closed(self): + c=source_document() + original_digest=c["control_sha256"] + c["source"]["image_reference"]="ghcr.io/example:latest" + # Exercise the production signature check, not the fixture helper's + # self-consistency assertion. Semantic checks come only after signing. + with patch.object(consumer,"CONTROL_SHA",original_digest): + with self.assertRaisesRegex(consumer.Refused,"CANONICAL_CONTROL_SHA_DRIFT"): + consumer.verify(c,expected_digest=original_digest) + with self.assertRaisesRegex(consumer.Refused,"CANONICAL_CONTROL_SHA_DRIFT"): + consumer.verify(c) + + def test_precise_fail_closed_target_runtime_owner_world(self): + mutations=[ + (("target","truenas_version"),"25.04.1","NATIVE_GUEST_TARGET_DRIFT"), + (("target","middleware_commit"),"f"*40,"NATIVE_GUEST_TARGET_DRIFT"), + (("source","apps_commit"),"f"*40,"NATIVE_CATALOG_SOURCE_DRIFT"), + (("source","catalog_version"),"1.15.29","NATIVE_CATALOG_SOURCE_DRIFT"), + (("source","image_reference"),"ghcr.io/example:latest","NATIVE_CATALOG_SOURCE_DRIFT"), + (("runtime","minecraft_version"),"1.21.11","NATIVE_VANILLA_PRODUCT_DRIFT"), + (("runtime","minecraft_protocol"),774,"NATIVE_VANILLA_PRODUCT_DRIFT"), + (("runtime","java_version"),21,"NATIVE_VANILLA_PRODUCT_DRIFT"), + (("runtime","values_template","minecraft","type"),"PAPER","NATIVE_VANILLA_RUNTIME_INPUT_DRIFT"), + (("runtime","values_template","minecraft","online_mode"),False,"NATIVE_VANILLA_RUNTIME_INPUT_DRIFT"), + (("runtime","values_template","minecraft","enable_rcon"),True,"NATIVE_VANILLA_RUNTIME_INPUT_DRIFT"), + (("runtime","values_template","storage","data","type"),"host_path","NATIVE_WORLD_OWNER_BOUNDARY_DRIFT"), + (("runtime","values_template","storage","additional_storage"),[],"NATIVE_WORLD_OWNER_BOUNDARY_DRIFT"), + (("runtime","values_template","network","host_network"),True,"NATIVE_PORT_POLICY_DRIFT"), + (("evidence","native_guest_created"),True,"SOURCE_ONLY_GUEST_OR_PHYSICAL_PROMOTION_DRIFT"), + (("physical_truenas_apply",),"READY","SOURCE_ONLY_GUEST_OR_PHYSICAL_PROMOTION_DRIFT"), + ] + for path,value,refusal in mutations: + with self.subTest(path=path): + doc=source_document() + node=doc + for key in path[:-1]: + node=node[key] + node[path[-1]]=value + sign=dict(doc) + sign.pop("control_sha256") + doc["control_sha256"]=consumer.canonical_hash(sign) + with self.assertRaisesRegex(consumer.Refused,refusal): + readmit_local_fixture(doc) + + def test_owner_env_duplicate_and_foreign_mount_refused(self): + for change in ("duplicate_env","foreign_world","readonly_world"): + with self.subTest(change=change): + doc=source_document() + values=doc["runtime"]["values_template"] + if change=="duplicate_env": + values["minecraft"]["additional_envs"].append( + dict(values["minecraft"]["additional_envs"][0])) + elif change=="foreign_world": + values["storage"]["additional_storage"][0]["host_path_config"]["path"]="/tmp/foreign" + else: + values["storage"]["additional_storage"][0]["read_only"]=True + sign=dict(doc) + sign.pop("control_sha256") + doc["control_sha256"]=consumer.canonical_hash(sign) + with self.assertRaises(consumer.Refused): + readmit_local_fixture(doc) + + def test_source_only_control_does_not_dispatch_heavy_guest(self): + # Source qualification has no actuator, even once an independently + # admitted request-edge legitimately selects vanilla263 for a guest. + proposed=json.loads((ROOT/"config/truenas-vanilla263-native-admission-request.json").read_text()) + self.assertEqual(proposed["version"],"25.04.2.6") + self.assertEqual(proposed["product"],"minecraft-vanilla-263") + self.assertEqual(proposed["consumer_authority_issue"],627) + self.assertIn("NOT wired",proposed["purpose"]) + workflow=(ROOT/".github/workflows/vanilla263-t6-source-admission.yml").read_text() + self.assertIn("scripts/admit_vanilla263_native_t6_control.py",workflow) + self.assertIn("SOURCE_CONTROL_READY_GUEST_NOT_DISPATCHED",workflow) + self.assertNotIn("gha_kvm_truenas_rdte.sh",workflow) + self.assertNotIn("--t6-product",workflow) + self.assertNotIn("config/truenas-rdte-run-request.json",workflow) + self.assertNotIn("native_guest_qualified\"] is True",workflow) + + +if __name__=="__main__": + unittest.main() diff --git a/tests/test_windows_w1_native_plan.py b/tests/test_windows_w1_native_plan.py new file mode 100644 index 000000000..84944ffd7 --- /dev/null +++ b/tests/test_windows_w1_native_plan.py @@ -0,0 +1,108 @@ +import pathlib +import unittest + +from scripts.compute_guest_profile_contract import load, validate +from scripts.windows_w1_native_plan import ( + FIXTURE_ID, + MEDIA_SHA256, + MEMORY_MIB, + SYSTEM_DISK_BYTES, + VCPUS, + proxmox_plan, + truenas_plan, + WindowsW1PlanError, +) + + +class WindowsW1NativePlanTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.profile = load(pathlib.Path("config/compute-guest-profiles.json")) + validate(cls.profile) + + def test_portable_semantics_match_across_platforms(self): + tn = truenas_plan( + self.profile, + pool="rdtepool", + iso_path="/mnt/rdtepool/windows/windows11.iso", + bridge="br0", + ) + pve = proxmox_plan( + self.profile, + vmid=9301, + storage="local-lvm", + iso_volume="local:iso/windows11.iso", + bridge="vmbr0", + ) + self.assertEqual(tn["portable_intent"], pve["portable_intent"]) + req = tn["portable_intent"]["requirements"] + self.assertEqual(req["virtual_processors"], VCPUS) + self.assertEqual(req["memory_mib"], MEMORY_MIB) + self.assertEqual(req["system_disk_gib"], 64) + self.assertTrue(req["secure_boot"]) + self.assertEqual(req["tpm_version"], "2.0") + self.assertEqual(tn["portable_intent"]["fixture"], FIXTURE_ID) + self.assertEqual(tn["portable_intent"]["media"]["sha256"], MEDIA_SHA256) + + def test_truenas_lowering_preserves_windows_requirements(self): + plan = truenas_plan( + self.profile, + pool="rdtepool", + iso_path="/mnt/rdtepool/windows/windows11.iso", + bridge="br0", + ) + create = plan["vm_create"] + self.assertEqual(create["vcpus"] * create["cores"] * create["threads"], 2) + self.assertEqual(create["memory"], 4096) + self.assertEqual(create["cpu_mode"], "HOST-PASSTHROUGH") + self.assertEqual(create["bootloader"], "UEFI") + self.assertEqual(create["bootloader_ovmf"], "OVMF_CODE_4M.secboot.fd") + self.assertEqual(create["machine_type"], "pc-q35-6.2") + self.assertTrue(create["trusted_platform_module"]) + self.assertTrue(create["enable_secure_boot"]) + self.assertTrue(create["hyperv_enlightenments"]) + disk = next(x for x in plan["device_templates"] if x["role"] == "system-disk") + self.assertEqual(disk["attributes"]["type"], "AHCI") + self.assertEqual(disk["attributes"]["zvol_volsize"], SYSTEM_DISK_BYTES) + nic = next(x for x in plan["device_templates"] if x["role"] == "network") + self.assertEqual(nic["attributes"]["type"], "E1000") + self.assertFalse(plan["mutation_authorized"]) + + def test_proxmox_lowering_preserves_windows_requirements(self): + plan = proxmox_plan( + self.profile, + vmid=9301, + storage="local-lvm", + iso_volume="local:iso/windows11.iso", + bridge="vmbr0", + ) + create = plan["create_fields"] + self.assertEqual(create["memory"], 4096) + self.assertEqual(create["cores"] * create["sockets"], 2) + self.assertEqual(create["cpu"], "host") + self.assertEqual(create["bios"], "ovmf") + self.assertEqual(create["machine"], "q35") + self.assertEqual(create["ostype"], "win11") + self.assertEqual(create["sata0"], "local-lvm:64") + self.assertEqual(create["ide2"], "local:iso/windows11.iso,media=cdrom") + self.assertIn("pre-enrolled-keys=1", create["efidisk0"]) + self.assertIn("version=v2.0", create["tpmstate0"]) + self.assertEqual(create["net0"], "e1000,bridge=vmbr0") + self.assertEqual(create["boot"], "order=ide2;sata0") + self.assertFalse(plan["mutation_authorized"]) + + def test_external_binding_validation_fails_closed(self): + with self.assertRaises(WindowsW1PlanError): + truenas_plan(self.profile, pool="pool/dataset", iso_path="/mnt/pool/w.iso", bridge="br0") + with self.assertRaises(WindowsW1PlanError): + proxmox_plan( + self.profile, + vmid=9301, + storage="local-lvm", + iso_volume="not-a-volume-id", + bridge="vmbr0", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/foliorelay-observer/main.go b/tools/foliorelay-observer/main.go new file mode 100644 index 000000000..f60b92e6b --- /dev/null +++ b/tools/foliorelay-observer/main.go @@ -0,0 +1,372 @@ +package main + +import ( + "encoding/binary" + "encoding/json" + "errors" + "flag" + "fmt" + "net/http" + "os" + "strings" + "sync" + "syscall" + "time" +) + +const queryName = "_universal._sub._ipp._tcp.local" +const legacyQueryID uint16 = 0x4652 + +type resultState struct { + mu sync.RWMutex + v map[string]any +} + +func (s *resultState) set(v map[string]any) { + s.mu.Lock() + s.v = v + s.mu.Unlock() +} + +func (s *resultState) get() map[string]any { + s.mu.RLock() + defer s.mu.RUnlock() + out := make(map[string]any, len(s.v)) + for k, v := range s.v { + out[k] = v + } + return out +} + +func encodeName(name string) []byte { + out := make([]byte, 0, len(name)+2) + for _, label := range strings.Split(name, ".") { + out = append(out, byte(len(label))) + out = append(out, label...) + } + return append(out, 0) +} + +func nameAt(pkt []byte, off int, seen map[int]bool) (string, int, error) { + if seen == nil { + seen = map[int]bool{} + } + labels := []string{} + end := -1 + for { + if off >= len(pkt) { + return "", 0, errors.New("dns name overflow") + } + n := int(pkt[off]) + if n == 0 { + if end < 0 { + end = off + 1 + } + return strings.Join(labels, "."), end, nil + } + if n&0xC0 == 0xC0 { + if off+1 >= len(pkt) { + return "", 0, errors.New("dns pointer overflow") + } + ptr := ((n & 0x3f) << 8) | int(pkt[off+1]) + if seen[ptr] { + return "", 0, errors.New("dns pointer loop") + } + seen[ptr] = true + tail, _, err := nameAt(pkt, ptr, seen) + if err != nil { + return "", 0, err + } + if tail != "" { + labels = append(labels, tail) + } + if end < 0 { + end = off + 2 + } + return strings.Join(labels, "."), end, nil + } + off++ + if n > 63 || off+n > len(pkt) { + return "", 0, errors.New("dns label overflow") + } + labels = append(labels, string(pkt[off:off+n])) + off += n + } +} + +func normalizeDNSName(name string) string { + return strings.ToLower(strings.TrimSuffix(name, ".")) +} + +type srvRecord struct { + target string + port int +} + +type observation struct { + universalTargets []string + txtByOwner map[string][]string + srvByOwner map[string]srvRecord +} + +func newObservation() observation { + return observation{ + txtByOwner: map[string][]string{}, + srvByOwner: map[string]srvRecord{}, + } +} + +func (o *observation) merge(other observation) { + o.universalTargets = append(o.universalTargets, other.universalTargets...) + for owner, items := range other.txtByOwner { + o.txtByOwner[owner] = append(o.txtByOwner[owner], items...) + } + for owner, srv := range other.srvByOwner { + o.srvByOwner[owner] = srv + } +} + +func parsePacket(pkt []byte) (observation, error) { + o := newObservation() + if len(pkt) < 12 { + return o, errors.New("short dns packet") + } + qd := int(binary.BigEndian.Uint16(pkt[4:6])) + an := int(binary.BigEndian.Uint16(pkt[6:8])) + ns := int(binary.BigEndian.Uint16(pkt[8:10])) + ar := int(binary.BigEndian.Uint16(pkt[10:12])) + off := 12 + for i := 0; i < qd; i++ { + _, next, err := nameAt(pkt, off, nil) + if err != nil { + return o, err + } + off = next + if off+4 > len(pkt) { + return o, errors.New("short dns question") + } + off += 4 + } + for i := 0; i < an+ns+ar; i++ { + name, next, err := nameAt(pkt, off, nil) + if err != nil { + return o, err + } + off = next + if off+10 > len(pkt) { + return o, errors.New("short dns rr") + } + typ := binary.BigEndian.Uint16(pkt[off : off+2]) + rdlen := int(binary.BigEndian.Uint16(pkt[off+8 : off+10])) + off += 10 + rstart := off + if off+rdlen > len(pkt) { + return o, errors.New("short dns rdata") + } + rdata := pkt[off : off+rdlen] + off += rdlen + owner := normalizeDNSName(name) + + if owner == normalizeDNSName(queryName) && typ == 12 { + target, _, err := nameAt(pkt, rstart, nil) + if err != nil { + return o, err + } + o.universalTargets = append(o.universalTargets, target) + } + if typ == 16 { + items := []string{} + for j := 0; j < len(rdata); { + n := int(rdata[j]) + j++ + if j+n > len(rdata) { + return o, errors.New("short dns txt item") + } + items = append(items, string(rdata[j:j+n])) + j += n + } + o.txtByOwner[owner] = append(o.txtByOwner[owner], items...) + } + if typ == 33 && rdlen >= 7 { + target, _, err := nameAt(pkt, rstart+6, nil) + if err != nil { + return o, err + } + o.srvByOwner[owner] = srvRecord{ + target: target, + port: int(binary.BigEndian.Uint16(rdata[4:6])), + } + } + } + return o, nil +} + +type serviceMatch struct { + instance string + txt []string + srvTarget string + srvPort int +} + +func matchObservation(o observation, expectedTxtUUID, expectedHost string, expectedPort int) (serviceMatch, bool) { + for _, instance := range o.universalTargets { + owner := normalizeDNSName(instance) + txt, ok := o.txtByOwner[owner] + if !ok { + continue + } + srv, ok := o.srvByOwner[owner] + if !ok { + continue + } + uuidMatch := false + for _, item := range txt { + if strings.EqualFold(item, "UUID="+expectedTxtUUID) { + uuidMatch = true + break + } + } + joined := strings.ToLower(strings.Join(txt, "\n")) + if uuidMatch && + strings.Contains(joined, "rp=printers/foliorelay") && + strings.Contains(joined, "pdl=application/pdf,image/urf") && + strings.EqualFold(normalizeDNSName(srv.target), normalizeDNSName(expectedHost)) && + srv.port == expectedPort { + return serviceMatch{ + instance: instance, + txt: txt, + srvTarget: srv.target, + srvPort: srv.port, + }, true + } + } + return serviceMatch{}, false +} + +func qualifying(o observation, expectedTxtUUID, expectedHost string, expectedPort int) bool { + _, ok := matchObservation(o, expectedTxtUUID, expectedHost, expectedPort) + return ok +} + +func observationSocketPlan(legacyUnicast bool) (bindPort int, joinMulticast bool, queryID uint16) { + if legacyUnicast { + return 0, false, legacyQueryID + } + return 5353, true, 0 +} + +func buildQuery(queryID uint16) []byte { + q := make([]byte, 12) + binary.BigEndian.PutUint16(q[0:2], queryID) + binary.BigEndian.PutUint16(q[4:6], 1) + q = append(q, encodeName(queryName)...) + q = append(q, 0, 12, 0, 1) + return q +} + +func observe(expected, expectedTxtUUID, expectedHost string, expectedPort int, seconds float64, legacyUnicast bool) (map[string]any, error) { + fd, err := syscall.Socket(syscall.AF_INET, syscall.SOCK_DGRAM, syscall.IPPROTO_UDP) + if err != nil { + return nil, err + } + defer syscall.Close(fd) + _ = syscall.SetsockoptInt(fd, syscall.SOL_SOCKET, syscall.SO_REUSEADDR, 1) + _ = syscall.SetsockoptInt(fd, syscall.SOL_SOCKET, 15, 1) + bindPort, joinMulticast, queryID := observationSocketPlan(legacyUnicast) + if err := syscall.Bind(fd, &syscall.SockaddrInet4{Port: bindPort}); err != nil { + return nil, err + } + if joinMulticast { + mreq := &syscall.IPMreq{Multiaddr: [4]byte{224, 0, 0, 251}} + if err := syscall.SetsockoptIPMreq(fd, syscall.IPPROTO_IP, syscall.IP_ADD_MEMBERSHIP, mreq); err != nil { + return nil, err + } + } + if err := syscall.SetsockoptInt(fd, syscall.IPPROTO_IP, syscall.IP_MULTICAST_TTL, 255); err != nil { + return nil, err + } + tv := syscall.NsecToTimeval(int64(time.Second)) + if err := syscall.SetsockoptTimeval(fd, syscall.SOL_SOCKET, syscall.SO_RCVTIMEO, &tv); err != nil { + return nil, err + } + + q := buildQuery(queryID) + dst := &syscall.SockaddrInet4{Port: 5353, Addr: [4]byte{224, 0, 0, 251}} + if err := syscall.Sendto(fd, q, 0, dst); err != nil { + return nil, err + } + + aggregate := newObservation() + deadline := time.Now().Add(time.Duration(seconds * float64(time.Second))) + buf := make([]byte, 65535) + for time.Now().Before(deadline) { + n, _, err := syscall.Recvfrom(fd, buf, 0) + if err != nil { + if err == syscall.EAGAIN || err == syscall.EWOULDBLOCK || err == syscall.EINTR { + _ = syscall.Sendto(fd, q, 0, dst) + continue + } + return nil, err + } + if legacyUnicast && n >= 2 && binary.BigEndian.Uint16(buf[:2]) != queryID { + continue + } + packetObservation, err := parsePacket(buf[:n]) + if err != nil { + continue + } + aggregate.merge(packetObservation) + if matched, ok := matchObservation(aggregate, expectedTxtUUID, expectedHost, expectedPort); ok { + transport := "multicast-5353" + if legacyUnicast { + transport = "legacy-unicast" + } + return map[string]any{ + "query_transport": transport, + "universal_ptr": true, + "service_instance": matched.instance, + "uuid": expected, + "txt": matched.txt, + "srv_target": matched.srvTarget, + "srv_port": matched.srvPort, + }, nil + } + } + return nil, errors.New("no qualifying _universal FolioRelay mDNS response") +} + +func main() { + uuid := flag.String("uuid", "", "expected canonical printer UUID") + txtUUID := flag.String("txt-uuid", "", "expected bare DNS-SD TXT UUID") + host := flag.String("expected-host", "", "expected DNS-SD SRV host") + ippPort := flag.Int("expected-ipp-port", 0, "expected DNS-SD SRV port") + port := flag.Int("port", 18081, "HTTP witness port") + seconds := flag.Float64("seconds", 25, "mDNS observation window") + legacyUnicast := flag.Bool("legacy-unicast", false, "query mDNS from an ephemeral source port and receive the RFC 6762 legacy-unicast reply") + flag.Parse() + if *uuid == "" || *txtUUID == "" || *host == "" || *ippPort <= 0 { + fmt.Fprintln(os.Stderr, "uuid, txt-uuid, expected-host, and expected-ipp-port are required") + os.Exit(2) + } + + state := &resultState{v: map[string]any{"status": "pending"}} + go func() { + r, err := observe(*uuid, *txtUUID, *host, *ippPort, *seconds, *legacyUnicast) + if err != nil { + state.set(map[string]any{"status": "error", "error": fmt.Sprintf("%T: %v", err, err)}) + return + } + r["status"] = "success" + state.set(r) + }() + + http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + body, _ := json.Marshal(state.get()) + w.Header().Set("Content-Length", fmt.Sprintf("%d", len(body))) + _, _ = w.Write(body) + }) + if err := http.ListenAndServe(fmt.Sprintf("0.0.0.0:%d", *port), nil); err != nil { + panic(err) + } +} diff --git a/tools/foliorelay-observer/main_test.go b/tools/foliorelay-observer/main_test.go new file mode 100644 index 000000000..f92c8a33d --- /dev/null +++ b/tools/foliorelay-observer/main_test.go @@ -0,0 +1,187 @@ +package main + +import ( + "encoding/binary" + "testing" +) + +const testInstance = "FolioRelay._ipp._tcp.local" + +func validObservation() observation { + o := newObservation() + o.universalTargets = []string{testInstance} + owner := normalizeDNSName(testInstance) + o.txtByOwner[owner] = []string{ + "UUID=01234567-89ab-4def-8123-456789abcdef", + "rp=printers/FolioRelay", + "pdl=application/pdf,image/urf", + } + o.srvByOwner[owner] = srvRecord{target: "foliorelay-t6.local.", port: 8634} + return o +} + +func TestObservationSocketPlan(t *testing.T) { + port, join, id := observationSocketPlan(false) + if port != 5353 || !join || id != 0 { + t.Fatalf("multicast plan = port %d join %v id %d", port, join, id) + } + port, join, id = observationSocketPlan(true) + if port != 0 || join || id != legacyQueryID { + t.Fatalf("legacy plan = port %d join %v id %d", port, join, id) + } +} + +func TestBuildLegacyQueryCarriesCorrelationID(t *testing.T) { + q := buildQuery(legacyQueryID) + if len(q) < 12 { + t.Fatal("short query") + } + if got := binary.BigEndian.Uint16(q[0:2]); got != legacyQueryID { + t.Fatalf("query id=%#x want=%#x", got, legacyQueryID) + } + if got := binary.BigEndian.Uint16(q[4:6]); got != 1 { + t.Fatalf("question count=%d want=1", got) + } +} + +func TestEncodeAndNameAt(t *testing.T) { + wire := encodeName("_universal._sub._ipp._tcp.local") + got, end, err := nameAt(wire, 0, nil) + if err != nil { t.Fatal(err) } + if got != queryName { t.Fatalf("got %q", got) } + if end != len(wire) { t.Fatalf("end=%d want=%d", end, len(wire)) } +} + +func TestCompressedNameAt(t *testing.T) { + base := encodeName("_ipp._tcp.local") + pkt := append([]byte{}, base...) + off := len(pkt) + pkt = append(pkt, 10) + pkt = append(pkt, []byte("_universal")...) + pkt = append(pkt, 0xC0, 0x00) + got, end, err := nameAt(pkt, off, nil) + if err != nil { t.Fatal(err) } + if got != "_universal._ipp._tcp.local" { t.Fatalf("got %q", got) } + if end != len(pkt) { t.Fatalf("end=%d want=%d", end, len(pkt)) } +} + +func TestQualifying(t *testing.T) { + o := validObservation() + if !qualifying(o, "01234567-89ab-4def-8123-456789abcdef", "foliorelay-t6.local", 8634) { + t.Fatal("expected qualifying observation") + } +} + +func appendRR(pkt *[]byte, owner string, typ uint16, rdata []byte) { + *pkt = append(*pkt, encodeName(owner)...) + hdr := make([]byte, 10) + binary.BigEndian.PutUint16(hdr[0:2], typ) + binary.BigEndian.PutUint16(hdr[2:4], 1) + binary.BigEndian.PutUint32(hdr[4:8], 120) + binary.BigEndian.PutUint16(hdr[8:10], uint16(len(rdata))) + *pkt = append(*pkt, hdr...) + *pkt = append(*pkt, rdata...) +} + +func TestParsePacket(t *testing.T) { + pkt := make([]byte, 12) + binary.BigEndian.PutUint16(pkt[6:8], 3) + appendRR(&pkt, queryName, 12, encodeName(testInstance)) + txt := []byte{} + for _, s := range []string{ + "UUID=01234567-89ab-4def-8123-456789abcdef", + "rp=printers/FolioRelay", + "pdl=application/pdf,image/urf", + } { + txt = append(txt, byte(len(s))) + txt = append(txt, []byte(s)...) + } + appendRR(&pkt, testInstance, 16, txt) + srv := make([]byte, 6) + binary.BigEndian.PutUint16(srv[4:6], 8634) + srv = append(srv, encodeName("foliorelay-t6.local")...) + appendRR(&pkt, testInstance, 33, srv) + + o, err := parsePacket(pkt) + if err != nil { t.Fatal(err) } + if !qualifying(o, "01234567-89ab-4def-8123-456789abcdef", "foliorelay-t6.local", 8634) { + t.Fatalf("not qualifying: %+v", o) + } +} + +func TestQualifyingRejectsCrossedIdentity(t *testing.T) { + base := validObservation() + tests := []struct { + name string + obs observation + expected string + host string + port int + }{ + {"missing-universal", func() observation { + x := validObservation(); x.universalTargets = nil; return x + }(), "01234567-89ab-4def-8123-456789abcdef", "foliorelay-t6.local", 8634}, + {"wrong-uuid", base, "ffffffff-ffff-4fff-8fff-ffffffffffff", "foliorelay-t6.local", 8634}, + {"wrong-host", base, "01234567-89ab-4def-8123-456789abcdef", "other.local", 8634}, + {"wrong-port", base, "01234567-89ab-4def-8123-456789abcdef", "foliorelay-t6.local", 9999}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if qualifying(tc.obs, tc.expected, tc.host, tc.port) { + t.Fatal("unexpected qualifying observation") + } + }) + } +} + +func TestRejectsCrossInstanceSplicing(t *testing.T) { + o := newObservation() + good := normalizeDNSName("Good._ipp._tcp.local") + other := normalizeDNSName("Other._ipp._tcp.local") + o.universalTargets = []string{"Good._ipp._tcp.local"} + o.txtByOwner[good] = []string{ + "UUID=01234567-89ab-4def-8123-456789abcdef", + "rp=printers/FolioRelay", + "pdl=application/pdf,image/urf", + } + o.srvByOwner[other] = srvRecord{target: "foliorelay-t6.local.", port: 8634} + if qualifying(o, "01234567-89ab-4def-8123-456789abcdef", "foliorelay-t6.local", 8634) { + t.Fatal("must not splice TXT and SRV from different service instances") + } +} + +func TestMergeAcrossPacketsPreservesSameInstanceCorrelation(t *testing.T) { + owner := normalizeDNSName(testInstance) + a := newObservation(); a.universalTargets = []string{testInstance} + b := newObservation(); b.txtByOwner[owner] = []string{ + "UUID=01234567-89ab-4def-8123-456789abcdef", + "rp=printers/FolioRelay", + "pdl=application/pdf,image/urf", + } + c := newObservation(); c.srvByOwner[owner] = srvRecord{target: "foliorelay-t6.local.", port: 8634} + a.merge(b); a.merge(c) + if !qualifying(a, "01234567-89ab-4def-8123-456789abcdef", "foliorelay-t6.local", 8634) { + t.Fatal("same service instance split across packets should qualify") + } +} + +func TestParsePacketRejectsMalformedInput(t *testing.T) { + if _, err := parsePacket([]byte{0, 1, 2}); err == nil { + t.Fatal("expected short packet error") + } + loop := make([]byte, 18) + binary.BigEndian.PutUint16(loop[4:6], 1) + loop[12], loop[13] = 0xC0, 0x0C + if _, err := parsePacket(loop); err == nil { + t.Fatal("expected DNS compression pointer loop error") + } +} + +func TestQualifyingRejectsCanonicalURNInTXT(t *testing.T) { + o := validObservation() + owner := normalizeDNSName(testInstance) + o.txtByOwner[owner][0] = "UUID=urn:uuid:01234567-89ab-4def-8123-456789abcdef" + if qualifying(o, "01234567-89ab-4def-8123-456789abcdef", "foliorelay-t6.local", 8634) { + t.Fatal("canonical URN must not satisfy bare TXT UUID projection") + } +}