diff --git a/RUBY_VS_GO_BUILDPACK_COMPARISON.md b/RUBY_VS_GO_BUILDPACK_COMPARISON.md index 3afc3e43c..1bd298909 100644 --- a/RUBY_VS_GO_BUILDPACK_COMPARISON.md +++ b/RUBY_VS_GO_BUILDPACK_COMPARISON.md @@ -445,6 +445,7 @@ func (t *TomcatContainer) Supply() error { | **Logging Support** | `TomcatLoggingSupport` | `installTomcatLoggingSupport()` | ✅ Complete | Installs `tomcat-logging-support.jar` (CloudFoundryConsoleHandler) | | **setenv.sh Generation** | `TomcatSetenv` | `createSetenvScript()` | ✅ Complete | Creates `bin/setenv.sh` for CLASSPATH | | **Utils (XML helpers)** | `TomcatUtils` | N/A | ✅ Complete | Go uses standard library XML parsing | +| **Context Path** | `TomcatInstance#root` (webapps dir rename) | `contextXMLFilename()` + context descriptor | ✅ Complete | Same config key; different mechanism — see §2A.11 | | **Geode/GemFire Session Store** | `TomcatGeodeStore` (199 lines) | **❌ Missing** | ❌ Not Implemented | Session clustering for Tanzu GemFire | | **Redis Session Store** | `TomcatRedisStore` (118 lines) | **❌ Missing** | ❌ Not Implemented | Session clustering for Redis | | **Spring Insight Support** | `TomcatInsightSupport` (51 lines) | **❌ Missing** | ⚠️ Deprecated | Spring Insight deprecated by VMware | @@ -849,6 +850,7 @@ cf set-env myapp JBP_CONFIG_TOMCAT '{tomcat: {version: 9.0.+}}' | **External Configuration** | ⚠️ 90% | Go requires manifest (no runtime repository_root) | | **Lifecycle Support** | ✅ 100% | Both detect startup failures | | **Logging Support** | ✅ 100% | Both use CloudFoundryConsoleHandler | +| **Context Path** | ✅ 100% | Same config key and URL behavior; implementation differs (see §2A.11) | | **Session Store Auto-Config** | ⚠️ 0% | Go missing convenience auto-configuration (manual setup possible) | | **Overall** | ⚠️ **95%** | Core features complete; auto-config conveniences missing | @@ -875,6 +877,22 @@ cf set-env myapp JBP_CONFIG_TOMCAT '{tomcat: {version: 9.0.+}}' 3. Read `VCAP_SERVICES` in application code (if needed) 4. Test with Go buildpack → Deploy +### 2A.11 Context Path: Implementation Difference + +Both buildpacks support `context_path` via `JBP_CONFIG_TOMCAT`, but use different Tomcat mechanisms: + +| Aspect | Ruby (4.x) | Go (5.x) | +|--------|-----------|---------| +| **Mechanism** | Deploys app into `tomcat/webapps//` | Writes `conf/Catalina/localhost/.xml` context descriptor | +| **App location** | `tomcat/webapps/foo#bar/` | `${user.home}/app` (unchanged) | +| **Root context prevention** | No `ROOT` directory in webapps | `ROOT.xml` explicitly removed when non-root path set | +| **Config key** | `tomcat.context_path` | `tomcat.context_path` (identical) | +| **URL result** | App at `/foo/bar` only | App at `/foo/bar` only (identical) | + +**Migration impact**: None for users. The `context_path` config key and resulting URL routing are identical between 4.x and 5.x. This is a smooth transition. + +**Caveat**: `ServletContext.getRealPath()` and webapps-relative path assumptions may behave differently — the Go buildpack serves from `${user.home}/app` rather than a webapps subdirectory. This is a pre-existing structural difference between the two buildpacks, not specific to `context_path`. + --- ## 2B. Container Feature Parity: Complete Analysis diff --git a/src/integration/tomcat_test.go b/src/integration/tomcat_test.go index 2952e3be1..edf047beb 100644 --- a/src/integration/tomcat_test.go +++ b/src/integration/tomcat_test.go @@ -441,5 +441,20 @@ func testTomcat(platform switchblade.Platform, fixtures string) func(*testing.T, Eventually(deployment).Should(matchers.Serve(ContainSubstring("OK"))) }) }) + + context("with context_path configured", func() { + it("serves app at configured path and not at root", func() { + deployment, logs, err := platform.Deploy. + WithEnv(map[string]string{ + "BP_JAVA_VERSION": "11", + "JBP_CONFIG_TOMCAT": `{tomcat: {context_path: /my/app}}`, + }). + Execute(name, filepath.Join(fixtures, "containers", "tomcat_jakarta")) + Expect(err).NotTo(HaveOccurred(), logs.String()) + + Eventually(deployment).Should(matchers.Serve(ContainSubstring("OK")).WithEndpoint("/my/app")) + Eventually(deployment).ShouldNot(matchers.Serve(ContainSubstring("OK")).WithEndpoint("/")) + }) + }) } } diff --git a/src/java/containers/tomcat.go b/src/java/containers/tomcat.go index 383e01e12..bab2a1662 100644 --- a/src/java/containers/tomcat.go +++ b/src/java/containers/tomcat.go @@ -553,12 +553,32 @@ func injectDocBase(xmlContent string, docBase string) string { return xmlContent[:idx] + newContextTag + xmlContent[endIdx:] } +// contextXMLFilename converts a context path to a Tomcat context XML filename. +// Tomcat convention: /foo/bar → foo#bar.xml, / or empty → ROOT.xml +func contextXMLFilename(contextPath string) string { + name := strings.Trim(contextPath, "/") + if name == "" { + return "ROOT.xml" + } + name = strings.ReplaceAll(name, "/", "#") + return name + ".xml" +} + // Finalize performs final Tomcat configuration func (t *TomcatContainer) Finalize() error { t.context.Log.BeginStep("Finalizing Tomcat") + if t.config == nil { + var err error + t.config, err = t.loadConfig() + if err != nil { + return fmt.Errorf("failed to load tomcat config: %w", err) + } + } + buildDir := t.context.Stager.BuildDir() - contextXMLPath := filepath.Join(t.tomcatDir(), "conf", "Catalina", "localhost", "ROOT.xml") + contextXMLName := contextXMLFilename(t.config.Tomcat.ContextPath) + contextXMLPath := filepath.Join(t.tomcatDir(), "conf", "Catalina", "localhost", contextXMLName) webInf := filepath.Join(buildDir, "WEB-INF") if _, err := os.Stat(webInf); err == nil { @@ -573,27 +593,73 @@ func (t *TomcatContainer) Finalize() error { return fmt.Errorf("failed to create context directory: %w", err) } - appContextXML := filepath.Join(buildDir, "META-INF", "context.xml") - var contextContent string - - if _, err := os.Stat(appContextXML); err == nil { - xmlBytes, err := os.ReadFile(appContextXML) - if err != nil { - return fmt.Errorf("failed to read META-INF/context.xml: %w", err) + _, statErr := os.Stat(contextXMLPath) + if statErr != nil && !os.IsNotExist(statErr) { + return fmt.Errorf("failed to check context XML %s: %w", contextXMLName, statErr) + } + if os.IsNotExist(statErr) { + appContextXML := filepath.Join(buildDir, "META-INF", "context.xml") + var contextContent string + + if _, err := os.Stat(appContextXML); err == nil { + xmlBytes, err := os.ReadFile(appContextXML) + if err != nil { + return fmt.Errorf("failed to read META-INF/context.xml: %w", err) + } + + xmlStr := string(xmlBytes) + xmlStr = strings.TrimSpace(xmlStr) + + contextContent = injectDocBase(xmlStr, "${user.home}/app") + t.context.Log.Info("Merged META-INF/context.xml with %s - realm and resource configurations preserved", contextXMLName) + } else { + contextContent = fmt.Sprintf("\n\n") + t.context.Log.Info("Created %s with docBase pointing to application directory", contextXMLName) } - xmlStr := string(xmlBytes) - xmlStr = strings.TrimSpace(xmlStr) - - contextContent = injectDocBase(xmlStr, "${user.home}/app") - t.context.Log.Info("Merged META-INF/context.xml with ROOT.xml - realm and resource configurations preserved") + if err := os.WriteFile(contextXMLPath, []byte(contextContent), 0644); err != nil { + return fmt.Errorf("failed to write %s: %w", contextXMLName, err) + } } else { - contextContent = fmt.Sprintf("\n\n") - t.context.Log.Info("Created ROOT.xml with docBase pointing to application directory") + t.context.Log.Info("Context XML %s already exists (e.g. from external config), skipping generation", contextXMLName) } + if contextXMLName != "ROOT.xml" { + rootXMLPath := filepath.Join(t.tomcatDir(), "conf", "Catalina", "localhost", "ROOT.xml") + if err := os.Remove(rootXMLPath); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("failed to remove ROOT.xml: %w", err) + } + } + } else { + warMatches, err := filepath.Glob(filepath.Join(buildDir, "*.war")) + if err == nil && len(warMatches) == 1 { + warFilename := filepath.Base(warMatches[0]) + contextContent := fmt.Sprintf("\n\n", warFilename) + + contextXMLDir := filepath.Dir(contextXMLPath) + if err := os.MkdirAll(contextXMLDir, 0755); err != nil { + return fmt.Errorf("failed to create context directory: %w", err) + } - if err := os.WriteFile(contextXMLPath, []byte(contextContent), 0644); err != nil { - return fmt.Errorf("failed to write ROOT.xml: %w", err) + _, statErr := os.Stat(contextXMLPath) + if statErr != nil && !os.IsNotExist(statErr) { + return fmt.Errorf("failed to check context XML %s: %w", contextXMLName, statErr) + } + if os.IsNotExist(statErr) { + if err := os.WriteFile(contextXMLPath, []byte(contextContent), 0644); err != nil { + return fmt.Errorf("failed to write %s: %w", contextXMLName, err) + } + t.context.Log.Info("Created %s with docBase pointing to %s", contextXMLName, warFilename) + } else { + t.context.Log.Info("Context XML %s already exists (e.g. from external config), skipping generation", contextXMLName) + } + if contextXMLName != "ROOT.xml" { + rootXMLPath := filepath.Join(t.tomcatDir(), "conf", "Catalina", "localhost", "ROOT.xml") + if err := os.Remove(rootXMLPath); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("failed to remove ROOT.xml: %w", err) + } + } + } else if len(warMatches) > 1 { + t.context.Log.Warning("Multiple WAR files found in build directory; context_path not applied") } } @@ -647,6 +713,7 @@ type tomcatConfig struct { type Tomcat struct { Version string `yaml:"version"` ExternalConfigurationEnabled bool `yaml:"external_configuration_enabled"` + ContextPath string `yaml:"context_path"` } type ExternalConfiguration struct { diff --git a/src/java/containers/tomcat_test.go b/src/java/containers/tomcat_test.go index f22426766..a9b24e797 100644 --- a/src/java/containers/tomcat_test.go +++ b/src/java/containers/tomcat_test.go @@ -195,6 +195,162 @@ var _ = Describe("Tomcat Container", func() { Expect(contentStr).NotTo(ContainSubstring("/old/path")) Expect(contentStr).To(ContainSubstring("org.apache.catalina.realm.UserDatabaseRealm")) }) + + It("creates context XML named after context_path when set", func() { + os.Setenv("JBP_CONFIG_TOMCAT", `{tomcat: {context_path: /the/intended/path}}`) + defer os.Unsetenv("JBP_CONFIG_TOMCAT") + + err := container.Finalize() + Expect(err).NotTo(HaveOccurred()) + + tomcatDir := filepath.Join(depsDir, "0", "tomcat") + contextFile := filepath.Join(tomcatDir, "conf", "Catalina", "localhost", "the#intended#path.xml") + Expect(contextFile).To(BeAnExistingFile()) + Expect(filepath.Join(tomcatDir, "conf", "Catalina", "localhost", "ROOT.xml")).NotTo(BeAnExistingFile()) + + content, err := os.ReadFile(contextFile) + Expect(err).NotTo(HaveOccurred()) + Expect(string(content)).To(ContainSubstring("docBase=\"${user.home}/app\"")) + }) + + It("uses ROOT.xml when context_path is empty", func() { + os.Setenv("JBP_CONFIG_TOMCAT", `{tomcat: {context_path: ""}}`) + defer os.Unsetenv("JBP_CONFIG_TOMCAT") + + err := container.Finalize() + Expect(err).NotTo(HaveOccurred()) + + tomcatDir := filepath.Join(depsDir, "0", "tomcat") + Expect(filepath.Join(tomcatDir, "conf", "Catalina", "localhost", "ROOT.xml")).To(BeAnExistingFile()) + }) + + It("uses ROOT.xml when context_path is /", func() { + os.Setenv("JBP_CONFIG_TOMCAT", `{tomcat: {context_path: /}}`) + defer os.Unsetenv("JBP_CONFIG_TOMCAT") + + err := container.Finalize() + Expect(err).NotTo(HaveOccurred()) + + tomcatDir := filepath.Join(depsDir, "0", "tomcat") + Expect(filepath.Join(tomcatDir, "conf", "Catalina", "localhost", "ROOT.xml")).To(BeAnExistingFile()) + }) + + It("normalizes trailing slash in context_path", func() { + os.Setenv("JBP_CONFIG_TOMCAT", `{tomcat: {context_path: /the/intended/path/}}`) + defer os.Unsetenv("JBP_CONFIG_TOMCAT") + + err := container.Finalize() + Expect(err).NotTo(HaveOccurred()) + + tomcatDir := filepath.Join(depsDir, "0", "tomcat") + contextFile := filepath.Join(tomcatDir, "conf", "Catalina", "localhost", "the#intended#path.xml") + Expect(contextFile).To(BeAnExistingFile()) + }) + + It("removes pre-existing ROOT.xml when context_path is non-root", func() { + os.Setenv("JBP_CONFIG_TOMCAT", `{tomcat: {context_path: /the/intended/path}}`) + defer os.Unsetenv("JBP_CONFIG_TOMCAT") + + tomcatDir := filepath.Join(depsDir, "0", "tomcat") + contextDir := filepath.Join(tomcatDir, "conf", "Catalina", "localhost") + Expect(os.MkdirAll(contextDir, 0755)).To(Succeed()) + rootXML := filepath.Join(contextDir, "ROOT.xml") + Expect(os.WriteFile(rootXML, []byte(""), 0644)).To(Succeed()) + + err := container.Finalize() + Expect(err).NotTo(HaveOccurred()) + Expect(rootXML).NotTo(BeAnExistingFile()) + Expect(filepath.Join(contextDir, "the#intended#path.xml")).To(BeAnExistingFile()) + }) + + It("skips writing context XML when file already exists (external config)", func() { + tomcatDir := filepath.Join(depsDir, "0", "tomcat") + contextDir := filepath.Join(tomcatDir, "conf", "Catalina", "localhost") + Expect(os.MkdirAll(contextDir, 0755)).To(Succeed()) + preExistingContent := "" + rootXML := filepath.Join(contextDir, "ROOT.xml") + Expect(os.WriteFile(rootXML, []byte(preExistingContent), 0644)).To(Succeed()) + + err := container.Finalize() + Expect(err).NotTo(HaveOccurred()) + + content, readErr := os.ReadFile(rootXML) + Expect(readErr).NotTo(HaveOccurred()) + Expect(string(content)).To(Equal(preExistingContent)) + }) + + It("removes ROOT.xml even when non-root context XML already exists (external config)", func() { + os.Setenv("JBP_CONFIG_TOMCAT", `{tomcat: {context_path: /my/path}}`) + defer os.Unsetenv("JBP_CONFIG_TOMCAT") + + tomcatDir := filepath.Join(depsDir, "0", "tomcat") + contextDir := filepath.Join(tomcatDir, "conf", "Catalina", "localhost") + Expect(os.MkdirAll(contextDir, 0755)).To(Succeed()) + externalContent := "" + contextXML := filepath.Join(contextDir, "my#path.xml") + Expect(os.WriteFile(contextXML, []byte(externalContent), 0644)).To(Succeed()) + rootXML := filepath.Join(contextDir, "ROOT.xml") + Expect(os.WriteFile(rootXML, []byte(""), 0644)).To(Succeed()) + + err := container.Finalize() + Expect(err).NotTo(HaveOccurred()) + + content, readErr := os.ReadFile(contextXML) + Expect(readErr).NotTo(HaveOccurred()) + Expect(string(content)).To(Equal(externalContent)) + Expect(rootXML).NotTo(BeAnExistingFile()) + }) + + Context("with packaged WAR (no WEB-INF)", func() { + BeforeEach(func() { + Expect(os.RemoveAll(filepath.Join(buildDir, "WEB-INF"))).To(Succeed()) + }) + + It("creates context XML pointing to WAR file when context_path is set", func() { + Expect(os.WriteFile(filepath.Join(buildDir, "myapp.war"), []byte("fakewar"), 0644)).To(Succeed()) + os.Setenv("JBP_CONFIG_TOMCAT", `{tomcat: {context_path: /my/path}}`) + defer os.Unsetenv("JBP_CONFIG_TOMCAT") + + err := container.Finalize() + Expect(err).NotTo(HaveOccurred()) + + tomcatDir := filepath.Join(depsDir, "0", "tomcat") + contextFile := filepath.Join(tomcatDir, "conf", "Catalina", "localhost", "my#path.xml") + Expect(contextFile).To(BeAnExistingFile()) + content, _ := os.ReadFile(contextFile) + Expect(string(content)).To(ContainSubstring(`docBase="${user.home}/app/myapp.war"`)) + }) + + It("creates ROOT.xml pointing to WAR file when no context_path set", func() { + Expect(os.WriteFile(filepath.Join(buildDir, "myapp.war"), []byte("fakewar"), 0644)).To(Succeed()) + + err := container.Finalize() + Expect(err).NotTo(HaveOccurred()) + + tomcatDir := filepath.Join(depsDir, "0", "tomcat") + contextFile := filepath.Join(tomcatDir, "conf", "Catalina", "localhost", "ROOT.xml") + Expect(contextFile).To(BeAnExistingFile()) + content, _ := os.ReadFile(contextFile) + Expect(string(content)).To(ContainSubstring(`docBase="${user.home}/app/myapp.war"`)) + }) + + It("removes ROOT.xml when packaged WAR uses non-root context_path", func() { + Expect(os.WriteFile(filepath.Join(buildDir, "myapp.war"), []byte("fakewar"), 0644)).To(Succeed()) + os.Setenv("JBP_CONFIG_TOMCAT", `{tomcat: {context_path: /my/path}}`) + defer os.Unsetenv("JBP_CONFIG_TOMCAT") + + tomcatDir := filepath.Join(depsDir, "0", "tomcat") + contextDir := filepath.Join(tomcatDir, "conf", "Catalina", "localhost") + Expect(os.MkdirAll(contextDir, 0755)).To(Succeed()) + rootXML := filepath.Join(contextDir, "ROOT.xml") + Expect(os.WriteFile(rootXML, []byte(""), 0644)).To(Succeed()) + + err := container.Finalize() + Expect(err).NotTo(HaveOccurred()) + Expect(rootXML).NotTo(BeAnExistingFile()) + Expect(filepath.Join(contextDir, "my#path.xml")).To(BeAnExistingFile()) + }) + }) }) Describe("SelectTomcatVersionPattern", func() {