go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/vpython/spec/load.go (about) 1 // Copyright 2017 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 spec 16 17 import ( 18 "bufio" 19 "context" 20 "os" 21 "path/filepath" 22 "runtime" 23 "strings" 24 25 "go.chromium.org/luci/common/errors" 26 "go.chromium.org/luci/common/logging" 27 cproto "go.chromium.org/luci/common/proto" 28 "go.chromium.org/luci/common/system/filesystem" 29 30 "go.chromium.org/luci/vpython/api/vpython" 31 ) 32 33 // DefaultPartnerSuffix is the default filesystem suffix for a script's partner 34 // specification file. 35 // 36 // See LoadForScript for more information. 37 const DefaultPartnerSuffix = ".vpython" 38 39 // DefaultCommonSpecNames is the name of the "common" specification file. 40 // 41 // If a script doesn't explicitly specific a specification file, "vpython" will 42 // automatically walk up from the script's directory towards filesystem root 43 // and will use the first file named CommonName that it finds. This enables 44 // repository-wide and shared environment specifications. 45 var DefaultCommonSpecNames = []string{ 46 "common.vpython", 47 } 48 49 const ( 50 // DefaultInlineBeginGuard is the default loader InlineBeginGuard value. 51 DefaultInlineBeginGuard = "[VPYTHON:BEGIN]" 52 // DefaultInlineEndGuard is the default loader InlineEndGuard value. 53 DefaultInlineEndGuard = "[VPYTHON:END]" 54 ) 55 56 // Load loads an specification file text protobuf from the supplied path. 57 func Load(path string, spec *vpython.Spec) error { 58 content, err := os.ReadFile(path) 59 if err != nil { 60 return errors.Annotate(err, "failed to load file from: %s", path).Err() 61 } 62 63 return Parse(string(content), spec) 64 } 65 66 // Parse loads a specification message from a content string. 67 func Parse(content string, spec *vpython.Spec) error { 68 if err := cproto.UnmarshalTextML(content, spec); err != nil { 69 return errors.Annotate(err, "failed to unmarshal vpython.Spec").Err() 70 } 71 return nil 72 } 73 74 // Loader implements the generic ability to load a "vpython" spec file. 75 type Loader struct { 76 // InlineBeginGuard is a string that signifies the beginning of an inline 77 // specification. If empty, DefaultInlineBeginGuard will be used. 78 InlineBeginGuard string 79 // InlineEndGuard is a string that signifies the end of an inline 80 // specification. If empty, DefaultInlineEndGuard will be used. 81 InlineEndGuard string 82 83 // CommonFilesystemBarriers is a list of filenames. During common spec, Loader 84 // walks directories towards root looking for a file named CommonName. If a 85 // directory is observed to contain a file in CommonFilesystemBarriers, the 86 // walk will terminate after processing that directory. 87 CommonFilesystemBarriers []string 88 89 // CommonSpecNames, if not empty, is the list of common "vpython" spec files 90 // to use. If empty, DefaultCommonSpecNames will be used. 91 // 92 // Names will be considered in the order that they appear. 93 CommonSpecNames []string 94 95 // PartnerSuffix is the filesystem suffix for a script's partner spec file. If 96 // empty, DefaultPartnerSuffix will be used. 97 PartnerSuffix string 98 } 99 100 // LoadForScript attempts to load a spec file for the specified script. If 101 // nothing went wrong, a nil error will be returned. If a spec file was 102 // identified, it will also be returned along with the path to the spec file 103 // itself. Otherwise, a nil spec will be returned. 104 // 105 // Spec files can be specified in a variety of ways. This function will look for 106 // them in the following order, and return the first one that was identified: 107 // 108 // - Partner File 109 // - Inline 110 // 111 // Partner File 112 // ============ 113 // 114 // LoadForScript traverses the filesystem to find the specification file that is 115 // naturally associated with the specified 116 // path. 117 // 118 // If the path is a Python script (e.g, "/path/to/test.py"), isModule will be 119 // false, and the file will be found at "/path/to/test.py.vpython". 120 // 121 // If the path is a Python module (isModule is true), findForScript walks 122 // upwards in the directory structure, looking for a file that shares a module 123 // directory name and ends with ".vpython". For example, for module: 124 // 125 // /path/to/foo/bar/baz/__init__.py 126 // /path/to/foo/bar/__init__.py 127 // /path/to/foo/__init__.py 128 // /path/to/foo.vpython 129 // 130 // LoadForScript will first look at "/path/to/foo/bar/baz", then walk upwards 131 // until it either hits a directory that doesn't contain an "__init__.py" file, 132 // or finds the ES path. In this case, for module "foo.bar.baz", it will 133 // identify "/path/to/foo.vpython" as the ES file for that module. 134 // 135 // Inline 136 // ====== 137 // 138 // LoadForScript scans through the contents of the file at path and attempts to 139 // load specification boundaries. 140 // 141 // If the file at path does not exist, or if the file does not contain spec 142 // guards, a nil spec will be returned. 143 // 144 // The embedded specification is a text protobuf embedded within the file. To 145 // parse it, the file is scanned line-by-line for a beginning and ending guard. 146 // The content between those guards is minimally processed, then interpreted as 147 // a text protobuf. 148 // 149 // [VPYTHON:BEGIN] 150 // wheel { 151 // path: ... 152 // version: ... 153 // } 154 // [VPYTHON:END] 155 // 156 // To allow VPYTHON directives to be embedded in a language-compatible manner 157 // (with indentation, comments, etc.), the processor will identify any common 158 // characters preceding the BEGIN and END clauses. If they match, those 159 // characters will be automatically stripped out of the intermediate lines. This 160 // can be used to embed the directives in comments: 161 // 162 // // [VPYTHON:BEGIN] 163 // // wheel { 164 // // path: ... 165 // // version: ... 166 // // } 167 // // [VPYTHON:END] 168 // 169 // In this case, the "// " characters will be removed. 170 // 171 // Common 172 // ====== 173 // 174 // LoadForScript will examine successive parent directories starting from the 175 // script's location, looking for a file named in CommonSpecNames. If it finds 176 // one, it will use that as the specification file. This enables scripts to 177 // implicitly share an specification. 178 func (l *Loader) LoadForScript(c context.Context, path string, isModule bool) (*vpython.Spec, string, error) { 179 // Spec search order: 180 // 1. Partner File of the symbolic link (if exist) 181 // 2. Partner File of the real file 182 // 3. Inline specification in the script 183 // 4. Common specification file from the real file 184 185 // Partner File: Try loading the spec from an adjacent file. 186 specPath, err := l.findForScript(path, isModule) 187 if err != nil { 188 return nil, "", errors.Annotate(err, "failed to scan for filesystem spec").Err() 189 } 190 191 // Partner File: Try loading the spec from an adjacent file to the evaluated path. 192 if specPath == "" && runtime.GOOS != "windows" { 193 // Skip EvalSymlinks for windows because it is broken: 194 // https://github.com/golang/go/issues/40180 195 if path, err = filepath.EvalSymlinks(path); err != nil { 196 return nil, "", errors.Annotate(err, "failed to get real path for script: %s", path).Err() 197 } 198 specPath, err = l.findForScript(path, isModule) 199 if err != nil { 200 return nil, "", errors.Annotate(err, "failed to scan for filesystem spec").Err() 201 } 202 } 203 204 if specPath != "" { 205 var spec vpython.Spec 206 if err := Load(specPath, &spec); err != nil { 207 return nil, "", err 208 } 209 210 logging.Infof(c, "Loaded specification from: %s", specPath) 211 return &spec, specPath, nil 212 } 213 214 // Inline: Try and parse the main script for the spec file. 215 mainScript := path 216 if isModule { 217 // Module. 218 mainScript = filepath.Join(mainScript, "__main__.py") 219 } 220 221 // Assume the path is a directory until we're sure it's not, then get its directory component 222 currPath := mainScript 223 info, err := os.Stat(currPath) 224 if err != nil { 225 return nil, "", errors.Annotate(err, "error stat-ing file: %s", currPath).Err() 226 } 227 228 if !info.IsDir() { 229 switch spec, err := l.parseFrom(currPath); { 230 case err != nil: 231 return nil, "", errors.Annotate(err, "failed to parse inline spec from: %s", currPath).Err() 232 233 case spec != nil: 234 logging.Infof(c, "Loaded inline spec from: %s", currPath) 235 return spec, currPath, nil 236 } 237 238 // Scan starting from directory containing the main script 239 currPath = filepath.Dir(currPath) 240 } 241 242 // Common: Try and identify a common specification file. 243 switch path, err := l.findCommonWalkingFrom(currPath); { 244 case err != nil: 245 return nil, "", err 246 247 case path != "": 248 var spec vpython.Spec 249 if err := Load(path, &spec); err != nil { 250 return nil, "", err 251 } 252 253 logging.Infof(c, "Loaded common spec from: %s", path) 254 return &spec, path, nil 255 } 256 257 // Couldn't identify a specification file. 258 return nil, "", nil 259 } 260 261 func (l *Loader) findForScript(path string, isModule bool) (string, error) { 262 if l.PartnerSuffix == "" { 263 l.PartnerSuffix = DefaultPartnerSuffix 264 } 265 266 if !isModule { 267 path += l.PartnerSuffix 268 if st, err := os.Stat(path); err != nil || st.IsDir() { 269 // File does not exist at this path. 270 return "", nil 271 } 272 return path, nil 273 } 274 275 // If it's a directory, scan for a ".vpython" file until we don't have a 276 // __init__.py. 277 for { 278 prev := path 279 280 // Directory must be a Python module. 281 initPath := filepath.Join(path, "__init__.py") 282 if _, err := os.Stat(initPath); err != nil { 283 if os.IsNotExist(err) { 284 // Not a Python module, so we're done our search. 285 return "", nil 286 } 287 return "", errors.Annotate(err, "failed to stat for: %s", path).Err() 288 } 289 290 // Does a spec file exist for this path? 291 specPath := path + l.PartnerSuffix 292 switch st, err := os.Stat(specPath); { 293 case err == nil && !st.IsDir(): 294 // Found the file. 295 return specPath, nil 296 297 case os.IsNotExist(err): 298 // Recurse to parent. 299 path = filepath.Dir(path) 300 if path == prev { 301 // Finished recursing, no ES file. 302 return "", nil 303 } 304 305 default: 306 return "", errors.Annotate(err, "failed to check for spec file at: %s", specPath).Err() 307 } 308 } 309 } 310 311 func (l *Loader) parseFrom(path string) (*vpython.Spec, error) { 312 fd, err := os.Open(path) 313 if err != nil { 314 return nil, errors.Annotate(err, "failed to open file").Err() 315 } 316 defer fd.Close() 317 318 // Determine our guards. 319 beginGuard := l.InlineBeginGuard 320 if beginGuard == "" { 321 beginGuard = DefaultInlineBeginGuard 322 } 323 324 endGuard := l.InlineEndGuard 325 if endGuard == "" { 326 endGuard = DefaultInlineEndGuard 327 } 328 329 s := bufio.NewScanner(fd) 330 var ( 331 content []string 332 beginLine string 333 endLine string 334 inRegion = false 335 ) 336 for s.Scan() { 337 line := strings.TrimSpace(s.Text()) 338 if !inRegion { 339 inRegion = strings.HasSuffix(line, beginGuard) 340 beginLine = line 341 } else { 342 if strings.HasSuffix(line, endGuard) { 343 // Finished processing. 344 endLine = line 345 break 346 } 347 content = append(content, line) 348 } 349 } 350 if err := s.Err(); err != nil { 351 return nil, errors.Annotate(err, "error scanning file").Err() 352 } 353 if len(content) == 0 { 354 return nil, nil 355 } 356 if endLine == "" { 357 return nil, errors.New("unterminated inline spec file") 358 } 359 360 // If we have a common begin/end prefix, trim it from each content line that 361 // also has it. 362 prefix := beginLine[:len(beginLine)-len(beginGuard)] 363 if endLine[:len(endLine)-len(endGuard)] != prefix { 364 prefix = "" 365 } 366 if prefix != "" { 367 for i, line := range content { 368 if len(line) < len(prefix) { 369 // This line is shorter than the prefix. Does the part of that line that 370 // exists match the prefix up until that point? 371 if line == prefix[:len(line)] { 372 // Yes, so empty line. 373 line = "" 374 } 375 } else { 376 line = strings.TrimPrefix(line, prefix) 377 } 378 content[i] = line 379 } 380 } 381 382 // Process the resulting file. 383 var spec vpython.Spec 384 if err := Parse(strings.Join(content, "\n"), &spec); err != nil { 385 return nil, errors.Annotate(err, "failed to parse spec file from: %s", path).Err() 386 } 387 return &spec, nil 388 } 389 390 func (l *Loader) findCommonWalkingFrom(startDir string) (string, error) { 391 names := l.CommonSpecNames 392 if len(names) == 0 { 393 names = DefaultCommonSpecNames 394 } 395 396 // Walk until we hit root. 397 prevDir := "" 398 for prevDir != startDir { 399 // Check the current directory before checking barrier files. 400 for _, name := range names { 401 checkPath := filepath.Join(startDir, name) 402 switch st, err := os.Stat(checkPath); { 403 case err == nil && !st.IsDir(): 404 return checkPath, nil 405 406 case filesystem.IsNotExist(err): 407 // Not in this directory. 408 409 default: 410 // Failed to load specification from this file. 411 return "", errors.Annotate(err, "failed to stat common spec file at: %s", checkPath).Err() 412 } 413 } 414 415 // If we have any barrier files, check to see if they are present in this 416 // directory. 417 for _, name := range l.CommonFilesystemBarriers { 418 barrierName := filepath.Join(startDir, name) 419 if _, err := os.Stat(barrierName); err == nil { 420 // Identified a barrier file in this directory. 421 return "", nil 422 } 423 } 424 425 // Walk up a directory. 426 startDir, prevDir = filepath.Dir(startDir), startDir 427 } 428 429 // Couldn't find the file. 430 return "", nil 431 }