blob: 3b9918ded38541443a061b8ca22186179763f57e [file]
// Copyright 2020 The LUCI Authors. All rights reserved.
// Use of this source code is governed under the Apache License, Version 2.0
// that can be found in the LICENSE file.
package dirmd
import (
"bufio"
"bytes"
"context"
"io/ioutil"
"os"
"os/exec"
"path"
"path/filepath"
"runtime"
"sort"
"strings"
"sync"
"golang.org/x/sync/errgroup"
"golang.org/x/sync/semaphore"
"google.golang.org/protobuf/encoding/prototext"
"google.golang.org/protobuf/proto"
"go.chromium.org/luci/common/data/stringset"
"go.chromium.org/luci/common/errors"
dirmdpb "infra/tools/dirmd/proto"
)
// Filename is the standard name of the metadata file.
const Filename = "DIR_METADATA"
var gitBinary string
func init() {
if runtime.GOOS != "windows" {
gitBinary = "git"
return
}
gitBinary = "git.exe"
if _, err := exec.LookPath("git.bat"); err == nil {
// git.bat is available. Prefer git.bat instead.
gitBinary = "git.bat"
}
// Note that this function does not raise errors (by panicking).
// Instead, if code execution needs git indeed, then it will fail with a nice
// error message (as opposed to a stack trace from panic).
}
// ReadFile reads metadata from a file.
func ReadFile(fileName string) (*dirmdpb.Metadata, error) {
contents, err := ioutil.ReadFile(fileName)
if err != nil {
return nil, err
}
ret := &dirmdpb.Metadata{}
return ret, prototext.Unmarshal(contents, ret)
}
// ReadMetadata reads metadata from a single directory.
// See also ReadMapping.
//
// Returns (nil, nil) if the metadata is not defined.
func ReadMetadata(dir string) (*dirmdpb.Metadata, error) {
md, err := ReadFile(filepath.Join(dir, Filename))
if os.IsNotExist(err) {
md, _, err = ReadOwners(dir)
}
return md, err
}
// ReadMapping reads all metadata from files in git in the given directories.
//
// Each directory must reside in a git checkout.
// One of the repos must be the root repo, while other repos must be its
// sub-repos. In other words, all git repos referred to by the directories
// must be subdirectories of one of the repos.
// The root dir of the root repo becomes the metadata root.
//
// Unless the form is sparse, the returned mapping includes metadata of
// ancestors and descendants of the specified directories.
// In the sparse form, metadata of only the specified directories is
// returned, which is usually much faster.
//
// Descendants of the specified directories are discovered using
// "git ls-files <dir>" and not FS walk.
// This means files outside of the repo are ignored, as well as files
// matched by .gitignore files.
// Note that when reading ancestors of the specified directories,
// the .gitignore files are not respected.
// This inconsistency should not make a difference in
// the vast majority of cases because it is confusing to have
// git-ignored DIR_METADATA in the middle of the ancestry chain.
// Such a case might indicate that DIR_METADATA files are used incorrectly.
// This behavior can be changed, but it would come with a performance penalty.
func ReadMapping(ctx context.Context, form dirmdpb.MappingForm, dirs ...string) (*Mapping, error) {
if len(dirs) == 0 {
return nil, nil
}
// Ensure all dir paths are clean and absolute, for simplicity down the road.
for i, d := range dirs {
var err error
if dirs[i], err = filepath.Abs(d); err != nil {
return nil, errors.Annotate(err, "%q", d).Err()
}
}
// Group all dirs by the repo root.
// Unlike Mapping.Repos, a mapping key is an absolute os-native file path.
repos, err := dirsByRepoRoot(ctx, dirs)
if err != nil {
return nil, err
}
r := &mappingReader{
Mapping: *NewMapping(0),
semReadMetadata: semaphore.NewWeighted(int64(runtime.NumCPU())),
}
r.eg, ctx = errgroup.WithContext(ctx)
defer r.eg.Wait()
// Find the metadata root, i.e. the root dir of the root repo.
if r.Root, err = findMetadataRoot(repos); err != nil {
return nil, err
}
// Read the metadata from the specified directories, their ancestors (for inheritance)
// and mixins they import.
var wgReadUpMissing sync.WaitGroup
for _, repo := range repos {
repo := repo
relRepoPath, err := filepath.Rel(r.Root, repo.absRoot)
if err != nil {
return nil, err
}
r.Repos[filepath.ToSlash(relRepoPath)] = repo.Repo
for _, dir := range repo.dirs {
dir := dir
wgReadUpMissing.Add(1)
r.eg.Go(func() error {
defer wgReadUpMissing.Done()
err := r.readUpMissing(ctx, repo, dir)
return errors.Annotate(err, "failed to process %q", dir).Err()
})
}
}
// Wait for readUpMissing calls to finish before proceeding because
// readUpMissing assumes it is the only one populating r.Dirs.
wgReadUpMissing.Wait()
// If the form isn't sparse, then also read the descendants.
if form != dirmdpb.MappingForm_SPARSE {
for _, repo := range repos {
repo := repo
// Remove redundant dirs to avoid reading the same files multiple times.
repo.dirs = removeRedundantDirs(repo.dirs...)
for _, dir := range repo.dirs {
dir := dir
r.eg.Go(func() error {
err := r.ReadGitFiles(ctx, repo, dir, form == dirmdpb.MappingForm_FULL)
return errors.Annotate(err, "failed to process %q", dir).Err()
})
}
}
}
if err := r.eg.Wait(); err != nil {
return nil, err
}
// Finally, bring the mapping to the desired form.
switch form {
case dirmdpb.MappingForm_SPARSE:
if err := r.Mapping.ComputeAll(); err != nil {
return nil, err
}
// Trim down the mapping.
ret := NewMapping(len(dirs))
ret.Repos = r.Repos
for _, dir := range dirs {
key, err := r.DirKey(dir)
if err != nil {
panic(err) // Impossible: we have just used these paths above.
}
ret.Dirs[key] = r.Mapping.Dirs[key]
}
return ret, nil
case dirmdpb.MappingForm_REDUCED:
if err := r.Mapping.Reduce(); err != nil {
return nil, err
}
case dirmdpb.MappingForm_COMPUTED, dirmdpb.MappingForm_FULL:
if err := r.Mapping.ComputeAll(); err != nil {
return nil, err
}
}
// Clean up nils and empty entries, left mostly by readUpMissing.
for key, md := range r.Dirs {
if md == nil || proto.Equal(md, emptyMD) {
delete(r.Dirs, key)
}
}
return &r.Mapping, nil
}
type repoInfo struct {
*dirmdpb.Repo
absRoot string
dirs []string
}
// findMetadataRoot returns the root directory of the root repo.
func findMetadataRoot(repos map[string]*repoInfo) (string, error) {
rootSlice := make([]string, 0, len(repos))
for rr := range repos {
rootSlice = append(rootSlice, rr)
}
sort.Strings(rootSlice)
// The shortest must be the root.
// Verify that all others have it as the prefix.
rootNormalized := normalizeDir(rootSlice[0])
for _, rr := range rootSlice[1:] {
if !strings.HasPrefix(normalizeDir(rr), rootNormalized) {
return "", errors.Reason("failed to determine the metadata root: expected %q to be a subdir of %q", rr, rootSlice[0]).Err()
}
}
return rootSlice[0], nil
}
// dirsByRepoRoot groups directories by the root of the git repo they reside in.
func dirsByRepoRoot(ctx context.Context, dirs []string) (map[string]*repoInfo, error) {
var mu sync.Mutex
// Most likely, dirs are in different repos, so allocate len(dirs) entries.
ret := make(map[string]*repoInfo, len(dirs))
eg, ctx := errgroup.WithContext(ctx)
for _, dir := range dirs {
dir := dir
eg.Go(func() error {
cmd := exec.CommandContext(ctx, gitBinary, "-C", dir, "rev-parse", "--show-toplevel")
stdout, err := cmd.Output()
if err != nil {
return errors.Annotate(err, "failed to call %q", cmd.Args).Err()
}
repoRoot := string(bytes.TrimSpace(stdout))
mu.Lock()
defer mu.Unlock()
repo := ret[repoRoot]
if repo == nil {
repo = &repoInfo{
Repo: &dirmdpb.Repo{Mixins: map[string]*dirmdpb.Metadata{}},
absRoot: repoRoot,
}
ret[repoRoot] = repo
}
repo.dirs = append(repo.dirs, dir)
return nil
})
}
return ret, eg.Wait()
}
// removeRedundantDirs removes directories already included in other directories.
// Mutates dirs in place.
func removeRedundantDirs(dirs ...string) []string {
// Sort directories from shorest-to-longest.
// Note that this sorts by byte-length (not rune-length) and there is a small
// chance that a directory path contains a 2+ byte rune, but this is OK
// because such a rune is very unlikely to be equivalent to another shorter
// rune.
sort.Slice(dirs, func(i, j int) bool {
return len(dirs[i]) < len(dirs[j])
})
ret := dirs[:0] // https://github.com/golang/go/wiki/SliceTricks#filter-in-place
acceptedNormalized := make([]string, 0, len(dirs))
for _, d := range dirs {
dirNormalized := normalizeDir(d)
redundant := false
for _, shorter := range acceptedNormalized {
if strings.HasPrefix(dirNormalized, shorter) {
redundant = true
break
}
}
if !redundant {
acceptedNormalized = append(acceptedNormalized, dirNormalized)
ret = append(ret, d)
}
}
return ret
}
// mappingReader reads Mapping from the file system.
type mappingReader struct {
// Root is an absolute path to the metadata root directory.
// In the case of multiple repos, it is the root dir of the root repo.
Root string
// Mapping is the result of reading.
Mapping
mu sync.Mutex
semReadMetadata *semaphore.Weighted
eg *errgroup.Group
}
// ReadGitFiles reads metadata files-in-git under dir and adds them to r.Mapping.
//
// It uses "git-ls-files <dir>" to discover the files, so for example it ignores
// files outside of the repo. See more in `git ls-files -help`.
func (r *mappingReader) ReadGitFiles(ctx context.Context, repo *repoInfo, absTreeRoot string, preserveFileStructure bool) error {
// First, determine the key prefix.
keyPrefixNative, err := filepath.Rel(r.Root, repo.absRoot)
if err != nil {
return err
}
keyPrefix := filepath.ToSlash(keyPrefixNative)
// Concurrently start `git ls-files`, read its output and read the discovered
// metadata files.
cmd := exec.CommandContext(ctx, gitBinary, "-C", repo.absRoot, "ls-files", "--full-name", absTreeRoot)
stdout, err := cmd.StdoutPipe()
if err != nil {
return err
}
if err := cmd.Start(); err != nil {
return errors.Annotate(err, "failed to start `git ls-files`").Err()
}
defer cmd.Wait() // do not exit the func until the subprocess exits.
seen := stringset.New(0)
scan := bufio.NewScanner(stdout)
for scan.Scan() {
relFileName := scan.Text() // slash-separated, relative to repo root
relDir := path.Dir(relFileName) // slash-separated, relative to repo root
key := path.Join(keyPrefix, relDir)
if preserveFileStructure {
// Ensure the existence of the directory is recorded even if there is no metadata.
r.mu.Lock()
if _, ok := r.Dirs[key]; !ok {
r.Dirs[key] = nil
}
r.mu.Unlock()
}
if base := path.Base(relFileName); base != Filename && base != OwnersFilename {
// Not a metadata file.
continue
}
if !seen.Add(relDir) {
// Already seen this dir.
continue
}
absDir := filepath.Join(repo.absRoot, filepath.FromSlash(relDir))
r.goReadDir(ctx, repo, absDir, key)
}
return scan.Err()
}
// goReadDir starts a goroutine with r.eg to read the metadata of the directory.
func (r *mappingReader) goReadDir(ctx context.Context, repo *repoInfo, absDir, key string) {
r.eg.Go(func() error {
if err := r.semReadMetadata.Acquire(ctx, 1); err != nil {
return err
}
defer r.semReadMetadata.Release(1)
md, err := ReadMetadata(absDir)
switch {
case err != nil:
return errors.Annotate(err, "failed to read metadata from %q", absDir).Err()
case md == nil:
return nil
}
r.mu.Lock()
defer r.mu.Unlock()
r.Dirs[key] = md
// If the file imports mixins, read them too.
for _, mx := range md.Mixins {
mx := mx
if strings.Contains(mx, "\\") {
return errors.Reason(
"%s: mixin path %s contains back slashes; only forward slashes are allowed",
absDir, mx,
).Err()
}
if path.Base(mx) == "DIR_METADATA" {
return errors.Reason(
"%s imports a file with base name 'DIR_METADATA'; this is prohibited "+
"to avoid a wrong expectation that the imported file implicitly includes metadata from its ancesors",
absDir,
).Err()
}
if _, ok := repo.Mixins[mx]; !ok {
repo.Mixins[mx] = nil // mark as seen
r.eg.Go(func() error {
mxFileName := filepath.Join(repo.absRoot, filepath.FromSlash(strings.TrimPrefix(mx, "//")))
switch mxMd, err := ReadFile(mxFileName); {
case err != nil:
return errors.Annotate(err, "failed to read %q", mxFileName).Err()
case len(mxMd.Mixins) != 0:
return errors.Reason("%s: importing a mixin in a mixin is not supported", mxFileName).Err()
default:
r.mu.Lock()
repo.Mixins[mx] = mxMd
r.mu.Unlock()
return nil
}
})
}
}
return nil
})
}
// readUpMissing reads metadata of the specified directory and its ancestors,
// or until it reaches a directory already present in r.Dirs.
func (r *mappingReader) readUpMissing(ctx context.Context, repo *repoInfo, dir string) error {
key, err := r.DirKey(dir)
if err != nil {
return err
}
// Compute the furthest ancestor and in a format suitable for exact-match
// checking.
// Strictly speaking it is incorrect to use r.Root, repo root should be
// used instead, but this would be a breaking change in Chromium.
// For example, src/v8/DIR_METADATA doesn't specify monorail project,
// so if this "bug" is fixed, then v8 would lose its monorail project.
// Also note that this is consistent with nearestAncestor() function behavior,
// which goes up the dir tree and does not stop at the git repo root.
// TODO(nodir): fix this.
upTo := filepath.Clean(normalizeDir(r.Root))
dirNormalized := filepath.Clean(normalizeDir(dir))
for {
r.mu.Lock()
_, seen := r.Dirs[key]
if !seen {
r.Dirs[key] = nil // mark as seen
}
r.mu.Unlock()
if seen {
return nil
}
r.goReadDir(ctx, repo, dir, key)
if dirNormalized == upTo {
return nil
}
// Go up.
parentDir := filepath.Dir(dir)
if parentDir == dir {
panic(errors.Reason("reached the root of the file system, but not %q", upTo).Err())
}
dir = parentDir
dirNormalized = filepath.Dir(dirNormalized)
// Do not call r.DirKey() again - it makes a syscall.
key = path.Dir(key)
}
}
// DirKey returns a r.Dirs key for the given dir on the file system.
// The path must be a part of the tree under r.Root.
func (r *mappingReader) DirKey(dir string) (string, error) {
key, err := filepath.Rel(r.Root, dir)
if err != nil {
return "", err
}
// Dir keys use forward slashes.
key = filepath.ToSlash(key)
if strings.HasPrefix(key, "../") {
return "", errors.Reason("the path %q must be under the root %q", dir, r.Root).Err()
}
return key, nil
}
var pathSepString = string(os.PathSeparator)
// normalizeDir returns version of the dir suitable for prefix checks.
// On Windows, returns the path in the lower case.
// The returned path ends with the path separator.
func normalizeDir(dir string) string {
if runtime.GOOS == "windows" {
// Windows is not the only OS with case-insensitive file systems, but that's
// the only one we support.
dir = strings.ToLower(dir)
}
if !strings.HasSuffix(dir, pathSepString) {
dir += pathSepString
}
return dir
}