cmd/java/native_image/main.go (240 lines of code) (raw):
// Copyright 2021 Google LLC
//
// 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
//
// http://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.
// Implements Java GraalVM Native Image buildpack.
// This buildpack installs the GraalVM compiler into a layer and builds a native image of the Java application.
package main
import (
"fmt"
"os"
"path/filepath"
"strings"
"github.com/GoogleCloudPlatform/buildpacks/pkg/env"
gcp "github.com/GoogleCloudPlatform/buildpacks/pkg/gcpbuildpack"
"github.com/GoogleCloudPlatform/buildpacks/pkg/java"
"github.com/buildpacks/libcnb/v2"
)
const (
invokerMain = "com.google.cloud.functions.invoker.runner.Invoker"
)
var (
requiresGraalvm = []libcnb.BuildPlanRequire{{Name: "graalvm"}}
planRequires = libcnb.BuildPlan{Requires: requiresGraalvm}
)
func main() {
gcp.Main(detectFn, buildFn)
}
func detectFn(ctx *gcp.Context) (gcp.DetectResult, error) {
return gcp.OptInAlways(gcp.WithBuildPlans(planRequires)), nil
}
func buildFn(ctx *gcp.Context) error {
entrypoint, err := createImage(ctx)
if err != nil {
return err
}
ctx.AddWebProcess(entrypoint)
return nil
}
// createImage builds a native-image and returns an image entrypoint. It handles
// all the logic for which workflow to use (e.g native-image build via command
// line or maven profile) based on the project setup.
func createImage(ctx *gcp.Context) ([]string, error) {
pom, err := parsePomFile(ctx)
if err != nil {
return nil, fmt.Errorf("parsing pom file: %w", err)
}
if pom == nil {
return buildDefault(ctx)
}
if functionTarget, ok := os.LookupEnv(env.FunctionTarget); ok {
return buildFunctionsFramework(ctx, functionTarget, pom)
}
if buildProfile, ok := findNativeBuildProfile(ctx, pom); ok {
return buildMaven(ctx, buildProfile)
}
// The presence of the `spring-boot-maven-plugin` may not always guarantee that
// the project will generate a Spring-Boot fat JAR. In the case where a Spring
// Boot fat JAR is not found, we fall through to the default mode of building a
// native image for standard Java apps.
if springBootPluginDefined(ctx, pom) {
if entrypoint, err := buildSpringBoot(ctx); err != nil {
return nil, err
} else if entrypoint != nil {
return entrypoint, nil
}
}
return buildDefault(ctx)
}
// buildDefault builds a native-image in the basic and non-specialized way that can work on any normal
// Java apps and returns the image entrypoint. Currently, only supported is an executable JAR in the context.
func buildDefault(ctx *gcp.Context) ([]string, error) {
jar, err := java.ExecutableJar(ctx)
if err != nil {
return nil, fmt.Errorf("finding executable jar: %w", err)
}
return buildCommandLine(ctx, []string{"-jar", jar})
}
// buildCommandLine runs the native-image build via command line and returns the image entrypoint.
func buildCommandLine(ctx *gcp.Context, buildArgs []string) ([]string, error) {
niDir, err := ctx.TempDir("native-image")
if err != nil {
return nil, err
}
tempImagePath := filepath.Join(niDir, "native-app")
// Use a temporary image path because this command may generate extra files
// (*.o and *.build_artifacts.txt) alongside the binary in the temp dir.
userArgs := os.Getenv(env.NativeImageBuildArgs)
command := fmt.Sprintf("native-image --no-fallback --no-server -H:+StaticExecutableWithDynamicLibC %s %s %s",
userArgs, strings.Join(buildArgs, " "), tempImagePath)
if _, err := ctx.Exec([]string{"bash", "-c", command}, gcp.WithUserAttribution); err != nil {
return nil, err
}
nativeLayer, err := ctx.Layer("native-image", gcp.LaunchLayer)
if err != nil {
return nil, fmt.Errorf("creating layer: %w", err)
}
finalImage := filepath.Join(nativeLayer.Path, "bin", "native-app")
if err := ctx.MkdirAll(finalImage, 0755); err != nil {
return nil, err
}
if err := ctx.Rename(tempImagePath, finalImage); err != nil {
return nil, err
}
return []string{finalImage}, nil
}
// buildMaven runs the Maven native-image build and returns the image entrypoint.
func buildMaven(ctx *gcp.Context, buildProfile string) ([]string, error) {
mvn, err := java.MvnCmd(ctx)
if err != nil {
return nil, err
}
command := []string{mvn, "package", "-DskipTests", "--batch-mode", "-Dhttp.keepAlive=false"}
if buildProfile != "" {
command = append(command, "-P"+buildProfile)
}
if _, err := ctx.Exec(command, gcp.WithUserAttribution); err != nil {
return nil, err
}
imagePath, err := findNativeExecutable(ctx)
if err != nil {
return nil, err
}
return []string{imagePath}, nil
}
// parsePomFile returns a parsed pom.xml if it exists.
func parsePomFile(ctx *gcp.Context) (*java.MavenProject, error) {
pomExists, err := ctx.FileExists("pom.xml")
if err != nil {
return nil, err
}
if !pomExists {
return nil, nil
}
tmpDir, err := ctx.TempDir("native-image-maven")
if err != nil {
return nil, fmt.Errorf("creating temp directory: %w", err)
}
// Write the effective Maven pom.xml to file.
effectivePomPath := filepath.Join(tmpDir, "project_effective_pom.xml")
mvn, err := java.MvnCmd(ctx)
if err != nil {
return nil, err
}
if _, err := ctx.Exec([]string{
mvn,
"help:effective-pom",
"--batch-mode",
"-Dhttp.keepAlive=false",
"-Doutput=" + effectivePomPath}, gcp.WithUserAttribution); err != nil {
return nil, err
}
// Parse the effective pom.xml.
effectivePom, err := ctx.ReadFile(effectivePomPath)
if err != nil {
return nil, err
}
project, err := java.ParsePomFile(effectivePom)
if err != nil {
ctx.Warnf("A pom.xml was found but unable to be parsed: %v\n", err)
return nil, nil
}
return project, nil
}
// findNativeBuildProfile returns the profile in which the native-image-plugin is defined
// and a bool which returns true if the plugin is found, false if not.
func findNativeBuildProfile(ctx *gcp.Context, project *java.MavenProject) (string, bool) {
for _, profile := range project.Profiles {
for _, plugin := range profile.Plugins {
if plugin.GroupID == "org.graalvm.nativeimage" &&
plugin.ArtifactID == "native-image-maven-plugin" {
return profile.ID, true
}
}
}
ctx.Logf("Did not find a native-image-plugin defined in the pom.xml")
return "", false
}
// springBootPluginDefined checks if the spring-boot-maven-plugin is defined.
func springBootPluginDefined(ctx *gcp.Context, project *java.MavenProject) bool {
for _, plugin := range project.Plugins {
if plugin.GroupID == "org.springframework.boot" &&
plugin.ArtifactID == "spring-boot-maven-plugin" {
return true
}
}
ctx.Logf("Did not find a spring-boot-maven-plugin defined in the pom.xml")
return false
}
// findNativeExecutable returns the path to the executable from the target/ folder
// and only succeeds if exactly 1 is found; returns error otherwise.
func findNativeExecutable(ctx *gcp.Context) (string, error) {
var allExecutables []string
targetDir, err := ctx.ReadDir("target")
if err != nil {
return "", err
}
for _, info := range targetDir {
// If any of the last three bits of the file mode are set, it is executable.
if !info.IsDir() && info.Mode()&0111 != 0 {
allExecutables = append(allExecutables, filepath.Join("target", info.Name()))
}
}
if len(allExecutables) != 1 {
return "", gcp.UserErrorf("expected project to produce exactly 1 executable in target/, but found: %v", allExecutables)
}
return allExecutables[0], nil
}
// buildSpringBoot attempts to build a native image from a Spring Boot fat JAR and returns the image entrypoint.
// It may return empty if, for example, no Spring Boot fat JAR is found.
func buildSpringBoot(ctx *gcp.Context) ([]string, error) {
classpath, main, err := classpathAndMainFromSpringBoot(ctx)
if err != nil {
return nil, err
} else if classpath == "" || main == "" {
return nil, nil
}
return buildCommandLine(ctx, []string{"--class-path", classpath, main})
}
// classpathAndMainFromSpringBoot returns classpath and main class of an exploded Spring Boot fat JAR
// that is suitable for the application exeuction on a JVM. It may return empty strings if, for example,
// no Spring Boot fat JAR is found.
func classpathAndMainFromSpringBoot(ctx *gcp.Context) (string, string, error) {
jar, err := java.ExecutableJar(ctx)
if err != nil {
ctx.Warnf("Spring Boot project assumed but no main executable JAR found: %v\n", err)
return "", "", nil
}
startClass, err := java.FindManifestValueFromJar(jar, "Start-Class")
if err != nil {
return "", "", fmt.Errorf("fetching manifest value from JAR: %q", jar)
}
if startClass == "" {
ctx.Warnf("Spring Boot project assumed but Start-Class undefined in executable JAR: %q", jar)
return "", "", nil
}
explodedJarDir, err := ctx.TempDir("exploded-jar")
if err != nil {
return "", "", fmt.Errorf("creating temp directory: %w", err)
}
if _, err := ctx.Exec([]string{"unzip", "-q", jar, "-d", explodedJarDir}, gcp.WithUserAttribution); err != nil {
return "", "", err
}
classes := filepath.Join(explodedJarDir, "BOOT-INF", "classes")
// TODO(chanseok): using '*' gives a different dependency order than the one computed by Maven.
// If a Spring Boot fat JAR contain classpath.idx, use it for the exact classpath.
// https://docs.spring.io/spring-boot/docs/current/reference/html/deployment.html#deployment.containers
libs := filepath.Join(explodedJarDir, "BOOT-INF", "lib", "*")
classpath := strings.Join([]string{explodedJarDir, classes, libs}, string(filepath.ListSeparator))
return classpath, startClass, nil
}
// buildFunctionsFramework runs the native-image build for the standard GCF workflow and returns the image entrypoint.
func buildFunctionsFramework(ctx *gcp.Context, functionTarget string, project *java.MavenProject) ([]string, error) {
classpath, err := createFunctionsClasspath(ctx, project)
if err != nil {
return nil, err
}
entrypoint, err := buildCommandLine(ctx, []string{"-cp", classpath, invokerMain})
if err != nil {
return nil, err
}
functionsFrameworkEntrypoint := append(entrypoint, "--target", functionTarget)
return functionsFrameworkEntrypoint, nil
}
// createFunctionsClasspath generates the full classpath to be used with native-image command line for GCF workflow
func createFunctionsClasspath(ctx *gcp.Context, project *java.MavenProject) (string, error) {
jarName := fmt.Sprintf("%s-%s.jar", project.ArtifactID, project.Version)
applicationJar := filepath.Join("target", jarName)
jarExists, err := ctx.FileExists(applicationJar)
if err != nil {
return "", err
}
if !jarExists {
return "", gcp.UserErrorf("finding application JAR: %s", applicationJar)
}
dependencies := filepath.Join("target", "dependency", "*")
classpath := strings.Join([]string{os.Getenv(java.FFJarPathEnv), applicationJar, dependencies}, string(filepath.ListSeparator))
return classpath, nil
}