12018: Skip CSV register if one of its fields is empty
[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         "encoding/csv"
9         "flag"
10         "fmt"
11         "io"
12         "log"
13         "os"
14         "strings"
15
16         "git.curoverse.com/arvados.git/sdk/go/arvados"
17         "git.curoverse.com/arvados.git/sdk/go/arvadosclient"
18 )
19
20 type resourceList interface {
21         items() []interface{}
22         itemsAvailable() int
23         offset() int
24 }
25
26 type groupInfo struct {
27         Group           group
28         PreviousMembers map[string]bool
29         CurrentMembers  map[string]bool
30 }
31
32 type user struct {
33         UUID     string `json:"uuid,omitempty"`
34         Email    string `json:"email,omitempty"`
35         Username string `json:"username,omitempty"`
36 }
37
38 func (u user) GetID(idSelector string) (string, error) {
39         switch idSelector {
40         case "email":
41                 return u.Email, nil
42         case "username":
43                 return u.Username, nil
44         default:
45                 return "", fmt.Errorf("cannot identify user by %q selector", idSelector)
46         }
47 }
48
49 // userList implements resourceList interface
50 type userList struct {
51         Items          []user `json:"items"`
52         ItemsAvailable int    `json:"items_available"`
53         Offset         int    `json:"offset"`
54 }
55
56 func (l userList) items() []interface{} {
57         var out []interface{}
58         for _, item := range l.Items {
59                 out = append(out, item)
60         }
61         return out
62 }
63
64 func (l userList) itemsAvailable() int {
65         return l.ItemsAvailable
66 }
67
68 func (l userList) offset() int {
69         return l.Offset
70 }
71
72 type group struct {
73         UUID      string `json:"uuid,omitempty"`
74         Name      string `json:"name,omitempty"`
75         OwnerUUID string `json:"owner_uuid,omitempty"`
76 }
77
78 // groupList implements resourceList interface
79 type groupList struct {
80         Items          []group `json:"items"`
81         ItemsAvailable int     `json:"items_available"`
82         Offset         int     `json:"offset"`
83 }
84
85 func (l groupList) items() []interface{} {
86         var out []interface{}
87         for _, item := range l.Items {
88                 out = append(out, item)
89         }
90         return out
91 }
92
93 func (l groupList) itemsAvailable() int {
94         return l.ItemsAvailable
95 }
96
97 func (l groupList) offset() int {
98         return l.Offset
99 }
100
101 type link struct {
102         UUID      string `json:"uuid, omiempty"`
103         Name      string `json:"name,omitempty"`
104         LinkClass string `json:"link_class,omitempty"`
105         HeadUUID  string `json:"head_uuid,omitempty"`
106         HeadKind  string `json:"head_kind,omitempty"`
107         TailUUID  string `json:"tail_uuid,omitempty"`
108         TailKind  string `json:"tail_kind,omitempty"`
109 }
110
111 // linkList implements resourceList interface
112 type linkList struct {
113         Items          []link `json:"items"`
114         ItemsAvailable int    `json:"items_available"`
115         Offset         int    `json:"offset"`
116 }
117
118 func (l linkList) items() []interface{} {
119         var out []interface{}
120         for _, item := range l.Items {
121                 out = append(out, item)
122         }
123         return out
124 }
125
126 func (l linkList) itemsAvailable() int {
127         return l.ItemsAvailable
128 }
129
130 func (l linkList) offset() int {
131         return l.Offset
132 }
133
134 func main() {
135         err := doMain()
136         if err != nil {
137                 log.Fatalf("%v", err)
138         }
139 }
140
141 func doMain() error {
142         const groupTag string = "remote_group"
143         const remoteGroupParentName string = "Externally synchronized groups"
144         userIDOpts := []string{"email", "username"}
145
146         flags := flag.NewFlagSet("arv-sync-groups", flag.ExitOnError)
147
148         srcPath := flags.String(
149                 "path",
150                 "",
151                 "Local file path containing a CSV format.")
152
153         userID := flags.String(
154                 "user-id",
155                 "email",
156                 "Attribute by which every user is identified. "+
157                         "Valid values are: email (the default) and username.")
158
159         verbose := flags.Bool(
160                 "verbose",
161                 false,
162                 "Log informational messages. By default is deactivated.")
163
164         retries := flags.Int(
165                 "retries",
166                 3,
167                 "Maximum number of times to retry server requests that encounter "+
168                         "temporary failures (e.g., server down).  Default 3.")
169
170         parentGroupUUID := flags.String(
171                 "parent-group-uuid",
172                 "",
173                 "Use given group UUID as a parent for the remote groups. Should "+
174                         "be owned by the system user. If not specified, a group named '"+
175                         remoteGroupParentName+"' will be used (and created if nonexistant).")
176
177         // Parse args; omit the first arg which is the command name
178         flags.Parse(os.Args[1:])
179
180         // Validations
181         if *retries < 0 {
182                 return fmt.Errorf("retry quantity must be >= 0")
183         }
184
185         if *srcPath == "" {
186                 return fmt.Errorf("please provide a path to an input file")
187         }
188
189         // Try opening the input file early, just in case there's problems.
190         f, err := os.Open(*srcPath)
191         if err != nil {
192                 return fmt.Errorf("%s", err)
193         }
194         defer f.Close()
195
196         validUserID := false
197         for _, opt := range userIDOpts {
198                 if *userID == opt {
199                         validUserID = true
200                 }
201         }
202         if !validUserID {
203                 return fmt.Errorf("user ID must be one of: %s",
204                         strings.Join(userIDOpts, ", "))
205         }
206
207         // Arvados Client setup
208         ac := arvados.NewClientFromEnv()
209         arv, err := arvadosclient.New(ac)
210         if err != nil {
211                 return fmt.Errorf("error setting up arvados client %s", err)
212         }
213         arv.Retries = *retries
214
215         // Check current user permissions & get System user's UUID
216         u, err := ac.CurrentUser()
217         if err != nil {
218                 return fmt.Errorf("error getting the current user: %s", err)
219         }
220         if !u.IsActive || !u.IsAdmin {
221                 return fmt.Errorf("current user (%s) is not an active admin user", u.UUID)
222         }
223         sysUserUUID := u.UUID[:12] + "000000000000000"
224
225         // Find/create parent group
226         var parentGroup group
227         if *parentGroupUUID == "" {
228                 // UUID not provided, search for preexisting parent group
229                 var gl groupList
230                 err := arv.List("groups", arvadosclient.Dict{
231                         "filters": [][]string{
232                                 {"name", "=", remoteGroupParentName},
233                                 {"owner_uuid", "=", sysUserUUID}},
234                 }, &gl)
235                 if err != nil {
236                         return fmt.Errorf("error searching for parent group: %s", err)
237                 }
238                 if len(gl.Items) == 0 {
239                         // Default parent group not existant, create one.
240                         if *verbose {
241                                 log.Println("Default parent group not found, creating...")
242                         }
243                         err := arv.Create("groups", arvadosclient.Dict{
244                                 "group": arvadosclient.Dict{
245                                         "name":       remoteGroupParentName,
246                                         "owner_uuid": sysUserUUID},
247                         }, &parentGroup)
248                         if err != nil {
249                                 return fmt.Errorf("error creating system user owned group named %q: %s", remoteGroupParentName, err)
250                         }
251                 } else if len(gl.Items) == 1 {
252                         // Default parent group found.
253                         parentGroup = gl.Items[0]
254                 } else {
255                         // This should never happen, as there's an unique index for
256                         // (owner_uuid, name) on groups.
257                         return fmt.Errorf("found %d groups owned by system user and named %q", len(gl.Items), remoteGroupParentName)
258                 }
259         } else {
260                 // UUID provided. Check if exists and if it's owned by system user
261                 err := arv.Get("groups", *parentGroupUUID, arvadosclient.Dict{}, &parentGroup)
262                 if err != nil {
263                         return fmt.Errorf("error searching for parent group with UUID %q: %s", *parentGroupUUID, err)
264                 }
265                 if parentGroup.OwnerUUID != sysUserUUID {
266                         return fmt.Errorf("parent group %q (%s) must be owned by system user", parentGroup.Name, *parentGroupUUID)
267                 }
268         }
269
270         log.Printf("Group sync starting. Using %q as users id and parent group UUID %q", *userID, parentGroup.UUID)
271
272         // Get the complete user list to minimize API Server requests
273         allUsers := make(map[string]user)
274         userIDToUUID := make(map[string]string) // Index by email or username
275         results, err := ListAll(arv, "users", arvadosclient.Dict{}, &userList{})
276         if err != nil {
277                 return fmt.Errorf("error getting user list: %s", err)
278         }
279         log.Printf("Found %d users", len(results))
280         for _, item := range results {
281                 u := item.(user)
282                 allUsers[u.UUID] = u
283                 uID, err := u.GetID(*userID)
284                 if err != nil {
285                         return err
286                 }
287                 userIDToUUID[uID] = u.UUID
288                 if *verbose {
289                         log.Printf("Seen user %q (%s)", u.Username, u.Email)
290                 }
291         }
292
293         // Request all UUIDs for groups tagged as remote
294         remoteGroupUUIDs := make(map[string]bool)
295         results, err = ListAll(arv, "links", arvadosclient.Dict{
296                 "filters": [][]string{
297                         {"link_class", "=", "tag"},
298                         {"name", "=", groupTag},
299                         {"head_kind", "=", "arvados#group"},
300                 },
301         }, &linkList{})
302         if err != nil {
303                 return fmt.Errorf("error getting remote group UUIDs: %s", err)
304         }
305         for _, item := range results {
306                 link := item.(link)
307                 remoteGroupUUIDs[link.HeadUUID] = true
308         }
309         // Get remote groups and their members
310         var uuidList []string
311         for uuid := range remoteGroupUUIDs {
312                 uuidList = append(uuidList, uuid)
313         }
314         remoteGroups := make(map[string]*groupInfo)
315         groupNameToUUID := make(map[string]string) // Index by group name
316         results, err = ListAll(arv, "groups", arvadosclient.Dict{
317                 "filters": [][]interface{}{
318                         {"uuid", "in", uuidList},
319                         {"owner_uuid", "=", parentGroup.UUID},
320                 },
321         }, &groupList{})
322         if err != nil {
323                 return fmt.Errorf("error getting remote groups by UUID: %s", err)
324         }
325         for _, item := range results {
326                 group := item.(group)
327                 results, err := ListAll(arv, "links", arvadosclient.Dict{
328                         "filters": [][]string{
329                                 {"link_class", "=", "permission"},
330                                 {"name", "=", "can_read"},
331                                 {"tail_uuid", "=", group.UUID},
332                                 {"head_kind", "=", "arvados#user"},
333                         },
334                 }, &linkList{})
335                 if err != nil {
336                         return fmt.Errorf("error getting member links: %s", err)
337                 }
338                 // Build a list of user ids (email or username) belonging to this group
339                 membersSet := make(map[string]bool)
340                 for _, item := range results {
341                         link := item.(link)
342                         memberID, err := allUsers[link.HeadUUID].GetID(*userID)
343                         if err != nil {
344                                 return err
345                         }
346                         membersSet[memberID] = true
347                 }
348                 remoteGroups[group.UUID] = &groupInfo{
349                         Group:           group,
350                         PreviousMembers: membersSet,
351                         CurrentMembers:  make(map[string]bool), // Empty set
352                 }
353                 groupNameToUUID[group.Name] = group.UUID
354         }
355         log.Printf("Found %d remote groups", len(remoteGroups))
356
357         groupsCreated := 0
358         membersAdded := 0
359         membersRemoved := 0
360         membersSkipped := 0
361
362         csvReader := csv.NewReader(f)
363         for {
364                 record, err := csvReader.Read()
365                 if err == io.EOF {
366                         break
367                 }
368                 if err != nil {
369                         return fmt.Errorf("error reading %q: %s", *srcPath, err)
370                 }
371                 groupName := record[0]
372                 groupMember := record[1] // User ID (username or email)
373                 if groupName == "" || groupMember == "" {
374                         log.Printf("Warning: CSV record has at least one field empty (%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.", *userID, groupMember)
381                         membersSkipped++
382                         continue
383                 }
384                 if _, found := groupNameToUUID[groupName]; !found {
385                         // Group doesn't exist, create and tag it before continuing
386                         if *verbose {
387                                 log.Printf("Remote group %q not found, creating...", groupName)
388                         }
389                         var group group
390                         err := arv.Create("groups", arvadosclient.Dict{
391                                 "group": arvadosclient.Dict{
392                                         "name":       groupName,
393                                         "owner_uuid": parentGroup.UUID,
394                                 },
395                         }, &group)
396                         if err != nil {
397                                 return fmt.Errorf("error creating group named %q: %s",
398                                         groupName, err)
399                         }
400                         link := make(map[string]interface{})
401                         err = arv.Create("links", arvadosclient.Dict{
402                                 "link": arvadosclient.Dict{
403                                         "link_class": "tag",
404                                         "name":       groupTag,
405                                         "head_uuid":  group.UUID,
406                                 },
407                         }, &link)
408                         if err != nil {
409                                 return fmt.Errorf("error creating tag for group %q: %s",
410                                         groupName, err)
411                         }
412                         // Update cached group data
413                         groupNameToUUID[groupName] = group.UUID
414                         remoteGroups[group.UUID] = &groupInfo{
415                                 Group:           group,
416                                 PreviousMembers: make(map[string]bool), // Empty set
417                                 CurrentMembers:  make(map[string]bool), // Empty set
418                         }
419                         groupsCreated++
420                 }
421                 // Both group & user exist, check if user is a member
422                 groupUUID := groupNameToUUID[groupName]
423                 gi := remoteGroups[groupUUID]
424                 if !gi.PreviousMembers[groupMember] && !gi.CurrentMembers[groupMember] {
425                         if *verbose {
426                                 log.Printf("Adding %q to group %q", groupMember, groupName)
427                         }
428                         // User wasn't a member, but should.
429                         link := make(map[string]interface{})
430                         err := arv.Create("links", arvadosclient.Dict{
431                                 "link": arvadosclient.Dict{
432                                         "link_class": "permission",
433                                         "name":       "can_read",
434                                         "tail_uuid":  groupUUID,
435                                         "head_uuid":  userIDToUUID[groupMember],
436                                 },
437                         }, &link)
438                         if err != nil {
439                                 return fmt.Errorf("error adding user %q to group %q: %s",
440                                         groupMember, groupName, err)
441                         }
442                         membersAdded++
443                 }
444                 gi.CurrentMembers[groupMember] = true
445         }
446
447         // Remove previous members not listed on this run
448         for groupUUID := range remoteGroups {
449                 gi := remoteGroups[groupUUID]
450                 evictedMembers := subtract(gi.PreviousMembers, gi.CurrentMembers)
451                 groupName := gi.Group.Name
452                 if len(evictedMembers) > 0 {
453                         log.Printf("Removing %d users from group %q", len(evictedMembers), groupName)
454                 }
455                 for evictedUser := range evictedMembers {
456                         links, err := ListAll(arv, "links", arvadosclient.Dict{
457                                 "filters": [][]string{
458                                         {"link_class", "=", "permission"},
459                                         {"name", "=", "can_read"},
460                                         {"tail_uuid", "=", groupUUID},
461                                         {"head_uuid", "=", userIDToUUID[evictedUser]},
462                                 },
463                         }, &linkList{})
464                         if err != nil {
465                                 return fmt.Errorf("error getting links needed to remove user %q from group %q: %s", evictedUser, groupName, err)
466                         }
467                         for _, item := range links {
468                                 link := item.(link)
469                                 var l map[string]interface{}
470                                 if *verbose {
471                                         log.Printf("Removing %q from group %q", evictedUser, gi.Group.Name)
472                                 }
473                                 err := arv.Delete("links", link.UUID, arvadosclient.Dict{}, &l)
474                                 if err != nil {
475                                         return fmt.Errorf("error removing user %q from group %q: %s", evictedUser, groupName, err)
476                                 }
477                         }
478                         membersRemoved++
479                 }
480         }
481         log.Printf("Groups created: %d, members added: %d, members removed: %d, members skipped: %d", groupsCreated, membersAdded, membersRemoved, membersSkipped)
482
483         return nil
484 }
485
486 // ListAll : Adds all objects of type 'resource' to the 'output' list
487 func ListAll(arv *arvadosclient.ArvadosClient, resource string, parameters arvadosclient.Dict, rl resourceList) (allItems []interface{}, err error) {
488         if _, ok := parameters["limit"]; !ok {
489                 // Default limit value: use the maximum page size the server allows
490                 parameters["limit"] = 1<<31 - 1
491         }
492         offset := 0
493         itemsAvailable := parameters["limit"].(int)
494         for len(allItems) < itemsAvailable {
495                 parameters["offset"] = offset
496                 err = arv.List(resource, parameters, &rl)
497                 if err != nil {
498                         return allItems, err
499                 }
500                 for _, i := range rl.items() {
501                         allItems = append(allItems, i)
502                 }
503                 offset = rl.offset() + len(rl.items())
504                 itemsAvailable = rl.itemsAvailable()
505         }
506         return allItems, nil
507 }
508
509 func subtract(setA map[string]bool, setB map[string]bool) map[string]bool {
510         result := make(map[string]bool)
511         for element := range setA {
512                 if !setB[element] {
513                         result[element] = true
514                 }
515         }
516         return result
517 }