devtools/depotview: init
This is a small service for accessing git repos read-only over gRPC.
It's going to be used to allow hackdoc to render arbitrary versions of
hscloud.
Change-Id: Ib3c5eb5a8bc679e8062142e6fa30505d9550e2fa
diff --git a/devtools/depotview/service/service.go b/devtools/depotview/service/service.go
new file mode 100644
index 0000000..910bf36
--- /dev/null
+++ b/devtools/depotview/service/service.go
@@ -0,0 +1,221 @@
+package service
+
+import (
+ "context"
+ "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/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
+ lastPull time.Time
+}
+
+func New(remote string) *Service {
+ return &Service{
+ remote: remote,
+ storer: memory.NewStorage(),
+ }
+}
+
+func (s *Service) ensureRepo() error {
+ // Clone repository if necessary.
+ if s.repo == nil {
+ repo, err := git.Clone(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
+ s.lastPull = time.Now()
+ }
+
+ // Fetch if necessary.
+ if time.Since(s.lastPull) > time.Minute {
+ err := s.repo.Fetch(&git.FetchOptions{})
+ if err != nil && err != git.NoErrAlreadyUpToDate {
+ glog.Errorf("Fetch(): %v", err)
+ } else {
+ s.lastPull = 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(); 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.lastPull.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.lastPull.UnixNano()}, nil
+ }
+}
+
+func (s *Service) getFile(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(); 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(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()
+
+ file, err := s.getFile(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()
+
+ ctx := srv.Context()
+ 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
+}