13111: Merge branch 'master' into 12308-go-fuse
[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 does not exist, create it.
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.UUID)
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         if cfg.Verbose {
321                 for groupUUID := range remoteGroups {
322                         log.Printf("- Group %q: %d users", remoteGroups[groupUUID].Group.Name, len(remoteGroups[groupUUID].PreviousMembers))
323                 }
324         }
325
326         membershipsRemoved := 0
327
328         // Read the CSV file
329         groupsCreated, membershipsAdded, membershipsSkipped, err := ProcessFile(cfg, f, userIDToUUID, groupNameToUUID, remoteGroups, allUsers)
330         if err != nil {
331                 return err
332         }
333
334         // Remove previous members not listed on this run
335         for groupUUID := range remoteGroups {
336                 gi := remoteGroups[groupUUID]
337                 evictedMembers := subtract(gi.PreviousMembers, gi.CurrentMembers)
338                 groupName := gi.Group.Name
339                 if len(evictedMembers) > 0 {
340                         log.Printf("Removing %d users from group %q", len(evictedMembers), groupName)
341                 }
342                 for evictedUser := range evictedMembers {
343                         if err := RemoveMemberFromGroup(cfg, allUsers[userIDToUUID[evictedUser]], gi.Group); err != nil {
344                                 return err
345                         }
346                         membershipsRemoved++
347                 }
348         }
349         log.Printf("Groups created: %d. Memberships added: %d, removed: %d, skipped: %d", groupsCreated, membershipsAdded, membershipsRemoved, membershipsSkipped)
350
351         return nil
352 }
353
354 // ProcessFile reads the CSV file and process every record
355 func ProcessFile(
356         cfg *ConfigParams,
357         f *os.File,
358         userIDToUUID map[string]string,
359         groupNameToUUID map[string]string,
360         remoteGroups map[string]*GroupInfo,
361         allUsers map[string]arvados.User,
362 ) (groupsCreated, membersAdded, membersSkipped int, err error) {
363         lineNo := 0
364         csvReader := csv.NewReader(f)
365         csvReader.FieldsPerRecord = 2
366         for {
367                 record, e := csvReader.Read()
368                 if e == io.EOF {
369                         break
370                 }
371                 lineNo++
372                 if e != nil {
373                         err = fmt.Errorf("error parsing %q, line %d", cfg.Path, lineNo)
374                         return
375                 }
376                 groupName := strings.TrimSpace(record[0])
377                 groupMember := strings.TrimSpace(record[1]) // User ID (username or email)
378                 if groupName == "" || groupMember == "" {
379                         log.Printf("Warning: CSV record has at least one empty field (%s, %s). Skipping", groupName, groupMember)
380                         membersSkipped++
381                         continue
382                 }
383                 if _, found := userIDToUUID[groupMember]; !found {
384                         // User not present on the system, skip.
385                         log.Printf("Warning: there's no user with %s %q on the system, skipping.", cfg.UserID, groupMember)
386                         membersSkipped++
387                         continue
388                 }
389                 if _, found := groupNameToUUID[groupName]; !found {
390                         // Group doesn't exist, create it before continuing
391                         if cfg.Verbose {
392                                 log.Printf("Remote group %q not found, creating...", groupName)
393                         }
394                         var newGroup arvados.Group
395                         groupData := map[string]string{
396                                 "name":        groupName,
397                                 "owner_uuid":  cfg.ParentGroupUUID,
398                                 "group_class": "role",
399                         }
400                         if e := CreateGroup(cfg, &newGroup, groupData); e != nil {
401                                 err = fmt.Errorf("error creating group named %q: %s", groupName, err)
402                                 return
403                         }
404                         // Update cached group data
405                         groupNameToUUID[groupName] = newGroup.UUID
406                         remoteGroups[newGroup.UUID] = &GroupInfo{
407                                 Group:           newGroup,
408                                 PreviousMembers: make(map[string]bool), // Empty set
409                                 CurrentMembers:  make(map[string]bool), // Empty set
410                         }
411                         groupsCreated++
412                 }
413                 // Both group & user exist, check if user is a member
414                 groupUUID := groupNameToUUID[groupName]
415                 gi := remoteGroups[groupUUID]
416                 if !gi.PreviousMembers[groupMember] && !gi.CurrentMembers[groupMember] {
417                         if cfg.Verbose {
418                                 log.Printf("Adding %q to group %q", groupMember, groupName)
419                         }
420                         // User wasn't a member, but should be.
421                         if e := AddMemberToGroup(cfg, allUsers[userIDToUUID[groupMember]], gi.Group); e != nil {
422                                 err = e
423                                 return
424                         }
425                         membersAdded++
426                 }
427                 gi.CurrentMembers[groupMember] = true
428         }
429         return
430 }
431
432 // GetAll : Adds all objects of type 'resource' to the 'allItems' list
433 func GetAll(c *arvados.Client, res string, params arvados.ResourceListParams, page resourceList) (allItems []interface{}, err error) {
434         // Use the maximum page size the server allows
435         limit := 1<<31 - 1
436         params.Limit = &limit
437         params.Offset = 0
438         params.Order = "uuid"
439         for {
440                 if err = GetResourceList(c, &page, res, params); err != nil {
441                         return allItems, err
442                 }
443                 // Have we finished paging?
444                 if page.Len() == 0 {
445                         break
446                 }
447                 for _, i := range page.GetItems() {
448                         allItems = append(allItems, i)
449                 }
450                 params.Offset += page.Len()
451         }
452         return allItems, nil
453 }
454
455 func subtract(setA map[string]bool, setB map[string]bool) map[string]bool {
456         result := make(map[string]bool)
457         for element := range setA {
458                 if !setB[element] {
459                         result[element] = true
460                 }
461         }
462         return result
463 }
464
465 func jsonReader(rscName string, ob interface{}) io.Reader {
466         j, err := json.Marshal(ob)
467         if err != nil {
468                 panic(err)
469         }
470         v := url.Values{}
471         v[rscName] = []string{string(j)}
472         return bytes.NewBufferString(v.Encode())
473 }
474
475 // GetRemoteGroups fetches all remote groups with their members
476 func GetRemoteGroups(cfg *ConfigParams, allUsers map[string]arvados.User) (remoteGroups map[string]*GroupInfo, groupNameToUUID map[string]string, err error) {
477         remoteGroups = make(map[string]*GroupInfo)
478         groupNameToUUID = make(map[string]string) // Index by group name
479
480         params := arvados.ResourceListParams{
481                 Filters: []arvados.Filter{{
482                         Attr:     "owner_uuid",
483                         Operator: "=",
484                         Operand:  cfg.ParentGroupUUID,
485                 }},
486         }
487         results, err := GetAll(cfg.Client, "groups", params, &GroupList{})
488         if err != nil {
489                 return remoteGroups, groupNameToUUID, fmt.Errorf("error getting remote groups: %s", err)
490         }
491         for _, item := range results {
492                 group := item.(arvados.Group)
493                 // Group -> User filter
494                 g2uFilter := arvados.ResourceListParams{
495                         Filters: []arvados.Filter{{
496                                 Attr:     "owner_uuid",
497                                 Operator: "=",
498                                 Operand:  cfg.SysUserUUID,
499                         }, {
500                                 Attr:     "link_class",
501                                 Operator: "=",
502                                 Operand:  "permission",
503                         }, {
504                                 Attr:     "name",
505                                 Operator: "=",
506                                 Operand:  "can_read",
507                         }, {
508                                 Attr:     "tail_uuid",
509                                 Operator: "=",
510                                 Operand:  group.UUID,
511                         }, {
512                                 Attr:     "head_uuid",
513                                 Operator: "like",
514                                 Operand:  "%-tpzed-%",
515                         }},
516                 }
517                 // User -> Group filter
518                 u2gFilter := arvados.ResourceListParams{
519                         Filters: []arvados.Filter{{
520                                 Attr:     "owner_uuid",
521                                 Operator: "=",
522                                 Operand:  cfg.SysUserUUID,
523                         }, {
524                                 Attr:     "link_class",
525                                 Operator: "=",
526                                 Operand:  "permission",
527                         }, {
528                                 Attr:     "name",
529                                 Operator: "=",
530                                 Operand:  "can_write",
531                         }, {
532                                 Attr:     "head_uuid",
533                                 Operator: "=",
534                                 Operand:  group.UUID,
535                         }, {
536                                 Attr:     "tail_uuid",
537                                 Operator: "like",
538                                 Operand:  "%-tpzed-%",
539                         }},
540                 }
541                 g2uLinks, err := GetAll(cfg.Client, "links", g2uFilter, &LinkList{})
542                 if err != nil {
543                         return remoteGroups, groupNameToUUID, fmt.Errorf("error getting member (can_read) links for group %q: %s", group.Name, err)
544                 }
545                 u2gLinks, err := GetAll(cfg.Client, "links", u2gFilter, &LinkList{})
546                 if err != nil {
547                         return remoteGroups, groupNameToUUID, fmt.Errorf("error getting member (can_write) links for group %q: %s", group.Name, err)
548                 }
549                 // Build a list of user ids (email or username) belonging to this group
550                 membersSet := make(map[string]bool)
551                 u2gLinkSet := make(map[string]bool)
552                 for _, l := range u2gLinks {
553                         linkedMemberUUID := l.(arvados.Link).TailUUID
554                         u2gLinkSet[linkedMemberUUID] = true
555                 }
556                 for _, item := range g2uLinks {
557                         link := item.(arvados.Link)
558                         // We may have received an old link pointing to a removed account.
559                         if _, found := allUsers[link.HeadUUID]; !found {
560                                 continue
561                         }
562                         // The matching User -> Group link may not exist if the link
563                         // creation failed on a previous run. If that's the case, don't
564                         // include this account on the "previous members" list.
565                         if _, found := u2gLinkSet[link.HeadUUID]; !found {
566                                 continue
567                         }
568                         memberID, err := GetUserID(allUsers[link.HeadUUID], cfg.UserID)
569                         if err != nil {
570                                 return remoteGroups, groupNameToUUID, err
571                         }
572                         membersSet[memberID] = true
573                 }
574                 remoteGroups[group.UUID] = &GroupInfo{
575                         Group:           group,
576                         PreviousMembers: membersSet,
577                         CurrentMembers:  make(map[string]bool), // Empty set
578                 }
579                 groupNameToUUID[group.Name] = group.UUID
580         }
581         return remoteGroups, groupNameToUUID, nil
582 }
583
584 // RemoveMemberFromGroup remove all links related to the membership
585 func RemoveMemberFromGroup(cfg *ConfigParams, user arvados.User, group arvados.Group) error {
586         if cfg.Verbose {
587                 log.Printf("Getting group membership links for user %q (%s) on group %q (%s)", user.Username, user.UUID, group.Name, group.UUID)
588         }
589         var links []interface{}
590         // Search for all group<->user links (both ways)
591         for _, filterset := range [][]arvados.Filter{
592                 // Group -> User
593                 {{
594                         Attr:     "link_class",
595                         Operator: "=",
596                         Operand:  "permission",
597                 }, {
598                         Attr:     "tail_uuid",
599                         Operator: "=",
600                         Operand:  group.UUID,
601                 }, {
602                         Attr:     "head_uuid",
603                         Operator: "=",
604                         Operand:  user.UUID,
605                 }},
606                 // Group <- User
607                 {{
608                         Attr:     "link_class",
609                         Operator: "=",
610                         Operand:  "permission",
611                 }, {
612                         Attr:     "tail_uuid",
613                         Operator: "=",
614                         Operand:  user.UUID,
615                 }, {
616                         Attr:     "head_uuid",
617                         Operator: "=",
618                         Operand:  group.UUID,
619                 }},
620         } {
621                 l, err := GetAll(cfg.Client, "links", arvados.ResourceListParams{Filters: filterset}, &LinkList{})
622                 if err != nil {
623                         userID, _ := GetUserID(user, cfg.UserID)
624                         return fmt.Errorf("error getting links needed to remove user %q from group %q: %s", userID, group.Name, err)
625                 }
626                 for _, link := range l {
627                         links = append(links, link)
628                 }
629         }
630         for _, item := range links {
631                 link := item.(arvados.Link)
632                 userID, _ := GetUserID(user, cfg.UserID)
633                 if cfg.Verbose {
634                         log.Printf("Removing %q permission link for %q on group %q", link.Name, userID, group.Name)
635                 }
636                 if err := DeleteLink(cfg, link.UUID); err != nil {
637                         return fmt.Errorf("error removing user %q from group %q: %s", userID, group.Name, err)
638                 }
639         }
640         return nil
641 }
642
643 // AddMemberToGroup create membership links
644 func AddMemberToGroup(cfg *ConfigParams, user arvados.User, group arvados.Group) error {
645         var newLink arvados.Link
646         linkData := map[string]string{
647                 "owner_uuid": cfg.SysUserUUID,
648                 "link_class": "permission",
649                 "name":       "can_read",
650                 "tail_uuid":  group.UUID,
651                 "head_uuid":  user.UUID,
652         }
653         if err := CreateLink(cfg, &newLink, linkData); err != nil {
654                 userID, _ := GetUserID(user, cfg.UserID)
655                 return fmt.Errorf("error adding group %q -> user %q read permission: %s", group.Name, userID, err)
656         }
657         linkData = map[string]string{
658                 "owner_uuid": cfg.SysUserUUID,
659                 "link_class": "permission",
660                 "name":       "can_write",
661                 "tail_uuid":  user.UUID,
662                 "head_uuid":  group.UUID,
663         }
664         if err := CreateLink(cfg, &newLink, linkData); err != nil {
665                 userID, _ := GetUserID(user, cfg.UserID)
666                 return fmt.Errorf("error adding user %q -> group %q write permission: %s", userID, group.Name, err)
667         }
668         return nil
669 }
670
671 // CreateGroup creates a group with groupData parameters, assigns it to dst
672 func CreateGroup(cfg *ConfigParams, dst *arvados.Group, groupData map[string]string) error {
673         return cfg.Client.RequestAndDecode(dst, "POST", "/arvados/v1/groups", jsonReader("group", groupData), nil)
674 }
675
676 // GetGroup fetches a group by its UUID
677 func GetGroup(cfg *ConfigParams, dst *arvados.Group, groupUUID string) error {
678         return cfg.Client.RequestAndDecode(&dst, "GET", "/arvados/v1/groups/"+groupUUID, nil, nil)
679 }
680
681 // CreateLink creates a link with linkData parameters, assigns it to dst
682 func CreateLink(cfg *ConfigParams, dst *arvados.Link, linkData map[string]string) error {
683         return cfg.Client.RequestAndDecode(dst, "POST", "/arvados/v1/links", jsonReader("link", linkData), nil)
684 }
685
686 // DeleteLink deletes a link by its UUID
687 func DeleteLink(cfg *ConfigParams, linkUUID string) error {
688         if linkUUID == "" {
689                 return fmt.Errorf("cannot delete link with invalid UUID: %q", linkUUID)
690         }
691         return cfg.Client.RequestAndDecode(&arvados.Link{}, "DELETE", "/arvados/v1/links/"+linkUUID, nil, nil)
692 }
693
694 // GetResourceList fetches res list using params
695 func GetResourceList(c *arvados.Client, dst *resourceList, res string, params interface{}) error {
696         return c.RequestAndDecode(dst, "GET", "/arvados/v1/"+res, nil, params)
697 }