diff --git a/.github/actions/spin-ci-dependencies/action.yml b/.github/actions/spin-ci-dependencies/action.yml index 28fe5200b3..5e8839c1a9 100644 --- a/.github/actions/spin-ci-dependencies/action.yml +++ b/.github/actions/spin-ci-dependencies/action.yml @@ -8,7 +8,7 @@ inputs: type: bool rust-version: description: 'Rust version to setup' - default: '1.91' + default: '1.93' required: false type: string diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 4b527c384d..2ccc766280 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -17,7 +17,7 @@ concurrency: env: CARGO_TERM_COLOR: always - RUST_VERSION: '1.91' + RUST_VERSION: '1.93' jobs: dependency-review: @@ -203,7 +203,7 @@ jobs: version: '0.14.1' - uses: actions/setup-go@v4 with: - go-version: '1.23' + go-version: '1.25' cache-dependency-path: "**/go.sum" # To suppress warning: https://github.com/actions/setup-go/issues/427 - uses: acifani/setup-tinygo@v2 with: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f6cd49daa2..e0e5f85ae0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,7 +11,7 @@ on: concurrency: ${{ github.workflow }}-${{ github.ref }} env: - RUST_VERSION: '1.91' + RUST_VERSION: '1.93' jobs: build-and-sign: @@ -386,6 +386,51 @@ jobs: event-type: spin-release client-payload: '{"version": "${{ github.ref_name }}"}' + notify-template-repos: + name: Open issues in template repos for new release + needs: create-gh-release + runs-on: ubuntu-latest + if: github.repository_owner == 'spinframework' && startsWith(github.ref, 'refs/tags/v') + steps: + - name: Check if this is at least a minor release + id: check-minor + shell: bash + run: | + TAG="${{ github.ref_name }}" + if [[ "$TAG" =~ ^v([0-9]+)\.([0-9]+)\.([0-9]+)(.*)$ ]]; then + PATCH="${BASH_REMATCH[3]}" + PRE="${BASH_REMATCH[4]}" + # Only notify for minor+ releases (patch == 0 and no pre-release suffix) + if [[ "$PATCH" == "0" && -z "$PRE" ]]; then + echo "is_minor=true" >> "$GITHUB_OUTPUT" + else + echo "is_minor=false" >> "$GITHUB_OUTPUT" + fi + else + echo "is_minor=false" >> "$GITHUB_OUTPUT" + fi + + - name: Open issues in template repos + if: steps.check-minor.outputs.is_minor == 'true' + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.DEST_REPO_ACCESS_TOKEN }} + script: | + const repos = [ + 'spinframework/spin-js-sdk', + 'spinframework/spin-python-sdk', + ]; + const version = '${{ github.ref_name }}'; + for (const fullRepo of repos) { + const [owner, repo] = fullRepo.split('/'); + await github.rest.issues.create({ + owner, + repo, + title: `Tag templates for Spin ${version} release`, + body: `Spin [${version}](https://github.com/spinframework/spin/releases/tag/${version}) has been released.\n\nPlease tag compatible templates for this release.`, + }); + } + docker: runs-on: "ubuntu-22.04" needs: [build-and-sign, build-spin-static] diff --git a/Cargo.lock b/Cargo.lock index ea9db8db10..a89787bf27 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1505,12 +1505,12 @@ dependencies = [ [[package]] name = "cargo_toml" -version = "0.17.2" +version = "0.22.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a969e13a7589e9e3e4207e153bae624ade2b5622fb4684a4923b23ec3d57719" +checksum = "374b7c592d9c00c1f4972ea58390ac6b18cbb6ab79011f3bdc90a0b82ca06b77" dependencies = [ "serde", - "toml 0.8.19", + "toml 0.9.8", ] [[package]] @@ -1524,9 +1524,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.41" +version = "1.2.62" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac9fe6cdbb24b6ade63616c0a0688e45bb56732262c158df3c0c4bea4ca47cb7" +checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" dependencies = [ "find-msvc-tools", "jobserver", @@ -1609,6 +1609,18 @@ dependencies = [ "terminal_size", ] +[[package]] +name = "clap_complete" +version = "4.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ff7a1dccbdd8b078c2bdebff47e404615151534d5043da397ec50286816f9cb" +dependencies = [ + "clap", + "clap_lex 1.1.0", + "is_executable", + "shlex", +] + [[package]] name = "clap_derive" version = "4.6.0" @@ -1648,9 +1660,9 @@ dependencies = [ [[package]] name = "cmake" -version = "0.1.51" +version = "0.1.58" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb1e43aa7fd152b1f968787f7dbcdeb306d1867ff373c69955211876c053f91a" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" dependencies = [ "cc", ] @@ -1872,27 +1884,27 @@ dependencies = [ [[package]] name = "cranelift-assembler-x64" -version = "0.130.1" +version = "0.131.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "046d4b584c3bb9b5eb500c8f29549bec36be11000f1ba2a927cef3d1a9875691" +checksum = "6edb5bdd1af46714e3224a017fabbbd57f70df4e840eb5ad6a7429dc456119d6" dependencies = [ "cranelift-assembler-x64-meta", ] [[package]] name = "cranelift-assembler-x64-meta" -version = "0.130.1" +version = "0.131.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9b194a7870becb1490366fc0ae392ccd188065ff35f8391e77ac659db6fb977" +checksum = "a819599186e1b1a1f88d464e06045696afc7aa3e0cc018aa0b2999cb63d1d088" dependencies = [ "cranelift-srcgen", ] [[package]] name = "cranelift-bforest" -version = "0.130.1" +version = "0.131.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb6a4ab44c6b371e661846b97dab687387a60ac4e2f864e2d4257284aad9e889" +checksum = "36e2c152d488e03c87b913bc2ed3414416eb1e0d66d61b49af60bf456a9665c7" dependencies = [ "cranelift-entity", "wasmtime-internal-core", @@ -1900,9 +1912,9 @@ dependencies = [ [[package]] name = "cranelift-bitset" -version = "0.130.1" +version = "0.131.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8b7a44150c2f471a94023482bda1902710746e4bed9f9973d60c5a94319b06d" +checksum = "b6559d4fbc253d1396e1f6beeae57fa88a244f02aaf0cde2a735afd3492d9b2e" dependencies = [ "serde", "serde_derive", @@ -1911,9 +1923,9 @@ dependencies = [ [[package]] name = "cranelift-codegen" -version = "0.130.1" +version = "0.131.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01b06598133b1dd76758b8b95f8d6747c124124aade50cea96a3d88b962da9fa" +checksum = "96d9315d98d6e0a64454d4c83be2ee0e8055c3f80c3b2d7bcad7079f281a06ff" dependencies = [ "bumpalo", "cranelift-assembler-x64", @@ -1939,9 +1951,9 @@ dependencies = [ [[package]] name = "cranelift-codegen-meta" -version = "0.130.1" +version = "0.131.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6190e2e7bcf0a678da2f715363d34ed530fedf7a2f0ab75edaefef72a70465ff" +checksum = "d89c00a88081c55e3087c45bebc77e0cc973de2d7b44ef6a943c7122647b89f5" dependencies = [ "cranelift-assembler-x64-meta", "cranelift-codegen-shared", @@ -1952,24 +1964,24 @@ dependencies = [ [[package]] name = "cranelift-codegen-shared" -version = "0.130.1" +version = "0.131.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f583cf203d1aa8b79560e3b01f929bdacf9070b015eec4ea9c46e22a3f83e4a0" +checksum = "879f77c497a1eb6273482aa1ac3b23cb8563ff04edb39ed5dfcfd28c8deff8f5" [[package]] name = "cranelift-control" -version = "0.130.1" +version = "0.131.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "803159df35cc398ae54473c150b16d6c77e92ab2948be638488de126a3328fbc" +checksum = "498dc1f17a6910c88316d49c7176d8fa97cf10c30859c32a266040449317f963" dependencies = [ "arbitrary", ] [[package]] name = "cranelift-entity" -version = "0.130.1" +version = "0.131.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3109e417257082d88087f5bcce677525bdaa8322b88dd7f175ed1a1fd41d546c" +checksum = "c2acba797f6a46042ce82aaf7680d0c3567fe2001e238db9df649fd104a2727f" dependencies = [ "cranelift-bitset", "serde", @@ -1979,9 +1991,9 @@ dependencies = [ [[package]] name = "cranelift-frontend" -version = "0.130.1" +version = "0.131.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "14db6b0e0e4994c581092df78d837be2072578f7cb2528f96a6cf895e56dee63" +checksum = "4dca3df1d107d98d88f159ad1d5eaa2d5cdb678b3d5bcfadc6fc83d8ebb448ea" dependencies = [ "cranelift-codegen", "log", @@ -1991,15 +2003,15 @@ dependencies = [ [[package]] name = "cranelift-isle" -version = "0.130.1" +version = "0.131.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec66ea5025c7317383699778282ac98741d68444f956e3b1d7b62f12b7216e67" +checksum = "f62dd18116d88bed649871feceda79dad7b59cc685ea8998c2b3e64d0e689602" [[package]] name = "cranelift-native" -version = "0.130.1" +version = "0.131.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "373ade56438e6232619d85678477d0a88a31b3581936e0503e61e96b546b0800" +checksum = "f843b80360d7fdf61a6124642af7597f6d55724cf521210c34af8a1c66daca6e" dependencies = [ "cranelift-codegen", "libc", @@ -2008,9 +2020,9 @@ dependencies = [ [[package]] name = "cranelift-srcgen" -version = "0.130.1" +version = "0.131.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef53619d3cd5c78fd998c6d9420547af26b72e6456f94c2a8a2334cb76b42baa" +checksum = "090ee5de58c6f17eb5e3a5ae8cf1695c7efea04ec4dd0ecba6a5b996c9bad7dc" [[package]] name = "crc32fast" @@ -2931,9 +2943,9 @@ dependencies = [ [[package]] name = "find-msvc-tools" -version = "0.1.4" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52051878f80a721bb68ebfbc930e07b65ba72f2da88968ea5c06fd6ca3d3a127" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" [[package]] name = "fixedbitset" @@ -2941,6 +2953,12 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + [[package]] name = "flate2" version = "1.1.0" @@ -4227,9 +4245,10 @@ dependencies = [ "hyper 1.8.1", "hyper-util", "rustls 0.23.37", + "rustls-native-certs 0.8.3", "rustls-pki-types", "tokio", - "tokio-rustls 0.26.0", + "tokio-rustls 0.26.4", "tower-service", "webpki-roots", ] @@ -4654,6 +4673,15 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "is_executable" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baabb8b4867b26294d818bf3f651a454b6901431711abb96e296245888d6e8c4" +dependencies = [ + "windows-sys 0.60.2", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.1" @@ -5834,9 +5862,9 @@ dependencies = [ [[package]] name = "object" -version = "0.38.1" +version = "0.39.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "271638cd5fa9cca89c4c304675ca658efc4e64a66c716b7cfe1afb4b9611dbbc" +checksum = "63944c133d03f44e75866bbd160b95af0ec3f6a13d936d69d31c81078cbc5baf" dependencies = [ "crc32fast", "hashbrown 0.16.1", @@ -5992,15 +6020,14 @@ dependencies = [ [[package]] name = "openssl" -version = "0.10.74" +version = "0.10.79" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24ad14dd45412269e1a30f52ad8f0664f0f4f4a89ee8fe28c3b3527021ebb654" +checksum = "bf0b434746ee2832f4f0baf10137e1cabb18cbe6912c69e2e33263c45250f542" dependencies = [ "bitflags 2.10.0", "cfg-if", "foreign-types 0.3.2", "libc", - "once_cell", "openssl-macros", "openssl-sys", ] @@ -6030,9 +6057,9 @@ checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" [[package]] name = "openssl-sys" -version = "0.9.110" +version = "0.9.116" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a9f0075ba3c21b09f8e8b2026584b1d18d49388648f2fbbf3c97ea8deced8e2" +checksum = "f28a22dc7140cda5f096e5e7724a6962ca81a7f8bfd2979f9b18c11af56318c4" dependencies = [ "cc", "libc", @@ -6112,6 +6139,12 @@ dependencies = [ "tonic", ] +[[package]] +name = "opentelemetry-semantic-conventions" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fb3a2f78c2d55362cd6c313b8abedfbc0142ab3c2676822068fd2ab7d51f9b7" + [[package]] name = "opentelemetry_sdk" version = "0.28.0" @@ -6359,7 +6392,7 @@ version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4c5cc86750666a3ed20bdaf5ca2a0344f9c67674cae0515bec2da16fbaa47db" dependencies = [ - "fixedbitset", + "fixedbitset 0.4.2", "indexmap 2.14.0", ] @@ -6831,9 +6864,9 @@ dependencies = [ [[package]] name = "pulley-interpreter" -version = "43.0.1" +version = "44.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "010dec3755eb61b2f1051ecb3611b718460b7a74c131e474de2af20a845938af" +checksum = "df866b7fd522992ccc6682e58b2741cc7972b163b661db24c4328f4c914cb09d" dependencies = [ "cranelift-bitset", "log", @@ -6843,9 +6876,9 @@ dependencies = [ [[package]] name = "pulley-macros" -version = "43.0.1" +version = "44.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad360c32e85ca4b083ac0e2b6856e8f11c3d5060dafa7d5dc57b370857fa3018" +checksum = "f7dfa8354acc622b3857e1bb1a4e4315d3bc1a44ad31d5653c3e87c0da9306d7" dependencies = [ "proc-macro2", "quote", @@ -7190,6 +7223,26 @@ dependencies = [ "thiserror 2.0.17", ] +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "regalloc2" version = "0.15.0" @@ -7321,6 +7374,7 @@ dependencies = [ "pin-project-lite", "quinn", "rustls 0.23.37", + "rustls-native-certs 0.8.3", "rustls-pemfile 2.2.0", "rustls-pki-types", "serde", @@ -7330,7 +7384,7 @@ dependencies = [ "system-configuration 0.6.1", "tokio", "tokio-native-tls", - "tokio-rustls 0.26.0", + "tokio-rustls 0.26.4", "tokio-socks", "tokio-util", "tower-service", @@ -7447,20 +7501,22 @@ dependencies = [ [[package]] name = "rumqttc" -version = "0.24.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1568e15fab2d546f940ed3a21f48bbbd1c494c90c99c4481339364a497f94a9" +version = "0.25.1" +source = "git+https://github.com/spinframework/rumqtt?rev=65b7b39a70b12d1781acb61cc07f1f1b680e7643#65b7b39a70b12d1781acb61cc07f1f1b680e7643" dependencies = [ "bytes", + "fixedbitset 0.5.7", "flume", "futures-util", "log", - "rustls-native-certs 0.7.3", + "rustls-native-certs 0.8.3", "rustls-pemfile 2.2.0", - "rustls-webpki 0.102.8", - "thiserror 1.0.69", + "rustls-webpki 0.103.9", + "thiserror 2.0.17", "tokio", - "tokio-rustls 0.25.0", + "tokio-rustls 0.26.4", + "tokio-stream", + "tokio-util", "url", ] @@ -7840,12 +7896,13 @@ dependencies = [ [[package]] name = "schemars" -version = "0.8.22" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" dependencies = [ "dyn-clone", "indexmap 2.14.0", + "ref-cast", "schemars_derive", "semver", "serde", @@ -7854,9 +7911,9 @@ dependencies = [ [[package]] name = "schemars_derive" -version = "0.8.22" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" +checksum = "7d115b50f4aaeea07e79c1912f645c7513d81715d0420f8bc77a18c6260b307f" dependencies = [ "proc-macro2", "quote", @@ -8451,6 +8508,7 @@ dependencies = [ "cargo-target-dep", "clap", "clap-markdown", + "clap_complete", "clap_lex 0.7.7", "clearscreen", "comfy-table", @@ -8643,11 +8701,13 @@ dependencies = [ "async-trait", "bytes", "chrono", + "dirs 6.0.0", "futures", "futures-util", "id-arena", "indexmap 2.14.0", "oci-distribution 0.11.0 (git+https://github.com/fermyon/oci-distribution?rev=7e4ce9be9bcd22e78a28f06204931f10c44402ba)", + "reqwest 0.12.9", "semver", "serde", "serde_json", @@ -8661,6 +8721,7 @@ dependencies = [ "tokio", "toml 0.8.19", "tracing", + "url", "wac-graph 0.8.1", "wac-types 0.8.1", "wasm-pkg-client", @@ -8759,6 +8820,7 @@ dependencies = [ "http-body-util", "hyper 1.8.1", "hyper-util", + "opentelemetry-semantic-conventions", "pin-project-lite", "reqwest 0.12.9", "rustls 0.23.37", @@ -8772,10 +8834,11 @@ dependencies = [ "spin-telemetry", "spin-world", "tokio", - "tokio-rustls 0.26.0", + "tokio-rustls 0.26.4", "tower-service", "tracing", "tracing-opentelemetry", + "tracing-subscriber", "wasmtime", "wasmtime-wasi", "wasmtime-wasi-http", @@ -8813,6 +8876,7 @@ dependencies = [ "spin-factors", "spin-factors-test", "spin-resource-table", + "spin-telemetry", "spin-world", "tokio", "tracing", @@ -8827,6 +8891,7 @@ dependencies = [ "futures-util", "http 1.3.1", "ip_network", + "opentelemetry-semantic-conventions", "rustls 0.23.37", "rustls-pki-types", "rustls-platform-verifier", @@ -8845,7 +8910,7 @@ dependencies = [ "tracing", "url", "wasmtime-wasi", - "webpki-roots", + "webpki-root-certs", ] [[package]] @@ -8872,11 +8937,12 @@ dependencies = [ "spin-factors-test", "spin-locked-app", "spin-resource-table", + "spin-telemetry", "spin-wasi-async", "spin-world", "tokio", "tokio-postgres", - "tokio-rustls 0.26.0", + "tokio-rustls 0.26.4", "tracing", "url", "uuid", @@ -9392,6 +9458,7 @@ dependencies = [ "opentelemetry-appender-tracing", "opentelemetry-otlp", "opentelemetry_sdk", + "reqwest 0.12.9", "terminal", "tracing", "tracing-opentelemetry", @@ -9473,6 +9540,7 @@ dependencies = [ "http-body-util", "hyper 1.8.1", "hyper-util", + "opentelemetry-semantic-conventions", "pin-project-lite", "rand 0.9.1", "rustls 0.23.37", @@ -9493,7 +9561,7 @@ dependencies = [ "spin-world", "terminal", "tokio", - "tokio-rustls 0.26.0", + "tokio-rustls 0.26.4", "tracing", "wasmtime", "wasmtime-wasi", @@ -10239,12 +10307,11 @@ dependencies = [ [[package]] name = "tokio-rustls" -version = "0.26.0" +version = "0.26.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c7bc40d0e5a97695bb96e27995cd3a08538541b0a846f65bba7a359f36700d4" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" dependencies = [ "rustls 0.23.37", - "rustls-pki-types", "tokio", ] @@ -10452,9 +10519,9 @@ checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" [[package]] name = "tracing" -version = "0.1.41" +version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "log", "pin-project-lite", @@ -10464,9 +10531,9 @@ dependencies = [ [[package]] name = "tracing-attributes" -version = "0.1.28" +version = "0.1.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "395ae124c09f9e6918a2310af6038fba074bcf474ac352496d5910dd59a2226d" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", @@ -10475,9 +10542,9 @@ dependencies = [ [[package]] name = "tracing-core" -version = "0.1.33" +version = "0.1.36" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e672c95779cf947c5311f83787af4fa8fffd12fb27e4993211a84bdfd9610f9c" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ "once_cell", "valuable", @@ -11200,22 +11267,18 @@ dependencies = [ [[package]] name = "wasm-compose" -version = "0.245.1" +version = "0.246.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fd23d12cc95c451c1306db5bc63075fbebb612bb70c53b4237b1ce5bc178343" +checksum = "f05a2b3bad87cc1ce45b63425ec09a854cc4cb369231c9fed1fee31538103efb" dependencies = [ "anyhow", "heck 0.5.0", - "im-rc", "indexmap 2.14.0", "log", "petgraph", - "serde", - "serde_derive", - "serde_yaml", "smallvec", - "wasm-encoder 0.245.1", - "wasmparser 0.245.1", + "wasm-encoder 0.246.2", + "wasmparser 0.246.2", "wat", ] @@ -11261,12 +11324,12 @@ dependencies = [ [[package]] name = "wasm-encoder" -version = "0.245.1" +version = "0.246.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f9dca005e69bf015e45577e415b9af8c67e8ee3c0e38b5b0add5aa92581ed5c" +checksum = "61fb705ce81adde29d2a8e99d87995e39a6e927358c91398f374474746070ef7" dependencies = [ "leb128fmt", - "wasmparser 0.245.1", + "wasmparser 0.246.2", ] [[package]] @@ -11471,9 +11534,9 @@ dependencies = [ [[package]] name = "wasmparser" -version = "0.245.1" +version = "0.246.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f08c9adee0428b7bddf3890fc27e015ac4b761cc608c822667102b8bfd6995e" +checksum = "71cde4757396defafd25417cfb36aa3161027d06d865b0c24baaae229aac005d" dependencies = [ "bitflags 2.10.0", "hashbrown 0.16.1", @@ -11507,20 +11570,20 @@ dependencies = [ [[package]] name = "wasmprinter" -version = "0.245.1" +version = "0.246.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f41517a3716fbb8ccf46daa9c1325f760fcbff5168e75c7392288e410b91ac8" +checksum = "6e41f7493ba994b8a779430a4c25ff550fd5a40d291693af43a6ef48688f00e3" dependencies = [ "anyhow", "termcolor", - "wasmparser 0.245.1", + "wasmparser 0.246.2", ] [[package]] name = "wasmtime" -version = "43.0.1" +version = "44.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce205cd643d661b5ba5ba4717e13730262e8cdbc8f2eacbc7b906d45c1a74026" +checksum = "fca3f777dfb4db45915f95eeb25cac7f2eeb268797a27e5eb78b072618135c7f" dependencies = [ "addr2line 0.26.0", "async-trait", @@ -11538,7 +11601,7 @@ dependencies = [ "log", "mach2", "memfd", - "object 0.38.1", + "object 0.39.0", "once_cell", "postcard", "pulley-interpreter", @@ -11551,9 +11614,9 @@ dependencies = [ "smallvec", "target-lexicon", "tempfile", - "wasm-compose 0.245.1", - "wasm-encoder 0.245.1", - "wasmparser 0.245.1", + "wasm-compose 0.246.2", + "wasm-encoder 0.246.2", + "wasmparser 0.246.2", "wasmtime-environ", "wasmtime-internal-cache", "wasmtime-internal-component-macro", @@ -11572,9 +11635,9 @@ dependencies = [ [[package]] name = "wasmtime-environ" -version = "43.0.1" +version = "44.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b8b78abf3677d4a0a5db82e5015b4d085ff3a1b8b472cbb8c70d4b769f019ce" +checksum = "7c5ca1af838cec374931242d07af5d354aedf63f297f95b3625ac863e516ef67" dependencies = [ "anyhow", "cpp_demangle", @@ -11585,7 +11648,7 @@ dependencies = [ "hashbrown 0.16.1", "indexmap 2.14.0", "log", - "object 0.38.1", + "object 0.39.0", "postcard", "rustc-demangle", "semver", @@ -11594,18 +11657,18 @@ dependencies = [ "sha2", "smallvec", "target-lexicon", - "wasm-encoder 0.245.1", - "wasmparser 0.245.1", - "wasmprinter 0.245.1", + "wasm-encoder 0.246.2", + "wasmparser 0.246.2", + "wasmprinter 0.246.2", "wasmtime-internal-component-util", "wasmtime-internal-core", ] [[package]] name = "wasmtime-internal-cache" -version = "43.0.1" +version = "44.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e4fd4103ba413c0da2e636f73490c6c8e446d708cbde7573703941bc3d6a448" +checksum = "b2004f7c86ebeb116550655377cdf16dbf7b03ae5aa6b4b1c1458cfa23aaa306" dependencies = [ "base64 0.22.1", "directories-next", @@ -11623,9 +11686,9 @@ dependencies = [ [[package]] name = "wasmtime-internal-component-macro" -version = "43.0.1" +version = "44.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d3d6914f34be2f9d78d8ee9f422e834dfc204e71ccce697205fae95fed87892" +checksum = "58b31927f7b613d8fe019609744e226f6458d8aa5e6289e92fbbc60e521cd026" dependencies = [ "anyhow", "proc-macro2", @@ -11633,20 +11696,20 @@ dependencies = [ "syn 2.0.117", "wasmtime-internal-component-util", "wasmtime-internal-wit-bindgen", - "wit-parser 0.245.1", + "wit-parser 0.246.2", ] [[package]] name = "wasmtime-internal-component-util" -version = "43.0.1" +version = "44.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3751b0616b914fdd87fe1bf804694a078f321b000338e6476bc48a4d6e454f21" +checksum = "dc29e3478928b93979831ba02a997ce7f707c673ce47180d643091cf4fa4f561" [[package]] name = "wasmtime-internal-core" -version = "43.0.1" +version = "44.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22632b187e1b0716f1b9ac57ad29013bed33175fcb19e10bb6896126f82fac67" +checksum = "816a61a75275c6be435131fc625a4f5956daf24d9f9f59443e81cbef228929b3" dependencies = [ "anyhow", "hashbrown 0.16.1", @@ -11656,9 +11719,9 @@ dependencies = [ [[package]] name = "wasmtime-internal-cranelift" -version = "43.0.1" +version = "44.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b3ca07b3e0bb3429674b173b5800577719d600774dd81bff58f775c0aaa64ee" +checksum = "69ceb5e079877e7e4565c1e2d86d9db889175d55f7ca0001315576d08c71e634" dependencies = [ "cfg-if", "cranelift-codegen", @@ -11669,12 +11732,12 @@ dependencies = [ "gimli 0.33.0", "itertools 0.14.0", "log", - "object 0.38.1", + "object 0.39.0", "pulley-interpreter", "smallvec", "target-lexicon", "thiserror 2.0.17", - "wasmparser 0.245.1", + "wasmparser 0.246.2", "wasmtime-environ", "wasmtime-internal-core", "wasmtime-internal-unwinder", @@ -11683,9 +11746,9 @@ dependencies = [ [[package]] name = "wasmtime-internal-fiber" -version = "43.0.1" +version = "44.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20c8b2c9704eb1f33ead025ec16038277ccb63d0a14c31e99d5b765d7c36da55" +checksum = "e18f8bb05d25e0d4cca7278147c9f9e2f26f66886ef754b562bf729128f1e537" dependencies = [ "cc", "cfg-if", @@ -11698,21 +11761,21 @@ dependencies = [ [[package]] name = "wasmtime-internal-jit-debug" -version = "43.0.1" +version = "44.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d950310d07391d34369f62c48336ebb14eacbd4d6f772bb5f349c24e838e0664" +checksum = "357f1070b31154ee463937b477ca0b2962bf450b40fc59799bef2f656b15da73" dependencies = [ "cc", - "object 0.38.1", + "object 0.39.0", "rustix 1.1.2", "wasmtime-internal-versioned-export-macros", ] [[package]] name = "wasmtime-internal-jit-icache-coherence" -version = "43.0.1" +version = "44.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3606662c156962d096be3127b8b8ae8ee2f8be3f896dad29259ff01ddb64abfd" +checksum = "2fd683a94490bf755d016a09697b0955602c50106b1ded97d16983ab2ded9fed" dependencies = [ "cfg-if", "libc", @@ -11722,22 +11785,22 @@ dependencies = [ [[package]] name = "wasmtime-internal-unwinder" -version = "43.0.1" +version = "44.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75eef0747e52dc545b075f64fd0e0cc237ae738e641266b1970e07e2d744bc32" +checksum = "4471746ce113c3c1862ce2c0674acb35399a4b3ed3ef4531dc087f333c74f064" dependencies = [ "cfg-if", "cranelift-codegen", "log", - "object 0.38.1", + "object 0.39.0", "wasmtime-environ", ] [[package]] name = "wasmtime-internal-versioned-export-macros" -version = "43.0.1" +version = "44.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8b0a5dab02a8fb527f547855ecc0e05f9fdc3d5bd57b8b080349408f9a6cece" +checksum = "d6af582ec18b674bf7a17775d6fbfbddfcc143f0edbd89c9c1778239c8aa92ed" dependencies = [ "proc-macro2", "quote", @@ -11746,16 +11809,16 @@ dependencies = [ [[package]] name = "wasmtime-internal-winch" -version = "43.0.1" +version = "44.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8007342bd12ff400293a817973f7ecd6f1d9a8549a53369a9c1af357166f1f1e" +checksum = "d31be8916bb60ea756d2f0ae1f634d9258442aa71e773c893e2f4cead30501b5" dependencies = [ "cranelift-codegen", "gimli 0.33.0", "log", - "object 0.38.1", + "object 0.39.0", "target-lexicon", - "wasmparser 0.245.1", + "wasmparser 0.246.2", "wasmtime-environ", "wasmtime-internal-cranelift", "winch-codegen", @@ -11763,22 +11826,22 @@ dependencies = [ [[package]] name = "wasmtime-internal-wit-bindgen" -version = "43.0.1" +version = "44.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7900c3e3c1d6e475bc225d73b02d6d5484815f260022e6964dca9558e50dd01a" +checksum = "e2150e63d502ab2d64754e5abe8eb737ae674b7dd4ad53144fd16bbeceaf4a19" dependencies = [ "anyhow", "bitflags 2.10.0", "heck 0.5.0", "indexmap 2.14.0", - "wit-parser 0.245.1", + "wit-parser 0.246.2", ] [[package]] name = "wasmtime-wasi" -version = "43.0.1" +version = "44.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed3e3ddcfad69e9eb025bd19bff70dad45bafe1d6eacd134c0ffdfc4c161d045" +checksum = "83f5109b4fd619b9796b9c9901de59d83e3575cd1226c1a36d1901371f43db28" dependencies = [ "async-trait", "bitflags 2.10.0", @@ -11806,9 +11869,9 @@ dependencies = [ [[package]] name = "wasmtime-wasi-http" -version = "43.0.1" +version = "44.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b286829e05b5d8559d9519f44451e82502739ef48689b66debe96612e2b88df" +checksum = "5798e6e48005406983ba6a7b27f3832f1571e53f1401ef2d60111c96eab534b4" dependencies = [ "async-trait", "bytes", @@ -11817,9 +11880,9 @@ dependencies = [ "http-body 1.0.1", "http-body-util", "hyper 1.8.1", - "rustls 0.22.4", + "rustls 0.23.37", "tokio", - "tokio-rustls 0.25.0", + "tokio-rustls 0.26.4", "tokio-util", "tracing", "wasmtime", @@ -11830,9 +11893,9 @@ dependencies = [ [[package]] name = "wasmtime-wasi-io" -version = "43.0.1" +version = "44.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ca5dd3b9f04a851c422d05f333366722742da46bff9369ae0191f32cf83565a" +checksum = "74ebe14c586e98d2fdc32c76ca0005ef28348e98ed737e776d378b3b0cc2afd0" dependencies = [ "async-trait", "bytes", @@ -11978,9 +12041,9 @@ dependencies = [ [[package]] name = "webpki-root-certs" -version = "1.0.6" +version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "804f18a4ac2676ffb4e8b5b5fa9ae38af06df08162314f96a68d2a363e21a8ca" +checksum = "f31141ce3fc3e300ae89b78c0dd67f9708061d1d2eda54b8209346fd6be9a92c" dependencies = [ "rustls-pki-types", ] @@ -12019,9 +12082,9 @@ dependencies = [ [[package]] name = "wiggle" -version = "43.0.1" +version = "44.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc1b1135efc8e5a008971897bea8d41ca56d8d501d4efb807842ae0a1c78f639" +checksum = "89cff414ef7dce0cc1cf8a033ff80d3f38e3987c37e3efeec7926ecb5ffaaae6" dependencies = [ "bitflags 2.10.0", "thiserror 2.0.17", @@ -12033,9 +12096,9 @@ dependencies = [ [[package]] name = "wiggle-generate" -version = "43.0.1" +version = "44.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7bc2b0d50ec8773b44fbfe1da6cb5cc44a92deaf8483233dcf0831e6db33172" +checksum = "ccf9dc7272b151a9616a2699e7f94ea1d4ae253b47b63a79fbc8f38e2cca5fa6" dependencies = [ "heck 0.5.0", "proc-macro2", @@ -12047,9 +12110,9 @@ dependencies = [ [[package]] name = "wiggle-macro" -version = "43.0.1" +version = "44.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d6c7d44ea552e1fbfdcd7a2cd83f5c2d1e803d5b1a11e3462c06888b77f455f" +checksum = "aa7c29fcf738630cba4e35f1805da5e42dde20ee9809ee9202b0648ae671602f" dependencies = [ "proc-macro2", "quote", @@ -12090,9 +12153,9 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] name = "winch-codegen" -version = "43.0.1" +version = "44.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb9f45f7172a2628c8317766e427babc0a400f9d10b1c0f0b0617c5ed5b79de6" +checksum = "9339858ad222412200fd8b1af9e270712201aaec440c7618991443af3446481f" dependencies = [ "cranelift-assembler-x64", "cranelift-codegen", @@ -12101,7 +12164,7 @@ dependencies = [ "smallvec", "target-lexicon", "thiserror 2.0.17", - "wasmparser 0.245.1", + "wasmparser 0.246.2", "wasmtime-environ", "wasmtime-internal-core", "wasmtime-internal-cranelift", @@ -12722,9 +12785,9 @@ dependencies = [ [[package]] name = "wit-parser" -version = "0.245.1" +version = "0.246.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "330698718e82983499419494dd1e3d7811a457a9bf9f69734e8c5f07a2547929" +checksum = "fd979042b5ff288607ccf3b314145435453f20fc67173195f91062d2289b204d" dependencies = [ "anyhow", "hashbrown 0.16.1", @@ -12736,7 +12799,7 @@ dependencies = [ "serde_derive", "serde_json", "unicode-xid", - "wasmparser 0.245.1", + "wasmparser 0.246.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 0704a41e64..041d9c47c6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,7 +8,7 @@ license = { workspace = true } [workspace.package] version = "4.0.0" authors = ["Spin Framework Contributors"] -edition = "2021" +edition = "2024" license = "Apache-2.0 WITH LLVM-exception" homepage = "https://spinframework.dev" repository = "https://github.com/spinframework/spin" @@ -38,7 +38,7 @@ pretty_assertions = "1.3" regex = { workspace = true } reqwest = { workspace = true } rpassword = "7" -schemars = { version = "0.8.21", features = ["indexmap2", "semver"] } +schemars = { workspace = true } semver = { workspace = true } serde = { version = "1", features = ["derive"] } serde_json = { workspace = true } @@ -78,6 +78,7 @@ spin-trigger-http = { path = "crates/trigger-http" } spin-trigger-redis = { path = "crates/trigger-redis" } terminal = { path = "crates/terminal" } rand.workspace = true +clap_complete = { version = "4.6.2", features = ["unstable-dynamic"] } [target.'cfg(target_os = "linux")'.dependencies] # This needs to be an explicit dependency to enable @@ -161,19 +162,21 @@ opentelemetry_sdk = {version = "0.28", features = [ "experimental_logs_batch_log_processor_with_async_runtime", "experimental_async_runtime" ]} +opentelemetry-semantic-conventions = "0.28" path-absolutize = "3" pin-project-lite = "0.2.16" quote = "1" rand = "0.9" redis = "0.32.5" regex = "1" -reqwest = { version = "0.12", features = ["stream", "blocking"] } +reqwest = { version = "0.12", features = ["stream", "blocking", "rustls-tls-native-roots"] } rusqlite = "0.34" # In `rustls` turn off the `aws_lc_rs` default feature and turn on `ring`. # If both `aws_lc_rs` and `ring` are enabled, a panic at runtime will occur. rustls = { version = "0.23", default-features = false, features = ["ring", "std", "logging", "tls12"] } rustls-pki-types = "1.12" rustls-platform-verifier = "0.6" +schemars = { version = "1.2", features = ["indexmap2", "semver1"] } semver = "1" serde = { version = "1", features = ["derive", "rc"] } serde_json = "1.0" @@ -188,7 +191,7 @@ tokio-rustls = { version = "0.26", default-features = false, features = ["loggin toml = "0.8" toml_edit = "0.22" tower-service = "0.3.3" -tracing = { version = "0.1.41", features = ["log"] } +tracing = { version = "0.1.44", features = ["log"] } url = "2.5.7" tracing-opentelemetry = { version = "0.29", default-features = false, features = ["metrics"] } walkdir = "2" @@ -198,9 +201,9 @@ wasm-metadata = "0.247.0" wasm-pkg-client = "0.11" wasm-pkg-common = "0.11" wasmparser = "0.247.0" -wasmtime = { version = "43.0.1", features = ["component-model-async"] } -wasmtime-wasi = { version = "43.0.1", features = ["p3"] } -wasmtime-wasi-http = { version = "43.0.1", features = ["p3", "component-model-async"] } +wasmtime = { version = "44.0.0", features = ["component-model-async"] } +wasmtime-wasi = { version = "44.0.0", features = ["p3"] } +wasmtime-wasi-http = { version = "44.0.0", features = ["p3", "component-model-async"] } wit-component = "0.247.0" wit-parser = "0.247.0" diff --git a/GOVERNANCE.md b/GOVERNANCE.md index 46351040c4..88c603acb6 100644 --- a/GOVERNANCE.md +++ b/GOVERNANCE.md @@ -20,9 +20,7 @@ The Spin project consists of several codebases with different release cycles. Th - [Spin Deps Plugin](https://github.com/spinframework/spin-deps-plugin) - [Spin OTel Plugin](https://github.com/spinframework/otel-plugin) - Triggers: - - [Spin Command Trigger](https://github.com/spinframework/spin-trigger-command) - - [Spin SQS Trigger](https://github.com/spinframework/spin-trigger-sqs) - - [Spin Cron Trigger](https://github.com/spinframework/spin-trigger-cron) + - [Spin Trigger Plugins](https://github.com/spinframework/spin-trigger-plugins) - Other - [Spin Fileserver](https://github.com/spinframework/spin-fileserver) - [Spin Redirect](https://github.com/spinframework/spin-redirect) diff --git a/README.md b/README.md index 8a4fa2014d..cacc0aba85 100644 --- a/README.md +++ b/README.md @@ -53,10 +53,10 @@ WebAssembly is a language-agnostic runtime: you can build WebAssembly components ## Usage -Below is an example of using the `spin` CLI to create a new Spin application. To run the example you will need to install the `wasm32-wasip1` target for Rust. +Below is an example of using the `spin` CLI to create a new Spin application. To run the example you will need to install the `wasm32-wasip2` target for Rust. ```bash -$ rustup target add wasm32-wasip1 +$ rustup target add wasm32-wasip2 ``` First, run the `spin new` command to create a Spin application from a template. @@ -69,7 +69,7 @@ Running the `spin new` command created a `hello-rust` directory with all the nec ```bash # Compile to Wasm by executing the `build` command. $ spin build -Executing the build command for component hello-rust: cargo build --target wasm32-wasip1 --release +Executing the build command for component hello-rust: cargo build --target wasm32-wasip2 --release Finished release [optimized] target(s) in 0.03s Successfully ran the build command for the Spin components. diff --git a/build.rs b/build.rs index 1df4afa12a..cf458076ee 100644 --- a/build.rs +++ b/build.rs @@ -12,7 +12,12 @@ const TIMER_TRIGGER_INTEGRATION_TEST: &str = "examples/spin-timer/app-example"; fn main() { // Don't inherit flags from our own invocation of cargo into sub-invocations // since the flags are intended for the host and we're compiling for wasm. - std::env::remove_var("CARGO_ENCODED_RUSTFLAGS"); + // + // SAFETY: `remove_var` is safe to call when no other threads are running, and + // we are at the top of a non-async `main`. + unsafe { + std::env::remove_var("CARGO_ENCODED_RUSTFLAGS"); + } // Extract environment information to be passed to plugins. // Git information will be set to defaults if Spin is not diff --git a/crates/build/src/lib.rs b/crates/build/src/lib.rs index dff2388ed6..01bc40114f 100644 --- a/crates/build/src/lib.rs +++ b/crates/build/src/lib.rs @@ -4,7 +4,7 @@ mod manifest; -use anyhow::{anyhow, bail, Context, Result}; +use anyhow::{Context, Result, anyhow, bail}; use manifest::ComponentBuildInfo; use spin_common::{paths::parent_dir, ui::quoted_path}; use spin_manifest::schema::v2; @@ -43,7 +43,9 @@ pub async fn build( if wit_generation.generate() { let wit_gen_errs = regenerate_wits(&components_to_build, &app_dir).await; if !wit_gen_errs.is_empty() { - terminal::warn!("One or more components specified dependencies for which Spin couldn't generate import interfaces."); + terminal::warn!( + "One or more components specified dependencies for which Spin couldn't generate import interfaces." + ); eprintln!( "If these components rely on Spin-generated interfaces they may fail to build." ); @@ -63,7 +65,9 @@ pub async fn build( if let Some(e) = build_info.load_error() { // The manifest had errors. We managed to attempt a build anyway, but we want to // let the user know about them. - terminal::warn!("The manifest has errors not related to the Wasm component build. Error details:\n{e:#}"); + terminal::warn!( + "The manifest has errors not related to the Wasm component build. Error details:\n{e:#}" + ); // Checking deployment targets requires a healthy manifest (because trigger types etc.), // if any of these were specified, warn they are being skipped. let should_have_checked_targets = @@ -109,7 +113,9 @@ pub async fn build( for error in target_validation.errors() { terminal::error!("{error}"); } - anyhow::bail!("All components built successfully, but one or more was incompatible with one or more of the deployment targets."); + anyhow::bail!( + "All components built successfully, but one or more was incompatible with one or more of the deployment targets." + ); } } @@ -195,7 +201,9 @@ fn build_components( ) -> anyhow::Result<()> { if components_to_build.iter().all(|c| c.build.is_none()) { println!("None of the components have a build command."); - println!("For information on specifying a build command, see https://spinframework.dev/build#setting-up-for-spin-build."); + println!( + "For information on specifying a build command, see https://spinframework.dev/build#setting-up-for-spin-build." + ); return Ok(()); } @@ -205,7 +213,9 @@ fn build_components( let (components_to_build, has_cycle) = sort(components_to_build); if has_cycle { - tracing::debug!("There is a dependency cycle among components. Spin cannot guarantee to build dependencies before consumers."); + tracing::debug!( + "There is a dependency cycle among components. Spin cannot guarantee to build dependencies before consumers." + ); } components_to_build @@ -449,7 +459,9 @@ pub fn warn_if_not_latest_build(manifest_path: &Path, profile: Option<&str>) { Some(p) => format!(" --profile {p}"), None => "".to_string(), }; - terminal::warn!("You built a different profile more recently than the one you are running. If the app appears to be behaving like an older version then run `spin up --build{profile_opt}`."); + terminal::warn!( + "You built a different profile more recently than the one you are running. If the app appears to be behaving like an older version then run `spin up --build{profile_opt}`." + ); } } diff --git a/crates/build/src/manifest.rs b/crates/build/src/manifest.rs index 1eb74a7984..47ae909e30 100644 --- a/crates/build/src/manifest.rs +++ b/crates/build/src/manifest.rs @@ -2,7 +2,7 @@ use anyhow::Result; use serde::Deserialize; use std::{collections::BTreeMap, path::Path}; -use spin_manifest::{schema::v2, ManifestVersion}; +use spin_manifest::{ManifestVersion, schema::v2}; #[allow(clippy::large_enum_variant)] // only ever constructed once pub enum ManifestBuildInfo { diff --git a/crates/capabilities/src/deny.rs b/crates/capabilities/src/deny.rs index 2ed7cd518a..7d460a4e7d 100644 --- a/crates/capabilities/src/deny.rs +++ b/crates/capabilities/src/deny.rs @@ -1,9 +1,9 @@ use crate::{ - InheritConfiguration, AI_MODELS, ALLOWED_OUTBOUND_HOSTS, CAPABILITY_SETS, ENVIRONMENT, FILES, + AI_MODELS, ALLOWED_OUTBOUND_HOSTS, CAPABILITY_SETS, ENVIRONMENT, FILES, InheritConfiguration, KEY_VALUE_STORES, SQLITE_DATABASES, VARIABLES, }; -use wac_graph::types::{are_semver_compatible, SubtypeChecker}; -use wac_graph::{types::Package, CompositionGraph}; +use wac_graph::types::{SubtypeChecker, are_semver_compatible}; +use wac_graph::{CompositionGraph, types::Package}; /// Composes a deny adapter into a Wasm component to block host capabilities that /// are not explicitly inherited. diff --git a/crates/common/src/cli.rs b/crates/common/src/cli.rs index 711e05ba53..5ce2a2b566 100644 --- a/crates/common/src/cli.rs +++ b/crates/common/src/cli.rs @@ -1,6 +1,6 @@ //! Common CLI code and constants -use clap::builder::{styling::AnsiColor, Styles}; +use clap::builder::{Styles, styling::AnsiColor}; /// Clap [`Styles`] for Spin CLI and plugins. pub const CLAP_STYLES: Styles = Styles::styled() diff --git a/crates/common/src/data_dir.rs b/crates/common/src/data_dir.rs index aeb2fcae38..8bb5685edc 100644 --- a/crates/common/src/data_dir.rs +++ b/crates/common/src/data_dir.rs @@ -1,6 +1,6 @@ //! Resolves Spin's default data directory paths -use anyhow::{anyhow, Result}; +use anyhow::{Result, anyhow}; use std::path::{Path, PathBuf}; /// Return the default data directory for Spin @@ -20,16 +20,15 @@ pub fn data_dir() -> Result { /// Get the package manager specific data directory fn package_manager_data_dir() -> Option { - if let Ok(brew_prefix) = std::env::var("HOMEBREW_PREFIX") { - if std::env::current_exe() + if let Ok(brew_prefix) = std::env::var("HOMEBREW_PREFIX") + && std::env::current_exe() .map(|p| p.starts_with(&brew_prefix)) .unwrap_or(false) - { - let data_dir = Path::new(&brew_prefix) - .join("etc") - .join("spinframework-spin"); - return Some(data_dir); - } + { + let data_dir = Path::new(&brew_prefix) + .join("etc") + .join("spinframework-spin"); + return Some(data_dir); } None } diff --git a/crates/common/src/paths.rs b/crates/common/src/paths.rs index 477baed8c0..82c20fb899 100644 --- a/crates/common/src/paths.rs +++ b/crates/common/src/paths.rs @@ -1,6 +1,6 @@ //! Resolves a file path to a manifest file -use anyhow::{anyhow, Context, Result}; +use anyhow::{Context, Result, anyhow}; use std::path::{Path, PathBuf}; use crate::ui::quoted_path; diff --git a/crates/common/src/sloth.rs b/crates/common/src/sloth.rs index 5f7b44fe7f..456f84d26f 100644 --- a/crates/common/src/sloth.rs +++ b/crates/common/src/sloth.rs @@ -1,7 +1,7 @@ //! Warn on slow operations use tokio::task::JoinHandle; -use tokio::time::{sleep, Duration}; +use tokio::time::{Duration, sleep}; /// Print a warning message after the given duration unless the returned /// [`SlothGuard`] is dropped first. diff --git a/crates/common/src/url.rs b/crates/common/src/url.rs index cfdae9ae7c..daecc1a1d9 100644 --- a/crates/common/src/url.rs +++ b/crates/common/src/url.rs @@ -1,6 +1,6 @@ //! Operations on URLs -use anyhow::{anyhow, Context}; +use anyhow::{Context, anyhow}; use std::path::PathBuf; diff --git a/crates/componentize/src/abi_conformance/mod.rs b/crates/componentize/src/abi_conformance/mod.rs index 4cda574841..6dee83e376 100644 --- a/crates/componentize/src/abi_conformance/mod.rs +++ b/crates/componentize/src/abi_conformance/mod.rs @@ -21,7 +21,7 @@ #![deny(warnings)] -use anyhow::{anyhow, bail, Result}; +use anyhow::{Result, anyhow, bail}; use fermyon::spin::http_types::{Method, Request, Response}; use serde::{Deserialize, Serialize}; use std::{future::Future, str}; @@ -34,8 +34,8 @@ use test_postgres::Postgres; use test_redis::Redis; use wasmtime::error::Context as _; use wasmtime::{ - component::{Component, HasSelf, InstancePre, Linker}, Engine, Store, + component::{Component, HasSelf, InstancePre, Linker}, }; use wasmtime_wasi::p2::pipe::MemoryOutputPipe; use wasmtime_wasi::{ResourceTable, WasiCtx, WasiCtxBuilder, WasiCtxView, WasiView}; diff --git a/crates/componentize/src/abi_conformance/test_config.rs b/crates/componentize/src/abi_conformance/test_config.rs index a5bb3a3260..3e3f329397 100644 --- a/crates/componentize/src/abi_conformance/test_config.rs +++ b/crates/componentize/src/abi_conformance/test_config.rs @@ -1,7 +1,7 @@ -use super::{config, Context, TestConfig}; -use anyhow::{ensure, Result}; +use super::{Context, TestConfig, config}; +use anyhow::{Result, ensure}; use std::collections::HashMap; -use wasmtime::{component::InstancePre, Engine}; +use wasmtime::{Engine, component::InstancePre}; #[derive(Default)] pub(super) struct Config { diff --git a/crates/componentize/src/abi_conformance/test_http.rs b/crates/componentize/src/abi_conformance/test_http.rs index 5cd519634c..f8ab44d923 100644 --- a/crates/componentize/src/abi_conformance/test_http.rs +++ b/crates/componentize/src/abi_conformance/test_http.rs @@ -1,11 +1,10 @@ use super::{ - http, + Context, TestConfig, http, http_types::{HttpError, Request, Response}, - Context, TestConfig, }; -use anyhow::{ensure, Result}; +use anyhow::{Result, ensure}; use std::collections::HashMap; -use wasmtime::{component::InstancePre, Engine}; +use wasmtime::{Engine, component::InstancePre}; #[derive(Default)] pub(crate) struct Http { diff --git a/crates/componentize/src/abi_conformance/test_inbound_http.rs b/crates/componentize/src/abi_conformance/test_inbound_http.rs index d6dab54a95..607c9f356b 100644 --- a/crates/componentize/src/abi_conformance/test_inbound_http.rs +++ b/crates/componentize/src/abi_conformance/test_inbound_http.rs @@ -1,9 +1,9 @@ use super::{ - http_types::{Method, Request, Response}, Context, TestConfig, + http_types::{Method, Request, Response}, }; use anyhow::{anyhow, ensure}; -use wasmtime::{component::InstancePre, Engine}; +use wasmtime::{Engine, component::InstancePre}; pub(crate) async fn test( engine: &Engine, diff --git a/crates/componentize/src/abi_conformance/test_inbound_redis.rs b/crates/componentize/src/abi_conformance/test_inbound_redis.rs index 72e5dc6be2..7e88ba3ae4 100644 --- a/crates/componentize/src/abi_conformance/test_inbound_redis.rs +++ b/crates/componentize/src/abi_conformance/test_inbound_redis.rs @@ -1,9 +1,9 @@ use super::{ - redis_types::{Error, Payload}, Context, TestConfig, + redis_types::{Error, Payload}, }; use anyhow::anyhow; -use wasmtime::{component::InstancePre, Engine}; +use wasmtime::{Engine, component::InstancePre}; pub(crate) async fn test( engine: &Engine, diff --git a/crates/componentize/src/abi_conformance/test_key_value.rs b/crates/componentize/src/abi_conformance/test_key_value.rs index f51ed7e313..26df61362a 100644 --- a/crates/componentize/src/abi_conformance/test_key_value.rs +++ b/crates/componentize/src/abi_conformance/test_key_value.rs @@ -1,14 +1,14 @@ use super::{ - key_value::{self, Error, Store as KvStore}, Context, TestConfig, + key_value::{self, Error, Store as KvStore}, }; -use anyhow::{anyhow, ensure, Result}; +use anyhow::{Result, anyhow, ensure}; use serde::Serialize; use std::{ collections::{HashMap, HashSet}, iter, }; -use wasmtime::{component::InstancePre, Engine}; +use wasmtime::{Engine, component::InstancePre}; /// Report of which key-value functions a module successfully used, if any #[derive(Serialize, PartialEq, Eq, Debug)] diff --git a/crates/componentize/src/abi_conformance/test_llm.rs b/crates/componentize/src/abi_conformance/test_llm.rs index 7a668b0e64..90bd4610e1 100644 --- a/crates/componentize/src/abi_conformance/test_llm.rs +++ b/crates/componentize/src/abi_conformance/test_llm.rs @@ -1,6 +1,6 @@ use std::collections::HashMap; -use anyhow::{ensure, Result}; +use anyhow::{Result, ensure}; use serde::Serialize; use super::llm; diff --git a/crates/componentize/src/abi_conformance/test_mysql.rs b/crates/componentize/src/abi_conformance/test_mysql.rs index c38d05e7ef..f64848b769 100644 --- a/crates/componentize/src/abi_conformance/test_mysql.rs +++ b/crates/componentize/src/abi_conformance/test_mysql.rs @@ -1,15 +1,15 @@ use super::{ + Context, TestConfig, mysql::{self, MysqlError}, rdbms_types::{Column, DbDataType, DbValue, ParameterValue, RowSet}, - Context, TestConfig, }; -use anyhow::{ensure, Result}; +use anyhow::{Result, ensure}; use serde::Serialize; use std::{ collections::{HashMap, HashSet}, iter, }; -use wasmtime::{component::InstancePre, Engine}; +use wasmtime::{Engine, component::InstancePre}; /// Report of which MySQL functions a module successfully used, if any #[derive(Serialize, PartialEq, Eq, Debug)] diff --git a/crates/componentize/src/abi_conformance/test_postgres.rs b/crates/componentize/src/abi_conformance/test_postgres.rs index 3f3a38c46e..5f6f20bb00 100644 --- a/crates/componentize/src/abi_conformance/test_postgres.rs +++ b/crates/componentize/src/abi_conformance/test_postgres.rs @@ -1,12 +1,12 @@ use super::{ + Context, TestConfig, postgres::{self, PgError}, rdbms_types::{Column, DbDataType, DbValue, ParameterValue, RowSet}, - Context, TestConfig, }; -use anyhow::{ensure, Result}; +use anyhow::{Result, ensure}; use serde::Serialize; use std::{collections::HashMap, iter}; -use wasmtime::{component::InstancePre, Engine}; +use wasmtime::{Engine, component::InstancePre}; /// Report of which PostgreSQL functions a module successfully used, if any #[derive(Serialize, PartialEq, Eq, Debug)] diff --git a/crates/componentize/src/abi_conformance/test_redis.rs b/crates/componentize/src/abi_conformance/test_redis.rs index 21249439f6..cfe4f66fb3 100644 --- a/crates/componentize/src/abi_conformance/test_redis.rs +++ b/crates/componentize/src/abi_conformance/test_redis.rs @@ -1,11 +1,11 @@ use super::{ - redis::{self, Error, RedisParameter, RedisResult}, Context, TestConfig, + redis::{self, Error, RedisParameter, RedisResult}, }; -use anyhow::{ensure, Result}; +use anyhow::{Result, ensure}; use serde::Serialize; use std::collections::{HashMap, HashSet}; -use wasmtime::{component::InstancePre, Engine}; +use wasmtime::{Engine, component::InstancePre}; /// Report of which Redis tests succeeded or failed #[derive(Serialize, PartialEq, Eq, Debug)] diff --git a/crates/componentize/src/abi_conformance/test_wasi.rs b/crates/componentize/src/abi_conformance/test_wasi.rs index ef01851798..0929e60572 100644 --- a/crates/componentize/src/abi_conformance/test_wasi.rs +++ b/crates/componentize/src/abi_conformance/test_wasi.rs @@ -1,14 +1,14 @@ use super::{Context, TestConfig}; -use anyhow::{ensure, Result}; +use anyhow::{Result, ensure}; use rand_chacha::ChaCha12Core; use rand_core::{ - block::{BlockRng, BlockRngCore}, SeedableRng, + block::{BlockRng, BlockRngCore}, }; use serde::Serialize; use std::sync::{ - atomic::{AtomicBool, Ordering}, Arc, + atomic::{AtomicBool, Ordering}, }; use std::{ collections::HashSet, @@ -17,10 +17,10 @@ use std::{ ops::Deref, time::{Duration, SystemTime}, }; -use wasmtime::{component::InstancePre, Engine}; +use wasmtime::{Engine, component::InstancePre}; use wasmtime_wasi::{ - p2::pipe::{MemoryInputPipe, MemoryOutputPipe}, HostWallClock, + p2::pipe::{MemoryInputPipe, MemoryOutputPipe}, }; /// Report of which WASI functions a module successfully used, if any diff --git a/crates/componentize/src/lib.rs b/crates/componentize/src/lib.rs index e2c7f2a49e..45b7215b60 100644 --- a/crates/componentize/src/lib.rs +++ b/crates/componentize/src/lib.rs @@ -1,13 +1,13 @@ #![deny(warnings)] use { - anyhow::{anyhow, Context, Result}, + anyhow::{Context, Result, anyhow}, module_info::ModuleInfo, std::{borrow::Cow, collections::HashSet}, wasm_encoder::reencode::{Reencode, RoundtripReencoder}, wasm_encoder::{CustomSection, ExportSection, ImportSection, Module, RawSection}, wasmparser::{Encoding, Parser, Payload}, - wit_component::{metadata, ComponentEncoder}, + wit_component::{ComponentEncoder, metadata}, }; pub mod bugs; @@ -262,11 +262,11 @@ mod tests { InvocationStyle, KeyValueReport, LlmReport, MysqlReport, PostgresReport, RedisReport, Report, TestConfig, WasiReport, }, - anyhow::{anyhow, Result}, + anyhow::{Result, anyhow}, tokio::fs, wasmtime::{ - component::{Component, Linker}, Config, Engine, Store, + component::{Component, Linker}, }, wasmtime_wasi::p2::{bindings::Command, pipe::MemoryInputPipe}, wasmtime_wasi::{ResourceTable, WasiCtx, WasiCtxBuilder, WasiCtxView, WasiView}, diff --git a/crates/componentize/tests/case-helper/Cargo.toml b/crates/componentize/tests/case-helper/Cargo.toml index e02de206ad..6b301e42e1 100644 --- a/crates/componentize/tests/case-helper/Cargo.toml +++ b/crates/componentize/tests/case-helper/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "case-helper" version = "0.1.0" -edition = "2021" +edition = "2024" [dependencies] anyhow = "1" diff --git a/crates/componentize/tests/rust-case-0.2/Cargo.toml b/crates/componentize/tests/rust-case-0.2/Cargo.toml index 15c4626459..b661e4b236 100644 --- a/crates/componentize/tests/rust-case-0.2/Cargo.toml +++ b/crates/componentize/tests/rust-case-0.2/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "rust-case-02" version = "0.1.0" -edition = "2021" +edition = "2024" [lib] crate-type = ["cdylib"] diff --git a/crates/componentize/tests/rust-case-0.8/Cargo.toml b/crates/componentize/tests/rust-case-0.8/Cargo.toml index 63bc09f4ee..7a7f336d76 100644 --- a/crates/componentize/tests/rust-case-0.8/Cargo.toml +++ b/crates/componentize/tests/rust-case-0.8/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "rust-case-08" version = "0.1.0" -edition = "2021" +edition = "2024" [lib] crate-type = ["cdylib"] diff --git a/crates/componentize/tests/rust-command/Cargo.toml b/crates/componentize/tests/rust-command/Cargo.toml index d301535a97..cabde669be 100644 --- a/crates/componentize/tests/rust-command/Cargo.toml +++ b/crates/componentize/tests/rust-command/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rust-command" version = "0.1.0" -edition = "2021" +edition = "2024" [workspace] diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index c57427270e..b2fc7dd3b6 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -155,7 +155,9 @@ pub enum ComposeError { conflicts: Vec<(String, Vec)>, }, /// Dependency doesn't contain an export to satisfy the import. - #[error("dependency '{dependency_name}' doesn't export '{export_name}' to satisfy import '{import_name}'")] + #[error( + "dependency '{dependency_name}' doesn't export '{export_name}' to satisfy import '{import_name}'" + )] MissingExport { dependency_name: DependencyName, export_name: String, diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index e4708c1ee0..f7d4a469c0 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -20,9 +20,8 @@ use wasmtime::{InstanceAllocationStrategy, PoolingAllocationConfig}; pub use async_trait::async_trait; pub use wasmtime::Engine as WasmtimeEngine; pub use wasmtime::{ - self, + self, Instance as ModuleInstance, Module, Trap, component::{Component, Instance, InstancePre, Linker}, - Instance as ModuleInstance, Module, Trap, }; pub use store::{AsState, Store, StoreBuilder}; @@ -326,12 +325,14 @@ impl EngineBuilder { } let engine_weak = self.engine.weak(); let interval = self.epoch_tick_interval; - std::thread::spawn(move || loop { - std::thread::sleep(interval); - let Some(engine) = engine_weak.upgrade() else { - break; - }; - engine.increment_epoch(); + std::thread::spawn(move || { + loop { + std::thread::sleep(interval); + let Some(engine) = engine_weak.upgrade() else { + break; + }; + engine.increment_epoch(); + } }); } diff --git a/crates/core/src/store.rs b/crates/core/src/store.rs index f1424f0506..c510ed0535 100644 --- a/crates/core/src/store.rs +++ b/crates/core/src/store.rs @@ -1,7 +1,7 @@ use anyhow::Result; use std::time::{Duration, Instant}; -use crate::{limits::StoreLimitsAsync, State, WasmtimeEngine}; +use crate::{State, WasmtimeEngine, limits::StoreLimitsAsync}; #[cfg(doc)] use crate::EngineBuilder; diff --git a/crates/core/tests/core-wasi-test/Cargo.toml b/crates/core/tests/core-wasi-test/Cargo.toml index 1df9c0eb6f..805b19fb3e 100644 --- a/crates/core/tests/core-wasi-test/Cargo.toml +++ b/crates/core/tests/core-wasi-test/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "core-wasi-test" version = "0.1.0" -edition = "2021" +edition = "2024" [profile.release] debug = true diff --git a/crates/dependency-wit/src/lib.rs b/crates/dependency-wit/src/lib.rs index 026772e6d6..5b623d07c8 100644 --- a/crates/dependency-wit/src/lib.rs +++ b/crates/dependency-wit/src/lib.rs @@ -7,6 +7,8 @@ use spin_serde::DependencyName; use wit_component::DecodedWasm; use wit_parser::Span; +const GENERATED_COMMENT: &str = "// This file is automatically generated by Spin\n// It is not intended for manual editing.\n\n"; + pub async fn extract_wits_into( source: impl ExactSizeIterator, app_root: impl AsRef, @@ -19,7 +21,11 @@ pub async fn extract_wits_into( return Ok(()); } - let wit_text = extract_wits(source, app_root).await?; + let wit_text = format!( + "{}{}", + GENERATED_COMMENT, + extract_wits(source, app_root).await? + ); tokio::fs::create_dir_all( dest_file @@ -217,7 +223,9 @@ fn munge_aliased_export( ) -> anyhow::Result { let export_qname = spin_serde::DependencyPackageName::try_from(export.to_string())?; let Some(export_itf_name) = export_qname.interface.as_ref() else { - anyhow::bail!("the export name should be a qualified interface name - {export_qname} doesn't specify interface"); + anyhow::bail!( + "the export name should be a qualified interface name - {export_qname} doesn't specify interface" + ); }; let export_pkg_name = wit_parser::PackageName { namespace: export_qname.package.namespace().to_string(), diff --git a/crates/doctor/src/lib.rs b/crates/doctor/src/lib.rs index f98c069242..f9e9da9b33 100644 --- a/crates/doctor/src/lib.rs +++ b/crates/doctor/src/lib.rs @@ -3,7 +3,7 @@ use std::{collections::VecDeque, fmt::Debug, fs, path::PathBuf}; -use anyhow::{ensure, Context, Result}; +use anyhow::{Context, Result, ensure}; use async_trait::async_trait; use spin_common::ui::quoted_path; use toml_edit::DocumentMut; diff --git a/crates/doctor/src/manifest/trigger.rs b/crates/doctor/src/manifest/trigger.rs index 7a16a53925..713d61a930 100644 --- a/crates/doctor/src/manifest/trigger.rs +++ b/crates/doctor/src/manifest/trigger.rs @@ -1,4 +1,4 @@ -use anyhow::{bail, ensure, Context, Result}; +use anyhow::{Context, Result, bail, ensure}; use async_trait::async_trait; use toml::Value; use toml_edit::{DocumentMut, InlineTable, Item, Table}; @@ -33,21 +33,21 @@ impl Diagnostic for TriggerDiagnostic { .get("trigger") .and_then(|item| item.get("type")) .and_then(|item| item.as_str()); - if let Some("http") = trigger_type { - if let Some(Value::Array(components)) = manifest.get("component") { - let single_component = components.len() == 1; - for component in components { - let id = component - .get("id") - .and_then(|value| value.as_str()) - .unwrap_or("") - .to_string(); - diags.extend(TriggerDiagnosis::for_http_component_trigger( - id, - component.get("trigger"), - single_component, - )); - } + if let Some("http") = trigger_type + && let Some(Value::Array(components)) = manifest.get("component") + { + let single_component = components.len() == 1; + for component in components { + let id = component + .get("id") + .and_then(|value| value.as_str()) + .unwrap_or("") + .to_string(); + diags.extend(TriggerDiagnosis::for_http_component_trigger( + id, + component.get("trigger"), + single_component, + )); } } @@ -166,10 +166,10 @@ impl ManifestTreatment for TriggerDiagnosis { let trigger_type = trigger.entry("type").or_insert(Item::Value("http".into())); if let Some("http") = trigger_type.as_str() { // Strip "type" trailing space - if let Some(decor) = trigger_type.as_value_mut().map(|v| v.decor_mut()) { - if let Some(suffix) = decor.suffix().and_then(|s| s.as_str()) { - decor.set_suffix(suffix.to_string().trim()); - } + if let Some(decor) = trigger_type.as_value_mut().map(|v| v.decor_mut()) + && let Some(suffix) = decor.suffix().and_then(|s| s.as_str()) + { + decor.set_suffix(suffix.to_string().trim()); } } } diff --git a/crates/doctor/src/manifest/upgrade.rs b/crates/doctor/src/manifest/upgrade.rs index 30a6685bda..49613a0e6a 100644 --- a/crates/doctor/src/manifest/upgrade.rs +++ b/crates/doctor/src/manifest/upgrade.rs @@ -1,8 +1,8 @@ -use anyhow::{anyhow, Context, Result}; +use anyhow::{Context, Result, anyhow}; use async_trait::async_trait; use spin_common::ui::quoted_path; -use spin_manifest::{compat::v1_to_v2_app, schema::v1::AppManifestV1, ManifestVersion}; -use toml_edit::{de::from_document, ser::to_document, Item, Table}; +use spin_manifest::{ManifestVersion, compat::v1_to_v2_app, schema::v1::AppManifestV1}; +use toml_edit::{Item, Table, de::from_document, ser::to_document}; use crate::{Diagnosis, Diagnostic, PatientApp, Treatment}; diff --git a/crates/doctor/src/manifest/version.rs b/crates/doctor/src/manifest/version.rs index 59e3764b25..42b59ca387 100644 --- a/crates/doctor/src/manifest/version.rs +++ b/crates/doctor/src/manifest/version.rs @@ -2,7 +2,7 @@ use anyhow::{Context, Result}; use async_trait::async_trait; use serde::Deserialize; use toml::Value; -use toml_edit::{de::from_document, DocumentMut, Item}; +use toml_edit::{DocumentMut, Item, de::from_document}; use crate::{Diagnosis, Diagnostic, PatientApp, Treatment}; diff --git a/crates/doctor/src/rustlang/target.rs b/crates/doctor/src/rustlang/target.rs index 3d37739f2f..82aded1576 100644 --- a/crates/doctor/src/rustlang/target.rs +++ b/crates/doctor/src/rustlang/target.rs @@ -37,8 +37,8 @@ async fn diagnose_rust_wasi_target() -> Result> { // - if rustup is not present, check if cargo is present // - if not, return RustNotInstalled // - if so, warn but return empty list (Rust is installed but not via rustup, so we can't perform a diagnosis - bit of an edge case this one, and the user probably knows what they're doing...?) - // - if rustup is present but the list does not contain wasm32-wasip1, return WasmTargetNotInstalled - // - if the list does contain wasm32-wasip1, return an empty list + // - if rustup is present but the list does not contain wasm32-wasip2, return WasmTargetNotInstalled + // - if the list does contain wasm32-wasip2, return an empty list // NOTE: this does not currently check against the Rust SDK MSRV - that could // be a future enhancement or separate diagnosis, but at least the Rust compiler // should give a clear error for that! @@ -49,7 +49,7 @@ async fn diagnose_rust_wasi_target() -> Result> { RustupStatus::RustupNotInstalled => match get_cargo_status().await? { CargoStatus::Installed => { terminal::warn!( - "Spin Doctor can't determine if the Rust wasm32-wasip1 target is installed." + "Spin Doctor can't determine if the Rust wasm32-wasip2 target is installed." ); vec![] } @@ -81,7 +81,7 @@ async fn get_rustup_target_status() -> Result { } Ok(output) => { let stdout = String::from_utf8_lossy(&output.stdout); - if stdout.lines().any(|line| line == "wasm32-wasip1") { + if stdout.lines().any(|line| line == "wasm32-wasip2") { RustupStatus::AllInstalled } else { RustupStatus::WasiNotInstalled @@ -119,7 +119,7 @@ async fn get_cargo_status() -> Result { pub enum TargetDiagnosis { /// Rust is not installed: neither cargo nor rustup is present RustNotInstalled, - /// The Rust wasm32-wasip1 target is not installed: rustup is present but the target isn't + /// The Rust wasm32-wasip2 target is not installed: rustup is present but the target isn't WasmTargetNotInstalled, } @@ -128,7 +128,7 @@ impl Diagnosis for TargetDiagnosis { match self { Self::RustNotInstalled => "The Rust compiler isn't installed".into(), Self::WasmTargetNotInstalled => { - "The required Rust target 'wasm32-wasip1' isn't installed".into() + "The required Rust target 'wasm32-wasip2' isn't installed".into() } } } @@ -142,16 +142,20 @@ impl Diagnosis for TargetDiagnosis { impl Treatment for TargetDiagnosis { fn summary(&self) -> String { match self { - Self::RustNotInstalled => "Install the Rust compiler and the wasm32-wasip1 target", - Self::WasmTargetNotInstalled => "Install the Rust wasm32-wasip1 target", + Self::RustNotInstalled => "Install the Rust compiler and the wasm32-wasip2 target", + Self::WasmTargetNotInstalled => "Install the Rust wasm32-wasip2 target", } .into() } async fn dry_run(&self, _patient: &PatientApp) -> Result { let message = match self { - Self::RustNotInstalled => "Download and run the Rust installer from https://rustup.rs, with the `--target wasm32-wasip1` option", - Self::WasmTargetNotInstalled => "Run the following command:\n `rustup target add wasm32-wasip1`", + Self::RustNotInstalled => { + "Download and run the Rust installer from https://rustup.rs, with the `--target wasm32-wasip2` option" + } + Self::WasmTargetNotInstalled => { + "Run the following command:\n `rustup target add wasm32-wasip2`" + } }; Ok(message.into()) } @@ -172,7 +176,9 @@ impl Treatment for TargetDiagnosis { async fn install_rust_with_wasi_target() -> Result<()> { let status = run_rust_installer().await?; anyhow::ensure!(status.success(), "Rust installation failed: {status:?}"); - let stop = StopDiagnosing::new("Because Rust was just installed, you may need to run a script or restart your command shell to add Rust to your PATH. Please follow the instructions at the end of the installer output above before re-running `spin doctor`."); + let stop = StopDiagnosing::new( + "Because Rust was just installed, you may need to run a script or restart your command shell to add Rust to your PATH. Please follow the instructions at the end of the installer output above before re-running `spin doctor`.", + ); Err(anyhow::anyhow!(stop)) } @@ -184,7 +190,7 @@ async fn run_rust_installer() -> Result { let script = resp.bytes().await?; let mut cmd = std::process::Command::new("sh"); - cmd.args(["-s", "--", "--target", "wasm32-wasip1"]); + cmd.args(["-s", "--", "--target", "wasm32-wasip2"]); cmd.stdin(std::process::Stdio::piped()); let mut shell = cmd.spawn()?; let mut stdin = shell.stdin.take().unwrap(); @@ -213,14 +219,14 @@ async fn run_rust_installer() -> Result { std::fs::write(&installer_path, &installer_bin)?; let mut cmd = std::process::Command::new(installer_path); - cmd.args(["--target", "wasm32-wasip1"]); + cmd.args(["--target", "wasm32-wasip2"]); let status = cmd.status()?; Ok(status) } fn install_wasi_target() -> Result<()> { let mut cmd = std::process::Command::new("rustup"); - cmd.args(["target", "add", "wasm32-wasip1"]); + cmd.args(["target", "add", "wasm32-wasip2"]); let status = cmd.status()?; anyhow::ensure!( status.success(), diff --git a/crates/doctor/src/wasm/missing.rs b/crates/doctor/src/wasm/missing.rs index 7759c7941b..e73d5ade25 100644 --- a/crates/doctor/src/wasm/missing.rs +++ b/crates/doctor/src/wasm/missing.rs @@ -1,6 +1,6 @@ use std::process::Command; -use anyhow::{ensure, Context, Result}; +use anyhow::{Context, Result, ensure}; use async_trait::async_trait; use spin_common::ui::quoted_path; @@ -21,10 +21,10 @@ impl WasmDiagnostic for WasmMissingDiagnostic { _app: &PatientApp, wasm: PatientWasm, ) -> anyhow::Result> { - if let Some(abs_path) = wasm.abs_source_path() { - if !abs_path.exists() { - return Ok(vec![WasmMissing(wasm)]); - } + if let Some(abs_path) = wasm.abs_source_path() + && !abs_path.exists() + { + return Ok(vec![WasmMissing(wasm)]); } Ok(vec![]) } @@ -90,7 +90,7 @@ impl Treatment for WasmMissing { #[cfg(test)] mod tests { - use crate::test::{assert_single_diagnosis, TestPatient}; + use crate::test::{TestPatient, assert_single_diagnosis}; use super::*; @@ -118,10 +118,11 @@ mod tests { let patient = TestPatient::from_toml_str(manifest); let diag = assert_single_diagnosis::(&patient).await; assert!(diag.treatment().is_some()); - assert!(diag - .build_cmd(&patient) - .unwrap() - .get_args() - .any(|arg| arg == "missing-source")); + assert!( + diag.build_cmd(&patient) + .unwrap() + .get_args() + .any(|arg| arg == "missing-source") + ); } } diff --git a/crates/environments/Cargo.toml b/crates/environments/Cargo.toml index 7ddd749f93..bab6ad441e 100644 --- a/crates/environments/Cargo.toml +++ b/crates/environments/Cargo.toml @@ -9,11 +9,13 @@ anyhow = { workspace = true } async-trait = "0.1" bytes = { workspace = true } chrono = { workspace = true } +dirs = { workspace = true } futures = "0.3" futures-util = "0.3" id-arena = "2" indexmap = "2" oci-distribution = { git = "https://github.com/fermyon/oci-distribution", rev = "7e4ce9be9bcd22e78a28f06204931f10c44402ba" } +reqwest = { workspace = true } semver = "1" serde = { version = "1", features = ["derive"] } serde_json = "1" @@ -26,6 +28,7 @@ spin-serde = { path = "../serde" } toml = { workspace = true } tokio = { version = "1.23", features = ["fs"] } tracing = { workspace = true } +url = { workspace = true } wac-graph = "0.8" wac-types = "0.8" wasm-pkg-client = { workspace = true } diff --git a/crates/environments/src/environment.rs b/crates/environments/src/environment.rs index afd416f3a9..45d31791ec 100644 --- a/crates/environments/src/environment.rs +++ b/crates/environments/src/environment.rs @@ -4,11 +4,15 @@ use anyhow::Context; use spin_common::ui::quoted_path; use spin_manifest::schema::v2::TargetEnvironmentRef; +mod catalogue; mod definition; mod env_loader; mod lockfile; +pub use catalogue::Catalogue; +pub use definition::EnvironmentDefinition; use definition::WorldName; +pub use env_loader::load_environment_def; use crate::Targets; @@ -234,10 +238,6 @@ impl CandidateWorld { } } -pub(super) fn is_versioned(env_id: &str) -> bool { - env_id.contains(':') -} - pub type TriggerType = String; #[cfg(test)] diff --git a/crates/environments/src/environment/catalogue.rs b/crates/environments/src/environment/catalogue.rs new file mode 100644 index 0000000000..d0cb6518b5 --- /dev/null +++ b/crates/environments/src/environment/catalogue.rs @@ -0,0 +1,216 @@ +const SPIN_ENV_REPO: &str = "https://github.com/spinframework/spin-environments"; +const ENVS_DIR_IN_REPO: &str = "envs"; + +pub struct Catalogue { + git_root: PathBuf, + envs_root: PathBuf, +} + +static CATALOGUE_UPDATE_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + +impl Catalogue { + pub fn try_default() -> anyhow::Result { + let root = dirs::cache_dir() + .ok_or(anyhow::anyhow!("No system cache directory"))? + .join("spin") + .join("environments"); + Ok(Self::new(root)) + } + + fn new(git_root: PathBuf) -> Self { + Self { + git_root: git_root.clone(), + envs_root: git_root.join(ENVS_DIR_IN_REPO), + } + } + + pub async fn update(&self) -> anyhow::Result<()> { + // We don't want two git pulls running concurrently + let _guard = CATALOGUE_UPDATE_LOCK.lock(); + + let url = Url::parse(SPIN_ENV_REPO)?; + let git_source = GitSource::new(&url, None, &self.git_root); + if self.git_root.exists() { + git_source.pull().await + } else { + tokio::fs::create_dir_all(&self.git_root).await?; + git_source.clone_repo().await + } + } + + /// This requires `env_id` to be normalised to the `ns@version` form + pub async fn get(&self, env_id: &str) -> anyhow::Result> { + // We add (redundant) directories to avoid having a single flat + // namespace that becomes unmanageable. + // + // ENV_ROOT + // |-- foo + // | |-- foo@1.2.toml + // | |-- foo@1.6.toml + // |-- bar + // | |-- bar.toml + let ns = sans_version(env_id); + // TODO: I suppose we should stop people making up path injectiony kind of names + // although I am unconvinced such a thing would get you anything you don't have already + let path = self.envs_root.join(ns).join(format!("{env_id}.toml")); + if !path.exists() { + return Ok(None); + } + let toml_text = tokio::fs::read_to_string(&path) + .await + .with_context(|| format!("Environment '{env_id}' not found"))?; + let env_def = toml::from_str(&toml_text) + .with_context(|| format!("Environment '{env_id}' definition is invalid format"))?; + Ok(Some(env_def)) + } + + pub async fn list(&self) -> anyhow::Result> { + let mut envs = vec![]; + + for ns_entry in self.envs_root.read_dir()? { + let Ok(ns_entry) = ns_entry else { + continue; // avoid blocking the list for one error + }; + if ns_entry.path().is_dir() { + let Ok(ns_reader) = ns_entry.path().read_dir() else { + continue; + }; + for env_entry in ns_reader { + let Ok(env_entry) = env_entry else { + continue; + }; + if env_entry.path().is_file() + && let Some(env_name) = + env_entry.path().file_stem().and_then(|s| s.to_str()) + { + envs.push(env_name.to_owned()); + } + } + } + } + + Ok(envs) + } +} + +fn sans_version(id: &str) -> &str { + match id.rsplit_once('@') { + None => id, + Some((stem, _)) => stem, + } +} + +// From here on this is a copy of plugins/git.rs, which itself was +// recycled from templates... + +use anyhow::{Context, Result}; +use std::io::ErrorKind; +use std::path::{Path, PathBuf}; +use tokio::process::Command; +use url::Url; + +use crate::environment::definition::EnvironmentDefinition; + +const DEFAULT_BRANCH: &str = "main"; + +/// Enables cloning and fetching the latest of a git repository to a local +/// directory. +pub struct GitSource { + /// Address to remote git repository. + source_url: Url, + /// Branch to clone/fetch. + branch: String, + /// Destination to clone repository into. + git_root: PathBuf, +} + +impl GitSource { + /// Creates a new git source + pub fn new(source_url: &Url, branch: Option, git_root: impl AsRef) -> GitSource { + Self { + source_url: source_url.clone(), + branch: branch.unwrap_or_else(|| DEFAULT_BRANCH.to_owned()), + git_root: git_root.as_ref().to_owned(), + } + } + + /// Clones a contents of a git repository to a local directory + pub async fn clone_repo(&self) -> Result<()> { + let mut git = Command::new("git"); + git.args([ + "clone", + self.source_url.as_ref(), + "--branch", + &self.branch, + "--single-branch", + ]) + .arg(&self.git_root); + let clone_result = git.output().await.understand_git_result(); + if let Err(e) = clone_result { + anyhow::bail!("Error cloning Git repo {}: {}", self.source_url, e) + } + Ok(()) + } + + /// Fetches the latest changes from the source repository + pub async fn pull(&self) -> Result<()> { + let mut git = Command::new("git"); + git.arg("-C").arg(&self.git_root).arg("pull"); + let pull_result = git.output().await.understand_git_result(); + if let Err(e) = pull_result { + anyhow::bail!( + "Error updating Git repo at {}: {}", + self.git_root.display(), + e + ) + } + Ok(()) + } +} + +// TODO: the following and templates/git.rs are duplicates + +pub(crate) enum GitError { + ProgramFailed(Vec), + ProgramNotFound, + Other(anyhow::Error), +} + +impl std::fmt::Display for GitError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::ProgramNotFound => f.write_str("`git` command not found - is git installed?"), + Self::Other(e) => e.fmt(f), + Self::ProgramFailed(stderr) => match std::str::from_utf8(stderr) { + Ok(s) => f.write_str(s), + Err(_) => f.write_str("(cannot get error)"), + }, + } + } +} + +pub(crate) trait UnderstandGitResult { + fn understand_git_result(self) -> Result, GitError>; +} + +impl UnderstandGitResult for Result { + fn understand_git_result(self) -> Result, GitError> { + match self { + Ok(output) => { + if output.status.success() { + Ok(output.stdout) + } else { + Err(GitError::ProgramFailed(output.stderr)) + } + } + Err(e) => match e.kind() { + // TODO: consider cases like insufficient permission? + ErrorKind::NotFound => Err(GitError::ProgramNotFound), + _ => { + let err = anyhow::Error::from(e).context("Failed to run `git` command"); + Err(GitError::Other(err)) + } + }, + } + } +} diff --git a/crates/environments/src/environment/definition.rs b/crates/environments/src/environment/definition.rs index 143019a4e2..23ecc92dc5 100644 --- a/crates/environments/src/environment/definition.rs +++ b/crates/environments/src/environment/definition.rs @@ -23,6 +23,8 @@ pub struct EnvironmentDefinition { triggers: HashMap, #[serde(default)] default: Option, + #[serde(default)] + metadata: Metadata, } /// The environment definition for a trigger, comprising the worlds which are @@ -54,6 +56,14 @@ impl EnvironmentDefinition { pub fn default(&self) -> Option<&TriggerEnvironment> { self.default.as_ref() } + + pub fn templates(&self) -> Option<&GitRepo> { + self.metadata.templates.as_ref() + } + + pub fn plugins(&self) -> &[String] { + &self.metadata.plugins + } } /// A reference to a world in an [EnvironmentDefinition]. This is formed @@ -150,6 +160,30 @@ impl std::fmt::Display for WorldName { } } +#[derive(Debug, Default, serde::Deserialize)] +pub struct Metadata { + #[serde(default)] + templates: Option, + #[serde(default)] + plugins: Vec, +} + +#[derive(Debug, serde::Deserialize)] +pub struct GitRepo { + url: String, + tag: Option, +} + +impl GitRepo { + pub fn url(&self) -> &str { + &self.url + } + + pub fn tag(&self) -> &Option { + &self.tag + } +} + #[cfg(test)] mod test { use super::*; diff --git a/crates/environments/src/environment/env_loader.rs b/crates/environments/src/environment/env_loader.rs index a06d779c76..f9ad85846e 100644 --- a/crates/environments/src/environment/env_loader.rs +++ b/crates/environments/src/environment/env_loader.rs @@ -2,21 +2,43 @@ //! a fully realised collection of WIT packages with their worlds and //! mappings. +use std::path::PathBuf; use std::sync::Arc; use std::{collections::HashMap, path::Path}; -use anyhow::{anyhow, Context}; +use anyhow::{Context, anyhow}; use futures::future::try_join_all; use spin_common::ui::quoted_path; use spin_manifest::schema::v2::TargetEnvironmentRef; +use crate::environment::catalogue::Catalogue; + use super::definition::{EnvironmentDefinition, WorldName, WorldRef}; use super::lockfile::TargetEnvironmentLockfile; -use super::{is_versioned, CandidateWorld, CandidateWorlds, TargetEnvironment, UnknownTrigger}; +use super::{CandidateWorld, CandidateWorlds, TargetEnvironment, UnknownTrigger}; -const DEFAULT_ENV_DEF_REGISTRY_PREFIX: &str = "ghcr.io/spinframework/environments"; const DEFAULT_PACKAGE_REGISTRY: &str = "spinframework.dev"; +pub struct LoadedEnvironmentDefinition { + pub name: String, + pub env_def: EnvironmentDefinition, + pub relative_path_base: Option, +} + +impl LoadedEnvironmentDefinition { + fn new( + name: impl Into, + env_def: EnvironmentDefinition, + relative_path_base: Option, + ) -> Self { + Self { + name: name.into(), + env_def, + relative_path_base, + } + } +} + /// Load all the listed environments from their registries or paths. /// Registry data will be cached, with a lockfile under `.spin` mapping /// environment IDs to digests (to allow cache lookup without needing @@ -54,11 +76,11 @@ pub async fn load_environments<'a>( .collect(); let final_lockfile = &*lockfile.read().await; - if *final_lockfile != orig_lockfile { - if let Ok(lockfile_json) = serde_json::to_string_pretty(&final_lockfile) { - _ = tokio::fs::create_dir_all(lockfile_dir).await; - _ = tokio::fs::write(&lockfile_path, lockfile_json).await; // failure to update lockfile is not an error - } + if *final_lockfile != orig_lockfile + && let Ok(lockfile_json) = serde_json::to_string_pretty(&final_lockfile) + { + _ = tokio::fs::create_dir_all(lockfile_dir).await; + _ = tokio::fs::write(&lockfile_path, lockfile_json).await; // failure to update lockfile is not an error } Ok(envs) @@ -71,45 +93,74 @@ async fn load_environment<'a>( cache: &spin_loader::cache::Cache, lockfile: &std::sync::Arc>, ) -> anyhow::Result<(&'a TargetEnvironmentRef, TargetEnvironment)> { - let env = match env_id { - TargetEnvironmentRef::DefaultRegistry(id) => { - load_environment_from_registry(DEFAULT_ENV_DEF_REGISTRY_PREFIX, id, cache, lockfile) - .await - } - TargetEnvironmentRef::Registry { registry, id } => { - load_environment_from_registry(registry, id, cache, lockfile).await - } + let loaded_env_def = load_environment_def(env_id, app_dir).await?; + let env = load_environment_from_env_def(loaded_env_def, cache, lockfile).await?; + Ok((env_id, env)) +} + +pub async fn load_environment_def( + env_id: &TargetEnvironmentRef, + app_dir: &Path, +) -> Result { + match env_id { + TargetEnvironmentRef::Catalogue(id) => load_environment_def_from_catalogue(id).await, + TargetEnvironmentRef::Http { url } => load_environment_def_from_http(url).await, TargetEnvironmentRef::File { path } => { - load_environment_from_file(app_dir.join(path), cache, lockfile).await + load_environment_def_from_file(app_dir.join(path)).await } - }?; - Ok((env_id, env)) + } } -/// Loads a `TargetEnvironment` from the environment definition at the given -/// registry location. The environment and any remote packages it references will be used +/// Loads a `EnvironmentDefinition` from the catalogue. If not found, the catalogue is refreshed +/// and retried. Any remote packages the environment references will be used /// from cache if available; otherwise, they will be saved to the cache, and the /// in-memory lockfile object updated. -async fn load_environment_from_registry( - registry: &str, +async fn load_environment_def_from_catalogue( env_id: &str, - cache: &spin_loader::cache::Cache, - lockfile: &std::sync::Arc>, -) -> anyhow::Result { - let env_def_toml = load_env_def_toml_from_registry(registry, env_id, cache, lockfile).await?; - load_environment_from_toml(env_id, &env_def_toml, None, cache, lockfile).await +) -> anyhow::Result { + let catalogue = Catalogue::try_default()?; + let env_id = env_id.replace(':', "@"); + let env_def = match catalogue.get(&env_id).await? { + Some(env_def) => env_def, + None => { + catalogue.update().await?; + catalogue + .get(&env_id) + .await? + .with_context(|| anyhow!("Cannot load target environment '{env_id}'"))? + } + }; + Ok(LoadedEnvironmentDefinition::new(env_id, env_def, None)) +} + +/// Loads a `EnvironmentDefinition` from the given +/// URL. Any remote packages the environment references will be used +/// from cache if available; otherwise, they will be saved to the cache, and the +/// in-memory lockfile object updated. +async fn load_environment_def_from_http(url: &str) -> anyhow::Result { + let toml_text = reqwest::get(url).await?.text().await?; + let env_def: EnvironmentDefinition = toml::from_str(&toml_text)?; + let url = url::Url::parse(url)?; + let env_id = url + .path_segments() + .with_context(|| format!("environment URL {url} does not have a path"))? + .next_back() + .with_context(|| format!("environment URL {url} does not have a path"))?; + let env_id = env_id + .rsplit_once('.') + .map(|(stem, _)| stem) + .unwrap_or(env_id); + Ok(LoadedEnvironmentDefinition::new(env_id, env_def, None)) } -/// Loads a `TargetEnvironment` from the given TOML file. Any remote packages +/// Loads a `EnvironmentDefinition` from the given TOML file. Any remote packages /// it references will be used from cache if available; otherwise, they will be saved /// to the cache, and the in-memory lockfile object updated. -async fn load_environment_from_file( +async fn load_environment_def_from_file( path: impl AsRef, - cache: &spin_loader::cache::Cache, - lockfile: &std::sync::Arc>, -) -> anyhow::Result { +) -> anyhow::Result { let path = path.as_ref(); - let env_def_dir = path.parent(); + let env_def_dir = path.parent().map(|p| p.to_owned()); let name = path .file_stem() .and_then(|s| s.to_str()) @@ -121,41 +172,50 @@ async fn load_environment_from_file( quoted_path(path) ) })?; - load_environment_from_toml(&name, &toml_text, env_def_dir, cache, lockfile).await + let env_def: EnvironmentDefinition = toml::from_str(&toml_text)?; + Ok(LoadedEnvironmentDefinition::new(name, env_def, env_def_dir)) } /// Loads a `TargetEnvironment` from the given TOML text. Any remote packages /// it references will be used from cache if available; otherwise, they will be saved /// to the cache, and the in-memory lockfile object updated. -async fn load_environment_from_toml( - name: &str, - toml_text: &str, - relative_to_dir: Option<&Path>, +async fn load_environment_from_env_def( + loaded_env_def: LoadedEnvironmentDefinition, cache: &spin_loader::cache::Cache, lockfile: &std::sync::Arc>, ) -> anyhow::Result { - let env: EnvironmentDefinition = toml::from_str(toml_text)?; - let mut trigger_worlds = HashMap::new(); let mut trigger_capabilities = HashMap::new(); + let LoadedEnvironmentDefinition { + name, + env_def, + relative_path_base, + } = loaded_env_def; + // TODO: parallel all the things // TODO: this loads _all_ triggers not just the ones we need - for (trigger_type, trigger_env) in env.triggers() { + for (trigger_type, trigger_env) in env_def.triggers() { trigger_worlds.insert( trigger_type.to_owned(), - load_worlds(trigger_env.world_refs(), relative_to_dir, cache, lockfile).await?, + load_worlds( + trigger_env.world_refs(), + &relative_path_base, + cache, + lockfile, + ) + .await?, ); trigger_capabilities.insert(trigger_type.to_owned(), trigger_env.capabilities()); } - let unknown_trigger = match env.default() { + let unknown_trigger = match env_def.default() { None => UnknownTrigger::Deny, Some(env) => UnknownTrigger::Allow( - load_worlds(env.world_refs(), relative_to_dir, cache, lockfile).await?, + load_worlds(env.world_refs(), &relative_path_base, cache, lockfile).await?, ), }; - let unknown_capabilities = match env.default() { + let unknown_capabilities = match env_def.default() { None => vec![], Some(env) => env.capabilities(), }; @@ -169,86 +229,9 @@ async fn load_environment_from_toml( }) } -/// Loads the text (assumed to be TOML) from the environment definition at the given -/// registry location. The environment will be used from cache if available; otherwise, -/// it be saved to the cache, and the in-memory lockfile object updated. -async fn load_env_def_toml_from_registry( - registry: &str, - env_id: &str, - cache: &spin_loader::cache::Cache, - lockfile: &std::sync::Arc>, -) -> anyhow::Result { - if let Some(digest) = lockfile.read().await.env_digest(registry, env_id) { - if let Ok(cache_file) = cache.data_file(digest) { - if let Ok(bytes) = tokio::fs::read(&cache_file).await { - return Ok(String::from_utf8_lossy(&bytes).to_string()); - } - } - } - - let (bytes, digest) = download_env_def_file(registry, env_id) - .await - .with_context(|| format!("downloading target environment {env_id} from {registry}"))?; - - let toml_text = String::from_utf8_lossy(&bytes).to_string(); - - _ = cache.write_data(bytes, &digest).await; - lockfile - .write() - .await - .set_env_digest(registry, env_id, &digest); - - Ok(toml_text) -} - -/// Downloads a single-layer document from the given registry. -/// (You can create a suitable document with e.g. `oras push ghcr.io/my/envs/sample:1.0 sample.toml`.) -/// The image must be publicly accessible (which is *NOT* the default with GHCR). -/// -/// The return value is a tuple of (content, digest). -async fn download_env_def_file(registry: &str, env_id: &str) -> anyhow::Result<(Vec, String)> { - // This implies env_id is in the format spin-up:3.2 - let registry_id = if is_versioned(env_id) { - env_id.to_string() - } else { - // Testing versionless tags with GHCR it didn't work - // TODO: is this expected or am I being a dolt - // TODO: is this a suitable workaround - format!("{env_id}:latest") - }; - - let reference = format!("{registry}/{registry_id}"); - let reference = oci_distribution::Reference::try_from(reference)?; - - let config = oci_distribution::client::ClientConfig::default(); - let client = oci_distribution::client::Client::new(config); - let auth = oci_distribution::secrets::RegistryAuth::Anonymous; - - let (manifest, digest) = client.pull_manifest(&reference, &auth).await?; - - let im = match manifest { - oci_distribution::manifest::OciManifest::Image(im) => im, - oci_distribution::manifest::OciManifest::ImageIndex(_) => { - anyhow::bail!("unexpected registry format for {reference}") - } - }; - - let count = im.layers.len(); - - if count != 1 { - anyhow::bail!("artifact {reference} should have had exactly one layer"); - } - - let the_layer = &im.layers[0]; - let mut out = Vec::with_capacity(the_layer.size.try_into().unwrap_or_default()); - client.pull_blob(&reference, the_layer, &mut out).await?; - - Ok((out, digest)) -} - async fn load_worlds( world_refs: &[WorldRef], - relative_to_dir: Option<&Path>, + relative_to_dir: &Option, cache: &spin_loader::cache::Cache, lockfile: &std::sync::Arc>, ) -> anyhow::Result { @@ -263,7 +246,7 @@ async fn load_worlds( async fn load_world( world_ref: &WorldRef, - relative_to_dir: Option<&Path>, + relative_to_dir: &Option, cache: &spin_loader::cache::Cache, lockfile: &std::sync::Arc>, ) -> anyhow::Result { @@ -311,12 +294,10 @@ async fn load_world_from_registry( .read() .await .package_digest(registry, world_name.package()) + && let Ok(cache_file) = cache.wasm_file(digest) + && let Ok(bytes) = tokio::fs::read(&cache_file).await { - if let Ok(cache_file) = cache.wasm_file(digest) { - if let Ok(bytes) = tokio::fs::read(&cache_file).await { - return CandidateWorld::from_package_bytes(world_name, bytes); - } - } + return CandidateWorld::from_package_bytes(world_name, bytes); } let pkg_name = world_name.package_namespaced_name(); diff --git a/crates/environments/src/environment/lockfile.rs b/crates/environments/src/environment/lockfile.rs index e9019b23a8..8c84b30fb2 100644 --- a/crates/environments/src/environment/lockfile.rs +++ b/crates/environments/src/environment/lockfile.rs @@ -1,65 +1,15 @@ use std::collections::HashMap; -use super::is_versioned; - -const DIGEST_TTL_HOURS: i64 = 24; - /// Serialisation format for the lockfile: registry -> env|pkg -> { name -> digest } #[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub struct TargetEnvironmentLockfile(HashMap); #[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] struct Digests { - env: HashMap, package: HashMap, } -#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] -#[serde(untagged)] -enum ExpirableDigest { - Forever(String), - Expiring { - digest: String, - correct_at: chrono::DateTime, - }, -} - impl TargetEnvironmentLockfile { - pub fn env_digest(&self, registry: &str, env_id: &str) -> Option<&str> { - self.0 - .get(registry) - .and_then(|ds| ds.env.get(env_id)) - .and_then(|s| s.current()) - } - - pub fn set_env_digest(&mut self, registry: &str, env_id: &str, digest: &str) { - // If the environment is versioned, we assume it will not change (that is, any changes will - // be reflected as a new version). If the environment is *not* versioned, it represents - // a hosted service which may change over time: allow the cached definition to expire every day or - // so that we do not use a definition that is out of sync with the actual service. - let expirable_digest = if is_versioned(env_id) { - ExpirableDigest::forever(digest) - } else { - ExpirableDigest::expiring(digest) - }; - - match self.0.get_mut(registry) { - Some(ds) => { - ds.env.insert(env_id.to_string(), expirable_digest); - } - None => { - let map = vec![(env_id.to_string(), expirable_digest)] - .into_iter() - .collect(); - let ds = Digests { - env: map, - package: Default::default(), - }; - self.0.insert(registry.to_string(), ds); - } - } - } - pub fn package_digest( &self, registry: &str, @@ -85,126 +35,9 @@ impl TargetEnvironmentLockfile { let map = vec![(package.to_string(), digest.to_string())] .into_iter() .collect(); - let ds = Digests { - env: Default::default(), - package: map, - }; + let ds = Digests { package: map }; self.0.insert(registry.to_string(), ds); } } } } - -impl ExpirableDigest { - fn current(&self) -> Option<&str> { - match self { - Self::Forever(digest) => Some(digest), - Self::Expiring { digest, correct_at } => { - let now = chrono::Utc::now(); - let time_since = now - correct_at; - if time_since.abs().num_hours() > DIGEST_TTL_HOURS { - None - } else { - Some(digest) - } - } - } - } - - fn forever(digest: &str) -> Self { - Self::Forever(digest.to_string()) - } - - fn expiring(digest: &str) -> Self { - Self::Expiring { - digest: digest.to_string(), - correct_at: chrono::Utc::now(), - } - } -} - -#[cfg(test)] -mod test { - use super::*; - - const DUMMY_REG: &str = "reggy-mc-regface"; - - #[test] - fn versioned_envs_have_no_expiry() { - const TEST_ENV: &str = "my-env:1.0"; - const TEST_DIGEST: &str = "12345"; - - let mut lockfile = TargetEnvironmentLockfile::default(); - lockfile.set_env_digest(DUMMY_REG, TEST_ENV, TEST_DIGEST); - - let json = serde_json::to_value(&lockfile).unwrap(); - - let saved_digest = json - .get(DUMMY_REG) - .and_then(|j| j.get("env")) - .and_then(|j| j.get(TEST_ENV)) - .expect("should have had recorded a digest"); - let saved_digest = saved_digest - .as_str() - .expect("saved digest should have been a string"); - assert_eq!(TEST_DIGEST, saved_digest); - } - - #[test] - fn unversioned_envs_expire() { - const TEST_ENV: &str = "my-env"; - const TEST_DIGEST: &str = "12345"; - - let mut lockfile = TargetEnvironmentLockfile::default(); - lockfile.set_env_digest(DUMMY_REG, TEST_ENV, TEST_DIGEST); - - let json = serde_json::to_value(&lockfile).unwrap(); - - let saved_digest = json - .get(DUMMY_REG) - .and_then(|j| j.get("env")) - .and_then(|j| j.get(TEST_ENV)) - .expect("should have recorded a digest"); - let saved_digest = saved_digest - .as_object() - .expect("saved digest should have been an object"); - assert_eq!(TEST_DIGEST, saved_digest.get("digest").unwrap()); - assert!(saved_digest - .get("correct_at") - .is_some_and(|v| v.is_string())); - } - - #[test] - fn expired_env_digests_are_not_returned() { - const TEST_ENV: &str = "my-env"; - const TEST_DIGEST: &str = "12345"; - - let mut lockfile = TargetEnvironmentLockfile::default(); - lockfile.set_env_digest(DUMMY_REG, TEST_ENV, TEST_DIGEST); - assert_eq!( - TEST_DIGEST, - lockfile - .env_digest(DUMMY_REG, TEST_ENV) - .expect("should have returned env digest") - ); - - // Pass this legit lockfile through JSON and massage the digest date to be old. NEARLY AS OLD AS ME - let mut json = serde_json::to_value(&lockfile).unwrap(); - let digest = json - .get_mut(DUMMY_REG) - .and_then(|j| j.get_mut("env")) - .and_then(|j| j.get_mut(TEST_ENV)) - .expect("should have recorded a digest"); - let digest = digest - .as_object_mut() - .expect("saved digest should have been an object"); - digest.insert( - "correct_at".to_string(), - serde_json::to_value("1969-12-31T01:01:01.001001001Z").unwrap(), - ); - let stale_lockfile: TargetEnvironmentLockfile = serde_json::from_value(json).unwrap(); - - // It should not give us the potentially stale digest - assert!(stale_lockfile.env_digest(DUMMY_REG, TEST_ENV).is_none()); - } -} diff --git a/crates/environments/src/lib.rs b/crates/environments/src/lib.rs index 8a7aa5a779..319ccddd19 100644 --- a/crates/environments/src/lib.rs +++ b/crates/environments/src/lib.rs @@ -1,11 +1,12 @@ use std::{collections::HashMap, sync::Arc}; -use anyhow::{anyhow, Context}; +use anyhow::{Context, anyhow}; mod environment; mod loader; use environment::{CandidateWorld, CandidateWorlds, TargetEnvironment, TriggerType}; +pub use environment::{Catalogue, EnvironmentDefinition, load_environment_def}; pub use loader::ApplicationToValidate; use loader::ComponentToValidate; use spin_manifest::schema::v2::TargetEnvironmentRef; @@ -193,7 +194,7 @@ async fn validate_wasm_against_world( target_world: &CandidateWorld, component: &ComponentToValidate<'_>, ) -> anyhow::Result<()> { - use wac_types::{validate_target, ItemKind, Package as WacPackage, Types as WacTypes, WorldId}; + use wac_types::{ItemKind, Package as WacPackage, Types as WacTypes, WorldId, validate_target}; // Gets the selected world from the component encoded WIT package // TODO: make this an export on `wac_types::Types`. @@ -265,7 +266,12 @@ fn validate_host_reqs( if unsatisfied.is_empty() { Ok(()) } else { - Err(anyhow!("Component {} can't run in environment {} because it requires the feature(s) '{}' which the environment does not support", component.id(), env.name(), unsatisfied.join(", "))) + Err(anyhow!( + "Component {} can't run in environment {} because it requires the feature(s) '{}' which the environment does not support", + component.id(), + env.name(), + unsatisfied.join(", ") + )) } } diff --git a/crates/environments/src/loader.rs b/crates/environments/src/loader.rs index c702390a10..0d6be1f7e5 100644 --- a/crates/environments/src/loader.rs +++ b/crates/environments/src/loader.rs @@ -2,7 +2,7 @@ use std::path::Path; -use anyhow::{anyhow, Context}; +use anyhow::{Context, anyhow}; use futures::future::try_join_all; use spin_common::ui::quoted_path; diff --git a/crates/expressions/tests/validation_test.rs b/crates/expressions/tests/validation_test.rs index 2f5c1d1f42..f316fba904 100644 --- a/crates/expressions/tests/validation_test.rs +++ b/crates/expressions/tests/validation_test.rs @@ -74,8 +74,8 @@ fn if_single_static_provider_has_data_for_variable_key_to_resolve_it_succeeds() } #[test] -fn if_there_is_a_single_static_provider_and_it_does_not_contain_a_required_variable_then_validation_fails( -) -> anyhow::Result<()> { +fn if_there_is_a_single_static_provider_and_it_does_not_contain_a_required_variable_then_validation_fails() +-> anyhow::Result<()> { let resolver = ResolverTester::new() .with_provider(StaticProvider::with_variable( "database_host", @@ -90,8 +90,8 @@ fn if_there_is_a_single_static_provider_and_it_does_not_contain_a_required_varia } #[test] -fn if_there_is_a_dynamic_provider_then_validation_succeeds_even_without_default_value_in_play( -) -> anyhow::Result<()> { +fn if_there_is_a_dynamic_provider_then_validation_succeeds_even_without_default_value_in_play() +-> anyhow::Result<()> { let resolver = ResolverTester::new() .with_provider(DynamicProvider) .with_variable("api_key", None) @@ -103,8 +103,8 @@ fn if_there_is_a_dynamic_provider_then_validation_succeeds_even_without_default_ } #[test] -fn if_there_is_a_dynamic_provider_and_static_provider_but_the_variable_to_be_resolved_is_not_in_play( -) -> anyhow::Result<()> { +fn if_there_is_a_dynamic_provider_and_static_provider_but_the_variable_to_be_resolved_is_not_in_play() +-> anyhow::Result<()> { let resolver = ResolverTester::new() .with_provider(DynamicProvider) .with_provider(StaticProvider::with_variable( @@ -120,8 +120,8 @@ fn if_there_is_a_dynamic_provider_and_static_provider_but_the_variable_to_be_res } #[test] -fn if_there_is_a_dynamic_provider_and_a_static_provider_then_validation_succeeds_even_if_there_is_a_variable_in_play( -) -> anyhow::Result<()> { +fn if_there_is_a_dynamic_provider_and_a_static_provider_then_validation_succeeds_even_if_there_is_a_variable_in_play() +-> anyhow::Result<()> { let resolver = ResolverTester::new() .with_provider(DynamicProvider) .with_provider(StaticProvider::with_variable( @@ -157,8 +157,8 @@ fn if_there_are_two_static_providers_where_one_has_data_is_valid() -> anyhow::Re // Ensure that if there are two or more static providers and the first one does not have data for the variable to be resolved, // but the second or subsequent one does, then validation still succeeds. #[test] -fn if_there_are_two_static_providers_where_first_provider_does_not_have_data_while_second_provider_does( -) -> anyhow::Result<()> { +fn if_there_are_two_static_providers_where_first_provider_does_not_have_data_while_second_provider_does() +-> anyhow::Result<()> { let resolver = ResolverTester::new() .with_provider(StaticProvider::with_variable( "database_host", diff --git a/crates/factor-key-value/src/host.rs b/crates/factor-key-value/src/host.rs index d93bbc91b1..d746289704 100644 --- a/crates/factor-key-value/src/host.rs +++ b/crates/factor-key-value/src/host.rs @@ -7,10 +7,10 @@ use spin_core::{ use spin_factor_otel::OtelFactorState; use spin_resource_table::Table; use spin_telemetry::traces::{self, Blame}; +use spin_world::MAX_HOST_BUFFERED_BYTES; use spin_world::spin::key_value::key_value as v3; use spin_world::v2::key_value; use spin_world::wasi::keyvalue as wasi_keyvalue; -use spin_world::MAX_HOST_BUFFERED_BYTES; use std::{any::Any, collections::HashSet, sync::Arc}; use tracing::instrument; @@ -63,7 +63,7 @@ pub trait Store: Sync + Send { async fn delete_many(&self, keys: Vec) -> Result<(), Error>; async fn increment(&self, key: String, delta: i64) -> Result; async fn new_compare_and_swap(&self, bucket_rep: u32, key: &str) - -> Result, Error>; + -> Result, Error>; } pub struct KeyValueDispatch { @@ -266,15 +266,13 @@ impl v3::HostStoreWithStore for crate::KeyValueFactorData { let store = manager.get(&label).await.map_err(to_v3_err)?; store.after_open().await.map_err(to_v3_err)?; - let rsrc = accessor.with(|mut access| { + accessor.with(|mut access| { let host = access.get(); host.stores .push(store) .map(Resource::new_own) .map_err(|()| v3::Error::StoreTableFull) - }); - - rsrc + }) } async fn get( diff --git a/crates/factor-key-value/src/lib.rs b/crates/factor-key-value/src/lib.rs index 87024121db..a6fc35c4a6 100644 --- a/crates/factor-key-value/src/lib.rs +++ b/crates/factor-key-value/src/lib.rs @@ -19,7 +19,7 @@ use spin_locked_app::MetadataKey; pub const KEY_VALUE_STORES_KEY: MetadataKey> = MetadataKey::new("key_value_stores"); pub use host::to_v3_err; pub use host::{ - log_cas_error, log_error, log_error_v3, Error, KeyValueDispatch, Store, StoreManager, + Error, KeyValueDispatch, Store, StoreManager, log_cas_error, log_error, log_error_v3, }; pub use runtime_config::RuntimeConfig; use spin_core::async_trait; diff --git a/crates/factor-key-value/src/runtime_config/spin.rs b/crates/factor-key-value/src/runtime_config/spin.rs index 719cd697d4..c02b725fac 100644 --- a/crates/factor-key-value/src/runtime_config/spin.rs +++ b/crates/factor-key-value/src/runtime_config/spin.rs @@ -18,7 +18,7 @@ pub trait MakeKeyValueStore: 'static + Send + Sync { /// Creates a new store manager from the runtime configuration. fn make_store(&self, runtime_config: Self::RuntimeConfig) - -> anyhow::Result; + -> anyhow::Result; } /// A function that creates a store manager from a TOML table. diff --git a/crates/factor-key-value/tests/factor_test.rs b/crates/factor-key-value/tests/factor_test.rs index a20243ddb5..d1d1beffc8 100644 --- a/crates/factor-key-value/tests/factor_test.rs +++ b/crates/factor-key-value/tests/factor_test.rs @@ -1,8 +1,8 @@ use anyhow::bail; use spin_core::async_trait; -use spin_factor_key_value::{v3, Cas, KeyValueFactor, RuntimeConfig, Store, StoreManager}; +use spin_factor_key_value::{Cas, KeyValueFactor, RuntimeConfig, Store, StoreManager, v3}; use spin_factors::RuntimeFactors; -use spin_factors_test::{toml, TestEnvironment}; +use spin_factors_test::{TestEnvironment, toml}; use spin_world::v2::key_value::{Error, HostStore}; use std::{collections::HashSet, sync::Arc}; @@ -64,9 +64,10 @@ async fn errors_when_store_is_not_defined() -> anyhow::Result<()> { bail!("expected instance build to fail but it didn't"); }; - assert!(err - .to_string() - .contains(r#"unknown key_value_stores label "default""#)); + assert!( + err.to_string() + .contains(r#"unknown key_value_stores label "default""#) + ); Ok(()) } diff --git a/crates/factor-llm/src/host.rs b/crates/factor-llm/src/host.rs index c0c95666bb..f7dbcef03f 100644 --- a/crates/factor-llm/src/host.rs +++ b/crates/factor-llm/src/host.rs @@ -1,8 +1,8 @@ +use spin_world::MAX_HOST_BUFFERED_BYTES; use spin_world::v1::llm::{self as v1}; use spin_world::v2::llm::{self as v2}; -use spin_world::MAX_HOST_BUFFERED_BYTES; use tracing::field::Empty; -use tracing::{instrument, Level}; +use tracing::{Level, instrument}; use crate::InstanceState; diff --git a/crates/factor-llm/tests/factor_test.rs b/crates/factor-llm/tests/factor_test.rs index a17ab83e73..e6e21674ba 100644 --- a/crates/factor-llm/tests/factor_test.rs +++ b/crates/factor-llm/tests/factor_test.rs @@ -2,8 +2,8 @@ use std::collections::HashSet; use std::sync::Arc; use spin_factor_llm::{LlmEngine, LlmFactor}; -use spin_factors::{anyhow, RuntimeFactors}; -use spin_factors_test::{toml, TestEnvironment}; +use spin_factors::{RuntimeFactors, anyhow}; +use spin_factors_test::{TestEnvironment, toml}; use spin_world::v1::llm::{self as v1}; use spin_world::v2::llm::{self as v2, Host}; use tokio::sync::Mutex; diff --git a/crates/factor-otel/src/host.rs b/crates/factor-otel/src/host.rs index 7adf1b1b36..da568f397c 100644 --- a/crates/factor-otel/src/host.rs +++ b/crates/factor-otel/src/host.rs @@ -1,6 +1,6 @@ use crate::InstanceState; -use anyhow::anyhow; use anyhow::Result; +use anyhow::anyhow; use opentelemetry::trace::TraceContextExt; use opentelemetry_sdk::error::OTelSdkError; use opentelemetry_sdk::logs::LogProcessor; diff --git a/crates/factor-otel/src/lib.rs b/crates/factor-otel/src/lib.rs index 6cdc824e62..d9b807963f 100644 --- a/crates/factor-otel/src/lib.rs +++ b/crates/factor-otel/src/lib.rs @@ -3,21 +3,21 @@ mod host; use anyhow::bail; use indexmap::IndexMap; use opentelemetry::{ - trace::{SpanContext, SpanId, TraceContextExt}, Context, + trace::{SpanContext, SpanId, TraceContextExt}, }; use opentelemetry_otlp::MetricExporter; use opentelemetry_sdk::{ - logs::{log_processor_with_async_runtime::BatchLogProcessor, LogProcessor}, + Resource, + logs::{LogProcessor, log_processor_with_async_runtime::BatchLogProcessor}, resource::{EnvResourceDetector, ResourceDetector, TelemetryResourceDetector}, runtime::Tokio, - trace::{span_processor_with_async_runtime::BatchSpanProcessor, SpanProcessor}, - Resource, + trace::{SpanProcessor, span_processor_with_async_runtime::BatchSpanProcessor}, }; use spin_factors::{Factor, FactorData, PrepareContext, RuntimeFactors, SelfInstanceBuilder}; use spin_telemetry::{ detector::SpinResourceDetector, - env::{otel_logs_enabled, otel_metrics_enabled, otel_tracing_enabled, OtlpProtocol}, + env::{OtlpProtocol, otel_logs_enabled, otel_metrics_enabled, otel_tracing_enabled}, }; use std::sync::{Arc, RwLock}; use tracing_opentelemetry::OpenTelemetrySpanExt; @@ -71,7 +71,9 @@ impl Factor for OtelFactor { && self.metric_exporter.is_none() && self.log_processor.is_none() { - tracing::warn!("WASI OTel experimental support is enabled but no OTEL_EXPORTER_* environment variables were found. No telemetry will be exported."); + tracing::warn!( + "WASI OTel experimental support is enabled but no OTEL_EXPORTER_* environment variables were found. No telemetry will be exported." + ); } Ok(InstanceState { @@ -275,7 +277,7 @@ impl OtelFactorState { .span() .span_context() .span_id(), - "Incorrectly attempting to reparent the original host span. Likely `reparent_tracing_span` was called in an incorrect location." + "Incorrectly attempting to reparent the original host span. Likely `reparent_tracing_span` was called in an incorrect location." ); } diff --git a/crates/factor-outbound-http/Cargo.toml b/crates/factor-outbound-http/Cargo.toml index 395a904e92..7ccfd3f63b 100644 --- a/crates/factor-outbound-http/Cargo.toml +++ b/crates/factor-outbound-http/Cargo.toml @@ -27,6 +27,7 @@ tokio-rustls = { workspace = true } tower-service = { workspace = true } tracing = { workspace = true } tracing-opentelemetry = { workspace = true } +opentelemetry-semantic-conventions = { workspace = true } wasmtime = { workspace = true } wasmtime-wasi = { workspace = true } wasmtime-wasi-http = { workspace = true } @@ -35,6 +36,7 @@ wasmtime-wasi-http = { workspace = true } spin-common = { path = "../common" } spin-factor-variables = { path = "../factor-variables" } spin-factors-test = { path = "../factors-test" } +tracing-subscriber = { version = "0.3", default-features = false, features = ["registry"] } [features] default = ["spin-cli"] diff --git a/crates/factor-outbound-http/src/intercept.rs b/crates/factor-outbound-http/src/intercept.rs index 15c3ba0b2c..413a5c7974 100644 --- a/crates/factor-outbound-http/src/intercept.rs +++ b/crates/factor-outbound-http/src/intercept.rs @@ -3,7 +3,7 @@ use std::net::SocketAddr; use http::{Request, Response}; use http_body_util::{BodyExt, Full}; use spin_world::async_trait; -use wasmtime_wasi_http::p2::{body::HyperOutgoingBody, HttpResult}; +use wasmtime_wasi_http::p2::{HttpResult, body::HyperOutgoingBody}; pub type HyperBody = HyperOutgoingBody; diff --git a/crates/factor-outbound-http/src/lib.rs b/crates/factor-outbound-http/src/lib.rs index a16cf866bd..e7b43e0bed 100644 --- a/crates/factor-outbound-http/src/lib.rs +++ b/crates/factor-outbound-http/src/lib.rs @@ -9,31 +9,31 @@ use std::{net::SocketAddr, sync::Arc}; use anyhow::Context; use http::{ - uri::{Authority, Parts, PathAndQuery, Scheme}, HeaderValue, Uri, + uri::{Authority, Parts, PathAndQuery, Scheme}, }; use intercept::OutboundHttpInterceptor; use runtime_config::RuntimeConfig; use spin_factor_otel::OtelFactorState; use spin_factor_outbound_networking::{ - config::{allowed_hosts::OutboundAllowedHosts, blocked_networks::BlockedNetworks}, ComponentTlsClientConfigs, OutboundNetworkingFactor, + config::{allowed_hosts::OutboundAllowedHosts, blocked_networks::BlockedNetworks}, }; use spin_factors::{ - anyhow, ConfigureAppContext, Factor, FactorData, PrepareContext, RuntimeFactors, - SelfInstanceBuilder, + ConfigureAppContext, Factor, FactorData, PrepareContext, RuntimeFactors, SelfInstanceBuilder, + anyhow, }; use tokio::sync::Semaphore; use wasmtime_wasi_http::WasiHttpCtx; pub use wasmtime_wasi_http::p2::{ + HttpResult, bindings::http::types::ErrorCode, body::HyperOutgoingBody, types::{HostFutureIncomingResponse, OutgoingRequestConfig}, - HttpResult, }; -pub use wasi::{p2_to_p3_error_code, p3_to_p2_error_code, MutexBody, NotifyOnDropBody}; +pub use wasi::{MutexBody, NotifyOnDropBody, p2_to_p3_error_code, p3_to_p2_error_code}; #[derive(Default)] pub struct OutboundHttpFactor { diff --git a/crates/factor-outbound-http/src/spin.rs b/crates/factor-outbound-http/src/spin.rs index 2ecb872bb4..5c47204bfb 100644 --- a/crates/factor-outbound-http/src/spin.rs +++ b/crates/factor-outbound-http/src/spin.rs @@ -2,20 +2,21 @@ use std::sync::Arc; use futures::stream::TryStreamExt as _; use http_body_util::BodyExt; +use opentelemetry_semantic_conventions::attribute as otel_attribute; use spin_factor_outbound_networking::config::blocked_networks::BlockedNetworks; +use spin_world::MAX_HOST_BUFFERED_BYTES; use spin_world::v1::{ http as spin_http, http_types::{self, HttpError, Method, Request, Response}, }; -use spin_world::MAX_HOST_BUFFERED_BYTES; -use tracing::{field::Empty, instrument, Span}; +use tracing::{Span, field::Empty, instrument}; use crate::intercept::InterceptOutcome; impl spin_http::Host for crate::InstanceState { #[instrument(name = "spin_outbound_http.send_request", skip_all, - fields(otel.kind = "client", url.full = Empty, http.request.method = Empty, - http.response.status_code = Empty, otel.name = Empty, server.address = Empty, server.port = Empty))] + fields(otel.kind = "client", {otel_attribute::URL_FULL} = Empty, {otel_attribute::HTTP_REQUEST_METHOD} = Empty, + {otel_attribute::HTTP_RESPONSE_STATUS_CODE} = Empty, otel.name = Empty, {otel_attribute::SERVER_ADDRESS} = Empty, {otel_attribute::SERVER_PORT} = Empty))] async fn send_request(&mut self, req: Request) -> Result { self.hooks.otel.reparent_tracing_span(); @@ -120,7 +121,10 @@ impl spin_http::Host for crate::InstanceState { drop(permit); tracing::trace!("Returning response from outbound request to {req_url}"); - span.record("http.response.status_code", resp.status().as_u16()); + span.record( + otel_attribute::HTTP_RESPONSE_STATUS_CODE, + resp.status().as_u16(), + ); response_from_reqwest(resp).await } } @@ -162,14 +166,14 @@ fn record_request_fields(span: &Span, req: &Request) { // Set otel.name to just the method name to fit with OpenTelemetry conventions // span.record("otel.name", method) - .record("http.request.method", method) - .record("url.full", req.uri.clone()); - if let Ok(uri) = req.uri.parse::() { - if let Some(authority) = uri.authority() { - span.record("server.address", authority.host()); - if let Some(port) = authority.port() { - span.record("server.port", port.as_u16()); - } + .record(otel_attribute::HTTP_REQUEST_METHOD, method) + .record(otel_attribute::URL_FULL, req.uri.clone()); + if let Ok(uri) = req.uri.parse::() + && let Some(authority) = uri.authority() + { + span.record(otel_attribute::SERVER_ADDRESS, authority.host()); + if let Some(port) = authority.port() { + span.record(otel_attribute::SERVER_PORT, port.as_u16()); } } } diff --git a/crates/factor-outbound-http/src/wasi.rs b/crates/factor-outbound-http/src/wasi.rs index 49324a6601..ed151f5647 100644 --- a/crates/factor-outbound-http/src/wasi.rs +++ b/crates/factor-outbound-http/src/wasi.rs @@ -10,25 +10,26 @@ use std::{ time::Duration, }; -use bytes::Bytes; +use bytes::{Buf, Bytes}; use futures::channel::oneshot; use http::{ + HeaderMap, Uri, header::{CONTENT_LENGTH, HOST}, uri::Scheme, - HeaderMap, Uri, }; use http_body::{Body, Frame, SizeHint}; -use http_body_util::{combinators::UnsyncBoxBody, BodyExt}; +use http_body_util::{BodyExt, combinators::UnsyncBoxBody}; use hyper_util::{ client::legacy::{ - connect::{Connected, Connection}, Client, + connect::{Connected, Connection}, }, rt::{TokioExecutor, TokioIo}, }; +use opentelemetry_semantic_conventions::attribute as otel_attribute; use spin_factor_outbound_networking::{ - config::{allowed_hosts::OutboundAllowedHosts, blocked_networks::BlockedNetworks}, ComponentTlsClientConfigs, TlsClientConfig, + config::{allowed_hosts::OutboundAllowedHosts, blocked_networks::BlockedNetworks}, }; use spin_factors::RuntimeFactorsInstanceState; use tokio::{ @@ -39,23 +40,23 @@ use tokio::{ }; use tokio_rustls::client::TlsStream; use tower_service::Service; -use tracing::{field::Empty, instrument, Instrument, Span}; +use tracing::{Instrument, Span, field::Empty, instrument}; use wasmtime::component::HasData; use wasmtime_wasi::TrappableError; use wasmtime_wasi_http::{ p2::{ - self, + self, HttpError, WasiHttpCtxView, bindings::http::types::{self as p2_types, ErrorCode}, body::HyperOutgoingBody, types::{HostFutureIncomingResponse, IncomingResponse, OutgoingRequestConfig}, - HttpError, WasiHttpCtxView, }, p3::{self, bindings::http::types as p3_types}, }; use crate::{ + InstanceHttpHooks, OutboundHttpFactor, SelfRequestOrigin, intercept::{InterceptOutcome, OutboundHttpInterceptor}, - wasi_2023_10_18, wasi_2023_11_10, InstanceHttpHooks, OutboundHttpFactor, SelfRequestOrigin, + wasi_2023_10_18, wasi_2023_11_10, }; use tracing_opentelemetry::OpenTelemetrySpanExt as _; @@ -135,12 +136,14 @@ impl p3::WasiHttpHooks for InstanceHttpHooks { skip_all, fields( otel.kind = "client", - url.full = Empty, - http.request.method = %request.method(), + {otel_attribute::URL_FULL} = Empty, + {otel_attribute::HTTP_REQUEST_METHOD} = %request.method(), otel.name = %request.method(), - http.response.status_code = Empty, - server.address = Empty, - server.port = Empty, + // Incubating convention; not yet a stable `opentelemetry_semantic_conventions` constant. + http.response.body.size = Empty, + {otel_attribute::HTTP_RESPONSE_STATUS_CODE} = Empty, + {otel_attribute::SERVER_ADDRESS} = Empty, + {otel_attribute::SERVER_PORT} = Empty, ) )] #[allow(clippy::type_complexity)] @@ -197,40 +200,47 @@ impl p3::WasiHttpHooks for InstanceHttpHooks { .and_then(|v| v.between_bytes_timeout) .unwrap_or(DEFAULT_TIMEOUT), }; - Box::new(async { - match request_sender - .send( - request.map(|body| body.map_err(p3_to_p2_error_code).boxed_unsync()), - config, - ) - .await - { - Ok(IncomingResponse { - resp, - between_bytes_timeout, - .. - }) => Ok(( - resp.map(|body| { - BetweenBytesTimeoutBody { - body, - sleep: None, - timeout: between_bytes_timeout, + Box::new( + async { + match request_sender + .send( + request.map(|body| body.map_err(p3_to_p2_error_code).boxed_unsync()), + config, + ) + .await + { + Ok(IncomingResponse { + resp, + between_bytes_timeout, + .. + }) => Ok(( + resp.map(|body| { + BetweenBytesTimeoutBody { + body, + sleep: None, + timeout: between_bytes_timeout, + byte_count: 0, + span: Some(Span::current()), + } + .boxed_unsync() + }), + Box::new(async { + // TODO: Can we plumb connection errors through to here, or + // will `hyper_util::client::legacy::Client` pass them all + // via the response body? + Ok(()) + }) as Box + Send>, + )), + Err(http_error) => match http_error.downcast() { + Ok(error_code) => { + Err(TrappableError::from(p2_to_p3_error_code(error_code))) } - .boxed_unsync() - }), - Box::new(async { - // TODO: Can we plumb connection errors through to here, or - // will `hyper_util::client::legacy::Client` pass them all - // via the response body? - Ok(()) - }) as Box + Send>, - )), - Err(http_error) => match http_error.downcast() { - Ok(error_code) => Err(TrappableError::from(p2_to_p3_error_code(error_code))), - Err(trap) => Err(TrappableError::trap(trap)), - }, + Err(trap) => Err(TrappableError::trap(trap)), + }, + } } - }) + .in_current_span(), + ) } } @@ -241,6 +251,8 @@ pin_project_lite::pin_project! { #[pin] sleep: Option, timeout: Duration, + byte_count: u64, + span: Option, } } @@ -253,9 +265,36 @@ impl> Body for BetweenBytesTimeoutBody { cx: &mut Context<'_>, ) -> Poll, Self::Error>>> { let mut me = self.project(); - match me.body.poll_frame(cx) { + match me.body.as_mut().poll_frame(cx) { Poll::Ready(value) => { me.sleep.as_mut().set(None); + + let mut record_body_size_once = |body_size: u64| { + if let Some(span) = me.span.take() { + // `http.response.body.size` is incubating (behind semconv_experimental) + // in opentelemetry-semantic-conventions 0.28. Leave as literal to avoid + // enabling the experimental feature. + span.record("http.response.body.size", body_size); + } + }; + + match &value { + Some(Ok(frame)) => { + if let Some(data) = frame.data_ref() { + *me.byte_count += data.remaining() as u64; + } + if me.body.as_ref().is_end_stream() { + record_body_size_once(*me.byte_count); + } + } + None => { + record_body_size_once(*me.byte_count); + } + Some(Err(e)) => { + tracing::warn!("error reading response body: {e:?}"); + } + } + Poll::Ready(value.map(|v| v.map_err(p2_to_p3_error_code))) } Poll::Pending => { @@ -363,12 +402,12 @@ impl p2::WasiHttpHooks for InstanceHttpHooks { skip_all, fields( otel.kind = "client", - url.full = Empty, - http.request.method = %request.method(), + {otel_attribute::URL_FULL} = Empty, + {otel_attribute::HTTP_REQUEST_METHOD} = %request.method(), otel.name = %request.method(), - http.response.status_code = Empty, - server.address = Empty, - server.port = Empty, + {otel_attribute::HTTP_RESPONSE_STATUS_CODE} = Empty, + {otel_attribute::SERVER_ADDRESS} = Empty, + {otel_attribute::SERVER_PORT} = Empty, ) )] fn send_request( @@ -450,12 +489,12 @@ impl RequestSender { // Backfill span fields after potentially updating the URL in the interceptor let span = tracing::Span::current(); if let Some(addr) = override_connect_addr { - span.record("server.address", addr.ip().to_string()); - span.record("server.port", addr.port()); + span.record(otel_attribute::SERVER_ADDRESS, addr.ip().to_string()); + span.record(otel_attribute::SERVER_PORT, addr.port()); } else if let Some(authority) = request.uri().authority() { - span.record("server.address", authority.host()); + span.record(otel_attribute::SERVER_ADDRESS, authority.host()); if let Some(port) = authority.port_u16() { - span.record("server.port", port); + span.record(otel_attribute::SERVER_PORT, port); } } @@ -489,7 +528,7 @@ impl RequestSender { } *uri = builder.build().unwrap(); } - tracing::Span::current().record("url.full", uri.to_string()); + tracing::Span::current().record(otel_attribute::URL_FULL, uri.to_string()); let is_self_request = match request.uri().authority() { // Some SDKs require an authority, so we support e.g. http://self.alt/self-request @@ -596,7 +635,10 @@ impl RequestSender { .map(|body| body.map_err(hyper_request_error).boxed_unsync()); let span = tracing::Span::current(); - span.record("http.response.status_code", resp.status().as_u16()); + span.record( + otel_attribute::HTTP_RESPONSE_STATUS_CODE, + resp.status().as_u16(), + ); record_content_length_header(&span, resp.headers(), "http.response.header.content-length"); @@ -901,10 +943,10 @@ impl AsyncWrite for PermittedTcpStream { /// Translate a [`hyper::Error`] to a wasi-http `ErrorCode` in the context of a request. fn hyper_request_error(err: hyper::Error) -> ErrorCode { // If there's a source, we might be able to extract a wasi-http error from it. - if let Some(cause) = err.source() { - if let Some(err) = cause.downcast_ref::() { - return err.clone(); - } + if let Some(cause) = err.source() + && let Some(err) = cause.downcast_ref::() + { + return err.clone(); } tracing::warn!("hyper request error: {err:?}"); @@ -915,10 +957,10 @@ fn hyper_request_error(err: hyper::Error) -> ErrorCode { /// Translate a [`hyper_util::client::legacy::Error`] to a wasi-http `ErrorCode` in the context of a request. fn hyper_legacy_request_error(err: hyper_util::client::legacy::Error) -> ErrorCode { // If there's a source, we might be able to extract a wasi-http error from it. - if let Some(cause) = err.source() { - if let Some(err) = cause.downcast_ref::() { - return err.clone(); - } + if let Some(cause) = err.source() + && let Some(err) = cause.downcast_ref::() + { + return err.clone(); } tracing::warn!("hyper request error: {err:?}"); @@ -1140,9 +1182,9 @@ pub fn p3_to_p2_error_code(code: p3_types::ErrorCode) -> p2_types::ErrorCode { } fn record_content_length_header(span: &Span, headers: &HeaderMap, attr_name: &'static str) { - if let Some(content_length) = headers.get(CONTENT_LENGTH) { - if let Ok(size_str) = content_length.to_str() { - span.set_attribute(attr_name, size_str.to_string()); - } + if let Some(content_length) = headers.get(CONTENT_LENGTH) + && let Ok(size_str) = content_length.to_str() + { + span.set_attribute(attr_name, size_str.to_string()); } } diff --git a/crates/factor-outbound-http/src/wasi_2023_10_18.rs b/crates/factor-outbound-http/src/wasi_2023_10_18.rs index 5f04d298b4..52c3dfcfd9 100644 --- a/crates/factor-outbound-http/src/wasi_2023_10_18.rs +++ b/crates/factor-outbound-http/src/wasi_2023_10_18.rs @@ -1,7 +1,7 @@ use anyhow::Result; use wasmtime::component::{Linker, Resource}; -use wasmtime_wasi_http::p2::bindings as latest; use wasmtime_wasi_http::p2::WasiHttpCtxView; +use wasmtime_wasi_http::p2::bindings as latest; mod bindings { use super::latest; @@ -468,41 +468,39 @@ impl wasi::http::outgoing_handler::Host for WasiHttpCtxView<'_> { let options = latest::http::types::HostRequestOptions::new(self)?; let borrow = || Resource::new_borrow(request.rep()); - if let Some(ms) = connect_timeout_ms { - if let Err(()) = latest::http::types::HostRequestOptions::set_connect_timeout( + if let Some(ms) = connect_timeout_ms + && let Err(()) = latest::http::types::HostRequestOptions::set_connect_timeout( self, borrow(), Some(ms.into()), - )? { - latest::http::types::HostRequestOptions::drop(self, options)?; - wasmtime::bail!("invalid connect timeout supplied"); - } + )? + { + latest::http::types::HostRequestOptions::drop(self, options)?; + wasmtime::bail!("invalid connect timeout supplied"); } - if let Some(ms) = first_byte_timeout_ms { - if let Err(()) = + if let Some(ms) = first_byte_timeout_ms + && let Err(()) = latest::http::types::HostRequestOptions::set_first_byte_timeout( self, borrow(), Some(ms.into()), )? - { - latest::http::types::HostRequestOptions::drop(self, options)?; - wasmtime::bail!("invalid first byte timeout supplied"); - } + { + latest::http::types::HostRequestOptions::drop(self, options)?; + wasmtime::bail!("invalid first byte timeout supplied"); } - if let Some(ms) = between_bytes_timeout_ms { - if let Err(()) = + if let Some(ms) = between_bytes_timeout_ms + && let Err(()) = latest::http::types::HostRequestOptions::set_between_bytes_timeout( self, borrow(), Some(ms.into()), )? - { - latest::http::types::HostRequestOptions::drop(self, options)?; - wasmtime::bail!("invalid between bytes timeout supplied"); - } + { + latest::http::types::HostRequestOptions::drop(self, options)?; + wasmtime::bail!("invalid between bytes timeout supplied"); } Some(options) diff --git a/crates/factor-outbound-http/src/wasi_2023_11_10.rs b/crates/factor-outbound-http/src/wasi_2023_11_10.rs index f0e89800ff..caa5490a68 100644 --- a/crates/factor-outbound-http/src/wasi_2023_11_10.rs +++ b/crates/factor-outbound-http/src/wasi_2023_11_10.rs @@ -3,8 +3,8 @@ use super::wasi_2023_10_18::convert; use anyhow::Result; use wasmtime::component::{Linker, Resource}; -use wasmtime_wasi_http::p2::bindings as latest; use wasmtime_wasi_http::p2::WasiHttpCtxView; +use wasmtime_wasi_http::p2::bindings as latest; mod bindings { use super::latest; diff --git a/crates/factor-outbound-http/tests/factor_test.rs b/crates/factor-outbound-http/tests/factor_test.rs index a5585657ef..74b402e1ef 100644 --- a/crates/factor-outbound-http/tests/factor_test.rs +++ b/crates/factor-outbound-http/tests/factor_test.rs @@ -1,19 +1,33 @@ +use std::sync::{Arc, Mutex}; use std::time::Duration; use anyhow::bail; +use bytes::Bytes; use http::{Request, Uri}; +use http_body_util::{BodyExt, Empty, combinators::UnsyncBoxBody}; use spin_common::{assert_matches, assert_not_matches}; use spin_factor_outbound_http::{ - intercept::{InterceptOutcome, InterceptRequest, OutboundHttpInterceptor}, ErrorCode, HostFutureIncomingResponse, OutboundHttpFactor, SelfRequestOrigin, + intercept::{InterceptOutcome, InterceptRequest, OutboundHttpInterceptor}, }; use spin_factor_outbound_networking::OutboundNetworkingFactor; use spin_factor_variables::VariablesFactor; -use spin_factors::{anyhow, RuntimeFactors}; -use spin_factors_test::{toml, TestEnvironment}; +use spin_factors::{RuntimeFactors, anyhow}; +use spin_factors_test::{TestEnvironment, toml}; use spin_world::async_trait; +use tracing::{ + Subscriber, + field::{Field, Visit}, + span::{self, Record}, +}; +use tracing_subscriber::{ + Layer, + layer::{Context, SubscriberExt}, + registry::LookupSpan, +}; use wasmtime_wasi::p2::Pollable; use wasmtime_wasi_http::p2::types::OutgoingRequestConfig; +use wasmtime_wasi_http::p3::{RequestOptions, bindings::http::types as p3_types}; #[derive(RuntimeFactors)] struct TestFactors { @@ -176,3 +190,92 @@ fn assert_discard_prefix_error(future_resp: HostFutureIncomingResponse) { | ErrorCode::DnsError(_)), ); } + +// Regression: deferred `Span::record(...)` calls (e.g. `url.full`) must +// land on the `spin_outbound_http.send_request` span created by +// `#[instrument]`. Uses the current_thread runtime so the thread-local +// subscriber covers all async work. +#[tokio::test(flavor = "current_thread")] +async fn p3_send_request_propagates_span_to_async_work() -> anyhow::Result<()> { + let layer = CaptureLayer::default(); + let records = Arc::clone(&layer.records); + let subscriber = tracing_subscriber::registry().with(layer); + let _guard = tracing::subscriber::set_default(subscriber); + + let mut state = test_instance_state("https://*", true).await?; + let p3_view = OutboundHttpFactor::get_wasi_p3_http_impl(&mut state).unwrap(); + // [100::1] is the IPv6 discard prefix — connection fails fast. + let req = Request::get("https://[100::1]:443").body(empty_p3_body())?; + let result_fut = p3_view + .hooks + .send_request(req, fast_p3_options(), p3_noop_cleanup_fut()); + let _ = Box::into_pin(result_fut).await; + + let records = records.lock().unwrap(); + assert!( + records.iter().any(|(span, field)| { + span == "spin_outbound_http.send_request" && field == "url.full" + }), + "`url.full` missing from `spin_outbound_http.send_request` span — \ + async block likely lost its `.in_current_span()` wrapper. \ + Recorded: {records:?}" + ); + Ok(()) +} + +type CapturedRecords = Arc>>; + +#[derive(Default)] +struct CaptureLayer { + records: CapturedRecords, +} + +impl Layer for CaptureLayer +where + S: Subscriber + for<'a> LookupSpan<'a>, +{ + fn on_record(&self, id: &span::Id, values: &Record<'_>, ctx: Context<'_, S>) { + let span_name = ctx + .span(id) + .map(|s| s.name().to_string()) + .unwrap_or_default(); + let mut v = CaptureVisitor { + span_name: &span_name, + records: &self.records, + }; + values.record(&mut v); + } +} + +struct CaptureVisitor<'a> { + span_name: &'a str, + records: &'a CapturedRecords, +} + +impl Visit for CaptureVisitor<'_> { + fn record_debug(&mut self, f: &Field, _v: &dyn std::fmt::Debug) { + self.records + .lock() + .unwrap() + .push((self.span_name.to_string(), f.name().to_string())); + } +} + +fn empty_p3_body() -> UnsyncBoxBody { + Empty::::new() + .map_err(|never: std::convert::Infallible| match never {}) + .boxed_unsync() +} + +fn fast_p3_options() -> Option { + Some(RequestOptions { + connect_timeout: Some(Duration::from_millis(10)), + first_byte_timeout: Some(Duration::from_millis(10)), + between_bytes_timeout: Some(Duration::from_millis(10)), + }) +} + +fn p3_noop_cleanup_fut() +-> Box> + Send> { + Box::new(async { Ok(()) }) +} diff --git a/crates/factor-outbound-mqtt/Cargo.toml b/crates/factor-outbound-mqtt/Cargo.toml index 962b5efb0a..cfbe8b6406 100644 --- a/crates/factor-outbound-mqtt/Cargo.toml +++ b/crates/factor-outbound-mqtt/Cargo.toml @@ -6,7 +6,8 @@ edition = { workspace = true } [dependencies] anyhow = { workspace = true } -rumqttc = { version = "0.24", features = ["url"] } +# Upstream hasn't been updating dependencies: https://github.com/bytebeamio/rumqtt/issues/1046 +rumqttc = { git = "https://github.com/spinframework/rumqtt", rev = "65b7b39a70b12d1781acb61cc07f1f1b680e7643", default-features = false, features = ["use-rustls-no-provider", "url"] } spin-core = { path = "../core" } spin-factor-otel = { path = "../factor-otel" } spin-factor-outbound-networking = { path = "../factor-outbound-networking" } diff --git a/crates/factor-outbound-mqtt/src/host.rs b/crates/factor-outbound-mqtt/src/host.rs index f8f55e012b..51c6ac0972 100644 --- a/crates/factor-outbound-mqtt/src/host.rs +++ b/crates/factor-outbound-mqtt/src/host.rs @@ -9,9 +9,9 @@ use spin_factor_otel::OtelFactorState; use spin_factor_outbound_networking::config::allowed_hosts::OutboundAllowedHosts; use spin_world::spin::mqtt::mqtt as v3; use spin_world::v2::mqtt as v2; -use tracing::{instrument, Level}; +use tracing::{Level, instrument}; -use crate::{allowed_hosts::AllowedHostChecker, ClientCreator}; +use crate::{ClientCreator, allowed_hosts::AllowedHostChecker}; pub struct InstanceState { allowed_hosts: AllowedHostChecker, @@ -132,15 +132,13 @@ impl v3::HostConnectionWithStore for crate::MqttFactorData { ) .unwrap(); - let rsrc = accessor.with(|mut access| { + accessor.with(|mut access| { let host = access.get(); host.connections .push(client) .map(Resource::new_own) .map_err(|_| v3::Error::TooManyConnections) - }); - - rsrc + }) } #[instrument(name = "spin_outbound_mqtt.publish", skip(accessor, connection, payload), err(level = Level::INFO), diff --git a/crates/factor-outbound-mqtt/tests/factor_test.rs b/crates/factor-outbound-mqtt/tests/factor_test.rs index 2d26838ae9..ad14e946d1 100644 --- a/crates/factor-outbound-mqtt/tests/factor_test.rs +++ b/crates/factor-outbound-mqtt/tests/factor_test.rs @@ -1,13 +1,13 @@ use std::sync::Arc; use std::time::Duration; -use anyhow::{bail, Result}; +use anyhow::{Result, bail}; use spin_core::async_trait; use spin_factor_outbound_mqtt::{ClientCreator, MqttClient, OutboundMqttFactor}; use spin_factor_outbound_networking::OutboundNetworkingFactor; use spin_factor_variables::VariablesFactor; -use spin_factors::{anyhow, RuntimeFactors}; -use spin_factors_test::{toml, TestEnvironment}; +use spin_factors::{RuntimeFactors, anyhow}; +use spin_factors_test::{TestEnvironment, toml}; use spin_world::spin::mqtt::mqtt::{Error, Qos}; pub struct MockMqttClient {} diff --git a/crates/factor-outbound-mysql/Cargo.toml b/crates/factor-outbound-mysql/Cargo.toml index a42b540dad..af4f075330 100644 --- a/crates/factor-outbound-mysql/Cargo.toml +++ b/crates/factor-outbound-mysql/Cargo.toml @@ -12,6 +12,7 @@ anyhow = { workspace = true } futures = { workspace = true } # Removing default features for mysql_async to remove flate2/zlib feature mysql_async = { version = "0.35", default-features = false, features = [ + "minimal-rust", "native-tls-tls", ] } spin-core = { path = "../core" } @@ -19,6 +20,7 @@ spin-factor-otel = { path = "../factor-otel" } spin-factor-outbound-networking = { path = "../factor-outbound-networking" } spin-factors = { path = "../factors" } spin-resource-table = { path = "../table" } +spin-telemetry = { path = "../telemetry" } spin-world = { path = "../world" } tokio = { workspace = true, features = ["rt-multi-thread"] } tracing = { workspace = true } diff --git a/crates/factor-outbound-mysql/src/client.rs b/crates/factor-outbound-mysql/src/client.rs index b68816d0a3..3d82068bc2 100644 --- a/crates/factor-outbound-mysql/src/client.rs +++ b/crates/factor-outbound-mysql/src/client.rs @@ -1,10 +1,10 @@ use std::sync::Arc; -use anyhow::{anyhow, Result}; +use anyhow::{Result, anyhow}; use futures::stream::TryStreamExt as _; use mysql_async::consts::ColumnType; use mysql_async::prelude::{FromValue, Queryable as _}; -use mysql_async::{from_value_opt, Conn as MysqlClient, Opts, OptsBuilder, SslOpts}; +use mysql_async::{Conn as MysqlClient, Opts, OptsBuilder, SslOpts, from_value_opt}; use spin_core::async_trait; use spin_world::v2::mysql::{self as v2}; use spin_world::v2::rdbms_types::{ @@ -284,10 +284,12 @@ mod test { #[test] fn test_mysql_address_without_ssl_mode() { - assert!(build_opts("mysql://myuser:password@127.0.0.1/db") - .unwrap() - .ssl_opts() - .is_none()) + assert!( + build_opts("mysql://myuser:password@127.0.0.1/db") + .unwrap() + .ssl_opts() + .is_none() + ) } #[test] diff --git a/crates/factor-outbound-mysql/src/host.rs b/crates/factor-outbound-mysql/src/host.rs index a9395958ea..60a44be097 100644 --- a/crates/factor-outbound-mysql/src/host.rs +++ b/crates/factor-outbound-mysql/src/host.rs @@ -1,32 +1,46 @@ use anyhow::Result; use spin_core::wasmtime::component::Resource; +use spin_telemetry::traces::{self, Blame}; +use spin_world::MAX_HOST_BUFFERED_BYTES; use spin_world::v1::mysql as v1; use spin_world::v2::mysql::{self as v2, Connection}; use spin_world::v2::rdbms_types as v2_types; use spin_world::v2::rdbms_types::ParameterValue; -use spin_world::MAX_HOST_BUFFERED_BYTES; use tracing::field::Empty; -use tracing::{instrument, Level}; +use tracing::{Level, instrument}; -use crate::client::Client; use crate::InstanceState; +use crate::client::Client; impl InstanceState { async fn open_connection(&mut self, address: &str) -> Result, v2::Error> { + let client = C::build_client(address).await.map_err(|e| { + // The guest supplies the address and credentials; connection + // failures (wrong password, TLS error, unreachable host, etc.) + // are the guest's problem. + let err = v2::Error::ConnectionFailed(format!("{e:?}")); + traces::mark_as_error(&err, Some(Blame::Guest)); + err + })?; self.connections - .push( - C::build_client(address) - .await - .map_err(|e| v2::Error::ConnectionFailed(format!("{e:?}")))?, - ) - .map_err(|_| v2::Error::ConnectionFailed("too many connections".into())) + .push(client) + .map_err(|_| { + // The guest exceeded the host-imposed connection limit. + let err = v2::Error::ConnectionFailed("too many connections".into()); + traces::mark_as_error(&err, Some(Blame::Guest)); + err + }) .map(Resource::new_own) } async fn get_client(&mut self, connection: Resource) -> Result<&mut C, v2::Error> { - self.connections - .get_mut(connection.rep()) - .ok_or_else(|| v2::Error::ConnectionFailed("no connection found".into())) + self.connections.get_mut(connection.rep()).ok_or_else(|| { + // The connection table is managed entirely by the host, so a + // missing handle indicates a host-side bug, not a guest mistake. + let err = v2::Error::ConnectionFailed("no connection found".into()); + traces::mark_as_error(&err, Some(Blame::Host)); + err + }) } async fn is_address_allowed(&self, address: &str) -> Result { @@ -42,14 +56,18 @@ impl v2::HostConnection for InstanceState { self.otel.reparent_tracing_span(); spin_factor_outbound_networking::record_address_fields(&address); - if !self - .is_address_allowed(&address) - .await - .map_err(|e| v2::Error::Other(e.to_string()))? - { - return Err(v2::Error::ConnectionFailed(format!( - "address {address} is not permitted" - ))); + if !self.is_address_allowed(&address).await.map_err(|e| { + // The allow-list check infrastructure itself failed; that's a + // host problem, not anything the guest did wrong. + let err = v2::Error::Other(e.to_string()); + traces::mark_as_error(&err, Some(Blame::Host)); + err + })? { + // The check succeeded but returned false: the guest supplied an + // address that isn't on the allow list. + let err = v2::Error::ConnectionFailed(format!("address {address} is not permitted")); + traces::mark_as_error(&err, Some(Blame::Guest)); + return Err(err); } self.open_connection(&address).await } @@ -66,6 +84,7 @@ impl v2::HostConnection for InstanceState { .await? .execute(statement, params) .await + .map_err(track_db_error_on_span) } #[instrument(name = "spin_outbound_mysql.query", skip(self, connection, params), err(level = Level::INFO), fields(otel.kind = "client", db.system = "mysql", otel.name = statement))] @@ -80,6 +99,7 @@ impl v2::HostConnection for InstanceState { .await? .query(statement, params, MAX_HOST_BUFFERED_BYTES) .await + .map_err(track_db_error_on_span) } async fn drop(&mut self, connection: Resource) -> Result<()> { @@ -97,15 +117,16 @@ impl v2_types::Host for InstanceState { /// Delegate a function call to the v2::HostConnection implementation macro_rules! delegate { ($self:ident.$name:ident($address:expr, $($arg:expr),*)) => {{ - if !$self.is_address_allowed(&$address).await.map_err(|e| v2::Error::Other(e.to_string()))? { - return Err(v1::MysqlError::ConnectionFailed(format!( - "address {} is not permitted", $address - ))); + if !$self.is_address_allowed(&$address).await.map_err(|e| { + let err = v2::Error::Other(e.to_string()); + traces::mark_as_error(&err, Some(Blame::Host)); + err + })? { + let err = v2::Error::ConnectionFailed(format!("address {} is not permitted", $address)); + traces::mark_as_error(&err, Some(Blame::Guest)); + return Err(err.into()); } - let connection = match $self.open_connection(&$address).await { - Ok(c) => c, - Err(e) => return Err(e.into()), - }; + let connection = $self.open_connection(&$address).await?; ::$name($self, connection, $($arg),*) .await .map_err(Into::into) @@ -144,3 +165,22 @@ impl v1::Host for InstanceState { Ok(error) } } + +/// Only for actual DB client calls (execute/query). +/// Blame is inferred from the error variant returned by the DB driver. +fn track_db_error_on_span(err: v2::Error) -> v2::Error { + let blame = match &err { + // The guest brings their own database, so connection failures during + // execution (dropped connection, auth rejected mid-session, etc.) are + // the guest's problem, not the host's. + v2::Error::ConnectionFailed(_) => Blame::Guest, + v2::Error::BadParameter(_) => Blame::Guest, + v2::Error::QueryFailed(_) => Blame::Guest, + // The host is responsible for mapping DB wire types to WIT types; + // a conversion failure is a host-side limitation or bug. + v2::Error::ValueConversionFailed(_) => Blame::Host, + v2::Error::Other(_) => Blame::Host, + }; + traces::mark_as_error(&err, Some(blame)); + err +} diff --git a/crates/factor-outbound-mysql/src/lib.rs b/crates/factor-outbound-mysql/src/lib.rs index aec71e36e1..a7daebf8c6 100644 --- a/crates/factor-outbound-mysql/src/lib.rs +++ b/crates/factor-outbound-mysql/src/lib.rs @@ -5,7 +5,7 @@ use client::Client; use mysql_async::Conn as MysqlClient; use spin_factor_otel::OtelFactorState; use spin_factor_outbound_networking::{ - config::allowed_hosts::OutboundAllowedHosts, OutboundNetworkingFactor, + OutboundNetworkingFactor, config::allowed_hosts::OutboundAllowedHosts, }; use spin_factors::{Factor, FactorData, InitContext, RuntimeFactors, SelfInstanceBuilder}; use spin_world::v1::mysql as v1; diff --git a/crates/factor-outbound-mysql/tests/factor_test.rs b/crates/factor-outbound-mysql/tests/factor_test.rs index d9c6a5eb4a..0b0dd705a9 100644 --- a/crates/factor-outbound-mysql/tests/factor_test.rs +++ b/crates/factor-outbound-mysql/tests/factor_test.rs @@ -1,10 +1,10 @@ -use anyhow::{bail, Result}; -use spin_factor_outbound_mysql::client::Client; +use anyhow::{Result, bail}; use spin_factor_outbound_mysql::OutboundMysqlFactor; +use spin_factor_outbound_mysql::client::Client; use spin_factor_outbound_networking::OutboundNetworkingFactor; use spin_factor_variables::VariablesFactor; -use spin_factors::{anyhow, RuntimeFactors}; -use spin_factors_test::{toml, TestEnvironment}; +use spin_factors::{RuntimeFactors, anyhow}; +use spin_factors_test::{TestEnvironment, toml}; use spin_world::async_trait; use spin_world::v2::mysql::HostConnection; use spin_world::v2::mysql::{self as v2}; diff --git a/crates/factor-outbound-networking/Cargo.toml b/crates/factor-outbound-networking/Cargo.toml index 490efc81a9..f340c27093 100644 --- a/crates/factor-outbound-networking/Cargo.toml +++ b/crates/factor-outbound-networking/Cargo.toml @@ -21,8 +21,9 @@ spin-manifest = { path = "../manifest" } spin-outbound-networking-config = { path = "../outbound-networking-config" } spin-serde = { path = "../serde" } tracing = { workspace = true } +opentelemetry-semantic-conventions = { workspace = true } url = { workspace = true } -webpki-roots = "0.26" +webpki-root-certs = "1.0.7" [dev-dependencies] spin-factors-test = { path = "../factors-test" } diff --git a/crates/factor-outbound-networking/src/allowed_hosts.rs b/crates/factor-outbound-networking/src/allowed_hosts.rs index 0a736eb6bf..831b192d35 100644 --- a/crates/factor-outbound-networking/src/allowed_hosts.rs +++ b/crates/factor-outbound-networking/src/allowed_hosts.rs @@ -49,9 +49,9 @@ pub fn validate_service_chaining_for_components( let allowed_hosts = allowed_outbound_hosts(&component).context("failed to get allowed hosts")?; for host in allowed_hosts { // Templated URLs are not yet resolved at this point, so ignore unresolvable URIs - if let Ok(uri) = host.parse::() { - if let Some(chaining_target) = parse_service_chaining_target(&uri) { - if !retained_components.contains(&chaining_target.as_ref()) { + if let Ok(uri) = host.parse::() && + let Some(chaining_target) = parse_service_chaining_target(&uri) && + !retained_components.contains(&chaining_target.as_ref()) { if chaining_target == "*" { return Err(anyhow::anyhow!("Selected component '{}' cannot use wildcard service chaining: allowed_outbound_hosts = [\"http://*.spin.internal\"]", component.id())); } @@ -60,8 +60,6 @@ pub fn validate_service_chaining_for_components( component.id(), chaining_target )); } - } - } } } anyhow::Ok(()) diff --git a/crates/factor-outbound-networking/src/lib.rs b/crates/factor-outbound-networking/src/lib.rs index 7b16a18d51..5b20c46be3 100644 --- a/crates/factor-outbound-networking/src/lib.rs +++ b/crates/factor-outbound-networking/src/lib.rs @@ -5,11 +5,12 @@ mod tls; use std::{collections::HashMap, sync::Arc}; use futures_util::FutureExt as _; +use opentelemetry_semantic_conventions::attribute::SERVER_PORT; use spin_factor_variables::VariablesFactor; use spin_factor_wasi::{SocketAddrUse, WasiFactor}; use spin_factors::{ - anyhow::{self, Context}, ConfigureAppContext, Error, Factor, FactorInstanceBuilder, PrepareContext, RuntimeFactors, + anyhow::{self, Context}, }; use spin_outbound_networking_config::allowed_hosts::{DisallowedHostHandler, OutboundAllowedHosts}; use url::Url; @@ -227,8 +228,10 @@ impl FactorInstanceBuilder for InstanceBuilder { pub fn record_address_fields(address: &str) { if let Ok(url) = Url::parse(address) { let span = tracing::Span::current(); + // `db.address` and `db.namespace` are incubating in opentelemetry-semantic-conventions 0.28. + // Leaving as string literals to avoid enabling the semconv_experimental feature. span.record("db.address", url.host_str().unwrap_or_default()); - span.record("server.port", url.port().unwrap_or_default()); + span.record(SERVER_PORT, url.port().unwrap_or_default()); span.record("db.namespace", url.path().trim_start_matches('/')); } } diff --git a/crates/factor-outbound-networking/src/runtime_config.rs b/crates/factor-outbound-networking/src/runtime_config.rs index 5f30da10b3..887742febb 100644 --- a/crates/factor-outbound-networking/src/runtime_config.rs +++ b/crates/factor-outbound-networking/src/runtime_config.rs @@ -19,17 +19,31 @@ pub struct RuntimeConfig { pub struct ClientTlsRuntimeConfig { /// The component(s) this configuration applies to. pub components: Vec, + /// The host(s) this configuration applies to. pub hosts: Vec, - /// A set of CA certs that should be considered valid roots. - pub root_certificates: Vec>, - /// If true, the operating system's certificate store will be used for - /// root certificate verification via `rustls-platform-verifier`. + + /// If `true`, the operating system's certificate store will be used for + /// root certificate verification + /// [`rustls-platform-verifier`](rustls_platform_verifier). + /// + /// By default this is `true`. pub use_platform_roots: bool, - /// If true, the "standard" CA certs defined by `webpki-roots` crate will be - /// considered valid roots in addition to `root_certificates`. - /// Only used when `use_platform_roots` is false. + + /// If `true`, the "standard" CA certs in the + /// [`webpki-root-certs`](webpki_root_certs) crate will be considered valid + /// roots. + /// + /// By default this is `true`. pub use_webpki_roots: bool, + + /// A set of CA certs that should be considered valid roots. + /// + /// These will be used _in addition_ to roots enabled by + /// [`use_platform_roots`](Self::use_platform_roots) and + /// [`use_webpki_roots`](Self::use_webpki_roots). + pub root_certificates: Vec>, + /// A certificate and private key to be used as the client certificate for /// "mutual TLS" (mTLS). pub client_cert: Option, @@ -41,9 +55,8 @@ impl Default for ClientTlsRuntimeConfig { components: vec![], hosts: vec![], root_certificates: vec![], - // Use platform roots by default use_platform_roots: true, - use_webpki_roots: false, + use_webpki_roots: true, client_cert: None, } } diff --git a/crates/factor-outbound-networking/src/runtime_config/spin.rs b/crates/factor-outbound-networking/src/runtime_config/spin.rs index 83344d3f5b..f41c4a0d75 100644 --- a/crates/factor-outbound-networking/src/runtime_config/spin.rs +++ b/crates/factor-outbound-networking/src/runtime_config/spin.rs @@ -1,4 +1,4 @@ -use anyhow::{bail, ensure, Context}; +use anyhow::{Context, bail, ensure}; use ip_network::IpNetwork; use rustls_pki_types::pem::PemObject; use serde::{Deserialize, Deserializer}; diff --git a/crates/factor-outbound-networking/src/tls.rs b/crates/factor-outbound-networking/src/tls.rs index f15771b1e3..cd05c57a14 100644 --- a/crates/factor-outbound-networking/src/tls.rs +++ b/crates/factor-outbound-networking/src/tls.rs @@ -1,7 +1,6 @@ use std::{collections::HashMap, ops::Deref, sync::Arc}; -use anyhow::{ensure, Context}; -use rustls::client::danger::ServerCertVerifier; +use anyhow::{Context, ensure}; use crate::runtime_config::{ClientCertRuntimeConfig, ClientTlsRuntimeConfig}; @@ -38,9 +37,9 @@ impl TlsClientConfigs { "client TLS 'hosts' list may not be empty" ); let tls_client_config = TlsClientConfig::new( - root_certificates, - use_webpki_roots, use_platform_roots, + use_webpki_roots, + root_certificates, client_cert, ) .context("error building TLS client config")?; @@ -104,46 +103,37 @@ pub struct TlsClientConfig(Arc); impl TlsClientConfig { fn new( - root_certificates: Vec>, - use_webpki_roots: bool, use_platform_roots: bool, + use_webpki_roots: bool, + root_certificates: Vec>, client_cert: Option, ) -> anyhow::Result { - let builder = if use_platform_roots { - let crypto_provider = rustls::crypto::CryptoProvider::get_default() - .cloned() - .unwrap_or_else(|| Arc::new(rustls::crypto::ring::default_provider())); + anyhow::ensure!( + use_platform_roots || use_webpki_roots || !root_certificates.is_empty(), + "at least one of 'use_platform_roots', 'use_webpki_roots', or 'root_certificates' must be set" + ); - let verifier: Arc = if root_certificates.is_empty() { - Arc::new( - rustls_platform_verifier::Verifier::new(crypto_provider.clone()) - .context("failed to initialize platform certificate verifier")?, - ) - } else { - Arc::new( - rustls_platform_verifier::Verifier::new_with_extra_roots( - root_certificates, - crypto_provider.clone(), - ) - .context( - "failed to initialize platform certificate verifier with extra roots", - )?, - ) - }; + let mut extra_roots: Box> = Box::new(root_certificates.into_iter()); + if use_webpki_roots { + extra_roots = Box::new( + extra_roots.chain(webpki_root_certs::TLS_SERVER_ROOT_CERTS.iter().cloned()), + ); + } - rustls::ClientConfig::builder_with_provider(crypto_provider) - .with_safe_default_protocol_versions()? + let builder = rustls::ClientConfig::builder(); + let builder = if use_platform_roots { + let verifier = rustls_platform_verifier::Verifier::new_with_extra_roots( + extra_roots, + builder.crypto_provider().clone(), + ) + .context("failed to initialize platform certificate verifier")?; + builder .dangerous() - .with_custom_certificate_verifier(verifier) + .with_custom_certificate_verifier(Arc::new(verifier)) } else { let mut root_store = rustls::RootCertStore::empty(); - if use_webpki_roots { - root_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned()); - } - for cert in root_certificates { - root_store.add(cert)?; - } - rustls::ClientConfig::builder().with_root_certificates(root_store) + extra_roots.try_for_each(|cert| root_store.add(cert))?; + builder.with_root_certificates(root_store) }; let client_config = if let Some(ClientCertRuntimeConfig { @@ -174,7 +164,7 @@ impl Deref for TlsClientConfig { impl Default for TlsClientConfig { fn default() -> Self { - Self::new(vec![], false, true, None).expect("default client config should be valid") + Self::new(true, false, vec![], None).expect("default client config should be valid") } } @@ -195,7 +185,7 @@ mod tests { use std::path::Path; use anyhow::Context; - use rustls_pki_types::{pem::PemObject, CertificateDer, PrivateKeyDer}; + use rustls_pki_types::{CertificateDer, PrivateKeyDer, pem::PemObject}; use super::*; @@ -214,7 +204,7 @@ mod tests { hosts: vec!["test-host".into()], root_certificates: vec![], use_platform_roots: false, - use_webpki_roots: false, + use_webpki_roots: true, client_cert: None, }])?; let config = configs.get_tls_client_config("test-component", "test-host"); diff --git a/crates/factor-outbound-networking/tests/factor_test.rs b/crates/factor-outbound-networking/tests/factor_test.rs index 4918d70ff4..ce7f0bd479 100644 --- a/crates/factor-outbound-networking/tests/factor_test.rs +++ b/crates/factor-outbound-networking/tests/factor_test.rs @@ -1,9 +1,9 @@ -use spin_factor_outbound_networking::runtime_config::spin::SpinRuntimeConfig; use spin_factor_outbound_networking::OutboundNetworkingFactor; +use spin_factor_outbound_networking::runtime_config::spin::SpinRuntimeConfig; use spin_factor_variables::VariablesFactor; use spin_factor_wasi::{DummyFilesMounter, WasiFactor}; -use spin_factors::{anyhow, RuntimeFactors}; -use spin_factors_test::{toml, TestEnvironment}; +use spin_factors::{RuntimeFactors, anyhow}; +use spin_factors_test::{TestEnvironment, toml}; use wasmtime_wasi::p2::bindings::sockets::instance_network::Host; use wasmtime_wasi::sockets::SocketAddrUse; diff --git a/crates/factor-outbound-pg/Cargo.toml b/crates/factor-outbound-pg/Cargo.toml index 4ad764814a..45dcc7a22f 100644 --- a/crates/factor-outbound-pg/Cargo.toml +++ b/crates/factor-outbound-pg/Cargo.toml @@ -24,6 +24,7 @@ spin-factor-outbound-networking = { path = "../factor-outbound-networking" } spin-factors = { path = "../factors" } spin-locked-app = { path = "../locked-app" } spin-resource-table = { path = "../table" } +spin-telemetry = { path = "../telemetry" } spin-wasi-async = { path = "../wasi-async" } spin-world = { path = "../world" } tokio = { workspace = true, features = ["rt-multi-thread"] } diff --git a/crates/factor-outbound-pg/src/client.rs b/crates/factor-outbound-pg/src/client.rs index 9f9dd80d9a..c5620dac31 100644 --- a/crates/factor-outbound-pg/src/client.rs +++ b/crates/factor-outbound-pg/src/client.rs @@ -330,7 +330,7 @@ impl PooledTokioClient { ) -> Result< ( impl std::future::Future>, - impl futures::Stream, v4::Error>>, + impl futures::Stream, v4::Error>> + 'static, ), v4::Error, > { @@ -351,11 +351,11 @@ impl PooledTokioClient { let row_stm = results.enumerate().map(move |(index, row_res)| { let row_res = row_res.map_err(query_failed); row_res.and_then(|r| { - if index == 0 { - if let Some(cols_tx) = cols_tx_opt.take() { - let cols = infer_columns(&r); - _ = cols_tx.send(cols); - } + if index == 0 + && let Some(cols_tx) = cols_tx_opt.take() + { + let cols = infer_columns(&r); + _ = cols_tx.send(cols); } convert_row(&r).map_err(query_failed_anyhow) }) diff --git a/crates/factor-outbound-pg/src/host.rs b/crates/factor-outbound-pg/src/host.rs index 93ad15f361..5faf7663a8 100644 --- a/crates/factor-outbound-pg/src/host.rs +++ b/crates/factor-outbound-pg/src/host.rs @@ -2,20 +2,21 @@ use anyhow::Result; use spin_core::wasmtime::component::{Accessor, FutureReader, Resource, StreamReader}; +use spin_telemetry::traces::{self, Blame}; +use spin_world::MAX_HOST_BUFFERED_BYTES; use spin_world::spin::postgres3_0_0::postgres::{self as v3}; use spin_world::spin::postgres4_2_0::postgres::{self as v4}; use spin_world::v1::postgres as v1; use spin_world::v1::rdbms_types as v1_types; use spin_world::v2::postgres::{self as v2}; use spin_world::v2::rdbms_types as v2_types; -use spin_world::MAX_HOST_BUFFERED_BYTES; +use tracing::Level; use tracing::field::Empty; use tracing::instrument; -use tracing::Level; +use crate::InstanceState; use crate::allowed_hosts::AllowedHostChecker; use crate::client::{Client, ClientFactory, HashableCertificate, QueryAsyncResult}; -use crate::InstanceState; impl InstanceState { async fn open_connection( @@ -23,14 +24,26 @@ impl InstanceState { address: &str, root_ca: Option, ) -> Result, v4::Error> { + let client = self + .client_factory + .get_client(address, root_ca) + .await + .map_err(|e| { + // The guest supplies the address and credentials; connection + // failures (wrong password, TLS error, unreachable host, etc.) + // are the guest's problem. + let err = v4::Error::ConnectionFailed(format!("{e:?}")); + traces::mark_as_error(&err, Some(Blame::Guest)); + err + })?; self.connections - .push( - self.client_factory - .get_client(address, root_ca) - .await - .map_err(|e| v4::Error::ConnectionFailed(format!("{e:?}")))?, - ) - .map_err(|_| v4::Error::ConnectionFailed("too many connections".into())) + .push(client) + .map_err(|_| { + // The guest exceeded the host-imposed connection limit. + let err = v4::Error::ConnectionFailed("too many connections".into()); + traces::mark_as_error(&err, Some(Blame::Guest)); + err + }) .map(Resource::new_own) } @@ -38,9 +51,13 @@ impl InstanceState { &self, connection: Resource, ) -> Result<&CF::Client, v4::Error> { - self.connections - .get(connection.rep()) - .ok_or_else(|| v4::Error::ConnectionFailed("no connection found".into())) + self.connections.get(connection.rep()).ok_or_else(|| { + // The connection table is managed entirely by the host, so a + // missing handle indicates a host-side bug, not a guest mistake. + let err = v4::Error::ConnectionFailed("no connection found".into()); + traces::mark_as_error(&err, Some(Blame::Host)); + err + }) } fn allowed_host_checker(&self) -> AllowedHostChecker { @@ -70,9 +87,14 @@ impl v3::HostConnection for InstanceState { async fn open(&mut self, address: String) -> Result, v3::Error> { spin_factor_outbound_networking::record_address_fields(&address); - self.ensure_address_allowed(&address).await?; + self.ensure_address_allowed(&address) + .await + .map_err(v3::Error::from) + .map_err(track_address_check_error_v3)?; - Ok(self.open_connection(&address, None).await?) + self.open_connection(&address, None) + .await + .map_err(v3::Error::from) } #[instrument(name = "spin_outbound_pg.execute", skip(self, connection, params), err(level = Level::INFO), fields(otel.kind = "client", db.system = "postgresql", otel.name = statement))] @@ -82,11 +104,13 @@ impl v3::HostConnection for InstanceState { statement: String, params: Vec, ) -> Result { - Ok(self - .get_client(connection) - .await? + self.get_client(connection) + .await + .map_err(v3::Error::from)? .execute(statement, v3_params_to_v4(params)) - .await?) + .await + .map_err(v3::Error::from) + .map_err(track_db_error_on_span_v3) } #[instrument(name = "spin_outbound_pg.query", skip(self, connection, params), err(level = Level::INFO), fields(otel.kind = "client", db.system = "postgresql", otel.name = statement))] @@ -96,12 +120,15 @@ impl v3::HostConnection for InstanceState { statement: String, params: Vec, ) -> Result { - Ok(self + let rowset = self .get_client(connection) - .await? + .await + .map_err(v3::Error::from)? .query(statement, v3_params_to_v4(params), MAX_HOST_BUFFERED_BYTES) - .await? - .into()) + .await + .map_err(v3::Error::from) + .map_err(track_db_error_on_span_v3)?; + Ok(rowset.into()) } async fn drop(&mut self, connection: Resource) -> anyhow::Result<()> { @@ -134,12 +161,16 @@ impl v4::HostConnectionBuilder for InstanceState { self_: Resource, certificate: String, ) -> Result<(), v4::Error> { - let root_ca = HashableCertificate::from_pem(&certificate) - .map_err(|e| v4::Error::Other(format!("invalid root certificate: {e}")))?; - let builder = self - .builders - .get_mut(self_.rep()) - .ok_or_else(|| v4::Error::ConnectionFailed("no builder found".into()))?; + let root_ca = HashableCertificate::from_pem(&certificate).map_err(|e| { + let err = v4::Error::Other(format!("invalid root certificate: {e}")); + traces::mark_as_error(&err, Some(Blame::Guest)); + err + })?; + let builder = self.builders.get_mut(self_.rep()).ok_or_else(|| { + let err = v4::Error::ConnectionFailed("no builder found".into()); + traces::mark_as_error(&err, Some(Blame::Host)); + err + })?; builder.root_ca = Some(root_ca); Ok(()) } @@ -163,7 +194,9 @@ impl v4::HostConnection for InstanceState { async fn open(&mut self, address: String) -> Result, v4::Error> { spin_factor_outbound_networking::record_address_fields(&address); - self.ensure_address_allowed(&address).await?; + self.ensure_address_allowed(&address) + .await + .map_err(track_address_check_error_v4)?; self.open_connection(&address, None).await } @@ -179,6 +212,7 @@ impl v4::HostConnection for InstanceState { .await? .execute(statement, params) .await + .map_err(track_db_error_on_span_v4) } #[instrument(name = "spin_outbound_pg.query", skip(self, connection, params), err(level = Level::INFO), fields(otel.kind = "client", db.system = "postgresql", otel.name = statement))] @@ -192,6 +226,7 @@ impl v4::HostConnection for InstanceState { .await? .query(statement, params, MAX_HOST_BUFFERED_BYTES) .await + .map_err(track_db_error_on_span_v4) } async fn drop(&mut self, connection: Resource) -> anyhow::Result<()> { @@ -210,7 +245,9 @@ impl spin_world::spin::postgres4_2_0::postgres::HostConnectio ) -> Result, v4::Error> { spin_factor_outbound_networking::record_address_fields(&address); - Self::ensure_address_allowed_async(accessor, &address).await?; + Self::ensure_address_allowed_async(accessor, &address) + .await + .map_err(track_address_check_error_v4)?; Self::open_connection_async(accessor, &address, None).await } @@ -226,7 +263,10 @@ impl spin_world::spin::postgres4_2_0::postgres::HostConnectio host.connections.get(connection.rep()).unwrap().clone() }); - client.execute(statement, params).await + client + .execute(statement, params) + .await + .map_err(track_db_error_on_span_v4) } #[allow(clippy::type_complexity)] // blame bindgen, clippy, blame bindgen @@ -255,7 +295,8 @@ impl spin_world::spin::postgres4_2_0::postgres::HostConnectio error, } = client .query_async(statement, params, MAX_HOST_BUFFERED_BYTES) - .await?; + .await + .map_err(track_db_error_on_span_v4)?; let row_producer = spin_wasi_async::stream::producer(rows); @@ -265,7 +306,13 @@ impl spin_world::spin::postgres4_2_0::postgres::HostConnectio let efr = FutureReader::new(&mut access, error)?; anyhow::Ok((sr, efr)) }) - .map_err(|e| v4::Error::Other(e.to_string()))?; + .map_err(|e| { + // Setting up the async stream/future channels is a host + // implementation detail; if it fails, that's a host bug. + let err = v4::Error::Other(e.to_string()); + traces::mark_as_error(&err, Some(Blame::Host)); + err + })?; Ok((columns, sr, efr)) } @@ -277,10 +324,11 @@ impl InstanceState { &mut self, builder_rep: u32, ) -> Result<(String, Option), v4::Error> { - let builder = self - .builders - .get_mut(builder_rep) - .ok_or_else(|| v4::Error::ConnectionFailed("no builder found".into()))?; + let builder = self.builders.get_mut(builder_rep).ok_or_else(|| { + let err = v4::Error::ConnectionFailed("no builder found".into()); + traces::mark_as_error(&err, Some(Blame::Host)); + err + })?; let address = builder.address.clone(); let root_ca = builder.root_ca.clone(); @@ -325,20 +373,23 @@ impl crate::PgFactorData { host.client_factory.clone() }); - let client = cf - .get_client(address, root_ca) - .await - .map_err(|e| v4::Error::ConnectionFailed(format!("{e:?}")))?; + let client = cf.get_client(address, root_ca).await.map_err(|e| { + let err = v4::Error::ConnectionFailed(format!("{e:?}")); + traces::mark_as_error(&err, Some(Blame::Guest)); + err + })?; - let rsrc = accessor.with(|mut access| { + accessor.with(|mut access| { let host = access.get(); host.connections .push(client) - .map_err(|_| v4::Error::ConnectionFailed("too many connections".into())) + .map_err(|_| { + let err = v4::Error::ConnectionFailed("too many connections".into()); + traces::mark_as_error(&err, Some(Blame::Guest)); + err + }) .map(Resource::new_own) - }); - - rsrc + }) } } @@ -353,7 +404,9 @@ impl spin_world::spin::postgres4_2_0::postgres::HostConnectio spin_factor_outbound_networking::record_address_fields(&address); - Self::ensure_address_allowed_async(accessor, &address).await?; + Self::ensure_address_allowed_async(accessor, &address) + .await + .map_err(track_address_check_error_v4)?; Self::open_connection_async(accessor, &address, root_ca).await } } @@ -398,8 +451,13 @@ impl v2::HostConnection for InstanceState { self.otel.reparent_tracing_span(); spin_factor_outbound_networking::record_address_fields(&address); - self.ensure_address_allowed(&address).await?; - Ok(self.open_connection(&address, None).await?) + self.ensure_address_allowed(&address) + .await + .map_err(v2::Error::from) + .map_err(track_address_check_error_v2)?; + self.open_connection(&address, None) + .await + .map_err(v2::Error::from) } #[instrument(name = "spin_outbound_pg.execute", skip(self, connection, params), err(level = Level::INFO), fields(otel.kind = "client", db.system = "postgresql", otel.name = statement))] @@ -410,11 +468,16 @@ impl v2::HostConnection for InstanceState { params: Vec, ) -> Result { self.otel.reparent_tracing_span(); - Ok(self - .get_client(connection) - .await? - .execute(statement, v2_params_to_v3(params)?) - .await?) + let params = v2_params_to_v3(params).inspect_err(|e| { + traces::mark_as_error(e, Some(Blame::Guest)); + })?; + self.get_client(connection) + .await + .map_err(v2::Error::from)? + .execute(statement, params) + .await + .map_err(v2::Error::from) + .map_err(track_db_error_on_span_v2) } #[instrument(name = "spin_outbound_pg.query", skip(self, connection, params), err(level = Level::INFO), fields(otel.kind = "client", db.system = "postgresql", otel.name = statement))] @@ -425,11 +488,17 @@ impl v2::HostConnection for InstanceState { params: Vec, ) -> Result { self.otel.reparent_tracing_span(); + let params = v2_params_to_v3(params).inspect_err(|e| { + traces::mark_as_error(e, Some(Blame::Guest)); + })?; Ok(self .get_client(connection) - .await? - .query(statement, v2_params_to_v3(params)?, MAX_HOST_BUFFERED_BYTES) - .await? + .await + .map_err(v2::Error::from)? + .query(statement, params, MAX_HOST_BUFFERED_BYTES) + .await + .map_err(v2::Error::from) + .map_err(track_db_error_on_span_v2)? .into()) } @@ -446,14 +515,16 @@ impl v1::Host for InstanceState { statement: String, params: Vec, ) -> Result { - delegate!(self.execute( - address, - statement, - params - .into_iter() - .map(TryInto::try_into) - .collect::, _>>()? - )) + delegate!( + self.execute( + address, + statement, + params + .into_iter() + .map(TryInto::try_into) + .collect::, _>>()? + ) + ) } async fn query( @@ -462,14 +533,16 @@ impl v1::Host for InstanceState { statement: String, params: Vec, ) -> Result { - delegate!(self.query( - address, - statement, - params - .into_iter() - .map(TryInto::try_into) - .collect::, _>>()? - )) + delegate!( + self.query( + address, + statement, + params + .into_iter() + .map(TryInto::try_into) + .collect::, _>>()? + ) + ) .map(Into::into) } @@ -477,3 +550,77 @@ impl v1::Host for InstanceState { Ok(error) } } + +/// Mark errors from `ensure_address_allowed` on the current span. +/// +/// Address check errors where the check infrastructure itself fails (`Other`) are Host-blamed. +/// All other errors (address not permitted, malformed address, unsupported socket type) are +/// Guest-blamed since the guest supplied the address. +fn track_address_check_error_v4(err: v4::Error) -> v4::Error { + let blame = match &err { + v4::Error::Other(_) => Blame::Host, + _ => Blame::Guest, + }; + traces::mark_as_error(&err, Some(blame)); + err +} + +fn track_address_check_error_v3(err: v3::Error) -> v3::Error { + let blame = match &err { + v3::Error::Other(_) => Blame::Host, + _ => Blame::Guest, + }; + traces::mark_as_error(&err, Some(blame)); + err +} + +fn track_address_check_error_v2(err: v2::Error) -> v2::Error { + let blame = match &err { + v2::Error::Other(_) => Blame::Host, + _ => Blame::Guest, + }; + traces::mark_as_error(&err, Some(blame)); + err +} + +/// Mark errors from actual DB client calls (execute/query) on the current span. +fn track_db_error_on_span_v4(err: v4::Error) -> v4::Error { + let blame = match &err { + // The guest brings their own database, so connection failures during + // execution (dropped connection, auth rejected mid-session, etc.) are + // the guest's problem, not the host's. + v4::Error::ConnectionFailed(_) => Blame::Guest, + v4::Error::BadParameter(_) => Blame::Guest, + v4::Error::QueryFailed(_) => Blame::Guest, + // The host is responsible for mapping DB wire types to WIT types; + // a conversion failure is a host-side limitation or bug. + v4::Error::ValueConversionFailed(_) => Blame::Host, + v4::Error::Other(_) => Blame::Host, + }; + traces::mark_as_error(&err, Some(blame)); + err +} + +fn track_db_error_on_span_v3(err: v3::Error) -> v3::Error { + let blame = match &err { + v3::Error::ConnectionFailed(_) => Blame::Guest, + v3::Error::BadParameter(_) => Blame::Guest, + v3::Error::QueryFailed(_) => Blame::Guest, + v3::Error::ValueConversionFailed(_) => Blame::Host, + v3::Error::Other(_) => Blame::Host, + }; + traces::mark_as_error(&err, Some(blame)); + err +} + +fn track_db_error_on_span_v2(err: v2::Error) -> v2::Error { + let blame = match &err { + v2::Error::ConnectionFailed(_) => Blame::Guest, + v2::Error::BadParameter(_) => Blame::Guest, + v2::Error::QueryFailed(_) => Blame::Guest, + v2::Error::ValueConversionFailed(_) => Blame::Host, + v2::Error::Other(_) => Blame::Host, + }; + traces::mark_as_error(&err, Some(blame)); + err +} diff --git a/crates/factor-outbound-pg/src/lib.rs b/crates/factor-outbound-pg/src/lib.rs index aae1c24439..d20cfe492f 100644 --- a/crates/factor-outbound-pg/src/lib.rs +++ b/crates/factor-outbound-pg/src/lib.rs @@ -10,7 +10,7 @@ use client::ClientFactory; use spin_factor_otel::OtelFactorState; use spin_factor_outbound_networking::OutboundNetworkingFactor; use spin_factors::{ - anyhow, ConfigureAppContext, Factor, PrepareContext, RuntimeFactors, SelfInstanceBuilder, + ConfigureAppContext, Factor, PrepareContext, RuntimeFactors, SelfInstanceBuilder, anyhow, }; pub struct OutboundPgFactor { diff --git a/crates/factor-outbound-pg/src/types.rs b/crates/factor-outbound-pg/src/types.rs index d8449c2d0f..d030946dfc 100644 --- a/crates/factor-outbound-pg/src/types.rs +++ b/crates/factor-outbound-pg/src/types.rs @@ -1,7 +1,7 @@ use anyhow::Result; use spin_world::spin::postgres4_2_0::postgres::{self as v4, DbDataType, DbValue, ParameterValue}; use tokio_postgres::types::{FromSql, Type}; -use tokio_postgres::{types::ToSql, Row}; +use tokio_postgres::{Row, types::ToSql}; mod convert; mod decimal; diff --git a/crates/factor-outbound-pg/src/types/convert.rs b/crates/factor-outbound-pg/src/types/convert.rs index 303cc53b13..984bf3f3ba 100644 --- a/crates/factor-outbound-pg/src/types/convert.rs +++ b/crates/factor-outbound-pg/src/types/convert.rs @@ -1,7 +1,7 @@ //! Conversions between WIT representations and the SQL types as surfaced by //! the tokio_postgres driver. -use anyhow::{anyhow, Context}; +use anyhow::{Context, anyhow}; use spin_world::spin::postgres4_2_0::postgres::{self as v4}; use super::decimal::RangeableDecimal; diff --git a/crates/factor-outbound-pg/tests/factor_test.rs b/crates/factor-outbound-pg/tests/factor_test.rs index eeafb5bfaf..0ff2fef530 100644 --- a/crates/factor-outbound-pg/tests/factor_test.rs +++ b/crates/factor-outbound-pg/tests/factor_test.rs @@ -1,13 +1,13 @@ -use anyhow::{bail, Result}; +use anyhow::{Result, bail}; use spin_factor_outbound_networking::OutboundNetworkingFactor; +use spin_factor_outbound_pg::OutboundPgFactor; use spin_factor_outbound_pg::client::Client; use spin_factor_outbound_pg::client::ClientFactory; use spin_factor_outbound_pg::client::HashableCertificate; use spin_factor_outbound_pg::client::QueryAsyncResult; -use spin_factor_outbound_pg::OutboundPgFactor; use spin_factor_variables::VariablesFactor; -use spin_factors::{anyhow, RuntimeFactors}; -use spin_factors_test::{toml, TestEnvironment}; +use spin_factors::{RuntimeFactors, anyhow}; +use spin_factors_test::{TestEnvironment, toml}; use spin_world::async_trait; use spin_world::spin::postgres4_2_0::postgres::Error as PgError; use spin_world::spin::postgres4_2_0::postgres::HostConnection; diff --git a/crates/factor-outbound-redis/src/host.rs b/crates/factor-outbound-redis/src/host.rs index 366b36b02e..61fd05708b 100644 --- a/crates/factor-outbound-redis/src/host.rs +++ b/crates/factor-outbound-redis/src/host.rs @@ -1,18 +1,18 @@ use std::net::SocketAddr; use anyhow::Result; -use redis::io::AsyncDNSResolver; use redis::AsyncConnectionConfig; -use redis::{aio::MultiplexedConnection, AsyncCommands, FromRedisValue, Value}; +use redis::io::AsyncDNSResolver; +use redis::{AsyncCommands, FromRedisValue, Value, aio::MultiplexedConnection}; use spin_core::wasmtime::component::{Accessor, Resource}; use spin_factor_otel::OtelFactorState; use spin_factor_outbound_networking::config::blocked_networks::BlockedNetworks; +use spin_world::MAX_HOST_BUFFERED_BYTES; use spin_world::spin::redis::redis as v3; use spin_world::v1::{redis as v1, redis_types}; use spin_world::v2::redis as v2; -use spin_world::MAX_HOST_BUFFERED_BYTES; use tracing::field::Empty; -use tracing::{instrument, Level}; +use tracing::{Level, instrument}; use crate::allowed_hosts::AllowedHostChecker; @@ -254,15 +254,13 @@ impl v3::HostConnectionWithStore for crate::RedisFactorData { .await .map_err(other_error_v3)?; - let rsrc = accessor.with(|mut access| { + accessor.with(|mut access| { let host = access.get(); host.connections .push(conn) .map(Resource::new_own) .map_err(|_| v3::Error::TooManyConnections) - }); - - rsrc + }) } #[instrument(name = "spin_outbound_redis.publish", skip(accessor, connection, payload), err(level = Level::INFO), fields(otel.kind = "client", db.system = "redis", otel.name = format!("PUBLISH {}", channel)))] diff --git a/crates/factor-outbound-redis/src/lib.rs b/crates/factor-outbound-redis/src/lib.rs index 0eab8a4751..494c5ca800 100644 --- a/crates/factor-outbound-redis/src/lib.rs +++ b/crates/factor-outbound-redis/src/lib.rs @@ -5,8 +5,8 @@ use host::InstanceState; use spin_factor_otel::OtelFactorState; use spin_factor_outbound_networking::OutboundNetworkingFactor; use spin_factors::{ - anyhow, ConfigureAppContext, Factor, FactorData, PrepareContext, RuntimeFactors, - SelfInstanceBuilder, + ConfigureAppContext, Factor, FactorData, PrepareContext, RuntimeFactors, SelfInstanceBuilder, + anyhow, }; use spin_world::spin::redis::redis as v3; diff --git a/crates/factor-outbound-redis/tests/factor_test.rs b/crates/factor-outbound-redis/tests/factor_test.rs index 6f2f7b051a..ccd1eb275d 100644 --- a/crates/factor-outbound-redis/tests/factor_test.rs +++ b/crates/factor-outbound-redis/tests/factor_test.rs @@ -2,8 +2,8 @@ use anyhow::bail; use spin_factor_outbound_networking::OutboundNetworkingFactor; use spin_factor_outbound_redis::OutboundRedisFactor; use spin_factor_variables::VariablesFactor; -use spin_factors::{anyhow, RuntimeFactors}; -use spin_factors_test::{toml, TestEnvironment}; +use spin_factors::{RuntimeFactors, anyhow}; +use spin_factors_test::{TestEnvironment, toml}; use spin_world::v2::redis::{Error, HostConnection}; #[derive(RuntimeFactors)] diff --git a/crates/factor-sqlite/src/host.rs b/crates/factor-sqlite/src/host.rs index c2e0796e13..502e6e2af1 100644 --- a/crates/factor-sqlite/src/host.rs +++ b/crates/factor-sqlite/src/host.rs @@ -4,13 +4,13 @@ use std::sync::Arc; use spin_core::wasmtime::component::{Accessor, FutureReader, StreamReader}; use spin_factor_otel::OtelFactorState; use spin_factors::wasmtime::component::Resource; -use spin_factors::{anyhow, SelfInstanceBuilder}; +use spin_factors::{SelfInstanceBuilder, anyhow}; +use spin_world::MAX_HOST_BUFFERED_BYTES; use spin_world::spin::sqlite3_1_0::sqlite as v3; use spin_world::v1::sqlite as v1; use spin_world::v2::sqlite as v2; -use spin_world::MAX_HOST_BUFFERED_BYTES; use tracing::field::Empty; -use tracing::{instrument, Level}; +use tracing::{Level, instrument}; use crate::{Connection, ConnectionCreator, QueryAsyncResult}; @@ -174,15 +174,13 @@ impl v3::HostConnectionWithStore for crate::SqliteFactorData { conn.summary().as_deref().unwrap_or("unknown"), ); - let resource = accessor.with(|mut access| { + accessor.with(|mut access| { let host = access.get(); host.connections .push(conn) .map_err(|()| v3::Error::Io("too many connections opened".to_string())) .map(Resource::new_own) - }); - - resource + }) } async fn execute_async( diff --git a/crates/factor-sqlite/src/lib.rs b/crates/factor-sqlite/src/lib.rs index 1ecabc22b9..44e921f897 100644 --- a/crates/factor-sqlite/src/lib.rs +++ b/crates/factor-sqlite/src/lib.rs @@ -8,7 +8,7 @@ use host::InstanceState; use async_trait::async_trait; use spin_factor_otel::OtelFactorState; -use spin_factors::{anyhow, Factor}; +use spin_factors::{Factor, anyhow}; use spin_locked_app::MetadataKey; use spin_world::spin::sqlite3_1_0::sqlite as v3; use spin_world::v1::sqlite as v1; diff --git a/crates/factor-sqlite/tests/factor_test.rs b/crates/factor-sqlite/tests/factor_test.rs index a0e9d95898..a2a2d6fbea 100644 --- a/crates/factor-sqlite/tests/factor_test.rs +++ b/crates/factor-sqlite/tests/factor_test.rs @@ -5,10 +5,10 @@ use std::{ use spin_factor_sqlite::{QueryAsyncResult, RuntimeConfig, SqliteFactor}; use spin_factors::{ - anyhow::{self, bail, Context as _}, RuntimeFactors, + anyhow::{self, Context as _, bail}, }; -use spin_factors_test::{toml, TestEnvironment}; +use spin_factors_test::{TestEnvironment, toml}; use spin_world::{async_trait, spin::sqlite3_1_0::sqlite as v3, v2::sqlite as v2}; use v2::HostConnection as _; @@ -31,9 +31,10 @@ async fn errors_when_non_configured_database_used() -> anyhow::Result<()> { bail!("Expected build_instance_state to error but it did not"); }; - assert!(err - .to_string() - .contains("One or more components use SQLite databases which are not defined.")); + assert!( + err.to_string() + .contains("One or more components use SQLite databases which are not defined.") + ); Ok(()) } diff --git a/crates/factor-variables/src/lib.rs b/crates/factor-variables/src/lib.rs index 072237cf4b..d87af02ca3 100644 --- a/crates/factor-variables/src/lib.rs +++ b/crates/factor-variables/src/lib.rs @@ -7,8 +7,8 @@ use runtime_config::RuntimeConfig; use spin_expressions::{ProviderResolver as ExpressionResolver, Template}; use spin_factor_otel::OtelFactorState; use spin_factors::{ - anyhow, ConfigureAppContext, Factor, FactorData, InitContext, PrepareContext, RuntimeFactors, - SelfInstanceBuilder, + ConfigureAppContext, Factor, FactorData, InitContext, PrepareContext, RuntimeFactors, + SelfInstanceBuilder, anyhow, }; use spin_world::spin::variables::variables as v3; diff --git a/crates/factor-variables/tests/factor_test.rs b/crates/factor-variables/tests/factor_test.rs index b7fee44697..51b2c98034 100644 --- a/crates/factor-variables/tests/factor_test.rs +++ b/crates/factor-variables/tests/factor_test.rs @@ -1,7 +1,7 @@ use spin_expressions::{Key, Provider}; -use spin_factor_variables::{runtime_config::RuntimeConfig, VariablesFactor}; -use spin_factors::{anyhow, RuntimeFactors}; -use spin_factors_test::{toml, TestEnvironment}; +use spin_factor_variables::{VariablesFactor, runtime_config::RuntimeConfig}; +use spin_factors::{RuntimeFactors, anyhow}; +use spin_factors_test::{TestEnvironment, toml}; use spin_world::v2::variables::Host; #[derive(RuntimeFactors)] diff --git a/crates/factor-wasi/src/lib.rs b/crates/factor-wasi/src/lib.rs index dddc3b6ebe..f86332764b 100644 --- a/crates/factor-wasi/src/lib.rs +++ b/crates/factor-wasi/src/lib.rs @@ -12,8 +12,8 @@ use std::{ use io::{PipeReadStream, PipedWriteStream}; use spin_factors::{ - anyhow, AppComponent, Factor, FactorInstanceBuilder, InitContext, PrepareContext, - RuntimeFactors, RuntimeFactorsInstanceState, + AppComponent, Factor, FactorInstanceBuilder, InitContext, PrepareContext, RuntimeFactors, + RuntimeFactorsInstanceState, anyhow, }; use wasmtime::component::HasData; use wasmtime_wasi::cli::{StdinStream, StdoutStream, WasiCli, WasiCliCtxView}; diff --git a/crates/factor-wasi/src/spin.rs b/crates/factor-wasi/src/spin.rs index 25de63b5e0..5f35830072 100644 --- a/crates/factor-wasi/src/spin.rs +++ b/crates/factor-wasi/src/spin.rs @@ -1,7 +1,7 @@ use std::path::PathBuf; use spin_common::{ui::quoted_path, url::parse_file_url}; -use spin_factors::anyhow::{ensure, Context}; +use spin_factors::anyhow::{Context, ensure}; use crate::FilesMounter; diff --git a/crates/factor-wasi/src/wasi_2023_10_18.rs b/crates/factor-wasi/src/wasi_2023_10_18.rs index fa670452cd..cc0a86111d 100644 --- a/crates/factor-wasi/src/wasi_2023_10_18.rs +++ b/crates/factor-wasi/src/wasi_2023_10_18.rs @@ -1,21 +1,21 @@ use spin_factors::anyhow::Result; use std::mem; use wasmtime::component::{Linker, Resource, ResourceTable}; +use wasmtime_wasi::TrappableError; use wasmtime_wasi::cli::{WasiCli, WasiCliCtxView}; use wasmtime_wasi::clocks::{WasiClocks, WasiClocksCtxView}; use wasmtime_wasi::filesystem::{WasiFilesystem, WasiFilesystemCtxView}; use wasmtime_wasi::p2::DynPollable; use wasmtime_wasi::random::{WasiRandom, WasiRandomCtx}; use wasmtime_wasi::sockets::{WasiSockets, WasiSocketsCtxView}; -use wasmtime_wasi::TrappableError; mod latest { pub use wasmtime_wasi::p2::bindings::*; } mod bindings { - use super::latest; pub use super::UdpSocket; + use super::latest; wasmtime::component::bindgen!({ path: "../../wit", diff --git a/crates/factor-wasi/tests/factor_test.rs b/crates/factor-wasi/tests/factor_test.rs index faf8f4a1b2..a15978296a 100644 --- a/crates/factor-wasi/tests/factor_test.rs +++ b/crates/factor-wasi/tests/factor_test.rs @@ -1,6 +1,6 @@ use spin_factor_wasi::{DummyFilesMounter, WasiFactor}; -use spin_factors::{anyhow, RuntimeFactors}; -use spin_factors_test::{toml, TestEnvironment}; +use spin_factors::{RuntimeFactors, anyhow}; +use spin_factors_test::{TestEnvironment, toml}; use wasmtime_wasi::p2::bindings::cli::environment::Host; #[derive(RuntimeFactors)] diff --git a/crates/factors-derive/src/lib.rs b/crates/factors-derive/src/lib.rs index 8002fe5642..1004824672 100644 --- a/crates/factors-derive/src/lib.rs +++ b/crates/factors-derive/src/lib.rs @@ -1,6 +1,6 @@ use proc_macro2::TokenStream; use quote::{format_ident, quote}; -use syn::{parse_macro_input, Data, DeriveInput, Error}; +use syn::{Data, DeriveInput, Error, parse_macro_input}; #[proc_macro_derive(RuntimeFactors)] pub fn derive_factors(input: proc_macro::TokenStream) -> proc_macro::TokenStream { @@ -43,7 +43,7 @@ fn expand_factors(input: &DeriveInput) -> syn::Result { return Err(Error::new_spanned( input, "can only derive Factors for structs", - )) + )); } }; let mut factor_names = Vec::with_capacity(fields.len()); diff --git a/crates/factors-executor/src/lib.rs b/crates/factors-executor/src/lib.rs index 0e01319603..dce2b32594 100644 --- a/crates/factors-executor/src/lib.rs +++ b/crates/factors-executor/src/lib.rs @@ -3,7 +3,7 @@ use std::{collections::HashMap, sync::Arc}; use anyhow::Context; use spin_app::{App, AppComponent}; -use spin_core::{async_trait, wasmtime::CallHook, Component}; +use spin_core::{Component, async_trait, wasmtime::CallHook}; use spin_factors::{ AsInstanceState, ConfiguredApp, Factor, HasInstanceBuilder, RuntimeFactors, RuntimeFactorsInstanceState, diff --git a/crates/factors-test/src/lib.rs b/crates/factors-test/src/lib.rs index 2cff65614e..34ceb3ba38 100644 --- a/crates/factors-test/src/lib.rs +++ b/crates/factors-test/src/lib.rs @@ -1,8 +1,8 @@ use spin_app::locked::LockedApp; use spin_factors::{ - anyhow::{self, Context}, - wasmtime::{component::Linker, Engine}, App, RuntimeFactors, + anyhow::{self, Context}, + wasmtime::{Engine, component::Linker}, }; use spin_loader::FilesMountStrategy; diff --git a/crates/factors/src/factor.rs b/crates/factors/src/factor.rs index be52220dce..7de3002b9f 100644 --- a/crates/factors/src/factor.rs +++ b/crates/factors/src/factor.rs @@ -4,7 +4,7 @@ use std::marker::PhantomData; use wasmtime::component::{HasData, Linker, ResourceTable}; use crate::{ - prepare::FactorInstanceBuilder, App, AsInstanceState, Error, PrepareContext, RuntimeFactors, + App, AsInstanceState, Error, PrepareContext, RuntimeFactors, prepare::FactorInstanceBuilder, }; /// A contained (i.e., "factored") piece of runtime functionality. @@ -124,7 +124,7 @@ pub trait FactorField { type Factor: Factor; fn get(field: &mut Self::State) - -> (&mut FactorInstanceState, &mut ResourceTable); + -> (&mut FactorInstanceState, &mut ResourceTable); } impl InitContext for FactorInitContext<'_, T, G> diff --git a/crates/factors/src/runtime_factors.rs b/crates/factors/src/runtime_factors.rs index 794b9435ad..b011f16687 100644 --- a/crates/factors/src/runtime_factors.rs +++ b/crates/factors/src/runtime_factors.rs @@ -1,6 +1,6 @@ use wasmtime::component::{Linker, ResourceTable}; -use crate::{factor::FactorInstanceState, App, ConfiguredApp, Factor}; +use crate::{App, ConfiguredApp, Factor, factor::FactorInstanceState}; /// A collection of `Factor`s that are initialized and configured together. /// diff --git a/crates/http/src/app_info.rs b/crates/http/src/app_info.rs index 2733328c89..c6d52a14c8 100644 --- a/crates/http/src/app_info.rs +++ b/crates/http/src/app_info.rs @@ -1,6 +1,6 @@ use serde::{Deserialize, Serialize}; #[cfg(feature = "runtime")] -use spin_app::{App, APP_NAME_KEY, APP_VERSION_KEY, OCI_IMAGE_DIGEST_KEY}; +use spin_app::{APP_NAME_KEY, APP_VERSION_KEY, App, OCI_IMAGE_DIGEST_KEY}; #[derive(Debug, Serialize, Deserialize)] pub struct AppInfo { diff --git a/crates/http/src/config.rs b/crates/http/src/config.rs index 911ffcee85..b8e00e565b 100644 --- a/crates/http/src/config.rs +++ b/crates/http/src/config.rs @@ -19,10 +19,16 @@ pub struct HttpTriggerConfig { impl HttpTriggerConfig { pub fn lookup_key(&self, trigger_id: &str) -> anyhow::Result { match (&self.component, &self.static_response) { - (None, None) => Err(anyhow::anyhow!("Triggers must specify either component or static_response - {trigger_id} has neither")), - (Some(_), Some(_)) => Err(anyhow::anyhow!("Triggers must specify either component or static_response - {trigger_id} has both")), + (None, None) => Err(anyhow::anyhow!( + "Triggers must specify either component or static_response - {trigger_id} has neither" + )), + (Some(_), Some(_)) => Err(anyhow::anyhow!( + "Triggers must specify either component or static_response - {trigger_id} has both" + )), (Some(c), None) => Ok(crate::routes::TriggerLookupKey::Component(c.to_string())), - (None, Some(_)) => Ok(crate::routes::TriggerLookupKey::Trigger(trigger_id.to_string())), + (None, Some(_)) => Ok(crate::routes::TriggerLookupKey::Trigger( + trigger_id.to_string(), + )), } } } diff --git a/crates/http/src/lib.rs b/crates/http/src/lib.rs index 9c78ca517c..833b787207 100644 --- a/crates/http/src/lib.rs +++ b/crates/http/src/lib.rs @@ -13,7 +13,7 @@ pub use spin_http_routes::WELL_KNOWN_PREFIX; #[cfg(feature = "runtime")] pub mod body { use super::Body; - use http_body_util::{combinators::UnsyncBoxBody, BodyExt, Empty, Full}; + use http_body_util::{BodyExt, Empty, Full, combinators::UnsyncBoxBody}; use hyper::body::Bytes; pub fn full(bytes: Bytes) -> Body { diff --git a/crates/http/src/wagi/mod.rs b/crates/http/src/wagi/mod.rs index 80d83fdafe..36cc0ed956 100644 --- a/crates/http/src/wagi/mod.rs +++ b/crates/http/src/wagi/mod.rs @@ -5,12 +5,12 @@ use std::{collections::HashMap, net::SocketAddr}; use anyhow::Error; use http::{ - header::{HeaderName, HOST}, - request::Parts, HeaderMap, HeaderValue, Response, StatusCode, + header::{HOST, HeaderName}, + request::Parts, }; -use crate::{body, routes::RouteMatch, Body}; +use crate::{Body, body, routes::RouteMatch}; /// This sets the version of CGI that WAGI adheres to. /// diff --git a/crates/key-value-aws/src/store.rs b/crates/key-value-aws/src/store.rs index 3c2e24dbb4..c2b0e07752 100644 --- a/crates/key-value-aws/src/store.rs +++ b/crates/key-value-aws/src/store.rs @@ -8,6 +8,7 @@ use anyhow::Result; use aws_config::{BehaviorVersion, Region, SdkConfig}; use aws_credential_types::Credentials; use aws_sdk_dynamodb::{ + Client, config::{ProvideCredentials, SharedCredentialsProvider}, operation::{ batch_get_item::BatchGetItemOutput, batch_write_item::BatchWriteItemOutput, @@ -18,11 +19,10 @@ use aws_sdk_dynamodb::{ AttributeValue, DeleteRequest, KeysAndAttributes, PutRequest, TransactWriteItem, Update, WriteRequest, }, - Client, }; use spin_core::async_trait; use spin_factor_key_value::{ - log_error, log_error_v3, v3, Cas, Error, Store, StoreManager, SwapError, + Cas, Error, Store, StoreManager, SwapError, log_error, log_error_v3, v3, }; pub struct KeyValueAwsDynamo { diff --git a/crates/key-value-azure/src/store.rs b/crates/key-value-azure/src/store.rs index 61ebe7c8d2..bdcef81f9e 100644 --- a/crates/key-value-azure/src/store.rs +++ b/crates/key-value-azure/src/store.rs @@ -1,15 +1,15 @@ use anyhow::{Context, Result}; use async_trait::async_trait; use azure_data_cosmos::{ + CosmosEntity, prelude::{ AuthorizationToken, CollectionClient, CosmosClient, CosmosClientBuilder, Operation, Query, }, - CosmosEntity, }; use futures::StreamExt; use serde::{Deserialize, Serialize}; use spin_factor_key_value::{ - log_cas_error, log_error, log_error_v3, v3, Cas, Error, Store, StoreManager, SwapError, + Cas, Error, Store, StoreManager, SwapError, log_cas_error, log_error, log_error_v3, v3, }; use std::sync::{Arc, Mutex}; diff --git a/crates/key-value-redis/src/store.rs b/crates/key-value-redis/src/store.rs index 1d8a80ca92..69f7bec113 100644 --- a/crates/key-value-redis/src/store.rs +++ b/crates/key-value-redis/src/store.rs @@ -1,8 +1,8 @@ use anyhow::{Context, Result}; -use redis::{aio::ConnectionManager, parse_redis_url, AsyncCommands, Client, RedisError}; +use redis::{AsyncCommands, Client, RedisError, aio::ConnectionManager, parse_redis_url}; use spin_core::async_trait; use spin_factor_key_value::{ - log_error, log_error_v3, v3, Cas, Error, Store, StoreManager, SwapError, + Cas, Error, Store, StoreManager, SwapError, log_error, log_error_v3, v3, }; use std::sync::Arc; use tokio::sync::OnceCell; diff --git a/crates/key-value-spin/src/store.rs b/crates/key-value-spin/src/store.rs index 66a12d112f..ddba18bb39 100644 --- a/crates/key-value-spin/src/store.rs +++ b/crates/key-value-spin/src/store.rs @@ -1,8 +1,8 @@ use anyhow::Result; -use rusqlite::{named_params, Connection}; +use rusqlite::{Connection, named_params}; use spin_core::async_trait; use spin_factor_key_value::{ - log_cas_error, log_error, log_error_v3, v3, Cas, Error, Store, StoreManager, SwapError, + Cas, Error, Store, StoreManager, SwapError, log_cas_error, log_error, log_error_v3, v3, }; use std::rc::Rc; use std::{ @@ -554,10 +554,11 @@ mod test { ("bin".to_string(), b"baz".to_vec()), ("alex".to_string(), b"pat".to_vec()), ]; - assert!(kv - .set_many(Resource::new_own(rep), keys_and_values.clone()) - .await - .is_ok()); + assert!( + kv.set_many(Resource::new_own(rep), keys_and_values.clone()) + .await + .is_ok() + ); let res = kv .get_many( diff --git a/crates/llm-local/src/bert.rs b/crates/llm-local/src/bert.rs index 43cf5f7627..c57966043c 100644 --- a/crates/llm-local/src/bert.rs +++ b/crates/llm-local/src/bert.rs @@ -3,7 +3,7 @@ /// https://github.com/huggingface/candle/blob/ee8bb1bde1a44738c314dfaacba743f4eabf917c/candle-examples/examples/bert/model.rs /// /// TODO: Remove this file when a new release of Candle makes it obsolete. -use anyhow::{bail, Result}; +use anyhow::{Result, bail}; use candle::{DType, Module, Tensor}; use candle_nn::{Embedding, VarBuilder}; use serde::Deserialize; diff --git a/crates/llm-local/src/lib.rs b/crates/llm-local/src/lib.rs index b0af67e452..432d6cbfc8 100644 --- a/crates/llm-local/src/lib.rs +++ b/crates/llm-local/src/lib.rs @@ -3,13 +3,13 @@ mod llama; use anyhow::Context; use bert::{BertModel, Config}; -use candle::{safetensors::load_buffer, DType}; +use candle::{DType, safetensors::load_buffer}; use candle_nn::VarBuilder; use spin_common::ui::quoted_path; use spin_core::async_trait; use spin_world::v2::llm::{self as wasi_llm}; use std::{ - collections::{hash_map::Entry, HashMap}, + collections::{HashMap, hash_map::Entry}, path::{Path, PathBuf}, str::FromStr, sync::Arc, diff --git a/crates/llm-local/src/llama.rs b/crates/llm-local/src/llama.rs index 4cf4d82d77..f55174fdef 100644 --- a/crates/llm-local/src/llama.rs +++ b/crates/llm-local/src/llama.rs @@ -1,6 +1,6 @@ use crate::InferencingModel; -use anyhow::{anyhow, bail, Context, Result}; -use candle::{safetensors::load_buffer, utils, Device, Tensor}; +use anyhow::{Context, Result, anyhow, bail}; +use candle::{Device, Tensor, safetensors::load_buffer, utils}; use candle_nn::VarBuilder; use candle_transformers::{ generation::{LogitsProcessor, Sampling}, diff --git a/crates/llm-remote-http/src/default.rs b/crates/llm-remote-http/src/default.rs index 7767e46faf..f4fa64babd 100644 --- a/crates/llm-remote-http/src/default.rs +++ b/crates/llm-remote-http/src/default.rs @@ -1,7 +1,7 @@ use anyhow::Result; use reqwest::{ - header::{HeaderMap, HeaderValue}, Client, Url, + header::{HeaderMap, HeaderValue}, }; use serde::{Deserialize, Serialize}; use serde_json::json; diff --git a/crates/llm-remote-http/src/open_ai/mod.rs b/crates/llm-remote-http/src/open_ai/mod.rs index 7e20378364..76df224a72 100644 --- a/crates/llm-remote-http/src/open_ai/mod.rs +++ b/crates/llm-remote-http/src/open_ai/mod.rs @@ -1,8 +1,8 @@ mod schemas; use reqwest::{ - header::{HeaderMap, HeaderValue}, Client, Url, + header::{HeaderMap, HeaderValue}, }; use spin_world::{ async_trait, diff --git a/crates/loader/src/cache.rs b/crates/loader/src/cache.rs index 9991de408a..519286daeb 100644 --- a/crates/loader/src/cache.rs +++ b/crates/loader/src/cache.rs @@ -1,6 +1,6 @@ //! Cache for OCI registry entities. -use anyhow::{ensure, Context, Result}; +use anyhow::{Context, Result, ensure}; use std::{ path::PathBuf, diff --git a/crates/loader/src/http.rs b/crates/loader/src/http.rs index f102e32425..e5617bbd8d 100644 --- a/crates/loader/src/http.rs +++ b/crates/loader/src/http.rs @@ -1,6 +1,6 @@ use std::path::Path; -use anyhow::{ensure, Context, Result}; +use anyhow::{Context, Result, ensure}; use sha2::Digest; use tokio::io::AsyncWriteExt; diff --git a/crates/loader/src/lib.rs b/crates/loader/src/lib.rs index 900ec78fb5..b41abc2483 100644 --- a/crates/loader/src/lib.rs +++ b/crates/loader/src/lib.rs @@ -23,8 +23,8 @@ mod fs; mod http; mod local; -pub use local::requires_service_chaining; pub use local::WasmLoader; +pub use local::requires_service_chaining; /// Maximum number of files to copy (or download) concurrently pub(crate) const MAX_FILE_LOADING_CONCURRENCY: usize = 16; diff --git a/crates/loader/src/local.rs b/crates/loader/src/local.rs index db82a3e050..492c3ac4c3 100644 --- a/crates/loader/src/local.rs +++ b/crates/loader/src/local.rs @@ -1,7 +1,7 @@ use std::path::{Path, PathBuf}; -use anyhow::{anyhow, bail, ensure, Context, Result}; -use futures::{future::try_join_all, StreamExt}; +use anyhow::{Context, Result, anyhow, bail, ensure}; +use futures::{StreamExt, future::try_join_all}; use reqwest::Url; use spin_common::{paths::parent_dir, sloth, ui::quoted_path}; use spin_expressions::Resolver; @@ -18,7 +18,7 @@ use spin_serde::DependencyName; use std::collections::BTreeMap; use tokio::{io::AsyncWriteExt, sync::Semaphore}; -use crate::{cache::Cache, FilesMountStrategy}; +use crate::{FilesMountStrategy, cache::Cache}; #[derive(Debug)] pub struct LocalLoader { @@ -332,17 +332,27 @@ impl LocalLoader { exclude_files: &[String], ) -> Result<()> { if glob_or_path == ".." || glob_or_path.ends_with("/..") { - bail!("A file pattern can't end in a parent directory path (..)\nIf you want to include a directory, use source-destination form, or a glob pattern ending in **/*.\nLearn more: https://spinframework.dev/writing-apps#including-files-with-components"); + bail!( + "A file pattern can't end in a parent directory path (..)\nIf you want to include a directory, use source-destination form, or a glob pattern ending in **/*.\nLearn more: https://spinframework.dev/writing-apps#including-files-with-components" + ); } if glob_or_path == "." || glob_or_path.ends_with("/.") { - bail!("A file pattern can't end in a current directory path (.)\nIf you want to include a directory, use source-destination form, or a glob pattern ending in **/*.\nLearn more: https://spinframework.dev/writing-apps#including-files-with-components"); + bail!( + "A file pattern can't end in a current directory path (.)\nIf you want to include a directory, use source-destination form, or a glob pattern ending in **/*.\nLearn more: https://spinframework.dev/writing-apps#including-files-with-components" + ); } if glob_or_path == "*" { - tracing::warn!(alert_in_dev = true, "A component is including the entire application directory as asset files. This is unlikely to be what you want.\nIf this is what you want, use the pattern \"./*\" to avoid this warning.\nLearn more: https://spinframework.dev/writing-apps#including-files-with-components\n"); + tracing::warn!( + alert_in_dev = true, + "A component is including the entire application directory as asset files. This is unlikely to be what you want.\nIf this is what you want, use the pattern \"./*\" to avoid this warning.\nLearn more: https://spinframework.dev/writing-apps#including-files-with-components\n" + ); } if glob_or_path == "**/*" { - tracing::warn!(alert_in_dev = true, "A component is including the entire application directory tree as asset files. This is unlikely to be what you want.\nIf this is what you want, use the pattern \"./**/*\" to avoid this warning.\nLearn more: https://spinframework.dev/writing-apps#including-files-with-components\n"); + tracing::warn!( + alert_in_dev = true, + "A component is including the entire application directory tree as asset files. This is unlikely to be what you want.\nIf this is what you want, use the pattern \"./**/*\" to avoid this warning.\nLearn more: https://spinframework.dev/writing-apps#including-files-with-components\n" + ); } let path = self.app_root.join(glob_or_path); @@ -430,7 +440,9 @@ impl LocalLoader { } let Ok(app_root_path) = src.strip_prefix(&self.app_root) else { - bail!("{pattern} cannot be mapped because it is outside the application directory. Files must be within the application directory."); + bail!( + "{pattern} cannot be mapped because it is outside the application directory. Files must be within the application directory." + ); }; if exclude_patterns @@ -489,15 +501,14 @@ impl LocalLoader { let dest_text = quoted_path(dest); let base_msg = format!("Failed to copy {src_text} to working path {dest_text}"); - if let Some(io_error) = e.downcast_ref::() { - if Self::is_directory_like(guest_dest) - || io_error.kind() == std::io::ErrorKind::NotFound - { - return Err(anyhow::anyhow!( - r#""{guest_dest}" is not a valid destination file name"# - )) - .context(base_msg); - } + if let Some(io_error) = e.downcast_ref::() + && (Self::is_directory_like(guest_dest) + || io_error.kind() == std::io::ErrorKind::NotFound) + { + return Err(anyhow::anyhow!( + r#""{guest_dest}" is not a valid destination file name"# + )) + .context(base_msg); } Err(e).with_context(|| format!("{base_msg} (for destination path \"{guest_dest}\")")) @@ -521,7 +532,9 @@ impl LocalLoader { }; let path = self.app_root.join(src); if !path.is_dir() { - bail!("Only directory mounts are supported with `--direct-mounts`; {src:?} is not a directory."); + bail!( + "Only directory mounts are supported with `--direct-mounts`; {src:?} is not a directory." + ); } Ok(ContentPath { content: file_content_ref(src)?, @@ -535,10 +548,10 @@ impl LocalLoader { } fn explain_file_mount_source_error(e: anyhow::Error, src: &Path) -> anyhow::Error { - if let Some(io_error) = e.downcast_ref::() { - if io_error.kind() == std::io::ErrorKind::NotFound { - return anyhow::anyhow!("File or directory {} does not exist", quoted_path(src)); - } + if let Some(io_error) = e.downcast_ref::() + && io_error.kind() == std::io::ErrorKind::NotFound + { + return anyhow::anyhow!("File or directory {} does not exist", quoted_path(src)); } e.context(format!("invalid file mount source {}", quoted_path(src))) } diff --git a/crates/locked-app/src/locked.rs b/crates/locked-app/src/locked.rs index 5acb9e2978..9053672976 100644 --- a/crates/locked-app/src/locked.rs +++ b/crates/locked-app/src/locked.rs @@ -109,7 +109,10 @@ where if unsupported.is_empty() { Ok(m) } else { - let msg = format!("This version of Spin does not support the following features required by this application: {}", unsupported.join(", ")); + let msg = format!( + "This version of Spin does not support the following features required by this application: {}", + unsupported.join(", ") + ); Err(serde::de::Error::custom(msg)) } } @@ -435,9 +438,10 @@ mod test { let err = LockedApp::from_json(&j).expect_err( "Should have refused to deserialise due to non-understood must-understand field", ); - assert!(err - .to_string() - .contains("never_create_field_with_this_name")); + assert!( + err.to_string() + .contains("never_create_field_with_this_name") + ); } #[test] diff --git a/crates/locked-app/src/metadata.rs b/crates/locked-app/src/metadata.rs index cfb487ce4d..a6ba3ab9fc 100644 --- a/crates/locked-app/src/metadata.rs +++ b/crates/locked-app/src/metadata.rs @@ -3,7 +3,7 @@ use std::marker::PhantomData; use serde::Deserialize; use serde_json::Value; -use crate::{values::ValuesMap, Error, Result}; +use crate::{Error, Result, values::ValuesMap}; /// MetadataKey is a handle to a typed metadata value. pub struct MetadataKey { diff --git a/crates/manifest/Cargo.toml b/crates/manifest/Cargo.toml index 42344658db..2f44e76e6d 100644 --- a/crates/manifest/Cargo.toml +++ b/crates/manifest/Cargo.toml @@ -7,7 +7,7 @@ edition = { workspace = true } [dependencies] anyhow = { workspace = true } indexmap = { workspace = true, features = ["serde"] } -schemars = { version = "0.8.21", features = ["indexmap2", "semver"] } +schemars = { workspace = true } semver = { workspace = true, features = ["serde"] } serde = { workspace = true } spin-serde = { path = "../serde" } diff --git a/crates/manifest/src/compat.rs b/crates/manifest/src/compat.rs index 54c1e1516a..4e17e002b8 100644 --- a/crates/manifest/src/compat.rs +++ b/crates/manifest/src/compat.rs @@ -6,7 +6,7 @@ use crate::{ error::Error, schema::{v1, v2}, }; -use allowed_http_hosts::{parse_allowed_http_hosts, AllowedHttpHosts}; +use allowed_http_hosts::{AllowedHttpHosts, parse_allowed_http_hosts}; /// Converts a V1 app manifest to V2. pub fn v1_to_v2_app(manifest: v1::AppManifestV1) -> Result { diff --git a/crates/manifest/src/compat/allowed_http_hosts.rs b/crates/manifest/src/compat/allowed_http_hosts.rs index b2b826d696..af45a6f7a9 100644 --- a/crates/manifest/src/compat/allowed_http_hosts.rs +++ b/crates/manifest/src/compat/allowed_http_hosts.rs @@ -1,4 +1,4 @@ -use anyhow::{anyhow, Result}; +use anyhow::{Result, anyhow}; use url::Url; const ALLOW_ALL_HOSTS: &str = "insecure:allow-all"; diff --git a/crates/manifest/src/normalize.rs b/crates/manifest/src/normalize.rs index 2ed152fd93..ff7d107ad8 100644 --- a/crates/manifest/src/normalize.rs +++ b/crates/manifest/src/normalize.rs @@ -308,7 +308,10 @@ fn ensure_is_acceptable_dependency( if surprises.is_empty() { Ok(()) } else { - anyhow::bail!("Dependencies may not have their own resources or permissions. Component {depended_on_id} cannot be used as a dependency of {depender_id} because it specifies: {}", surprises.join(", ")); + anyhow::bail!( + "Dependencies may not have their own resources or permissions. Component {depended_on_id} cannot be used as a dependency of {depender_id} because it specifies: {}", + surprises.join(", ") + ); } } diff --git a/crates/manifest/src/schema/json_schema.rs b/crates/manifest/src/schema/json_schema.rs index 0129b02b4b..16498e67a9 100644 --- a/crates/manifest/src/schema/json_schema.rs +++ b/crates/manifest/src/schema/json_schema.rs @@ -1,4 +1,4 @@ -use crate::schema::v2::{ComponentSpec, Map, OneOrManyComponentSpecs}; +use crate::schema::v2::{Component, ComponentSpec, Map, OneOrManyComponentSpecs}; use schemars::JsonSchema; // The structs here allow dead code because they exist only @@ -163,35 +163,34 @@ pub enum WatchCommand { Command(String), } -pub fn toml_table(_gen: &mut schemars::gen::SchemaGenerator) -> schemars::schema::Schema { - schemars::schema::Schema::Object(schemars::schema::SchemaObject { - instance_type: Some(schemars::schema::SingleOrVec::Single(Box::new( - schemars::schema::InstanceType::Object, - ))), - ..Default::default() +pub fn toml_table(_gen: &mut schemars::generate::SchemaGenerator) -> schemars::Schema { + schemars::json_schema!({ + "type": "object" }) } -pub fn map_of_toml_tables(_gen: &mut schemars::gen::SchemaGenerator) -> schemars::schema::Schema { - schemars::schema::Schema::Object(schemars::schema::SchemaObject { - instance_type: Some(schemars::schema::SingleOrVec::Single(Box::new( - schemars::schema::InstanceType::Object, - ))), - ..Default::default() +pub fn map_of_toml_tables(_gen: &mut schemars::generate::SchemaGenerator) -> schemars::Schema { + schemars::json_schema!({ + "type": "object" }) } pub fn one_or_many( - gen: &mut schemars::gen::SchemaGenerator, -) -> schemars::schema::Schema { - schemars::schema::Schema::Object(schemars::schema::SchemaObject { - subschemas: Some(Box::new(schemars::schema::SubschemaValidation { - one_of: Some(vec![ - gen.subschema_for::(), - gen.subschema_for::>(), - ]), - ..Default::default() - })), - ..Default::default() + generator: &mut schemars::generate::SchemaGenerator, +) -> schemars::Schema { + schemars::json_schema!({ + "oneOf": [ + generator.subschema_for::(), + generator.subschema_for::>(), + ] + }) +} + +pub fn id_or_component(generator: &mut schemars::generate::SchemaGenerator) -> schemars::Schema { + schemars::json_schema!({ + "oneOf": [ + generator.subschema_for::(), + generator.subschema_for::(), + ] }) } diff --git a/crates/manifest/src/schema/v2.rs b/crates/manifest/src/schema/v2.rs index b5b77b335d..ccc7d105ac 100644 --- a/crates/manifest/src/schema/v2.rs +++ b/crates/manifest/src/schema/v2.rs @@ -1,4 +1,4 @@ -use anyhow::{anyhow, Context}; +use anyhow::{Context, anyhow}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use spin_serde::{DependencyName, DependencyPackageName, FixedVersion, LowerSnakeId}; @@ -15,7 +15,7 @@ pub(crate) type Map = indexmap::IndexMap; #[serde(deny_unknown_fields)] pub struct AppManifest { /// `spin_manifest_version = 2` - #[schemars(with = "usize", range = (min = 2, max = 2))] + #[schemars(with = "usize", range(min = 2, max = 2))] pub spin_manifest_version: FixedVersion<2>, /// `[application]` pub application: AppDetails, @@ -173,6 +173,7 @@ pub struct OneOrManyComponentSpecs( /// Component reference or inline definition #[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)] #[serde(deny_unknown_fields, untagged, try_from = "toml::Value")] +#[schemars(schema_with = "json_schema::id_or_component")] pub enum ComponentSpec { /// `"component-id"` Reference(KebabId), @@ -644,19 +645,19 @@ impl ComponentDependencies { /// interfaces, e.g. `"foo:bar = { ..., export = "my-export" }"` is invalid. fn ensure_package_names_no_export(&self) -> anyhow::Result<()> { for (dependency_name, dependency) in self.inner.iter() { - if let DependencyName::Package(name) = dependency_name { - if name.interface.is_none() { - let export = match dependency { - ComponentDependency::Package { export, .. } => export, - ComponentDependency::Local { export, .. } => export, - _ => continue, - }; - - anyhow::ensure!( - export.is_none(), - "using an export to satisfy the package dependency {dependency_name:?} is not currently permitted", - ); - } + if let DependencyName::Package(name) = dependency_name + && name.interface.is_none() + { + let export = match dependency { + ComponentDependency::Package { export, .. } => export, + ComponentDependency::Local { export, .. } => export, + _ => continue, + }; + + anyhow::ensure!( + export.is_none(), + "using an export to satisfy the package dependency {dependency_name:?} is not currently permitted", + ); } } Ok(()) @@ -688,20 +689,18 @@ impl ComponentDependencies { ) -> anyhow::Result<()> { assert_eq!(this.package, other.package); - if let (Some(this_ver), Some(other_ver)) = (this.version.clone(), other.version.clone()) { - if Self::normalize_compatible_version(this_ver) + if let (Some(this_ver), Some(other_ver)) = (this.version.clone(), other.version.clone()) + && Self::normalize_compatible_version(this_ver) != Self::normalize_compatible_version(other_ver) - { - return Ok(()); - } + { + return Ok(()); } if let (Some(this_itf), Some(other_itf)) = (this.interface.as_ref(), other.interface.as_ref()) + && this_itf != other_itf { - if this_itf != other_itf { - return Ok(()); - } + return Ok(()); } Err(anyhow!("{this:?} dependency conflicts with {other:?}")) @@ -740,15 +739,12 @@ impl ComponentDependencies { #[serde(untagged, deny_unknown_fields)] pub enum TargetEnvironmentRef { /// Environment definition doc reference e.g. `spin-up:3.2`, `my-host`. This is looked up - /// in the default environment catalogue (registry). - DefaultRegistry(String), - /// An environment definition doc in an OCI registry other than the default - Registry { - /// Registry or prefix hosting the environment document e.g. `ghcr.io/my/environments`. - registry: String, - /// Environment definition document name e.g. `my-spin-env:1.2`. For hosted environments - /// where you always want `latest`, omit the version tag e.g. `my-host`. - id: String, + /// in the default environment catalogue (the `spin-environments` repo, `env` directory). + Catalogue(String), + /// An environment definition doc HTTP URL + Http { + /// The environment document URL e.g. `https://github.com/me/environments/blob/main/target-envs/spin-up.3.6.toml`. + url: String, }, /// A local environment document file. This is expected to contain a serialised /// EnvironmentDefinition in TOML format. @@ -758,6 +754,16 @@ pub enum TargetEnvironmentRef { }, } +impl std::fmt::Display for TargetEnvironmentRef { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Catalogue(e) => e.fmt(f), + Self::Http { url } => url.fmt(f), + Self::File { path } => path.display().fmt(f), + } + } +} + mod kebab_or_snake_case { use serde::{Deserialize, Serialize}; pub use spin_serde::{KebabId, SnakeId}; @@ -939,17 +945,19 @@ mod tests { #[test] fn deserializing_labels_fails_for_non_kebab_or_snake() { - assert!(AppManifest::deserialize(toml! { - spin_manifest_version = 2 - [application] - name = "trigger-configs" - [[trigger.fake]] - something = "something else" - [component.fake] - source = "dummy" - key_value_stores = ["b@dlabel"] - }) - .is_err()); + assert!( + AppManifest::deserialize(toml! { + spin_manifest_version = 2 + [application] + name = "trigger-configs" + [[trigger.fake]] + something = "something else" + [component.fake] + source = "dummy" + key_value_stores = ["b@dlabel"] + }) + .is_err() + ); } fn get_test_component_with_labels(labels: Vec) -> Component { @@ -1058,111 +1066,135 @@ mod tests { #[test] fn test_validate_dependencies() { // Specifying a dependency name as a plain-name without a package is an error - assert!(ComponentDependencies::deserialize(toml! { - "plain-name" = "0.1.0" - }) - .unwrap() - .validate() - .is_err()); + assert!( + ComponentDependencies::deserialize(toml! { + "plain-name" = "0.1.0" + }) + .unwrap() + .validate() + .is_err() + ); // Specifying a dependency name as a plain-name without a package is an error - assert!(ComponentDependencies::deserialize(toml! { - "plain-name" = { version = "0.1.0" } - }) - .unwrap() - .validate() - .is_err()); + assert!( + ComponentDependencies::deserialize(toml! { + "plain-name" = { version = "0.1.0" } + }) + .unwrap() + .validate() + .is_err() + ); // Specifying an export to satisfy a package dependency name is an error - assert!(ComponentDependencies::deserialize(toml! { - "foo:baz@0.1.0" = { path = "foo.wasm", export = "foo"} - }) - .unwrap() - .validate() - .is_err()); + assert!( + ComponentDependencies::deserialize(toml! { + "foo:baz@0.1.0" = { path = "foo.wasm", export = "foo"} + }) + .unwrap() + .validate() + .is_err() + ); // Two compatible versions of the same package is an error - assert!(ComponentDependencies::deserialize(toml! { - "foo:baz@0.1.0" = "0.1.0" - "foo:bar@0.2.1" = "0.2.1" - "foo:bar@0.2.2" = "0.2.2" - }) - .unwrap() - .validate() - .is_err()); + assert!( + ComponentDependencies::deserialize(toml! { + "foo:baz@0.1.0" = "0.1.0" + "foo:bar@0.2.1" = "0.2.1" + "foo:bar@0.2.2" = "0.2.2" + }) + .unwrap() + .validate() + .is_err() + ); // Two disjoint versions of the same package is ok - assert!(ComponentDependencies::deserialize(toml! { - "foo:bar@0.1.0" = "0.1.0" - "foo:bar@0.2.0" = "0.2.0" - "foo:baz@0.2.0" = "0.1.0" - }) - .unwrap() - .validate() - .is_ok()); + assert!( + ComponentDependencies::deserialize(toml! { + "foo:bar@0.1.0" = "0.1.0" + "foo:bar@0.2.0" = "0.2.0" + "foo:baz@0.2.0" = "0.1.0" + }) + .unwrap() + .validate() + .is_ok() + ); // Unversioned and versioned dependencies of the same package is an error - assert!(ComponentDependencies::deserialize(toml! { - "foo:bar@0.1.0" = "0.1.0" - "foo:bar" = ">= 0.2.0" - }) - .unwrap() - .validate() - .is_err()); + assert!( + ComponentDependencies::deserialize(toml! { + "foo:bar@0.1.0" = "0.1.0" + "foo:bar" = ">= 0.2.0" + }) + .unwrap() + .validate() + .is_err() + ); // Two interfaces of two disjoint versions of a package is ok - assert!(ComponentDependencies::deserialize(toml! { - "foo:bar/baz@0.1.0" = "0.1.0" - "foo:bar/baz@0.2.0" = "0.2.0" - }) - .unwrap() - .validate() - .is_ok()); + assert!( + ComponentDependencies::deserialize(toml! { + "foo:bar/baz@0.1.0" = "0.1.0" + "foo:bar/baz@0.2.0" = "0.2.0" + }) + .unwrap() + .validate() + .is_ok() + ); // A versioned interface and a different versioned package is ok - assert!(ComponentDependencies::deserialize(toml! { - "foo:bar/baz@0.1.0" = "0.1.0" - "foo:bar@0.2.0" = "0.2.0" - }) - .unwrap() - .validate() - .is_ok()); + assert!( + ComponentDependencies::deserialize(toml! { + "foo:bar/baz@0.1.0" = "0.1.0" + "foo:bar@0.2.0" = "0.2.0" + }) + .unwrap() + .validate() + .is_ok() + ); // A versioned interface and package of the same version is an error - assert!(ComponentDependencies::deserialize(toml! { - "foo:bar/baz@0.1.0" = "0.1.0" - "foo:bar@0.1.0" = "0.1.0" - }) - .unwrap() - .validate() - .is_err()); + assert!( + ComponentDependencies::deserialize(toml! { + "foo:bar/baz@0.1.0" = "0.1.0" + "foo:bar@0.1.0" = "0.1.0" + }) + .unwrap() + .validate() + .is_err() + ); // A versioned interface and unversioned package is an error - assert!(ComponentDependencies::deserialize(toml! { - "foo:bar/baz@0.1.0" = "0.1.0" - "foo:bar" = "0.1.0" - }) - .unwrap() - .validate() - .is_err()); + assert!( + ComponentDependencies::deserialize(toml! { + "foo:bar/baz@0.1.0" = "0.1.0" + "foo:bar" = "0.1.0" + }) + .unwrap() + .validate() + .is_err() + ); // An unversioned interface and versioned package is an error - assert!(ComponentDependencies::deserialize(toml! { - "foo:bar/baz" = "0.1.0" - "foo:bar@0.1.0" = "0.1.0" - }) - .unwrap() - .validate() - .is_err()); + assert!( + ComponentDependencies::deserialize(toml! { + "foo:bar/baz" = "0.1.0" + "foo:bar@0.1.0" = "0.1.0" + }) + .unwrap() + .validate() + .is_err() + ); // An unversioned interface and unversioned package is an error - assert!(ComponentDependencies::deserialize(toml! { - "foo:bar/baz" = "0.1.0" - "foo:bar" = "0.1.0" - }) - .unwrap() - .validate() - .is_err()); + assert!( + ComponentDependencies::deserialize(toml! { + "foo:bar/baz" = "0.1.0" + "foo:bar" = "0.1.0" + }) + .unwrap() + .validate() + .is_err() + ); } fn normalized_component( diff --git a/crates/oci/src/auth.rs b/crates/oci/src/auth.rs index 82b28525a1..0b069c7317 100644 --- a/crates/oci/src/auth.rs +++ b/crates/oci/src/auth.rs @@ -3,7 +3,7 @@ use std::{ path::{Path, PathBuf}, }; -use anyhow::{bail, Context, Result}; +use anyhow::{Context, Result, bail}; use oci_distribution::secrets::RegistryAuth; use serde::{Deserialize, Serialize}; use spin_common::ui::quoted_path; diff --git a/crates/oci/src/client.rs b/crates/oci/src/client.rs index 3036659a71..04fde4fe81 100644 --- a/crates/oci/src/client.rs +++ b/crates/oci/src/client.rs @@ -3,22 +3,22 @@ use std::collections::{BTreeMap, HashMap}; use std::path::{Path, PathBuf}; -use anyhow::{bail, Context, Result}; +use anyhow::{Context, Result, bail}; use docker_credential::DockerCredential; use futures_util::future; use futures_util::stream::{self, StreamExt, TryStreamExt}; use itertools::Itertools; use oci_distribution::{ - client::ImageLayer, config::ConfigFile, manifest::OciImageManifest, secrets::RegistryAuth, - token_cache::RegistryTokenType, Reference, RegistryOperation, + Reference, RegistryOperation, client::ImageLayer, config::ConfigFile, + manifest::OciImageManifest, secrets::RegistryAuth, token_cache::RegistryTokenType, }; use reqwest::Url; use spin_common::sha256; use spin_common::ui::quoted_path; use spin_common::url::parse_file_url; use spin_compose::ComponentSourceLoaderFs; -use spin_loader::cache::Cache; use spin_loader::FilesMountStrategy; +use spin_loader::cache::Cache; use spin_locked_app::locked::{ContentPath, ContentRef, LockedApp, LockedComponent}; use tokio::fs; use walkdir::WalkDir; @@ -844,7 +844,7 @@ fn all_annotations( explicit: Option>, predefined: InferPredefinedAnnotations, ) -> Option> { - use spin_locked_app::{MetadataKey, APP_DESCRIPTION_KEY, APP_NAME_KEY, APP_VERSION_KEY}; + use spin_locked_app::{APP_DESCRIPTION_KEY, APP_NAME_KEY, APP_VERSION_KEY, MetadataKey}; const APP_AUTHORS_KEY: MetadataKey> = MetadataKey::new("authors"); if predefined == InferPredefinedAnnotations::None { @@ -902,10 +902,10 @@ fn all_annotations( } fn add_inferred(map: &mut BTreeMap, key: &str, value: Option) { - if let Some(value) = value { - if let std::collections::btree_map::Entry::Vacant(e) = map.entry(key.to_string()) { - e.insert(value); - } + if let Some(value) = value + && let std::collections::btree_map::Entry::Vacant(e) = map.entry(key.to_string()) + { + e.insert(value); } } diff --git a/crates/oci/src/loader.rs b/crates/oci/src/loader.rs index cc82eb9b7c..bbe6b650c2 100644 --- a/crates/oci/src/loader.rs +++ b/crates/oci/src/loader.rs @@ -1,6 +1,6 @@ use std::path::{Path, PathBuf}; -use anyhow::{anyhow, ensure, Context, Result}; +use anyhow::{Context, Result, anyhow, ensure}; use oci_distribution::Reference; use reqwest::Url; use spin_common::ui::quoted_path; diff --git a/crates/oci/src/validate.rs b/crates/oci/src/validate.rs index a28ccc3893..bb896f793b 100644 --- a/crates/oci/src/validate.rs +++ b/crates/oci/src/validate.rs @@ -1,4 +1,4 @@ -use anyhow::{bail, Context, Result}; +use anyhow::{Context, Result, bail}; use spin_common::{ui::quoted_path, url::parse_file_url}; use spin_locked_app::locked::{LockedComponent, LockedComponentSource}; diff --git a/crates/outbound-networking-config/src/allowed_hosts.rs b/crates/outbound-networking-config/src/allowed_hosts.rs index 3a6fc91a04..07544a5578 100644 --- a/crates/outbound-networking-config/src/allowed_hosts.rs +++ b/crates/outbound-networking-config/src/allowed_hosts.rs @@ -1,7 +1,7 @@ use std::ops::Range; use std::sync::Arc; -use anyhow::{bail, ensure, Context as _}; +use anyhow::{Context as _, bail, ensure}; use futures_util::future::{BoxFuture, Shared}; use spin_expressions::Resolver; use url::Host; @@ -118,8 +118,12 @@ impl AllowedHostConfig { let url = original.trim(); let Some((scheme, rest)) = url.split_once("://") else { match url { - "*" | ":" | "" | "?" => bail!("{url:?} is not an allowed outbound host format.\nHosts must be in the form ://[:], with '*' wildcards allowed for each.\nIf you intended to allow all outbound networking, you can use '*://*:*' - this will obviate all network sandboxing.\nLearn more: https://spinframework.dev/v3/http-outbound#granting-http-permissions-to-components"), - _ => bail!("{url:?} does not contain a scheme (e.g., 'http://' or '*://')\nLearn more: https://spinframework.dev/v3/http-outbound#granting-http-permissions-to-components"), + "*" | ":" | "" | "?" => bail!( + "{url:?} is not an allowed outbound host format.\nHosts must be in the form ://[:], with '*' wildcards allowed for each.\nIf you intended to allow all outbound networking, you can use '*://*:*' - this will obviate all network sandboxing.\nLearn more: https://spinframework.dev/v3/http-outbound#granting-http-permissions-to-components" + ), + _ => bail!( + "{url:?} does not contain a scheme (e.g., 'http://' or '*://')\nLearn more: https://spinframework.dev/v3/http-outbound#granting-http-permissions-to-components" + ), } }; let (host, rest) = rest.rsplit_once(':').unwrap_or((rest, "")); @@ -488,7 +492,9 @@ impl AllowedHostsConfig { /// templated values. fn parse_partial>(hosts: &[S]) -> anyhow::Result> { if hosts.len() == 1 && hosts[0].as_ref() == "insecure:allow-all" { - bail!("'insecure:allow-all' is not allowed - use '*://*:*' instead if you really want to allow all outbound traffic'") + bail!( + "'insecure:allow-all' is not allowed - use '*://*:*' instead if you really want to allow all outbound traffic'" + ) } let mut allowed = Vec::with_capacity(hosts.len()); for host in hosts { @@ -928,12 +934,14 @@ mod test { assert!( AllowedHostsConfig::parse(&["insecure:allow-all"], &dummy_resolver(), &[]).is_err() ); - assert!(AllowedHostsConfig::parse( - &["spin.fermyon.dev", "insecure:allow-all"], - &dummy_resolver(), - &[] - ) - .is_err()); + assert!( + AllowedHostsConfig::parse( + &["spin.fermyon.dev", "insecure:allow-all"], + &dummy_resolver(), + &[] + ) + .is_err() + ); } #[test] @@ -991,13 +999,22 @@ mod test { assert!( allowed.allows(&OutboundUrl::parse("http://a.b.example.com/foo/bar", "http").unwrap()) ); - assert!(allowed - .allows(&OutboundUrl::parse("http://a.b.example2.com:8383/foo/bar", "http").unwrap())); - assert!(!allowed - .allows(&OutboundUrl::parse("http://a.b.example2.com/foo/bar", "http").unwrap())); - assert!(!allowed.allows(&OutboundUrl::parse("http://example.com/foo/bar", "http").unwrap())); - assert!(!allowed - .allows(&OutboundUrl::parse("http://example.com:8383/foo/bar", "http").unwrap())); + assert!( + allowed.allows( + &OutboundUrl::parse("http://a.b.example2.com:8383/foo/bar", "http").unwrap() + ) + ); + assert!( + !allowed + .allows(&OutboundUrl::parse("http://a.b.example2.com/foo/bar", "http").unwrap()) + ); + assert!( + !allowed.allows(&OutboundUrl::parse("http://example.com/foo/bar", "http").unwrap()) + ); + assert!( + !allowed + .allows(&OutboundUrl::parse("http://example.com:8383/foo/bar", "http").unwrap()) + ); assert!( !allowed.allows(&OutboundUrl::parse("http://myexample.com/foo/bar", "http").unwrap()) ); @@ -1010,9 +1027,14 @@ mod test { assert!( allowed.allows(&OutboundUrl::parse("mysql://user:pass#word@xyz.com", "mysql").unwrap()) ); - assert!(allowed - .allows(&OutboundUrl::parse("mysql://user%3Apass%23word@xyz.com", "mysql").unwrap())); - assert!(allowed.allows(&OutboundUrl::parse("user%3Apass%23word@xyz.com", "mysql").unwrap())); + assert!( + allowed.allows( + &OutboundUrl::parse("mysql://user%3Apass%23word@xyz.com", "mysql").unwrap() + ) + ); + assert!( + allowed.allows(&OutboundUrl::parse("user%3Apass%23word@xyz.com", "mysql").unwrap()) + ); } #[test] diff --git a/crates/plugins/src/badger/mod.rs b/crates/plugins/src/badger/mod.rs index 05f9b8b5ea..f4cbf9a852 100644 --- a/crates/plugins/src/badger/mod.rs +++ b/crates/plugins/src/badger/mod.rs @@ -128,7 +128,9 @@ impl BadgerEvaluator { fn fire_and_forget_update() { if let Err(e) = Self::fire_and_forget_update_impl() { - tracing::info!("Failed to launch plugins update process; checking using latest local repo anyway. Error: {e:#}"); + tracing::info!( + "Failed to launch plugins update process; checking using latest local repo anyway. Error: {e:#}" + ); } } @@ -166,18 +168,15 @@ impl BadgerEvaluator { } async fn available_upgrades(&self) -> anyhow::Result { - let store = self.plugin_manager.store(); + let catalogue = self.plugin_manager.catalogue(); let latest_version = { - let latest_lookup = crate::lookup::PluginLookup::new(&self.plugin_name, None); - let latest_manifest = latest_lookup - .resolve_manifest_exact(store.get_plugins_directory()) - .await - .ok(); + let latest_lookup = crate::lookup::PluginRef::new(&self.plugin_name, None); + let latest_manifest = latest_lookup.resolve_manifest_exact(&catalogue).await.ok(); latest_manifest.and_then(|m| semver::Version::parse(m.version()).ok()) }; - let manifests = store.catalogue_manifests()?; + let manifests = catalogue.manifests()?; let relevant_manifests = manifests .into_iter() .filter(|m| m.name() == self.plugin_name); diff --git a/crates/plugins/src/catalogue.rs b/crates/plugins/src/catalogue.rs new file mode 100644 index 0000000000..897c1d5ee7 --- /dev/null +++ b/crates/plugins/src/catalogue.rs @@ -0,0 +1,108 @@ +use crate::{git::GitSource, manifest::PluginManifest}; +use anyhow::Context; +use semver::Version; +use std::path::{Path, PathBuf}; +use url::Url; + +const SPIN_PLUGINS_REPO: &str = "https://github.com/spinframework/spin-plugins/"; + +pub(crate) fn plugins_repo_url() -> Result { + Url::parse(SPIN_PLUGINS_REPO) +} + +/// The local clone of the spin-plugins repo. +pub struct Catalogue { + git_root: PathBuf, + manifests_root: PathBuf, +} + +// Name of directory containing the installed manifests +const LOCAL_CATALOGUE_MANIFESTS_DIRECTORY: &str = "manifests"; + +impl Catalogue { + pub fn new(git_root: PathBuf) -> Self { + let manifests_root = git_root.join(LOCAL_CATALOGUE_MANIFESTS_DIRECTORY); + Self { + git_root, + manifests_root, + } + } + + pub fn manifests(&self) -> anyhow::Result> { + // Structure: + // CATALOGUE_DIR (spin/plugins/.spin-plugins/manifests) + // |- foo + // | |- foo@0.1.2.json + // | |- foo@1.2.3.json + // | |- foo.json + // |- bar + // |- bar.json + let catalogue_manifests_dir = &self.manifests_root; + + // Catalogue directory doesn't exist so likely nothing has been installed. + if !catalogue_manifests_dir.exists() { + return Ok(Vec::new()); + } + + let plugin_dirs = catalogue_manifests_dir + .read_dir() + .with_context(|| format!("reading manifest catalogue at {catalogue_manifests_dir:?}"))? + .filter_map(|d| d.ok()) + .map(|d| d.path()) + .filter(|p| p.is_dir()); + let manifest_paths = plugin_dirs.flat_map(|path| crate::util::json_files_in(&path)); + let manifests: Vec<_> = manifest_paths + .filter_map(|path| crate::util::try_read_manifest_from(&path)) + .collect(); + Ok(manifests) + } + + /// Get expected path to the manifest of a plugin with a given name + /// and version within the spin-plugins repository + pub(crate) fn manifest_path( + &self, + plugin_name: &str, + plugin_version: &Option, + ) -> PathBuf { + self.manifests_root + .join(plugin_name) + .join(crate::util::manifest_file_name_version( + plugin_name, + plugin_version, + )) + } + + /// Clones or pulls the spin-plugins repo as required. THIS IS NOT SYNCHRONISED + /// and should be used only if you know nothing else is updating the working + /// copy at the same time: generally, prefer `PluginManager::update()` which + /// checks for contention. + pub(crate) async fn fetch_from_remote(&self, repo_url: &Url) -> anyhow::Result<()> { + let git_root = &self.git_root; + let git_source = GitSource::new(repo_url, None, git_root); + if accept_as_repo(git_root) { + git_source.pull().await?; + } else { + git_source.clone_repo().await?; + } + Ok(()) + } + + pub(crate) async fn ensure_inited(&self, repo_url: &Url) -> anyhow::Result<()> { + let git_root = &self.git_root; + let git_source = GitSource::new(repo_url, None, git_root); + if !accept_as_repo(git_root) { + git_source.clone_repo().await?; + } + Ok(()) + } +} + +#[cfg(not(test))] +fn accept_as_repo(git_root: &Path) -> bool { + git_root.join(".git").exists() +} + +#[cfg(test)] +fn accept_as_repo(git_root: &Path) -> bool { + git_root.join(".git").exists() || git_root.join("_spin_test_dot_git").exists() +} diff --git a/crates/plugins/src/lib.rs b/crates/plugins/src/lib.rs index 0759eaf9fc..6974458933 100644 --- a/crates/plugins/src/lib.rs +++ b/crates/plugins/src/lib.rs @@ -1,11 +1,16 @@ pub mod badger; +mod catalogue; pub mod error; mod git; -pub mod lookup; +mod lookup; pub mod manager; pub mod manifest; mod store; -pub use store::PluginStore; +mod util; + +pub use catalogue::Catalogue; +pub use lookup::PluginRef; +pub use manager::PluginManager; /// List of Spin internal subcommands pub(crate) const SPIN_INTERNAL_COMMANDS: &[&str] = &[ diff --git a/crates/plugins/src/lookup.rs b/crates/plugins/src/lookup.rs index c0fdf7235f..b0d402864e 100644 --- a/crates/plugins/src/lookup.rs +++ b/crates/plugins/src/lookup.rs @@ -1,27 +1,14 @@ -use crate::{error::*, git::GitSource, manifest::PluginManifest, store::manifest_file_name}; +use crate::{Catalogue, catalogue::plugins_repo_url, error::*, manifest::PluginManifest}; use semver::Version; -use std::{ - fs::File, - path::{Path, PathBuf}, -}; -use url::Url; - -// Name of directory that contains the cloned centralized Spin plugins -// repository -const PLUGINS_REPO_LOCAL_DIRECTORY: &str = ".spin-plugins"; - -// Name of directory containing the installed manifests -pub(crate) const PLUGINS_REPO_MANIFESTS_DIRECTORY: &str = "manifests"; - -pub(crate) const SPIN_PLUGINS_REPO: &str = "https://github.com/spinframework/spin-plugins/"; +use std::fs::File; /// Looks up plugin manifests in centralized spin plugin repository. -pub struct PluginLookup { +pub struct PluginRef { pub name: String, pub version: Option, } -impl PluginLookup { +impl PluginRef { pub fn new(name: &str, version: Option) -> Self { Self { name: name.to_lowercase(), @@ -29,13 +16,17 @@ impl PluginLookup { } } - pub async fn resolve_manifest( + /// This looks up this reference in the current snapshot, but if the reference + /// is missing or incompatible with the given version of Spin and the current OS + /// and processor environment, then it tries to find a fallback version + /// in the snapshot that *will* work. This is the "eager to please" resolver. + pub(crate) async fn resolve_manifest( &self, - plugins_dir: &Path, + catalogue: &Catalogue, skip_compatibility_check: bool, spin_version: &str, ) -> PluginLookupResult { - let exact = self.resolve_manifest_exact(plugins_dir).await?; + let exact = self.resolve_manifest_exact(catalogue).await?; if skip_compatibility_check || self.version.is_some() || exact.is_compatible_spin_version(spin_version) @@ -43,10 +34,8 @@ impl PluginLookup { return Ok(exact); } - let store = crate::store::PluginStore::new(plugins_dir.to_owned()); - // TODO: This is very similar to some logic in the badger module - look for consolidation opportunities. - let manifests = store.catalogue_manifests()?; + let manifests = catalogue.manifests()?; let relevant_manifests = manifests.into_iter().filter(|m| m.name() == self.name); let compatible_manifests = relevant_manifests .filter(|m| m.has_compatible_package() && m.is_compatible_spin_version(spin_version)); @@ -56,29 +45,30 @@ impl PluginLookup { Ok(highest_compatible_manifest.unwrap_or(exact)) } - pub async fn resolve_manifest_exact( + /// This looks up this **exact** reference in the current snapshot. The snapshot + /// will not be refreshed, but it may be initialised if it does not yet exist. + /// Compatibility is not considered; no alternative versions are considered. + pub(crate) async fn resolve_manifest_exact( &self, - plugins_dir: &Path, + catalogue: &Catalogue, ) -> PluginLookupResult { let url = plugins_repo_url()?; tracing::info!("Pulling manifest for plugin {} from {url}", self.name); - fetch_plugins_repo(&url, plugins_dir, false) - .await - .map_err(|e| { - Error::ConnectionFailed(ConnectionFailedError::new(url.to_string(), e.to_string())) - })?; + catalogue.ensure_inited(&url).await.map_err(|e| { + Error::ConnectionFailed(ConnectionFailedError::new(url.to_string(), e.to_string())) + })?; - self.resolve_manifest_exact_from_good_repo(plugins_dir) + self.resolve_manifest_exact_from_good_repo(catalogue) } // This is split from resolve_manifest_exact because it may recurse (once) and that makes // Rust async sad. So we move the potential recursion to a sync helper. #[allow(clippy::let_and_return)] - pub fn resolve_manifest_exact_from_good_repo( + fn resolve_manifest_exact_from_good_repo( &self, - plugins_dir: &Path, + catalogue: &Catalogue, ) -> PluginLookupResult { - let expected_path = spin_plugins_repo_manifest_path(&self.name, &self.version, plugins_dir); + let expected_path = catalogue.manifest_path(&self.name, &self.version); let not_found = |e: std::io::Error| { Err(Error::NotFound(NotFoundError::new( @@ -100,7 +90,7 @@ impl PluginLookup { // If a user has asked for a version by number, and the path doesn't exist, // it _might_ be because it's the latest version. This checks for that case. let latest = Self::new(&self.name, None); - match latest.resolve_manifest_exact_from_good_repo(plugins_dir) { + match latest.resolve_manifest_exact_from_good_repo(catalogue) { Ok(manifest) if manifest.try_version().ok() == self.version => Ok(manifest), _ => not_found(e), } @@ -112,74 +102,16 @@ impl PluginLookup { } } -pub fn plugins_repo_url() -> Result { - Url::parse(SPIN_PLUGINS_REPO) -} - -#[cfg(not(test))] -fn accept_as_repo(git_root: &Path) -> bool { - git_root.join(".git").exists() -} - -#[cfg(test)] -fn accept_as_repo(git_root: &Path) -> bool { - git_root.join(".git").exists() || git_root.join("_spin_test_dot_git").exists() -} - -pub async fn fetch_plugins_repo( - repo_url: &Url, - plugins_dir: &Path, - update: bool, -) -> anyhow::Result<()> { - let git_root = plugin_manifests_repo_path(plugins_dir); - let git_source = GitSource::new(repo_url, None, &git_root); - if accept_as_repo(&git_root) { - if update { - git_source.pull().await?; - } - } else { - git_source.clone_repo().await?; - } - Ok(()) -} - -fn plugin_manifests_repo_path(plugins_dir: &Path) -> PathBuf { - plugins_dir.join(PLUGINS_REPO_LOCAL_DIRECTORY) -} - -// Given a name and option version, outputs expected file name for the plugin. -fn manifest_file_name_version(plugin_name: &str, version: &Option) -> String { - match version { - Some(v) => format!("{plugin_name}@{v}.json"), - None => manifest_file_name(plugin_name), - } -} - -/// Get expected path to the manifest of a plugin with a given name -/// and version within the spin-plugins repository -fn spin_plugins_repo_manifest_path( - plugin_name: &str, - plugin_version: &Option, - plugins_dir: &Path, -) -> PathBuf { - spin_plugins_repo_manifest_dir(plugins_dir) - .join(plugin_name) - .join(manifest_file_name_version(plugin_name, plugin_version)) -} - -pub fn spin_plugins_repo_manifest_dir(plugins_dir: &Path) -> PathBuf { - plugins_dir - .join(PLUGINS_REPO_LOCAL_DIRECTORY) - .join(PLUGINS_REPO_MANIFESTS_DIRECTORY) -} - fn null_version() -> semver::Version { semver::Version::new(0, 0, 0) } #[cfg(test)] mod tests { + use std::path::PathBuf; + use super::*; + use crate::store::PluginStore; const TEST_NAME: &str = "some-spin-ver-some-not"; const TESTS_STORE_DIR: &str = "tests"; @@ -188,25 +120,29 @@ mod tests { PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(TESTS_STORE_DIR) } + fn tests_store() -> Catalogue { + PluginStore::new(tests_store_dir()).catalogue() + } + #[tokio::test] async fn if_no_version_given_and_latest_is_compatible_then_latest() -> PluginLookupResult<()> { - let lookup = PluginLookup::new(TEST_NAME, None); + let lookup = PluginRef::new(TEST_NAME, None); let resolved = lookup - .resolve_manifest(&tests_store_dir(), false, "99.0.0") + .resolve_manifest(&tests_store(), false, "99.0.0") .await?; assert_eq!("99.0.1", resolved.version); Ok(()) } #[tokio::test] - async fn if_no_version_given_and_latest_is_not_compatible_then_highest_compatible( - ) -> PluginLookupResult<()> { + async fn if_no_version_given_and_latest_is_not_compatible_then_highest_compatible() + -> PluginLookupResult<()> { // NOTE: The setup assumes you are NOT running Windows on aarch64, so as to check 98.1.0 is not // offered. If that assumption fails then this test will fail with actual version being 98.1.0. // (We use this combination because the OS and architecture enums don't allow for fake operating systems!) - let lookup = PluginLookup::new(TEST_NAME, None); + let lookup = PluginRef::new(TEST_NAME, None); let resolved = lookup - .resolve_manifest(&tests_store_dir(), false, "98.0.0") + .resolve_manifest(&tests_store(), false, "98.0.0") .await?; assert_eq!("98.0.0", resolved.version); Ok(()) @@ -214,9 +150,9 @@ mod tests { #[tokio::test] async fn if_version_given_it_gets_used_regardless() -> PluginLookupResult<()> { - let lookup = PluginLookup::new(TEST_NAME, Some(semver::Version::parse("99.0.0").unwrap())); + let lookup = PluginRef::new(TEST_NAME, Some(semver::Version::parse("99.0.0").unwrap())); let resolved = lookup - .resolve_manifest(&tests_store_dir(), false, "98.0.0") + .resolve_manifest(&tests_store(), false, "98.0.0") .await?; assert_eq!("99.0.0", resolved.version); Ok(()) @@ -224,9 +160,9 @@ mod tests { #[tokio::test] async fn if_latest_version_given_it_gets_used_regardless() -> PluginLookupResult<()> { - let lookup = PluginLookup::new(TEST_NAME, Some(semver::Version::parse("99.0.1").unwrap())); + let lookup = PluginRef::new(TEST_NAME, Some(semver::Version::parse("99.0.1").unwrap())); let resolved = lookup - .resolve_manifest(&tests_store_dir(), false, "98.0.0") + .resolve_manifest(&tests_store(), false, "98.0.0") .await?; assert_eq!("99.0.1", resolved.version); Ok(()) @@ -234,9 +170,9 @@ mod tests { #[tokio::test] async fn if_no_version_given_but_skip_compat_then_highest() -> PluginLookupResult<()> { - let lookup = PluginLookup::new(TEST_NAME, None); + let lookup = PluginRef::new(TEST_NAME, None); let resolved = lookup - .resolve_manifest(&tests_store_dir(), true, "98.0.0") + .resolve_manifest(&tests_store(), true, "98.0.0") .await?; assert_eq!("99.0.1", resolved.version); Ok(()) @@ -244,9 +180,9 @@ mod tests { #[tokio::test] async fn if_non_existent_version_given_then_error() -> PluginLookupResult<()> { - let lookup = PluginLookup::new(TEST_NAME, Some(semver::Version::parse("177.7.7").unwrap())); + let lookup = PluginRef::new(TEST_NAME, Some(semver::Version::parse("177.7.7").unwrap())); lookup - .resolve_manifest(&tests_store_dir(), true, "99.0.0") + .resolve_manifest(&tests_store(), true, "99.0.0") .await .expect_err("Should have errored because plugin v177.7.7 does not exist"); Ok(()) diff --git a/crates/plugins/src/manager.rs b/crates/plugins/src/manager.rs index 7b1e34eb94..854ecb7592 100644 --- a/crates/plugins/src/manager.rs +++ b/crates/plugins/src/manager.rs @@ -1,23 +1,23 @@ use crate::{ + SPIN_INTERNAL_COMMANDS, error::*, - lookup::PluginLookup, - manifest::{warn_unsupported_version, PluginManifest, PluginPackage}, + lookup::PluginRef, + manifest::{PluginManifest, PluginPackage, warn_unsupported_version}, store::PluginStore, - SPIN_INTERNAL_COMMANDS, }; -use anyhow::{anyhow, bail, Context, Result}; +use anyhow::{Context, Result, anyhow, bail}; use path_absolutize::Absolutize; -use reqwest::{header::HeaderMap, Client}; +use reqwest::{Client, header::HeaderMap}; use serde::Serialize; use spin_common::sha256; use std::{ cmp::Ordering, fs::{self, File}, - io::{copy, Cursor}, + io::{Cursor, copy}, path::{Path, PathBuf}, }; -use tempfile::{tempdir, TempDir}; +use tempfile::{TempDir, tempdir}; use url::Url; // Url scheme prefix of a plugin that is installed from a local source @@ -30,7 +30,7 @@ pub enum ManifestLocation { /// Plugin manifest should be pulled from a specific address. Remote(Url), /// Plugin manifest lives in the centralized plugins repository - PluginsRepository(PluginLookup), + PluginsRepository(PluginRef), } impl ManifestLocation { @@ -61,7 +61,11 @@ pub(crate) enum RawInstallRecord { Local { file: PathBuf }, } -/// Provides accesses to functionality to inspect and manage the installation of plugins. +/// The entry point for plugin functionality. Use this to list, install, and remove +/// plugins, and to locate plugin binaries for execution. +/// +/// PluginManager also provides access to the catalogue of available manifests via +/// the `catalogue()` function. It also provides for synchronised catalogue updates. pub struct PluginManager { store: PluginStore, } @@ -73,11 +77,6 @@ impl PluginManager { Ok(Self { store }) } - /// Returns the underlying store object - pub fn store(&self) -> &PluginStore { - &self.store - } - /// Installs the Spin plugin with the given manifest If installing a plugin from the centralized /// Spin plugins repository, it fetches the latest contents of the repository and searches for /// the appropriately named and versioned plugin manifest. Parses the plugin manifest to get the @@ -131,11 +130,31 @@ impl PluginManager { Ok(plugin_manifest.name()) } + /// Installs the latest (default) version of the given plugin from the + /// catalogue, checking for compatbility against the given Spin version + /// (unfortunately we can't infer this because this is a crate not a command). + /// + /// This is roughly equivalent to `spin plugins install ` with no options. + pub async fn install_latest(&self, name: &str, spin_version: &str) -> anyhow::Result { + let manifest_location = ManifestLocation::PluginsRepository(PluginRef { + name: name.to_string(), + version: None, + }); + let plugin_manifest = self + .get_manifest(&manifest_location, false, spin_version, &None) + .await?; + let plugin_package = plugin_manifest + .get_package() + .context("Plugin does not contain a compatible package")?; + self.install(&plugin_manifest, plugin_package, &manifest_location, &None) + .await + } + /// Uninstalls a plugin with a given name, removing it and it's manifest from the local plugins /// directory. /// Returns true if plugin was successfully uninstalled and false if plugin did not exist. pub fn uninstall(&self, plugin_name: &str) -> Result { - let plugin_store = self.store(); + let plugin_store = &self.store; let manifest_file = plugin_store.installed_manifest_path(plugin_name); let exists = manifest_file.exists(); if exists { @@ -168,7 +187,7 @@ impl PluginManager { } // Disallow reinstalling identical plugins and downgrading unless permitted. - if let Ok(installed) = self.store.read_plugin_manifest(&plugin_manifest.name()) { + if let Ok(installed) = self.get_installed_manifest(&plugin_manifest.name()) { if &installed == plugin_manifest { return Ok(InstallAction::NoAction { name: plugin_manifest.name(), @@ -251,30 +270,146 @@ impl PluginManager { } ManifestLocation::PluginsRepository(lookup) => { lookup - .resolve_manifest( - self.store().get_plugins_directory(), - skip_compatibility_check, - spin_version, - ) + .resolve_manifest(&self.catalogue(), skip_compatibility_check, spin_version) .await? } }; Ok(plugin_manifest) } - pub async fn update_lock(&self) -> PluginManagerUpdateLock { + /// Returns the PluginManifest for an installed plugin with a given name. + /// Looks up and parses the JSON plugin manifest file into object form. + pub fn get_installed_manifest(&self, plugin_name: &str) -> PluginLookupResult { + let manifest_path = self.store.installed_manifest_path(plugin_name); + tracing::info!("Reading plugin manifest from {}", manifest_path.display()); + let manifest_file = File::open(manifest_path.clone()).map_err(|e| { + Error::NotFound(NotFoundError::new( + Some(plugin_name.to_string()), + manifest_path.display().to_string(), + e.to_string(), + )) + })?; + let manifest = serde_json::from_reader(manifest_file).map_err(|e| { + Error::InvalidManifest(InvalidManifestError::new( + Some(plugin_name.to_string()), + manifest_path.display().to_string(), + e.to_string(), + )) + })?; + Ok(manifest) + } + + pub fn is_empty(&self) -> bool { + let manifests_dir = self.store.installed_manifests_directory(); + if !manifests_dir.exists() { + return true; + } + let Ok(mut rd) = manifests_dir.read_dir() else { + return true; + }; + rd.next().is_none() + } + + pub fn installed_plugins(&self) -> anyhow::Result> { + let manifests_dir = self.store.installed_manifests_directory(); + let manifest_paths = crate::util::json_files_in(&manifests_dir); + let manifests = manifest_paths + .iter() + .filter_map(|path| crate::util::try_read_manifest_from(path)) + .collect(); + Ok(manifests) + } + + pub async fn installed_plugins_latest_versions( + &self, + skip_compatibility_check: bool, + spin_version: &str, + auth_header_value: &Option, + ) -> anyhow::Result> { + let mut plugins = vec![]; + + let manifests_dir = self.store.installed_manifests_directory(); + + for plugin in std::fs::read_dir(manifests_dir)? { + let path = plugin?.path(); + let name = path + .file_stem() + .ok_or_else(|| anyhow!("No stem for path {}", path.display()))? + .to_str() + .ok_or_else(|| anyhow!("Cannot convert path {} stem to str", path.display()))? + .to_string(); + let manifest_location = + ManifestLocation::PluginsRepository(PluginRef::new(&name, None)); + let manifest = match self + .get_manifest( + &manifest_location, + skip_compatibility_check, + spin_version, + auth_header_value, + ) + .await + { + Err(Error::NotFound(e)) => { + tracing::info!("Could not upgrade plugin '{name}': {e:?}"); + continue; + } + Err(e) => return Err(e.into()), + Ok(m) => m, + }; + + plugins.push((manifest, manifest_location)); + } + + Ok(plugins) + } + + pub fn is_installed(&self, plugin_name: &str) -> bool { + self.installed_plugins() + .unwrap_or_default() + .iter() + .any(|m| m.name() == plugin_name) + } + + pub fn is_installed_exact(&self, manifest: &PluginManifest) -> bool { + match self.get_installed_manifest(&manifest.name()) { + Ok(m) => m.eq(manifest), + Err(_) => false, + } + } + + pub async fn update(&self) -> Result<()> { + let mut locker = self.update_lock().await; + let guard = locker.lock_updates(); + if guard.denied() { + anyhow::bail!("Another plugin update operation is already in progress"); + } + + let url = crate::catalogue::plugins_repo_url()?; + self.catalogue().fetch_from_remote(&url).await?; + Ok(()) + } + + async fn update_lock(&self) -> PluginManagerUpdateLock { let lock = self.update_lock_impl().await; PluginManagerUpdateLock::from(lock) } async fn update_lock_impl(&self) -> anyhow::Result> { - let plugins_dir = self.store().get_plugins_directory(); + let plugins_dir = self.store.get_plugins_directory(); tokio::fs::create_dir_all(plugins_dir).await?; let file = tokio::fs::File::create(plugins_dir.join(".updatelock")).await?; let locker = fd_lock::RwLock::new(file); Ok(locker) } + pub fn catalogue(&self) -> crate::Catalogue { + self.store.catalogue() + } + + pub fn installed_binary_path(&self, plugin_name: &str) -> PathBuf { + self.store.installed_binary_path(plugin_name) + } + fn write_install_record(&self, plugin_name: &str, source: &ManifestLocation) { let install_record_path = self.store.installation_record_file(plugin_name); @@ -340,18 +475,6 @@ pub enum InstallAction { NoAction { name: String, version: String }, } -/// Gets the appropriate package for the running OS and Arch if exists -pub fn get_package(plugin_manifest: &PluginManifest) -> Result<&PluginPackage> { - use std::env::consts::{ARCH, OS}; - plugin_manifest - .packages - .iter() - .find(|p| p.os.rust_name() == OS && p.arch.rust_name() == ARCH) - .ok_or_else(|| { - anyhow!("This plugin does not support this OS ({OS}) or architecture ({ARCH}).") - }) -} - async fn download_plugin( name: &str, temp_dir: &TempDir, @@ -367,8 +490,13 @@ async fn download_plugin( .await?; if !plugin_bin.status().is_success() { match plugin_bin.status() { - reqwest::StatusCode::NOT_FOUND => bail!("The download URL specified in the plugin manifest was not found ({target_url} returned HTTP error 404). Please contact the plugin author."), - _ => bail!("HTTP error {} when downloading plugin from {target_url}", plugin_bin.status()), + reqwest::StatusCode::NOT_FOUND => bail!( + "The download URL specified in the plugin manifest was not found ({target_url} returned HTTP error 404). Please contact the plugin author." + ), + _ => bail!( + "HTTP error {} when downloading plugin from {target_url}", + plugin_bin.status() + ), } } diff --git a/crates/plugins/src/manifest.rs b/crates/plugins/src/manifest.rs index ff3d464fd4..76c04f8dd1 100644 --- a/crates/plugins/src/manifest.rs +++ b/crates/plugins/src/manifest.rs @@ -1,12 +1,10 @@ use std::io::IsTerminal; -use anyhow::{anyhow, Context, Result}; +use anyhow::{Context, Result, anyhow}; use semver::{Version, VersionReq}; use serde::{Deserialize, Serialize}; use url::Url; -use crate::PluginStore; - /// Expected schema of a plugin manifest. Should match the latest Spin plugin /// manifest JSON schema: /// @@ -57,15 +55,10 @@ impl PluginManifest { pub fn has_compatible_package(&self) -> bool { self.packages.iter().any(|p| p.matches_current_os_arch()) } + pub fn is_compatible_spin_version(&self, spin_version: &str) -> bool { is_version_compatible_enough(&self.spin_compatibility, spin_version).unwrap_or(false) } - pub fn is_installed_in(&self, store: &PluginStore) -> bool { - match store.read_plugin_manifest(&self.name) { - Ok(m) => m.eq(self), - Err(_) => false, - } - } pub fn try_version(&self) -> Result { semver::Version::parse(&self.version) @@ -73,13 +66,24 @@ impl PluginManifest { // Compares the versions. Returns None if either's version string is invalid semver. pub fn compare_versions(&self, other: &Self) -> Option { - if let Ok(this_version) = self.try_version() { - if let Ok(other_version) = other.try_version() { - return Some(this_version.cmp_precedence(&other_version)); - } + if let Ok(this_version) = self.try_version() + && let Ok(other_version) = other.try_version() + { + return Some(this_version.cmp_precedence(&other_version)); } None } + + /// Gets the appropriate package for the running OS and Arch if exists + pub fn get_package(&self) -> Result<&PluginPackage> { + use std::env::consts::{ARCH, OS}; + self.packages + .iter() + .find(|p| p.os.rust_name() == OS && p.arch.rust_name() == ARCH) + .ok_or_else(|| { + anyhow!("This plugin does not support this OS ({OS}) or architecture ({ARCH}).") + }) + } } /// Describes compatibility and location of a plugin source. @@ -195,16 +199,20 @@ fn inner_warn_unsupported_version( let version = Version::parse(spin_version)?; if !version.pre.is_empty() { if std::io::stderr().is_terminal() && show_warnings { - terminal::warn!("You're using a pre-release version of Spin ({spin_version}). This plugin might not be compatible (supported: {supported_on}). Continuing anyway."); + terminal::warn!( + "You're using a pre-release version of Spin ({spin_version}). This plugin might not be compatible (supported: {supported_on}). Continuing anyway." + ); } } else if override_compatibility_check { if show_warnings { - terminal::warn!("Plugin is not compatible with this version of Spin (supported: {supported_on}, actual: {spin_version}). Check overridden ... continuing to install or execute plugin."); + terminal::warn!( + "Plugin is not compatible with this version of Spin (supported: {supported_on}, actual: {spin_version}). Check overridden ... continuing to install or execute plugin." + ); } } else { return Err(anyhow!( - "Plugin is not compatible with this version of Spin (supported: {supported_on}, actual: {spin_version}). Try running `spin plugins update && spin plugins upgrade --all` to install latest or override with `--override-compatibility-check`." - )); + "Plugin is not compatible with this version of Spin (supported: {supported_on}, actual: {spin_version}). Try running `spin plugins update && spin plugins upgrade --all` to install latest or override with `--override-compatibility-check`." + )); } } Ok(()) diff --git a/crates/plugins/src/store.rs b/crates/plugins/src/store.rs index 0e84aeb2dd..186f80a79f 100644 --- a/crates/plugins/src/store.rs +++ b/crates/plugins/src/store.rs @@ -1,19 +1,22 @@ -use anyhow::{Context, Result}; +use anyhow::Result; use flate2::read::GzDecoder; use spin_common::data_dir::data_dir; use std::{ - ffi::OsStr, fs::{self, File}, path::{Path, PathBuf}, }; use tar::Archive; -use crate::{error::*, manifest::PluginManifest}; +use crate::{Catalogue, manifest::PluginManifest}; /// Directory where the manifests of installed plugins are stored. pub const PLUGIN_MANIFESTS_DIRECTORY_NAME: &str = "manifests"; const INSTALLATION_RECORD_FILE_NAME: &str = ".install.json"; +// Name of directory that contains the cloned centralized Spin plugins +// repository +const LOCAL_SNAPSHOT_PATH_IN_STORE: &str = ".spin-plugins"; + /// Houses utilities for getting the path to Spin plugin directories. pub struct PluginStore { root: PathBuf, @@ -38,6 +41,14 @@ impl PluginStore { self.root.join(plugin_name) } + pub fn installed_binary_path(&self, plugin_name: &str) -> PathBuf { + let mut binary = self.root.join(plugin_name).join(plugin_name); + if cfg!(target_os = "windows") { + binary.set_extension("exe"); + } + binary + } + /// Get the path to the manifests directory which contains the plugin manifests /// of all installed Spin plugins. pub fn installed_manifests_directory(&self) -> PathBuf { @@ -46,15 +57,7 @@ impl PluginStore { pub fn installed_manifest_path(&self, plugin_name: &str) -> PathBuf { self.installed_manifests_directory() - .join(manifest_file_name(plugin_name)) - } - - pub fn installed_binary_path(&self, plugin_name: &str) -> PathBuf { - let mut binary = self.root.join(plugin_name).join(plugin_name); - if cfg!(target_os = "windows") { - binary.set_extension("exe"); - } - binary + .join(crate::util::manifest_file_name(plugin_name)) } pub fn installation_record_file(&self, plugin_name: &str) -> PathBuf { @@ -63,84 +66,9 @@ impl PluginStore { .join(INSTALLATION_RECORD_FILE_NAME) } - pub fn installed_manifests(&self) -> Result> { - let manifests_dir = self.installed_manifests_directory(); - let manifest_paths = Self::json_files_in(&manifests_dir); - let manifests = manifest_paths - .iter() - .filter_map(|path| Self::try_read_manifest_from(path)) - .collect(); - Ok(manifests) - } - - // TODO: report errors on individuals - pub fn catalogue_manifests(&self) -> Result> { - // Structure: - // CATALOGUE_DIR (spin/plugins/.spin-plugins/manifests) - // |- foo - // | |- foo@0.1.2.json - // | |- foo@1.2.3.json - // | |- foo.json - // |- bar - // |- bar.json - let catalogue_dir = - crate::lookup::spin_plugins_repo_manifest_dir(self.get_plugins_directory()); - - // Catalogue directory doesn't exist so likely nothing has been installed. - if !catalogue_dir.exists() { - return Ok(Vec::new()); - } - - let plugin_dirs = catalogue_dir - .read_dir() - .context("reading manifest catalogue at {catalogue_dir:?}")? - .filter_map(|d| d.ok()) - .map(|d| d.path()) - .filter(|p| p.is_dir()); - let manifest_paths = plugin_dirs.flat_map(|path| Self::json_files_in(&path)); - let manifests: Vec<_> = manifest_paths - .filter_map(|path| Self::try_read_manifest_from(&path)) - .collect(); - Ok(manifests) - } - - fn try_read_manifest_from(manifest_path: &Path) -> Option { - let manifest_file = File::open(manifest_path).ok()?; - serde_json::from_reader(manifest_file).ok() - } - - fn json_files_in(dir: &Path) -> Vec { - let json_ext = Some(OsStr::new("json")); - match dir.read_dir() { - Err(_) => vec![], - Ok(rd) => rd - .filter_map(|de| de.ok()) - .map(|de| de.path()) - .filter(|p| p.is_file() && p.extension() == json_ext) - .collect(), - } - } - - /// Returns the PluginManifest for an installed plugin with a given name. - /// Looks up and parses the JSON plugin manifest file into object form. - pub fn read_plugin_manifest(&self, plugin_name: &str) -> PluginLookupResult { - let manifest_path = self.installed_manifest_path(plugin_name); - tracing::info!("Reading plugin manifest from {}", manifest_path.display()); - let manifest_file = File::open(manifest_path.clone()).map_err(|e| { - Error::NotFound(NotFoundError::new( - Some(plugin_name.to_string()), - manifest_path.display().to_string(), - e.to_string(), - )) - })?; - let manifest = serde_json::from_reader(manifest_file).map_err(|e| { - Error::InvalidManifest(InvalidManifestError::new( - Some(plugin_name.to_string()), - manifest_path.display().to_string(), - e.to_string(), - )) - })?; - Ok(manifest) + pub(crate) fn catalogue(&self) -> Catalogue { + let git_root = self.root.join(LOCAL_SNAPSHOT_PATH_IN_STORE); + Catalogue::new(git_root) } pub(crate) fn add_manifest(&self, plugin_manifest: &PluginManifest) -> Result<()> { @@ -170,8 +98,3 @@ impl PluginStore { Ok(()) } } - -/// Given a plugin name, returns the expected file name for the installed manifest -pub fn manifest_file_name(plugin_name: &str) -> String { - format!("{plugin_name}.json") -} diff --git a/crates/plugins/src/util.rs b/crates/plugins/src/util.rs new file mode 100644 index 0000000000..2c510624ec --- /dev/null +++ b/crates/plugins/src/util.rs @@ -0,0 +1,36 @@ +use crate::manifest::PluginManifest; +use std::{ + ffi::OsStr, + fs::File, + path::{Path, PathBuf}, +}; + +pub fn try_read_manifest_from(manifest_path: &Path) -> Option { + let manifest_file = File::open(manifest_path).ok()?; + serde_json::from_reader(manifest_file).ok() +} + +pub fn json_files_in(dir: &Path) -> Vec { + let json_ext = Some(OsStr::new("json")); + match dir.read_dir() { + Err(_) => vec![], + Ok(rd) => rd + .filter_map(|de| de.ok()) + .map(|de| de.path()) + .filter(|p| p.is_file() && p.extension() == json_ext) + .collect(), + } +} + +// Given a name and option version, outputs expected file name for the plugin. +pub fn manifest_file_name_version(plugin_name: &str, version: &Option) -> String { + match version { + Some(v) => format!("{plugin_name}@{v}.json"), + None => manifest_file_name(plugin_name), + } +} + +/// Given a plugin name, returns the expected file name for the installed manifest +pub fn manifest_file_name(plugin_name: &str) -> String { + format!("{plugin_name}.json") +} diff --git a/crates/routes/src/lib.rs b/crates/routes/src/lib.rs index 8d2c159631..3272e3da3e 100644 --- a/crates/routes/src/lib.rs +++ b/crates/routes/src/lib.rs @@ -2,7 +2,7 @@ #![deny(missing_docs)] -use anyhow::{anyhow, Result}; +use anyhow::{Result, anyhow}; use indexmap::IndexMap; use serde::{Deserialize, Serialize}; use std::{borrow::Cow, collections::HashMap, fmt}; @@ -247,11 +247,7 @@ impl RouteInfo for ParsedRoute { ParsedRoute::Exact(path) => path, ParsedRoute::TrailingWildcard(pattern) => pattern, }; - if p.is_empty() { - "/" - } else { - p - } + if p.is_empty() { "/" } else { p } } fn is_wildcard(&self) -> bool { @@ -750,9 +746,11 @@ mod route_tests { .unwrap(); assert_eq!(3, routes.routes().count()); - assert!(!routes - .routes() - .any(|(_r, tcr)| tcr.component_id() == "comp-private")); + assert!( + !routes + .routes() + .any(|(_r, tcr)| tcr.component_id() == "comp-private") + ); } #[test] diff --git a/crates/runtime-config/src/lib.rs b/crates/runtime-config/src/lib.rs index b9b3448be7..f3169beb6a 100644 --- a/crates/runtime-config/src/lib.rs +++ b/crates/runtime-config/src/lib.rs @@ -2,15 +2,15 @@ use std::path::{Path, PathBuf}; use anyhow::Context as _; use spin_common::ui::quoted_path; -use spin_factor_key_value::runtime_config::spin::{self as key_value}; use spin_factor_key_value::KeyValueFactor; -use spin_factor_llm::{spin as llm, LlmFactor}; +use spin_factor_key_value::runtime_config::spin::{self as key_value}; +use spin_factor_llm::{LlmFactor, spin as llm}; use spin_factor_otel::OtelFactor; use spin_factor_outbound_http::OutboundHttpFactor; use spin_factor_outbound_mqtt::OutboundMqttFactor; use spin_factor_outbound_mysql::OutboundMysqlFactor; -use spin_factor_outbound_networking::runtime_config::spin::SpinRuntimeConfig as OutboundNetworkingSpinRuntimeConfig; use spin_factor_outbound_networking::OutboundNetworkingFactor; +use spin_factor_outbound_networking::runtime_config::spin::SpinRuntimeConfig as OutboundNetworkingSpinRuntimeConfig; use spin_factor_outbound_pg::OutboundPgFactor; use spin_factor_outbound_redis::OutboundRedisFactor; use spin_factor_sqlite::SqliteFactor; @@ -18,7 +18,7 @@ use spin_factor_variables::VariablesFactor; use spin_factor_wasi::WasiFactor; use spin_factors::runtime_config::toml::GetTomlValue as _; use spin_factors::{ - runtime_config::toml::TomlKeyTracker, FactorRuntimeConfigSource, RuntimeConfigSourceFinalizer, + FactorRuntimeConfigSource, RuntimeConfigSourceFinalizer, runtime_config::toml::TomlKeyTracker, }; use spin_key_value_spin::{SpinKeyValueRuntimeConfig, SpinKeyValueStore}; use spin_sqlite as sqlite; @@ -568,9 +568,11 @@ mod tests { type = "spin" }; let runtime_config = resolve_toml(toml, "config.toml").unwrap().runtime_config; - assert!(["default", "foo"] - .iter() - .all(|label| runtime_config.has_store_manager(label))); + assert!( + ["default", "foo"] + .iter() + .all(|label| runtime_config.has_store_manager(label)) + ); } #[tokio::test(flavor = "multi_thread", worker_threads = 1)] diff --git a/crates/runtime-factors/src/lib.rs b/crates/runtime-factors/src/lib.rs index a9d8bf3636..078f79a3b6 100644 --- a/crates/runtime-factors/src/lib.rs +++ b/crates/runtime-factors/src/lib.rs @@ -19,7 +19,7 @@ use spin_factor_outbound_pg::OutboundPgFactor; use spin_factor_outbound_redis::OutboundRedisFactor; use spin_factor_sqlite::SqliteFactor; use spin_factor_variables::VariablesFactor; -use spin_factor_wasi::{spin::SpinFilesMounter, WasiFactor}; +use spin_factor_wasi::{WasiFactor, spin::SpinFilesMounter}; use spin_factors::RuntimeFactors; use spin_runtime_config::{ResolvedRuntimeConfig, TomlRuntimeConfigSource}; use spin_variables_static::VariableSource; @@ -77,13 +77,17 @@ fn outbound_networking_factor() -> OutboundNetworkingFactor { let host_pattern = format!("{scheme}://{authority}"); tracing::error!("Outbound network destination not allowed: {host_pattern}"); if scheme.starts_with("http") && authority == "self" { - terminal::warn!("A component tried to make an HTTP request to its own app but it does not have permission."); + terminal::warn!( + "A component tried to make an HTTP request to its own app but it does not have permission." + ); } else { terminal::warn!( "A component tried to make an outbound network connection to disallowed destination '{host_pattern}'." ); }; - eprintln!("To allow this request, add 'allowed_outbound_hosts = [\"{host_pattern}\"]' to the manifest component section."); + eprintln!( + "To allow this request, add 'allowed_outbound_hosts = [\"{host_pattern}\"]' to the manifest component section." + ); } let mut factor = OutboundNetworkingFactor::new(); diff --git a/crates/serde/Cargo.toml b/crates/serde/Cargo.toml index 1c6616ad43..25d8d742e0 100644 --- a/crates/serde/Cargo.toml +++ b/crates/serde/Cargo.toml @@ -7,7 +7,7 @@ edition = { workspace = true } [dependencies] anyhow = { workspace = true } base64 = { workspace = true } -schemars = { version = "0.8.21", features = ["indexmap2", "semver"] } +schemars = { workspace = true } semver = { workspace = true, features = ["serde"] } serde = { workspace = true } wasm-pkg-common = { workspace = true } diff --git a/crates/serde/src/base64.rs b/crates/serde/src/base64.rs index cabff37eef..cf81f4d774 100644 --- a/crates/serde/src/base64.rs +++ b/crates/serde/src/base64.rs @@ -2,8 +2,8 @@ use std::borrow::Cow; -use base64::{engine::GeneralPurpose, prelude::BASE64_STANDARD_NO_PAD, Engine}; -use serde::{de, Deserialize, Deserializer, Serializer}; +use base64::{Engine, engine::GeneralPurpose, prelude::BASE64_STANDARD_NO_PAD}; +use serde::{Deserialize, Deserializer, Serializer, de}; const BASE64: GeneralPurpose = BASE64_STANDARD_NO_PAD; diff --git a/crates/serde/src/dependencies.rs b/crates/serde/src/dependencies.rs index 7c66e21e05..f8a784e43c 100644 --- a/crates/serde/src/dependencies.rs +++ b/crates/serde/src/dependencies.rs @@ -2,6 +2,7 @@ use crate::KebabId; use anyhow::anyhow; +use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use std::str::FromStr; use wasm_pkg_common::package::PackageRef; @@ -80,8 +81,11 @@ impl FromStr for DependencyPackageName { /// Name of an import dependency. /// /// For example: `foo:bar/baz@0.1.0`, `foo:bar/baz`, `foo:bar@0.1.0`, `foo:bar`, `foo-bar`. -#[derive(Clone, Debug, Serialize, Deserialize, Eq, PartialEq, Ord, PartialOrd, Hash)] +#[derive( + Clone, Debug, Serialize, Deserialize, Eq, PartialEq, Ord, PartialOrd, Hash, JsonSchema, +)] #[serde(into = "String", try_from = "String")] +#[schemars(with = "String")] pub enum DependencyName { /// Plain name Plain(KebabId), diff --git a/crates/serde/src/id.rs b/crates/serde/src/id.rs index a577e9658a..98fc00b791 100644 --- a/crates/serde/src/id.rs +++ b/crates/serde/src/id.rs @@ -37,12 +37,12 @@ impl TryFrom for Id return Err("empty".into()); } // Special-case common "wrong separator" errors - if let Some(wrong) = wrong_delim::() { - if id.contains(wrong) { - return Err(format!( - "words must be separated with {DELIM:?}, not {wrong:?}" - )); - } + if let Some(wrong) = wrong_delim::() + && id.contains(wrong) + { + return Err(format!( + "words must be separated with {DELIM:?}, not {wrong:?}" + )); } for word in id.split(DELIM) { if word.is_empty() { @@ -63,7 +63,9 @@ impl TryFrom for Id "{DELIM:?}-separated words may only contain alphanumeric ASCII; got {ch:?}" )); } else if ch.is_ascii_uppercase() != word_is_uppercase { - return Err(format!("{DELIM:?}-separated words must be all lowercase or all UPPERCASE; got {word:?}")); + return Err(format!( + "{DELIM:?}-separated words must be all lowercase or all UPPERCASE; got {word:?}" + )); } } if LOWER && word_is_uppercase { diff --git a/crates/serde/src/version.rs b/crates/serde/src/version.rs index 931fbc89b8..49c2e023a2 100644 --- a/crates/serde/src/version.rs +++ b/crates/serde/src/version.rs @@ -4,7 +4,7 @@ use serde::{Deserialize, Serialize}; /// FixedVersion represents a version integer field with a const value. #[derive(Clone, Debug, Default, Serialize, Deserialize, JsonSchema)] #[serde(into = "usize", try_from = "usize")] -#[schemars(with = "usize", range = (min = V, max = V))] +#[schemars(with = "usize")] pub struct FixedVersion; impl From> for usize { @@ -28,7 +28,7 @@ impl TryFrom for FixedVersion { /// but accepts lower versions during deserialisation. #[derive(Clone, Debug, Default, Deserialize, JsonSchema)] #[serde(into = "usize", try_from = "usize")] -#[schemars(with = "usize", range = (min = 1, max = V))] +#[schemars(with = "usize")] pub struct FixedVersionBackwardCompatible; impl From> for usize { diff --git a/crates/sqlite-inproc/Cargo.toml b/crates/sqlite-inproc/Cargo.toml index 2cb06ae4f3..30f6b77056 100644 --- a/crates/sqlite-inproc/Cargo.toml +++ b/crates/sqlite-inproc/Cargo.toml @@ -8,7 +8,7 @@ edition = { workspace = true } anyhow = { workspace = true } async-trait = { workspace = true } futures = { workspace = true } -rusqlite = { workspace = true, features = ["bundled"] } +rusqlite = { workspace = true, features = ["bundled", "hooks"] } spin-factor-sqlite = { path = "../factor-sqlite" } spin-wasi-async = { path = "../wasi-async" } spin-world = { path = "../world" } diff --git a/crates/sqlite-inproc/src/lib.rs b/crates/sqlite-inproc/src/lib.rs index dc9cedf79e..9272847dea 100644 --- a/crates/sqlite-inproc/src/lib.rs +++ b/crates/sqlite-inproc/src/lib.rs @@ -46,14 +46,19 @@ impl InProcDatabaseLocation { /// A connection to a sqlite database pub struct InProcConnection { location: InProcDatabaseLocation, + allow_attach_file: bool, connection: OnceLock>>, } impl InProcConnection { - pub fn new(location: InProcDatabaseLocation) -> Result { + pub fn new( + location: InProcDatabaseLocation, + allow_attach_file: bool, + ) -> Result { let connection = OnceLock::new(); Ok(Self { location, + allow_attach_file, connection, }) } @@ -74,6 +79,18 @@ impl InProcConnection { InProcDatabaseLocation::Path(path) => rusqlite::Connection::open(path), } .map_err(|e| sqlite::Error::Io(e.to_string()))?; + if !self.allow_attach_file { + connection.authorizer(Some(|ctx: rusqlite::hooks::AuthContext<'_>| { + use rusqlite::hooks::{AuthAction, Authorization}; + match ctx.action { + // Deny attaching files except tempfile ("") and in-memory (":memory:") databases + AuthAction::Attach { filename } if !matches!(filename, "" | ":memory:") => { + Authorization::Deny + } + _ => Authorization::Allow, + } + })); + } Ok(Arc::new(Mutex::new(connection))) } } diff --git a/crates/sqlite/src/lib.rs b/crates/sqlite/src/lib.rs index fa5541f252..f39696e686 100644 --- a/crates/sqlite/src/lib.rs +++ b/crates/sqlite/src/lib.rs @@ -126,7 +126,7 @@ impl RuntimeConfigResolver { .map(|p| p.join(DEFAULT_SQLITE_DB_FILENAME)); let factory = move || { let location = InProcDatabaseLocation::from_path(path.clone())?; - let connection = spin_sqlite_inproc::InProcConnection::new(location)?; + let connection = spin_sqlite_inproc::InProcConnection::new(location, false)?; Ok(Arc::new(connection) as _) }; Arc::new(factory) @@ -140,20 +140,33 @@ const DEFAULT_SQLITE_DB_FILENAME: &str = "sqlite_db.db"; #[serde(deny_unknown_fields)] pub struct InProcDatabase { pub path: Option, + + /// If `false` (the default), disallows `ATTACH`ing an existing file to a + /// database connection. + /// + /// Note: Attaching a new tempfile or `:memory:` database is always allowed. + #[serde(default)] + pub allow_attach_file: bool, } impl InProcDatabase { /// Get a new connection creator for a local database. /// /// `base_dir` is the base directory path from which `path` is resolved if it is a relative path. - fn connection_creator(self, base_dir: &Path) -> anyhow::Result { + fn connection_creator( + self, + base_dir: &Path, + ) -> anyhow::Result { let path = self .path .as_ref() .map(|p| resolve_relative_path(p, base_dir)); let location = InProcDatabaseLocation::from_path(path)?; let factory = move || { - let connection = spin_sqlite_inproc::InProcConnection::new(location.clone())?; + let connection = spin_sqlite_inproc::InProcConnection::new( + location.clone(), + self.allow_attach_file, + )?; Ok(Arc::new(connection) as _) }; Ok(factory) diff --git a/crates/telemetry/Cargo.toml b/crates/telemetry/Cargo.toml index 740b191abe..1651d832c0 100644 --- a/crates/telemetry/Cargo.toml +++ b/crates/telemetry/Cargo.toml @@ -11,6 +11,7 @@ http1 = { version = "1.0.0", package = "http" } opentelemetry = { version = "0.28", features = ["metrics", "trace", "logs"] } opentelemetry-appender-tracing = "0.28" opentelemetry-otlp = { workspace = true, features = ["grpc-tonic"] } +reqwest = { workspace = true } opentelemetry_sdk = { workspace = true, features = ["rt-tokio", "spec_unstable_logs_enabled", "metrics"] } terminal = { path = "../terminal" } tracing = { workspace = true } diff --git a/crates/telemetry/src/alert_in_dev.rs b/crates/telemetry/src/alert_in_dev.rs index b55a41e053..be3dd66544 100644 --- a/crates/telemetry/src/alert_in_dev.rs +++ b/crates/telemetry/src/alert_in_dev.rs @@ -4,14 +4,14 @@ //! want application developers to know they have a problem. use tracing::{Event, Subscriber}; -use tracing_subscriber::{filter::filter_fn, registry::LookupSpan, Layer}; +use tracing_subscriber::{Layer, filter::filter_fn, registry::LookupSpan}; const ALERT_IN_DEV_TAG: &str = "alert_in_dev"; /// A layer which prints a terminal warning (using [terminal::warn!]) if /// a trace event contains the tag "alert_in_dev" (with any value). -pub(crate) fn alert_in_dev_layer LookupSpan<'span> + 'static>( -) -> impl Layer { +pub(crate) fn alert_in_dev_layer LookupSpan<'span> + 'static>() +-> impl Layer { CommandLineAlertingLayer.with_filter(filter_fn(|meta| { meta.fields().field(ALERT_IN_DEV_TAG).is_some() })) diff --git a/crates/telemetry/src/detector.rs b/crates/telemetry/src/detector.rs index c30d99c05c..7b7276ae88 100644 --- a/crates/telemetry/src/detector.rs +++ b/crates/telemetry/src/detector.rs @@ -2,8 +2,8 @@ use std::env; use opentelemetry::{Key, KeyValue, Value}; use opentelemetry_sdk::{ - resource::{EnvResourceDetector, ResourceDetector}, Resource, + resource::{EnvResourceDetector, ResourceDetector}, }; const OTEL_SERVICE_NAME: &str = "OTEL_SERVICE_NAME"; diff --git a/crates/telemetry/src/lib.rs b/crates/telemetry/src/lib.rs index 14650e74a1..6a328e8f55 100644 --- a/crates/telemetry/src/lib.rs +++ b/crates/telemetry/src/lib.rs @@ -5,7 +5,7 @@ use env::otel_logs_enabled; use env::otel_metrics_enabled; use env::otel_tracing_enabled; use opentelemetry_sdk::propagation::TraceContextPropagator; -use tracing_subscriber::{fmt, prelude::*, registry, EnvFilter, Layer}; +use tracing_subscriber::{EnvFilter, Layer, fmt, prelude::*, registry}; mod alert_in_dev; pub mod detector; @@ -108,3 +108,11 @@ pub fn init(spin_version: String) -> anyhow::Result<()> { Ok(()) } + +/// Build a reqwest::Client that explicitly uses rustls as the TLS backend with native root certs. +pub(crate) fn rustls_reqwest_client() -> anyhow::Result { + reqwest::Client::builder() + .use_rustls_tls() + .build() + .context("failed to build rustls reqwest client for OTLP exporter") +} diff --git a/crates/telemetry/src/logs.rs b/crates/telemetry/src/logs.rs index b8770f2739..299c261f6b 100644 --- a/crates/telemetry/src/logs.rs +++ b/crates/telemetry/src/logs.rs @@ -2,16 +2,17 @@ use std::{ascii::escape_default, sync::OnceLock}; use anyhow::bail; use opentelemetry::logs::{LogRecord, Logger, LoggerProvider}; +use opentelemetry_otlp::WithHttpConfig; use opentelemetry_sdk::{ - logs::{log_processor_with_async_runtime::BatchLogProcessor, BatchConfigBuilder, SdkLogger}, + Resource, + logs::{BatchConfigBuilder, SdkLogger, log_processor_with_async_runtime::BatchLogProcessor}, resource::{EnvResourceDetector, ResourceDetector, TelemetryResourceDetector}, runtime::Tokio, - Resource, }; use crate::{ detector::SpinResourceDetector, - env::{self, otel_logs_enabled, OtlpProtocol}, + env::{self, OtlpProtocol, otel_logs_enabled}, }; static LOGGER: OnceLock = OnceLock::new(); @@ -93,6 +94,7 @@ pub(crate) fn init_otel_logging_backend(spin_version: String) -> anyhow::Result< .build()?, OtlpProtocol::HttpProtobuf => opentelemetry_otlp::LogExporter::builder() .with_http() + .with_http_client(crate::rustls_reqwest_client()?) .build()?, OtlpProtocol::HttpJson => bail!("http/json OTLP protocol is not supported"), }; diff --git a/crates/telemetry/src/metrics.rs b/crates/telemetry/src/metrics.rs index 4a0ee1614f..abb19ec884 100644 --- a/crates/telemetry/src/metrics.rs +++ b/crates/telemetry/src/metrics.rs @@ -1,14 +1,15 @@ -use anyhow::{bail, Result}; +use anyhow::{Result, bail}; use opentelemetry::global; +use opentelemetry_otlp::WithHttpConfig; use opentelemetry_sdk::{ - metrics::{periodic_reader_with_async_runtime::PeriodicReader, SdkMeterProvider}, + Resource, + metrics::{SdkMeterProvider, periodic_reader_with_async_runtime::PeriodicReader}, resource::{EnvResourceDetector, ResourceDetector, TelemetryResourceDetector}, runtime::Tokio, - Resource, }; use tracing::Subscriber; use tracing_opentelemetry::MetricsLayer; -use tracing_subscriber::{registry::LookupSpan, Layer}; +use tracing_subscriber::{Layer, registry::LookupSpan}; use crate::{detector::SpinResourceDetector, env::OtlpProtocol}; @@ -42,6 +43,7 @@ pub(crate) fn otel_metrics_layer LookupSpan<'span>>( .build()?, OtlpProtocol::HttpProtobuf => opentelemetry_otlp::MetricExporter::builder() .with_http() + .with_http_client(crate::rustls_reqwest_client()?) .build()?, OtlpProtocol::HttpJson => bail!("http/json OTLP protocol is not supported"), }; diff --git a/crates/telemetry/src/propagation.rs b/crates/telemetry/src/propagation.rs index ba1975d899..4695f7b9c1 100644 --- a/crates/telemetry/src/propagation.rs +++ b/crates/telemetry/src/propagation.rs @@ -31,17 +31,17 @@ impl Injector for HeaderInjector<'_> { fn set(&mut self, key: &str, value: String) { match self { HeaderInjector::Http0(headers) => { - if let Ok(name) = http0::header::HeaderName::from_bytes(key.as_bytes()) { - if let Ok(val) = http0::header::HeaderValue::from_str(&value) { - headers.insert(name, val); - } + if let Ok(name) = http0::header::HeaderName::from_bytes(key.as_bytes()) + && let Ok(val) = http0::header::HeaderValue::from_str(&value) + { + headers.insert(name, val); } } HeaderInjector::Http1(headers) => { - if let Ok(name) = http1::header::HeaderName::from_bytes(key.as_bytes()) { - if let Ok(val) = http1::header::HeaderValue::from_str(&value) { - headers.insert(name, val); - } + if let Ok(name) = http1::header::HeaderName::from_bytes(key.as_bytes()) + && let Ok(val) = http1::header::HeaderValue::from_str(&value) + { + headers.insert(name, val); } } } diff --git a/crates/telemetry/src/traces.rs b/crates/telemetry/src/traces.rs index 0b9f92f020..c608f2561d 100644 --- a/crates/telemetry/src/traces.rs +++ b/crates/telemetry/src/traces.rs @@ -1,13 +1,14 @@ use anyhow::bail; use opentelemetry::{global, trace::TracerProvider}; +use opentelemetry_otlp::WithHttpConfig; use opentelemetry_sdk::{ + Resource, resource::{EnvResourceDetector, ResourceDetector, TelemetryResourceDetector}, runtime::Tokio, - Resource, }; use tracing::Subscriber; use tracing_opentelemetry::OpenTelemetrySpanExt as _; -use tracing_subscriber::{registry::LookupSpan, EnvFilter, Layer}; +use tracing_subscriber::{EnvFilter, Layer, registry::LookupSpan}; use crate::detector::SpinResourceDetector; use crate::env::OtlpProtocol; @@ -39,6 +40,7 @@ pub(crate) fn otel_tracing_layer LookupSpan<'span>>( .build()?, OtlpProtocol::HttpProtobuf => opentelemetry_otlp::SpanExporter::builder() .with_http() + .with_http_client(crate::rustls_reqwest_client()?) .build()?, OtlpProtocol::HttpJson => bail!("http/json OTLP protocol is not supported"), }; diff --git a/crates/templates/src/constraints.rs b/crates/templates/src/constraints.rs index a19f779fc6..00ce076a5b 100644 --- a/crates/templates/src/constraints.rs +++ b/crates/templates/src/constraints.rs @@ -8,19 +8,19 @@ pub(crate) struct StringConstraints { impl StringConstraints { pub fn validate(&self, text: String) -> anyhow::Result { - if let Some(regex) = self.regex.as_ref() { - if !regex.is_match(&text) { - anyhow::bail!("Input '{}' does not match pattern '{}'", text, regex); - } + if let Some(regex) = self.regex.as_ref() + && !regex.is_match(&text) + { + anyhow::bail!("Input '{}' does not match pattern '{}'", text, regex); } - if let Some(allowed_values) = self.allowed_values.as_ref() { - if !allowed_values.contains(&text) { - anyhow::bail!( - "Input '{}' is not one of the allowed values ({})", - text, - allowed_values.join(", ") - ); - } + if let Some(allowed_values) = self.allowed_values.as_ref() + && !allowed_values.contains(&text) + { + anyhow::bail!( + "Input '{}' is not one of the allowed values ({})", + text, + allowed_values.join(", ") + ); } Ok(text) } diff --git a/crates/templates/src/interaction.rs b/crates/templates/src/interaction.rs index ba5ffaa338..1eb9adfde5 100644 --- a/crates/templates/src/interaction.rs +++ b/crates/templates/src/interaction.rs @@ -1,9 +1,9 @@ use std::{collections::HashMap, path::Path}; use crate::{ + Run, cancellable::Cancellable, template::{TemplateParameter, TemplateParameterDataType}, - Run, }; use anyhow::anyhow; @@ -147,10 +147,10 @@ fn ask_choice( allowed_values: &[String], ) -> anyhow::Result { let mut select = Select::new().with_prompt(prompt).items(allowed_values); - if let Some(s) = default_value { - if let Some(default_index) = allowed_values.iter().position(|item| item == s) { - select = select.default(default_index); - } + if let Some(s) = default_value + && let Some(default_index) = allowed_values.iter().position(|item| item == s) + { + select = select.default(default_index); } let selected_index = select.interact()?; Ok(allowed_values[selected_index].clone()) diff --git a/crates/templates/src/manager.rs b/crates/templates/src/manager.rs index 46b0f22a34..39b0f5e7c8 100644 --- a/crates/templates/src/manager.rs +++ b/crates/templates/src/manager.rs @@ -68,6 +68,8 @@ pub enum SkippedReason { AlreadyExists, /// The template was skipped because its manifest was missing or invalid. InvalidManifest(String), + /// The template was removed from the source but could not be removed locally. + CouldNotRemove, } /// The results of installing a set of templates. @@ -76,6 +78,8 @@ pub struct InstallationResults { pub installed: Vec