blob: be5e743e396aa5dfeae58aef1d8e6e6581fbfa5f [file] [log] [blame]
Sergiusz Bazanski4c0e9b52020-04-08 22:42:33 +02001package service
2
3import (
4 "context"
Sergiusz Bazanskif157b4d2020-04-10 17:39:43 +02005 "fmt"
Sergiusz Bazanski4c0e9b52020-04-08 22:42:33 +02006 "io"
7 "regexp"
8 "strings"
9 "sync"
10 "time"
11
12 "github.com/golang/glog"
13 "google.golang.org/grpc/codes"
14 "google.golang.org/grpc/status"
15
16 git "github.com/go-git/go-git/v5"
Sergiusz Bazanskif157b4d2020-04-10 17:39:43 +020017 "github.com/go-git/go-git/v5/config"
Sergiusz Bazanski4c0e9b52020-04-08 22:42:33 +020018 "github.com/go-git/go-git/v5/plumbing"
19 "github.com/go-git/go-git/v5/plumbing/filemode"
20 "github.com/go-git/go-git/v5/plumbing/object"
21 "github.com/go-git/go-git/v5/storage"
22 "github.com/go-git/go-git/v5/storage/memory"
23
24 pb "code.hackerspace.pl/hscloud/devtools/depotview/proto"
25)
26
27var (
28 reHash = regexp.MustCompile(`[a-f0-9]{40,64}`)
29)
30
31type Service struct {
32 remote string
33 storer storage.Storer
34
Sergiusz Bazanskif157b4d2020-04-10 17:39:43 +020035 mu sync.Mutex
36 repo *git.Repository
37 lastFetch time.Time
Sergiusz Bazanski4c0e9b52020-04-08 22:42:33 +020038}
39
40func New(remote string) *Service {
41 return &Service{
42 remote: remote,
43 storer: memory.NewStorage(),
44 }
45}
46
Sergiusz Bazanskif157b4d2020-04-10 17:39:43 +020047func (s *Service) ensureRepo(ctx context.Context) error {
Sergiusz Bazanski4c0e9b52020-04-08 22:42:33 +020048 // Clone repository if necessary.
Sergiusz Bazanskiebaa4082020-04-12 14:38:27 +020049 // Use background context - we don't want this to get canceled.
Sergiusz Bazanski4c0e9b52020-04-08 22:42:33 +020050 if s.repo == nil {
Sergiusz Bazanskiebaa4082020-04-12 14:38:27 +020051 glog.Infof("Cloning %q...", s.remote)
52 repo, err := git.CloneContext(context.Background(), s.storer, nil, &git.CloneOptions{
Sergiusz Bazanski4c0e9b52020-04-08 22:42:33 +020053 URL: s.remote,
54 })
55 if err != nil {
56 glog.Errorf("Clone(%q): %v", s.remote, err)
57 return status.Error(codes.Unavailable, "could not clone repository")
58 }
59 s.repo = repo
Sergiusz Bazanskiebaa4082020-04-12 14:38:27 +020060 glog.Infof("Clone done.")
61 }
62
63 // We could've gotten canceled by now.
64 if err := ctx.Err(); err != nil {
65 return err
Sergiusz Bazanski4c0e9b52020-04-08 22:42:33 +020066 }
67
68 // Fetch if necessary.
Sergiusz Bazanskif157b4d2020-04-10 17:39:43 +020069 if time.Since(s.lastFetch) > 10*time.Second {
Sergiusz Bazanskif157b4d2020-04-10 17:39:43 +020070 err := s.repo.FetchContext(ctx, &git.FetchOptions{
71 RefSpecs: []config.RefSpec{
Sergiusz Bazanskiebaa4082020-04-12 14:38:27 +020072 config.RefSpec("+refs/heads/*:refs/heads/*"),
Sergiusz Bazanskif157b4d2020-04-10 17:39:43 +020073 config.RefSpec("+refs/changes/*:refs/changes/*"),
74 },
75 Force: true,
76 })
Sergiusz Bazanski4c0e9b52020-04-08 22:42:33 +020077 if err != nil && err != git.NoErrAlreadyUpToDate {
78 glog.Errorf("Fetch(): %v", err)
79 } else {
Sergiusz Bazanskif157b4d2020-04-10 17:39:43 +020080 s.lastFetch = time.Now()
Sergiusz Bazanski4c0e9b52020-04-08 22:42:33 +020081 }
Sergiusz Bazanskif157b4d2020-04-10 17:39:43 +020082
Sergiusz Bazanski4c0e9b52020-04-08 22:42:33 +020083 }
84
85 return nil
86}
87
88func (s *Service) Resolve(ctx context.Context, req *pb.ResolveRequest) (*pb.ResolveResponse, error) {
89 s.mu.Lock()
90 defer s.mu.Unlock()
91
92 if req.Ref == "" {
93 return nil, status.Error(codes.InvalidArgument, "ref must be set")
94 }
95
Sergiusz Bazanskif157b4d2020-04-10 17:39:43 +020096 if err := s.ensureRepo(ctx); err != nil {
Sergiusz Bazanski4c0e9b52020-04-08 22:42:33 +020097 return nil, err
98 }
99
100 h, err := s.repo.ResolveRevision(plumbing.Revision(req.Ref))
101 switch {
102 case err == plumbing.ErrReferenceNotFound:
Sergiusz Bazanskif157b4d2020-04-10 17:39:43 +0200103 return &pb.ResolveResponse{Hash: "", LastChecked: s.lastFetch.UnixNano()}, nil
Sergiusz Bazanski4c0e9b52020-04-08 22:42:33 +0200104 case err != nil:
105 return nil, status.Errorf(codes.Unavailable, "git resolve error: %v", err)
106 default:
Sergiusz Bazanskif157b4d2020-04-10 17:39:43 +0200107 return &pb.ResolveResponse{Hash: h.String(), LastChecked: s.lastFetch.UnixNano()}, nil
Sergiusz Bazanski4c0e9b52020-04-08 22:42:33 +0200108 }
109}
110
Sergiusz Bazanskif157b4d2020-04-10 17:39:43 +0200111func (s *Service) ResolveGerritChange(ctx context.Context, req *pb.ResolveGerritChangeRequest) (*pb.ResolveGerritChangeResponse, error) {
112 if err := s.ensureRepo(ctx); err != nil {
113 return nil, err
114 }
115
116 // I'm totally guessing this, from these examples:
117 // refs/changes/03/3/meta
118 // refs/changes/77/77/meta
119 // refs/changes/47/247/meta
120 // etc...
121 shard := fmt.Sprintf("%02d", req.Change%100)
122 metaRef := fmt.Sprintf("refs/changes/%s/%d/meta", shard, req.Change)
123
124 h, err := s.repo.ResolveRevision(plumbing.Revision(metaRef))
125 switch {
126 case err == plumbing.ErrReferenceNotFound:
127 return &pb.ResolveGerritChangeResponse{Hash: "", LastChecked: s.lastFetch.UnixNano()}, nil
128 case err != nil:
129 return nil, status.Errorf(codes.Unavailable, "git metadata resolve error: %v", err)
130 }
131
132 c, err := s.repo.CommitObject(*h)
133 if err != nil {
134 return nil, status.Errorf(codes.Unavailable, "git error: %v", err)
135 }
136
137 var messages []string
138 for {
139 messages = append([]string{c.Message}, messages...)
140
141 if len(c.ParentHashes) != 1 {
142 break
143 }
144
145 c, err = s.repo.CommitObject(c.ParentHashes[0])
146 if err != nil {
147 return nil, status.Errorf(codes.Unavailable, "git error: %v", err)
148 }
149 }
150
151 meta := parseGerritMetadata(messages)
152 if meta == nil {
153 return nil, status.Errorf(codes.Internal, "could not parse gerrit metadata for ref %q", metaRef)
154 }
155 return &pb.ResolveGerritChangeResponse{Hash: meta.commit, LastChecked: s.lastFetch.UnixNano()}, nil
156}
157
158func (s *Service) getFile(ctx context.Context, hash, path string, notFoundOkay bool) (*object.File, error) {
Sergiusz Bazanski4c0e9b52020-04-08 22:42:33 +0200159 if !reHash.MatchString(hash) {
160 return nil, status.Error(codes.InvalidArgument, "hash must be valid full git hash string")
161 }
162 if path == "" {
163 return nil, status.Error(codes.InvalidArgument, "path must be set")
164 }
165
166 path = pathNormalize(path)
167 if path == "" {
168 return nil, status.Error(codes.InvalidArgument, "path must be a valid unix or depot-style path")
169 }
170
Sergiusz Bazanskif157b4d2020-04-10 17:39:43 +0200171 if err := s.ensureRepo(ctx); err != nil {
Sergiusz Bazanski4c0e9b52020-04-08 22:42:33 +0200172 return nil, err
173 }
174
175 c, err := s.repo.CommitObject(plumbing.NewHash(hash))
176 switch {
177 case err == plumbing.ErrObjectNotFound:
178 return nil, status.Error(codes.NotFound, "hash not found")
179 case err != nil:
180 return nil, status.Errorf(codes.Unavailable, "git error: %v", err)
181 }
182
183 file, err := c.File(path)
184 switch {
185 case err == object.ErrFileNotFound && !notFoundOkay:
186 return nil, status.Error(codes.NotFound, "file not found")
187 case err == object.ErrFileNotFound && notFoundOkay:
188 return nil, nil
189 case err != nil:
190 return nil, status.Errorf(codes.Unavailable, "git error: %v", err)
191 }
192
193 return file, nil
194}
195
196func (s *Service) Stat(ctx context.Context, req *pb.StatRequest) (*pb.StatResponse, error) {
197 s.mu.Lock()
198 defer s.mu.Unlock()
199
Sergiusz Bazanskif157b4d2020-04-10 17:39:43 +0200200 file, err := s.getFile(ctx, req.Hash, req.Path, true)
Sergiusz Bazanski4c0e9b52020-04-08 22:42:33 +0200201 if err != nil {
202 return nil, err
203 }
204
205 if file == nil {
206 return &pb.StatResponse{Type: pb.StatResponse_TYPE_NOT_PRESENT}, nil
207 }
208
209 switch {
210 case file.Mode == filemode.Dir:
211 return &pb.StatResponse{Type: pb.StatResponse_TYPE_DIRECTORY}, nil
212 case file.Mode.IsFile():
213 return &pb.StatResponse{Type: pb.StatResponse_TYPE_FILE}, nil
214 default:
215 return nil, status.Errorf(codes.Unimplemented, "unknown file type %o", file.Mode)
216 }
217}
218
219func (s *Service) Read(req *pb.ReadRequest, srv pb.DepotView_ReadServer) error {
220 s.mu.Lock()
221 defer s.mu.Unlock()
222
Sergiusz Bazanskif157b4d2020-04-10 17:39:43 +0200223 ctx := srv.Context()
224
225 file, err := s.getFile(ctx, req.Hash, req.Path, false)
Sergiusz Bazanski4c0e9b52020-04-08 22:42:33 +0200226 if err != nil {
227 return err
228 }
229
230 reader, err := file.Reader()
231 if err != nil {
232 return status.Errorf(codes.Unavailable, "file read error: %v", err)
233 }
234 defer reader.Close()
235
Sergiusz Bazanski4c0e9b52020-04-08 22:42:33 +0200236 for {
237 if ctx.Err() != nil {
238 return ctx.Err()
239 }
240
241 // 1 MB read
242 chunk := make([]byte, 16*1024)
243 n, err := reader.Read(chunk)
244 switch {
245 case err == io.EOF:
246 n = 0
247 case err != nil:
248 return status.Errorf(codes.Unavailable, "file read error: %v", err)
249 }
250
251 err = srv.Send(&pb.ReadResponse{Data: chunk[:n]})
252 if err != nil {
253 return err
254 }
255
256 if n == 0 {
257 break
258 }
259 }
260
261 return nil
262}
263
264func pathNormalize(path string) string {
265 leadingSlashes := 0
266 for _, c := range path {
267 if c != '/' {
268 break
269 }
270 leadingSlashes += 1
271 }
272
273 // Only foo/bar, /foo/bar, and //foo/bar paths allowed.
274 if leadingSlashes > 2 {
275 return ""
276 }
277 path = path[leadingSlashes:]
278
279 // No trailing slashes allowed.
280 if strings.HasSuffix(path, "/") {
281 return ""
282 }
283
284 return path
285}