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