diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..25f6404 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @heroku/languages diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..c0e00fb --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,10 @@ +version: 2 +updates: + - package-ecosystem: "cargo" + directory: "/" + schedule: + interval: "monthly" + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "monthly" diff --git a/.github/workflows/check_changelog.yml b/.github/workflows/check_changelog.yml new file mode 100644 index 0000000..b3ab6a1 --- /dev/null +++ b/.github/workflows/check_changelog.yml @@ -0,0 +1,22 @@ +name: Check Changelog + +on: + pull_request: + types: [opened, reopened, labeled, unlabeled, synchronize] + +permissions: + contents: read + +jobs: + check-changelog: + runs-on: ubuntu-latest + if: | + !contains(github.event.pull_request.labels.*.name, 'skip changelog') && + !contains(github.event.pull_request.labels.*.name, 'dependencies') + steps: + - name: Checkout + uses: actions/checkout@v3 + - name: Check that CHANGELOG is touched + run: | + git fetch origin ${{ github.base_ref }} --depth 1 && \ + git diff remotes/origin/${{ github.base_ref }} --name-only | grep CHANGELOG.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..0ecdf9c --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,66 @@ +name: CI + +on: + push: + # Avoid duplicate builds on PRs. + branches: + - main + pull_request: + +permissions: + contents: read + +env: + CARGO_TERM_COLOR: always + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v3 + - name: Update Rust toolchain + run: rustup update + - name: Rust Cache + uses: Swatinem/rust-cache@v2.2.1 + - name: Clippy + run: cargo clippy --all-targets --locked -- --deny warnings + - name: rustfmt + run: cargo fmt -- --check + + unit-test: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v3 + - name: Update Rust toolchain + run: rustup update + - name: Rust Cache + uses: Swatinem/rust-cache@v2.2.1 + - name: Run unit tests + run: cargo test --locked + + integration-test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + builder: ["builder:22", "buildpacks:20"] + env: + INTEGRATION_TEST_CNB_BUILDER: heroku/${{ matrix.builder }} + steps: + - name: Checkout + uses: actions/checkout@v3 + - name: Install musl-tools + run: sudo apt-get install musl-tools --no-install-recommends + - name: Update Rust toolchain + run: rustup update + - name: Install Rust linux-musl target + run: rustup target add x86_64-unknown-linux-musl + - name: Rust Cache + uses: Swatinem/rust-cache@v2.2.1 + - name: Install Pack CLI + uses: buildpacks/github-actions/setup-pack@v5.0.1 + - name: Run integration tests + # Runs only tests annotated with the `ignore` attribute (which in this repo, are the integration tests). + run: cargo test --locked -- --ignored --test-threads 5 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..65b39fc --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,80 @@ +name: Release Buildpack + +on: + workflow_dispatch: + +permissions: + contents: write + +env: + BUILDPACK_DOCKER_REPO: docker.io/heroku/buildpack-python + CARGO_TERM_COLOR: always + +jobs: + # Releases the buildpack to Docker Hub and registers it with the CNB Buildpack Registry. + # This release process intentionally does not create a .cnb file release for now, since + # there are currently no use-cases that need it for Python. + release: + name: Release heroku/python + runs-on: ubuntu-latest + steps: + # Setup + - name: Checkout + uses: actions/checkout@v3 + - name: Install musl-tools + run: sudo apt-get install musl-tools --no-install-recommends + - name: Update Rust toolchain + run: rustup update + - name: Install Rust linux-musl target + run: rustup target add x86_64-unknown-linux-musl + - name: Rust Cache + uses: Swatinem/rust-cache@v2.2.1 + - name: Install libcnb-cargo + run: cargo install libcnb-cargo + - name: Install Pack CLI + uses: buildpacks/github-actions/setup-pack@v5.0.1 + - name: Install yj and crane + uses: buildpacks/github-actions/setup-tools@v5.0.1 + - name: Login to Docker Hub + uses: docker/login-action@v2 + with: + registry: docker.io + username: ${{ secrets.DOCKER_HUB_USER }} + password: ${{ secrets.DOCKER_HUB_TOKEN }} + + # Build + - name: Compile the buildpack + run: cargo libcnb package --release + + # Publish + - name: Read buildpack metadata + run: | + echo "buildpack_id=$(yj -t < buildpack.toml | jq -r .buildpack.id)" >> $GITHUB_ENV + echo "buildpack_version=$(yj -t < buildpack.toml | jq -r .buildpack.version)" >> $GITHUB_ENV + - name: Check version is unique on Docker Hub + run: | + if docker manifest inspect "${{ env.BUILDPACK_DOCKER_REPO }}:${{ env.buildpack_version }}" > /dev/null; then + echo "Duplicate version found on Docker Hub ${{ env.BUILDPACK_DOCKER_REPO }}:${{ env.buildpack_version }}" + exit 1 + fi + - name: Publish the buildpack to Docker Hub + run: pack buildpack package --path target/buildpack/release/heroku_python --publish "${{ env.BUILDPACK_DOCKER_REPO }}:${{ env.buildpack_version }}" + - name: Calculate the buildpack image digest + run: echo "buildpack_digest=$(crane digest ${{ env.BUILDPACK_DOCKER_REPO }}:${{ env.buildpack_version }})" >> $GITHUB_ENV + - name: Register the new version with the CNB Buildpack Registry + uses: docker://ghcr.io/buildpacks/actions/registry/request-add-entry:5.0.1 + with: + token: ${{ secrets.CNB_REGISTRY_RELEASE_BOT_GITHUB_TOKEN }} + id: ${{ env.buildpack_id }} + version: ${{ env.buildpack_version }} + address: ${{ env.BUILDPACK_DOCKER_REPO }}@${{ env.buildpack_digest }} + - name: Create GitHub release + uses: actions/create-release@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + tag_name: v${{ env.buildpack_version }} + release_name: v${{ env.buildpack_version }} + body: | + See the [CHANGELOG](./CHANGELOG.md) for details. + draft: false diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..280b2ea --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +__pycache__/ +target/ +.DS_Store diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..4b9c1c6 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,3 @@ +{ + "recommendations": ["rust-lang.rust-analyzer"], +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..108d59e --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,6 @@ +{ + "rust-analyzer.check.command": "clippy", + "rust-analyzer.imports.granularity.enforce": true, + "rust-analyzer.imports.granularity.group": "module", + "rust-analyzer.imports.prefix": "crate", +} diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..53a218b --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,12 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added + +- Initial implementation. diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..5a5f12f --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1547 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "adler" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "autocfg" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" + +[[package]] +name = "base64" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" + +[[package]] +name = "base64" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4a4ddaa51a5bc52a6948f74c06d20aaaddb71924eab79b8c97a8c556e942d6a" + +[[package]] +name = "bit-set" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bollard" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af254ed2da4936ef73309e9597180558821cb16ae9bba4cb24ce6b612d8d80ed" +dependencies = [ + "base64 0.21.0", + "bollard-stubs", + "bytes", + "futures-core", + "futures-util", + "hex", + "http", + "hyper", + "hyperlocal", + "log", + "pin-project-lite", + "serde", + "serde_derive", + "serde_json", + "serde_repr", + "serde_urlencoded", + "thiserror", + "tokio", + "tokio-util", + "url", + "winapi", +] + +[[package]] +name = "bollard-stubs" +version = "1.42.0-rc.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "602bda35f33aeb571cef387dcd4042c643a8bf689d8aaac2cc47ea24cb7bc7e0" +dependencies = [ + "serde", + "serde_with", +] + +[[package]] +name = "bumpalo" +version = "3.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d261e256854913907f67ed06efbc3338dfe6179796deefc1ff763fc1aee5535" + +[[package]] +name = "bytes" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89b2fd2a0dcf38d7971e2194b6b6eebab45ae01067456a7fd93d5547a61b70be" + +[[package]] +name = "camino" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6031a462f977dd38968b6f23378356512feeace69cef817e1a4475108093cec3" +dependencies = [ + "serde", +] + +[[package]] +name = "cargo-platform" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbdb825da8a5df079a43676dbe042702f1707b1109f713a01420fbb4cc71fa27" +dependencies = [ + "serde", +] + +[[package]] +name = "cargo_metadata" +version = "0.15.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08a1ec454bc3eead8719cb56e15dbbfecdbc14e4b3a3ae4936cc6e31f5fc0d07" +dependencies = [ + "camino", + "cargo-platform", + "semver", + "serde", + "serde_json", + "thiserror", +] + +[[package]] +name = "cc" +version = "1.0.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50d30906286121d95be3d479533b458f87493b30a4b5f79a607db8f5d11aa91f" + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "chrono" +version = "0.4.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16b0a3d9ed01224b22057780a37bb8c5dbfe1be8ba48678e7bf57ec4b385411f" +dependencies = [ + "iana-time-zone", + "num-integer", + "num-traits", + "serde", + "winapi", +] + +[[package]] +name = "codespan-reporting" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3538270d33cc669650c4b093848450d380def10c331d38c768e34cac80576e6e" +dependencies = [ + "termcolor", + "unicode-width", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5827cebf4670468b8772dd191856768aedcb1b0278a04f989f7766351917b9dc" + +[[package]] +name = "crc32fast" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b540bd8bc810d3885c6ea91e2018302f68baba2129ab3e88f32389ee9370880d" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "cxx" +version = "1.0.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a140f260e6f3f79013b8bfc65e7ce630c9ab4388c6a89c71e07226f49487b72" +dependencies = [ + "cc", + "cxxbridge-flags", + "cxxbridge-macro", + "link-cplusplus", +] + +[[package]] +name = "cxx-build" +version = "1.0.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da6383f459341ea689374bf0a42979739dc421874f112ff26f829b8040b8e613" +dependencies = [ + "cc", + "codespan-reporting", + "once_cell", + "proc-macro2", + "quote", + "scratch", + "syn", +] + +[[package]] +name = "cxxbridge-flags" +version = "1.0.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90201c1a650e95ccff1c8c0bb5a343213bdd317c6e600a93075bca2eff54ec97" + +[[package]] +name = "cxxbridge-macro" +version = "1.0.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b75aed41bb2e6367cae39e6326ef817a851db13c13e4f3263714ca3cfb8de56" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "either" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fcaabb2fef8c910e7f4c7ce9f67a1283a1715879a7c230ca9d6d1ae31f16d91" + +[[package]] +name = "errno" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f639046355ee4f37944e44f60642c6f3a7efa3cf6b78c78a0d989a8ce6c396a1" +dependencies = [ + "errno-dragonfly", + "libc", + "winapi", +] + +[[package]] +name = "errno-dragonfly" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa68f1b12764fab894d2755d2518754e71b4fd80ecfb822714a1206c2aab39bf" +dependencies = [ + "cc", + "libc", +] + +[[package]] +name = "fancy-regex" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b95f7c0680e4142284cf8b22c14a476e87d61b004a3a0861872b32ef7ead40a2" +dependencies = [ + "bit-set", + "regex", +] + +[[package]] +name = "fastrand" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e51093e27b0797c359783294ca4f0a911c270184cb10f85783b118614a1501be" +dependencies = [ + "instant", +] + +[[package]] +name = "filetime" +version = "0.2.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a3de6e8d11b22ff9edc6d916f890800597d60f8b2da1caf2955c274638d6412" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "windows-sys 0.45.0", +] + +[[package]] +name = "flate2" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8a2db397cb1c8772f31494cb8917e48cd1e64f0fa7efac59fbd741a0a8ce841" +dependencies = [ + "crc32fast", + "libz-sys", + "miniz_oxide", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "form_urlencoded" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9c384f161156f5260c24a097c56119f9be8c798586aecc13afbcbe7b7e26bf8" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "futures-channel" +version = "0.3.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e5317663a9089767a1ec00a487df42e0ca174b61b4483213ac24448e4664df5" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec90ff4d0fe1f57d600049061dc6bb68ed03c7d2fbd697274c41805dcb3f8608" + +[[package]] +name = "futures-macro" +version = "0.3.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95a73af87da33b5acf53acfebdc339fe592ecf5357ac7c0a7734ab9d8c876a70" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f310820bb3e8cfd46c80db4d7fb8353e15dfff853a127158425f31e0be6c8364" + +[[package]] +name = "futures-task" +version = "0.3.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dcf79a1bf610b10f42aea489289c5a2c478a786509693b80cd39c44ccd936366" + +[[package]] +name = "futures-util" +version = "0.3.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c1d6de3acfef38d2be4b1f543f553131788603495be83da675e180c8d6b7bd1" +dependencies = [ + "futures-core", + "futures-macro", + "futures-task", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "h2" +version = "0.3.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5be7b54589b581f624f566bf5d8eb2bab1db736c51528720b6bd36b96b55924d" +dependencies = [ + "bytes", + "fnv", + "futures-core", + "futures-sink", + "futures-util", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hermit-abi" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee512640fe35acbfb4bb779db6f0d80704c2cacfa2e39b601ef3e3f47d1ae4c7" +dependencies = [ + "libc", +] + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "http" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd6effc99afb63425aff9b05836f029929e345a6148a14b7ecd5ab67af944482" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "http-body" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5f38f16d184e36f2408a55281cd658ecbd3ca05cce6d6510a176eca393e26d1" +dependencies = [ + "bytes", + "http", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d897f394bad6a705d5f4104762e116a75639e470d80901eed05a860a95cb1904" + +[[package]] +name = "httpdate" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4a1e36c821dbe04574f602848a19f742f4fb3c98d40449f11bcad18d6b17421" + +[[package]] +name = "hyper" +version = "0.14.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e011372fa0b68db8350aa7a248930ecc7839bf46d8485577d69f117a75f164c" +dependencies = [ + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", + "want", +] + +[[package]] +name = "hyperlocal" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fafdf7b2b2de7c9784f76e02c0935e65a8117ec3b768644379983ab333ac98c" +dependencies = [ + "futures-util", + "hex", + "hyper", + "pin-project", + "tokio", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64c122667b287044802d6ce17ee2ddf13207ed924c712de9a66a5814d5b64765" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "wasm-bindgen", + "winapi", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0703ae284fc167426161c2e3f1da3ea71d94b21bedbcc9494e92b28e334e3dca" +dependencies = [ + "cxx", + "cxx-build", +] + +[[package]] +name = "idna" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e14ddfc70884202db2244c223200c204c2bda1bc6e0998d11b5e024d657209e6" +dependencies = [ + "unicode-bidi", + "unicode-normalization", +] + +[[package]] +name = "indexmap" +version = "1.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1885e79c1fc4b10f0e172c475f458b7f7b93061064d98c3293e98c5ba0c8b399" +dependencies = [ + "autocfg", + "hashbrown", + "serde", +] + +[[package]] +name = "indoc" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f2cb48b81b1dc9f39676bf99f5499babfec7cd8fe14307f7b3d747208fb5690" + +[[package]] +name = "instant" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a5bbe824c507c5da5956355e86a746d82e0e1464f65d862cc5e71da70e94b2c" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "io-lifetimes" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1abeb7a0dd0f8181267ff8adc397075586500b81b28a73e8a0208b00fc170fb3" +dependencies = [ + "libc", + "windows-sys 0.45.0", +] + +[[package]] +name = "itoa" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "453ad9f582a441959e5f0d088b02ce04cfe8d51a8eaf077f12ac6d3e94164ca6" + +[[package]] +name = "js-sys" +version = "0.3.61" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "445dde2150c55e483f3d8416706b97ec8e8237c307e5b7b4b8dd15e6af2a0730" +dependencies = [ + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.139" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "201de327520df007757c1f0adce6e827fe8562fbc28bfd9c15571c66ca1f5f79" + +[[package]] +name = "libcnb" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e4fd7573558173267930e31446da65a0275770bde88847cad4b4cf9a6ff8375" +dependencies = [ + "libcnb-data", + "libcnb-proc-macros", + "serde", + "thiserror", + "toml", +] + +[[package]] +name = "libcnb-data" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c0112478d479c8900929894426818bea8e769ce923536a58baac719d3ca4dcb" +dependencies = [ + "fancy-regex", + "libcnb-proc-macros", + "serde", + "thiserror", + "toml", +] + +[[package]] +name = "libcnb-package" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aacd18d358a1078cf48f518ef8398c504f8d4fc691ba2e8773bafa1a71d66b59" +dependencies = [ + "cargo_metadata", + "libcnb-data", + "toml", + "which", +] + +[[package]] +name = "libcnb-proc-macros" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5930cea22615255081c0c44b902e6e8b37a824ebe1374a7c7d52724d5b7d6e4e" +dependencies = [ + "cargo_metadata", + "fancy-regex", + "quote", + "syn", +] + +[[package]] +name = "libcnb-test" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f86e8c1847c8ba3c37e30841ee241887203110f4373731e7967706ab77c42b7d" +dependencies = [ + "bollard", + "cargo_metadata", + "fastrand", + "fs_extra", + "libcnb-data", + "libcnb-package", + "serde", + "tempfile", + "tokio", + "tokio-stream", +] + +[[package]] +name = "libherokubuildpack" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "878674906e0140191f89047ef1e8c142cb31becce91b4e64b1b6419fe03da7c1" +dependencies = [ + "termcolor", +] + +[[package]] +name = "libz-sys" +version = "1.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9702761c3935f8cc2f101793272e202c72b99da8f4224a19ddcf1279a6450bbf" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "link-cplusplus" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecd207c9c713c34f95a097a5b029ac2ce6010530c7b49d7fea24d977dede04f5" +dependencies = [ + "cc", +] + +[[package]] +name = "linux-raw-sys" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f051f77a7c8e6957c0696eac88f26b0117e54f52d3fc682ab19397a8812846a4" + +[[package]] +name = "log" +version = "0.4.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abb12e687cfb44aa40f41fc3978ef76448f9b6038cad6aef4259d3c095a2382e" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "memchr" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dffe52ecf27772e601905b7522cb4ef790d2cc203488bbd0e2fe85fcb74566d" + +[[package]] +name = "miniz_oxide" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b275950c28b37e794e8c55d88aeb5e139d0ce23fdbbeda68f8d7174abdf9e8fa" +dependencies = [ + "adler", +] + +[[package]] +name = "mio" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b9d9a46eff5b4ff64b45a9e316a6d1e0bc719ef429cbec4dc630684212bfdf9" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.45.0", +] + +[[package]] +name = "num-integer" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "225d3389fb3509a24c93f5c29eb6bde2586b98d9f016636dff58d7c6f7569cd9" +dependencies = [ + "autocfg", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "578ede34cf02f8924ab9447f50c28075b4d3e5b269972345e7e0372b38c6cdcd" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_cpus" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fac9e2da13b5eb447a6ce3d392f23a29d8694bff781bf03a16cd9ac8697593b" +dependencies = [ + "hermit-abi", + "libc", +] + +[[package]] +name = "once_cell" +version = "1.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7e5500299e16ebb147ae15a00a942af264cf3688f47923b8fc2cd5858f23ad3" + +[[package]] +name = "percent-encoding" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "478c572c3d73181ff3c2539045f6eb99e5491218eae919370993b890cdbdd98e" + +[[package]] +name = "pin-project" +version = "1.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad29a609b6bcd67fee905812e544992d216af9d755757c05ed2d0e15a74c6ecc" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "069bdb1e05adc7a8990dce9cc75370895fbe4e3d58b9b73bf1aee56359344a55" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0a7ae3ac2f1173085d398531c705756c94a4c56843785df85a60c1a0afac116" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "pkg-config" +version = "0.3.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ac9a59f73473f1b8d852421e59e64809f025994837ef743615c6d0c5b305160" + +[[package]] +name = "proc-macro2" +version = "1.0.51" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d727cae5b39d21da60fa540906919ad737832fe0b1c165da3a34d6548c849d6" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "python-buildpack" +version = "0.0.0" +dependencies = [ + "flate2", + "indoc", + "libcnb", + "libcnb-test", + "libherokubuildpack", + "serde", + "tar", + "toml", + "ureq", +] + +[[package]] +name = "quote" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8856d8364d252a14d474036ea1358d63c9e6965c8e5c1885c18f73d70bff9c7b" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "redox_syscall" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb5a58c1855b4b6819d59012155603f0b22ad30cad752600aadfcb695265519a" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex" +version = "1.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48aaa5748ba571fb95cd2c85c09f629215d3a6ece942baa100950af03a34f733" +dependencies = [ + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.6.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456c603be3e8d448b072f410900c09faf164fbce2d480456f50eea6e25f9c848" + +[[package]] +name = "ring" +version = "0.16.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3053cf52e236a3ed746dfc745aa9cacf1b791d846bdaf412f60a8d7d6e17c8fc" +dependencies = [ + "cc", + "libc", + "once_cell", + "spin", + "untrusted", + "web-sys", + "winapi", +] + +[[package]] +name = "rustix" +version = "0.36.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd5c6ff11fecd55b40746d1995a02f2eb375bf8c00d192d521ee09f42bef37bc" +dependencies = [ + "bitflags", + "errno", + "io-lifetimes", + "libc", + "linux-raw-sys", + "windows-sys 0.45.0", +] + +[[package]] +name = "rustls" +version = "0.20.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fff78fc74d175294f4e83b28343315ffcfb114b156f0185e9741cb5570f50e2f" +dependencies = [ + "log", + "ring", + "sct", + "webpki", +] + +[[package]] +name = "ryu" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f91339c0467de62360649f8d3e185ca8de4224ff281f66000de5eb2a77a79041" + +[[package]] +name = "scratch" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1792db035ce95be60c3f8853017b3999209281c24e2ba5bc8e59bf97a0c590c1" + +[[package]] +name = "sct" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d53dcdb7c9f8158937a7981b48accfd39a43af418591a5d008c7b22b5e1b7ca4" +dependencies = [ + "ring", + "untrusted", +] + +[[package]] +name = "semver" +version = "1.0.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58bc9567378fc7690d6b2addae4e60ac2eeea07becb2c64b9f218b53865cba2a" +dependencies = [ + "serde", +] + +[[package]] +name = "serde" +version = "1.0.152" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb7d1f0d3021d347a83e556fc4683dea2ea09d87bccdf88ff5c12545d89d5efb" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.152" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af487d118eecd09402d70a5d72551860e788df87b464af30e5ea6a38c75c541e" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.94" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c533a59c9d8a93a09c6ab31f0fd5e5f4dd1b8fc9434804029839884765d04ea" +dependencies = [ + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_repr" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "395627de918015623b32e7669714206363a7fc00382bf477e72c1f7533e8eafc" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_spanned" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0efd8caf556a6cebd3b285caf480045fcc1ac04f6bd786b09a6f11af30c4fcf4" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_with" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d904179146de381af4c93d3af6ca4984b3152db687dacb9c3c35e86f39809c" +dependencies = [ + "base64 0.13.1", + "chrono", + "hex", + "indexmap", + "serde", + "serde_json", + "time", +] + +[[package]] +name = "slab" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6528351c9bc8ab22353f9d776db39a20288e8d6c37ef8cfe3317cf875eecfc2d" +dependencies = [ + "autocfg", +] + +[[package]] +name = "socket2" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64a4a911eed85daf18834cfaa86a79b7d266ff93ff5ba14005426219480ed662" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "spin" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e63cff320ae2c57904679ba7cb63280a3dc4613885beafb148ee7bf9aa9042d" + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tar" +version = "0.4.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b55807c0344e1e6c04d7c965f5289c39a8d94ae23ed5c0b57aabac549f871c6" +dependencies = [ + "filetime", + "libc", + "xattr", +] + +[[package]] +name = "tempfile" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af18f7ae1acd354b992402e9ec5864359d693cd8a79dcbef59f76891701c1e95" +dependencies = [ + "cfg-if", + "fastrand", + "redox_syscall", + "rustix", + "windows-sys 0.42.0", +] + +[[package]] +name = "termcolor" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be55cf8942feac5c765c2c993422806843c9a9a45d4d5c407ad6dd2ea95eb9b6" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "thiserror" +version = "1.0.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5ab016db510546d856297882807df8da66a16fb8c4101cb8b30054b0d5b2d9c" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5420d42e90af0c38c3290abcca25b9b3bdf379fc9f55c528f53a269d9c9a267e" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "time" +version = "0.3.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd0cbfecb4d19b5ea75bb31ad904eb5b9fa13f21079c3b92017ebdf4999a5890" +dependencies = [ + "itoa", + "serde", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e153e1f1acaef8acc537e68b44906d2db6436e2b35ac2c6b42640fff91f00fd" + +[[package]] +name = "time-macros" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd80a657e71da814b8e5d60d3374fc6d35045062245d80224748ae522dd76f36" +dependencies = [ + "time-core", +] + +[[package]] +name = "tinyvec" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87cc5ceb3875bb20c2890005a4e226a4651264a5c75edb2421b52861a0a0cb50" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03201d01c3c27a29c8a5cee5b55a93ddae1ccf6f08f65365c2c918f8c1b76f64" +dependencies = [ + "autocfg", + "bytes", + "libc", + "memchr", + "mio", + "num_cpus", + "pin-project-lite", + "socket2", + "windows-sys 0.45.0", +] + +[[package]] +name = "tokio-stream" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fb52b74f05dbf495a8fba459fdc331812b96aa086d9eb78101fa0d4569c3313" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5427d89453009325de0d8f342c9490009f76e999cb7672d77e46267448f7e6b2" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", + "tracing", +] + +[[package]] +name = "toml" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7afcae9e3f0fe2c370fd4657108972cbb2fa9db1b9f84849cefd80741b01cb6" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ab8ed2edee10b50132aed5f331333428b011c99402b5a534154ed15746f9622" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.19.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a1eb0622d28f4b9c90adc4ea4b2b46b47663fde9ac5fafcb14a1369d5508825" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "winnow", +] + +[[package]] +name = "tower-service" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6bc1c9ce2b5135ac7f93c72918fc37feb872bdc6a5533a8b85eb4b86bfdae52" + +[[package]] +name = "tracing" +version = "0.1.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ce8c33a8d48bd45d624a6e523445fd21ec13d3653cd51f681abf67418f54eb8" +dependencies = [ + "cfg-if", + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24eb03ba0eab1fd845050058ce5e616558e8f8d8fca633e6b163fe25c797213a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3528ecfd12c466c6f163363caf2d02a71161dd5e1cc6ae7b34207ea2d42d81ed" + +[[package]] +name = "unicode-bidi" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d54675592c1dbefd78cbd98db9bacd89886e1ca50692a0692baefffdeb92dd58" + +[[package]] +name = "unicode-ident" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5464a87b239f13a63a501f2701565754bae92d243d4bb7eb12f6d57d2269bf4" + +[[package]] +name = "unicode-normalization" +version = "0.1.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c5713f0fc4b5db668a2ac63cdb7bb4469d8c9fed047b1d0292cc7b0ce2ba921" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-width" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0edd1e5b14653f783770bce4a4dabb4a5108a5370a5f5d8cfe8710c361f6c8b" + +[[package]] +name = "untrusted" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" + +[[package]] +name = "ureq" +version = "2.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "338b31dd1314f68f3aabf3ed57ab922df95ffcd902476ca7ba3c4ce7b908c46d" +dependencies = [ + "base64 0.13.1", + "log", + "once_cell", + "rustls", + "url", + "webpki", + "webpki-roots", +] + +[[package]] +name = "url" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d68c799ae75762b8c3fe375feb6600ef5602c883c5d21eb51c09f22b83c4643" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", +] + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "want" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ce8a968cb1cd110d136ff8b819a556d6fb6d919363c61534f6860c7eb172ba0" +dependencies = [ + "log", + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" + +[[package]] +name = "wasm-bindgen" +version = "0.2.84" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31f8dcbc21f30d9b8f2ea926ecb58f6b91192c17e9d33594b3df58b2007ca53b" +dependencies = [ + "cfg-if", + "wasm-bindgen-macro", +] + +[[package]] +name = "wasm-bindgen-backend" +version = "0.2.84" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95ce90fd5bcc06af55a641a86428ee4229e44e07033963a2290a8e241607ccb9" +dependencies = [ + "bumpalo", + "log", + "once_cell", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.84" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c21f77c0bedc37fd5dc21f897894a5ca01e7bb159884559461862ae90c0b4c5" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.84" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2aff81306fcac3c7515ad4e177f521b5c9a15f2b08f4e32d823066102f35a5f6" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-backend", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.84" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0046fef7e28c3804e5e38bfa31ea2a0f73905319b677e57ebe37e49358989b5d" + +[[package]] +name = "web-sys" +version = "0.3.61" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e33b99f4b23ba3eec1a53ac264e35a755f00e966e0065077d6027c0f575b0b97" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f095d78192e208183081cc07bc5515ef55216397af48b873e5edcd72637fa1bd" +dependencies = [ + "ring", + "untrusted", +] + +[[package]] +name = "webpki-roots" +version = "0.22.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c71e40d7d2c34a5106301fb632274ca37242cd0c9d3e64dbece371a40a2d87" +dependencies = [ + "webpki", +] + +[[package]] +name = "which" +version = "4.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2441c784c52b289a054b7201fc93253e288f094e2f4be9058343127c4226a269" +dependencies = [ + "either", + "libc", + "once_cell", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70ec6ce85bb158151cae5e5c87f95a8e97d2c0c4b001223f33a334e3ce5de178" +dependencies = [ + "winapi", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-sys" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a3e1820f08b8513f676f7ab6c1f99ff312fb97b553d30ff4dd86f9f15728aa7" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-targets" +version = "0.42.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2522491fbfcd58cc84d47aeb2958948c4b8982e9a2d8a2a35bbaed431390e7" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c9864e83243fdec7fc9c5444389dcbbfd258f745e7853198f365e3c4968a608" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c8b1b673ffc16c47a9ff48570a9d85e25d265735c503681332589af6253c6c7" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de3887528ad530ba7bdbb1faa8275ec7a1155a45ffa57c37993960277145d640" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf4d1122317eddd6ff351aa852118a2418ad4214e6613a50e0191f7004372605" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1040f221285e17ebccbc2591ffdc2d44ee1f9186324dd3e84e99ac68d699c45" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "628bfdf232daa22b0d64fdb62b09fcc36bb01f05a3939e20ab73aaf9470d0463" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "447660ad36a13288b1db4d4248e857b510e8c3a225c822ba4fb748c0aafecffd" + +[[package]] +name = "winnow" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c95fb4ff192527911dd18eb138ac30908e7165b8944e528b6af93aa4c842d345" +dependencies = [ + "memchr", +] + +[[package]] +name = "xattr" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d1526bbe5aaeb5eb06885f4d987bcdfa5e23187055de9b83fe00156a821fabc" +dependencies = [ + "libc", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..9929756 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "python-buildpack" +version = "0.0.0" +edition = "2021" +rust-version = "1.67" +publish = false + +[dependencies] +# The default `miniz_oxide` flate2 backend has poor performance in debug/under QEMU: +# https://github.com/rust-lang/flate2-rs/issues/297 +# Ideally we'd use the fastest `zlib-ng` backend, however it fails to cross-compile: +# https://github.com/rust-lang/libz-sys/issues/93 +# As such we have to use the next best alternate backend, which is `zlib`. +flate2 = { version = "1", default-features = false, features = ["zlib"] } +indoc = "2" +libcnb = "0.11" +libherokubuildpack = { version = "0.11", default-features = false, features = ["log"] } +serde = "1" +tar = "0.4" +toml = "0.7" +ureq = { version = "2", default-features = false, features = ["tls"] } + +[dev-dependencies] +libcnb-test = "0.11" diff --git a/LICENSE b/LICENSE index 4a8073f..8b62596 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ BSD 3-Clause License -Copyright (c) 2022 Salesforce, Inc. +Copyright (c) 2023 Salesforce, Inc. All rights reserved. Redistribution and use in source and binary forms, with or without diff --git a/README.md b/README.md index 757b374..dd6ba99 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,5 @@ # Heroku Cloud Native Buildpack for Python +[![CI](https://github.com/heroku/buildpacks-python/actions/workflows/ci.yml/badge.svg)](https://github.com/heroku/buildpacks-python/actions/workflows/ci.yml) + Heroku's official [Cloud Native Buildpack](https://buildpacks.io) for the Python ecosystem. diff --git a/buildpack.toml b/buildpack.toml new file mode 100644 index 0000000..110cbfb --- /dev/null +++ b/buildpack.toml @@ -0,0 +1,19 @@ +api = "0.8" + +[buildpack] +id = "heroku/python" +version = "0.1.0" +name = "Python" +homepage = "https://github.com/heroku/buildpacks-python" +description = "Heroku's official Python Cloud Native Buildpack." +keywords = ["python", "heroku"] +clear-env = true + +[[buildpack.licenses]] +type = "BSD-3-Clause" + +[[stacks]] +id = "heroku-20" + +[[stacks]] +id = "heroku-22" diff --git a/src/errors.rs b/src/errors.rs new file mode 100644 index 0000000..efdd9f2 --- /dev/null +++ b/src/errors.rs @@ -0,0 +1,289 @@ +use crate::layers::pip_dependencies::PipDependenciesLayerError; +use crate::layers::python::PythonLayerError; +use crate::package_manager::DeterminePackageManagerError; +use crate::project_descriptor::ProjectDescriptorError; +use crate::python_version::{PythonVersion, PythonVersionError, DEFAULT_PYTHON_VERSION}; +use crate::runtime_txt::{ParseRuntimeTxtError, RuntimeTxtError}; +use crate::salesforce_functions::{CheckSalesforceFunctionError, FUNCTION_RUNTIME_PROGRAM_NAME}; +use crate::utils::{CommandError, DownloadUnpackArchiveError}; +use crate::BuildpackError; +use indoc::{formatdoc, indoc}; +use libherokubuildpack::log::log_error; +use std::io; + +/// Handle any non-recoverable buildpack or libcnb errors that occur. +/// +/// The buildpack will exit non-zero after this handler has run, so all that needs to be +/// performed here is the logging of an error message - and in the future, emitting metrics. +/// +/// We're intentionally not using `libherokubuildpack::error::on_error` since: +/// - It doesn't currently do anything other than logging an internal error for the libcnb +/// error case, and by inlining that here it's easier to keep the output consistent with +/// the messages emitted for buildpack-specific errors. +/// - Using it causes trait mismatch errors when Dependabot PRs incrementally update crates. +/// - When we want to add metrics to our buildpacks, it's going to need a rewrite of +/// `Buildpack::on_error` anyway (we'll need to write out metrics not log them, so will need +/// access to the `BuildContext`), at which point we can re-evaluate. +pub(crate) fn on_error(error: libcnb::Error) { + match error { + libcnb::Error::BuildpackError(buildpack_error) => on_buildpack_error(buildpack_error), + libcnb_error => log_error( + "Internal buildpack error", + formatdoc! {" + An unexpected internal error was reported by the framework used by this buildpack. + + Please open a support ticket and include the full log output of this build. + + Details: {libcnb_error} + "}, + ), + }; +} + +fn on_buildpack_error(error: BuildpackError) { + match error { + BuildpackError::CheckSalesforceFunction(error) => on_check_salesforce_function_error(error), + BuildpackError::DetectIo(io_error) => log_io_error( + "Unable to complete buildpack detection", + "determining if the Python buildpack should be run for this application", + &io_error, + ), + BuildpackError::DeterminePackageManager(error) => on_determine_package_manager_error(error), + BuildpackError::PipDependenciesLayer(error) => on_pip_dependencies_layer_error(error), + BuildpackError::ProjectDescriptor(error) => on_project_descriptor_error(error), + BuildpackError::PythonLayer(error) => on_python_layer_error(error), + BuildpackError::PythonVersion(error) => on_python_version_error(error), + }; +} + +fn on_project_descriptor_error(error: ProjectDescriptorError) { + match error { + ProjectDescriptorError::Io(io_error) => log_io_error( + "Unable to read project.toml", + "reading the (optional) project.toml file", + &io_error, + ), + ProjectDescriptorError::Parse(toml_error) => log_error( + "Invalid project.toml", + formatdoc! {" + A parsing/validation error error occurred whilst loading the project.toml file. + + Details: {toml_error} + "}, + ), + }; +} + +fn on_determine_package_manager_error(error: DeterminePackageManagerError) { + match error { + DeterminePackageManagerError::Io(io_error) => log_io_error( + "Unable to determine the package manager", + "determining which Python package manager to use for this project", + &io_error, + ), + // TODO: Should this mention the setup.py / pyproject.toml case? + DeterminePackageManagerError::NoneFound => log_error( + "No Python package manager files were found", + indoc! {" + A Pip requirements file was not found in your application's source code. + This file is required so that your application's dependencies can be installed. + + Please add a file named exactly 'requirements.txt' to the root directory of your + application, containing a list of the packages required by your application. + + For more information on what this file should contain, see: + https://pip.pypa.io/en/stable/reference/requirements-file-format/ + "}, + ), + }; +} + +fn on_python_version_error(error: PythonVersionError) { + match error { + PythonVersionError::RuntimeTxt(error) => match error { + RuntimeTxtError::Io(io_error) => log_io_error( + "Unable to read runtime.txt", + "reading the (optional) runtime.txt file", + &io_error, + ), + // TODO: (W-12613425) Write the supported Python versions inline, instead of linking out to Dev Center. + RuntimeTxtError::Parse(ParseRuntimeTxtError { cleaned_contents }) => { + let PythonVersion { + major, + minor, + patch, + } = DEFAULT_PYTHON_VERSION; + log_error( + "Invalid Python version in runtime.txt", + formatdoc! {" + The Python version specified in 'runtime.txt' is not in the correct format. + + The following file contents were found: + {cleaned_contents} + + However, the file contents must begin with a 'python-' prefix, followed by the + version specified as '..'. Comments are not supported. + + For example, to request Python {DEFAULT_PYTHON_VERSION}, the correct version format is: + python-{major}.{minor}.{patch} + + Please update 'runtime.txt' to use the correct version format, or else remove + the file to instead use the default version (currently Python {DEFAULT_PYTHON_VERSION}). + + For a list of the supported Python versions, see: + https://devcenter.heroku.com/articles/python-support#supported-runtimes + "}, + ); + } + }, + }; +} + +fn on_python_layer_error(error: PythonLayerError) { + match error { + PythonLayerError::BootstrapPipCommand(error) => match error { + CommandError::Io(io_error) => log_io_error( + "Unable to bootstrap pip", + "running the command to install pip, setuptools and wheel", + &io_error, + ), + CommandError::NonZeroExitStatus(exit_status) => log_error( + "Unable to bootstrap pip", + formatdoc! {" + The command to install pip, setuptools and wheel did not exit successfully ({exit_status}). + + See the log output above for more information. + + In some cases, this happens due to an unstable network connection. + Please try again to see if the error resolves itself. + + If that does not help, check the status of PyPI (the upstream Python + package repository service), here: + https://status.python.org + "}, + ), + }, + PythonLayerError::DownloadUnpackPythonArchive(error) => match error { + DownloadUnpackArchiveError::Io(io_error) => log_io_error( + "Unable to unpack the Python archive", + "unpacking the downloaded Python runtime archive and writing it to disk", + &io_error, + ), + DownloadUnpackArchiveError::Request(ureq_error) => log_error( + "Unable to download Python", + formatdoc! {" + An error occurred whilst downloading the Python runtime archive. + + In some cases, this happens due to an unstable network connection. + Please try again and to see if the error resolves itself. + + Details: {ureq_error} + "}, + ), + }, + PythonLayerError::LocateBundledPipIo(io_error) => log_io_error( + "Unable to locate the bundled copy of pip", + "locating the pip wheel file bundled inside the Python 'ensurepip' module", + &io_error, + ), + PythonLayerError::MakeSitePackagesReadOnlyIo(io_error) => log_io_error( + "Unable to make site-packages directory read-only", + "modifying the permissions on Python's 'site-packages' directory", + &io_error, + ), + // This error will change once the Python version is validated against a manifest. + // TODO: (W-12613425) Write the supported Python versions inline, instead of linking out to Dev Center. + PythonLayerError::PythonArchiveNotFound { + python_version, + stack, + } => log_error( + "Requested Python version is not available", + formatdoc! {" + The requested Python version ({python_version}) is not available for this stack ({stack}). + + Please update the version in 'runtime.txt' to a supported Python version, or else + remove the file to instead use the default version (currently Python {DEFAULT_PYTHON_VERSION}). + + For a list of the supported Python versions, see: + https://devcenter.heroku.com/articles/python-support#supported-runtimes + "}, + ), + }; +} + +fn on_pip_dependencies_layer_error(error: PipDependenciesLayerError) { + match error { + PipDependenciesLayerError::CreateSrcDirIo(io_error) => log_io_error( + "Unable to create 'src' directory required for pip install", + "creating the 'src' directory in the pip layer, prior to running pip install", + &io_error, + ), + PipDependenciesLayerError::PipInstallCommand(error) => match error { + CommandError::Io(io_error) => log_io_error( + "Unable to install dependencies using pip", + "running the 'pip install' command to install the application's dependencies", + &io_error, + ), + // TODO: Add more suggestions here as to causes (eg network, invalid requirements.txt, + // package broken or not compatible with version of Python, missing system dependencies etc) + CommandError::NonZeroExitStatus(exit_status) => log_error( + "Unable to install dependencies using pip", + formatdoc! {" + The 'pip install' command to install the application's dependencies from + 'requirements.txt' failed ({exit_status}). + + See the log output above for more information. + "}, + ), + }, + }; +} + +fn on_check_salesforce_function_error(error: CheckSalesforceFunctionError) { + match error { + CheckSalesforceFunctionError::Io(io_error) => log_io_error( + "Unable to run the Salesforce Functions self-check command", + &format!("running the '{FUNCTION_RUNTIME_PROGRAM_NAME} check' command"), + &io_error, + ), + CheckSalesforceFunctionError::NonZeroExitStatus(output) => log_error( + "The Salesforce Functions self-check failed", + formatdoc! {" + The '{FUNCTION_RUNTIME_PROGRAM_NAME} check' command failed ({exit_status}), indicating + there is a problem with the Python Salesforce Function in this project. + + Details: + {stderr} + ", + exit_status = output.status, + stderr = String::from_utf8_lossy(&output.stderr), + }, + ), + CheckSalesforceFunctionError::ProgramNotFound => log_error( + "The Salesforce Functions package is not installed", + formatdoc! {" + The '{FUNCTION_RUNTIME_PROGRAM_NAME}' program that is required for Python Salesforce + Functions could not be found. + + Check that the 'salesforce-functions' Python package is listed as a + dependency in 'requirements.txt'. + + If this project is not intended to be a Salesforce Function, remove the + 'type = \"function\"' declaration from 'project.toml' to skip this check. + "}, + ), + }; +} + +fn log_io_error(header: &str, occurred_whilst: &str, io_error: &io::Error) { + // We don't suggest opening a support ticket, since a subset of I/O errors can be caused + // by issues in the application. In the future, perhaps we should try and split these out? + log_error( + header, + formatdoc! {" + An unexpected error occurred whilst {occurred_whilst}. + + Details: I/O Error: {io_error} + "}, + ); +} diff --git a/src/layers/mod.rs b/src/layers/mod.rs new file mode 100644 index 0000000..74c1faa --- /dev/null +++ b/src/layers/mod.rs @@ -0,0 +1,3 @@ +pub(crate) mod pip_cache; +pub(crate) mod pip_dependencies; +pub(crate) mod python; diff --git a/src/layers/pip_cache.rs b/src/layers/pip_cache.rs new file mode 100644 index 0000000..ef1cb39 --- /dev/null +++ b/src/layers/pip_cache.rs @@ -0,0 +1,80 @@ +use crate::packaging_tool_versions::PackagingToolVersions; +use crate::python_version::PythonVersion; +use crate::PythonBuildpack; +use libcnb::build::BuildContext; +use libcnb::data::buildpack::StackId; +use libcnb::data::layer_content_metadata::LayerTypes; +use libcnb::layer::{ExistingLayerStrategy, Layer, LayerData, LayerResult, LayerResultBuilder}; +use libcnb::Buildpack; +use libherokubuildpack::log::log_info; +use serde::{Deserialize, Serialize}; +use std::path::Path; + +/// Layer containing Pip's cache of HTTP requests/downloads and built package wheels. +pub(crate) struct PipCacheLayer<'a> { + /// The Python version used for this build. + pub python_version: &'a PythonVersion, + /// The pip, setuptools and wheel versions used for this build. + pub packaging_tool_versions: &'a PackagingToolVersions, +} + +impl Layer for PipCacheLayer<'_> { + type Buildpack = PythonBuildpack; + type Metadata = PipCacheLayerMetadata; + + fn types(&self) -> LayerTypes { + LayerTypes { + build: false, + cache: true, + launch: false, + } + } + + fn create( + &self, + context: &BuildContext, + _layer_path: &Path, + ) -> Result, ::Error> { + let layer_metadata = self.generate_layer_metadata(&context.stack_id); + LayerResultBuilder::new(layer_metadata).build() + } + + fn existing_layer_strategy( + &self, + context: &BuildContext, + layer_data: &LayerData, + ) -> Result::Error> { + let cached_metadata = &layer_data.content_metadata.metadata; + let new_metadata = &self.generate_layer_metadata(&context.stack_id); + + if cached_metadata == new_metadata { + log_info("Using cached pip download/wheel cache"); + Ok(ExistingLayerStrategy::Keep) + } else { + log_info("Discarding cached pip download/wheel cache"); + Ok(ExistingLayerStrategy::Recreate) + } + } +} + +impl<'a> PipCacheLayer<'a> { + fn generate_layer_metadata(&self, stack_id: &StackId) -> PipCacheLayerMetadata { + PipCacheLayerMetadata { + stack: stack_id.clone(), + python_version: self.python_version.to_string(), + packaging_tool_versions: self.packaging_tool_versions.clone(), + } + } +} + +/// Metadata stored in the generated layer that allows future builds to determine whether +/// the cached layer needs to be invalidated or not. +// Timestamp based cache invalidation isn't used here since the Python/pip/setuptools/wheel +// versions will change often enough that it isn't worth the added complexity. Ideally pip +// would support cleaning up its own cache: https://github.com/pypa/pip/issues/6956 +#[derive(Clone, Deserialize, PartialEq, Serialize)] +pub(crate) struct PipCacheLayerMetadata { + stack: StackId, + python_version: String, + packaging_tool_versions: PackagingToolVersions, +} diff --git a/src/layers/pip_dependencies.rs b/src/layers/pip_dependencies.rs new file mode 100644 index 0000000..71eaa0e --- /dev/null +++ b/src/layers/pip_dependencies.rs @@ -0,0 +1,173 @@ +use crate::utils::{self, CommandError}; +use crate::{BuildpackError, PythonBuildpack}; +use libcnb::build::BuildContext; +use libcnb::data::layer_content_metadata::LayerTypes; +use libcnb::generic::GenericMetadata; +use libcnb::layer::{Layer, LayerResult, LayerResultBuilder}; +use libcnb::layer_env::{LayerEnv, ModificationBehavior, Scope}; +use libcnb::{Buildpack, Env}; +use libherokubuildpack::log::log_info; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::{fs, io}; + +/// Layer containing the application's Python dependencies, installed using Pip. +pub(crate) struct PipDependenciesLayer<'a> { + /// Environment variables inherited from earlier buildpack steps. + pub command_env: &'a Env, + /// The path to the Pip cache directory, which is stored in another layer since it isn't needed at runtime. + pub pip_cache_dir: PathBuf, +} + +impl Layer for PipDependenciesLayer<'_> { + type Buildpack = PythonBuildpack; + type Metadata = GenericMetadata; + + fn types(&self) -> LayerTypes { + // This layer is not cached, since: + // - Pip is a package installer rather than a project/environment manager, and so does + // not deterministically manage installed Python packages. For example, if a package + // entry in a requirements file is later removed, Pip will not uninstall the package. + // In addition, there is no official lockfile support (only partial support via + // third-party requirements file tools), so changes in transitive dependencies add yet + // more opportunity for non-determinism between each install. + // - The Pip HTTP/wheel cache is itself cached in a separate layer, which covers the most + // time consuming part of performing a pip install: downloading the dependencies and then + // generating wheels (for any packages that use compiled components but don't distribute + // pre-built wheels matching the current Python version). + // - The only case where the Pip wheel cache doesn't help, is for projects that use + // hash-checking mode and so are affected by this Pip issue: + // https://github.com/pypa/pip/issues/5037 + // ...however, the limitation should really be fixed upstream, and this mode is rarely + // used in practice. + // + // Longer term, the best option for projects that want no-op deterministic installs will + // be to use Poetry instead of Pip (once the buildpack supports Poetry). + LayerTypes { + build: true, + cache: false, + launch: true, + } + } + + fn create( + &self, + _context: &BuildContext, + layer_path: &Path, + ) -> Result, ::Error> { + let layer_env = generate_layer_env(layer_path); + let command_env = layer_env.apply(Scope::Build, self.command_env); + + // When Pip installs dependencies from a VCS URL it has to clone the repository in order + // to install it. In standard installation mode the clone is made to a temporary directory + // and then deleted, however, when packages are installed in editable mode Pip must keep + // the repository around, since the directory is added to the Python path directly (via + // the `.pth` file created in `site-packages`). By default Pip will store the repository + // in the current working directory (the app dir), however, we would prefer it to be stored + // in the dependencies layer instead for consistency. (Plus if the dependencies layer were + // ever cached, storing the repository in the app dir would break on repeat-builds). + let src_dir = layer_path.join("src"); + fs::create_dir(&src_dir).map_err(PipDependenciesLayerError::CreateSrcDirIo)?; + + log_info("Running pip install"); + + utils::run_command( + Command::new("pip") + .args([ + "install", + "--cache-dir", + &self.pip_cache_dir.to_string_lossy(), + "--no-input", + // Prevent warning about the `bin/` directory not being on `PATH`, since it + // will be added automatically by libcnb/lifecycle later. + "--no-warn-script-location", + "--progress", + "off", + // Install dependencies into the user `site-packages` directory (set by `PYTHONUSERBASE`), + // rather than the system `site-packages` directory (since we want to keep dependencies in + // a separate layer to the Python runtime). + // + // Another option is to install into an arbitrary directory using Pip's `--target` option + // combined with adding that directory to `PYTHONPATH`, however: + // - Using `--target` causes a number of issues with Pip, eg: + // https://github.com/pypa/pip/issues/8799 + // - Directories added to `PYTHONPATH` take precedence over the Python stdlib (unlike + // the system or user site-packages directories), and so can result in hard to debug + // stdlib shadowing problems that users won't encounter locally (for example if one + // of the app's transitive dependencies is an outdated stdlib backport package). + "--user", + "--requirement", + "requirements.txt", + // Clone any VCS repositories installed in editable mode into the directory created + // above, rather than the default of the current working directory (the app dir). + "--src", + &src_dir.to_string_lossy(), + ]) + .env_clear() + .envs(&command_env), + ) + .map_err(PipDependenciesLayerError::PipInstallCommand)?; + + LayerResultBuilder::new(GenericMetadata::default()) + .env(layer_env) + .build() + } +} + +/// Environment variables that will be set by this layer. +fn generate_layer_env(layer_path: &Path) -> LayerEnv { + LayerEnv::new() + // `PYTHONUSERBASE` overrides the default user base directory, which is used by Python to + // compute the path of the user `site-packages` directory: + // https://docs.python.org/3/using/cmdline.html#envvar-PYTHONUSERBASE + // + // Setting this: + // - Makes `pip install --user` install the dependencies into the current layer rather + // than the user's home directory (which would be discarded at the end of the build). + // - Allows Python to find the installed packages at import time. + // + // It's fine for this directory to be set to the root of the layer, since all of the files + // created by Pip will be nested inside subdirectories (such as `bin/` or `lib/`), and so + // won't conflict with the CNB layer metadata related files generated by libcnb.rs. + .chainable_insert( + Scope::All, + ModificationBehavior::Override, + "PYTHONUSERBASE", + layer_path, + ) +} + +/// Errors that can occur when installing the project's dependencies into a layer using Pip. +#[derive(Debug)] +pub(crate) enum PipDependenciesLayerError { + CreateSrcDirIo(io::Error), + PipInstallCommand(CommandError), +} + +impl From for BuildpackError { + fn from(error: PipDependenciesLayerError) -> Self { + Self::PipDependenciesLayer(error) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn pip_dependencies_layer_env() { + let mut base_env = Env::new(); + base_env.insert("PYTHONUSERBASE", "this-should-be-overridden"); + + let layer_env = generate_layer_env(Path::new("/layers/dependencies")); + + assert_eq!( + utils::environment_as_sorted_vector(&layer_env.apply(Scope::Build, &base_env)), + vec![("PYTHONUSERBASE", "/layers/dependencies")] + ); + assert_eq!( + utils::environment_as_sorted_vector(&layer_env.apply(Scope::Launch, &base_env)), + vec![("PYTHONUSERBASE", "/layers/dependencies")] + ); + } +} diff --git a/src/layers/python.rs b/src/layers/python.rs new file mode 100644 index 0000000..55b8a90 --- /dev/null +++ b/src/layers/python.rs @@ -0,0 +1,582 @@ +use crate::packaging_tool_versions::PackagingToolVersions; +use crate::python_version::PythonVersion; +use crate::utils::{self, CommandError, DownloadUnpackArchiveError}; +use crate::{BuildpackError, PythonBuildpack}; +use libcnb::build::BuildContext; +use libcnb::data::buildpack::StackId; +use libcnb::data::layer_content_metadata::LayerTypes; +use libcnb::layer::{ExistingLayerStrategy, Layer, LayerData, LayerResult, LayerResultBuilder}; +use libcnb::layer_env::{LayerEnv, ModificationBehavior, Scope}; +use libcnb::{Buildpack, Env}; +use libherokubuildpack::log::log_info; +use serde::{Deserialize, Serialize}; +use std::fs::Permissions; +use std::os::unix::prelude::PermissionsExt; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::{fs, io}; + +/// Layer containing the Python runtime, and the packages `pip`, `setuptools` and `wheel`. +/// +/// We install both Python and the packaging tools into the same layer, since: +/// - We don't want to mix buildpack/packaging dependencies with the app's own dependencies +/// (for a start, we need pip installed to even install the user's own dependencies, plus +/// want to keep caching separate), so cannot install the packaging tools into the user +/// site-packages directory. +/// - We don't want to install the packaging tools into an arbitrary directory added to +/// `PYTHONPATH`, since directories added to `PYTHONPATH` take precedence over the Python +/// stdlib (unlike the system or user site-packages directories), and so can result in hard +/// to debug stdlib shadowing problems that users won't encounter locally. +/// - This leaves just the system site-packages directory, which exists within the Python +/// installation directory and Python does not support moving it elsewhere. +/// - It matches what both local and official Docker image environments do. +pub(crate) struct PythonLayer<'a> { + /// Environment variables inherited from earlier buildpack steps. + pub command_env: &'a Env, + /// The Python version that this layer should install. + pub python_version: &'a PythonVersion, + /// The pip, setuptools and wheel versions that this layer should install. + pub packaging_tool_versions: &'a PackagingToolVersions, +} + +impl Layer for PythonLayer<'_> { + type Buildpack = PythonBuildpack; + type Metadata = PythonLayerMetadata; + + fn types(&self) -> LayerTypes { + LayerTypes { + build: true, + cache: true, + launch: true, + } + } + + fn create( + &self, + context: &BuildContext, + layer_path: &Path, + ) -> Result, ::Error> { + log_info(format!("Installing Python {}", self.python_version)); + + let archive_url = self.python_version.url(&context.stack_id); + utils::download_and_unpack_gzipped_archive(&archive_url, layer_path).map_err(|error| { + match error { + // TODO: Remove this once the Python version is validated against a manifest (at which + // point 404s can be treated as an internal error, instead of user error) + DownloadUnpackArchiveError::Request(ureq::Error::Status(404, _)) => { + PythonLayerError::PythonArchiveNotFound { + stack: context.stack_id.clone(), + python_version: self.python_version.clone(), + } + } + other_error => PythonLayerError::DownloadUnpackPythonArchive(other_error), + } + })?; + + let layer_env = generate_layer_env(layer_path, self.python_version); + let mut command_env = layer_env.apply(Scope::Build, self.command_env); + + // The Python binaries are built using `--shared`, and since they're being installed at a + // different location from their original `--prefix`, they need `LD_LIBRARY_PATH` to be set + // in order to find `libpython3`. Whilst `LD_LIBRARY_PATH` will be automatically set later by + // lifecycle/libcnb, it's not set by libcnb until this `Layer` has ended, and so we have to + // explicitly set it for the Python invocations within this layer. + command_env.insert("LD_LIBRARY_PATH", layer_path.join("lib")); + + let PackagingToolVersions { + pip_version, + setuptools_version, + wheel_version, + } = self.packaging_tool_versions; + + log_info(format!( + "Installing pip {pip_version}, setuptools {setuptools_version} and wheel {wheel_version}" + )); + + let python_binary = layer_path.join("bin/python"); + let python_stdlib_dir = layer_path.join(format!( + "lib/python{}.{}", + self.python_version.major, self.python_version.minor + )); + let site_packages_dir = python_stdlib_dir.join("site-packages"); + + // Python bundles Pip within its standard library, which we can use to install our chosen + // pip version from PyPI, saving us from having to download the usual pip bootstrap script. + let bundled_pip_module_path = bundled_pip_module_path(&python_stdlib_dir) + .map_err(PythonLayerError::LocateBundledPipIo)?; + + utils::run_command( + Command::new(python_binary) + .args([ + &bundled_pip_module_path.to_string_lossy(), + "install", + // There is no point using Pip's cache here, since the layer itself will be cached. + "--no-cache-dir", + "--no-input", + "--quiet", + format!("pip=={pip_version}").as_str(), + format!("setuptools=={setuptools_version}").as_str(), + format!("wheel=={wheel_version}").as_str(), + ]) + .env_clear() + .envs(&command_env), + ) + .map_err(PythonLayerError::BootstrapPipCommand)?; + + // By default Pip will install into the system site-packages directory if it is writeable + // by the current user. Whilst the buildpack's own `pip install` invocations always use + // `--user` to ensure application dependencies are instead installed into the user + // site-packages, it's possible other buildpacks or custom scripts may forget to do so. + // By making the system site-packages directory read-only, Pip will automatically use + // user installs in such cases: + // https://github.com/pypa/pip/blob/23.0/src/pip/_internal/commands/install.py#L715-L773 + fs::set_permissions(site_packages_dir, Permissions::from_mode(0o555)) + .map_err(PythonLayerError::MakeSitePackagesReadOnlyIo)?; + + let layer_metadata = self.generate_layer_metadata(&context.stack_id); + LayerResultBuilder::new(layer_metadata) + .env(layer_env) + .build() + } + + fn existing_layer_strategy( + &self, + context: &BuildContext, + layer_data: &LayerData, + ) -> Result::Error> { + let cached_metadata = &layer_data.content_metadata.metadata; + let new_metadata = self.generate_layer_metadata(&context.stack_id); + + if let Some(reason) = cache_invalidation_reason(cached_metadata, &new_metadata) { + log_info(format!("Discarding cache {reason}")); + Ok(ExistingLayerStrategy::Recreate) + } else { + log_info(format!( + "Using cached Python {}", + cached_metadata.python_version + )); + let PackagingToolVersions { + pip_version, + setuptools_version, + wheel_version, + } = &cached_metadata.packaging_tool_versions; + log_info(format!( + "Using cached pip {pip_version}, setuptools {setuptools_version} and wheel {wheel_version}" + )); + Ok(ExistingLayerStrategy::Keep) + } + } +} + +impl<'a> PythonLayer<'a> { + fn generate_layer_metadata(&self, stack_id: &StackId) -> PythonLayerMetadata { + PythonLayerMetadata { + stack: stack_id.clone(), + python_version: self.python_version.to_string(), + packaging_tool_versions: self.packaging_tool_versions.clone(), + } + } +} + +/// Metadata stored in the generated layer that allows future builds to determine whether +/// the cached layer needs to be invalidated or not. +#[derive(Clone, Deserialize, PartialEq, Serialize)] +pub(crate) struct PythonLayerMetadata { + stack: StackId, + python_version: String, + packaging_tool_versions: PackagingToolVersions, +} + +/// Compare cached layer metadata to the new layer metadata to determine if the cache should +/// be invalidated, and if so, for what reason. +fn cache_invalidation_reason( + cached_metadata: &PythonLayerMetadata, + new_metadata: &PythonLayerMetadata, +) -> Option { + // By destructuring here we ensure that if any additional fields are added to the layer + // metadata in the future, it forces them to be used as part of cache invalidation, + // otherwise Clippy would report unused variable errors. + let PythonLayerMetadata { + stack: cached_stack, + python_version: cached_python_version, + packaging_tool_versions: + PackagingToolVersions { + pip_version: cached_pip_version, + setuptools_version: cached_setuptools_version, + wheel_version: cached_wheel_version, + }, + } = cached_metadata; + + let PythonLayerMetadata { + stack, + python_version, + packaging_tool_versions: + PackagingToolVersions { + pip_version, + setuptools_version, + wheel_version, + }, + } = new_metadata; + + let mut reasons = Vec::new(); + + if cached_stack != stack { + reasons.push(format!( + "the stack has changed from {cached_stack} to {stack}" + )); + } + + if cached_python_version != python_version { + reasons.push(format!( + "the Python version has changed from {cached_python_version} to {python_version}" + )); + } + + if cached_pip_version != pip_version { + reasons.push(format!( + "the pip version has changed from {cached_pip_version} to {pip_version}" + )); + } + + if cached_setuptools_version != setuptools_version { + reasons.push(format!( + "the setuptools version has changed from {cached_setuptools_version} to {setuptools_version}" + )); + } + + if cached_wheel_version != wheel_version { + reasons.push(format!( + "the wheel version has changed from {cached_wheel_version} to {wheel_version}" + )); + } + + // If there is more than one reason then all are mentioned to hopefully prevent support + // tickets where build failures are blamed on a stack upgrade but were actually caused + // by the app's Python version being updated at the same time. + match reasons.as_slice() { + [] => None, + [reason] => Some(format!("since {reason}")), + reasons => Some(format!("since:\n - {}", reasons.join("\n - "))), + } +} + +/// Environment variables that will be set by this layer. +fn generate_layer_env(layer_path: &Path, python_version: &PythonVersion) -> LayerEnv { + // Several of the env vars below are technically build-time only vars, however, we use + // `Scope::All` instead of `Scope::Build` to reduce confusion if pip install commands + // are used at runtime when debugging. + // + // Remember to force invalidation of the cached layer if these env vars ever change. + LayerEnv::new() + // We have to set `CPATH` explicitly, since: + // - The automatic path set by lifecycle/libcnb is `/include/` whereas Python's + // headers are at `/include/pythonX.Y/` (compilers don't recursively search). + // - Older setuptools cannot find this directory without `CPATH` being set: + // https://github.com/pypa/setuptools/issues/3657 + .chainable_insert( + Scope::All, + ModificationBehavior::Prepend, + "CPATH", + layer_path.join(format!( + "include/python{}.{}", + python_version.major, python_version.minor + )), + ) + .chainable_insert(Scope::All, ModificationBehavior::Delimiter, "CPATH", ":") + // Ensure Python uses a Unicode locate, to prevent the issues described in: + // https://github.com/docker-library/python/pull/570 + .chainable_insert( + Scope::All, + ModificationBehavior::Override, + "LANG", + "C.UTF-8", + ) + // We use a curated Pip version, so disable the update check to speed up Pip invocations, + // reduce build log spam and prevent users from thinking they need to manually upgrade. + // This uses an env var (rather than the `--disable-pip-version-check` arg) so that it also + // takes effect for any pip invocations in later buildpacks or when debugging at runtime. + .chainable_insert( + Scope::All, + ModificationBehavior::Override, + "PIP_DISABLE_PIP_VERSION_CHECK", + "1", + ) + // We have to set `PKG_CONFIG_PATH` explicitly, since the automatic path set by lifecycle/libcnb + // is `/pkgconfig/`, whereas Python's pkgconfig files are at `/lib/pkgconfig/`. + .chainable_insert( + Scope::All, + ModificationBehavior::Prepend, + "PKG_CONFIG_PATH", + layer_path.join("lib/pkgconfig"), + ) + .chainable_insert( + Scope::All, + ModificationBehavior::Delimiter, + "PKG_CONFIG_PATH", + ":", + ) + // Disable Python's output buffering to ensure logs aren't dropped if an app crashes. + .chainable_insert( + Scope::All, + ModificationBehavior::Override, + "PYTHONUNBUFFERED", + "1", + ) + // By default, when Python creates cached bytecode files (`.pyc` files) it embeds the + // `.py` source file's last-modified time in the `.pyc` file, so it can later be used + // to determine whether the cached bytecode file needs regenerating. + // + // This causes the `.pyc` file contents (and thus layer SHA256) to be non-deterministic in + // cases where the `.py` file's last-modified time can vary (such as files installed by Pip, + // since it doesn't preserve the last modified time of the original downloaded package). + // + // In addition, as part of generating the OCI image, lifecycle resets the timestamps on all + // files to a fixed value in order to improve the determinism of builds: + // https://buildpacks.io/docs/features/reproducibility/#consequences-and-caveats + // + // At runtime, this then means the timestamps embedded in the `.pyc` files no longer match + // the timestamps of the original `.py` files, causing Python to have to regenerate the + // bytecode, and so losing any benefit of having kept the `.pyc` files in the image. + // + // One option to solve all of the above, would be to delete the `.pyc` files from the image + // at the end of the buildpack's build phase, however: + // - This means they need to be regenerated at app boot, slowing boot times. + // (For a simple Django project on a Perf-M, boot time increases from ~0.5s to ~1.5s.) + // - If any other later buildpack runs any of the Python files added by this buildpack, then + // the timestamp based `.pyc` files will be created again, re-introducing non-determinism. + // + // Instead, we use the hash-based cache files mode added in Python 3.7+, which embeds a hash + // of the original `.py` file in the `.pyc` file instead of the timestamp: + // https://docs.python.org/3.11/reference/import.html#pyc-invalidation + // https://peps.python.org/pep-0552/ + // + // This mode can be enabled by passing `--invalidation-mode checked-hash` to `compileall`, + // or via the `SOURCE_DATE_EPOCH` env var: + // https://docs.python.org/3.11/library/compileall.html#cmdoption-compileall-invalidation-mode + // + // Note: Both the CLI args and the env var only apply to usages of `compileall` or `py_compile`, + // and not `.pyc` generation as part of Python importing a file during normal operation. + // + // We use the env var, since: + // - Pip calls `compileall` itself after installing packages, and doesn't allow us to + // customise the options passed to it, which would mean we'd have to pass `--no-compile` + // to Pip followed by running `compileall` manually ourselves, meaning more complexity + // every time we (or a later buildpack) use `pip install`. + // - When we add support for Poetry, we'll have to use an env var regardless, since Poetry + // doesn't allow customising the options passed to its internal Pip invocations, so we'd + // have no way of passing `--no-compile` to Pip. + .chainable_insert( + Scope::Build, + ModificationBehavior::Default, + "SOURCE_DATE_EPOCH", + // Whilst `compileall` doesn't use the value of `SOURCE_DATE_EPOCH` (only whether it is + // set or not), the value ends up being used when wheel archives are generated during + // the pip install. As such, we cannot use a zero value since the ZIP file format doesn't + // support dates before 1980. Instead, we use a value equivalent to `1980-01-01T00:00:01Z`, + // for parity with that used by lifecycle: + // https://github.com/buildpacks/lifecycle/blob/v0.15.3/archive/writer.go#L12 + "315532801", + ) +} + +/// The path to the Pip module bundled in Python's standard library. +fn bundled_pip_module_path(python_stdlib_dir: &Path) -> io::Result { + let bundled_wheels_dir = python_stdlib_dir.join("ensurepip/_bundled"); + + // The wheel filename includes the Pip version (for example `pip-XX.Y-py3-none-any.whl`), + // which varies from one Python release to the next (including between patch releases). + // As such, we have to find the wheel based on the known filename prefix of `pip-`. + for entry in fs::read_dir(bundled_wheels_dir)? { + let entry = entry?; + if entry.file_name().to_string_lossy().starts_with("pip-") { + let pip_wheel_path = entry.path(); + // The Pip module exists inside the pip wheel (which is a zip file), however, + // Python can load it directly by appending the module name to the zip filename, + // as though it were a path. For example: `pip-XX.Y-py3-none-any.whl/pip` + let pip_module_path = pip_wheel_path.join("pip"); + return Ok(pip_module_path); + } + } + + Err(io::Error::new( + io::ErrorKind::NotFound, + "No files found matching the pip wheel filename prefix", + )) +} + +/// Errors that can occur when installing Python and required packaging tools into a layer. +#[derive(Debug)] +pub(crate) enum PythonLayerError { + BootstrapPipCommand(CommandError), + DownloadUnpackPythonArchive(DownloadUnpackArchiveError), + LocateBundledPipIo(io::Error), + MakeSitePackagesReadOnlyIo(io::Error), + PythonArchiveNotFound { + python_version: PythonVersion, + stack: StackId, + }, +} + +impl From for BuildpackError { + fn from(error: PythonLayerError) -> Self { + Self::PythonLayer(error) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use indoc::indoc; + use libcnb::data::stack_id; + + #[test] + fn cache_invalidation_reason_unchanged() { + let metadata = PythonLayerMetadata { + stack: stack_id!("heroku-22"), + python_version: "3.11.0".to_string(), + packaging_tool_versions: PackagingToolVersions { + pip_version: "A.B.C".to_string(), + setuptools_version: "D.E.F".to_string(), + wheel_version: "G.H.I".to_string(), + }, + }; + assert_eq!(cache_invalidation_reason(&metadata, &metadata), None); + } + + #[test] + fn cache_invalidation_reason_single_change() { + let cached_metadata = PythonLayerMetadata { + stack: stack_id!("heroku-22"), + python_version: "3.11.0".to_string(), + packaging_tool_versions: PackagingToolVersions { + pip_version: "A.B.C".to_string(), + setuptools_version: "D.E.F".to_string(), + wheel_version: "G.H.I".to_string(), + }, + }; + let new_metadata = PythonLayerMetadata { + python_version: "3.11.1".to_string(), + ..cached_metadata.clone() + }; + assert_eq!( + cache_invalidation_reason(&cached_metadata, &new_metadata), + Some("since the Python version has changed from 3.11.0 to 3.11.1".to_string()) + ); + } + + #[test] + fn cache_invalidation_reason_all_changed() { + let cached_metadata = PythonLayerMetadata { + stack: stack_id!("heroku-20"), + python_version: "3.9.0".to_string(), + packaging_tool_versions: PackagingToolVersions { + pip_version: "A.B.C".to_string(), + setuptools_version: "D.E.F".to_string(), + wheel_version: "G.H.I".to_string(), + }, + }; + let new_metadata = PythonLayerMetadata { + stack: stack_id!("heroku-22"), + python_version: "3.11.1".to_string(), + packaging_tool_versions: PackagingToolVersions { + pip_version: "A.B.C-new".to_string(), + setuptools_version: "D.E.F-new".to_string(), + wheel_version: "G.H.I-new".to_string(), + }, + }; + assert_eq!( + cache_invalidation_reason(&cached_metadata, &new_metadata), + Some( + indoc! {" + since: + - the stack has changed from heroku-20 to heroku-22 + - the Python version has changed from 3.9.0 to 3.11.1 + - the pip version has changed from A.B.C to A.B.C-new + - the setuptools version has changed from D.E.F to D.E.F-new + - the wheel version has changed from G.H.I to G.H.I-new + "} + .trim() + .to_string() + ) + ); + } + + #[test] + fn python_layer_env() { + let layer_env = generate_layer_env( + Path::new("/layers/python"), + &PythonVersion { + major: 3, + minor: 9, + patch: 0, + }, + ); + + // Remember to force invalidation of the cached layer if these env vars ever change. + assert_eq!( + utils::environment_as_sorted_vector(&layer_env.apply_to_empty(Scope::Build)), + vec![ + ("CPATH", "/layers/python/include/python3.9"), + ("LANG", "C.UTF-8"), + ("PIP_DISABLE_PIP_VERSION_CHECK", "1"), + ("PKG_CONFIG_PATH", "/layers/python/lib/pkgconfig"), + ("PYTHONUNBUFFERED", "1"), + ("SOURCE_DATE_EPOCH", "315532801"), + ] + ); + assert_eq!( + utils::environment_as_sorted_vector(&layer_env.apply_to_empty(Scope::Launch)), + vec![ + ("CPATH", "/layers/python/include/python3.9"), + ("LANG", "C.UTF-8"), + ("PIP_DISABLE_PIP_VERSION_CHECK", "1"), + ("PKG_CONFIG_PATH", "/layers/python/lib/pkgconfig"), + ("PYTHONUNBUFFERED", "1"), + ] + ); + } + + #[test] + fn python_layer_env_with_existing_env() { + let mut base_env = Env::new(); + base_env.insert("CPATH", "/base"); + base_env.insert("LANG", "this-should-be-overridden"); + base_env.insert("PIP_DISABLE_PIP_VERSION_CHECK", "this-should-be-overridden"); + base_env.insert("PKG_CONFIG_PATH", "/base"); + base_env.insert("PYTHONUNBUFFERED", "this-should-be-overridden"); + base_env.insert("SOURCE_DATE_EPOCH", "this-should-be-preserved"); + + let layer_env = generate_layer_env( + Path::new("/layers/python"), + &PythonVersion { + major: 3, + minor: 11, + patch: 1, + }, + ); + + // Remember to force invalidation of the cached layer if these env vars ever change. + assert_eq!( + utils::environment_as_sorted_vector(&layer_env.apply(Scope::Build, &base_env)), + vec![ + ("CPATH", "/layers/python/include/python3.11:/base"), + ("LANG", "C.UTF-8"), + ("PIP_DISABLE_PIP_VERSION_CHECK", "1"), + ("PKG_CONFIG_PATH", "/layers/python/lib/pkgconfig:/base"), + ("PYTHONUNBUFFERED", "1"), + ("SOURCE_DATE_EPOCH", "this-should-be-preserved"), + ] + ); + assert_eq!( + utils::environment_as_sorted_vector(&layer_env.apply(Scope::Launch, &base_env)), + vec![ + ("CPATH", "/layers/python/include/python3.11:/base"), + ("LANG", "C.UTF-8"), + ("PIP_DISABLE_PIP_VERSION_CHECK", "1"), + ("PKG_CONFIG_PATH", "/layers/python/lib/pkgconfig:/base"), + ("PYTHONUNBUFFERED", "1"), + ("SOURCE_DATE_EPOCH", "this-should-be-preserved"), + ] + ); + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..6a2b637 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,162 @@ +#![warn(clippy::pedantic)] +#![warn(unused_crate_dependencies)] +// Prevent warnings caused by the large size of `ureq::Error` in error enums, +// where it is not worth boxing since the enum size doesn't affect performance. +#![allow(clippy::large_enum_variant)] +#![allow(clippy::result_large_err)] + +mod errors; +mod layers; +mod package_manager; +mod packaging_tool_versions; +mod project_descriptor; +mod python_version; +mod runtime_txt; +mod salesforce_functions; +mod utils; + +use crate::layers::pip_cache::PipCacheLayer; +use crate::layers::pip_dependencies::{PipDependenciesLayer, PipDependenciesLayerError}; +use crate::layers::python::{PythonLayer, PythonLayerError}; +use crate::package_manager::{DeterminePackageManagerError, PackageManager}; +use crate::packaging_tool_versions::PackagingToolVersions; +use crate::project_descriptor::ProjectDescriptorError; +use crate::python_version::PythonVersionError; +use crate::salesforce_functions::CheckSalesforceFunctionError; +use libcnb::build::{BuildContext, BuildResult, BuildResultBuilder}; +use libcnb::data::layer_name; +use libcnb::detect::{DetectContext, DetectResult, DetectResultBuilder}; +use libcnb::generic::{GenericMetadata, GenericPlatform}; +use libcnb::layer_env::Scope; +use libcnb::{buildpack_main, Buildpack, Env}; +use libherokubuildpack::log::{log_header, log_info}; +use std::io; + +struct PythonBuildpack; + +impl Buildpack for PythonBuildpack { + type Platform = GenericPlatform; + type Metadata = GenericMetadata; + type Error = BuildpackError; + + fn detect(&self, context: DetectContext) -> libcnb::Result { + // In the future we will add support for requiring this buildpack through the build plan, + // but we first need a better understanding of real-world use-cases, so that we can work + // out how best to support them without sacrificing existing error handling UX (such as + // wanting to show a clear error when requirements.txt is missing). + if utils::is_python_project(&context.app_dir).map_err(BuildpackError::DetectIo)? { + DetectResultBuilder::pass().build() + } else { + log_info("No Python project files found (such as requirements.txt)."); + DetectResultBuilder::fail().build() + } + } + + fn build(&self, context: BuildContext) -> libcnb::Result { + // We perform all project analysis up front, so the build can fail early if the config is invalid. + // TODO: Add a "Build config" header and list all config in one place? + let is_function = salesforce_functions::is_function_project(&context.app_dir) + .map_err(BuildpackError::ProjectDescriptor)?; + let package_manager = package_manager::determine_package_manager(&context.app_dir) + .map_err(BuildpackError::DeterminePackageManager)?; + + log_header("Determining Python version"); + let python_version = python_version::determine_python_version(&context.app_dir) + .map_err(BuildpackError::PythonVersion)?; + let packaging_tool_versions = PackagingToolVersions::default(); + + // We inherit the current process's env vars, since we want `PATH` and `HOME` from the OS + // to be set, so that later commands can find tools like Git in the stack image. We exclude + // user-provided env vars (by setting `clear-env` to true in `buildpack.toml`) to prevent an + // app's env vars from breaking internal buildpack commands. Any buildpack steps that need + // user-provided env vars must explicitly retrieve them via `context.platform.env`. + let mut command_env = Env::from_current(); + + // Create the layer containing the Python runtime, and the packages `pip`, `setuptools` and `wheel`. + log_header("Installing Python and packaging tools"); + let python_layer = context.handle_layer( + layer_name!("python"), + PythonLayer { + command_env: &command_env, + python_version: &python_version, + packaging_tool_versions: &packaging_tool_versions, + }, + )?; + command_env = python_layer.env.apply(Scope::Build, &command_env); + + // Create the layers for the application dependencies and package manager cache. + // In the future support will be added for package managers other than pip. + let dependencies_layer_env = match package_manager { + PackageManager::Pip => { + log_header("Installing dependencies using Pip"); + let pip_cache_layer = context.handle_layer( + layer_name!("pip-cache"), + PipCacheLayer { + python_version: &python_version, + packaging_tool_versions: &packaging_tool_versions, + }, + )?; + let pip_layer = context.handle_layer( + layer_name!("dependencies"), + PipDependenciesLayer { + command_env: &command_env, + pip_cache_dir: pip_cache_layer.path, + }, + )?; + pip_layer.env + } + }; + command_env = dependencies_layer_env.apply(Scope::Build, &command_env); + + if is_function { + log_header("Validating Salesforce Function"); + salesforce_functions::check_function(&command_env) + .map_err(BuildpackError::CheckSalesforceFunction)?; + log_info("Function passed validation."); + + BuildResultBuilder::new() + .launch(salesforce_functions::launch_config()) + .build() + } else { + BuildResultBuilder::new().build() + } + } + + fn on_error(&self, error: libcnb::Error) { + errors::on_error(error); + } +} + +#[derive(Debug)] +pub(crate) enum BuildpackError { + /// Errors running the `sf-functions-python check` command. + CheckSalesforceFunction(CheckSalesforceFunctionError), + /// IO errors when performing buildpack detection. + DetectIo(io::Error), + /// Errors determining which Python package manager to use for a project. + DeterminePackageManager(DeterminePackageManagerError), + /// Errors installing the project's dependencies into a layer using Pip. + PipDependenciesLayer(PipDependenciesLayerError), + /// Errors reading and parsing a `project.toml` file. + ProjectDescriptor(ProjectDescriptorError), + /// Errors installing Python and required packaging tools into a layer. + PythonLayer(PythonLayerError), + /// Errors determining which Python version to use for a project. + PythonVersion(PythonVersionError), +} + +impl From for libcnb::Error { + fn from(error: BuildpackError) -> Self { + Self::BuildpackError(error) + } +} + +buildpack_main!(PythonBuildpack); + +// The integration tests are imported into the crate so that they can have access to private +// APIs and constants, saving having to (a) run a dual binary/library crate, (b) expose APIs +// publicly for things only used for testing. See: +// https://doc.rust-lang.org/reference/items/modules.html#the-path-attribute +#[cfg(test)] +#[path = "../tests/integration/mod.rs"] +mod integration_tests; diff --git a/src/package_manager.rs b/src/package_manager.rs new file mode 100644 index 0000000..0246cd4 --- /dev/null +++ b/src/package_manager.rs @@ -0,0 +1,62 @@ +use std::io; +use std::path::Path; + +/// An ordered mapping of project filenames to their associated package manager. +/// Earlier entries will take precedence if a project matches multiple package managers. +pub(crate) const PACKAGE_MANAGER_FILE_MAPPING: [(&str, PackageManager); 1] = + [("requirements.txt", PackageManager::Pip)]; + +/// Python package managers supported by the buildpack. +#[derive(Debug)] +pub(crate) enum PackageManager { + Pip, +} + +/// Determine the Python package manager to use for a project, or return an error if no supported +/// package manager files are found. If a project contains the files for multiple package managers, +/// then the earliest entry in `PACKAGE_MANAGER_FILE_MAPPING` takes precedence. +pub(crate) fn determine_package_manager( + app_dir: &Path, +) -> Result { + // Until `Iterator::try_find` is stabilised, this is cleaner as a for loop. + for (filename, package_manager) in PACKAGE_MANAGER_FILE_MAPPING { + if app_dir + .join(filename) + .try_exists() + .map_err(DeterminePackageManagerError::Io)? + { + return Ok(package_manager); + } + } + + Err(DeterminePackageManagerError::NoneFound) +} + +/// Errors that can occur when determining which Python package manager to use for a project. +#[derive(Debug)] +pub(crate) enum DeterminePackageManagerError { + Io(io::Error), + NoneFound, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn determine_package_manager_requirements_txt() { + assert!(matches!( + determine_package_manager(Path::new("tests/fixtures/pip_editable_git_compiled")) + .unwrap(), + PackageManager::Pip + )); + } + + #[test] + fn determine_package_manager_none() { + assert!(matches!( + determine_package_manager(Path::new("tests/fixtures/empty")).unwrap_err(), + DeterminePackageManagerError::NoneFound + )); + } +} diff --git a/src/packaging_tool_versions.rs b/src/packaging_tool_versions.rs new file mode 100644 index 0000000..73822e1 --- /dev/null +++ b/src/packaging_tool_versions.rs @@ -0,0 +1,22 @@ +use serde::{Deserialize, Serialize}; + +/// The versions of various packaging tools used during the build. +/// These are always installed, and are independent of the chosen package manager. +/// Strings are used instead of a semver version, since these packages don't use +/// semver, and we never introspect the version parts anyway. +#[derive(Clone, Deserialize, PartialEq, Serialize)] +pub(crate) struct PackagingToolVersions { + pub pip_version: String, + pub setuptools_version: String, + pub wheel_version: String, +} + +impl Default for PackagingToolVersions { + fn default() -> Self { + Self { + pip_version: "23.0.1".to_string(), + setuptools_version: "67.5.0".to_string(), + wheel_version: "0.38.4".to_string(), + } + } +} diff --git a/src/project_descriptor.rs b/src/project_descriptor.rs new file mode 100644 index 0000000..f5d3f1e --- /dev/null +++ b/src/project_descriptor.rs @@ -0,0 +1,279 @@ +use crate::utils; +use serde::Deserialize; +use std::io; +use std::path::Path; + +/// Reads the `com.salesforce.type` field from any `project.toml` in the specified directory. +/// +/// It is permitted for the `project.toml` file not to exist, or for there to be no `com.salesforce` +/// table within the TOML document, in which case `Ok(None)` will be returned. +/// +/// However, an error will be returned if any other IO error occurred, the file is not valid TOML, +/// or the TOML document does not adhere to the schema. +pub(crate) fn read_salesforce_project_type( + app_dir: &Path, +) -> Result, ProjectDescriptorError> { + read_project_descriptor(app_dir).map(|descriptor| { + descriptor + .unwrap_or_default() + .com + .unwrap_or_default() + .salesforce + .map(|salesforce| salesforce.project_type) + }) +} + +/// Reads any `project.toml` file in the specified directory, parsing it into a [`ProjectDescriptor`]. +/// +/// It is permitted for the `project.toml` file not to exist, in which case `Ok(None)` will be returned. +/// +/// However, an error will be returned if any other IO error occurred, the file is not valid TOML, +/// or the TOML document does not adhere to the schema. +fn read_project_descriptor( + app_dir: &Path, +) -> Result, ProjectDescriptorError> { + let project_descriptor_path = app_dir.join("project.toml"); + + utils::read_optional_file(&project_descriptor_path) + .map_err(ProjectDescriptorError::Io)? + .map(|contents| parse(&contents).map_err(ProjectDescriptorError::Parse)) + .transpose() +} + +/// Parse the contents of a project descriptor TOML file into a [`ProjectDescriptor`]. +/// +/// An error will be returned if the string is not valid TOML, or the TOML document does not +/// adhere to the schema. +fn parse(contents: &str) -> Result { + toml::from_str::(contents) +} + +/// Represents a Cloud Native Buildpack project descriptor file (`project.toml`). +/// +/// Currently only fields used by the buildpack are enforced, so this represents only a +/// subset of the upstream CNB project descriptor schema. +/// +/// See: +#[derive(Debug, Default, Deserialize, PartialEq)] +struct ProjectDescriptor { + com: Option, +} + +/// Represents the `com` table in the project descriptor. +#[derive(Debug, Default, Deserialize, PartialEq)] +struct ComTable { + salesforce: Option, +} + +/// Represents the `com.salesforce` table in the project descriptor. +/// +/// Currently only fields used by the buildpack are enforced, so this represents only a +/// subset of the Salesforce-specific project descriptor schema. +/// +/// See: +#[derive(Debug, Deserialize, PartialEq)] +struct SalesforceTable { + #[serde(rename = "type")] + project_type: SalesforceProjectType, +} + +/// The type of a Salesforce project. +/// +/// For now `Function` is the only valid type, however others will be added in the future. +/// +/// Unknown project types are intentionally rejected, since we're prioritising the UX for +/// functions projects where the type may have been mis-spelt, over forward-compatibility. +#[derive(Debug, Deserialize, PartialEq)] +pub(crate) enum SalesforceProjectType { + #[serde(rename = "function")] + Function, +} + +/// Errors that can occur when reading and parsing a `project.toml` file. +#[derive(Debug)] +pub(crate) enum ProjectDescriptorError { + Io(io::Error), + Parse(toml::de::Error), +} + +#[cfg(test)] +mod tests { + use super::*; + use libcnb_test::assert_contains; + + #[test] + fn deserialize_empty_descriptor() { + assert_eq!(parse("").unwrap(), ProjectDescriptor { com: None }); + } + + #[test] + fn deserialize_non_salesforce_descriptor() { + let toml_str = r#" + [_] + schema-version = "0.2" + + [io.buildpacks] + builder = "my-builder" + + [com.example] + key = "value" + "#; + + assert_eq!( + parse(toml_str), + Ok(ProjectDescriptor { + com: Some(ComTable { salesforce: None }) + }) + ); + } + + #[test] + fn deserialize_function_descriptor() { + let toml_str = r#" + [_] + schema-version = "0.2" + + [com.salesforce] + schema-version = "0.1" + id = "example" + description = "Example function" + type = "function" + salesforce-api-version = "56.0" + "#; + + assert_eq!( + parse(toml_str), + Ok(ProjectDescriptor { + com: Some(ComTable { + salesforce: Some(SalesforceTable { + project_type: SalesforceProjectType::Function + }) + }) + }) + ); + } + + #[test] + fn deserialize_minimal_function_descriptor() { + let toml_str = r#" + [com.salesforce] + type = "function" + "#; + + assert_eq!( + parse(toml_str), + Ok(ProjectDescriptor { + com: Some(ComTable { + salesforce: Some(SalesforceTable { + project_type: SalesforceProjectType::Function + }) + }) + }) + ); + } + + #[test] + fn reject_salesforce_table_with_no_project_type() { + let toml_str = r#" + [com.salesforce] + schema-version = "0.1" + id = "example" + "#; + + let error = parse(toml_str).unwrap_err(); + assert_contains!(error.to_string(), "missing field `type`"); + } + + #[test] + fn reject_unknown_salesforce_project_type() { + let toml_str = r#" + [com.salesforce] + type = "some_unknown_type" + "#; + + let error = parse(toml_str).unwrap_err(); + assert_contains!( + error.to_string(), + "unknown variant `some_unknown_type`, expected `function`" + ); + } + + #[test] + fn read_project_descriptor_no_project_toml_file() { + let app_dir = Path::new("tests/fixtures/empty"); + + assert_eq!(read_project_descriptor(app_dir).unwrap(), None); + } + + #[test] + fn read_project_descriptor_non_salesforce() { + let app_dir = Path::new("tests/fixtures/project_toml_non_salesforce"); + + assert_eq!( + read_project_descriptor(app_dir).unwrap(), + Some(ProjectDescriptor { + com: Some(ComTable { salesforce: None }) + }) + ); + } + + #[test] + fn read_project_descriptor_function() { + let app_dir = Path::new("tests/fixtures/salesforce_function_template"); + + assert_eq!( + read_project_descriptor(app_dir).unwrap(), + Some(ProjectDescriptor { + com: Some(ComTable { + salesforce: Some(SalesforceTable { + project_type: SalesforceProjectType::Function + }) + }) + }) + ); + } + + #[test] + fn read_project_descriptor_invalid_project_toml_file() { + let app_dir = Path::new("tests/fixtures/project_toml_invalid"); + + assert!(matches!( + read_project_descriptor(app_dir).unwrap_err(), + ProjectDescriptorError::Parse(_) + )); + } + + #[test] + fn get_salesforce_project_type_missing() { + let app_dir = Path::new("tests/fixtures/empty"); + + assert_eq!(read_salesforce_project_type(app_dir).unwrap(), None); + } + + #[test] + fn get_salesforce_project_type_non_salesforce() { + let app_dir = Path::new("tests/fixtures/project_toml_non_salesforce"); + + assert_eq!(read_salesforce_project_type(app_dir).unwrap(), None); + } + + #[test] + fn get_salesforce_project_type_function() { + let app_dir = Path::new("tests/fixtures/salesforce_function_template"); + + assert_eq!( + read_salesforce_project_type(app_dir).unwrap(), + Some(SalesforceProjectType::Function) + ); + } + + #[test] + fn get_salesforce_project_type_invalid_project_toml_file() { + let app_dir = Path::new("tests/fixtures/project_toml_invalid"); + + assert!(matches!( + read_salesforce_project_type(app_dir).unwrap_err(), + ProjectDescriptorError::Parse(_) + )); + } +} diff --git a/src/python_version.rs b/src/python_version.rs new file mode 100644 index 0000000..456725e --- /dev/null +++ b/src/python_version.rs @@ -0,0 +1,121 @@ +use crate::runtime_txt::{self, RuntimeTxtError}; +use indoc::formatdoc; +use libcnb::data::buildpack::StackId; +use libherokubuildpack::log::log_info; +use std::fmt::{self, Display}; +use std::path::Path; + +/// The Python version that will be installed if the project does not specify an explicit version. +pub(crate) const DEFAULT_PYTHON_VERSION: PythonVersion = PythonVersion { + major: 3, + minor: 11, + patch: 2, +}; + +/// Representation of a specific Python `X.Y.Z` version. +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct PythonVersion { + pub major: u16, + pub minor: u16, + pub patch: u16, +} + +impl PythonVersion { + pub fn new(major: u16, minor: u16, patch: u16) -> Self { + Self { + major, + minor, + patch, + } + } + + pub fn url(&self, stack_id: &StackId) -> String { + // TODO: (W-11474658) Switch to tracking versions/URLs via a manifest file. + format!( + "https://heroku-buildpack-python.s3.us-east-1.amazonaws.com/{}/runtimes/python-{}.{}.{}.tar.gz", + stack_id, self.major, self.minor, self.patch + ) + } +} + +impl Display for PythonVersion { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}.{}.{}", self.major, self.minor, self.patch) + } +} + +/// Determine the Python version that should be installed for the project. +/// +/// If no known version specifier file is found a default Python version will be used. +pub(crate) fn determine_python_version( + app_dir: &Path, +) -> Result { + if let Some(runtime_txt_version) = + runtime_txt::read_version(app_dir).map_err(PythonVersionError::RuntimeTxt)? + { + // TODO: Consider passing this back as a `source` field on PythonVersion + // so this can be logged by the caller. + log_info(format!( + "Using Python version {runtime_txt_version} specified in runtime.txt" + )); + return Ok(runtime_txt_version); + } + + // TODO: (W-12613425) Write this content inline, instead of linking out to Dev Center. + // Also adjust wording to mention pinning as a use-case, not just using a different version. + log_info(formatdoc! {" + No Python version specified, using the current default of Python {DEFAULT_PYTHON_VERSION}. + To use a different version, see: https://devcenter.heroku.com/articles/python-runtimes"}); + Ok(DEFAULT_PYTHON_VERSION) +} + +/// Errors that can occur when determining which Python version to use for a project. +#[derive(Debug)] +pub(crate) enum PythonVersionError { + /// Errors reading and parsing a `runtime.txt` file. + RuntimeTxt(RuntimeTxtError), +} + +#[cfg(test)] +mod tests { + use super::*; + use libcnb::data::stack_id; + + #[test] + fn python_version_url() { + assert_eq!( + PythonVersion::new(3, 11, 0).url(&stack_id!("heroku-22")), + "https://heroku-buildpack-python.s3.us-east-1.amazonaws.com/heroku-22/runtimes/python-3.11.0.tar.gz" + ); + } + + #[test] + fn determine_python_version_runtime_txt_valid() { + assert_eq!( + determine_python_version(Path::new("tests/fixtures/python_3.9")).unwrap(), + PythonVersion::new(3, 9, 16) + ); + assert_eq!( + determine_python_version(Path::new("tests/fixtures/runtime_txt_non_existent_version")) + .unwrap(), + PythonVersion::new(999, 888, 777) + ); + } + + #[test] + fn determine_python_version_runtime_txt_error() { + assert!(matches!( + determine_python_version(Path::new("tests/fixtures/runtime_txt_invalid_version")) + .unwrap_err(), + PythonVersionError::RuntimeTxt(RuntimeTxtError::Parse(_)) + )); + } + + #[test] + fn determine_python_version_none_specified() { + assert_eq!( + determine_python_version(Path::new("tests/fixtures/empty")).unwrap(), + DEFAULT_PYTHON_VERSION + ); + } +} diff --git a/src/runtime_txt.rs b/src/runtime_txt.rs new file mode 100644 index 0000000..0ccd5a3 --- /dev/null +++ b/src/runtime_txt.rs @@ -0,0 +1,230 @@ +use crate::python_version::PythonVersion; +use crate::utils; +use std::io; +use std::path::Path; + +/// Retrieve a parsed Python version from a `runtime.txt` file if it exists in the +/// specified project directory. +/// +/// Returns `Ok(None)` if the file does not exist, but returns the error for all other +/// forms of IO or parsing errors. +pub(crate) fn read_version(app_dir: &Path) -> Result, RuntimeTxtError> { + let runtime_txt_path = app_dir.join("runtime.txt"); + + utils::read_optional_file(&runtime_txt_path) + .map_err(RuntimeTxtError::Io)? + .map(|contents| parse(&contents).map_err(RuntimeTxtError::Parse)) + .transpose() +} + +/// Parse the contents of a `runtime.txt` file into a [`PythonVersion`]. +/// +/// The file is expected to contain a string of form `python-X.Y.Z`. +/// Any leading or trailing whitespace will be removed. +fn parse(contents: &str) -> Result { + // All leading/trailing whitespace is trimmed, since that's what the classic buildpack + // permitted (however it's primarily trailing newlines that we need to support). The + // string is then escaped, to aid debugging when non-ascii characters have inadvertently + // been used, such as when an editor has auto-corrected the hyphen to an en/em dash. + let cleaned_contents = contents.trim().escape_default().to_string(); + + let version_substring = + cleaned_contents + .strip_prefix("python-") + .ok_or_else(|| ParseRuntimeTxtError { + cleaned_contents: cleaned_contents.clone(), + })?; + + match version_substring + .split('.') + .map(str::parse) + .collect::, _>>() + .unwrap_or_default() + .as_slice() + { + &[major, minor, patch] => Ok(PythonVersion::new(major, minor, patch)), + _ => Err(ParseRuntimeTxtError { + cleaned_contents: cleaned_contents.clone(), + }), + } +} + +/// Errors that can occur when reading and parsing a `runtime.txt` file. +#[derive(Debug)] +pub(crate) enum RuntimeTxtError { + Io(io::Error), + Parse(ParseRuntimeTxtError), +} + +/// Errors that can occur when parsing the contents of a `runtime.txt` file. +#[derive(Debug, PartialEq)] +pub(crate) struct ParseRuntimeTxtError { + pub cleaned_contents: String, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_valid() { + assert_eq!(parse("python-1.2.3"), Ok(PythonVersion::new(1, 2, 3))); + assert_eq!( + parse("python-987.654.3210"), + Ok(PythonVersion::new(987, 654, 3210)) + ); + assert_eq!( + parse("\n python-1.2.3 \n"), + Ok(PythonVersion::new(1, 2, 3)) + ); + } + + #[test] + fn parse_invalid_prefix() { + assert_eq!( + parse(""), + Err(ParseRuntimeTxtError { + cleaned_contents: String::new() + }) + ); + assert_eq!( + parse("1.2.3"), + Err(ParseRuntimeTxtError { + cleaned_contents: "1.2.3".to_string() + }) + ); + assert_eq!( + parse("python 1.2.3"), + Err(ParseRuntimeTxtError { + cleaned_contents: "python 1.2.3".to_string() + }) + ); + assert_eq!( + parse("python -1.2.3"), + Err(ParseRuntimeTxtError { + cleaned_contents: "python -1.2.3".to_string() + }) + ); + assert_eq!( + parse("abc-1.2.3"), + Err(ParseRuntimeTxtError { + cleaned_contents: "abc-1.2.3".to_string() + }) + ); + assert_eq!( + parse("\n -1.2.3 \n"), + Err(ParseRuntimeTxtError { + cleaned_contents: "-1.2.3".to_string() + }) + ); + assert_eq!( + // En dash. + parse("python–1.2.3"), + Err(ParseRuntimeTxtError { + cleaned_contents: "python\\u{2013}1.2.3".to_string() + }) + ); + assert_eq!( + // Em dash. + parse("python—1.2.3"), + Err(ParseRuntimeTxtError { + cleaned_contents: "python\\u{2014}1.2.3".to_string() + }) + ); + } + + #[test] + fn parse_invalid_version() { + assert_eq!( + parse("python-1"), + Err(ParseRuntimeTxtError { + cleaned_contents: "python-1".to_string(), + }) + ); + assert_eq!( + parse("python-1.2"), + Err(ParseRuntimeTxtError { + cleaned_contents: "python-1.2".to_string(), + }) + ); + assert_eq!( + parse("python-1.2.3.4"), + Err(ParseRuntimeTxtError { + cleaned_contents: "python-1.2.3.4".to_string(), + }) + ); + assert_eq!( + parse("python-1..3"), + Err(ParseRuntimeTxtError { + cleaned_contents: "python-1..3".to_string(), + }) + ); + assert_eq!( + parse("python-1.2.3."), + Err(ParseRuntimeTxtError { + cleaned_contents: "python-1.2.3.".to_string(), + }) + ); + assert_eq!( + parse("python- 1.2.3"), + Err(ParseRuntimeTxtError { + cleaned_contents: "python- 1.2.3".to_string(), + }) + ); + assert_eq!( + parse("\n python-1.2.3a \n"), + Err(ParseRuntimeTxtError { + cleaned_contents: "python-1.2.3a".to_string(), + }) + ); + // These are valid semver versions, but not supported Python versions. + assert_eq!( + parse("python-1.2.3-dev"), + Err(ParseRuntimeTxtError { + cleaned_contents: "python-1.2.3-dev".to_string(), + }) + ); + assert_eq!( + parse("python-1.2.3+abc"), + Err(ParseRuntimeTxtError { + cleaned_contents: "python-1.2.3+abc".to_string(), + }) + ); + } + + #[test] + fn read_version_valid_runtime_txt() { + assert_eq!( + read_version(Path::new("tests/fixtures/python_3.9")).unwrap(), + Some(PythonVersion::new(3, 9, 16)) + ); + assert_eq!( + read_version(Path::new("tests/fixtures/runtime_txt_non_existent_version")).unwrap(), + Some(PythonVersion::new(999, 888, 777)) + ); + } + + #[test] + fn read_version_runtime_txt_not_present() { + assert_eq!( + read_version(Path::new("tests/fixtures/empty")).unwrap(), + None + ); + } + + #[test] + fn read_version_io_error() { + assert!(matches!( + read_version(Path::new("tests/fixtures/empty/.gitkeep")).unwrap_err(), + RuntimeTxtError::Io(_) + )); + } + + #[test] + fn read_version_parse_error() { + assert!(matches!( + read_version(Path::new("tests/fixtures/runtime_txt_invalid_version")).unwrap_err(), + RuntimeTxtError::Parse(_) + )); + } +} diff --git a/src/salesforce_functions.rs b/src/salesforce_functions.rs new file mode 100644 index 0000000..4f4dbd2 --- /dev/null +++ b/src/salesforce_functions.rs @@ -0,0 +1,120 @@ +use crate::project_descriptor::{self, ProjectDescriptorError, SalesforceProjectType}; +use libcnb::data::launch::{Launch, LaunchBuilder, ProcessBuilder}; +use libcnb::data::process_type; +use libcnb::Env; +use std::io; +use std::path::Path; +use std::process::{Command, Output}; + +/// The program/script name of the Python Functions runtime's CLI. +pub(crate) const FUNCTION_RUNTIME_PROGRAM_NAME: &str = "sf-functions-python"; + +/// Detect whether the specified project directory is that of a Salesforce Function. +/// +/// Returns `Ok(true)` if the specified project directory contains a `project.toml` file with a +/// `com.salesforce.type` of "function". +/// +/// It is permitted for the `project.toml` file not to exist, or for there to be no `com.salesforce` +/// TOML table within the file, in which case `Ok(false)` will be returned. +/// +/// However, an error will be returned if any other IO error occurred, if the `project.toml` file +/// is not valid TOML, or the TOML document does not adhere to the schema. +pub(crate) fn is_function_project(app_dir: &Path) -> Result { + project_descriptor::read_salesforce_project_type(app_dir) + .map(|project_type| project_type == Some(SalesforceProjectType::Function)) +} + +/// Validate the function using the `sf-functions-python check` command. +pub(crate) fn check_function(command_env: &Env) -> Result<(), CheckSalesforceFunctionError> { + // Not using `utils::run_command` since we want to capture output and only + // display it if the check command fails. + Command::new(FUNCTION_RUNTIME_PROGRAM_NAME) + .args(["check", "."]) + .env_clear() + .envs(command_env) + .output() + .map_err(|io_error| match io_error.kind() { + io::ErrorKind::NotFound => CheckSalesforceFunctionError::ProgramNotFound, + _ => CheckSalesforceFunctionError::Io(io_error), + }) + .and_then(|output| { + if output.status.success() { + Ok(()) + } else { + Err(CheckSalesforceFunctionError::NonZeroExitStatus(output)) + } + }) +} + +/// Generate a `launch.toml` configuration for running Python Salesforce Functions. +/// +/// Runs the `sf-functions-python serve` command with suitable options for production. +pub(crate) fn launch_config() -> Launch { + LaunchBuilder::new() + .process( + // TODO: Stop running via bash once direct processes support env var interpolation: + // https://github.com/buildpacks/rfcs/issues/258 + ProcessBuilder::new(process_type!("web"), "bash") + .args([ + "-c", + &[ + "exec", + FUNCTION_RUNTIME_PROGRAM_NAME, + "serve", + "--host", + "0.0.0.0", + "--port", + "\"${PORT:-8080}\"", + // TODO: Determine optimal number of workers. + "--workers", + "4", + ".", + ] + .join(" "), + ]) + .default(true) + .direct(true) + .build(), + ) + .build() +} + +/// Errors that can occur when running the `sf-functions-python check` command. +#[derive(Debug)] +pub(crate) enum CheckSalesforceFunctionError { + Io(io::Error), + NonZeroExitStatus(Output), + ProgramNotFound, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn is_function_project_no_project_toml() { + assert!(!is_function_project(Path::new("tests/fixtures/empty")).unwrap()); + } + + #[test] + fn is_function_project_non_salesforce_project_toml() { + assert!( + !is_function_project(Path::new("tests/fixtures/project_toml_non_salesforce")).unwrap() + ); + } + + #[test] + fn is_function_project_valid_function_project_toml() { + assert!( + is_function_project(Path::new("tests/fixtures/salesforce_function_template")).unwrap() + ); + } + + #[test] + fn is_function_project_invalid_project_toml() { + assert!(matches!( + is_function_project(Path::new("tests/fixtures/project_toml_invalid")).unwrap_err(), + ProjectDescriptorError::Parse(_) + )); + } +} diff --git a/src/utils.rs b/src/utils.rs new file mode 100644 index 0000000..e0ca74b --- /dev/null +++ b/src/utils.rs @@ -0,0 +1,158 @@ +use flate2::read::GzDecoder; +use std::path::Path; +use std::process::{Command, ExitStatus}; +use std::{fs, io}; +use tar::Archive; + +/// Filenames that if found in a project mean it should be treated as a Python project, +/// and so pass this buildpack's detection phase. +/// +/// This list is deliberately larger than just the list of supported package manager files, +/// so that Python projects that are missing some of the required files still pass detection, +/// allowing us to show a more detailed error message during the build phase than is possible +/// during detect. +const KNOWN_PYTHON_PROJECT_FILES: [&str; 9] = [ + ".python-version", + "main.py", + "manage.py", + "Pipfile", + "poetry.lock", + "pyproject.toml", + "requirements.txt", + "runtime.txt", + "setup.py", +]; + +/// Returns whether the specified project directory is that of a Python project, and so +/// should pass buildpack detection. +pub(crate) fn is_python_project(app_dir: &Path) -> io::Result { + // Until `Iterator::try_find` is stabilised, this is cleaner as a for loop. + for filename in KNOWN_PYTHON_PROJECT_FILES { + if app_dir.join(filename).try_exists()? { + return Ok(true); + } + } + + Ok(false) +} + +/// Read the contents of the provided filepath if the file exists, gracefully handling +/// the file not being present, but still returning any other form of IO error. +pub(crate) fn read_optional_file(path: &Path) -> io::Result> { + fs::read_to_string(path) + .map(Some) + .or_else(|io_error| match io_error.kind() { + io::ErrorKind::NotFound => Ok(None), + _ => Err(io_error), + }) +} + +/// Download a gzipped tar file and unpack it to the specified directory. +pub(crate) fn download_and_unpack_gzipped_archive( + uri: &str, + destination: &Path, +) -> Result<(), DownloadUnpackArchiveError> { + // TODO: (W-12613141) Add a timeout: https://docs.rs/ureq/latest/ureq/struct.AgentBuilder.html?search=timeout + // TODO: (W-12613168) Add retries for certain failure modes, eg: https://github.com/algesten/ureq/blob/05b9a82a380af013338c4f42045811fc15689a6b/src/error.rs#L39-L63 + let response = ureq::get(uri) + .call() + .map_err(DownloadUnpackArchiveError::Request)?; + let gzip_decoder = GzDecoder::new(response.into_reader()); + Archive::new(gzip_decoder) + .unpack(destination) + .map_err(DownloadUnpackArchiveError::Io) +} + +/// Errors that can occur when downloading and unpacking an archive using `download_and_unpack_gzipped_archive`. +#[derive(Debug)] +pub(crate) enum DownloadUnpackArchiveError { + Io(io::Error), + Request(ureq::Error), +} + +/// A helper for running an external process using [`Command`], that checks the exit +/// status of the process was non-zero. +pub(crate) fn run_command(command: &mut Command) -> Result<(), CommandError> { + command + .status() + .map_err(CommandError::Io) + .and_then(|exit_status| { + if exit_status.success() { + Ok(()) + } else { + Err(CommandError::NonZeroExitStatus(exit_status)) + } + }) +} + +/// Errors that can occur when running an external process using `run_command`. +#[derive(Debug)] +pub(crate) enum CommandError { + Io(io::Error), + NonZeroExitStatus(ExitStatus), +} + +/// Convert a [`libcnb::Env`] to a sorted vector of key-value string slice tuples, for easier +/// testing of the environment variables set in the buildpack layers. +#[cfg(test)] +pub(crate) fn environment_as_sorted_vector(environment: &libcnb::Env) -> Vec<(&str, &str)> { + let mut result: Vec<(&str, &str)> = environment + .iter() + .map(|(k, v)| (k.to_str().unwrap(), v.to_str().unwrap())) + .collect(); + + result.sort_by_key(|kv| kv.0); + result +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::package_manager::PACKAGE_MANAGER_FILE_MAPPING; + + #[test] + fn is_python_project_valid_project() { + assert!(is_python_project(Path::new("tests/fixtures/pyproject_toml_only")).unwrap()); + } + + #[test] + fn is_python_project_empty() { + assert!(!is_python_project(Path::new("tests/fixtures/empty")).unwrap()); + } + + #[test] + fn is_python_project_io_error() { + assert!(is_python_project(Path::new("tests/fixtures/empty/.gitkeep")).is_err()); + } + + #[test] + fn read_optional_file_valid_file() { + assert_eq!( + read_optional_file(Path::new("tests/fixtures/python_3.9/runtime.txt")).unwrap(), + Some("python-3.9.16\n".to_string()) + ); + } + + #[test] + fn read_optional_file_missing_file() { + assert_eq!( + read_optional_file(Path::new( + "tests/fixtures/non-existent-dir/non-existent-file" + )) + .unwrap(), + None + ); + } + + #[test] + fn read_optional_file_io_error() { + assert!(read_optional_file(Path::new("tests/fixtures/")).is_err()); + } + + #[test] + fn known_python_project_files_contains_all_package_manager_files() { + assert!(PACKAGE_MANAGER_FILE_MAPPING + .iter() + .all(|(filename, _)| { KNOWN_PYTHON_PROJECT_FILES.contains(filename) })); + } +} diff --git a/tests/fixtures/empty/.gitkeep b/tests/fixtures/empty/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/fixtures/pip_editable_git_compiled/requirements.txt b/tests/fixtures/pip_editable_git_compiled/requirements.txt new file mode 100644 index 0000000..191a23a --- /dev/null +++ b/tests/fixtures/pip_editable_git_compiled/requirements.txt @@ -0,0 +1,9 @@ +# This requirement uses a VCS URL and `-e` in order to test that: +# - Git from the stack image can be found (ie: the system PATH has been correctly propagated to pip). +# - The editable mode repository clone is saved into the dependencies layer (via the `--src` option). +# +# The psycopg2 package is used instead of a pure Python package, in order to test that: +# - The Python headers can be found in the `include/pythonX.Y/` directory of the Python layer. +# - Headers/libraries from the stack image can be found (in this case, for libpq-dev). + +-e git+https://github.com/psycopg/psycopg2@2_9_5#egg=psycopg2 diff --git a/tests/fixtures/pip_invalid_requirement/requirements.txt b/tests/fixtures/pip_invalid_requirement/requirements.txt new file mode 100644 index 0000000..db42b7e --- /dev/null +++ b/tests/fixtures/pip_invalid_requirement/requirements.txt @@ -0,0 +1 @@ +an-invalid-requirement! diff --git a/tests/fixtures/project_toml_invalid/project.toml b/tests/fixtures/project_toml_invalid/project.toml new file mode 100644 index 0000000..7a6399a --- /dev/null +++ b/tests/fixtures/project_toml_invalid/project.toml @@ -0,0 +1,5 @@ +[_] +schema-version = "0.2" + +[com.salesforce] +# The required fields here are missing. diff --git a/tests/fixtures/project_toml_invalid/requirements.txt b/tests/fixtures/project_toml_invalid/requirements.txt new file mode 100644 index 0000000..e69de29 diff --git a/tests/fixtures/project_toml_non_salesforce/project.toml b/tests/fixtures/project_toml_non_salesforce/project.toml new file mode 100644 index 0000000..dd8b5ef --- /dev/null +++ b/tests/fixtures/project_toml_non_salesforce/project.toml @@ -0,0 +1,8 @@ +[_] +schema-version = "0.2" + +[io.buildpacks] +builder = "my-builder" + +[com.example] +key = "value" diff --git a/tests/fixtures/pyproject_toml_only/pyproject.toml b/tests/fixtures/pyproject_toml_only/pyproject.toml new file mode 100644 index 0000000..e69de29 diff --git a/tests/fixtures/python_3.10/requirements.txt b/tests/fixtures/python_3.10/requirements.txt new file mode 100644 index 0000000..fd0f81c --- /dev/null +++ b/tests/fixtures/python_3.10/requirements.txt @@ -0,0 +1,2 @@ +# This package has been picked since it has no dependencies and is small/fast to install. +typing-extensions==4.4.0 diff --git a/tests/fixtures/python_3.10/runtime.txt b/tests/fixtures/python_3.10/runtime.txt new file mode 100644 index 0000000..9769138 --- /dev/null +++ b/tests/fixtures/python_3.10/runtime.txt @@ -0,0 +1 @@ +python-3.10.10 diff --git a/tests/fixtures/python_3.11/requirements.txt b/tests/fixtures/python_3.11/requirements.txt new file mode 100644 index 0000000..fd0f81c --- /dev/null +++ b/tests/fixtures/python_3.11/requirements.txt @@ -0,0 +1,2 @@ +# This package has been picked since it has no dependencies and is small/fast to install. +typing-extensions==4.4.0 diff --git a/tests/fixtures/python_3.11/runtime.txt b/tests/fixtures/python_3.11/runtime.txt new file mode 100644 index 0000000..04d03e3 --- /dev/null +++ b/tests/fixtures/python_3.11/runtime.txt @@ -0,0 +1 @@ +python-3.11.2 diff --git a/tests/fixtures/python_3.7/requirements.txt b/tests/fixtures/python_3.7/requirements.txt new file mode 100644 index 0000000..fd0f81c --- /dev/null +++ b/tests/fixtures/python_3.7/requirements.txt @@ -0,0 +1,2 @@ +# This package has been picked since it has no dependencies and is small/fast to install. +typing-extensions==4.4.0 diff --git a/tests/fixtures/python_3.7/runtime.txt b/tests/fixtures/python_3.7/runtime.txt new file mode 100644 index 0000000..91b17a1 --- /dev/null +++ b/tests/fixtures/python_3.7/runtime.txt @@ -0,0 +1 @@ +python-3.7.16 diff --git a/tests/fixtures/python_3.8/requirements.txt b/tests/fixtures/python_3.8/requirements.txt new file mode 100644 index 0000000..fd0f81c --- /dev/null +++ b/tests/fixtures/python_3.8/requirements.txt @@ -0,0 +1,2 @@ +# This package has been picked since it has no dependencies and is small/fast to install. +typing-extensions==4.4.0 diff --git a/tests/fixtures/python_3.8/runtime.txt b/tests/fixtures/python_3.8/runtime.txt new file mode 100644 index 0000000..9e9414f --- /dev/null +++ b/tests/fixtures/python_3.8/runtime.txt @@ -0,0 +1 @@ +python-3.8.16 diff --git a/tests/fixtures/python_3.9/requirements.txt b/tests/fixtures/python_3.9/requirements.txt new file mode 100644 index 0000000..fd0f81c --- /dev/null +++ b/tests/fixtures/python_3.9/requirements.txt @@ -0,0 +1,2 @@ +# This package has been picked since it has no dependencies and is small/fast to install. +typing-extensions==4.4.0 diff --git a/tests/fixtures/python_3.9/runtime.txt b/tests/fixtures/python_3.9/runtime.txt new file mode 100644 index 0000000..c9cbcea --- /dev/null +++ b/tests/fixtures/python_3.9/runtime.txt @@ -0,0 +1 @@ +python-3.9.16 diff --git a/tests/fixtures/python_version_unspecified/requirements.txt b/tests/fixtures/python_version_unspecified/requirements.txt new file mode 100644 index 0000000..fd0f81c --- /dev/null +++ b/tests/fixtures/python_version_unspecified/requirements.txt @@ -0,0 +1,2 @@ +# This package has been picked since it has no dependencies and is small/fast to install. +typing-extensions==4.4.0 diff --git a/tests/fixtures/runtime_txt_invalid_version/requirements.txt b/tests/fixtures/runtime_txt_invalid_version/requirements.txt new file mode 100644 index 0000000..e69de29 diff --git a/tests/fixtures/runtime_txt_invalid_version/runtime.txt b/tests/fixtures/runtime_txt_invalid_version/runtime.txt new file mode 100644 index 0000000..606a469 --- /dev/null +++ b/tests/fixtures/runtime_txt_invalid_version/runtime.txt @@ -0,0 +1 @@ +python-an.invalid.version diff --git a/tests/fixtures/runtime_txt_non_existent_version/requirements.txt b/tests/fixtures/runtime_txt_non_existent_version/requirements.txt new file mode 100644 index 0000000..e69de29 diff --git a/tests/fixtures/runtime_txt_non_existent_version/runtime.txt b/tests/fixtures/runtime_txt_non_existent_version/runtime.txt new file mode 100644 index 0000000..f5bde40 --- /dev/null +++ b/tests/fixtures/runtime_txt_non_existent_version/runtime.txt @@ -0,0 +1 @@ +python-999.888.777 diff --git a/tests/fixtures/salesforce_function_fails_self_check/main.py b/tests/fixtures/salesforce_function_fails_self_check/main.py new file mode 100644 index 0000000..b46ee45 --- /dev/null +++ b/tests/fixtures/salesforce_function_fails_self_check/main.py @@ -0,0 +1,5 @@ +from salesforce_functions import Context, InvocationEvent + + +def function(_event: InvocationEvent[None], _context: Context) -> None: + return None diff --git a/tests/fixtures/salesforce_function_fails_self_check/project.toml b/tests/fixtures/salesforce_function_fails_self_check/project.toml new file mode 100644 index 0000000..2a44a3a --- /dev/null +++ b/tests/fixtures/salesforce_function_fails_self_check/project.toml @@ -0,0 +1,3 @@ +[com.salesforce] +type = "function" +salesforce-api-version = "invalid" diff --git a/tests/fixtures/salesforce_function_fails_self_check/requirements.txt b/tests/fixtures/salesforce_function_fails_self_check/requirements.txt new file mode 100644 index 0000000..ced5be3 --- /dev/null +++ b/tests/fixtures/salesforce_function_fails_self_check/requirements.txt @@ -0,0 +1 @@ +salesforce-functions diff --git a/tests/fixtures/salesforce_function_missing_package/main.py b/tests/fixtures/salesforce_function_missing_package/main.py new file mode 100644 index 0000000..80920de --- /dev/null +++ b/tests/fixtures/salesforce_function_missing_package/main.py @@ -0,0 +1,5 @@ +from salesforce_functions import Context, InvocationEvent + + +async def function(_event: InvocationEvent[None], _context: Context) -> None: + return None diff --git a/tests/fixtures/salesforce_function_missing_package/project.toml b/tests/fixtures/salesforce_function_missing_package/project.toml new file mode 100644 index 0000000..ef6d5f8 --- /dev/null +++ b/tests/fixtures/salesforce_function_missing_package/project.toml @@ -0,0 +1,2 @@ +[com.salesforce] +type = "function" diff --git a/tests/fixtures/salesforce_function_missing_package/requirements.txt b/tests/fixtures/salesforce_function_missing_package/requirements.txt new file mode 100644 index 0000000..fbb9d22 --- /dev/null +++ b/tests/fixtures/salesforce_function_missing_package/requirements.txt @@ -0,0 +1 @@ +# The salesforce-functions package is missing from here. diff --git a/tests/fixtures/salesforce_function_template/README.md b/tests/fixtures/salesforce_function_template/README.md new file mode 100644 index 0000000..4bcafa9 --- /dev/null +++ b/tests/fixtures/salesforce_function_template/README.md @@ -0,0 +1,3 @@ +# Pythonexample Function + + diff --git a/tests/fixtures/salesforce_function_template/main.py b/tests/fixtures/salesforce_function_template/main.py new file mode 100644 index 0000000..4c05d20 --- /dev/null +++ b/tests/fixtures/salesforce_function_template/main.py @@ -0,0 +1,20 @@ +from typing import Any + +from salesforce_functions import Context, InvocationEvent, get_logger + +# The type of the data payload sent with the invocation event. +# Change this to a more specific type matching the expected payload for +# improved IDE auto-completion and linting coverage. For example: +# `EventPayloadType = dict[str, Any]` +EventPayloadType = Any + +logger = get_logger() + + +async def function(event: InvocationEvent[EventPayloadType], context: Context): + """Describe the function here.""" + + result = await context.org.data_api.query("SELECT Id, Name FROM Account") + logger.info(f"Function successfully queried {result.total_size} account records!") + + return result.records diff --git a/tests/fixtures/salesforce_function_template/payload.json b/tests/fixtures/salesforce_function_template/payload.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/tests/fixtures/salesforce_function_template/payload.json @@ -0,0 +1 @@ +{} diff --git a/tests/fixtures/salesforce_function_template/project.toml b/tests/fixtures/salesforce_function_template/project.toml new file mode 100644 index 0000000..ac505f2 --- /dev/null +++ b/tests/fixtures/salesforce_function_template/project.toml @@ -0,0 +1,9 @@ +[_] +schema-version = "0.2" + +[com.salesforce] +schema-version = "0.1" +id = "pythonexample" +description = "A Salesforce Function" +type = "function" +salesforce-api-version = "56.0" diff --git a/tests/fixtures/salesforce_function_template/requirements.txt b/tests/fixtures/salesforce_function_template/requirements.txt new file mode 100644 index 0000000..ced5be3 --- /dev/null +++ b/tests/fixtures/salesforce_function_template/requirements.txt @@ -0,0 +1 @@ +salesforce-functions diff --git a/tests/integration/detect.rs b/tests/integration/detect.rs new file mode 100644 index 0000000..b227483 --- /dev/null +++ b/tests/integration/detect.rs @@ -0,0 +1,36 @@ +use crate::integration_tests::builder; +use indoc::formatdoc; +use libcnb::data::buildpack::{BuildpackVersion, SingleBuildpackDescriptor}; +use libcnb_test::{assert_contains, BuildConfig, PackResult, TestRunner}; +use std::fs; + +#[test] +#[ignore = "integration test"] +fn detect_rejects_non_python_projects() { + let buildpack_version = buildpack_version(); + + TestRunner::default().build( + BuildConfig::new(builder(), "tests/fixtures/empty") + .expected_pack_result(PackResult::Failure), + |context| { + assert_contains!( + context.pack_stdout, + &formatdoc! {" + ===> DETECTING + ======== Output: heroku/python@{buildpack_version} ======== + No Python project files found (such as requirements.txt). + ======== Results ======== + fail: heroku/python@{buildpack_version} + ERROR: No buildpack groups passed detection. + "} + ); + }, + ); +} + +fn buildpack_version() -> BuildpackVersion { + let buildpack_toml = fs::read_to_string("buildpack.toml").unwrap(); + let buildpack_descriptor = + toml::from_str::>>(&buildpack_toml).unwrap(); + buildpack_descriptor.buildpack.version +} diff --git a/tests/integration/mod.rs b/tests/integration/mod.rs new file mode 100644 index 0000000..43afd4f --- /dev/null +++ b/tests/integration/mod.rs @@ -0,0 +1,23 @@ +//! All integration tests are skipped by default (using the `ignore` attribute), +//! since performing builds is slow. To run the tests use: `cargo test -- --ignored` + +use std::env; + +mod detect; +mod package_manager; +mod pip; +mod python_version; +mod salesforce_functions; + +const LATEST_PYTHON_3_7: &str = "3.7.16"; +const LATEST_PYTHON_3_8: &str = "3.8.16"; +const LATEST_PYTHON_3_9: &str = "3.9.16"; +const LATEST_PYTHON_3_10: &str = "3.10.10"; +const LATEST_PYTHON_3_11: &str = "3.11.2"; +const DEFAULT_PYTHON_VERSION: &str = LATEST_PYTHON_3_11; + +const DEFAULT_BUILDER: &str = "heroku/builder:22"; + +fn builder() -> String { + env::var("INTEGRATION_TEST_CNB_BUILDER").unwrap_or(DEFAULT_BUILDER.to_string()) +} diff --git a/tests/integration/package_manager.rs b/tests/integration/package_manager.rs new file mode 100644 index 0000000..8f26db7 --- /dev/null +++ b/tests/integration/package_manager.rs @@ -0,0 +1,28 @@ +use crate::integration_tests::builder; +use indoc::indoc; +use libcnb_test::{assert_contains, BuildConfig, PackResult, TestRunner}; + +#[test] +#[ignore = "integration test"] +fn no_package_manager_detected() { + TestRunner::default().build( + BuildConfig::new(builder(), "tests/fixtures/pyproject_toml_only") + .expected_pack_result(PackResult::Failure), + |context| { + assert_contains!( + context.pack_stderr, + indoc! {" + [Error: No Python package manager files were found] + A Pip requirements file was not found in your application's source code. + This file is required so that your application's dependencies can be installed. + + Please add a file named exactly 'requirements.txt' to the root directory of your + application, containing a list of the packages required by your application. + + For more information on what this file should contain, see: + https://pip.pypa.io/en/stable/reference/requirements-file-format/ + "} + ); + }, + ); +} diff --git a/tests/integration/pip.rs b/tests/integration/pip.rs new file mode 100644 index 0000000..f3c1723 --- /dev/null +++ b/tests/integration/pip.rs @@ -0,0 +1,229 @@ +use crate::integration_tests::{ + builder, DEFAULT_PYTHON_VERSION, LATEST_PYTHON_3_10, LATEST_PYTHON_3_11, +}; +use crate::packaging_tool_versions::PackagingToolVersions; +use indoc::formatdoc; +use libcnb_test::{assert_contains, assert_empty, BuildConfig, PackResult, TestRunner}; + +#[test] +#[ignore = "integration test"] +fn pip_editable_git_compiled() { + // This tests that: + // - Git from the stack image can be found (ie: the system PATH has been correctly propagated to pip). + // - The editable mode repository clone is saved into the dependencies layer not the app dir. + // - Compiling a source distribution package (as opposed to a pre-built wheel) works. + // - The Python headers can be found in the `include/pythonX.Y/` directory of the Python layer. + // - Headers/libraries from the stack image can be found (in this case, for libpq-dev). + TestRunner::default().build( + BuildConfig::new(builder(), "tests/fixtures/pip_editable_git_compiled"), + |context| { + assert_contains!( + context.pack_stdout, + "Cloning https://github.com/psycopg/psycopg2 (to revision 2_9_5) to /layers/heroku_python/dependencies/src/psycopg2" + ); + }, + ); +} + +#[test] +#[ignore = "integration test"] +fn pip_install_error() { + TestRunner::default().build( + BuildConfig::new(builder(), "tests/fixtures/pip_invalid_requirement") + .expected_pack_result(PackResult::Failure), + |context| { + // Ideally we could test a combined stdout/stderr, however libcnb-test doesn't support this: + // https://github.com/heroku/libcnb.rs/issues/536 + assert_contains!( + context.pack_stdout, + &formatdoc! {" + [Installing dependencies using Pip] + Running pip install + "} + ); + assert_contains!( + context.pack_stderr, + &formatdoc! {" + ERROR: Invalid requirement: 'an-invalid-requirement!' (from line 1 of requirements.txt) + + [Error: Unable to install dependencies using pip] + The 'pip install' command to install the application's dependencies from + 'requirements.txt' failed (exit status: 1). + + See the log output above for more information. + "} + ); + }, + ); +} + +#[test] +#[ignore = "integration test"] +fn cache_used_for_repeat_builds() { + let PackagingToolVersions { + pip_version, + setuptools_version, + wheel_version, + } = PackagingToolVersions::default(); + + let config = BuildConfig::new(builder(), "tests/fixtures/python_3.11"); + + TestRunner::default().build(&config, |context| { + context.rebuild(&config, |rebuild_context| { + assert_empty!(rebuild_context.pack_stderr); + assert_contains!( + rebuild_context.pack_stdout, + &formatdoc! {" + ===> BUILDING + + [Determining Python version] + Using Python version {LATEST_PYTHON_3_11} specified in runtime.txt + + [Installing Python and packaging tools] + Using cached Python {LATEST_PYTHON_3_11} + Using cached pip {pip_version}, setuptools {setuptools_version} and wheel {wheel_version} + + [Installing dependencies using Pip] + Using cached pip download/wheel cache + Running pip install + Collecting typing-extensions==4.4.0 + Using cached typing_extensions-4.4.0-py3-none-any.whl (26 kB) + Installing collected packages: typing-extensions + Successfully installed typing-extensions-4.4.0 + ===> EXPORTING + "} + ); + }); + }); +} + +#[test] +#[ignore = "integration test"] +fn cache_discarded_on_python_version_change() { + let PackagingToolVersions { + pip_version, + setuptools_version, + wheel_version, + } = PackagingToolVersions::default(); + + let builder = builder(); + let config_before = BuildConfig::new(&builder, "tests/fixtures/python_3.10"); + let config_after = BuildConfig::new(&builder, "tests/fixtures/python_3.11"); + + TestRunner::default().build(config_before, |context| { + context.rebuild(config_after, |rebuild_context| { + assert_empty!(rebuild_context.pack_stderr); + assert_contains!( + rebuild_context.pack_stdout, + &formatdoc! {" + ===> BUILDING + + [Determining Python version] + Using Python version {LATEST_PYTHON_3_11} specified in runtime.txt + + [Installing Python and packaging tools] + Discarding cache since the Python version has changed from {LATEST_PYTHON_3_10} to {LATEST_PYTHON_3_11} + Installing Python {LATEST_PYTHON_3_11} + Installing pip {pip_version}, setuptools {setuptools_version} and wheel {wheel_version} + + [Installing dependencies using Pip] + Discarding cached pip download/wheel cache + Running pip install + Collecting typing-extensions==4.4.0 + Downloading typing_extensions-4.4.0-py3-none-any.whl (26 kB) + Installing collected packages: typing-extensions + Successfully installed typing-extensions-4.4.0 + ===> EXPORTING + "} + ); + }); + }); +} + +#[test] +#[ignore = "integration test"] +fn cache_discarded_on_stack_change() { + let PackagingToolVersions { + pip_version, + setuptools_version, + wheel_version, + } = PackagingToolVersions::default(); + + let fixture = "tests/fixtures/python_version_unspecified"; + let config_before = BuildConfig::new("heroku/buildpacks:20", fixture); + let config_after = BuildConfig::new("heroku/builder:22", fixture); + + TestRunner::default().build(config_before, |context| { + context.rebuild(config_after, |rebuild_context| { + assert_empty!(rebuild_context.pack_stderr); + assert_contains!( + rebuild_context.pack_stdout, + &formatdoc! {" + ===> BUILDING + + [Determining Python version] + No Python version specified, using the current default of Python {DEFAULT_PYTHON_VERSION}. + To use a different version, see: https://devcenter.heroku.com/articles/python-runtimes + + [Installing Python and packaging tools] + Discarding cache since the stack has changed from heroku-20 to heroku-22 + Installing Python {DEFAULT_PYTHON_VERSION} + Installing pip {pip_version}, setuptools {setuptools_version} and wheel {wheel_version} + + [Installing dependencies using Pip] + Discarding cached pip download/wheel cache + Running pip install + Collecting typing-extensions==4.4.0 + Downloading typing_extensions-4.4.0-py3-none-any.whl (26 kB) + Installing collected packages: typing-extensions + Successfully installed typing-extensions-4.4.0 + ===> EXPORTING + "} + ); + }); + }); +} + +#[test] +#[ignore = "integration test"] +fn cache_discarded_on_multiple_changes() { + let PackagingToolVersions { + pip_version, + setuptools_version, + wheel_version, + } = PackagingToolVersions::default(); + + let config_before = BuildConfig::new("heroku/buildpacks:20", "tests/fixtures/python_3.10"); + let config_after = BuildConfig::new("heroku/builder:22", "tests/fixtures/python_3.11"); + + TestRunner::default().build(config_before, |context| { + context.rebuild(config_after, |rebuild_context| { + assert_empty!(rebuild_context.pack_stderr); + assert_contains!( + rebuild_context.pack_stdout, + &formatdoc! {" + ===> BUILDING + + [Determining Python version] + Using Python version {LATEST_PYTHON_3_11} specified in runtime.txt + + [Installing Python and packaging tools] + Discarding cache since: + - the stack has changed from heroku-20 to heroku-22 + - the Python version has changed from 3.10.10 to 3.11.2 + Installing Python {LATEST_PYTHON_3_11} + Installing pip {pip_version}, setuptools {setuptools_version} and wheel {wheel_version} + + [Installing dependencies using Pip] + Discarding cached pip download/wheel cache + Running pip install + Collecting typing-extensions==4.4.0 + Downloading typing_extensions-4.4.0-py3-none-any.whl (26 kB) + Installing collected packages: typing-extensions + Successfully installed typing-extensions-4.4.0 + ===> EXPORTING + "} + ); + }); + }); +} diff --git a/tests/integration/python_version.rs b/tests/integration/python_version.rs new file mode 100644 index 0000000..bce028c --- /dev/null +++ b/tests/integration/python_version.rs @@ -0,0 +1,250 @@ +use crate::integration_tests::{ + builder, DEFAULT_PYTHON_VERSION, LATEST_PYTHON_3_10, LATEST_PYTHON_3_11, LATEST_PYTHON_3_7, + LATEST_PYTHON_3_8, LATEST_PYTHON_3_9, +}; +use crate::packaging_tool_versions::PackagingToolVersions; +use indoc::{formatdoc, indoc}; +use libcnb_test::{assert_contains, assert_empty, BuildConfig, PackResult, TestRunner}; + +#[test] +#[ignore = "integration test"] +fn python_version_unspecified() { + let PackagingToolVersions { + pip_version, + setuptools_version, + wheel_version, + } = PackagingToolVersions::default(); + + TestRunner::default().build( + BuildConfig::new(builder(), "tests/fixtures/python_version_unspecified"), + |context| { + assert_empty!(context.pack_stderr); + assert_contains!( + context.pack_stdout, + &formatdoc! {" + ===> BUILDING + + [Determining Python version] + No Python version specified, using the current default of Python {DEFAULT_PYTHON_VERSION}. + To use a different version, see: https://devcenter.heroku.com/articles/python-runtimes + + [Installing Python and packaging tools] + Installing Python {DEFAULT_PYTHON_VERSION} + Installing pip {pip_version}, setuptools {setuptools_version} and wheel {wheel_version} + + [Installing dependencies using Pip] + Running pip install + Collecting typing-extensions==4.4.0 + Downloading typing_extensions-4.4.0-py3-none-any.whl (26 kB) + Installing collected packages: typing-extensions + Successfully installed typing-extensions-4.4.0 + ===> EXPORTING + "} + ); + }, + ); +} + +#[test] +#[ignore = "integration test"] +fn python_3_7() { + // Python 3.7 is only available on Heroku-20 and older. + let fixture = "tests/fixtures/python_3.7"; + match builder().as_str() { + "heroku/buildpacks:20" => builds_with_python_version(fixture, LATEST_PYTHON_3_7), + _ => rejects_non_existent_python_version(fixture, LATEST_PYTHON_3_7), + }; +} + +#[test] +#[ignore = "integration test"] +fn python_3_8() { + // Python 3.8 is only available on Heroku-20 and older. + let fixture = "tests/fixtures/python_3.8"; + match builder().as_str() { + "heroku/buildpacks:20" => builds_with_python_version(fixture, LATEST_PYTHON_3_8), + _ => rejects_non_existent_python_version(fixture, LATEST_PYTHON_3_8), + }; +} + +#[test] +#[ignore = "integration test"] +fn python_3_9() { + builds_with_python_version("tests/fixtures/python_3.9", LATEST_PYTHON_3_9); +} + +#[test] +#[ignore = "integration test"] +fn python_3_10() { + builds_with_python_version("tests/fixtures/python_3.10", LATEST_PYTHON_3_10); +} + +#[test] +#[ignore = "integration test"] +fn python_3_11() { + builds_with_python_version("tests/fixtures/python_3.11", LATEST_PYTHON_3_11); +} + +#[test] +#[ignore = "integration test"] +fn runtime_txt_invalid_version() { + TestRunner::default().build( + BuildConfig::new(builder(), "tests/fixtures/runtime_txt_invalid_version") + .expected_pack_result(PackResult::Failure), + |context| { + assert_contains!( + context.pack_stderr, + &formatdoc! {" + [Error: Invalid Python version in runtime.txt] + The Python version specified in 'runtime.txt' is not in the correct format. + + The following file contents were found: + python-an.invalid.version + + However, the file contents must begin with a 'python-' prefix, followed by the + version specified as '..'. Comments are not supported. + + For example, to request Python {DEFAULT_PYTHON_VERSION}, the correct version format is: + python-{DEFAULT_PYTHON_VERSION} + + Please update 'runtime.txt' to use the correct version format, or else remove + the file to instead use the default version (currently Python {DEFAULT_PYTHON_VERSION}). + + For a list of the supported Python versions, see: + https://devcenter.heroku.com/articles/python-support#supported-runtimes + "} + ); + }, + ); +} + +#[test] +#[ignore = "integration test"] +fn runtime_txt_non_existent_version() { + rejects_non_existent_python_version( + "tests/fixtures/runtime_txt_non_existent_version", + "999.888.777", + ); +} + +fn builds_with_python_version(fixture_path: &str, python_version: &str) { + let PackagingToolVersions { + pip_version, + setuptools_version, + wheel_version, + } = PackagingToolVersions::default(); + + let mut config = BuildConfig::new(builder(), fixture_path); + // Checks that potentially broken user-provided env vars are not being passed unfiltered to + // subprocesses we launch (such as `pip install`), thanks to `clear-env` in `buildpack.toml`. + config.env("PYTHONHOME", "/invalid-path"); + + TestRunner::default().build(config, |context| { + assert_empty!(context.pack_stderr); + assert_contains!( + context.pack_stdout, + &formatdoc! {" + ===> BUILDING + + [Determining Python version] + Using Python version {python_version} specified in runtime.txt + + [Installing Python and packaging tools] + Installing Python {python_version} + Installing pip {pip_version}, setuptools {setuptools_version} and wheel {wheel_version} + + [Installing dependencies using Pip] + Running pip install + Collecting typing-extensions==4.4.0 + Downloading typing_extensions-4.4.0-py3-none-any.whl (26 kB) + Installing collected packages: typing-extensions + Successfully installed typing-extensions-4.4.0 + ===> EXPORTING + "} + ); + // There's no sensible default process type we can set for Python apps. + assert_contains!(context.pack_stdout, "no default process type"); + + // Validate the Python/Pip install works as expected at runtime. + let command_output = context.run_shell_command( + indoc! {r#" + set -euo pipefail + + # Check that we installed the correct Python version, and that the command + # 'python' works (since it's a symlink to the actual 'python3' binary). + python --version + + # Check that the Python binary is using its own 'libpython' and not the system one: + # https://github.com/docker-library/python/issues/784 + # Note: This has to handle Python 3.9 and older not being built in shared library mode. + libpython_path=$(ldd /layers/heroku_python/python/bin/python | grep libpython || true) + if [[ -n "${libpython_path}" && "${libpython_path}" != *"=> /layers/"* ]]; then + echo "The Python binary is not using the correct libpython!" + echo "${libpython_path}" + exit 1 + fi + + # Check all required dynamically linked libraries can be found in the runtime image. + if find /layers -name '*.so' -exec ldd '{}' + | grep 'not found'; then + echo "The above dynamically linked libraries were not found!" + exit 1 + fi + + # Check that: + # - Pip is available at runtime too (and not just during the build). + # - The correct versions of pip/setuptools/wheel were installed. + # - Pip uses (via 'PYTHONUSERBASE') the user site-packages in the dependencies + # layer, and so can find the typing-extensions package installed there. + # - The "pip update available" warning is not shown (since it should be suppressed). + # - The system site-packages directory is protected against running 'pip install' + # without having passed '--user'. + pip list + pip install --dry-run typing-extensions + "#} + ); + assert_empty!(command_output.stderr); + assert_contains!( + command_output.stdout, + &formatdoc! {" + Python {python_version} + Package Version + ----------------- ------- + pip {pip_version} + setuptools {setuptools_version} + typing_extensions 4.4.0 + wheel {wheel_version} + Defaulting to user installation because normal site-packages is not writeable + Requirement already satisfied: typing-extensions in /layers/heroku_python/dependencies/lib/" + } + ); + }); +} + +fn rejects_non_existent_python_version(fixture_path: &str, python_version: &str) { + let builder = builder(); + + TestRunner::default().build( + BuildConfig::new(&builder, fixture_path).expected_pack_result(PackResult::Failure), + |context| { + let expected_stack = match builder.as_str() { + "heroku/buildpacks:20" => "heroku-20", + "heroku/builder:22" => "heroku-22", + _ => unimplemented!("Unknown builder!"), + }; + + assert_contains!( + context.pack_stderr, + &formatdoc! {" + [Error: Requested Python version is not available] + The requested Python version ({python_version}) is not available for this stack ({expected_stack}). + + Please update the version in 'runtime.txt' to a supported Python version, or else + remove the file to instead use the default version (currently Python {DEFAULT_PYTHON_VERSION}). + + For a list of the supported Python versions, see: + https://devcenter.heroku.com/articles/python-support#supported-runtimes + "} + ); + }, + ); +} diff --git a/tests/integration/salesforce_functions.rs b/tests/integration/salesforce_functions.rs new file mode 100644 index 0000000..c6d4ed8 --- /dev/null +++ b/tests/integration/salesforce_functions.rs @@ -0,0 +1,137 @@ +use crate::integration_tests::builder; +use indoc::indoc; +use libcnb_test::{ + assert_contains, assert_empty, BuildConfig, ContainerConfig, PackResult, TestRunner, +}; +use std::thread; +use std::time::Duration; + +const TEST_PORT: u16 = 12345; + +#[test] +#[ignore = "integration test"] +fn salesforce_function_template() { + TestRunner::default().build( + BuildConfig::new(builder(), "tests/fixtures/salesforce_function_template"), + |context| { + assert_empty!(context.pack_stderr); + assert_contains!( + context.pack_stdout, + indoc! {" + [Validating Salesforce Function] + Function passed validation. + ===> EXPORTING + "} + ); + assert_contains!(context.pack_stdout, "Setting default process type 'web'"); + + // Test that the `sf-functions-python` web process the buildpack configures works correctly. + context.start_container( + ContainerConfig::new() + .env("PORT", TEST_PORT.to_string()) + .expose_port(TEST_PORT), + |container| { + let address_on_host = container.address_for_port(TEST_PORT).unwrap(); + let url = format!("http://{}:{}", address_on_host.ip(), address_on_host.port()); + + // Retries needed since the server takes a moment to start up. + let mut attempts_remaining = 5; + let response = loop { + let response = ureq::post(&url).set("x-health-check", "true").call(); + if response.is_ok() || attempts_remaining == 0 { + break response; + } + attempts_remaining -= 1; + thread::sleep(Duration::from_secs(1)); + }; + + let server_log_output = container.logs_now(); + assert_contains!( + server_log_output.stderr, + &format!("Uvicorn running on http://0.0.0.0:{TEST_PORT}") + ); + + let body = response.unwrap().into_string().unwrap(); + assert_eq!(body, r#""OK""#); + }, + ); + }, + ); +} + +#[test] +#[ignore = "integration test"] +fn salesforce_function_missing_package() { + TestRunner::default().build( + BuildConfig::new( + builder(), + "tests/fixtures/salesforce_function_missing_package", + ) + .expected_pack_result(PackResult::Failure), + |context| { + assert_contains!( + context.pack_stderr, + indoc! {r#" + [Error: The Salesforce Functions package is not installed] + The 'sf-functions-python' program that is required for Python Salesforce + Functions could not be found. + + Check that the 'salesforce-functions' Python package is listed as a + dependency in 'requirements.txt'. + + If this project is not intended to be a Salesforce Function, remove the + 'type = "function"' declaration from 'project.toml' to skip this check. + "#} + ); + }, + ); +} + +#[test] +#[ignore = "integration test"] +fn salesforce_function_fails_self_check() { + TestRunner::default().build( + BuildConfig::new( + builder(), + "tests/fixtures/salesforce_function_fails_self_check", + ) + .expected_pack_result(PackResult::Failure), + |context| { + assert_contains!( + context.pack_stderr, + indoc! {" + [Error: The Salesforce Functions self-check failed] + The 'sf-functions-python check' command failed (exit status: 1), indicating + there is a problem with the Python Salesforce Function in this project. + + Details: + Function failed validation: 'invalid' isn't a valid Salesforce REST API version." + } + ); + }, + ); +} + +#[test] +#[ignore = "integration test"] +fn project_toml_invalid() { + TestRunner::default().build( + BuildConfig::new(builder(), "tests/fixtures/project_toml_invalid") + .expected_pack_result(PackResult::Failure), + |context| { + assert_contains!( + context.pack_stderr, + indoc! {r#" + [Error: Invalid project.toml] + A parsing/validation error error occurred whilst loading the project.toml file. + + Details: TOML parse error at line 4, column 1 + | + 4 | [com.salesforce] + | ^^^^^^^^^^^^^^^^ + missing field `type` + "#} + ); + }, + ); +}