Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 34 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,39 @@ cd hello-world
lein repl
```

### Running individual commands

For JDK 16+ images:

```sh
# -v "$PWD":/app makes your current folder available inside the container at /app.
# -w /app runs the command from that folder.

# Built-in Leiningen tasks and aliases
docker run --rm -v "$PWD":/app -w /app clojure:lein test
docker run --rm -v "$PWD":/app -w /app clojure:lein cp

# Plugin tasks and project aliases: 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.

In lein images, a command that isn't a built-in task or an executable on `PATH`
still runs through Leiningen, so `clojure:lein my-alias` works but prints a
warning. Include `lein` to skip the warning, and to run a task that shares its
name with an executable.

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
27 changes: 21 additions & 6 deletions resources/entrypoint
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,33 @@ 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
# 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
# Plugin tasks and project aliases aren't executables, so they still reach
# lein. Paths and executables on PATH run directly.
if [ "${1#*/}" = "$1" ] && ! type -P "$1" >/dev/null; then
echo "entrypoint: '$1' is not on PATH, running 'lein $1'. Prefer 'lein $1' for custom tasks and aliases." >&2
exec "${entrypoint}" "$@"
fi
elif [ -f "$1" ] && [ ! -x "$1" ] && ! type -P "$1" >/dev/null; then
# Non-executable Clojure scripts run once, through clj.
exec "${entrypoint}" "$@"
fi

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

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

stub=$(mktemp -d)
printf '%s\n' '#!/bin/sh' 'echo "lein $1"' > "$stub/lein"
chmod +x "$stub/lein"

# Built-ins must route to lein without the fallback warning, even when an
# executable on the image's PATH shares their name.
status=0
while IFS= read -r command; do
routed=$(PATH="$stub:$PATH" /usr/local/bin/entrypoint "$command" 2>&1 </dev/null) || true
if [ "$routed" != "lein $command" ]; then
echo "entrypoint does not pass $command to lein: $routed" >&2
status=1
fi
done <<EOF
$commands
EOF

exit $status
46 changes: 40 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,47 @@
(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))
;; Skip helper namespaces that aren't tasks
;; or need a project to load.
(catch Exception _ nil))]
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))))

(defn- 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")))

(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
Expand Down
19 changes: 18 additions & 1 deletion test/docker_clojure/docker_test.clj
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
(ns docker-clojure.docker-test
(:require [clojure.string :as str]
(:require [clojure.java.shell :refer [sh]]
[clojure.string :as str]
[clojure.test :refer :all]
[docker-clojure.config :as cfg]
[docker-clojure.docker :refer :all]))
Expand Down Expand Up @@ -68,3 +69,19 @@
:debian-slim/trixie-slim "tools-deps-trixie-slim"
:debian/bookworm "tools-deps-bookworm"
:debian-slim/bookworm-slim "tools-deps-bookworm-slim")))

(deftest smoke-test-cmds-test
(testing "every variant checks its build tools start"
(are [build-tool jdk-version cmds]
(= cmds (smoke-test-cmds {:build-tool build-tool :jdk-version jdk-version}))
"lein" 11 [["lein" "version"]]
"tools-deps" 21 [["clojure" "-M" "-e" "(clojure-version)"]]
:docker-clojure.core/all 21 [["lein" "version"]
["clojure" "-M" "-e" "(clojure-version)"]]))
(testing "lein images with an entrypoint also check lein routing"
(let [[version-cmd [sh-cmd flag script & args]]
(smoke-test-cmds {:build-tool "lein" :jdk-version 21})]
(is (= ["lein" "version"] version-cmd))
(is (= ["sh" "-c"] [sh-cmd flag]))
(is (= {:exit 0 :err ""} (select-keys (sh "sh" "-n" "-c" script) [:exit :err])))
(is (= 2 (count args))))))
109 changes: 109 additions & 0 deletions test/docker_clojure/entrypoint_test.clj
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
(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\"))")
(doseq [[script out] [["launch" "external"] ["test" "local"]]]
(spit (io/file dir script) (str "#!/bin/sh\nprintf '%s\\n' " out "\n"))
(.setExecutable (io/file dir script) true false))
(let [result (apply sh (str (io/file bin "entrypoint"))
(concat args [:dir (str dir)
:env {"PATH" (str bin ":" (System/getenv "PATH"))
"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 (= {:exit 0 :out "" :err "" :calls ["lein" task]}
(run-entrypoint "lein" task)))))
(testing "explicit commands and paths bypass shorthand routing"
(is (= {:exit 0 :out "" :err "" :calls ["lein" "my-alias"]}
(run-entrypoint "lein" "lein" "my-alias")))
(is (= {:exit 0 :out "local\n" :err "" :calls []}
(run-entrypoint "lein" "./test")))))

(deftest lein-fallback-test
(testing "plugin tasks and project aliases still reach lein, with a warning"
(doseq [args [["cljsbuild" "once"] ["my-alias"] ["no-such-command"]]]
(let [result (apply run-entrypoint "lein" args)]
(is (= 0 (:exit result)))
(is (= (into ["lein"] args) (:calls result)))
(is (str/includes? (:err result) (str "running 'lein " (first args) "'"))))))
(testing "missing paths do not fall back to lein"
(let [result (run-entrypoint "lein" "./no-such-command")]
(is (not= 0 (:exit result)))
(is (= [] (:calls result))))))

(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 ["tools-deps" :docker-clojure.core/all]
command ["no-such-command" "launch"]]
(testing (str build-tool " does not pass " command " to clj")
(let [result (run-entrypoint build-tool command)]
(is (= 127 (:exit result)))
(is (str/includes? (:err result) 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