From 7b9309278c8fa8b204a9cafc8eebd30ed151f98a Mon Sep 17 00:00:00 2001 From: Peter Paul Bakker Date: Thu, 16 Jul 2026 13:58:14 +0000 Subject: [PATCH] feat(integration): add real Spring Boot 3.x/4.x integration tests (#1357) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add integration tests using real fat jars from cloudfoundry/java-test-applications v1.0.0 to verify end-to-end framework injection. Three tests: - SB3 (Boot 3.5, Java 17): java-cfenv 3.x injection, cloud profile verified via RuntimeLogs (boot3 app lacks RuntimeUtils endpoints) - SB4 (Boot 4.1, Java 21): java-cfenv 4.x injection, cloud profile via /active-profiles, java-cfenv presence via /loaded-jars - SB4 combined: java-cfenv + container-security-provider env var + cf-metrics-exporter javaagent — all in one deployment Key design decisions: - Pre-explode fat jars in fixtures_helper_test.go for Docker-mode tests to simulate CF staging (CF bits service extracts pushed jars as zip before running the buildpack). CF-mode tests pass raw jar to cf push. See switchblade#134 for CF-vs-Docker divergence. - Jars sourced from cloudfoundry/java-test-applications@v1.0.0. Also: - Fix dead 404 URL in frameworks_test.go - Update isSpringBootJar comment in spring_boot.go - Update README with correct 5-level -run filter patterns --- docs/framework-java-cfenv.md | 26 +++++ src/integration/README.md | 39 +++++-- src/integration/fixtures_helper_test.go | 148 ++++++++++++++++++++++++ src/integration/frameworks_test.go | 2 +- src/integration/init_test.go | 18 ++- src/integration/spring_boot_test.go | 145 ++++++++++++++++++++++- src/java/containers/spring_boot.go | 9 +- 7 files changed, 373 insertions(+), 14 deletions(-) create mode 100644 src/integration/fixtures_helper_test.go diff --git a/docs/framework-java-cfenv.md b/docs/framework-java-cfenv.md index 0a94b27e2f..bdbbe2fac9 100644 --- a/docs/framework-java-cfenv.md +++ b/docs/framework-java-cfenv.md @@ -34,6 +34,32 @@ The framework is implemented in `src/java/frameworks/java_cf_env.go`: 3. **Finalize** — appends the installed jar to `CLASSPATH` via a `.profile.d/java_cf_env.sh` script, so it is on the application's runtime classpath. 4. **Runtime** — Spring Boot reads the jar's `META-INF/spring.factories`: the `EnvironmentPostProcessor`s map `VCAP_SERVICES` to Spring properties, and `CloudProfileApplicationListener` (in the `java-cfenv-all` module) activates the `cloud` profile when running in Cloud Foundry. +## VCAP_SERVICES auto-configuration + +Spring Boot has built-in support for flattening `VCAP_SERVICES` into `vcap.services..credentials.*` properties (`CloudFoundryVcapEnvironmentPostProcessor`). This works without java-cfenv but does **not** set `spring.datasource.url` or activate the `cloud` profile. + +`java-cfenv-all` adds service-aware auto-configuration on top: it detects bound services by tag, label, or URI scheme and maps them to the appropriate Spring Boot properties. For example, a bound PostgreSQL service with URI `postgres://host:5432/db` is automatically mapped to `spring.datasource.url=jdbc:postgresql://host:5432/db` (plus username, password, and driver class). For reactive apps, `spring.r2dbc.*` properties are set as well. With a single bound database service, no `application.yml` DataSource configuration is needed. + +This behavior is identical across java-cfenv 3.x and 4.x (the only difference is a Spring Boot 4 package relocation of `EnvironmentPostProcessor`). + +### JDBC services (`CfDataSourceEnvironmentPostProcessor`) + +| Service | Matching criteria | JDBC prefix | +|---------|-------------------|-------------| +| PostgreSQL | tag `postgresql`/`postgres`, label prefix `postgresql`, URI scheme `postgres://`/`postgresql://` | `jdbc:postgresql://` | +| MySQL / MariaDB | tag `mysql`/`mariadb`, label prefix `mysql`/`mariadb`, URI scheme `mysql://`/`mariadb://` | `jdbc:mysql://` or `jdbc:mariadb://` | +| SQL Server | label prefix `sqlserver`, URI scheme `sqlserver://` | `jdbc:sqlserver://` | +| Oracle | label prefix `oracle`, URI scheme `oracle://` | `jdbc:oracle:thin:@` | +| DB2 | tag `db2`/`sqldb`/`dashDB`, label prefix `db2`, URI scheme `db2://` | `jdbc:db2://` | + +### Other services (`CfEnvProcessor` implementations) + +| Service | Matching criteria | Properties set | +|---------|-------------------|----------------| +| MongoDB | tag `mongodb`, label prefix `mongolab`/`mongodb` | `spring.data.mongodb.uri` | +| Redis | tag `redis`, URI scheme `redis://`/`rediss://` | `spring.data.redis.*` | +| RabbitMQ | tag `rabbitmq`/`amqp`, URI scheme `amqp://`/`amqps://` | `spring.rabbitmq.*` | + ## Configuration The framework can be disabled via the `JBP_CONFIG_JAVA_CF_ENV` environment variable: diff --git a/src/integration/README.md b/src/integration/README.md index e4580ba095..dd104fc51c 100644 --- a/src/integration/README.md +++ b/src/integration/README.md @@ -57,16 +57,41 @@ You can also run the tests directly using Go: cd src/integration # Run all tests -BUILDPACK_FILE=/path/to/buildpack.zip go test -v -timeout 30m +BUILDPACK_FILE=/path/to/buildpack.zip go test -mod vendor -v -timeout 30m ./ -# Run specific test suite -BUILDPACK_FILE=/path/to/buildpack.zip go test -v -run TestIntegration/Tomcat - -# Run on Docker -BUILDPACK_FILE=/path/to/buildpack.zip go test -v -platform=docker +# Run on Docker (requires GitHub token) +BUILDPACK_FILE=/path/to/buildpack.zip go test -mod vendor -v -timeout 30m ./ \ + -platform=docker -github-token= # Run offline tests -BUILDPACK_FILE=/path/to/buildpack.zip go test -v -cached +BUILDPACK_FILE=/path/to/buildpack.zip go test -mod vendor -v -timeout 30m ./ -cached +``` + +### Running a Subset of Tests + +The spec framework creates subtests 5 levels deep: +`TestIntegration/integration///` + +Use `-run` with a regex; Go does substring matching at each `/`-separated level: + +```bash +# All SpringBoot real-jar tests +-run "TestIntegration/integration/SpringBoot/with_real_Spring_Boot_fat_jars" + +# Single test — SB3 only +-run "TestIntegration/integration/SpringBoot/with_real_Spring_Boot_fat_jars/SB3" + +# Single test — SB4 cfenv + loaded-jars +-run "TestIntegration/integration/SpringBoot/with_real_Spring_Boot_fat_jars/SB4.*cloud_profile" + +# Single test — SB4 combined (cfenv + csp + metrics) +-run "TestIntegration/integration/SpringBoot/with_real_Spring_Boot_fat_jars/SB4.*container-security" + +# All Tomcat tests +-run "TestIntegration/integration/Tomcat" + +# All Frameworks tests +-run "TestIntegration/integration/Frameworks" ``` ## Test Organization diff --git a/src/integration/fixtures_helper_test.go b/src/integration/fixtures_helper_test.go new file mode 100644 index 0000000000..bbb21a51df --- /dev/null +++ b/src/integration/fixtures_helper_test.go @@ -0,0 +1,148 @@ +package integration_test + +import ( + "archive/zip" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "time" +) + +const ( + javaTestAppsReleaseTag = "v1.0.0" + javaTestAppsBaseURL = "https://github.com/cloudfoundry/java-test-applications/releases/download/" + javaTestAppsReleaseTag + sb3JarName = "java-main-application-boot3-1.0.0.jar" + sb4JarName = "java-main-application-1.0.0.jar" +) + +// downloadJavaTestAppsJars downloads the pinned SB3 and SB4 fat jars. +// For Docker-mode tests, jars are extracted (exploded) into fixture directories +// to simulate what real CF staging does: CF treats the pushed artifact as a zip +// and extracts it before running the buildpack, so BOOT-INF/ and META-INF/ +// appear as flat files on disk. +// For CF-mode tests, the raw jar is returned as-is — `cf push -p` handles +// zip extraction natively. +// +// Pre-exploding is the correct pattern for Docker-mode tests: switchblade's +// Docker mode archives the fixture directory as-is (TGZArchiver), while CF mode +// delegates to `cf push -p` which handles zip extraction natively. +// See https://github.com/cloudfoundry/switchblade/issues/134 for details. +// +// Returns (sb3FixturePath, sb4FixturePath, cleanup func, error). +func downloadJavaTestAppsJars(platform string) (string, string, func(), error) { + dir, err := os.MkdirTemp("", "java-test-apps-*") + if err != nil { + return "", "", nil, fmt.Errorf("create temp dir: %w", err) + } + + cleanup := func() { os.RemoveAll(dir) } + + type jarEntry struct { + jarName string + path string // set after download/extract + } + entries := []jarEntry{ + {jarName: sb3JarName}, + {jarName: sb4JarName}, + } + + for i := range entries { + jarPath := filepath.Join(dir, entries[i].jarName) + if err := downloadFile(javaTestAppsBaseURL+"/"+entries[i].jarName, jarPath); err != nil { + cleanup() + return "", "", nil, fmt.Errorf("download %s: %w", entries[i].jarName, err) + } + + if platform == "docker" { + // Docker mode: explode jar into directory (simulates CF staging zip extraction). + // Switchblade Docker archives the fixture dir as-is via TGZArchiver. + explodedDir := filepath.Join(dir, fmt.Sprintf("exploded-%d", i)) + if err := extractZip(jarPath, explodedDir); err != nil { + cleanup() + return "", "", nil, fmt.Errorf("extract %s: %w", entries[i].jarName, err) + } + _ = os.Remove(jarPath) + entries[i].path = explodedDir + } else { + // CF mode: return raw jar path — `cf push -p` handles zip extraction natively. + entries[i].path = jarPath + } + } + + return entries[0].path, entries[1].path, cleanup, nil +} + +// extractZip extracts a zip/jar file into destDir, replicating the flat-file +// layout that CF staging creates when it unpacks the pushed artifact. +func extractZip(src, destDir string) error { + r, err := zip.OpenReader(src) + if err != nil { + return fmt.Errorf("open zip: %w", err) + } + defer r.Close() + + for _, f := range r.File { + target := filepath.Join(destDir, f.Name) + + // Guard against zip-slip + if !strings.HasPrefix(filepath.Clean(target)+string(os.PathSeparator), + filepath.Clean(destDir)+string(os.PathSeparator)) { + return fmt.Errorf("zip entry %q escapes destination", f.Name) + } + + if f.FileInfo().IsDir() { + if err := os.MkdirAll(target, 0755); err != nil { + return err + } + continue + } + + if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil { + return err + } + + out, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, f.Mode()) + if err != nil { + return err + } + + rc, err := f.Open() + if err != nil { + out.Close() + return err + } + + _, copyErr := io.Copy(out, rc) + rc.Close() + out.Close() + if copyErr != nil { + return copyErr + } + } + return nil +} + +func downloadFile(url, dest string) error { + client := &http.Client{Timeout: 2 * time.Minute} + resp, err := client.Get(url) //nolint:noctx + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("HTTP %d for %s", resp.StatusCode, url) + } + + f, err := os.Create(dest) + if err != nil { + return err + } + defer f.Close() + + _, err = io.Copy(f, resp.Body) + return err +} diff --git a/src/integration/frameworks_test.go b/src/integration/frameworks_test.go index 1ae274d9bf..0c70054ad8 100644 --- a/src/integration/frameworks_test.go +++ b/src/integration/frameworks_test.go @@ -445,7 +445,7 @@ func testFrameworks(platform switchblade.Platform, fixtures string) func(*testin deployment, logs, err := platform.Deploy. WithServices(map[string]switchblade.Service{ "checkmarx-iast": { - "url": "https://github.com/cloudfoundry/java-test-applications/raw/main/java-main-application/java-main-application.jar", + "url": "https://github.com/cloudfoundry/java-test-applications/releases/download/v1.0.0/java-main-application-1.0.0.jar", "manager_url": "https://checkmarx.example.com", "api_key": "test-api-key-12345", }, diff --git a/src/integration/init_test.go b/src/integration/init_test.go index e91848ba0e..ff5557d3f4 100644 --- a/src/integration/init_test.go +++ b/src/integration/init_test.go @@ -28,6 +28,12 @@ var settings struct { GitHubToken string Platform string Stack string + + // Paths to fixture directories containing real Spring Boot fat jars from java-test-applications release. + // Each directory holds a single fat jar; passed to switchblade Deploy.Execute as the app source. + // Populated in TestIntegration before the suite runs. + SB3JarPath string + SB4JarPath string } func init() { @@ -66,6 +72,16 @@ func TestIntegration(t *testing.T) { ) Expect(err).NotTo(HaveOccurred()) + // Download real Spring Boot fat jars for java-cfenv integration tests. + // Skip in cached/offline mode — tests self-skip when paths are empty. + if !settings.Cached { + sb3Jar, sb4Jar, cleanupJars, err := downloadJavaTestAppsJars(settings.Platform) + Expect(err).NotTo(HaveOccurred(), "failed to download java-test-applications jars for integration tests") + defer cleanupJars() + settings.SB3JarPath = sb3Jar + settings.SB4JarPath = sb4Jar + } + var suite spec.Suite if settings.Serial { suite = spec.New("integration", spec.Report(report.Terminal{}), spec.Sequential()) @@ -75,7 +91,7 @@ func TestIntegration(t *testing.T) { // Core container tests suite("Tomcat", testTomcat(platform, fixtures)) - suite("SpringBoot", testSpringBoot(platform, fixtures)) + suite("SpringBoot", testSpringBoot(platform, fixtures, settings.SB3JarPath, settings.SB4JarPath)) suite("JavaMain", testJavaMain(platform, fixtures)) suite("DistZip", testDistZip(platform, fixtures)) diff --git a/src/integration/spring_boot_test.go b/src/integration/spring_boot_test.go index fc691a718a..ba73361966 100644 --- a/src/integration/spring_boot_test.go +++ b/src/integration/spring_boot_test.go @@ -1,6 +1,8 @@ package integration_test import ( + "io" + "net/http" "path/filepath" "testing" @@ -11,7 +13,7 @@ import ( . "github.com/onsi/gomega" ) -func testSpringBoot(platform switchblade.Platform, fixtures string) func(*testing.T, spec.G, spec.S) { +func testSpringBoot(platform switchblade.Platform, fixtures string, sb3JarPath, sb4JarPath string) func(*testing.T, spec.G, spec.S) { return func(t *testing.T, context spec.G, it spec.S) { var ( Expect = NewWithT(t).Expect @@ -290,5 +292,144 @@ func testSpringBoot(platform switchblade.Platform, fixtures string) func(*testin )).WithEndpoint("/jvm-args")) }) }) + + // Tests using real Spring Boot fat jars from cloudfoundry/java-test-applications@v1.0.0. + // SB4 (java-main-application) exposes RuntimeUtils endpoints (via core module): + // GET /active-profiles -> ["cloud"] when java-cfenv activates cloud profile + // GET /loaded-jars -> full classloader chain URLs incl. BOOT-INF/lib/* + // GET /spring-env?key= -> Spring Environment property value + // GET /environment-variables -> raw env vars + // GET /input-arguments -> JVM input arguments (-javaagent etc.) + // SB3 (java-main-application-boot3) only exposes GET / (no core module dependency). + // Cloud profile activation verified via runtime logs for SB3. + context("with real Spring Boot fat jars: java-cfenv injection", func() { + it("SB3 (Spring Boot 3.x) -- java-cfenv 3.x, cloud profile via logs", func() { + if sb3JarPath == "" { + t.Skip("SB3 jar not available") + } + deployment, logs, err := platform.Deploy. + WithServices(map[string]switchblade.Service{ + "db": {"uri": "postgres://host:5432/dbname"}, + }). + WithEnv(map[string]string{ + "BP_JAVA_VERSION": "17", + "JBP_CONFIG_JAVA_CF_ENV": "{enabled: true}", + }). + Execute(name, sb3JarPath) + Expect(err).NotTo(HaveOccurred(), logs.String) + + // Buildpack detected and injected correct java-cfenv 3.x + Expect(logs.String()).To(ContainSubstring("Java CF Env")) + Expect(logs.String()).To(ContainSubstring("3.")) + + // java-cfenv activated "cloud" profile — verified via Spring Boot startup log + // (SB3 jar doesn't expose /active-profiles endpoint yet) + // Use Eventually: RuntimeLogs() may be called before Spring Boot finishes starting. + Eventually(func() (string, error) { + return deployment.RuntimeLogs() + }).Should(ContainSubstring(`profile is active: "cloud"`)) + + // App is live + Eventually(deployment).Should(matchers.Serve(ContainSubstring("ok")).WithEndpoint("/")) + }) + + it("SB4 (Spring Boot 4.x) -- java-cfenv 4.x, cloud profile, loaded-jars, vcap mapping", func() { + if sb4JarPath == "" { + t.Skip("SB4 jar not available") + } + deployment, logs, err := platform.Deploy. + WithServices(map[string]switchblade.Service{ + "db": { + "uri": "postgres://host:5432/dbname", + "username": "testuser", + "password": "testpass", + }, + }). + WithEnv(map[string]string{ + "BP_JAVA_VERSION": "21", + "JBP_CONFIG_JAVA_CF_ENV": "{enabled: true}", + }). + Execute(name, sb4JarPath) + Expect(err).NotTo(HaveOccurred(), logs.String) + + // Buildpack detected and injected correct java-cfenv 4.x (java-cfenv-all fat jar) + Expect(logs.String()).To(ContainSubstring("Java CF Env")) + Expect(logs.String()).To(ContainSubstring("4.")) + + // java-cfenv-all activated "cloud" profile — this is the core value-add of java-cfenv. + Eventually(deployment).Should(matchers.Serve( + ContainSubstring("cloud")).WithEndpoint("/active-profiles")) + + // java-cfenv-all jar loaded by JarLauncher via BOOT-INF/lib symlink + Eventually(deployment).Should(matchers.Serve( + ContainSubstring("java-cfenv")).WithEndpoint("/loaded-jars")) + + // Helper for /spring-env?key= queries (WithEndpoint encodes '?' as %3F) + springEnv := func(key string) func() (string, error) { + return func() (string, error) { + resp, err := http.Get(deployment.ExternalURL + "/spring-env?key=" + key) //nolint:noctx + if err != nil { + return "", err + } + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + return string(body), err + } + } + + // Spring Boot built-in: CloudFoundryVcapEnvironmentPostProcessor flattens + // VCAP_SERVICES into vcap.services..credentials.* properties (works + // without java-cfenv). Service name is dynamic (container-name + key), so + // we don't assert the exact property path here. + + // java-cfenv-all JDBC auto-configuration: CfDataSourceEnvironmentPostProcessor + // detects postgres:// URI scheme and maps to spring.datasource.url/username/password. + // This proves the full service connector pipeline is active, beyond + // Spring Boot's built-in vcap.services.* property flattening. + Eventually(springEnv("spring.datasource.url")). + Should(ContainSubstring("jdbc:postgresql://host:5432/dbname")) + Eventually(springEnv("spring.datasource.username")). + Should(ContainSubstring("testuser")) + Eventually(springEnv("spring.datasource.password")). + Should(ContainSubstring("testpass")) + }) + + it("SB4 (Spring Boot 4.x) -- java-cfenv 4.x, container-security-provider, cf-metrics-exporter", func() { + if sb4JarPath == "" { + t.Skip("SB4 jar not available") + } + deployment, logs, err := platform.Deploy. + WithServices(map[string]switchblade.Service{ + "db": {"uri": "postgres://host:5432/dbname"}, + }). + WithEnv(map[string]string{ + "BP_JAVA_VERSION": "21", + "JBP_CONFIG_JAVA_CF_ENV": "{enabled: true}", + // cf-metrics-exporter: rpsType=random + enableLogEmitter requires no external infra + "CF_METRICS_EXPORTER_ENABLED": "true", + "CF_METRICS_EXPORTER_PROPS": "rpsType=random,enableLogEmitter", + }). + Execute(name, sb4JarPath) + Expect(err).NotTo(HaveOccurred(), logs.String) + + // java-cfenv: correct 4.x version detected and injected + Expect(logs.String()).To(ContainSubstring("Java CF Env")) + Expect(logs.String()).To(ContainSubstring("4.")) + + // cf-metrics-exporter: -javaagent present in JVM input arguments + Expect(logs.String()).To(ContainSubstring("CF Metrics Exporter")) + Expect(logs.String()).To(ContainSubstring("enabled, with properties: rpsType=random,enableLogEmitter")) + Eventually(deployment).Should(matchers.Serve( + ContainSubstring("cf-metrics-exporter")).WithEndpoint("/input-arguments")) + + // container-security-provider: env var set at runtime + Eventually(deployment).Should(matchers.Serve( + ContainSubstring("CONTAINER_SECURITY_PROVIDER")).WithEndpoint("/environment-variables")) + + // java-cfenv activated "cloud" profile + Eventually(deployment).Should(matchers.Serve( + ContainSubstring("cloud")).WithEndpoint("/active-profiles")) + }) + }) } -} +} \ No newline at end of file diff --git a/src/java/containers/spring_boot.go b/src/java/containers/spring_boot.go index b584973a7f..ee6ebff42b 100644 --- a/src/java/containers/spring_boot.go +++ b/src/java/containers/spring_boot.go @@ -86,10 +86,13 @@ func (s *SpringBootContainer) findSpringBootJar(buildDir string) (string, error) return "", nil } -// isSpringBootJar checks if a JAR is a Spring Boot JAR +// isSpringBootJar checks if a JAR is a Spring Boot JAR by filename heuristic. +// In real CF staging, the artifact is extracted as a zip before the buildpack runs, +// so BOOT-INF/ exists on disk and Detect() never reaches this path. +// This fallback only matters when a fat jar is pushed without prior extraction +// (e.g. local testing). For a more robust check, read MANIFEST.MF from inside +// the jar zip and look for Spring-Boot-Version or Start-Class headers. func (s *SpringBootContainer) isSpringBootJar(jarPath string) bool { - // TODO: In full implementation, we'd extract and check MANIFEST.MF - // For now, check file name patterns name := filepath.Base(jarPath) return strings.Contains(name, "spring") || strings.Contains(name, "boot") ||