blob: 070d2cc269db2b99655d5b6f92e4e1259682ce5e [file] [log] [blame]
// Copyright 2017 The LUCI Authors.
//
// 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 venv
import (
"fmt"
"os"
"path/filepath"
"time"
"unicode/utf8"
"github.com/luci/luci-go/vpython/api/vpython"
"github.com/luci/luci-go/vpython/python"
"github.com/luci/luci-go/vpython/spec"
"github.com/luci/luci-go/common/errors"
"github.com/luci/luci-go/common/logging"
"github.com/luci/luci-go/common/system/filesystem"
"golang.org/x/net/context"
)
// Config is the configuration for a managed VirtualEnv.
//
// A VirtualEnv is specified based on its resolved vpython.Spec.
type Config struct {
// MaxHashLen is the maximum number of hash characters to use in VirtualEnv
// directory names.
MaxHashLen int
// BaseDir is the parent directory of all VirtualEnv.
BaseDir string
// OverrideName overrides the name of the specified VirtualEnv.
//
// Because the name is no longer derived from the specification, this will
// force revalidation and deletion of any existing content if it is not a
// fully defined and matching VirtualEnv
OverrideName string
// Package is the VirtualEnv package to install. It must be non-nil and
// valid. It will be used if the environment specification doesn't supply an
// overriding one.
Package vpython.Spec_Package
// Python is the Python interpreter to use. If empty, one will be resolved
// based on the Spec and the current PATH.
Python string
// LookPathFunc, if not nil, will be used instead of exec.LookPath to find the
// underlying Python interpreter.
LookPathFunc python.LookPathFunc
// Spec is the specification file to use to construct the VirtualEnv. If
// nil, or if fields are missing, they will be filled in by probing the system
// PATH.
Spec *vpython.Spec
// PruneThreshold, if >0, is the maximum age of a VirtualEnv before it should
// be pruned. If <= 0, there is no maximum age, so no pruning will be
// performed.
PruneThreshold time.Duration
// MaxPrunesPerSweep applies a limit to the number of items to prune per
// execution.
//
// If <= 0, no limit will be applied.
MaxPrunesPerSweep int
// Loader is the PackageLoader instance to use for package resolution and
// deployment.
Loader PackageLoader
// MaxScriptPathLen, if >0, is the maximum allowed VirutalEnv path length.
// If set, and if the VirtualEnv is configured to be installed at a path
// greater than this, Env will fail.
//
// This can be used to enforce "shebang" length limits, whereupon generated
// VirtualEnv scripts may be generated with a "shebang" (#!) line longer than
// what is allowed by the operating system.
MaxScriptPathLen int
// si is the system Python interpreter. It is resolved during
// "resolvePythonInterpreter".
si *python.Interpreter
// rt is the resolved Python runtime.
rt vpython.Runtime
// testPreserveInstallationCapability is a testing parameter. If true, the
// VirtualEnv's ability to install will be preserved after the setup. This is
// used by the test whell generation bootstrap code.
testPreserveInstallationCapability bool
// testLeaveReadWrite, if true, instructs the VirtualEnv setup to leave the
// directory read/write. This makes it easier to manage, and is safe since it
// is not a production directory.
testLeaveReadWrite bool
}
// WithoutWheels returns a clone of cfg that depends on no additional packages.
//
// If cfg is already an empty it will be returned directly.
func (cfg *Config) WithoutWheels() *Config {
if !cfg.HasWheels() {
return cfg
}
clone := *cfg
clone.OverrideName = ""
clone.Spec = clone.Spec.Clone()
clone.Spec.Wheel = nil
return &clone
}
// HasWheels returns true if this environment declares wheel dependencies.
func (cfg *Config) HasWheels() bool {
return cfg.Spec != nil && len(cfg.Spec.Wheel) > 0
}
// makeEnv processes the config, validating and, where appropriate, populating
// any components. Upon success, it returns a configured Env instance.
//
// The supplied vpython.Environment is derived externally, and may be nil if
// this is a bootstrapped Environment.
//
// The returned Env instance may or may not actually exist. Setup must be called
// prior to using it.
func (cfg *Config) makeEnv(c context.Context, e *vpython.Environment) (*Env, error) {
// We MUST have a package loader.
if cfg.Loader == nil {
return nil, errors.New("no package loader provided")
}
// Resolve our base directory, if one is not supplied.
if cfg.BaseDir == "" {
// Use one in a temporary directory.
cfg.BaseDir = filepath.Join(os.TempDir(), "vpython")
logging.Debugf(c, "Using tempdir-relative environment root: %s", cfg.BaseDir)
}
if err := filesystem.AbsPath(&cfg.BaseDir); err != nil {
return nil, errors.Annotate(err, "failed to resolve absolute path of base directory").Err()
}
// Enforce maximum path length.
if cfg.MaxScriptPathLen > 0 {
if longestPath := longestGeneratedScriptPath(cfg.BaseDir); longestPath != "" {
longestPathLen := utf8.RuneCountInString(longestPath)
if longestPathLen > cfg.MaxScriptPathLen {
return nil, errors.Reason(
"expected deepest path length (%d) exceeds threshold (%d)",
longestPathLen, cfg.MaxScriptPathLen,
).InternalReason("longestPath(%q)", longestPath).Err()
}
}
}
// Construct a new, independent Environment for this Env.
e = e.Clone()
if cfg.Spec != nil {
e.Spec = cfg.Spec.Clone()
}
if err := spec.NormalizeEnvironment(e); err != nil {
return nil, errors.Annotate(err, "invalid environment").Err()
}
// If the environment doesn't specify a VirtualEnv package (expected), use
// our default.
if e.Spec.Virtualenv == nil {
e.Spec.Virtualenv = &cfg.Package
}
if err := cfg.Loader.Resolve(c, e); err != nil {
return nil, errors.Annotate(err, "failed to resolve packages").Err()
}
if err := cfg.resolvePythonInterpreter(c, e.Spec); err != nil {
return nil, errors.Annotate(err, "failed to resolve system Python interpreter").Err()
}
e.Runtime.Path = cfg.si.Python
e.Runtime.Version = e.Spec.PythonVersion
var err error
if e.Runtime.Hash, err = cfg.si.Hash(); err != nil {
return nil, err
}
logging.Debugf(c, "Resolved system Python runtime (%s @ %s): %s",
e.Runtime.Version, e.Runtime.Hash, e.Runtime.Path)
// Ensure that our base directory exists.
if err := filesystem.MakeDirs(cfg.BaseDir); err != nil {
return nil, errors.Annotate(err, "could not create environment root: %s", cfg.BaseDir).Err()
}
// Generate our environment name based on the deterministic hash of its
// fully-resolved specification.
envName := cfg.OverrideName
if envName == "" {
envName = cfg.envNameForSpec(e.Spec, e.Runtime)
}
env := cfg.envForName(envName, e)
return env, nil
}
// EnvName returns the VirtualEnv environment name for the environment that cfg
// describes.
func (cfg *Config) envNameForSpec(s *vpython.Spec, rt *vpython.Runtime) string {
name := spec.Hash(s, rt, EnvironmentVersion)
if cfg.MaxHashLen > 0 && len(name) > cfg.MaxHashLen {
name = name[:cfg.MaxHashLen]
}
return name
}
// Prune performs a pruning round on the environment set described by this
// Config.
func (cfg *Config) Prune(c context.Context) error {
if err := prune(c, cfg, nil); err != nil {
return errors.Annotate(err, "").Err()
}
return nil
}
// envForName creates an Env for a named directory.
//
// The Environment, e, can be nil; however, code paths that require it may not
// be called.
func (cfg *Config) envForName(name string, e *vpython.Environment) *Env {
// Env-specific root directory: <BaseDir>/<name>
venvRoot := filepath.Join(cfg.BaseDir, name)
binDir := venvBinDir(venvRoot)
return &Env{
Config: cfg,
Root: venvRoot,
Name: name,
Python: filepath.Join(binDir, "python"),
Environment: e,
BinDir: binDir,
EnvironmentStampPath: filepath.Join(venvRoot, fmt.Sprintf("environment.%s.pb.txt", vpython.Version)),
lockPath: filepath.Join(cfg.BaseDir, fmt.Sprintf(".%s.lock", name)),
completeFlagPath: filepath.Join(venvRoot, "complete.flag"),
}
}
func (cfg *Config) resolvePythonInterpreter(c context.Context, s *vpython.Spec) error {
specVers, err := python.ParseVersion(s.PythonVersion)
if err != nil {
return errors.Annotate(err, "failed to parse Python version from: %q", s.PythonVersion).Err()
}
if cfg.Python == "" {
// No explicitly-specified Python path. Determine one based on the
// specification.
if cfg.si, err = python.Find(c, specVers, cfg.LookPathFunc); err != nil {
return errors.Annotate(err, "could not find Python for: %s", specVers).Err()
}
cfg.Python = cfg.si.Python
} else {
cfg.si = &python.Interpreter{
Python: cfg.Python,
}
}
if err := cfg.si.Normalize(); err != nil {
return err
}
// Confirm that the version of the interpreter matches that which is
// expected.
interpreterVers, err := cfg.si.GetVersion(c)
if err != nil {
return errors.Annotate(err, "failed to determine Python version for: %s", cfg.Python).Err()
}
if !specVers.IsSatisfiedBy(interpreterVers) {
return errors.Reason("supplied Python version (%s) doesn't match specification (%s)", interpreterVers, specVers).Err()
}
s.PythonVersion = interpreterVers.String()
// Resolve to absolute path.
if err := filesystem.AbsPath(&cfg.Python); err != nil {
return errors.Annotate(err, "could not get absolute path for: %s", cfg.Python).Err()
}
return nil
}
func (cfg *Config) systemInterpreter() *python.Interpreter { return cfg.si }