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 }