pkg/nodejs/yarn.go (127 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.
package nodejs
import (
"fmt"
"io/ioutil"
"os"
"path/filepath"
"strings"
"github.com/GoogleCloudPlatform/buildpacks/pkg/fetch"
gcp "github.com/GoogleCloudPlatform/buildpacks/pkg/gcpbuildpack"
"github.com/buildpacks/libcnb/v2"
"github.com/Masterminds/semver"
"gopkg.in/yaml.v2"
)
var (
yarnURL = "https://yarnpkg.com/downloads/%[1]s/yarn-v%[1]s.tar.gz"
yarn2URL = "https://repo.yarnpkg.com/%s/packages/yarnpkg-cli/bin/yarn.js"
version2 = semver.MustParse("2.0.0")
versionKey = "version"
)
const (
// YarnLock is the name of the yarn lock file.
YarnLock = "yarn.lock"
)
type yarn2Lock struct {
Metadata struct {
Version string `yaml:"version"`
} `yaml:"__metadata"`
}
// UseFrozenLockfile returns an true if the environment supporte Yarn's --frozen-lockfile flag. This
// is a hack to maintain backwards compatibility on App Engine Node.js 10 and older.
func UseFrozenLockfile(ctx *gcp.Context) (bool, error) {
oldNode, err := isPreNode11(ctx)
return !oldNode, err
}
// IsYarn2 detects whether the given lockfile was generated with Yarn 2.
func IsYarn2(rootDir string) (bool, error) {
data, err := ioutil.ReadFile(filepath.Join(rootDir, YarnLock))
if err != nil {
return false, gcp.InternalErrorf("reading yarn.lock: %v", err)
}
var manifest yarn2Lock
if err := yaml.Unmarshal(data, &manifest); err != nil {
// In Yarn1, yarn.lock was not necessarily valid YAML.
return false, nil
}
// After Yarn2, yarn.lock files contain a __metadata.version field.
return manifest.Metadata.Version != "", nil
}
// HasYarnWorkspacePlugin returns true if this project has Yarn2's workspaces plugin installed.
func HasYarnWorkspacePlugin(ctx *gcp.Context) (bool, error) {
res, err := ctx.Exec([]string{"yarn", "plugin", "runtime"})
if err != nil {
return false, err
}
return strings.Contains(res.Stdout, "plugin-workspace-tools"), nil
}
// detectYarnVersion determines the version of Yarn that should be installed in a Node.js project
// by examining the "engines.yarn" and "packageManager" constraints specified in package.json and comparing it against all
// published versions in the NPM registry, if both exist "engines.yarn" will take precedence.
// If the package.json does not include "engines.yarn" or "packageManager" it
// returns the latest stable version available.
// TODO(b/338411091) create a shared packagejson util library and refactor out a generic detect
// package manager version function.
func detectYarnVersion(pjs *PackageJSON) (string, error) {
if pjs == nil || (pjs.Engines.Yarn == "" && pjs.PackageManager == "") {
version, err := latestPackageVersion("yarn")
if err != nil {
return "", gcp.InternalErrorf("fetching available Yarn versions: %w", err)
}
return version, nil
}
var requestedVersion string
if pjs.Engines.Yarn != "" {
requestedVersion = pjs.Engines.Yarn
} else {
packageManagerName, packageManagerVersion, err := parsePackageManager(pjs.PackageManager)
if err != nil {
return "", err
}
if packageManagerName != "yarn" {
return "", gcp.UserErrorf("yarn was detected but %s is set in the packageManager package.json field.", packageManagerName)
}
requestedVersion = packageManagerVersion
}
version, err := resolvePackageVersion("yarn", requestedVersion)
if err != nil {
return "", gcp.UserErrorf("finding Yarn version that matched %q: %w", requestedVersion, err)
}
return version, nil
}
// InstallYarnLayer installs Yarn in the given layer if it is not already cached.
func InstallYarnLayer(ctx *gcp.Context, yarnLayer *libcnb.Layer, pjs *PackageJSON) error {
layerName := yarnLayer.Name
version, err := detectYarnVersion(pjs)
if err != nil {
return err
}
// Check the metadata in the cache layer to determine if we need to proceed.
metaVersion := ctx.GetMetadata(yarnLayer, versionKey)
if version == metaVersion {
ctx.CacheHit(layerName)
ctx.Logf("Yarn cache hit: %q, %q, skipping installation.", version, metaVersion)
} else {
ctx.CacheMiss(layerName)
if err := ctx.ClearLayer(yarnLayer); err != nil {
return fmt.Errorf("clearing layer %q: %w", layerName, err)
}
// Download and install yarn in layer.
ctx.Logf("Installing Yarn v%s", version)
if err := InstallYarn(ctx, yarnLayer.Path, version); err != nil {
return err
}
}
// Store layer flags and metadata.
ctx.SetMetadata(yarnLayer, versionKey, version)
// We need to update the path here to ensure the version we just installed take precendence over
// anything pre-installed in the base image.
if err := ctx.Setenv("PATH", filepath.Join(yarnLayer.Path, "bin")+":"+os.Getenv("PATH")); err != nil {
return err
}
return nil
}
// InstallYarn downloads a given version of Yarn into the provided directory.
func InstallYarn(ctx *gcp.Context, dir, version string) error {
v, err := semver.NewVersion(version)
if err != nil {
gcp.UserErrorf("parsing yarn version %q: %v", version, err)
}
if v.LessThan(version2) {
archiveURL := fmt.Sprintf(yarnURL, version)
stripComponents := 1
return fetch.Tarball(archiveURL, dir, stripComponents)
}
yarnPath := filepath.Join(dir, "bin", "yarn")
if err = os.MkdirAll(filepath.Dir(yarnPath), 0755); err != nil {
return gcp.InternalErrorf("creating directory %q: %v", filepath.Dir(yarnPath), err)
}
out, err := os.OpenFile(yarnPath, os.O_CREATE|os.O_RDWR, os.FileMode(0777))
if err != nil {
return gcp.InternalErrorf("creating file %q: %v", yarnPath, err)
}
defer out.Close()
binURL := fmt.Sprintf(yarn2URL, version)
if err = fetch.GetURL(binURL, out); err != nil {
return err
}
return nil
}