cmd/nodejs/firebasebundle/main.go (314 lines of code) (raw):

// Copyright 2023 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 nodejs/firebasebundle buildpack. // The output bundle buildpack sets up the output bundle for future steps // It will do the following // 1. Copy over static assets to the output bundle dir // 2. Override run script with a new one to run the optimized build package main import ( "fmt" "os" "path" "path/filepath" "strings" "unicode" "github.com/GoogleCloudPlatform/buildpacks/pkg/buildermetadata" "github.com/GoogleCloudPlatform/buildpacks/pkg/env" "github.com/GoogleCloudPlatform/buildpacks/pkg/fileutil" "github.com/GoogleCloudPlatform/buildpacks/pkg/firebase/util" gcp "github.com/GoogleCloudPlatform/buildpacks/pkg/gcpbuildpack" "github.com/GoogleCloudPlatform/buildpacks/pkg/nodejs" "gopkg.in/yaml.v2" ) const ( defaultPublicDir = "public" firebaseOutputBundleDir = "FIREBASE_OUTPUT_BUNDLE_DIR" // TODO(b/401016345): Remove this env var once there is a better way to pass the apphosting.yaml file path in tests. apphostingYamlPathTestsEnv = "APPHOSTINGYAML_FILEPATH_TESTS" environmentName = "ENVIRONMENT_NAME" apphostingPreprocessedPathForPack = "/workspace/apphosting_preprocessed" ) func main() { gcp.Main(detectFn, buildFn) } func detectFn(ctx *gcp.Context) (gcp.DetectResult, error) { // This buildpack handles some necessary setup for future app hosting processes, // it should always run for any app hosting initial build. if !env.IsFAH() { return gcp.OptOut("not a firebase apphosting application"), nil } return gcp.OptIn("firebase apphosting application"), nil } func buildFn(ctx *gcp.Context) error { bundlePath := filepath.Join(ctx.ApplicationRoot(), ".apphosting", "bundle.yaml") bundleYaml, err := readBundleYaml(ctx, bundlePath) if err != nil { return err } outputBundleDir, ok := os.LookupEnv(firebaseOutputBundleDir) if !ok { return gcp.InternalErrorf("looking up output bundle env %s", firebaseOutputBundleDir) } workspacePublicDir := filepath.Join(ctx.ApplicationRoot(), defaultPublicDir) outputPublicDir := filepath.Join(outputBundleDir, defaultPublicDir) appDir := util.ApplicationDirectory(ctx) apphostingYamlPathTests, ok := os.LookupEnv(apphostingYamlPathTestsEnv) var apphostingYaml *apphostingYaml if ok { apphostingYaml, err = readAppHostingYaml(ctx, apphostingYamlPathTests) } else { apphostingYaml, err = readAppHostingYaml(ctx, apphostingPreprocessedPathForPack) } if err != nil { return err } if bundleYaml == nil { ctx.Logf("bundle.yaml does not exist, assuming default configs") err = generateDefaultBundleYaml(bundlePath, ctx) if err != nil { return err } } ctx.Logf("Copying static assets.") err = fileutil.CopyFile(filepath.Join(outputBundleDir, "bundle.yaml"), bundlePath) if err != nil { return gcp.InternalErrorf("copying output bundle dir %s: %w", outputBundleDir, err) } err = copyPublicDirToOutputBundleDir(outputPublicDir, workspacePublicDir, ctx) if err != nil { return err } nodeDeps, err := nodejs.ReadNodeDependencies(ctx, appDir) // We don't want to fail builds for failing to collect optional metadata. Ignore error. if err == nil { setMetadata(nodeDeps.PackageJSON) } err = deleteFilesNotIncluded(apphostingYaml, bundleYaml, ctx.ApplicationRoot()) if err != nil { return err } err = SetRunCommand(apphostingYaml, bundleYaml, ctx) if err != nil { return err } return nil } // apphostingYaml represents the relevant contents of a apphosting.yaml file. type apphostingYaml struct { OutputFiles outputFiles `yaml:"outputFiles,omitempty"` Scripts scripts `yaml:"scripts,omitempty"` } // bundleYaml represents the relevant contents of a bundle.yaml file. type bundleYaml struct { Version string `yaml:"version"` RunConfig runConfig `yaml:"runConfig"` OutputFiles outputFiles `yaml:"outputFiles,omitempty"` } // runConfig is the struct representation of the passed run config. type runConfig struct { RunCommand string `yaml:"runCommand"` } // outputFiles is the struct representation of the passed output files. type outputFiles struct { ServerApp serverApp `yaml:"serverApp"` } // serverApp is the struct representation of the passed server app files. type serverApp struct { Include []string `yaml:"include"` } // scripts is the struct representation of the apphosting.yaml scripts section. type scripts struct { RunCommand string `yaml:"runCommand"` } func readBundleYaml(ctx *gcp.Context, bundlePath string) (*bundleYaml, error) { bundleYamlExists, err := ctx.FileExists(bundlePath) if err != nil { return nil, err } if !bundleYamlExists { // return an empty struct if the file doesn't exist return nil, nil } rawBundleYaml, err := ctx.ReadFile(bundlePath) if err != nil { return nil, gcp.InternalErrorf("reading %s: %w", bundlePath, err) } var bundleYaml bundleYaml if err := yaml.Unmarshal(rawBundleYaml, &bundleYaml); err != nil { return nil, gcp.UserErrorf("invalid %s: %w", bundlePath, err) } return &bundleYaml, nil } func readAppHostingYaml(ctx *gcp.Context, appHostingPath string) (*apphostingYaml, error) { appHostingYamlExists, err := ctx.FileExists(appHostingPath) if err != nil { return nil, err } if !appHostingYamlExists { // return an empty struct if the file doesn't exist return nil, nil } rawAppHostingYaml, err := ctx.ReadFile(appHostingPath) if err != nil { return nil, gcp.InternalErrorf("reading %s: %w", appHostingPath, err) } var appHostingYaml apphostingYaml if err := yaml.Unmarshal(rawAppHostingYaml, &appHostingYaml); err != nil { return nil, gcp.UserErrorf("invalid %s: %w", appHostingPath, err) } return &appHostingYaml, nil } func generateDefaultBundleYaml(bundleYamlPath string, ctx *gcp.Context) error { ctx.MkdirAll(path.Dir(bundleYamlPath), 0744) f, err := ctx.CreateFile(filepath.Join(bundleYamlPath)) if err != nil { return err } defer f.Close() return nil } func copyPublicDirToOutputBundleDir(outputPublicDir string, workspacePublicDir string, ctx *gcp.Context) error { publicDirExists, err := ctx.FileExists(workspacePublicDir) if err != nil { return err } if !publicDirExists { return nil } err = ctx.MkdirAll(outputPublicDir, 0744) if err != nil { return err } if err := fileutil.MaybeCopyPathContents(outputPublicDir, workspacePublicDir, fileutil.AllPaths); err != nil { return err } return nil } // detect if the app uses AI (Genkit or GenAI API) and the corresponding version, and set metadata accordingly func setMetadata(packageJSON *nodejs.PackageJSON) { if packageJSON != nil { genkitVersion := nodejs.DependencyVersion(packageJSON, "genkit") genAIVersion := nodejs.DependencyVersion(packageJSON, "@google/generative-ai") if genkitVersion != "" { buildermetadata.GlobalBuilderMetadata().SetValue(buildermetadata.IsUsingGenkit, buildermetadata.MetadataValue(genkitVersion)) } if genAIVersion != "" { buildermetadata.GlobalBuilderMetadata().SetValue(buildermetadata.IsUsingGenAI, buildermetadata.MetadataValue(genAIVersion)) } } } // parseCommand parses a command string into a list of arguments. // The command string is expected to be in the format of a shell command. // It will split the command string on spaces and remove any leading and trailing spaces. // It will also handle quoted strings and arguments. // For example, the command string "npm run start" will be parsed into the list ["npm", "run", "start"]. // The command string `npm run "node package" test` will be parsed into the list ["npm", "run", "node package", "test"]. // The command string `mycommand "arg with spaces" anotherarg` will be parsed into the list ["mycommand", "arg with spaces", "anotherarg"]. // The command string `noquotes` will be parsed into the list ["noquotes"]. // The command string `npm run "node package test` will return an error because the quotes are not closed. func parseCommand(command string) ([]string, error) { var result []string var currCmdUnit strings.Builder inQuotes := false for _, r := range command { if r == '"' { inQuotes = !inQuotes if !inQuotes { result = append(result, currCmdUnit.String()) currCmdUnit.Reset() } continue } if unicode.IsSpace(r) && !inQuotes { if currCmdUnit.Len() > 0 { result = append(result, currCmdUnit.String()) currCmdUnit.Reset() } continue } currCmdUnit.WriteRune(r) } if currCmdUnit.Len() > 0 { result = append(result, currCmdUnit.String()) } if inQuotes { return nil, gcp.UserErrorf("parsing command, there was an unclosed quote %s", command) } return result, nil } // SetRunCommand sets the run command for the web process. // The run command is set from the apphosting.yaml file if it exists, otherwise it is set from the bundle.yaml file. // If neither exists, the run command is set to the default "npm run start". func SetRunCommand(apphostingYaml *apphostingYaml, bundleYaml *bundleYaml, ctx *gcp.Context) error { if apphostingYaml != nil && apphostingYaml.Scripts.RunCommand != "" { ctx.Logf("Setting run command from apphosting.yaml: %s", apphostingYaml.Scripts.RunCommand) parsedCommand, err := parseCommand(apphostingYaml.Scripts.RunCommand) if err != nil { return err } ctx.AddWebProcess(parsedCommand) return nil } if bundleYaml != nil && bundleYaml.RunConfig.RunCommand != "" { ctx.Logf("Setting run command from bundle.yaml: %s", bundleYaml.RunConfig.RunCommand) parsedCommand, err := parseCommand(bundleYaml.RunConfig.RunCommand) if err != nil { return err } ctx.AddWebProcess(parsedCommand) } return nil } func convertToMap(slice []string) map[string]bool { var newMap map[string]bool newMap = make(map[string]bool) for _, s := range slice { newMap[s] = true } return newMap } func extractAllDirs(files []string) []string { var result []string for _, file := range files { dir := file for dir != "." && dir != "/" { // Stop at root directory result = append(result, dir) dir = filepath.Dir(dir) } } return result } // walkDirStructureAndDeleteAllFilesNotIncluded walks the directory structure and deletes all files that are not included. // If a directory is labeled as included, all files in that directory will be kept. // "." in either apphosting.yaml or bundle.yaml will include all files. func walkDirStructureAndDeleteAllFilesNotIncluded(rootDir string, filesToInclude []string, dirsToIncludeAll []string) error { filesToIncludeMap := convertToMap(filesToInclude) dirsToIncludeAllMap := convertToMap(dirsToIncludeAll) return filepath.Walk(rootDir, func(path string, info os.FileInfo, err error) error { if err != nil { if os.IsNotExist(err) { return nil // Skip this file/directory } return fmt.Errorf("walking directory structure: %w", err) } relPath, err := filepath.Rel(rootDir, path) if err != nil { return fmt.Errorf("getting relative path: %w", err) } // Keep the root directory if relPath == "." { return nil } if !filesToIncludeMap[relPath] && !anyParentDirMatches(relPath, dirsToIncludeAllMap) { if info.IsDir() { return os.RemoveAll(path) } return os.Remove(path) } return nil }) } func anyParentDirMatches(path string, targets map[string]bool) bool { dir := path for dir != "." && dir != "/" { // Stop at the root directory if targets[dir] { return true } dir = filepath.Dir(dir) // Move to the parent directory } return false } // deleteFilesNotIncluded deletes all files that are not included in the apphosting.yaml or bundle.yaml. // This is done by walking the directory structure and deleting all files that are not included. // if a directory is labeled as included, all files in that directory will be kept. // "." in either apphosting.yaml or bundle.yaml will include all files. func deleteFilesNotIncluded(apphostingSchema *apphostingYaml, bundleSchema *bundleYaml, appPath string) error { // always include apphosting.yaml includedFiles := originalApphostingYamlPaths() // always include all of .apphosting fullyIncludedDirs := []string{".apphosting"} if bundleSchema != nil && bundleSchema.OutputFiles.ServerApp.Include != nil { includedFiles = append(extractAllDirs(bundleSchema.OutputFiles.ServerApp.Include), includedFiles...) fullyIncludedDirs = append(bundleSchema.OutputFiles.ServerApp.Include, fullyIncludedDirs...) } if apphostingSchema != nil && apphostingSchema.OutputFiles.ServerApp.Include != nil { includedFiles = append(extractAllDirs(apphostingSchema.OutputFiles.ServerApp.Include), includedFiles...) fullyIncludedDirs = append(apphostingSchema.OutputFiles.ServerApp.Include, fullyIncludedDirs...) } // if both apphosting.yaml and bundle.yaml are empty, don't delete anything if (apphostingSchema == nil || apphostingSchema.OutputFiles.ServerApp.Include == nil) && (bundleSchema == nil || bundleSchema.OutputFiles.ServerApp.Include == nil) { return nil } // Check if "." is present in either include list for _, dir := range fullyIncludedDirs { if dir == "." { // If "." is present, don't delete anything return nil } } return walkDirStructureAndDeleteAllFilesNotIncluded(appPath, includedFiles, fullyIncludedDirs) } func originalApphostingYamlPaths() []string { paths := []string{"apphosting.yaml"} envName, ok := os.LookupEnv(environmentName) if ok { paths = append(paths, fmt.Sprintf("apphosting.%v.yaml", envName)) } return paths }