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 }