go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/vpython/application/application.go (about)

     1  // Copyright 2022 The LUCI Authors.
     2  //
     3  // Licensed under the Apache License, Version 2.0 (the "License");
     4  // you may not use this file except in compliance with the License.
     5  // You may obtain a copy of the License at
     6  //
     7  //      http://www.apache.org/licenses/LICENSE-2.0
     8  //
     9  // Unless required by applicable law or agreed to in writing, software
    10  // distributed under the License is distributed on an "AS IS" BASIS,
    11  // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    12  // See the License for the specific language governing permissions and
    13  // limitations under the License.
    14  
    15  // Package application contains the base framework to build `vpython` binaries
    16  // for different python versions or bundles.
    17  package application
    18  
    19  import (
    20  	"context"
    21  	"flag"
    22  	"fmt"
    23  	"os"
    24  	"os/exec"
    25  	"path/filepath"
    26  	"strings"
    27  	"time"
    28  
    29  	"go.chromium.org/luci/cipd/client/cipd"
    30  	"go.chromium.org/luci/cipkg/base/actions"
    31  	"go.chromium.org/luci/cipkg/base/generators"
    32  	"go.chromium.org/luci/cipkg/base/workflow"
    33  	"go.chromium.org/luci/common/errors"
    34  	"go.chromium.org/luci/common/logging"
    35  	"go.chromium.org/luci/common/logging/gologger"
    36  	"go.chromium.org/luci/common/system/environ"
    37  	"go.chromium.org/luci/common/system/filesystem"
    38  
    39  	vpythonAPI "go.chromium.org/luci/vpython/api/vpython"
    40  	"go.chromium.org/luci/vpython/common"
    41  	"go.chromium.org/luci/vpython/python"
    42  	"go.chromium.org/luci/vpython/spec"
    43  )
    44  
    45  const (
    46  	// VirtualEnvRootENV is an environment variable that, if set, will be used
    47  	// as the default VirtualEnv root.
    48  	//
    49  	// This value overrides the default (~/.vpython-root), but can be overridden
    50  	// by the "-vpython-root" flag.
    51  	//
    52  	// Like "-vpython-root", if this value is present but empty, a tempdir will be
    53  	// used for the VirtualEnv root.
    54  	VirtualEnvRootENV = "VPYTHON_VIRTUALENV_ROOT"
    55  
    56  	// DefaultSpecENV is an environment variable that, if set, will be used as the
    57  	// default VirtualEnv spec file if none is provided or found through probing.
    58  	DefaultSpecENV = "VPYTHON_DEFAULT_SPEC"
    59  
    60  	// LogTraceENV is an environment variable that, if set, will set the default
    61  	// log level to Debug.
    62  	//
    63  	// This is useful when debugging scripts that invoke "vpython" internally,
    64  	// where adding the "-vpython-log-level" flag is not straightforward. The
    65  	// flag is preferred when possible.
    66  	LogTraceENV = "VPYTHON_LOG_TRACE"
    67  
    68  	// BypassENV is an environment variable that is used to detect if we shouldn't
    69  	// do any vpython stuff at all, but should instead directly invoke the next
    70  	// `python` on PATH.
    71  	BypassENV = "VPYTHON_BYPASS"
    72  
    73  	// InterpreterENV is an environment variable that override the default
    74  	// searching behaviour for the bundled interpreter. It should only be used
    75  	// for testing and debugging purpose.
    76  	InterpreterENV = "VPYTHON_INTERPRETER"
    77  
    78  	// BypassSentinel must be the BypassENV value (verbatim) in order to trigger
    79  	// vpython bypass.
    80  	BypassSentinel = "manually managed python not supported by chrome operations"
    81  )
    82  
    83  // Application contains the basic configuration for the application framework.
    84  type Application struct {
    85  	// PruneThreshold, if > 0, is the maximum age of a VirtualEnv before it
    86  	// becomes candidate for pruning. If <= 0, no pruning will be performed.
    87  	PruneThreshold time.Duration
    88  
    89  	// MaxPrunesPerSweep, if > 0, is the maximum number of VirtualEnv that should
    90  	// be pruned passively. If <= 0, no limit will be applied.
    91  	MaxPrunesPerSweep int
    92  
    93  	// Bypass, if true, instructs vpython to completely bypass VirtualEnv
    94  	// bootstrapping and execute with the local system interpreter.
    95  	Bypass bool
    96  
    97  	// Loglevel is used to configure the default logger set in the context.
    98  	LogLevel logging.Level
    99  
   100  	// Help, if true, displays the usage from both vpython and python
   101  	Help  bool
   102  	Usage string
   103  
   104  	// Path to environment specification file to load. Default probes for one.
   105  	SpecPath string
   106  
   107  	// Path to default specification file to load if no specification is found.
   108  	DefaultSpecPath string
   109  
   110  	// Pattern of default specification file. If empty, uses .vpython3.
   111  	DefaultSpecPattern string
   112  
   113  	// Path to virtual environment root directory.
   114  	// If explicitly set to empty string, a temporary directory will be used and
   115  	// cleaned up on completion.
   116  	VpythonRoot string
   117  
   118  	// Path to cipd cache directory.
   119  	CIPDCacheDir string
   120  
   121  	// Tool mode, if it's not empty, vpython will execute the tool instead of
   122  	// python.
   123  	ToolMode string
   124  
   125  	// WorkDir is the Python working directory. If empty, the current working
   126  	// directory will be used.
   127  	WorkDir string
   128  
   129  	// InterpreterPath is the path to the python interpreter cipd package. If
   130  	// empty, uses the bundled python from paths relative to the vpython binary.
   131  	InterpreterPath string
   132  
   133  	Environments []string
   134  	Arguments    []string
   135  
   136  	VpythonSpec       *vpythonAPI.Spec
   137  	PythonCommandLine *python.CommandLine
   138  	PythonExecutable  string
   139  
   140  	// Use os.UserCacheDir by default.
   141  	userCacheDir func() (string, error)
   142  	// close() is usually unnecessary since resources will be released after
   143  	// process exited. However we need to release them manually in the tests.
   144  	close func()
   145  }
   146  
   147  // Initialize logger first to make it available for all steps after.
   148  func (a *Application) Initialize(ctx context.Context) context.Context {
   149  	a.LogLevel = logging.Error
   150  	if os.Getenv(LogTraceENV) != "" {
   151  		a.LogLevel = logging.Debug
   152  	}
   153  	a.close = func() {}
   154  	a.userCacheDir = os.UserCacheDir
   155  
   156  	ctx = gologger.StdConfig.Use(ctx)
   157  	return logging.SetLevel(ctx, a.LogLevel)
   158  }
   159  
   160  // SetLogLevel sets log level to the provided context.
   161  func (a *Application) SetLogLevel(ctx context.Context) context.Context {
   162  	return logging.SetLevel(ctx, a.LogLevel)
   163  }
   164  
   165  // ParseEnvs parses arguments from environment variables.
   166  func (a *Application) ParseEnvs(ctx context.Context) (err error) {
   167  	e := environ.New(a.Environments)
   168  
   169  	// Determine our VirtualEnv base directory.
   170  	if v, ok := e.Lookup(VirtualEnvRootENV); ok {
   171  		a.VpythonRoot = v
   172  	} else {
   173  		cdir, err := a.userCacheDir()
   174  		if err != nil {
   175  			logging.Infof(ctx, "failed to get user cache dir: %s", err)
   176  		} else {
   177  			a.VpythonRoot = filepath.Join(cdir, ".vpython-root")
   178  		}
   179  	}
   180  
   181  	// Get default spec path
   182  	a.DefaultSpecPath = e.Get(DefaultSpecENV)
   183  
   184  	// Get interpreter path
   185  	if p := e.Get(InterpreterENV); p != "" {
   186  		p, err = filepath.Abs(p)
   187  		if err != nil {
   188  			return err
   189  		}
   190  		a.InterpreterPath = p
   191  	}
   192  
   193  	// Check if it's in bypass mode
   194  	if e.Get(BypassENV) == BypassSentinel {
   195  		a.Bypass = true
   196  	}
   197  
   198  	// Get CIPD cache directory
   199  	a.CIPDCacheDir = e.Get(cipd.EnvCacheDir)
   200  
   201  	return nil
   202  }
   203  
   204  // ParseArgs parses arguments from command line.
   205  func (a *Application) ParseArgs(ctx context.Context) (err error) {
   206  	var fs flag.FlagSet
   207  	fs.BoolVar(&a.Help, "help", a.Help,
   208  		"Display help for 'vpython' top-level arguments.")
   209  	fs.BoolVar(&a.Help, "h", a.Help,
   210  		"Display help for 'vpython' top-level arguments (same as -help).")
   211  
   212  	fs.StringVar(&a.VpythonRoot, "vpython-root", a.VpythonRoot,
   213  		"Path to virtual environment root directory. "+
   214  			"If explicitly set to empty string, a temporary directory will be used and cleaned up "+
   215  			"on completion.")
   216  	fs.StringVar(&a.SpecPath, "vpython-spec", a.SpecPath,
   217  		"Path to environment specification file to load. Default probes for one.")
   218  	fs.StringVar(&a.ToolMode, "vpython-tool", a.ToolMode,
   219  		"Tools for vpython command:\n"+
   220  			"install: installs the configured virtual environment.\n"+
   221  			"verify: verifies that a spec and its wheels are valid.")
   222  
   223  	fs.Var(&a.LogLevel, "vpython-log-level",
   224  		"The logging level. Valid options are: debug, info, warning, error.")
   225  
   226  	vpythonArgs, pythonArgs, err := extractFlagsForSet("vpython-", a.Arguments, &fs)
   227  	if err != nil {
   228  		return errors.Annotate(err, "failed to extract flags").Err()
   229  	}
   230  	if err := fs.Parse(vpythonArgs); err != nil {
   231  		return errors.Annotate(err, "failed to parse flags").Err()
   232  	}
   233  
   234  	if a.VpythonRoot == "" {
   235  		// Using temporary directory is only for a last resort and shouldn't be
   236  		// considered as part of the normal workflow.
   237  		// We won't be able to cleanup this temporary directory after execve.
   238  		logging.Warningf(ctx, "fallback to temporary directory for vpython root")
   239  		if a.VpythonRoot, err = os.MkdirTemp("", "vpython"); err != nil {
   240  			return errors.Annotate(err, "failed to create temporary vpython root").Err()
   241  		}
   242  	}
   243  	if a.VpythonRoot, err = filepath.Abs(a.VpythonRoot); err != nil {
   244  		return errors.Annotate(err, "failed to get absolute vpython root path").Err()
   245  	}
   246  
   247  	// Set CIPD CacheDIR
   248  	if a.CIPDCacheDir == "" {
   249  		a.CIPDCacheDir = filepath.Join(a.VpythonRoot, "cipd")
   250  	}
   251  
   252  	if a.PythonCommandLine, err = python.ParseCommandLine(pythonArgs); err != nil {
   253  		return errors.Annotate(err, "failed to parse python commandline").Err()
   254  	}
   255  
   256  	if a.Help {
   257  		var usage strings.Builder
   258  		fmt.Fprintln(&usage, "Usage of vpython:")
   259  		fs.SetOutput(&usage)
   260  		fs.PrintDefaults()
   261  		a.Usage = usage.String()
   262  
   263  		a.PythonCommandLine = &python.CommandLine{
   264  			Target: python.NoTarget{},
   265  		}
   266  		a.PythonCommandLine.AddSingleFlag("h")
   267  	}
   268  	return nil
   269  }
   270  
   271  // LoadSpec searches and load vpython spec from path or script.
   272  func (a *Application) LoadSpec(ctx context.Context) error {
   273  	// default spec
   274  	if a.VpythonSpec == nil {
   275  		a.VpythonSpec = &vpythonAPI.Spec{}
   276  	}
   277  
   278  	if a.SpecPath != "" {
   279  		var sp vpythonAPI.Spec
   280  		if err := spec.Load(a.SpecPath, &sp); err != nil {
   281  			return err
   282  		}
   283  		a.VpythonSpec = sp.Clone()
   284  		return nil
   285  	}
   286  
   287  	if a.DefaultSpecPath != "" {
   288  		a.VpythonSpec = &vpythonAPI.Spec{}
   289  		if err := spec.Load(a.DefaultSpecPath, a.VpythonSpec); err != nil {
   290  			return errors.Annotate(err, "failed to load default spec: %#v", a.DefaultSpecPath).Err()
   291  		}
   292  	}
   293  
   294  	specPattern := a.DefaultSpecPattern
   295  	if specPattern == "" {
   296  		specPattern = ".vpython3"
   297  	}
   298  
   299  	specLoader := &spec.Loader{
   300  		CommonFilesystemBarriers: []string{
   301  			".gclient",
   302  		},
   303  		CommonSpecNames: []string{
   304  			specPattern,
   305  		},
   306  		PartnerSuffix: specPattern,
   307  	}
   308  
   309  	workDir := a.WorkDir
   310  	if workDir == "" {
   311  		wd, err := os.Getwd()
   312  		if err != nil {
   313  			return errors.Annotate(err, "failed to get working directory").Err()
   314  		}
   315  		workDir = wd
   316  	}
   317  	if err := filesystem.AbsPath(&workDir); err != nil {
   318  		return errors.Annotate(err, "failed to resolve absolute path of WorkDir").Err()
   319  	}
   320  
   321  	if spec, err := spec.ResolveSpec(ctx, specLoader, a.PythonCommandLine.Target, workDir); err != nil {
   322  		return err
   323  	} else if spec != nil {
   324  		a.VpythonSpec = spec.Clone()
   325  	}
   326  	return nil
   327  }
   328  
   329  // BuildVENV builds the derivation for the venv and updates applications'
   330  // PythonExecutable to the python binary in the venv.
   331  func (a *Application) BuildVENV(ctx context.Context, ap *actions.ActionProcessor, venv generators.Generator) error {
   332  	pm, err := workflow.NewLocalPackageManager(filepath.Join(a.VpythonRoot, "store"))
   333  	if err != nil {
   334  		return errors.Annotate(err, "failed to load storage").Err()
   335  	}
   336  
   337  	// Generate derivations
   338  	curPlat := generators.CurrentPlatform()
   339  	plats := generators.Platforms{
   340  		Build:  curPlat,
   341  		Host:   curPlat,
   342  		Target: curPlat,
   343  	}
   344  
   345  	b := workflow.NewBuilder(plats, pm, ap)
   346  	pkg, err := b.Build(ctx, "", venv)
   347  	if err != nil {
   348  		return errors.Annotate(err, "failed to generate venv derivation").Err()
   349  	}
   350  	workflow.MustIncRefRecursiveRuntime(pkg)
   351  	a.close = func() {
   352  		workflow.MustDecRefRecursiveRuntime(pkg)
   353  	}
   354  
   355  	// Prune used packages
   356  	if a.PruneThreshold > 0 {
   357  		pm.Prune(ctx, a.PruneThreshold, a.MaxPrunesPerSweep)
   358  	}
   359  
   360  	a.PythonExecutable = common.PythonVENV(pkg.Handler.OutputDirectory(), a.PythonExecutable)
   361  	return nil
   362  }
   363  
   364  // ExecutePython executes the python with arguments. It uses execve on linux and
   365  // simulates execve's behavior on windows.
   366  func (a *Application) ExecutePython(ctx context.Context) error {
   367  	if a.Bypass {
   368  		var err error
   369  		if a.PythonExecutable, err = exec.LookPath(a.PythonExecutable); err != nil {
   370  			return errors.Annotate(err, "failed to find python in path").Err()
   371  		}
   372  	}
   373  
   374  	// The python and venv packages used here has been referenced after they are
   375  	// built at the end BuildVENV(). workflow.LocalPackageManager uses fslock and
   376  	// ensure CLOEXEC is cleared from fd so the the references can be kept after
   377  	// execve.
   378  	if err := cmdExec(ctx, a.GetExecCommand()); err != nil {
   379  		return errors.Annotate(err, "failed to execute python").Err()
   380  	}
   381  	return nil
   382  }
   383  
   384  // GetExecCommand returns the equivalent command when python is executed using
   385  // ExecutePython.
   386  func (a *Application) GetExecCommand() *exec.Cmd {
   387  	env := environ.New(a.Environments)
   388  	python.IsolateEnvironment(&env)
   389  
   390  	cl := a.PythonCommandLine.Clone()
   391  	cl.AddSingleFlag("s")
   392  
   393  	return &exec.Cmd{
   394  		Path: a.PythonExecutable,
   395  		Args: append([]string{a.PythonExecutable}, cl.BuildArgs()...),
   396  		Env:  env.Sorted(),
   397  		Dir:  a.WorkDir,
   398  	}
   399  }