diff --git a/bb.edn b/bb.edn index f9544bc..d8af832 100644 --- a/bb.edn +++ b/bb.edn @@ -1,4 +1,5 @@ -{:tasks {build {:doc "Build uberjar" +{:paths ["src/babashka"] + :tasks {build {:doc "Build uberjar" ;; FIXME: Consider if it would be better to handle Cljs build in separate classpath :task (clojure "-T:shadow-cljs:frontend:backend:build uberjar")} outdated {:doc "Check depdendencies" @@ -19,4 +20,28 @@ :task (shell "npx @svgr/cli --out-dir target/gen/svg -- src/svg")} icons {:doc "Generate React component JS files for svg files" :depends [-svgr] - :task (shell "npx babel target/gen/svg --out-dir src/js/icons")}}} + :task (shell "npx babel target/gen/svg --out-dir src/js/icons")} + + db-up {:doc "Start the database" + :task (shell "docker compose -p example-project up -d")} + db-down {:doc "Stop the database" + :task (shell "docker compose -p example-project down -d")} + + native-image-agent {:doc "Run backend with native-image-agent to collect metadata for building a native image" + :requires ([native-image :as n]) + :task (n/run-with-agent)} + native-image {:doc "Build native image" + :task (shell "clojure -T:build:shadow-cljs native-image")} + native-image-build {:doc "Builds uberjar, then native-image and runs the app for verification" + :task (do + (shell "bb build") + (shell "bb native-image-agent") + (shell "bb native-image") + (shell {:extra-env {"CONFIG_EDN" "resources/config.edn" + "HTTP_PORT" 3333}} + "target/app exit-after-start"))} + + dev {:doc "Start backend development environment" + :task (do + (run 'db-up) + (shell "clojure -A:backend:dev:repl:build:shadow-cljs"))}}} diff --git a/build.clj b/build.clj index 243a0df..0058f41 100644 --- a/build.clj +++ b/build.clj @@ -1,8 +1,9 @@ (ns build - (:require [shadow.cljs.devtools.api :as shadow] - [clojure.tools.build.api :as b])) + (:require [babashka.process :as p] + [babashka.process.pprint] + [clojure.tools.build.api :as b] + [shadow.cljs.devtools.api :as shadow])) -(def version "0.0.1-SNAPSHOT") (def class-dir "target/classes") (def uber-file "target/app.jar") @@ -13,6 +14,7 @@ (defn clean [_] (b/delete {:path "target"})) +#_{:clj-kondo/ignore [:clojure-lsp/unused-public-var]} (defn uberjar [_] (clean nil) (b/copy-dir {:src-dirs ["src/clj" "src/cljc" "resources"] @@ -21,14 +23,67 @@ ;; Build frontend: (shadow/release :app) - #_ (b/compile-clj {:basis @basis - :ns-compile '[backend.main] - :class-dir class-dir}) + :class-dir class-dir + :compile-opts {:direct-linking true}}) (b/uber {:class-dir class-dir :uber-file uber-file - ;; :main 'backend.main + :main 'backend.main :basis @basis}) (println "Uberjar:" uber-file)) + + +#_{:clj-kondo/ignore [:clojure-lsp/unused-public-var]} +(defn native-image [_] + (println "Compiling GraalVM feature classes") + (b/javac {:src-dirs ["src/java"] + :class-dir "target/native-image-configuration/classes" + :basis @basis + :javac-opts ["--add-exports" "org.graalvm.nativeimage/org.graalvm.nativeimage.impl=ALL-UNNAMED" + "-Xlint:deprecation"]}) + + (println "Building native image") + (p/shell "native-image" + + ;; Clojure namespaces + "--features=clj_easy.graal_build_time.InitClojureClasses" + + ;; Buddy support + "--add-exports" "org.graalvm.nativeimage/org.graalvm.nativeimage.impl=ALL-UNNAMED" + "--features=graalvm.features.BouncyCastleFeature" + + ;; Logback related classes + "--initialize-at-build-time=ch.qos.logback" + "--initialize-at-build-time=ch.qos.logback.classic.Logger" + "--initialize-at-build-time=org.xml.sax" + + ;; Don't allow to fall back to launching a VM + "--no-fallback" + + ;; To make shutdown hooks work + "--install-exit-handlers" + + "--enable-monitoring" + + "--emit" "build-report=native-image-build-report.html" + + "--enable-sbom=export" + + "-H:+UnlockExperimentalVMOptions" + "-H:IncludeResources=swagger-ui/.*" ;; TODO: Should create META-INF/native-image/metosin/ring-swagger-ui/native-image.properties + + "-H:+PrintClassInitialization" + + "-cp" (str + ;; From training run with native-image-agent + "target/native-image-configuration" + ;; Compiled GraalVM feature classes, buddy support + ":target/native-image-configuration/classes") + + "--native-image-info" + + "-jar" "target/app.jar" + "-o" "target/app") + (println "native image created: target/app")) diff --git a/deps.edn b/deps.edn index 4a01fda..1e26d85 100644 --- a/deps.edn +++ b/deps.edn @@ -19,7 +19,8 @@ ;; Hugsql hikari-cp/hikari-cp {:mvn/version "3.1.0"} migratus/migratus {:mvn/version "1.5.8"} - ring/ring-jetty-adapter {:mvn/version "1.12.2"} + #_#_ring/ring-jetty-adapter {:mvn/version "1.12.2"} + info.sunng/ring-jetty9-adapter {:mvn/version "0.35.1"} metosin/ring-swagger-ui {:mvn/version "5.17.14"} metosin/ring-http-response {:mvn/version "0.9.4"} metosin/muuntaja {:mvn/version "0.6.10"} @@ -30,7 +31,14 @@ ;; org.apache.logging.log4j/log4j-to-slf4j {:mvn/version "2.23.1"} ;; org.slf4j/jul-to-slf4j {:mvn/version "2.0.16"} ;; org.slf4j/jcl-over-slf4j {:mvn/version "2.0.16"} - org.clojure/tools.logging {:mvn/version "1.3.0"}}} + org.clojure/tools.logging {:mvn/version "1.3.0"} + + ;; For native-image, used by --features=clj_easy.graal_build_time.InitClojureClasses + com.github.clj-easy/graal-build-time {:mvn/version "1.0.5"} + + ;; For showing how to make buddy work in native-image + buddy/buddy-sign {:mvn/version "3.5.351"} + buddy/buddy-core {:mvn/version "1.11.423"}}} :dev ;; Include test folder here for REPL, @@ -38,7 +46,15 @@ {:extra-paths ["dev/clj" "target/dev" "test/clj"]} :repl - {:extra-deps {}} + {:extra-deps {cider/cider-nrepl {:mvn/version "0.50.2"} + nrepl/nrepl {:mvn/version "1.3.0"} + com.bhauman/rebel-readline {:mvn/version "0.1.4"} + + hashp/hashp {:mvn/version "0.2.2"}} + :main-opts ["-m" "nrepl.cmdline" + "--middleware" "[cider.nrepl/cider-middleware]" + "--interactive" + "--repl-fn" "rebel-readline.main/-main"]} :test {:extra-paths ["test/clj"] @@ -64,8 +80,13 @@ org.slf4j/slf4j-simple {:mvn/version "2.0.16"}}} :build - {:deps {io.github.clojure/tools.build {:mvn/version "0.10.5"}} - :ns-default build} + {:deps {io.github.clojure/tools.build {:mvn/version "0.10.5"} + + babashka/process {:mvn/version "0.5.22"} + babashka/fs {:mvn/version "0.5.22"}} + + :ns-default build + :jvm-opts ["-XX:-OmitStackTraceInFastThrow" "-Dclojure.main.report=stderr"]} :outdated {:deps {com.github.liquidz/antq {:mvn/version "RELEASE"} diff --git a/dev/clj/user.clj b/dev/clj/user.clj index bbec288..6f7848a 100644 --- a/dev/clj/user.clj +++ b/dev/clj/user.clj @@ -1,5 +1,6 @@ (ns user - (:require [integrant.repl :refer [clear go halt prep init reset reset-all]] + (:require [hashp.core] + [integrant.repl :refer [clear go halt prep init reset reset-all]] [integrant.repl.state :as state] [migratus.core :as migratus])) diff --git a/src/babashka/native_image.clj b/src/babashka/native_image.clj new file mode 100644 index 0000000..14b10be --- /dev/null +++ b/src/babashka/native_image.clj @@ -0,0 +1,109 @@ +(ns native-image + (:require [babashka.fs :as fs] + [babashka.http-client :as http] + [babashka.process :as p] + [babashka.wait :as wait] + [cheshire.core :as json] + [clojure.string :as str] + [clojure.walk :as walk])) + +(defn start-backend [{:keys [http-port extra-env timeout-ms command]}] + (p/shell "bb db-up") + (let [command (if (string? command) + [command] + command) + backend-process (apply p/process + {:inherit true + :extra-env extra-env} + command)] + (when (= :timeout (wait/wait-for-port "localhost" http-port + {:timeout timeout-ms + :default :timeout})) + (p/destroy-tree backend-process) + (throw (Exception. (str "Backend didn't start in " timeout-ms "ms")))) + backend-process)) + +(defn run-training [http-port] + (let [base-url (format "http://localhost:%s" http-port) + test-requests [;; Database access + [200 (str base-url "/api/todo")] + ;; Buddy + [200 (str base-url "/api/buddy-test")]]] + (doall (keep (fn [[expected-status url]] + (let [response (http/get url {:throw false})] + (when (not= expected-status (:status response)) + response))) + test-requests)))) + +(defn check-failures [failures] + (when (not (zero? (count failures))) + (println "Training requests failed:") + (doseq [failure failures] + (prn failure)) + (System/exit 1))) + +(defn remove-clojure-name [coll clojure-names keeps] + (remove (fn [{:keys [type glob]}] + (let [s (or glob + (when (and type + (string? type)) + type))] + (if s + (some #(and (or (.startsWith s %) + (.contains s %)) + (not (some (fn [k] + (.contains % k)) + keeps))) + clojure-names) + false))) + coll)) + +(defn post-process-metadata + "Removes Clojure relates entries from the metadata file, idea is to keep only entries from dependencies + + Apparently, keeping Clojure entries leads into problems with code that modifies dynamic variables such as *warn-on-reflection*" + [filename] + (fs/copy filename (str filename ".orig")) + (let [data (json/parse-string (slurp filename) true) + clojure-names-atom (atom #{"clojure"})] + (walk/postwalk (fn [o] + (when (and (string? o) + (or (.endsWith o ".clj") + (.endsWith o ".cljc"))) + (let [clojure-name (str/replace o + (if (.endsWith o ".clj") + ".clj" + ".cljc") + "")] + (swap! clojure-names-atom conj clojure-name (str/replace clojure-name "/" ".")))) + o) + data) + (let [new-data (-> data + (update :reflection #(remove-clojure-name % @clojure-names-atom #{"jetty9"})) ;; To support ring-jetty9-adapter + (update :resources #(remove-clojure-name % @clojure-names-atom #{})) + (update :jni #(remove-clojure-name % @clojure-names-atom #{})))] + (spit filename (json/generate-string new-data {:pretty true}))))) + +;; Do a training run to gather metadata via native-image-agent +;; Note that should not refer to any unnecessary test resources on the classpath during training, +;; since those will get included into the metadata +(defn run-with-agent [] + (let [http-port 3322 + tracing-process (start-backend {:http-port http-port + :extra-env {"CONFIG_EDN" "resources/config.edn" + "HTTP_PORT" http-port} + :timeout-ms 5000 + :command ["java" (str "-agentlib:native-image-agent=" + "config-output-dir=target/native-image-configuration/META-INF/native-image") + "-jar" "target/app.jar"]}) + failures (run-training http-port)] + (p/destroy-tree tracing-process) + (check-failures failures) + ;; Wait for native-image-agent to create the tracing file + (when (= :timeout (wait/wait-for-path "target/native-image-configuration/META-INF/native-image/reachability-metadata.json" + {:timeout 5000 + :default :timeout})) + (println "Reachability metadata file not created") + (System/exit 1))) + + (post-process-metadata "target/native-image-configuration/META-INF/native-image/reachability-metadata.json")) diff --git a/src/clj/backend/main.clj b/src/clj/backend/main.clj index 6a16fa5..e19916d 100644 --- a/src/clj/backend/main.clj +++ b/src/clj/backend/main.clj @@ -1,21 +1,25 @@ (ns backend.main (:require [aero.core :as aero] [backend.routes] - [clojure.java.io :as io] [clojure.tools.logging :as log] [cognitect.transit] [hikari-cp.core :as hikari-cp] [integrant.core :as ig] [next.jdbc.date-time] [reitit.ring.middleware.exception] - [ring.adapter.jetty :as jetty])) + [ring.adapter.jetty9 :as jetty] + #_[ring.adapter.jetty :as jetty]) + (:gen-class)) + +(set! *warn-on-reflection* true) (defmethod aero.core/reader 'ig/ref [_opts _tag value] (ig/ref value)) (defn system-config [] - (aero/read-config (io/resource "config.edn"))) + (aero/read-config (or (System/getenv "CONFIG_EDN") + "config.edn"))) (defmethod ig/init-key :adapter/jetty [_ {:keys [port routes] :as jetty-opts}] (log/infof "Starting Jetty server on http://localhost:%s" port) @@ -48,5 +52,8 @@ (catch Throwable t (log/error t "Failed to start system")))) -(defn -main [] - (run-system (system-config))) +(defn -main [& args] + (run-system (system-config)) + (when (= "exit-after-start" (first args)) + (log/info "Exiting") + (System/exit 0))) diff --git a/src/clj/backend/routes.clj b/src/clj/backend/routes.clj index c728f05..8c779e6 100644 --- a/src/clj/backend/routes.clj +++ b/src/clj/backend/routes.clj @@ -1,4 +1,4 @@ -(ns backend.routes +(ns backend.routes (:require [backend.api.todo :as todo] [clojure.edn :as edn] [clojure.java.io :as io] @@ -15,7 +15,10 @@ [reitit.ring.middleware.muuntaja :as muuntaja] [reitit.swagger :as swagger] [reitit.swagger-ui :as swagger-ui] - [ring.util.http-response :as resp])) + [ring.util.http-response :as resp] + [buddy.sign.jwt :as jwt] + buddy.sign.util + [buddy.core.keys :as buddy-keys])) (def muuntaja-instance (m/create (-> m/default-options @@ -66,10 +69,33 @@ [:h1 "Loading, please wait..."]]] [:script {:type "text/javascript" :src (str "/js/" (main-js-file))}]]])) +(def buddy-test-route + ["/buddy-test" {:get (fn [_] + (try + (let [id-token "eyJraWQiOiJVd3VIdERJeXVYTGs5NytmUlVHeTIzM3pNRnVCMkZPc2ErcURBMUt4OGNRPSIsImFsZyI6IlJTMjU2In0.eyJhdF9oYXNoIjoiQkNkbC16MEowRXkzb25DU1cwUndLZyIsInN1YiI6IjZlNWEwNTE4LTNkNzctNDA2NS05MjVkLWUzZGM3YTU4MjkyYiIsImNvZ25pdG86Z3JvdXBzIjpbImFkbWluIl0sImVtYWlsX3ZlcmlmaWVkIjp0cnVlLCJpc3MiOiJodHRwczpcL1wvY29nbml0by1pZHAudXMtd2VzdC0yLmFtYXpvbmF3cy5jb21cL3VzLXdlc3QtMl9XNDVGSUU0SzciLCJjb2duaXRvOnVzZXJuYW1lIjoia2ltbW9AcmVwbGlhbmNlLmNvbSIsImF1ZCI6ImVyMHFkbGwwODdncnJhY20wM2tsMmU0OGEiLCJldmVudF9pZCI6IjkzMDhiMDU5LTk2Y2EtNGQwNi05NzNmLWI5YzcwNGM3NmVmNCIsInRva2VuX3VzZSI6ImlkIiwiYXV0aF90aW1lIjoxNjY1NzMzODQyLCJleHAiOjE2NjU3NjI2NDIsImlhdCI6MTY2NTczMzg0MiwianRpIjoiZTAyZGE1ZDctMjZlZC00OGJlLWEwMDMtMzJhMGMwMzM1ZDI5IiwiZW1haWwiOiJraW1tb0ByZXBsaWFuY2UuY29tIn0.p54_PyPrK0z_g87FikyKh4pkTpMeITP27TPyeBzc8dSCbNuShR8epGpw2Ag1rcBaKS1Tcig2qicnJdHlCHuG6nkVvhjnu4I3DjH45b1Nx3neV5ii5t6YZ1GNTipzqypVd_Y9pdSxYCZYrwA442LzLSLKgzJlYD5sDB91iL0KAmcYx5JLHJ0Tpm__BILt-SHm3s3nRhbYe6isrSHeOdr19NIFOL5S5UYYS641Tlz5MQyEFHQ9tmSCMv3BudnaaImacpRQVy-uu5zseP5Db9BGjPtCoRHnpYpF8YZC9fLj2hY3AMiBBEkmfXhIh6dUnPahC8bwGaDk36OWnMVw1hxbyA" + jwt-key {:alg "RS256", + :e "AQAB", + :kid "UwuHtDIyuXLk97+fRUGy233zMFuB2FOsa+qDA1Kx8cQ=", + :kty "RSA", + :n "qos6mdmypWvK74e7z9-JT5x-0ua4Sjjo5_uV-AKdkx3s7n_oAV7TfYBIYaFDfRxkTDIfWIy2yQUqUlkkxLHn7MiEF0m6mP7Wwb0tdSxgYOF9TmciPDGnxjSAgdi6E8sb0jHYnN79U0DJI-mpxB1v79MFwR6erxDROikQVtecnj-4vVhhjxc6q098HrrOIRMZIXvyEhdkbB_UTr6u8-OYAAHX2GuDgkmnX6rpUQLOqv66WxSsebWvj88UJZWqQ6KoG9gV2KvzxvwiO0gV0ePnzZ6p8oiEqhK6sxDBksXDY9vASQwW1xDcLMY_iOSJ-YpaeFiTD33Oevkyv0nRzLhe5w", + :use "sig"}] + (jwt/unsign id-token + (buddy-keys/jwk->public-key jwt-key) + {:alg :rs256, + :aud "er0qdll087grracm03kl2e48a", + :iss "https://cognito-idp.us-west-2.amazonaws.com/us-west-2_W45FIE4K7"})) + (catch Exception e + (let [{:keys [type cause] :as data} (ex-data e)] + (assert (= :validation type)) + (assert (= :exp cause)) + (prn data)))) + {:status 200})}]) + (defn app [env] (ring/ring-handler (ring/router ["/api" + buddy-test-route ["/todo" ["" {:summary "Return a list of todo items" diff --git a/src/java/graalvm/features/BouncyCastleFeature.java b/src/java/graalvm/features/BouncyCastleFeature.java new file mode 100644 index 0000000..e325143 --- /dev/null +++ b/src/java/graalvm/features/BouncyCastleFeature.java @@ -0,0 +1,23 @@ +package graalvm.features; + +import org.bouncycastle.jce.provider.BouncyCastleProvider; +import org.graalvm.nativeimage.ImageSingletons; +import org.graalvm.nativeimage.hosted.Feature; +import org.graalvm.nativeimage.hosted.RuntimeClassInitialization; +import org.graalvm.nativeimage.impl.RuntimeClassInitializationSupport; + +import java.security.Security; + +public class BouncyCastleFeature implements Feature { + + @Override + public void afterRegistration(AfterRegistrationAccess access) { + System.out.println("[BouncyCastleFeature] Adding BouncyCastle support"); + RuntimeClassInitialization.initializeAtBuildTime("org.bouncycastle"); + RuntimeClassInitializationSupport rci = ImageSingletons.lookup(RuntimeClassInitializationSupport.class); + rci.rerunInitialization("org.bouncycastle.jcajce.provider.drbg.DRBG$Default", ""); + rci.rerunInitialization("org.bouncycastle.jcajce.provider.drbg.DRBG$NonceAndIV", ""); + Security.addProvider(new BouncyCastleProvider()); + } + +}