11453: Merge branch 'master' into 11453-federated-tokens
[arvados.git] / tools / sync-groups / 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 var version = "dev"
23
24 type resourceList interface {
25         Len() int
26         GetItems() []interface{}
27 }
28
29 // GroupInfo tracks previous and current members of a particular Group
30 type GroupInfo struct {
31         Group           arvados.Group
32         PreviousMembers map[string]bool
33         CurrentMembers  map[string]bool
34 }
35
36 // GetUserID returns the correct user id value depending on the selector
37 func GetUserID(u arvados.User, idSelector string) (string, error) {
38         switch idSelector {
39         case "email":
40                 return u.Email, nil
41         case "username":
42                 return u.Username, nil
43         default:
44                 return "", fmt.Errorf("cannot identify user by %q selector", idSelector)
45         }
46 }
47
48 // UserList implements resourceList interface
49 type UserList struct {
50         arvados.UserList
51 }
52
53 // Len returns the amount of items this list holds
54 func (l UserList) Len() int {
55         return len(l.Items)
56 }
57
58 // GetItems returns the list of items
59 func (l UserList) GetItems() (out []interface{}) {
60         for _, item := range l.Items {
61                 out = append(out, item)
62         }
63         return
64 }
65
66 // GroupList implements resourceList interface
67 type GroupList struct {
68         arvados.GroupList
69 }
70
71 // Len returns the amount of items this list holds
72 func (l GroupList) Len() int {
73         return len(l.Items)
74 }
75
76 // GetItems returns the list of items
77 func (l GroupList) GetItems() (out []interface{}) {
78         for _, item := range l.Items {
79                 out = append(out, item)
80         }
81         return
82 }
83
84 // LinkList implements resourceList interface
85 type LinkList struct {
86         arvados.LinkList
87 }
88
89 // Len returns the amount of items this list holds
90 func (l LinkList) Len() int {
91         return len(l.Items)
92 }
93
94 // GetItems returns the list of items
95 func (l LinkList) GetItems() (out []interface{}) {
96         for _, item := range l.Items {
97                 out = append(out, item)
98         }
99         return
100 }
101
102 func main() {
103         // Parse & validate arguments, set up arvados client.
104         cfg, err := GetConfig()
105         if err != nil {
106                 log.Fatalf("%v", err)
107         }
108
109         if err := doMain(&cfg); err != nil {
110                 log.Fatalf("%v", err)
111         }
112 }
113
114 // ConfigParams holds configuration data for this tool
115 type ConfigParams struct {
116         Path            string
117         UserID          string
118         Verbose         bool
119         ParentGroupUUID string
120         ParentGroupName string
121         SysUserUUID     string
122         Client          *arvados.Client
123 }
124
125 // ParseFlags parses and validates command line arguments
126 func ParseFlags(config *ConfigParams) error {
127         // Acceptable attributes to identify a user on the CSV file
128         userIDOpts := map[string]bool{
129                 "email":    true, // default
130                 "username": true,
131         }
132
133         flags := flag.NewFlagSet(os.Args[0], flag.ExitOnError)
134
135         // Set up usage message
136         flags.Usage = func() {
137                 usageStr := `Synchronize remote groups into Arvados from a CSV format file with 2 columns:
138   * 1st column: Group name
139   * 2nd column: User identifier`
140                 fmt.Fprintf(os.Stderr, "%s\n\n", usageStr)
141                 fmt.Fprintf(os.Stderr, "Usage:\n%s [OPTIONS] <input-file.csv>\n\n", os.Args[0])
142                 fmt.Fprintf(os.Stderr, "Options:\n")
143                 flags.PrintDefaults()
144         }
145
146         // Set up option flags
147         userID := flags.String(
148                 "user-id",
149                 "email",
150                 "Attribute by which every user is identified. Valid values are: email and username.")
151         verbose := flags.Bool(
152                 "verbose",
153                 false,
154                 "Log informational messages. Off by default.")
155         getVersion := flags.Bool(
156                 "version",
157                 false,
158                 "Print version information and exit.")
159         parentGroupUUID := flags.String(
160                 "parent-group-uuid",
161                 "",
162                 "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).")
163
164         // Parse args; omit the first arg which is the command name
165         flags.Parse(os.Args[1:])
166
167         // Print version information if requested
168         if *getVersion {
169                 fmt.Printf("%s %s\n", os.Args[0], version)
170                 os.Exit(0)
171         }
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("%s %s started. Using %q as users id and parent group UUID %q", os.Args[0], version, 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(
351         cfg *ConfigParams,
352         f *os.File,
353         userIDToUUID map[string]string,
354         groupNameToUUID map[string]string,
355         remoteGroups map[string]*GroupInfo,
356         allUsers map[string]arvados.User,
357 ) (groupsCreated, membersAdded, membersSkipped int, err error) {
358         lineNo := 0
359         csvReader := csv.NewReader(f)
360         csvReader.FieldsPerRecord = 2
361         for {
362                 record, e := csvReader.Read()
363                 if e == io.EOF {
364                         break
365                 }
366                 lineNo++
367                 if e != nil {
368                         err = fmt.Errorf("error parsing %q, line %d", cfg.Path, lineNo)
369                         return
370                 }
371                 groupName := strings.TrimSpace(record[0])
372                 groupMember := strings.TrimSpace(record[1]) // User ID (username or email)
373                 if groupName == "" || groupMember == "" {
374                         log.Printf("Warning: CSV record has at least one empty field (%s, %s). Skipping", groupName, groupMember)
375                         membersSkipped++
376                         continue
377                 }
378                 if _, found := userIDToUUID[groupMember]; !found {
379                         // User not present on the system, skip.
380                         log.Printf("Warning: there's no user with %s %q on the system, skipping.", cfg.UserID, groupMember)
381                         membersSkipped++
382                         continue
383                 }
384                 if _, found := groupNameToUUID[groupName]; !found {
385                         // Group doesn't exist, create it before continuing
386                         if cfg.Verbose {
387                                 log.Printf("Remote group %q not found, creating...", groupName)
388                         }
389                         var newGroup arvados.Group
390                         groupData := map[string]string{
391                                 "name":        groupName,
392                                 "owner_uuid":  cfg.ParentGroupUUID,
393                                 "group_class": "role",
394                         }
395                         if e := CreateGroup(cfg, &newGroup, groupData); e != nil {
396                                 err = fmt.Errorf("error creating group named %q: %s", groupName, err)
397                                 return
398                         }
399                         // Update cached group data
400                         groupNameToUUID[groupName] = newGroup.UUID
401                         remoteGroups[newGroup.UUID] = &GroupInfo{
402                                 Group:           newGroup,
403                                 PreviousMembers: make(map[string]bool), // Empty set
404                                 CurrentMembers:  make(map[string]bool), // Empty set
405                         }
406                         groupsCreated++
407                 }
408                 // Both group & user exist, check if user is a member
409                 groupUUID := groupNameToUUID[groupName]
410                 gi := remoteGroups[groupUUID]
411                 if !gi.PreviousMembers[groupMember] && !gi.CurrentMembers[groupMember] {
412                         if cfg.Verbose {
413                                 log.Printf("Adding %q to group %q", groupMember, groupName)
414                         }
415                         // User wasn't a member, but should be.
416                         if e := AddMemberToGroup(cfg, allUsers[userIDToUUID[groupMember]], gi.Group); e != nil {
417                                 err = e
418                                 return
419                         }
420                         membersAdded++
421                 }
422                 gi.CurrentMembers[groupMember] = true
423         }
424         return
425 }
426
427 // GetAll : Adds all objects of type 'resource' to the 'allItems' list
428 func GetAll(c *arvados.Client, res string, params arvados.ResourceListParams, page resourceList) (allItems []interface{}, err error) {
429         // Use the maximum page size the server allows
430         limit := 1<<31 - 1
431         params.Limit = &limit
432         params.Offset = 0
433         params.Order = "uuid"
434         for {
435                 if err = GetResourceList(c, &page, res, params); err != nil {
436                         return allItems, err
437                 }
438                 // Have we finished paging?
439                 if page.Len() == 0 {
440                         break
441                 }
442                 for _, i := range page.GetItems() {
443                         allItems = append(allItems, i)
444                 }
445                 params.Offset += page.Len()
446         }
447         return allItems, nil
448 }
449
450 func subtract(setA map[string]bool, setB map[string]bool) map[string]bool {
451         result := make(map[string]bool)
452         for element := range setA {
453                 if !setB[element] {
454                         result[element] = true
455                 }
456         }
457         return result
458 }
459
460 func jsonReader(rscName string, ob interface{}) io.Reader {
461         j, err := json.Marshal(ob)
462         if err != nil {
463                 panic(err)
464         }
465         v := url.Values{}
466         v[rscName] = []string{string(j)}
467         return bytes.NewBufferString(v.Encode())
468 }
469
470 // GetRemoteGroups fetches all remote groups with their members
471 func GetRemoteGroups(cfg *ConfigParams, allUsers map[string]arvados.User) (remoteGroups map[string]*GroupInfo, groupNameToUUID map[string]string, err error) {
472         remoteGroups = make(map[string]*GroupInfo)
473         groupNameToUUID = make(map[string]string) // Index by group name
474
475         params := arvados.ResourceListParams{
476                 Filters: []arvados.Filter{{
477                         Attr:     "owner_uuid",
478                         Operator: "=",
479                         Operand:  cfg.ParentGroupUUID,
480                 }},
481         }
482         results, err := GetAll(cfg.Client, "groups", params, &GroupList{})
483         if err != nil {
484                 return remoteGroups, groupNameToUUID, fmt.Errorf("error getting remote groups: %s", err)
485         }
486         for _, item := range results {
487                 group := item.(arvados.Group)
488                 // Group -> User filter
489                 g2uFilter := arvados.ResourceListParams{
490                         Filters: []arvados.Filter{{
491                                 Attr:     "owner_uuid",
492                                 Operator: "=",
493                                 Operand:  cfg.SysUserUUID,
494                         }, {
495                                 Attr:     "link_class",
496                                 Operator: "=",
497                                 Operand:  "permission",
498                         }, {
499                                 Attr:     "name",
500                                 Operator: "=",
501                                 Operand:  "can_read",
502                         }, {
503                                 Attr:     "tail_uuid",
504                                 Operator: "=",
505                                 Operand:  group.UUID,
506                         }, {
507                                 Attr:     "head_kind",
508                                 Operator: "=",
509                                 Operand:  "arvados#user",
510                         }},
511                 }
512                 // User -> Group filter
513                 u2gFilter := arvados.ResourceListParams{
514                         Filters: []arvados.Filter{{
515                                 Attr:     "owner_uuid",
516                                 Operator: "=",
517                                 Operand:  cfg.SysUserUUID,
518                         }, {
519                                 Attr:     "link_class",
520                                 Operator: "=",
521                                 Operand:  "permission",
522                         }, {
523                                 Attr:     "name",
524                                 Operator: "=",
525                                 Operand:  "can_write",
526                         }, {
527                                 Attr:     "head_uuid",
528                                 Operator: "=",
529                                 Operand:  group.UUID,
530                         }, {
531                                 Attr:     "tail_kind",
532                                 Operator: "=",
533                                 Operand:  "arvados#user",
534                         }},
535                 }
536                 g2uLinks, err := GetAll(cfg.Client, "links", g2uFilter, &LinkList{})
537                 if err != nil {
538                         return remoteGroups, groupNameToUUID, fmt.Errorf("error getting member (can_read) links for group %q: %s", group.Name, err)
539                 }
540                 u2gLinks, err := GetAll(cfg.Client, "links", u2gFilter, &LinkList{})
541                 if err != nil {
542                         return remoteGroups, groupNameToUUID, fmt.Errorf("error getting member (can_write) links for group %q: %s", group.Name, err)
543                 }
544                 // Build a list of user ids (email or username) belonging to this group
545                 membersSet := make(map[string]bool)
546                 u2gLinkSet := make(map[string]bool)
547                 for _, l := range u2gLinks {
548                         linkedMemberUUID := l.(arvados.Link).TailUUID
549                         u2gLinkSet[linkedMemberUUID] = true
550                 }
551                 for _, item := range g2uLinks {
552                         link := item.(arvados.Link)
553                         // We may have received an old link pointing to a removed account.
554                         if _, found := allUsers[link.HeadUUID]; !found {
555                                 continue
556                         }
557                         // The matching User -> Group link may not exist if the link
558                         // creation failed on a previous run. If that's the case, don't
559                         // include this account on the "previous members" list.
560                         if _, found := u2gLinkSet[link.HeadUUID]; !found {
561                                 continue
562                         }
563                         memberID, err := GetUserID(allUsers[link.HeadUUID], cfg.UserID)
564                         if err != nil {
565                                 return remoteGroups, groupNameToUUID, err
566                         }
567                         membersSet[memberID] = true
568                 }
569                 remoteGroups[group.UUID] = &GroupInfo{
570                         Group:           group,
571                         PreviousMembers: membersSet,
572                         CurrentMembers:  make(map[string]bool), // Empty set
573                 }
574                 groupNameToUUID[group.Name] = group.UUID
575         }
576         return remoteGroups, groupNameToUUID, nil
577 }
578
579 // RemoveMemberFromGroup remove all links related to the membership
580 func RemoveMemberFromGroup(cfg *ConfigParams, user arvados.User, group arvados.Group) error {
581         if cfg.Verbose {
582                 log.Printf("Getting group membership links for user %q (%s) on group %q (%s)", user.Email, user.UUID, group.Name, group.UUID)
583         }
584         var links []interface{}
585         // Search for all group<->user links (both ways)
586         for _, filterset := range [][]arvados.Filter{
587                 // Group -> User
588                 {{
589                         Attr:     "link_class",
590                         Operator: "=",
591                         Operand:  "permission",
592                 }, {
593                         Attr:     "tail_uuid",
594                         Operator: "=",
595                         Operand:  group.UUID,
596                 }, {
597                         Attr:     "head_uuid",
598                         Operator: "=",
599                         Operand:  user.UUID,
600                 }},
601                 // Group <- User
602                 {{
603                         Attr:     "link_class",
604                         Operator: "=",
605                         Operand:  "permission",
606                 }, {
607                         Attr:     "tail_uuid",
608                         Operator: "=",
609                         Operand:  user.UUID,
610                 }, {
611                         Attr:     "head_uuid",
612                         Operator: "=",
613                         Operand:  group.UUID,
614                 }},
615         } {
616                 l, err := GetAll(cfg.Client, "links", arvados.ResourceListParams{Filters: filterset}, &LinkList{})
617                 if err != nil {
618                         userID, _ := GetUserID(user, cfg.UserID)
619                         return fmt.Errorf("error getting links needed to remove user %q from group %q: %s", userID, group.Name, err)
620                 }
621                 for _, link := range l {
622                         links = append(links, link)
623                 }
624         }
625         for _, item := range links {
626                 link := item.(arvados.Link)
627                 userID, _ := GetUserID(user, cfg.UserID)
628                 if cfg.Verbose {
629                         log.Printf("Removing %q permission link for %q on group %q", link.Name, userID, group.Name)
630                 }
631                 if err := DeleteLink(cfg, link.UUID); err != nil {
632                         return fmt.Errorf("error removing user %q from group %q: %s", userID, group.Name, err)
633                 }
634         }
635         return nil
636 }
637
638 // AddMemberToGroup create membership links
639 func AddMemberToGroup(cfg *ConfigParams, user arvados.User, group arvados.Group) error {
640         var newLink arvados.Link
641         linkData := map[string]string{
642                 "owner_uuid": cfg.SysUserUUID,
643                 "link_class": "permission",
644                 "name":       "can_read",
645                 "tail_uuid":  group.UUID,
646                 "head_uuid":  user.UUID,
647         }
648         if err := CreateLink(cfg, &newLink, linkData); err != nil {
649                 userID, _ := GetUserID(user, cfg.UserID)
650                 return fmt.Errorf("error adding group %q -> user %q read permission: %s", group.Name, userID, err)
651         }
652         linkData = map[string]string{
653                 "owner_uuid": cfg.SysUserUUID,
654                 "link_class": "permission",
655                 "name":       "can_write",
656                 "tail_uuid":  user.UUID,
657                 "head_uuid":  group.UUID,
658         }
659         if err := CreateLink(cfg, &newLink, linkData); err != nil {
660                 userID, _ := GetUserID(user, cfg.UserID)
661                 return fmt.Errorf("error adding user %q -> group %q write permission: %s", userID, group.Name, err)
662         }
663         return nil
664 }
665
666 // CreateGroup creates a group with groupData parameters, assigns it to dst
667 func CreateGroup(cfg *ConfigParams, dst *arvados.Group, groupData map[string]string) error {
668         return cfg.Client.RequestAndDecode(dst, "POST", "/arvados/v1/groups", jsonReader("group", groupData), nil)
669 }
670
671 // GetGroup fetches a group by its UUID
672 func GetGroup(cfg *ConfigParams, dst *arvados.Group, groupUUID string) error {
673         return cfg.Client.RequestAndDecode(&dst, "GET", "/arvados/v1/groups/"+groupUUID, nil, nil)
674 }
675
676 // CreateLink creates a link with linkData parameters, assigns it to dst
677 func CreateLink(cfg *ConfigParams, dst *arvados.Link, linkData map[string]string) error {
678         return cfg.Client.RequestAndDecode(dst, "POST", "/arvados/v1/links", jsonReader("link", linkData), nil)
679 }
680
681 // DeleteLink deletes a link by its UUID
682 func DeleteLink(cfg *ConfigParams, linkUUID string) error {
683         if linkUUID == "" {
684                 return fmt.Errorf("cannot delete link with invalid UUID: %q", linkUUID)
685         }
686         return cfg.Client.RequestAndDecode(&arvados.Link{}, "DELETE", "/arvados/v1/links/"+linkUUID, nil, nil)
687 }
688
689 // GetResourceList fetches res list using params
690 func GetResourceList(c *arvados.Client, dst *resourceList, res string, params interface{}) error {
691         return c.RequestAndDecode(dst, "GET", "/arvados/v1/"+res, nil, params)
692 }