cmd/nodejs/yarn/main.go (222 lines of code) (raw):
// Copyright 2020 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/yarn buildpack.
// The npm buildpack installs dependencies using yarn and installs yarn itself if not present.
package main
import (
"fmt"
"os"
"path/filepath"
"strings"
"github.com/GoogleCloudPlatform/buildpacks/pkg/ar"
"github.com/GoogleCloudPlatform/buildpacks/pkg/cache"
"github.com/GoogleCloudPlatform/buildpacks/pkg/devmode"
"github.com/GoogleCloudPlatform/buildpacks/pkg/env"
"github.com/GoogleCloudPlatform/buildpacks/pkg/firebase/faherror"
gcp "github.com/GoogleCloudPlatform/buildpacks/pkg/gcpbuildpack"
"github.com/GoogleCloudPlatform/buildpacks/pkg/nodejs"
)
const (
cacheTag = "prod dependencies"
yarnLayer = "yarn_engine"
)
func main() {
gcp.Main(detectFn, buildFn)
}
func detectFn(ctx *gcp.Context) (gcp.DetectResult, error) {
pkgJSONExists, err := ctx.FileExists("package.json")
if err != nil {
return nil, err
}
if !pkgJSONExists {
return gcp.OptOutFileNotFound("package.json"), nil
}
yarnLockExists, err := ctx.FileExists(nodejs.YarnLock)
if err != nil {
return nil, err
}
if !yarnLockExists {
return gcp.OptOutFileNotFound("yarn.lock"), nil
}
return gcp.OptIn("found yarn.lock and package.json"), nil
}
func buildFn(ctx *gcp.Context) error {
pjs, err := nodejs.ReadPackageJSONIfExists(ctx.ApplicationRoot())
if err != nil {
return err
}
if err := installYarn(ctx, pjs); err != nil {
return fmt.Errorf("installing Yarn: %w", err)
}
if yarn2, err := nodejs.IsYarn2(ctx.ApplicationRoot()); err != nil {
return err
} else if yarn2 {
if err := yarn2InstallModules(ctx, pjs); err != nil {
return err
}
} else {
if err := yarn1InstallModules(ctx, pjs); err != nil {
return err
}
}
el, err := ctx.Layer("env", gcp.BuildLayer, gcp.LaunchLayer)
if err != nil {
return fmt.Errorf("creating layer: %w", err)
}
el.SharedEnvironment.Prepend("PATH", string(os.PathListSeparator), filepath.Join(ctx.ApplicationRoot(), "node_modules", ".bin"))
el.SharedEnvironment.Default("NODE_ENV", nodejs.NodeEnv())
// Configure the entrypoint for production.
cmd := []string{"yarn", "run", "start"}
if !devmode.Enabled(ctx) {
ctx.AddWebProcess(cmd)
return nil
}
// Configure the entrypoint and metadata for dev mode.
if err := devmode.AddFileWatcherProcess(ctx, devmode.Config{
RunCmd: cmd,
Ext: devmode.NodeWatchedExtensions,
}); err != nil {
return fmt.Errorf("adding devmode file watcher: %w", err)
}
return nil
}
func yarn1InstallModules(ctx *gcp.Context, pjs *nodejs.PackageJSON) error {
freezeLockfile, err := nodejs.UseFrozenLockfile(ctx)
if err != nil {
return err
}
ml, err := ctx.Layer("yarn_modules", gcp.BuildLayer, gcp.CacheLayer, gcp.LaunchLayer)
if err != nil {
return fmt.Errorf("creating layer: %w", err)
}
if err := ar.GenerateNPMConfig(ctx); err != nil {
return fmt.Errorf("generating Artifact Registry credentials: %w", err)
}
_, err = nodejs.CheckOrClearCache(ctx, ml, cache.WithFiles("package.json", nodejs.YarnLock))
if err != nil {
return fmt.Errorf("checking cache: %w", err)
}
// Use Yarn's --modules-folder flag to install directly into the layer and then symlink them into
// the app dir.
layerModules := filepath.Join(ml.Path, "node_modules")
appModules := filepath.Join(ctx.ApplicationRoot(), "node_modules")
if err := ctx.MkdirAll(layerModules, 0755); err != nil {
return err
}
if err := ctx.RemoveAll(appModules); err != nil {
return err
}
if err := ctx.Symlink(layerModules, appModules); err != nil {
return err
}
locationFlag := fmt.Sprintf("--modules-folder=%s", layerModules)
runtimeconfigJSONExists, err := ctx.FileExists(".runtimeconfig.json")
if err != nil {
return err
}
// This is a hack to fix a bug in an old version of Firebase that loaded a config using a path
// relative to node_modules: https://github.com/firebase/firebase-functions/issues/630.
if runtimeconfigJSONExists {
layerConfig := filepath.Join(ml.Path, ".runtimeconfig.json")
if err := ctx.RemoveAll(layerConfig); err != nil {
return err
}
if err := ctx.Symlink(filepath.Join(ctx.ApplicationRoot(), ".runtimeconfig.json"), layerConfig); err != nil {
return err
}
}
// Always run yarn install to execute customer's lifecycle hooks.
cmd := []string{"yarn", "install", "--non-interactive", "--prefer-offline", locationFlag}
// HACK: For backwards compatibility on App Engine Node.js 10 and older, skip using `--frozen-lockfile`.
if freezeLockfile {
cmd = append(cmd, "--frozen-lockfile")
}
gcpBuild := nodejs.HasGCPBuild(pjs)
appHostingBuildEnv, appHostingBuildEnvPresent := os.LookupEnv(nodejs.AppHostingBuildEnv)
if gcpBuild || appHostingBuildEnvPresent {
// Setting --production=false causes the devDependencies to be installed regardless of the
// NODE_ENV value. The allows the customer's lifecycle hooks to access to them. We purge the
// devDependencies from the final app.
cmd = append(cmd, "--production=false")
}
// Add the layer's node_modules/.bin to the path so it is available in postinstall scripts.
nodeBin := filepath.Join(layerModules, ".bin")
if _, err := ctx.Exec(cmd, gcp.WithUserAttribution, gcp.WithEnv(fmt.Sprintf("PATH=%s:%s", os.Getenv("PATH"), nodeBin))); err != nil {
return err
}
pjs, err = nodejs.OverrideAppHostingBuildScript(ctx, nodejs.ApphostingPreprocessedPathForPack)
if err != nil {
return err
}
appHostingBuildScriptPresent := nodejs.HasApphostingPackageBuild(pjs)
if gcpBuild || appHostingBuildEnvPresent || appHostingBuildScriptPresent {
if appHostingBuildScriptPresent {
if _, err := ctx.Exec([]string{"yarn", "run", "apphosting:build"}, gcp.WithUserAttribution); err != nil {
return gcp.UserErrorf("%w", faherror.FailedFrameworkBuildError(pjs.Scripts[nodejs.ScriptApphostingBuild], err))
}
} else if appHostingBuildEnvPresent {
if _, err := ctx.Exec(strings.Split(appHostingBuildEnv, " "), gcp.WithUserAttribution); err != nil {
return gcp.UserErrorf("%w", faherror.FailedFrameworkBuildError(appHostingBuildEnv, err))
}
} else {
if _, err := ctx.Exec([]string{"yarn", "run", "gcp-build"}, gcp.WithUserAttribution); err != nil {
return err
}
}
// If there was a gcp-build script we installed all the devDependencies above. We should try to
// prune them from the final app image.
nodeEnv := nodejs.NodeEnv()
if nodejs.NodeEnv() != nodejs.EnvProduction {
ctx.Logf("Retaining devDependencies because NODE_ENV=%q", nodeEnv)
} else {
if env.IsFAH() {
// We don't prune if the user is using App Hosting since App Hosting builds don't
// rely on the node_modules folder at this point.
return nil
}
// For Yarn1, setting `--production=true` causes all `devDependencies` to be deleted.
ctx.Logf("Pruning devDependencies")
cmd := []string{"yarn", "install", "--ignore-scripts", "--prefer-offline", "--production=true", locationFlag}
if freezeLockfile {
cmd = append(cmd, "--frozen-lockfile")
}
if _, err := ctx.Exec(cmd, gcp.WithUserAttribution); err != nil {
return err
}
}
}
return nil
}
func yarn2InstallModules(ctx *gcp.Context, pjs *nodejs.PackageJSON) error {
if err := ar.GenerateYarnConfig(ctx); err != nil {
return fmt.Errorf("generating Artifact Registry credentials: %w", err)
}
cmd := []string{"yarn", "install", "--immutable"}
yarnCacheExists, err := ctx.FileExists(ctx.ApplicationRoot(), ".yarn", "cache")
if err != nil {
return err
}
// In Plug'n'Play mode (https://yarnpkg.com/features/pnp) all dependencies must be included in
// the Yarn cache. The --immutable-cache option will abort the install with an error if anything
// is missing or out of date.
if yarnCacheExists {
cmd = append(cmd, "--immutable-cache")
}
if _, err := ctx.Exec(cmd, gcp.WithUserAttribution); err != nil {
return err
}
if gcpBuild := nodejs.HasGCPBuild(pjs); gcpBuild {
if _, err := ctx.Exec([]string{"yarn", "run", "gcp-build"}, gcp.WithUserAttribution); err != nil {
return err
}
}
if appHostingBuildScript, ok := os.LookupEnv(nodejs.AppHostingBuildEnv); ok {
if _, err := ctx.Exec(strings.Split(appHostingBuildScript, " "), gcp.WithUserAttribution); err != nil {
return err
}
}
// If there are no devDependencies, there is nothing to prune. We are done.
if !nodejs.HasDevDependencies(pjs) {
return nil
}
nodeEnv := nodejs.NodeEnv()
if nodeEnv != nodejs.EnvProduction {
ctx.Logf("Retaining devDependencies because NODE_ENV=%q", nodeEnv)
return nil
}
hasWorkPlugin, err := nodejs.HasYarnWorkspacePlugin(ctx)
if err != nil {
return err
}
if !hasWorkPlugin {
ctx.Warnf("Keeping devDependencies because the Yarn workspace-tools plugin is not installed. You can add it to your project by running 'yarn plugin import workspace-tools'")
return nil
}
// For Yarn2, dependency pruning is via the workspaces plugin.
ctx.Logf("Pruning devDependencies")
if _, err := ctx.Exec([]string{"yarn", "workspaces", "focus", "--all", "--production"}, gcp.WithUserAttribution); err != nil {
return err
}
return nil
}
func installYarn(ctx *gcp.Context, pjs *nodejs.PackageJSON) error {
yrl, err := ctx.Layer(yarnLayer, gcp.BuildLayer, gcp.CacheLayer, gcp.LaunchLayer)
if err != nil {
return fmt.Errorf("creating %v layer: %w", yarnLayer, err)
}
return nodejs.InstallYarnLayer(ctx, yrl, pjs)
}