From 130f479fee0e29c43d3be56fcccd2aab4f8265f6 Mon Sep 17 00:00:00 2001 From: Peter Paul Bakker Date: Thu, 25 Jun 2026 07:30:43 +0000 Subject: [PATCH 1/8] feat(tomcat): restore context_path support from Ruby buildpack MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit context_path was implemented in the Ruby buildpack (commit 01862708, 2015) but never ported to Go. Users setting JBP_CONFIG_TOMCAT context_path always got ROOT.xml regardless of config. Tomcat convention: /foo/bar → foo#bar.xml (strip leading /, replace / with #). Empty or / → ROOT.xml (root context, existing default). Changes: - Add ContextPath field to Tomcat config struct (yaml:"context_path") - Add contextXMLFilename() helper implementing the path→filename transform - Finalize() loads config if not already loaded (Supply() may not have run in all call paths), computes XML filename from context_path - Three new tests (TDD): context_path set, empty, and / Fixes #1331. --- src/java/containers/tomcat.go | 25 +++++++++++++++++++-- src/java/containers/tomcat_test.go | 36 ++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 2 deletions(-) diff --git a/src/java/containers/tomcat.go b/src/java/containers/tomcat.go index 383e01e12..b70e73c9e 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 { + if contextPath == "" || contextPath == "/" { + return "ROOT.xml" + } + name := strings.TrimPrefix(contextPath, "/") + 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 { @@ -589,7 +609,7 @@ func (t *TomcatContainer) Finalize() error { t.context.Log.Info("Merged META-INF/context.xml with ROOT.xml - realm and resource configurations preserved") } else { contextContent = fmt.Sprintf("\n\n") - t.context.Log.Info("Created ROOT.xml with docBase pointing to application directory") + t.context.Log.Info("Created %s with docBase pointing to application directory", contextXMLName) } if err := os.WriteFile(contextXMLPath, []byte(contextContent), 0644); err != nil { @@ -647,6 +667,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..abb4ec02f 100644 --- a/src/java/containers/tomcat_test.go +++ b/src/java/containers/tomcat_test.go @@ -195,6 +195,42 @@ 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() { + 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()) + }) }) Describe("SelectTomcatVersionPattern", func() { From 3b4b434aa414edeef503a4fdddb448fafc743a1b Mon Sep 17 00:00:00 2001 From: Peter Paul Bakker Date: Thu, 25 Jun 2026 11:38:44 +0000 Subject: [PATCH 2/8] fix(tomcat): use actual context XML filename in write error message --- src/java/containers/tomcat.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/java/containers/tomcat.go b/src/java/containers/tomcat.go index b70e73c9e..4fc50a865 100644 --- a/src/java/containers/tomcat.go +++ b/src/java/containers/tomcat.go @@ -613,7 +613,7 @@ func (t *TomcatContainer) Finalize() error { } if err := os.WriteFile(contextXMLPath, []byte(contextContent), 0644); err != nil { - return fmt.Errorf("failed to write ROOT.xml: %w", err) + return fmt.Errorf("failed to write %s: %w", contextXMLName, err) } } From 4a16504eddef693406182335484830724b73f1b3 Mon Sep 17 00:00:00 2001 From: Peter Paul Bakker Date: Thu, 25 Jun 2026 12:07:42 +0000 Subject: [PATCH 3/8] fix(tomcat): normalize trailing slash in context_path and fix hardcoded ROOT.xml in log --- src/java/containers/tomcat.go | 6 +++--- src/java/containers/tomcat_test.go | 12 ++++++++++++ 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/src/java/containers/tomcat.go b/src/java/containers/tomcat.go index 4fc50a865..3ea19a016 100644 --- a/src/java/containers/tomcat.go +++ b/src/java/containers/tomcat.go @@ -556,10 +556,10 @@ func injectDocBase(xmlContent string, docBase string) string { // 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 { - if contextPath == "" || contextPath == "/" { + name := strings.Trim(contextPath, "/") + if name == "" { return "ROOT.xml" } - name := strings.TrimPrefix(contextPath, "/") name = strings.ReplaceAll(name, "/", "#") return name + ".xml" } @@ -606,7 +606,7 @@ func (t *TomcatContainer) Finalize() error { 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") + 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) diff --git a/src/java/containers/tomcat_test.go b/src/java/containers/tomcat_test.go index abb4ec02f..9e27baee1 100644 --- a/src/java/containers/tomcat_test.go +++ b/src/java/containers/tomcat_test.go @@ -231,6 +231,18 @@ var _ = Describe("Tomcat Container", func() { 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()) + }) }) Describe("SelectTomcatVersionPattern", func() { From 937509c251ffcf3add17499a2b4d5537cd40f3ad Mon Sep 17 00:00:00 2001 From: Peter Paul Bakker Date: Thu, 25 Jun 2026 12:33:27 +0000 Subject: [PATCH 4/8] fix(tomcat): remove stale ROOT.xml when non-root context_path is configured; add explicit empty context_path test --- src/java/containers/tomcat.go | 7 +++++++ src/java/containers/tomcat_test.go | 19 +++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/src/java/containers/tomcat.go b/src/java/containers/tomcat.go index 3ea19a016..ee77ff41d 100644 --- a/src/java/containers/tomcat.go +++ b/src/java/containers/tomcat.go @@ -615,6 +615,13 @@ func (t *TomcatContainer) Finalize() error { if err := os.WriteFile(contextXMLPath, []byte(contextContent), 0644); err != nil { return fmt.Errorf("failed to write %s: %w", contextXMLName, err) } + + 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) + } + } } return nil diff --git a/src/java/containers/tomcat_test.go b/src/java/containers/tomcat_test.go index 9e27baee1..09e33c850 100644 --- a/src/java/containers/tomcat_test.go +++ b/src/java/containers/tomcat_test.go @@ -214,6 +214,9 @@ var _ = Describe("Tomcat Container", func() { }) 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()) @@ -243,6 +246,22 @@ var _ = Describe("Tomcat Container", func() { 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()) + }) }) Describe("SelectTomcatVersionPattern", func() { From 267ada6895525415752f099c940b5e9f4845aa35 Mon Sep 17 00:00:00 2001 From: Peter Paul Bakker Date: Thu, 25 Jun 2026 12:51:48 +0000 Subject: [PATCH 5/8] =?UTF-8?q?docs:=20document=20context=5Fpath=20impleme?= =?UTF-8?q?ntation=20difference=20between=20Ruby=20and=20Go=20buildpack=20?= =?UTF-8?q?(=C2=A72A.11)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- RUBY_VS_GO_BUILDPACK_COMPARISON.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) 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 From 9d886b06de821782ea2e0f446735fb94ea0d5476 Mon Sep 17 00:00:00 2001 From: Peter Paul Bakker Date: Fri, 26 Jun 2026 11:51:02 +0000 Subject: [PATCH 6/8] feat(tomcat): handle packaged WAR and fix external config ordering for context_path - Finalize() now writes context XML for packaged .war apps (docBase points to the WAR file), so context_path works for both exploded and packaged WARs - Skip writing context XML if the file already exists (external config via external_configuration_enabled provides its own context descriptor) - Unit tests (TDD): 4 new tests covering WAR path, external-config guard - Integration test: verify context_path routes /my/app correctly and / returns 404 --- src/integration/tomcat_test.go | 17 +++++++ src/java/containers/tomcat.go | 75 +++++++++++++++++++++--------- src/java/containers/tomcat_test.go | 67 ++++++++++++++++++++++++++ 3 files changed, 138 insertions(+), 21 deletions(-) diff --git a/src/integration/tomcat_test.go b/src/integration/tomcat_test.go index 2952e3be1..a96621ba8 100644 --- a/src/integration/tomcat_test.go +++ b/src/integration/tomcat_test.go @@ -1,11 +1,13 @@ package integration_test import ( + "net/http" "path/filepath" "testing" "github.com/cloudfoundry/switchblade" "github.com/cloudfoundry/switchblade/matchers" + "github.com/onsi/gomega" "github.com/sclevine/spec" . "github.com/onsi/gomega" @@ -441,5 +443,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 returns 404 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).Should(matchers.Serve(gomega.Anything()).WithEndpoint("/").WithExpectedStatusCode(http.StatusNotFound)) + }) + }) } } diff --git a/src/java/containers/tomcat.go b/src/java/containers/tomcat.go index ee77ff41d..cbc0bf5dd 100644 --- a/src/java/containers/tomcat.go +++ b/src/java/containers/tomcat.go @@ -593,34 +593,67 @@ 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) + if _, statErr := os.Stat(contextXMLPath); 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) + if err := os.WriteFile(contextXMLPath, []byte(contextContent), 0644); err != nil { + return fmt.Errorf("failed to write %s: %w", contextXMLName, err) + } - contextContent = injectDocBase(xmlStr, "${user.home}/app") - t.context.Log.Info("Merged META-INF/context.xml with %s - realm and resource configurations preserved", 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 { - contextContent = fmt.Sprintf("\n\n") - t.context.Log.Info("Created %s with docBase pointing to application directory", contextXMLName) - } - - if err := os.WriteFile(contextXMLPath, []byte(contextContent), 0644); err != nil { - return fmt.Errorf("failed to write %s: %w", contextXMLName, err) + t.context.Log.Info("Context XML %s already exists (e.g. from external config), skipping generation", contextXMLName) } + } 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 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) + if _, statErr := os.Stat(contextXMLPath); os.IsNotExist(statErr) { + if err := os.WriteFile(contextXMLPath, []byte(contextContent), 0644); err != nil { + return fmt.Errorf("failed to write %s: %w", contextXMLName, err) + } + + 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) + } + } + 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) } + } else if len(warMatches) > 1 { + t.context.Log.Warning("Multiple WAR files found in build directory; context_path not applied") } } diff --git a/src/java/containers/tomcat_test.go b/src/java/containers/tomcat_test.go index 09e33c850..7ce76bb09 100644 --- a/src/java/containers/tomcat_test.go +++ b/src/java/containers/tomcat_test.go @@ -262,6 +262,73 @@ var _ = Describe("Tomcat Container", func() { 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)) + }) + + 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() { From 02f0fe132a36df7248637659e5aee5d4a2755d2d Mon Sep 17 00:00:00 2001 From: Peter Paul Bakker Date: Fri, 26 Jun 2026 12:17:36 +0000 Subject: [PATCH 7/8] fix(tomcat): fix context_path integration test assertion - root serves Tomcat default page not 404 --- src/integration/tomcat_test.go | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/integration/tomcat_test.go b/src/integration/tomcat_test.go index a96621ba8..edf047beb 100644 --- a/src/integration/tomcat_test.go +++ b/src/integration/tomcat_test.go @@ -1,13 +1,11 @@ package integration_test import ( - "net/http" "path/filepath" "testing" "github.com/cloudfoundry/switchblade" "github.com/cloudfoundry/switchblade/matchers" - "github.com/onsi/gomega" "github.com/sclevine/spec" . "github.com/onsi/gomega" @@ -445,7 +443,7 @@ func testTomcat(platform switchblade.Platform, fixtures string) func(*testing.T, }) context("with context_path configured", func() { - it("serves app at configured path and returns 404 at root", 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", @@ -455,7 +453,7 @@ func testTomcat(platform switchblade.Platform, fixtures string) func(*testing.T, Expect(err).NotTo(HaveOccurred(), logs.String()) Eventually(deployment).Should(matchers.Serve(ContainSubstring("OK")).WithEndpoint("/my/app")) - Eventually(deployment).Should(matchers.Serve(gomega.Anything()).WithEndpoint("/").WithExpectedStatusCode(http.StatusNotFound)) + Eventually(deployment).ShouldNot(matchers.Serve(ContainSubstring("OK")).WithEndpoint("/")) }) }) } From f65931012eb9b039c14aa85ede3e65328c1ea6db Mon Sep 17 00:00:00 2001 From: Peter Paul Bakker Date: Fri, 26 Jun 2026 13:46:32 +0000 Subject: [PATCH 8/8] fix(tomcat): propagate os.Stat errors and always remove ROOT.xml on non-root context_path - os.Stat unexpected errors (non-IsNotExist) now returned as failures instead of silently treating the context XML as already existing - ROOT.xml removal now happens regardless of whether context XML was generated or already existed from external config, so non-root context_path always removes a stale ROOT.xml - Unit test added (TDD) for external-config + non-root ROOT.xml removal --- src/java/containers/tomcat.go | 38 +++++++++++++++++------------- src/java/containers/tomcat_test.go | 22 +++++++++++++++++ 2 files changed, 44 insertions(+), 16 deletions(-) diff --git a/src/java/containers/tomcat.go b/src/java/containers/tomcat.go index cbc0bf5dd..bab2a1662 100644 --- a/src/java/containers/tomcat.go +++ b/src/java/containers/tomcat.go @@ -593,7 +593,11 @@ func (t *TomcatContainer) Finalize() error { return fmt.Errorf("failed to create context directory: %w", err) } - if _, statErr := os.Stat(contextXMLPath); os.IsNotExist(statErr) { + _, 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 @@ -616,16 +620,15 @@ func (t *TomcatContainer) Finalize() error { if err := os.WriteFile(contextXMLPath, []byte(contextContent), 0644); err != nil { return fmt.Errorf("failed to write %s: %w", contextXMLName, err) } - - 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 { 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 { @@ -637,21 +640,24 @@ func (t *TomcatContainer) Finalize() error { return fmt.Errorf("failed to create context directory: %w", err) } - if _, statErr := os.Stat(contextXMLPath); os.IsNotExist(statErr) { + _, 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) } - - 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) - } - } 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") } diff --git a/src/java/containers/tomcat_test.go b/src/java/containers/tomcat_test.go index 7ce76bb09..a9b24e797 100644 --- a/src/java/containers/tomcat_test.go +++ b/src/java/containers/tomcat_test.go @@ -279,6 +279,28 @@ var _ = Describe("Tomcat Container", func() { 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())