From 3799ad2c5401854d22cb83e7643d78c747f23cbd Mon Sep 17 00:00:00 2001 From: Weirong Zhu Date: Wed, 1 Nov 2023 22:35:52 -0700 Subject: [PATCH] Support multiple war files --- tomcat/base.go | 61 ++++++++++++++-- tomcat/base_test.go | 117 ++++++++++++++++++++++++++++++- tomcat/build.go | 54 +++++++++----- tomcat/build_test.go | 73 +++++++++++++++++++ tomcat/detect.go | 33 +++++++-- tomcat/detect_test.go | 49 +++++++++++++ tomcat/testdata/warfiles/api.war | Bin 0 -> 4803 bytes tomcat/testdata/warfiles/ui.war | Bin 0 -> 8993 bytes 8 files changed, 354 insertions(+), 33 deletions(-) create mode 100644 tomcat/testdata/warfiles/api.war create mode 100644 tomcat/testdata/warfiles/ui.war diff --git a/tomcat/base.go b/tomcat/base.go index a9ec130..ad3eada 100644 --- a/tomcat/base.go +++ b/tomcat/base.go @@ -21,6 +21,7 @@ import ( "os" "path/filepath" "strconv" + "strings" "github.com/paketo-buildpacks/libpak/sbom" @@ -44,6 +45,7 @@ type Base struct { LifecycleDependency libpak.BuildpackDependency LoggingDependency libpak.BuildpackDependency Logger bard.Logger + WarFilesExist bool } func NewBase( @@ -56,6 +58,7 @@ func NewBase( lifecycleDependency libpak.BuildpackDependency, loggingDependency libpak.BuildpackDependency, cache libpak.DependencyCache, + warFilesExist bool, ) (Base, []libcnb.BOMEntry) { dependencies := []libpak.BuildpackDependency{accessLoggingDependency, lifecycleDependency, loggingDependency} @@ -79,6 +82,7 @@ func NewBase( }), LifecycleDependency: lifecycleDependency, LoggingDependency: loggingDependency, + WarFilesExist: warFilesExist, } var bomEntries []libcnb.BOMEntry @@ -163,14 +167,23 @@ func (b Base) Contribute(layer libcnb.Layer) (libcnb.Layer, error) { } file = filepath.Join(layer.Path, "webapps") - if err := os.MkdirAll(file, 0755); err != nil { - return libcnb.Layer{}, fmt.Errorf("unable to create directory %s\n%w", file, err) - } + if b.WarFilesExist { + if err := os.Symlink(b.ApplicationPath, file); err != nil { + return libcnb.Layer{}, fmt.Errorf("unable to create symlink from %s to %s\n%w", b.ApplicationPath, file, err) + } + if err := b.explodeWarFiles(); err != nil { + return libcnb.Layer{}, fmt.Errorf("unable to explode war files in %s\n%w", b.ApplicationPath, err) + } + } else { + if err := os.MkdirAll(file, 0755); err != nil { + return libcnb.Layer{}, fmt.Errorf("unable to create directory %s\n%w", file, err) + } - file = filepath.Join(layer.Path, "webapps", b.ContextPath) - b.Logger.Headerf("Mounting application at %s", b.ContextPath) - if err := os.Symlink(b.ApplicationPath, file); err != nil { - return libcnb.Layer{}, fmt.Errorf("unable to create symlink from %s to %s\n%w", b.ApplicationPath, file, err) + file = filepath.Join(layer.Path, "webapps", b.ContextPath) + b.Logger.Headerf("Mounting application at %s", b.ContextPath) + if err := os.Symlink(b.ApplicationPath, file); err != nil { + return libcnb.Layer{}, fmt.Errorf("unable to create symlink from %s to %s\n%w", b.ApplicationPath, file, err) + } } environmentPropertySourceDisabled := b.ConfigurationResolver.ResolveBool("BP_TOMCAT_ENV_PROPERTY_SOURCE_DISABLED") @@ -359,6 +372,40 @@ func (b Base) writeDependencySBOM(layer libcnb.Layer, syftArtifacts []sbom.SyftA return nil } +func (b Base) explodeWarFiles() error { + warFiles, err := filepath.Glob(filepath.Join(b.ApplicationPath, "*.war")) + if err != nil { + return err + } + + for _, warFilePath := range warFiles { + b.Logger.Debugf("Extracting: %s\n", warFilePath) + + if _, err := os.Stat(warFilePath); err == nil { + in, err := os.Open(warFilePath) + if err != nil { + return fmt.Errorf("An error occurred while extracting %s: %s\n", warFilePath, err) + } + defer in.Close() + + targetDir := strings.TrimSuffix(warFilePath, filepath.Ext(warFilePath)) + if err := os.MkdirAll(targetDir, 0755); err != nil { + return fmt.Errorf("An error occurred while extracting %s: %s\n", warFilePath, err) + } + + if err := crush.Extract(in, targetDir, 0); err != nil { + return fmt.Errorf("An error occurred while extracting %s: %s\n", warFilePath, err) + } + + err = os.Remove(warFilePath) + if err != nil { + return fmt.Errorf("An error occurred while removing the .war file: %s\n", err) + } + } + } + return nil +} + func (Base) Name() string { return "catalina-base" } diff --git a/tomcat/base_test.go b/tomcat/base_test.go index 46637c5..5e70a33 100644 --- a/tomcat/base_test.go +++ b/tomcat/base_test.go @@ -18,8 +18,10 @@ package tomcat_test import ( "fmt" + "io" "os" "path/filepath" + "strings" "testing" "github.com/buildpacks/libcnb" @@ -101,6 +103,7 @@ func testBase(t *testing.T, context spec.G, it spec.S) { lifecycleDep, loggingDep, dc, + false, ) Expect(entries).To(HaveLen(3)) @@ -175,7 +178,18 @@ func testBase(t *testing.T, context spec.G, it spec.S) { dc := libpak.DependencyCache{CachePath: "testdata"} - contrib, entries := tomcat.NewBase(ctx.Application.Path, ctx.Buildpack.Path, libpak.ConfigurationResolver{}, "test-context-path", accessLoggingDep, &externalConfigurationDep, lifecycleDep, loggingDep, dc) + contrib, entries := tomcat.NewBase( + ctx.Application.Path, + ctx.Buildpack.Path, + libpak.ConfigurationResolver{}, + "test-context-path", + accessLoggingDep, + &externalConfigurationDep, + lifecycleDep, + loggingDep, + dc, + false, + ) layer, err := ctx.Layers.Layer("test-layer") Expect(err).NotTo(HaveOccurred()) @@ -240,7 +254,18 @@ func testBase(t *testing.T, context spec.G, it spec.S) { dc := libpak.DependencyCache{CachePath: "testdata"} - contrib, entries := tomcat.NewBase(ctx.Application.Path, ctx.Buildpack.Path, libpak.ConfigurationResolver{}, "test-context-path", accessLoggingDep, &externalConfigurationDep, lifecycleDep, loggingDep, dc) + contrib, entries := tomcat.NewBase( + ctx.Application.Path, + ctx.Buildpack.Path, + libpak.ConfigurationResolver{}, + "test-context-path", + accessLoggingDep, + &externalConfigurationDep, + lifecycleDep, + loggingDep, + dc, + false, + ) Expect(entries).To(HaveLen(4)) Expect(entries[0].Name).To(Equal("tomcat-access-logging-support")) Expect(entries[0].Build).To(BeFalse()) @@ -309,6 +334,7 @@ func testBase(t *testing.T, context spec.G, it spec.S) { lifecycleDep, loggingDep, dc, + false, ) Expect(entries).To(HaveLen(3)) @@ -393,6 +419,7 @@ func testBase(t *testing.T, context spec.G, it spec.S) { lifecycleDep, loggingDep, dc, + false, ) Expect(entries).To(HaveLen(3)) @@ -418,4 +445,90 @@ func testBase(t *testing.T, context spec.G, it spec.S) { }) + context("Contribute multiple war files", func() { + files := []string{"api.war", "ui.war"} + it.Before(func() { + for _, file := range files { + in, err := os.Open(filepath.Join("testdata", "warfiles", file)) + Expect(err).NotTo(HaveOccurred()) + + out, err := os.OpenFile(filepath.Join(ctx.Application.Path, file), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644) + Expect(err).NotTo(HaveOccurred()) + + _, err = io.Copy(out, in) + Expect(err).NotTo(HaveOccurred()) + Expect(in.Close()).To(Succeed()) + Expect(out.Close()).To(Succeed()) + } + }) + + it.After(func() { + for _, file := range files { + os.Remove(filepath.Join(ctx.Application.Path, file)) + } + }) + + it("Multiple war files have been exploded in application path", func() { + accessLoggingDep := libpak.BuildpackDependency{ + ID: "tomcat-access-logging-support", + URI: "https://localhost/stub-tomcat-access-logging-support.jar", + SHA256: "d723bfe2ba67dfa92b24e3b6c7b2d0e6a963de7313350e306d470e44e330a5d2", + PURL: "pkg:generic/tomcat-access-logging-support@3.3.0", + CPEs: []string{"cpe:2.3:a:cloudfoundry:tomcat-access-logging-support:3.3.0:*:*:*:*:*:*:*"}, + } + lifecycleDep := libpak.BuildpackDependency{ + ID: "tomcat-lifecycle-support", + URI: "https://localhost/stub-tomcat-lifecycle-support.jar", + SHA256: "723126712c0b22a7fe409664adf1fbb78cf3040e313a82c06696f5058e190534", + PURL: "pkg:generic/tomcat-lifecycle-support@3.3.0", + CPEs: []string{"cpe:2.3:a:cloudfoundry:tomcat-lifecycle-support:3.3.0:*:*:*:*:*:*:*"}, + } + loggingDep := libpak.BuildpackDependency{ + ID: "tomcat-logging-support", + URI: "https://localhost/stub-tomcat-logging-support.jar", + SHA256: "e0a7e163cc9f1ffd41c8de3942c7c6b505090b7484c2ba9be846334e31c44a2c", + PURL: "pkg:generic/tomcat-logging-support@3.3.0", + CPEs: []string{"cpe:2.3:a:cloudfoundry:tomcat-logging-support:3.3.0:*:*:*:*:*:*:*"}, + } + + dc := libpak.DependencyCache{CachePath: "testdata"} + + contributor, entries := tomcat.NewBase( + ctx.Application.Path, + ctx.Buildpack.Path, + libpak.ConfigurationResolver{}, + "test-context-path", + accessLoggingDep, + nil, + lifecycleDep, + loggingDep, + dc, + true, + ) + + Expect(entries).To(HaveLen(3)) + Expect(entries[0].Name).To(Equal("tomcat-access-logging-support")) + Expect(entries[0].Build).To(BeFalse()) + Expect(entries[0].Launch).To(BeTrue()) + Expect(entries[1].Name).To(Equal("tomcat-lifecycle-support")) + Expect(entries[1].Build).To(BeFalse()) + Expect(entries[1].Launch).To(BeTrue()) + Expect(entries[2].Name).To(Equal("tomcat-logging-support")) + Expect(entries[2].Build).To(BeFalse()) + Expect(entries[2].Launch).To(BeTrue()) + + layer, err := ctx.Layers.Layer("test-layer") + Expect(err).NotTo(HaveOccurred()) + + layer, err = contributor.Contribute(layer) + Expect(err).NotTo(HaveOccurred()) + + Expect(os.Readlink(filepath.Join(layer.Path, "webapps"))).To(Equal(ctx.Application.Path)) + for _, file := range files { + targetDir := strings.TrimSuffix(file, filepath.Ext(file)) + Expect(filepath.Join(layer.Path, "webapps", targetDir, "META-INF", "MANIFEST.MF")).To(BeARegularFile()) + } + }) + }) + } diff --git a/tomcat/build.go b/tomcat/build.go index 380758d..b98df2e 100644 --- a/tomcat/build.go +++ b/tomcat/build.go @@ -18,6 +18,7 @@ package tomcat import ( "fmt" + "io/ioutil" "os" "path" "path/filepath" @@ -51,26 +52,31 @@ func (b Build) Build(context libcnb.BuildContext) (libcnb.BuildResult, error) { return result, nil } - m, err := libjvm.NewManifest(context.Application.Path) - if err != nil { - return libcnb.BuildResult{}, fmt.Errorf("unable to read manifest\n%w", err) - } + warFilesExist, _ := b.containsWarFiles(context.Application.Path) + if warFilesExist { + b.Logger.Infof("%s contains war files.", context.Application.Path) + } else { + m, err := libjvm.NewManifest(context.Application.Path) + if err != nil { + return libcnb.BuildResult{}, fmt.Errorf("unable to read manifest\n%w", err) + } - if _, ok := m.Get("Main-Class"); ok { - for _, entry := range context.Plan.Entries { - result.Unmet = append(result.Unmet, libcnb.UnmetPlanEntry{Name: entry.Name}) + if _, ok := m.Get("Main-Class"); ok { + for _, entry := range context.Plan.Entries { + result.Unmet = append(result.Unmet, libcnb.UnmetPlanEntry{Name: entry.Name}) + } + return result, nil } - return result, nil - } - file := filepath.Join(context.Application.Path, "WEB-INF") - if _, err := os.Stat(file); err != nil && !os.IsNotExist(err) { - return libcnb.BuildResult{}, fmt.Errorf("unable to stat file %s\n%w", file, err) - } else if os.IsNotExist(err) { - for _, entry := range context.Plan.Entries { - result.Unmet = append(result.Unmet, libcnb.UnmetPlanEntry{Name: entry.Name}) + file := filepath.Join(context.Application.Path, "WEB-INF") + if _, err := os.Stat(file); err != nil && !os.IsNotExist(err) { + return libcnb.BuildResult{}, fmt.Errorf("unable to stat file %s\n%w", file, err) + } else if os.IsNotExist(err) { + for _, entry := range context.Plan.Entries { + result.Unmet = append(result.Unmet, libcnb.UnmetPlanEntry{Name: entry.Name}) + } + return result, nil } - return result, nil } b.Logger.Title(context.Buildpack) @@ -144,7 +150,7 @@ func (b Build) Build(context libcnb.BuildContext) (libcnb.BuildResult, error) { } } - base, bomEntries := NewBase(context.Application.Path, context.Buildpack.Path, cr, b.ContextPath(cr), accessLoggingDependency, externalConfigurationDependency, lifecycleDependency, loggingDependency, dc) + base, bomEntries := NewBase(context.Application.Path, context.Buildpack.Path, cr, b.ContextPath(cr), accessLoggingDependency, externalConfigurationDependency, lifecycleDependency, loggingDependency, dc, warFilesExist) base.Logger = b.Logger result.Layers = append(result.Layers, base) @@ -217,3 +223,17 @@ func (b Build) tinyStartCommand(homePath, basePath string, loggingDep libpak.Bui return command, arguments } + +func (b Build) containsWarFiles(dir string) (bool, error) { + files, err := ioutil.ReadDir(dir) + if err != nil { + return false, err + } + + for _, file := range files { + if strings.HasSuffix(file.Name(), ".war") { + return true, nil + } + } + return false, nil +} diff --git a/tomcat/build_test.go b/tomcat/build_test.go index 5766a16..1acdfa3 100644 --- a/tomcat/build_test.go +++ b/tomcat/build_test.go @@ -459,4 +459,77 @@ func testBuild(t *testing.T, context spec.G, it spec.S) { }) }) + it("contributes Tomcat with war files", func() { + Expect(os.WriteFile(filepath.Join(ctx.Application.Path, "test.war"), []byte(`test`), 0644)).To(Succeed()) + + ctx.Buildpack.Metadata = map[string]interface{}{ + "dependencies": []map[string]interface{}{ + { + "id": "tomcat", + "version": "1.1.1", + "stacks": []interface{}{"test-stack-id"}, + "purl": "pkg:generic/tomcat@1.1.1", + "cpes": "cpe:2.3:a:apache:tomcat:1.1.1:*:*:*:*:*:*:*", + }, + { + "id": "tomcat-access-logging-support", + "version": "1.1.1", + "stacks": []interface{}{"test-stack-id"}, + "purl": "pkg:generic/tomcat-access-logging-support@1.1.1", + "cpes": "cpe:2.3:a:cloudfoundry:tomcat-access-logging-support:1.1.1:*:*:*:*:*:*:*", + }, + { + "id": "tomcat-lifecycle-support", + "version": "1.1.1", + "stacks": []interface{}{"test-stack-id"}, + "purl": "pkg:generic/tomcat-lifecycle-logging-support@1.1.1", + "cpes": "cpe:2.3:a:cloudfoundry:tomcat-lifecycle-logging-support:1.1.1:*:*:*:*:*:*:*", + }, + { + "id": "tomcat-logging-support", + "version": "1.1.1", + "uri": "https://example.com/releases/tomcat-logging-support-1.1.1.RELEASE.jar", + "stacks": []interface{}{"test-stack-id"}, + "purl": "pkg:generic/tomcat-logging-support@1.1.1", + "cpes": "cpe:2.3:a:cloudfoundry:tomcat-logging-support:1.1.1:*:*:*:*:*:*:*", + }, + }, + } + ctx.StackID = "test-stack-id" + + result, err := tomcat.Build{SBOMScanner: &sbomScanner}.Build(ctx) + Expect(err).NotTo(HaveOccurred()) + + Expect(result.Processes).To(ContainElements( + libcnb.Process{Type: "task", Command: "sh", Arguments: []string{"tomcat/bin/catalina.sh", "run"}, Direct: true}, + libcnb.Process{Type: "tomcat", Command: "sh", Arguments: []string{"tomcat/bin/catalina.sh", "run"}, Direct: true}, + libcnb.Process{Type: "web", Command: "sh", Arguments: []string{"tomcat/bin/catalina.sh", "run"}, Direct: true, Default: true}, + )) + + Expect(result.Layers).To(HaveLen(3)) + Expect(result.Layers[0].Name()).To(Equal("tomcat")) + Expect(result.Layers[1].Name()).To(Equal("helper")) + Expect(result.Layers[1].(libpak.HelperLayerContributor).Names).To(Equal([]string{"access-logging-support"})) + Expect(result.Layers[2].Name()).To(Equal("catalina-base")) + + Expect(result.BOM.Entries).To(HaveLen(5)) + Expect(result.BOM.Entries[0].Name).To(Equal("tomcat")) + Expect(result.BOM.Entries[0].Build).To(BeFalse()) + Expect(result.BOM.Entries[0].Launch).To(BeTrue()) + Expect(result.BOM.Entries[1].Name).To(Equal("helper")) + Expect(result.BOM.Entries[1].Build).To(BeFalse()) + Expect(result.BOM.Entries[1].Launch).To(BeTrue()) + Expect(result.BOM.Entries[2].Name).To(Equal("tomcat-access-logging-support")) + Expect(result.BOM.Entries[2].Build).To(BeFalse()) + Expect(result.BOM.Entries[2].Launch).To(BeTrue()) + Expect(result.BOM.Entries[3].Name).To(Equal("tomcat-lifecycle-support")) + Expect(result.BOM.Entries[3].Build).To(BeFalse()) + Expect(result.BOM.Entries[3].Launch).To(BeTrue()) + Expect(result.BOM.Entries[4].Name).To(Equal("tomcat-logging-support")) + Expect(result.BOM.Entries[4].Build).To(BeFalse()) + Expect(result.BOM.Entries[4].Launch).To(BeTrue()) + + sbomScanner.AssertCalled(t, "ScanLaunch", ctx.Application.Path, libcnb.SyftJSON, libcnb.CycloneDXJSON) + }) + } diff --git a/tomcat/detect.go b/tomcat/detect.go index 736655c..9d6bc1b 100644 --- a/tomcat/detect.go +++ b/tomcat/detect.go @@ -18,8 +18,10 @@ package tomcat import ( "fmt" + "io/ioutil" "os" "path/filepath" + "strings" "github.com/buildpacks/libcnb" "github.com/paketo-buildpacks/libjvm" @@ -52,14 +54,17 @@ func (d Detect) Detect(context libcnb.DetectContext) (libcnb.DetectResult, error return libcnb.DetectResult{Pass: false}, nil } - m, err := libjvm.NewManifest(context.Application.Path) - if err != nil { - return libcnb.DetectResult{}, fmt.Errorf("unable to read manifest\n%w", err) - } + warFilesExist, _ := containsWarFiles(context.Application.Path) + if !warFilesExist { + m, err := libjvm.NewManifest(context.Application.Path) + if err != nil { + return libcnb.DetectResult{}, fmt.Errorf("unable to read manifest\n%w", err) + } - if _, ok := m.Get("Main-Class"); ok { - d.Logger.Info("SKIPPED: Manifest attribute 'Main-Class' was found") - return libcnb.DetectResult{Pass: false}, nil + if _, ok := m.Get("Main-Class"); ok { + d.Logger.Info("SKIPPED: Manifest attribute 'Main-Class' was found") + return libcnb.DetectResult{Pass: false}, nil + } } result := libcnb.DetectResult{ @@ -92,3 +97,17 @@ func (d Detect) Detect(context libcnb.DetectContext) (libcnb.DetectResult, error result.Plans[0].Provides = append(result.Plans[0].Provides, libcnb.BuildPlanProvide{Name: PlanEntryJVMApplicationPackage}) return result, nil } + +func containsWarFiles(dir string) (bool, error) { + files, err := ioutil.ReadDir(dir) + if err != nil { + return false, err + } + + for _, file := range files { + if strings.HasSuffix(file.Name(), ".war") { + return true, nil + } + } + return false, nil +} diff --git a/tomcat/detect_test.go b/tomcat/detect_test.go index 284b983..c5ac84e 100644 --- a/tomcat/detect_test.go +++ b/tomcat/detect_test.go @@ -17,6 +17,7 @@ package tomcat_test import ( + "io" "os" "path/filepath" "testing" @@ -151,4 +152,52 @@ func testDetect(t *testing.T, context spec.G, it spec.S) { Expect(detect.Detect(ctx)).To(Equal(libcnb.DetectResult{Pass: false})) }) }) + + context("Multiple war files found", func() { + files := []string{"api.war", "ui.war"} + + it.Before(func() { + for _, file := range files { + in, err := os.Open(filepath.Join("testdata", "warfiles", file)) + Expect(err).NotTo(HaveOccurred()) + + out, err := os.OpenFile(filepath.Join(ctx.Application.Path, file), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644) + Expect(err).NotTo(HaveOccurred()) + + _, err = io.Copy(out, in) + Expect(err).NotTo(HaveOccurred()) + Expect(in.Close()).To(Succeed()) + Expect(out.Close()).To(Succeed()) + } + Expect(os.Setenv("BP_JAVA_APP_SERVER", "tomcat")).To(Succeed()) + }) + + it.After(func() { + Expect(os.Unsetenv("BP_JAVA_APP_SERVER")).To(Succeed()) + for _, file := range files { + os.Remove(filepath.Join(ctx.Application.Path, file)) + } + }) + + it("contributes Tomcat", func() { + Expect(detect.Detect(ctx)).To(Equal(libcnb.DetectResult{ + Pass: true, + Plans: []libcnb.BuildPlan{ + { + Provides: []libcnb.BuildPlanProvide{ + {Name: "jvm-application"}, + {Name: "java-app-server"}, + }, + Requires: []libcnb.BuildPlanRequire{ + {Name: "syft"}, + {Name: "jre", Metadata: map[string]interface{}{"launch": true}}, + {Name: "jvm-application-package"}, + {Name: "jvm-application"}, + {Name: "java-app-server"}, + }, + }, + }, + })) + }) + }) } diff --git a/tomcat/testdata/warfiles/api.war b/tomcat/testdata/warfiles/api.war new file mode 100644 index 0000000000000000000000000000000000000000..7535ce0d4af24df999954a60222de5d04207e0ad GIT binary patch literal 4803 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 literal 0 HcmV?d00001 diff --git a/tomcat/testdata/warfiles/ui.war b/tomcat/testdata/warfiles/ui.war new file mode 100644 index 0000000000000000000000000000000000000000..4ed6f577cc2eecc2ba24a5831c3cfdf7596ec352 GIT binary patch literal 8993 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 H1)cu^(t5J@ literal 0 HcmV?d00001