diff --git a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_22.adoc b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_22.adoc index 44014f2466d3a..a12492d29a6c4 100644 --- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_22.adoc +++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_22.adoc @@ -56,6 +56,30 @@ run the CLI automatically renames a pre-existing global `~/.camel-jbang-user.pro `~/.camel-cli.properties` and a pre-existing local `./camel-jbang-user.properties` to `./camel-cli.properties`; an existing new file is never overwritten. +==== Camel CLI launcher Java runtime discovery + +The self-contained Camel CLI launcher scripts (`bin/camel.sh` and `bin/camel.bat` in the +`camel-launcher` distribution) now share a single Java runtime discovery contract. Candidates +are evaluated in this fixed order, and the first one that exists, is executable, and reports a +Java major version of at least 17 is used: + +1. `JAVACMD` (explicit override, unchanged). +2. `$JAVA_HOME/bin/java` (`%JAVA_HOME%\bin\java.exe` on Windows). This is also how the SDKMAN + `java` candidate is honored, since SDKMAN exports `JAVA_HOME`. +3. The first `java` (`java.exe`) found on `PATH`. +4. `CAMEL_FALLBACK_JAVA`, a new variable set by package-manager installs when the JDK location + is deterministic. + +Candidates older than Java 17, missing, non-executable, or with unparseable version output are +skipped. If none qualify, the launcher now exits nonzero with a diagnostic listing the checked +sources instead of attempting to run an unsuitable Java. + +The obsolete macOS `JAVA_HOME=/System/Library/Frameworks/JavaVM.framework/...` default was +removed from `camel.sh`; set `JAVA_HOME` or `JAVACMD`, or ensure a Java 17+ `java` is on `PATH`. +The Gentoo `java-config` auto-detection and the IBM AIX `$JAVA_HOME/jre/sh/java` probe were also +removed; set `JAVA_HOME` or `JAVACMD` explicitly on those platforms. +`JAVA_OPTS` handling is unchanged: when unset, the default `-Xmx512m` heap is applied. + === camel-langchain4j-agent The `Agent.chat()` method return type has changed from `String` to `Result` (from `dev.langchain4j.service.Result`). diff --git a/dsl/camel-jbang/camel-launcher/src/main/resources/bin/camel.bat b/dsl/camel-jbang/camel-launcher/src/main/resources/bin/camel.bat index 1f6a7d38b4411..692dbea4de267 100644 --- a/dsl/camel-jbang/camel-launcher/src/main/resources/bin/camel.bat +++ b/dsl/camel-jbang/camel-launcher/src/main/resources/bin/camel.bat @@ -28,45 +28,106 @@ set DIRNAME=%~dp0 if "%DIRNAME%" == "" set DIRNAME=. set BASEDIR=%DIRNAME% -@REM Java command to use -if "%JAVA_HOME%" == "" goto noJavaHome -set JAVACMD=%JAVA_HOME%\bin\java.exe -goto checkJavaCmd +@REM --- Shared Java 17+ discovery contract --- +@REM Candidates in order: JAVACMD, %JAVA_HOME%\bin\java.exe, java.exe on PATH, CAMEL_FALLBACK_JAVA. +set CAMEL_MIN_JAVA=17 -:noJavaHome -set JAVACMD=java.exe +set "CAMEL_JAVACMD=" +call :tryJava "%JAVACMD%" +if defined CAMEL_JAVACMD goto haveJava +if defined JAVA_HOME call :tryJava "%JAVA_HOME%\bin\java.exe" +if defined CAMEL_JAVACMD goto haveJava +for %%p in (java.exe) do call :tryJava "%%~$PATH:p" +if defined CAMEL_JAVACMD goto haveJava +call :tryJava "%CAMEL_FALLBACK_JAVA%" +if defined CAMEL_JAVACMD goto haveJava +goto noJava -:checkJavaCmd -if exist "%JAVACMD%" goto init +:haveJava +set "JAVACMD=%CAMEL_JAVACMD%" -echo. -echo Error: JAVA_HOME is not defined correctly. >&2 -echo Cannot execute "%JAVACMD%" >&2 -echo. -goto error - -:init @REM Set JVM options if specified if "%JAVA_OPTS%" == "" set JAVA_OPTS=-Xmx512m -@REM Find the JAR file -dir /b "%BASEDIR%\camel-launcher-*.jar" > nul 2>&1 -if not errorlevel 1 ( - for /f "tokens=*" %%j in ('dir /b "%BASEDIR%\camel-launcher-*.jar"') do set LAUNCHER_JAR=%BASEDIR%\%%j -) else ( - for %%i in ("%BASEDIR%\camel-launcher-*.jar") do set LAUNCHER_JAR=%%i -) +@REM Find the launcher JAR +set "LAUNCHER_JAR=" +for %%i in ("%BASEDIR%\camel-launcher-*.jar") do set "LAUNCHER_JAR=%%i" -@REM Execute Camel CLI -"%JAVACMD%" %JAVA_OPTS% -jar "%LAUNCHER_JAR%" %* -if ERRORLEVEL 1 goto error -goto end +@REM Execute Camel CLI, preserving arguments and the child exit code +for %%e in ("%JAVACMD%") do set "_JAVACMD_EXT=%%~xe" +if /i "%_JAVACMD_EXT%"==".bat" goto executeJavaBatch +if /i "%_JAVACMD_EXT%"==".cmd" goto executeJavaBatch +goto executeJavaNative -:error -set ERROR_CODE=1 +:executeJavaBatch +call "%JAVACMD%" %JAVA_OPTS% -jar "%LAUNCHER_JAR%" %* +set ERROR_CODE=%ERRORLEVEL% +goto javaExecuted -:end +:executeJavaNative +"%JAVACMD%" %JAVA_OPTS% -jar "%LAUNCHER_JAR%" %* +set ERROR_CODE=%ERRORLEVEL% + +:javaExecuted @REM End local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" endlocal & set ERROR_CODE=%ERROR_CODE% +exit /B %ERROR_CODE% + +:noJava +echo. 1>&2 +echo Error: no suitable Java runtime found. Camel CLI requires Java %CAMEL_MIN_JAVA% or newer. 1>&2 +echo Checked: JAVACMD, %%JAVA_HOME%%\bin\java.exe, java.exe on PATH, CAMEL_FALLBACK_JAVA. 1>&2 +echo Install a Java %CAMEL_MIN_JAVA%+ runtime and set JAVA_HOME or JAVACMD, or add java.exe to PATH. 1>&2 if "%OS%"=="Windows_NT" endlocal +exit /B 1 -exit /B %ERROR_CODE% +:tryJava +@REM %1 = candidate path (quoted). On success sets CAMEL_JAVACMD. +set "_CAND=%~1" +if "%_CAND%"=="" exit /b 1 +if not exist "%_CAND%" exit /b 1 +for %%e in ("%_CAND%") do set "_CAND_EXT=%%~xe" +set "_PROBE_DIR=" +if defined TEMP set "_PROBE_DIR=%TEMP%" +if not defined _PROBE_DIR if defined TMP set "_PROBE_DIR=%TMP%" +if not defined _PROBE_DIR set "_PROBE_DIR=%BASEDIR%" + +:tryJavaProbePath +set "_PROBE=%_PROBE_DIR%\camel-java-probe-%RANDOM%-%RANDOM%.tmp" +if exist "%_PROBE%" goto tryJavaProbePath +if /i "%_CAND_EXT%"==".bat" goto tryJavaProbeBatch +if /i "%_CAND_EXT%"==".cmd" goto tryJavaProbeBatch +goto tryJavaProbeNative + +:tryJavaProbeBatch +call "%_CAND%" -version "%_PROBE%" 2>&1 +set "_PROBE_RC=%ERRORLEVEL%" +goto tryJavaProbeComplete + +:tryJavaProbeNative +"%_CAND%" -version "%_PROBE%" 2>&1 +set "_PROBE_RC=%ERRORLEVEL%" + +:tryJavaProbeComplete +if "%_PROBE_RC%"=="0" goto tryJavaParseProbe +del /q "%_PROBE%" >NUL 2>&1 +set "_PROBE=" +exit /b 1 + +:tryJavaParseProbe +set "_RAW=" +for /f "tokens=3" %%v in ('findstr /b /l /i /c:"java version " /c:"openjdk version " "%_PROBE%"') do if not defined _RAW set "_RAW=%%v" +del /q "%_PROBE%" >NUL 2>&1 +set "_PROBE=" +if not defined _RAW exit /b 1 +set "_RAW=%_RAW:"=%" +set "_MAJOR=" +for /f "tokens=1,2 delims=." %%a in ("%_RAW%") do ( + if "%%a"=="1" (set "_MAJOR=%%b") else (set "_MAJOR=%%a") +) +if not defined _MAJOR exit /b 1 +@REM Reject non-numeric majors (e.g. early-access "17-ea") so the numeric compare below is safe. +for /f "delims=0123456789" %%z in ("%_MAJOR%") do exit /b 1 +if %_MAJOR% LSS %CAMEL_MIN_JAVA% exit /b 1 +set "CAMEL_JAVACMD=%_CAND%" +exit /b 0 diff --git a/dsl/camel-jbang/camel-launcher/src/main/resources/bin/camel.sh b/dsl/camel-jbang/camel-launcher/src/main/resources/bin/camel.sh index 9e72387549bd8..430e7a0fc1a84 100644 --- a/dsl/camel-jbang/camel-launcher/src/main/resources/bin/camel.sh +++ b/dsl/camel-jbang/camel-launcher/src/main/resources/bin/camel.sh @@ -37,50 +37,61 @@ REPO= # OS specific support. $var _must_ be set to either true or false. cygwin=false; -darwin=false; case "`uname`" in CYGWIN*) cygwin=true ;; - Darwin*) darwin=true - if [ -z "$JAVA_VERSION" ] ; then - JAVA_VERSION="CurrentJDK" - else - echo "Using Java version: $JAVA_VERSION" - fi - if [ -z "$JAVA_HOME" ] ; then - JAVA_HOME=/System/Library/Frameworks/JavaVM.framework/Versions/${JAVA_VERSION}/Home - fi - ;; esac -if [ -z "$JAVA_HOME" ] ; then - if [ -r /etc/gentoo-release ] ; then - JAVA_HOME=`java-config --jre-home` - fi -fi - -# For Cygwin, ensure paths are in UNIX format before anything is touched +# For Cygwin, ensure JAVA_HOME is in UNIX format before it is probed. if $cygwin ; then [ -n "$JAVA_HOME" ] && JAVA_HOME=`cygpath --unix "$JAVA_HOME"` [ -n "$CLASSPATH" ] && CLASSPATH=`cygpath --path --unix "$CLASSPATH"` fi -# If a specific java binary isn't specified search for the standard 'java' binary -if [ -z "$JAVACMD" ] ; then - if [ -n "$JAVA_HOME" ] ; then - if [ -x "$JAVA_HOME/jre/sh/java" ] ; then - # IBM's JDK on AIX uses strange locations for the executables - JAVACMD="$JAVA_HOME/jre/sh/java" - else - JAVACMD="$JAVA_HOME/bin/java" - fi - else - JAVACMD=`which java` - fi -fi +# --- Shared Java 17+ discovery contract --- +# Candidates are tried in order: JAVACMD, $JAVA_HOME/bin/java, java on PATH, CAMEL_FALLBACK_JAVA. +# ($JAVA_HOME is also how SDKMAN's java candidate exposes its runtime.) +# Each must exist, be executable, and report a Java major version >= CAMEL_MIN_JAVA. +# Probe output is captured and never printed during a normal invocation. +CAMEL_MIN_JAVA=17 + +# Echo the Java major version of the launcher in $1, or nothing if it cannot be determined. +camel_java_major() { + _cjm_out=`"$1" -version &1` || return 1 + _cjm_ver=`echo "$_cjm_out" | sed -n 's/.*version "\([0-9][0-9.]*\).*/\1/p' | head -n 1` + [ -n "$_cjm_ver" ] || return 1 + case "$_cjm_ver" in + 1.*) echo "$_cjm_ver" | cut -d. -f2 ;; + *) echo "$_cjm_ver" | cut -d. -f1 ;; + esac +} + +# Return 0 and set JAVACMD when $1 is an existing, executable, Java >= CAMEL_MIN_JAVA launcher. +camel_try_java() { + [ -n "$1" ] || return 1 + [ -x "$1" ] || return 1 + _ctj_major=`camel_java_major "$1"` || return 1 + [ -n "$_ctj_major" ] || return 1 + { [ "$_ctj_major" -ge "$CAMEL_MIN_JAVA" ]; } 2>/dev/null || return 1 + JAVACMD="$1" + return 0 +} -if [ ! -x "$JAVACMD" ] ; then - echo "Error: JAVA_HOME is not defined correctly." 1>&2 - echo " We cannot execute $JAVACMD" 1>&2 +if camel_try_java "$JAVACMD"; then + : +elif [ -n "$JAVA_HOME" ] && camel_try_java "$JAVA_HOME/bin/java"; then + : +elif _camel_path_java=`command -v java 2>/dev/null` && camel_try_java "$_camel_path_java"; then + : +elif camel_try_java "$CAMEL_FALLBACK_JAVA"; then + : +else + echo "Error: no suitable Java runtime found. Camel CLI requires Java $CAMEL_MIN_JAVA or newer." 1>&2 + echo "Checked the following sources (in order):" 1>&2 + echo " 1. JAVACMD = ${JAVACMD:-}" 1>&2 + echo " 2. JAVA_HOME/bin/java = ${JAVA_HOME:+$JAVA_HOME/bin/java}" 1>&2 + echo " 3. java on PATH = ${_camel_path_java:-}" 1>&2 + echo " 4. CAMEL_FALLBACK_JAVA = ${CAMEL_FALLBACK_JAVA:-}" 1>&2 + echo "Install a Java $CAMEL_MIN_JAVA+ runtime and either add it to PATH, set JAVA_HOME, or set JAVACMD." 1>&2 exit 1 fi diff --git a/dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/CamelBatDiscoveryTest.java b/dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/CamelBatDiscoveryTest.java new file mode 100644 index 0000000000000..4243115762756 --- /dev/null +++ b/dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/CamelBatDiscoveryTest.java @@ -0,0 +1,564 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.camel.dsl.jbang.launcher; + +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.jar.Attributes; +import java.util.jar.JarEntry; +import java.util.jar.JarOutputStream; +import java.util.jar.Manifest; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledOnOs; +import org.junit.jupiter.api.condition.OS; +import org.junit.jupiter.api.io.TempDir; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Windows equivalent of {@link CamelShDiscoveryTest}: verifies camel.bat implements the same shared Java 17+ discovery + * contract with observably equivalent behavior. + */ +@EnabledOnOs(OS.WINDOWS) +class CamelBatDiscoveryTest { + + private static final Path SCRIPT = Paths.get("src/main/resources/bin/camel.bat"); + + private static final String JAVA17 = "openjdk version \"17.0.2\" 2022-01-18"; + private static final String JAVA21 = "openjdk version \"21.0.3\" 2024-04-16"; + private static final String JAVA11 = "openjdk version \"11.0.20\" 2023-07-18"; + private static final String JAVA8 = "java version \"1.8.0_202\""; + private static final String JAVA17_EA = "openjdk version \"17-ea\""; + private static final String GARBAGE = "totally not a java banner"; + + private Path home(Path base) throws Exception { + return FakeJava.newLauncherHome(base, SCRIPT); + } + + private Map isolatedEnvironment() { + String systemRoot = System.getenv("SystemRoot"); + assertTrue(systemRoot != null && !systemRoot.isBlank(), "Windows test fixture requires SystemRoot"); + Path system32 = Path.of(systemRoot, "System32"); + assertTrue(Files.isDirectory(system32), "Windows test fixture requires SystemRoot\\System32: " + system32); + Map env = new HashMap<>(); + env.put("PATH", system32.toString()); + return env; + } + + private Path currentJavaHome() { + Path javaHome = Path.of(System.getProperty("java.home")); + assertTrue(Files.isRegularFile(javaHome.resolve("bin/java.exe")), + "Windows test fixture requires the current JDK java.exe: " + javaHome); + return javaHome; + } + + @Test + void acceptsJava17ViaJavacmd(@TempDir Path base) throws Exception { + Path h = home(base); + Path java = FakeJava.writeFakeJava(base, "java", JAVA17, 0, "JAVACMD"); + Map env = isolatedEnvironment(); + env.put("JAVACMD", java.toString()); + + FakeJava.Result r = FakeJava.run(h.resolve("camel.bat"), env, "version"); + + assertEquals(0, r.exitCode(), r.stderr()); + assertTrue(r.stdout().contains("RAN=JAVACMD"), r.stdout()); + assertFalse(r.stdout().contains("openjdk version"), "probe banner leaked to stdout"); + assertFalse(r.stderr().contains("openjdk version"), "probe banner leaked to stderr"); + } + + @Test + void acceptsJava21ViaJavacmd(@TempDir Path base) throws Exception { + Path h = home(base); + Path java = FakeJava.writeFakeJava(base, "java", JAVA21, 0, "JAVA21"); + Map env = isolatedEnvironment(); + env.put("JAVACMD", java.toString()); + + FakeJava.Result r = FakeJava.run(h.resolve("camel.bat"), env, "version"); + + assertEquals(0, r.exitCode(), r.stderr()); + assertTrue(r.stdout().contains("RAN=JAVA21"), r.stdout()); + } + + @Test + void precedenceJavacmdBeatsJavaHome(@TempDir Path base) throws Exception { + Path h = home(base); + Path java = FakeJava.writeFakeJava(base, "java", JAVA17, 0, "JAVACMD-FIRST"); + Map env = isolatedEnvironment(); + env.put("JAVACMD", java.toString()); + env.put("JAVA_HOME", currentJavaHome().toString()); + + FakeJava.Result r = FakeJava.run(h.resolve("camel.bat"), env, "version"); + + assertEquals(0, r.exitCode(), r.stderr()); + assertTrue(r.stdout().contains("RAN=JAVACMD-FIRST"), r.stdout()); + assertFalse(r.stdout().contains("RAN=NATIVE-JAVA"), r.stdout()); + } + + @Test + void candidateSourceOrderIsJavacmdJavaHomePathThenFallback(@TempDir Path base) throws Exception { + Path h = home(base); + String launcher = Files.readString(h.resolve("camel.bat"), StandardCharsets.UTF_8); + List expected = expectedDiscoveryLines(); + + assertEquals(expected, executableDiscoveryLines(launcher), + "active discovery order must be JAVACMD, JAVA_HOME, PATH java.exe, CAMEL_FALLBACK_JAVA"); + + String commentedCandidates = String.join("\n", expected.stream().map(line -> "REM " + line).toList()); + String commentedDecoy = "set \"CAMEL_JAVACMD=\"\n" + commentedCandidates + "\n" + + ":haveJava\n" + + String.join("\n", expected); + assertFalse(expected.equals(executableDiscoveryLines(commentedDecoy)), + "commented or out-of-region candidate text must not satisfy the active discovery contract"); + } + + @Test + void honorsJavaHomeWhenOnlySource(@TempDir Path base) throws Exception { + Path h = home(base); + writeRealJavaMarkerJar(h); + Map env = isolatedEnvironment(); + env.put("JAVA_HOME", currentJavaHome().toString()); + + FakeJava.Result r = FakeJava.run(h.resolve("camel.bat"), env, "version"); + + assertEquals(0, r.exitCode(), r.stderr()); + assertTrue(r.stdout().contains("RAN=NATIVE-JAVA"), r.stdout()); + assertTrue(r.stdout().contains("version"), r.stdout()); + } + + @Test + void missingJavacmdContinuesToJavaHome(@TempDir Path base) throws Exception { + Path h = home(base); + writeRealJavaMarkerJar(h); + Map env = isolatedEnvironment(); + env.put("JAVACMD", base.resolve("missing-java.bat").toString()); + env.put("JAVA_HOME", currentJavaHome().toString()); + + FakeJava.Result r = FakeJava.run(h.resolve("camel.bat"), env, "version"); + + assertEquals(0, r.exitCode(), r.stderr()); + assertTrue(r.stdout().contains("RAN=NATIVE-JAVA"), r.stdout()); + } + + @Test + void presentButNonRunnableJavacmdContinuesToJavaHome(@TempDir Path base) throws Exception { + Path h = home(base); + writeRealJavaMarkerJar(h); + Path nonRunnable = base.resolve("not-a-program.exe"); + Files.writeString(nonRunnable, "not a Windows executable", StandardCharsets.UTF_8); + Map env = isolatedEnvironment(); + env.put("JAVACMD", nonRunnable.toString()); + env.put("JAVA_HOME", currentJavaHome().toString()); + + FakeJava.Result r = FakeJava.run(h.resolve("camel.bat"), env, "version"); + + assertEquals(0, r.exitCode(), r.stderr()); + assertTrue(r.stdout().contains("RAN=NATIVE-JAVA"), r.stdout()); + } + + @Test + void acceptsNativeJavaFromPath(@TempDir Path base) throws Exception { + Path h = home(base); + writeRealJavaMarkerJar(h); + Map env = isolatedEnvironment(); + env.put("PATH", currentJavaHome().resolve("bin") + ";" + env.get("PATH")); + + FakeJava.Result r = FakeJava.run(h.resolve("camel.bat"), env, "version"); + + assertEquals(0, r.exitCode(), r.stderr()); + assertTrue(r.stdout().contains("RAN=NATIVE-JAVA"), r.stdout()); + } + + @Test + void precedenceNativePathBeatsCamelFallbackJava(@TempDir Path base) throws Exception { + Path h = home(base); + writeRealJavaMarkerJar(h); + Path fallback = FakeJava.writeFakeJava(base.resolve("fallback"), "java", JAVA17, 0, "FALLBACK"); + Map env = isolatedEnvironment(); + env.put("PATH", currentJavaHome().resolve("bin") + ";" + env.get("PATH")); + env.put("CAMEL_FALLBACK_JAVA", fallback.toString()); + + FakeJava.Result r = FakeJava.run(h.resolve("camel.bat"), env, "version"); + + assertEquals(0, r.exitCode(), r.stderr()); + assertTrue(r.stdout().contains("RAN=NATIVE-JAVA"), r.stdout()); + assertFalse(r.stdout().contains("RAN=FALLBACK"), r.stdout()); + } + + @Test + void skipsOldJavacmdFallsBackToFallback(@TempDir Path base) throws Exception { + Path h = home(base); + Path old = FakeJava.writeFakeJava(base.resolve("old"), "java", JAVA11, 0, "OLD"); + Path fallback = FakeJava.writeFakeJava(base.resolve("fallback"), "java", JAVA17, 0, "FALLBACK"); + Map env = isolatedEnvironment(); + env.put("JAVACMD", old.toString()); + env.put("JAVA_HOME", base.resolve("invalid-java-home").toString()); + env.put("CAMEL_FALLBACK_JAVA", fallback.toString()); + + FakeJava.Result r = FakeJava.run(h.resolve("camel.bat"), env, "version"); + + assertEquals(0, r.exitCode(), r.stderr()); + assertTrue(r.stdout().contains("RAN=FALLBACK"), r.stdout()); + assertFalse(r.stdout().contains("RAN=OLD"), r.stdout()); + } + + @Test + void rejectsWhenNoCandidateQualifies(@TempDir Path base) throws Exception { + Path h = home(base); + Path old = FakeJava.writeFakeJava(base.resolve("old"), "java", JAVA8, 0, "OLD"); + Map env = isolatedEnvironment(); + env.put("JAVACMD", old.toString()); + + FakeJava.Result r = FakeJava.run(h.resolve("camel.bat"), env, "version"); + + assertEquals(1, r.exitCode()); + assertTrue(r.stderr().contains("17"), "diagnostic must state the Java 17 minimum: " + r.stderr()); + assertTrue(r.stderr().toLowerCase().contains("java"), r.stderr()); + } + + @Test + void rejectsUnparseableCandidate(@TempDir Path base) throws Exception { + Path h = home(base); + Path garbage = FakeJava.writeFakeJava(base.resolve("garbage"), "java", GARBAGE, 0, "GARBAGE"); + Map env = isolatedEnvironment(); + env.put("JAVACMD", garbage.toString()); + + FakeJava.Result r = FakeJava.run(h.resolve("camel.bat"), env, "version"); + + assertEquals(1, r.exitCode()); + } + + @Test + void rejectsNonnumericMajorAndContinuesToFallback(@TempDir Path base) throws Exception { + Path h = home(base); + Path earlyAccess = FakeJava.writeFakeJava(base.resolve("early-access"), "java", JAVA17_EA, 0, "EARLY"); + Path fallback = FakeJava.writeFakeJava(base.resolve("fallback"), "java", JAVA17, 0, "FALLBACK"); + Map env = isolatedEnvironment(); + env.put("JAVACMD", earlyAccess.toString()); + env.put("CAMEL_FALLBACK_JAVA", fallback.toString()); + + FakeJava.Result r = FakeJava.run(h.resolve("camel.bat"), env, "version"); + + assertEquals(0, r.exitCode(), r.stderr()); + assertTrue(r.stdout().contains("RAN=FALLBACK"), r.stdout()); + assertFalse(r.stdout().contains("RAN=EARLY"), r.stdout()); + } + + @Test + void handlesSpacesAndUnicodeInJavaPath(@TempDir Path base) throws Exception { + Path h = home(base); + Path java = FakeJava.writeFakeJava(base.resolve("spa ce/über"), "java", JAVA17, 0, "UNICODE"); + Map env = isolatedEnvironment(); + env.put("JAVACMD", java.toString()); + + FakeJava.Result r = FakeJava.run(h.resolve("camel.bat"), env, "version"); + + assertEquals(0, r.exitCode(), r.stderr()); + assertTrue(r.stdout().contains("RAN=UNICODE"), r.stdout()); + } + + @Test + void defaultsHeapWhenJavaOptsUnset(@TempDir Path base) throws Exception { + Path h = home(base); + Path java = FakeJava.writeFakeJava(base, "java", JAVA17, 0, "HEAP"); + Map env = isolatedEnvironment(); + env.put("JAVACMD", java.toString()); + + FakeJava.Result r = FakeJava.run(h.resolve("camel.bat"), env, "version"); + + assertEquals(0, r.exitCode(), r.stderr()); + assertTrue(r.stdout().contains("-Xmx512m"), "default heap must be applied: " + r.stdout()); + } + + @Test + void preservesJavaOptsWhenSet(@TempDir Path base) throws Exception { + Path h = home(base); + Path java = FakeJava.writeFakeJava(base, "java", JAVA17, 0, "OPTS"); + Map env = isolatedEnvironment(); + env.put("JAVACMD", java.toString()); + env.put("JAVA_OPTS", "-Dcamel.test=1 -Xmx256m"); + + FakeJava.Result r = FakeJava.run(h.resolve("camel.bat"), env, "version"); + + assertEquals(0, r.exitCode(), r.stderr()); + assertTrue(r.stdout().contains("-Dcamel.test=1"), r.stdout()); + assertTrue(r.stdout().contains("-Xmx256m"), r.stdout()); + assertFalse(r.stdout().contains("-Xmx512m"), "default heap must not be added when JAVA_OPTS is set"); + } + + @Test + void javacmdBatchWrapperReturnsForProbeAndLaunchAndPreservesBehavior(@TempDir Path base) throws Exception { + Path h = home(base); + Path wrapper = writeBatch(base.resolve("wrapper"), "java", + "if \"%~1\"==\"-version\" (", + " echo " + JAVA17 + " 1>&2", + " exit /b 0", + ")", + "set ARG_COUNT=0", + "for %%a in (%*) do set /a ARG_COUNT+=1 >NUL", + "echo ARG_COUNT=%ARG_COUNT%", + "echo ARG1=[%~1]", + "echo ARG2=[%~2]", + "echo ARG3=[%~3]", + "echo ARG4=[%~4]", + "echo ARG5=[%~5]", + "echo ARG6=[%~6]", + "echo STDERR=JAVACMD-WRAPPER 1>&2", + "more", + "exit /b 37"); + Map env = isolatedEnvironment(); + env.put("JAVACMD", wrapper.toString()); + + FakeJava.Result r = FakeJava.runWithInput(h.resolve("camel.bat"), env, "route input\r\n", "run", + "my route.yaml"); + + assertEquals(37, r.exitCode(), "batch child exit code must be propagated"); + assertTrue(r.stdout().contains("ARG_COUNT=5"), r.stdout()); + assertTrue(r.stdout().contains("ARG1=[-Xmx512m]"), r.stdout()); + assertTrue(r.stdout().contains("ARG2=[-jar]"), r.stdout()); + assertTrue(r.stdout().contains("ARG3=[" + h.resolve("camel-launcher-9.9.9.jar") + "]"), r.stdout()); + assertTrue(r.stdout().contains("ARG4=[run]"), r.stdout()); + assertTrue(r.stdout().contains("ARG5=[my route.yaml]"), r.stdout()); + assertTrue(r.stdout().contains("ARG6=[]"), r.stdout()); + assertTrue(r.stdout().contains("route input"), "stdin did not reach batch child stdout: " + r.stdout()); + assertTrue(r.stderr().contains("STDERR=JAVACMD-WRAPPER"), "batch child stderr was not preserved: " + r.stderr()); + } + + @Test + void finalBatchWrapperReturnsToLauncherStatementAfterCall(@TempDir Path base) throws Exception { + Path sentinel = base.resolve("launcher-resumed.txt"); + Path h = instrumentBatchLaunchReturn(home(base), sentinel); + Path wrapper = FakeJava.writeFakeJava(base.resolve("wrapper"), "java", JAVA17, 41, "RETURN"); + Map env = isolatedEnvironment(); + env.put("JAVACMD", wrapper.toString()); + + FakeJava.Result r = FakeJava.run(h.resolve("camel.bat"), env, "version"); + + assertEquals(41, r.exitCode(), "captured batch child exit code must survive sentinel instrumentation"); + assertEquals("LAUNCHER-RESUMED", Files.readString(sentinel).trim(), + "launcher did not resume after the final batch wrapper call"); + } + + @Test + void fallbackBatchWrapperIsReachedAfterInvalidCandidatesAndPropagatesExit(@TempDir Path base) throws Exception { + Path h = home(base); + Path old = FakeJava.writeFakeJava(base.resolve("old"), "java", JAVA11, 0, "OLD"); + Path fallback = FakeJava.writeFakeJava(base.resolve("fallback"), "java", JAVA17, 29, "FALLBACK-WRAPPER"); + Map env = isolatedEnvironment(); + env.put("JAVACMD", old.toString()); + env.put("JAVA_HOME", base.resolve("missing-home").toString()); + env.put("CAMEL_FALLBACK_JAVA", fallback.toString()); + + FakeJava.Result r = FakeJava.run(h.resolve("camel.bat"), env, "version"); + + assertEquals(29, r.exitCode(), "fallback batch child exit code must be propagated"); + assertTrue(r.stdout().contains("RAN=FALLBACK-WRAPPER"), r.stdout()); + } + + @Test + void baseDirProbeFallbackCleansArtifactWhenTempAndTmpAreAbsent(@TempDir Path base) throws Exception { + Path h = home(base); + Path probePathMarker = base.resolve("probe-path.txt"); + Path java = writeBatch(base.resolve("wrapper"), "java", + "if \"%~1\"==\"-version\" (", + " echo %_PROBE% >\"" + probePathMarker + "\"", + " echo " + JAVA17 + " 1>&2", + " exit /b 0", + ")", + "echo RAN=BASEDIR-PROBE", + "exit /b 0"); + Map env = isolatedEnvironment(); + env.put("JAVACMD", java.toString()); + assertFalse(env.containsKey("TEMP")); + assertFalse(env.containsKey("TMP")); + + FakeJava.Result r = FakeJava.run(h.resolve("camel.bat"), env, "version"); + + assertEquals(0, r.exitCode(), r.stderr()); + Path actualProbe = Path.of(Files.readString(probePathMarker).trim()).toAbsolutePath().normalize(); + assertEquals(h.toAbsolutePath().normalize(), actualProbe.getParent(), "probe did not use BASEDIR fallback"); + try (var probes = Files.list(h)) { + assertFalse(probes.anyMatch(path -> path.getFileName().toString().startsWith("camel-java-probe-") + && path.getFileName().toString().endsWith(".tmp")), "probe artifact remained in BASEDIR"); + } + } + + @Test + void skipsValidLookingBannerWhenProbeExitsNonzero(@TempDir Path base) throws Exception { + Path h = home(base); + Path rejected = writeBatch(base.resolve("rejected"), "java", + "if \"%~1\"==\"-version\" (", + " echo " + JAVA17 + " 1>&2", + " exit /b 7", + ")", + "echo RAN=REJECTED"); + Path fallback = FakeJava.writeFakeJava(base.resolve("fallback"), "java", JAVA17, 0, "FALLBACK"); + Map env = isolatedEnvironment(); + env.put("JAVACMD", rejected.toString()); + env.put("CAMEL_FALLBACK_JAVA", fallback.toString()); + + FakeJava.Result r = FakeJava.run(h.resolve("camel.bat"), env, "version"); + + assertEquals(0, r.exitCode(), r.stderr()); + assertTrue(r.stdout().contains("RAN=FALLBACK"), r.stdout()); + assertFalse(r.stdout().contains("RAN=REJECTED"), r.stdout()); + } + + @Test + void acceptsValidBannerAfterPreBannerLineContainingVersion(@TempDir Path base) throws Exception { + Path h = home(base); + Path java = writeBatch(base.resolve("banner"), "java", + "if \"%~1\"==\"-version\" (", + " echo wrapper version metadata 1>&2", + " echo " + JAVA17 + " 1>&2", + " exit /b 0", + ")", + "echo RAN=VALID-BANNER", + "exit /b 0"); + Map env = isolatedEnvironment(); + env.put("JAVACMD", java.toString()); + + FakeJava.Result r = FakeJava.run(h.resolve("camel.bat"), env, "version"); + + assertEquals(0, r.exitCode(), r.stderr()); + assertTrue(r.stdout().contains("RAN=VALID-BANNER"), r.stdout()); + assertFalse(r.stdout().contains("wrapper version metadata"), "probe output leaked: " + r.stdout()); + assertFalse(r.stderr().contains("wrapper version metadata"), "probe output leaked: " + r.stderr()); + } + + @Test + void probeCannotConsumeChildStdin(@TempDir Path base) throws Exception { + Path h = home(base); + Path java = writeBatch(base.resolve("stdin"), "java", + "if \"%~1\"==\"-version\" (", + " set /p \"ignored=\"", + " echo " + JAVA17 + " 1>&2", + " exit /b 0", + ")", + "echo RAN=PROBE-STDIN", + "more", + "exit /b 0"); + Map env = isolatedEnvironment(); + env.put("JAVACMD", java.toString()); + + FakeJava.Result r = FakeJava.runWithInput(h.resolve("camel.bat"), env, "route input\r\n", "version"); + + assertEquals(0, r.exitCode(), r.stderr()); + assertTrue(r.stdout().contains("RAN=PROBE-STDIN"), r.stdout()); + assertTrue(r.stdout().contains("route input"), "version probe consumed child stdin: " + r.stdout()); + } + + private Path writeBatch(Path dir, String name, String... lines) throws Exception { + Files.createDirectories(dir); + Path script = dir.resolve(name + ".bat"); + Files.writeString(script, "@echo off\r\n" + String.join("\r\n", lines) + "\r\n", StandardCharsets.UTF_8); + return script; + } + + private List expectedDiscoveryLines() { + return List.of( + "call :tryJava \"%JAVACMD%\"", + "if defined CAMEL_JAVACMD goto haveJava", + "if defined JAVA_HOME call :tryJava \"%JAVA_HOME%\\bin\\java.exe\"", + "if defined CAMEL_JAVACMD goto haveJava", + "for %%p in (java.exe) do call :tryJava \"%%~$PATH:p\"", + "if defined CAMEL_JAVACMD goto haveJava", + "call :tryJava \"%CAMEL_FALLBACK_JAVA%\"", + "if defined CAMEL_JAVACMD goto haveJava", + "goto noJava"); + } + + private List executableDiscoveryLines(String source) { + List lines = source.lines() + .map(String::trim) + .map(line -> line.replaceAll("\\s+", " ")) + .toList(); + assertEquals(1, lines.stream().filter("set \"CAMEL_JAVACMD=\""::equals).count(), + "active discovery start must be unique"); + assertEquals(1, lines.stream().filter(":haveJava"::equals).count(), + "active discovery end must be unique"); + int start = lines.indexOf("set \"CAMEL_JAVACMD=\""); + int end = lines.indexOf(":haveJava"); + assertTrue(start >= 0, "active discovery start was not found"); + assertTrue(end > start, "active discovery end was not found after its start"); + return lines.subList(start + 1, end).stream() + .filter(line -> !line.isBlank()) + .filter(line -> !isBatchComment(line)) + .toList(); + } + + private boolean isBatchComment(String line) { + String command = line.startsWith("@") ? line.substring(1).stripLeading() : line; + return command.equalsIgnoreCase("REM") + || command.regionMatches(true, 0, "REM ", 0, 4) + || command.startsWith("::"); + } + + private Path instrumentBatchLaunchReturn(Path launcherHome, Path sentinel) throws Exception { + Path launcher = launcherHome.resolve("camel.bat"); + String source = Files.readString(launcher, StandardCharsets.UTF_8); + String newline = source.contains("\r\n") ? "\r\n" : "\n"; + String launchBlock = "call \"%JAVACMD%\" %JAVA_OPTS% -jar \"%LAUNCHER_JAR%\" %*" + newline + + "set ERROR_CODE=%ERRORLEVEL%" + newline + + "goto javaExecuted"; + String instrumentedBlock = "call \"%JAVACMD%\" %JAVA_OPTS% -jar \"%LAUNCHER_JAR%\" %*" + newline + + "set ERROR_CODE=%ERRORLEVEL%" + newline + + "echo LAUNCHER-RESUMED>\"" + sentinel + "\"" + newline + + "goto javaExecuted"; + String instrumented = source.replace(launchBlock, instrumentedBlock); + assertFalse(instrumented.equals(source), "could not instrument copied batch-launch block"); + Files.writeString(launcher, instrumented, StandardCharsets.UTF_8); + return launcherHome; + } + + private void writeRealJavaMarkerJar(Path launcherHome) throws Exception { + Manifest manifest = new Manifest(); + manifest.getMainAttributes().put(Attributes.Name.MANIFEST_VERSION, "1.0"); + manifest.getMainAttributes().put(Attributes.Name.MAIN_CLASS, CamelBatRealJavaMarker.class.getName()); + String entryName = CamelBatRealJavaMarker.class.getName().replace('.', '/') + ".class"; + Path jar = launcherHome.resolve("camel-launcher-9.9.9.jar"); + try (InputStream classBytes = CamelBatRealJavaMarker.class.getResourceAsStream("/" + entryName); + JarOutputStream out = new JarOutputStream(Files.newOutputStream(jar), manifest)) { + assertTrue(classBytes != null, "could not load marker class bytes"); + out.putNextEntry(new JarEntry(entryName)); + classBytes.transferTo(out); + out.closeEntry(); + } + } + +} + +final class CamelBatRealJavaMarker { + + private CamelBatRealJavaMarker() { + } + + public static void main(String[] args) { + System.out.println("RAN=NATIVE-JAVA"); + System.out.println("ARGS=" + Arrays.toString(args)); + } +} diff --git a/dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/CamelShDiscoveryTest.java b/dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/CamelShDiscoveryTest.java new file mode 100644 index 0000000000000..ad2735ed362ce --- /dev/null +++ b/dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/CamelShDiscoveryTest.java @@ -0,0 +1,404 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.camel.dsl.jbang.launcher; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.HashMap; +import java.util.Map; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.DisabledOnOs; +import org.junit.jupiter.api.condition.OS; +import org.junit.jupiter.api.io.TempDir; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Verifies the shared Java 17+ discovery contract implemented in camel.sh. POSIX only; the Windows batch equivalent + * lives in {@link CamelBatDiscoveryTest}. + */ +@DisabledOnOs(OS.WINDOWS) +class CamelShDiscoveryTest { + + private static final Path SCRIPT = Paths.get("src/main/resources/bin/camel.sh"); + + private static final String JAVA17 = "openjdk version \"17.0.2\" 2022-01-18"; + private static final String JAVA21 = "openjdk version \"21.0.3\" 2024-04-16"; + private static final String JAVA11 = "openjdk version \"11.0.20\" 2023-07-18"; + private static final String JAVA8 = "java version \"1.8.0_202\""; + private static final String GARBAGE = "totally not a java banner"; + + private Path home(Path base) throws Exception { + return FakeJava.newLauncherHome(base, SCRIPT); + } + + private String pathWithNonqualifyingJava(Path base) throws Exception { + Path pathDir = base.resolve("path-shadow"); + FakeJava.writeFakeJava(pathDir, "java", GARBAGE, 0, "PATH-REJECTED"); + return pathDir + ":/usr/bin:/bin"; + } + + @Test + void acceptsJava17ViaJavacmd(@TempDir Path base) throws Exception { + Path h = home(base); + Path java = FakeJava.writeFakeJava(base, "java", JAVA17, 0, "JAVACMD"); + Map env = new HashMap<>(); + env.put("JAVACMD", java.toString()); + + FakeJava.Result r = FakeJava.run(h.resolve("camel.sh"), env, "version"); + + assertEquals(0, r.exitCode(), r.stderr()); + assertTrue(r.stdout().contains("RAN=JAVACMD"), r.stdout()); + // Probe output must not leak to the user during a normal invocation. + assertFalse(r.stdout().contains("openjdk version"), "probe banner leaked to stdout"); + assertFalse(r.stderr().contains("openjdk version"), "probe banner leaked to stderr"); + } + + @Test + void acceptsJava21(@TempDir Path base) throws Exception { + Path h = home(base); + Path java = FakeJava.writeFakeJava(base, "java", JAVA21, 0, "JAVACMD"); + Map env = new HashMap<>(); + env.put("JAVACMD", java.toString()); + + FakeJava.Result r = FakeJava.run(h.resolve("camel.sh"), env, "version"); + + assertEquals(0, r.exitCode(), r.stderr()); + assertTrue(r.stdout().contains("RAN=JAVACMD")); + } + + @Test + void precedenceJavacmdBeatsJavaHomeAndPath(@TempDir Path base) throws Exception { + Path h = home(base); + Path first = FakeJava.writeFakeJava(base.resolve("j1"), "java", JAVA17, 0, "JAVACMD"); + Path jhomeBin = base.resolve("jh/bin"); + FakeJava.writeFakeJava(jhomeBin, "java", JAVA21, 0, "JAVAHOME"); + Path pathDir = base.resolve("pd"); + FakeJava.writeFakeJava(pathDir, "java", JAVA21, 0, "PATH"); + + Map env = new HashMap<>(); + env.put("JAVACMD", first.toString()); + env.put("JAVA_HOME", base.resolve("jh").toString()); + env.put("PATH", pathDir + ":/usr/bin:/bin"); + + FakeJava.Result r = FakeJava.run(h.resolve("camel.sh"), env, "version"); + + assertEquals(0, r.exitCode(), r.stderr()); + assertTrue(r.stdout().contains("RAN=JAVACMD"), r.stdout()); + } + + @Test + void honorsJavaHomeWhenOnlySourceLikeSdkman(@TempDir Path base) throws Exception { + // SDKMAN provides Java by exporting JAVA_HOME (candidate #2) with no JAVACMD set. + Path h = home(base); + Path jhomeBin = base.resolve("jh/bin"); + FakeJava.writeFakeJava(jhomeBin, "java", JAVA17, 0, "JAVAHOME"); + + Map env = new HashMap<>(); + env.put("JAVA_HOME", base.resolve("jh").toString()); + env.put("PATH", pathWithNonqualifyingJava(base)); + + FakeJava.Result r = FakeJava.run(h.resolve("camel.sh"), env, "version"); + + assertEquals(0, r.exitCode(), r.stderr()); + assertTrue(r.stdout().contains("RAN=JAVAHOME"), r.stdout()); + } + + @Test + void skipsInvalidJavacmdFallsBackToJavaHome(@TempDir Path base) throws Exception { + Path h = home(base); + Path jhomeBin = base.resolve("jh/bin"); + FakeJava.writeFakeJava(jhomeBin, "java", JAVA17, 0, "JAVAHOME"); + + Map env = new HashMap<>(); + env.put("JAVACMD", base.resolve("does-not-exist").toString()); + env.put("JAVA_HOME", base.resolve("jh").toString()); + + FakeJava.Result r = FakeJava.run(h.resolve("camel.sh"), env, "version"); + + assertEquals(0, r.exitCode(), r.stderr()); + assertTrue(r.stdout().contains("RAN=JAVAHOME"), r.stdout()); + } + + @Test + void skipsNonExecutableJavacmdFallsBackToJavaHome(@TempDir Path base) throws Exception { + Path h = home(base); + Path nonExecutable = base.resolve("non-executable-java"); + Files.writeString(nonExecutable, "#!/bin/sh\nexit 0\n"); + assertFalse(Files.isExecutable(nonExecutable), "test candidate must not be executable"); + Path jhomeBin = base.resolve("jh/bin"); + FakeJava.writeFakeJava(jhomeBin, "java", JAVA17, 0, "JAVAHOME"); + + Map env = new HashMap<>(); + env.put("JAVACMD", nonExecutable.toString()); + env.put("JAVA_HOME", base.resolve("jh").toString()); + + FakeJava.Result r = FakeJava.run(h.resolve("camel.sh"), env, "version"); + + assertEquals(0, r.exitCode(), r.stderr()); + assertTrue(r.stdout().contains("RAN=JAVAHOME"), r.stdout()); + } + + @Test + void skipsOldJavaHomeFallsBackToPath(@TempDir Path base) throws Exception { + Path h = home(base); + Path jhomeBin = base.resolve("jh/bin"); + FakeJava.writeFakeJava(jhomeBin, "java", JAVA11, 0, "JAVAHOME"); // too old + Path pathDir = base.resolve("pd"); + FakeJava.writeFakeJava(pathDir, "java", JAVA17, 0, "PATH"); + + Map env = new HashMap<>(); + env.put("JAVA_HOME", base.resolve("jh").toString()); + env.put("PATH", pathDir + ":/usr/bin:/bin"); + + FakeJava.Result r = FakeJava.run(h.resolve("camel.sh"), env, "version"); + + assertEquals(0, r.exitCode(), r.stderr()); + assertTrue(r.stdout().contains("RAN=PATH"), r.stdout()); + assertFalse(r.stdout().contains(JAVA11), "rejected probe banner leaked to stdout"); + assertFalse(r.stderr().contains(JAVA11), "rejected probe banner leaked to stderr"); + } + + @Test + void precedenceJavaHomeBeatsPath(@TempDir Path base) throws Exception { + Path h = home(base); + Path jhomeBin = base.resolve("jh/bin"); + FakeJava.writeFakeJava(jhomeBin, "java", JAVA17, 0, "JAVAHOME"); + Path pathDir = base.resolve("pd"); + FakeJava.writeFakeJava(pathDir, "java", JAVA21, 0, "PATH"); + + Map env = new HashMap<>(); + env.put("JAVA_HOME", base.resolve("jh").toString()); + env.put("PATH", pathDir + ":/usr/bin:/bin"); + + FakeJava.Result r = FakeJava.run(h.resolve("camel.sh"), env, "version"); + + assertEquals(0, r.exitCode(), r.stderr()); + assertTrue(r.stdout().contains("RAN=JAVAHOME"), r.stdout()); + } + + @Test + void precedencePathBeatsCamelFallbackJava(@TempDir Path base) throws Exception { + Path h = home(base); + Path pathDir = base.resolve("pd"); + FakeJava.writeFakeJava(pathDir, "java", JAVA17, 0, "PATH"); + Path fallback = FakeJava.writeFakeJava(base.resolve("fb"), "java", JAVA21, 0, "FALLBACK"); + + Map env = new HashMap<>(); + env.put("PATH", pathDir + ":/usr/bin:/bin"); + env.put("CAMEL_FALLBACK_JAVA", fallback.toString()); + + FakeJava.Result r = FakeJava.run(h.resolve("camel.sh"), env, "version"); + + assertEquals(0, r.exitCode(), r.stderr()); + assertTrue(r.stdout().contains("RAN=PATH"), r.stdout()); + } + + @Test + void usesCamelFallbackJavaWhenNothingElseQualifies(@TempDir Path base) throws Exception { + Path h = home(base); + Path fb = FakeJava.writeFakeJava(base.resolve("fb"), "java", JAVA17, 0, "FALLBACK"); + + Map env = new HashMap<>(); + env.put("PATH", pathWithNonqualifyingJava(base)); + env.put("CAMEL_FALLBACK_JAVA", fb.toString()); + + FakeJava.Result r = FakeJava.run(h.resolve("camel.sh"), env, "version"); + + assertEquals(0, r.exitCode(), r.stderr()); + assertTrue(r.stdout().contains("RAN=FALLBACK"), r.stdout()); + } + + @Test + void rejectsWhenNoCandidateQualifies(@TempDir Path base) throws Exception { + Path h = home(base); + Path old = FakeJava.writeFakeJava(base.resolve("old"), "java", JAVA8, 0, "OLD"); + + Map env = new HashMap<>(); + env.put("JAVACMD", old.toString()); + env.put("PATH", pathWithNonqualifyingJava(base)); + + FakeJava.Result r = FakeJava.run(h.resolve("camel.sh"), env, "version"); + + assertEquals(1, r.exitCode()); + assertTrue(r.stderr().contains("17"), "diagnostic must state the Java 17 minimum: " + r.stderr()); + assertTrue(r.stderr().toLowerCase().contains("java"), r.stderr()); + } + + @Test + void rejectsUnparseableCandidate(@TempDir Path base) throws Exception { + Path h = home(base); + Path garbage = FakeJava.writeFakeJava(base.resolve("g"), "java", GARBAGE, 0, "GARBAGE"); + + Map env = new HashMap<>(); + env.put("JAVACMD", garbage.toString()); + env.put("PATH", pathWithNonqualifyingJava(base)); + + FakeJava.Result r = FakeJava.run(h.resolve("camel.sh"), env, "version"); + + assertEquals(1, r.exitCode()); + } + + @Test + void skipsUnparseableJavacmdFallsBackToJavaHome(@TempDir Path base) throws Exception { + Path h = home(base); + Path garbage = FakeJava.writeFakeJava(base.resolve("g"), "java", GARBAGE, 0, "GARBAGE"); + Path jhomeBin = base.resolve("jh/bin"); + FakeJava.writeFakeJava(jhomeBin, "java", JAVA17, 0, "JAVAHOME"); + + Map env = new HashMap<>(); + env.put("JAVACMD", garbage.toString()); + env.put("JAVA_HOME", base.resolve("jh").toString()); + + FakeJava.Result r = FakeJava.run(h.resolve("camel.sh"), env, "version"); + + assertEquals(0, r.exitCode(), r.stderr()); + assertTrue(r.stdout().contains("RAN=JAVAHOME"), r.stdout()); + assertFalse(r.stdout().contains(GARBAGE), "rejected probe banner leaked to stdout"); + assertFalse(r.stderr().contains(GARBAGE), "rejected probe banner leaked to stderr"); + } + + @Test + void parsesLegacyOneDotEightAsMajorEight(@TempDir Path base) throws Exception { + // 1.8.0_202 must resolve to major 8 and therefore be rejected. + Path h = home(base); + Path java8 = FakeJava.writeFakeJava(base.resolve("j8"), "java", JAVA8, 0, "J8"); + + Map env = new HashMap<>(); + env.put("JAVACMD", java8.toString()); + env.put("PATH", pathWithNonqualifyingJava(base)); + + FakeJava.Result r = FakeJava.run(h.resolve("camel.sh"), env, "version"); + + assertEquals(1, r.exitCode(), "Java 8 must be rejected: " + r.stdout()); + } + + @Test + void handlesSpacesAndUnicodeInJavaPath(@TempDir Path base) throws Exception { + Path h = home(base); + Path dir = base.resolve("spa ce/über"); + Path java = FakeJava.writeFakeJava(dir, "java", JAVA17, 0, "UNICODE"); + + Map env = new HashMap<>(); + env.put("JAVACMD", java.toString()); + + FakeJava.Result r = FakeJava.run(h.resolve("camel.sh"), env, "version"); + + assertEquals(0, r.exitCode(), r.stderr()); + assertTrue(r.stdout().contains("RAN=UNICODE"), r.stdout()); + } + + @Test + void preservesArgumentsWithSpaces(@TempDir Path base) throws Exception { + Path h = home(base); + Path java = FakeJava.writeFakeJava(base, "java", JAVA17, 0, "ARGS"); + Map env = new HashMap<>(); + env.put("JAVACMD", java.toString()); + + FakeJava.Result r = FakeJava.run(h.resolve("camel.sh"), env, "run", "my route.yaml"); + + assertEquals(0, r.exitCode(), r.stderr()); + assertTrue(r.stdout().contains("|run|my route.yaml|"), r.stdout()); + } + + @Test + void defaultsHeapWhenJavaOptsUnset(@TempDir Path base) throws Exception { + Path h = home(base); + Path java = FakeJava.writeFakeJava(base, "java", JAVA17, 0, "HEAP"); + Map env = new HashMap<>(); + env.put("JAVACMD", java.toString()); + + FakeJava.Result r = FakeJava.run(h.resolve("camel.sh"), env, "version"); + + assertTrue(r.stdout().contains("-Xmx512m"), "default heap must be applied: " + r.stdout()); + } + + @Test + void preservesJavaOptsWhenSet(@TempDir Path base) throws Exception { + Path h = home(base); + Path java = FakeJava.writeFakeJava(base, "java", JAVA17, 0, "OPTS"); + Map env = new HashMap<>(); + env.put("JAVACMD", java.toString()); + env.put("JAVA_OPTS", "-Dcamel.test=1 -Xmx256m"); + + FakeJava.Result r = FakeJava.run(h.resolve("camel.sh"), env, "version"); + + assertTrue(r.stdout().contains("-Dcamel.test=1"), r.stdout()); + assertTrue(r.stdout().contains("-Xmx256m"), r.stdout()); + assertFalse(r.stdout().contains("-Xmx512m"), "default heap must not be added when JAVA_OPTS set"); + } + + @Test + void preservesChildExitCode(@TempDir Path base) throws Exception { + Path h = home(base); + Path java = FakeJava.writeFakeJava(base, "java", JAVA17, 42, "EXIT"); + Map env = new HashMap<>(); + env.put("JAVACMD", java.toString()); + + FakeJava.Result r = FakeJava.run(h.resolve("camel.sh"), env, "version"); + + assertEquals(42, r.exitCode(), "child exit code must be propagated"); + } + + @Test + void preservesChildStdinStdoutAndStderr(@TempDir Path base) throws Exception { + Path h = home(base); + Path java = FakeJava.writeFakeJava(base, "java", JAVA17, 0, "STDIO"); + Map env = new HashMap<>(); + env.put("JAVACMD", java.toString()); + + FakeJava.Result r = FakeJava.runWithInput(h.resolve("camel.sh"), env, "route input\n", "version"); + + assertEquals(0, r.exitCode(), r.stderr()); + assertTrue(r.stdout().contains("RAN=STDIO"), r.stdout()); + assertTrue(r.stdout().contains("route input"), "stdin did not reach child stdout: " + r.stdout()); + assertTrue(r.stderr().contains("STDERR=STDIO"), "child stderr was not preserved: " + r.stderr()); + } + + @Test + void probeCannotConsumeChildStdin(@TempDir Path base) throws Exception { + Path h = home(base); + Path java = FakeJava.writeFakeJava(base, "java", JAVA17, 0, "PROBE-STDIN"); + Files.writeString(java, "#!/bin/sh\n" + + "if [ \"$1\" = \"-version\" ]; then\n" + + " IFS= read -r ignored\n" + + " echo '" + JAVA17 + "' 1>&2\n" + + " exit 0\n" + + "fi\n" + + "echo 'RAN=PROBE-STDIN'\n" + + "cat\n"); + Map env = new HashMap<>(); + env.put("JAVACMD", java.toString()); + + FakeJava.Result r = FakeJava.runWithInput(h.resolve("camel.sh"), env, "route input\n", "version"); + + assertEquals(0, r.exitCode(), r.stderr()); + assertTrue(r.stdout().contains("RAN=PROBE-STDIN"), r.stdout()); + assertTrue(r.stdout().contains("route input"), "version probe consumed child stdin: " + r.stdout()); + } + + @Test + void scriptItselfIsExecutableFixture(@TempDir Path base) throws Exception { + Path h = home(base); + assertTrue(Files.exists(h.resolve("camel.sh"))); + assertTrue(Files.isExecutable(h.resolve("camel.sh"))); + } +} diff --git a/dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/FakeJava.java b/dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/FakeJava.java new file mode 100644 index 0000000000000..9ad3612ca1eac --- /dev/null +++ b/dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/FakeJava.java @@ -0,0 +1,213 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.camel.dsl.jbang.launcher; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +import static java.nio.file.attribute.PosixFilePermission.OWNER_EXECUTE; +import static java.nio.file.attribute.PosixFilePermission.OWNER_READ; +import static java.nio.file.attribute.PosixFilePermission.OWNER_WRITE; + +/** + * Test helper that materializes fake {@code java} executables and a throwaway launcher home so the shared + * Java-discovery contract in camel.sh / camel.bat can be exercised without a real JDK. + */ +final class FakeJava { + + private static final long PROCESS_TIMEOUT_SECONDS = 60; + private static final long CLEANUP_TIMEOUT_SECONDS = 10; + + static final boolean WINDOWS = System.getProperty("os.name").toLowerCase().contains("win"); + + private FakeJava() { + } + + record Result(int exitCode, String stdout, String stderr) { + } + + static Path writeFakeJava(Path dir, String name, String versionBanner, int normalExit, String marker) + throws IOException { + Files.createDirectories(dir); + if (WINDOWS) { + Path exe = dir.resolve(name + ".bat"); + String body = "@echo off\r\n" + + "if \"%~1\"==\"-version\" (\r\n" + + " echo " + versionBanner + " 1>&2\r\n" + + " exit /b 0\r\n" + + ")\r\n" + + "echo RAN=" + marker + "\r\n" + + "echo ARGS=%*\r\n" + + "echo STDERR=" + marker + " 1>&2\r\n" + + "more\r\n" + + "exit /b " + normalExit + "\r\n"; + Files.writeString(exe, body, StandardCharsets.UTF_8); + return exe; + } + Path exe = dir.resolve(name); + String body = "#!/bin/sh\n" + + "if [ \"$1\" = \"-version\" ]; then\n" + + " echo '" + versionBanner + "' 1>&2\n" + + " exit 0\n" + + "fi\n" + + "echo \"RAN=" + marker + "\"\n" + + "printf 'ARGS='; for a in \"$@\"; do printf '%s|' \"$a\"; done; echo\n" + + "echo \"STDERR=" + marker + "\" 1>&2\n" + + "cat\n" + + "exit " + normalExit + "\n"; + Files.writeString(exe, body, StandardCharsets.UTF_8); + Files.setPosixFilePermissions(exe, Set.of(OWNER_READ, OWNER_WRITE, OWNER_EXECUTE)); + return exe; + } + + static Path newLauncherHome(Path base, Path scriptResource) throws IOException { + Path home = Files.createTempDirectory(base, "launcher-home"); + Path script = home.resolve(scriptResource.getFileName().toString()); + Files.copy(scriptResource, script); + if (!WINDOWS) { + Files.setPosixFilePermissions(script, Set.of(OWNER_READ, OWNER_WRITE, OWNER_EXECUTE)); + } + // A stub jar so the launcher's "camel-launcher-*.jar" glob resolves; fake java never reads it. + Files.writeString(home.resolve("camel-launcher-9.9.9.jar"), "stub"); + return home; + } + + static Result run(Path script, Map env, String... args) throws Exception { + return runWithInput(script, env, null, args); + } + + static Result runWithInput(Path script, Map env, String input, String... args) throws Exception { + List cmd = new ArrayList<>(); + if (WINDOWS) { + cmd.add("cmd.exe"); + cmd.add("/c"); + cmd.add(script.toString()); + } else { + cmd.add("/bin/sh"); + cmd.add(script.toString()); + } + for (String a : args) { + cmd.add(a); + } + ProcessBuilder pb = new ProcessBuilder(cmd); + pb.environment().clear(); + // Keep a minimal PATH so /bin/sh and cmd.exe internals resolve; tests override as needed. + pb.environment().put("PATH", WINDOWS ? System.getenv("PATH") : "/usr/bin:/bin"); + if (WINDOWS) { + pb.environment().put("SystemRoot", System.getenv("SystemRoot")); + pb.environment().put("OS", "Windows_NT"); + pb.environment().put("PATHEXT", ".COM;.EXE;.BAT;.CMD"); + } + pb.environment().putAll(env); + Process p = pb.start(); + ExecutorService collectors = Executors.newFixedThreadPool(2); + Throwable failure = null; + try { + Future stdout = collectors.submit(() -> p.getInputStream().readAllBytes()); + Future stderr = collectors.submit(() -> p.getErrorStream().readAllBytes()); + if (input != null) { + p.getOutputStream().write(input.getBytes(StandardCharsets.UTF_8)); + } + p.getOutputStream().close(); + if (!p.waitFor(PROCESS_TIMEOUT_SECONDS, TimeUnit.SECONDS)) { + terminate(p); + throw new IllegalStateException("launcher did not exit in time"); + } + String out = new String(await(stdout, "stdout"), StandardCharsets.UTF_8); + String err = new String(await(stderr, "stderr"), StandardCharsets.UTF_8); + return new Result(p.exitValue(), out, err); + } catch (Exception | Error e) { + failure = e; + throw e; + } finally { + try { + cleanup(p, collectors); + } catch (Exception | Error cleanupFailure) { + if (failure != null) { + failure.addSuppressed(cleanupFailure); + } else { + throw cleanupFailure; + } + } + } + } + + private static void cleanup(Process process, ExecutorService collectors) throws Exception { + Throwable failure = null; + try { + terminate(process); + } catch (Exception | Error e) { + failure = e; + } + collectors.shutdownNow(); + try { + if (!collectors.awaitTermination(CLEANUP_TIMEOUT_SECONDS, TimeUnit.SECONDS)) { + throw new IllegalStateException("launcher stream collectors did not terminate"); + } + } catch (Exception | Error e) { + if (failure != null) { + failure.addSuppressed(e); + } else { + failure = e; + } + } + if (failure instanceof Exception exception) { + throw exception; + } + if (failure instanceof Error error) { + throw error; + } + } + + private static byte[] await(Future collector, String stream) throws Exception { + try { + return collector.get(CLEANUP_TIMEOUT_SECONDS, TimeUnit.SECONDS); + } catch (ExecutionException e) { + Throwable cause = e.getCause(); + if (cause instanceof Exception exception) { + throw exception; + } + if (cause instanceof Error error) { + throw error; + } + throw new IllegalStateException("failed to collect launcher " + stream, cause); + } catch (TimeoutException e) { + throw new IllegalStateException("timed out collecting launcher " + stream, e); + } + } + + private static void terminate(Process process) throws InterruptedException { + if (process.isAlive()) { + process.destroyForcibly(); + if (!process.waitFor(CLEANUP_TIMEOUT_SECONDS, TimeUnit.SECONDS)) { + throw new IllegalStateException("launcher could not be terminated"); + } + } + } +} diff --git a/dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/FakeJavaTest.java b/dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/FakeJavaTest.java new file mode 100644 index 0000000000000..fb99336cc9228 --- /dev/null +++ b/dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/FakeJavaTest.java @@ -0,0 +1,93 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.camel.dsl.jbang.launcher; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.util.Map; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledOnOs; +import org.junit.jupiter.api.condition.OS; +import org.junit.jupiter.api.io.TempDir; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTimeoutPreemptively; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class FakeJavaTest { + + @TempDir + Path tempDir; + + @Test + void drainsStdoutAndStderrWithoutDeadlock() throws Exception { + Path script = tempDir.resolve(FakeJava.WINDOWS ? "large-output.bat" : "large-output.sh"); + Files.writeString(script, largeOutputScript(), StandardCharsets.UTF_8); + + FakeJava.Result result = assertTimeoutPreemptively(Duration.ofSeconds(10), + () -> FakeJava.run(script, Map.of())); + + assertEquals(0, result.exitCode()); + assertTrue(result.stdout().contains("stdout-20000")); + assertTrue(result.stderr().contains("stderr-20000")); + } + + @Test + void fakeJavaPreservesInputAndNormalStderr() throws Exception { + Path java = FakeJava.writeFakeJava(tempDir, "java", "openjdk version \"17.0.2\"", 0, "STDIO"); + + FakeJava.Result result = FakeJava.runWithInput(java, Map.of(), "route input\n", "version"); + + assertEquals(0, result.exitCode()); + assertTrue(result.stdout().contains("RAN=STDIO"), result.stdout()); + assertTrue(result.stdout().contains("route input"), result.stdout()); + assertTrue(result.stderr().contains("STDERR=STDIO"), result.stderr()); + } + + @Test + @EnabledOnOs(OS.WINDOWS) + void windowsEnvironmentExercisesSetlocalEndlocalAndPreservesExit() throws Exception { + Path script = tempDir.resolve("setlocal-exit.bat"); + Files.writeString(script, "@echo off\r\n" + + "if not \"%OS%\"==\"Windows_NT\" exit /b 91\r\n" + + "if \"%OS%\"==\"Windows_NT\" setlocal\r\n" + + "set ERROR_CODE=43\r\n" + + "if \"%OS%\"==\"Windows_NT\" endlocal & set ERROR_CODE=%ERROR_CODE%\r\n" + + "exit /b %ERROR_CODE%\r\n", + StandardCharsets.UTF_8); + + FakeJava.Result result = FakeJava.run(script, Map.of()); + + assertEquals(43, result.exitCode(), "Windows_NT setlocal/endlocal path did not preserve exit status"); + } + + private static String largeOutputScript() { + if (FakeJava.WINDOWS) { + return "@echo off\r\n" + + "for /L %%i in (1,1,20000) do echo stderr-%%i 1>&2\r\n" + + "for /L %%i in (1,1,20000) do echo stdout-%%i\r\n"; + } + return "#!/bin/sh\n" + + "i=1\n" + + "while [ $i -le 20000 ]; do echo stderr-$i 1>&2; i=$((i + 1)); done\n" + + "i=1\n" + + "while [ $i -le 20000 ]; do echo stdout-$i; i=$((i + 1)); done\n"; + } +}