diff --git a/build.go b/build.go index 2781740..9f59352 100644 --- a/build.go +++ b/build.go @@ -55,7 +55,7 @@ func (b Build) Build(context libcnb.BuildContext) (libcnb.BuildResult, error) { cl := NewCertificateLoader() cl.Logger = b.Logger.BodyWriter() - jvmVersion := JVMVersion{Logger: b.Logger} + jvmVersion := NewJVMVersion(b.Logger) v, err := jvmVersion.GetJVMVersion(context.Application.Path, cr) if err != nil { return libcnb.BuildResult{}, fmt.Errorf("unable to determine jvm version\n%w", err) diff --git a/init_test.go b/init_test.go index d508464..5a9c777 100644 --- a/init_test.go +++ b/init_test.go @@ -41,6 +41,7 @@ func TestUnit(t *testing.T) { suite("NewManifest", testNewManifest) suite("NewManifestFromJAR", testNewManifestFromJAR) suite("MavenJARListing", testMavenJARListing) + suite("SDKMAN", testSDKMAN) suite("Versions", testVersions) suite("JVMVersions", testJVMVersion) suite.Run(t) diff --git a/jvm_version.go b/jvm_version.go index ed8034b..0050fb4 100644 --- a/jvm_version.go +++ b/jvm_version.go @@ -1,6 +1,10 @@ package libjvm import ( + "errors" + "fmt" + "os" + "path/filepath" "strings" "github.com/heroku/color" @@ -12,36 +16,76 @@ type JVMVersion struct { Logger bard.Logger } -func (jvmVersion JVMVersion) GetJVMVersion(appPath string, cr libpak.ConfigurationResolver) (string, error) { +func NewJVMVersion(logger bard.Logger) JVMVersion { + return JVMVersion{Logger: logger} +} + +func (j JVMVersion) GetJVMVersion(appPath string, cr libpak.ConfigurationResolver) (string, error) { version, explicit := cr.Resolve("BP_JVM_VERSION") + if explicit { + f := color.New(color.Faint) + j.Logger.Body(f.Sprintf("Using Java version %s from BP_JVM_VERSION", version)) + return version, nil + } - if !explicit { - manifest, err := NewManifest(appPath) - if err != nil { - return version, err - } + sdkmanrcJavaVersion, err := readJavaVersionFromSDKMANRCFile(appPath) + if err != nil { + return "", fmt.Errorf("unable to read Java version from SDMANRC file\n%w", err) + } - javaVersion := "" + if len(sdkmanrcJavaVersion) > 0 { + sdkmanrcJavaMajorVersion := extractMajorVersion(sdkmanrcJavaVersion) + f := color.New(color.Faint) + j.Logger.Body(f.Sprintf("Using Java version %s extracted from .sdkmanrc", sdkmanrcJavaMajorVersion)) + return sdkmanrcJavaMajorVersion, nil + } - buildJdkSpecVersion, ok := manifest.Get("Build-Jdk-Spec") - if ok { - javaVersion = buildJdkSpecVersion - } + mavenJavaVersion, err := readJavaVersionFromMavenMetadata(appPath) + if err != nil { + return "", fmt.Errorf("unable to read Java version from Maven metadata\n%w", err) + } - buildJdkVersion, ok := manifest.Get("Build-Jdk") - if ok { - javaVersion = buildJdkVersion - } + if len(mavenJavaVersion) > 0 { + mavenJavaMajorVersion := extractMajorVersion(mavenJavaVersion) + f := color.New(color.Faint) + j.Logger.Body(f.Sprintf("Using Java version %s extracted from MANIFEST.MF", mavenJavaMajorVersion)) + return mavenJavaMajorVersion, nil + } + + f := color.New(color.Faint) + j.Logger.Body(f.Sprintf("Using buildpack default Java version %s", version)) + return version, nil +} + +func readJavaVersionFromSDKMANRCFile(appPath string) (string, error) { + components, err := ReadSDKMANRC(filepath.Join(appPath, ".sdkmanrc")) + if err != nil && errors.Is(err, os.ErrNotExist) { + return "", nil + } else if err != nil { + return "", err + } - if len(javaVersion) > 0 { - javaVersionFromMaven := extractMajorVersion(javaVersion) - f := color.New(color.Faint) - jvmVersion.Logger.Body(f.Sprintf("Using Java version %s extracted from MANIFEST.MF", javaVersionFromMaven)) - return javaVersionFromMaven, nil + for _, component := range components { + if component.Type == "java" { + return component.Version, nil } } - return version, nil + return "", nil +} + +func readJavaVersionFromMavenMetadata(appPath string) (string, error) { + manifest, err := NewManifest(appPath) + if err != nil { + return "", err + } + + javaVersion, ok := manifest.Get("Build-Jdk-Spec") + if !ok { + javaVersion, _ = manifest.Get("Build-Jdk") + } + + return javaVersion, nil } func extractMajorVersion(version string) string { diff --git a/jvm_version_test.go b/jvm_version_test.go index 2c4b6db..022fb52 100644 --- a/jvm_version_test.go +++ b/jvm_version_test.go @@ -17,13 +17,14 @@ package libjvm_test import ( - "github.com/paketo-buildpacks/libpak" - "github.com/paketo-buildpacks/libpak/bard" "io/ioutil" "os" "path/filepath" "testing" + "github.com/paketo-buildpacks/libpak" + "github.com/paketo-buildpacks/libpak/bard" + "github.com/buildpacks/libcnb" . "github.com/onsi/gomega" "github.com/sclevine/spec" @@ -40,6 +41,11 @@ func testJVMVersion(t *testing.T, context spec.G, it spec.S) { ) it.Before(func() { + var err error + + appPath, err = ioutil.TempDir("", "application") + Expect(err).NotTo(HaveOccurred()) + buildpack = libcnb.Buildpack{ Metadata: map[string]interface{}{ "configurations": []map[string]interface{}{ @@ -53,6 +59,10 @@ func testJVMVersion(t *testing.T, context spec.G, it spec.S) { logger = bard.NewLogger(ioutil.Discard) }) + it.After(func() { + Expect(os.RemoveAll(appPath)).To(Succeed()) + }) + it("detecting JVM version from default", func() { jvmVersion := libjvm.JVMVersion{Logger: logger} @@ -85,13 +95,7 @@ func testJVMVersion(t *testing.T, context spec.G, it spec.S) { context("detecting JVM version", func() { it.Before(func() { - temp, err := prepareAppWithEntry("Build-Jdk: 1.8") - Expect(err).ToNot(HaveOccurred()) - appPath = temp - }) - - it.After(func() { - os.RemoveAll(appPath) + Expect(prepareAppWithEntry(appPath, "Build-Jdk: 1.8")).ToNot(HaveOccurred()) }) it("from manifest via Build-Jdk-Spec", func() { @@ -108,14 +112,11 @@ func testJVMVersion(t *testing.T, context spec.G, it spec.S) { context("detecting JVM version", func() { it.Before(func() { Expect(os.Setenv("BP_JVM_VERSION", "17")).To(Succeed()) - temp, err := prepareAppWithEntry("Build-Jdk: 1.8") - Expect(err).ToNot(HaveOccurred()) - appPath = temp + Expect(prepareAppWithEntry(appPath, "Build-Jdk: 1.8")).ToNot(HaveOccurred()) }) it.After(func() { Expect(os.Unsetenv("BP_JVM_VERSION")).To(Succeed()) - os.RemoveAll(appPath) }) it("prefers environment variable over manifest", func() { @@ -129,22 +130,56 @@ func testJVMVersion(t *testing.T, context spec.G, it spec.S) { }) }) + context("detecting JVM version", func() { + var sdkmanrcFile string + + it.Before(func() { + sdkmanrcFile = filepath.Join(appPath, ".sdkmanrc") + Expect(ioutil.WriteFile(sdkmanrcFile, []byte(`java=17.0.2-tem`), 0644)).To(Succeed()) + }) + + it("from .sdkmanrc file", func() { + jvmVersion := libjvm.JVMVersion{Logger: logger} + + cr, err := libpak.NewConfigurationResolver(buildpack, &logger) + Expect(err).ToNot(HaveOccurred()) + version, err := jvmVersion.GetJVMVersion(appPath, cr) + Expect(err).ToNot(HaveOccurred()) + Expect(version).To(Equal("17")) + }) + }) + + context("detecting JVM version", func() { + var sdkmanrcFile string + + it.Before(func() { + sdkmanrcFile = filepath.Join(appPath, ".sdkmanrc") + Expect(ioutil.WriteFile(sdkmanrcFile, []byte(`java=17.0.2-tem +java=11.0.2-tem`), 0644)).To(Succeed()) + }) + + it("picks first from .sdkmanrc file if there are multiple", func() { + jvmVersion := libjvm.JVMVersion{Logger: logger} + + cr, err := libpak.NewConfigurationResolver(buildpack, &logger) + Expect(err).ToNot(HaveOccurred()) + version, err := jvmVersion.GetJVMVersion(appPath, cr) + Expect(err).ToNot(HaveOccurred()) + Expect(version).To(Equal("17")) + }) + }) } -func prepareAppWithEntry(entry string) (string, error) { - temp, err := ioutil.TempDir("", "jre-app") - if err != nil { - return "", err - } - err = os.Mkdir(filepath.Join(temp, "META-INF"), 0744) +func prepareAppWithEntry(appPath, entry string) error { + err := os.Mkdir(filepath.Join(appPath, "META-INF"), 0744) if err != nil { - return "", err + return err } - manifest := filepath.Join(temp, "META-INF", "MANIFEST.MF") + manifest := filepath.Join(appPath, "META-INF", "MANIFEST.MF") manifestContent := []byte(entry) err = ioutil.WriteFile(manifest, manifestContent, 0644) if err != nil { - return "", err + return err } - return temp, nil + return nil } diff --git a/sdkman.go b/sdkman.go new file mode 100644 index 0000000..e7ec995 --- /dev/null +++ b/sdkman.go @@ -0,0 +1,76 @@ +/* + * Copyright 2018-2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package libjvm + +import ( + "fmt" + "io/ioutil" + "strings" +) + +// SDKInfo represents the information from each line in the `.sdkmanrc` file +type SDKInfo struct { + Type string + Version string + Vendor string +} + +// ReadSDKMANRC reads the `.sdkmanrc` format file from path and retuns the list of SDKS in it +func ReadSDKMANRC(path string) ([]SDKInfo, error) { + sdkmanrcContents, err := ioutil.ReadFile(path) + if err != nil { + return []SDKInfo{}, fmt.Errorf("unable to read SDKMANRC file at %s\n%w", path, err) + } + + sdks := []SDKInfo{} + for _, line := range strings.Split(string(sdkmanrcContents), "\n") { + if strings.TrimSpace(line) == "" { + continue + } + + parts := strings.SplitN(line, "#", 2) // strip comments + if len(parts) != 1 && len(parts) != 2 { + return []SDKInfo{}, fmt.Errorf("unable to strip comments from %q resulted in %q", line, parts) + } + + if strings.TrimSpace(parts[0]) != "" { + kv := strings.SplitN(parts[0], "=", 2) // split key=value + if len(kv) != 2 { + return []SDKInfo{}, fmt.Errorf("unable to split key/value from %q resulted in %q", parts[0], kv) + } + + versionAndVendor := []string{"", ""} + if strings.TrimSpace(kv[1]) != "" { + versionAndVendor = strings.SplitN(kv[1], "-", 2) // split optional vendor name + if len(versionAndVendor) == 1 { + versionAndVendor = append(versionAndVendor, "") + } + if len(versionAndVendor) != 2 { + return []SDKInfo{}, fmt.Errorf("unable to split vendor from %q resulted in %q", kv[1], versionAndVendor) + } + } + + sdks = append(sdks, SDKInfo{ + Type: strings.ToLower(strings.TrimSpace(kv[0])), + Version: strings.TrimSpace(versionAndVendor[0]), + Vendor: strings.ToLower(strings.TrimSpace(versionAndVendor[1])), + }) + } + } + + return sdks, nil +} diff --git a/sdkman_test.go b/sdkman_test.go new file mode 100644 index 0000000..266f9e4 --- /dev/null +++ b/sdkman_test.go @@ -0,0 +1,159 @@ +/* + * Copyright 2018-2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package libjvm_test + +import ( + "io/ioutil" + "os" + "path/filepath" + "testing" + + . "github.com/onsi/gomega" + "github.com/paketo-buildpacks/libjvm" + "github.com/sclevine/spec" +) + +func testSDKMAN(t *testing.T, context spec.G, it spec.S) { + var ( + Expect = NewWithT(t).Expect + + path string + ) + + it.Before(func() { + var err error + path, err = ioutil.TempDir("", "sdkman") + Expect(err).NotTo(HaveOccurred()) + }) + + it.After(func() { + Expect(os.RemoveAll(path)).To(Succeed()) + }) + + it("parses single entry sdkmanrc file", func() { + sdkmanrcFile := filepath.Join(path, "sdkmanrc") + Expect(ioutil.WriteFile(sdkmanrcFile, []byte(`java=17.0.2-tem`), 0644)).To(Succeed()) + + res, err := libjvm.ReadSDKMANRC(sdkmanrcFile) + Expect(err).ToNot(HaveOccurred()) + Expect(res).To(Equal([]libjvm.SDKInfo{ + {Type: "java", Version: "17.0.2", Vendor: "tem"}, + })) + }) + + it("parses single entry sdkmanrc file and forces lowercase", func() { + sdkmanrcFile := filepath.Join(path, "sdkmanrc") + Expect(ioutil.WriteFile(sdkmanrcFile, []byte(` jAva = 17.0.2-TEM `), 0644)).To(Succeed()) + + res, err := libjvm.ReadSDKMANRC(sdkmanrcFile) + Expect(err).ToNot(HaveOccurred()) + Expect(res).To(Equal([]libjvm.SDKInfo{ + {Type: "java", Version: "17.0.2", Vendor: "tem"}, + })) + }) + + it("parses multiple entry sdkmanrc and doesn't care if there's overlap", func() { + sdkmanrcFile := filepath.Join(path, "sdkmanrc") + Expect(ioutil.WriteFile(sdkmanrcFile, []byte(`java=11.0.2-tem +java=17.0.2-tem`), 0644)).To(Succeed()) + + res, err := libjvm.ReadSDKMANRC(sdkmanrcFile) + Expect(err).ToNot(HaveOccurred()) + Expect(res).To(Equal([]libjvm.SDKInfo{ + {Type: "java", Version: "11.0.2", Vendor: "tem"}, + {Type: "java", Version: "17.0.2", Vendor: "tem"}, + })) + }) + + context("handles comments and whitespace", func() { + it("ignores full-line comments", func() { + sdkmanrcFile := filepath.Join(path, "sdkmanrc") + Expect(ioutil.WriteFile(sdkmanrcFile, []byte(`# Enable auto-env through the sdkman_auto_env config +# Add key=value pairs of SDKs to use below +java=17.0.2-tem + # has some leading whitespace`), 0644)).To(Succeed()) + + res, err := libjvm.ReadSDKMANRC(sdkmanrcFile) + Expect(err).ToNot(HaveOccurred()) + Expect(res).To(Equal([]libjvm.SDKInfo{ + {Type: "java", Version: "17.0.2", Vendor: "tem"}, + })) + }) + + it("ignores trailing-line comments", func() { + sdkmanrcFile := filepath.Join(path, "sdkmanrc") + Expect(ioutil.WriteFile(sdkmanrcFile, []byte(`java=17.0.2-tem # comment`), 0644)).To(Succeed()) + + res, err := libjvm.ReadSDKMANRC(sdkmanrcFile) + Expect(err).ToNot(HaveOccurred()) + Expect(res).To(Equal([]libjvm.SDKInfo{ + {Type: "java", Version: "17.0.2", Vendor: "tem"}, + })) + }) + + it("ignores empty lines", func() { + sdkmanrcFile := filepath.Join(path, "sdkmanrc") + Expect(ioutil.WriteFile(sdkmanrcFile, []byte(` +# Enable auto-env through the sdkman_auto_env config + +java=17.0.2-tem + +`), 0644)).To(Succeed()) + + res, err := libjvm.ReadSDKMANRC(sdkmanrcFile) + Expect(err).ToNot(HaveOccurred()) + Expect(res).To(Equal([]libjvm.SDKInfo{ + {Type: "java", Version: "17.0.2", Vendor: "tem"}, + })) + }) + }) + + context("handles malformed key/values", func() { + it("parses an empty value", func() { + sdkmanrcFile := filepath.Join(path, "sdkmanrc") + Expect(ioutil.WriteFile(sdkmanrcFile, []byte(`java=`), 0644)).To(Succeed()) + + res, err := libjvm.ReadSDKMANRC(sdkmanrcFile) + Expect(err).ToNot(HaveOccurred()) + Expect(res).To(Equal([]libjvm.SDKInfo{ + {Type: "java", Version: "", Vendor: ""}, + })) + }) + + it("parses with an empty key", func() { + sdkmanrcFile := filepath.Join(path, "sdkmanrc") + Expect(ioutil.WriteFile(sdkmanrcFile, []byte(`=foo-vend`), 0644)).To(Succeed()) + + res, err := libjvm.ReadSDKMANRC(sdkmanrcFile) + Expect(err).ToNot(HaveOccurred()) + Expect(res).To(Equal([]libjvm.SDKInfo{ + {Type: "", Version: "foo", Vendor: "vend"}, + })) + }) + + it("parses with an empty vendor", func() { + sdkmanrcFile := filepath.Join(path, "sdkmanrc") + Expect(ioutil.WriteFile(sdkmanrcFile, []byte(`foo=bar`), 0644)).To(Succeed()) + + res, err := libjvm.ReadSDKMANRC(sdkmanrcFile) + Expect(err).ToNot(HaveOccurred()) + Expect(res).To(Equal([]libjvm.SDKInfo{ + {Type: "foo", Version: "bar", Vendor: ""}, + })) + }) + }) +}