blob: be5e743e396aa5dfeae58aef1d8e6e6581fbfa5f [file] [log] [blame]
package service
import (
"context"
"fmt"
"io"
"regexp"
"strings"
"sync"
"time"
"github.com/golang/glog"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
git "github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/config"
"github.com/go-git/go-git/v5/plumbing"
"github.com/go-git/go-git/v5/plumbing/filemode"
"github.com/go-git/go-git/v5/plumbing/object"
"github.com/go-git/go-git/v5/storage"
"github.com/go-git/go-git/v5/storage/memory"
pb "code.hackerspace.pl/hscloud/devtools/depotview/proto"
)
var (
reHash = regexp.MustCompile(`[a-f0-9]{40,64}`)
)
type Service struct {
remote string
storer storage.Storer
mu sync.Mutex
repo *git.Repository
lastFetch time.Time
}
func New(remote string) *Service {
return &Service{
remote: remote,
storer: memory.NewStorage(),
}
}
func (s *Service) ensureRepo(ctx context.Context) error {
// Clone repository if necessary.
// Use background context - we don't want this to get canceled.
if s.repo == nil {
glog.Infof("Cloning %q...", s.remote)
repo, err := git.CloneContext(context.Background(), s.storer, nil, &git.CloneOptions{
URL: s.remote,
})
if err != nil {
glog.Errorf("Clone(%q): %v", s.remote, err)
return status.Error(codes.Unavailable, "could not clone repository")
}
s.repo = repo
glog.Infof("Clone done.")
}
// We could've gotten canceled by now.
if err := ctx.Err(); err != nil {
return err
}
// Fetch if necessary.
if time.Since(s.lastFetch) > 10*time.Second {
err := s.repo.FetchContext(ctx, &git.FetchOptions{
RefSpecs: []config.RefSpec{
config.RefSpec("+refs/heads/*:refs/heads/*"),
config.RefSpec("+refs/changes/*:refs/changes/*"),
},
Force: true,
})
if err != nil && err != git.NoErrAlreadyUpToDate {
glog.Errorf("Fetch(): %v", err)
} else {
s.lastFetch = time.Now()
}
}
return nil
}
func (s *Service) Resolve(ctx context.Context, req *pb.ResolveRequest) (*pb.ResolveResponse, error) {
s.mu.Lock()
defer s.mu.Unlock()
if req.Ref == "" {
return nil, status.Error(codes.InvalidArgument, "ref must be set")
}
if err := s.ensureRepo(ctx); err != nil {
return nil, err
}
h, err := s.repo.ResolveRevision(plumbing.Revision(req.Ref))
switch {
case err == plumbing.ErrReferenceNotFound:
return &pb.ResolveResponse{Hash: "", LastChecked: s.lastFetch.UnixNano()}, nil
case err != nil:
return nil, status.Errorf(codes.Unavailable, "git resolve error: %v", err)
default:
return &pb.ResolveResponse{Hash: h.String(), LastChecked: s.lastFetch.UnixNano()}, nil
}
}
func (s *Service) ResolveGerritChange(ctx context.Context, req *pb.ResolveGerritChangeRequest) (*pb.ResolveGerritChangeResponse, error) {
if err := s.ensureRepo(ctx); err != nil {
return nil, err
}
// I'm totally guessing this, from these examples:
// refs/changes/03/3/meta
// refs/changes/77/77/meta
// refs/changes/47/247/meta
// etc...
shard := fmt.Sprintf("%02d", req.Change%100)
metaRef := fmt.Sprintf("refs/changes/%s/%d/meta", shard, req.Change)
h, err := s.repo.ResolveRevision(plumbing.Revision(metaRef))
switch {
case err == plumbing.ErrReferenceNotFound:
return &pb.ResolveGerritChangeResponse{Hash: "", LastChecked: s.lastFetch.UnixNano()}, nil
case err != nil:
return nil, status.Errorf(codes.Unavailable, "git metadata resolve error: %v", err)
}
c, err := s.repo.CommitObject(*h)
if err != nil {
return nil, status.Errorf(codes.Unavailable, "git error: %v", err)
}
var messages []string
for {
messages = append([]string{c.Message}, messages...)
if len(c.ParentHashes) != 1 {
break
}
c, err = s.repo.CommitObject(c.ParentHashes[0])
if err != nil {
return nil, status.Errorf(codes.Unavailable, "git error: %v", err)
}
}
meta := parseGerritMetadata(messages)
if meta == nil {
return nil, status.Errorf(codes.Internal, "could not parse gerrit metadata for ref %q", metaRef)
}
return &pb.ResolveGerritChangeResponse{Hash: meta.commit, LastChecked: s.lastFetch.UnixNano()}, nil
}
func (s *Service) getFile(ctx context.Context, hash, path string, notFoundOkay bool) (*object.File, error) {
if !reHash.MatchString(hash) {
return nil, status.Error(codes.InvalidArgument, "hash must be valid full git hash string")
}
if path == "" {
return nil, status.Error(codes.InvalidArgument, "path must be set")
}
path = pathNormalize(path)
if path == "" {
return nil, status.Error(codes.InvalidArgument, "path must be a valid unix or depot-style path")
}
if err := s.ensureRepo(ctx); err != nil {
return nil, err
}
c, err := s.repo.CommitObject(plumbing.NewHash(hash))
switch {
case err == plumbing.ErrObjectNotFound:
return nil, status.Error(codes.NotFound, "hash not found")
case err != nil:
return nil, status.Errorf(codes.Unavailable, "git error: %v", err)
}
file, err := c.File(path)
switch {
case err == object.ErrFileNotFound && !notFoundOkay:
return nil, status.Error(codes.NotFound, "file not found")
case err == object.ErrFileNotFound && notFoundOkay:
return nil, nil
case err != nil:
return nil, status.Errorf(codes.Unavailable, "git error: %v", err)
}
return file, nil
}
func (s *Service) Stat(ctx context.Context, req *pb.StatRequest) (*pb.StatResponse, error) {
s.mu.Lock()
defer s.mu.Unlock()
file, err := s.getFile(ctx, req.Hash, req.Path, true)
if err != nil {
return nil, err
}
if file == nil {
return &pb.StatResponse{Type: pb.StatResponse_TYPE_NOT_PRESENT}, nil
}
switch {
case file.Mode == filemode.Dir:
return &pb.StatResponse{Type: pb.StatResponse_TYPE_DIRECTORY}, nil
case file.Mode.IsFile():
return &pb.StatResponse{Type: pb.StatResponse_TYPE_FILE}, nil
default:
return nil, status.Errorf(codes.Unimplemented, "unknown file type %o", file.Mode)
}
}
func (s *Service) Read(req *pb.ReadRequest, srv pb.DepotView_ReadServer) error {
s.mu.Lock()
defer s.mu.Unlock()
ctx := srv.Context()
file, err := s.getFile(ctx, req.Hash, req.Path, false)
if err != nil {
return err
}
reader, err := file.Reader()
if err != nil {
return status.Errorf(codes.Unavailable, "file read error: %v", err)
}
defer reader.Close()
for {
if ctx.Err() != nil {
return ctx.Err()
}
// 1 MB read
chunk := make([]byte, 16*1024)
n, err := reader.Read(chunk)
switch {
case err == io.EOF:
n = 0
case err != nil:
return status.Errorf(codes.Unavailable, "file read error: %v", err)
}
err = srv.Send(&pb.ReadResponse{Data: chunk[:n]})
if err != nil {
return err
}
if n == 0 {
break
}
}
return nil
}
func pathNormalize(path string) string {
leadingSlashes := 0
for _, c := range path {
if c != '/' {
break
}
leadingSlashes += 1
}
// Only foo/bar, /foo/bar, and //foo/bar paths allowed.
if leadingSlashes > 2 {
return ""
}
path = path[leadingSlashes:]
// No trailing slashes allowed.
if strings.HasSuffix(path, "/") {
return ""
}
return path
}