diff --git a/.github/workflows/build-cli-e2e-image.yml b/.github/workflows/build-cli-e2e-image.yml new file mode 100644 index 00000000000..6d1ec2a5a6d --- /dev/null +++ b/.github/workflows/build-cli-e2e-image.yml @@ -0,0 +1,154 @@ +# Builds CLI E2E images shared across split test jobs. +# Podman uses a separate privileged runtime path and is intentionally left on its +# existing per-job Dockerfile path. +name: Build CLI E2E Docker Image + +on: + workflow_call: + inputs: + includePolyglotImages: + required: false + type: boolean + default: true + +env: + CLI_E2E_DOTNET_IMAGE_ARTIFACT: cli-e2e-dotnet-image + CLI_E2E_DOTNET_IMAGE_TAG: aspire-cli-e2e-dotnet:prebuilt + CLI_E2E_DOTNET_IMAGE_CACHE_SCOPE: cli-e2e-dotnet + CLI_E2E_POLYGLOT_IMAGE_ARTIFACT: cli-e2e-polyglot-image + CLI_E2E_POLYGLOT_IMAGE_TAG: aspire-cli-e2e-polyglot:prebuilt + CLI_E2E_POLYGLOT_JAVA_IMAGE_ARTIFACT: cli-e2e-polyglot-java-image + CLI_E2E_POLYGLOT_JAVA_IMAGE_TAG: aspire-cli-e2e-polyglot-java:prebuilt + CLI_E2E_POLYGLOT_BASE_IMAGE_TAG: aspire-e2e-polyglot-base:latest + CLI_E2E_POLYGLOT_BASE_IMAGE_CACHE_SCOPE: cli-e2e-polyglot-base + CLI_E2E_INCLUDE_POLYGLOT_IMAGES: ${{ inputs.includePolyglotImages }} + +jobs: + build: + name: Build CLI E2E Docker image + runs-on: ${{ github.repository_owner == 'microsoft' && '8-core-ubuntu-latest' || 'ubuntu-latest' }} + timeout-minutes: 45 + + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Verify Docker is running + run: docker info + + - name: Export GitHub Actions cache runtime + uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 + with: + script: | + for (const name of ['ACTIONS_RUNTIME_TOKEN', 'ACTIONS_RUNTIME_URL', 'ACTIONS_CACHE_URL', 'ACTIONS_RESULTS_URL']) { + const value = process.env[name]; + if (value) { + core.setSecret(value); + core.exportVariable(name, value); + } + } + + - name: Build CLI E2E Docker image + shell: bash + run: | + set -euo pipefail + + docker buildx create --name cli-e2e-builder --use + docker buildx inspect --bootstrap + + build_image() { + local display_name="$1" + local dockerfile="$2" + local tag="$3" + local cache_scope="$4" + local mirror="$5" + local -a build_args=(--build-arg SKIP_SOURCE_BUILD=true) + + if [[ -n "$mirror" ]]; then + echo "Building $display_name with Ubuntu apt mirror: $mirror" + build_args+=(--build-arg "UBUNTU_APT_MIRROR=$mirror") + else + echo "Building $display_name with the default Ubuntu apt sources" + fi + + docker buildx build \ + --load \ + --cache-from "type=gha,scope=$cache_scope" \ + --cache-to "type=gha,scope=$cache_scope,mode=max,ignore-error=true" \ + "${build_args[@]}" \ + -f "$dockerfile" \ + -t "$tag" \ + . + } + + build_java_image() { + local mirror="$1" + local -a build_args=() + + if [[ -n "$mirror" ]]; then + echo "Building Java polyglot image with Ubuntu apt mirror: $mirror" + build_args+=(--build-arg "UBUNTU_APT_MIRROR=$mirror") + else + echo "Building Java polyglot image with the default Ubuntu apt sources" + fi + + DOCKER_BUILDKIT=1 docker build \ + "${build_args[@]}" \ + -f tests/Shared/Docker/Dockerfile.e2e-polyglot-java \ + -t "$CLI_E2E_POLYGLOT_JAVA_IMAGE_TAG" \ + . + } + + build_with_mirror_retry() { + local display_name="$1" + local dockerfile="$2" + local tag="$3" + local cache_scope="$4" + + if ! build_image "$display_name" "$dockerfile" "$tag" "$cache_scope" "http://azure.archive.ubuntu.com/ubuntu/"; then + echo "$display_name build failed with the Azure Ubuntu apt mirror; retrying with default Ubuntu apt sources" + build_image "$display_name" "$dockerfile" "$tag" "$cache_scope" "" + fi + } + + build_with_mirror_retry ".NET image" "tests/Shared/Docker/Dockerfile.e2e" "$CLI_E2E_DOTNET_IMAGE_TAG" "$CLI_E2E_DOTNET_IMAGE_CACHE_SCOPE" + + if [[ "$CLI_E2E_INCLUDE_POLYGLOT_IMAGES" == "true" ]]; then + build_with_mirror_retry "polyglot base image" "tests/Shared/Docker/Dockerfile.e2e-polyglot-base" "$CLI_E2E_POLYGLOT_BASE_IMAGE_TAG" "$CLI_E2E_POLYGLOT_BASE_IMAGE_CACHE_SCOPE" + docker tag "$CLI_E2E_POLYGLOT_BASE_IMAGE_TAG" "$CLI_E2E_POLYGLOT_IMAGE_TAG" + + if ! build_java_image "http://azure.archive.ubuntu.com/ubuntu/"; then + echo "Java polyglot image build failed with the Azure Ubuntu apt mirror; retrying with default Ubuntu apt sources" + build_java_image "" + fi + fi + + mkdir -p artifacts/cli-e2e-image + docker save "$CLI_E2E_DOTNET_IMAGE_TAG" | gzip > artifacts/cli-e2e-image/aspire-cli-e2e-dotnet.tar.gz + if [[ "$CLI_E2E_INCLUDE_POLYGLOT_IMAGES" == "true" ]]; then + docker save "$CLI_E2E_POLYGLOT_IMAGE_TAG" | gzip > artifacts/cli-e2e-image/aspire-cli-e2e-polyglot.tar.gz + docker save "$CLI_E2E_POLYGLOT_JAVA_IMAGE_TAG" | gzip > artifacts/cli-e2e-image/aspire-cli-e2e-polyglot-java.tar.gz + fi + + - name: Upload CLI E2E .NET Docker image + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: ${{ env.CLI_E2E_DOTNET_IMAGE_ARTIFACT }} + path: artifacts/cli-e2e-image/aspire-cli-e2e-dotnet.tar.gz + retention-days: 1 + + - name: Upload CLI E2E polyglot Docker image + if: ${{ inputs.includePolyglotImages }} + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: ${{ env.CLI_E2E_POLYGLOT_IMAGE_ARTIFACT }} + path: artifacts/cli-e2e-image/aspire-cli-e2e-polyglot.tar.gz + retention-days: 1 + + - name: Upload CLI E2E Java Docker image + if: ${{ inputs.includePolyglotImages }} + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: ${{ env.CLI_E2E_POLYGLOT_JAVA_IMAGE_ARTIFACT }} + path: artifacts/cli-e2e-image/aspire-cli-e2e-polyglot-java.tar.gz + retention-days: 1 diff --git a/.github/workflows/reproduce-flaky-tests.yml b/.github/workflows/reproduce-flaky-tests.yml index 5be8d416106..fa46c6f8844 100644 --- a/.github/workflows/reproduce-flaky-tests.yml +++ b/.github/workflows/reproduce-flaky-tests.yml @@ -48,6 +48,7 @@ jobs: runs-on: ubuntu-latest outputs: matrix: ${{ steps.generate.outputs.matrix }} + is_cli_e2e: ${{ steps.detect_cli_e2e.outputs.is_cli_e2e }} steps: - name: Validate configuration shell: bash @@ -89,6 +90,16 @@ jobs: if [[ $ERRORS -ne 0 ]]; then exit 1; fi + - name: Detect CLI E2E project + id: detect_cli_e2e + shell: bash + run: | + if [ "${{ env.TEST_PROJECT }}" = "Cli.EndToEnd" ]; then + echo "is_cli_e2e=true" >> "$GITHUB_OUTPUT" + else + echo "is_cli_e2e=false" >> "$GITHUB_OUTPUT" + fi + - name: Generate runner matrix id: generate shell: bash @@ -113,9 +124,21 @@ jobs: echo "Each runner will execute ${{ env.ITERATIONS_PER_RUNNER }} iterations" echo "Total test executions: $(( TOTAL * ${{ env.ITERATIONS_PER_RUNNER }} ))" + build_cli_e2e_image: + name: Build CLI E2E Docker image + needs: setup + if: ${{ needs.setup.outputs.is_cli_e2e == 'true' }} + uses: ./.github/workflows/build-cli-e2e-image.yml + reproduce: name: "${{ matrix.os }} #${{ matrix.index }}" - needs: setup + needs: [setup, build_cli_e2e_image] + if: >- + ${{ + !cancelled() && + needs.setup.result == 'success' && + (needs.build_cli_e2e_image.result == 'success' || needs.build_cli_e2e_image.result == 'skipped') + }} runs-on: ${{ matrix.os }} timeout-minutes: 60 strategy: @@ -158,6 +181,38 @@ jobs: if: runner.os == 'Linux' run: docker info + - name: Download prebuilt CLI E2E Docker image + if: ${{ needs.setup.outputs.is_cli_e2e == 'true' && runner.os == 'Linux' }} + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: cli-e2e-dotnet-image + path: ${{ github.workspace }}/cli-e2e-image + + - name: Download prebuilt CLI E2E Java Docker image + if: ${{ needs.setup.outputs.is_cli_e2e == 'true' && runner.os == 'Linux' }} + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: cli-e2e-polyglot-java-image + path: ${{ github.workspace }}/cli-e2e-image + + - name: Download prebuilt CLI E2E polyglot Docker image + if: ${{ needs.setup.outputs.is_cli_e2e == 'true' && runner.os == 'Linux' }} + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: cli-e2e-polyglot-image + path: ${{ github.workspace }}/cli-e2e-image + + - name: Load prebuilt CLI E2E Docker image + if: ${{ needs.setup.outputs.is_cli_e2e == 'true' && runner.os == 'Linux' }} + shell: bash + run: | + set -euo pipefail + eng/scripts/load-cli-e2e-images.sh \ + --image-dir "${{ github.workspace }}/cli-e2e-image" \ + --require-dotnet true \ + --require-polyglot true \ + --require-java true + - name: Unlock macOS keychain if: runner.os == 'macOS' uses: ./.github/actions/unlock-macos-keychain diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 92e53231665..f295648f7aa 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -118,6 +118,38 @@ jobs: if: runner.os == 'Linux' run: docker info + - name: Download prebuilt CLI E2E Docker image + if: ${{ fromJson(inputs.properties).requiresCliArchive == true && runner.os == 'Linux' }} + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: cli-e2e-dotnet-image + path: ${{ github.workspace }}/cli-e2e-image + + - name: Download prebuilt CLI E2E polyglot Docker image + if: ${{ fromJson(inputs.properties).requiresCliArchive == true && runner.os == 'Linux' }} + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: cli-e2e-polyglot-image + path: ${{ github.workspace }}/cli-e2e-image + + - name: Download prebuilt CLI E2E Java Docker image + if: ${{ fromJson(inputs.properties).requiresCliArchive == true && runner.os == 'Linux' && contains(inputs.testShortName, 'Java') }} + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: cli-e2e-polyglot-java-image + path: ${{ github.workspace }}/cli-e2e-image + + - name: Load prebuilt CLI E2E Docker images + if: ${{ fromJson(inputs.properties).requiresCliArchive == true && runner.os == 'Linux' }} + shell: bash + run: | + set -euo pipefail + eng/scripts/load-cli-e2e-images.sh \ + --image-dir "${{ github.workspace }}/cli-e2e-image" \ + --require-dotnet true \ + --require-polyglot true \ + --require-java "${{ contains(inputs.testShortName, 'Java') }}" + - name: Download built nugets if: ${{ fromJson(inputs.properties).requiresNugets == true }} uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 diff --git a/.github/workflows/specialized-test-runner.yml b/.github/workflows/specialized-test-runner.yml index c6c1fa7b93c..a44145b212e 100644 --- a/.github/workflows/specialized-test-runner.yml +++ b/.github/workflows/specialized-test-runner.yml @@ -140,9 +140,15 @@ jobs: if: ${{ github.repository_owner == 'microsoft' && (needs.generate_tests_matrix.outputs.requiresNugets == 'true' || needs.generate_tests_matrix.outputs.requiresCliArchive == 'true') }} uses: ./.github/workflows/build-cli-native-archives.yml + build_cli_e2e_image: + name: Build CLI E2E Docker image + needs: [generate_tests_matrix] + if: ${{ github.repository_owner == 'microsoft' && needs.generate_tests_matrix.outputs.requiresCliArchive == 'true' }} + uses: ./.github/workflows/build-cli-e2e-image.yml + run_tests: name: ${{ matrix.tests.project }} - needs: [generate_tests_matrix, build_packages, build_cli_archives] + needs: [generate_tests_matrix, build_packages, build_cli_archives, build_cli_e2e_image] strategy: fail-fast: false matrix: @@ -156,7 +162,8 @@ jobs: github.repository_owner == 'microsoft' && !cancelled() && needs.generate_tests_matrix.result == 'success' && - (needs.build_packages.result == 'success' || needs.build_packages.result == 'skipped') + (needs.build_packages.result == 'success' || needs.build_packages.result == 'skipped') && + (needs.build_cli_e2e_image.result == 'success' || needs.build_cli_e2e_image.result == 'skipped') }} uses: ./.github/workflows/run-tests.yml with: @@ -172,7 +179,7 @@ jobs: if: ${{ always() && github.repository_owner == 'microsoft' }} runs-on: ubuntu-latest name: Final Results - needs: [generate_tests_matrix, build_packages, build_cli_archives, run_tests] + needs: [generate_tests_matrix, build_packages, build_cli_archives, build_cli_e2e_image, run_tests] steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -244,6 +251,8 @@ jobs: needs.run_tests.result == 'failure' || needs.run_tests.result == 'cancelled' || needs.run_tests.result == 'skipped' || + (needs.build_cli_e2e_image.result == 'failure') || + (needs.build_cli_e2e_image.result == 'cancelled') || (needs.build_packages.result == 'failure') || (needs.build_packages.result == 'cancelled') ) diff --git a/.github/workflows/tests-daily-smoke.yml b/.github/workflows/tests-daily-smoke.yml index 6768dcb5255..62c86da7cf0 100644 --- a/.github/workflows/tests-daily-smoke.yml +++ b/.github/workflows/tests-daily-smoke.yml @@ -26,8 +26,15 @@ permissions: contents: read jobs: + build_cli_e2e_image: + name: Build CLI E2E Docker image + uses: ./.github/workflows/build-cli-e2e-image.yml + with: + includePolyglotImages: false + smoke-test: name: CLI Smoke Test (${{ inputs.quality || 'dev' }}) + needs: build_cli_e2e_image runs-on: ubuntu-latest timeout-minutes: 30 @@ -45,6 +52,23 @@ jobs: - name: Restore run: ./restore.sh + - name: Verify Docker is running + run: docker info + + - name: Download prebuilt CLI E2E Docker image + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: cli-e2e-dotnet-image + path: ${{ github.workspace }}/cli-e2e-image + + - name: Load prebuilt CLI E2E Docker image + shell: bash + run: | + set -euo pipefail + eng/scripts/load-cli-e2e-images.sh \ + --image-dir "${{ github.workspace }}/cli-e2e-image" \ + --require-dotnet true + - name: Build E2E test project run: dotnet build tests/Aspire.Cli.EndToEnd.Tests/Aspire.Cli.EndToEnd.Tests.csproj diff --git a/.github/workflows/tests-outerloop.yml b/.github/workflows/tests-outerloop.yml index 48d7a8bf73d..0347cd2057a 100644 --- a/.github/workflows/tests-outerloop.yml +++ b/.github/workflows/tests-outerloop.yml @@ -2,8 +2,8 @@ # # COPILOT INSTRUCTIONS: # - Keep the shared 'paths:' entries (specialized-test-runner.yml, -# run-tests.yml) in sync across tests-outerloop.yml and -# tests-quarantine.yml. Each workflow also lists itself. +# run-tests.yml, build-cli-e2e-image.yml) in sync across +# tests-outerloop.yml and tests-quarantine.yml. Each workflow also lists itself. # - Validate that each path exists in the repository before adding or # updating the list # - No external YAML file is used—only the workflow YAMLs themselves @@ -28,6 +28,7 @@ on: - '.github/workflows/tests-outerloop.yml' - '.github/workflows/specialized-test-runner.yml' - '.github/workflows/run-tests.yml' + - '.github/workflows/build-cli-e2e-image.yml' concurrency: group: ${{ github.workflow }}-${{ github.ref }} diff --git a/.github/workflows/tests-quarantine.yml b/.github/workflows/tests-quarantine.yml index 73f57cbf20a..de8e198c7ce 100644 --- a/.github/workflows/tests-quarantine.yml +++ b/.github/workflows/tests-quarantine.yml @@ -2,8 +2,8 @@ # # COPILOT INSTRUCTIONS: # - Keep the shared 'paths:' entries (specialized-test-runner.yml, -# run-tests.yml) in sync across tests-outerloop.yml and -# tests-quarantine.yml. Each workflow also lists itself. +# run-tests.yml, build-cli-e2e-image.yml) in sync across +# tests-outerloop.yml and tests-quarantine.yml. Each workflow also lists itself. # - Validate that each path exists in the repository before adding or # updating the list # - No external YAML file is used—only the workflow YAMLs themselves @@ -29,6 +29,7 @@ on: - '.github/workflows/tests-quarantine.yml' - '.github/workflows/specialized-test-runner.yml' - '.github/workflows/run-tests.yml' + - '.github/workflows/build-cli-e2e-image.yml' concurrency: group: ${{ github.workflow }}-${{ github.ref }} diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 4061445999a..3a7a5b97e50 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -82,6 +82,12 @@ jobs: versionOverrideArg: ${{ inputs.versionOverrideArg }} targets: '[{"os": "macos-latest", "runner": "macos-latest", "rids": "osx-arm64"}]' + build_cli_e2e_image: + name: Build CLI E2E Docker image + uses: ./.github/workflows/build-cli-e2e-image.yml + needs: setup_for_tests + if: ${{ fromJson(needs.setup_for_tests.outputs.tests_matrix_requires_cli_archive).include[0] != null }} + tests_no_nugets: name: ${{ matrix.shortname }} uses: ./.github/workflows/run-tests.yml @@ -172,7 +178,7 @@ jobs: tests_requires_cli_archive: name: ${{ matrix.shortname }} uses: ./.github/workflows/run-tests.yml - needs: [setup_for_tests, build_packages, build_cli_archive_linux] + needs: [setup_for_tests, build_packages, build_cli_archive_linux, build_cli_e2e_image] if: ${{ fromJson(needs.setup_for_tests.outputs.tests_matrix_requires_cli_archive).include[0] != null }} strategy: fail-fast: false @@ -319,6 +325,7 @@ jobs: build_cli_archive_windows, build_cli_archive_windows_arm64, build_cli_archive_macos, + build_cli_e2e_image, extension_tests_win, cli_starter_validation_windows, typescript_sdk_tests, @@ -398,27 +405,29 @@ jobs: if: >- ${{ always() && (contains(needs.*.result, 'failure') || - contains(needs.*.result, 'cancelled') || - (github.event_name == 'pull_request' && - (needs.extension_tests_win.result == 'skipped' || - needs.cli_starter_validation_windows.result == 'skipped' || - needs.typescript_sdk_tests.result == 'skipped' || - needs.tests_no_nugets.result == 'skipped' || - needs.tests_requires_nugets_linux.result == 'skipped' || - needs.tests_requires_nugets_windows.result == 'skipped' || - (fromJson(needs.setup_for_tests.outputs.tests_matrix_requires_nugets_macos).include[0] != null && - needs.tests_requires_nugets_macos.result == 'skipped') || - needs.tests_requires_cli_archive.result == 'skipped' || - needs.polyglot_validation.result == 'skipped')) || - (github.event_name != 'pull_request' && - (needs.extension_tests_win.result == 'skipped' || - needs.typescript_sdk_tests.result == 'skipped' || - needs.tests_no_nugets.result == 'skipped' || - needs.tests_requires_nugets_linux.result == 'skipped' || - needs.tests_requires_nugets_windows.result == 'skipped' || - (fromJson(needs.setup_for_tests.outputs.tests_matrix_requires_nugets_macos).include[0] != null && - needs.tests_requires_nugets_macos.result == 'skipped') || - needs.polyglot_validation.result == 'skipped'))) }} + contains(needs.*.result, 'cancelled') || + (github.event_name == 'pull_request' && + (needs.extension_tests_win.result == 'skipped' || + needs.cli_starter_validation_windows.result == 'skipped' || + needs.typescript_sdk_tests.result == 'skipped' || + needs.tests_no_nugets.result == 'skipped' || + needs.tests_requires_nugets_linux.result == 'skipped' || + needs.tests_requires_nugets_windows.result == 'skipped' || + (fromJson(needs.setup_for_tests.outputs.tests_matrix_requires_nugets_macos).include[0] != null && + needs.tests_requires_nugets_macos.result == 'skipped') || + (fromJson(needs.setup_for_tests.outputs.tests_matrix_requires_cli_archive).include[0] != null && + needs.build_cli_e2e_image.result == 'skipped') || + needs.tests_requires_cli_archive.result == 'skipped' || + needs.polyglot_validation.result == 'skipped')) || + (github.event_name != 'pull_request' && + (needs.extension_tests_win.result == 'skipped' || + needs.typescript_sdk_tests.result == 'skipped' || + needs.tests_no_nugets.result == 'skipped' || + needs.tests_requires_nugets_linux.result == 'skipped' || + needs.tests_requires_nugets_windows.result == 'skipped' || + (fromJson(needs.setup_for_tests.outputs.tests_matrix_requires_nugets_macos).include[0] != null && + needs.tests_requires_nugets_macos.result == 'skipped') || + needs.polyglot_validation.result == 'skipped'))) }} run: | echo "One or more dependent jobs failed." exit 1 diff --git a/docs/ci/cli-e2e-images.md b/docs/ci/cli-e2e-images.md new file mode 100644 index 00000000000..2c2cc8fae61 --- /dev/null +++ b/docs/ci/cli-e2e-images.md @@ -0,0 +1,50 @@ +# CLI E2E Docker images + +The CLI E2E tests run in Docker containers through Hex1b. GitHub Actions splits those tests across many isolated jobs, so the CI workflows prebuild shared images once per workflow run and pass them to test jobs as short-lived artifacts. + +## Image contract + +The reusable `.github/workflows/build-cli-e2e-image.yml` workflow produces these artifacts: + +| Variant | Dockerfile | Artifact | Image tag | Image env var | Require env var | +| --- | --- | --- | --- | --- | --- | +| DotNet | `tests/Shared/Docker/Dockerfile.e2e` | `cli-e2e-dotnet-image` | `aspire-cli-e2e-dotnet:prebuilt` | `ASPIRE_E2E_DOTNET_IMAGE` | `ASPIRE_E2E_REQUIRE_DOTNET_IMAGE` | +| Polyglot | `tests/Shared/Docker/Dockerfile.e2e-polyglot-base` | `cli-e2e-polyglot-image` | `aspire-cli-e2e-polyglot:prebuilt` | `ASPIRE_E2E_POLYGLOT_IMAGE` | `ASPIRE_E2E_REQUIRE_POLYGLOT_IMAGE` | +| Polyglot Java | `tests/Shared/Docker/Dockerfile.e2e-polyglot-java` | `cli-e2e-polyglot-java-image` | `aspire-cli-e2e-polyglot-java:prebuilt` | `ASPIRE_E2E_POLYGLOT_JAVA_IMAGE` | `ASPIRE_E2E_REQUIRE_POLYGLOT_JAVA_IMAGE` | + +`Dockerfile.e2e-podman` is not part of this contract because it uses a separate privileged Podman-in-Docker runtime path. + +## Fail-fast semantics + +`CliE2ETestHelpers` uses the image environment variable for the selected Dockerfile variant when it is set. If the matching `ASPIRE_E2E_REQUIRE_*_IMAGE` variable is `true` or `1`, the helper throws when the image variable is missing. If the require variable is not set, the helper falls back to building the variant Dockerfile locally. + +This preserves the local development path while making opted-in CI jobs fail early when their prebuilt image artifact was not loaded correctly. + +## Workflow behavior + +The image build workflow has an `includePolyglotImages` input. It defaults to `true` and builds all three shared images. Daily CLI smoke tests set it to `false` because they only need the DotNet image. + +Consumer workflows download image artifacts into `${{ github.workspace }}/cli-e2e-image` and call `eng/scripts/load-cli-e2e-images.sh` to load the images and export the matching environment variables. Regular split CLI E2E jobs always require DotNet and Polyglot images. Java image download and loading is conditional on Java test jobs to avoid transferring the larger Java tarball to every split job. + +## Adding another variant + +When adding a new shared CLI E2E Dockerfile variant: + +1. Add a stable artifact name, image tag, image environment variable, and require environment variable. +2. Update `.github/workflows/build-cli-e2e-image.yml` to build, save, and upload the new image. +3. Update `eng/scripts/load-cli-e2e-images.sh` to load the tarball and export the environment variables. +4. Update workflow consumers to download the artifact where needed. +5. Update `CliE2ETestHelpers.GetDockerfilePath`, `CliE2ETestHelpers.GetPrebuiltImageName`, and the helper tests. + +## Local usage + +To run a CLI E2E test against a prebuilt image locally, load the tarball and set the matching image variable: + +```bash +docker load -i /path/to/aspire-cli-e2e-dotnet.tar.gz +ASPIRE_E2E_DOTNET_IMAGE=aspire-cli-e2e-dotnet:prebuilt \ + dotnet test tests/Aspire.Cli.EndToEnd.Tests/Aspire.Cli.EndToEnd.Tests.csproj \ + -- --filter-class "*.SmokeTests" +``` + +Set the matching `ASPIRE_E2E_REQUIRE_*_IMAGE=true` variable when you want local execution to fail instead of falling back to a Dockerfile build. diff --git a/eng/scripts/load-cli-e2e-images.sh b/eng/scripts/load-cli-e2e-images.sh new file mode 100755 index 00000000000..49d3975c4f2 --- /dev/null +++ b/eng/scripts/load-cli-e2e-images.sh @@ -0,0 +1,186 @@ +#!/usr/bin/env bash + +set -euo pipefail + +image_dir="" +github_env="${GITHUB_ENV:-}" +require_dotnet="false" +require_polyglot="false" +require_java="false" + +usage() { + cat <<'EOF' +Usage: load-cli-e2e-images.sh --image-dir [options] + +Loads prebuilt Aspire CLI E2E Docker image artifacts and exports the matching +ASPIRE_E2E_* image environment variables to GITHUB_ENV. + +Options: + --image-dir Directory containing the image tarballs. + --github-env Environment file to append to. Defaults to GITHUB_ENV. + --require-dotnet true, false, or auto. Defaults to false. + --require-polyglot true, false, or auto. Defaults to false. + --require-java true, false, or auto. Defaults to false. + +Mode behavior: + true The tarball must exist. Load it, export IMAGE and REQUIRE=true. + false Do not load the tarball. Export REQUIRE=false. + auto Load and require the image if the tarball exists; otherwise REQUIRE=false. +EOF +} + +read_value_arg() { + local option="$1" + local value="${2:-}" + if [[ -z "$value" ]]; then + echo "Missing value for $option" >&2 + usage >&2 + exit 2 + fi + + printf "%s" "$value" +} + +normalize_mode() { + local option="$1" + local value="$2" + local normalized_value + normalized_value="$(printf "%s" "$value" | tr '[:upper:]' '[:lower:]')" + case "$normalized_value" in + true|1) + printf "true" + ;; + false|0) + printf "false" + ;; + auto) + printf "auto" + ;; + *) + echo "Invalid value for $option: $value. Expected true, false, or auto." >&2 + exit 2 + ;; + esac +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --image-dir) + image_dir="$(read_value_arg "$1" "${2:-}")" + shift 2 + ;; + --image-dir=*) + image_dir="${1#*=}" + shift + ;; + --github-env) + github_env="$(read_value_arg "$1" "${2:-}")" + shift 2 + ;; + --github-env=*) + github_env="${1#*=}" + shift + ;; + --require-dotnet) + require_dotnet="$(normalize_mode "$1" "$(read_value_arg "$1" "${2:-}")")" + shift 2 + ;; + --require-dotnet=*) + require_dotnet="$(normalize_mode "--require-dotnet" "${1#*=}")" + shift + ;; + --require-polyglot) + require_polyglot="$(normalize_mode "$1" "$(read_value_arg "$1" "${2:-}")")" + shift 2 + ;; + --require-polyglot=*) + require_polyglot="$(normalize_mode "--require-polyglot" "${1#*=}")" + shift + ;; + --require-java) + require_java="$(normalize_mode "$1" "$(read_value_arg "$1" "${2:-}")")" + shift 2 + ;; + --require-java=*) + require_java="$(normalize_mode "--require-java" "${1#*=}")" + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Unknown argument: $1" >&2 + usage >&2 + exit 2 + ;; + esac +done + +if [[ -z "$image_dir" ]]; then + echo "--image-dir is required." >&2 + usage >&2 + exit 2 +fi + +if [[ -z "$github_env" ]]; then + echo "GITHUB_ENV is not set. Pass --github-env or run inside GitHub Actions." >&2 + exit 2 +fi + +write_env() { + local name="$1" + local value="$2" + printf "%s=%s\n" "$name" "$value" >> "$github_env" +} + +load_image() { + local display_name="$1" + local mode="$2" + local tarball_name="$3" + local image_tag="$4" + local image_env_name="$5" + local require_env_name="$6" + local tarball_path="${image_dir%/}/$tarball_name" + + if [[ "$mode" == "auto" ]]; then + if [[ -f "$tarball_path" ]]; then + mode="true" + else + mode="false" + fi + fi + + if [[ "$mode" == "false" ]]; then + write_env "$require_env_name" "false" + return + fi + + if [[ ! -f "$tarball_path" ]]; then + echo "::error::$display_name image is required but artifact was not found at $tarball_path" + exit 1 + fi + + docker load -i "$tarball_path" + docker image inspect "$image_tag" > /dev/null + write_env "$image_env_name" "$image_tag" + write_env "$require_env_name" "true" +} + +load_image ".NET" "$require_dotnet" \ + "aspire-cli-e2e-dotnet.tar.gz" \ + "aspire-cli-e2e-dotnet:prebuilt" \ + "ASPIRE_E2E_DOTNET_IMAGE" \ + "ASPIRE_E2E_REQUIRE_DOTNET_IMAGE" + +load_image "polyglot" "$require_polyglot" \ + "aspire-cli-e2e-polyglot.tar.gz" \ + "aspire-cli-e2e-polyglot:prebuilt" \ + "ASPIRE_E2E_POLYGLOT_IMAGE" \ + "ASPIRE_E2E_REQUIRE_POLYGLOT_IMAGE" + +load_image "Java polyglot" "$require_java" \ + "aspire-cli-e2e-polyglot-java.tar.gz" \ + "aspire-cli-e2e-polyglot-java:prebuilt" \ + "ASPIRE_E2E_POLYGLOT_JAVA_IMAGE" \ + "ASPIRE_E2E_REQUIRE_POLYGLOT_JAVA_IMAGE" diff --git a/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliE2ETestHelpers.cs b/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliE2ETestHelpers.cs index b92a2b4d373..efa72c80999 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliE2ETestHelpers.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliE2ETestHelpers.cs @@ -18,6 +18,12 @@ namespace Aspire.Cli.EndToEnd.Tests.Helpers; internal static class CliE2ETestHelpers { internal const string CliArchiveDirEnvironmentVariableName = CliInstallStrategy.CliArchiveDirEnvironmentVariableName; + internal const string DotNetImageEnvironmentVariableName = "ASPIRE_E2E_DOTNET_IMAGE"; + internal const string RequireDotNetImageEnvironmentVariableName = "ASPIRE_E2E_REQUIRE_DOTNET_IMAGE"; + internal const string PolyglotImageEnvironmentVariableName = "ASPIRE_E2E_POLYGLOT_IMAGE"; + internal const string RequirePolyglotImageEnvironmentVariableName = "ASPIRE_E2E_REQUIRE_POLYGLOT_IMAGE"; + internal const string PolyglotJavaImageEnvironmentVariableName = "ASPIRE_E2E_POLYGLOT_JAVA_IMAGE"; + internal const string RequirePolyglotJavaImageEnvironmentVariableName = "ASPIRE_E2E_REQUIRE_POLYGLOT_JAVA_IMAGE"; internal const string CliVersionOutputDirEnvironmentVariableName = "ASPIRE_E2E_CLI_VERSION_OUTPUT_DIR"; internal const string ContainerCliVersionOutputDir = "/tmp/aspire-cli-versions"; private static readonly Regex s_commitShaPattern = new("^[0-9a-fA-F]{40}$", RegexOptions.Compiled); @@ -158,16 +164,10 @@ internal static Hex1bTerminal CreateDockerTestTerminal( { var recordingPath = GetTestResultsRecordingPath(testName); RegisterCaptureFile("recording.cast", recordingPath); - var dockerfileName = variant switch - { - DockerfileVariant.DotNet => "Dockerfile.e2e", - DockerfileVariant.Polyglot => "Dockerfile.e2e-polyglot-base", - DockerfileVariant.PolyglotJava => "Dockerfile.e2e-polyglot-java", - _ => throw new ArgumentOutOfRangeException(nameof(variant)), - }; - var dockerfilePath = Path.Combine(repoRoot, "tests", "Shared", "Docker", dockerfileName); + var dockerfilePath = GetDockerfilePath(repoRoot, variant); + var prebuiltImageName = GetPrebuiltImageName(variant); - if (variant is DockerfileVariant.PolyglotJava) + if (variant is DockerfileVariant.PolyglotJava && prebuiltImageName is null) { EnsurePolyglotBaseImage(repoRoot, output); } @@ -177,7 +177,8 @@ internal static Hex1bTerminal CreateDockerTestTerminal( output.WriteLine($" Strategy: {strategy}"); output.WriteLine($" Expected ver: {strategy.ExpectedVersion ?? "(not available)"}"); output.WriteLine($" Variant: {variant}"); - output.WriteLine($" Dockerfile: {dockerfilePath}"); + output.WriteLine($" Dockerfile: {(prebuiltImageName is null ? dockerfilePath : "(prebuilt image)")}"); + output.WriteLine($" Image: {prebuiltImageName ?? "(build from Dockerfile)"}"); output.WriteLine($" Workspace: {workspace?.WorkspaceRoot.FullName ?? "(none)"}"); output.WriteLine($" Docker socket: {mountDockerSocket}"); output.WriteLine($" Dimensions: {width}x{height}"); @@ -189,8 +190,7 @@ internal static Hex1bTerminal CreateDockerTestTerminal( .WithAsciinemaRecording(recordingPath) .WithDockerContainer(c => { - c.DockerfilePath = dockerfilePath; - c.BuildContext = repoRoot; + ConfigureDockerContainerSource(c, repoRoot, variant); if (mountDockerSocket) { @@ -218,13 +218,105 @@ internal static Hex1bTerminal CreateDockerTestTerminal( } } - // Delegate all mode-specific Docker config to the strategy - strategy.ConfigureContainer(c); + ConfigureDockerContainerStrategy(c, strategy, prebuiltImageSelected: prebuiltImageName is not null); }); return builder.Build(); } + internal static void ConfigureDockerContainerStrategy(DockerContainerOptions options, CliInstallStrategy strategy, bool prebuiltImageSelected = false) + { + // Delegate all mode-specific Docker config to the strategy. + strategy.ConfigureContainer(options); + + if (prebuiltImageSelected) + { + if (!string.IsNullOrEmpty(options.DockerfilePath) || !string.IsNullOrEmpty(options.BuildContext)) + { + throw new InvalidOperationException("A prebuilt CLI E2E image was selected, but Dockerfile configuration was also set. Prebuilt-image runs must not fall back to Dockerfile builds."); + } + + options.BuildArgs.Clear(); + } + } + + internal static void ConfigureDockerContainerSource(DockerContainerOptions options, string repoRoot, DockerfileVariant variant) + { + var prebuiltImageName = GetPrebuiltImageName(variant); + if (prebuiltImageName is not null) + { + options.Image = prebuiltImageName; + return; + } + + if (variant is DockerfileVariant.DotNet && IsDotNetImageRequired()) + { + throw new InvalidOperationException($"{DotNetImageEnvironmentVariableName} must be set when the prebuilt CLI E2E .NET image is required."); + } + + if (variant is DockerfileVariant.Polyglot && IsPolyglotImageRequired()) + { + throw new InvalidOperationException($"{PolyglotImageEnvironmentVariableName} must be set when the prebuilt CLI E2E polyglot image is required."); + } + + if (variant is DockerfileVariant.PolyglotJava && IsPolyglotJavaImageRequired()) + { + throw new InvalidOperationException($"{PolyglotJavaImageEnvironmentVariableName} must be set when the prebuilt CLI E2E Java image is required."); + } + + options.DockerfilePath = GetDockerfilePath(repoRoot, variant); + options.BuildContext = repoRoot; + } + + private static string? GetPrebuiltImageName(DockerfileVariant variant) + { + var environmentVariableName = variant switch + { + DockerfileVariant.DotNet => DotNetImageEnvironmentVariableName, + DockerfileVariant.Polyglot => PolyglotImageEnvironmentVariableName, + DockerfileVariant.PolyglotJava => PolyglotJavaImageEnvironmentVariableName, + _ => throw new ArgumentOutOfRangeException(nameof(variant)), + }; + + var imageName = Environment.GetEnvironmentVariable(environmentVariableName); + return string.IsNullOrWhiteSpace(imageName) ? null : imageName.Trim(); + } + + private static string GetDockerfilePath(string repoRoot, DockerfileVariant variant) + { + var dockerfileName = variant switch + { + DockerfileVariant.DotNet => "Dockerfile.e2e", + DockerfileVariant.Polyglot => "Dockerfile.e2e-polyglot-base", + DockerfileVariant.PolyglotJava => "Dockerfile.e2e-polyglot-java", + _ => throw new ArgumentOutOfRangeException(nameof(variant)), + }; + + return Path.Combine(repoRoot, "tests", "Shared", "Docker", dockerfileName); + } + + private static bool IsDotNetImageRequired() + { + return IsImageRequired(RequireDotNetImageEnvironmentVariableName); + } + + private static bool IsPolyglotImageRequired() + { + return IsImageRequired(RequirePolyglotImageEnvironmentVariableName); + } + + private static bool IsPolyglotJavaImageRequired() + { + return IsImageRequired(RequirePolyglotJavaImageEnvironmentVariableName); + } + + private static bool IsImageRequired(string environmentVariableName) + { + var value = Environment.GetEnvironmentVariable(environmentVariableName); + return string.Equals(value, "true", StringComparison.OrdinalIgnoreCase) || + string.Equals(value, "1", StringComparison.OrdinalIgnoreCase); + } + /// /// Creates a Hex1b terminal backed by a privileged Docker container that runs Podman internally. /// diff --git a/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliInstallStrategyTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliInstallStrategyTests.cs index 6c79e77c563..65060ccc578 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliInstallStrategyTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliInstallStrategyTests.cs @@ -186,6 +186,213 @@ public void ConfigureContainer_DoesNotAddUbuntuAptMirrorBuildArgWhenEnvironmentV Assert.DoesNotContain(CliInstallStrategy.UbuntuAptMirrorBuildArgName, options.BuildArgs.Keys); } + [Fact] + public void ConfigureDockerContainerSource_UsesDotNetImageWhenEnvironmentVariableIsSet() + { + using var environment = WithCleanCliE2ETestEnvironment( + (CliE2ETestHelpers.DotNetImageEnvironmentVariableName, "aspire-cli-e2e-dotnet:prebuilt"), + (CliE2ETestHelpers.RequireDotNetImageEnvironmentVariableName, "true"), + ("GITHUB_ACTIONS", "true")); + var options = new DockerContainerOptions(); + + CliE2ETestHelpers.ConfigureDockerContainerSource(options, "/repo", CliE2ETestHelpers.DockerfileVariant.DotNet); + + Assert.Equal("aspire-cli-e2e-dotnet:prebuilt", options.Image); + Assert.True(string.IsNullOrEmpty(options.DockerfilePath)); + Assert.True(string.IsNullOrEmpty(options.BuildContext)); + } + + [Fact] + public void ConfigureContainer_BuildArgsCanBeClearedForPrebuiltImage() + { + using var environment = WithCleanCliE2ETestEnvironment( + (CliE2ETestHelpers.DotNetImageEnvironmentVariableName, "aspire-cli-e2e-dotnet:prebuilt"), + (CliE2ETestHelpers.RequireDotNetImageEnvironmentVariableName, "true"), + (CliInstallStrategy.UbuntuAptMirrorEnvironmentVariableName, "http://azure.archive.ubuntu.com/ubuntu/")); + var strategy = CliInstallStrategy.LatestGa(); + var options = new DockerContainerOptions(); + + CliE2ETestHelpers.ConfigureDockerContainerSource(options, "/repo", CliE2ETestHelpers.DockerfileVariant.DotNet); + CliE2ETestHelpers.ConfigureDockerContainerStrategy(options, strategy, prebuiltImageSelected: true); + + Assert.Equal("aspire-cli-e2e-dotnet:prebuilt", options.Image); + Assert.Empty(options.BuildArgs); + } + + [Fact] + public void ConfigureContainer_ThrowsWhenPrebuiltImageAlsoHasDockerfileConfiguration() + { + using var environment = WithCleanCliE2ETestEnvironment( + (CliE2ETestHelpers.DotNetImageEnvironmentVariableName, "aspire-cli-e2e-dotnet:prebuilt"), + (CliE2ETestHelpers.RequireDotNetImageEnvironmentVariableName, "true")); + var strategy = CliInstallStrategy.LatestGa(); + var options = new DockerContainerOptions(); + + CliE2ETestHelpers.ConfigureDockerContainerSource(options, "/repo", CliE2ETestHelpers.DockerfileVariant.DotNet); + options.DockerfilePath = "/unexpected/Dockerfile"; + options.BuildContext = "/unexpected"; + + var exception = Assert.Throws(() => + CliE2ETestHelpers.ConfigureDockerContainerStrategy(options, strategy, prebuiltImageSelected: true)); + + Assert.Contains("prebuilt CLI E2E image", exception.Message); + } + + [Fact] + public void ConfigureContainer_PreservesBuildArgsForDockerfileVariant() + { + using var environment = WithCleanCliE2ETestEnvironment( + (CliE2ETestHelpers.DotNetImageEnvironmentVariableName, "aspire-cli-e2e-dotnet:prebuilt"), + (CliE2ETestHelpers.RequireDotNetImageEnvironmentVariableName, "true"), + (CliInstallStrategy.UbuntuAptMirrorEnvironmentVariableName, "http://azure.archive.ubuntu.com/ubuntu/")); + var strategy = CliInstallStrategy.LatestGa(); + var options = new DockerContainerOptions(); + + CliE2ETestHelpers.ConfigureDockerContainerSource(options, "/repo", CliE2ETestHelpers.DockerfileVariant.Polyglot); + CliE2ETestHelpers.ConfigureDockerContainerStrategy(options, strategy); + + Assert.Equal(Path.Combine("/repo", "tests", "Shared", "Docker", "Dockerfile.e2e-polyglot-base"), options.DockerfilePath); + Assert.Equal("true", options.BuildArgs["SKIP_SOURCE_BUILD"]); + Assert.Equal("http://azure.archive.ubuntu.com/ubuntu/", options.BuildArgs[CliInstallStrategy.UbuntuAptMirrorBuildArgName]); + } + + [Fact] + public void ConfigureDockerContainerSource_UsesPolyglotImageWhenEnvironmentVariableIsSet() + { + using var environment = WithCleanCliE2ETestEnvironment( + (CliE2ETestHelpers.PolyglotImageEnvironmentVariableName, "aspire-cli-e2e-polyglot:prebuilt"), + (CliE2ETestHelpers.RequirePolyglotImageEnvironmentVariableName, "true"), + ("GITHUB_ACTIONS", "true")); + var options = new DockerContainerOptions(); + + CliE2ETestHelpers.ConfigureDockerContainerSource(options, "/repo", CliE2ETestHelpers.DockerfileVariant.Polyglot); + + Assert.Equal("aspire-cli-e2e-polyglot:prebuilt", options.Image); + Assert.True(string.IsNullOrEmpty(options.DockerfilePath)); + Assert.True(string.IsNullOrEmpty(options.BuildContext)); + } + + [Fact] + public void ConfigureDockerContainerSource_RequiresPolyglotImageWhenConfigured() + { + using var environment = WithCleanCliE2ETestEnvironment( + (CliE2ETestHelpers.PolyglotImageEnvironmentVariableName, null), + (CliE2ETestHelpers.RequirePolyglotImageEnvironmentVariableName, "true"), + ("GITHUB_ACTIONS", "true")); + var options = new DockerContainerOptions(); + + var exception = Assert.Throws(() => + CliE2ETestHelpers.ConfigureDockerContainerSource(options, "/repo", CliE2ETestHelpers.DockerfileVariant.Polyglot)); + + Assert.Contains(CliE2ETestHelpers.PolyglotImageEnvironmentVariableName, exception.Message); + } + + [Fact] + public void ConfigureDockerContainerSource_UsesPolyglotJavaImageWhenEnvironmentVariableIsSet() + { + using var environment = WithCleanCliE2ETestEnvironment( + (CliE2ETestHelpers.PolyglotJavaImageEnvironmentVariableName, "aspire-cli-e2e-polyglot-java:prebuilt"), + (CliE2ETestHelpers.RequirePolyglotJavaImageEnvironmentVariableName, "true"), + ("GITHUB_ACTIONS", "true")); + var options = new DockerContainerOptions(); + + CliE2ETestHelpers.ConfigureDockerContainerSource(options, "/repo", CliE2ETestHelpers.DockerfileVariant.PolyglotJava); + + Assert.Equal("aspire-cli-e2e-polyglot-java:prebuilt", options.Image); + Assert.True(string.IsNullOrEmpty(options.DockerfilePath)); + Assert.True(string.IsNullOrEmpty(options.BuildContext)); + } + + [Fact] + public void ConfigureDockerContainerSource_RequiresPolyglotJavaImageWhenConfigured() + { + using var environment = WithCleanCliE2ETestEnvironment( + (CliE2ETestHelpers.PolyglotJavaImageEnvironmentVariableName, null), + (CliE2ETestHelpers.RequirePolyglotJavaImageEnvironmentVariableName, "true"), + ("GITHUB_ACTIONS", "true")); + var options = new DockerContainerOptions(); + + var exception = Assert.Throws(() => + CliE2ETestHelpers.ConfigureDockerContainerSource(options, "/repo", CliE2ETestHelpers.DockerfileVariant.PolyglotJava)); + + Assert.Contains(CliE2ETestHelpers.PolyglotJavaImageEnvironmentVariableName, exception.Message); + } + + [Fact] + public void ConfigureDockerContainerSource_IgnoresPolyglotJavaImageForPolyglotVariant() + { + using var environment = WithCleanCliE2ETestEnvironment( + (CliE2ETestHelpers.PolyglotJavaImageEnvironmentVariableName, "aspire-cli-e2e-polyglot-java:prebuilt"), + (CliE2ETestHelpers.RequirePolyglotJavaImageEnvironmentVariableName, "true"), + ("GITHUB_ACTIONS", "true")); + var options = new DockerContainerOptions(); + + CliE2ETestHelpers.ConfigureDockerContainerSource(options, "/repo", CliE2ETestHelpers.DockerfileVariant.Polyglot); + + Assert.Equal(Path.Combine("/repo", "tests", "Shared", "Docker", "Dockerfile.e2e-polyglot-base"), options.DockerfilePath); + Assert.Equal("/repo", options.BuildContext); + } + + [Fact] + public void ConfigureDockerContainerSource_FallsBackToDockerfileOutsideCI() + { + using var environment = WithCleanCliE2ETestEnvironment( + (CliE2ETestHelpers.DotNetImageEnvironmentVariableName, null), + (CliE2ETestHelpers.RequireDotNetImageEnvironmentVariableName, null), + ("GITHUB_ACTIONS", null)); + var options = new DockerContainerOptions(); + + CliE2ETestHelpers.ConfigureDockerContainerSource(options, "/repo", CliE2ETestHelpers.DockerfileVariant.DotNet); + + Assert.Equal(Path.Combine("/repo", "tests", "Shared", "Docker", "Dockerfile.e2e"), options.DockerfilePath); + Assert.Equal("/repo", options.BuildContext); + } + + [Fact] + public void ConfigureDockerContainerSource_RequiresDotNetImageWhenConfigured() + { + using var environment = WithCleanCliE2ETestEnvironment( + (CliE2ETestHelpers.DotNetImageEnvironmentVariableName, null), + (CliE2ETestHelpers.RequireDotNetImageEnvironmentVariableName, "true"), + ("GITHUB_ACTIONS", "true")); + var options = new DockerContainerOptions(); + + var exception = Assert.Throws(() => + CliE2ETestHelpers.ConfigureDockerContainerSource(options, "/repo", CliE2ETestHelpers.DockerfileVariant.DotNet)); + + Assert.Contains(CliE2ETestHelpers.DotNetImageEnvironmentVariableName, exception.Message); + } + + [Fact] + public void ConfigureDockerContainerSource_FallsBackToDockerfileInCIWhenDotNetImageIsNotRequired() + { + using var environment = WithCleanCliE2ETestEnvironment( + (CliE2ETestHelpers.DotNetImageEnvironmentVariableName, null), + (CliE2ETestHelpers.RequireDotNetImageEnvironmentVariableName, null), + ("GITHUB_ACTIONS", "true")); + var options = new DockerContainerOptions(); + + CliE2ETestHelpers.ConfigureDockerContainerSource(options, "/repo", CliE2ETestHelpers.DockerfileVariant.DotNet); + + Assert.Equal(Path.Combine("/repo", "tests", "Shared", "Docker", "Dockerfile.e2e"), options.DockerfilePath); + Assert.Equal("/repo", options.BuildContext); + } + + [Fact] + public void ConfigureDockerContainerSource_IgnoresDotNetImageForPolyglotVariant() + { + using var environment = WithCleanCliE2ETestEnvironment( + (CliE2ETestHelpers.DotNetImageEnvironmentVariableName, "aspire-cli-e2e-dotnet:prebuilt"), + (CliE2ETestHelpers.RequireDotNetImageEnvironmentVariableName, "true"), + ("GITHUB_ACTIONS", "true")); + var options = new DockerContainerOptions(); + + CliE2ETestHelpers.ConfigureDockerContainerSource(options, "/repo", CliE2ETestHelpers.DockerfileVariant.Polyglot); + + Assert.Equal(Path.Combine("/repo", "tests", "Shared", "Docker", "Dockerfile.e2e-polyglot-base"), options.DockerfilePath); + Assert.Equal("/repo", options.BuildContext); + } + [Fact] public void Detect_DotnetTool_WhenEnvironmentVariableIsSet() { @@ -772,6 +979,28 @@ public void Detect_ReturnsPreinstalled_WhenPreinstalledIsSet() Assert.Equal(CliInstallMode.Preinstalled, strategy.Mode); } + private static EnvironmentVariableScope WithCleanCliE2ETestEnvironment(params (string Name, string? Value)[] variables) + { + (string Name, string? Value)[] defaults = + [ + (CliE2ETestHelpers.DotNetImageEnvironmentVariableName, null), + (CliE2ETestHelpers.RequireDotNetImageEnvironmentVariableName, null), + (CliE2ETestHelpers.PolyglotImageEnvironmentVariableName, null), + (CliE2ETestHelpers.RequirePolyglotImageEnvironmentVariableName, null), + (CliE2ETestHelpers.PolyglotJavaImageEnvironmentVariableName, null), + (CliE2ETestHelpers.RequirePolyglotJavaImageEnvironmentVariableName, null), + ("GITHUB_ACTIONS", null), + ]; + + var cleanVariables = defaults.ToDictionary(variable => variable.Name, variable => variable.Value); + foreach (var (name, value) in variables) + { + cleanVariables[name] = value; + } + + return new EnvironmentVariableScope([.. cleanVariables.Select(variable => (variable.Key, variable.Value))]); + } + private sealed class EnvironmentVariableScope : IDisposable { private readonly Dictionary _originalValues; diff --git a/tests/Shared/Docker/Dockerfile.e2e b/tests/Shared/Docker/Dockerfile.e2e index b05c5299010..dd6726b4944 100644 --- a/tests/Shared/Docker/Dockerfile.e2e +++ b/tests/Shared/Docker/Dockerfile.e2e @@ -1,7 +1,7 @@ # Multi-stage Dockerfile for Aspire E2E testing (.NET variant). # # Includes: .NET SDK 10.0, Docker CLI (via host socket mount), gh CLI, -# Node.js, Python, Java, Aspire install scripts. +# Node.js, Python, Aspire install scripts. # # Usage: # Local dev (build from source): @@ -86,19 +86,6 @@ RUN apt-get update -qq && \ RUN curl -LsSf https://astral.sh/uv/install.sh | sh ENV PATH="/root/.local/bin:${PATH}" -# Install Java (needed for Java AppHost tests). -RUN apt-get update -qq && \ - apt-get install -y --no-install-recommends openjdk-21-jdk && \ - rm -rf /var/lib/apt/lists/* - -# --- Aspire CLI setup --- - -# Copy the install scripts. -COPY eng/scripts/get-aspire-cli.sh /opt/aspire-scripts/ -COPY eng/scripts/get-aspire-cli-pr.sh /opt/aspire-scripts/ -COPY tests/Shared/Docker/NuGet.DotnetTool.config /opt/aspire-scripts/NuGet.config -RUN chmod +x /opt/aspire-scripts/*.sh - # Copy the bundle directory from the build stage. # When SKIP_SOURCE_BUILD=true the directory is empty; the conditional RUN below handles both cases. COPY --from=build /repo/artifacts/bundle /tmp/bundle @@ -110,6 +97,15 @@ RUN if ls /tmp/bundle/aspire-*-linux-x64.tar.gz 1>/dev/null 2>&1; then \ rm -f /tmp/bundle/aspire-*-linux-x64.tar.gz; \ fi +# --- Aspire CLI setup --- + +# Keep repository-dependent copies after the expensive toolchain and bundle layers so BuildKit +# cache hits are preserved when source files or scripts change independently. +COPY eng/scripts/get-aspire-cli.sh /opt/aspire-scripts/ +COPY eng/scripts/get-aspire-cli-pr.sh /opt/aspire-scripts/ +COPY tests/Shared/Docker/NuGet.DotnetTool.config /opt/aspire-scripts/NuGet.config +RUN chmod +x /opt/aspire-scripts/*.sh + # Create the workspace mount point. RUN mkdir -p /workspace WORKDIR /workspace