diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..4b01400 --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,2 @@ +[alias] +xtask = "run --manifest-path xtask/Cargo.toml --" diff --git a/.dockerignore b/.dockerignore index bc7e3c3..99ed75b 100644 --- a/.dockerignore +++ b/.dockerignore @@ -2,5 +2,5 @@ target/ .git/ .github/ .vscode/ -testdata/ +xtask/ *.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 57c132b..b4a17e0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,8 +33,28 @@ jobs: # name: coverage # path: coverage.lcov - publish: + docker-test: needs: test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build image + uses: docker/build-push-action@v6 + with: + context: . + push: false + load: true + tags: envsubst:ci-test + + - name: Run image integration tests + run: cargo xtask docker-test envsubst:ci-test + + publish: + needs: [test, docker-test] if: github.event_name == 'push' runs-on: ubuntu-latest permissions: diff --git a/Dockerfile b/Dockerfile index 0d8e510..89419ca 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,5 @@ # syntax=docker/dockerfile:1 -# Global args — declared before any FROM so they can be used in FROM instructions. # Override at build time: --build-arg DISTROLESS_VARIANT=static-debian13 --build-arg DISTROLESS_TAG=nonroot ARG DISTROLESS_VARIANT=static-debian12 ARG DISTROLESS_TAG=latest @@ -21,7 +20,6 @@ COPY --from=verifier /etc/os-release /tmp/distroless-verified ARG TARGETARCH -# Map Docker architecture → Rust musl target triple RUN case "$TARGETARCH" in \ amd64) echo x86_64-unknown-linux-musl ;; \ arm64) echo aarch64-unknown-linux-musl ;; \ @@ -40,7 +38,8 @@ RUN mkdir src && echo 'fn main(){}' > src/main.rs \ && rm -rf src COPY src ./src -RUN cargo zigbuild --release --target "$(cat /rust_target)" \ +RUN touch src/main.rs \ + && cargo zigbuild --release --target "$(cat /rust_target)" \ && cp "target/$(cat /rust_target)/release/envsubst" /envsubst # Final image diff --git a/Taskfile.yml b/Taskfile.yml index d827da0..199a14f 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -1,53 +1,37 @@ version: '3' -# pipefail ensures pipe errors propagate correctly on Unix. -# Task silently ignores shell options it cannot apply on the current platform. set: [pipefail] vars: - # Override at the CLI: task docker:build IMAGE=myregistry/myimage IMAGE: envsubst tasks: - # ── Default ────────────────────────────────────────────────────────────────── - default: desc: List all available tasks silent: true cmds: - task --list - # ── Rust ───────────────────────────────────────────────────────────────────── - test: - desc: Run tests + desc: Run unit and integration tests cmds: - - cargo test + - cargo xtask test test:coverage: - desc: Run tests with LLVM coverage and emit an HTML report under target/llvm-cov/ + desc: Run tests with LLVM coverage (HTML report under target/llvm-cov/) cmds: - cargo llvm-cov --html lint: - desc: Run all lints in parallel (Clippy + formatting check) - deps: [lint:clippy, lint:fmt] - - lint:clippy: - desc: Run Clippy — all warnings treated as errors - cmds: - - cargo clippy -- -D warnings - - lint:fmt: - desc: Check that source is formatted (does not modify files) + desc: Run Clippy and formatting check cmds: - - cargo fmt --check + - cargo xtask lint fmt: - desc: Auto-format all source files with rustfmt + desc: Auto-format source files cmds: - - cargo fmt + - cargo xtask fmt build: desc: Debug build @@ -61,54 +45,25 @@ tasks: docker:build: desc: > - Build one Docker variant for the current platform and load it into Docker. - Defaults to the standard variant (static-debian12, distroless:latest). - Override any combination at the CLI: - task docker:build VARIANT=static-debian13 DISTROLESS_TAG=nonroot IMAGE_TAG=mytest + Build the default image variant for the current platform (tag: dev). + Override the tag: task docker:build IMAGE_TAG=mytest vars: - VARIANT: "{{.VARIANT | default \"static-debian12\"}}" - DISTROLESS_TAG: "{{.DISTROLESS_TAG | default \"latest\"}}" - IMAGE_TAG: "{{.IMAGE_TAG | default \"dev\"}}" + IMAGE_TAG: "{{.IMAGE_TAG | default \"dev\"}}" cmds: - - >- - docker buildx build - --build-arg DISTROLESS_VARIANT={{.VARIANT}} - --build-arg DISTROLESS_TAG={{.DISTROLESS_TAG}} - --tag {{.IMAGE}}:{{.IMAGE_TAG}} - --load - . + - cargo xtask docker-build {{.IMAGE_TAG}} docker:build:all: - desc: > - Build all four matrix variants for the current platform and load them - into Docker. Produces: dev, dev-nonroot, dev-debian13, dev-debian13-nonroot. + desc: Build all four image variants for the current platform cmds: - - task: _docker:variant - vars: {VARIANT: static-debian12, DISTROLESS_TAG: latest, IMAGE_TAG: dev} - - task: _docker:variant - vars: {VARIANT: static-debian12, DISTROLESS_TAG: nonroot, IMAGE_TAG: dev-nonroot} - - task: _docker:variant - vars: {VARIANT: static-debian13, DISTROLESS_TAG: latest, IMAGE_TAG: dev-debian13} - - task: _docker:variant - vars: {VARIANT: static-debian13, DISTROLESS_TAG: nonroot, IMAGE_TAG: dev-debian13-nonroot} + - cargo xtask docker-build-all - ci: - desc: > - Run the full CI pipeline locally: test → lint → docker build (all variants). - Mirrors the GitHub Actions workflow — same steps, current platform only - (no multi-arch push). Requires Docker to be running. + docker:test: + desc: Build default image and run Docker integration tests cmds: - - task: test - - task: lint - - task: docker:build:all + - cargo xtask docker-build + - cargo xtask docker-test {{.IMAGE}}:dev - _docker:variant: - internal: true + ci: + desc: Full CI pipeline — test, lint, docker-build, docker-test cmds: - - >- - docker buildx build - --build-arg DISTROLESS_VARIANT={{.VARIANT}} - --build-arg DISTROLESS_TAG={{.DISTROLESS_TAG}} - --tag {{.IMAGE}}:{{.IMAGE_TAG}} - --load - . + - cargo xtask ci diff --git a/src/main.rs b/src/main.rs index fb72a1c..74c5f10 100644 --- a/src/main.rs +++ b/src/main.rs @@ -338,8 +338,8 @@ mod tests { #[test] fn integration_template_to_output_dir() { let out_dir = tempfile::TempDir::new().unwrap(); - let base = PathBuf::from("testdata/integration"); - let file = PathBuf::from("testdata/integration/template.yaml"); + let base = PathBuf::from("xtask/testdata/integration"); + let file = PathBuf::from("xtask/testdata/integration/template.yaml"); temp_env::with_vars( [ @@ -363,8 +363,8 @@ mod tests { #[test] fn integration_nested_structure_preserved() { let out_dir = tempfile::TempDir::new().unwrap(); - let base = PathBuf::from("testdata/integration"); - let file = PathBuf::from("testdata/integration/nested/service.conf"); + let base = PathBuf::from("xtask/testdata/integration"); + let file = PathBuf::from("xtask/testdata/integration/nested/service.conf"); temp_env::with_vars( [ @@ -401,8 +401,8 @@ mod tests { || { let preloaded = parse_env_file(env_file.path()).unwrap(); let ctx = EnvContext::new(preloaded, false); - let base = PathBuf::from("testdata/integration"); - let file = PathBuf::from("testdata/integration/nested/service.conf"); + let base = PathBuf::from("xtask/testdata/integration"); + let file = PathBuf::from("xtask/testdata/integration/nested/service.conf"); process_file(&file, &base, Some(out_dir.path()), &ctx, false); let out = fs::read_to_string(out_dir.path().join("nested/service.conf")).unwrap(); assert!(out.contains("host = db.example.com")); @@ -415,8 +415,8 @@ mod tests { #[test] fn integration_env_file_mode_ignores_real_env() { let out_dir = tempfile::TempDir::new().unwrap(); - let base = PathBuf::from("testdata/integration"); - let file = PathBuf::from("testdata/integration/nested/service.conf"); + let base = PathBuf::from("xtask/testdata/integration"); + let file = PathBuf::from("xtask/testdata/integration/nested/service.conf"); // Real env has different values — file mode must not see them temp_env::with_vars( @@ -443,8 +443,8 @@ mod tests { #[test] fn integration_missing_vars_tracked() { let out_dir = tempfile::TempDir::new().unwrap(); - let base = PathBuf::from("testdata/integration"); - let file = PathBuf::from("testdata/integration/template.yaml"); + let base = PathBuf::from("xtask/testdata/integration"); + let file = PathBuf::from("xtask/testdata/integration/template.yaml"); temp_env::with_vars( [("EV_GREETING", Some("hey")), ("EV_SERVICE", Some("svc"))], @@ -459,8 +459,8 @@ mod tests { #[test] fn integration_stdout_when_no_output_dir() { // process_file with no output_dir should not panic and not write files - let base = PathBuf::from("testdata/integration"); - let file = PathBuf::from("testdata/integration/template.yaml"); + let base = PathBuf::from("xtask/testdata/integration"); + let file = PathBuf::from("xtask/testdata/integration/template.yaml"); temp_env::with_vars( [("EV_GREETING", Some("hi")), ("EV_SERVICE", Some("s"))], @@ -477,8 +477,8 @@ mod tests { #[test] fn fail_on_missing_real_env_has_unresolved_vars() { let out_dir = tempfile::TempDir::new().unwrap(); - let base = PathBuf::from("testdata/integration"); - let file = PathBuf::from("testdata/integration/template.yaml"); + let base = PathBuf::from("xtask/testdata/integration"); + let file = PathBuf::from("xtask/testdata/integration/template.yaml"); // EV_GREETING and EV_SERVICE are set; EV_UNDEFINED_12345 is not temp_env::with_vars( @@ -512,8 +512,8 @@ mod tests { let preloaded = parse_env_file(env_file.path()).unwrap(); let ctx = EnvContext::new(preloaded, false); - let base = PathBuf::from("testdata/integration"); - let file = PathBuf::from("testdata/integration/template.yaml"); + let base = PathBuf::from("xtask/testdata/integration"); + let file = PathBuf::from("xtask/testdata/integration/template.yaml"); process_file(&file, &base, Some(out_dir.path()), &ctx, false); let missing = ctx.missing_vars(); @@ -527,8 +527,8 @@ mod tests { #[test] fn fail_on_missing_all_vars_resolved_means_empty_missing() { let out_dir = tempfile::TempDir::new().unwrap(); - let base = PathBuf::from("testdata/integration"); - let file = PathBuf::from("testdata/integration/nested/service.conf"); + let base = PathBuf::from("xtask/testdata/integration"); + let file = PathBuf::from("xtask/testdata/integration/nested/service.conf"); // All three vars in service.conf are provided temp_env::with_vars( diff --git a/xtask/Cargo.lock b/xtask/Cargo.lock new file mode 100644 index 0000000..5c8086c --- /dev/null +++ b/xtask/Cargo.lock @@ -0,0 +1,448 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", + "wasip3", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "log" +version = "0.4.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" + +[[package]] +name = "memchr" +version = "2.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "syn" +version = "2.0.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "xtask" +version = "0.0.0" +dependencies = [ + "tempfile", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/xtask/Cargo.toml b/xtask/Cargo.toml new file mode 100644 index 0000000..b1777e5 --- /dev/null +++ b/xtask/Cargo.toml @@ -0,0 +1,10 @@ +[workspace] + +[package] +name = "xtask" +version = "0.0.0" +edition = "2024" +publish = false + +[dependencies] +tempfile = "3" diff --git a/xtask/src/main.rs b/xtask/src/main.rs new file mode 100644 index 0000000..3c335c4 --- /dev/null +++ b/xtask/src/main.rs @@ -0,0 +1,356 @@ +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; +use tempfile::TempDir; + +fn main() { + let mut args = std::env::args().skip(1); + match args.next().as_deref() { + Some("test") => cmd_test(), + Some("lint") => cmd_lint(), + Some("fmt") => cmd_fmt(), + Some("docker-build") => cmd_docker_build(&args.next().unwrap_or_else(|| "dev".into())), + Some("docker-build-all") => cmd_docker_build_all(), + Some("docker-test") => { + let image = args.next().unwrap_or_else(|| "envsubst:dev".into()); + if !run_docker_tests(&image) { + std::process::exit(1); + } + } + Some("ci") => cmd_ci(), + Some(cmd) => { + eprintln!("error: unknown command: {cmd}"); + std::process::exit(2); + } + None => { + eprintln!("Usage: cargo xtask "); + eprintln!(" test run unit and integration tests"); + eprintln!(" lint clippy + formatting check"); + eprintln!(" fmt auto-format source files"); + eprintln!(" docker-build [TAG] build default image variant (default tag: dev)"); + eprintln!(" docker-build-all build all four image variants"); + eprintln!(" docker-test [IMAGE] run Docker image integration tests"); + eprintln!(" ci test + lint + docker-build + docker-test"); + std::process::exit(2); + } + } +} + +// ── Orchestration commands ──────────────────────────────────────────────────── + +fn cmd_test() { + exec(Command::new("cargo").arg("test")); +} + +fn cmd_lint() { + exec(Command::new("cargo").args(["clippy", "--", "-D", "warnings"])); + exec(Command::new("cargo").args(["fmt", "--check"])); +} + +fn cmd_fmt() { + exec(Command::new("cargo").arg("fmt")); +} + +fn cmd_docker_build(tag: &str) { + docker_build_variant("envsubst", tag, "static-debian12", "latest"); +} + +fn cmd_docker_build_all() { + for (variant, distroless_tag, image_tag) in VARIANTS { + println!("building envsubst:{image_tag}"); + docker_build_variant("envsubst", image_tag, variant, distroless_tag); + } +} + +fn cmd_ci() { + cmd_test(); + cmd_lint(); + cmd_docker_build("dev"); + if !run_docker_tests("envsubst:dev") { + std::process::exit(1); + } +} + +const VARIANTS: &[(&str, &str, &str)] = &[ + ("static-debian12", "latest", "dev"), + ("static-debian12", "nonroot", "dev-nonroot"), + ("static-debian13", "latest", "dev-debian13"), + ("static-debian13", "nonroot", "dev-debian13-nonroot"), +]; + +fn docker_build_variant(image: &str, image_tag: &str, variant: &str, distroless_tag: &str) { + exec(Command::new("docker").args([ + "buildx", "build", + "--build-arg", &format!("DISTROLESS_VARIANT={variant}"), + "--build-arg", &format!("DISTROLESS_TAG={distroless_tag}"), + "--tag", &format!("{image}:{image_tag}"), + "--load", + ".", + ])); +} + +fn exec(cmd: &mut Command) { + let status = cmd.status().unwrap_or_else(|e| panic!("failed to run {:?}: {e}", cmd.get_program())); + if !status.success() { + std::process::exit(status.code().unwrap_or(1)); + } +} + +// ── Docker image integration tests ─────────────────────────────────────────── + +struct Runner { + pass: u32, + fail: u32, + errors: Vec, + workdir: TempDir, +} + +struct Out { + exit: i32, + stdout: String, + stderr: String, +} + +impl Runner { + fn new() -> Self { + Self { + pass: 0, + fail: 0, + errors: Vec::new(), + workdir: TempDir::new().expect("temp dir"), + } + } + + fn run(&self, args: &[&str]) -> Out { + let output = Command::new("docker") + .args(["run", "--rm"]) + .args(args) + .output() + .expect("failed to invoke docker — is it installed and running?"); + Out { + exit: output.status.code().unwrap_or(-1), + stdout: String::from_utf8_lossy(&output.stdout).into_owned(), + stderr: String::from_utf8_lossy(&output.stderr).into_owned(), + } + } + + fn mktmpl(&self, rel: &str, content: &str) { + let p = self.p(rel); + fs::create_dir_all(p.parent().unwrap()).unwrap(); + fs::write(p, content).unwrap(); + } + + fn mkdir(&self, rel: &str) -> PathBuf { + let p = self.p(rel); + fs::create_dir_all(&p).unwrap(); + p + } + + fn p(&self, rel: &str) -> PathBuf { + rel.split('/').fold(self.workdir.path().to_path_buf(), |acc, c| acc.join(c)) + } + + fn ok(&mut self, name: &str) { + println!(" PASS: {name}"); + self.pass += 1; + } + + fn fail(&mut self, name: &str, msg: &str) { + println!(" FAIL: {name} — {msg}"); + self.fail += 1; + self.errors.push(name.to_string()); + } + + fn assert_exit(&mut self, name: &str, out: &Out, expected: i32) { + if out.exit == expected { + self.ok(name); + } else { + self.fail(name, &format!("expected exit {expected}, got {}", out.exit)); + if !out.stdout.trim().is_empty() { println!(" stdout: {}", out.stdout.trim()); } + if !out.stderr.trim().is_empty() { println!(" stderr: {}", out.stderr.trim()); } + } + } + + fn assert_nonzero(&mut self, name: &str, out: &Out) { + if out.exit != 0 { self.ok(name); } + else { self.fail(name, "expected non-zero exit, got 0"); } + } + + fn assert_contains(&mut self, name: &str, text: &str, substr: &str) { + if text.contains(substr) { self.ok(name); } + else { + self.fail(name, &format!("did not find {substr:?}")); + println!(" text: {}", text.trim()); + } + } + + fn assert_not_contains(&mut self, name: &str, text: &str, substr: &str) { + if !text.contains(substr) { self.ok(name); } + else { + self.fail(name, &format!("unexpectedly found {substr:?}")); + println!(" text: {}", text.trim()); + } + } + + fn assert_file(&mut self, name: &str, path: &Path, substr: &str) { + match fs::read_to_string(path) { + Ok(s) if s.contains(substr) => self.ok(name), + Ok(s) => { + self.fail(name, &format!("did not find {substr:?} in output file")); + println!(" file: {}", s.trim()); + } + Err(e) => self.fail(name, &format!("could not read output file: {e}")), + } + } + + fn summary(self) -> bool { + println!(); + println!("Results: {} passed, {} failed", self.pass, self.fail); + if !self.errors.is_empty() { + println!(); + println!("Failed:"); + for e in &self.errors { println!(" - {e}"); } + } + self.fail == 0 + } +} + +fn dp(p: &Path) -> String { + p.to_string_lossy().replace('\\', "/") +} + +fn section(title: &str) { + println!(); + println!("=== {title} ==="); +} + +fn run_docker_tests(image: &str) -> bool { + println!("Testing image: {image}"); + let mut r = Runner::new(); + + section("Smoke"); + + let o = r.run(&[image, "--help"]); + r.assert_exit("help: exits 0", &o, 0); + r.assert_contains("help: shows PATTERN", &o.stdout, "PATTERN"); + + let o = r.run(&[image]); + r.assert_nonzero("no-args: exits non-zero", &o); + + section("Stdout substitution"); + + r.mktmpl("s1/a.txt", "hello ${GREETING}"); + let d = r.mkdir("s1"); + let o = r.run(&[ + "-e", "GREETING=world", + "-v", &format!("{}:/in:ro", dp(&d)), + image, "/in/a.txt", + ]); + r.assert_exit("basic: exits 0", &o, 0); + r.assert_contains("basic: substituted", &o.stdout, "hello world"); + + r.mktmpl("s2/a.txt", "val: ${__ABSENT_XYZ__}"); + let d = r.mkdir("s2"); + let o = r.run(&["-v", &format!("{}:/in:ro", dp(&d)), image, "/in/a.txt"]); + r.assert_exit("missing-literal: exits 0", &o, 0); + r.assert_contains("missing-literal: left as-is", &o.stdout, "${__ABSENT_XYZ__}"); + + r.mktmpl("s3/a.txt", "${__NODEFAULT__:-fallback}"); + let d = r.mkdir("s3"); + let o = r.run(&["-v", &format!("{}:/in:ro", dp(&d)), image, "/in/a.txt"]); + r.assert_exit("default-expansion: exits 0", &o, 0); + r.assert_contains("default-expansion: applied", &o.stdout, "fallback"); + r.assert_not_contains("default-expansion: no literal", &o.stdout, "${__NODEFAULT__"); + + section("Output directory"); + + r.mktmpl("o1/in/app.yaml", "db: ${DB_HOST}"); + let (in_d, out_d) = (r.mkdir("o1/in"), r.mkdir("o1/out")); + let o = r.run(&[ + "-e", "DB_HOST=postgres", + "-v", &format!("{}:/in:ro", dp(&in_d)), + "-v", &format!("{}:/out", dp(&out_d)), + image, "/in/app.yaml", "--output", "/out", + ]); + r.assert_exit("output-dir: exits 0", &o, 0); + r.assert_file("output-dir: file written", &out_d.join("app.yaml"), "db: postgres"); + + // Nested directory structure is mirrored: glob base is /in, so + // /in/root.conf → /out/root.conf and /in/nested/inner.conf → /out/nested/inner.conf. + r.mktmpl("o2/in/root.conf", "a=${VAL}"); + r.mktmpl("o2/in/nested/inner.conf", "b=${VAL}"); + let (in_d, out_d) = (r.mkdir("o2/in"), r.mkdir("o2/out")); + let o = r.run(&[ + "-e", "VAL=x", + "-v", &format!("{}:/in:ro", dp(&in_d)), + "-v", &format!("{}:/out", dp(&out_d)), + image, "/in/**/*.conf", "--output", "/out", + ]); + r.assert_exit("nested: exits 0", &o, 0); + r.assert_file("nested: root file", &out_d.join("root.conf"), "a=x"); + r.assert_file("nested: inner file", &out_d.join("nested").join("inner.conf"), "b=x"); + + section("Env-file mode"); + + r.mktmpl("e1/template.txt", "service=${SVC_NAME}"); + r.mktmpl("e1/vars.env", "SVC_NAME=myapp\n"); + let d = r.mkdir("e1"); + let o = r.run(&[ + "-v", &format!("{}:/in:ro", dp(&d)), + image, "/in/template.txt", "--env-file", "/in/vars.env", + ]); + r.assert_exit("env-file: exits 0", &o, 0); + r.assert_contains("env-file: substituted", &o.stdout, "service=myapp"); + + r.mktmpl("e2/template.txt", "val=${REAL_SYS_VAR}"); + r.mktmpl("e2/empty.env", ""); + let d = r.mkdir("e2"); + let o = r.run(&[ + "-e", "REAL_SYS_VAR=should_not_appear", + "-v", &format!("{}:/in:ro", dp(&d)), + image, "/in/template.txt", "--env-file", "/in/empty.env", + ]); + r.assert_exit("env-file-isolation: exits 0", &o, 0); + r.assert_not_contains("env-file-isolation: sys env ignored", &o.stdout, "should_not_appear"); + r.assert_contains("env-file-isolation: left as literal", &o.stdout, "${REAL_SYS_VAR}"); + + section("Fail on missing"); + + r.mktmpl("f1/tmpl.txt", "${__DEFINITELY_MISSING__}"); + let d = r.mkdir("f1"); + let o = r.run(&[ + "-v", &format!("{}:/in:ro", dp(&d)), + image, "/in/tmpl.txt", "--fail-on-missing", + ]); + r.assert_nonzero("fail-on-missing: exits non-zero for unresolved", &o); + + r.mktmpl("f2/tmpl.txt", "${ALL_PRESENT}"); + let d = r.mkdir("f2"); + let o = r.run(&[ + "-e", "ALL_PRESENT=yes", + "-v", &format!("{}:/in:ro", dp(&d)), + image, "/in/tmpl.txt", "--fail-on-missing", + ]); + r.assert_exit("fail-on-missing: exits 0 when all resolved", &o, 0); + + section("Verbose"); + + r.mktmpl("v1/tmpl.txt", "${__VERBOSE_MISS__}"); + let d = r.mkdir("v1"); + let o = r.run(&[ + "-v", &format!("{}:/in:ro", dp(&d)), + image, "/in/tmpl.txt", "--verbose", + ]); + r.assert_contains("verbose: unresolved var in stderr", &o.stderr, "__VERBOSE_MISS__"); + + r.mktmpl("v2/tmpl.txt", "x=${X}"); + let d = r.mkdir("v2"); + let o = r.run(&[ + "-e", "X=1", + "-v", &format!("{}:/in:ro", dp(&d)), + image, "/in/tmpl.txt", "--verbose", + ]); + r.assert_contains("verbose: file path in stderr", &o.stderr, "tmpl.txt"); + + r.summary() +} diff --git a/testdata/integration/nested/service.conf b/xtask/testdata/integration/nested/service.conf similarity index 100% rename from testdata/integration/nested/service.conf rename to xtask/testdata/integration/nested/service.conf diff --git a/testdata/integration/template.yaml b/xtask/testdata/integration/template.yaml similarity index 100% rename from testdata/integration/template.yaml rename to xtask/testdata/integration/template.yaml