diff --git a/README.md b/README.md index 436bb516..dcf2aaf1 100644 --- a/README.md +++ b/README.md @@ -105,7 +105,7 @@ Java 21 tools-deps on Alpine: `clojure:temurin-21-tools-deps-alpine` or `clojure ## Examples -### Interactive Shell +### Interactive shell Run an interactive shell from this image. @@ -121,6 +121,31 @@ cd hello-world lein repl ``` +### Running individual commands + +For JDK 17+ images: + +```sh +# Built-in Leiningen tasks and aliases +docker run --rm clojure:lein test +docker run --rm clojure:lein cp + +# Custom Leiningen tasks and aliases: always include lein +docker run --rm -v "$PWD":/app -w /app clojure:lein lein my-alias + +# Clojure flags and scripts +docker run --rm clojure:tools-deps -M -e '(println (+ 1 2))' +docker run --rm -v "$PWD":/app -w /app clojure:tools-deps example.clj + +# deps.edn aliases +docker run --rm -v "$PWD":/app -w /app clojure:tools-deps clj -M:my-alias +``` + +Programs such as `bash` and `git` run directly. Use `/bin/cp` to select the +executable instead of Leiningen's `cp` alias. + +With older JDK images, invoke `lein` or `clj` explicitly. + ## Builds The Dockerfiles are generated by the `docker-clojure` Clojure app in this repo. diff --git a/resources/entrypoint b/resources/entrypoint index 3a50a540..d7954afc 100755 --- a/resources/entrypoint +++ b/resources/entrypoint @@ -4,18 +4,28 @@ set -eou pipefail entrypoint=@@entrypoint@@ -cmd=${1:-} - -# check if the first arg starts with a hyphen +# No arguments or a leading flag belong to the image's build tool. if [ "$#" -eq 0 ] || [ "${1#-}" != "$1" ]; then exec "${entrypoint}" "$@" fi -if [[ -n "${cmd}" ]]; then - # see if help for the subcommand is successful - if "${entrypoint}" "${cmd}" --help >/dev/null 2>&1; then - exec "${entrypoint}" "$@" - fi +# Built-in Leiningen tasks and aliases take precedence over executables such as +# test and cp. Keep this Leiningen 2.13.0 list in sync when upgrading (the image +# smoke test checks it); never probe a command with --help, which can evaluate +# code from the current project. +if [ "${entrypoint}" = lein ]; then + case "$1" in + change|check|classpath|clean|compile|cp|deploy|deps|do|halp|help|install|jar|javac|\ + new|plugin|pom|readme|release|repl|retest|run|sample|search|show-profiles|\ + static-classpath|test|trampoline|tutorial|uberjar|update-in|upgrade|vcs|version|\ + with-profile|with-profiles|überjar) + exec "${entrypoint}" "$@" + ;; + esac +elif [ -f "$1" ] && ! type -P "$1" >/dev/null; then + # Non-executable Clojure scripts run once, through clj. + exec "${entrypoint}" "$@" fi +# Other commands run directly. Use explicit lein for custom tasks and aliases. exec "$@" diff --git a/resources/lein-routing-check.sh b/resources/lein-routing-check.sh new file mode 100644 index 00000000..5493f0b5 --- /dev/null +++ b/resources/lein-routing-check.sh @@ -0,0 +1,20 @@ +set -e + +commands=$(java -cp /usr/share/java/leiningen-*-standalone.jar clojure.main -e "$1") + +stub=$(mktemp -d) +printf '%s\n' '#!/bin/sh' 'echo "lein $1"' > "$stub/lein" +chmod +x "$stub/lein" + +status=0 +while IFS= read -r command; do + routed=$(PATH="$stub:/usr/bin:/bin" /usr/local/bin/entrypoint "$command" 2>/dev/null) || true + if [ "$routed" != "lein $command" ]; then + echo "entrypoint does not pass $command to lein" >&2 + status=1 + fi +done < `lein test`. + $1 is the `print-lein-commands` form." + (slurp (io/resource "lein-routing-check.sh"))) + (defn smoke-test-cmds - "Commands to run in a freshly built image to check its build tool(s) work." - [{:keys [build-tool]}] + "Returns commands to check build tools start. Each must exit 0. + Lein images with an entrypoint also check every built-in command routes + to lein, e.g. `test` -> `lein test`." + [{:keys [build-tool] :as variant}] (let [cmds {"lein" ["lein" "version"] "tools-deps" ["clojure" "-M" "-e" "(clojure-version)"]}] - (if (= ::core/all build-tool) - (vals cmds) - [(get cmds build-tool)]))) + (cond-> (if (= ::core/all build-tool) + (vals cmds) + [(get cmds build-tool)]) + (and (= "lein" build-tool) (df/entrypoint? variant)) + (conj ["sh" "-c" lein-routing-check "sh" (pr-str print-lein-commands)])))) (defn smoke-test "Runs the variant's smoke test commands in `image-tag`. Returns true if they diff --git a/test/docker_clojure/entrypoint_test.clj b/test/docker_clojure/entrypoint_test.clj new file mode 100644 index 00000000..9c759284 --- /dev/null +++ b/test/docker_clojure/entrypoint_test.clj @@ -0,0 +1,94 @@ +(ns docker-clojure.entrypoint-test + (:require [clojure.java.io :as io] + [clojure.java.shell :refer [sh]] + [clojure.string :as str] + [clojure.test :refer [deftest is testing]] + [docker-clojure.dockerfile :as df]) + (:import (java.nio.file Files) + (java.nio.file.attribute FileAttribute))) + +(defn- nul-fields [s] + (vec (butlast (str/split s #"\x00" -1)))) + +(defn- run-entrypoint [build-tool & args] + (let [dir (.toFile (Files/createTempDirectory "entrypoint test " (make-array FileAttribute 0))) + bin (io/file dir "bin") + log (io/file dir "calls")] + (try + (.mkdirs bin) + (df/shared-prereqs (str bin) {:build-tool build-tool}) + ;; Record every tool invocation, including probes with hidden output. + (doseq [tool ["lein" "clj"]] + (let [file (io/file bin tool)] + (spit file (str "#!/bin/sh\nprintf '%s\\0' '" tool "' \"$@\" >> \"$CALLS_LOG\"\n")) + (.setExecutable file true false))) + (spit (io/file dir "example.clj") "(println :example)") + ;; A local script must not take precedence over the executable on PATH. + (spit (io/file dir "echo") "(throw (Exception. \"unexpected project execution\"))") + (spit (io/file dir "launch") "#!/bin/sh\nprintf '%s\\n' external\n") + (.setExecutable (io/file dir "launch") true false) + (let [result (apply sh (str (io/file bin "entrypoint")) + (concat args [:dir (str dir) + :env {"PATH" (str bin ":/usr/bin:/bin") + "CALLS_LOG" (str log)}]))] + (assoc result :calls (if (.exists log) (nul-fields (slurp log)) []))) + (finally + (doseq [file (reverse (file-seq dir))] + (io/delete-file file)))))) + +(deftest external-commands-do-not-probe-test + (doseq [build-tool ["lein" "tools-deps" :docker-clojure.core/all]] + (testing (str build-tool " runs executables without launching a build tool") + (is (= {:exit 0 :out "hello\n" :err "" :calls []} + (run-entrypoint build-tool "echo" "hello"))) + (is (= {:exit 0 :out "external\n" :err "" :calls []} + (run-entrypoint build-tool "./launch"))) + (is (= {:exit 23 :out "" :err "failed\n" :calls []} + (run-entrypoint build-tool "/bin/sh" "-c" "echo failed >&2; exit 23")))))) + +(deftest defaults-and-flags-test + (doseq [[build-tool tool] [["lein" "lein"] ["tools-deps" "clj"] + [:docker-clojure.core/all "clj"]] + args [[] ["-h"] ["-M" "-e" "(+ 1 2)"]]] + (let [result (apply run-entrypoint build-tool args)] + (is (= 0 (:exit result))) + (is (= (into [tool] args) (:calls result)))))) + +(deftest lein-shorthand-and-explicit-invocation-test + (testing "built-in tasks and aliases keep precedence over executables" + (doseq [task ["repl" "test" "cp" "install" "help" "static-classpath" + "uberjar" "überjar"]] + (is (= ["lein" task] (:calls (run-entrypoint "lein" task)))))) + (testing "explicit commands and paths bypass shorthand routing" + (is (= ["lein" "my-alias"] (:calls (run-entrypoint "lein" "lein" "my-alias")))) + (is (= {:exit 0 :out "" :err "" :calls []} + (run-entrypoint "lein" "/bin/test" "1" "=" "1")))) + (testing "custom tasks use explicit Leiningen invocation" + (is (= ["lein" "my-task"] (:calls (run-entrypoint "lein" "lein" "my-task")))))) + +(deftest clojure-scripts-run-once-test + (doseq [build-tool ["tools-deps" :docker-clojure.core/all] + script ["example.clj" "./example.clj"]] + (let [result (run-entrypoint build-tool script "argument")] + (is (= 0 (:exit result))) + (is (= ["clj" script "argument"] (:calls result)))))) + +(deftest unknown-commands-do-not-launch-project-test + (doseq [build-tool ["lein" "tools-deps" :docker-clojure.core/all]] + (let [result (run-entrypoint build-tool "no-such-command")] + (is (= 127 (:exit result))) + (is (str/includes? (:err result) "no-such-command")) + (is (= [] (:calls result)))))) + +(deftest literal-arguments-test + (let [args ["" "two words" "*.clj" "line one\nline two" "$(exit 99)" "'quoted'"]] + (doseq [[build-tool command] [["lein" "test"] ["tools-deps" "-M"]]] + (let [result (apply run-entrypoint build-tool command args)] + (is (= 0 (:exit result))) + (is (= (into [(if (= build-tool "lein") "lein" "clj") command] args) + (:calls result))))) + (doseq [build-tool ["lein" "tools-deps"]] + (let [result (apply run-entrypoint build-tool "printf" "%s\\0" args)] + (is (= 0 (:exit result))) + (is (= args (nul-fields (:out result)))) + (is (= [] (:calls result)))))))