exp.upspin.io@v0.0.0-20230625230448-5076e5b595ec/cmd/issueserver/main.go (about) 1 // Copyright 2017 The Upspin Authors. All rights reserved. 2 // Use of this source code is governed by a BSD-style 3 // license that can be found in the LICENSE file. 4 5 // Command issueserver is an Upspin server that serves GitHub issues. 6 // 7 // To try it out, first create a GitHub Personal Access Token which which to 8 // access the GitHub API, giving it "repo" privileges. 9 // See: https://github.com/settings/tokens/new 10 // Put the token string (a string of hex digits) in the file 11 // $HOME/upspin/issueserver-github-token and run issueserver with upbox: 12 // $ upbox -schema=issueserver.upbox 13 // If all goes well, upbox will leave you in an 'upspin shell' session as 14 // issueserver@example.com. Type 'ls' to look around. 15 package main // import "exp.upspin.io/cmd/issueserver" 16 17 import ( 18 "bytes" 19 "context" 20 "flag" 21 "fmt" 22 "io/ioutil" 23 "log" 24 "net/http" 25 "os" 26 "path/filepath" 27 "sort" 28 "strconv" 29 "strings" 30 "sync" 31 "time" 32 33 linebreak "github.com/dgryski/go-linebreak" 34 "golang.org/x/build/maintner" 35 36 "upspin.io/access" 37 "upspin.io/cloud/https" 38 "upspin.io/config" 39 "upspin.io/errors" 40 "upspin.io/flags" 41 "upspin.io/pack" 42 "upspin.io/path" 43 "upspin.io/rpc/dirserver" 44 "upspin.io/rpc/storeserver" 45 "upspin.io/serverutil" 46 "upspin.io/upspin" 47 48 _ "upspin.io/key/transports" 49 _ "upspin.io/pack/eeintegrity" 50 ) 51 52 var ( 53 watchGitHub = flag.String("watch-github", "", "Comma-separated list of GitHub owner/repo pairs to sync") 54 dataDir = flag.String("data-dir", defaultDataDir, "Local directory in which to write issueserver files") 55 defaultDataDir = filepath.Join(os.Getenv("HOME"), "upspin", "issueserver") 56 ) 57 58 func main() { 59 flags.Parse(flags.Server) 60 61 addr := upspin.NetAddr(flags.NetAddr) 62 ep := upspin.Endpoint{ 63 Transport: upspin.Remote, 64 NetAddr: addr, 65 } 66 cfg, err := config.FromFile(flags.Config) 67 if err != nil { 68 log.Fatal(err) 69 } 70 71 // Set up maintner Corpus. 72 corpus := new(maintner.Corpus) 73 logger := maintner.NewDiskMutationLogger(*dataDir) 74 corpus.EnableLeaderMode(logger, *dataDir) 75 if *watchGitHub != "" { 76 for _, pair := range strings.Split(*watchGitHub, ",") { 77 splits := strings.SplitN(pair, "/", 2) 78 if len(splits) != 2 || splits[1] == "" { 79 log.Fatalf("Invalid github repo: %s. Should be 'owner/repo,owner2/repo2'", pair) 80 } 81 token, err := getGitHubToken() 82 if err != nil { 83 log.Fatalf("getting github token: %v", err) 84 } 85 corpus.TrackGitHub(splits[0], splits[1], token) 86 } 87 } 88 ctx, cancel := context.WithCancel(context.Background()) 89 defer cancel() 90 if err := corpus.Initialize(ctx, logger); err != nil { 91 log.Fatal(err) 92 } 93 if *watchGitHub != "" { 94 go func() { log.Fatal(fmt.Errorf("Corpus.SyncLoop = %v", corpus.SyncLoop(ctx))) }() 95 } 96 97 // Set up DirServer and StoreServer. 98 s, err := newServer(ep, cfg, corpus) 99 if err != nil { 100 log.Fatal(err) 101 } 102 http.Handle("/api/Store/", storeserver.New(cfg, storeServer{s}, addr)) 103 http.Handle("/api/Dir/", dirserver.New(cfg, dirServer{s}, addr)) 104 105 https.ListenAndServeFromFlags(nil) 106 } 107 108 // getGitHubToken reads a GitHub Personal Access Token from the file 109 // $HOME/upspin/issueserver-github-token of the format "token". 110 func getGitHubToken() (string, error) { 111 file := filepath.Join(config.Home(), "upspin", "issueserver-github-token") 112 token, err := ioutil.ReadFile(file) 113 if err != nil { 114 return "", err 115 } 116 return string(bytes.TrimSpace(token)), nil 117 } 118 119 // server provides implementations of upspin.DirServer and upspin.StoreServer 120 // (accessed by calling the respective methods) that serve a tree containing 121 // the GitHub issues in its maintner Corpus. 122 // 123 // The resulting tree looks like this (issue 1 is closed and 2 is open): 124 // user@example.com/owner/repo/all/1 125 // user@example.com/owner/repo/all/2 126 // user@example.com/owner/repo/closed/1 (link to all/1) 127 // user@example.com/owner/repo/open/2 (link to all/2) 128 type server struct { 129 ep upspin.Endpoint 130 cfg upspin.Config 131 132 // The Access file entry and data, computed by newServer. 133 accessEntry *upspin.DirEntry 134 accessBytes []byte 135 136 corpus *maintner.Corpus 137 138 mu sync.Mutex 139 issue map[issueKey]packedIssue 140 } 141 142 type issueKey struct { 143 name upspin.PathName 144 updated time.Time 145 } 146 147 type packedIssue struct { 148 de *upspin.DirEntry 149 data []byte 150 } 151 152 func (k issueKey) Ref() upspin.Reference { 153 return upspin.Reference(fmt.Sprintf("%v %v", k.name, k.updated.Format(time.RFC3339))) 154 } 155 156 func refToIssueKey(ref upspin.Reference) (issueKey, error) { 157 p := strings.SplitN(string(ref), " ", 2) 158 if len(p) != 2 { 159 return issueKey{}, errors.Str("invalid reference") 160 } 161 updated, err := time.Parse(time.RFC3339, p[1]) 162 if err != nil { 163 return issueKey{}, err 164 } 165 return issueKey{ 166 name: upspin.PathName(p[0]), 167 updated: updated, 168 }, nil 169 } 170 171 type dirServer struct { 172 *server 173 } 174 175 type storeServer struct { 176 *server 177 } 178 179 const ( 180 accessRef = upspin.Reference(access.AccessFile) 181 accessFile = "read,list:all\n" 182 ) 183 184 var accessRefdata = upspin.Refdata{Reference: accessRef} 185 186 func newServer(ep upspin.Endpoint, cfg upspin.Config, c *maintner.Corpus) (*server, error) { 187 s := &server{ 188 ep: ep, 189 cfg: cfg, 190 corpus: c, 191 issue: make(map[issueKey]packedIssue), 192 } 193 194 var err error 195 accessName := upspin.PathName(s.cfg.UserName()) + "/" + access.AccessFile 196 s.accessEntry, s.accessBytes, err = s.pack(accessName, accessRef, []byte(accessFile)) 197 if err != nil { 198 return nil, err 199 } 200 201 return s, nil 202 } 203 204 const packing = upspin.EEIntegrityPack 205 206 // pack packs the given data and returns the resulting DirEntry and ciphertext. 207 func (s *server) pack(name upspin.PathName, ref upspin.Reference, data []byte) (*upspin.DirEntry, []byte, error) { 208 de := &upspin.DirEntry{ 209 Writer: s.cfg.UserName(), 210 Name: name, 211 SignedName: name, 212 Packing: packing, 213 Time: upspin.Now(), 214 Sequence: 1, 215 } 216 217 bp, err := pack.Lookup(packing).Pack(s.cfg, de) 218 if err != nil { 219 return nil, nil, err 220 } 221 cipher, err := bp.Pack(data) 222 if err != nil { 223 return nil, nil, err 224 } 225 bp.SetLocation(upspin.Location{ 226 Endpoint: s.ep, 227 Reference: ref, 228 }) 229 return de, cipher, bp.Close() 230 } 231 232 // packIssue formats and packs the given issue at the given path, updates the 233 // server's issue map, and returns the resulting DirEntry. If the issue is 234 // already present in the issue map then that DirEntry is returned instead. 235 func (s *server) packIssue(name upspin.PathName, issue *maintner.GitHubIssue) (*upspin.DirEntry, error) { 236 key := issueKey{ 237 name: name, 238 updated: issue.Updated, 239 } 240 s.mu.Lock() 241 packed, ok := s.issue[key] 242 s.mu.Unlock() 243 if ok { 244 return packed.de, nil 245 } 246 de, data, err := s.pack(name, key.Ref(), formatIssue(issue)) 247 if err != nil { 248 return nil, err 249 } 250 s.mu.Lock() 251 s.issue[key] = packedIssue{ 252 de: de, 253 data: data, 254 } 255 s.mu.Unlock() 256 return de, nil 257 } 258 259 // formatIssue formats the given issue as text. 260 func formatIssue(issue *maintner.GitHubIssue) []byte { 261 const timeFormat = "15:04 on 2 Jan 2006" 262 var buf bytes.Buffer 263 fmt.Fprintf(&buf, "%s\ncreated %s at %s\n\n%s\n", 264 issue.Title, 265 formatUser(issue.User), 266 issue.Created.Format(timeFormat), 267 wrap("\t", issue.Body)) 268 269 type update struct { 270 time time.Time 271 printed []byte 272 } 273 var updates []update 274 issue.ForeachComment(func(comment *maintner.GitHubComment) error { 275 var buf bytes.Buffer 276 fmt.Fprintf(&buf, "comment %s at %s\n\n%s\n", 277 formatUser(comment.User), 278 comment.Created.Format(timeFormat), 279 wrap("\t", comment.Body)) 280 updates = append(updates, update{comment.Created, buf.Bytes()}) 281 return nil 282 }) 283 issue.ForeachEvent(func(event *maintner.GitHubIssueEvent) error { 284 var buf bytes.Buffer 285 switch event.Type { 286 case "closed", "reopened": 287 fmt.Fprintf(&buf, "%s %s at %s\n\n", 288 event.Type, 289 formatUser(event.Actor), 290 event.Created.Format(timeFormat)) 291 default: 292 // TODO(adg): other types 293 } 294 updates = append(updates, update{event.Created, buf.Bytes()}) 295 return nil 296 }) 297 sort.Slice(updates, func(i, j int) bool { 298 return updates[i].time.Before(updates[j].time) 299 }) 300 for _, u := range updates { 301 buf.Write(u.printed) 302 } 303 return buf.Bytes() 304 } 305 306 // formatUser returns "by username" or the empty string if user is nil. 307 func formatUser(user *maintner.GitHubUser) string { 308 if user != nil { 309 return "by " + user.Login 310 } 311 return "" 312 } 313 314 // wrap wraps the given text and adds prefix to the beginning of each line. 315 func wrap(prefix, text string) []byte { 316 maxWidth := 80 317 for _, c := range prefix { 318 maxWidth -= 1 319 if c == '\t' { 320 maxWidth -= 7 321 } 322 } 323 var buf bytes.Buffer 324 for _, line := range strings.Split(linebreak.Wrap(text, maxWidth, maxWidth), "\n") { 325 buf.WriteString(prefix) 326 buf.WriteString(line) 327 buf.WriteByte('\n') 328 } 329 return buf.Bytes() 330 } 331 332 // These methods implement upspin.Service. 333 334 func (s *server) Endpoint() upspin.Endpoint { return s.ep } 335 func (*server) Ping() bool { return true } 336 func (*server) Close() {} 337 338 // These methods implement upspin.Dialer. 339 340 func (s storeServer) Dial(upspin.Config, upspin.Endpoint) (upspin.Service, error) { return s, nil } 341 func (s dirServer) Dial(upspin.Config, upspin.Endpoint) (upspin.Service, error) { return s, nil } 342 343 // These methods implement upspin.DirServer. 344 345 func (s dirServer) Lookup(name upspin.PathName) (*upspin.DirEntry, error) { 346 p, err := path.Parse(name) 347 if err != nil { 348 return nil, err 349 } 350 351 switch p.FilePath() { 352 case "": // Root directory. 353 return directory(p.Path()), nil 354 case access.AccessFile: 355 return s.accessEntry, nil 356 } 357 358 git := s.corpus.GitHub() 359 switch p.NElem() { 360 case 1: // Owner directory. 361 ok := false 362 git.ForeachRepo(func(repo *maintner.GitHubRepo) error { 363 if repo.ID().Owner == p.Elem(0) { 364 ok = true 365 } 366 return nil 367 }) 368 if ok { 369 return directory(p.Path()), nil 370 } 371 case 2: // User directory. 372 if git.Repo(p.Elem(0), p.Elem(1)) != nil { 373 return directory(p.Path()), nil 374 } 375 case 3: // State directory. 376 if validState(p.Elem(2)) { 377 return directory(p.Path()), nil 378 } 379 case 4: // Issue file or link. 380 state := p.Elem(2) 381 if !validState(state) { 382 break 383 } 384 repo := git.Repo(p.Elem(0), p.Elem(1)) 385 n, err := strconv.ParseInt(p.Elem(3), 10, 32) 386 if err != nil { 387 break 388 } 389 issue := repo.Issue(int32(n)) 390 if issue == nil { 391 break 392 } 393 if state == "open" && issue.Closed || state == "closed" && !issue.Closed { 394 break 395 } 396 if state == "open" || state == "closed" { 397 return link(p.Path(), issue), upspin.ErrFollowLink 398 } 399 de, err := s.packIssue(p.Path(), issue) 400 if err != nil { 401 return nil, errors.E(name, err) 402 } 403 return de, nil 404 } 405 406 return nil, errors.E(name, errors.NotExist) 407 } 408 409 // validState reports whether the given issue state 410 // path component is one of (open, closed, all). 411 func validState(state string) bool { 412 return state == "open" || state == "closed" || state == "all" 413 } 414 415 // directory returns a DirEntry for the directory with the given name. 416 func directory(name upspin.PathName) *upspin.DirEntry { 417 return &upspin.DirEntry{ 418 Name: name, 419 SignedName: name, 420 Attr: upspin.AttrDirectory, 421 Time: upspin.Now(), 422 } 423 } 424 425 // link returns a DirEntry for the link with the given name 426 // that points to the given issue. 427 func link(name upspin.PathName, issue *maintner.GitHubIssue) *upspin.DirEntry { 428 p, _ := path.Parse(name) 429 link := p.Drop(2).Path() + upspin.PathName(fmt.Sprintf("/all/%d", issue.Number)) 430 return &upspin.DirEntry{ 431 Packing: upspin.PlainPack, 432 Name: name, 433 SignedName: name, 434 Link: link, 435 Attr: upspin.AttrLink, 436 Time: upspin.Now(), 437 } 438 } 439 440 func (s dirServer) Glob(pattern string) ([]*upspin.DirEntry, error) { 441 return serverutil.Glob(pattern, s.Lookup, s.listDir) 442 } 443 444 func (s dirServer) listDir(name upspin.PathName) ([]*upspin.DirEntry, error) { 445 p, err := path.Parse(name) 446 if err != nil { 447 return nil, err 448 } 449 if p.User() != s.cfg.UserName() { 450 return nil, errors.E(name, errors.NotExist) 451 } 452 453 var des []*upspin.DirEntry 454 455 switch p.NElem() { 456 case 0: 457 des = append(des, s.accessEntry) 458 owners := repoIDStrings(s.corpus, func(id maintner.GitHubRepoID) string { 459 return id.Owner 460 }) 461 for _, owner := range owners { 462 name := p.Path() + upspin.PathName(owner) 463 des = append(des, directory(name)) 464 } 465 case 1: 466 repos := repoIDStrings(s.corpus, func(id maintner.GitHubRepoID) string { 467 if id.Owner == p.Elem(0) { 468 return id.Repo 469 } 470 return "" 471 }) 472 for _, repo := range repos { 473 name := p.Path() + upspin.PathName("/"+repo) 474 des = append(des, directory(name)) 475 } 476 case 2: 477 if s.corpus.GitHub().Repo(p.Elem(0), p.Elem(1)) == nil { 478 break 479 } 480 des = append(des, 481 directory(p.Path()+"/all"), 482 directory(p.Path()+"/closed"), 483 directory(p.Path()+"/open"), 484 ) 485 case 3: 486 state := p.Elem(2) 487 if !validState(state) { 488 break 489 } 490 repo := s.corpus.GitHub().Repo(p.Elem(0), p.Elem(1)) 491 if repo == nil { 492 break 493 } 494 err := repo.ForeachIssue(func(issue *maintner.GitHubIssue) error { 495 if state == "open" && issue.Closed || state == "closed" && !issue.Closed { 496 return nil 497 } 498 name := p.Path() + upspin.PathName(fmt.Sprintf("/%d", issue.Number)) 499 if state == "open" || state == "closed" { 500 des = append(des, link(name, issue)) 501 return nil 502 } 503 de, err := s.packIssue(name, issue) 504 if err != nil { 505 return errors.E(name, err) 506 } 507 des = append(des, de) 508 return nil 509 }) 510 if err != nil { 511 return nil, err 512 } 513 } 514 515 if len(des) == 0 { 516 return nil, errors.E(name, errors.NotExist) 517 } 518 return des, nil 519 } 520 521 // repoIDStrings returns a deduplicated, lexicographically sorted list of 522 // strings returned by iterating over the given corpus' GitHub repositories and 523 // calling fn for each of them. Empty strings returned by fn are ignored. 524 func repoIDStrings(corpus *maintner.Corpus, fn func(maintner.GitHubRepoID) string) []string { 525 idmap := map[string]bool{} 526 corpus.GitHub().ForeachRepo(func(repo *maintner.GitHubRepo) error { 527 idmap[fn(repo.ID())] = true 528 return nil 529 }) 530 var ids []string 531 for id := range idmap { 532 if id == "" { 533 continue 534 } 535 ids = append(ids, id) 536 } 537 sort.Strings(ids) 538 return ids 539 } 540 541 func (s dirServer) WhichAccess(name upspin.PathName) (*upspin.DirEntry, error) { 542 return s.accessEntry, nil 543 } 544 545 // This method implements upspin.StoreServer. 546 547 func (s storeServer) Get(ref upspin.Reference) ([]byte, *upspin.Refdata, []upspin.Location, error) { 548 if ref == accessRef { 549 return s.accessBytes, &accessRefdata, nil, nil 550 } 551 key, err := refToIssueKey(ref) 552 if err != nil { 553 return nil, nil, nil, errors.E(errors.NotExist, err) 554 } 555 s.mu.Lock() 556 issue, ok := s.issue[key] 557 s.mu.Unlock() 558 if !ok { 559 return nil, nil, nil, errors.E(errors.NotExist) 560 } 561 return issue.data, &upspin.Refdata{Reference: ref}, nil, nil 562 } 563 564 // The DirServer and StoreServer methods below are not implemented. 565 566 var errNotImplemented = errors.E(errors.Permission, "method not implemented: demoserver is read-only") 567 568 func (dirServer) Watch(name upspin.PathName, seq int64, done <-chan struct{}) (<-chan upspin.Event, error) { 569 return nil, upspin.ErrNotSupported 570 } 571 572 func (dirServer) Put(entry *upspin.DirEntry) (*upspin.DirEntry, error) { 573 return nil, errNotImplemented 574 } 575 576 func (dirServer) Delete(name upspin.PathName) (*upspin.DirEntry, error) { 577 return nil, errNotImplemented 578 } 579 580 func (storeServer) Put(data []byte) (*upspin.Refdata, error) { 581 return nil, errNotImplemented 582 } 583 584 func (storeServer) Delete(ref upspin.Reference) error { 585 return errNotImplemented 586 }