Skip to content

Commit 4702cc5

Browse files
nigredo-torimuuki88
authored andcommitted
Fix dependency handling in JlinkPlugin (+ general improvements) (#1226)
* Fix missing dependency handling in JlinkPlugin * Switch to OpenJDK 12 for JLink test * Add comments/documentation following PR discussion
1 parent b9228fd commit 4702cc5

File tree

11 files changed

+205
-7
lines changed

11 files changed

+205
-7
lines changed

.travis.yml

+4
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,10 @@ jobs:
8181
name: "scripted jlink tests"
8282
jdk: oraclejdk11
8383
if: type = pull_request OR (type = push AND branch = master)
84+
- script: sbt "^validateJlink"
85+
name: "scripted jlink tests"
86+
jdk: openjdk12
87+
if: type = pull_request OR (type = push AND branch = master)
8488
- script: sbt "^validateDocker"
8589
name: "scripted docker integration-tests"
8690
if: type = pull_request OR (type = push AND branch = master)

src/main/scala/com/typesafe/sbt/packager/archetypes/jlink/JlinkKeys.scala

+13
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,19 @@ private[packager] trait JlinkKeys {
1111
val jlinkBundledJvmLocation =
1212
TaskKey[String]("jlinkBundledJvmLocation", "The location of the resulting JVM image")
1313

14+
val jlinkModules = TaskKey[Seq[String]]("jlinkModules", "Modules to link")
15+
16+
val jlinkIgnoreMissingDependency =
17+
TaskKey[((String, String)) => Boolean](
18+
"jlinkIgnoreMissingDependency",
19+
"""A hook to mask missing package dependency issues.
20+
|This receives a pair of dependent and dependee packages (where the dependee package is NOT
21+
|present in the classpath), and returns true if this dependency should be ignored. Any
22+
|missing dependencies that are not ignored will result in an error when running
23+
|jlinkBuildImage.
24+
""".stripMargin
25+
)
26+
1427
val jlinkOptions =
1528
TaskKey[Seq[String]]("jlinkOptions", "Options for the jlink utility")
1629

src/main/scala/com/typesafe/sbt/packager/archetypes/jlink/JlinkPlugin.scala

+110-7
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,9 @@ import com.typesafe.sbt.packager.universal.UniversalPlugin
2929
*/
3030
object JlinkPlugin extends AutoPlugin {
3131

32-
object autoImport extends JlinkKeys
32+
object autoImport extends JlinkKeys {
33+
val JlinkIgnore = JlinkPlugin.Ignore
34+
}
3335

3436
import autoImport._
3537

@@ -39,15 +41,61 @@ object JlinkPlugin extends AutoPlugin {
3941
target in jlinkBuildImage := target.value / "jlink" / "output",
4042
jlinkBundledJvmLocation := "jre",
4143
bundledJvmLocation := Some(jlinkBundledJvmLocation.value),
42-
jlinkOptions := (jlinkOptions ?? Nil).value,
43-
jlinkOptions ++= {
44+
jlinkIgnoreMissingDependency :=
45+
(jlinkIgnoreMissingDependency ?? JlinkIgnore.nothing).value,
46+
// Don't use `fullClasspath in Compile` directly - this way we can inject
47+
// custom classpath elements for the scan.
48+
fullClasspath in jlinkBuildImage := (fullClasspath in Compile).value,
49+
jlinkModules := (jlinkModules ?? Nil).value,
50+
jlinkModules ++= {
4451
val log = streams.value.log
4552
val run = runJavaTool(javaHome.in(jlinkBuildImage).value, log) _
53+
val paths = fullClasspath.in(jlinkBuildImage).value.map(_.data.getPath)
54+
val shouldIgnore = jlinkIgnoreMissingDependency.value
55+
56+
// Jdeps has a few convenient options (like --print-module-deps), but those
57+
// are not flexible enough - we need to parse the full output.
58+
val output = run("jdeps", "-R" +: paths) !! log
59+
60+
val deps = output.linesIterator
61+
// There are headers in some of the lines - ignore those.
62+
.flatMap(PackageDependency.parse(_).iterator)
63+
.toSeq
64+
65+
// Check that we don't have any dangling dependencies that were not
66+
// explicitly ignored.
67+
val missingDeps = deps
68+
.collect {
69+
case PackageDependency(dependent, dependee, PackageDependency.NotFound) =>
70+
(dependent, dependee)
71+
}
72+
.filterNot(shouldIgnore)
73+
.distinct
74+
75+
if (missingDeps.nonEmpty) {
76+
log.error(
77+
"Dependee packages not found in classpath. You can use jlinkIgnoreMissingDependency to silence these."
78+
)
79+
missingDeps.foreach {
80+
case (a, b) =>
81+
log.error(s" $a -> $b")
82+
}
83+
sys.error("Missing package dependencies")
84+
}
4685

47-
val paths = fullClasspath.in(Compile).value.map(_.data.getPath)
48-
val modules =
49-
(run("jdeps", "-R" +: "--print-module-deps" +: paths) !! log).trim
50-
.split(",")
86+
// Collect all the found modules
87+
deps.collect {
88+
case PackageDependency(_, _, PackageDependency.Module(module)) =>
89+
module
90+
}.distinct
91+
},
92+
jlinkOptions := (jlinkOptions ?? Nil).value,
93+
jlinkOptions ++= {
94+
val modules = jlinkModules.value
95+
96+
if (modules.isEmpty) {
97+
sys.error("jlinkModules is empty")
98+
}
5199

52100
JlinkOptions(addModules = modules, output = Some(target.in(jlinkBuildImage).value))
53101
},
@@ -102,4 +150,59 @@ object JlinkPlugin extends AutoPlugin {
102150
private def list(arg: String, values: Seq[String]): Seq[String] =
103151
if (values.nonEmpty) Seq(arg, values.mkString(",")) else Nil
104152
}
153+
154+
// Jdeps output row
155+
private final case class PackageDependency(dependent: String, dependee: String, source: PackageDependency.Source)
156+
157+
private final object PackageDependency {
158+
sealed trait Source
159+
160+
object Source {
161+
def parse(s: String): Source = s match {
162+
case "not found" => NotFound
163+
// We have no foolproof way to separate jars from modules here, so
164+
// we have to do something flaky.
165+
case name
166+
if name.toLowerCase.endsWith(".jar") ||
167+
!name.contains('.') ||
168+
name.contains(' ') =>
169+
JarOrDir(name)
170+
case name => Module(name)
171+
}
172+
}
173+
174+
case object NotFound extends Source
175+
final case class Module(name: String) extends Source
176+
final case class JarOrDir(name: String) extends Source
177+
178+
// Examples of package dependencies in jdeps output (whitespace may vary,
179+
// but there will always be some leading whitespace):
180+
// Dependency on a package(java.lang) in a module (java.base):
181+
// foo.bar -> java.lang java.base
182+
// Dependency on a package (scala.collection) in a JAR
183+
// (scala-library-2.12.8.jar):
184+
// foo.bar -> scala.collection scala-library-2.12.8.jar
185+
// Dependency on a package (foo.baz) in a class directory (classes):
186+
// foo.bar -> foo.baz classes
187+
// Missing dependency on a package (qux.quux):
188+
// foo.bar -> qux.quux not found
189+
// There are also jar/directory/module-level dependencies, but we are
190+
// not interested in those:
191+
// foo.jar -> scala-library-2.12.8.jar
192+
// classes -> java.base
193+
// foo.jar -> not found
194+
private val pattern = """^\s+([^\s]+)\s+->\s+([^\s]+)\s+([^\s].*?)\s*$""".r
195+
196+
def parse(s: String): Option[PackageDependency] = s match {
197+
case pattern(dependent, dependee, source) =>
198+
Some(PackageDependency(dependent, dependee, Source.parse(source)))
199+
case _ => None
200+
}
201+
}
202+
203+
object Ignore {
204+
val nothing: ((String, String)) => Boolean = Function.const(false)
205+
val everything: ((String, String)) => Boolean = Function.const(true)
206+
def only(dependencies: (String, String)*): ((String, String)) => Boolean = dependencies.toSet.contains
207+
}
105208
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
package bar;
2+
3+
public class Bar {}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
// Tests jlink behavior with missing dependencies.
2+
3+
import scala.sys.process.Process
4+
import com.typesafe.sbt.packager.Compat._
5+
6+
7+
// Exclude Scala to simplify the test
8+
autoScalaLibrary in ThisBuild := false
9+
10+
// Simulate a missing dependency (foo -> bar)
11+
lazy val foo = project.dependsOn(bar % "provided")
12+
lazy val bar = project
13+
14+
lazy val withoutIgnore = project.dependsOn(foo)
15+
.enablePlugins(JlinkPlugin)
16+
17+
lazy val withIgnore = project.dependsOn(foo)
18+
.enablePlugins(JlinkPlugin)
19+
.settings(
20+
jlinkIgnoreMissingDependency := JlinkIgnore.only("foo" -> "bar")
21+
)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
package foo;
2+
3+
public class Foo {
4+
public Foo() {
5+
new bar.Bar();
6+
}
7+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
val pluginVersion = sys.props("project.version")
3+
if (pluginVersion == null)
4+
throw new RuntimeException("""|The system property 'project.version' is not defined.
5+
|Specify this property using the scriptedLaunchOpts -D.""".stripMargin)
6+
else
7+
addSbtPlugin("com.typesafe.sbt" % "sbt-native-packager" % sys.props("project.version"))
8+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
> compile
2+
# Should fail since we have a missing dependency.
3+
-> withoutIgnore/jlinkBuildImage
4+
# Should work OK since the issue is silenced
5+
> withIgnore/jlinkBuildImage
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
class WithIgnore {
2+
public WithIgnore() {
3+
new foo.Foo();
4+
}
5+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
class WithoutIgnore {
2+
public WithoutIgnore() {
3+
new foo.Foo();
4+
}
5+
}

src/sphinx/archetypes/misc_archetypes.rst

+24
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,30 @@ addressed in the current plugin version.
4141
This plugin must be run on the platform of the target installer. The tooling does *not*
4242
provide a means of creating, say, Windows installers on MacOS, or MacOS on Linux, etc.
4343

44+
The plugin analyzes the dependencies between packages using `jdeps`, and raises an error in case of a missing dependency (e.g. for a provided transitive dependency). The missing dependencies can be suppressed on a case-by-case basis (e.g. if you are sure the missing dependency is properly handled):
45+
46+
.. code-block:: scala
47+
48+
jlinkIgnoreMissingDependency := JlinkIgnore.only(
49+
"foo.bar" -> "bar.baz",
50+
"foo.bar" -> "bar.qux"
51+
)
52+
53+
For large projects with a lot of dependencies this can get unwieldy. You can implement a more flexible ignore strategy:
54+
55+
.. code-block:: scala
56+
57+
jlinkIgnoreMissingDependency := {
58+
case ("foo.bar", dependee) if dependee.startsWith("bar") => true
59+
case _ => false
60+
}
61+
62+
Otherwise you may opt out of the check altogether (which is not recommended):
63+
64+
.. code-block:: scala
65+
66+
jlinkIgnoreMissingDependency := JlinkIgnore.everything
67+
4468
For further details on the capabilities of `jlink`, see the
4569
`jlink <https://docs.oracle.com/en/java/javase/11/tools/jlink.html>`_ and
4670
`jdeps <https://docs.oracle.com/en/java/javase/11/tools/jdeps.html>`_ references.

0 commit comments

Comments
 (0)