| 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 |
| } |