cmd/go/functions_framework/main.go (370 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 go/functions_framework buildpack. // The functions_framework buildpack converts a functionn into an application and sets up the execution environment. package main import ( "encoding/json" "fmt" "io/fs" "os" "path/filepath" "strings" "text/template" "github.com/GoogleCloudPlatform/buildpacks/pkg/cloudfunctions" "github.com/GoogleCloudPlatform/buildpacks/pkg/env" "github.com/GoogleCloudPlatform/buildpacks/pkg/fileutil" gcp "github.com/GoogleCloudPlatform/buildpacks/pkg/gcpbuildpack" "github.com/GoogleCloudPlatform/buildpacks/pkg/golang" "github.com/Masterminds/semver" ) const ( layerName = "functions-framework" gopathLayerName = "gopath" functionsFrameworkModule = "github.com/GoogleCloudPlatform/functions-framework-go" functionsFrameworkPackage = functionsFrameworkModule + "/funcframework" functionsFrameworkFunctionsPackage = functionsFrameworkModule + "/functions" functionsFrameworkVersion = "v1.9.2" appModule = "functions.local/app" fnSourceDir = "serverless_function_source_code" ) var ( googleDirs = []string{fnSourceDir, ".googlebuild", ".googleconfig"} tmplV0 = template.Must(template.New("mainV0").Parse(mainTextTemplateV0)) tmplV1_1 = template.Must(template.New("mainV1_1").Parse(mainTextTemplateV1_1)) tmplDeclarative = template.Must(template.New("main_declarative").Parse(mainTextTemplateDeclarative)) ) type fnInfo struct { Source string Target string Package string Imports map[string]struct{} } type parsedPackage struct { Name string `json:"name"` Imports map[string]struct{} `json:"imports"` } func main() { gcp.Main(detectFn, buildFn) } func detectFn(ctx *gcp.Context) (gcp.DetectResult, error) { if golang.IsGo111Runtime() { return gcp.OptOut("Incompatible with go111"), nil } 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 { l, err := ctx.Layer(layerName, gcp.LaunchLayer) if err != nil { return fmt.Errorf("creating %v layer: %w", layerName, err) } if err := ctx.SetFunctionsEnvVars(l); err != nil { return err } ctx.AddWebProcess([]string{golang.OutBin}) fnTarget := os.Getenv(env.FunctionTarget) // Move the function source code into a subdirectory. if err := ctx.RemoveAll(fnSourceDir); err != nil { return err } if err := ctx.MkdirAll(fnSourceDir, 0755); err != nil { return err } wd, err := os.Getwd() if err != nil { return gcp.InternalErrorf("getting current directory: %w", err) } if err := fileutil.MaybeMovePathContents(fnSourceDir, wd, func(path string, d fs.DirEntry) (bool, error) { name := filepath.Base(path) // Exclude serverless_function_source_code and .google* dir e.g. .googlebuild, .googleconfig return name != fnSourceDir && !strings.HasPrefix(name, ".google"), nil }); err != nil { return gcp.InternalErrorf("unable to move source code to build directory: %v", err) } fnSource := filepath.Join(ctx.ApplicationRoot(), fnSourceDir) pkg, err := extractPackageNameInDir(ctx, fnSource) if err != nil { return gcp.UserErrorf("error extracting package name: %v", err) } fn := fnInfo{ Source: fnSource, Target: fnTarget, Package: pkg.Name, Imports: pkg.Imports, } goMod := filepath.Join(fn.Source, "go.mod") goModExists, err := ctx.FileExists(goMod) if err != nil { return err } if !goModExists { return createMainVendored(ctx, fn) } isWriteable, err := ctx.IsWritable(goMod) if err != nil { return err } if !isWriteable { // Preempt an obscure failure mode: if go.mod is not writable then `go list -m` can fail saying: // go: updates to go.sum needed, disabled by -mod=readonly return gcp.UserErrorf("go.mod exists but is not writable") } vendorExists, err := ctx.FileExists(fn.Source, "vendor") if err != nil { return err } if vendorExists { return createMainGoModVendored(ctx, fn) } return createMainGoMod(ctx, fn) } func createMainGoMod(ctx *gcp.Context, fn fnInfo) error { l, err := ctx.Layer(gopathLayerName, gcp.BuildLayer) if err != nil { return fmt.Errorf("creating %v layer: %w", gopathLayerName, err) } l.BuildEnvironment.Override("GOPATH", l.Path) if err := ctx.Setenv("GOPATH", l.Path); err != nil { return err } goSumExists, err := ctx.FileExists(fn.Source, "go.sum") if err != nil { return err } // If the function source does not include a go.sum, `go list` will fail under Go 1.16+. if !goSumExists { ctx.Logf(`go.sum not found, generating using "go mod tidy"`) if _, err := golang.ExecWithGoproxyFallback(ctx, []string{"go", "mod", "tidy"}, gcp.WithWorkDir(fn.Source), gcp.WithUserAttribution); err != nil { return fmt.Errorf("running go mod tiny: %w", err) } } _, fnPackage, err := moduleAndPackageNames(ctx, fn) if err != nil { return fmt.Errorf("extracting module and package names: %w", err) } fn.Package = fnPackage // If the framework is not present in the function's go.mod, we require the current version. version, err := frameworkSpecifiedVersion(ctx, fn.Source) if err != nil { return fmt.Errorf("checking for functions framework dependency in go.mod: %w", err) } injected := false if version == "" { if err := cloudfunctions.AssertFrameworkInjectionAllowed(); err != nil { return err } if _, err := ctx.Exec([]string{"go", "mod", "edit", "-require", fmt.Sprintf("%s@%s", functionsFrameworkModule, functionsFrameworkVersion)}, gcp.WithWorkDir(fn.Source), gcp.WithLogCommand(true)); err != nil { return err } version = functionsFrameworkVersion injected = true } cloudfunctions.AddFrameworkVersionLabel(ctx, &cloudfunctions.FrameworkVersionInfo{ Runtime: "go", Version: version, Injected: injected, }) mainPackageDirectory := filepath.Join(fn.Source, appModule) if err := ctx.MkdirAll(mainPackageDirectory, 0755); err != nil { return err } fnSourceMain := filepath.Join(mainPackageDirectory, "main.go") if err := createMainGoFile(ctx, fn, fnSourceMain, version); err != nil { return err } // Generate a go.sum entry which is required starting with Go 1.16. // We generate a go.mod file dynamically since the function may request a specific version of // the framework, in which case we want to import that version. For that reason we cannot // include a pre-generated go.sum file. if _, err := golang.ExecWithGoproxyFallback(ctx, []string{"go", "mod", "tidy"}, gcp.WithUserAttribution, gcp.WithWorkDir(fn.Source)); err != nil { return fmt.Errorf("running go mod tidy: %w", err) } // Make function's source the work directory when running go build l.BuildEnvironment.Override(golang.BuildDirEnv, fn.Source) // Specify what to build in the go build buildpack l.BuildEnvironment.Override(env.Buildable, mainPackageDirectory) return nil } func createMainGoModVendored(ctx *gcp.Context, fn fnInfo) error { l, err := ctx.Layer(gopathLayerName, gcp.BuildLayer) if err != nil { return fmt.Errorf("creating %v layer: %w", gopathLayerName, err) } l.BuildEnvironment.Override("GOPATH", l.Path) if err := ctx.Setenv("GOPATH", l.Path); err != nil { return err } _, fnPackage, err := moduleAndPackageNames(ctx, fn) if err != nil { return fmt.Errorf("extracting module and package names: %w", err) } fn.Package = fnPackage fnFrameworkVendoredPathExists, err := ctx.FileExists(fn.Source, "vendor", functionsFrameworkPackage) if err != nil { return err } version, err := frameworkSpecifiedVersion(ctx, fn.Source) if err != nil { return fmt.Errorf("checking for functions framework dependency in go.mod: %w", err) } // The function must declare functions framework as a dependency. if version == "" || !fnFrameworkVendoredPathExists { // Vendored dependencies must include the functions framework. Modifying vendored dependencies // and adding the framework ourselves by merging two vendor directories is brittle and likely // to cause conflicts among the function's and the framework's dependencies. return gcp.UserErrorf("vendored dependencies must include %q; if your function does not depend on the module, please add a blank import: `_ %q`", functionsFrameworkModule, functionsFrameworkPackage) } cloudfunctions.AddFrameworkVersionLabel(ctx, &cloudfunctions.FrameworkVersionInfo{ Runtime: "go", Version: version, Injected: false, }) var buildablePackagePath string var appDir string supportsVendorModification, err := golang.SupportsVendorModificaton(ctx) if err != nil { return fmt.Errorf("checking if vendor modification is supported: %w", err) } if !supportsVendorModification { appDir = filepath.Join(fn.Source, appModule) buildablePackagePath = appDir } else { appDir = filepath.Join(fn.Source, "vendor", appModule) buildablePackagePath = appModule } if err := ctx.MkdirAll(appDir, 0755); err != nil { return err } appMainPath := filepath.Join(appDir, "main.go") l.BuildEnvironment.Override(env.Buildable, buildablePackagePath) l.BuildEnvironment.Override(golang.BuildDirEnv, fn.Source) return createMainGoFile(ctx, fn, appMainPath, version) } // moduleAndPackageNames extracts the module name and package name of the function. func moduleAndPackageNames(ctx *gcp.Context, fn fnInfo) (string, string, error) { result, err := ctx.Exec([]string{"go", "list", "-m"}, gcp.WithWorkDir(fn.Source), gcp.WithUserAttribution) if err != nil { return "", "", err } fnMod := result.Stdout // golang.org/ref/mod requires that package names in a replace contains at least one dot. if parts := strings.Split(fnMod, "/"); len(parts) > 0 && !strings.Contains(parts[0], ".") { return "", "", gcp.UserErrorf("the module path in the function's go.mod must contain a dot in the first path element before a slash, e.g. example.com/module, found: %s", fnMod) } // Add the module name to the the package name, such that go build will be able to find it, // if a directory with the package name is not at the app root. Otherwise, assume the package is at the module root. fnPackage := fnMod fnPackageExists, err := ctx.FileExists(ctx.ApplicationRoot(), fn.Package) if err != nil { return "", "", err } if fnPackageExists { fnPackage = fmt.Sprintf("%s/%s", fnMod, fn.Package) } return fnMod, fnPackage, nil } // createMainVendored creates the main.go file for vendored functions. // This should only be run for Go 1.11 and 1.13. // Go 1.11 and 1.13 on GCF allow for vendored go.mod deployments without a go.mod file. // Note that despite the lack of a go.mod file, this does *not* mean that these are GOPATH deployments. // These deployments were created by running `go mod vendor` and then .gcloudignoring the go.mod file, // so that Go versions that don't natively handle gomod vendoring would be able to pick up the vendored deps. // n.b. later versions of Go (1.14+) handle vendored go.mod files natively, and so we just use the go.mod route there. func createMainVendored(ctx *gcp.Context, fn fnInfo) error { l, err := ctx.Layer(gopathLayerName, gcp.BuildLayer) if err != nil { return fmt.Errorf("creating %v layer: %w", gopathLayerName, err) } gopath := ctx.ApplicationRoot() gopathSrc := filepath.Join(gopath, "src") if err := ctx.MkdirAll(gopathSrc, 0755); err != nil { return err } l.BuildEnvironment.Override(env.Buildable, appModule+"/main") l.BuildEnvironment.Override("GOPATH", gopath) l.BuildEnvironment.Override("GO111MODULE", "auto") if err := ctx.Setenv("GOPATH", gopath); err != nil { return err } appPath := filepath.Join(gopathSrc, appModule, "main") if err := ctx.MkdirAll(appPath, 0755); err != nil { return err } // We move the function source (including any vendored deps) into GOPATH. if err := ctx.Rename(fn.Source, filepath.Join(gopathSrc, fn.Package)); err != nil { return err } fnVendoredPath := filepath.Join(gopathSrc, fn.Package, "vendor") fnFrameworkVendoredPath := filepath.Join(fnVendoredPath, functionsFrameworkPackage) fnFrameworkVendoredPathExists, err := ctx.FileExists(fnFrameworkVendoredPath) if err != nil { return err } // Use v0.0.0 as the requested version for go.mod-less vendored builds, since we don't know and // can't really tell. This won't matter for Go 1.14+, since for those we'll have a go.mod file // regardless. requestedFrameworkVersion := "v0.0.0" injected := false if fnFrameworkVendoredPathExists { ctx.Logf("Found function with vendored dependencies including functions-framework") if _, err := ctx.Exec([]string{"cp", "-r", fnVendoredPath, appPath}, gcp.WithUserTimingAttribution); err != nil { return err } } else { // If the framework isn't in the user-provided vendor directory, we need to fetch it ourselves. ctx.Logf("Found function with vendored dependencies excluding functions-framework") if err := cloudfunctions.AssertFrameworkInjectionAllowed(); err != nil { return err } // Install the functions framework. Use `go mod vendor` to do this because that allows the // versions of all of the framework's dependencies to be pinned as specified in the framework's // go.mod. Using `go get` -- the usual way to install packages in GOPATH -- downloads each // repository at HEAD, which can lead to breakages. ctx.Warnf("Your vendored dependencies don't contain the functions-framework (%s) so a version will be auto-injected. Versioning conflicts might cause unexpected issues or crashes with your function. Fix this by adding a dependency on functions-framework (%s) and vendoring again.", functionsFrameworkPackage, functionsFrameworkPackage) ffDepsDir, err := ctx.TempDir("ffdeps") if err != nil { return fmt.Errorf("creating temp directory: %w", err) } cvt := filepath.Join(ctx.BuildpackRoot(), "converter", "without-framework") cmd := []string{ fmt.Sprintf("cp --archive %s/. %s", cvt, ffDepsDir), // The only dependency is the functions framework. fmt.Sprintf("go mod edit -require %s@%s", functionsFrameworkModule, functionsFrameworkVersion), // Download dependencies and generate the go.sum file. "go mod tidy", // Prepare the vendor folder. "go mod vendor", // Copy the contents of the vendor dir into GOPATH/src. fmt.Sprintf("cp --archive vendor/. %s", gopathSrc), } if _, err := golang.ExecWithGoproxyFallback(ctx, []string{"/bin/bash", "-c", strings.Join(cmd, " && ")}, gcp.WithWorkDir(ffDepsDir), gcp.WithUserAttribution); err != nil { return fmt.Errorf("running command chain: %w", err) } // Since the user didn't pin it, we want the current version of the framework. requestedFrameworkVersion = functionsFrameworkVersion injected = true } cloudfunctions.AddFrameworkVersionLabel(ctx, &cloudfunctions.FrameworkVersionInfo{ Runtime: "go", Version: requestedFrameworkVersion, Injected: injected, }) return createMainGoFile(ctx, fn, filepath.Join(appPath, "main.go"), requestedFrameworkVersion) } func createMainGoFile(ctx *gcp.Context, fn fnInfo, main, version string) error { f, err := ctx.CreateFile(main) if err != nil { return err } defer f.Close() requestedVersion, err := semver.NewVersion(version) if err != nil { return fmt.Errorf("unable to parse framework version string %s: %w", version, err) } tmpl := tmplDeclarative if _, ok := fn.Imports[functionsFrameworkFunctionsPackage]; !ok { // By default, use the v0 template. // For framework versions greater than or equal to v1.1.0, use the v1_1 template. tmpl = tmplV0 v1_1, err := semver.NewConstraint(">= 1.1.0") if err != nil { return fmt.Errorf("unable to parse framework version string v1.1.0: %v", err) } if v1_1.Check(requestedVersion) { tmpl = tmplV1_1 } } if err := tmpl.Execute(f, fn); err != nil { return fmt.Errorf("executing template: %v", err) } return nil } // If a framework is specified, return the version. If unspecified, return an empty string. func frameworkSpecifiedVersion(ctx *gcp.Context, fnSource string) (string, error) { res, err := ctx.Exec([]string{"go", "list", "-m", "-f", "{{.Version}}", functionsFrameworkModule}, gcp.WithWorkDir(fnSource), gcp.WithUserAttribution) if err == nil { v := strings.TrimSpace(res.Stdout) ctx.Logf("Found framework version %s", v) return v, nil } if res != nil { if strings.Contains(res.Stderr, "not a known dependency") { ctx.Logf("functions-framework not specified in go.mod, using default") } else if strings.Contains(res.Stderr, "can't resolve module using the vendor directory") { ctx.Logf("functions-framework not found in vendor directory, using default") } return "", nil } return "", err } // extractPackageNameInDir builds the script that does the extraction, and then runs it with the // specified source directory. // The parser is dependent on the language version being used, and it's highly likely that the buildpack binary // will be built with a different version of the language than the function deployment. Building this script ensures // that the version of Go used to build the function app will be the same as the version used to parse it. func extractPackageNameInDir(ctx *gcp.Context, source string) (*parsedPackage, error) { script := filepath.Join(ctx.BuildpackRoot(), "converter", "get_package", "main.go") cacheDir, err := ctx.TempDir("app") if err != nil { return nil, fmt.Errorf("creating temp directory: %w", err) } result, err := ctx.Exec([]string{"go", "run", script, "-dir", source}, gcp.WithEnv("GOCACHE="+cacheDir), gcp.WithUserAttribution) if err != nil { return nil, err } var pkg parsedPackage if err := json.Unmarshal([]byte(result.Stdout), &pkg); err != nil { return nil, fmt.Errorf("unable to parse function package: %v", err) } return &pkg, nil }