go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/common/proto/protoc/inputs.go (about)

     1  // Copyright 2021 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 protoc
    16  
    17  import (
    18  	"context"
    19  	"encoding/json"
    20  	"go/build"
    21  	"io/ioutil"
    22  	"os"
    23  	"os/exec"
    24  	"path/filepath"
    25  	"strings"
    26  
    27  	"go.chromium.org/luci/common/data/stringset"
    28  	"go.chromium.org/luci/common/errors"
    29  	"go.chromium.org/luci/common/logging"
    30  )
    31  
    32  // StagedInputs represents a staging directory with Go modules symlinked into
    33  // a way that `protoc` sees a consistent --proto_path.
    34  type StagedInputs struct {
    35  	Paths        []string // absolute paths to search for protos
    36  	InputDir     string   // absolute path to the directory with protos to compile
    37  	OutputDir    string   // a directory with Go package root to put *.pb.go under
    38  	ProtoFiles   []string // names of proto files in InputDir
    39  	ProtoPackage string   // proto package path matching InputDir
    40  	tmp          string   // if not empty, should be deleted (recursively) when done
    41  }
    42  
    43  // Cleanup removes the temporary staging directory.
    44  func (s *StagedInputs) Cleanup() error {
    45  	if s.tmp != "" {
    46  		return os.RemoveAll(s.tmp)
    47  	}
    48  	return nil
    49  }
    50  
    51  // StageGoInputs stages a directory with Go Modules symlinked in appropriate
    52  // places and prepares corresponding --proto_path paths.
    53  //
    54  // If `inputDir` or any of given `protoImportPaths` are under any of the
    55  // directories being staged, changes their paths to be rooted in the staged
    56  // directory root. Such paths still point to the exact same directories, just
    57  // through symlinks in the staging area.
    58  func StageGoInputs(ctx context.Context, inputDir string, mods, rootMods, protoImportPaths []string) (inputs *StagedInputs, err error) {
    59  	// Try to find the main module (if running in modules mode). We'll put
    60  	// generated files there.
    61  	var mainMod *moduleInfo
    62  	if os.Getenv("GO111MODULE") != "off" {
    63  		var err error
    64  		if mainMod, err = getModuleInfo("main"); err != nil {
    65  			return nil, errors.Annotate(err, "could not find the main module").Err()
    66  		}
    67  		logging.Debugf(ctx, "The main module is %q at %q", mainMod.Path, mainMod.Dir)
    68  	} else {
    69  		logging.Debugf(ctx, "Running in GOPATH mode")
    70  	}
    71  
    72  	// If running in Go Modules mode, always put the main module and luci-go
    73  	// modules into the proto path.
    74  	modules := stringset.NewFromSlice(mods...)
    75  	modules.AddAll(rootMods)
    76  	if mainMod != nil {
    77  		modules.Add(mainMod.Path)
    78  		if luciInfo, err := getModuleInfo("go.chromium.org/luci"); err == nil {
    79  			modules.Add(luciInfo.Path)
    80  		} else {
    81  			logging.Warningf(ctx, "LUCI protos will be unavailable: %s", err)
    82  		}
    83  	}
    84  
    85  	// The directory with staged modules to use as --proto_path.
    86  	stagedRoot, err := ioutil.TempDir("", "cproto")
    87  	if err != nil {
    88  		return nil, err
    89  	}
    90  	defer func() {
    91  		if err != nil {
    92  			os.RemoveAll(stagedRoot)
    93  		}
    94  	}()
    95  
    96  	// Stage requested modules into a temp directory using symlinks.
    97  	mapping, err := stageModules(stagedRoot, modules.ToSortedSlice())
    98  	if err != nil {
    99  		return nil, err
   100  	}
   101  
   102  	// "Relocate" paths into the staging directory. It doesn't change what they
   103  	// point to, just makes them resolve through symlinks in the staging
   104  	// directory, if possible. Also convert path to absolute and verify they are
   105  	// existing directories. This is needed to make relative paths calculation in
   106  	// protoc happy.
   107  	relocatePath := func(p string) (string, error) {
   108  		abs, err := filepath.Abs(p)
   109  		if err != nil {
   110  			return "", err
   111  		}
   112  		switch s, err := os.Stat(abs); {
   113  		case err != nil:
   114  			return "", err
   115  		case !s.IsDir():
   116  			return "", errors.Reason("%q is not a directory", p).Err()
   117  		}
   118  		for pre, post := range mapping {
   119  			if abs == pre {
   120  				return post, nil
   121  			}
   122  			if strings.HasPrefix(abs, pre+string(filepath.Separator)) {
   123  				return post + abs[len(pre):], nil
   124  			}
   125  		}
   126  		return abs, nil
   127  	}
   128  
   129  	inputDir, err = relocatePath(inputDir)
   130  	if err != nil {
   131  		return nil, errors.Annotate(err, "bad input directory").Err()
   132  	}
   133  
   134  	// Prep import paths: union of GOPATH (if any), staged modules and
   135  	// relocated `protoImportPaths`.
   136  	var paths []string
   137  	paths = append(paths, stagedRoot)
   138  	for _, mod := range rootMods {
   139  		paths = append(paths, filepath.Join(stagedRoot, mod))
   140  	}
   141  	paths = append(paths, build.Default.SrcDirs()...)
   142  
   143  	// Explicitly requested import paths come last. This is needed to make sure
   144  	// protoc is not confused if an input *.proto name shows up in one of the
   145  	// protoImportPaths. It already shows up in the stagedRoot proto path (by
   146  	// construction). If protoc sees the input proto in another --proto_path
   147  	// that comes *before* stagedRoot, it spits out this confusing error:
   148  	//
   149  	//   Input is shadowed in the --proto_path by "<explicitly requested path>".
   150  	//   Either use the latter file as your input or reorder the --proto_path so
   151  	//   that the former file's location comes first.
   152  	//
   153  	// By putting protoImportPaths last, we "reorder the --proto_path so that the
   154  	// former file's location comes first".
   155  	for _, p := range protoImportPaths {
   156  		p, err := relocatePath(p)
   157  		if err != nil {
   158  			return nil, errors.Annotate(err, "bad proto import path").Err()
   159  		}
   160  		paths = append(paths, p)
   161  	}
   162  
   163  	// Include googleapis proto files vendored into the luci-go repo.
   164  	for _, p := range paths {
   165  		abs := filepath.Join(p, "go.chromium.org", "luci", "common", "proto", "googleapis")
   166  		if _, err := os.Stat(abs); err == nil {
   167  			paths = append(paths, abs)
   168  			break
   169  		}
   170  	}
   171  
   172  	// In Go Modules mode, put outputs into a staging directory. They'll end up in
   173  	// the correct module based on go_package definitions. In GOPATH mode, put
   174  	// them into the GOPATH entry that contains the input directory.
   175  	var outputDir string
   176  	if mainMod != nil {
   177  		outputDir = stagedRoot
   178  	} else {
   179  		srcDirs := build.Default.SrcDirs()
   180  		for _, p := range srcDirs {
   181  			if strings.HasPrefix(inputDir, p) {
   182  				outputDir = p
   183  				break
   184  			}
   185  		}
   186  		if outputDir == "" {
   187  			return nil, errors.Annotate(err, "the input directory %q is not under GOPATH %v", inputDir, srcDirs).Err()
   188  		}
   189  	}
   190  
   191  	// Find .proto files in the staged input directory.
   192  	protoFiles, err := findProtoFiles(inputDir)
   193  	if err != nil {
   194  		return nil, err
   195  	}
   196  	if len(protoFiles) == 0 {
   197  		return nil, errors.Reason("%s: no .proto files found", inputDir).Err()
   198  	}
   199  
   200  	// Discover the proto package path by locating `inputDir` among import paths.
   201  	var protoPkg string
   202  	for _, p := range paths {
   203  		if strings.HasPrefix(inputDir, p) {
   204  			protoPkg = filepath.ToSlash(inputDir[len(p)+1:])
   205  			break
   206  		}
   207  	}
   208  	if protoPkg == "" {
   209  		return nil, errors.Reason("the input directory %q is outside of any proto path %v", inputDir, paths).Err()
   210  	}
   211  
   212  	return &StagedInputs{
   213  		Paths:        paths,
   214  		InputDir:     inputDir,
   215  		OutputDir:    outputDir,
   216  		ProtoFiles:   protoFiles,
   217  		ProtoPackage: protoPkg,
   218  		tmp:          stagedRoot,
   219  	}, nil
   220  }
   221  
   222  // StageGenericInputs just prepares StagedInputs from some generic directories
   223  // on disk.
   224  //
   225  // Unlike StageGoInputs it doesn't try to derive any information from what's
   226  // there. Just converts paths to absolute and discovers *.proto files.
   227  func StageGenericInputs(ctx context.Context, inputDir string, protoImportPaths []string) (*StagedInputs, error) {
   228  	absInputDir, err := filepath.Abs(inputDir)
   229  	if err != nil {
   230  		return nil, errors.Annotate(err, "could not make path %q absolute", inputDir).Err()
   231  	}
   232  
   233  	absImportPaths := make([]string, 0, len(protoImportPaths)+1)
   234  	includesInputDir := false
   235  	protoPackageDir := ""
   236  	for _, path := range protoImportPaths {
   237  		abs, err := filepath.Abs(path)
   238  		if err != nil {
   239  			return nil, errors.Annotate(err, "could not make path %q absolute", path).Err()
   240  		}
   241  		absImportPaths = append(absImportPaths, abs)
   242  		if strings.HasPrefix(absInputDir, abs+string(filepath.Separator)) && !includesInputDir {
   243  			includesInputDir = true
   244  			protoPackageDir = filepath.ToSlash(absInputDir[len(abs)+1:])
   245  		}
   246  	}
   247  
   248  	// Add the input directory to the proto import path only if it is not already
   249  	// included via some existing import path. Adding it twice confuses protoc.
   250  	// Not adding it at all breaks relative imports.
   251  	if !includesInputDir {
   252  		absImportPaths = append(absImportPaths, absInputDir)
   253  		protoPackageDir = "."
   254  	}
   255  
   256  	protoFiles, err := findProtoFiles(absInputDir)
   257  	if err != nil {
   258  		return nil, err
   259  	}
   260  	if len(protoFiles) == 0 {
   261  		return nil, errors.Reason(".proto files not found").Err()
   262  	}
   263  
   264  	return &StagedInputs{
   265  		Paths:        absImportPaths,
   266  		InputDir:     absInputDir,
   267  		OutputDir:    absInputDir, // drop generated files (if any) right there
   268  		ProtoFiles:   protoFiles,
   269  		ProtoPackage: protoPackageDir,
   270  	}, nil
   271  }
   272  
   273  // stageModules symlinks given Go modules (and only them) at their module paths.
   274  //
   275  // Returns a map "original abs path => symlinked abs path".
   276  func stageModules(root string, mods []string) (map[string]string, error) {
   277  	mapping := make(map[string]string, len(mods))
   278  	for _, m := range mods {
   279  		info, err := getModuleInfo(m)
   280  		if err != nil {
   281  			return nil, err
   282  		}
   283  		dest := filepath.Join(root, filepath.FromSlash(m))
   284  		if err := os.MkdirAll(filepath.Dir(dest), 0700); err != nil {
   285  			return nil, err
   286  		}
   287  		if err := os.Symlink(info.Dir, dest); err != nil {
   288  			return nil, err
   289  		}
   290  		mapping[info.Dir] = dest
   291  	}
   292  	return mapping, nil
   293  }
   294  
   295  type moduleInfo struct {
   296  	Path string // e.g. "go.chromium.org/luci"
   297  	Dir  string // e.g. "/home/work/gomodcache/.../luci"
   298  }
   299  
   300  // getModuleInfo returns the information about a module.
   301  //
   302  // Pass "main" to get the information about the main module.
   303  func getModuleInfo(mod string) (*moduleInfo, error) {
   304  	args := []string{"-mod=readonly", "-m"}
   305  	if mod != "main" {
   306  		args = append(args, mod)
   307  	}
   308  	info := &moduleInfo{}
   309  	if err := goList(args, info); err != nil {
   310  		return nil, errors.Annotate(err, "failed to resolve path of module %q", mod).Err()
   311  	}
   312  	return info, nil
   313  }
   314  
   315  // goList calls "go list -json <args>" and parses the result.
   316  func goList(args []string, out any) error {
   317  	cmd := exec.Command("go", append([]string{"list", "-json"}, args...)...)
   318  	buf, err := cmd.Output()
   319  	if err != nil {
   320  		if er, ok := err.(*exec.ExitError); ok && len(er.Stderr) > 0 {
   321  			return errors.Reason("%s", er.Stderr).Err()
   322  		}
   323  		return err
   324  	}
   325  	return json.Unmarshal(buf, out)
   326  }
   327  
   328  // findProtoFiles returns .proto files in dir. The returned file paths
   329  // are relative to dir.
   330  func findProtoFiles(dir string) ([]string, error) {
   331  	files, err := filepath.Glob(filepath.Join(dir, "*.proto"))
   332  	if err != nil {
   333  		return nil, err
   334  	}
   335  	for i, f := range files {
   336  		files[i] = filepath.Base(f)
   337  	}
   338  	return files, err
   339  }