Skip to content
Open
27 changes: 26 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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
Comment on lines +125 to +130

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

9. Docs vs. code. entrypoint? installs the wrapper for JDK 16+, but this says JDK 17+. Also, docker run --rm clojure:lein test and clojure:lein cp don't mount a project, so as written they fail with "Couldn't find project.clj". The custom-alias example below includes -v "$PWD":/app -w /app, and these probably should too.


Generated by Claude Code

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.
Expand Down
26 changes: 18 additions & 8 deletions resources/entrypoint
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

3. Hand-maintained task list pinned to Leiningen 2.13.0. The lein version lives in cfg/build-tools, so a version bump leaves this list stale and the drift only shows up as failed image builds in CI. Could the list be generated instead, e.g. when shared-prereqs writes the entrypoint (it already substitutes @@entrypoint@@), or by running print-lein-commands during the Dockerfile build, so it always comes from the installed jar?


Generated by Claude Code

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2. Executable scripts named without a slash are evaluated as Clojure. type -P only searches PATH, so with -w /app and an executable /app/run.sh, docker run … clojure:tools-deps run.sh fails the type -P check, passes [ -f run.sh ], and runs clj run.sh, giving a confusing reader/compiler error. The comment and README say only non-executable Clojure scripts take this branch; adding [ ! -x "$1" ] (or similar) plus a test for an executable file named without ./ would cover it.


Generated by Claude Code

# Non-executable Clojure scripts run once, through clj.
exec "${entrypoint}" "$@"
fi

# Other commands run directly. Use explicit lein for custom tasks and aliases.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1. Breaking change for plugin tasks and project aliases. Previously lein <cmd> --help succeeded for plugin tasks (ring, cljsbuild, midje, figwheel, kibit, …) and project :aliases, so e.g. docker run -v $PWD:/app -w /app clojure:lein cljsbuild once ran lein cljsbuild once. Now it falls through to exec cljsbuild once and fails with not found (exit 127). The README note is the only notice existing CI pipelines get.


Generated by Claude Code

exec "$@"
20 changes: 20 additions & 0 deletions resources/lein-routing-check.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
set -e

commands=$(java -cp /usr/share/java/leiningen-*-standalone.jar clojure.main -e "$1")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

6. Jar path uses a glob. If leiningen-*-standalone.jar matches several files, the second one becomes the main-class argument. If it matches none, the literal pattern becomes the classpath. Either way -cp is wrong. The image already sets LEIN_VERSION, so /usr/share/java/leiningen-$LEIN_VERSION-standalone.jar would be exact.


Generated by Claude Code


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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

4. The entrypoint shares the loop's stdin. The entrypoint runs inside while read … <<EOF, so a fallback command that reads stdin can consume the rest of the command list. The loop then ends early and the check passes without testing the remaining commands. Suggest /usr/local/bin/entrypoint "$command" </dev/null.


Generated by Claude Code

if [ "$routed" != "lein $command" ]; then
echo "entrypoint does not pass $command to lein" >&2
status=1
fi
done <<EOF
$commands
EOF

exit $status
43 changes: 37 additions & 6 deletions src/docker_clojure/docker.clj
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
(ns docker-clojure.docker
(:require [clojure.java.shell :refer [sh with-sh-dir]]
(:require [clojure.java.io :as io]
[clojure.java.shell :refer [sh with-sh-dir]]
[clojure.spec.alpha :as s]
[clojure.string :as str]
[docker-clojure.config :as cfg]
Expand All @@ -12,14 +13,44 @@
(defn pull-image [image]
(sh "docker" "pull" image))

(def ^:private print-lein-commands
"Prints built-in Leiningen tasks and aliases, one per line.
Includes tasks missing from `lein help`, e.g. `static-classpath`."
'(do
(require 'leiningen.core.main)
(let [jar (java.util.jar.JarFile. (System/getProperty "java.class.path"))
tasks (for [entry (enumeration-seq (.entries jar))
:let [[_ file] (re-find #"^leiningen/([^/]+)\.clj$"
(.getName entry))]
:when file
:let [task (.replace file \_ \-)
task-ns (symbol (str "leiningen." task))]
:when (try (require task-ns)
(ns-resolve task-ns (symbol task))
(catch java.io.FileNotFoundException _ nil))]

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

5. Only FileNotFoundException is caught. If any leiningen/*.clj namespace in the jar fails to load standalone (missing optional dep, init that needs a project), require throws a Compiler$CompilerException. That aborts the whole listing, set -e stops the check script, and the image build fails even though routing is fine. Catching Exception (or skipping namespaces that aren't tasks) would avoid this.


Generated by Claude Code

task)
out (java.io.PrintWriter. (java.io.OutputStreamWriter. System/out "UTF-8"))]
(doseq [command (concat tasks (keys leiningen.core.main/aliases))]
(.println out command))
(.flush out))))

(def ^:private lein-routing-check
"Checks every built-in command routes to lein, e.g. `test` -> `lein test`.
$1 is the `print-lein-commands` form."
(slurp (io/resource "lein-routing-check.sh")))

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

7. Resource slurped at namespace load. If resources isn't on the classpath (or the file is renamed), io/resource returns nil and slurp throws while docker-clojure.docker loads. That breaks every entry point requiring the namespace, including generate/manifest commands that never smoke test. Reading it lazily inside smoke-test-cmds keeps the failure local.


Generated by Claude Code


(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)]))))

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

8. No unit test for the new routing-check branch. A regression in this cond-> condition (e.g. the check stops being added for JDK 21 lein variants, or is wrongly added for ::core/all or JDK 11) or a syntax error in lein-routing-check.sh would pass clojure -X:test and only show up (or be silently missed) in CI image builds. A docker_test case asserting smoke-test-cmds output for lein/jdk-11, lein/jdk-21 and tools-deps would cover it.


Generated by Claude Code


(defn smoke-test
"Runs the variant's smoke test commands in `image-tag`. Returns true if they
Expand Down
94 changes: 94 additions & 0 deletions test/docker_clojure/entrypoint_test.clj
Original file line number Diff line number Diff line change
@@ -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"))))

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

10. Tests depend on the host layout. The tests rely on /bin/test, /bin/sh, and #!/usr/bin/env bash resolving under a PATH restricted to /usr/bin:/bin. On hosts like NixOS/Guix, or where bash only lives in /usr/local/bin or /opt/homebrew/bin, every test fails with 127 instead of exercising the routing logic.


Generated by Claude Code

(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)))))))
Loading