d7efdefb6f68a791c1417c05357b5f2636227e84
[arvados.git] / tools / arv-sync-groups / arv-sync-groups.go
1 // Copyright (C) The Arvados Authors. All rights reserved.
2 //
3 // SPDX-License-Identifier: AGPL-3.0
4
5 package main
6
7 import (
8         "bytes"
9         "encoding/csv"
10         "encoding/json"
11         "flag"
12         "fmt"
13         "io"
14         "log"
15         "net/url"
16         "os"
17         "strings"
18
19         "git.curoverse.com/arvados.git/sdk/go/arvados"
20 )
21
22 type resourceList interface {
23         Len() int
24         GetItems() []interface{}
25 }
26
27 // GroupInfo tracks previous and current members of a particular Group
28 type GroupInfo struct {
29         Group           arvados.Group
30         PreviousMembers map[string]bool
31         CurrentMembers  map[string]bool
32 }
33
34 // GetUserID returns the correct user id value depending on the selector
35 func GetUserID(u arvados.User, idSelector string) (string, error) {
36         switch idSelector {
37         case "email":
38                 return u.Email, nil
39         case "username":
40                 return u.Username, nil
41         default:
42                 return "", fmt.Errorf("cannot identify user by %q selector", idSelector)
43         }
44 }
45
46 // UserList implements resourceList interface
47 type UserList struct {
48         arvados.UserList
49 }
50
51 // Len returns the amount of items this list holds
52 func (l UserList) Len() int {
53         return len(l.Items)
54 }
55
56 // GetItems returns the list of items
57 func (l UserList) GetItems() (out []interface{}) {
58         for _, item := range l.Items {
59                 out = append(out, item)
60         }
61         return
62 }
63
64 // GroupList implements resourceList interface
65 type GroupList struct {
66         arvados.GroupList
67 }
68
69 // Len returns the amount of items this list holds
70 func (l GroupList) Len() int {
71         return len(l.Items)
72 }
73
74 // GetItems returns the list of items
75 func (l GroupList) GetItems() (out []interface{}) {
76         for _, item := range l.Items {
77                 out = append(out, item)
78         }
79         return
80 }
81
82 // LinkList implements resourceList interface
83 type LinkList struct {
84         arvados.LinkList
85 }
86
87 // Len returns the amount of items this list holds
88 func (l LinkList) Len() int {
89         return len(l.Items)
90 }
91
92 // GetItems returns the list of items
93 func (l LinkList) GetItems() (out []interface{}) {
94         for _, item := range l.Items {
95                 out = append(out, item)
96         }
97         return
98 }
99
100 func main() {
101         // Parse & validate arguments, set up arvados client.
102         cfg, err := GetConfig()
103         if err != nil {
104                 log.Fatalf("%v", err)
105         }
106
107         if err := doMain(&cfg); err != nil {
108                 log.Fatalf("%v", err)
109         }
110 }
111
112 // ConfigParams holds configuration data for this tool
113 type ConfigParams struct {
114         Path            string
115         UserID          string
116         Verbose         bool
117         ParentGroupUUID string
118         ParentGroupName string
119         SysUserUUID     string
120         Client          *arvados.Client
121 }
122
123 // ParseFlags parses and validates command line arguments
124 func ParseFlags(config *ConfigParams) error {
125         // Acceptable attributes to identify a user on the CSV file
126         userIDOpts := map[string]bool{
127                 "email":    true, // default
128                 "username": true,
129         }
130
131         flags := flag.NewFlagSet("arv-sync-groups", flag.ExitOnError)
132
133         // Set up usage message
134         flags.Usage = func() {
135                 usageStr := `Synchronize remote groups into Arvados from a CSV format file with 2 columns:
136   * 1st column: Group name
137   * 2nd column: User identifier`
138                 fmt.Fprintf(os.Stderr, "%s\n\n", usageStr)
139                 fmt.Fprintf(os.Stderr, "Usage:\n%s [OPTIONS] <input-file.csv>\n\n", os.Args[0])
140                 fmt.Fprintf(os.Stderr, "Options:\n")
141                 flags.PrintDefaults()
142         }
143
144         // Set up option flags
145         userID := flags.String(
146                 "user-id",
147                 "email",
148                 "Attribute by which every user is identified. Valid values are: email and username.")
149         verbose := flags.Bool(
150                 "verbose",
151                 false,
152                 "Log informational messages. Off by default.")
153         parentGroupUUID := flags.String(
154                 "parent-group-uuid",
155                 "",
156                 "Use given group UUID as a parent for the remote groups. Should be owned by the system user. If not specified, a group named '"+config.ParentGroupName+"' will be used (and created if nonexistant).")
157
158         // Parse args; omit the first arg which is the command name
159         flags.Parse(os.Args[1:])
160
161         // Input file as a required positional argument
162         if flags.NArg() == 0 {
163                 return fmt.Errorf("please provide a path to an input file")
164         }
165         srcPath := &os.Args[flags.NFlag()+1]
166
167         // Validations
168         if *srcPath == "" {
169                 return fmt.Errorf("input file path invalid")
170         }
171         if !userIDOpts[*userID] {
172                 var options []string
173                 for opt := range userIDOpts {
174                         options = append(options, opt)
175                 }
176                 return fmt.Errorf("user ID must be one of: %s", strings.Join(options, ", "))
177         }
178
179         config.Path = *srcPath
180         config.ParentGroupUUID = *parentGroupUUID
181         config.UserID = *userID
182         config.Verbose = *verbose
183
184         return nil
185 }
186
187 // SetParentGroup finds/create parent group of all remote groups
188 func SetParentGroup(cfg *ConfigParams) error {
189         var parentGroup arvados.Group
190         if cfg.ParentGroupUUID == "" {
191                 // UUID not provided, search for preexisting parent group
192                 var gl GroupList
193                 params := arvados.ResourceListParams{
194                         Filters: []arvados.Filter{{
195                                 Attr:     "name",
196                                 Operator: "=",
197                                 Operand:  cfg.ParentGroupName,
198                         }, {
199                                 Attr:     "owner_uuid",
200                                 Operator: "=",
201                                 Operand:  cfg.SysUserUUID,
202                         }},
203                 }
204                 if err := cfg.Client.RequestAndDecode(&gl, "GET", "/arvados/v1/groups", nil, params); err != nil {
205                         return fmt.Errorf("error searching for parent group: %s", err)
206                 }
207                 if len(gl.Items) == 0 {
208                         // Default parent group not existant, create one.
209                         if cfg.Verbose {
210                                 log.Println("Default parent group not found, creating...")
211                         }
212                         groupData := map[string]string{
213                                 "name":       cfg.ParentGroupName,
214                                 "owner_uuid": cfg.SysUserUUID,
215                         }
216                         if err := CreateGroup(cfg, &parentGroup, groupData); err != nil {
217                                 return fmt.Errorf("error creating system user owned group named %q: %s", groupData["name"], err)
218                         }
219                 } else if len(gl.Items) == 1 {
220                         // Default parent group found.
221                         parentGroup = gl.Items[0]
222                 } else {
223                         // This should never happen, as there's an unique index for
224                         // (owner_uuid, name) on groups.
225                         return fmt.Errorf("bug: found %d groups owned by system user and named %q", len(gl.Items), cfg.ParentGroupName)
226                 }
227                 cfg.ParentGroupUUID = parentGroup.UUID
228         } else {
229                 // UUID provided. Check if exists and if it's owned by system user
230                 if err := GetGroup(cfg, &parentGroup, cfg.ParentGroupUUID); err != nil {
231                         return fmt.Errorf("error searching for parent group with UUID %q: %s", cfg.ParentGroupUUID, err)
232                 }
233                 if parentGroup.OwnerUUID != cfg.SysUserUUID {
234                         return fmt.Errorf("parent group %q (%s) must be owned by system user", parentGroup.Name, cfg.ParentGroupUUID)
235                 }
236         }
237         return nil
238 }
239
240 // GetConfig sets up a ConfigParams struct
241 func GetConfig() (config ConfigParams, err error) {
242         config.ParentGroupName = "Externally synchronized groups"
243
244         // Command arguments
245         err = ParseFlags(&config)
246         if err != nil {
247                 return config, err
248         }
249
250         // Arvados Client setup
251         config.Client = arvados.NewClientFromEnv()
252
253         // Check current user permissions & get System user's UUID
254         u, err := config.Client.CurrentUser()
255         if err != nil {
256                 return config, fmt.Errorf("error getting the current user: %s", err)
257         }
258         if !u.IsActive || !u.IsAdmin {
259                 return config, fmt.Errorf("current user (%s) is not an active admin user", u.UUID)
260         }
261         config.SysUserUUID = u.UUID[:12] + "000000000000000"
262
263         // Set up remote groups' parent
264         if err = SetParentGroup(&config); err != nil {
265                 return config, err
266         }
267
268         return config, nil
269 }
270
271 func doMain(cfg *ConfigParams) error {
272         // Try opening the input file early, just in case there's a problem.
273         f, err := os.Open(cfg.Path)
274         if err != nil {
275                 return fmt.Errorf("%s", err)
276         }
277         defer f.Close()
278
279         log.Printf("Group sync starting. Using %q as users id and parent group UUID %q", cfg.UserID, cfg.ParentGroupUUID)
280
281         // Get the complete user list to minimize API Server requests
282         allUsers := make(map[string]arvados.User)
283         userIDToUUID := make(map[string]string) // Index by email or username
284         results, err := GetAll(cfg.Client, "users", arvados.ResourceListParams{}, &UserList{})
285         if err != nil {
286                 return fmt.Errorf("error getting user list: %s", err)
287         }
288         log.Printf("Found %d users", len(results))
289         for _, item := range results {
290                 u := item.(arvados.User)
291                 allUsers[u.UUID] = u
292                 uID, err := GetUserID(u, cfg.UserID)
293                 if err != nil {
294                         return err
295                 }
296                 userIDToUUID[uID] = u.UUID
297                 if cfg.Verbose {
298                         log.Printf("Seen user %q (%s)", u.Username, u.Email)
299                 }
300         }
301
302         // Get remote groups and their members
303         remoteGroups, groupNameToUUID, err := GetRemoteGroups(cfg, allUsers)
304         if err != nil {
305                 return err
306         }
307         log.Printf("Found %d remote groups", len(remoteGroups))
308
309         membershipsRemoved := 0
310
311         // Read the CSV file
312         groupsCreated, membershipsAdded, membershipsSkipped, err := ProcessFile(cfg, f, userIDToUUID, groupNameToUUID, remoteGroups, allUsers)
313         if err != nil {
314                 return err
315         }
316
317         // Remove previous members not listed on this run
318         for groupUUID := range remoteGroups {
319                 gi := remoteGroups[groupUUID]
320                 evictedMembers := subtract(gi.PreviousMembers, gi.CurrentMembers)
321                 groupName := gi.Group.Name
322                 if len(evictedMembers) > 0 {
323                         log.Printf("Removing %d users from group %q", len(evictedMembers), groupName)
324                 }
325                 for evictedUser := range evictedMembers {
326                         if err := RemoveMemberFromGroup(cfg, allUsers[userIDToUUID[evictedUser]], gi.Group); err != nil {
327                                 return err
328                         }
329                         membershipsRemoved++
330                 }
331         }
332         log.Printf("Groups created: %d. Memberships added: %d, removed: %d, skipped: %d", groupsCreated, membershipsAdded, membershipsRemoved, membershipsSkipped)
333
334         return nil
335 }
336
337 // ProcessFile reads the CSV file and process every record
338 func ProcessFile(
339         cfg *ConfigParams,
340         f *os.File,
341         userIDToUUID map[string]string,
342         groupNameToUUID map[string]string,
343         remoteGroups map[string]*GroupInfo,
344         allUsers map[string]arvados.User,
345 ) (groupsCreated, membersAdded, membersSkipped int, err error) {
346         lineNo := 0
347         csvReader := csv.NewReader(f)
348         csvReader.FieldsPerRecord = 2
349         for {
350                 record, e := csvReader.Read()
351                 if e == io.EOF {
352                         break
353                 }
354                 lineNo++
355                 if e != nil {
356                         err = fmt.Errorf("error parsing %q, line %d", cfg.Path, lineNo)
357                         return
358                 }
359                 groupName := strings.TrimSpace(record[0])
360                 groupMember := strings.TrimSpace(record[1]) // User ID (username or email)
361                 if groupName == "" || groupMember == "" {
362                         log.Printf("Warning: CSV record has at least one empty field (%s, %s). Skipping", groupName, groupMember)
363                         membersSkipped++
364                         continue
365                 }
366                 if _, found := userIDToUUID[groupMember]; !found {
367                         // User not present on the system, skip.
368                         log.Printf("Warning: there's no user with %s %q on the system, skipping.", cfg.UserID, groupMember)
369                         membersSkipped++
370                         continue
371                 }
372                 if _, found := groupNameToUUID[groupName]; !found {
373                         // Group doesn't exist, create it before continuing
374                         if cfg.Verbose {
375                                 log.Printf("Remote group %q not found, creating...", groupName)
376                         }
377                         var newGroup arvados.Group
378                         groupData := map[string]string{
379                                 "name":        groupName,
380                                 "owner_uuid":  cfg.ParentGroupUUID,
381                                 "group_class": "role",
382                         }
383                         if e := CreateGroup(cfg, &newGroup, groupData); e != nil {
384                                 err = fmt.Errorf("error creating group named %q: %s", groupName, err)
385                                 return
386                         }
387                         // Update cached group data
388                         groupNameToUUID[groupName] = newGroup.UUID
389                         remoteGroups[newGroup.UUID] = &GroupInfo{
390                                 Group:           newGroup,
391                                 PreviousMembers: make(map[string]bool), // Empty set
392                                 CurrentMembers:  make(map[string]bool), // Empty set
393                         }
394                         groupsCreated++
395                 }
396                 // Both group & user exist, check if user is a member
397                 groupUUID := groupNameToUUID[groupName]
398                 gi := remoteGroups[groupUUID]
399                 if !gi.PreviousMembers[groupMember] && !gi.CurrentMembers[groupMember] {
400                         if cfg.Verbose {
401                                 log.Printf("Adding %q to group %q", groupMember, groupName)
402                         }
403                         // User wasn't a member, but should be.
404                         if e := AddMemberToGroup(cfg, allUsers[userIDToUUID[groupMember]], gi.Group); e != nil {
405                                 err = e
406                                 return
407                         }
408                         membersAdded++
409                 }
410                 gi.CurrentMembers[groupMember] = true
411         }
412         return
413 }
414
415 // GetAll : Adds all objects of type 'resource' to the 'allItems' list
416 func GetAll(c *arvados.Client, res string, params arvados.ResourceListParams, page resourceList) (allItems []interface{}, err error) {
417         // Use the maximum page size the server allows
418         limit := 1<<31 - 1
419         params.Limit = &limit
420         params.Offset = 0
421         params.Order = "uuid"
422         for {
423                 if err = GetResourceList(c, &page, res, params); err != nil {
424                         return allItems, err
425                 }
426                 // Have we finished paging?
427                 if page.Len() == 0 {
428                         break
429                 }
430                 for _, i := range page.GetItems() {
431                         allItems = append(allItems, i)
432                 }
433                 params.Offset += page.Len()
434         }
435         return allItems, nil
436 }
437
438 func subtract(setA map[string]bool, setB map[string]bool) map[string]bool {
439         result := make(map[string]bool)
440         for element := range setA {
441                 if !setB[element] {
442                         result[element] = true
443                 }
444         }
445         return result
446 }
447
448 func jsonReader(rscName string, ob interface{}) io.Reader {
449         j, err := json.Marshal(ob)
450         if err != nil {
451                 panic(err)
452         }
453         v := url.Values{}
454         v[rscName] = []string{string(j)}
455         return bytes.NewBufferString(v.Encode())
456 }
457
458 // GetRemoteGroups fetches all remote groups with their members
459 func GetRemoteGroups(cfg *ConfigParams, allUsers map[string]arvados.User) (remoteGroups map[string]*GroupInfo, groupNameToUUID map[string]string, err error) {
460         remoteGroups = make(map[string]*GroupInfo)
461         groupNameToUUID = make(map[string]string) // Index by group name
462
463         params := arvados.ResourceListParams{
464                 Filters: []arvados.Filter{{
465                         Attr:     "owner_uuid",
466                         Operator: "=",
467                         Operand:  cfg.ParentGroupUUID,
468                 }},
469         }
470         results, err := GetAll(cfg.Client, "groups", params, &GroupList{})
471         if err != nil {
472                 return remoteGroups, groupNameToUUID, fmt.Errorf("error getting remote groups: %s", err)
473         }
474         for _, item := range results {
475                 group := item.(arvados.Group)
476                 // Group -> User filter
477                 g2uFilter := arvados.ResourceListParams{
478                         Filters: []arvados.Filter{{
479                                 Attr:     "owner_uuid",
480                                 Operator: "=",
481                                 Operand:  cfg.SysUserUUID,
482                         }, {
483                                 Attr:     "link_class",
484                                 Operator: "=",
485                                 Operand:  "permission",
486                         }, {
487                                 Attr:     "name",
488                                 Operator: "=",
489                                 Operand:  "can_read",
490                         }, {
491                                 Attr:     "tail_uuid",
492                                 Operator: "=",
493                                 Operand:  group.UUID,
494                         }, {
495                                 Attr:     "head_kind",
496                                 Operator: "=",
497                                 Operand:  "arvados#user",
498                         }},
499                 }
500                 // User -> Group filter
501                 u2gFilter := arvados.ResourceListParams{
502                         Filters: []arvados.Filter{{
503                                 Attr:     "owner_uuid",
504                                 Operator: "=",
505                                 Operand:  cfg.SysUserUUID,
506                         }, {
507                                 Attr:     "link_class",
508                                 Operator: "=",
509                                 Operand:  "permission",
510                         }, {
511                                 Attr:     "name",
512                                 Operator: "=",
513                                 Operand:  "can_write",
514                         }, {
515                                 Attr:     "head_uuid",
516                                 Operator: "=",
517                                 Operand:  group.UUID,
518                         }, {
519                                 Attr:     "tail_kind",
520                                 Operator: "=",
521                                 Operand:  "arvados#user",
522                         }},
523                 }
524                 g2uLinks, err := GetAll(cfg.Client, "links", g2uFilter, &LinkList{})
525                 if err != nil {
526                         return remoteGroups, groupNameToUUID, fmt.Errorf("error getting member (can_read) links for group %q: %s", group.Name, err)
527                 }
528                 u2gLinks, err := GetAll(cfg.Client, "links", u2gFilter, &LinkList{})
529                 if err != nil {
530                         return remoteGroups, groupNameToUUID, fmt.Errorf("error getting member (can_write) links for group %q: %s", group.Name, err)
531                 }
532                 // Build a list of user ids (email or username) belonging to this group
533                 membersSet := make(map[string]bool)
534                 u2gLinkSet := make(map[string]bool)
535                 for _, l := range u2gLinks {
536                         linkedMemberUUID := l.(arvados.Link).TailUUID
537                         u2gLinkSet[linkedMemberUUID] = true
538                 }
539                 for _, item := range g2uLinks {
540                         link := item.(arvados.Link)
541                         // We may have received an old link pointing to a removed account.
542                         if _, found := allUsers[link.HeadUUID]; !found {
543                                 continue
544                         }
545                         // The matching User -> Group link may not exist if the link
546                         // creation failed on a previous run. If that's the case, don't
547                         // include this account on the "previous members" list.
548                         if _, found := u2gLinkSet[link.HeadUUID]; !found {
549                                 continue
550                         }
551                         memberID, err := GetUserID(allUsers[link.HeadUUID], cfg.UserID)
552                         if err != nil {
553                                 return remoteGroups, groupNameToUUID, err
554                         }
555                         membersSet[memberID] = true
556                 }
557                 remoteGroups[group.UUID] = &GroupInfo{
558                         Group:           group,
559                         PreviousMembers: membersSet,
560                         CurrentMembers:  make(map[string]bool), // Empty set
561                 }
562                 groupNameToUUID[group.Name] = group.UUID
563         }
564         return remoteGroups, groupNameToUUID, nil
565 }
566
567 // RemoveMemberFromGroup remove all links related to the membership
568 func RemoveMemberFromGroup(cfg *ConfigParams, user arvados.User, group arvados.Group) error {
569         if cfg.Verbose {
570                 log.Printf("Getting group membership links for user %q (%s) on group %q (%s)", user.Email, user.UUID, group.Name, group.UUID)
571         }
572         var links []interface{}
573         // Search for all group<->user links (both ways)
574         for _, filterset := range [][]arvados.Filter{
575                 // Group -> User
576                 {{
577                         Attr:     "link_class",
578                         Operator: "=",
579                         Operand:  "permission",
580                 }, {
581                         Attr:     "tail_uuid",
582                         Operator: "=",
583                         Operand:  group.UUID,
584                 }, {
585                         Attr:     "head_uuid",
586                         Operator: "=",
587                         Operand:  user.UUID,
588                 }},
589                 // Group <- User
590                 {{
591                         Attr:     "link_class",
592                         Operator: "=",
593                         Operand:  "permission",
594                 }, {
595                         Attr:     "tail_uuid",
596                         Operator: "=",
597                         Operand:  user.UUID,
598                 }, {
599                         Attr:     "head_uuid",
600                         Operator: "=",
601                         Operand:  group.UUID,
602                 }},
603         } {
604                 l, err := GetAll(cfg.Client, "links", arvados.ResourceListParams{Filters: filterset}, &LinkList{})
605                 if err != nil {
606                         userID, _ := GetUserID(user, cfg.UserID)
607                         return fmt.Errorf("error getting links needed to remove user %q from group %q: %s", userID, group.Name, err)
608                 }
609                 for _, link := range l {
610                         links = append(links, link)
611                 }
612         }
613         for _, item := range links {
614                 link := item.(arvados.Link)
615                 userID, _ := GetUserID(user, cfg.UserID)
616                 if cfg.Verbose {
617                         log.Printf("Removing %q permission link for %q on group %q", link.Name, userID, group.Name)
618                 }
619                 if err := DeleteLink(cfg, link.UUID); err != nil {
620                         return fmt.Errorf("error removing user %q from group %q: %s", userID, group.Name, err)
621                 }
622         }
623         return nil
624 }
625
626 // AddMemberToGroup create membership links
627 func AddMemberToGroup(cfg *ConfigParams, user arvados.User, group arvados.Group) error {
628         var newLink arvados.Link
629         linkData := map[string]string{
630                 "owner_uuid": cfg.SysUserUUID,
631                 "link_class": "permission",
632                 "name":       "can_read",
633                 "tail_uuid":  group.UUID,
634                 "head_uuid":  user.UUID,
635         }
636         if err := CreateLink(cfg, &newLink, linkData); err != nil {
637                 userID, _ := GetUserID(user, cfg.UserID)
638                 return fmt.Errorf("error adding group %q -> user %q read permission: %s", group.Name, userID, err)
639         }
640         linkData = map[string]string{
641                 "owner_uuid": cfg.SysUserUUID,
642                 "link_class": "permission",
643                 "name":       "can_write",
644                 "tail_uuid":  user.UUID,
645                 "head_uuid":  group.UUID,
646         }
647         if err := CreateLink(cfg, &newLink, linkData); err != nil {
648                 userID, _ := GetUserID(user, cfg.UserID)
649                 return fmt.Errorf("error adding user %q -> group %q write permission: %s", userID, group.Name, err)
650         }
651         return nil
652 }
653
654 // CreateGroup creates a group with groupData parameters, assigns it to dst
655 func CreateGroup(cfg *ConfigParams, dst *arvados.Group, groupData map[string]string) error {
656         return cfg.Client.RequestAndDecode(dst, "POST", "/arvados/v1/groups", jsonReader("group", groupData), nil)
657 }
658
659 // GetGroup fetches a group by its UUID
660 func GetGroup(cfg *ConfigParams, dst *arvados.Group, groupUUID string) error {
661         return cfg.Client.RequestAndDecode(&dst, "GET", "/arvados/v1/groups/"+groupUUID, nil, nil)
662 }
663
664 // CreateLink creates a link with linkData parameters, assigns it to dst
665 func CreateLink(cfg *ConfigParams, dst *arvados.Link, linkData map[string]string) error {
666         return cfg.Client.RequestAndDecode(dst, "POST", "/arvados/v1/links", jsonReader("link", linkData), nil)
667 }
668
669 // DeleteLink deletes a link by its UUID
670 func DeleteLink(cfg *ConfigParams, linkUUID string) error {
671         if linkUUID == "" {
672                 return fmt.Errorf("cannot delete link with invalid UUID: %q", linkUUID)
673         }
674         return cfg.Client.RequestAndDecode(&arvados.Link{}, "DELETE", "/arvados/v1/links/"+linkUUID, nil, nil)
675 }
676
677 // GetResourceList fetches res list using params
678 func GetResourceList(c *arvados.Client, dst *resourceList, res string, params interface{}) error {
679         return c.RequestAndDecode(dst, "GET", "/arvados/v1/"+res, nil, params)
680 }