blob: 241bb9c0e3cfed81a5084d4bad01cba1cec328ec [file] [log] [blame]
// Copyright 2021 The LUCI Authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package protoc
import (
"context"
"encoding/json"
"go/build"
"io/ioutil"
"os"
"os/exec"
"path/filepath"
"strings"
"go.chromium.org/luci/common/data/stringset"
"go.chromium.org/luci/common/errors"
"go.chromium.org/luci/common/logging"
)
// StagedInputs represents a staging directory with Go modules symlinked into
// a way that `protoc` sees a consistent --proto_path.
type StagedInputs struct {
Paths []string // absolute paths to search for protos
InputDir string // absolute path to the directory with protos to compile
OutputDir string // a directory with Go package root to put *.pb.go under
ProtoFiles []string // names of proto files in InputDir
ProtoPackage string // proto package path matching InputDir
tmp string // if not empty, should be deleted (recursively) when done
}
// Cleanup removes the temporary staging directory.
func (s *StagedInputs) Cleanup() error {
if s.tmp != "" {
return os.RemoveAll(s.tmp)
}
return nil
}
// StageGoInputs stages a directory with Go Modules symlinked in appropriate
// places and prepares corresponding --proto_path paths.
//
// If `inputDir` or any of given `protoImportPaths` are under any of the
// directories being staged, changes their paths to be rooted in the staged
// directory root. Such paths still point to the exact same directories, just
// through symlinks in the staging area.
func StageGoInputs(ctx context.Context, inputDir string, mods, rootMods, protoImportPaths []string) (inputs *StagedInputs, err error) {
// Try to find the main module (if running in modules mode). We'll put
// generated files there.
var mainMod *moduleInfo
if os.Getenv("GO111MODULE") != "off" {
var err error
if mainMod, err = getModuleInfo("main"); err != nil {
return nil, errors.Annotate(err, "could not find the main module").Err()
}
logging.Debugf(ctx, "The main module is %q at %q", mainMod.Path, mainMod.Dir)
} else {
logging.Debugf(ctx, "Running in GOPATH mode")
}
// If running in Go Modules mode, always put the main module and luci-go
// modules into the proto path.
modules := stringset.NewFromSlice(mods...)
modules.AddAll(rootMods)
if mainMod != nil {
modules.Add(mainMod.Path)
if luciInfo, err := getModuleInfo("go.chromium.org/luci"); err == nil {
modules.Add(luciInfo.Path)
} else {
logging.Warningf(ctx, "LUCI protos will be unavailable: %s", err)
}
}
// The directory with staged modules to use as --proto_path.
stagedRoot, err := ioutil.TempDir("", "cproto")
if err != nil {
return nil, err
}
defer func() {
if err != nil {
os.RemoveAll(stagedRoot)
}
}()
// Stage requested modules into a temp directory using symlinks.
mapping, err := stageModules(stagedRoot, modules.ToSortedSlice())
if err != nil {
return nil, err
}
// "Relocate" paths into the staging directory. It doesn't change what they
// point to, just makes them resolve through symlinks in the staging
// directory, if possible. Also convert path to absolute and verify they are
// existing directories. This is needed to make relative paths calculation in
// protoc happy.
relocatePath := func(p string) (string, error) {
abs, err := filepath.Abs(p)
if err != nil {
return "", err
}
switch s, err := os.Stat(abs); {
case err != nil:
return "", err
case !s.IsDir():
return "", errors.Reason("%q is not a directory", p).Err()
}
for pre, post := range mapping {
if abs == pre {
return post, nil
}
if strings.HasPrefix(abs, pre+string(filepath.Separator)) {
return post + abs[len(pre):], nil
}
}
return abs, nil
}
inputDir, err = relocatePath(inputDir)
if err != nil {
return nil, errors.Annotate(err, "bad input directory").Err()
}
// Prep import paths: union of GOPATH (if any), staged modules and
// relocated `protoImportPaths`.
var paths []string
paths = append(paths, stagedRoot)
for _, mod := range rootMods {
paths = append(paths, filepath.Join(stagedRoot, mod))
}
paths = append(paths, build.Default.SrcDirs()...)
// Explicitly requested import paths come last. This is needed to make sure
// protoc is not confused if an input *.proto name shows up in one of the
// protoImportPaths. It already shows up in the stagedRoot proto path (by
// construction). If protoc sees the input proto in another --proto_path
// that comes *before* stagedRoot, it spits out this confusing error:
//
// Input is shadowed in the --proto_path by "<explicitly requested path>".
// Either use the latter file as your input or reorder the --proto_path so
// that the former file's location comes first.
//
// By putting protoImportPaths last, we "reorder the --proto_path so that the
// former file's location comes first".
for _, p := range protoImportPaths {
p, err := relocatePath(p)
if err != nil {
return nil, errors.Annotate(err, "bad proto import path").Err()
}
paths = append(paths, p)
}
// Include googleapis proto files vendored into the luci-go repo.
for _, p := range paths {
abs := filepath.Join(p, "go.chromium.org", "luci", "common", "proto", "googleapis")
if _, err := os.Stat(abs); err == nil {
paths = append(paths, abs)
break
}
}
// In Go Modules mode, put outputs into a staging directory. They'll end up in
// the correct module based on go_package definitions. In GOPATH mode, put
// them into the GOPATH entry that contains the input directory.
var outputDir string
if mainMod != nil {
outputDir = stagedRoot
} else {
srcDirs := build.Default.SrcDirs()
for _, p := range srcDirs {
if strings.HasPrefix(inputDir, p) {
outputDir = p
break
}
}
if outputDir == "" {
return nil, errors.Annotate(err, "the input directory %q is not under GOPATH %v", inputDir, srcDirs).Err()
}
}
// Find .proto files in the staged input directory.
protoFiles, err := findProtoFiles(inputDir)
if err != nil {
return nil, err
}
if len(protoFiles) == 0 {
return nil, errors.Reason("%s: no .proto files found", inputDir).Err()
}
// Discover the proto package path by locating `inputDir` among import paths.
var protoPkg string
for _, p := range paths {
if strings.HasPrefix(inputDir, p) {
protoPkg = filepath.ToSlash(inputDir[len(p)+1:])
break
}
}
if protoPkg == "" {
return nil, errors.Reason("the input directory %q is outside of any proto path %v", inputDir, paths).Err()
}
return &StagedInputs{
Paths: paths,
InputDir: inputDir,
OutputDir: outputDir,
ProtoFiles: protoFiles,
ProtoPackage: protoPkg,
tmp: stagedRoot,
}, nil
}
// StageGenericInputs just prepares StagedInputs from some generic directories
// on disk.
//
// Unlike StageGoInputs it doesn't try to derive any information from what's
// there. Just converts paths to absolute and discovers *.proto files.
func StageGenericInputs(ctx context.Context, inputDir string, protoImportPaths []string) (*StagedInputs, error) {
absInputDir, err := filepath.Abs(inputDir)
if err != nil {
return nil, errors.Annotate(err, "could not make path %q absolute", inputDir).Err()
}
absImportPaths := make([]string, 0, len(protoImportPaths)+1)
includesInputDir := false
protoPackageDir := ""
for _, path := range protoImportPaths {
abs, err := filepath.Abs(path)
if err != nil {
return nil, errors.Annotate(err, "could not make path %q absolute", path).Err()
}
absImportPaths = append(absImportPaths, abs)
if strings.HasPrefix(absInputDir, abs+string(filepath.Separator)) && !includesInputDir {
includesInputDir = true
protoPackageDir = filepath.ToSlash(absInputDir[len(abs)+1:])
}
}
// Add the input directory to the proto import path only if it is not already
// included via some existing import path. Adding it twice confuses protoc.
// Not adding it at all breaks relative imports.
if !includesInputDir {
absImportPaths = append(absImportPaths, absInputDir)
protoPackageDir = "."
}
protoFiles, err := findProtoFiles(absInputDir)
if err != nil {
return nil, err
}
if len(protoFiles) == 0 {
return nil, errors.Reason(".proto files not found").Err()
}
return &StagedInputs{
Paths: absImportPaths,
InputDir: absInputDir,
OutputDir: absInputDir, // drop generated files (if any) right there
ProtoFiles: protoFiles,
ProtoPackage: protoPackageDir,
}, nil
}
// stageModules symlinks given Go modules (and only them) at their module paths.
//
// Returns a map "original abs path => symlinked abs path".
func stageModules(root string, mods []string) (map[string]string, error) {
mapping := make(map[string]string, len(mods))
for _, m := range mods {
info, err := getModuleInfo(m)
if err != nil {
return nil, err
}
dest := filepath.Join(root, filepath.FromSlash(m))
if err := os.MkdirAll(filepath.Dir(dest), 0700); err != nil {
return nil, err
}
if err := os.Symlink(info.Dir, dest); err != nil {
return nil, err
}
mapping[info.Dir] = dest
}
return mapping, nil
}
type moduleInfo struct {
Path string // e.g. "go.chromium.org/luci"
Dir string // e.g. "/home/work/gomodcache/.../luci"
}
// getModuleInfo returns the information about a module.
//
// Pass "main" to get the information about the main module.
func getModuleInfo(mod string) (*moduleInfo, error) {
args := []string{"-mod=readonly", "-m"}
if mod != "main" {
args = append(args, mod)
}
info := &moduleInfo{}
if err := goList(args, info); err != nil {
return nil, errors.Annotate(err, "failed to resolve path of module %q", mod).Err()
}
return info, nil
}
// goList calls "go list -json <args>" and parses the result.
func goList(args []string, out any) error {
cmd := exec.Command("go", append([]string{"list", "-json"}, args...)...)
buf, err := cmd.Output()
if err != nil {
if er, ok := err.(*exec.ExitError); ok && len(er.Stderr) > 0 {
return errors.Reason("%s", er.Stderr).Err()
}
return err
}
return json.Unmarshal(buf, out)
}
// findProtoFiles returns .proto files in dir. The returned file paths
// are relative to dir.
func findProtoFiles(dir string) ([]string, error) {
files, err := filepath.Glob(filepath.Join(dir, "*.proto"))
if err != nil {
return nil, err
}
for i, f := range files {
files[i] = filepath.Base(f)
}
return files, err
}