diff --git a/docs/container-java_main.md b/docs/container-java_main.md index 62d65f14e..f2bcfc92b 100644 --- a/docs/container-java_main.md +++ b/docs/container-java_main.md @@ -10,7 +10,7 @@ Command line arguments may optionally be configured. - + @@ -23,17 +23,25 @@ If the application uses Spring, [Spring profiles][] can be specified by setting ## Spring Boot -If the main class is Spring Boot's `JarLauncher`, `PropertiesLauncher` or `WarLauncher`, the Java Main Container adds a `--server.port` argument to the command so that the application uses the correct port. +If `java_main_class` is set to one of Spring Boot's launchers (`JarLauncher`, `PropertiesLauncher` or `WarLauncher`), the Java Main Container sets `SERVER_PORT=$PORT` so that the application binds to the CF-assigned port. ## Configuration For general information on configuring the buildpack, including how to specify configuration values through environment variables, refer to [Configuration and Extension][]. -The container can be configured by modifying the `config/java_main.yml` file in the buildpack fork. +The container can be configured using the `JBP_CONFIG_JAVA_MAIN` environment variable. | Name | Description | ---- | ----------- | `arguments` | Optional command line arguments to be passed to the Java main class. The arguments are specified as a single YAML scalar in plain style or enclosed in single or double quotes. -| `java_main_class` | Optional Java class name to run. Values containing whitespace are rejected with an error, but all others values appear without modification on the Java command line. If not specified, the Java Manifest value of `Main-Class` is used. +| `java_main_class` | Optional Java class name to run. Values containing whitespace are rejected with an error, but all others values appear without modification on the Java command line. If not specified, the Java Manifest value of `Main-Class` is used. Setting this overrides container detection — even Spring Boot apps will use the Java Main container when this is set. + +### Example: PropertiesLauncher with external config + +```yaml +env: + JBP_CONFIG_JAVA_MAIN: '{java_main_class: "org.springframework.boot.loader.launch.PropertiesLauncher", arguments: "--loader.home=/home/vcap/data"}' + JAVA_OPTS: '-Dloader.path=/home/vcap/data/lib' +``` [Configuration and Extension]: ../README.md#configuration-and-extension [Spring profiles]:http://blog.springsource.com/2011/02/14/spring-3-1-m1-introducing-profile/ diff --git a/src/integration/java_main_test.go b/src/integration/java_main_test.go index 2a6e958ad..954ddd83f 100644 --- a/src/integration/java_main_test.go +++ b/src/integration/java_main_test.go @@ -62,6 +62,16 @@ func testJavaMain(platform switchblade.Platform, fixtures string) func(*testing. // Verify buildpack detects and applies explicit main class configuration Expect(logs.String()).To(ContainSubstring("Java Buildpack")) Expect(logs.String()).To(ContainSubstring("Java Main")) + + // NOTE: this test does NOT verify that java_main_class actually overrides the + // manifest Main-Class, because: + // 1. The fixture's MANIFEST.MF already has Main-Class: io.pivotal.SimpleJava + // (same value as JBP_CONFIG_JAVA_MAIN), so the test passes even if the + // config is ignored. + // 2. switchblade's Deployment struct does not expose the release command, + // so we cannot assert -cp vs -jar or which class was used. + // The override behaviour and -cp mode are covered by unit tests in + // src/java/containers/java_main_test.go. }) }) @@ -81,6 +91,8 @@ func testJavaMain(platform switchblade.Platform, fixtures string) func(*testing. // Verify app can start (validates command with arguments is valid) Eventually(deployment.ExternalURL).ShouldNot(BeEmpty()) + // NOTE: does not verify arguments are actually appended to the command line; + // that is covered by unit tests in src/java/containers/java_main_test.go. }) }) @@ -98,6 +110,8 @@ func testJavaMain(platform switchblade.Platform, fixtures string) func(*testing. // Verify buildpack stages successfully with JAVA_OPTS Expect(logs.String()).To(ContainSubstring("Java Buildpack")) Expect(logs.String()).To(ContainSubstring("Java Main")) + // NOTE: does not verify JAVA_OPTS are applied to the JVM command line; + // staging success only confirms the options did not break the build. }) }) diff --git a/src/java/containers/container.go b/src/java/containers/container.go index d525a9916..eab82e50c 100644 --- a/src/java/containers/container.go +++ b/src/java/containers/container.go @@ -39,8 +39,26 @@ func (r *Registry) Register(c Container) { r.containers = append(r.containers, c) } -// Detect finds the first container that can handle the application +// Detect finds the first container that can handle the application. +// If JBP_CONFIG_JAVA_MAIN specifies an explicit java_main_class, the Java Main +// container is selected unconditionally — before the normal priority order — +// so it can override higher-priority containers such as Spring Boot. func (r *Registry) Detect() (Container, string, error) { + cfg := loadJavaMainConfig(r.context.Log) + if cfg.JavaMainClass != "" { + for _, container := range r.containers { + if _, ok := container.(*JavaMainContainer); ok { + name, err := container.Detect() + if err != nil { + return nil, "", err + } + if name != "" { + return container, name, nil + } + } + } + } + for _, container := range r.containers { name, err := container.Detect() if err != nil { diff --git a/src/java/containers/container_test.go b/src/java/containers/container_test.go index df67fa514..d130296b3 100644 --- a/src/java/containers/container_test.go +++ b/src/java/containers/container_test.go @@ -84,6 +84,37 @@ var _ = Describe("Container Registry", func() { }) }) + Context("with Spring Boot app and JBP_CONFIG_JAVA_MAIN java_main_class set", func() { + BeforeEach(func() { + // App looks like Spring Boot + os.MkdirAll(filepath.Join(buildDir, "BOOT-INF"), 0755) + os.MkdirAll(filepath.Join(buildDir, "META-INF"), 0755) + manifest := "Manifest-Version: 1.0\nStart-Class: com.example.App\nSpring-Boot-Version: 2.7.0\n" + os.WriteFile(filepath.Join(buildDir, "META-INF", "MANIFEST.MF"), []byte(manifest), 0644) + os.Setenv("JBP_CONFIG_JAVA_MAIN", `{java_main_class: "org.springframework.boot.loader.launch.PropertiesLauncher", arguments: "--loader.home=/home/vcap/data"}`) + }) + + AfterEach(func() { + os.Unsetenv("JBP_CONFIG_JAVA_MAIN") + }) + + It("selects Java Main container instead of Spring Boot", func() { + container, name, err := registry.Detect() + Expect(err).NotTo(HaveOccurred()) + Expect(container).NotTo(BeNil()) + Expect(name).To(Equal("Java Main")) + }) + + It("uses the configured class and arguments in the start command", func() { + container, _, err := registry.Detect() + Expect(err).NotTo(HaveOccurred()) + cmd, err := container.Release() + Expect(err).NotTo(HaveOccurred()) + Expect(cmd).To(ContainSubstring("org.springframework.boot.loader.launch.PropertiesLauncher")) + Expect(cmd).To(ContainSubstring("--loader.home=/home/vcap/data")) + }) + }) + Context("with no detectable app", func() { It("returns nil container", func() { container, name, err := registry.Detect() diff --git a/src/java/containers/java_main.go b/src/java/containers/java_main.go index d75b7a1af..99570f339 100644 --- a/src/java/containers/java_main.go +++ b/src/java/containers/java_main.go @@ -11,6 +11,25 @@ import ( "github.com/cloudfoundry/java-buildpack/src/java/common" ) +type javaMainConfig struct { + JavaMainClass string `yaml:"java_main_class"` + Arguments string `yaml:"arguments"` +} + +func loadJavaMainConfig(log interface{ Warning(string, ...interface{}) }) javaMainConfig { + cfg := javaMainConfig{} + raw := os.Getenv("JBP_CONFIG_JAVA_MAIN") + if raw == "" { + return cfg + } + yamlHandler := common.YamlHandler{} + if err := yamlHandler.ValidateFields([]byte(raw), &cfg); err != nil { + log.Warning("Unknown JBP_CONFIG_JAVA_MAIN values: %s", err.Error()) + } + _ = yamlHandler.Unmarshal([]byte(raw), &cfg) + return cfg +} + // JavaMainContainer handles standalone JAR applications with a main class type JavaMainContainer struct { context *common.Context @@ -29,6 +48,14 @@ func NewJavaMainContainer(ctx *common.Context) *JavaMainContainer { func (j *JavaMainContainer) Detect() (string, error) { buildDir := j.context.Stager.BuildDir() + // JBP_CONFIG_JAVA_MAIN with java_main_class always wins (Ruby parity) + cfg := loadJavaMainConfig(j.context.Log) + if cfg.JavaMainClass != "" { + j.mainClass = cfg.JavaMainClass + j.context.Log.Debug("Detected Java Main application via JBP_CONFIG_JAVA_MAIN: %s", j.mainClass) + return "Java Main", nil + } + // Look for JAR files with Main-Class manifest mainClass, jarFile := j.findMainClass(buildDir) if mainClass != "" { @@ -168,6 +195,20 @@ func (j *JavaMainContainer) Supply() error { return nil } +// isSpringBootLauncher returns true if the given class is one of the Spring Boot launchers. +func isSpringBootLauncher(mainClass string) bool { + switch mainClass { + case "org.springframework.boot.loader.JarLauncher", + "org.springframework.boot.loader.WarLauncher", + "org.springframework.boot.loader.PropertiesLauncher", + "org.springframework.boot.loader.launch.JarLauncher", + "org.springframework.boot.loader.launch.WarLauncher", + "org.springframework.boot.loader.launch.PropertiesLauncher": + return true + } + return false +} + // Finalize performs final Java Main configuration func (j *JavaMainContainer) Finalize() error { j.context.Log.BeginStep("Finalizing Java Main") @@ -180,6 +221,17 @@ func (j *JavaMainContainer) Finalize() error { profileScript := fmt.Sprintf("export CLASSPATH=\"%s${CLASSPATH:+:$CLASSPATH}\"\n", classpath) + // Ruby parity: set SERVER_PORT=$PORT when the main class is a Spring Boot launcher + // so the app binds to the CF-assigned port. + cfg := loadJavaMainConfig(j.context.Log) + mainClass := cfg.JavaMainClass + if mainClass == "" { + mainClass = j.mainClass + } + if isSpringBootLauncher(mainClass) { + profileScript += "export SERVER_PORT=$PORT\n" + } + if err := j.context.Stager.WriteProfileD("java_main.sh", profileScript); err != nil { return fmt.Errorf("failed to write java_main.sh profile.d script: %w", err) } @@ -230,10 +282,23 @@ func (j *JavaMainContainer) buildClasspath() (string, error) { // Release returns the Java Main startup command func (j *JavaMainContainer) Release() (string, error) { + cfg := loadJavaMainConfig(j.context.Log) + + args := "" + if cfg.Arguments != "" { + args = " " + cfg.Arguments + } + + // JBP_CONFIG_JAVA_MAIN java_main_class takes precedence over manifest Main-Class. + // Use classpath mode so the configured class is actually invoked (not the manifest's). + if cfg.JavaMainClass != "" { + return fmt.Sprintf("eval exec $JAVA_HOME/bin/java $JAVA_OPTS -cp ${CLASSPATH}${CONTAINER_SECURITY_PROVIDER:+:$CONTAINER_SECURITY_PROVIDER} %s%s", cfg.JavaMainClass, args), nil + } + if j.jarFile != "" { // JAR has its own Main-Class in the manifest — java -jar handles it // Use eval to properly handle backslash-escaped values in $JAVA_OPTS (Ruby buildpack parity) - return fmt.Sprintf("eval exec $JAVA_HOME/bin/java $JAVA_OPTS -jar %s", j.jarFile), nil + return fmt.Sprintf("eval exec $JAVA_HOME/bin/java $JAVA_OPTS -jar %s%s", j.jarFile, args), nil } // Classpath mode: need an explicit main class @@ -247,5 +312,5 @@ func (j *JavaMainContainer) Release() (string, error) { } // Use eval to properly handle backslash-escaped values in $JAVA_OPTS (Ruby buildpack parity) - return fmt.Sprintf("eval exec $JAVA_HOME/bin/java $JAVA_OPTS -cp ${CLASSPATH}${CONTAINER_SECURITY_PROVIDER:+:$CONTAINER_SECURITY_PROVIDER} %s", mainClass), nil + return fmt.Sprintf("eval exec $JAVA_HOME/bin/java $JAVA_OPTS -cp ${CLASSPATH}${CONTAINER_SECURITY_PROVIDER:+:$CONTAINER_SECURITY_PROVIDER} %s%s", mainClass, args), nil } diff --git a/src/java/containers/java_main_test.go b/src/java/containers/java_main_test.go index b5e083e4d..2a0d0f0cd 100644 --- a/src/java/containers/java_main_test.go +++ b/src/java/containers/java_main_test.go @@ -197,6 +197,89 @@ var _ = Describe("Java Main Container", func() { }) }) + Context("with JBP_CONFIG_JAVA_MAIN java_main_class overriding manifest Main-Class", func() { + // Ruby parity: config[MAIN_CLASS_PROPERTY] takes precedence over manifest Main-Class + // This is how PropertiesLauncher is used with Spring Boot exploded JARs + BeforeEach(func() { + os.Setenv("JBP_CONFIG_JAVA_MAIN", "{java_main_class: org.springframework.boot.loader.launch.PropertiesLauncher}") + Expect(createJar( + filepath.Join(buildDir, "app.jar"), + "Manifest-Version: 1.0\nMain-Class: org.springframework.boot.loader.JarLauncher\n", + )).To(Succeed()) + }) + + AfterEach(func() { + os.Unsetenv("JBP_CONFIG_JAVA_MAIN") + }) + + It("uses the configured java_main_class instead of the manifest Main-Class", func() { + container.Detect() + cmd, err := container.Release() + Expect(err).NotTo(HaveOccurred()) + Expect(cmd).To(ContainSubstring("org.springframework.boot.loader.launch.PropertiesLauncher")) + Expect(cmd).NotTo(ContainSubstring("JarLauncher")) + }) + + It("uses classpath mode (not java -jar) so the overridden main class is actually invoked", func() { + container.Detect() + cmd, err := container.Release() + Expect(err).NotTo(HaveOccurred()) + Expect(cmd).NotTo(ContainSubstring("-jar")) + Expect(cmd).To(ContainSubstring("-cp")) + }) + }) + + Context("with JBP_CONFIG_JAVA_MAIN java_main_class on app with no manifest Main-Class", func() { + BeforeEach(func() { + os.Setenv("JBP_CONFIG_JAVA_MAIN", "{java_main_class: com.example.CustomMain}") + // App has no Main-Class in manifest — detection still works via JBP_CONFIG_JAVA_MAIN + os.WriteFile(filepath.Join(buildDir, "app.jar"), []byte("fake"), 0644) + }) + + AfterEach(func() { + os.Unsetenv("JBP_CONFIG_JAVA_MAIN") + }) + + It("detects as Java Main application", func() { + name, err := container.Detect() + Expect(err).NotTo(HaveOccurred()) + Expect(name).To(Equal("Java Main")) + }) + + It("uses the configured main class", func() { + container.Detect() + cmd, err := container.Release() + Expect(err).NotTo(HaveOccurred()) + Expect(cmd).To(ContainSubstring("com.example.CustomMain")) + }) + }) + + Context("with JBP_CONFIG_JAVA_MAIN arguments", func() { + AfterEach(func() { + os.Unsetenv("JBP_CONFIG_JAVA_MAIN") + }) + + It("appends arguments after main class when using java_main_class", func() { + os.Setenv("JBP_CONFIG_JAVA_MAIN", `{java_main_class: com.example.Main, arguments: "--server.port=$PORT"}`) + container.Detect() + cmd, err := container.Release() + Expect(err).NotTo(HaveOccurred()) + Expect(cmd).To(ContainSubstring("com.example.Main --server.port=$PORT")) + }) + + It("appends arguments after main class when using manifest Main-Class", func() { + os.Setenv("JBP_CONFIG_JAVA_MAIN", `{arguments: "--foo=bar"}`) + Expect(createJar( + filepath.Join(buildDir, "app.jar"), + "Manifest-Version: 1.0\nMain-Class: com.example.Main\n", + )).To(Succeed()) + container.Detect() + cmd, err := container.Release() + Expect(err).NotTo(HaveOccurred()) + Expect(cmd).To(ContainSubstring("--foo=bar")) + }) + }) + Context("without main class or JAR", func() { It("returns error", func() { _, err := container.Release() @@ -303,16 +386,44 @@ var _ = Describe("Java Main Container", func() { }) }) - Context("with empty build directory", func() { + Context("with Spring Boot launcher in JBP_CONFIG_JAVA_MAIN", func() { + BeforeEach(func() { + os.Setenv("JBP_CONFIG_JAVA_MAIN", `{java_main_class: "org.springframework.boot.loader.launch.PropertiesLauncher"}`) + os.WriteFile(filepath.Join(buildDir, "app.jar"), []byte("fake"), 0644) + }) + + AfterEach(func() { + os.Unsetenv("JBP_CONFIG_JAVA_MAIN") + }) + + It("writes SERVER_PORT=$PORT to profile.d for Ruby parity", func() { + container.Detect() + err := container.Finalize() + Expect(err).NotTo(HaveOccurred()) + + profileScript := filepath.Join(depsDir, "0", "profile.d", "java_main.sh") + data, err := os.ReadFile(profileScript) + Expect(err).NotTo(HaveOccurred()) + Expect(string(data)).To(ContainSubstring("export SERVER_PORT=$PORT\n")) + }) + }) + + Context("with non-Spring-Boot main class", func() { BeforeEach(func() { os.WriteFile(filepath.Join(buildDir, "Main.class"), []byte("fake"), 0644) }) - It("creates minimal classpath", func() { + It("does not write SERVER_PORT to profile.d", func() { container.Detect() err := container.Finalize() Expect(err).NotTo(HaveOccurred()) + + profileScript := filepath.Join(depsDir, "0", "profile.d", "java_main.sh") + data, err := os.ReadFile(profileScript) + Expect(err).NotTo(HaveOccurred()) + Expect(string(data)).NotTo(ContainSubstring("SERVER_PORT")) }) }) + }) })
Detection CriteriaMain-Class attribute set in META-INF/MANIFEST.MF or java_main_class set in config/java_main.ymlMain-Class attribute set in META-INF/MANIFEST.MF, or java_main_class set in JBP_CONFIG_JAVA_MAIN
Tags