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  }