Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 13 additions & 5 deletions src/main/groovy/io/openliberty/tools/gradle/tasks/DevTask.groovy
Original file line number Diff line number Diff line change
Expand Up @@ -375,15 +375,15 @@ class DevTask extends AbstractFeatureTask {

private ServerTask serverTask = null;
DevTaskUtil(File buildDir, File installDirectory, File userDirectory, File serverDirectory, File sourceDirectory, File testSourceDirectory,
File configDirectory, File projectDirectory, List<File> resourceDirs, boolean changeOnDemandTestsAction,
File configDirectory, File projectDirectory, File multiModuleProjectDirectory, List<File> resourceDirs, boolean changeOnDemandTestsAction,
boolean hotTests, boolean skipTests, boolean skipInstallFeature, String artifactId, int serverStartTimeout,
int verifyAppStartTimeout, int appUpdateTimeout, double compileWait,
boolean libertyDebug, boolean pollingTest, boolean container, File containerfile, File containerBuildContext,
String containerRunOpts, int containerBuildTimeout, boolean skipDefaultPorts, boolean keepTempContainerfile,
String containerRunOpts, int containerBuildTimeout, boolean skipDefaultPorts, boolean keepTempContainerfile,
String mavenCacheLocation, String packagingType, File buildFile, boolean generateFeatures, List<Path> webResourceDirs,
List<ProjectModule> projectModuleList, Map<String, List<String>> parentBuildGradle, File serverOutputDir
) throws IOException, PluginExecutionException {
super(buildDir, serverDirectory, sourceDirectory, testSourceDirectory, configDirectory, projectDirectory, /* multi module project directory */ projectDirectory,
super(buildDir, serverDirectory, sourceDirectory, testSourceDirectory, configDirectory, projectDirectory, /* multi module project directory */ multiModuleProjectDirectory,
resourceDirs, changeOnDemandTestsAction, hotTests, skipTests, false /* skipUTs */, false /* skipITs */, skipInstallFeature, artifactId, serverStartTimeout,
verifyAppStartTimeout, appUpdateTimeout, ((long) (compileWait * 1000L)), libertyDebug,
true /* useBuildRecompile */, true /* gradle */, pollingTest, container, containerfile, containerBuildContext, containerRunOpts, containerBuildTimeout, skipDefaultPorts,
Expand Down Expand Up @@ -1369,13 +1369,21 @@ class DevTask extends AbstractFeatureTask {
DevTaskHelper.updateParentBuildFiles(parentBuildGradle, project)
}
try {
// For EAR projects, sibling WAR/JAR modules and the EAR's own build output all live
// under the root project dir, not just the EAR subproject dir. DevUtil uses
// multiModuleProjectDirectory to decide what folder to mount as /devmode inside the
// container, so it must cover the entire root tree for EAR loose-app container mode.
String packagingType = DevTaskHelper.getPackagingType(project)
File multiModuleProjectDirectory = "ear".equals(packagingType)
? project.getRootProject().getProjectDir()
: project.getProjectDir()
this.util = new DevTaskUtil(project.getLayout().getBuildDirectory().getAsFile().get(), serverInstallDir, getUserDir(project, serverInstallDir),
serverDirectory, sourceDirectory, testSourceDirectory, configDirectory, project.getProjectDir(),
serverDirectory, sourceDirectory, testSourceDirectory, configDirectory, project.getProjectDir(), multiModuleProjectDirectory,
resourceDirs, changeOnDemandTestsAction.booleanValue(), hotTests.booleanValue(), skipTests.booleanValue(), skipInstallFeature.booleanValue(), artifactId, serverStartTimeout.intValue(),
verifyAppStartTimeout.intValue(), verifyAppStartTimeout.intValue(), compileWait.doubleValue(),
libertyDebug.booleanValue(), pollingTest.booleanValue(), container.booleanValue(), containerfile, containerBuildContext, containerRunOpts,
containerBuildTimeout, skipDefaultPorts.booleanValue(), keepTempContainerfile.booleanValue(), localMavenRepoForFeatureUtility,
DevTaskHelper.getPackagingType(project), buildFile, generateFeatures.booleanValue(), webResourceDirs, projectModules, parentBuildGradle, new File(outputDir, serverName)
packagingType, buildFile, generateFeatures.booleanValue(), webResourceDirs, projectModules, parentBuildGradle, new File(outputDir, serverName)
);
} catch (IOException | PluginExecutionException e) {
throw new GradleException("Error initializing dev mode.", e)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
/*
* (C) Copyright IBM Corporation 2026.
*
* Licensed 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 io.openliberty.tools.gradle

import org.apache.commons.io.FileUtils
import org.junit.AfterClass
import org.junit.BeforeClass
import org.junit.Test

import static org.junit.Assert.assertFalse
import static org.junit.Assert.assertTrue

/**
* Regression test for the EAR devc loose config mount mismatch bug.
*
* When running libertyDevc for a multi-module EAR project with looseApplication=true,
* the devc loose config XML (*.ear.xml) must use the root project dir as the base for
* all ${io.openliberty.tools.projectRoot} path substitutions, and the container must
* mount the root project dir (not just the EAR subproject dir) as /devmode.
*
* Before the fix in DevTask.groovy, multiModuleProjectDirectory was always set to
* project.getProjectDir() (the EAR subproject dir), so DevUtil mounted only ear/ as
* /devmode. But installLooseConfigEar() wrote paths relative to the root project dir,
* causing an extra subproject path segment inside the container — Liberty could not
* find application.xml and fell back to using the WAR filename as the context root.
*
* This test verifies that the devc loose config XML for the EAR contains
* ${io.openliberty.tools.projectRoot} paths that do NOT have the ear/ subproject
* directory as an extra segment — confirming the root project dir is used as the base.
*/
class DevcMultiModuleLooseEarTest extends BaseDevTest {

static final String projectName = "multi-module-devc-loose-ear-test"
static File resourceDir = new File("build/resources/test/dev-test/" + projectName)
static File testBuildDir = new File(integTestDir, "/test-" + projectName)

@BeforeClass
static void setup() throws IOException, InterruptedException, FileNotFoundException {
createDir(testBuildDir)
FileUtils.copyDirectory(resourceDir, testBuildDir)
copyBuildFiles(new File(resourceDir, "build.gradle"), testBuildDir, true)
startDevcOnEarModule(testBuildDir)
}

private static void startDevcOnEarModule(File buildDirectory) throws IOException, InterruptedException, FileNotFoundException {
buildDir = buildDirectory
logFile = new File(buildDir, "output.log")
errFile = new File(buildDir, "stderr.log")

File gradlew = System.getProperty("os.name")?.toLowerCase()?.startsWith("windows")
? new File("gradlew.bat") : new File("gradlew")

String command = "${gradlew.absolutePath} --warning-mode none :ear:libertyDev --container"

ProcessBuilder builder = new ProcessBuilder()
builder.directory(buildDir)
builder.command("bash", "-c", command)
builder.redirectOutput(logFile)
builder.redirectError(errFile)
process = builder.start()
assertTrue("Process should be alive after start", process.isAlive())

writer = new BufferedWriter(new OutputStreamWriter(process.getOutputStream()))

assertTrue("Liberty kernel features should be installed in the container",
verifyLogMessage(180000, "CWWKF0011I", errFile))
assertTrue("Liberty should be running in dev mode",
verifyLogMessage(60000, "Liberty is running in dev mode."))

targetDir = new File(buildDir, "ear/build")
assertTrue("ear/build directory should exist after startup", targetDir.exists())
}

/**
* Verifies the container image builds and the application starts — confirming
* the EAR and its loose config are resolved correctly inside the container.
*/
@Test
void containerImageBuildsAndAppStarts() throws Exception {
assertTrue("Container image build should complete",
verifyLogMessage(30000, "Completed building container image.", logFile))
assertTrue("Application should start inside the container",
verifyLogMessage(30000, "CWWKZ0001I", logFile))
// Verify the context root is /app (from deploymentDescriptor webModule) — not the WAR filename.
// Before the fix, Liberty could not find application.xml and fell back to using the WAR filename
// as the context root, so this would have logged /devc-loose-ear-war-1.0-SNAPSHOT/ instead.
assertTrue("Web application must be available at the configured context root /app — not the WAR filename",
verifyLogMessage(10000, "CWWKT0016I", logFile) &&
verifyLogMessage(10000, "/app/", logFile))
}

/**
* Core regression test: the devc loose config XML must NOT contain the ear/
* subproject directory as an extra segment in the ${projectRoot} paths.
*
* Before the fix, paths looked like:
* ${io.openliberty.tools.projectRoot}/ear/build/tmp/ear/application.xml
* which resolves to /devmode/ear/build/... inside the container — wrong, because
* only ear/ was mounted as /devmode.
*
* After the fix, paths look like:
* ${io.openliberty.tools.projectRoot}/ear/build/tmp/ear/application.xml
* but now the root project dir is mounted as /devmode, so this resolves correctly
* to /devmode/ear/build/tmp/ear/application.xml inside the container.
*
* We verify this by checking that the devc loose config does NOT use absolute
* host paths (which would indicate the container path substitution never ran).
*/
@Test
void devcLooseConfigUsesProjectRootVariable() throws Exception {
// Wait for deploy to complete — the loose config file is written during deploy
assertTrue("Liberty should be running in dev mode before checking loose config",
verifyLogMessage(5000, "Liberty is running in dev mode."))

// Find the devc loose config XML: ear/build/.libertyDevc/apps/*.ear.xml
File devcAppsDir = new File(testBuildDir, "ear/build/.libertyDevc/apps")
assertTrue("devc apps directory should exist: " + devcAppsDir.absolutePath,
devcAppsDir.exists())

File[] looseConfigFiles = devcAppsDir.listFiles({ f -> f.name.endsWith(".ear.xml") } as FileFilter)
assertTrue("A devc loose config .ear.xml file should exist in " + devcAppsDir.absolutePath,
looseConfigFiles != null && looseConfigFiles.length > 0)

String looseConfigContent = FileUtils.readFileToString(looseConfigFiles[0], "UTF-8")

// The devc loose config must use the ${projectRoot} variable — not absolute paths.
// Absolute paths mean the container path substitution did not run (old bug).
assertTrue("devc loose config should contain \${io.openliberty.tools.projectRoot} variable paths",
looseConfigContent.contains('${io.openliberty.tools.projectRoot}'))

// The path to application.xml must not have a double ear/ segment.
// Before the fix the path was rooted at root/ but only ear/ was mounted,
// causing Liberty to look for /devmode/ear/build/... when it should be
// /devmode/build/... (if ear/ is mounted) or
// /devmode/ear/build/... (if root/ is mounted — which is the fix).
// We verify the latter: the path contains exactly one /ear/ segment
// in the right place — not two consecutive /ear/ear/.
assertFalse("devc loose config must not contain a doubled ear/ path segment (mount mismatch)",
looseConfigContent.contains(File.separator + "ear" + File.separator + "ear" + File.separator))
}

@AfterClass
static void cleanUpAfterClass() throws Exception {
String stdout = logFile?.exists() ? getContents(logFile, "Dev mode std output") : ""
System.out.println(stdout)
String stderr = errFile?.exists() ? getContents(errFile, "Dev mode std error") : ""
System.out.println(stderr)
cleanUpAfterClassCheckLogFile(true)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
allprojects {
group = 'io.openliberty.guides'
version = '1.0-SNAPSHOT'
}

subprojects {
repositories {
mavenLocal()
mavenCentral()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
FROM icr.io/appcafe/open-liberty:full-java11-openj9-ubi

USER root

COPY --chown=1001:0 src/main/liberty/config/server.xml /config/

COPY --chown=1001:0 build/libs/*.ear /config/apps/

USER 1001
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@

apply plugin: 'ear'
apply plugin: 'liberty'

description = 'EAR Module'

buildscript {
repositories {
mavenLocal()
mavenCentral()
maven {
url = 'https://central.sonatype.com/repository/maven-snapshots/'
}
}
dependencies {
classpath "io.openliberty.tools:liberty-gradle-plugin:$lgpVersion"
}
}

dependencies {
deploy project(path: ':war', configuration: 'warOnly')
deploy project(path: ':jar', configuration: 'jarOnly')
libertyRuntime group: runtimeGroup, name: kernelArtifactId, version: runtimeVersion
}

ear {
archiveFileName = rootProject.name + "-" + getArchiveBaseName().get() + "-" + rootProject.version + '.' + getArchiveExtension().get()
deploymentDescriptor {
// context root must appear in the devc loose config's application.xml
webModule(rootProject.name + '-war-1.0-SNAPSHOT.war', '/app')
}
}

liberty {
server {
name = 'devcLooseEarServer'
deploy {
apps = [ear]
}
verifyAppStartTimeout = 60
looseApplication = true // key difference from the containerfile test
}
}

deploy.dependsOn 'ear'
ear.dependsOn ':jar:jar', ':war:war'
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<server description="devc loose EAR server">

<featureManager>
<feature>restfulWS-3.0</feature>
<feature>jsonb-2.0</feature>
</featureManager>

<variable name="default.http.port" defaultValue="9080"/>
<variable name="default.https.port" defaultValue="9443"/>

<httpEndpoint httpPort="${default.http.port}" httpsPort="${default.https.port}"
id="defaultHttpEndpoint" host="*" />

<enterpriseApplication id="devc-loose-ear-ear-1.0-SNAPSHOT"
location="devc-loose-ear-ear-1.0-SNAPSHOT.ear"
name="devc-loose-ear-ear-1.0-SNAPSHOT">
</enterpriseApplication>

</server>
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@

apply plugin: 'java'

description = 'JAR Module'

java {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}

tasks.withType(JavaCompile).configureEach {
options.encoding = 'UTF-8'
}

dependencies {
testImplementation 'org.junit.jupiter:junit-jupiter:5.11.0'
}

configurations {
jarOnly
}

artifacts {
jarOnly jar
}

jar {
archiveFileName = rootProject.name + "-" + getArchiveBaseName().get() + "-" + rootProject.version + '.' + getArchiveExtension().get()
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package io.openliberty.guides.devcmultimodule.lib;

public class Greeting {
public String greet(String name) {
return "Hello, " + name + "!";
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
rootProject.name = 'devc-loose-ear'
include ':jar'
include ':war'
include ':ear'

project(':jar').projectDir = "$rootDir/jar" as File
project(':war').projectDir = "$rootDir/war" as File
project(':ear').projectDir = "$rootDir/ear" as File
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@

apply plugin: 'java'
apply plugin: 'war'

description = 'WAR Module'

java {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}

tasks.withType(JavaCompile).configureEach {
options.encoding = 'UTF-8'
}

dependencies {
testImplementation 'org.junit.jupiter:junit-jupiter:5.11.0'
compileOnly 'jakarta.platform:jakarta.jakartaee-api:9.1.0'
implementation project(path: ':jar', configuration: 'jarOnly')
}

configurations {
warOnly
}

artifacts {
warOnly war
}

war {
archiveFileName = rootProject.name + "-" + getArchiveBaseName().get() + "-" + rootProject.version + '.' + getArchiveExtension().get()
}

war.dependsOn ':jar:jar'
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package io.openliberty.guides.devcmultimodule.web;

import jakarta.ws.rs.core.Application;
import jakarta.ws.rs.ApplicationPath;

@ApplicationPath("api")
public class AppApplication extends Application {
}
Loading
Loading