diff --git a/.gitattributes b/.gitattributes index 2288ba47..8289a470 100644 --- a/.gitattributes +++ b/.gitattributes @@ -5,4 +5,6 @@ *.bin filter=lfs diff=lfs merge=lfs -text *.itb filter=lfs diff=lfs merge=lfs -text *mfgtool* filter=lfs diff=lfs merge=lfs -text +# Build entry points are source, even when their names contain "mfgtool". +scripts/kas-build-mfgtools.sh -filter -diff -merge text eol=lf fitImage-* filter=lfs diff=lfs merge=lfs -text diff --git a/.github/actionlint.yaml b/.github/actionlint.yaml new file mode 100644 index 00000000..cfd084cf --- /dev/null +++ b/.github/actionlint.yaml @@ -0,0 +1,3 @@ +self-hosted-runner: + labels: + - yocto diff --git a/.github/workflows/kas-build-ci.yml b/.github/workflows/kas-build-ci.yml deleted file mode 100644 index e1bf06f3..00000000 --- a/.github/workflows/kas-build-ci.yml +++ /dev/null @@ -1,203 +0,0 @@ -name: KAS Build CI - -on: - push: - branches: [ main, develop ] - paths: - - 'kas/**' - - 'meta-dynamicdevices-bsp/**' - - 'meta-dynamicdevices-distro/**' - - 'recipes-**' - - 'classes/**' - - 'conf/**' - - '.github/workflows/kas-build-ci.yml' - - 'scripts/kas-*.sh' - - 'scripts/validation/**' - - 'bbappends/**' - - 'custom-boot-files/**' - - pull_request: - branches: [ main, develop ] - paths: - - 'kas/**' - - 'meta-dynamicdevices-bsp/**' - - 'meta-dynamicdevices-distro/**' - - 'recipes-**' - - 'classes/**' - - 'conf/**' - - '.github/workflows/kas-build-ci.yml' - - 'scripts/kas-*.sh' - - 'scripts/validation/**' - - 'bbappends/**' - - 'custom-boot-files/**' - - workflow_dispatch: - inputs: - debug_enabled: - type: boolean - description: 'Run the build with tmate debugging enabled (https://github.com/marketplace/actions/debugging-with-tmate)' - required: false - default: false - -env: - DEBIAN_FRONTEND: noninteractive - -jobs: - # Validation job - validates Yocto layer compatibility - validate: - name: Validate Yocto Layers - # Pin to a Linux self-hosted runner: bare `self-hosted` also matches the - # org's macOS runners, which have no Docker and fail this container job - # in ~6s with "docker: command not found". - runs-on: [self-hosted, Linux, X64] - container: - image: dynamicdevices/yocto-ci-build:latest - options: --privileged --platform linux/amd64 - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - with: - submodules: recursive - - - name: Cache KAS layers - uses: actions/cache@v4 - with: - path: | - build/layers - build/cache - key: kas-layers-${{ hashFiles('kas/lmp-dynamicdevices-base.yml') }}-${{ github.sha }} - restore-keys: | - kas-layers-${{ hashFiles('kas/lmp-dynamicdevices-base.yml') }}- - kas-layers- - - - name: Cache Yocto downloads - uses: actions/cache@v4 - with: - path: ~/yocto/downloads - key: yocto-downloads-${{ runner.os }}-${{ hashFiles('kas/lmp-dynamicdevices-base.yml') }} - restore-keys: | - yocto-downloads-${{ runner.os }}- - - - name: Validate Yocto Layers - timeout-minutes: 15 - run: | - echo "๐Ÿ… Meta-DynamicDevices Layer Validation (CI)" - echo "=============================================" - echo "๐Ÿ“‹ Using official yocto-check-layer for Yocto Project compliance" - echo "" - - # Install KAS if not available - if ! command -v kas >/dev/null 2>&1; then - echo "๐Ÿ“ฆ Installing KAS..." - pip3 install kas - fi - - # Create validation workspace - VALIDATION_DIR="ci-layer-validation" - rm -rf "$VALIDATION_DIR" - mkdir -p "$VALIDATION_DIR" - cd "$VALIDATION_DIR" - - echo "๐Ÿ”ง Setting up KAS environment for layer validation..." - - # Copy KAS configuration for validation - cp ../kas/layer-validation.yml . - - # Initialize KAS environment - echo "๐Ÿ“‹ Initializing KAS build environment..." - kas shell layer-validation.yml -c "echo 'KAS environment initialized'" - - echo "โœ… KAS environment ready" - echo "" - - echo "๐Ÿ” Starting comprehensive layer validation..." - echo "" - - # Run yocto-check-layer validation - echo "1๏ธโƒฃ Validating all meta-dynamicdevices layers together..." - - if kas shell layer-validation.yml -c " - # Use the yocto-check-layer script from openembedded-core - YOCTO_CHECK_LAYER='./layers/openembedded-core/scripts/yocto-check-layer' - - if [ ! -f \"\$YOCTO_CHECK_LAYER\" ]; then - echo 'โŒ yocto-check-layer script not found at expected location' - exit 1 - fi - - echo 'โœ… Found yocto-check-layer: '\$YOCTO_CHECK_LAYER - - # Clean up all potential conflicts from BitBake test data - rm -rf layers/bitbake/lib/layerindexlib/tests/testdata/ 2>/dev/null || true - find ../.. -name 'bitbake' -type d -exec rm -rf {}/lib/layerindexlib/tests/testdata/ 2>/dev/null \\; || true - - # Clean up any other build directories that might cause collection conflicts - find ../.. -maxdepth 2 -name 'build*' -type d ! -path '*/ci-layer-validation/build' -exec echo '๐Ÿงน Temporarily moving {}' \\; -exec mv {} {}.bak 2>/dev/null \\; || true - - # Run validation on all meta-dynamicdevices layers together to handle dependencies - echo '๐Ÿ” Running yocto-check-layer validation...' - python3 \"\$YOCTO_CHECK_LAYER\" \"$PWD/../meta-dynamicdevices-bsp\" \"$PWD/../meta-dynamicdevices-distro\" \"$PWD/..\" - - # Restore moved directories - find ../.. -maxdepth 2 -name 'build*.bak' -type d -exec sh -c 'mv \"\$1\" \"\${1%.bak}\"' _ {} \\; 2>/dev/null || true - "; then - echo "" - echo "โœ… meta-dynamicdevices layers validation PASSED" - echo "" - echo "=============================================" - echo "โœ… All layer validations PASSED" - echo "โœ… All meta-dynamicdevices layers pass comprehensive yocto-check-layer validation!" - echo "โœ… Layers are ready for Yocto Project compatibility." - echo "" - else - echo "" - echo "โŒ meta-dynamicdevices layers validation FAILED" - echo "" - echo "=============================================" - echo "โŒ Layer validation FAILED" - echo "" - echo "โ„น๏ธ Please fix the yocto-check-layer issues above before proceeding." - echo "โ„น๏ธ Run './scripts/validate-layers-local.sh' locally to debug issues." - echo "" - exit 1 - fi - - # Cleanup validation workspace - cd .. - rm -rf "$VALIDATION_DIR" - - # Summary job - summary: - name: Validation Summary - runs-on: [self-hosted, Linux, X64] - needs: [validate] - if: always() - - steps: - - name: Generate validation summary - run: | - echo "# KAS Layer Validation Summary" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "**Workflow:** ${{ github.workflow }}" >> $GITHUB_STEP_SUMMARY - echo "**Trigger:** ${{ github.event_name }}" >> $GITHUB_STEP_SUMMARY - echo "**Commit:** ${{ github.sha }}" >> $GITHUB_STEP_SUMMARY - echo "**Branch:** ${{ github.ref_name }}" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - - # Determine overall status - if [ "${{ needs.validate.result }}" = "success" ]; then - echo "## โœ… Validation Status: SUCCESS" >> $GITHUB_STEP_SUMMARY - echo "All meta-dynamicdevices layers pass comprehensive yocto-check-layer validation!" >> $GITHUB_STEP_SUMMARY - echo "Layers are ready for Yocto Project compatibility." >> $GITHUB_STEP_SUMMARY - else - echo "## โŒ Validation Status: FAILED" >> $GITHUB_STEP_SUMMARY - echo "- Layer validation: ${{ needs.validate.result }}" >> $GITHUB_STEP_SUMMARY - echo "Please fix validation issues before proceeding." >> $GITHUB_STEP_SUMMARY - fi - - echo "" >> $GITHUB_STEP_SUMMARY - echo "## Validation Coverage" >> $GITHUB_STEP_SUMMARY - echo "- **Tool:** Official yocto-check-layer" >> $GITHUB_STEP_SUMMARY - echo "- **Layers:** meta-dynamicdevices, meta-dynamicdevices-bsp, meta-dynamicdevices-distro" >> $GITHUB_STEP_SUMMARY - echo "- **Compliance:** Full Yocto Project compatibility validation" >> $GITHUB_STEP_SUMMARY \ No newline at end of file diff --git a/.github/workflows/layer-adoption-gate.yml b/.github/workflows/layer-adoption-gate.yml new file mode 100644 index 00000000..7e875fb9 --- /dev/null +++ b/.github/workflows/layer-adoption-gate.yml @@ -0,0 +1,170 @@ +name: Layer Adoption Gate + +on: + pull_request: + branches: [main, develop] + push: + branches: [main, develop] + workflow_dispatch: + inputs: + base_sha: + description: Baseline commit to compare + required: true + +concurrency: + group: layer-adoption-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +defaults: + run: + shell: bash + +jobs: + detect: + name: Detect material layer change + runs-on: [self-hosted, Linux, X64, yocto, ai-tools] + outputs: + material: ${{ steps.detect.outputs.material }} + base_sha: ${{ steps.base.outputs.sha }} + tuple_ids: ${{ steps.detect.outputs.tuple_ids }} + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + - id: base + name: Resolve immutable baseline + env: + PR_BASE: ${{ github.event.pull_request.base.sha }} + PUSH_BASE: ${{ github.event.before }} + INPUT_BASE: ${{ inputs.base_sha }} + run: | + sha="${PR_BASE:-${INPUT_BASE:-${PUSH_BASE:-}}}" + if [ -z "$sha" ] || printf '%s' "$sha" | grep -Eq '^0+$'; then + sha=$(git rev-parse HEAD^) + fi + git cat-file -e "$sha^{commit}" + echo "sha=$sha" >> "$GITHUB_OUTPUT" + - id: detect + name: Validate repository and require an adoption contract + run: | + python3 -m unittest discover -s scripts/validation/tests -p 'test_*.py' + find scripts -type f -name '*.sh' -print0 | xargs -0 -r -n1 bash -n + python3 -m json.tool ci/layer-adoption-contract.json >/dev/null + python3 -m json.tool ci/layer-adoption-tuples.json >/dev/null + # Mail-format patch payloads legitimately contain the conventional + # "-- " separator. Check repository sources without rewriting the + # third-party patches that BitBake applies. + git diff --check '${{ steps.base.outputs.sha }}...${{ github.sha }}' -- . ':(exclude)**/*.patch' + python3 scripts/validation/detect-layer-adoption.py \ + --base '${{ steps.base.outputs.sha }}' \ + --head '${{ github.sha }}' \ + --github-output "$GITHUB_OUTPUT" + tuple_ids=$(python3 -c 'import json; print(json.dumps([entry["id"] for entry in json.load(open("ci/layer-adoption-tuples.json"))["tuples"]], separators=(",", ":")))') + echo "tuple_ids=$tuple_ids" >> "$GITHUB_OUTPUT" + regression: + name: Existing product regression (${{ matrix.tuple_id }}) + needs: detect + if: needs.detect.outputs.material == 'true' + strategy: + fail-fast: false + matrix: + tuple_id: ${{ fromJSON(needs.detect.outputs.tuple_ids) }} + # Each tuple is an independent shard so one failure cannot hide later + # product failures. The final Layer Adoption Gate remains the single + # branch-protection contract, and local driver runs still cover all tuples. + runs-on: [self-hosted, Linux, X64, yocto, ai-tools] + container: + image: ghcr.io/siemens/kas/kas@sha256:d989add57fc441fe9e27bb2dd6ed98c5597b44c807928e35a72dc1cfbdda9abe + # Match the dedicated ai-tools runner account so BitBake's root-user + # sanity check remains active and bind-mounted cache files stay writable. + options: --privileged --platform linux/amd64 --user 1002:1002 -v /home/ghrunner/yocto-layer-adoption:/var/cache/layer-adoption + env: + LAYER_ADOPTION_CACHE: /var/cache/layer-adoption + steps: + - name: Reset job-owned workspace + run: | + workspace=$(realpath -m -- "$GITHUB_WORKSPACE") + test -n "$workspace" + test "$workspace" != / + for name in candidate baseline evidence; do + target=$(realpath -m -- "$workspace/$name") + test "$(dirname "$target")" = "$workspace" + rm -rf -- "$target" + done + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + path: candidate + - name: Create baseline worktree + working-directory: candidate + run: git worktree add ../baseline '${{ needs.detect.outputs.base_sha }}' + - name: Require safe build capacity + run: | + available_kib=$(df -Pk "$GITHUB_WORKSPACE" | awk 'NR == 2 {print $4}') + minimum_kib=$((150 * 1024 * 1024)) + if [ "$available_kib" -lt "$minimum_kib" ]; then + echo "ERROR: layer-adoption build requires at least 150 GiB free; found $((available_kib / 1024 / 1024)) GiB" >&2 + exit 1 + fi + - name: Prepare persistent test-only signing identity + run: | + set -euo pipefail + keys="$LAYER_ADOPTION_CACHE/test-keys" + if ! candidate/scripts/validation/generate-layer-adoption-test-keys.sh \ + --check "$keys"; then + test "$keys" = /var/cache/layer-adoption/test-keys + rm -rf -- "$keys" + rm -rf -- "$LAYER_ADOPTION_CACHE/baselines" + temporary=$(mktemp -d "$LAYER_ADOPTION_CACHE/.test-keys.XXXXXX") + candidate/scripts/validation/generate-layer-adoption-test-keys.sh "$temporary" + mv "$temporary" "$keys" + fi + - name: Build and compare every protected tuple + run: | + python3 candidate/scripts/validation/run-layer-adoption-regression.py \ + --baseline baseline \ + --candidate candidate \ + --evidence evidence \ + --cache "$LAYER_ADOPTION_CACHE" \ + --test-keys "$LAYER_ADOPTION_CACHE/test-keys" \ + --tuple-id '${{ matrix.tuple_id }}' + - name: Preserve comparison evidence + if: always() + uses: actions/upload-artifact@v7 + with: + name: layer-adoption-evidence-${{ matrix.tuple_id }} + path: evidence + retention-days: 14 + - name: Remove job-owned build trees + if: always() + run: | + workspace=$(realpath -m -- "$GITHUB_WORKSPACE") + test -n "$workspace" + test "$workspace" != / + for name in candidate/build baseline/build evidence; do + target=$(realpath -m -- "$workspace/$name") + case "$target" in + "$workspace/candidate/build"|"$workspace/baseline/build"|"$workspace/evidence") ;; + *) echo "ERROR: refusing unsafe cleanup target: $target" >&2; exit 1 ;; + esac + rm -rf -- "$target" + done + + required: + name: Layer Adoption Gate + needs: [detect, regression] + if: always() + runs-on: [self-hosted, Linux, X64, yocto, ai-tools] + steps: + - name: Enforce gate result + env: + DETECT: ${{ needs.detect.result }} + MATERIAL: ${{ needs.detect.outputs.material }} + REGRESSION: ${{ needs.regression.result }} + run: | + test "$DETECT" = success + if [ "$MATERIAL" = true ]; then + test "$REGRESSION" = success + else + test "$REGRESSION" = skipped + fi diff --git a/ci/layer-adoption-contract.json b/ci/layer-adoption-contract.json new file mode 100644 index 00000000..8496f62f --- /dev/null +++ b/ci/layer-adoption-contract.json @@ -0,0 +1,26 @@ +{ + "schema": 1, + "reason": "Adopt the isolated NXP i.MX95 partner layer without changing any pre-existing Dynamic Devices build tuple.", + "baseline_repairs": [ + { + "base_sha": "dda54409ee27e29612c01cc1ff0eb88233ca1da5", + "submodule": "meta-dynamicdevices-bsp", + "from": "47542ad3f8d49850df69e37a3414c1ef60c51809", + "to": "b926d4e96532fb95c83984b154d7b2f72d82471e", + "url": "https://github.com/DynamicDevices/meta-dynamicdevices-bsp.git", + "ref": "refs/heads/fix/imx8mm-jaguar-uboot-dtb-context-lmp-v96", + "files": [ + "recipes-bsp/u-boot/u-boot-fio/imx8mm-jaguar-handheld/01-customise-dtb.patch", + "recipes-bsp/u-boot/u-boot-fio/imx8mm-jaguar-phasora/01-customise-dtb.patch", + "recipes-bsp/board-scripts/board-scripts_1.0.bb", + "recipes-bsp/device-tree/lmp-device-tree/imx8mm-jaguar-handheld.dts", + "recipes-bsp/device-tree/lmp-device-tree/imx8mm-jaguar-inst.dts", + "recipes-bsp/device-tree/lmp-device-tree/imx8mm-jaguar-phasora.dts", + "recipes-bsp/upd72020x-load/upd72020x-load/LicenseRef-markusj-upd72020x-load", + "recipes-bsp/upd72020x-load/upd72020x-load_git.bb" + ], + "reason": "The immutable baseline has two stale U-Boot patch contexts, mishandles intentionally empty board-scripts packages, carries three product DTS files that no longer compile against the pinned kernel, and does not expose the Phasora loader's custom license text to SPDX generation. Build both sides with the focused eight-file backport while auditing the exact old-to-new submodule transition." + } + ], + "allowed_deltas": {} +} diff --git a/ci/layer-adoption-tuples.json b/ci/layer-adoption-tuples.json new file mode 100644 index 00000000..280468ab --- /dev/null +++ b/ci/layer-adoption-tuples.json @@ -0,0 +1,7 @@ +{ + "schema": 1, + "tuples": [ + {"id": "imx8mm-jaguar-screen-image", "machine": "imx8mm-jaguar-screen", "distro": "lmp-dynamicdevices", "image": "lmp-factory-image", "config": "kas/lmp-dynamicdevices.yml", "product_features": "display flutter godot"}, + {"id": "imx8mm-jaguar-screen-mfgtool", "machine": "imx8mm-jaguar-screen", "distro": "lmp-mfgtool", "image": "mfgtool-files", "config": "kas/lmp-dynamicdevices-mfgtool.yml", "product_features": ""} + ] +} diff --git a/docs/PRODUCT_TUPLE_LIFECYCLE.md b/docs/PRODUCT_TUPLE_LIFECYCLE.md new file mode 100644 index 00000000..cbb2a3f6 --- /dev/null +++ b/docs/PRODUCT_TUPLE_LIFECYCLE.md @@ -0,0 +1,27 @@ +# Product tuple lifecycle + +## Deprecated on 13 September 2026 + +The product owner has retired CI builds for these machine families: + +- `imx8mm-jaguar-inst` +- `imx8mm-jaguar-phasora` + +The retirement covers normal factory images and mfgtool/recovery images. For +Foundries CI it also covers the `main-jaguar-phasora`, +`main-jaguar-phasora-ext`, and `main-jaguar-inst` refs. + +These tuples are intentionally absent from `ci/layer-adoption-tuples.json` and +must not be treated as accidentally deleted regression coverage. Board source +may remain in the repository for history or possible future reactivation, but +reactivation requires an explicit product decision and restoration of both +image and recovery coverage. + +## Temporarily deferred CI coverage + +For the current R26 screen-board integration phase, the layer-adoption workflow +runs only the `imx8mm-jaguar-screen` factory-image and mfgtool/recovery tuples. +Other still-active product families are deferred to a later CI expansion; they +are not deprecated, and this focused run must not be cited as proof that the +full historical product matrix passed. Production-wide adoption remains gated +on restoring and passing that broader coverage. diff --git a/meta-dynamicdevices-bsp b/meta-dynamicdevices-bsp index 47542ad3..b926d4e9 160000 --- a/meta-dynamicdevices-bsp +++ b/meta-dynamicdevices-bsp @@ -1 +1 @@ -Subproject commit 47542ad3f8d49850df69e37a3414c1ef60c51809 +Subproject commit b926d4e96532fb95c83984b154d7b2f72d82471e diff --git a/scripts/README.md b/scripts/README.md index 2818734b..ed87194e 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -159,6 +159,20 @@ custom-boot-files/ # Custom boot files directory ## ๐Ÿ—๏ธ Build & Development +### Local/CI KAS parity + +Use the `scripts/kas-*.sh` entry points for local KAS work. They source +`scripts/kas-container-image.sh`, which pins the same container digest as the +Layer Adoption Gate. The gate treats changes to these wrappers, KAS YAML, +pinned layer submodules, its workflow, contract, and regression implementation +as material. Such changes must update `ci/layer-adoption-contract.json` and run +every immutable tuple in `ci/layer-adoption-tuples.json`; the tuple matrix may +be extended but existing coverage cannot be removed or redefined. + +The shared `scripts/validation/run-layer-adoption-regression.py` driver owns +repository preparation and all baseline/candidate captures in both local and +CI use. Do not duplicate KAS preparation steps in workflow YAML. + ### `kas-build-base.sh` **Purpose:** Builds the base LmP (Linux microPlatform) image using KAS configuration. diff --git a/scripts/kas-build-base-enhanced.sh b/scripts/kas-build-base-enhanced.sh index 211d1211..14cb1d7d 100755 --- a/scripts/kas-build-base-enhanced.sh +++ b/scripts/kas-build-base-enhanced.sh @@ -9,6 +9,8 @@ set -euo pipefail # Exit on error, undefined vars, pipe failures # Script configuration SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PROJECT_ROOT="$(dirname "$SCRIPT_DIR")" +# shellcheck source=kas-container-image.sh +. "$SCRIPT_DIR/kas-container-image.sh" DEFAULT_CACHE_DIR="${HOME}/yocto" LOG_FILE="${PROJECT_ROOT}/logs/kas-build-$(date +%Y%m%d-%H%M%S).log" diff --git a/scripts/kas-build-base.sh b/scripts/kas-build-base.sh index 2c7bf21f..1b32f40e 100755 --- a/scripts/kas-build-base.sh +++ b/scripts/kas-build-base.sh @@ -1,5 +1,9 @@ #!/bin/sh +script_dir=$(CDPATH='' cd -P "$(dirname "$0")" && pwd) +# shellcheck source=kas-container-image.sh +. "$script_dir/kas-container-image.sh" + # TODO: Look at this to fix missing key issue # #conf/machine/include/lmp-factory-custom.inc:OPTEE_TA_SIGN_KEY = "${TOPDIR}/conf/factory-keys/opteedev.key" diff --git a/scripts/kas-build-mfgtools.sh b/scripts/kas-build-mfgtools.sh index 09136b4d..cfbed8bb 100755 --- a/scripts/kas-build-mfgtools.sh +++ b/scripts/kas-build-mfgtools.sh @@ -1,3 +1,60 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:180de9e62039ccce8fd0ae8ffe96091f6b4f3fdac562054fad8101055000e998 -size 1435 +#!/bin/sh + +script_dir=$(CDPATH='' cd -P "$(dirname "$0")" && pwd) +# shellcheck source=kas-container-image.sh +. "$script_dir/kas-container-image.sh" + +# Build mfgtool images for supported i.MX machines +# +# Usage: +# KAS_MACHINE=imx95-frdm-evk ./scripts/kas-build-mfgtools.sh +# KAS_MACHINE=imx93-jaguar-eink ./scripts/kas-build-mfgtools.sh +# KAS_MACHINE=imx93-11x11-lpddr4x-evk ./scripts/kas-build-mfgtools.sh +# +# The machine-specific BSP selects the correct imx-boot manufacturing target. + +# Set default machine if not specified +if [ -z "$KAS_MACHINE" ]; then + export KAS_MACHINE="imx93-11x11-lpddr4x-evk" + echo "No KAS_MACHINE specified, defaulting to $KAS_MACHINE" +else + echo "Building mfgtool for machine: $KAS_MACHINE" +fi + +KAS_CONFIG="kas/lmp-dynamicdevices-mfgtool.yml" +if [ "$KAS_MACHINE" = "imx95-frdm-evk" ]; then + # Keep local validation aligned with the Foundries manifest revisions used + # for FRDM-i.MX95 production builds. + KAS_CONFIG="kas/lmp-imx95-frdm-evk-mfgtool.yml" +fi + +# TODO: Look at this to fix missing key issue if needed +# +#conf/machine/include/lmp-factory-custom.inc:OPTEE_TA_SIGN_KEY = "${TOPDIR}/conf/factory-keys/opteedev.key" +#lmp-tools/scripts/rotate_ci_keys.sh:openssl genpkey -algorithm RSA -out factory-keys/opteedev.key \ +#lmp-tools/scripts/rotate_ci_keys.sh:openssl req -batch -new -x509 -key factory-keys/opteedev.key -out factory-keys/opteedev.crt + +if [ ! -d ~/yocto ] +then + mkdir -p ~/yocto + mkdir -p ~/yocto/downloads + mkdir -p ~/yocto/persistent + mkdir -p ~/yocto/sstate + chmod 755 ~/yocto + chmod 755 ~/yocto/downloads + chmod 755 ~/yocto/persistent + chmod 755 ~/yocto/sstate +fi + +# Pass KAS_MACHINE to kas-container to override the machine in the config file. +# Forward SSH credentials only when they exist; the standard layer URLs are HTTPS, +# so unattended builders such as ai-tools do not require an SSH agent. +set -- --runtime-args "-v ${HOME}/yocto:/var/cache -e KAS_MACHINE=$KAS_MACHINE" +if [ -n "${SSH_AUTH_SOCK:-}" ] && [ -S "${SSH_AUTH_SOCK}" ]; then + set -- --ssh-agent "$@" +fi +if [ -d "${HOME}/.ssh" ]; then + set -- --ssh-dir "${HOME}/.ssh" "$@" +fi + +kas-container "$@" build "$KAS_CONFIG" diff --git a/scripts/kas-build-profiling.sh b/scripts/kas-build-profiling.sh index 48cece16..35344070 100755 --- a/scripts/kas-build-profiling.sh +++ b/scripts/kas-build-profiling.sh @@ -7,6 +7,8 @@ set -e SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PROJECT_ROOT="$(dirname "$SCRIPT_DIR")" +# shellcheck source=kas-container-image.sh +. "$SCRIPT_DIR/kas-container-image.sh" # Default values DEFAULT_MACHINE="imx93-jaguar-eink" diff --git a/scripts/kas-container-image.sh b/scripts/kas-container-image.sh new file mode 100644 index 00000000..e23e578a --- /dev/null +++ b/scripts/kas-container-image.sh @@ -0,0 +1,6 @@ +#!/bin/sh + +# Keep local KAS wrappers on the exact container image exercised by the +# layer-adoption gate. Update the workflow and its parity test with this pin. +KAS_CONTAINER_IMAGE="ghcr.io/siemens/kas/kas@sha256:d989add57fc441fe9e27bb2dd6ed98c5597b44c807928e35a72dc1cfbdda9abe" +export KAS_CONTAINER_IMAGE diff --git a/scripts/kas-dev-boot.sh b/scripts/kas-dev-boot.sh index 89ea7b77..a50d2c7d 100755 --- a/scripts/kas-dev-boot.sh +++ b/scripts/kas-dev-boot.sh @@ -10,6 +10,8 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PROJECT_ROOT="$(dirname "${SCRIPT_DIR}")" +# shellcheck source=kas-container-image.sh +. "$SCRIPT_DIR/kas-container-image.sh" # Default values ACTION="" diff --git a/scripts/kas-dev-kernel.sh b/scripts/kas-dev-kernel.sh index 9c52e2b9..1ac2aa6f 100755 --- a/scripts/kas-dev-kernel.sh +++ b/scripts/kas-dev-kernel.sh @@ -10,6 +10,8 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PROJECT_ROOT="$(dirname "${SCRIPT_DIR}")" +# shellcheck source=kas-container-image.sh +. "$SCRIPT_DIR/kas-container-image.sh" # Default values ACTION="" diff --git a/scripts/kas-dev-recipe.sh b/scripts/kas-dev-recipe.sh index c27548ee..3ef0b451 100755 --- a/scripts/kas-dev-recipe.sh +++ b/scripts/kas-dev-recipe.sh @@ -10,6 +10,8 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PROJECT_ROOT="$(dirname "${SCRIPT_DIR}")" +# shellcheck source=kas-container-image.sh +. "$SCRIPT_DIR/kas-container-image.sh" # Default values ACTION="" diff --git a/scripts/kas-shell-base.sh b/scripts/kas-shell-base.sh index 56ff4178..b6be08a6 100755 --- a/scripts/kas-shell-base.sh +++ b/scripts/kas-shell-base.sh @@ -1,5 +1,9 @@ #!/bin/sh +script_dir=$(CDPATH='' cd -P "$(dirname "$0")" && pwd) +# shellcheck source=kas-container-image.sh +. "$script_dir/kas-container-image.sh" + # KAS Shell Base Script # Usage: ./kas-shell-base.sh [options] # -c "command" : Execute command in kas environment diff --git a/scripts/monitor-foundries-build.sh b/scripts/monitor-foundries-build.sh index 42379356..2a609d54 100755 --- a/scripts/monitor-foundries-build.sh +++ b/scripts/monitor-foundries-build.sh @@ -5,7 +5,7 @@ set -e TARGET=${1:-2027} -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +POLL_INTERVAL=${FOUNDRIES_POLL_INTERVAL:-30} # Extract OAuth token from fioctl config FIOCTL_CONFIG="$HOME/.config/fioctl.yaml" @@ -43,14 +43,14 @@ show_build_info() { echo "$build_data" | jq -r '.data.build | " Build ID: \(.build_id // "N/A") Status: \(.status // "UNKNOWN") - Created: \(.created_at // "N/A") - Updated: \(.updated_at // "N/A")"' + Created: \(.created // "N/A") + Updated: \((.status_events // [] | last | .time) // "N/A")"' # Show runs if available - if echo "$build_data" | jq -e '.data.runs[]?' >/dev/null 2>&1; then + if echo "$build_data" | jq -e '.data.build.runs[]?' >/dev/null 2>&1; then echo "" echo "๐Ÿƒ Build Runs:" - echo "$build_data" | jq -r '.data.runs[] | " \(.name): \(.status)"' + echo "$build_data" | jq -r '.data.build.runs[] | " \(.name): \(.status)"' else echo "" echo "๐Ÿƒ Build Runs: Not started yet" @@ -64,10 +64,13 @@ echo "โฑ๏ธ Starting monitoring (Ctrl+C to stop)..." echo "" while true; do - BUILD_DATA=$(get_build_status) - - if [ $? -eq 0 ]; then - clear + if BUILD_DATA=$(get_build_status); then + # `clear` fails when there is no interactive TERM (for example when + # this monitor is run by CI or an agent). Display refresh is cosmetic + # and must never terminate authoritative build monitoring. + if [ -t 1 ] && [ -n "${TERM:-}" ]; then + clear || true + fi echo "๐Ÿ” Monitoring Foundries.io Build $TARGET - $(date)" echo "========================================" echo "" @@ -83,10 +86,10 @@ while true; do break fi - echo "๐Ÿ”„ Refreshing in 30 seconds..." + echo "๐Ÿ”„ Refreshing in ${POLL_INTERVAL} seconds..." else echo "โŒ Failed to get build status" fi - sleep 30 + sleep "$POLL_INTERVAL" done diff --git a/scripts/validation/canonicalise-bitbake-layer-output.py b/scripts/validation/canonicalise-bitbake-layer-output.py new file mode 100644 index 00000000..e59546ad --- /dev/null +++ b/scripts/validation/canonicalise-bitbake-layer-output.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python3 +"""Canonicalise bitbake-layers reports without discarding policy evidence.""" + +from __future__ import annotations + +import argparse +import re +import sys + + +VOLATILE_PREFIXES = ("Loaded ", "Parsing of ") + + +def canonicalise_feature_sets(line: str) -> str: + def replace(match: re.Match[str]) -> str: + words = match.group(1).split() + if len(words) > 1 and all(re.fullmatch(r"[A-Za-z0-9+_.-]+", word) for word in words): + return "'" + " ".join(sorted(words)) + "'" + return match.group(0) + + return re.sub(r"'([^']+)'", replace, line) + + +def canonicalise_blocks(lines: list[str]) -> list[str]: + result: list[str] = [] + heading = "" + for raw in lines: + line = raw.rstrip() + stripped = line.strip() + if not stripped or stripped.startswith(VOLATILE_PREFIXES): + continue + if not line[:1].isspace() and stripped.endswith(":"): + heading = stripped[:-1] + result.append(f"entry\t{heading}") + elif line[:1].isspace() and heading: + result.append(f"value\t{heading}\t{canonicalise_feature_sets(stripped)}") + else: + heading = "" + result.append(f"message\t{canonicalise_feature_sets(stripped)}") + return sorted(result) + + +def canonicalise_layers(lines: list[str]) -> list[str]: + result: list[str] = [] + for raw in lines: + fields = raw.split() + if not fields or fields[:3] == ["layer", "path", "priority"]: + continue + if len(fields) == 3 and fields[2].lstrip("-").isdigit(): + result.append("\t".join(("layer", *fields))) + else: + result.append("message\t" + canonicalise_feature_sets(raw.strip())) + return sorted(result) + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("mode", choices=("layers", "appends", "recipes")) + args = parser.parse_args() + lines = sys.stdin.read().splitlines() + output = canonicalise_layers(lines) if args.mode == "layers" else canonicalise_blocks(lines) + if output: + print("\n".join(output)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/validation/capture-layer-state.sh b/scripts/validation/capture-layer-state.sh new file mode 100755 index 00000000..28c101f1 --- /dev/null +++ b/scripts/validation/capture-layer-state.sh @@ -0,0 +1,272 @@ +#!/usr/bin/env bash +# Build one protected tuple and capture deterministic layer/package/task state. +set -euo pipefail + +if [ "$#" -ne 6 ]; then + echo "Usage: $0 KAS_CONFIG MACHINE DISTRO TARGET PRODUCT_FEATURES OUTPUT_DIR" >&2 + exit 2 +fi + +config=$1 +machine=$2 +distro=$3 +target=$4 +product_features=$5 +output_dir=$6 +test_keys_dir=${LAYER_ADOPTION_TEST_KEYS_DIR:-} +cache_root=${LAYER_ADOPTION_CACHE_DIR:-$HOME/yocto} + +if [ -z "$test_keys_dir" ] || [ ! -d "$test_keys_dir" ]; then + echo "ERROR: LAYER_ADOPTION_TEST_KEYS_DIR must name the generated test-key directory" >&2 + exit 2 +fi +test_keys_dir=$(realpath "$test_keys_dir") +mkdir -p "$cache_root/downloads" "$cache_root/sstate-cache" +cache_root=$(realpath "$cache_root") +for key in \ + ubootdev.key ubootdev.crt spldev.key spldev.crt \ + privkey_modsign.pem x509_modsign.crt \ + uefi/DB.key uefi/DB.crt tf-a/privkey_ec_prime256v1.pem +do + if [ ! -s "$test_keys_dir/$key" ]; then + echo "ERROR: required test signing key is missing: $key" >&2 + exit 2 + fi +done + +case "$output_dir" in + /*) ;; + *) output_dir="$PWD/$output_dir" ;; +esac +mkdir -p "$output_dir" + +export KAS_MACHINE="$machine" +export KAS_DISTRO="$distro" +export DISTRO="$distro" + +# KAS deliberately sanitises the environment before entering BitBake's build +# environment, so an exported DD_PRODUCT_FEATURES is silently lost. Inject the +# reviewed tuple value through a generated KAS overlay instead. JSON string +# quoting is also valid BitBake quoting and prevents tuple text from becoming +# local.conf syntax. +product_features_quoted=$(python3 -c 'import json, sys; print(json.dumps(sys.argv[1]))' "$product_features") +downloads_quoted=$(python3 -c 'import json, sys; print(json.dumps(sys.argv[1]))' "$cache_root/downloads") +sstate_quoted=$(python3 -c 'import json, sys; print(json.dumps(sys.argv[1]))' "$cache_root/sstate-cache") +repository_root=$(git rev-parse --show-toplevel) +overlay_dir="$repository_root/.layer-adoption-overlays" +mkdir -p "$overlay_dir" +overlay=$(mktemp "$overlay_dir/product-features.XXXXXX.yml") +cleanup_overlay() { + rm -f "$overlay" + rmdir "$overlay_dir" 2>/dev/null || true +} +trap cleanup_overlay EXIT +cat > "$overlay" < "$output_dir/metadata.json" </dev/null \ + | sha256sum | cut -d ' ' -f 1) + printf '%s\t%s\n' "$key" "$fingerprint" +done > "$output_dir/test-signing-key-fingerprints.txt" +printf '%s\n' \ + "kas checkout CONFIG:PRODUCT_FEATURE_OVERLAY" \ + "bitbake-layers show-layers" \ + "bitbake-layers show-appends" \ + "bitbake-layers show-recipes" \ + "bitbake -g $target" \ + "bitbake -e $target" \ + "bitbake $target" \ + "DD_PRODUCT_FEATURES=$product_features" > "$output_dir/commands.txt" + +# Static layer surfaces are captured as well as BitBake's resolved view. This +# makes wildcard/dangling appends and global layer.conf policy visible even +# when they do not happen to alter the first recipe selected by BitBake. +find build/layers "$repository_root" \ + -path "$repository_root/build" -prune -o \ + -path "$repository_root/.git" -prune -o \ + -type f \( -path '*/conf/layer.conf' -o -name '*.bbclass' \) -print0 \ + | sort -zu \ + | xargs -0 grep -nHE \ + '(^|[[:space:]])(IMAGE_INSTALL|CORE_IMAGE_|PACKAGE_INSTALL|DISTRO_FEATURES|MACHINE_FEATURES|PACKAGECONFIG|PREFERRED_(VERSION|PROVIDER)|DEFAULT_PREFERENCE|RDEPENDS|INHERIT|BBMASK|BBPATH|BBFILES|BBFILE_PRIORITY|INITRAMFS_MAXSIZE|IMAGE_FSTYPES|WKS_FILE|UBOOT_|KERNEL_|OPTEE_|SDKIMAGE_FEATURES|TOOLCHAIN_TARGET_TASK)(:|\[|[[:space:]])*([+?:.]?=)' \ + | sed -E "s#^$repository_root/#meta-dynamicdevices/#" \ + > "$output_dir/layer-conf-policy.txt" || true +find build/layers "$repository_root" \ + -path "$repository_root/build" -prune -o \ + -path "$repository_root/.git" -prune -o \ + -type f -name '*.bbappend' -printf '%p\n' \ + | sed -E -e 's#^build/layers/##' \ + -e "s#^$repository_root/#meta-dynamicdevices/#" \ + | sort -u > "$output_dir/all-bbappends.txt" + +run_bitbake() { + kas shell "$combined_config" -c "$1" +} + +capture_command() { + local name=$1 + shift + local log="$output_dir/$name.log" + if "$@" 2>&1 | tee "$log"; then + return 0 + else + local statuses=("${PIPESTATUS[@]}") + echo "ERROR: command failed while capturing $name" >&2 + cat "$log" >&2 + return "${statuses[0]}" + fi +} + +normalise_kas_projection() { + sed -E \ + -e '/^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2} - (DEBUG|INFO|WARNING|ERROR)[[:space:]]+- /d' \ + -e '/WARNING:/d' \ + -e '/^Summary: There were [0-9]+ WARNING messages?\.?$/d' \ + -e '/^NOTE: Starting bitbake server\.\.\.$/d' \ + -e 's#(/[^/[:space:]]+)*/(baseline|candidate)(/|$)#\3#g' \ + -e 's/_(baseline|candidate)_build_layers_/_REPO_build_layers_/g' \ + -e "s#$PWD##g" \ + -e 's#[[:space:]]+$##' +} + +capture_command show-layers run_bitbake "bitbake-layers show-layers" \ + | normalise_kas_projection \ + | python3 "$(dirname "$0")/canonicalise-bitbake-layer-output.py" layers \ + > "$output_dir/layers.txt" +capture_command show-appends run_bitbake "bitbake-layers show-appends" \ + | normalise_kas_projection \ + | python3 "$(dirname "$0")/canonicalise-bitbake-layer-output.py" appends \ + > "$output_dir/appends.txt" +capture_command show-recipes run_bitbake "bitbake-layers show-recipes" \ + | normalise_kas_projection \ + | python3 "$(dirname "$0")/canonicalise-bitbake-layer-output.py" recipes \ + > "$output_dir/recipes.txt" + +capture_command graph run_bitbake "bitbake -g $target" +sort -u build/pn-buildlist > "$output_dir/pn-buildlist.txt" +sed -E "s#$PWD/##g" build/task-depends.dot | sort -u \ + > "$output_dir/task-depends.dot" +# Current BitBake deliberately removes the obsolete recipe-depends.dot output. +# pn-buildlist plus the finer-grained task graph retain provider and dependency +# selection coverage without relying on that removed compatibility artifact. + +# Capture final values and BitBake's assignment provenance for policy that a +# newly enabled layer can silently change. The full environment is retained +# temporarily only as input, avoiding volatile host variables in comparisons. +# The complete environment is intentionally retained in evidence but is too +# large for routine CI output. capture_command still replays it to stderr if +# BitBake fails, so diagnostics are not lost. +capture_command environment run_bitbake "bitbake -e $target" >/dev/null +python3 "$(dirname "$0")/select-bitbake-env.py" \ + "$output_dir/environment.log" \ + | sed -E \ + -e 's#(/[^/[:space:]]+)*/(baseline|candidate)(/|$)#\3#g' \ + -e 's/_(baseline|candidate)_build_layers_/_REPO_build_layers_/g' \ + -e "s#$PWD##g" \ + -e "s#$test_keys_dir##g" \ + -e "s#$cache_root##g" \ + -e 's/[0-9]{14}/TIMESTAMP/g' \ + > "$output_dir/selected-environment.txt" +require_selected_value() { + local name=$1 + local expected=$2 + if ! grep -Fqx "$name=\"$expected\"" "$output_dir/selected-environment.txt"; then + echo "ERROR: selected BitBake environment does not contain $name=\"$expected\"" >&2 + grep -E "^${name}=" "$output_dir/selected-environment.txt" >&2 || \ + echo "ERROR: $name is absent from selected BitBake environment" >&2 + return 1 + fi +} +require_selected_value MACHINE "$machine" +require_selected_value DISTRO "$distro" +require_selected_value DD_PRODUCT_FEATURES "$product_features" +require_selected_value MODSIGN_PRIVKEY "/privkey_modsign.pem" +require_selected_value MODSIGN_X509 "/x509_modsign.crt" +require_selected_value UBOOT_SIGN_KEYDIR "" +require_selected_value UBOOT_SPL_SIGN_KEYDIR "" +require_selected_value UEFI_SIGN_KEYDIR "/uefi" +require_selected_value OPTEE_TA_SIGN_KEY "/ubootdev.key" +require_selected_value TF_A_SIGN_KEY_PATH "/tf-a/privkey_ec_prime256v1.pem" +rm "$output_dir/environment.log" + +# A parse-only graph is not proof that packaging, signing, recovery image size, +# or deploy layout still works. Complete the real image/recovery build for both +# baseline and candidate. +capture_command build run_bitbake "bitbake $target" + +# Task warnings must not depend on whether shared sstate caused the task to run +# during this particular image build. Force the kernel's warning-producing +# configuration check on both sides before collecting cooker diagnostics. +capture_command kernel-configcheck run_bitbake \ + "bitbake -f -c kernel_configcheck virtual/kernel" >/dev/null + +deploy_dir="build/tmp/deploy/images/$machine" +if [ ! -d "$deploy_dir" ]; then + echo "ERROR: deploy directory missing after successful build: $deploy_dir" >&2 + exit 1 +fi + +find "$deploy_dir" -maxdepth 1 -type f -printf '%f\t%s\n' \ + | sed -E 's/-[0-9]{14}(\.|-|$)/-TIMESTAMP\1/g' \ + | sort -u > "$output_dir/deploy-layout-and-sizes.txt" +# The expression belongs to awk; shell expansion would be a bug. +# shellcheck disable=SC2016 +find "$deploy_dir" -maxdepth 1 -type f -name '*.manifest' -print0 \ + | sort -z \ + | xargs -0 -r awk '{print $1}' \ + | sort -u > "$output_dir/packages.txt" + +# New warnings are regressions even when BitBake returns zero. +# Cooker logs preserve one BitBake warning per line. Combined stdout/stderr +# command logs can splice concurrent parser warnings together and are not a +# deterministic warning source. +find build/tmp/log/cooker -type f -name '*.log' -print0 \ + | xargs -0 -r grep -hE '(^|[[:space:]])WARNING:' \ + | sed -E \ + -e 's/^.*WARNING:/WARNING:/' \ + -e 's#(/[^/[:space:]]+)*/(baseline|candidate)(/|$)#\3#g' \ + -e "s#$PWD##g" \ + -e 's/[0-9]{4}-[0-9]{2}-[0-9]{2}[^ ]*//g' \ + | sort -u > "$output_dir/warnings.txt" || true + +# Raw command logs are useful for diagnosis but contain progress ordering and +# timing noise. The deterministic projections and cooker warnings above are +# the comparison input. +rm -f "$output_dir"/*.log +# The generated overlay is removed by the EXIT trap. Keeping it inside the +# worktree satisfies KAS's same-repository rule for concatenated configs. diff --git a/scripts/validation/compare-layer-state.py b/scripts/validation/compare-layer-state.py new file mode 100755 index 00000000..a28fec01 --- /dev/null +++ b/scripts/validation/compare-layer-state.py @@ -0,0 +1,125 @@ +#!/usr/bin/env python3 +"""Fail on unexplained baseline/candidate build-state differences.""" + +from __future__ import annotations + +import argparse +import difflib +import json +import re +import sys +from pathlib import Path + +DEPLOY_SIZES = "deploy-layout-and-sizes.txt" +WARNINGS = "warnings.txt" +MAX_DEPLOY_SIZE_DRIFT_RATIO = 0.005 +MIN_DEPLOY_SIZE_DRIFT_BYTES = 4096 + + +def deploy_entries(lines: list[str]) -> dict[str, int]: + entries: dict[str, int] = {} + for line in lines: + try: + name, raw_size = line.rsplit("\t", 1) + size = int(raw_size) + except ValueError as exc: + raise ValueError(f"invalid deploy entry: {line!r}") from exc + if name in entries: + raise ValueError(f"duplicate deploy entry: {name}") + entries[name] = size + return entries + + +def deploy_deltas(old_lines: list[str], new_lines: list[str]) -> list[str]: + """Compare deploy layout exactly and sizes within reproducible-build noise.""" + old = deploy_entries(old_lines) + new = deploy_entries(new_lines) + deltas = [f"removed {name}" for name in sorted(set(old) - set(new))] + deltas.extend(f"added {name}" for name in sorted(set(new) - set(old))) + for name in sorted(set(old) & set(new)): + tolerance = max( + MIN_DEPLOY_SIZE_DRIFT_BYTES, + int(old[name] * MAX_DEPLOY_SIZE_DRIFT_RATIO), + ) + if abs(new[name] - old[name]) > tolerance: + deltas.append( + f"size {name}: {old[name]} -> {new[name]} " + f"(tolerance {tolerance})" + ) + return deltas + + +def warning_deltas(old_lines: list[str], new_lines: list[str]) -> list[str]: + """Return candidate-added warnings; removing a baseline warning is safe.""" + return sorted(set(new_lines) - set(old_lines)) + + +def files(root: Path) -> dict[str, list[str]]: + result: dict[str, list[str]] = {} + for path in sorted(root.rglob("*")): + if path.is_file() and path.name != "metadata.json": + result[str(path.relative_to(root))] = path.read_text(errors="replace").splitlines() + return result + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--baseline", required=True, type=Path) + parser.add_argument("--candidate", required=True, type=Path) + parser.add_argument("--tuple", required=True) + parser.add_argument("--contract", required=True, type=Path) + args = parser.parse_args() + + contract = json.loads(args.contract.read_text()) + rules = contract.get("allowed_deltas", {}).get(args.tuple, []) + compiled: list[tuple[re.Pattern[str], re.Pattern[str], str]] = [] + for rule in rules: + try: + compiled.append(( + re.compile(rule["file"]), + re.compile(rule["pattern"]), + str(rule["reason"]).strip(), + )) + except (KeyError, re.error) as exc: + print(f"ERROR: invalid allow rule for {args.tuple}: {exc}", file=sys.stderr) + return 2 + if any(not reason for _, _, reason in compiled): + print("ERROR: every allowed delta needs a reason", file=sys.stderr) + return 2 + + old, new = files(args.baseline), files(args.candidate) + unexplained: list[str] = [] + for name in sorted(set(old) | set(new)): + if old.get(name) == new.get(name): + continue + if name == DEPLOY_SIZES: + try: + changed_lines = deploy_deltas(old.get(name, []), new.get(name, [])) + except ValueError as exc: + print(f"ERROR: {name}: {exc}", file=sys.stderr) + return 2 + elif name == WARNINGS: + changed_lines = warning_deltas(old.get(name, []), new.get(name, [])) + else: + delta = list(difflib.unified_diff(old.get(name, []), new.get(name, []), lineterm="")) + changed_lines = [ + line[1:] for line in delta + if line.startswith(("+", "-")) and not line.startswith(("+++", "---")) + ] + for line in changed_lines: + if not any(file_re.search(name) and line_re.search(line) for file_re, line_re, _ in compiled): + unexplained.append(f"{name}: {line}") + + if unexplained: + print(f"ERROR: unexplained build deltas for {args.tuple}:", file=sys.stderr) + for line in unexplained[:250]: + print(f" {line}", file=sys.stderr) + if len(unexplained) > 250: + print(f" ... {len(unexplained) - 250} more", file=sys.stderr) + return 1 + print(f"PASS: {args.tuple} has no unexplained layer-adoption delta") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/validation/detect-layer-adoption.py b/scripts/validation/detect-layer-adoption.py new file mode 100755 index 00000000..a4e34cd2 --- /dev/null +++ b/scripts/validation/detect-layer-adoption.py @@ -0,0 +1,169 @@ +#!/usr/bin/env python3 +"""Detect layer topology/pin changes and enforce an explicit adoption contract.""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import subprocess +import sys +from pathlib import Path + + +MATERIAL_PATHS = ( + re.compile(r"^\.github/workflows/layer-adoption-gate\.yml$"), + re.compile(r"^\.gitattributes$"), + re.compile(r"^\.gitmodules$"), + re.compile(r"^ci/layer-adoption-contract\.json$"), + re.compile(r"(^|/)conf/layer\.conf$"), + re.compile(r"^kas/.*\.ya?ml$"), + re.compile(r"^meta-dynamicdevices-(?:bsp|distro)$"), + re.compile(r"^meta-partner-nxp-imx$"), + re.compile(r"^scripts/kas-.*\.sh$"), + re.compile( + r"^scripts/validation/(?:capture-layer-state\.sh|compare-layer-state\.py|" + r"detect-layer-adoption\.py|generate-layer-adoption-test-keys\.sh|" + r"run-layer-adoption-regression\.py)$" + ), + re.compile(r"^ci/layer-adoption-tuples\.json$"), +) + +TUPLE_FIELDS = ("id", "machine", "distro", "image", "config", "product_features") +NONEMPTY_TUPLE_FIELDS = ("id", "machine", "distro", "image", "config") + + +def is_material_path(path: str) -> bool: + """Return whether a change can alter local or CI KAS build semantics.""" + return any(pattern.search(path) for pattern in MATERIAL_PATHS) + + +def git(*args: str) -> str: + return subprocess.check_output(["git", *args], text=True) + + +def parse_tuples(raw: str, source: str) -> dict[str, dict[str, str]]: + try: + document = json.loads(raw) + except json.JSONDecodeError as exc: + raise ValueError(f"{source}: invalid JSON: {exc}") from exc + if document.get("schema") != 1 or not isinstance(document.get("tuples"), list): + raise ValueError(f"{source}: expected schema=1 and a tuples array") + + result: dict[str, dict[str, str]] = {} + for index, entry in enumerate(document["tuples"]): + if not isinstance(entry, dict): + raise ValueError(f"{source}: tuple {index} is not an object") + missing = [field for field in TUPLE_FIELDS if field not in entry] + missing.extend( + field for field in NONEMPTY_TUPLE_FIELDS + if field in entry and not str(entry[field]).strip() + ) + if missing: + raise ValueError(f"{source}: tuple {index} lacks {', '.join(missing)}") + tuple_id = str(entry["id"]) + if tuple_id in result: + raise ValueError(f"{source}: duplicate tuple id {tuple_id}") + result[tuple_id] = {field: str(entry[field]) for field in TUPLE_FIELDS} + if not result: + raise ValueError(f"{source}: tuple matrix must not be empty") + return result + + +def validate_tuple_matrix(base: str, path: Path) -> None: + candidate = parse_tuples(path.read_text(encoding="utf-8"), str(path)) + missing_configs = sorted( + tuple_id for tuple_id, entry in candidate.items() + if not Path(entry["config"]).is_file() + ) + if missing_configs: + raise ValueError( + f"{path}: tuple configs do not exist for: {', '.join(missing_configs)}" + ) + + baseline_result = subprocess.run( + ["git", "show", f"{base}:{path.as_posix()}"], + check=False, + capture_output=True, + text=True, + ) + if baseline_result.returncode != 0: + # Bootstrap case: the gate and its matrix are being introduced together. + return + baseline_raw = baseline_result.stdout + baseline = parse_tuples(baseline_raw, f"{base}:{path}") + removed = sorted(set(baseline) - set(candidate)) + changed = sorted( + tuple_id for tuple_id in set(baseline) & set(candidate) + if baseline[tuple_id] != candidate[tuple_id] + ) + if removed or changed: + details = [] + if removed: + details.append("removed=" + ",".join(removed)) + if changed: + details.append("redefined=" + ",".join(changed)) + raise ValueError( + "protected baseline tuples may only be extended, not removed or redefined (" + + "; ".join(details) + ")" + ) + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--base", required=True) + parser.add_argument("--head", default="HEAD") + parser.add_argument("--contract", default="ci/layer-adoption-contract.json") + parser.add_argument("--tuples", default="ci/layer-adoption-tuples.json") + parser.add_argument("--github-output") + args = parser.parse_args() + + try: + validate_tuple_matrix(args.base, Path(args.tuples)) + except (OSError, ValueError) as exc: + print(f"ERROR: invalid protected tuple matrix: {exc}", file=sys.stderr) + return 2 + + changed = git("diff", "--name-only", f"{args.base}...{args.head}").splitlines() + material_files = [path for path in changed if is_material_path(path)] + # Treat every KAS change as material. YAML context makes line-only pin + # detection easy to evade (for example by adding a list item below an + # existing `includes:` key), and a false-positive build is safer than a + # layer adoption escaping the hard gate. + material = bool(material_files) + + if material: + if args.contract not in changed: + print( + f"ERROR: material Yocto layer change requires {args.contract} " + "to be updated in the same change", + file=sys.stderr, + ) + return 2 + try: + contract = json.loads(Path(args.contract).read_text()) + except (OSError, json.JSONDecodeError) as exc: + print(f"ERROR: invalid adoption contract: {exc}", file=sys.stderr) + return 2 + if contract.get("schema") != 1 or not str(contract.get("reason", "")).strip(): + print("ERROR: contract needs schema=1 and a non-empty reason", file=sys.stderr) + return 2 + if not isinstance(contract.get("allowed_deltas"), dict): + print("ERROR: allowed_deltas must be an object", file=sys.stderr) + return 2 + + result = "true" if material else "false" + print(f"material={result}") + if material_files: + print("material candidates:") + for path in material_files: + print(f" {path}") + if args.github_output: + with open(args.github_output, "a", encoding="utf-8") as output: + output.write(f"material={result}\n") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/validation/generate-layer-adoption-test-keys.sh b/scripts/validation/generate-layer-adoption-test-keys.sh new file mode 100755 index 00000000..77a38a3e --- /dev/null +++ b/scripts/validation/generate-layer-adoption-test-keys.sh @@ -0,0 +1,94 @@ +#!/usr/bin/env bash +# Generate or validate one genuine, test-only signing identity shared by builds. +set -euo pipefail +umask 077 + +if [ "$#" -ne 1 ] && { [ "$#" -ne 2 ] || [ "$1" != "--check" ]; }; then + echo "Usage: $0 [--check] OUTPUT_DIR" >&2 + exit 2 +fi + +if [ "$#" -eq 2 ]; then + check_only=true + output_dir=$(realpath -m "$2") +else + check_only=false + output_dir=$(realpath -m "$1") +fi +case "$output_dir" in + /|/home|/root|/tmp|/var|/usr) + echo "ERROR: refusing broad test-key output directory: $output_dir" >&2 + exit 2 + ;; +esac +if [ "$check_only" = false ] && [ -e "$output_dir" ] && \ + find "$output_dir" -mindepth 1 -print -quit | grep -q . +then + echo "ERROR: test-key output directory is not empty: $output_dir" >&2 + exit 2 +fi + +validate_pair() { + local key=$1 + local certificate=$2 + local key_fingerprint certificate_fingerprint + openssl pkey -in "$key" -check -noout >/dev/null 2>&1 + # Never let a persistent CI identity expire during a long gate run. + openssl x509 -in "$certificate" -noout -checkend 604800 >/dev/null 2>&1 + key_fingerprint=$(openssl pkey -in "$key" -pubout -outform DER 2>/dev/null \ + | sha256sum | cut -d ' ' -f 1) + certificate_fingerprint=$(openssl x509 -in "$certificate" -pubkey -noout 2>/dev/null \ + | openssl pkey -pubin -outform DER 2>/dev/null \ + | sha256sum | cut -d ' ' -f 1) + [ "$key_fingerprint" = "$certificate_fingerprint" ] +} + +validate_keyset() { + validate_pair "$output_dir/ubootdev.key" "$output_dir/ubootdev.crt" && + validate_pair "$output_dir/spldev.key" "$output_dir/spldev.crt" && + validate_pair "$output_dir/privkey_modsign.pem" "$output_dir/x509_modsign.crt" && + validate_pair "$output_dir/uefi/DB.key" "$output_dir/uefi/DB.crt" && + openssl ec -in "$output_dir/tf-a/privkey_ec_prime256v1.pem" \ + -check -noout >/dev/null 2>&1 +} + +if [ "$check_only" = true ]; then + if validate_keyset; then + printf 'PASS: validated test-only layer-adoption signing keys in %s\n' "$output_dir" + exit 0 + fi + echo "ERROR: test-only layer-adoption signing keys are invalid or expire within seven days" >&2 + exit 1 +fi + +mkdir -p "$output_dir/uefi" "$output_dir/tf-a" + +make_rsa_certificate() { + local key=$1 + local certificate=$2 + local common_name=$3 + openssl genpkey -algorithm RSA -out "$key" -pkeyopt rsa_keygen_bits:2048 + openssl req -batch -new -x509 -sha256 -days 3650 \ + -key "$key" -out "$certificate" -subj "/CN=$common_name/" + openssl pkey -in "$key" -check -noout + openssl x509 -in "$certificate" -noout +} + +make_rsa_certificate \ + "$output_dir/ubootdev.key" "$output_dir/ubootdev.crt" \ + layer-adoption-uboot +make_rsa_certificate \ + "$output_dir/spldev.key" "$output_dir/spldev.crt" \ + layer-adoption-spl +make_rsa_certificate \ + "$output_dir/privkey_modsign.pem" "$output_dir/x509_modsign.crt" \ + layer-adoption-module +make_rsa_certificate \ + "$output_dir/uefi/DB.key" "$output_dir/uefi/DB.crt" \ + layer-adoption-uefi + +openssl ecparam -name prime256v1 -genkey -noout \ + -out "$output_dir/tf-a/privkey_ec_prime256v1.pem" +validate_keyset + +printf 'PASS: generated test-only layer-adoption signing keys in %s\n' "$output_dir" diff --git a/scripts/validation/run-layer-adoption-regression.py b/scripts/validation/run-layer-adoption-regression.py new file mode 100644 index 00000000..0ce953a4 --- /dev/null +++ b/scripts/validation/run-layer-adoption-regression.py @@ -0,0 +1,366 @@ +#!/usr/bin/env python3 +"""Run every protected Yocto tuple in one fail-closed regression job.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import re +import shutil +import subprocess +import tempfile +from pathlib import Path + + +MARKER = ".complete.json" +LFS_POINTER_PREFIX = b"version https://git-lfs.github.com/spec/v1\n" +FIELDS = ("id", "machine", "distro", "image", "config", "product_features") +PRODUCT_SUBMODULES = ("meta-dynamicdevices-bsp", "meta-dynamicdevices-distro") +CAPTURE_SCHEMA_FILES = ( + "ci/layer-adoption-contract.json", + "scripts/validation/run-layer-adoption-regression.py", + "scripts/validation/capture-layer-state.sh", + "scripts/validation/canonicalise-bitbake-layer-output.py", + "scripts/validation/select-bitbake-env.py", + "scripts/validation/generate-layer-adoption-test-keys.sh", +) + + +def submodule_commit(repository: Path, relative: str) -> str: + entry = git_output(repository, "ls-tree", "HEAD", "--", relative).split() + if len(entry) < 3 or entry[0] != "160000" or entry[1] != "commit": + raise RuntimeError(f"{repository}: {relative} is not a pinned git submodule") + return entry[2] + + +def evidence_digest(root: Path) -> str: + digest = hashlib.sha256() + for path in sorted(root.rglob("*")): + if not path.is_file() or path.name == MARKER: + continue + relative = path.relative_to(root).as_posix().encode() + digest.update(relative) + digest.update(b"\0") + digest.update(hashlib.sha256(path.read_bytes()).digest()) + return digest.hexdigest() + + +def capture_schema_digest(repository: Path) -> str: + digest = hashlib.sha256() + for relative in CAPTURE_SCHEMA_FILES: + digest.update(relative.encode()) + digest.update(b"\0") + digest.update(hashlib.sha256((repository / relative).read_bytes()).digest()) + return digest.hexdigest() + + +def valid_cached_evidence( + root: Path, base_sha: str, tuple_id: str, capture_schema: str +) -> bool: + try: + marker = json.loads((root / MARKER).read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return False + return marker == { + "base_sha": base_sha, + "tuple_id": tuple_id, + "capture_schema": capture_schema, + "evidence_sha256": evidence_digest(root), + } + + +def load_tuples(path: Path) -> list[dict[str, str]]: + document = json.loads(path.read_text(encoding="utf-8")) + if document.get("schema") != 1 or not isinstance(document.get("tuples"), list): + raise ValueError(f"{path}: expected schema=1 and a tuples array") + tuples = [] + seen = set() + for index, raw in enumerate(document["tuples"]): + if not isinstance(raw, dict) or any(field not in raw for field in FIELDS): + raise ValueError(f"{path}: tuple {index} is incomplete") + entry = {field: str(raw[field]) for field in FIELDS} + if not entry["id"] or entry["id"] in seen: + raise ValueError(f"{path}: duplicate or empty tuple id {entry['id']!r}") + seen.add(entry["id"]) + tuples.append(entry) + if not tuples: + raise ValueError(f"{path}: no protected tuples") + return tuples + + +def select_tuples( + tuples: list[dict[str, str]], tuple_id: str | None +) -> list[dict[str, str]]: + """Select one CI shard while keeping the local default as the full gate.""" + if tuple_id is None: + return tuples + selected = [entry for entry in tuples if entry["id"] == tuple_id] + if not selected: + raise ValueError(f"unknown protected tuple: {tuple_id}") + return selected + + +def materialize_config(repository: Path, relative: str) -> None: + """Resolve a KAS config stored in Git LFS before either build starts.""" + path = repository / relative + if not path.is_file(): + raise RuntimeError(f"{repository}: protected KAS config is missing: {relative}") + if not path.read_bytes().startswith(LFS_POINTER_PREFIX): + return + subprocess.run( + ["git", "lfs", "pull", f"--include={relative}", "--exclude="], + cwd=repository, + check=True, + ) + if path.read_bytes().startswith(LFS_POINTER_PREFIX): + raise RuntimeError( + f"{repository}: Git LFS did not materialize protected KAS config: {relative}" + ) + + +def remove_build_tree(repository: Path) -> None: + repository = repository.resolve() + build = (repository / "build").resolve() + if build.parent != repository or repository == Path("/"): + raise RuntimeError(f"refusing unsafe build cleanup: {build}") + shutil.rmtree(build, ignore_errors=True) + + +def git_output(repository: Path, *args: str) -> str: + return subprocess.check_output(["git", *args], cwd=repository, text=True).strip() + + +def prepare_repository(repository: Path) -> None: + """Initialize and verify the pinned local layers used by every KAS tuple.""" + for relative in PRODUCT_SUBMODULES: + expected = submodule_commit(repository, relative) + layer = repository / relative + layer_conf = layer / "conf/layer.conf" + if not layer_conf.is_file(): + subprocess.run( + [ + "git", + "-c", + "url.https://github.com/.insteadOf=git@github.com:", + "submodule", + "update", + "--init", + "--recursive", + "--", + relative, + ], + cwd=repository, + check=True, + ) + if not layer_conf.is_file(): + raise RuntimeError(f"{repository}: {relative}/conf/layer.conf is missing") + actual = git_output(layer, "rev-parse", "HEAD") + if actual != expected: + raise RuntimeError( + f"{repository}: {relative} is at {actual}, expected pinned {expected}" + ) + if git_output(layer, "status", "--porcelain", "--untracked-files=all"): + raise RuntimeError(f"{repository}: {relative} has uncommitted content") + + +def apply_baseline_repairs( + baseline: Path, candidate: Path, contract_path: Path, base_sha: str +) -> None: + """Apply an exact, audited repair to an otherwise unbuildable baseline.""" + contract = json.loads(contract_path.read_text(encoding="utf-8")) + repairs = contract.get("baseline_repairs", []) + if not isinstance(repairs, list): + raise ValueError("baseline_repairs must be an array") + for repair in repairs: + if not isinstance(repair, dict): + raise ValueError("baseline repair must be an object") + if repair.get("base_sha") != base_sha: + continue + required = {"submodule", "from", "to", "url", "ref", "files", "reason"} + if not required <= repair.keys(): + raise ValueError("baseline repair is incomplete") + relative = str(repair["submodule"]) + old = str(repair["from"]) + new = str(repair["to"]) + url = str(repair["url"]) + ref = str(repair["ref"]) + files = repair["files"] + reason = str(repair["reason"]).strip() + if relative not in PRODUCT_SUBMODULES: + raise ValueError(f"unsupported baseline repair submodule: {relative}") + if any(not re.fullmatch(r"[0-9a-f]{40}", commit) for commit in (old, new)): + raise ValueError("baseline repair pins must be full lowercase commit IDs") + if not url.startswith("https://github.com/DynamicDevices/"): + raise ValueError("baseline repair URL must use the DynamicDevices HTTPS origin") + if not ref.startswith("refs/heads/"): + raise ValueError("baseline repair ref must be an explicit branch") + if ( + not reason + or not isinstance(files, list) + or not files + or any( + not isinstance(path, str) + or Path(path).is_absolute() + or ".." in Path(path).parts + for path in files + ) + ): + raise ValueError("baseline repair needs a reason and exact file list") + if submodule_commit(baseline, relative) != old: + raise RuntimeError(f"baseline repair {relative}: unexpected source pin") + if submodule_commit(candidate, relative) != new: + raise RuntimeError(f"baseline repair {relative}: unexpected candidate pin") + + candidate_layer = candidate / relative + subprocess.run( + ["git", "merge-base", "--is-ancestor", old, new], + cwd=candidate_layer, + check=True, + ) + changed = git_output(candidate_layer, "diff", "--name-only", old, new).splitlines() + if sorted(changed) != sorted(str(path) for path in files): + raise RuntimeError( + f"baseline repair {relative}: changed files do not match contract" + ) + + baseline_layer = baseline / relative + subprocess.run(["git", "fetch", url, ref], cwd=baseline_layer, check=True) + fetched = git_output(baseline_layer, "rev-parse", "FETCH_HEAD") + if fetched != new: + raise RuntimeError(f"baseline repair {relative}: ref resolved to {fetched}") + subprocess.run( + ["git", "checkout", "--detach", new], cwd=baseline_layer, check=True + ) + if git_output(baseline_layer, "status", "--porcelain", "--untracked-files=all"): + raise RuntimeError(f"baseline repair {relative}: checkout is dirty") + print( + f"Applying audited baseline repair for {relative}: {old} -> {new}", + flush=True, + ) + + +def capture( + script: Path, + repository: Path, + entry: dict[str, str], + output: Path, + environment: dict[str, str], +) -> None: + subprocess.run( + [ + str(script), + entry["config"], + entry["machine"], + entry["distro"], + entry["image"], + entry["product_features"], + str(output), + ], + cwd=repository, + env=environment, + check=True, + ) + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--baseline", required=True, type=Path) + parser.add_argument("--candidate", required=True, type=Path) + parser.add_argument("--evidence", required=True, type=Path) + parser.add_argument("--cache", required=True, type=Path) + parser.add_argument("--test-keys", required=True, type=Path) + parser.add_argument("--tuple-id") + args = parser.parse_args() + + baseline = args.baseline.resolve() + candidate = args.candidate.resolve() + evidence = args.evidence.resolve() + cache = args.cache.resolve() + test_keys = args.test_keys.resolve() + capture_script = candidate / "scripts/validation/capture-layer-state.sh" + compare_script = candidate / "scripts/validation/compare-layer-state.py" + contract = candidate / "ci/layer-adoption-contract.json" + tuples = select_tuples( + load_tuples(candidate / "ci/layer-adoption-tuples.json"), args.tuple_id + ) + base_sha = subprocess.check_output( + ["git", "rev-parse", "HEAD"], cwd=baseline, text=True + ).strip() + capture_schema = capture_schema_digest(candidate) + + # This preparation is intentionally owned by the shared regression driver, + # not by CI YAML. Local and hosted runs therefore build the same pinned + # submodule content through the same KAS capture path. + prepare_repository(baseline) + prepare_repository(candidate) + apply_baseline_repairs(baseline, candidate, contract, base_sha) + for config in sorted({entry["config"] for entry in tuples}): + materialize_config(baseline, config) + materialize_config(candidate, config) + + environment = os.environ.copy() + environment["LAYER_ADOPTION_TEST_KEYS_DIR"] = str(test_keys) + environment["LAYER_ADOPTION_CACHE_DIR"] = str(cache / "yocto") + shutil.rmtree(evidence, ignore_errors=True) + (evidence / "baseline").mkdir(parents=True) + (evidence / "candidate").mkdir(parents=True) + + for entry in tuples: + tuple_id = entry["id"] + print(f"::group::Protect {tuple_id}", flush=True) + cached = cache / "baselines" / base_sha / tuple_id + baseline_output = evidence / "baseline" / tuple_id + candidate_output = evidence / "candidate" / tuple_id + temporary: Path | None = None + try: + if valid_cached_evidence(cached, base_sha, tuple_id, capture_schema): + print(f"Reusing immutable baseline evidence for {base_sha}", flush=True) + else: + shutil.rmtree(cached, ignore_errors=True) + cached.parent.mkdir(parents=True, exist_ok=True) + temporary = Path(tempfile.mkdtemp(prefix=f".{tuple_id}-", dir=cached.parent)) + capture(capture_script, baseline, entry, temporary, environment) + marker = { + "base_sha": base_sha, + "tuple_id": tuple_id, + "capture_schema": capture_schema, + "evidence_sha256": evidence_digest(temporary), + } + (temporary / MARKER).write_text( + json.dumps(marker, sort_keys=True) + "\n", encoding="utf-8" + ) + temporary.rename(cached) + temporary = None + + shutil.copytree(cached, baseline_output, ignore=shutil.ignore_patterns(MARKER)) + capture(capture_script, candidate, entry, candidate_output, environment) + subprocess.run( + [ + "python3", + str(compare_script), + "--baseline", + str(baseline_output), + "--candidate", + str(candidate_output), + "--tuple", + tuple_id, + "--contract", + str(contract), + ], + check=True, + ) + finally: + if temporary is not None: + shutil.rmtree(temporary, ignore_errors=True) + remove_build_tree(baseline) + remove_build_tree(candidate) + print("::endgroup::", flush=True) + + print(f"PASS: all {len(tuples)} protected Yocto tuples are unchanged") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/validation/select-bitbake-env.py b/scripts/validation/select-bitbake-env.py new file mode 100644 index 00000000..c58ea9e5 --- /dev/null +++ b/scripts/validation/select-bitbake-env.py @@ -0,0 +1,51 @@ +#!/usr/bin/env python3 +"""Project BitBake -e output to stable policy values with assignment history.""" + +from __future__ import annotations + +import re +import sys +from pathlib import Path + + +EXACT = { + "BBMASK", "CORE_IMAGE_BASE_INSTALL", "CORE_IMAGE_EXTRA_INSTALL", + "DD_PRODUCT_FEATURES", + "DISTRO", "DISTRO_FEATURES", "IMAGE_BOOT_FILES", "IMAGE_FEATURES", + "IMAGE_FSTYPES", "IMAGE_INSTALL", "IMAGE_ROOTFS_EXTRA_SPACE", + "IMAGE_ROOTFS_SIZE", "INITRAMFS_FSTYPES", "INITRAMFS_IMAGE", + "INITRAMFS_MAXSIZE", "LOCAL_DEVELOPMENT_BUILD", "MACHINE", "MACHINE_FEATURES", + "MODSIGN", "SIGN_ENABLE", "TF_A_SIGN_ENABLE", "UEFI_SIGN_ENABLE", + "PACKAGE_INSTALL", "SDKIMAGE_FEATURES", "TOOLCHAIN_TARGET_TASK", + "WKS_FILE", +} +PREFIXES = ( + "FIT_", "KERNEL_", "MODSIGN_", "OPTEE_", "OSTREE_", "PREFERRED_PROVIDER_", + "PREFERRED_VERSION_", "SIGNING_", "SOTA_", "TF_A_", "UBOOT_", "UEFI_", +) +ASSIGNMENT = re.compile(r'^([A-Za-z0-9_${}/:.+-]+)=') + + +def selected(name: str) -> bool: + return name in EXACT or name.startswith(PREFIXES) + + +def main() -> int: + if len(sys.argv) != 2: + print(f"Usage: {sys.argv[0]} BITBAKE_ENV", file=sys.stderr) + return 2 + comments: list[str] = [] + for line in Path(sys.argv[1]).read_text(errors="replace").splitlines(): + if line.startswith("#"): + comments.append(line) + continue + match = ASSIGNMENT.match(line) + if match and selected(match.group(1)): + print("\n".join(comments[-40:])) + print(line) + comments.clear() + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/validation/tests/test_canonicalise_bitbake_layer_output.py b/scripts/validation/tests/test_canonicalise_bitbake_layer_output.py new file mode 100644 index 00000000..302349cb --- /dev/null +++ b/scripts/validation/tests/test_canonicalise_bitbake_layer_output.py @@ -0,0 +1,54 @@ +#!/usr/bin/env python3 + +import importlib.util +import unittest +from pathlib import Path + + +SCRIPT = Path(__file__).parents[1] / "canonicalise-bitbake-layer-output.py" +SPEC = importlib.util.spec_from_file_location("canonicalise_output", SCRIPT) +MODULE = importlib.util.module_from_spec(SPEC) +assert SPEC.loader is not None +SPEC.loader.exec_module(MODULE) + + +class CanonicaliseOutputTests(unittest.TestCase): + def test_recipe_values_keep_their_recipe_identity(self) -> None: + self.assertEqual( + MODULE.canonicalise_blocks( + ["foo:", " layer-a 1.0", "bar:", " layer-b 2.0"] + ), + [ + "entry\tbar", + "entry\tfoo", + "value\tbar\tlayer-b 2.0", + "value\tfoo\tlayer-a 1.0", + ], + ) + + def test_parse_counts_are_removed_and_feature_sets_are_sorted(self) -> None: + self.assertEqual( + MODULE.canonicalise_blocks( + [ + "Parsing of 10 .bb files complete (0 cached)", + "foo:", + " layer 1.0 (skipped: missing required distro features 'x11 opengl')", + ] + ), + [ + "entry\tfoo", + "value\tfoo\tlayer 1.0 (skipped: missing required distro features 'opengl x11')", + ], + ) + + def test_layer_spacing_is_not_evidence(self) -> None: + self.assertEqual( + MODULE.canonicalise_layers( + ["layer path priority", "meta-dd build/.. 11"] + ), + ["layer\tmeta-dd\tbuild/..\t11"], + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/validation/tests/test_capture_layer_state.py b/scripts/validation/tests/test_capture_layer_state.py new file mode 100644 index 00000000..f8e60742 --- /dev/null +++ b/scripts/validation/tests/test_capture_layer_state.py @@ -0,0 +1,164 @@ +#!/usr/bin/env python3 +"""Regressions for the layer-state capture shell boundary.""" + +from pathlib import Path +import re +import subprocess +import tempfile +import unittest + + +SCRIPT = Path(__file__).parents[1] / "capture-layer-state.sh" + + +class CaptureLayerStateTests(unittest.TestCase): + def test_shell_does_not_expand_sed_end_anchor_as_argument_count(self) -> None: + source = SCRIPT.read_text(encoding="utf-8") + self.assertNotIn('s#[[:space:]]+$##"', source) + self.assertEqual(source.count("-e 's#[[:space:]]+$##'"), 1) + self.assertEqual(source.count("| normalise_kas_projection"), 3) + + def test_kas_projection_removes_host_noise_and_sorts_at_call_site(self) -> None: + source = SCRIPT.read_text(encoding="utf-8") + match = re.search(r"(?ms)^normalise_kas_projection\(\) \{\n.*?^\}\n", source) + self.assertIsNotNone(match) + result = subprocess.run( + [ + "bash", + "-c", + match.group(0) + + """ +PWD=/workspace/candidate +printf '%s\n' \\ + '2026-09-12 20:00:00 - INFO - kas 4.7 started' \\ + '/__w/project/baseline/build/layers/meta/conf/layer.conf ' \\ + 'Parsing recipes...WARNING: deterministic warning' \\ + '/workspace/candidate/recipe.bb' | normalise_kas_projection +""", + ], + check=True, + capture_output=True, + text=True, + ) + self.assertEqual( + result.stdout, + "/build/layers/meta/conf/layer.conf\n/recipe.bb\n", + ) + + def test_warnings_come_from_cooker_logs_not_interleaved_command_logs(self) -> None: + source = SCRIPT.read_text(encoding="utf-8") + self.assertIn("find build/tmp/log/cooker -type f -name '*.log'", source) + self.assertNotIn("find \"$output_dir\" -type f -name '*.log'", source) + + def test_kernel_warning_capture_is_independent_of_sstate_reuse(self) -> None: + source = SCRIPT.read_text(encoding="utf-8") + self.assertIn( + '"bitbake -f -c kernel_configcheck virtual/kernel" >/dev/null', + source, + ) + + def test_encoded_bitbake_provenance_checkout_is_normalised(self) -> None: + source = SCRIPT.read_text(encoding="utf-8") + self.assertIn( + "'s/_(baseline|candidate)_build_layers_/_REPO_build_layers_/g'", + source, + ) + + def test_capture_command_replays_failure_log_and_preserves_status(self) -> None: + source = SCRIPT.read_text(encoding="utf-8") + match = re.search(r"(?ms)^capture_command\(\) \{\n.*?^\}\n", source) + self.assertIsNotNone(match) + + with tempfile.TemporaryDirectory() as directory: + result = subprocess.run( + [ + "bash", + "-c", + match.group(0) + + """ +set -o pipefail +output_dir=$1 +capture_command failure bash -c 'printf "visible diagnostic\\n"; exit 7' \\ + | sed 's/diagnostic/output/' +""", + "capture-command-test", + directory, + ], + check=False, + capture_output=True, + text=True, + ) + + self.assertEqual(result.returncode, 7) + self.assertEqual(result.stdout, "visible output\n") + self.assertIn("ERROR: command failed while capturing failure", result.stderr) + self.assertIn("visible diagnostic", result.stderr) + self.assertEqual( + (Path(directory) / "failure.log").read_text(encoding="utf-8"), + "visible diagnostic\n", + ) + + def test_dependency_capture_uses_current_bitbake_graph_outputs(self) -> None: + source = SCRIPT.read_text(encoding="utf-8") + self.assertIn("build/pn-buildlist", source) + self.assertIn("build/task-depends.dot", source) + self.assertNotIn("build/recipe-depends.dot", source) + + def test_environment_assertions_emit_named_diagnostics(self) -> None: + source = SCRIPT.read_text(encoding="utf-8") + self.assertIn('capture_command environment run_bitbake "bitbake -e $target" >/dev/null', source) + self.assertIn("require_selected_value() {", source) + self.assertEqual(source.count("require_selected_value "), 10) + self.assertIn("ERROR: selected BitBake environment does not contain", source) + + def test_module_signing_uses_kernel_runtime_variables(self) -> None: + source = SCRIPT.read_text(encoding="utf-8") + self.assertIn( + 'MODSIGN_PRIVKEY:forcevariable = "$test_keys_dir/privkey_modsign.pem"', + source, + ) + self.assertIn( + 'MODSIGN_X509:forcevariable = "$test_keys_dir/x509_modsign.crt"', + source, + ) + self.assertIn( + 'require_selected_value MODSIGN_PRIVKEY "/privkey_modsign.pem"', + source, + ) + self.assertIn( + 'require_selected_value MODSIGN_X509 "/x509_modsign.crt"', + source, + ) + + def test_every_signing_consumer_path_is_forced_and_asserted(self) -> None: + source = SCRIPT.read_text(encoding="utf-8") + runtime_paths = { + "UBOOT_SIGN_KEYDIR": "", + "UBOOT_SPL_SIGN_KEYDIR": "", + "UEFI_SIGN_KEYDIR": "/uefi", + "OPTEE_TA_SIGN_KEY": "/ubootdev.key", + "TF_A_SIGN_KEY_PATH": "/tf-a/privkey_ec_prime256v1.pem", + } + for name, expected in runtime_paths.items(): + with self.subTest(name=name): + self.assertIn(f"{name}:forcevariable =", source) + self.assertIn( + f'require_selected_value {name} "{expected}"', source + ) + self.assertNotRegex( + source, + r"(?m)^ (?:SIGNING_|MODSIGN_|UBOOT_|UEFI_|OPTEE_|TF_A_)[A-Z0-9_]+ =", + ) + + def test_disk_monitor_uses_inode_not_disk_units(self) -> None: + source = SCRIPT.read_text(encoding="utf-8") + disk_monitor = next( + line for line in source.splitlines() if "BB_DISKMON_DIRS =" in line + ) + self.assertNotIn(",1G", disk_monitor) + self.assertEqual(disk_monitor.count(",100K"), 3) + self.assertEqual(disk_monitor.count(",50K"), 3) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/validation/tests/test_compare_layer_state.py b/scripts/validation/tests/test_compare_layer_state.py new file mode 100644 index 00000000..0bf7a3d8 --- /dev/null +++ b/scripts/validation/tests/test_compare_layer_state.py @@ -0,0 +1,62 @@ +#!/usr/bin/env python3 + +import importlib.util +import unittest +from pathlib import Path + + +SCRIPT = Path(__file__).parents[1] / "compare-layer-state.py" +SPEC = importlib.util.spec_from_file_location("compare_layer_state", SCRIPT) +MODULE = importlib.util.module_from_spec(SPEC) +assert SPEC.loader is not None +SPEC.loader.exec_module(MODULE) + + +class DeployComparisonTests(unittest.TestCase): + def test_layout_change_is_a_delta(self) -> None: + self.assertEqual( + MODULE.deploy_deltas(["old.wic\t10000"], ["new.wic\t10000"]), + ["removed old.wic", "added new.wic"], + ) + + def test_small_rebuild_size_noise_is_accepted(self) -> None: + self.assertEqual( + MODULE.deploy_deltas(["image.wic\t100000000"], ["image.wic\t100300000"]), + [], + ) + + def test_material_size_change_is_a_delta(self) -> None: + self.assertEqual( + MODULE.deploy_deltas(["image.wic\t100000000"], ["image.wic\t101000000"]), + ["size image.wic: 100000000 -> 101000000 (tolerance 500000)"], + ) + + def test_malformed_or_duplicate_entries_fail_closed(self) -> None: + with self.assertRaises(ValueError): + MODULE.deploy_entries(["missing-size"]) + with self.assertRaises(ValueError): + MODULE.deploy_entries(["image.wic\t1", "image.wic\t2"]) + + +class WarningComparisonTests(unittest.TestCase): + def test_new_candidate_warning_is_a_delta(self) -> None: + self.assertEqual( + MODULE.warning_deltas( + ["WARNING: existing"], + ["WARNING: existing", "WARNING: regression"], + ), + ["WARNING: regression"], + ) + + def test_removed_baseline_warning_is_not_a_delta(self) -> None: + self.assertEqual( + MODULE.warning_deltas( + ["WARNING: fixed", "WARNING: existing"], + ["WARNING: existing"], + ), + [], + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/validation/tests/test_detect_layer_adoption.py b/scripts/validation/tests/test_detect_layer_adoption.py new file mode 100644 index 00000000..a1aecf21 --- /dev/null +++ b/scripts/validation/tests/test_detect_layer_adoption.py @@ -0,0 +1,106 @@ +#!/usr/bin/env python3 +"""Regression tests for protected layer-adoption tuple handling.""" + +from __future__ import annotations + +import importlib.util +import json +import subprocess +import tempfile +import unittest +from pathlib import Path +from unittest import mock + + +MODULE_PATH = Path(__file__).parents[1] / "detect-layer-adoption.py" +SPEC = importlib.util.spec_from_file_location("detect_layer_adoption", MODULE_PATH) +assert SPEC and SPEC.loader +MODULE = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(MODULE) + + +def document(*tuples: dict[str, str]) -> str: + return json.dumps({"schema": 1, "tuples": list(tuples)}) + + +def entry(tuple_id: str, machine: str = "machine-a") -> dict[str, str]: + return { + "id": tuple_id, + "machine": machine, + "distro": "distro-a", + "image": "image-a", + "config": "kas/test.yml", + "product_features": "", + } + + +class ProtectedTupleTests(unittest.TestCase): + def test_local_and_ci_kas_process_changes_are_material(self) -> None: + paths = ( + ".github/workflows/layer-adoption-gate.yml", + ".gitattributes", + "ci/layer-adoption-contract.json", + "ci/layer-adoption-tuples.json", + "kas/lmp-dynamicdevices.yml", + "meta-dynamicdevices-bsp", + "meta-dynamicdevices-distro", + "meta-partner-nxp-imx", + "scripts/kas-build-base.sh", + "scripts/kas-shell-base.sh", + "scripts/validation/capture-layer-state.sh", + "scripts/validation/compare-layer-state.py", + "scripts/validation/detect-layer-adoption.py", + "scripts/validation/generate-layer-adoption-test-keys.sh", + "scripts/validation/run-layer-adoption-regression.py", + ) + for path in paths: + with self.subTest(path=path): + self.assertTrue(MODULE.is_material_path(path)) + + def test_unrelated_utility_change_is_not_material(self) -> None: + self.assertFalse(MODULE.is_material_path("scripts/analyze-boot-logs.sh")) + + def validate(self, baseline: str, candidate: str) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + (root / "kas").mkdir() + (root / "kas/test.yml").touch() + matrix = root / "ci/layer-adoption-tuples.json" + matrix.parent.mkdir() + matrix.write_text(candidate, encoding="utf-8") + result = subprocess.CompletedProcess([], 0, stdout=baseline, stderr="") + with mock.patch.object(MODULE.subprocess, "run", return_value=result), mock.patch.object( + MODULE.Path, "is_file", return_value=True + ): + MODULE.validate_tuple_matrix("baseline", matrix) + + def test_extension_preserves_existing_tuple(self) -> None: + self.validate(document(entry("existing")), document(entry("existing"), entry("new"))) + + def test_removing_existing_tuple_fails(self) -> None: + with self.assertRaisesRegex(ValueError, "removed=existing"): + self.validate(document(entry("existing")), document(entry("replacement"))) + + def test_redefining_existing_tuple_fails(self) -> None: + with self.assertRaisesRegex(ValueError, "redefined=existing"): + self.validate(document(entry("existing")), document(entry("existing", "machine-b"))) + + def test_duplicate_candidate_id_fails(self) -> None: + with self.assertRaisesRegex(ValueError, "duplicate tuple id existing"): + self.validate(document(entry("existing")), document(entry("existing"), entry("existing"))) + + def test_missing_product_features_fails(self) -> None: + candidate = entry("existing") + del candidate["product_features"] + with self.assertRaisesRegex(ValueError, "lacks product_features"): + self.validate(document(entry("existing")), document(candidate)) + + def test_redefining_product_features_fails(self) -> None: + candidate = entry("existing") + candidate["product_features"] = "display" + with self.assertRaisesRegex(ValueError, "redefined=existing"): + self.validate(document(entry("existing")), document(candidate)) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/validation/tests/test_run_layer_adoption_regression.py b/scripts/validation/tests/test_run_layer_adoption_regression.py new file mode 100644 index 00000000..04722f1f --- /dev/null +++ b/scripts/validation/tests/test_run_layer_adoption_regression.py @@ -0,0 +1,308 @@ +#!/usr/bin/env python3 +"""Tests for immutable layer-adoption baseline evidence.""" + +from __future__ import annotations + +import importlib.util +import json +import re +import subprocess +import tempfile +import unittest +from pathlib import Path +from unittest import mock + + +MODULE_PATH = Path(__file__).parents[1] / "run-layer-adoption-regression.py" +WORKFLOW_PATH = Path(__file__).parents[3] / ".github/workflows/layer-adoption-gate.yml" +SPEC = importlib.util.spec_from_file_location("run_layer_adoption_regression", MODULE_PATH) +assert SPEC and SPEC.loader +MODULE = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(MODULE) + + +class BaselineEvidenceTests(unittest.TestCase): + def test_ci_shard_selects_exactly_one_known_tuple(self) -> None: + tuples = [ + {"id": "image-a"}, + {"id": "mfgtool-a"}, + ] + self.assertIs(MODULE.select_tuples(tuples, None), tuples) + self.assertEqual( + MODULE.select_tuples(tuples, "mfgtool-a"), + [{"id": "mfgtool-a"}], + ) + with self.assertRaisesRegex(ValueError, "unknown protected tuple"): + MODULE.select_tuples(tuples, "missing") + + def test_lfs_backed_kas_config_is_materialized_by_shared_driver(self) -> None: + with tempfile.TemporaryDirectory() as directory: + repository = Path(directory) + config = repository / "kas/lmp-mfgtool.yml" + config.parent.mkdir() + config.write_bytes( + MODULE.LFS_POINTER_PREFIX + + b"oid sha256:" + b"a" * 64 + b"\nsize 42\n" + ) + + def materialize(*args: object, **kwargs: object) -> None: + config.write_text("header:\n version: 14\n", encoding="utf-8") + + with mock.patch.object( + MODULE.subprocess, "run", side_effect=materialize + ) as run: + MODULE.materialize_config(repository, "kas/lmp-mfgtool.yml") + + run.assert_called_once_with( + [ + "git", + "lfs", + "pull", + "--include=kas/lmp-mfgtool.yml", + "--exclude=", + ], + cwd=repository, + check=True, + ) + + def test_unresolved_lfs_backed_kas_config_fails_closed(self) -> None: + with tempfile.TemporaryDirectory() as directory: + repository = Path(directory) + config = repository / "kas/lmp-mfgtool.yml" + config.parent.mkdir() + config.write_bytes(MODULE.LFS_POINTER_PREFIX) + with mock.patch.object(MODULE.subprocess, "run"): + with self.assertRaisesRegex(RuntimeError, "did not materialize"): + MODULE.materialize_config(repository, "kas/lmp-mfgtool.yml") + + def test_workflow_shards_all_tuples_without_fail_fast(self) -> None: + workflow = WORKFLOW_PATH.read_text(encoding="utf-8") + self.assertIn("fail-fast: false", workflow) + self.assertIn("fromJSON(needs.detect.outputs.tuple_ids)", workflow) + self.assertIn("--tuple-id '${{ matrix.tuple_id }}'", workflow) + self.assertIn("name: Layer Adoption Gate", workflow) + + def test_audited_baseline_repair_is_exact_and_fail_closed(self) -> None: + old = "a" * 40 + new = "b" * 40 + changed_file = "recipes-bsp/u-boot/u-boot-fio/board/fix.patch" + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + contract = root / "contract.json" + contract.write_text( + json.dumps( + { + "baseline_repairs": [ + { + "base_sha": "base", + "submodule": "meta-dynamicdevices-bsp", + "from": old, + "to": new, + "url": "https://github.com/DynamicDevices/bsp.git", + "ref": "refs/heads/focused-backport", + "files": [changed_file], + "reason": "repair an exact pre-existing patch failure", + } + ] + } + ), + encoding="utf-8", + ) + with mock.patch.object( + MODULE, "submodule_commit", side_effect=[old, new] + ), mock.patch.object( + MODULE, + "git_output", + side_effect=[changed_file + "\n", new, ""], + ), mock.patch.object(MODULE.subprocess, "run") as run: + MODULE.apply_baseline_repairs( + root / "baseline", root / "candidate", contract, "base" + ) + + self.assertEqual(run.call_count, 3) + self.assertEqual( + run.call_args_list[1].args[0], + [ + "git", + "fetch", + "https://github.com/DynamicDevices/bsp.git", + "refs/heads/focused-backport", + ], + ) + + def test_audited_baseline_repair_rejects_extra_files(self) -> None: + old = "a" * 40 + new = "b" * 40 + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + contract = root / "contract.json" + contract.write_text( + json.dumps( + { + "baseline_repairs": [ + { + "base_sha": "base", + "submodule": "meta-dynamicdevices-bsp", + "from": old, + "to": new, + "url": "https://github.com/DynamicDevices/bsp.git", + "ref": "refs/heads/focused-backport", + "files": ["expected.patch"], + "reason": "focused repair", + } + ] + } + ), + encoding="utf-8", + ) + with mock.patch.object( + MODULE, "submodule_commit", side_effect=[old, new] + ), mock.patch.object( + MODULE, "git_output", return_value="unexpected.patch\n" + ), mock.patch.object(MODULE.subprocess, "run"): + with self.assertRaisesRegex(RuntimeError, "do not match contract"): + MODULE.apply_baseline_repairs( + root / "baseline", root / "candidate", contract, "base" + ) + + def test_generated_signing_identity_is_validated_before_reuse(self) -> None: + generator = MODULE_PATH.parent / "generate-layer-adoption-test-keys.sh" + with tempfile.TemporaryDirectory() as directory: + keys = Path(directory) / "keys" + subprocess.run([str(generator), str(keys)], check=True, capture_output=True) + subprocess.run( + [str(generator), "--check", str(keys)], check=True, capture_output=True + ) + (keys / "x509_modsign.crt").write_text("invalid\n", encoding="utf-8") + invalid = subprocess.run( + [str(generator), "--check", str(keys)], + check=False, + capture_output=True, + text=True, + ) + self.assertNotEqual(invalid.returncode, 0) + self.assertIn("invalid or expire within seven days", invalid.stderr) + + def test_local_kas_wrappers_use_ci_container_digest(self) -> None: + root = WORKFLOW_PATH.parents[2] + workflow = WORKFLOW_PATH.read_text(encoding="utf-8") + helper_name = "kas-container-image.sh" + helper = (root / "scripts" / helper_name).read_text(encoding="utf-8") + local_pin = re.search(r'^KAS_CONTAINER_IMAGE="([^"]+)"$', helper, re.MULTILINE) + ci_pin = re.search(r"^\s+image: (\S+)$", workflow, re.MULTILINE) + self.assertIsNotNone(local_pin) + self.assertIsNotNone(ci_pin) + self.assertEqual(local_pin.group(1), ci_pin.group(1)) + + wrappers = [ + path for path in (root / "scripts").glob("kas-*.sh") + if path.name != helper_name and "kas-container" in path.read_text(encoding="utf-8") + ] + self.assertTrue(wrappers) + for wrapper in wrappers: + with self.subTest(wrapper=wrapper.name): + self.assertIn(helper_name, wrapper.read_text(encoding="utf-8")) + + def test_worktree_preparation_is_owned_by_shared_driver(self) -> None: + workflow = WORKFLOW_PATH.read_text(encoding="utf-8") + self.assertNotIn("submodule update", workflow) + + with tempfile.TemporaryDirectory() as directory: + repository = Path(directory) + pinned = "a" * 40 + + def git_result(command: list[str], **kwargs: object) -> str: + if "ls-tree" in command: + relative = command[-1] + return f"160000 commit {pinned}\t{relative}\n" + if "rev-parse" in command: + return pinned + "\n" + if "status" in command: + return "" + self.fail(f"unexpected git command: {command}") + + def initialize(command: list[str], **kwargs: object) -> None: + relative = command[-1] + layer_conf = repository / relative / "conf/layer.conf" + layer_conf.parent.mkdir(parents=True) + layer_conf.touch() + + with mock.patch.object( + MODULE.subprocess, "check_output", side_effect=git_result + ), mock.patch.object(MODULE.subprocess, "run", side_effect=initialize) as run: + MODULE.prepare_repository(repository) + + self.assertEqual(run.call_count, 2) + + def test_worktree_preparation_rejects_unpinned_layer(self) -> None: + with tempfile.TemporaryDirectory() as directory: + repository = Path(directory) + layer = repository / MODULE.PRODUCT_SUBMODULES[0] + (layer / "conf").mkdir(parents=True) + (layer / "conf/layer.conf").touch() + results = [ + f"160000 commit {'a' * 40}\t{layer.name}\n", + "b" * 40 + "\n", + ] + with mock.patch.object( + MODULE.subprocess, "check_output", side_effect=results + ): + with self.assertRaisesRegex(RuntimeError, "expected pinned"): + MODULE.prepare_repository(repository) + + def test_gate_does_not_run_bitbake_as_root(self) -> None: + workflow = WORKFLOW_PATH.read_text(encoding="utf-8") + self.assertNotIn("--user 0:0", workflow) + self.assertIn("--user 1002:1002", workflow) + + def test_valid_cache_is_accepted_and_tampering_is_rejected(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + (root / "packages.txt").write_text("package-a\n", encoding="utf-8") + marker = { + "base_sha": "abc123", + "tuple_id": "machine-image", + "capture_schema": "schema-a", + "evidence_sha256": MODULE.evidence_digest(root), + } + (root / MODULE.MARKER).write_text(json.dumps(marker), encoding="utf-8") + self.assertTrue( + MODULE.valid_cached_evidence( + root, "abc123", "machine-image", "schema-a" + ) + ) + self.assertFalse( + MODULE.valid_cached_evidence( + root, "abc123", "machine-image", "schema-b" + ) + ) + + (root / "packages.txt").write_text("package-b\n", encoding="utf-8") + self.assertFalse( + MODULE.valid_cached_evidence( + root, "abc123", "machine-image", "schema-a" + ) + ) + + def test_capture_schema_covers_every_evidence_producer(self) -> None: + root = MODULE_PATH.parents[2] + first = MODULE.capture_schema_digest(root) + self.assertRegex(first, r"^[0-9a-f]{64}$") + self.assertIn("scripts/validation/capture-layer-state.sh", MODULE.CAPTURE_SCHEMA_FILES) + self.assertIn("ci/layer-adoption-contract.json", MODULE.CAPTURE_SCHEMA_FILES) + self.assertIn( + "scripts/validation/canonicalise-bitbake-layer-output.py", + MODULE.CAPTURE_SCHEMA_FILES, + ) + + def test_marker_is_not_part_of_evidence_digest(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + (root / "state.txt").write_text("stable\n", encoding="utf-8") + before = MODULE.evidence_digest(root) + (root / MODULE.MARKER).write_text("{}\n", encoding="utf-8") + self.assertEqual(before, MODULE.evidence_digest(root)) + + +if __name__ == "__main__": + unittest.main()