diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 653d9363e..be6104cd3 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -190,7 +190,7 @@ jobs: fi echo "::endgroup::" echo "::group::JDK" - sudo apt-get install -y openjdk-17-jdk-headless openjdk-21-jdk-headless openjdk-8-jdk-headless + sudo apt-get install -y openjdk-17-jdk openjdk-21-jdk openjdk-8-jdk-headless echo "::endgroup::" - name: Install uv uses: astral-sh/setup-uv@v4 diff --git a/craft_parts/plugins/jlink_plugin.py b/craft_parts/plugins/jlink_plugin.py new file mode 100644 index 000000000..593847337 --- /dev/null +++ b/craft_parts/plugins/jlink_plugin.py @@ -0,0 +1,154 @@ +# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- +# +# Copyright 2024 Canonical Ltd. +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License version 3 as published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program. If not, see . + +"""The JLink plugin.""" + +from typing import Literal, cast + +from overrides import override + +from .base import Plugin +from .properties import PluginProperties +from .validator import PluginEnvironmentValidator + + +class JLinkPluginProperties(PluginProperties, frozen=True): + """The part properties used by the JLink plugin.""" + + plugin: Literal["jlink"] = "jlink" + jlink_jars: list[str] = [] + + +class JLinkPluginEnvironmentValidator(PluginEnvironmentValidator): + """Check the execution environment for the JLink plugin. + + :param part_name: The part whose build environment is being validated. + :param env: A string containing the build step environment setup. + """ + + @override + def validate_environment( + self, *, part_dependencies: list[str] | None = None + ) -> None: + """Ensure the environment contains dependencies needed by the plugin. + + :param part_dependencies: A list of the parts this part depends on. + + :raises PluginEnvironmentValidationError: If go is invalid + and there are no parts named go. + """ + self.validate_dependency( + dependency="jlink", plugin_name="jlink", part_dependencies=part_dependencies + ) + + +class JLinkPlugin(Plugin): + """Create a Java Runtime using JLink.""" + + properties_class = JLinkPluginProperties + validator_class = JLinkPluginEnvironmentValidator + + @override + def get_build_packages(self) -> set[str]: + """Return a set of required packages to install in the build environment.""" + return set() + + @override + def get_build_environment(self) -> dict[str, str]: + """Return a dictionary with the environment to use in the build step.""" + return {} + + @override + def get_build_snaps(self) -> set[str]: + """Return a set of required snaps to install in the build environment.""" + return set() + + @override + def get_build_commands(self) -> list[str]: + """Return a list of commands to run during the build step.""" + options = cast(JLinkPluginProperties, self._options) + + commands = [] + + # Set JAVA_HOME to be used in jlink commands + commands.append( + """ + if [ -z "${JAVA_HOME+x}" ]; then + JAVA_HOME=$(dirname $(dirname $(readlink -f $(which jlink)))) + fi + if [ ! -x "${JAVA_HOME}/bin/java" ]; then + echo "Error: JAVA_HOME: '${JAVA_HOME}/bin/java' is not an executable." >&2 + exit 1 + fi + JLINK=${JAVA_HOME}/bin/jlink + JDEPS=${JAVA_HOME}/bin/jdeps + """ + ) + + # extract jlink version and use it to define the destination + # and multi-release jar version for the dependency enumeration + commands.append("JLINK_VERSION=$(${JLINK} --version)") + commands.append( + "DEST=usr/lib/jvm/java-${JLINK_VERSION%%.*}-openjdk-${CRAFT_ARCH_BUILD_FOR}" + ) + commands.append("MULTI_RELEASE=${JLINK_VERSION%%.*}") + + # find application jars - either all jars in the staging area + # or a list specified in jlink_jars option + if len(options.jlink_jars) > 0: + jars = " ".join(["${CRAFT_STAGE}/" + x for x in options.jlink_jars]) + commands.append(f"PROCESS_JARS={jars}") + else: + commands.append("PROCESS_JARS=$(find ${CRAFT_STAGE} -type f -name *.jar)") + + # create temp folder + commands.append("mkdir -p ${CRAFT_PART_BUILD}/tmp") + # extract jar files into temp folder - spring boot fat jar + # contains dependent jars inside + commands.append( + "(cd ${CRAFT_PART_BUILD}/tmp && for jar in ${PROCESS_JARS}; do jar xvf ${jar}; done;)" + ) + # create classpath - add all dependent jars and all staged jars + commands.append("CPATH=.") + commands.append( + """ + find ${CRAFT_PART_BUILD}/tmp -type f -name *.jar | while IFS= read -r file; do + CPATH=$CPATH:${file} + done + find ${CRAFT_STAGE} -type f -name *.jar | while IFS= read -r file; do + CPATH=$CPATH:${file} + done + """ + ) + commands.append( + """if [ "x${PROCESS_JARS}" != "x" ]; then + deps=$(${JDEPS} --class-path=${CPATH} -q --recursive --ignore-missing-deps \ + --print-module-deps --multi-release ${MULTI_RELEASE} ${PROCESS_JARS}) + else + deps=java.base + fi + """ + ) + commands.append("INSTALL_ROOT=${CRAFT_PART_INSTALL}/${DEST}") + + commands.append( + "rm -rf ${INSTALL_ROOT} && ${JLINK} --no-header-files --no-man-pages --strip-debug --add-modules ${deps} --output ${INSTALL_ROOT}" + ) + # create /usr/bin/java link + commands.append( + "(cd ${CRAFT_PART_INSTALL} && mkdir -p usr/bin && ln -s --relative ${DEST}/bin/java usr/bin/)" + ) + return commands diff --git a/craft_parts/plugins/plugins.py b/craft_parts/plugins/plugins.py index 1df175ef6..7d14b7974 100644 --- a/craft_parts/plugins/plugins.py +++ b/craft_parts/plugins/plugins.py @@ -27,6 +27,7 @@ from .dump_plugin import DumpPlugin from .go_plugin import GoPlugin from .go_use_plugin import GoUsePlugin +from .jlink_plugin import JLinkPlugin from .make_plugin import MakePlugin from .maven_plugin import MavenPlugin from .meson_plugin import MesonPlugin @@ -57,6 +58,7 @@ "dump": DumpPlugin, "go": GoPlugin, "go-use": GoUsePlugin, + "jlink": JLinkPlugin, "make": MakePlugin, "maven": MavenPlugin, "meson": MesonPlugin, diff --git a/craft_parts/sources/__init__.py b/craft_parts/sources/__init__.py index 90052e467..62592937e 100644 --- a/craft_parts/sources/__init__.py +++ b/craft_parts/sources/__init__.py @@ -41,7 +41,7 @@ def _detect_source_type( - data: SourceModel | dict[str, Any] + data: SourceModel | dict[str, Any], ) -> SourceModel | dict[str, Any]: """Get the source type for a source if it's not already provided.""" if isinstance(data, BaseSourceModel) or "source-type" in data: diff --git a/docs/common/craft-parts/craft-parts.wordlist.txt b/docs/common/craft-parts/craft-parts.wordlist.txt index e4ad09b30..e98c9cec2 100644 --- a/docs/common/craft-parts/craft-parts.wordlist.txt +++ b/docs/common/craft-parts/craft-parts.wordlist.txt @@ -101,7 +101,12 @@ InvalidSourceOption InvalidSourceOptions InvalidSourceType Iterable +JAR JavaPlugin +JLink +JLinkPlugin +JLinkPluginEnvironmentValidator +JLinkPluginProperties JSON LDFLAGS LLVM @@ -145,6 +150,7 @@ NpmPlugin NpmPluginEnvironmentValidator NpmPluginProperties OCI +OpenJDK OSError OsRelease OsReleaseCodenameError @@ -313,6 +319,7 @@ chmod chroot chrooted classmethod +classpath classvar cli cls @@ -368,7 +375,9 @@ ing initialized iterable iojs +jdeps jdk +jlink jre js json @@ -398,6 +407,7 @@ oci ok onboarding opencontainers +openjdk organization organize organized diff --git a/docs/common/craft-parts/reference/plugins/jlink_plugin.rst b/docs/common/craft-parts/reference/plugins/jlink_plugin.rst new file mode 100644 index 000000000..71f6a5459 --- /dev/null +++ b/docs/common/craft-parts/reference/plugins/jlink_plugin.rst @@ -0,0 +1,107 @@ +.. _craft_parts_jlink_plugin: + +JLink plugin +============= + +The `JLink `_ plugin can be used for Java projects where +you would want to deploy a Java runtime specific for your application +or install a minimal Java runtime. + + +Keywords +-------- + +This plugin uses the common :ref:`plugin ` keywords as +well as those for :ref:`sources `. + +Additionally, this plugin provides the plugin-specific keywords defined in the +following sections. + +jlink-jars +~~~~~~~~~~~~~~~~~~ +**Type:** list of strings + +List of paths to your application's JAR files. If not specified, the plugin +will find all JAR files in the staging area. + +Dependencies +------------ + +The plugin expects OpenJDK to be available on the system and to contain +the ``jlink`` executable. OpenJDK can be defined as a +``build-package`` in the part using ``jlink`` plugin. +Another alternative is to define another part with the name +``jlink-deps``, and declare that the part using the +``jlink`` plugin comes :ref:`after ` the ``jlink-deps`` part. + +If the system has multiple OpenJDK installations available, one +must be selected by setting the ``JAVA_HOME`` environment variable. + +.. code-block:: yaml + + parts: + runtime: + plugin: jlink + build-packages: + - openjdk-21-jdk + build-environment: + - JAVA_HOME: /usr/jvm/java-21-openjdk-${CRAFT_ARCH_BUILD_FOR} + + +The user is expected to stage OpenJDK dependencies either by installing +an appropriate OpenJDK slice: + +.. code-block:: yaml + + parts: + runtime: + plugin: jlink + build-packages: + - openjdk-21-jdk + after: + - deps + + deps: + plugin: nil + stage-packages: + - openjdk-21-jre-headless_security + stage: + - -usr/lib/jvm + +Or, by installing the dependencies directly: + +.. code-block:: yaml + + parts: + runtime: + plugin: jlink + build-packages: + - openjdk-21-jdk + after: + - deps + + deps: + plugin: nil + stage-packages: + - libc6_libs + - libgcc-s1_libs + - libstdc++6_libs + - zlib1g_libs + - libnss3_libs + + +How it works +------------ + +During the build step, the plugin performs the following actions: + +* Finds all JAR files in the staging area or selects jars specified in + ``jlink-jars``. +* Unpacks JAR files to the temporary location and concatenates all embedded jars + into `jdeps `_ classpath. +* Runs `jdeps `_ to discover Java modules required for the staged jars. +* Runs `jlink `_ to create a runtime image from the build JDK. + + +.. _`jdeps`: https://docs.oracle.com/en/java/javase/21/docs/specs/man/jdeps.html +.. _`jlink`: https://docs.oracle.com/en/java/javase/21/docs/specs/man/jlink.html diff --git a/docs/reference/changelog.rst b/docs/reference/changelog.rst index fc526827d..3743defed 100644 --- a/docs/reference/changelog.rst +++ b/docs/reference/changelog.rst @@ -2,6 +2,14 @@ Changelog ********* +2.5.0 (2025-XX-XX) +------------------ + +New features: + +- Add the :ref:`jlink plugin` for setting up + Java runtime. + 2.4.1 (2025-01-24) ------------------ diff --git a/docs/reference/plugins.rst b/docs/reference/plugins.rst index f8eddf242..9e5dfda48 100644 --- a/docs/reference/plugins.rst +++ b/docs/reference/plugins.rst @@ -17,6 +17,7 @@ lifecycle. /common/craft-parts/reference/plugins/dump_plugin.rst /common/craft-parts/reference/plugins/go_plugin.rst /common/craft-parts/reference/plugins/go_use_plugin.rst + /common/craft-parts/reference/plugins/jlink_plugin.rst /common/craft-parts/reference/plugins/make_plugin.rst /common/craft-parts/reference/plugins/maven_plugin.rst /common/craft-parts/reference/plugins/meson_plugin.rst diff --git a/tests/integration/plugins/test_jlink.py b/tests/integration/plugins/test_jlink.py new file mode 100644 index 000000000..f7a6665a1 --- /dev/null +++ b/tests/integration/plugins/test_jlink.py @@ -0,0 +1,163 @@ +# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- +# +# Copyright 2024 Canonical Ltd. +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License version 3 as published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program. If not, see . + +import subprocess +import textwrap +from pathlib import Path + +import pytest +import yaml +from craft_parts import LifecycleManager, Step, errors + + +def test_jlink_plugin_with_jar(new_dir, partitions): + """Test that jlink produces tailored modules""" + + parts_yaml = textwrap.dedent( + """ + parts: + my-part: + plugin: jlink + source: https://github.com/canonical/chisel-releases + source-type: git + source-branch: ubuntu-24.04 + jlink-jars: ["test.jar"] + after: ["stage-jar"] + stage-jar: + plugin: dump + source: . + """ + ) + parts = yaml.safe_load(parts_yaml) + + # build test jar + Path("Test.java").write_text( + """ + import javax.swing.*; + public class Test { + public static void main(String[] args) { + new JFrame("foo").setVisible(true); + } + } + """ + ) + subprocess.run(["javac", "Test.java"], check=True, capture_output=True) + subprocess.run( + ["jar", "cvf", "test.jar", "Test.class"], check=True, capture_output=True + ) + + lf = LifecycleManager( + parts, application_name="test_jlink", cache_dir=new_dir, partitions=partitions + ) + actions = lf.plan(Step.PRIME) + + with lf.action_executor() as ctx: + ctx.execute(actions) + + # java.desktop module should be included in the image + assert len(list(Path(f"{new_dir}/stage/usr/lib/jvm/").rglob("libawt.so"))) > 0 + + +def test_jlink_plugin_bad_java_home(new_dir, partitions): + """Test that jlink fails when JAVA_HOME is + set incorrectly.""" + parts_yaml = textwrap.dedent( + """ + parts: + my-part: + plugin: jlink + source: "https://github.com/canonical/chisel-releases" + source-type: "git" + source-branch: "ubuntu-24.04" + build-environment: + - JAVA_HOME: /bad + """ + ) + parts = yaml.safe_load(parts_yaml) + + lf = LifecycleManager( + parts, application_name="test_jlink", cache_dir=new_dir, partitions=partitions + ) + actions = lf.plan(Step.PRIME) + + with pytest.raises( + errors.PluginBuildError, + match="Failed to run the build script for part 'my-part", + ) as pe: + with lf.action_executor() as ctx: + ctx.execute(actions) + + assert "Error: JAVA_HOME: '/bad/bin/java' is not an executable." in str( + pe.value.stderr + ) + + +def test_jlink_plugin_java_home(new_dir, partitions): + + parts_yaml = textwrap.dedent( + """ + parts: + my-part: + plugin: jlink + source: "https://github.com/canonical/chisel-releases" + source-type: "git" + source-branch: "ubuntu-24.04" + build-environment: + - JAVA_HOME: /usr/lib/jvm/java-17-openjdk-${CRAFT_ARCH_BUILD_FOR} + """ + ) + parts = yaml.safe_load(parts_yaml) + + lf = LifecycleManager( + parts, application_name="test_jlink", cache_dir=new_dir, partitions=partitions + ) + actions = lf.plan(Step.PRIME) + + with lf.action_executor() as ctx: + ctx.execute(actions) + + java_release = Path( + new_dir + / f"stage/usr/lib/jvm/java-17-openjdk-{lf.project_info.target_arch}/release" + ) + assert 'JAVA_VERSION="17.' in java_release.read_text() + + +def test_jlink_plugin_base(new_dir, partitions): + """Test that jlink produces base image""" + + parts_yaml = textwrap.dedent( + """ + parts: + my-part: + plugin: jlink + source: "https://github.com/canonical/chisel-releases" + source-type: "git" + source-branch: "ubuntu-24.04" + """ + ) + parts = yaml.safe_load(parts_yaml) + + lf = LifecycleManager( + parts, application_name="test_jlink", cache_dir=new_dir, partitions=partitions + ) + actions = lf.plan(Step.PRIME) + + with lf.action_executor() as ctx: + ctx.execute(actions) + + java = new_dir / "stage/usr/bin/java" + assert java.isfile() diff --git a/tests/unit/plugins/test_jlink_plugin.py b/tests/unit/plugins/test_jlink_plugin.py new file mode 100644 index 000000000..371c0c7cf --- /dev/null +++ b/tests/unit/plugins/test_jlink_plugin.py @@ -0,0 +1,51 @@ +# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- +# +# Copyright 2025 Canonical Ltd. +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License version 3 as published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program. If not, see . + + +import pytest +from craft_parts.infos import PartInfo, ProjectInfo +from craft_parts.parts import Part +from craft_parts.plugins.jlink_plugin import JLinkPlugin + + +@pytest.fixture +def part_info(new_dir): + return PartInfo( + project_info=ProjectInfo(application_name="test", cache_dir=new_dir), + part=Part("my-part", {}), + ) + + +def test_jlink_plugin_defaults(part_info): + """Validate default settings of jlink plugin.""" + properties = JLinkPlugin.properties_class.unmarshal({"source": "."}) + plugin = JLinkPlugin(properties=properties, part_info=part_info) + + assert ( + "DEST=usr/lib/jvm/java-${JLINK_VERSION%%.*}-openjdk-${CRAFT_ARCH_BUILD_FOR}" + in plugin.get_build_commands() + ) + assert plugin.get_build_environment() == {} + + +def test_jlink_plugin_jar_files(part_info): + """Validate setting of jlink version.""" + properties = JLinkPlugin.properties_class.unmarshal( + {"source": ".", "jlink-jars": ["foo.jar"]} + ) + plugin = JLinkPlugin(properties=properties, part_info=part_info) + + assert "PROCESS_JARS=${CRAFT_STAGE}/foo.jar" in plugin.get_build_commands()