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