cmd/java/functions_framework/main.go (269 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/functions_framework buildpack.
// The functions_framework buildpack copies the function framework into a layer, and adds it to a compiled function to make an executable app.
package main
import (
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"github.com/GoogleCloudPlatform/buildpacks/pkg/cloudfunctions"
"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 (
layerName = "functions-framework"
javaFunctionInvokerURLBase = "https://maven-central.storage-download.googleapis.com/maven2/com/google/cloud/functions/invoker/java-function-invoker/"
defaultFrameworkVersion = "1.4.1"
functionsFrameworkURLTemplate = javaFunctionInvokerURLBase + "%[1]s/java-function-invoker-%[1]s.jar"
versionKey = "version"
invokerMain = "com.google.cloud.functions.invoker.runner.Invoker"
implementationVersionKey = "Implementation-Version"
)
var (
frameworkVersionRegex = regexp.MustCompile("java-function-invoker-((\\d+\\.)*\\d+)")
)
func main() {
gcp.Main(detectFn, buildFn)
}
func detectFn(ctx *gcp.Context) (gcp.DetectResult, error) {
if _, ok := os.LookupEnv(env.FunctionTarget); ok {
return gcp.OptInEnvSet(env.FunctionTarget), nil
}
return gcp.OptOutEnvNotSet(env.FunctionTarget), nil
}
func buildFn(ctx *gcp.Context) error {
classpath, err := classpath(ctx)
if err != nil {
return err
}
layer, err := ctx.Layer(layerName, gcp.BuildLayer, gcp.CacheLayer, gcp.LaunchLayer)
if err != nil {
return fmt.Errorf("creating %v layer: %w", layerName, err)
}
ffPath, err := installFunctionsFramework(ctx, layer)
layer.BuildEnvironment.Override(java.FFJarPathEnv, ffPath)
if err != nil {
return err
}
if err := ctx.SetFunctionsEnvVars(layer); err != nil {
return err
}
// Use javap to check that the class is indeed in the classpath we just determined.
// On success, it will output a description of the class and its public members, which we discard.
// On failure it will output an error saying what's wrong (usually that the class doesn't exist).
// Success here doesn't guarantee that the function will execute. It might not implement one of the
// required interfaces, for example. But it eliminates the commonest problem of specifying the wrong target.
// We use an ExecUser* method so that the time taken by the javap command is counted as user time.
target := os.Getenv(env.FunctionTarget)
if result, err := ctx.Exec([]string{"javap", "-classpath", classpath, target}, gcp.WithUserAttribution); err != nil {
// The javap error output will typically be "Error: class not found: foo.Bar".
return gcp.UserErrorf("build succeeded but did not produce the class %q specified as the function target: %s", target, result.Combined)
}
launcherSource := filepath.Join(ctx.BuildpackRoot(), "launch.sh")
launcherTarget := filepath.Join(layer.Path, "launch.sh")
createLauncher(ctx, launcherSource, launcherTarget)
ctx.AddWebProcess([]string{launcherTarget, "java", "-jar", ffPath, "--classpath", classpath})
return nil
}
func createLauncher(ctx *gcp.Context, launcherSource, launcherTarget string) error {
launcherContents, err := ctx.ReadFile(launcherSource)
if err != nil {
return err
}
if err := ctx.WriteFile(launcherTarget, launcherContents, os.FileMode(0755)); err != nil {
return err
}
return nil
}
// classpath determines what the --classpath argument should be. This tells the Functions Framework where to find
// the classes of the function, including dependencies.
func classpath(ctx *gcp.Context) (string, error) {
pomExists, err := ctx.FileExists("pom.xml")
if err != nil {
return "", err
}
if pomExists {
return mavenClasspath(ctx)
}
buildGradleExists, err := ctx.FileExists("build.gradle")
if err != nil {
return "", err
}
if buildGradleExists {
return gradleClasspath(ctx)
}
jars, err := ctx.Glob("*.jar")
if err != nil {
return "", fmt.Errorf("finding jar files: %w", err)
}
if len(jars) == 1 {
// Already-built jar file. It should be self-contained, which means that it can be the only thing given to --classpath.
return jars[0], nil
}
if len(jars) > 1 {
return "", gcp.UserErrorf("function has no pom.xml and more than one jar file: %s", strings.Join(jars, ", "))
}
// We have neither pom.xml nor a jar file. Show what files there are. If the user deployed the wrong directory, this may help them see the problem more easily.
description := "directory is empty"
files, err := ctx.Glob("*")
if err != nil {
return "", fmt.Errorf("finding files: %w", err)
}
if len(files) > 0 {
description = fmt.Sprintf("directory has these entries: %s", strings.Join(files, ", "))
}
return "", gcp.UserErrorf("function has neither pom.xml nor already-built jar file; %s", description)
}
// mavenClasspath determines the --classpath when there is a pom.xml. This will consist of the jar file built
// from the pom.xml itself, plus all jar files that are dependencies mentioned in the pom.xml.
func mavenClasspath(ctx *gcp.Context) (string, error) {
mvn, err := java.MvnCmd(ctx)
if err != nil {
return "", err
}
// Copy the dependencies of the function (`<dependencies>` in pom.xml) into target/dependency.
if _, err := ctx.Exec([]string{mvn, "--batch-mode", "dependency:copy-dependencies", "-Dmdep.prependGroupId", "-DincludeScope=runtime"}, gcp.WithUserAttribution); err != nil {
return "", err
}
// Extract the final jar name from the user's pom.xml definitions.
execResult, err := ctx.Exec([]string{mvn, "help:evaluate", "-q", "-DforceStdout", "-Dexpression=project.build.finalName"}, gcp.WithUserAttribution)
if err != nil {
return "", err
}
artifactName := strings.TrimSpace(execResult.Stdout)
if len(artifactName) == 0 {
return "", gcp.UserErrorf("invalid project.build.finalName configured in pom.xml")
}
jarName := fmt.Sprintf("target/%s.jar", artifactName)
jarExists, err := ctx.FileExists(jarName)
if err != nil {
return "", err
}
if !jarExists {
return "", gcp.UserErrorf("expected output jar %s does not exist", jarName)
}
// The Functions Framework understands "*" to mean every jar file in that directory.
// So this classpath consists of the just-built jar and all of the dependency jars.
return jarName + ":target/dependency/*", nil
}
// gradleClasspath determines the --classpath when there is a build.gradle. This will consist of the jar file built
// from the build.gradle, plus all jar files that are dependencies mentioned there.
// Unlike Maven, Gradle doesn't have a simple way to query the contents of the build.gradle. But we can update
// the user's build.gradle to append tasks that do that. This is a bit ugly, but using --init-script didn't work
// because apparently you can't define tasks there; and having the predefined script include the user's build.gradle
// didn't work very well either, because you can't use a plugins {} clause in an included script.
func gradleClasspath(ctx *gcp.Context) (string, error) {
gradle, err := java.GradleCmd(ctx)
if err != nil {
return "", err
}
extraTasksSource := filepath.Join(ctx.BuildpackRoot(), "extra_tasks.gradle")
extraTasksText, err := ctx.ReadFile(extraTasksSource)
if err != nil {
return "", err
}
if err := os.Chmod("build.gradle", 0644); err != nil {
return "", gcp.InternalErrorf("making build.gradle writable: %v", err)
}
f, err := os.OpenFile("build.gradle", os.O_APPEND|os.O_WRONLY, 0600)
if err != nil {
return "", gcp.InternalErrorf("opening build.gradle for appending: %v", err)
}
defer f.Close()
if _, err := f.Write(extraTasksText); err != nil {
return "", gcp.InternalErrorf("appending extra definitions to build.gradle: %v", err)
}
// Copy the dependencies of the function (`dependencies {...}` in build.gradle) into build/_javaFunctionDependencies.
if _, err := ctx.Exec([]string{gradle, "--quiet", "_javaFunctionCopyAllDependencies"}, gcp.WithUserAttribution); err != nil {
return "", err
}
// Extract the name of the target jar.
execResult, err := ctx.Exec([]string{gradle, "--quiet", "_javaFunctionPrintJarTarget"}, gcp.WithUserAttribution)
if err != nil {
return "", err
}
jarName := strings.TrimSpace(execResult.Stdout)
jarExists, err := ctx.FileExists(jarName)
if err != nil {
return "", err
}
if !jarExists {
return "", gcp.UserErrorf("expected output jar %s does not exist", jarName)
}
// The Functions Framework understands "*" to mean every jar file in that directory.
// So this classpath consists of the just-built jar and all of the dependency jars.
return fmt.Sprintf("%s:build/_javaFunctionDependencies/*", jarName), nil
}
func installFunctionsFramework(ctx *gcp.Context, layer *libcnb.Layer) (string, error) {
jars := []string{}
pomExists, err := ctx.FileExists("pom.xml")
if err != nil {
return "", err
}
if pomExists {
mvn, err := java.MvnCmd(ctx)
if err != nil {
return "", err
}
// If the invoker was listed as a dependency in the pom.xml, copy it into target/_javaInvokerDependency.
if _, err := ctx.Exec([]string{
mvn,
"--batch-mode",
"dependency:copy-dependencies",
"-DoutputDirectory=target/_javaInvokerDependency",
"-DincludeGroupIds=com.google.cloud.functions",
"-DincludeArtifactIds=java-function-invoker",
}, gcp.WithUserAttribution); err != nil {
return "", err
}
jars, err = ctx.Glob("target/_javaInvokerDependency/java-function-invoker-*.jar")
if err != nil {
return "", fmt.Errorf("finding java-function-invoker jar: %w", err)
}
} else {
buildGradleExists, err := ctx.FileExists("build.gradle")
if err != nil {
return "", err
}
if buildGradleExists {
// If the invoker was listed as an implementation dependency it will have been copied to build/_javaFunctionDependencies.
jars, err = ctx.Glob("build/_javaFunctionDependencies/java-function-invoker-*.jar")
if err != nil {
return "", fmt.Errorf("finding java-function-invoker jar: %w", err)
}
}
}
if len(jars) == 1 && isInvokerJar(ctx, jars[0]) {
if err := ctx.ClearLayer(layer); err != nil {
return "", fmt.Errorf("clearing layer %q: %w", layer.Name, err)
}
// No need to cache the layer because we aren't downloading the framework.
layer.Cache = false
addFrameworkVersionLabel(ctx, layer, jars[0])
return jars[0], nil
}
ctx.Warnf("Failed to find vendored functions-framework dependency. Installing version %s:\n%v", defaultFrameworkVersion, err)
frameworkVersion := defaultFrameworkVersion
// Install functions-framework.
metaVersion := ctx.GetMetadata(layer, versionKey)
if frameworkVersion == metaVersion {
ctx.CacheHit(layerName)
} else {
ctx.CacheMiss(layerName)
if err := ctx.ClearLayer(layer); err != nil {
return "", fmt.Errorf("clearing layer %q: %w", layer.Name, err)
}
if err := downloadFramework(ctx, layer, frameworkVersion); err != nil {
return "", err
}
ctx.SetMetadata(layer, versionKey, frameworkVersion)
}
cloudfunctions.AddFrameworkVersionLabel(ctx, &cloudfunctions.FrameworkVersionInfo{
Runtime: "java",
Version: frameworkVersion,
Injected: true,
})
return filepath.Join(layer.Path, "functions-framework.jar"), nil
}
// isInvokerjar checks if the .jar at the given filepath is the functions framework invoker by checking
// that the manifest's Main-Class matches an expected value.
func isInvokerJar(ctx *gcp.Context, jar string) bool {
main, err := java.MainManifestEntry(jar)
return err == nil && main == invokerMain
}
func addFrameworkVersionLabel(ctx *gcp.Context, layer *libcnb.Layer, frameworkJar string) {
version, err := java.FindManifestValueFromJar(frameworkJar, implementationVersionKey)
if err != nil {
ctx.Logf("Functions framework manifest could not be read: %v", err)
}
if version == "" {
// If version isn't found the ff version may predate setting implementationVersionKey.
// In these cases a regex match is the best way to identify the framework version.
if matches := frameworkVersionRegex.FindStringSubmatch(frameworkJar); matches != nil {
version = matches[1]
} else {
ctx.Logf("Unable to identify functions framework version from %v", frameworkJar)
version = "unknown"
}
}
cloudfunctions.AddFrameworkVersionLabel(ctx, &cloudfunctions.FrameworkVersionInfo{
Runtime: "java",
Version: version,
Injected: false,
})
}
// downloadFramework downloads the functions framework invoker jar and saves it in the provided layer.
func downloadFramework(ctx *gcp.Context, layer *libcnb.Layer, version string) error {
url := fmt.Sprintf(functionsFrameworkURLTemplate, version)
ffName := filepath.Join(layer.Path, "functions-framework.jar")
result, err := ctx.Exec([]string{"curl", "--silent", "--fail", "--show-error", "--output", ffName, url})
if err != nil {
return gcp.InternalErrorf("fetching functions framework jar: %v\n%s", err, result.Stderr)
}
return nil
}