diff --git a/.github/ACTIONS-SECURITY.md b/.github/ACTIONS-SECURITY.md index 011f427f..e2990a76 100644 --- a/.github/ACTIONS-SECURITY.md +++ b/.github/ACTIONS-SECURITY.md @@ -22,7 +22,7 @@ All binary tool downloads in workflow steps must include SHA256 checksum verific * Verify with `sha256sum --check --strict` * Extract only after verification passes -Currently verified binaries: Gitleaks, Grype, TFLint. +Currently verified binaries: Gitleaks, Grype, Syft, TFLint. ## Permission Scoping diff --git a/.github/instructions/README.md b/.github/instructions/README.md index 3fff4327..cb776dd5 100644 --- a/.github/instructions/README.md +++ b/.github/instructions/README.md @@ -79,6 +79,14 @@ Development standards and practices for C# code implementation. - **Scope**: Code structure, naming conventions, best practices - **Apply When**: Writing C# code in `**/*.cs` files +#### [Rust Crate Registration Instructions](rust-crate-registration.instructions.md) + +Required registration of Rust crates under `src/500-application` for CI test/coverage and Codecov reporting. + +- **Context**: Rust workspace coverage, CI matrix, Codecov flag mapping +- **Scope**: `rust-tests.yml` matrix and triggers, `codecov.yml` flags and ignore lists, opt-out path +- **Apply When**: Adding, restructuring, or removing crates under `**/src/500-application/**/Cargo.toml`, or editing `**/.github/workflows/rust-tests.yml` or `**/codecov.yml` + ### Scripting and Automation #### [Bash Instructions](bash.instructions.md) diff --git a/.github/instructions/rust-crate-registration.instructions.md b/.github/instructions/rust-crate-registration.instructions.md new file mode 100644 index 00000000..b0d13bc8 --- /dev/null +++ b/.github/instructions/rust-crate-registration.instructions.md @@ -0,0 +1,130 @@ +--- +description: 'Required registration of Rust crates under src/500-application for CI test/coverage and Codecov reporting - Brought to you by microsoft/edge-ai' +applyTo: '**/src/500-application/**/Cargo.toml,**/.github/workflows/rust-tests.yml,**/.github/workflows/pr-validation.yml,**/scripts/build/Detect-Folder-Changes.ps1,**/codecov.yml' +--- + +# Rust Crate Registration Instructions + +These rules govern how Rust application crates under `src/500-application/**` are registered with CI and Codecov. They complement the broader Rust guidance in [.github/instructions/rust.instructions.md](.github/instructions/rust.instructions.md) ("Workspace Architecture" section) and are enforced by an automated CI gate (see "CI Gate" below). + +Every Rust crate under `src/500-application/**` MUST be either: + +1. **Registered for coverage** in all three locations described in [Required Registration](#required-registration), OR +2. **Explicitly opted out** via the [Coverage Opt-Out](#coverage-opt-out) path in `codecov.yml`. + +There is no third option. PRs that add or restructure a Rust crate without satisfying one of the above will fail the `validate-rust-registration` CI gate. + + + +## Required Registration + +When a Rust crate participates in coverage, it MUST be registered in **all three** of the following locations. Missing any one of them is a CI failure. + +### 1. `.github/workflows/rust-tests.yml` matrix + +Add the crate as an `include:` entry under `jobs.coverage.strategy.matrix`. Each entry is an object with a `crate` path and optional `system_deps` for extra apt packages: + +```yaml +jobs: + coverage: + strategy: + matrix: + include: + - crate: src/500-application/503-media-capture-service/services/media-capture-service + system_deps: ffmpeg # optional: extra apt packages installed before build + - crate: src/500-application/507-ai-inference/services/ai-edge-inference + - crate: src/500-application/507-ai-inference/services/ai-edge-inference-crate + - crate: src/500-application/NNN-your-new-crate/services/your-service # <-- add here +``` + +The `crate` value MUST be the directory containing the crate's `Cargo.toml`. When adding an entry, also bump the `vuln-scan` job's `matrix.index` array so its length matches the number of `include:` entries (zero-based indices). + +### 2. `scripts/build/Detect-Folder-Changes.ps1` change-detection regex + +`rust-tests.yml` is a reusable workflow (`on: workflow_call`) and has no path triggers of its own. It is invoked by the `rust-tests` job in `pr-validation.yml`, which is gated by the `changesInRust` output of the shared `matrix-changes` job (the reusable `matrix-folder-check.yml` workflow). That output is computed by `scripts/build/Detect-Folder-Changes.ps1`, which matches the diffed PR file list against this regex: + +```text +^src/500-application/ # any path under this prefix +^Cargo\.toml$ +^Cargo\.lock$ +^\.github/workflows/rust-tests\.yml$ +^\.github/workflows/pr-validation\.yml$ +^codecov\.yml$ +``` + +Any crate located under `src/500-application/` is already covered by the `^src/500-application/` prefix and requires **no change** to this filter. Only extend the filter when a crate lives outside that prefix; in that case add a matching condition to the `$rustChangeFiles` block in `scripts/build/Detect-Folder-Changes.ps1` (for example `$_ -match '^src/600-other-area/'`). + +### 3. `codecov.yml` rust flag paths + +Add a glob covering the crate to `flags.rust.paths` so Codecov associates uploaded coverage with the `rust` flag: + +```yaml +flags: + rust: + paths: + - "src/500-application/503-media-capture-service/**" + - "src/500-application/507-ai-inference/**" + - "src/500-application/NNN-your-new-crate/**" # <-- add here + carryforward: true +``` + +## Coverage Opt-Out + +Crates that are intentionally excluded from coverage (for example, experimental scaffolding, WASM operators with no host-side test surface, or crates pending refactor) MUST be listed in `codecov.yml` under `ignore`: + +```yaml +ignore: + - "src/500-application/512-avro-to-json/**" + - "src/500-application/NNN-your-new-crate/**" # <-- opt out here + - "target/**" +``` + +When a crate is listed under `ignore`, it MUST NOT appear in the `rust-tests.yml` matrix or in `flags.rust.paths`. The CI gate treats ignored crates as fully satisfying the registration requirement. + +## CI Gate + +The workflow `.github/workflows/validate-rust-registration.yml` runs the script `scripts/Validate-RustCrateRegistration.ps1` on every PR that touches `src/500-application/**`, `.github/workflows/rust-tests.yml`, `codecov.yml`, or the validator itself. The gate fails the build with an itemized report when any crate under `src/500-application/**` is neither fully registered (all three locations) nor explicitly opted out. + +## Local Validation + +Run before opening a PR: + +```pwsh +pwsh ./scripts/Validate-RustCrateRegistration.ps1 +``` + +Tests live in `scripts/Validate-RustCrateRegistration.Tests.ps1` and are gated by `.github/workflows/validate-rust-registration.yml` on PR. + +## Example: Adding a New Crate + +For a hypothetical new crate at `src/500-application/520-example-service` (under the existing `src/500-application/` prefix), only the matrix and the Codecov flag paths need updating; the `pr-validation.yml` regex already matches: + +```diff + # .github/workflows/rust-tests.yml + matrix: + include: + - crate: src/500-application/503-media-capture-service/services/media-capture-service + system_deps: ffmpeg + - crate: src/500-application/507-ai-inference/services/ai-edge-inference + - crate: src/500-application/507-ai-inference/services/ai-edge-inference-crate ++ - crate: src/500-application/520-example-service/services/example +``` + +Also bump the `vuln-scan` job's `matrix.index` array length to match the new `include:` entry count. + +```diff + # codecov.yml + flags: + rust: + paths: + - "src/500-application/503-media-capture-service/**" + - "src/500-application/507-ai-inference/**" ++ - "src/500-application/520-example-service/**" + carryforward: true +``` + +If a future crate lives outside `src/500-application/`, also extend the rust-change filter in `scripts/build/Detect-Folder-Changes.ps1` so its path triggers the `rust-tests` job via the `matrix-changes` `changesInRust` output. + +To opt out instead, omit both diffs above and add a single `ignore` entry to `codecov.yml`. + + diff --git a/.github/workflows/application-matrix-builds.yml b/.github/workflows/application-matrix-builds.yml index 734eda64..cc19820b 100644 --- a/.github/workflows/application-matrix-builds.yml +++ b/.github/workflows/application-matrix-builds.yml @@ -359,14 +359,15 @@ jobs: # Install Grype vulnerability scanner pinned to a release tag with checksum verification GRYPE_VERSION="v0.86.1" GRYPE_VER="${GRYPE_VERSION#v}" + GRYPE_TARBALL="grype_${GRYPE_VER}_linux_amd64.tar.gz" GRYPE_TMP="$(mktemp -d)" echo "Installing Grype ${GRYPE_VERSION}..." - curl -sSfL -o "${GRYPE_TMP}/grype.tar.gz" \ - "https://github.com/anchore/grype/releases/download/${GRYPE_VERSION}/grype_${GRYPE_VER}_linux_amd64.tar.gz" + curl -sSfL -o "${GRYPE_TMP}/${GRYPE_TARBALL}" \ + "https://github.com/anchore/grype/releases/download/${GRYPE_VERSION}/${GRYPE_TARBALL}" curl -sSfL -o "${GRYPE_TMP}/grype_checksums.txt" \ "https://github.com/anchore/grype/releases/download/${GRYPE_VERSION}/grype_${GRYPE_VER}_checksums.txt" - (cd "${GRYPE_TMP}" && grep " grype_${GRYPE_VER}_linux_amd64.tar.gz$" grype_checksums.txt | sha256sum -c -) - sudo tar -xzf "${GRYPE_TMP}/grype.tar.gz" -C /usr/local/bin grype + (cd "${GRYPE_TMP}" && grep " ${GRYPE_TARBALL}$" grype_checksums.txt | sha256sum -c -) + sudo tar -xzf "${GRYPE_TMP}/${GRYPE_TARBALL}" -C /usr/local/bin grype rm -rf "${GRYPE_TMP}" echo "Verifying Grype installation..." diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index d48c4b02..5fc560a0 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -154,6 +154,15 @@ jobs: break-build: true secrets: inherit + # Rust unit/integration tests with coverage for main branch + rust-tests-main: + name: Rust Tests + permissions: + contents: read + id-token: write + uses: ./.github/workflows/rust-tests.yml + secrets: inherit + # Dependency advisory audit (cargo-audit + govulncheck) for main branch dep-audit-main: name: Dependency Audit diff --git a/.github/workflows/matrix-folder-check.yml b/.github/workflows/matrix-folder-check.yml index 0a414095..b258c6dc 100644 --- a/.github/workflows/matrix-folder-check.yml +++ b/.github/workflows/matrix-folder-check.yml @@ -28,6 +28,7 @@ # - changedBicepFolders: JSON object with all identified Bicep folder names for matrix strategy # - changesInApplications: true/false indicating if any Application folders have changed (when includeApplications=true) # - changedApplicationFolders: JSON object with Application folder details for matrix strategy (when includeApplications=true) +# - changesInRust: true/false indicating if any Rust-related files have changed (gates the rust-tests workflow) # # Usage Examples: # ```yaml @@ -123,6 +124,9 @@ on: # yamllint disable-line rule:truthy changedApplicationFolders: description: 'JSON matrix of Application folders that have changed' value: ${{ jobs.map-outputs.outputs.changedApplicationFolders }} + changesInRust: + description: 'Whether any Rust-relevant files have changed (gates rust-tests)' + value: ${{ jobs.map-outputs.outputs.changesInRust }} permissions: contents: read # Read repository contents and git history for change detection @@ -140,6 +144,7 @@ jobs: changedBicepFolders: ${{ steps.detect.outputs.changedBicepFolders }} changesInApplications: ${{ steps.detect.outputs.changesInApplications }} changedApplicationFolders: ${{ steps.detect.outputs.changedApplicationFolders }} + changesInRust: ${{ steps.detect.outputs.changesInRust }} steps: - name: Checkout repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -178,6 +183,7 @@ jobs: "changedBicepFolders=$($jsonData.bicep.folders | ConvertTo-Json -Compress)" >> $env:GITHUB_OUTPUT "changesInApplications=$($jsonData.applications.has_changes)" >> $env:GITHUB_OUTPUT "changedApplicationFolders=$($jsonData.applications.folders | ConvertTo-Json -Compress)" >> $env:GITHUB_OUTPUT + "changesInRust=$($jsonData.rust.has_changes)" >> $env:GITHUB_OUTPUT # Display results for debugging Write-Host "Detection results:" @@ -189,6 +195,7 @@ jobs: Write-Host "Bicep folders: $($jsonData.bicep.folders | ConvertTo-Json)" Write-Host "Application changes: $($jsonData.applications.has_changes)" Write-Host "Application folders: $($jsonData.applications.folders | ConvertTo-Json)" + Write-Host "Rust changes: $($jsonData.rust.has_changes)" # Map outputs from the detection job to maintain backward compatibility map-outputs: @@ -203,6 +210,7 @@ jobs: changedBicepFolders: ${{ needs.detect-changes.outputs.changedBicepFolders }} changesInApplications: ${{ needs.detect-changes.outputs.changesInApplications }} changedApplicationFolders: ${{ needs.detect-changes.outputs.changedApplicationFolders }} + changesInRust: ${{ needs.detect-changes.outputs.changesInRust }} steps: - name: Map outputs for backward compatibility run: echo "Mapping outputs from detection job for backward compatibility" diff --git a/.github/workflows/pr-validation.yml b/.github/workflows/pr-validation.yml index a53ef1d9..bb4bbd7c 100644 --- a/.github/workflows/pr-validation.yml +++ b/.github/workflows/pr-validation.yml @@ -228,6 +228,20 @@ jobs: break-build: true secrets: inherit + # Rust unit/integration tests with coverage for PRs + # Gated on Rust-relevant changes via the shared matrix-changes detection job + # (changesInRust output) to avoid running the expensive coverage matrix on + # PRs that touch no Rust crates, manifests, or rust-tests workflow files. + rust-tests: + name: Rust Tests + needs: [matrix-changes] + if: github.event_name != 'pull_request' || needs.matrix-changes.outputs.changesInRust == 'true' + permissions: + contents: read + id-token: write + uses: ./.github/workflows/rust-tests.yml + secrets: inherit + # Dependency advisory audit (cargo-audit + govulncheck) for PRs dep-audit: name: Dependency Audit @@ -339,7 +353,7 @@ jobs: - name: Upload Test Results if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: pester-test-results path: test-results/ diff --git a/.github/workflows/rust-tests.yml b/.github/workflows/rust-tests.yml new file mode 100644 index 00000000..50cacc2d --- /dev/null +++ b/.github/workflows/rust-tests.yml @@ -0,0 +1,235 @@ +--- +name: rust-tests + +on: + workflow_call: + +permissions: + contents: read + id-token: write + +jobs: + coverage: + # Pinned Ubuntu image to keep apt package set stable and reproducible across runs. + # Tracked via SBOM + Grype scan below; revisit when bumping to next LTS. + runs-on: ubuntu-24.04 + permissions: + contents: read + id-token: write + strategy: + fail-fast: false + matrix: + include: + - name: 501-telemetry-receiver + crate: src/500-application/501-rust-telemetry/services/receiver + - name: 501-telemetry-sender + crate: src/500-application/501-rust-telemetry/services/sender + - name: 502-http-connector-broker + crate: src/500-application/502-rust-http-connector/services/broker + - name: 502-http-connector-subscriber + crate: src/500-application/502-rust-http-connector/services/subscriber + - name: 503-media-capture-service + crate: src/500-application/503-media-capture-service/services/media-capture-service + system_deps: ffmpeg + - name: 504-mqtt-otel-trace-exporter + crate: src/500-application/504-mqtt-otel-trace-exporter/services/mqtt-otel-trace-exporter + - name: 507-ai-edge-inference + crate: src/500-application/507-ai-inference/services/ai-edge-inference + - name: 507-ai-edge-inference-crate + crate: src/500-application/507-ai-inference/services/ai-edge-inference-crate + - name: 901-video-to-gif + crate: src/900-tools-utilities/901-video-tools/cli/video-to-gif + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Install base system build dependencies + shell: bash + run: | + set -euo pipefail + for attempt in 1 2 3 4 5; do + if sudo apt-get update && \ + sudo apt-get install -y --no-install-recommends \ + protobuf-compiler \ + pkg-config \ + clang; then + exit 0 + fi + echo "apt attempt ${attempt} failed; sleeping $((attempt * 10))s" >&2 + sleep $((attempt * 10)) + done + echo "apt failed after 5 attempts" >&2 + exit 1 + + - name: Install ffmpeg system libraries + if: matrix.system_deps == 'ffmpeg' + shell: bash + run: | + set -euo pipefail + for attempt in 1 2 3 4 5; do + if sudo apt-get install -y --no-install-recommends \ + libavcodec-dev \ + libavformat-dev \ + libavfilter-dev \ + libavdevice-dev \ + libavutil-dev \ + libswscale-dev \ + libswresample-dev \ + libopencv-dev \ + libclang-dev; then + exit 0 + fi + echo "ffmpeg apt attempt ${attempt} failed; sleeping $((attempt * 10))s" >&2 + sleep $((attempt * 10)) + done + echo "ffmpeg apt install failed after 5 attempts" >&2 + exit 1 + + # SBOM + vulnerability scan for apt-installed system packages (and repo sources). + # Tracking-only mitigation; replace with a pinned, scanned base container (Option B) + # to fully close the supply-chain gap. + - name: Cache Syft binary + id: cache-syft + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + with: + path: /usr/local/bin/syft + key: syft-bin-v1.17.0-linux-amd64 + + - name: Install Syft (SBOM generator) + if: steps.cache-syft.outputs.cache-hit != 'true' + shell: bash + run: | + SYFT_VERSION="v1.17.0" + SYFT_VER="${SYFT_VERSION#v}" + SYFT_TMP="$(mktemp -d)" + echo "Installing Syft ${SYFT_VERSION}..." + SYFT_TARBALL="syft_${SYFT_VER}_linux_amd64.tar.gz" + # Retry downloads to tolerate transient GitHub Releases 5xx (e.g., 502). + fetch_with_retry() { + local url="$1" out="$2" attempt + for attempt in 1 2 3 4 5; do + if curl -sSfL --retry 3 --retry-delay 2 --retry-all-errors -o "${out}" "${url}"; then + return 0 + fi + echo "Attempt ${attempt} failed for ${url}; sleeping $((attempt * 5))s" >&2 + sleep $((attempt * 5)) + done + echo "All retries failed for ${url}" >&2 + return 22 + } + fetch_with_retry \ + "https://github.com/anchore/syft/releases/download/${SYFT_VERSION}/${SYFT_TARBALL}" \ + "${SYFT_TMP}/${SYFT_TARBALL}" + fetch_with_retry \ + "https://github.com/anchore/syft/releases/download/${SYFT_VERSION}/syft_${SYFT_VER}_checksums.txt" \ + "${SYFT_TMP}/syft_checksums.txt" + (cd "${SYFT_TMP}" && grep " ${SYFT_TARBALL}$" syft_checksums.txt | sha256sum -c -) + sudo tar -xzf "${SYFT_TMP}/${SYFT_TARBALL}" -C /usr/local/bin syft + rm -rf "${SYFT_TMP}" + syft version + + - name: Generate repo SBOM + shell: bash + run: | + # Scan only this repository's checked-out sources. Implicit include-list + # semantics: no exclude maintenance as runner images evolve. Runner OS / + # toolchain CVEs are GitHub's responsibility, not gated by this workflow. + syft scan dir:. -o cyclonedx-json=sbom.cdx.json + echo "SBOM components: $(jq '.components | length' sbom.cdx.json)" + + - name: Upload SBOM artifact + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: rust-tests-runner-sbom-${{ matrix.name }} + path: sbom.cdx.json + if-no-files-found: error + retention-days: 30 + + - name: Install Rust toolchain + shell: bash + run: | + rustup toolchain install stable --profile minimal --component llvm-tools-preview + rustup default stable + + - name: Cache cargo registry and build + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + ${{ matrix.crate }}/target + key: ${{ runner.os }}-cargo-${{ matrix.crate }}-${{ hashFiles(format('{0}/Cargo.lock', matrix.crate), format('{0}/Cargo.toml', matrix.crate)) }} + restore-keys: | + ${{ runner.os }}-cargo-${{ matrix.crate }}- + + - name: Install cargo-llvm-cov + shell: bash + run: cargo install cargo-llvm-cov --locked + + - name: Generate Cobertura coverage + working-directory: ${{ matrix.crate }} + run: cargo llvm-cov --cobertura --output-path coverage.xml + + - name: Upload to Codecov + if: success() + uses: codecov/codecov-action@3f20e214133d0983f9a10f3d63b0faf9241a3daa # v6.0.0 + with: + files: ${{ matrix.crate }}/coverage.xml + use_oidc: true + fail_ci_if_error: false + verbose: true + flags: rust + name: rust-coverage-${{ matrix.crate }} + + vuln-scan: + needs: [coverage] + runs-on: ubuntu-24.04 + permissions: + contents: read + strategy: + fail-fast: false + matrix: + include: + - name: 501-telemetry-receiver + - name: 501-telemetry-sender + - name: 502-http-connector-broker + - name: 502-http-connector-subscriber + - name: 503-media-capture-service + - name: 504-mqtt-otel-trace-exporter + - name: 507-ai-edge-inference + - name: 507-ai-edge-inference-crate + - name: 901-video-to-gif + steps: + - name: Checkout (sparse, for .grype.yaml) + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + sparse-checkout: | + .grype.yaml + sparse-checkout-cone-mode: false + + - name: Download SBOM artifact + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: rust-tests-runner-sbom-${{ matrix.name }} + + - name: Install Grype + shell: bash + run: | + GRYPE_VERSION="v0.86.1" + GRYPE_VER="${GRYPE_VERSION#v}" + GRYPE_TARBALL="grype_${GRYPE_VER}_linux_amd64.tar.gz" + GRYPE_TMP="$(mktemp -d)" + echo "Installing Grype ${GRYPE_VERSION}..." + curl -sSfL -o "${GRYPE_TMP}/${GRYPE_TARBALL}" \ + "https://github.com/anchore/grype/releases/download/${GRYPE_VERSION}/${GRYPE_TARBALL}" + curl -sSfL -o "${GRYPE_TMP}/grype_checksums.txt" \ + "https://github.com/anchore/grype/releases/download/${GRYPE_VERSION}/grype_${GRYPE_VER}_checksums.txt" + (cd "${GRYPE_TMP}" && grep " ${GRYPE_TARBALL}$" grype_checksums.txt | sha256sum -c -) + sudo tar -xzf "${GRYPE_TMP}/${GRYPE_TARBALL}" -C /usr/local/bin grype + rm -rf "${GRYPE_TMP}" + grype version + + - name: Scan SBOM with Grype + shell: bash + run: grype sbom:./sbom.cdx.json --config .grype.yaml --fail-on critical diff --git a/.github/workflows/validate-rust-registration.yml b/.github/workflows/validate-rust-registration.yml new file mode 100644 index 00000000..dc9fc2ce --- /dev/null +++ b/.github/workflows/validate-rust-registration.yml @@ -0,0 +1,61 @@ +--- +name: validate-rust-registration + +on: + pull_request: + paths: + - 'src/500-application/**' + - '.github/workflows/rust-tests.yml' + - '.github/workflows/validate-rust-registration.yml' + - 'codecov.yml' + - 'scripts/Validate-RustCrateRegistration.ps1' + - 'scripts/tests/Validate-RustCrateRegistration.Tests.ps1' + - '.github/instructions/rust-crate-registration.instructions.md' + push: + branches: [main, dev] + paths: + - 'src/500-application/**' + - '.github/workflows/rust-tests.yml' + - '.github/workflows/validate-rust-registration.yml' + - 'codecov.yml' + - 'scripts/Validate-RustCrateRegistration.ps1' + - 'scripts/tests/Validate-RustCrateRegistration.Tests.ps1' + - '.github/instructions/rust-crate-registration.instructions.md' + +permissions: + contents: read + +jobs: + validate: + name: Validate Rust crate registration + runs-on: ubuntu-latest + defaults: + run: + shell: pwsh + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Install powershell-yaml + run: | + Install-Module -Name powershell-yaml -Scope CurrentUser -Force -AllowClobber + + - name: Run Pester tests for validator + run: | + ./scripts/Invoke-Pester.ps1 -CI -Path './scripts/tests/Validate-RustCrateRegistration.Tests.ps1' + + - name: Validate Rust crate registration + run: | + ./scripts/Validate-RustCrateRegistration.ps1 + + - name: Upload validation artifacts + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: rust-crate-registration-results + path: | + logs/rust-crate-registration-report.json + if-no-files-found: ignore + retention-days: 14 diff --git a/.grype.yaml b/.grype.yaml index acaa52b7..4d772a43 100644 --- a/.grype.yaml +++ b/.grype.yaml @@ -22,3 +22,10 @@ ignore: # for PR #411 (Issue #362 PowerShell security-gate naming fix). # Reference: GHSA-cq8v-f236-94qc - vulnerability: GHSA-cq8v-f236-94qc + + # GHSA-rp8m-h266-53jh - grype v0.86.1 pep440 parser bug + # Justification: syft scan dir:/ captures Debian dpkg version 1.9.0ubuntu1.2 + # which grype 0.86.1 mis-routes into the pypi ecosystem, triggering a pep440 + # inflate error (exit 16). Upstream grype bug; remove when upgrading past 0.86.1 + # with the fix included. + - vulnerability: GHSA-rp8m-h266-53jh diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 00000000..9e5fd262 --- /dev/null +++ b/codecov.yml @@ -0,0 +1,30 @@ +--- +codecov: + notify: + wait_for_ci: true +coverage: + status: + project: + default: { target: auto, threshold: 1% } + rust: { target: auto, threshold: 1%, flags: [rust] } + patch: + default: { target: 80%, informational: true } + rust: { target: 80%, informational: true, flags: [rust] } +ignore: + - "src/500-application/511-rust-embedded-wasm-provider/operators/map/**" + - "src/500-application/511-rust-embedded-wasm-provider/operators/custom-provider/**" + - "src/500-application/512-avro-to-json/**" + - "src/500-application/514-wasm-msg-to-dss/**" + - "target/**" +comment: + layout: "reach,diff,flags,files" + behavior: default +flags: + rust: + paths: + - "src/500-application/501-rust-telemetry/**" + - "src/500-application/502-rust-http-connector/**" + - "src/500-application/503-media-capture-service/**" + - "src/500-application/504-mqtt-otel-trace-exporter/**" + - "src/500-application/507-ai-inference/**" + carryforward: true diff --git a/scripts/Validate-RustCrateRegistration.ps1 b/scripts/Validate-RustCrateRegistration.ps1 new file mode 100644 index 00000000..9c6c6f4a --- /dev/null +++ b/scripts/Validate-RustCrateRegistration.ps1 @@ -0,0 +1,308 @@ +# Copyright (c) Microsoft Corporation. +# SPDX-License-Identifier: MIT +<# +.SYNOPSIS + Validates that every Rust crate under src/500-application is either fully registered + for CI/Codecov coverage or explicitly opted out. + +.DESCRIPTION + Enforces .github/instructions/rust-crate-registration.instructions.md by checking + each discovered crate against: + 1. .github/workflows/rust-tests.yml jobs.coverage.strategy.matrix.crate + 2. .github/workflows/rust-tests.yml on.pull_request.paths AND on.push.paths + 3. codecov.yml flags.rust.paths + OR coverage by a codecov.yml ignore glob. + + Writes a JSON report and exits non-zero on gaps. + +.PARAMETER RepoRoot + Repository root. Defaults to the parent of this script's directory. + +.PARAMETER OutputPath + Directory to write the JSON report. Defaults to "$RepoRoot/logs". + +.EXAMPLE + ./scripts/Validate-RustCrateRegistration.ps1 +#> + +#Requires -Version 7.0 +#Requires -Modules powershell-yaml + +[CmdletBinding()] +param( + [string]$RepoRoot, + [string]$OutputPath +) + +function Install-YamlModuleIfNeeded { + [CmdletBinding()] + param() + if (-not (Get-Module -ListAvailable -Name 'powershell-yaml')) { + Install-Module -Name 'powershell-yaml' -Force -Scope CurrentUser -AllowClobber -ErrorAction Stop | Out-Null + } + Import-Module 'powershell-yaml' -ErrorAction Stop +} + +function Get-CrateDirectory { + [CmdletBinding()] + [OutputType([string[]])] + param( + [Parameter(Mandatory)] [string]$ApplicationRoot, + [Parameter(Mandatory)] [string]$RepoRootPath + ) + if (-not (Test-Path -LiteralPath $ApplicationRoot)) { + return [string[]]@() + } + $cargoFiles = Get-ChildItem -LiteralPath $ApplicationRoot -Recurse -File -Filter 'Cargo.toml' -ErrorAction SilentlyContinue | + Where-Object { $_.FullName -notmatch '[\\/]target[\\/]' } + $crates = [System.Collections.Generic.List[string]]::new() + foreach ($file in $cargoFiles) { + $content = Get-Content -LiteralPath $file.FullName -Raw -ErrorAction SilentlyContinue + if (-not $content) { continue } + if ($content -notmatch '(?ms)^\s*\[package\]\s*$') { continue } + $rel = [System.IO.Path]::GetRelativePath($RepoRootPath, $file.Directory.FullName) + $crates.Add(($rel -replace '\\', '/')) + } + return [string[]]($crates | Sort-Object -Unique) +} + +function Convert-GlobToRegex { + [CmdletBinding()] + [OutputType([string])] + param([Parameter(Mandatory)] [string]$Glob) + $normalized = $Glob -replace '\\', '/' + $sb = [System.Text.StringBuilder]::new() + $i = 0 + while ($i -lt $normalized.Length) { + $c = $normalized[$i] + if ($c -eq '*' -and ($i + 1) -lt $normalized.Length -and $normalized[$i + 1] -eq '*') { + [void]$sb.Append('.*') + $i += 2 + if ($i -lt $normalized.Length -and $normalized[$i] -eq '/') { $i++ } + } + elseif ($c -eq '*') { + [void]$sb.Append('[^/]*') + $i++ + } + elseif ($c -eq '?') { + [void]$sb.Append('[^/]') + $i++ + } + elseif ('.+()|^$[]{}\'.Contains([string]$c)) { + [void]$sb.Append('\').Append($c) + $i++ + } + else { + [void]$sb.Append($c) + $i++ + } + } + return ('^' + $sb.ToString() + '$') +} + +function Test-PathCoveredByGlob { + [CmdletBinding()] + [OutputType([bool])] + param( + [Parameter(Mandatory)] [string]$Path, + [string[]]$Globs + ) + if (-not $Globs -or $Globs.Count -eq 0) { return $false } + $candidate = $Path -replace '\\', '/' + foreach ($glob in $Globs) { + if ([string]::IsNullOrWhiteSpace($glob)) { continue } + $regex = Convert-GlobToRegex -Glob $glob + if ($candidate -match $regex) { return $true } + # also match any descendant file under the crate directory + if (($candidate + '/anything.rs') -match $regex) { return $true } + } + return $false +} + +function Test-MatrixCover { + [CmdletBinding()] + [OutputType([bool])] + param( + [Parameter(Mandatory)] [string]$Crate, + [string[]]$MatrixEntries + ) + if (-not $MatrixEntries) { return $false } + $cratePath = $Crate -replace '\\', '/' + foreach ($entry in $MatrixEntries) { + if ([string]::IsNullOrWhiteSpace($entry)) { continue } + $normalized = ($entry -replace '\\', '/').TrimEnd('/') + if ($cratePath -eq $normalized) { return $true } + if ($cratePath.StartsWith($normalized + '/')) { return $true } + } + return $false +} + +function Get-RustTestsConfig { + [CmdletBinding()] + param([Parameter(Mandatory)] [string]$WorkflowPath) + if (-not (Test-Path -LiteralPath $WorkflowPath)) { + throw "rust-tests.yml not found at: $WorkflowPath" + } + $raw = Get-Content -LiteralPath $WorkflowPath -Raw + $doc = ConvertFrom-Yaml -Yaml $raw + # PowerShell-Yaml maps the YAML key "on" to either "on" or boolean True depending on version. + $onSection = $null + foreach ($key in @('on', $true, 'True')) { + if ($doc.Contains($key)) { $onSection = $doc[$key]; break } + } + $pullPaths = @() + $pushPaths = @() + if ($onSection) { + if ($onSection['pull_request'] -and $onSection['pull_request']['paths']) { + $pullPaths = @($onSection['pull_request']['paths']) + } + if ($onSection['push'] -and $onSection['push']['paths']) { + $pushPaths = @($onSection['push']['paths']) + } + } + $matrix = @() + $matrixSection = $null + if ($doc['jobs'] -and $doc['jobs']['coverage'] -and $doc['jobs']['coverage']['strategy'] ` + -and $doc['jobs']['coverage']['strategy']['matrix']) { + $matrixSection = $doc['jobs']['coverage']['strategy']['matrix'] + } + if ($matrixSection) { + if ($matrixSection['crate']) { + $matrix += @($matrixSection['crate']) + } + if ($matrixSection['include']) { + foreach ($item in @($matrixSection['include'])) { + if ($item -is [System.Collections.IDictionary] -and $item['crate']) { + $matrix += $item['crate'] + } + } + } + } + return [pscustomobject]@{ + PullRequestPaths = $pullPaths + PushPaths = $pushPaths + MatrixCrates = $matrix + } +} + +function Get-CodecovConfig { + [CmdletBinding()] + param([Parameter(Mandatory)] [string]$CodecovPath) + if (-not (Test-Path -LiteralPath $CodecovPath)) { + throw "codecov.yml not found at: $CodecovPath" + } + $raw = Get-Content -LiteralPath $CodecovPath -Raw + $doc = ConvertFrom-Yaml -Yaml $raw + $rustPaths = @() + if ($doc['flags'] -and $doc['flags']['rust'] -and $doc['flags']['rust']['paths']) { + $rustPaths = @($doc['flags']['rust']['paths']) + } + $ignore = @() + if ($doc['ignore']) { $ignore = @($doc['ignore']) } + return [pscustomobject]@{ + RustFlagPaths = $rustPaths + Ignore = $ignore + } +} + +function Test-CrateRegistration { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [string]$Crate, + [Parameter(Mandatory)] [pscustomobject]$RustTests, + [Parameter(Mandatory)] [pscustomobject]$Codecov + ) + $ignored = Test-PathCoveredByGlob -Path $Crate -Globs $Codecov.Ignore + if ($ignored) { + return [pscustomobject]@{ + Crate = $Crate + Status = 'opted-out' + Missing = @() + } + } + $missing = @() + if (-not (Test-MatrixCover -Crate $Crate -MatrixEntries $RustTests.MatrixCrates)) { + $missing += 'rust-tests.yml jobs.coverage.strategy.matrix.crate' + } + # Path filters are optional. When rust-tests.yml is workflow_call-only (reusable workflow), + # pull_request/push paths are not declared; matrix-crate coverage + codecov flags are authoritative. + if ($RustTests.PullRequestPaths.Count -gt 0 -and -not (Test-PathCoveredByGlob -Path $Crate -Globs $RustTests.PullRequestPaths)) { + $missing += 'rust-tests.yml on.pull_request.paths' + } + if ($RustTests.PushPaths.Count -gt 0 -and -not (Test-PathCoveredByGlob -Path $Crate -Globs $RustTests.PushPaths)) { + $missing += 'rust-tests.yml on.push.paths' + } + if (-not (Test-PathCoveredByGlob -Path $Crate -Globs $Codecov.RustFlagPaths)) { + $missing += 'codecov.yml flags.rust.paths' + } + return [pscustomobject]@{ + Crate = $Crate + Status = if ($missing.Count -eq 0) { 'registered' } else { 'unregistered' } + Missing = $missing + } +} + +function Invoke-Validation { + [CmdletBinding()] + [OutputType([int])] + param( + [Parameter(Mandatory)] [string]$RepoRootPath, + [Parameter(Mandatory)] [string]$ReportPath + ) + Install-YamlModuleIfNeeded + $appRoot = Join-Path -Path $RepoRootPath -ChildPath 'src/500-application' + $crates = Get-CrateDirectory -ApplicationRoot $appRoot -RepoRootPath $RepoRootPath + $rustTests = Get-RustTestsConfig -WorkflowPath (Join-Path $RepoRootPath '.github/workflows/rust-tests.yml') + $codecov = Get-CodecovConfig -CodecovPath (Join-Path $RepoRootPath 'codecov.yml') + + $results = @() + foreach ($crate in $crates) { + $results += Test-CrateRegistration -Crate $crate -RustTests $rustTests -Codecov $codecov + } + + $unregistered = @($results | Where-Object { $_.Status -eq 'unregistered' }) + $report = [pscustomobject]@{ + timestamp = (Get-Date).ToString('o') + repoRoot = $RepoRootPath + cratesDiscovered = $crates.Count + registered = @($results | Where-Object { $_.Status -eq 'registered' }).Count + optedOut = @($results | Where-Object { $_.Status -eq 'opted-out' }).Count + unregistered = $unregistered.Count + results = $results + } + + $reportDir = $ReportPath + if (-not (Test-Path -LiteralPath $reportDir)) { + New-Item -ItemType Directory -Path $reportDir -Force | Out-Null + } + $reportFile = Join-Path -Path $reportDir -ChildPath 'rust-crate-registration-report.json' + $report | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath $reportFile -Encoding utf8 + + if ($unregistered.Count -gt 0) { + Write-Host "Rust crate registration check FAILED. $($unregistered.Count) crate(s) unregistered:" -ForegroundColor Red + foreach ($item in $unregistered) { + Write-Host " - $($item.Crate)" -ForegroundColor Red + foreach ($miss in $item.Missing) { + Write-Host " missing: $miss" -ForegroundColor Yellow + } + } + Write-Host "Report: $reportFile" + return 1 + } + + Write-Host "Rust crate registration check passed. $($crates.Count) crate(s) inspected." -ForegroundColor Green + Write-Host "Report: $reportFile" + return 0 +} + +if ($MyInvocation.InvocationName -ne '.') { + if (-not $RepoRoot) { + $RepoRoot = Split-Path -Parent $PSScriptRoot + if (-not $RepoRoot) { $RepoRoot = (Get-Location).Path } + } + if (-not $OutputPath) { + $OutputPath = Join-Path -Path $RepoRoot -ChildPath 'logs' + } + $exitCode = Invoke-Validation -RepoRootPath $RepoRoot -ReportPath $OutputPath + exit $exitCode +} diff --git a/scripts/build/Detect-Folder-Changes.Tests.ps1 b/scripts/build/Detect-Folder-Changes.Tests.ps1 new file mode 100644 index 00000000..9db55769 --- /dev/null +++ b/scripts/build/Detect-Folder-Changes.Tests.ps1 @@ -0,0 +1,99 @@ +# Copyright (c) Microsoft Corporation. +# SPDX-License-Identifier: MIT + +BeforeAll { + $script:SutPath = (Resolve-Path (Join-Path -Path $PSScriptRoot -ChildPath 'Detect-Folder-Changes.ps1')).Path + $tokens = $null + $errors = $null + $ast = [System.Management.Automation.Language.Parser]::ParseFile( + $script:SutPath, [ref]$tokens, [ref]$errors) + $functionDefs = $ast.FindAll( + { param($n) $n -is [System.Management.Automation.Language.FunctionDefinitionAst] }, + $true) + $functionScript = ($functionDefs | ForEach-Object { $_.Extent.Text }) -join "`n" + . ([scriptblock]::Create($functionScript)) +} + +Describe 'Test-IsRustChangeFile' -Tag 'Unit' { + It 'matches files under src/500-application/' { + Test-IsRustChangeFile -Path 'src/500-application/503/media-capture-service/src/main.rs' | Should -BeTrue + } + + It 'matches root Cargo.toml exactly' { + Test-IsRustChangeFile -Path 'Cargo.toml' | Should -BeTrue + } + + It 'matches root Cargo.lock exactly' { + Test-IsRustChangeFile -Path 'Cargo.lock' | Should -BeTrue + } + + It 'matches codecov.yml exactly' { + Test-IsRustChangeFile -Path 'codecov.yml' | Should -BeTrue + } + + It 'matches the rust-tests workflow' { + Test-IsRustChangeFile -Path '.github/workflows/rust-tests.yml' | Should -BeTrue + } + + It 'matches the pr-validation workflow' { + Test-IsRustChangeFile -Path '.github/workflows/pr-validation.yml' | Should -BeTrue + } + + It 'returns false for unrelated documentation' { + Test-IsRustChangeFile -Path 'docs/readme.md' | Should -BeFalse + } + + It 'returns false for terraform sources outside 500-application' { + Test-IsRustChangeFile -Path 'src/020-iac/main.tf' | Should -BeFalse + } + + It 'returns true for any nested Cargo.toml' { + Test-IsRustChangeFile -Path 'crates/foo/Cargo.toml' | Should -BeTrue + } + + It 'matches Rust sources under src/900-tools-utilities/' { + Test-IsRustChangeFile -Path 'src/900-tools-utilities/901-video-tools/cli/video-to-gif/src/main.rs' | Should -BeTrue + } + + It 'matches Cargo.toml under src/900-tools-utilities/' { + Test-IsRustChangeFile -Path 'src/900-tools-utilities/901-video-tools/cli/video-to-gif/Cargo.toml' | Should -BeTrue + } + + It 'returns false for markdown files containing .rs in the name' { + Test-IsRustChangeFile -Path 'docs/example.rs.md' | Should -BeFalse + } + + It 'returns false for text files referencing rs' { + Test-IsRustChangeFile -Path 'notes/rs-overview.txt' | Should -BeFalse + } + + It 'returns false for the matrix-folder-check workflow' { + Test-IsRustChangeFile -Path '.github/workflows/matrix-folder-check.yml' | Should -BeFalse + } + + It 'returns false for an empty path' { + Test-IsRustChangeFile -Path '' | Should -BeFalse + } +} + +Describe 'Test-RustHasChange' -Tag 'Unit' { + It 'returns false for $null input' { + Test-RustHasChange -ChangedFiles $null | Should -BeFalse + } + + It 'returns false for an empty array' { + Test-RustHasChange -ChangedFiles @() | Should -BeFalse + } + + It 'returns true when any file matches' { + Test-RustHasChange -ChangedFiles @('docs/readme.md', 'Cargo.toml') | Should -BeTrue + } + + It 'returns true when a src/500-application file is present' { + Test-RustHasChange -ChangedFiles @('src/500-application/503/foo/src/lib.rs') | Should -BeTrue + } + + It 'returns false when no file matches' { + Test-RustHasChange -ChangedFiles @('docs/readme.md', 'src/020-iac/main.tf') | Should -BeFalse + } +} diff --git a/scripts/build/Detect-Folder-Changes.ps1 b/scripts/build/Detect-Folder-Changes.ps1 index 7e5a72aa..995f03b8 100644 --- a/scripts/build/Detect-Folder-Changes.ps1 +++ b/scripts/build/Detect-Folder-Changes.ps1 @@ -329,9 +329,54 @@ $terraformHasChanges = $false $terraformFolders = @{} $bicepHasChanges = $false $bicepFolders = @{} +$rustHasChanges = $false # Use native PowerShell commands where possible and minimize redundant operations +function Test-IsRustChangeFile { + <# + .SYNOPSIS + Returns $true when a repo-relative path should trigger the rust-tests workflow. + .DESCRIPTION + Matches any Rust source file (*.rs), any Cargo manifest or lockfile at any + depth (Cargo.toml / Cargo.lock), and the rust-tests, pr-validation, and + codecov gating files. Broader than the legacy 500-application-only rule so + Rust crates anywhere in the repository (for example tools and utilities) + trigger the rust-tests workflow. + #> + param ( + [Parameter(Mandatory = $true)] + [AllowEmptyString()] + [string]$Path + ) + + return $Path -match '(\.rs$|(^|/)Cargo\.(toml|lock)$|^\.github/workflows/(rust-tests|pr-validation)\.yml$|^codecov\.yml$)' +} + +function Test-RustHasChange { + <# + .SYNOPSIS + Returns $true when any path in $ChangedFiles matches the rust gating ruleset. + #> + param ( + [Parameter(Mandatory = $false)] + [AllowNull()] + [string[]]$ChangedFiles + ) + + if ($null -eq $ChangedFiles -or $ChangedFiles.Count -eq 0) { + return $false + } + + foreach ($file in $ChangedFiles) { + if (Test-IsRustChangeFile -Path $file) { + return $true + } + } + + return $false +} + function Get-ChangedFileData { param ( [switch]$IncludeAll, @@ -710,6 +755,16 @@ $jsonOutput | Add-Member -MemberType NoteProperty -Name "applications" -Value ([ folders = $applicationChanges }) +# Detect Rust-relevant changes that should gate the rust-tests workflow. +# Mirrors the regex previously enforced by the standalone detect-rust-changes job +# in pr-validation.yml: any crate under src/500-application, root Cargo manifests, +# or workflow/codecov files that influence the rust-tests pipeline. +$rustHasChanges = Test-RustHasChange -ChangedFiles $changedFiles + +$jsonOutput | Add-Member -MemberType NoteProperty -Name "rust" -Value ([PSCustomObject]@{ + has_changes = [bool]$rustHasChanges + }) + # Convert to JSON $jsonString = $jsonOutput | ConvertTo-Json -Depth 10 diff --git a/scripts/tests/Validate-RustCrateRegistration.Tests.ps1 b/scripts/tests/Validate-RustCrateRegistration.Tests.ps1 new file mode 100644 index 00000000..497fd973 --- /dev/null +++ b/scripts/tests/Validate-RustCrateRegistration.Tests.ps1 @@ -0,0 +1,276 @@ +# Copyright (c) Microsoft Corporation. +# SPDX-License-Identifier: MIT + +BeforeAll { + $script:SutPath = (Resolve-Path (Join-Path -Path $PSScriptRoot -ChildPath '../Validate-RustCrateRegistration.ps1')).Path + $tokens = $null + $errors = $null + $ast = [System.Management.Automation.Language.Parser]::ParseFile( + $script:SutPath, [ref]$tokens, [ref]$errors) + $functionDefs = $ast.FindAll( + { param($n) $n -is [System.Management.Automation.Language.FunctionDefinitionAst] }, + $true) + $functionScript = ($functionDefs | ForEach-Object { $_.Extent.Text }) -join "`n" + . ([scriptblock]::Create($functionScript)) + + $script:HasYaml = [bool](Get-Module -ListAvailable -Name 'powershell-yaml') +} + +Describe 'Convert-GlobToRegex' -Tag 'Unit' { + It 'translates ** to .*' { + $regex = Convert-GlobToRegex -Glob 'src/500-application/503/**' + 'src/500-application/503/media-capture-service/Cargo.toml' | Should -Match $regex + } + + It 'translates single * to non-slash segment' { + $regex = Convert-GlobToRegex -Glob 'src/500-application/*/Cargo.toml' + 'src/500-application/503/Cargo.toml' | Should -Match $regex + 'src/500-application/503/sub/Cargo.toml' | Should -Not -Match $regex + } + + It 'escapes regex special characters' { + $regex = Convert-GlobToRegex -Glob 'foo.bar+baz' + 'foo.bar+baz' | Should -Match $regex + 'fooXbar+baz' | Should -Not -Match $regex + } +} + +Describe 'Test-MatrixCover' -Tag 'Unit' { + It 'matches exact crate path' { + Test-MatrixCover -Crate 'src/500-application/503' -MatrixEntries @('src/500-application/503') | Should -BeTrue + } + + It 'matches crate that is a subdirectory of a matrix entry' { + Test-MatrixCover -Crate 'src/500-application/507/ai-edge-inference-crate' ` + -MatrixEntries @('src/500-application/507') | Should -BeTrue + } + + It 'returns false when no entry covers the crate' { + Test-MatrixCover -Crate 'src/500-application/999' -MatrixEntries @('src/500-application/503') | Should -BeFalse + } + + It 'returns false for empty matrix' { + Test-MatrixCover -Crate 'src/500-application/503' -MatrixEntries @() | Should -BeFalse + } +} + +Describe 'Test-PathCoveredByGlob' -Tag 'Unit' { + It 'matches a crate directory under a ** glob' { + Test-PathCoveredByGlob -Path 'src/500-application/503/media-capture-service' ` + -Globs @('src/500-application/503/**') | Should -BeTrue + } + + It 'matches the crate root itself when glob targets the crate' { + Test-PathCoveredByGlob -Path 'src/500-application/507' ` + -Globs @('src/500-application/507/**') | Should -BeTrue + } + + It 'returns false when no glob matches' { + Test-PathCoveredByGlob -Path 'src/500-application/999' ` + -Globs @('src/500-application/503/**', 'src/500-application/507/**') | Should -BeFalse + } + + It 'returns false for empty glob list' { + Test-PathCoveredByGlob -Path 'src/500-application/503' -Globs @() | Should -BeFalse + } +} + +Describe 'Get-CrateDirectory' -Tag 'Unit' { + It 'discovers Cargo.toml files containing a [package] section' { + $appRoot = Join-Path $TestDrive 'src/500-application' + $crateDir = Join-Path $appRoot '600/widget' + New-Item -ItemType Directory -Path $crateDir -Force | Out-Null + Set-Content -LiteralPath (Join-Path $crateDir 'Cargo.toml') -Value "[package]`nname = `"widget`"`nversion = `"0.1.0`"`n" + + $crates = @(Get-CrateDirectory -ApplicationRoot $appRoot -RepoRootPath $TestDrive) + $crates | Should -Contain 'src/500-application/600/widget' + } + + It 'skips Cargo.toml under target/ directories' { + $appRoot = Join-Path $TestDrive 'src/500-application' + $targetDir = Join-Path $appRoot '601/app/target/debug/build/foo' + New-Item -ItemType Directory -Path $targetDir -Force | Out-Null + Set-Content -LiteralPath (Join-Path $targetDir 'Cargo.toml') -Value "[package]`nname = `"foo`"`n" + + $crates = @(Get-CrateDirectory -ApplicationRoot $appRoot -RepoRootPath $TestDrive) + $crates | Where-Object { $_ -match '/target/' } | Should -BeNullOrEmpty + } + + It 'skips Cargo.toml lacking a [package] section (workspace manifests)' { + $appRoot = Join-Path $TestDrive 'src/500-application' + $wsDir = Join-Path $appRoot '602' + New-Item -ItemType Directory -Path $wsDir -Force | Out-Null + Set-Content -LiteralPath (Join-Path $wsDir 'Cargo.toml') -Value "[workspace]`nmembers = [`"a`"]`n" + + $crates = @(Get-CrateDirectory -ApplicationRoot $appRoot -RepoRootPath $TestDrive) + $crates | Should -Not -Contain 'src/500-application/602' + } + + It 'returns empty array when application root does not exist' { + $missing = Join-Path $TestDrive 'no-such-root' + $crates = @(Get-CrateDirectory -ApplicationRoot $missing -RepoRootPath $TestDrive) + $crates.Count | Should -Be 0 + } +} + +Describe 'Test-CrateRegistration' -Tag 'Unit' { + BeforeAll { + $script:RustOk = [pscustomobject]@{ + PullRequestPaths = @('src/500-application/503/**') + PushPaths = @('src/500-application/503/**') + MatrixCrates = @('src/500-application/503') + } + $script:CodecovOk = [pscustomobject]@{ + RustFlagPaths = @('src/500-application/503/**') + Ignore = @() + } + } + + It 'returns opted-out when crate is covered by a codecov ignore glob' { + $codecov = [pscustomobject]@{ + RustFlagPaths = @() + Ignore = @('src/500-application/501/**') + } + $rust = [pscustomobject]@{ PullRequestPaths = @(); PushPaths = @(); MatrixCrates = @() } + $result = Test-CrateRegistration -Crate 'src/500-application/501/sender' -RustTests $rust -Codecov $codecov + $result.Status | Should -Be 'opted-out' + $result.Missing.Count | Should -Be 0 + } + + It 'returns registered when matrix, both paths, and rust flag paths cover the crate' { + $result = Test-CrateRegistration -Crate 'src/500-application/503' -RustTests $script:RustOk -Codecov $script:CodecovOk + $result.Status | Should -Be 'registered' + $result.Missing.Count | Should -Be 0 + } + + It 'returns unregistered with matrix and codecov entries when path filters are absent (reusable workflow)' { + $rust = [pscustomobject]@{ PullRequestPaths = @(); PushPaths = @(); MatrixCrates = @() } + $codecov = [pscustomobject]@{ RustFlagPaths = @(); Ignore = @() } + $result = Test-CrateRegistration -Crate 'src/500-application/999' -RustTests $rust -Codecov $codecov + $result.Status | Should -Be 'unregistered' + $result.Missing | Should -Contain 'rust-tests.yml jobs.coverage.strategy.matrix.crate' + $result.Missing | Should -Contain 'codecov.yml flags.rust.paths' + $result.Missing | Should -Not -Contain 'rust-tests.yml on.pull_request.paths' + $result.Missing | Should -Not -Contain 'rust-tests.yml on.push.paths' + } + + It 'returns unregistered with path entries when path filters exist but do not cover the crate' { + $rust = [pscustomobject]@{ + PullRequestPaths = @('src/500-application/503/**') + PushPaths = @('src/500-application/503/**') + MatrixCrates = @() + } + $codecov = [pscustomobject]@{ RustFlagPaths = @(); Ignore = @() } + $result = Test-CrateRegistration -Crate 'src/500-application/999' -RustTests $rust -Codecov $codecov + $result.Status | Should -Be 'unregistered' + $result.Missing | Should -Contain 'rust-tests.yml on.pull_request.paths' + $result.Missing | Should -Contain 'rust-tests.yml on.push.paths' + } + + It 'returns unregistered listing only the missing piece when matrix is the gap' { + $rust = [pscustomobject]@{ + PullRequestPaths = @('src/500-application/503/**') + PushPaths = @('src/500-application/503/**') + MatrixCrates = @() + } + $result = Test-CrateRegistration -Crate 'src/500-application/503' -RustTests $rust -Codecov $script:CodecovOk + $result.Status | Should -Be 'unregistered' + $result.Missing | Should -Be @('rust-tests.yml jobs.coverage.strategy.matrix.crate') + } +} + +Describe 'Invoke-Validation end-to-end' -Tag 'Unit' -Skip:(-not $script:HasYaml) { + It 'returns 0 and writes a JSON report when every crate is registered' { + $repoRoot = Join-Path $TestDrive 'repo-clean' + $crateDir = Join-Path $repoRoot 'src/500-application/503/media-capture-service' + New-Item -ItemType Directory -Path $crateDir -Force | Out-Null + Set-Content -LiteralPath (Join-Path $crateDir 'Cargo.toml') -Value "[package]`nname = `"x`"`n" + + $workflowDir = Join-Path $repoRoot '.github/workflows' + New-Item -ItemType Directory -Path $workflowDir -Force | Out-Null + $workflow = @' +name: rust-tests +on: + pull_request: + paths: + - 'src/500-application/503/**' + push: + paths: + - 'src/500-application/503/**' +jobs: + coverage: + strategy: + matrix: + crate: + - 'src/500-application/503' +'@ + Set-Content -LiteralPath (Join-Path $workflowDir 'rust-tests.yml') -Value $workflow + + $codecov = @' +flags: + rust: + paths: + - 'src/500-application/503/**' +ignore: + - 'target/**' +'@ + Set-Content -LiteralPath (Join-Path $repoRoot 'codecov.yml') -Value $codecov + + $reportDir = Join-Path $repoRoot 'test-results' + $exit = Invoke-Validation -RepoRootPath $repoRoot -ReportPath $reportDir + $exit | Should -Be 0 + + $reportFile = Join-Path $reportDir 'rust-crate-registration-report.json' + Test-Path -LiteralPath $reportFile | Should -BeTrue + $report = Get-Content -LiteralPath $reportFile -Raw | ConvertFrom-Json + $report.unregistered | Should -Be 0 + $report.registered | Should -Be 1 + } + + It 'returns 1 and records gaps when a crate is missing from coverage configuration' { + $repoRoot = Join-Path $TestDrive 'repo-gap' + $crateDir = Join-Path $repoRoot 'src/500-application/999/orphan' + New-Item -ItemType Directory -Path $crateDir -Force | Out-Null + Set-Content -LiteralPath (Join-Path $crateDir 'Cargo.toml') -Value "[package]`nname = `"orphan`"`n" + + $workflowDir = Join-Path $repoRoot '.github/workflows' + New-Item -ItemType Directory -Path $workflowDir -Force | Out-Null + $workflow = @' +name: rust-tests +on: + pull_request: + paths: + - 'src/500-application/503/**' + push: + paths: + - 'src/500-application/503/**' +jobs: + coverage: + strategy: + matrix: + crate: + - 'src/500-application/503' +'@ + Set-Content -LiteralPath (Join-Path $workflowDir 'rust-tests.yml') -Value $workflow + + $codecov = @' +flags: + rust: + paths: + - 'src/500-application/503/**' +ignore: + - 'target/**' +'@ + Set-Content -LiteralPath (Join-Path $repoRoot 'codecov.yml') -Value $codecov + + $reportDir = Join-Path $repoRoot 'test-results' + $exit = Invoke-Validation -RepoRootPath $repoRoot -ReportPath $reportDir + $exit | Should -Be 1 + + $report = Get-Content -LiteralPath (Join-Path $reportDir 'rust-crate-registration-report.json') -Raw | ConvertFrom-Json + $report.unregistered | Should -Be 1 + $orphan = $report.results | Where-Object { $_.Crate -eq 'src/500-application/999/orphan' } + $orphan.Status | Should -Be 'unregistered' + $orphan.Missing | Should -Contain 'rust-tests.yml jobs.coverage.strategy.matrix.crate' + } +} diff --git a/src/500-application/503-media-capture-service/services/media-capture-service/src/multi_trigger.rs b/src/500-application/503-media-capture-service/services/media-capture-service/src/multi_trigger.rs index a5cf57f4..5e1eec19 100644 --- a/src/500-application/503-media-capture-service/services/media-capture-service/src/multi_trigger.rs +++ b/src/500-application/503-media-capture-service/services/media-capture-service/src/multi_trigger.rs @@ -23,7 +23,7 @@ impl MultiTriggerWorker { let t = topic.to_lowercase(); // Treat any topic containing "alert" (e.g., "alerts/trigger", ".../alert/true") as an Alert - if t.contains("alert") && t.contains("trigger") { + if t.contains("alert") { return MessageType::Alert; } diff --git a/src/500-application/507-ai-inference/services/ai-edge-inference-crate/src/backend.rs b/src/500-application/507-ai-inference/services/ai-edge-inference-crate/src/backend.rs index 673cf464..bde54680 100644 --- a/src/500-application/507-ai-inference/services/ai-edge-inference-crate/src/backend.rs +++ b/src/500-application/507-ai-inference/services/ai-edge-inference-crate/src/backend.rs @@ -422,9 +422,12 @@ mod tests { fn test_backend_factory_available_backends() { let backends = BackendFactory::available_backends(); - // At least one backend should be available + #[cfg(any(feature = "onnx-runtime", feature = "candle"))] assert!(!backends.is_empty(), "No backends compiled in"); + #[cfg(not(any(feature = "onnx-runtime", feature = "candle")))] + assert!(backends.is_empty(), "Expected no backends without features"); + #[cfg(feature = "onnx-runtime")] assert!(backends.contains(&BackendType::OnnxRuntime)); diff --git a/src/500-application/507-ai-inference/services/ai-edge-inference-crate/src/postprocessing.rs b/src/500-application/507-ai-inference/services/ai-edge-inference-crate/src/postprocessing.rs index 9457c314..0d54f01c 100644 --- a/src/500-application/507-ai-inference/services/ai-edge-inference-crate/src/postprocessing.rs +++ b/src/500-application/507-ai-inference/services/ai-edge-inference-crate/src/postprocessing.rs @@ -676,7 +676,7 @@ pub mod presets { #[cfg(test)] mod tests { use super::*; - use ndarray::Array3; + use ndarray::{Array3, Array4}; #[test] fn test_yolov8_postprocessing() { diff --git a/src/500-application/507-ai-inference/services/ai-edge-inference/src/topic_router.rs b/src/500-application/507-ai-inference/services/ai-edge-inference/src/topic_router.rs index 535aad52..45314f70 100644 --- a/src/500-application/507-ai-inference/services/ai-edge-inference/src/topic_router.rs +++ b/src/500-application/507-ai-inference/services/ai-edge-inference/src/topic_router.rs @@ -154,42 +154,23 @@ impl TopicRouter { #[cfg(test)] mod tests { use super::*; - use ai_edge_inference_crate::{ModelOutput, Prediction, SiteContext}; + use ai_edge_inference_crate::Prediction; use std::collections::HashMap; fn create_test_result() -> InferenceResult { InferenceResult { - request_id: "test-001".to_string(), model_name: "industrial-safety-vision".to_string(), - model_type: ModelType::Vision, - model_version: "1.0.0".to_string(), - outputs: vec![ - ModelOutput { - output_type: "predictions".to_string(), - predictions: vec![ - Prediction { - class_id: 1, - class_name: "safety_helmet".to_string(), - confidence: 0.95, - bounding_box: Some([0.1, 0.1, 0.3, 0.4]), - attributes: HashMap::new(), - } - ], - raw_output: None, - } - ], - confidence_threshold: 0.5, - processing_time_ms: 45, - timestamp: chrono::Utc::now(), - site_context: Some(SiteContext { - site_id: "pilot-facility-001".to_string(), - facility_name: "Pilot Industrial AI Site".to_string(), - business_unit: Some("Digital Innovation".to_string()), - region: Some("North America".to_string()), - environmental_data: HashMap::new(), - equipment_mapping: HashMap::new(), - }), - metadata: HashMap::new(), + model_type: "vision".to_string(), + predictions: vec![Prediction { + class: "safety_helmet".to_string(), + confidence: 0.95, + bbox: Some([0.1, 0.1, 0.3, 0.4]), + metadata: HashMap::new(), + severity: None, + }], + confidence: 0.95, + inference_time_ms: 45.0, + metadata: serde_json::json!({}), } } @@ -201,7 +182,6 @@ mod tests { let topic = router.route_result(&result); assert!(topic.contains("edge-ai/downstream/facility_01/gateway_001")); - assert!(topic.contains("site/pilot-facility-001")); assert!(topic.contains("inference/vision")); assert!(topic.contains("industrial_safety_vision")); assert!(topic.contains("high")); // High confidence prediction @@ -212,13 +192,13 @@ mod tests { let mut router = TopicRouter::new("edge-ai".to_string()); router.add_custom_route( "industrial-safety-vision".to_string(), - "{prefix}/safety/{site_id}/alerts/{priority}".to_string() + "{prefix}/safety/{model_name}/alerts/{priority}".to_string() ); let result = create_test_result(); let topic = router.route_result(&result); - assert_eq!(topic, "edge-ai/safety/pilot-facility-001/alerts/high"); + assert_eq!(topic, "edge-ai/safety/industrial-safety-vision/alerts/high"); } #[test]