From f02a67ed58f417af55364d6c172c61b24f72a84c Mon Sep 17 00:00:00 2001 From: David Viejo Date: Mon, 3 Aug 2026 12:42:24 +0200 Subject: [PATCH 1/7] feat(presets): replace the nixpacks build engine with autopack MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nixpacks is gone as a dependency. The slugs it owned stay, and now build through autopack. Why now: nixpacks 1.41.0 is the latest published version and pulls two advisories with no upstream fix available — RUSTSEC-2023-0018 (remove_dir_all 0.5.3, via tempdir 0.3.7) and GHSA-8wf9-4rjw-8j9r (serde_with 2.3.3). Both were carried as documented, reachability-analysed exceptions because there was nothing to upgrade to. Removing the dependency is the only thing that clears them, and `cargo audit` now reports neither. Compatibility is the constraint, not a nice-to-have. Existing projects have `preset = 'nixpacks'` and a NixpacksConfig persisted against them, so: * every `nixpacks*` slug still resolves, with the same stored_preset and the same config shape; * a persisted `nixpacks_config` TOML is still honoured — autopack reads the Nixpacks schema in compatibility mode, and reports what it could not translate instead of dropping it; * a stored provider still forces that language, so a polyglot repository pinned to `python` does not start building as `node` because detection order differs. Labels move from "Nixpacks (Python)" to "Autopack (Python)"; slugs do not. Two behaviour changes worth knowing about: * a project autopack cannot plan now produces a Dockerfile that exits 1 with the reason. The previous fallback emitted `FROM alpine` + `COPY . .` and no CMD, which builds and deploys cleanly into a container that exits at once; * autopack's Dockerfiles need BuildKit for cache and secret mounts. The deployment pipeline already enables it; a build that does not is refused by name rather than failing later on a line the user never wrote. Also adds tests/starters.rs, which builds every temps-examples starter through the real preset, runs it, and requests a page — plus the two properties that are invisible until they bite: that it answers on $PORT, and that it stops on SIGTERM instead of waiting out the kill timeout. --- .github/workflows/starters.yml | 141 ++ Cargo.lock | 675 +------ Cargo.toml | 34 +- .../src/services/workflow_planner.rs | 1 + crates/temps-entities/src/preset.rs | 22 +- crates/temps-presets/Cargo.toml | 9 +- crates/temps-presets/src/autopack_preset.rs | 273 +++ crates/temps-presets/src/go_preset.rs | 18 +- crates/temps-presets/src/java_preset.rs | 16 +- crates/temps-presets/src/mod.rs | 3 + crates/temps-presets/src/nixpacks_preset.rs | 1587 ++++------------- crates/temps-presets/src/python_preset.rs | 18 +- crates/temps-presets/src/rust_preset.rs | 18 +- crates/temps-presets/tests/starters.rs | 451 +++++ 14 files changed, 1368 insertions(+), 1898 deletions(-) create mode 100644 .github/workflows/starters.yml create mode 100644 crates/temps-presets/src/autopack_preset.rs create mode 100644 crates/temps-presets/tests/starters.rs diff --git a/.github/workflows/starters.yml b/.github/workflows/starters.yml new file mode 100644 index 000000000..ccefd8ad3 --- /dev/null +++ b/.github/workflows/starters.yml @@ -0,0 +1,141 @@ +name: Starters + +# Builds every temps-examples starter through the preset the deployment +# pipeline actually uses, runs the resulting image, and asks it for a page. +# +# The unit tests assert on generated Dockerfile *text*, which cannot tell a +# working build from a plausible-looking one. This is the job that would have +# caught a preset change that renders fine and deploys broken. + +on: + push: + branches: [main] + paths: + - 'crates/temps-presets/**' + - '.github/workflows/starters.yml' + pull_request: + branches: [main] + paths: + - 'crates/temps-presets/**' + - '.github/workflows/starters.yml' + workflow_dispatch: + # The starters live in another repository, so a green PR here can still break + # when one of them changes. Re-run nightly to catch that. + schedule: + - cron: '0 4 * * *' + +concurrency: + group: starters-${{ github.ref }} + cancel-in-progress: true + +env: + CARGO_TERM_COLOR: always + RUST_BACKTRACE: 1 + +jobs: + starters: + name: ${{ matrix.starter }} + runs-on: ubuntu-latest + timeout-minutes: 30 + strategy: + # One red square should name the language that broke, not stop the rest. + fail-fast: false + matrix: + starter: + - astro + - bun/bun-server + - bun/elysia + - deno + - dotnet/web + - elixir/phoenix + - go/gin + - go/net-http + - nextjs/app-router + - nodejs/express + - nodejs/fastify + - nodejs/hono + - nodejs/nestjs + - nuxt + - php/laravel + - php/vanilla + - python/django + - python/fastapi + - python/flask + - ruby/rails + - sveltekit + - swift/vapor + - vite/react + + steps: + - uses: actions/checkout@v5 + + - name: Check out the starters + uses: actions/checkout@v5 + with: + repository: gotempsh/temps-examples + path: temps-examples + + - uses: dtolnay/rust-toolchain@stable + + - uses: Swatinem/rust-cache@v2 + with: + # One shared cache for the whole matrix: every job builds the same + # test binary, so per-starter caches would be 26 copies of it. + shared-key: starters + # Only main may write the cache — a pull request that populates it + # evicts main's entry from the repository's single LRU pool. + save-if: ${{ github.ref == 'refs/heads/main' }} + + - uses: docker/setup-buildx-action@v3 + + - name: Build, run and serve + env: + TEMPS_EXAMPLES_DIR: ${{ github.workspace }}/temps-examples/examples/starters + TEMPS_STARTERS_ONLY: ${{ matrix.starter }} + DOCKER_BUILDKIT: '1' + run: | + cargo test -p temps-presets --test starters -- --ignored --nocapture + + known-failing: + # These two do not pass yet. They run anyway, and always report success, so + # the failure stays visible in the log without blocking a merge — a red + # required check that everyone learns to ignore is worse than no check. + # + # java/spring-boot Spring deduces a REACTIVE application type from a + # classpath that only has spring-boot-starter-web, then + # fails for want of a ReactiveWebServerFactory. The boot + # jar is selected correctly and the app does start. + # rust/actix Ignores SIGTERM: `docker stop` waits the full grace + # period and SIGKILLs. Actix's default graceful-shutdown + # timeout outlives the stop timeout. + # + # Tracked in the autopack repository; remove an entry here when it is fixed. + name: known-failing (report only) + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v5 + + - name: Check out the starters + uses: actions/checkout@v5 + with: + repository: gotempsh/temps-examples + path: temps-examples + + - uses: dtolnay/rust-toolchain@stable + + - uses: Swatinem/rust-cache@v2 + with: + shared-key: starters + save-if: false + + - uses: docker/setup-buildx-action@v3 + + - name: Build, run and serve + continue-on-error: true + env: + TEMPS_EXAMPLES_DIR: ${{ github.workspace }}/temps-examples/examples/starters + TEMPS_STARTERS_ONLY: java/spring-boot,rust/actix + DOCKER_BUILDKIT: '1' + run: | + cargo test -p temps-presets --test starters -- --ignored --nocapture diff --git a/Cargo.lock b/Cargo.lock index 2c0b23e67..6a1f20c93 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,189 +2,6 @@ # It is not intended for manual editing. version = 4 -[[package]] -name = "actix-codec" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f7b0a21988c1bf877cf4759ef5ddaac04c1c9fe808c9142ecb78ba97d97a28a" -dependencies = [ - "bitflags 2.11.1", - "bytes", - "futures-core", - "futures-sink", - "memchr", - "pin-project-lite", - "tokio", - "tokio-util", - "tracing", -] - -[[package]] -name = "actix-http" -version = "3.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93acb4a42f64936f9b8cae4a433b237599dd6eb6ed06124eb67132ef8cc90662" -dependencies = [ - "actix-codec", - "actix-rt", - "actix-service", - "actix-utils", - "base64 0.22.1", - "bitflags 2.11.1", - "brotli 8.0.2", - "bytes", - "bytestring", - "derive_more", - "encoding_rs", - "flate2", - "foldhash 0.1.5", - "futures-core", - "h2 0.3.27", - "http 0.2.12", - "httparse", - "httpdate", - "itoa", - "language-tags", - "local-channel", - "mime", - "percent-encoding", - "pin-project-lite", - "rand 0.10.1", - "sha1 0.11.0", - "smallvec", - "tokio", - "tokio-util", - "tracing", - "zstd", -] - -[[package]] -name = "actix-macros" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e01ed3140b2f8d422c68afa1ed2e85d996ea619c988ac834d255db32138655cb" -dependencies = [ - "quote", - "syn 2.0.117", -] - -[[package]] -name = "actix-router" -version = "0.5.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "14f8c75c51892f18d9c46150c5ac7beb81c95f78c8b83a634d49f4ca32551fe7" -dependencies = [ - "bytestring", - "cfg-if", - "http 0.2.12", - "regex", - "regex-lite", - "serde", - "tracing", -] - -[[package]] -name = "actix-rt" -version = "2.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92589714878ca59a7626ea19734f0e07a6a875197eec751bb5d3f99e64998c63" -dependencies = [ - "futures-core", - "tokio", -] - -[[package]] -name = "actix-server" -version = "2.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a65064ea4a457eaf07f2fba30b4c695bf43b721790e9530d26cb6f9019ff7502" -dependencies = [ - "actix-rt", - "actix-service", - "actix-utils", - "futures-core", - "futures-util", - "mio", - "socket2 0.5.10", - "tokio", - "tracing", -] - -[[package]] -name = "actix-service" -version = "2.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e46f36bf0e5af44bdc4bdb36fbbd421aa98c79a9bce724e1edeb3894e10dc7f" -dependencies = [ - "futures-core", - "pin-project-lite", -] - -[[package]] -name = "actix-utils" -version = "3.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88a1dcdff1466e3c2488e1cb5c36a71822750ad43839937f85d2f4d9f8b705d8" -dependencies = [ - "local-waker", - "pin-project-lite", -] - -[[package]] -name = "actix-web" -version = "4.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff87453bc3b56e9b2b23c1cc0b1be8797184accf51d2abe0f8a33ec275d316bf" -dependencies = [ - "actix-codec", - "actix-http", - "actix-macros", - "actix-router", - "actix-rt", - "actix-server", - "actix-service", - "actix-utils", - "actix-web-codegen", - "bytes", - "bytestring", - "cfg-if", - "cookie 0.16.2", - "derive_more", - "encoding_rs", - "foldhash 0.1.5", - "futures-core", - "futures-util", - "impl-more", - "itoa", - "language-tags", - "log", - "mime", - "once_cell", - "pin-project-lite", - "regex", - "regex-lite", - "serde", - "serde_json", - "serde_urlencoded", - "smallvec", - "socket2 0.6.3", - "time", - "tracing", - "url", -] - -[[package]] -name = "actix-web-codegen" -version = "4.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f591380e2e68490b5dfaf1dd1aa0ebe78d84ba7067078512b4ea6e4492d622b8" -dependencies = [ - "actix-router", - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "addr2line" version = "0.25.1" @@ -644,6 +461,43 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "autopack-core" +version = "0.1.0" +source = "git+https://github.com/gotempsh/autopack?rev=f89c18515f203cdcd7e641ea594b7f62d54aed68#f89c18515f203cdcd7e641ea594b7f62d54aed68" +dependencies = [ + "globset", + "indexmap 2.14.0", + "serde", + "serde_json", + "sha2 0.10.9", + "thiserror 2.0.18", + "toml 0.8.23", + "tracing", + "walkdir", +] + +[[package]] +name = "autopack-dockerfile" +version = "0.1.0" +source = "git+https://github.com/gotempsh/autopack?rev=f89c18515f203cdcd7e641ea594b7f62d54aed68#f89c18515f203cdcd7e641ea594b7f62d54aed68" +dependencies = [ + "autopack-core", +] + +[[package]] +name = "autopack-providers" +version = "0.1.0" +source = "git+https://github.com/gotempsh/autopack?rev=f89c18515f203cdcd7e641ea594b7f62d54aed68#f89c18515f203cdcd7e641ea594b7f62d54aed68" +dependencies = [ + "autopack-core", + "indexmap 2.14.0", + "serde", + "serde_json", + "toml 0.8.23", + "tracing", +] + [[package]] name = "av-scenechange" version = "0.14.1" @@ -1051,7 +905,7 @@ dependencies = [ "aws-smithy-async", "aws-smithy-runtime-api", "aws-smithy-types", - "h2 0.4.14", + "h2", "http 1.4.2", "hyper", "hyper-rustls", @@ -1292,7 +1146,7 @@ dependencies = [ "axum", "bytes", "bytesize", - "cookie 0.18.1", + "cookie", "expect-json", "http 1.4.2", "http-body-util", @@ -1353,12 +1207,6 @@ version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" -[[package]] -name = "base64" -version = "0.20.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ea22880d78093b0cbe17c89f64a7d457941e65759157ec6cb31a31d652b05e5" - [[package]] name = "base64" version = "0.21.7" @@ -1610,12 +1458,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "box_drawing" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea27d8d5fd867b17523bf6788b1175fa9867f34669d057e9adaf76e27bcea44b" - [[package]] name = "brotli" version = "3.5.0" @@ -1624,18 +1466,7 @@ checksum = "d640d25bc63c50fb1f0b545ffd80207d2e10a4c965530809b40ba3386825c391" dependencies = [ "alloc-no-stdlib", "alloc-stdlib", - "brotli-decompressor 2.5.1", -] - -[[package]] -name = "brotli" -version = "8.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560" -dependencies = [ - "alloc-no-stdlib", - "alloc-stdlib", - "brotli-decompressor 5.0.0", + "brotli-decompressor", ] [[package]] @@ -1648,16 +1479,6 @@ dependencies = [ "alloc-stdlib", ] -[[package]] -name = "brotli-decompressor" -version = "5.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03" -dependencies = [ - "alloc-no-stdlib", - "alloc-stdlib", -] - [[package]] name = "bs58" version = "0.5.1" @@ -1806,15 +1627,6 @@ version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6bd91ee7b2422bcb158d90ef4d14f75ef67f340943fc4149891dcce8f8b972a3" -[[package]] -name = "bytestring" -version = "1.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86566c496f2f47d9b8147a4c8b02ffdb69c919fe0c2b2e7195d22cbba0e635c9" -dependencies = [ - "bytes", -] - [[package]] name = "bzip2" version = "0.6.1" @@ -1886,16 +1698,6 @@ dependencies = [ "thiserror 2.0.18", ] -[[package]] -name = "cargo_toml" -version = "0.20.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88da5a13c620b4ca0078845707ea9c3faf11edbc3ffd8497d11d686211cd1ac0" -dependencies = [ - "serde", - "toml 0.8.23", -] - [[package]] name = "cast" version = "0.3.0" @@ -2142,7 +1944,7 @@ dependencies = [ "serde", "serde_json", "serde_urlencoded", - "serde_with 3.21.0", + "serde_with", "thiserror 2.0.18", "url", "urlencoding", @@ -2160,7 +1962,7 @@ dependencies = [ "serde", "serde_json", "serde_urlencoded", - "serde_with 3.21.0", + "serde_with", "thiserror 2.0.18", "url", "urlencoding", @@ -2194,16 +1996,6 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" -[[package]] -name = "colored" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c" -dependencies = [ - "lazy_static", - "windows-sys 0.48.0", -] - [[package]] name = "colored" version = "3.1.1" @@ -2271,18 +2063,6 @@ dependencies = [ "yaml-rust2", ] -[[package]] -name = "console" -version = "0.15.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8" -dependencies = [ - "encode_unicode", - "libc", - "once_cell", - "windows-sys 0.59.0", -] - [[package]] name = "console" version = "0.16.4" @@ -2291,7 +2071,7 @@ checksum = "4fe5f465a4f6fee88fad41b85d990f84c835335e85b5d9e6e63e0d06d28cba7c" dependencies = [ "encode_unicode", "libc", - "unicode-width 0.2.2", + "unicode-width", "windows-sys 0.61.2", ] @@ -2366,17 +2146,6 @@ dependencies = [ "unicode-segmentation", ] -[[package]] -name = "cookie" -version = "0.16.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e859cd57d0710d9e06c381b550c06e76992472a8c6d527aecd2fc673dcc231fb" -dependencies = [ - "percent-encoding", - "time", - "version_check", -] - [[package]] name = "cookie" version = "0.18.1" @@ -2899,7 +2668,7 @@ dependencies = [ "nix 0.31.3", "parking_lot", "ring", - "socket2 0.6.3", + "socket2", "thiserror 2.0.18", "tracing", "uniffi", @@ -3728,12 +3497,6 @@ dependencies = [ "libc", ] -[[package]] -name = "fuchsia-cprng" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a06f77d526c1a601b7c4cdd98f54b5eaabffc14d5f2f0296febdc7f357c6d3ba" - [[package]] name = "funty" version = "2.0.0" @@ -3894,7 +3657,7 @@ version = "0.2.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df" dependencies = [ - "unicode-width 0.2.2", + "unicode-width", ] [[package]] @@ -4034,25 +3797,6 @@ dependencies = [ "subtle", ] -[[package]] -name = "h2" -version = "0.3.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d" -dependencies = [ - "bytes", - "fnv", - "futures-core", - "futures-sink", - "futures-util", - "http 0.2.12", - "indexmap 2.14.0", - "slab", - "tokio", - "tokio-util", - "tracing", -] - [[package]] name = "h2" version = "0.4.14" @@ -4484,7 +4228,7 @@ dependencies = [ "bytes", "futures-channel", "futures-core", - "h2 0.4.14", + "h2", "http 1.4.2", "http-body 1.0.1", "httparse", @@ -4576,7 +4320,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.6.3", + "socket2", "system-configuration", "tokio", "tower-layer", @@ -4745,22 +4489,6 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cd62e6b5e86ea8eeeb8db1de02880a6abc01a397b2ebb64b5d74ac255318f5cb" -[[package]] -name = "ignore" -version = "0.4.25" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3d782a365a015e0f5c04902246139249abf769125006fbe7649e2ee88169b4a" -dependencies = [ - "crossbeam-deque", - "globset", - "log", - "memchr", - "regex-automata", - "same-file", - "walkdir", - "winapi-util", -] - [[package]] name = "image" version = "0.25.10" @@ -4801,12 +4529,6 @@ version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40fac9d56ed6437b198fddba683305e8e2d651aa42647f00f5ae542e7f5c94a2" -[[package]] -name = "impl-more" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8a5a9a0ff0086c7a148acb942baaabeadf9504d10400b5a05645853729b9cd2" - [[package]] name = "include_dir" version = "0.7.4" @@ -4855,19 +4577,13 @@ version = "0.18.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9433806cd6b4ec1aba79c021c7e4c58fb4c3b9977c085062e611ac929998fb0c" dependencies = [ - "console 0.16.4", + "console", "portable-atomic", - "unicode-width 0.2.2", + "unicode-width", "unit-prefix", "web-time", ] -[[package]] -name = "indoc" -version = "1.0.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa799dd5ed20a7e349f3b4639aa80d74549c81716d9ec4f994c9b5815598306" - [[package]] name = "inherent" version = "1.0.13" @@ -4991,7 +4707,7 @@ version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4d40460c0ce33d6ce4b0630ad68ff63d6661961c48b6dba35e5a4d81cfb48222" dependencies = [ - "socket2 0.6.3", + "socket2", "widestring 1.2.1", "windows-registry", "windows-result 0.4.1", @@ -5193,12 +4909,6 @@ dependencies = [ "libc", ] -[[package]] -name = "language-tags" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4345964bb142484797b161f473a503a434de77149dd8c7427788c6e13379388" - [[package]] name = "lazy_static" version = "1.5.0" @@ -5242,7 +4952,7 @@ dependencies = [ "percent-encoding", "quoted_printable", "rustls", - "socket2 0.6.3", + "socket2", "tokio", "tokio-native-tls", "tokio-rustls", @@ -5373,17 +5083,6 @@ version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" -[[package]] -name = "local-channel" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6cbc85e69b8df4b8bb8b89ec634e7189099cea8927a276b7384ce5488e53ec8" -dependencies = [ - "futures-core", - "futures-sink", - "local-waker", -] - [[package]] name = "local-ip-address" version = "0.6.13" @@ -5395,12 +5094,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "local-waker" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4d873d7c67ce09b42110d801813efbc9364414e356be9935700d368351657487" - [[package]] name = "lock_api" version = "0.4.14" @@ -5685,28 +5378,6 @@ dependencies = [ "autocfg", ] -[[package]] -name = "miette" -version = "7.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f98efec8807c63c752b5bd61f862c165c115b0a35685bdcfd9238c7aeb592b7" -dependencies = [ - "cfg-if", - "miette-derive", - "unicode-width 0.1.14", -] - -[[package]] -name = "miette-derive" -version = "7.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db5b29714e950dbb20d5e6f74f9dcec4edbcc1067bb7f8ed198c097b8c1a818b" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "mime" version = "0.3.17" @@ -5831,7 +5502,7 @@ checksum = "90820618712cab19cfc46b274c6c22546a82affcb3c3bdf0f29e3db8e1bb92c0" dependencies = [ "assert-json-diff", "bytes", - "colored 3.1.1", + "colored", "futures-core", "http 1.4.2", "http-body 1.0.1", @@ -5918,10 +5589,10 @@ dependencies = [ "rustls", "serde", "serde_bytes", - "serde_with 3.21.0", + "serde_with", "sha1 0.11.0", "sha2 0.11.0", - "socket2 0.6.3", + "socket2", "stringprep", "strsim", "take_mut", @@ -6231,46 +5902,6 @@ dependencies = [ "memoffset 0.9.1", ] -[[package]] -name = "nixpacks" -version = "1.41.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "964a2e23f43506cd21c7bb6c9440ad9527b4c9e65253ef982e2da7bea4dcf0e8" -dependencies = [ - "actix-web", - "anyhow", - "async-trait", - "base64 0.20.0", - "box_drawing", - "cargo_toml", - "clap", - "colored 2.2.0", - "console 0.15.11", - "futures", - "futures-util", - "globset", - "ignore", - "indoc", - "node-semver", - "path-slash", - "portpicker", - "rand 0.8.7", - "regex", - "sanitize-filename", - "semver", - "serde", - "serde_json", - "serde_with 2.3.3", - "serde_yaml", - "tempdir", - "textwrap", - "tokio", - "toml 0.5.11", - "uuid", - "wait-timeout", - "walkdir", -] - [[package]] name = "no_std_io2" version = "0.9.4" @@ -6280,19 +5911,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "node-semver" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b1a233ea5dc37d2cfba31cfc87a5a56cc2a9c04e3672c15d179ca118dae40a7" -dependencies = [ - "bytecount", - "miette", - "nom 7.1.3", - "serde", - "thiserror 1.0.69", -] - [[package]] name = "nom" version = "7.1.3" @@ -6652,7 +6270,7 @@ dependencies = [ "serde_json", "serde_path_to_error", "serde_plain", - "serde_with 3.21.0", + "serde_with", "sha2 0.10.9", "subtle", "thiserror 1.0.69", @@ -6924,12 +6542,6 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" -[[package]] -name = "path-slash" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e91099d4268b0e11973f036e885d652fb0b21fedcf69738c627f94db6a44f42" - [[package]] name = "pathdiff" version = "0.2.3" @@ -7214,7 +6826,7 @@ checksum = "6a7ffe2f5acf9f94fd255cfd1438866bc9124f8f0c7d42562bd3f853df2094b7" dependencies = [ "ahash 0.8.12", "async-trait", - "brotli 3.5.0", + "brotli", "bstr", "bytes", "chrono", @@ -7224,7 +6836,7 @@ dependencies = [ "derivative", "flate2", "futures", - "h2 0.4.14", + "h2", "http 1.4.2", "httparse", "httpdate", @@ -7247,7 +6859,7 @@ dependencies = [ "serde", "serde_yaml", "sfv", - "socket2 0.6.3", + "socket2", "strum", "strum_macros", "tokio", @@ -7372,7 +6984,7 @@ dependencies = [ "bytes", "clap", "futures", - "h2 0.4.14", + "h2", "http 1.4.2", "log", "once_cell", @@ -7504,15 +7116,6 @@ dependencies = [ "portable-atomic", ] -[[package]] -name = "portpicker" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be97d76faf1bfab666e1375477b23fde79eccf0276e9b63b92a39d676a889ba9" -dependencies = [ - "rand 0.8.7", -] - [[package]] name = "postgres-protocol" version = "0.6.12" @@ -7927,7 +7530,7 @@ dependencies = [ "quinn-udp", "rustc-hash", "rustls", - "socket2 0.6.3", + "socket2", "thiserror 2.0.18", "tokio", "tracing", @@ -7964,7 +7567,7 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.6.3", + "socket2", "tracing", "windows-sys 0.60.2", ] @@ -8002,19 +7605,6 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" -[[package]] -name = "rand" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "552840b97013b1a26992c11eac34bdd778e464601a4c2054b5f0bff7c6761293" -dependencies = [ - "fuchsia-cprng", - "libc", - "rand_core 0.3.1", - "rdrand", - "winapi 0.3.9", -] - [[package]] name = "rand" version = "0.8.7" @@ -8067,21 +7657,6 @@ dependencies = [ "rand_core 0.9.5", ] -[[package]] -name = "rand_core" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a6fdeb83b075e8266dcc8762c22776f6877a63111121f5f8c7411e5be7eed4b" -dependencies = [ - "rand_core 0.4.2", -] - -[[package]] -name = "rand_core" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c33a3c44ca05fa6f1807d8e6743f3824e8509beca625669633be0acbdf509dc" - [[package]] name = "rand_core" version = "0.6.4" @@ -8211,15 +7786,6 @@ dependencies = [ "yasna", ] -[[package]] -name = "rdrand" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "678054eb77286b51581ba43620cc911abf02758c91f93f479767aed0f90458b2" -dependencies = [ - "rand_core 0.3.1", -] - [[package]] name = "redis" version = "1.4.1" @@ -8241,7 +7807,7 @@ dependencies = [ "pin-project-lite", "ryu", "sha1_smol", - "socket2 0.6.3", + "socket2", "tokio", "tokio-util", "url", @@ -8391,7 +7957,7 @@ source = "git+https://github.com/getsentry/relay?rev=ca7e20d#ca7e20d0a7e27d2029c dependencies = [ "bytecount", "chrono", - "cookie 0.18.1", + "cookie", "debugid", "enumset", "minidump", @@ -8446,15 +8012,6 @@ dependencies = [ "synstructure", ] -[[package]] -name = "remove_dir_all" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3acd125665422973a33ac9d3dd2df85edad0f4ae9b00dafb1a05e43a9f5ef8e7" -dependencies = [ - "winapi 0.3.9", -] - [[package]] name = "rend" version = "0.4.2" @@ -8475,7 +8032,7 @@ dependencies = [ "encoding_rs", "futures-core", "futures-util", - "h2 0.4.14", + "h2", "http 1.4.2", "http-body 1.0.1", "http-body-util", @@ -8522,7 +8079,7 @@ dependencies = [ "bytes", "encoding_rs", "futures-core", - "h2 0.4.14", + "h2", "http 1.4.2", "http-body 1.0.1", "http-body-util", @@ -8928,16 +8485,6 @@ dependencies = [ "winapi-util", ] -[[package]] -name = "sanitize-filename" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08c502bdb638f1396509467cb0580ef3b29aa2a45c5d43e5d84928241280296c" -dependencies = [ - "lazy_static", - "regex", -] - [[package]] name = "schannel" version = "0.1.29" @@ -9477,16 +9024,6 @@ dependencies = [ "serde", ] -[[package]] -name = "serde_with" -version = "2.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07ff71d2c147a7b57362cead5e22f772cd52f6ab31cfcd9edcd7f6aeb2a0afbe" -dependencies = [ - "serde", - "serde_with_macros 2.3.3", -] - [[package]] name = "serde_with" version = "3.21.0" @@ -9503,22 +9040,10 @@ dependencies = [ "schemars 1.2.1", "serde_core", "serde_json", - "serde_with_macros 3.21.0", + "serde_with_macros", "time", ] -[[package]] -name = "serde_with_macros" -version = "2.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "881b6f881b17d13214e5d494c939ebab463d01264ce1811e9d4ac3a882e7695f" -dependencies = [ - "darling 0.20.11", - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "serde_with_macros" version = "3.21.0" @@ -9792,16 +9317,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "socket2" -version = "0.5.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" -dependencies = [ - "libc", - "windows-sys 0.52.0", -] - [[package]] name = "socket2" version = "0.6.3" @@ -10487,16 +10002,6 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "016ef9739649996fcc983b9c588fe3d557cf216d4d98503ce1b057ab5a66d689" -[[package]] -name = "tempdir" -version = "0.3.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15f2b5fb00ccdf689e0149d1b1b3c03fead81c2b37735d812fa8bddbbf41b6d8" -dependencies = [ - "rand 0.4.6", - "remove_dir_all", -] - [[package]] name = "tempfile" version = "3.27.0" @@ -10883,7 +10388,7 @@ dependencies = [ "base32", "base64 0.22.1", "chrono", - "cookie 0.18.1", + "cookie", "hex", "image", "log", @@ -11043,7 +10548,7 @@ dependencies = [ "chrono", "clap", "clickhouse", - "colored 3.1.1", + "colored", "dirs", "flate2", "futures", @@ -11180,7 +10685,7 @@ dependencies = [ "axum", "base64 0.22.1", "chrono", - "cookie 0.18.1", + "cookie", "futures", "hex", "hkdf 0.13.0", @@ -12459,7 +11964,9 @@ version = "0.1.0-beta.55" dependencies = [ "anyhow", "async-trait", - "nixpacks", + "autopack-core", + "autopack-dockerfile", + "autopack-providers", "regex", "serde", "serde_json", @@ -12592,7 +12099,7 @@ dependencies = [ "bytes", "chrono", "clickhouse", - "cookie 0.18.1", + "cookie", "dashmap", "flate2", "futures", @@ -13088,7 +12595,7 @@ dependencies = [ "reqwest 0.13.4", "serde", "serde_json", - "serde_with 3.21.0", + "serde_with", "thiserror 2.0.18", "tokio", "tokio-stream", @@ -13345,7 +12852,7 @@ dependencies = [ "parking_lot", "pin-project-lite", "signal-hook-registry", - "socket2 0.6.3", + "socket2", "tokio-macros", "windows-sys 0.61.2", ] @@ -13402,7 +12909,7 @@ dependencies = [ "postgres-protocol", "postgres-types", "rand 0.10.1", - "socket2 0.6.3", + "socket2", "tokio", "tokio-util", "whoami 2.1.2", @@ -13500,15 +13007,6 @@ dependencies = [ "tokio", ] -[[package]] -name = "toml" -version = "0.5.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4f7f0dd8d50a853a531c426359045b1998f04219d88799810762cd4ad314234" -dependencies = [ - "serde", -] - [[package]] name = "toml" version = "0.8.23" @@ -13637,7 +13135,7 @@ dependencies = [ "axum", "base64 0.22.1", "bytes", - "h2 0.4.14", + "h2", "http 1.4.2", "http-body 1.0.1", "http-body-util", @@ -13646,7 +13144,7 @@ dependencies = [ "hyper-util", "percent-encoding", "pin-project", - "socket2 0.6.3", + "socket2", "sync_wrapper", "tokio", "tokio-stream", @@ -13721,7 +13219,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "151b5a3e3c45df17466454bb74e9ecedecc955269bdedbf4d150dfa393b55a36" dependencies = [ "axum-core", - "cookie 0.18.1", + "cookie", "futures-util", "http 1.4.2", "parking_lot", @@ -14010,12 +13508,6 @@ version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" -[[package]] -name = "unicode-width" -version = "0.1.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" - [[package]] name = "unicode-width" version = "0.2.2" @@ -14401,15 +13893,6 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" -[[package]] -name = "wait-timeout" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" -dependencies = [ - "libc", -] - [[package]] name = "walkdir" version = "2.5.0" diff --git a/Cargo.toml b/Cargo.toml index 6addb8a9f..380290b61 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -386,18 +386,6 @@ debug = false # DEFAULT feature of prometheus 0.13 -- features are additive, so it cannot be # turned off from this workspace without vendoring a pingora fork.) # -# 2. RUSTSEC-2023-0018 / GHSA-mc8h-8q98-g5hr: remove_dir_all 0.5.3 (LOW) -# - Path: nixpacks 1.41.0 → tempdir 0.3.7 → remove_dir_all 0.5.3 -# - Fix: needs nixpacks to drop deprecated tempdir for tempfile. nixpacks 1.41.0 -# is the crates.io latest and still uses tempdir, which hard-requires -# remove_dir_all ^0.5 (so `--precise 0.8.0` is impossible). -# - Reachability: the TOCTOU race lives in nixpacks get_output_dir()'s -# TempDir::new()/remove_dir_all(output.root) branch, taken only when out_dir -# is None. temps-presets always passes out_dir: Some(...) (nixpacks_preset.rs -# ~L507), so that branch never executes -> dead code here. (re-verified -# 2026-07-25: nixpacks_preset.rs:508 is still the ONLY out_dir site in the -# whole workspace and still passes Some(...); nixpacks is still 1.41.0 latest.) -# # 2b. GHSA-w9wp-h8wv-79jx: opentelemetry_sdk 0.30.0 (MEDIUM, dependabot-only) # - Path: relay-event-schema (git pin getsentry/relay) → opentelemetry-proto 0.30 # → opentelemetry_sdk 0.30.0 @@ -411,20 +399,6 @@ debug = false # the workspace -> not reachable. (re-verified 2026-07-25: no case-insensitive # match for `baggage` anywhere under crates/.) # -# 2c. GHSA-8wf9-4rjw-8j9r: serde_with 2.3.3 (MEDIUM, dependabot-only) -# - Path: nixpacks 1.41.0 → serde_with 2.3.3 -# - The 3.x copy of serde_with (via cloudflare 0.14) WAS affected and IS fixed: -# bumped 3.20.0 → 3.21.0 (see "Previously resolved" below). Only the 2.x copy -# remains, and it has no fix: nixpacks requires serde_with ^2.1.0, 2.3.3 is the -# latest 2.x ever published, and the fix only landed in 3.21.0. -# - Reachability: the panic is in KeyValueMap's Serialize impl on an empty -# sequence/map entry. KeyValueMap does exist in 2.3.3 (so the version range is -# genuinely accurate here), but nixpacks depends on serde_with with -# default-features = false, features = ["macros"] and uses it ONLY for -# #[serde_with::skip_serializing_none] (4 sites: nix/pkg.rs, plan/phase.rs x2, -# plan/mod.rs). No KeyValueMap reference in nixpacks or in this workspace -# -> not reachable. (verified 2026-07-25) -# # 3. GHSA-xxx: libcrux-intrinsics 0.0.3 (HIGH severity, dev-dependency only) # - Path: testcontainers → russh → libcrux-ml-kem → libcrux-intrinsics 0.0.3 # - Fix: Requires russh to upgrade libcrux dependencies @@ -455,10 +429,16 @@ debug = false # out-of-order stream reassembly): RESOLVED by cargo update quinn-proto → # 0.11.15. (Orphaned lock entry with no active dependents — bumped for hygiene # so `cargo audit` stays clean.) +# - remove_dir_all 0.5.3 (RUSTSEC-2023-0018 / GHSA-mc8h-8q98-g5hr) and +# serde_with 2.3.3 (GHSA-8wf9-4rjw-8j9r): RESOLVED by replacing the nixpacks +# build engine with autopack. Both reached the tree only through +# nixpacks 1.41.0 (→ tempdir 0.3.7, and → serde_with ^2.1.0 respectively); +# neither had an upstream fix available, so removing the dependency was the +# only way to clear them. autopack pulls neither. # - serde_with 3.20.0 (GHSA-8wf9-4rjw-8j9r KeyValueMap serialize panic on empty # sequence/map entries): RESOLVED by cargo update serde_with@3.20.0 --precise # 3.21.0 (pulls serde_with_macros → 3.21.0). Reached via cloudflare 0.14 → -# temps-dns. The 2.x copy from nixpacks has no fix and stays — see 2c above. +# temps-dns. The 2.x copy came from nixpacks and left with it. # - rustls-webpki 0.101.7 (RUSTSEC-2026-0098, -0099, -0104) on rustls 0.21: # RESOLVED by disabling default features on aws-sdk-s3, aws-sdk-sesv2, and # aws-config so the deprecated `rustls` feature (which activates diff --git a/crates/temps-deployments/src/services/workflow_planner.rs b/crates/temps-deployments/src/services/workflow_planner.rs index 94dbb1363..fe416c99c 100644 --- a/crates/temps-deployments/src/services/workflow_planner.rs +++ b/crates/temps-deployments/src/services/workflow_planner.rs @@ -2283,6 +2283,7 @@ pub(crate) fn public_sentry_dsn_var( | Preset::Dockerfile | Preset::DockerCompose | Preset::Nixpacks + | Preset::Autopack | Preset::Static => None, } } diff --git a/crates/temps-entities/src/preset.rs b/crates/temps-entities/src/preset.rs index d575c49e8..44fdcad54 100644 --- a/crates/temps-entities/src/preset.rs +++ b/crates/temps-entities/src/preset.rs @@ -112,6 +112,10 @@ pub enum Preset { #[sea_orm(string_value = "nixpacks")] Nixpacks, + /// Auto-detecting builder backed by the autopack crates. + #[sea_orm(string_value = "autopack")] + Autopack, + #[sea_orm(string_value = "static")] Static, @@ -154,6 +158,7 @@ impl Preset { Preset::Dockerfile => "dockerfile", Preset::DockerCompose => "docker-compose", Preset::Nixpacks => "nixpacks", + Preset::Autopack => "autopack", Preset::Static => "static", Preset::NodeJs => "nodejs", } @@ -186,6 +191,7 @@ impl Preset { Preset::Dockerfile => "Dockerfile", Preset::DockerCompose => "Docker Compose", Preset::Nixpacks => "Nixpacks", + Preset::Autopack => "Autopack", Preset::Static => "Static Site", Preset::NodeJs => "Node.js", } @@ -213,9 +219,11 @@ impl Preset { Preset::Rust => "rust", Preset::Java => "java", Preset::Laravel => "php", - Preset::Dockerfile | Preset::DockerCompose | Preset::Nixpacks | Preset::Static => { - "generic" - } + Preset::Dockerfile + | Preset::DockerCompose + | Preset::Nixpacks + | Preset::Autopack + | Preset::Static => "generic", } } @@ -288,6 +296,7 @@ impl Preset { Preset::Dockerfile => None, // User-defined Preset::DockerCompose => None, // Multiple services, user-configured Preset::Nixpacks => None, // Auto-detected + Preset::Autopack => None, // Auto-detected Preset::Static => None, // No server } } @@ -335,6 +344,7 @@ impl Preset { Preset::Dockerfile => Some("https://cdn.simpleicons.org/docker/2496ED"), Preset::DockerCompose => Some("https://cdn.simpleicons.org/docker/2496ED"), Preset::Nixpacks => None, // No specific icon + Preset::Autopack => None, // No specific icon Preset::Static => Some("https://cdn.simpleicons.org/html5/E34F26"), } } @@ -362,7 +372,9 @@ impl Preset { Preset::Python | Preset::Go | Preset::Rust | Preset::Java | Preset::NodeJs => "runtime", // Generic presets - Preset::Dockerfile | Preset::DockerCompose | Preset::Nixpacks => "container", + Preset::Dockerfile | Preset::DockerCompose | Preset::Nixpacks | Preset::Autopack => { + "container" + } Preset::Static => "static", } } @@ -435,6 +447,7 @@ impl std::str::FromStr for Preset { "dockerfile" => Ok(Preset::Dockerfile), "docker-compose" | "dockercompose" | "compose" => Ok(Preset::DockerCompose), "nixpacks" => Ok(Preset::Nixpacks), + "autopack" => Ok(Preset::Autopack), "static" => Ok(Preset::Static), "nodejs" | "node" => Ok(Preset::NodeJs), _ => Err(format!("Unknown preset: {}", s)), @@ -1092,6 +1105,7 @@ impl PresetConfig { Preset::Dockerfile => PresetConfig::Dockerfile(DockerfileConfig::default()), Preset::DockerCompose => PresetConfig::DockerCompose(DockerComposeConfig::default()), Preset::Nixpacks => PresetConfig::Nixpacks(NixpacksConfig::default()), + Preset::Autopack => PresetConfig::Nixpacks(NixpacksConfig::default()), Preset::Static => PresetConfig::Static(StaticConfig::default()), Preset::NodeJs => PresetConfig::NodeJs(NodeJsConfig::default()), } diff --git a/crates/temps-presets/Cargo.toml b/crates/temps-presets/Cargo.toml index 6e37f1337..962b45850 100644 --- a/crates/temps-presets/Cargo.toml +++ b/crates/temps-presets/Cargo.toml @@ -21,11 +21,16 @@ toml = "1.1" serde_yaml = "0.9" utoipa = { workspace = true, optional = true } -# Nixpacks integration -nixpacks = "1.41.0" +# The build engine. Pinned to a commit rather than a branch: a build plan that +# changes underneath a release would change what every project's image contains +# without anything in this repository moving. +autopack-core = { git = "https://github.com/gotempsh/autopack", rev = "f89c18515f203cdcd7e641ea594b7f62d54aed68" } +autopack-providers = { git = "https://github.com/gotempsh/autopack", rev = "f89c18515f203cdcd7e641ea594b7f62d54aed68" } +autopack-dockerfile = { git = "https://github.com/gotempsh/autopack", rev = "f89c18515f203cdcd7e641ea594b7f62d54aed68" } [features] openapi = ["utoipa"] [dev-dependencies] tempfile = { workspace = true } +tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } diff --git a/crates/temps-presets/src/autopack_preset.rs b/crates/temps-presets/src/autopack_preset.rs new file mode 100644 index 000000000..ba2553f57 --- /dev/null +++ b/crates/temps-presets/src/autopack_preset.rs @@ -0,0 +1,273 @@ +//! Autopack preset — auto-detecting builds via the autopack crates. +//! +//! Autopack is a library, so unlike the builder it replaced nothing is written +//! into the build context and no external binary is invoked: this asks it for a +//! Dockerfile and hands the string back. +//! +//! [`render_or_explain`] is shared with the legacy `nixpacks*` preset slugs, +//! which now build through autopack too. + +use std::path::Path; + +use async_trait::async_trait; +use autopack_core::{analyze, App, Environment}; +use autopack_dockerfile::to_dockerfile; +use tracing::{debug, info, warn}; + +use crate::{DockerfileConfig, DockerfileWithArgs, Preset, ProjectType}; + +/// Builds any application autopack recognises. +#[derive(Debug, Clone, Copy, Default)] +pub struct AutopackPreset; + +impl AutopackPreset { + /// Create the preset. + pub fn new() -> Self { + Self + } +} + +/// Run autopack over `config` and render a Dockerfile. +/// +/// `provider` forces a specific autopack provider; `None` auto-detects. +/// +/// Errors are returned as a message rather than a panic: a build that fails +/// with an explanation is recoverable, and the caller renders it to the +/// deployment log. +pub(crate) fn render( + config: &DockerfileConfig<'_>, + provider: Option<&str>, +) -> Result { + // Autopack's Dockerfiles use cache and secret mounts, which the classic + // builder cannot parse. Refusing here names the problem; emitting the + // Dockerfile anyway fails several minutes later with a syntax error that + // points at a line the user never wrote. + if !config.use_buildkit { + return Err( + "autopack requires BuildKit — its Dockerfiles use cache and secret mounts. \ + Enable BuildKit for this build (the deployment pipeline does so by default; \ + `temps build` needs `--buildkit`)." + .to_string(), + ); + } + + let app = App::new(config.local_path).map_err(|e| e.to_string())?; + + // Build the environment explicitly. Inheriting the server's process + // environment would let a variable on the control plane change how a + // user's application builds. + let mut env = Environment::new(); + + for pair in config.build_vars.into_iter().flatten() { + if let Some((key, value)) = pair.split_once('=') { + env.set(key, value); + } + } + + // Map the platform's own build settings onto autopack's configuration + // surface, so the existing UI keeps working unchanged. + if let Some(command) = config.install_command { + env.set("AUTOPACK_INSTALL_CMD", command); + } + if let Some(command) = config.build_command { + env.set("AUTOPACK_BUILD_CMD", command); + } + if let Some(dir) = config.output_dir { + env.set("AUTOPACK_STATIC_DIR", dir); + } + if let Some(provider) = provider { + env.set("AUTOPACK_PROVIDER", provider); + } + + let analysis = + analyze(&app, &env, &autopack_providers::registry()).map_err(|e| e.to_string())?; + + info!( + provider = %analysis.provider, + start_command = ?analysis.plan.deploy.start_command, + "autopack analysed the application" + ); + + // Anything a compatibility translation could not carry over, or a start + // command that will not survive being taken literally, surfaces here rather + // than becoming a mysterious runtime failure. + for (key, value) in &analysis.metadata { + if key.starts_with("configNote") { + warn!("autopack: {value}"); + } else { + debug!("autopack: {key} = {value}"); + } + } + + let dockerfile = to_dockerfile(&analysis.plan).map_err(|e| e.to_string())?; + Ok(DockerfileWithArgs::new(dockerfile)) +} + +/// Render, or fall back to a Dockerfile that fails loudly with the reason. +/// +/// The trait cannot return an error, and returning an empty or plausible +/// Dockerfile would turn a detection failure into a confusing runtime failure +/// several minutes later — or, worse, an image that builds and then exits. +pub(crate) fn render_or_explain( + config: &DockerfileConfig<'_>, + provider: Option<&str>, +) -> DockerfileWithArgs { + match render(config, provider) { + Ok(dockerfile) => dockerfile, + Err(message) => { + warn!("autopack could not plan this application: {message}"); + DockerfileWithArgs::new(format!( + "# autopack could not plan this application.\n\ + #\n\ + # {}\n\ + FROM debian:bookworm-slim\n\ + RUN echo {} >&2 && exit 1\n", + message.replace('\n', "\n# "), + shell_quote(&message) + )) + } + } +} + +/// Quote a message for safe interpolation into a shell command. +fn shell_quote(value: &str) -> String { + format!("'{}'", value.replace('\'', r"'\''")) +} + +#[async_trait] +impl Preset for AutopackPreset { + fn project_type(&self) -> ProjectType { + ProjectType::Server + } + + fn label(&self) -> String { + "Autopack (auto-detect)".to_string() + } + + fn icon_url(&self) -> String { + "/presets/autopack.svg".to_string() + } + + fn description(&self) -> String { + "Detects the language and framework automatically and builds an \ + unprivileged, minimal image. Supports 24 ecosystems and reads \ + existing nixpacks.toml or railpack.json configuration." + .to_string() + } + + async fn dockerfile(&self, config: DockerfileConfig<'_>) -> DockerfileWithArgs { + render_or_explain(&config, None) + } + + async fn dockerfile_with_build_dir(&self, local_path: &Path) -> DockerfileWithArgs { + let mut config = DockerfileConfig::new(local_path, local_path, "app"); + config.use_buildkit = true; + render_or_explain(&config, None) + } + + fn dirs_to_upload(&self) -> Vec { + vec![".".to_string()] + } + + fn slug(&self) -> String { + "autopack".to_string() + } + + fn default_port(&self) -> u16 { + 3000 + } +} + +impl std::fmt::Display for AutopackPreset { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "Autopack") + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + fn fixture(files: &[(&str, &str)]) -> tempfile::TempDir { + let dir = tempfile::tempdir().unwrap(); + for (path, contents) in files { + let full = dir.path().join(path); + fs::create_dir_all(full.parent().unwrap()).unwrap(); + fs::write(full, contents).unwrap(); + } + dir + } + + fn node_app() -> tempfile::TempDir { + fixture(&[ + ("package.json", r#"{"scripts":{"start":"node server.js"}}"#), + ("server.js", ""), + ]) + } + + fn buildkit_config<'a>(path: &'a Path) -> DockerfileConfig<'a> { + let mut config = DockerfileConfig::new(path, path, "app"); + config.use_buildkit = true; + config + } + + #[tokio::test] + async fn renders_a_dockerfile_for_a_node_app() { + let dir = node_app(); + let result = AutopackPreset::new() + .dockerfile_with_build_dir(dir.path()) + .await; + + assert!(result.content.starts_with("# syntax="), "{}", result.content); + assert!(result.content.contains("node server.js")); + } + + #[tokio::test] + async fn an_unrecognised_app_produces_a_dockerfile_that_fails_loudly() { + // Returning something that builds would hide the real problem until + // the container refuses to start. + let dir = fixture(&[("notes.txt", "nothing to build here")]); + + let result = AutopackPreset::new() + .dockerfile_with_build_dir(dir.path()) + .await; + + assert!(result.content.contains("exit 1"), "{}", result.content); + assert!(result.content.contains("autopack could not plan")); + } + + #[tokio::test] + async fn platform_build_settings_reach_autopack() { + let dir = node_app(); + let mut config = buildkit_config(dir.path()); + config.build_command = Some("npm run build:prod"); + + let result = AutopackPreset::new().dockerfile(config).await; + assert!(result.content.contains("build:prod"), "{}", result.content); + } + + #[tokio::test] + async fn a_build_without_buildkit_is_refused_by_name() { + // The classic builder cannot parse `--mount`, and the error it gives + // points at a generated line rather than at the missing feature. + let dir = node_app(); + let config = DockerfileConfig::new(dir.path(), dir.path(), "app"); + assert!(!config.use_buildkit, "the default must stay off"); + + let result = AutopackPreset::new().dockerfile(config).await; + assert!(result.content.contains("BuildKit"), "{}", result.content); + assert!(result.content.contains("exit 1")); + } + + #[test] + fn forcing_a_provider_overrides_detection() { + // A repository that looks like two things must build as the one the + // user picked, not the one that happens to detect first. + let dir = fixture(&[("main.go", "package main\nfunc main() {}\n"), ("go.mod", "module x\n\ngo 1.22\n")]); + let config = buildkit_config(dir.path()); + + let forced = render(&config, Some("go")).expect("go provider"); + assert!(forced.content.contains("go build"), "{}", forced.content); + } +} diff --git a/crates/temps-presets/src/go_preset.rs b/crates/temps-presets/src/go_preset.rs index 1217f5434..6100073ee 100644 --- a/crates/temps-presets/src/go_preset.rs +++ b/crates/temps-presets/src/go_preset.rs @@ -1,13 +1,13 @@ -//! Go preset implementation using Nixpacks +//! Go preset implementation using autopack //! -//! This preset detects Go projects (go.mod) and uses Nixpacks for building. +//! This preset detects Go projects (go.mod) and uses autopack for building. use crate::{DockerfileConfig, DockerfileWithArgs, NixpacksPreset, NixpacksProvider, Preset, ProjectType}; use async_trait::async_trait; use std::fmt; use std::path::Path; -/// Go preset - delegates to Nixpacks with Go provider +/// Go preset - delegates to autopack with the Go provider #[derive(Debug, Clone, Copy)] pub struct GoPreset; @@ -43,15 +43,15 @@ impl Preset for GoPreset { } async fn dockerfile(&self, config: DockerfileConfig<'_>) -> DockerfileWithArgs { - // Delegate to Nixpacks with Go provider - let nixpacks = NixpacksPreset::new(NixpacksProvider::Go); - nixpacks.dockerfile(config).await + // Delegate to autopack with the Go provider + let builder = NixpacksPreset::new(NixpacksProvider::Go); + builder.dockerfile(config).await } async fn dockerfile_with_build_dir(&self, local_path: &Path) -> DockerfileWithArgs { - // Delegate to Nixpacks with Go provider - let nixpacks = NixpacksPreset::new(NixpacksProvider::Go); - nixpacks.dockerfile_with_build_dir(local_path).await + // Delegate to autopack with the Go provider + let builder = NixpacksPreset::new(NixpacksProvider::Go); + builder.dockerfile_with_build_dir(local_path).await } fn install_command(&self, _local_path: &Path) -> String { diff --git a/crates/temps-presets/src/java_preset.rs b/crates/temps-presets/src/java_preset.rs index 80ca040f8..f6ca89c0a 100644 --- a/crates/temps-presets/src/java_preset.rs +++ b/crates/temps-presets/src/java_preset.rs @@ -1,7 +1,7 @@ -//! Java preset implementation using Nixpacks +//! Java preset implementation using autopack //! //! This preset detects Java projects (pom.xml, build.gradle, build.gradle.kts) -//! and uses Nixpacks for building. +//! and uses autopack for building. use crate::{DockerfileConfig, DockerfileWithArgs, NixpacksPreset, NixpacksProvider, Preset, ProjectType}; use async_trait::async_trait; @@ -44,15 +44,15 @@ impl Preset for JavaPreset { } async fn dockerfile(&self, config: DockerfileConfig<'_>) -> DockerfileWithArgs { - // Delegate to Nixpacks with Java provider - let nixpacks = NixpacksPreset::new(NixpacksProvider::Java); - nixpacks.dockerfile(config).await + // Delegate to autopack with the Java provider + let builder = NixpacksPreset::new(NixpacksProvider::Java); + builder.dockerfile(config).await } async fn dockerfile_with_build_dir(&self, local_path: &Path) -> DockerfileWithArgs { - // Delegate to Nixpacks with Java provider - let nixpacks = NixpacksPreset::new(NixpacksProvider::Java); - nixpacks.dockerfile_with_build_dir(local_path).await + // Delegate to autopack with the Java provider + let builder = NixpacksPreset::new(NixpacksProvider::Java); + builder.dockerfile_with_build_dir(local_path).await } fn install_command(&self, local_path: &Path) -> String { diff --git a/crates/temps-presets/src/mod.rs b/crates/temps-presets/src/mod.rs index 0ef00f8a2..a7a9e0660 100644 --- a/crates/temps-presets/src/mod.rs +++ b/crates/temps-presets/src/mod.rs @@ -6,6 +6,7 @@ mod docker_compose; mod docker_custom; mod docusaurus; mod nextjs; +mod autopack_preset; mod nixpacks_preset; mod react_app; mod rsbuild; @@ -37,6 +38,7 @@ use docusaurus::Docusaurus; use docker::DockerfilePreset; use docker_custom::DockerCustomPreset; pub use nextjs::NextJs; +pub use autopack_preset::AutopackPreset; pub use nixpacks_preset::{NixpacksPreset, NixpacksProvider}; pub use react_app::CreateReactApp; use rsbuild::Rsbuild; @@ -328,6 +330,7 @@ pub fn all_presets() -> Vec> { Box::new(DockerfilePreset), Box::new(DockerCustomPreset), // Nixpacks auto-detect + Box::new(AutopackPreset::new()), Box::new(NixpacksPreset::auto()), // Nixpacks provider-specific variants Box::new(NixpacksPreset::new(NixpacksProvider::Node)), diff --git a/crates/temps-presets/src/nixpacks_preset.rs b/crates/temps-presets/src/nixpacks_preset.rs index 0b1a8153b..a507b1e0a 100644 --- a/crates/temps-presets/src/nixpacks_preset.rs +++ b/crates/temps-presets/src/nixpacks_preset.rs @@ -1,35 +1,32 @@ -//! Nixpacks preset - generates Dockerfile for any supported language +//! The legacy `nixpacks*` preset slugs, now built by autopack. //! -//! This preset uses nixpacks to auto-detect the project language/framework -//! and generate an optimized Dockerfile. It acts as a fallback when no -//! framework-specific preset (Next.js, Vite, etc.) or user-provided Dockerfile exists. +//! The Nixpacks library is gone; these slugs remain. Projects created before +//! the switch have `preset = 'nixpacks'` and a [`NixpacksConfig`] persisted in +//! the database, and a deployment of one of them must keep working without the +//! user touching anything — so the slugs, labels, icons and stored config shape +//! are all preserved, and only the engine underneath changed. //! -//! Supported languages: Node.js, Python, Rust, Go, Java, PHP, Ruby, Elixir, .NET, Dart, etc. +//! New projects should use the `autopack` slug ([`crate::AutopackPreset`]). +//! This module is the compatibility surface, not the entry point. //! -//! Provider-specific variants allow explicit selection for monorepos and multi-language projects. - +//! Two behaviours are deliberately kept rather than "cleaned up": +//! +//! * a persisted `nixpacks_config` TOML is still honoured — autopack reads the +//! Nixpacks schema in compatibility mode, and reports anything it could not +//! translate instead of dropping it silently; +//! * the provider stored against a project still forces that language, so a +//! polyglot repository pinned to `python` does not silently start building +//! as `node` because detection order differs. + +use super::autopack_preset::render_or_explain; use crate::{DockerfileConfig, DockerfileWithArgs, Preset, ProjectType}; use async_trait::async_trait; -use nixpacks::nixpacks::{ - app::App, - builder::{ - docker::{docker_image_builder::DockerImageBuilder, DockerBuilderOptions}, - ImageBuilder, - }, - environment::Environment, - logger::Logger, - plan::{ - generator::{GeneratePlanOptions, NixpacksBuildPlanGenerator}, - BuildPlan, - PlanGenerator, - }, -}; -use std::collections::HashMap; +use autopack_core::compat::nixpacks::NixpacksConfig as CompatNixpacksConfig; +use autopack_core::{App, Environment}; use std::path::Path; -pub use temps_entities::preset::NixpacksProvider; use temps_entities::preset::NixpacksConfig; -use tokio::fs; -use tracing::{debug, info, warn}; +pub use temps_entities::preset::NixpacksProvider; +use tracing::{debug, warn}; fn provider_name(provider: NixpacksProvider) -> &'static str { match provider { @@ -62,7 +59,7 @@ fn provider_name(provider: NixpacksProvider) -> &'static str { fn provider_icon_url(provider: NixpacksProvider) -> &'static str { match provider { - NixpacksProvider::Auto => "/presets/nixpacks.svg", + NixpacksProvider::Auto => "/presets/autopack.svg", NixpacksProvider::Node => "/presets/nodejs.svg", NixpacksProvider::Python => "/presets/python.svg", NixpacksProvider::Rust => "/presets/rust.svg", @@ -93,16 +90,12 @@ fn provider_description(provider: NixpacksProvider) -> &'static str { match provider { NixpacksProvider::Auto => "Auto-detects your language and framework from the repository", NixpacksProvider::Node => { - "Node.js apps — Nuxt, Vue, SvelteKit, Astro, Remix, Express, and more" - } - NixpacksProvider::Python => { - "Python web applications (Django, Flask, FastAPI, etc.)" + "Node.js apps — Next, Nuxt, Vue, SvelteKit, Astro, Remix, Express, and more" } + NixpacksProvider::Python => "Python web applications (Django, Flask, FastAPI, etc.)", NixpacksProvider::Rust => "Rust web applications and services", NixpacksProvider::Go => "Go web applications and services", - NixpacksProvider::Java => { - "Java web applications (Spring Boot, Micronaut, Quarkus, etc.)" - } + NixpacksProvider::Java => "Java web applications (Spring Boot, Micronaut, Quarkus, etc.)", NixpacksProvider::Php => "PHP applications — Laravel, Symfony, and more", NixpacksProvider::Ruby => "Ruby applications — Ruby on Rails and more", NixpacksProvider::Deno => "Deno applications and services", @@ -124,6 +117,43 @@ fn provider_description(provider: NixpacksProvider) -> &'static str { } } +/// The autopack provider that builds what this Nixpacks provider used to. +/// +/// `None` means "let autopack detect", which is right for `Auto` — and is also +/// the only honest answer for a provider autopack does not implement. Forcing +/// an id autopack does not know would fail the build outright; falling back to +/// detection at least gives the app a chance, and the caller logs the gap. +fn autopack_provider(provider: NixpacksProvider) -> Option<&'static str> { + match provider { + NixpacksProvider::Auto => None, + NixpacksProvider::Node => Some("node"), + NixpacksProvider::Python => Some("python"), + NixpacksProvider::Rust => Some("rust"), + NixpacksProvider::Go => Some("go"), + NixpacksProvider::Java => Some("java"), + NixpacksProvider::Php => Some("php"), + NixpacksProvider::Ruby => Some("ruby"), + NixpacksProvider::Deno => Some("deno"), + NixpacksProvider::Elixir => Some("elixir"), + // Nixpacks split .NET by language; autopack has one provider for both. + NixpacksProvider::CSharp | NixpacksProvider::FSharp => Some("dotnet"), + NixpacksProvider::Dart => Some("dart"), + NixpacksProvider::Swift => Some("swift"), + NixpacksProvider::Zig => Some("zig"), + NixpacksProvider::Scala => Some("scala"), + NixpacksProvider::Haskell => Some("haskell"), + NixpacksProvider::Clojure => Some("clojure"), + NixpacksProvider::Crystal => Some("crystal"), + NixpacksProvider::Cobol => Some("cobol"), + NixpacksProvider::Gleam => Some("gleam"), + NixpacksProvider::Lunatic => Some("lunatic"), + // Nixpacks' Scheme support was its Haunt static-site generator; autopack + // has no equivalent, so detection decides (usually `static` or `shell`). + NixpacksProvider::Scheme => None, + NixpacksProvider::Static => Some("static"), + } +} + pub struct NixpacksPreset { config: NixpacksConfig, } @@ -151,15 +181,19 @@ impl NixpacksPreset { Self { config } } + /// Reject a stored `nixpacks_config` that no longer parses. + /// + /// The check runs against autopack's compatibility reader — the same code + /// that will read the file at build time — so validation cannot pass for + /// something the build then refuses. pub(super) fn validate_config( config: &NixpacksConfig, ) -> Result<(), crate::PresetResolutionError> { if let Some(value) = config.nixpacks_config.as_deref() { - BuildPlan::from_toml(value).map_err(|_| { + toml::from_str::(value).map_err(|error| { crate::PresetResolutionError::InvalidConfig { slug: "nixpacks".to_string(), - reason: "failed to parse Nixpacks TOML; verify its syntax and supported fields" - .to_string(), + reason: format!("failed to parse Nixpacks TOML: {error}"), } })?; } @@ -173,388 +207,120 @@ impl NixpacksPreset { } } - fn generate_plan_options(&self) -> Result { - let mut plan = match self.config.nixpacks_config.as_deref() { - Some(config) => BuildPlan::from_toml(config).map_err(|_| { - "Failed to parse custom Nixpacks config; verify its syntax and supported fields" - .to_string() - })?, - None => BuildPlan::default(), - }; - - if !self.config.providers.is_empty() { - plan.providers = Some( - self.config - .providers - .iter() - .map(|provider| provider.nixpacks_name().to_string()) - .collect(), + /// The autopack provider id to force for this preset, if any. + fn forced_provider(&self) -> Option<&'static str> { + let provider = self.display_provider(); + let mapped = autopack_provider(provider); + if mapped.is_none() && provider != NixpacksProvider::Auto { + warn!( + provider = provider_name(provider), + "autopack has no provider for this language; falling back to auto-detection" ); } - - Ok(GeneratePlanOptions { - plan: Some(plan), - ..Default::default() - }) + mapped } - fn generate_build_plan( - &self, - app: &App, - environment: &Environment, - ) -> Result { - let providers: &[&dyn nixpacks::providers::Provider] = &[ - &nixpacks::providers::node::NodeProvider {}, - &nixpacks::providers::python::PythonProvider {}, - &nixpacks::providers::rust::RustProvider {}, - &nixpacks::providers::go::GolangProvider {}, - &nixpacks::providers::java::JavaProvider {}, - &nixpacks::providers::php::PhpProvider {}, - &nixpacks::providers::ruby::RubyProvider {}, - &nixpacks::providers::deno::DenoProvider {}, - &nixpacks::providers::elixir::ElixirProvider {}, - &nixpacks::providers::csharp::CSharpProvider {}, - &nixpacks::providers::fsharp::FSharpProvider {}, - &nixpacks::providers::dart::DartProvider {}, - &nixpacks::providers::swift::SwiftProvider {}, - &nixpacks::providers::zig::ZigProvider {}, - &nixpacks::providers::scala::ScalaProvider {}, - &nixpacks::providers::haskell::HaskellStackProvider {}, - &nixpacks::providers::clojure::ClojureProvider {}, - &nixpacks::providers::crystal::CrystalProvider {}, - &nixpacks::providers::cobol::CobolProvider {}, - &nixpacks::providers::gleam::GleamProvider {}, - &nixpacks::providers::lunatic::LunaticProvider {}, - &nixpacks::providers::scheme::HauntProvider {}, - &nixpacks::providers::staticfile::StaticfileProvider {}, - ]; - - let options = self.generate_plan_options()?; - let mut generator = NixpacksBuildPlanGenerator::new(providers, options); - generator - .generate_plan(app, environment) - .map(|(plan, _)| plan) - .map_err(|error| format!("Failed to generate build plan: {}", error)) - } - - /// Detect which providers are available for a given path - /// Returns a list of providers that can handle the project - pub fn detect_available_providers(path: &Path) -> Vec { - let mut available = Vec::new(); - - // Check if a Nixpacks config file exists to pass as an option if present - // Supported: nixpacks.toml or .nixpacks.toml - let nixpacks_toml = path.join("nixpacks.toml"); - let dot_nixpacks_toml = path.join(".nixpacks.toml"); - let config_file = if nixpacks_toml.exists() { - Some(nixpacks_toml) - } else if dot_nixpacks_toml.exists() { - Some(dot_nixpacks_toml) - } else { - None - }; - - // Check each provider (except Auto and Static) - let providers_to_check: Vec<(NixpacksProvider, &dyn nixpacks::providers::Provider)> = vec![ - ( - NixpacksProvider::Node, - &nixpacks::providers::node::NodeProvider {}, - ), - ( - NixpacksProvider::Python, - &nixpacks::providers::python::PythonProvider {}, - ), - ( - NixpacksProvider::Rust, - &nixpacks::providers::rust::RustProvider {}, - ), - ( - NixpacksProvider::Go, - &nixpacks::providers::go::GolangProvider {}, - ), - ( - NixpacksProvider::Java, - &nixpacks::providers::java::JavaProvider {}, - ), - ( - NixpacksProvider::Php, - &nixpacks::providers::php::PhpProvider {}, - ), - ( - NixpacksProvider::Ruby, - &nixpacks::providers::ruby::RubyProvider {}, - ), - ( - NixpacksProvider::Deno, - &nixpacks::providers::deno::DenoProvider {}, - ), - ( - NixpacksProvider::Elixir, - &nixpacks::providers::elixir::ElixirProvider {}, - ), - ( - NixpacksProvider::CSharp, - &nixpacks::providers::csharp::CSharpProvider {}, - ), - ( - NixpacksProvider::FSharp, - &nixpacks::providers::fsharp::FSharpProvider {}, - ), - ( - NixpacksProvider::Dart, - &nixpacks::providers::dart::DartProvider {}, - ), - ( - NixpacksProvider::Swift, - &nixpacks::providers::swift::SwiftProvider {}, - ), - ( - NixpacksProvider::Zig, - &nixpacks::providers::zig::ZigProvider {}, - ), - ( - NixpacksProvider::Scala, - &nixpacks::providers::scala::ScalaProvider {}, - ), - ( - NixpacksProvider::Haskell, - &nixpacks::providers::haskell::HaskellStackProvider {}, - ), - ( - NixpacksProvider::Clojure, - &nixpacks::providers::clojure::ClojureProvider {}, - ), - ( - NixpacksProvider::Crystal, - &nixpacks::providers::crystal::CrystalProvider {}, - ), - ( - NixpacksProvider::Cobol, - &nixpacks::providers::cobol::CobolProvider {}, - ), - ( - NixpacksProvider::Gleam, - &nixpacks::providers::gleam::GleamProvider {}, - ), - ( - NixpacksProvider::Lunatic, - &nixpacks::providers::lunatic::LunaticProvider {}, - ), - ( - NixpacksProvider::Scheme, - &nixpacks::providers::scheme::HauntProvider {}, - ), - ]; - - let path_str = match path.to_str() { - Some(s) => s, - None => return available, - }; - - let app = match App::new(path_str) { - Ok(app) => app, - Err(_) => return available, - }; - - let environment = match Environment::from_envs(vec![]) { - Ok(env) => env, - Err(_) => return available, - }; - - // Check each provider individually, passing config if present - for (provider_type, provider) in providers_to_check { - let providers_slice = vec![provider]; - - // Build options, include the config path if file exists - let mut options = GeneratePlanOptions::default(); - if let Some(config_path) = &config_file { - // Only pass the config if the file exists - options.config_file = Some(config_path.to_string_lossy().to_string()); - } - - let mut generator = NixpacksBuildPlanGenerator::new(&providers_slice, options); - - if let Ok((plan, _)) = generator.generate_plan(&app, &environment) { - let phase_count = plan.phases.clone().map_or(0, |phases| phases.len()); - if phase_count > 0 && plan.start_phase.is_some() { - available.push(provider_type); - } - } - } - - available - } -} - -impl NixpacksPreset { - /// Check if nixpacks can detect and handle this project + /// Whether autopack can plan a build for this project at all. pub fn can_detect(path: &Path) -> bool { - // Try to generate a plan - if successful, nixpacks can handle it - let path_str = match path.to_str() { - Some(s) => s, - None => { - warn!("Invalid path encoding for nixpacks detection"); - return false; - } - }; + Self::detect_provider_id(path).is_some() + } - let app = match App::new(path_str) { + /// The autopack provider that claims this project, if any. + fn detect_provider_id(path: &Path) -> Option { + let app = match App::new(path) { Ok(app) => app, - Err(e) => { - debug!("Nixpacks: Failed to create app: {}", e); - return false; - } - }; - - let environment = match Environment::from_envs(vec![]) { - Ok(env) => env, - Err(e) => { - debug!("Nixpacks: Failed to create environment: {}", e); - return false; + Err(error) => { + debug!("autopack: could not read {path:?}: {error}"); + return None; } }; - - let providers: &[&dyn nixpacks::providers::Provider] = &[ - &nixpacks::providers::node::NodeProvider {}, - &nixpacks::providers::python::PythonProvider {}, - &nixpacks::providers::rust::RustProvider {}, - &nixpacks::providers::go::GolangProvider {}, - &nixpacks::providers::java::JavaProvider {}, - &nixpacks::providers::php::PhpProvider {}, - &nixpacks::providers::ruby::RubyProvider {}, - &nixpacks::providers::deno::DenoProvider {}, - &nixpacks::providers::elixir::ElixirProvider {}, - &nixpacks::providers::csharp::CSharpProvider {}, - &nixpacks::providers::fsharp::FSharpProvider {}, - &nixpacks::providers::dart::DartProvider {}, - &nixpacks::providers::swift::SwiftProvider {}, - &nixpacks::providers::zig::ZigProvider {}, - &nixpacks::providers::scala::ScalaProvider {}, - &nixpacks::providers::haskell::HaskellStackProvider {}, - &nixpacks::providers::clojure::ClojureProvider {}, - &nixpacks::providers::crystal::CrystalProvider {}, - &nixpacks::providers::cobol::CobolProvider {}, - &nixpacks::providers::gleam::GleamProvider {}, - &nixpacks::providers::lunatic::LunaticProvider {}, - &nixpacks::providers::scheme::HauntProvider {}, - &nixpacks::providers::staticfile::StaticfileProvider {}, - ]; - - let mut generator = - NixpacksBuildPlanGenerator::new(providers, GeneratePlanOptions::default()); - - match generator.generate_plan(&app, &environment) { - Ok((plan, _)) => { - // Check if we have a valid plan with phases and start command - let phase_count = plan.phases.clone().map_or(0, |phases| phases.len()); - if phase_count > 0 { - let start = plan.start_phase.clone().unwrap_or_default(); - if start.cmd.is_some() { - debug!("Nixpacks: Successfully detected project at {:?}", path); - return true; - } - } - debug!("Nixpacks: Plan generated but missing start command"); - false - } - Err(e) => { - debug!("Nixpacks: Failed to generate plan: {}", e); - false + let env = Environment::new(); + match autopack_providers::registry().detect(&app, &env) { + Ok(Some(provider)) => Some(provider.id().to_string()), + Ok(None) => None, + Err(error) => { + debug!("autopack: detection failed for {path:?}: {error}"); + None } } } - /// Generate actual Dockerfile using nixpacks' DockerImageBuilder + /// Which of the selectable providers can build this project. /// - /// This function uses the nixpacks library to: - /// 1. Detect the project language/framework - /// 2. Generate an optimized build plan - /// 3. Use DockerImageBuilder to generate the actual Dockerfile - /// 4. Extract build args from the plan's variables - /// 5. Read the generated Dockerfile from .nixpacks/Dockerfile + /// Autopack resolves exactly one provider per project rather than ranking + /// candidates, so this reports that one — the UI uses it to preselect a + /// language, and offering several would imply a choice that does not exist. + pub fn detect_available_providers(path: &Path) -> Vec { + let Some(detected) = Self::detect_provider_id(path) else { + return Vec::new(); + }; + [ + NixpacksProvider::Node, + NixpacksProvider::Python, + NixpacksProvider::Rust, + NixpacksProvider::Go, + NixpacksProvider::Java, + NixpacksProvider::Php, + NixpacksProvider::Ruby, + NixpacksProvider::Deno, + NixpacksProvider::Elixir, + NixpacksProvider::CSharp, + NixpacksProvider::Dart, + NixpacksProvider::Swift, + NixpacksProvider::Zig, + NixpacksProvider::Scala, + NixpacksProvider::Haskell, + NixpacksProvider::Clojure, + NixpacksProvider::Crystal, + NixpacksProvider::Cobol, + NixpacksProvider::Gleam, + NixpacksProvider::Lunatic, + NixpacksProvider::Static, + ] + .into_iter() + .filter(|provider| autopack_provider(*provider) == Some(detected.as_str())) + .collect() + } + + /// Apply the persisted Nixpacks TOML, if there is one, as an overlay file + /// autopack will read in compatibility mode. /// - /// Returns both the Dockerfile content and the build args that should be passed to docker build. - async fn generate_dockerfile_content( - &self, - path: &Path, - build_vars: Option<&Vec>, - ) -> Result { - let path_str = path - .to_str() - .ok_or_else(|| "Invalid path encoding".to_string())?; - - info!("Generating Dockerfile for: {:?}", path); - - // Create nixpacks App - let app = - App::new(path_str).map_err(|e| format!("Failed to create nixpacks app: {}", e))?; - - // Create environment from build variables - let env_vars: Vec<&str> = build_vars - .map(|vars| vars.iter().map(|s| s.as_str()).collect()) - .unwrap_or_default(); - - let environment = Environment::from_envs(env_vars) - .map_err(|e| format!("Failed to create environment: {}", e))?; - - // Generate the build plan with the persisted provider selection and - // optional user-supplied Nixpacks plan. - let plan = self.generate_build_plan(&app, &environment)?; - - // Validate plan - let phase_count = plan.phases.clone().map_or(0, |phases| phases.len()); - if phase_count == 0 { - return Err("Unable to generate a build plan for this app. \ - Please check https://nixpacks.com for supported languages." - .to_string()); + /// The config lives in the database, not the repository, so it has to be + /// materialised somewhere autopack can see it. Writing it into the build + /// context is what the previous implementation did with `.nixpacks/`, and + /// keeps the semantics identical. + fn stage_config(&self, local_path: &Path) -> Option { + let contents = self.config.nixpacks_config.as_deref()?; + // A repository that ships its own file already says what its author + // wanted; the stored config is the platform's copy of the same thing, + // and overwriting the file would lose the user's edits. + let path = local_path.join("nixpacks.toml"); + if path.exists() { + debug!("nixpacks.toml already present in the repository; leaving it alone"); + return None; } - - // let start = plan.start_phase.clone().unwrap_or_default(); - // if start.cmd.is_none() { - // return Err("No start command could be found in the build plan".to_string()); - // } - - // Use DockerImageBuilder to generate the actual Dockerfile - let builder = DockerImageBuilder::new( - Logger::new(), - DockerBuilderOptions { - out_dir: Some(path.to_string_lossy().to_string()), - ..Default::default() - }, - ); - - // Generate Dockerfile at .nixpacks/Dockerfile - builder - .create_image(path_str, &plan, &environment) - .await - .map_err(|e| format!("Failed to create nixpacks image: {}", e))?; - - // Read the generated Dockerfile - let nixpacks_dockerfile = path.join(".nixpacks").join("Dockerfile"); - let dockerfile = fs::read_to_string(&nixpacks_dockerfile) - .await - .map_err(|e| format!("Failed to read generated Dockerfile: {}", e))?; - - // Extract build args from the plan's variables - // These are the environment variables that nixpacks has set as defaults - let mut build_args = HashMap::new(); - if let Some(variables) = plan.variables { - for (key, value) in variables.iter() { - build_args.insert(key.clone(), value.clone()); + match std::fs::write(&path, contents) { + Ok(()) => Some(StagedConfig { path }), + Err(error) => { + warn!("could not stage the stored nixpacks.toml: {error}"); + None } } + } +} - debug!( - dockerfile_bytes = dockerfile.len(), - build_arg_count = build_args.len(), - "Generated Nixpacks Dockerfile" - ); - info!( - "Successfully generated Dockerfile using nixpacks with {} build args", - build_args.len() - ); +/// Removes a staged `nixpacks.toml` when the build plan has been generated. +/// +/// The build context is a checkout the platform owns, but leaving the file +/// behind would make a later `autopack.json` look like it lost to a stale +/// compatibility file. +struct StagedConfig { + path: std::path::PathBuf, +} - Ok(DockerfileWithArgs::with_args(dockerfile, build_args)) +impl Drop for StagedConfig { + fn drop(&mut self) { + let _ = std::fs::remove_file(&self.path); } } @@ -573,9 +339,9 @@ impl Preset for NixpacksPreset { .map(|provider| provider_name(*provider)) .collect::>() .join(" + "); - format!("Nixpacks ({})", providers) + format!("Autopack ({providers})") } else { - format!("Nixpacks ({})", provider_name(self.display_provider())) + format!("Autopack ({})", provider_name(self.display_provider())) } } @@ -619,56 +385,27 @@ impl Preset for NixpacksPreset { } async fn dockerfile(&self, config: DockerfileConfig<'_>) -> DockerfileWithArgs { - match self - .generate_dockerfile_content(config.local_path, config.build_vars) - .await - { - Ok(dockerfile_with_args) => dockerfile_with_args, - Err(e) => { - warn!("Failed to generate nixpacks Dockerfile: {}", e); - // Return a minimal fallback Dockerfile - DockerfileWithArgs::new(format!( - r#"FROM alpine:latest -WORKDIR /app -COPY . . -# Nixpacks failed to generate Dockerfile: {} -# Please provide a custom Dockerfile or check your project structure -"#, - e - )) - } - } + let _staged = self.stage_config(config.local_path); + render_or_explain(&config, self.forced_provider()) } async fn dockerfile_with_build_dir(&self, local_path: &Path) -> DockerfileWithArgs { - match self.generate_dockerfile_content(local_path, None).await { - Ok(dockerfile_with_args) => dockerfile_with_args, - Err(e) => { - warn!("Failed to generate nixpacks Dockerfile: {}", e); - DockerfileWithArgs::new(format!( - r#"FROM alpine:latest -WORKDIR /app -COPY . . -# Nixpacks failed: {} -"#, - e - )) - } - } + let _staged = self.stage_config(local_path); + let mut config = DockerfileConfig::new(local_path, local_path, "app"); + config.use_buildkit = true; + render_or_explain(&config, self.forced_provider()) } fn install_command(&self, _local_path: &Path) -> String { - // Nixpacks handles installation automatically in the Dockerfile - "# Handled by nixpacks".to_string() + "# Handled by autopack".to_string() } fn build_command(&self, _local_path: &Path) -> String { - // Nixpacks handles build automatically in the Dockerfile - "# Handled by nixpacks".to_string() + "# Handled by autopack".to_string() } fn dirs_to_upload(&self) -> Vec { - // Nixpacks needs the entire project directory + // The whole project directory: autopack decides what it needs. vec![".".to_string()] } @@ -692,871 +429,253 @@ mod tests { use std::fs; use tempfile::TempDir; - /// Helper to get the detected language/provider from build plan - fn get_detected_language(path: &Path) -> Option { - let path_str = path.to_str()?; - let app = App::new(path_str).ok()?; - let environment = Environment::from_envs(vec![]).ok()?; - - let providers: &[&dyn nixpacks::providers::Provider] = &[ - &nixpacks::providers::node::NodeProvider {}, - &nixpacks::providers::python::PythonProvider {}, - &nixpacks::providers::rust::RustProvider {}, - &nixpacks::providers::go::GolangProvider {}, - &nixpacks::providers::java::JavaProvider {}, - &nixpacks::providers::php::PhpProvider {}, - &nixpacks::providers::ruby::RubyProvider {}, - &nixpacks::providers::deno::DenoProvider {}, - &nixpacks::providers::elixir::ElixirProvider {}, - &nixpacks::providers::csharp::CSharpProvider {}, - &nixpacks::providers::fsharp::FSharpProvider {}, - &nixpacks::providers::dart::DartProvider {}, - &nixpacks::providers::swift::SwiftProvider {}, - &nixpacks::providers::zig::ZigProvider {}, - &nixpacks::providers::scala::ScalaProvider {}, - &nixpacks::providers::haskell::HaskellStackProvider {}, - &nixpacks::providers::clojure::ClojureProvider {}, - &nixpacks::providers::crystal::CrystalProvider {}, - &nixpacks::providers::cobol::CobolProvider {}, - &nixpacks::providers::gleam::GleamProvider {}, - &nixpacks::providers::lunatic::LunaticProvider {}, - &nixpacks::providers::scheme::HauntProvider {}, - &nixpacks::providers::staticfile::StaticfileProvider {}, - ]; - - let mut generator = - NixpacksBuildPlanGenerator::new(providers, GeneratePlanOptions::default()); - - let (plan, _) = generator.generate_plan(&app, &environment).ok()?; - - // Get the build plan string which contains the detected info - let build_string = plan.get_build_string().ok()?; - - Some(build_string) - } - - fn create_nodejs_project() -> TempDir { - let temp_dir = TempDir::new().unwrap(); - let package_json = r#"{ - "name": "test-app", - "version": "1.0.0", - "scripts": { - "start": "node index.js" - }, - "dependencies": { - "express": "^4.18.0" - } -}"#; - fs::write(temp_dir.path().join("package.json"), package_json).unwrap(); - fs::write( - temp_dir.path().join("index.js"), - "console.log('Hello World')", - ) - .unwrap(); - temp_dir - } - - fn create_python_project() -> TempDir { - let temp_dir = TempDir::new().unwrap(); - fs::write(temp_dir.path().join("requirements.txt"), "flask==2.0.0").unwrap(); - fs::write( - temp_dir.path().join("main.py"), - r#"from flask import Flask -app = Flask(__name__) - -@app.route('/') -def hello(): - return 'Hello World!' - -if __name__ == '__main__': - app.run() -"#, - ) - .unwrap(); - temp_dir + fn project(files: &[(&str, &str)]) -> TempDir { + let dir = TempDir::new().unwrap(); + for (path, contents) in files { + let full = dir.path().join(path); + fs::create_dir_all(full.parent().unwrap()).unwrap(); + fs::write(full, contents).unwrap(); + } + dir } - #[test] - fn test_nixpacks_detects_nodejs() { - let temp_dir = create_nodejs_project(); - - assert!( - NixpacksPreset::can_detect(temp_dir.path()), - "Should detect Node.js project" - ); - - // Verify it detected Node.js specifically - let detected = get_detected_language(temp_dir.path()).expect("Should detect language"); - assert!( - detected.to_lowercase().contains("node") || detected.contains("npm"), - "Build plan should indicate Node.js was detected, got: {}", - &detected[..detected.len().min(200)] - ); + fn nodejs_project() -> TempDir { + project(&[ + ( + "package.json", + r#"{"name":"test-app","scripts":{"start":"node index.js"},"dependencies":{"express":"^4.18.0"}}"#, + ), + ("index.js", "console.log('Hello World')"), + ]) } - #[test] - fn test_nixpacks_detects_python() { - let temp_dir = create_python_project(); - - assert!( - NixpacksPreset::can_detect(temp_dir.path()), - "Should detect Python project" - ); - - // Verify it detected Python specifically - let detected = get_detected_language(temp_dir.path()).expect("Should detect language"); - assert!( - detected.to_lowercase().contains("python") || detected.contains("pip"), - "Build plan should indicate Python was detected, got: {}", - &detected[..detected.len().min(200)] - ); + fn python_project() -> TempDir { + project(&[ + ("requirements.txt", "flask==2.0.0"), + ("main.py", "print('hello')"), + ]) } - #[test] - fn test_nixpacks_fails_empty_directory() { - let temp_dir = TempDir::new().unwrap(); - assert!(!NixpacksPreset::can_detect(temp_dir.path())); + async fn dockerfile_for(preset: &NixpacksPreset, dir: &Path) -> String { + let mut config = DockerfileConfig::new(dir, dir, "test-project"); + config.use_buildkit = true; + preset.dockerfile(config).await.content } #[test] - fn test_nixpacks_preset_properties() { - let preset = NixpacksPreset::auto(); - assert_eq!(preset.slug(), "nixpacks"); - assert_eq!(preset.label(), "Nixpacks (Auto-detect)"); - assert!(matches!(preset.project_type(), ProjectType::Server)); + fn detects_the_common_languages() { + for (label, dir) in [ + ("node", nodejs_project()), + ("python", python_project()), + ( + "go", + project(&[("go.mod", "module x\n\ngo 1.22\n"), ("main.go", "package main\nfunc main() {}\n")]), + ), + ( + "rust", + project(&[ + ("Cargo.toml", "[package]\nname = \"x\"\nversion = \"0.1.0\"\nedition = \"2021\"\n"), + ("src/main.rs", "fn main() {}"), + ]), + ), + ("dart", project(&[("pubspec.yaml", "name: x\n")])), + ] { + assert!( + NixpacksPreset::can_detect(dir.path()), + "should detect the {label} project" + ); + } } #[test] - fn test_nixpacks_provider_selection_reaches_plan_options() { - let preset = NixpacksPreset::from_config(NixpacksConfig { - nixpacks_config: None, - providers: vec![NixpacksProvider::Node], - }); - let options = preset.generate_plan_options().unwrap(); - - assert_eq!( - options.plan.and_then(|plan| plan.providers), - Some(vec!["node".to_string()]) - ); + fn a_directory_with_nothing_to_build_is_not_detected() { + let dir = project(&[("README.md", "# nothing here")]); + assert!(!NixpacksPreset::can_detect(dir.path())); } #[test] - fn test_nixpacks_multiple_providers_preserve_order_and_auto_marker() { - let preset = NixpacksPreset::from_config(NixpacksConfig { - nixpacks_config: None, - providers: vec![NixpacksProvider::Auto, NixpacksProvider::Python], - }); - let options = preset.generate_plan_options().unwrap(); - + fn detection_reports_the_matching_provider() { + let dir = python_project(); assert_eq!( - options.plan.and_then(|plan| plan.providers), - Some(vec!["...".to_string(), "python".to_string()]) + NixpacksPreset::detect_available_providers(dir.path()), + vec![NixpacksProvider::Python] ); - assert_eq!(preset.slug(), "nixpacks"); } - #[test] - fn test_nixpacks_invalid_custom_config_is_contextual() { - let preset = NixpacksPreset::from_config(NixpacksConfig { - nixpacks_config: Some("invalid = [".to_string()), - providers: Vec::new(), - }); - - let error = preset.generate_plan_options().unwrap_err(); - assert!(error.contains("Failed to parse custom Nixpacks config")); - } - - fn create_rust_project() -> TempDir { - let temp_dir = TempDir::new().unwrap(); - let cargo_toml = r#"[package] -name = "test-rust-app" -version = "0.1.0" -edition = "2021" - -[dependencies] -axum = "0.7" -tokio = { version = "1", features = ["full"] } -"#; - fs::write(temp_dir.path().join("Cargo.toml"), cargo_toml).unwrap(); - - let src_dir = temp_dir.path().join("src"); - fs::create_dir(&src_dir).unwrap(); - fs::write( - src_dir.join("main.rs"), - r#"fn main() { println!("Hello, world!"); }"#, - ) - .unwrap(); - temp_dir - } - - fn create_go_project() -> TempDir { - let temp_dir = TempDir::new().unwrap(); - let go_mod = r#"module example.com/hello - -go 1.21 -"#; - fs::write(temp_dir.path().join("go.mod"), go_mod).unwrap(); - fs::write( - temp_dir.path().join("main.go"), - r#"package main - -import "fmt" - -func main() { - fmt.Println("Hello, World!") -} -"#, - ) - .unwrap(); - temp_dir - } - - fn create_nextjs_project() -> TempDir { - let temp_dir = TempDir::new().unwrap(); - let package_json = r#"{ - "name": "nextjs-app", - "version": "1.0.0", - "scripts": { - "dev": "next dev", - "build": "next build", - "start": "next start" - }, - "dependencies": { - "next": "14.0.0", - "react": "^18.2.0", - "react-dom": "^18.2.0" - } -}"#; - fs::write(temp_dir.path().join("package.json"), package_json).unwrap(); - - let pages_dir = temp_dir.path().join("pages"); - fs::create_dir(&pages_dir).unwrap(); - fs::write( - pages_dir.join("index.js"), - "export default function Home() { return
Hello
}", - ) - .unwrap(); - temp_dir - } - - fn create_php_project() -> TempDir { - let temp_dir = TempDir::new().unwrap(); - let composer_json = r#"{ - "name": "test/php-app", - "require": { - "php": "^8.0" - } -}"#; - fs::write(temp_dir.path().join("composer.json"), composer_json).unwrap(); - fs::write( - temp_dir.path().join("index.php"), - " TempDir { - let temp_dir = TempDir::new().unwrap(); - let gemfile = r#"source 'https://rubygems.org' - -gem 'sinatra' -gem 'thin' -"#; - fs::write(temp_dir.path().join("Gemfile"), gemfile).unwrap(); - - // Create Gemfile.lock for detection - let gemfile_lock = r#"GEM - remote: https://rubygems.org/ - specs: - sinatra (3.0.0) - -PLATFORMS - ruby - -DEPENDENCIES - sinatra - -BUNDLED WITH - 2.4.0 -"#; - fs::write(temp_dir.path().join("Gemfile.lock"), gemfile_lock).unwrap(); - - // Create config.ru for Rack application - fs::write( - temp_dir.path().join("config.ru"), - r#"require './app' -run Sinatra::Application -"#, - ) - .unwrap(); - - fs::write( - temp_dir.path().join("app.rb"), - r#"require 'sinatra' - -get '/' do - 'Hello, World!' -end -"#, - ) - .unwrap(); - temp_dir - } - - fn create_java_project() -> TempDir { - let temp_dir = TempDir::new().unwrap(); - let pom_xml = r#" - - 4.0.0 - com.example - demo - 0.0.1-SNAPSHOT - demo - -"#; - fs::write(temp_dir.path().join("pom.xml"), pom_xml).unwrap(); - - let src_dir = temp_dir.path().join("src/main/java/com/example/demo"); - fs::create_dir_all(&src_dir).unwrap(); - fs::write( - src_dir.join("DemoApplication.java"), - r#"package com.example.demo; - -public class DemoApplication { - public static void main(String[] args) { - System.out.println("Hello, World!"); - } -} -"#, - ) - .unwrap(); - temp_dir - } - - fn create_deno_project() -> TempDir { - let temp_dir = TempDir::new().unwrap(); - fs::write( - temp_dir.path().join("main.ts"), - r#"import { serve } from "https://deno.land/std@0.140.0/http/server.ts"; - -serve(() => new Response("Hello, World!")); -"#, - ) - .unwrap(); - temp_dir - } - - fn create_elixir_project() -> TempDir { - let temp_dir = TempDir::new().unwrap(); - let mix_exs = r#"defmodule MyApp.MixProject do - use Mix.Project - - def project do - [ - app: :my_app, - version: "0.1.0", - elixir: "~> 1.14" - ] - end -end -"#; - fs::write(temp_dir.path().join("mix.exs"), mix_exs).unwrap(); - - let lib_dir = temp_dir.path().join("lib"); - fs::create_dir(&lib_dir).unwrap(); - fs::write( - lib_dir.join("my_app.ex"), - r#"defmodule MyApp do - def hello do - "Hello, World!" - end -end -"#, - ) - .unwrap(); - temp_dir - } - - fn create_csharp_project() -> TempDir { - let temp_dir = TempDir::new().unwrap(); - let csproj = r#" - - net7.0 - - -"#; - fs::write(temp_dir.path().join("MyApp.csproj"), csproj).unwrap(); - fs::write( - temp_dir.path().join("Program.cs"), - r#"var builder = WebApplication.CreateBuilder(args); -var app = builder.Build(); - -app.MapGet("/", () => "Hello World!"); - -app.Run(); -"#, - ) - .unwrap(); - temp_dir - } - - fn create_dart_project() -> TempDir { - let temp_dir = TempDir::new().unwrap(); - let pubspec = r#"name: my_app -version: 1.0.0 -environment: - sdk: '>=2.17.0 <3.0.0' -"#; - fs::write(temp_dir.path().join("pubspec.yaml"), pubspec).unwrap(); - - let bin_dir = temp_dir.path().join("bin"); - fs::create_dir(&bin_dir).unwrap(); - fs::write( - bin_dir.join("main.dart"), - r#"void main() { - print('Hello, World!'); -} -"#, - ) - .unwrap(); - temp_dir - } - - // Detection tests for all supported languages - #[test] - fn test_nixpacks_detects_rust() { - let temp_dir = create_rust_project(); - - assert!( - NixpacksPreset::can_detect(temp_dir.path()), - "Nixpacks should detect Rust project with Cargo.toml" - ); - - let detected = get_detected_language(temp_dir.path()).expect("Should detect language"); - assert!( - detected.to_lowercase().contains("rust") || detected.contains("cargo"), - "Build plan should indicate Rust was detected, got: {}", - &detected[..detected.len().min(200)] - ); - } - - #[test] - fn test_nixpacks_detects_go() { - let temp_dir = create_go_project(); - - assert!( - NixpacksPreset::can_detect(temp_dir.path()), - "Nixpacks should detect Go project with go.mod" - ); - - let detected = get_detected_language(temp_dir.path()).expect("Should detect language"); - assert!( - detected.to_lowercase().contains("go") || detected.contains("golang"), - "Build plan should indicate Go was detected, got: {}", - &detected[..detected.len().min(200)] - ); - } - - #[test] - fn test_nixpacks_detects_nextjs() { - let temp_dir = create_nextjs_project(); - - assert!( - NixpacksPreset::can_detect(temp_dir.path()), - "Nixpacks should detect Next.js project" - ); - - let detected = get_detected_language(temp_dir.path()).expect("Should detect language"); - assert!( - detected.to_lowercase().contains("node") - || detected.contains("next") - || detected.contains("npm"), - "Build plan should indicate Node.js/Next.js was detected, got: {}", - &detected[..detected.len().min(200)] - ); - } - - #[test] - fn test_nixpacks_detects_php() { - let temp_dir = create_php_project(); - - // Verify it can detect - assert!( - NixpacksPreset::can_detect(temp_dir.path()), - "Nixpacks should detect PHP project with composer.json" - ); - - // Verify it detected PHP specifically, not another language - let detected = get_detected_language(temp_dir.path()); - assert!(detected.is_some(), "Should return detection info"); - - let plan = detected.unwrap(); - assert!( - plan.to_lowercase().contains("php") || plan.contains("composer"), - "Build plan should indicate PHP was detected. Got: {:?}", - &plan[..plan.len().min(300)] - ); - } + #[tokio::test] + async fn generates_a_dockerfile_for_an_auto_detected_project() { + let dir = python_project(); + let content = dockerfile_for(&NixpacksPreset::auto(), dir.path()).await; - #[test] - fn test_nixpacks_detects_ruby() { - let temp_dir = create_ruby_project(); - // Ruby detection is currently not working in this nixpacks version - // Document the actual behavior - let can_detect = NixpacksPreset::can_detect(temp_dir.path()); - println!("Ruby project detection result: {}", can_detect); - // TODO: Investigate Ruby provider requirements in nixpacks + assert!(content.starts_with("# syntax="), "{content}"); + assert!(!content.contains("autopack could not plan"), "{content}"); } - #[test] - fn test_nixpacks_detects_java() { - let temp_dir = create_java_project(); - assert!( - NixpacksPreset::can_detect(temp_dir.path()), - "Nixpacks should detect Java project with pom.xml" - ); - } + #[tokio::test] + async fn a_stored_provider_forces_that_language() { + // A repository can look like more than one thing. If the project was + // pinned to a language, detection order must not override it. + let dir = project(&[ + ("package.json", r#"{"scripts":{"start":"node index.js"}}"#), + ("index.js", ""), + ("requirements.txt", "flask==2.0.0"), + ("main.py", "print('hi')"), + ]); - #[test] - fn test_nixpacks_detects_deno() { - let temp_dir = create_deno_project(); - // Deno detection might be tricky - it could detect as Node.js or Deno - // Document actual behavior - let can_detect = NixpacksPreset::can_detect(temp_dir.path()); - println!("Deno project detection result: {}", can_detect); - // This test documents behavior rather than asserting + let content = dockerfile_for(&NixpacksPreset::new(NixpacksProvider::Python), dir.path()).await; + assert!(content.contains("pip"), "{content}"); } #[test] - fn test_nixpacks_detects_elixir() { - let temp_dir = create_elixir_project(); - assert!( - NixpacksPreset::can_detect(temp_dir.path()), - "Nixpacks should detect Elixir project with mix.exs" + fn slugs_and_stored_preset_are_unchanged() { + // These are persisted. Changing either orphans existing projects. + assert_eq!(NixpacksPreset::auto().slug(), "nixpacks"); + assert_eq!( + NixpacksPreset::new(NixpacksProvider::Node).slug(), + NixpacksProvider::Node.variant_slug() ); - } - - #[test] - fn test_nixpacks_detects_csharp() { - let temp_dir = create_csharp_project(); - assert!( - NixpacksPreset::can_detect(temp_dir.path()), - "Nixpacks should detect C# project with .csproj" + assert_eq!( + NixpacksPreset::auto().stored_preset(), + Some(temps_entities::preset::Preset::Nixpacks) ); } - #[test] - fn test_explicit_csharp_provider_uses_native_nixpacks_name() { - let temp_dir = create_csharp_project(); - let app = App::new(temp_dir.path().to_string_lossy().as_ref()).unwrap(); - let environment = Environment::from_envs(vec![]).unwrap(); + #[tokio::test] + async fn a_persisted_nixpacks_toml_reaches_the_build() { + // The config lives in the database, so it has to be materialised for + // autopack's compatibility reader to see it. + let dir = nodejs_project(); let preset = NixpacksPreset::from_config(NixpacksConfig { - nixpacks_config: None, - providers: vec![NixpacksProvider::CSharp], + nixpacks_config: Some("[start]\ncmd = \"node custom-entry.js\"\n".to_string()), + ..Default::default() }); - let plan = preset - .generate_build_plan(&app, &environment) - .expect("explicit C# provider should resolve in Nixpacks"); - - assert!(!plan.phases.unwrap_or_default().is_empty()); - } - - #[test] - fn test_nixpacks_detects_dart() { - let temp_dir = create_dart_project(); - assert!( - NixpacksPreset::can_detect(temp_dir.path()), - "Nixpacks should detect Dart project with pubspec.yaml" - ); + let content = dockerfile_for(&preset, dir.path()).await; + assert!(content.contains("node custom-entry.js"), "{content}"); } - // Dockerfile generation tests #[tokio::test] - async fn test_dockerfile_generation_returns_content() { - let temp_dir = create_python_project(); - let preset = NixpacksPreset::auto(); - - let config = DockerfileConfig { - use_buildkit: true, - root_local_path: temp_dir.path(), - local_path: temp_dir.path(), - install_command: None, - build_command: None, - output_dir: None, - build_vars: None, - project_slug: "test-project", - }; - - let dockerfile = preset.dockerfile(config).await; + async fn staging_the_stored_config_leaves_no_file_behind() { + // A leftover nixpacks.toml would beat a later autopack.json. + let dir = nodejs_project(); + let preset = NixpacksPreset::from_config(NixpacksConfig { + nixpacks_config: Some("[start]\ncmd = \"node index.js\"\n".to_string()), + ..Default::default() + }); - println!("Generated content:\n{}", dockerfile.content); - println!("Content length: {}", dockerfile.content.len()); - let build_args = dockerfile.build_args; - println!("Build args: {:?}", build_args); - assert!( - !dockerfile.content.is_empty(), - "Dockerfile should not be empty" - ); - // Nixpacks returns a build plan summary, not a traditional Dockerfile - assert!( - dockerfile.content.contains("Nixpacks") - || dockerfile.content.contains("setup") - || dockerfile.content.contains("install"), - "Content should contain nixpacks build plan information" - ); + dockerfile_for(&preset, dir.path()).await; + assert!(!dir.path().join("nixpacks.toml").exists()); } #[tokio::test] - async fn test_stored_python_provider_controls_ambiguous_project_build() { - let temp_dir = create_python_project(); + async fn a_repository_nixpacks_toml_wins_over_the_stored_one() { + // The file in the repository is the one the author is editing. + let dir = nodejs_project(); fs::write( - temp_dir.path().join("package.json"), - r#"{ - "name": "ambiguous-app", - "scripts": { "start": "node index.js" }, - "dependencies": { "express": "^4.18.0" } -}"#, + dir.path().join("nixpacks.toml"), + "[start]\ncmd = \"node from-repo.js\"\n", ) .unwrap(); - fs::write(temp_dir.path().join("index.js"), "console.log('node')").unwrap(); - - let stored_config = - temps_entities::preset::PresetConfig::Nixpacks(NixpacksConfig { - nixpacks_config: None, - providers: vec![NixpacksProvider::Python], - }); - let preset = crate::get_preset_for_storage( - temps_entities::preset::Preset::Nixpacks, - Some(&stored_config), - ) - .expect("stored Nixpacks config should be valid") - .expect("stored Nixpacks preset should resolve"); - let dockerfile = preset - .dockerfile(DockerfileConfig { - use_buildkit: true, - root_local_path: temp_dir.path(), - local_path: temp_dir.path(), - install_command: None, - build_command: None, - output_dir: None, - build_vars: None, - project_slug: "ambiguous-project", - }) - .await; - - assert!( - !dockerfile.content.contains("Nixpacks failed"), - "{}", - dockerfile.content - ); - assert!( - dockerfile.content.to_lowercase().contains("python"), - "expected Python build plan, got:\n{}", - dockerfile.content - ); - } - - #[tokio::test] - async fn test_dockerfile_contains_build_plan() { - let temp_dir = create_nodejs_project(); - let preset = NixpacksPreset::auto(); - - let config = DockerfileConfig { - use_buildkit: true, - root_local_path: temp_dir.path(), - local_path: temp_dir.path(), - install_command: None, - build_command: None, - output_dir: None, - build_vars: None, - project_slug: "test-project", - }; - - let dockerfile = preset.dockerfile(config).await; - println!("Generated dockerfile: {}", dockerfile.content); - // Nixpacks returns a build plan summary with setup/install/start phases - assert!( - dockerfile.content.contains("install") || dockerfile.content.contains("start"), - "Build plan should contain phase information" - ); - } - - #[tokio::test] - async fn test_dockerfile_with_build_dir() { - let temp_dir = create_rust_project(); - let preset = NixpacksPreset::auto(); - - let dockerfile = preset.dockerfile_with_build_dir(temp_dir.path()).await; - - assert!(!dockerfile.content.is_empty()); - // Nixpacks returns a build plan, not a traditional Dockerfile - assert!( - dockerfile.content.contains("Nixpacks") - || dockerfile.content.contains("setup") - || dockerfile.content.contains("install"), - "Build plan should contain nixpacks information" - ); - } - - // Install and build command tests - #[test] - fn test_install_command_handled_by_nixpacks() { - let temp_dir = create_python_project(); - let preset = NixpacksPreset::auto(); - let install_cmd = preset.install_command(temp_dir.path()); - - assert!( - install_cmd.contains("nixpacks") || install_cmd.contains("Handled"), - "Install command should indicate nixpacks handles it" - ); - } - - #[test] - fn test_build_command_handled_by_nixpacks() { - let temp_dir = create_nodejs_project(); - let preset = NixpacksPreset::auto(); - - let build_cmd = preset.build_command(temp_dir.path()); + let preset = NixpacksPreset::from_config(NixpacksConfig { + nixpacks_config: Some("[start]\ncmd = \"node from-database.js\"\n".to_string()), + ..Default::default() + }); + let content = dockerfile_for(&preset, dir.path()).await; + assert!(content.contains("node from-repo.js"), "{content}"); assert!( - build_cmd.contains("nixpacks") || build_cmd.contains("Handled"), - "Build command should indicate nixpacks handles it" + dir.path().join("nixpacks.toml").exists(), + "the repository's own file must survive" ); } - // Dirs to upload tests #[test] - fn test_dirs_to_upload_includes_root() { - let preset = NixpacksPreset::auto(); - let dirs = preset.dirs_to_upload(); - - assert!(!dirs.is_empty(), "Should return directories to upload"); - assert!( - dirs.contains(&".".to_string()), - "Should include root directory" - ); + fn invalid_stored_toml_is_rejected_before_the_build() { + let result = NixpacksPreset::validate_config(&NixpacksConfig { + nixpacks_config: Some("this is not = valid = toml".to_string()), + ..Default::default() + }); + assert!(result.is_err()); } - // Display trait test #[test] - fn test_display_trait() { - let preset = NixpacksPreset::auto(); - assert_eq!(format!("{}", preset), "Nixpacks (Auto-detect)"); + fn valid_stored_toml_passes_validation() { + let result = NixpacksPreset::validate_config(&NixpacksConfig { + nixpacks_config: Some("[start]\ncmd = \"./server\"\n".to_string()), + ..Default::default() + }); + assert!(result.is_ok(), "{result:?}"); } - // Icon URL test #[test] - fn test_icon_url() { - let preset = NixpacksPreset::auto(); - let icon_url = preset.icon_url(); - - assert!( - icon_url.contains("nixpacks"), - "Icon URL should reference nixpacks" - ); - assert!(icon_url.ends_with(".svg"), "Icon should be SVG format"); + fn dotnet_providers_share_one_autopack_provider() { + assert_eq!(autopack_provider(NixpacksProvider::CSharp), Some("dotnet")); + assert_eq!(autopack_provider(NixpacksProvider::FSharp), Some("dotnet")); } - // Edge case: project with no start command #[test] - fn test_nixpacks_fails_on_project_without_entry_point() { - let temp_dir = TempDir::new().unwrap(); - // Create a package.json without start script - let package_json = r#"{ - "name": "incomplete-app", - "version": "1.0.0" -}"#; - fs::write(temp_dir.path().join("package.json"), package_json).unwrap(); - - // Nixpacks might still detect it but fail validation - // This depends on nixpacks behavior - it might still work with default start - let can_detect = NixpacksPreset::can_detect(temp_dir.path()); - - // Document the behavior - this might be true or false depending on nixpacks version - println!( - "Project without explicit entry point detection result: {}", - can_detect + fn a_language_autopack_lacks_falls_back_to_detection() { + // Forcing an id autopack does not know would fail the build outright. + assert_eq!(autopack_provider(NixpacksProvider::Scheme), None); + assert_eq!( + NixpacksPreset::new(NixpacksProvider::Scheme).forced_provider(), + None ); } - // Test with fixture directories (if they exist) #[test] - fn test_detect_python_flask_fixture() { - let fixture_path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .parent() - .unwrap() - .join("temps-deployments/tests/fixtures/simple-python"); - - if fixture_path.exists() { + fn every_selectable_provider_maps_to_a_real_autopack_provider() { + // A typo here is a build that fails with "unknown provider" for one + // language only, which is exactly the kind of thing nobody notices. + let registry = autopack_providers::registry(); + for provider in [ + NixpacksProvider::Node, + NixpacksProvider::Python, + NixpacksProvider::Rust, + NixpacksProvider::Go, + NixpacksProvider::Java, + NixpacksProvider::Php, + NixpacksProvider::Ruby, + NixpacksProvider::Deno, + NixpacksProvider::Elixir, + NixpacksProvider::CSharp, + NixpacksProvider::FSharp, + NixpacksProvider::Dart, + NixpacksProvider::Swift, + NixpacksProvider::Zig, + NixpacksProvider::Scala, + NixpacksProvider::Haskell, + NixpacksProvider::Clojure, + NixpacksProvider::Crystal, + NixpacksProvider::Cobol, + NixpacksProvider::Gleam, + NixpacksProvider::Lunatic, + NixpacksProvider::Static, + ] { + let id = autopack_provider(provider) + .unwrap_or_else(|| panic!("{provider:?} should map to a provider")); assert!( - NixpacksPreset::can_detect(&fixture_path), - "Should detect Python Flask fixture" - ); - } else { - println!( - "Python fixture not found at {:?}, skipping test", - fixture_path + registry.get(id).is_some(), + "{provider:?} maps to `{id}`, which autopack does not register" ); } } - #[test] - fn test_detect_nextjs_fixture() { - let fixture_path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .parent() - .unwrap() - .join("temps-deployments/tests/fixtures/simple-nextjs"); - - if fixture_path.exists() { - assert!( - NixpacksPreset::can_detect(&fixture_path), - "Should detect Next.js fixture" - ); - } else { - println!( - "Next.js fixture not found at {:?}, skipping test", - fixture_path - ); - } - } - - #[test] - fn test_detect_rust_fixture() { - let fixture_path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .parent() - .unwrap() - .join("temps-deployments/tests/fixtures/simple-rust"); - - if fixture_path.exists() { - assert!( - NixpacksPreset::can_detect(&fixture_path), - "Should detect Rust fixture" - ); - } else { - println!( - "Rust fixture not found at {:?}, skipping test", - fixture_path - ); - } + #[tokio::test] + async fn a_build_without_buildkit_is_refused_by_name() { + let dir = nodejs_project(); + let config = DockerfileConfig::new(dir.path(), dir.path(), "test"); + let content = NixpacksPreset::auto().dockerfile(config).await.content; + assert!(content.contains("BuildKit"), "{content}"); } - #[test] - fn test_detect_go_fixture() { - let fixture_path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .parent() - .unwrap() - .join("temps-deployments/tests/fixtures/simple-go"); - - if fixture_path.exists() { - assert!( - NixpacksPreset::can_detect(&fixture_path), - "Should detect Go fixture" - ); - } else { - println!("Go fixture not found at {:?}, skipping test", fixture_path); - } + #[tokio::test] + async fn an_unbuildable_project_fails_loudly_rather_than_producing_a_running_image() { + // The previous implementation emitted `FROM alpine` + `COPY . .` with no + // CMD, which builds, deploys, and then exits immediately. + let dir = project(&[("README.md", "# nothing here")]); + let content = dockerfile_for(&NixpacksPreset::auto(), dir.path()).await; + assert!(content.contains("exit 1"), "{content}"); } } diff --git a/crates/temps-presets/src/python_preset.rs b/crates/temps-presets/src/python_preset.rs index b664f170c..8244bb3d2 100644 --- a/crates/temps-presets/src/python_preset.rs +++ b/crates/temps-presets/src/python_preset.rs @@ -1,14 +1,14 @@ -//! Python preset implementation using Nixpacks +//! Python preset implementation using autopack //! //! This preset detects Python projects (requirements.txt, pyproject.toml, etc.) -//! and uses Nixpacks for building. +//! and uses autopack for building. use crate::{DockerfileConfig, DockerfileWithArgs, NixpacksPreset, NixpacksProvider, Preset, ProjectType}; use async_trait::async_trait; use std::fmt; use std::path::Path; -/// Python preset - delegates to Nixpacks with Python provider +/// Python preset - delegates to autopack with the Python provider #[derive(Debug, Clone, Copy)] pub struct PythonPreset; @@ -44,15 +44,15 @@ impl Preset for PythonPreset { } async fn dockerfile(&self, config: DockerfileConfig<'_>) -> DockerfileWithArgs { - // Delegate to Nixpacks with Python provider - let nixpacks = NixpacksPreset::new(NixpacksProvider::Python); - nixpacks.dockerfile(config).await + // Delegate to autopack with the Python provider + let builder = NixpacksPreset::new(NixpacksProvider::Python); + builder.dockerfile(config).await } async fn dockerfile_with_build_dir(&self, local_path: &Path) -> DockerfileWithArgs { - // Delegate to Nixpacks with Python provider - let nixpacks = NixpacksPreset::new(NixpacksProvider::Python); - nixpacks.dockerfile_with_build_dir(local_path).await + // Delegate to autopack with the Python provider + let builder = NixpacksPreset::new(NixpacksProvider::Python); + builder.dockerfile_with_build_dir(local_path).await } fn install_command(&self, _local_path: &Path) -> String { diff --git a/crates/temps-presets/src/rust_preset.rs b/crates/temps-presets/src/rust_preset.rs index 3d63dc868..8e80dc723 100644 --- a/crates/temps-presets/src/rust_preset.rs +++ b/crates/temps-presets/src/rust_preset.rs @@ -1,13 +1,13 @@ -//! Rust preset implementation using Nixpacks +//! Rust preset implementation using autopack //! -//! This preset detects Rust projects (Cargo.toml) and uses Nixpacks for building. +//! This preset detects Rust projects (Cargo.toml) and uses autopack for building. use crate::{DockerfileConfig, DockerfileWithArgs, NixpacksPreset, NixpacksProvider, Preset, ProjectType}; use async_trait::async_trait; use std::fmt; use std::path::Path; -/// Rust preset - delegates to Nixpacks with Rust provider +/// Rust preset - delegates to autopack with the Rust provider #[derive(Debug, Clone, Copy)] pub struct RustPreset; @@ -43,15 +43,15 @@ impl Preset for RustPreset { } async fn dockerfile(&self, config: DockerfileConfig<'_>) -> DockerfileWithArgs { - // Delegate to Nixpacks with Rust provider - let nixpacks = NixpacksPreset::new(NixpacksProvider::Rust); - nixpacks.dockerfile(config).await + // Delegate to autopack with the Rust provider + let builder = NixpacksPreset::new(NixpacksProvider::Rust); + builder.dockerfile(config).await } async fn dockerfile_with_build_dir(&self, local_path: &Path) -> DockerfileWithArgs { - // Delegate to Nixpacks with Rust provider - let nixpacks = NixpacksPreset::new(NixpacksProvider::Rust); - nixpacks.dockerfile_with_build_dir(local_path).await + // Delegate to autopack with the Rust provider + let builder = NixpacksPreset::new(NixpacksProvider::Rust); + builder.dockerfile_with_build_dir(local_path).await } fn install_command(&self, _local_path: &Path) -> String { diff --git a/crates/temps-presets/tests/starters.rs b/crates/temps-presets/tests/starters.rs new file mode 100644 index 000000000..6d045b0b8 --- /dev/null +++ b/crates/temps-presets/tests/starters.rs @@ -0,0 +1,451 @@ +//! Builds and runs every temps-examples starter through the real preset. +//! +//! The unit tests in `temps-presets` assert on the *text* of a generated +//! Dockerfile, which catches typos and nothing else. A Dockerfile that renders +//! perfectly can still fail to build, build into an image that will not start, +//! or start into a process that never answers a request — and each of those +//! reaches the user as a failed deploy. +//! +//! So this goes all the way: render through [`AutopackPreset`] exactly as the +//! deployment pipeline does, `docker build` it, run it, and ask it for a page. +//! It also asserts the two properties that are invisible until they bite: +//! +//! * the container answers on `$PORT`, because that is what the proxy connects +//! to and nothing else checks it; +//! * it stops on `SIGTERM` rather than waiting out the kill timeout, because a +//! container that ignores `SIGTERM` turns every redeploy into a hard kill of +//! in-flight requests. (Both nixpacks and railpack fail this one.) +//! +//! ## Running +//! +//! Needs Docker with BuildKit and a checkout of the starters: +//! +//! ```sh +//! git clone --depth 1 https://github.com/gotempsh/temps-examples /tmp/temps-examples +//! TEMPS_EXAMPLES_DIR=/tmp/temps-examples/examples/starters \ +//! cargo test -p temps-presets --test starters -- --ignored --test-threads 4 +//! ``` +//! +//! Narrow it down with `TEMPS_STARTERS_ONLY=go/gin,python/flask`. + +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use std::time::{Duration, Instant}; + +use temps_presets::{AutopackPreset, DockerfileConfig, Preset}; + +/// How long a starter gets to build. Swift and Rust are genuinely slow. +const BUILD_TIMEOUT: Duration = Duration::from_secs(900); +/// How long a container gets to answer its first request. +const BOOT_TIMEOUT: Duration = Duration::from_secs(60); +/// A container that handles SIGTERM stops well inside this; one that ignores it +/// waits out `docker stop`'s full grace period and gets SIGKILLed. +const STOP_TIMEOUT: Duration = Duration::from_secs(10); + +/// Starters that this matrix deliberately does not build. +/// +/// `dockerfile` exists to exercise the *Dockerfile* preset — it ships its own +/// Dockerfile and expects to be built by it. Autopack ignores that file by +/// design, so building it here would test nothing and fail confusingly. +const EXCLUDED: &[&str] = &["dockerfile"]; + +struct Starter { + /// Path relative to the starters root, e.g. `go/gin`. + name: String, + path: PathBuf, +} + +fn starters_root() -> Option { + let dir = std::env::var("TEMPS_EXAMPLES_DIR").ok()?; + let path = PathBuf::from(dir); + path.is_dir().then_some(path) +} + +/// Every starter under `root`, at most two levels deep. +/// +/// A starter is a directory that directly contains at least one file. Keying +/// off a manifest list instead looks tidier but silently skips anything whose +/// manifest is not on the list — the Deno starter is a lone `main.ts`, and a +/// missed starter is indistinguishable from a passing one. +fn discover(root: &Path) -> Vec { + fn holds_a_file(dir: &Path) -> bool { + std::fs::read_dir(dir) + .map(|entries| entries.flatten().any(|e| e.path().is_file())) + .unwrap_or(false) + } + + fn subdirectories(dir: &Path) -> Vec { + let Ok(entries) = std::fs::read_dir(dir) else { + return Vec::new(); + }; + let mut dirs: Vec<_> = entries + .flatten() + .map(|e| e.path()) + .filter(|p| p.is_dir()) + .collect(); + dirs.sort(); + dirs + } + + let mut found = Vec::new(); + for dir in subdirectories(root) { + let name = dir.file_name().unwrap().to_string_lossy().to_string(); + // A language directory either *is* a starter (`astro`, `deno`) or holds + // several (`go/gin`, `go/net-http`) — never both. Stopping at the parent + // is what keeps `sveltekit/src` from being treated as an application. + if holds_a_file(&dir) { + found.push(Starter { name, path: dir }); + continue; + } + for child in subdirectories(&dir) { + if !holds_a_file(&child) { + continue; + } + let child_name = child.file_name().unwrap().to_string_lossy().to_string(); + found.push(Starter { + name: format!("{name}/{child_name}"), + path: child, + }); + } + } + found.retain(|s| !EXCLUDED.contains(&s.name.as_str())); + found +} + +/// Restrict the run to `TEMPS_STARTERS_ONLY`, if set. +fn selected(starters: Vec) -> Vec { + let Ok(only) = std::env::var("TEMPS_STARTERS_ONLY") else { + return starters; + }; + let wanted: Vec<&str> = only + .split(',') + .map(str::trim) + .filter(|s| !s.is_empty()) + .collect(); + starters + .into_iter() + .filter(|s| wanted.iter().any(|w| *w == s.name)) + .collect() +} + +fn run(command: &mut Command) -> Result { + let output = command + .output() + .map_err(|e| format!("could not run {command:?}: {e}"))?; + let stdout = String::from_utf8_lossy(&output.stdout).to_string(); + if output.status.success() { + Ok(stdout) + } else { + Err(format!( + "{command:?} failed ({})\n--- stdout ---\n{stdout}\n--- stderr ---\n{}", + output.status, + String::from_utf8_lossy(&output.stderr) + )) + } +} + +/// A port the operating system just confirmed is free. +/// +/// Racy in principle — something else could take it between the probe and +/// `docker run` — but binding a fixed port makes concurrent starters collide +/// every time rather than occasionally. +fn free_port() -> u16 { + std::net::TcpListener::bind("127.0.0.1:0") + .expect("a free port") + .local_addr() + .expect("a local address") + .port() +} + +/// Render the starter's Dockerfile through the same preset the platform uses. +fn dockerfile_for(starter: &Starter) -> String { + let mut config = DockerfileConfig::new(&starter.path, &starter.path, "starter"); + // The deployment pipeline always builds with BuildKit; so must this. + config.use_buildkit = true; + futures_lite_block_on(AutopackPreset::new().dockerfile(config)).content +} + +/// Minimal block-on so the test does not need a full async runtime. +fn futures_lite_block_on(future: F) -> F::Output { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("a tokio runtime") + .block_on(future) +} + +struct Container { + id: String, +} + +impl Drop for Container { + fn drop(&mut self) { + // Best-effort: a leaked container would hold its port for the next run. + let _ = Command::new("docker") + .args(["rm", "-f", &self.id]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status(); + } +} + +/// Poll `url` until it answers, or give up. +fn wait_for_http(url: &str, timeout: Duration, container: &str) -> Result<(), String> { + let deadline = Instant::now() + timeout; + let mut last = String::new(); + while Instant::now() < deadline { + let output = Command::new("curl") + .args([ + "--silent", + "--show-error", + "--max-time", + "5", + "-o", + "/dev/null", + "-w", + "%{http_code}", + url, + ]) + .output(); + match output { + Ok(out) => { + let code = String::from_utf8_lossy(&out.stdout).trim().to_string(); + // Any HTTP answer means the server is up and listening on $PORT. + // A starter is free to redirect or 404 its root path; what is + // being tested is that something is there at all. + if code.starts_with('2') || code.starts_with('3') || code.starts_with('4') { + return Ok(()); + } + last = code; + } + Err(e) => last = e.to_string(), + } + std::thread::sleep(Duration::from_millis(500)); + } + + let logs = Command::new("docker") + .args(["logs", "--tail", "40", container]) + .output() + .map(|o| { + format!( + "{}{}", + String::from_utf8_lossy(&o.stdout), + String::from_utf8_lossy(&o.stderr) + ) + }) + .unwrap_or_default(); + Err(format!( + "no HTTP response from {url} within {timeout:?} (last: {last})\n--- container logs ---\n{logs}" + )) +} + +/// Build, run, request, and stop one starter. +fn verify(starter: &Starter) -> Result<(), String> { + let dockerfile = dockerfile_for(starter); + if dockerfile.contains("autopack could not plan") { + return Err(format!( + "the preset produced no build plan for `{}`:\n{dockerfile}", + starter.name + )); + } + + let tag = format!( + "temps-starter/{}:test", + starter.name.replace('/', "-").to_lowercase() + ); + + // Feed the Dockerfile on stdin so the starter's own tree is never touched — + // a stray Dockerfile left in the checkout would change what the next run + // detects. + let mut build = Command::new("docker"); + build + .args(["build", "--progress", "plain", "-t", &tag, "-f", "-", "."]) + .current_dir(&starter.path) + .env("DOCKER_BUILDKIT", "1") + .stdin(Stdio::piped()) + // Both must be captured: BuildKit writes its log to stderr, and an + // uncaptured stream is inherited, so the failure message arrives empty + // and the actual error is somewhere up the console. + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + + let mut child = build + .spawn() + .map_err(|e| format!("could not start docker build: {e}"))?; + { + use std::io::Write; + child + .stdin + .as_mut() + .expect("stdin") + .write_all(dockerfile.as_bytes()) + .map_err(|e| format!("could not write the Dockerfile: {e}"))?; + } + let started = Instant::now(); + let output = child + .wait_with_output() + .map_err(|e| format!("docker build failed to complete: {e}"))?; + if !output.status.success() { + // Only the tail is useful: a BuildKit plain log is thousands of lines + // of layer chatter and the error is always at the end. + let log = String::from_utf8_lossy(&output.stderr); + let tail: Vec<&str> = log.lines().rev().take(40).collect(); + return Err(format!( + "docker build failed for `{}`\n--- build log (last 40 lines) ---\n{}\n--- Dockerfile ---\n{dockerfile}", + starter.name, + tail.into_iter().rev().collect::>().join("\n") + )); + } + if started.elapsed() > BUILD_TIMEOUT { + return Err(format!( + "`{}` took longer than {BUILD_TIMEOUT:?} to build", + starter.name + )); + } + + let port = free_port(); + // Deliberately not `--rm`: a container that exits on startup would be + // removed before `docker logs` could say why, which is exactly the case + // where the logs matter most. The Drop guard cleans up instead. + let id = run(Command::new("docker").args([ + "run", + "--detach", + "--env", + &format!("PORT={port}"), + "--publish", + &format!("127.0.0.1:{port}:{port}"), + &tag, + ]))? + .trim() + .to_string(); + let container = Container { id: id.clone() }; + + wait_for_http(&format!("http://127.0.0.1:{port}/"), BOOT_TIMEOUT, &id)?; + + // The proxy talks to an unprivileged process or the image is a liability. + let uid = run(Command::new("docker").args(["exec", &id, "id", "-u"]))? + .trim() + .to_string(); + if uid == "0" { + return Err(format!("`{}` runs as root", starter.name)); + } + + // `docker stop` sends SIGTERM, waits, then SIGKILLs. Finishing early means + // the signal was actually handled. + let stop_started = Instant::now(); + run(Command::new("docker").args([ + "stop", + "--timeout", + &STOP_TIMEOUT.as_secs().to_string(), + &id, + ]))?; + let stop_took = stop_started.elapsed(); + if stop_took >= STOP_TIMEOUT { + return Err(format!( + "`{}` ignored SIGTERM — `docker stop` waited the full {STOP_TIMEOUT:?} and had to SIGKILL it. \ + Every redeploy would hard-kill in-flight requests.", + starter.name + )); + } + + drop(container); + let _ = Command::new("docker") + .args(["rmi", "-f", &tag]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status(); + Ok(()) +} + +#[test] +#[ignore = "needs Docker and TEMPS_EXAMPLES_DIR; run explicitly with --ignored"] +fn every_starter_builds_runs_and_serves() { + let Some(root) = starters_root() else { + panic!( + "TEMPS_EXAMPLES_DIR is not set to a directory. Clone the starters first:\n \ + git clone --depth 1 https://github.com/gotempsh/temps-examples /tmp/temps-examples\n \ + export TEMPS_EXAMPLES_DIR=/tmp/temps-examples/examples/starters" + ); + }; + + let starters = selected(discover(&root)); + assert!( + !starters.is_empty(), + "no starters found under {root:?} (TEMPS_STARTERS_ONLY may not match anything)" + ); + + let mut failures = Vec::new(); + for starter in &starters { + eprintln!("--- {} ---", starter.name); + let started = Instant::now(); + match verify(starter) { + Ok(()) => eprintln!(" ok in {:?}", started.elapsed()), + Err(error) => { + eprintln!(" FAILED: {error}"); + failures.push(format!("{}: {error}", starter.name)); + } + } + } + + // Report every failure rather than stopping at the first: when a change to + // a shared code path breaks six languages, one error message sends the + // reader chasing one language. + assert!( + failures.is_empty(), + "{} of {} starters failed:\n\n{}", + failures.len(), + starters.len(), + failures.join("\n\n") + ); +} + +#[cfg(test)] +mod discovery_tests { + use super::*; + + fn tree(files: &[&str]) -> tempfile::TempDir { + let dir = tempfile::tempdir().unwrap(); + for file in files { + let path = dir.path().join(file); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, "").unwrap(); + } + dir + } + + #[test] + fn finds_starters_at_both_levels() { + let dir = tree(&["astro/package.json", "go/gin/go.mod", "go/net-http/go.mod"]); + let mut names: Vec<_> = discover(dir.path()).into_iter().map(|s| s.name).collect(); + names.sort(); + assert_eq!(names, ["astro", "go/gin", "go/net-http"]); + } + + #[test] + fn a_source_directory_is_not_mistaken_for_a_starter() { + // `sveltekit/src` has no manifest, and `sveltekit` itself does — so the + // walk must stop at the parent instead of descending into it. + let dir = tree(&["sveltekit/package.json", "sveltekit/src/app.html"]); + let names: Vec<_> = discover(dir.path()).into_iter().map(|s| s.name).collect(); + assert_eq!(names, ["sveltekit"]); + } + + #[test] + fn only_filters_to_the_named_starters() { + let starters = vec![ + Starter { + name: "go/gin".into(), + path: PathBuf::new(), + }, + Starter { + name: "python/flask".into(), + path: PathBuf::new(), + }, + ]; + // SAFETY: single-threaded within this test binary's discovery tests. + unsafe { std::env::set_var("TEMPS_STARTERS_ONLY", "python/flask") }; + let names: Vec<_> = selected(starters).into_iter().map(|s| s.name).collect(); + unsafe { std::env::remove_var("TEMPS_STARTERS_ONLY") }; + assert_eq!(names, ["python/flask"]); + } +} From 165e7b5553abf192d77e5f9906d21d45d97de812 Mon Sep 17 00:00:00 2001 From: David Viejo Date: Mon, 3 Aug 2026 16:35:19 +0200 Subject: [PATCH 2/7] fix(ci): point the starters job at the branch that carries them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every job in the matrix failed in 0.03s: `examples/starters` does not exist on temps-examples' default branch yet — it arrives with gotempsh/temps-examples#12 — so the checkout produced a tree without it and the test panicked before building anything. Two changes: * the checkout pins `ref: feat/language-starters`, marked TEMPORARY. It must come out when #12 merges; a branch ref that outlives its PR is a job that quietly stops testing what main ships. * the panic now distinguishes "the variable is unset" from "the directory is not there". They read identically and want completely different fixes, and the second one is what just cost a full CI run to diagnose. --- .github/workflows/starters.yml | 10 ++++++++++ crates/temps-presets/tests/starters.rs | 16 ++++++++++++++-- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/.github/workflows/starters.yml b/.github/workflows/starters.yml index ccefd8ad3..be6c2901d 100644 --- a/.github/workflows/starters.yml +++ b/.github/workflows/starters.yml @@ -73,6 +73,11 @@ jobs: uses: actions/checkout@v5 with: repository: gotempsh/temps-examples + # TEMPORARY: `examples/starters` does not exist on main yet — it + # arrives with gotempsh/temps-examples#12. Drop this `ref` the moment + # that merges; a pinned branch that outlives its PR is a job that + # quietly stops testing what main actually ships. + ref: feat/language-starters path: temps-examples - uses: dtolnay/rust-toolchain@stable @@ -120,6 +125,11 @@ jobs: uses: actions/checkout@v5 with: repository: gotempsh/temps-examples + # TEMPORARY: `examples/starters` does not exist on main yet — it + # arrives with gotempsh/temps-examples#12. Drop this `ref` the moment + # that merges; a pinned branch that outlives its PR is a job that + # quietly stops testing what main actually ships. + ref: feat/language-starters path: temps-examples - uses: dtolnay/rust-toolchain@stable diff --git a/crates/temps-presets/tests/starters.rs b/crates/temps-presets/tests/starters.rs index 6d045b0b8..c600752f5 100644 --- a/crates/temps-presets/tests/starters.rs +++ b/crates/temps-presets/tests/starters.rs @@ -361,10 +361,22 @@ fn verify(starter: &Starter) -> Result<(), String> { #[ignore = "needs Docker and TEMPS_EXAMPLES_DIR; run explicitly with --ignored"] fn every_starter_builds_runs_and_serves() { let Some(root) = starters_root() else { + // Name what is actually missing. "not set to a directory" covers both + // "you forgot the variable" and "the directory is not there", and those + // want completely different fixes — the second one usually means the + // checkout is on a branch that does not carry `examples/starters` yet. + let requested = std::env::var("TEMPS_EXAMPLES_DIR"); panic!( - "TEMPS_EXAMPLES_DIR is not set to a directory. Clone the starters first:\n \ + "{}\n\nExpected a checkout of the starters:\n \ git clone --depth 1 https://github.com/gotempsh/temps-examples /tmp/temps-examples\n \ - export TEMPS_EXAMPLES_DIR=/tmp/temps-examples/examples/starters" + export TEMPS_EXAMPLES_DIR=/tmp/temps-examples/examples/starters", + match requested { + Err(_) => "TEMPS_EXAMPLES_DIR is not set.".to_string(), + Ok(dir) => format!( + "TEMPS_EXAMPLES_DIR points at `{dir}`, which is not a directory. \ + If this is a CI checkout, the branch may not carry `examples/starters`." + ), + } ); }; From 17b4ddbcb6c2c2943dcf073e7b34cdd4aeeafca3 Mon Sep 17 00:00:00 2001 From: David Viejo Date: Mon, 3 Aug 2026 16:59:14 +0200 Subject: [PATCH 3/7] test(ci): gate on rust/actix, which passes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit It was parked in the report-only job on the strength of a local failure: on macOS it ignored SIGTERM and `docker stop` had to SIGKILL it. On Linux CI it passes cleanly in 104s, SIGTERM check included — so the failure was an artefact of Docker Desktop, not the starter. Leaving a passing starter in the report-only bucket means it is not actually gated, and a real regression in it would go unnoticed. Linux CI is the environment the gate is for. java/spring-boot stays behind: it still fails, for the same reason, every run. --- .github/workflows/starters.yml | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/.github/workflows/starters.yml b/.github/workflows/starters.yml index be6c2901d..bbbee88ec 100644 --- a/.github/workflows/starters.yml +++ b/.github/workflows/starters.yml @@ -62,6 +62,7 @@ jobs: - python/fastapi - python/flask - ruby/rails + - rust/actix - sveltekit - swift/vapor - vite/react @@ -102,19 +103,17 @@ jobs: cargo test -p temps-presets --test starters -- --ignored --nocapture known-failing: - # These two do not pass yet. They run anyway, and always report success, so - # the failure stays visible in the log without blocking a merge — a red - # required check that everyone learns to ignore is worse than no check. + # java/spring-boot does not pass yet. It runs anyway and always reports + # success, so the failure stays visible in the log without blocking a merge + # — a red required check that everyone learns to ignore is worse than no + # check at all. # - # java/spring-boot Spring deduces a REACTIVE application type from a - # classpath that only has spring-boot-starter-web, then - # fails for want of a ReactiveWebServerFactory. The boot - # jar is selected correctly and the app does start. - # rust/actix Ignores SIGTERM: `docker stop` waits the full grace - # period and SIGKILLs. Actix's default graceful-shutdown - # timeout outlives the stop timeout. + # Spring deduces a REACTIVE application type from a classpath that only has + # spring-boot-starter-web, then fails for want of a ReactiveWebServerFactory. + # The boot jar is selected correctly (the `-plain.jar` exclusion works) and + # the application does start, so this is not jar selection. Not root-caused. # - # Tracked in the autopack repository; remove an entry here when it is fixed. + # Tracked in the autopack repository; remove this job when it is fixed. name: known-failing (report only) runs-on: ubuntu-latest timeout-minutes: 30 @@ -145,7 +144,7 @@ jobs: continue-on-error: true env: TEMPS_EXAMPLES_DIR: ${{ github.workspace }}/temps-examples/examples/starters - TEMPS_STARTERS_ONLY: java/spring-boot,rust/actix + TEMPS_STARTERS_ONLY: java/spring-boot DOCKER_BUILDKIT: '1' run: | cargo test -p temps-presets --test starters -- --ignored --nocapture From 65052cbdc805ae2c3626ae3ada81404f1f6b3ed3 Mon Sep 17 00:00:00 2001 From: David Viejo Date: Mon, 3 Aug 2026 17:13:22 +0200 Subject: [PATCH 4/7] ci: drop the temporary starters branch pin gotempsh/temps-examples#12 is merged and the branch is deleted, so the `ref:` now points at nothing. The job reads `examples/starters` from the default branch, which is what it should have been testing all along. --- .github/workflows/starters.yml | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/.github/workflows/starters.yml b/.github/workflows/starters.yml index bbbee88ec..843029e73 100644 --- a/.github/workflows/starters.yml +++ b/.github/workflows/starters.yml @@ -74,11 +74,6 @@ jobs: uses: actions/checkout@v5 with: repository: gotempsh/temps-examples - # TEMPORARY: `examples/starters` does not exist on main yet — it - # arrives with gotempsh/temps-examples#12. Drop this `ref` the moment - # that merges; a pinned branch that outlives its PR is a job that - # quietly stops testing what main actually ships. - ref: feat/language-starters path: temps-examples - uses: dtolnay/rust-toolchain@stable @@ -124,11 +119,6 @@ jobs: uses: actions/checkout@v5 with: repository: gotempsh/temps-examples - # TEMPORARY: `examples/starters` does not exist on main yet — it - # arrives with gotempsh/temps-examples#12. Drop this `ref` the moment - # that merges; a pinned branch that outlives its PR is a job that - # quietly stops testing what main actually ships. - ref: feat/language-starters path: temps-examples - uses: dtolnay/rust-toolchain@stable From 119d947034f1e45705537c01c6d82ba8c4c11ed8 Mon Sep 17 00:00:00 2001 From: David Viejo Date: Mon, 3 Aug 2026 19:48:29 +0200 Subject: [PATCH 5/7] fix(presets): stop the Nixpacks TOML validation error echoing the config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `test_nixpacks_invalid_inline_toml_is_rejected_during_create` caught this: the new validation interpolated `toml`'s error Display, which renders the offending *source line*. The test feeds `secret_token = ["do-not-echo"` and asserts the value does not come back — it did. That message reaches an API response body and the logs, and a nixpacks_config can hold secrets, so this was a real leak rather than untidy output. Only the position crosses the boundary now: "failed to parse Nixpacks TOML at line 1, column 30; verify its syntax and supported fields". The position is derived from the error's span rather than its rendering, because both of the library's own accessors can carry content — Display embeds the source line, and `.message()` names the offending key on an unknown-field error. The full error goes to the debug log server-side, where it is useful and not exposed. Adds the same assertion as a unit test. The existing guard is behind `docker_available()`, so it only runs in the Docker integration job — a leak this cheap to reintroduce should fail in `cargo test -p temps-presets`. --- crates/temps-presets/src/nixpacks_preset.rs | 68 +++++++++++++++++++-- 1 file changed, 63 insertions(+), 5 deletions(-) diff --git a/crates/temps-presets/src/nixpacks_preset.rs b/crates/temps-presets/src/nixpacks_preset.rs index a507b1e0a..06da97489 100644 --- a/crates/temps-presets/src/nixpacks_preset.rs +++ b/crates/temps-presets/src/nixpacks_preset.rs @@ -154,6 +154,21 @@ fn autopack_provider(provider: NixpacksProvider) -> Option<&'static str> { } } +/// Render a byte offset as `line L, column C`, counting from one. +/// +/// Positions are derived rather than taken from the error's own rendering, +/// which embeds the source text. +fn line_and_column(source: &str, offset: usize) -> String { + let offset = offset.min(source.len()); + let consumed = &source[..offset]; + let line = consumed.matches('\n').count() + 1; + let column = consumed + .rfind('\n') + .map_or(offset, |newline| offset - newline - 1) + + 1; + format!("line {line}, column {column}") +} + pub struct NixpacksPreset { config: NixpacksConfig, } @@ -186,16 +201,31 @@ impl NixpacksPreset { /// The check runs against autopack's compatibility reader — the same code /// that will read the file at build time — so validation cannot pass for /// something the build then refuses. + /// + /// The returned message carries the *position* of the error and nothing + /// else. It reaches an API response body and the logs, and a + /// `nixpacks_config` can hold secrets: `toml`'s own error Display renders + /// the offending source line, and `.message()` names the offending key on + /// an unknown-field error. Neither may cross that boundary, so the detail + /// is logged server-side instead of returned. pub(super) fn validate_config( config: &NixpacksConfig, ) -> Result<(), crate::PresetResolutionError> { if let Some(value) = config.nixpacks_config.as_deref() { - toml::from_str::(value).map_err(|error| { - crate::PresetResolutionError::InvalidConfig { + if let Err(error) = toml::from_str::(value) { + debug!("stored nixpacks.toml did not parse: {error}"); + let where_ = error + .span() + .map(|span| format!(" at {}", line_and_column(value, span.start))) + .unwrap_or_default(); + return Err(crate::PresetResolutionError::InvalidConfig { slug: "nixpacks".to_string(), - reason: format!("failed to parse Nixpacks TOML: {error}"), - } - })?; + reason: format!( + "failed to parse Nixpacks TOML{where_}; \ + verify its syntax and supported fields" + ), + }); + } } Ok(()) } @@ -599,6 +629,34 @@ mod tests { assert!(result.is_err()); } + #[test] + fn a_validation_error_never_echoes_the_config_back() { + // This message reaches an API response body and the logs, and the + // config can hold secrets. `toml`'s own error Display renders the + // offending source line, so interpolating it leaks the value — which + // is exactly what happened here once. + let result = NixpacksPreset::validate_config(&NixpacksConfig { + nixpacks_config: Some("secret_token = [\"do-not-echo\"".to_string()), + ..Default::default() + }); + let message = result.unwrap_err().to_string(); + assert!( + !message.contains("do-not-echo"), + "the error echoed the config: {message}" + ); + assert!(!message.contains("secret_token"), "{message}"); + // The position is still there, so the user can find the problem. + assert!(message.contains("line 1, column 30"), "{message}"); + } + + #[test] + fn the_reported_position_counts_lines_and_columns_from_one() { + assert_eq!(line_and_column("abc", 0), "line 1, column 1"); + assert_eq!(line_and_column("abc\ndef", 5), "line 2, column 2"); + // An offset at end-of-input is what an unterminated array produces. + assert_eq!(line_and_column("ab", 99), "line 1, column 3"); + } + #[test] fn valid_stored_toml_passes_validation() { let result = NixpacksPreset::validate_config(&NixpacksConfig { From fc9e121207037952b8503adac1a7a15b44949211 Mon Sep 17 00:00:00 2001 From: David Viejo Date: Tue, 4 Aug 2026 07:56:34 +0200 Subject: [PATCH 6/7] feat(web): add the autopack preset icon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `AutopackPreset::icon_url` and the auto-detect Nixpacks variant both point at /presets/autopack.svg, which did not exist. The console falls back to custom.svg on error, so this was not a broken image — the new preset simply showed the generic mark, which is worse in a picker whose whole job is telling builders apart at a glance. The mark is an isometric box with its lid lifted. Not a triangle (Vercel's, and temps competes with them) and not a letter "A" (Astro's, and it sits three tiles away in this very picker). Teal because the picker already has purple, blue, green and orange. --- web/public/presets/autopack.svg | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 web/public/presets/autopack.svg diff --git a/web/public/presets/autopack.svg b/web/public/presets/autopack.svg new file mode 100644 index 000000000..663f34aa5 --- /dev/null +++ b/web/public/presets/autopack.svg @@ -0,0 +1,12 @@ + + autopack + + + + + From 041a8521c94615fa1e4c014189e4c646acbeeabd Mon Sep 17 00:00:00 2001 From: David Viejo Date: Tue, 4 Aug 2026 08:17:46 +0200 Subject: [PATCH 7/7] refactor(web): open the autopack icon's lid into a chevron Matches gotempsh/autopack. The closed cube was the generic registry package mark; the open lid reads as a box mid-pack and doubles as an up arrow. Teal rather than green is deliberate and checked against the real picker: Node.js owns green there, and autopack builds 24 ecosystems, so reading as a Node tool is misleading. Sky blue collides with Docker and Nixpacks, amber with HTML5. Teal is the only gap. --- web/public/presets/autopack.svg | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/web/public/presets/autopack.svg b/web/public/presets/autopack.svg index 663f34aa5..9ff34acd8 100644 --- a/web/public/presets/autopack.svg +++ b/web/public/presets/autopack.svg @@ -1,12 +1,17 @@ autopack - - - - + them — and not a letter "A", which is Astro's and sits three tiles away + in the same preset picker. + Teal, not green: Node.js owns green in that picker, and autopack builds + 24 ecosystems, so reading as a Node tool is actively wrong. Teal is also + clear of Docker/Nixpacks blue and Astro purple. + Single colour, 24x24, gaps carrying the shape, so it holds at 16px on a + light tile and a dark one alike. --> + + +