diff --git a/.github/workflows/camel-launcher-windows.yml b/.github/workflows/camel-launcher-windows.yml new file mode 100644 index 0000000000000..d6a9cca151769 --- /dev/null +++ b/.github/workflows/camel-launcher-windows.yml @@ -0,0 +1,133 @@ +# +# 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. +# + +name: Camel launcher Windows build + +on: + push: + branches: + - main + pull_request: + branches: + - main + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + detect-changes: + runs-on: ubuntu-latest + outputs: + camel-exe: ${{ steps.changes.outputs.camel-exe }} + camel-launcher: ${{ steps.changes.outputs.camel-launcher }} + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + # 50 commits covers any reasonable push batch; PRs fetch the base ref below. + fetch-depth: 50 + - id: changes + run: | + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + echo "camel-exe=true" >> "$GITHUB_OUTPUT" + echo "camel-launcher=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + + if [ "${{ github.event_name }}" = "pull_request" ]; then + git fetch origin "${{ github.base_ref }}" --depth=1 + CHANGED="$(git diff --name-only "origin/${{ github.base_ref }}"...HEAD)" + else + CHANGED="$(git diff --name-only "${{ github.event.before }}" "${{ github.sha }}")" + fi + + echo "Changed files:" + echo "$CHANGED" + + camel_exe=false + camel_launcher=false + + if echo "$CHANGED" | grep -qE '^(tooling/camel-exe/|\.github/workflows/camel-launcher-windows\.yml)'; then + camel_exe=true + fi + + if echo "$CHANGED" | grep -qE '^(dsl/camel-jbang/camel-launcher/|tooling/camel-exe/|\.github/workflows/camel-launcher-windows\.yml)'; then + camel_launcher=true + fi + + echo "camel-exe=$camel_exe" >> "$GITHUB_OUTPUT" + echo "camel-launcher=$camel_launcher" >> "$GITHUB_OUTPUT" + + camel-exe: + needs: detect-changes + if: github.event_name == 'workflow_dispatch' || needs.detect-changes.outputs.camel-exe == 'true' + runs-on: windows-2022 + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + - name: Set up JDK 21 + uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 + with: + distribution: 'temurin' + java-version: '21' + cache: 'maven' + - name: Build and test native camel.exe + shell: cmd + run: | + for /f "usebackq delims=" %%i in (`"%ProgramFiles(x86)%\Microsoft Visual Studio\Installer\vswhere.exe" -latest -products * -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath`) do set "VSINSTALL=%%i" + call "%VSINSTALL%\VC\Auxiliary\Build\vcvars64.bat" + mvn -B -ntp -pl buildingtools install -DskipTests + mvn -B -ntp -pl tooling/camel-exe verify -Dcamel.exe.requireWindowsExe=true + + camel-launcher-windows: + needs: detect-changes + if: github.event_name == 'workflow_dispatch' || needs.detect-changes.outputs.camel-launcher == 'true' + runs-on: windows-2022 + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + - name: Set up JDK 21 + uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 + with: + distribution: 'temurin' + java-version: '21' + cache: 'maven' + - name: Build launcher with native camel.exe + shell: cmd + run: | + for /f "usebackq delims=" %%i in (`"%ProgramFiles(x86)%\Microsoft Visual Studio\Installer\vswhere.exe" -latest -products * -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath`) do set "VSINSTALL=%%i" + call "%VSINSTALL%\VC\Auxiliary\Build\vcvars64.bat" + mvn -B -ntp -pl buildingtools,tooling/camel-exe,dsl/camel-jbang/camel-launcher -am install -DskipTests -Dcamel.launcher.requireWindowsExe=true + - name: Assert archive carries bin/camel.exe + shell: pwsh + run: | + $zip = Get-ChildItem dsl/camel-jbang/camel-launcher/target/camel-launcher-*-bin.zip | Select-Object -First 1 + Add-Type -AssemblyName System.IO.Compression.FileSystem + $archive = [System.IO.Compression.ZipFile]::OpenRead($zip.FullName) + try { + $names = $archive.Entries.FullName + if (-not ($names -like '*/bin/camel.exe')) { throw 'camel.exe missing from launcher archive' } + } finally { + $archive.Dispose() + } 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 0454ac8a835a2..e58517c676e49 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 @@ -80,6 +80,14 @@ The Gentoo `java-config` auto-detection and the IBM AIX `$JAVA_HOME/jre/sh/java` removed; set `JAVA_HOME` or `JAVACMD` explicitly on those platforms. `JAVA_OPTS` handling is unchanged: when unset, the default `-Xmx512m` heap is applied. +==== Native `camel.exe` in the launcher distribution + +The `camel-launcher` Windows distribution now ships a native x64 `bin/camel.exe` +alongside `bin/camel.bat`. `camel.exe` is a thin bootstrap: it forwards all +arguments to the adjacent `camel.bat` (preserving spaces and Unicode) and returns +its exit code. It exists so package managers that require a genuine executable +users may continue to invoke `bin\camel.bat`; both behave identically. + === 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/README.md b/dsl/camel-jbang/camel-launcher/README.md index 06e2f1dd63cb9..845c8b894fe5a 100644 --- a/dsl/camel-jbang/camel-launcher/README.md +++ b/dsl/camel-jbang/camel-launcher/README.md @@ -16,6 +16,22 @@ This will create: 1. A self-executing JAR (`camel-launcher-.jar`) in the `target` directory using Spring Boot's nested JAR structure 2. Distribution archives (`camel-launcher--bin.zip` and `camel-launcher--bin.tar.gz`) in the `target` directory +On Windows, the distribution archives also include `bin/camel.exe`, a native bootstrap built by +[`tooling/camel-exe`](../../../tooling/camel-exe). Release builds on a Windows x64 host run an integration test during `verify` that asserts +`target/camel.exe` is staged and `bin/camel.exe` is present in the assembled ZIP: + +```bash +mvn -pl tooling/camel-exe,dsl/camel-jbang/camel-launcher -am verify -Dcamel.launcher.requireWindowsExe=true +``` + +To build and test only the native bootstrap: + +```bash +mvn -pl tooling/camel-exe verify -Dcamel.exe.requireWindowsExe=true +``` + +See [`tooling/camel-exe/src/main/native/README.md`](../../../tooling/camel-exe/src/main/native/README.md) for MSVC requirements. + ## Usage ### Using the JAR directly @@ -47,8 +63,12 @@ java -jar camel-launcher-.jar run hello.java # On Windows bin\camel.bat [command] [options] + bin\camel.exe [command] [options] ``` + `camel.exe` and `camel.bat` behave identically on Windows; `camel.exe` exists for package managers + (such as WinGet) that require a genuine executable command. + ## Benefits - No need for JBang installation diff --git a/dsl/camel-jbang/camel-launcher/pom.xml b/dsl/camel-jbang/camel-launcher/pom.xml index 3694faf67e92d..6668d00051cde 100644 --- a/dsl/camel-jbang/camel-launcher/pom.xml +++ b/dsl/camel-jbang/camel-launcher/pom.xml @@ -325,4 +325,110 @@ + + + + + include-camel-exe + + + windows + + + + + org.apache.camel + camel-exe + ${project.version} + exe + + + + + + org.apache.maven.plugins + maven-dependency-plugin + + + copy-camel-exe + generate-resources + + copy + + + + + org.apache.camel + camel-exe + ${project.version} + exe + ${project.build.directory} + camel.exe + + + + + + + + + + + + + require-windows-exe + + + camel.launcher.requireWindowsExe + true + + + + + + org.apache.maven.plugins + maven-enforcer-plugin + + + require-camel-exe + prepare-package + + enforce + + + + + + ${project.build.directory}/camel.exe + + + + true + + + + + + org.apache.maven.plugins + maven-failsafe-plugin + + + + integration-test + verify + + + + + + + + diff --git a/dsl/camel-jbang/camel-launcher/src/main/assembly/bin.xml b/dsl/camel-jbang/camel-launcher/src/main/assembly/bin.xml index 32432cac0ccb7..7506708d80a9f 100644 --- a/dsl/camel-jbang/camel-launcher/src/main/assembly/bin.xml +++ b/dsl/camel-jbang/camel-launcher/src/main/assembly/bin.xml @@ -70,5 +70,13 @@ *.* + + ${project.build.directory} + bin + + camel.exe + + 0755 + diff --git a/dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/CamelLauncherWindowsExeIT.java b/dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/CamelLauncherWindowsExeIT.java new file mode 100644 index 0000000000000..c2108f78fa20c --- /dev/null +++ b/dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/CamelLauncherWindowsExeIT.java @@ -0,0 +1,77 @@ +/* + * 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.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.stream.Stream; +import java.util.zip.ZipEntry; +import java.util.zip.ZipFile; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfSystemProperty; +import org.junit.jupiter.api.condition.EnabledOnOs; +import org.junit.jupiter.api.condition.OS; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Release gate for the native {@code camel.exe} packaged into the launcher distribution. Runs during {@code verify} + * when {@code -Dcamel.launcher.requireWindowsExe=true} is set on a Windows x64 host. + */ +@EnabledOnOs(OS.WINDOWS) +@EnabledIfSystemProperty(named = "camel.launcher.requireWindowsExe", matches = "true") +class CamelLauncherWindowsExeIT { + + private static final Path TARGET = Paths.get("target"); + private static final Path STAGED_EXE = TARGET.resolve("camel.exe"); + + @Test + void stagedCamelExeExists() { + assertTrue(Files.isRegularFile(STAGED_EXE), + "target/camel.exe must be present before packaging the launcher distribution"); + } + + @Test + void binArchiveIncludesCamelExe() throws IOException { + Path zip = findBinZip(); + assertNotNull(zip, "camel-launcher-*-bin.zip must be produced by the assembly plugin"); + + try (ZipFile archive = new ZipFile(zip.toFile())) { + // maven-assembly-plugin defaults includeBaseDirectory to true, so entries are + // nested under camel-launcher-/, not at the archive root. + ZipEntry entry = archive.stream() + .filter(e -> e.getName().endsWith("/bin/camel.exe")) + .findFirst() + .orElse(null); + assertNotNull(entry, "bin/camel.exe must be included in " + zip.getFileName()); + assertTrue(entry.getSize() > 0, "bin/camel.exe must not be empty"); + } + } + + private static Path findBinZip() throws IOException { + try (Stream paths = Files.list(TARGET)) { + return paths + .filter(p -> p.getFileName().toString().matches("camel-launcher-.*-bin\\.zip")) + .findFirst() + .orElse(null); + } + } +} diff --git a/tooling/camel-exe/README.md b/tooling/camel-exe/README.md new file mode 100644 index 0000000000000..390463492995e --- /dev/null +++ b/tooling/camel-exe/README.md @@ -0,0 +1,45 @@ +# Camel :: Exe + +Standalone Maven module that builds the native Windows **`camel.exe`** bootstrap for the +[Camel CLI launcher](../../dsl/camel-jbang/camel-launcher). + +## Purpose + +WinGet's portable installer (and similar package managers) require a genuine executable as +the `camel` command. A `.bat` or `.cmd` shim is not enough — it produces a broken +`camel.exe` symlink. This module compiles a minimal x64 native binary that satisfies that +contract. + +`camel.exe` does not embed Java or run Camel itself. It locates `camel.bat` in the same +directory, forwards the caller's command line (preserving spaces and Unicode), and returns +its exit code. All Java discovery and CLI parsing remain in `camel.bat` and the launcher +JAR. + +## Why a separate module? + +The launcher (`dsl/camel-jbang/camel-launcher`) is a fat JAR with a large upstream +dependency tree (jbang core, plugins, repackager, and their transitive Camel deps). The +native bootstrap is ~100 lines of C with **no Java dependencies**. Keeping it here allows: + +- **Fast Windows CI** — `mvn -pl tooling/camel-exe verify` compiles and tests the exe + without building the full jbang graph. +- **Clear separation** — packaging bootstrap vs. runtime launcher. +- **Reusable artifact** — `camel-launcher` copies the attached `exe` artifact into its + distribution on Windows (`bin/camel.exe` inside `camel-launcher-*-bin.zip`). + +## Build and test + +On a Windows x64 host with Microsoft C/C++ Build Tools (`cl.exe`) on PATH: + +```bash +mvn -pl tooling/camel-exe verify -Dcamel.exe.requireWindowsExe=true +``` + +Release and integration builds that produce the launcher ZIP also build this module first: + +```bash +mvn -pl tooling/camel-exe,dsl/camel-jbang/camel-launcher -am verify -Dcamel.launcher.requireWindowsExe=true +``` + +See [src/main/native/README.md](src/main/native/README.md) for MSVC setup, compiler +flags, and the release-gate profile. diff --git a/tooling/camel-exe/pom.xml b/tooling/camel-exe/pom.xml new file mode 100644 index 0000000000000..f05e18ea80f56 --- /dev/null +++ b/tooling/camel-exe/pom.xml @@ -0,0 +1,214 @@ + + + + + 4.0.0 + + + org.apache.camel + tooling + 4.22.0-SNAPSHOT + + + camel-exe + pom + + Camel :: Exe + Native Windows camel.exe bootstrap for the Camel CLI launcher + + + false + + + + + + org.junit.jupiter + junit-jupiter + test + + + + + + + + org.codehaus.mojo + build-helper-maven-plugin + + + add-test-source + generate-test-sources + + add-test-source + + + + src/test/java + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + default-testCompile + test-compile + + testCompile + + + + + + org.apache.maven.plugins + maven-surefire-plugin + + + default-test + test + + test + + + + + + + + + + + build-windows-exe + + + windows + + + + + + org.codehaus.mojo + exec-maven-plugin + + + compile-camel-exe + generate-test-resources + + exec + + + cl.exe + + /nologo + /W4 + /O1 + /MT + /Brepro + /Fe:${project.build.directory}\camel.exe + /Fo:${project.build.directory}\camel.obj + ${project.basedir}\src\main\native\camel.c + /link + /Brepro + /SUBSYSTEM:CONSOLE + + + + + + + org.codehaus.mojo + build-helper-maven-plugin + + + attach-camel-exe + package + + attach-artifact + + + + + ${project.build.directory}/camel.exe + exe + + + + + + + + + + + + + require-windows-exe + + + camel.exe.requireWindowsExe + true + + + + + + org.apache.maven.plugins + maven-enforcer-plugin + + + require-camel-exe + prepare-package + + enforce + + + + + + ${project.build.directory}/camel.exe + + + + true + + + + + + + + + diff --git a/tooling/camel-exe/src/main/native/README.md b/tooling/camel-exe/src/main/native/README.md new file mode 100644 index 0000000000000..506b2bde8c711 --- /dev/null +++ b/tooling/camel-exe/src/main/native/README.md @@ -0,0 +1,26 @@ +# Native build (`camel.c`) + +Compiler flags, MSVC setup, and release-gate details for the `camel.exe` bootstrap. +See the [module README](../../../README.md) for purpose, architecture, and integration +with `camel-launcher`. + +## MSVC setup + +The `build-windows-exe` Maven profile activates automatically on Windows and invokes +`cl.exe`. MSVC must be on PATH — open a developer command prompt (e.g. run +`vcvars64.bat`) or provision MSVC in CI before running Maven. + +## Compiler invocation + + cl /nologo /W4 /O1 /MT /Brepro /Fe:target\camel.exe src\main\native\camel.c /link /Brepro /SUBSYSTEM:CONSOLE + +- `/MT` — static CRT (no runtime DLL dependency) +- `/Brepro` — deterministic PE header timestamp on both compiler and linker, so + repeated builds from the same source produce byte-identical output + +## Release gate + +Release builds pass `-Dcamel.exe.requireWindowsExe=true`, which activates the +`require-windows-exe` profile. The enforcer fails the build if `target/camel.exe` is +absent. Because MSVC cannot cross-compile from macOS/Linux, the launcher ZIP/TAR that +carries `camel.exe` must be assembled on a Windows x64 host. diff --git a/tooling/camel-exe/src/main/native/camel.c b/tooling/camel-exe/src/main/native/camel.c new file mode 100644 index 0000000000000..8f77121d10468 --- /dev/null +++ b/tooling/camel-exe/src/main/native/camel.c @@ -0,0 +1,146 @@ +/** + * 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. + */ +/* + * camel.exe - minimal WinGet-compatible bootstrap for the Camel CLI launcher. + * + * It locates the camel.bat sitting in the same directory, forwards the caller's + * exact command-line tail (preserving Unicode and Windows quoting), inherits the + * standard streams, and returns the child process exit code. It performs NO Java + * discovery and NO Camel option parsing; that all lives in camel.bat. + */ + +#include +#include +#include +#include + +/* Return the command-line tail after argv[0], preserving the caller's quoting. */ +static LPWSTR skip_argv0(LPWSTR cmd) { + LPWSTR p = cmd; + if (*p == L'"') { + p++; + while (*p && *p != L'"') { + p++; + } + if (*p == L'"') { + p++; + } + } else { + while (*p && *p != L' ' && *p != L'\t') { + p++; + } + } + while (*p == L' ' || *p == L'\t') { + p++; + } + return p; +} + +/* + * Return a heap-allocated wide string holding the directory that contains this + * exe, or NULL on failure. The caller must free() the result. Uses a doubling + * loop so paths beyond MAX_PATH (260) work on Windows 10+ with long-path + * support enabled. + */ +static wchar_t *get_exe_dir(void) { + DWORD bufSize = MAX_PATH; + wchar_t *buf = (wchar_t *) malloc(bufSize * sizeof(wchar_t)); + if (buf == NULL) { + return NULL; + } + for (;;) { + DWORD n = GetModuleFileNameW(NULL, buf, bufSize); + if (n == 0) { + free(buf); + return NULL; + } + if (n < bufSize) { + break; + } + bufSize *= 2; + wchar_t *tmp = (wchar_t *) realloc(buf, bufSize * sizeof(wchar_t)); + if (tmp == NULL) { + free(buf); + return NULL; + } + buf = tmp; + } + wchar_t *slash = wcsrchr(buf, L'\\'); + if (slash == NULL) { + free(buf); + return NULL; + } + *slash = L'\0'; + return buf; +} + +int wmain(void) { + wchar_t *exePath = get_exe_dir(); + if (exePath == NULL) { + fwprintf(stderr, L"camel: cannot resolve launcher directory\n"); + return 1; + } + + LPWSTR tail = skip_argv0(GetCommandLineW()); + + /* + * cmd.exe /S /C ""\camel.bat" " + * With /S and a command wrapped in an outer pair of quotes, cmd strips the + * first and last quote and executes the remainder verbatim, so the inner + * quotes around the (possibly spaced/Unicode) camel.bat path survive. + */ + size_t cap = wcslen(exePath) + wcslen(tail) + 64; + LPWSTR cmdline = (LPWSTR) malloc(cap * sizeof(wchar_t)); + if (cmdline == NULL) { + fwprintf(stderr, L"camel: out of memory\n"); + free(exePath); + return 1; + } + int ret = _snwprintf_s(cmdline, cap, _TRUNCATE, + L"cmd.exe /S /C \"\"%s\\camel.bat\" %s\"", exePath, tail); + if (ret == -1) { + fwprintf(stderr, L"camel: command line was truncated\n"); + free(cmdline); + free(exePath); + return 1; + } + + STARTUPINFOW si; + PROCESS_INFORMATION pi; + ZeroMemory(&si, sizeof(si)); + si.cb = sizeof(si); + ZeroMemory(&pi, sizeof(pi)); + + /* bInheritHandles=TRUE and no STARTF_USESTDHANDLES: child shares our console. */ + if (!CreateProcessW(NULL, cmdline, NULL, NULL, TRUE, 0, NULL, NULL, &si, &pi)) { + fwprintf(stderr, L"camel: failed to start camel.bat (error %lu)\n", GetLastError()); + free(cmdline); + free(exePath); + return 1; + } + + WaitForSingleObject(pi.hProcess, INFINITE); + DWORD code = 1; + if (!GetExitCodeProcess(pi.hProcess, &code)) { + fwprintf(stderr, L"camel: failed to get exit code (error %lu)\n", GetLastError()); + } + CloseHandle(pi.hProcess); + CloseHandle(pi.hThread); + free(cmdline); + free(exePath); + return (int) code; +} diff --git a/tooling/camel-exe/src/test/java/org/apache/camel/tooling/exe/CamelExeBootstrapTest.java b/tooling/camel-exe/src/test/java/org/apache/camel/tooling/exe/CamelExeBootstrapTest.java new file mode 100644 index 0000000000000..3171012393c3b --- /dev/null +++ b/tooling/camel-exe/src/test/java/org/apache/camel/tooling/exe/CamelExeBootstrapTest.java @@ -0,0 +1,217 @@ +/* + * 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.tooling.exe; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.TimeUnit; + +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.assertTrue; + +/** + * Windows-only behavioral test for the native camel.exe bootstrap. Requires the build-windows-exe profile to have + * produced target/camel.exe; the test fails loudly if it is missing so a broken native build does not pass silently. + */ +@EnabledOnOs(OS.WINDOWS) +class CamelExeBootstrapTest { + + private static final Path BUILT_EXE = Paths.get("target/camel.exe"); + private static final String CAPTURED_ARGS_FILE = "camel-args.txt"; + private static final String CAPTURE_ARGS_SCRIPT = "capture-arg.ps1"; + + /** Writes a fake camel.bat that echoes each argument on its own line and returns exitCode. */ + private Path fakeBat(Path dir, int exitCode) throws Exception { + Path bat = dir.resolve("camel.bat"); + String body = "@echo off\r\n" + + ":loop\r\n" + + "if \"%~1\"==\"\" goto done\r\n" + + "echo ARG=%~1\r\n" + + "shift\r\n" + + "goto loop\r\n" + + ":done\r\n" + + "exit /b " + exitCode + "\r\n"; + Files.writeString(bat, body, StandardCharsets.UTF_8); + return bat; + } + + /** + * Writes a fake camel.bat that appends each forwarded argument to {@link #CAPTURED_ARGS_FILE} as UTF-8. + *

+ * Non-ASCII argument checks cannot rely on parsing process stdout: cmd.exe {@code echo} uses the console OEM code + * page on Windows CI hosts, so Unicode forwarded correctly by camel.exe is still mangled (for example {@code ü} + * becomes {@code ?}) before Java reads the pipe. We therefore capture arguments in a UTF-8 file via a helper + * PowerShell script. The batch file passes all arguments at once using {@code %*} (the raw command-line tail) + * rather than iterating with {@code %~1}, because per-argument expansion through cmd.exe's batch variable + * substitution can corrupt non-ASCII characters on certain OEM code pages. + */ + private void fakeBatCapturingArgsToUtf8File(Path dir, int exitCode) throws Exception { + Path captureScript = dir.resolve(CAPTURE_ARGS_SCRIPT); + String ps1 = "$outFile = $args[0]\r\n" + + "for ($i = 1; $i -lt $args.Count; $i++) {\r\n" + + " [System.IO.File]::AppendAllText($outFile,\r\n" + + " $args[$i] + [Environment]::NewLine,\r\n" + + " [System.Text.UTF8Encoding]::new($false))\r\n" + + "}\r\n" + + "exit " + exitCode + "\r\n"; + Files.writeString(captureScript, ps1, StandardCharsets.UTF_8); + + Path bat = dir.resolve("camel.bat"); + String body = "@powershell -NoProfile -ExecutionPolicy Bypass -File \"%~dp0" + CAPTURE_ARGS_SCRIPT + "\" " + + "\"%~dp0" + CAPTURED_ARGS_FILE + "\" %*\r\n" + + "@exit /b %ERRORLEVEL%\r\n"; + Files.writeString(bat, body, StandardCharsets.UTF_8); + } + + private List readCapturedArgs(Path dir) throws Exception { + Path file = dir.resolve(CAPTURED_ARGS_FILE); + if (!Files.exists(file)) { + return List.of(); + } + return Files.readAllLines(file, StandardCharsets.UTF_8); + } + + private Path stagedExe(Path dir) throws Exception { + assertTrue(Files.exists(BUILT_EXE), + "target/camel.exe must be built by the build-windows-exe profile before this test"); + Path exe = dir.resolve("camel.exe"); + Files.copy(BUILT_EXE, exe); + return exe; + } + + private static final class Result { + int exit; + String stdout; + } + + private Result run(Path exe, String... args) throws Exception { + List cmd = new ArrayList<>(); + cmd.add(exe.toString()); + for (String a : args) { + cmd.add(a); + } + ProcessBuilder pb = new ProcessBuilder(cmd); + pb.redirectErrorStream(true); + Process p = pb.start(); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + // Drain stdout on a background thread: reading it inline would block until the pipe closes + // at process exit, making the waitFor timeout below unreachable if camel.exe hangs. + Thread drain = new Thread(() -> { + try { + p.getInputStream().transferTo(out); + } catch (IOException e) { + // stream closes abruptly when the process is destroyed on timeout below + } + }); + drain.start(); + try { + boolean exited = p.waitFor(60, TimeUnit.SECONDS); + if (!exited) { + p.destroyForcibly(); + } + assertTrue(exited, "camel.exe did not exit in time"); + drain.join(TimeUnit.SECONDS.toMillis(5)); + Result r = new Result(); + r.exit = p.exitValue(); + r.stdout = out.toString(StandardCharsets.UTF_8); + return r; + } finally { + if (p.isAlive()) { + p.destroyForcibly(); + } + } + } + + @Test + void forwardsArgumentsToAdjacentCamelBat(@TempDir Path dir) throws Exception { + fakeBat(dir, 0); + Path exe = stagedExe(dir); + + Result r = run(exe, "version"); + + assertEquals(0, r.exit); + assertTrue(r.stdout.contains("ARG=version"), r.stdout); + } + + @Test + void preservesArgumentWithSpaces(@TempDir Path dir) throws Exception { + fakeBat(dir, 0); + Path exe = stagedExe(dir); + + Result r = run(exe, "run", "my route.yaml"); + + assertTrue(r.stdout.contains("ARG=run"), r.stdout); + assertTrue(r.stdout.contains("ARG=my route.yaml"), "spaced argument must survive as one token: " + r.stdout); + } + + @Test + void preservesUnicodeArgument(@TempDir Path dir) throws Exception { + fakeBatCapturingArgsToUtf8File(dir, 0); + Path exe = stagedExe(dir); + + Result r = run(exe, "run", "rüte-über.yaml"); + + assertEquals(0, r.exit); + List captured = readCapturedArgs(dir); + assertTrue(captured.contains("run"), captured.toString()); + assertTrue(captured.contains("rüte-über.yaml"), "Unicode argument must survive: " + captured); + } + + @Test + void propagatesChildExitCode(@TempDir Path dir) throws Exception { + fakeBat(dir, 42); + Path exe = stagedExe(dir); + + Result r = run(exe, "version"); + + assertEquals(42, r.exit, "camel.exe must return camel.bat's exit code"); + } + + @Test + void worksWhenExeDirectoryHasSpaces(@TempDir Path base) throws Exception { + Path dir = base.resolve("Program Files X"); + Files.createDirectories(dir); + fakeBat(dir, 0); + Path exe = stagedExe(dir); + + Result r = run(exe, "version"); + + assertEquals(0, r.exit, r.stdout); + assertTrue(r.stdout.contains("ARG=version"), r.stdout); + } + + @Test + void failsGracefullyWhenCamelBatIsMissing(@TempDir Path dir) throws Exception { + Path exe = stagedExe(dir); + + Result r = run(exe, "version"); + + assertTrue(r.exit != 0, "exit code must be non-zero when camel.bat is missing"); + assertTrue(r.stdout.length() > 0, "error message must be present in output"); + } +} diff --git a/tooling/maven/camel-repackager-maven-plugin/src/main/java/org/apache/camel/maven/RepackageMojo.java b/tooling/maven/camel-repackager-maven-plugin/src/main/java/org/apache/camel/maven/RepackageMojo.java index 3ffeec345ba47..3c2bab83feacf 100644 --- a/tooling/maven/camel-repackager-maven-plugin/src/main/java/org/apache/camel/maven/RepackageMojo.java +++ b/tooling/maven/camel-repackager-maven-plugin/src/main/java/org/apache/camel/maven/RepackageMojo.java @@ -129,7 +129,13 @@ private void getLibraries(LibraryCallback callback) throws IOException { } } - private boolean includeArtifact(Artifact artifact) { + boolean includeArtifact(Artifact artifact) { + // Only jar-type artifacts are valid Spring Boot loader libraries. Non-jar artifacts + // (e.g. the native camel-exe:exe bootstrap, which camel-launcher depends on purely to + // stage bin/camel.exe via the assembly descriptor) must never be embedded in BOOT-INF/lib. + if (!"jar".equals(artifact.getType())) { + return false; + } String scope = artifact.getScope(); // Include compile and runtime dependencies return Artifact.SCOPE_COMPILE.equals(scope) || diff --git a/tooling/maven/camel-repackager-maven-plugin/src/test/java/org/apache/camel/maven/RepackageMojoTest.java b/tooling/maven/camel-repackager-maven-plugin/src/test/java/org/apache/camel/maven/RepackageMojoTest.java index 279a8aeab423d..329a43d571978 100644 --- a/tooling/maven/camel-repackager-maven-plugin/src/test/java/org/apache/camel/maven/RepackageMojoTest.java +++ b/tooling/maven/camel-repackager-maven-plugin/src/test/java/org/apache/camel/maven/RepackageMojoTest.java @@ -18,6 +18,9 @@ import java.io.File; +import org.apache.maven.artifact.Artifact; +import org.apache.maven.artifact.DefaultArtifact; +import org.apache.maven.artifact.handler.DefaultArtifactHandler; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; @@ -61,4 +64,46 @@ public void testDependencyInclusion() throws Exception { assertTrue(true, "Placeholder test - would verify dependency inclusion"); } + + @Test + public void testCompileScopedJarIsIncluded() { + RepackageMojo mojo = new RepackageMojo(); + Artifact artifact = artifact("org.apache.camel", "camel-jbang-core", "jar", Artifact.SCOPE_COMPILE); + + assertTrue(mojo.includeArtifact(artifact), + "a compile-scoped jar dependency must be bundled into BOOT-INF/lib"); + } + + @Test + public void testNonJarArtifactIsExcludedEvenWhenCompileScoped() { + // camel-launcher depends on camel-exe:exe purely so the assembly descriptor can stage + // bin/camel.exe; it must never end up embedded as a Spring Boot loader library. + RepackageMojo mojo = new RepackageMojo(); + Artifact artifact = artifact("org.apache.camel", "camel-exe", "exe", Artifact.SCOPE_COMPILE); + + assertFalse(mojo.includeArtifact(artifact), + "a non-jar artifact (e.g. the native camel-exe:exe bootstrap) must not be bundled into BOOT-INF/lib"); + } + + @Test + public void testProvidedCamelJarIsIncluded() { + RepackageMojo mojo = new RepackageMojo(); + Artifact artifact = artifact("org.apache.camel", "camel-util", "jar", Artifact.SCOPE_PROVIDED); + + assertTrue(mojo.includeArtifact(artifact), + "a provided-scope org.apache.camel jar dependency must still be bundled"); + } + + @Test + public void testProvidedNonCamelJarIsExcluded() { + RepackageMojo mojo = new RepackageMojo(); + Artifact artifact = artifact("org.jolokia", "jolokia-agent-jvm", "jar", Artifact.SCOPE_PROVIDED); + + assertFalse(mojo.includeArtifact(artifact), + "a provided-scope non-Camel jar dependency must not be bundled"); + } + + private static Artifact artifact(String groupId, String artifactId, String type, String scope) { + return new DefaultArtifact(groupId, artifactId, "1.0", scope, type, null, new DefaultArtifactHandler(type)); + } } diff --git a/tooling/pom.xml b/tooling/pom.xml index 16fbb6fe64e45..282288030da4c 100644 --- a/tooling/pom.xml +++ b/tooling/pom.xml @@ -42,6 +42,7 @@ parent + camel-exe spi-annotations camel-tooling-model camel-util-json