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