18858: Implements basic sync-users tool.
[arvados.git] / tools / sync-users / sync-users.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         "strconv"
18         "strings"
19
20         "git.arvados.org/arvados.git/lib/cmd"
21         "git.arvados.org/arvados.git/sdk/go/arvados"
22 )
23
24 var version = "dev"
25
26 type resourceList interface {
27         Len() int
28         GetItems() []interface{}
29 }
30
31 // UserList implements resourceList interface
32 type UserList struct {
33         arvados.UserList
34 }
35
36 // Len returns the amount of items this list holds
37 func (l UserList) Len() int {
38         return len(l.Items)
39 }
40
41 // GetItems returns the list of items
42 func (l UserList) GetItems() (out []interface{}) {
43         for _, item := range l.Items {
44                 out = append(out, item)
45         }
46         return
47 }
48
49 func main() {
50         cfg, err := GetConfig()
51         if err != nil {
52                 log.Fatalf("%v", err)
53         }
54
55         if err := doMain(&cfg); err != nil {
56                 log.Fatalf("%v", err)
57         }
58 }
59
60 type ConfigParams struct {
61         Path    string
62         Verbose bool
63         Client  *arvados.Client
64 }
65
66 func ParseFlags(cfg *ConfigParams) error {
67         flags := flag.NewFlagSet(os.Args[0], flag.ExitOnError)
68         flags.Usage = func() {
69                 usageStr := `Synchronize remote users into Arvados from a CSV format file with 5 columns:
70   * 1st: E-mail address
71   * 2nd: First name
72   * 3rd: Last name
73   * 4th: Active status (0 or 1)
74   * 5th: Admin status (0 or 1)`
75                 fmt.Fprintf(flags.Output(), "%s\n\n", usageStr)
76                 fmt.Fprintf(flags.Output(), "Usage:\n%s [OPTIONS] <input-file.csv>\n\n", os.Args[0])
77                 fmt.Fprintf(flags.Output(), "Options:\n")
78                 flags.PrintDefaults()
79         }
80
81         verbose := flags.Bool(
82                 "verbose",
83                 false,
84                 "Log informational messages. Off by default.")
85         getVersion := flags.Bool(
86                 "version",
87                 false,
88                 "Print version information and exit.")
89
90         if ok, code := cmd.ParseFlags(flags, os.Args[0], os.Args[1:], "input-file.csv", os.Stderr); !ok {
91                 os.Exit(code)
92         } else if *getVersion {
93                 fmt.Printf("%s %s\n", os.Args[0], version)
94                 os.Exit(0)
95         }
96
97         // Input file as a required positional argument
98         if flags.NArg() == 0 {
99                 return fmt.Errorf("please provide a path to an input file")
100         }
101         srcPath := &os.Args[flags.NFlag()+1]
102
103         // Validations
104         if *srcPath == "" {
105                 return fmt.Errorf("input file path invalid")
106         }
107
108         cfg.Path = *srcPath
109         cfg.Verbose = *verbose
110
111         return nil
112 }
113
114 // GetConfig sets up a ConfigParams struct
115 func GetConfig() (cfg ConfigParams, err error) {
116         err = ParseFlags(&cfg)
117         if err != nil {
118                 return
119         }
120
121         cfg.Client = arvados.NewClientFromEnv()
122
123         // Check current user permissions
124         u, err := cfg.Client.CurrentUser()
125         if err != nil {
126                 return cfg, fmt.Errorf("error getting the current user: %s", err)
127         }
128         if !u.IsAdmin {
129                 return cfg, fmt.Errorf("current user (%s) is not an admin user", u.UUID)
130         }
131
132         return cfg, nil
133 }
134
135 func doMain(cfg *ConfigParams) error {
136         // Try opening the input file early, just in case there's a problem.
137         f, err := os.Open(cfg.Path)
138         if err != nil {
139                 return fmt.Errorf("error opening input file: %s", err)
140         }
141         defer f.Close()
142
143         allUsers := make(map[string]arvados.User)
144         results, err := GetAll(cfg.Client, "users", arvados.ResourceListParams{}, &UserList{})
145         if err != nil {
146                 return fmt.Errorf("error getting all users: %s", err)
147         }
148         log.Printf("Found %d users", len(results))
149         for _, item := range results {
150                 u := item.(arvados.User)
151                 allUsers[strings.ToLower(u.Email)] = u
152         }
153
154         loadedRecords, err := LoadInputFile(f)
155         if err != nil {
156                 return fmt.Errorf("reading input file %q: %s", cfg.Path, err)
157         }
158         log.Printf("Loaded %d records from input file", len(loadedRecords))
159
160         updatesSucceeded, updatesFailed := 0, 0
161         for _, record := range loadedRecords {
162                 if updated, err := ProcessRecord(cfg, record, allUsers); err != nil {
163                         log.Printf("error processing record %q: %s", record.Email, err)
164                         updatesFailed++
165                 } else if updated {
166                         updatesSucceeded++
167                 }
168         }
169         log.Printf("Updated %d account(s), failed to update %d account(s)", updatesSucceeded, updatesFailed)
170
171         return nil
172 }
173
174 type userRecord struct {
175         Email     string
176         FirstName string
177         LastName  string
178         Active    bool
179         Admin     bool
180 }
181
182 // ProcessRecord creates or updates a user based on the given record
183 func ProcessRecord(cfg *ConfigParams, record userRecord, allUsers map[string]arvados.User) (bool, error) {
184         wantedActiveStatus := strconv.FormatBool(record.Active)
185         wantedAdminStatus := strconv.FormatBool(record.Admin)
186         updateRequired := false
187         // Check if user exists, set its active & admin status.
188         var user arvados.User
189         user, ok := allUsers[record.Email]
190         if !ok {
191                 err := CreateUser(cfg.Client, &user, map[string]string{
192                         "email":      record.Email,
193                         "first_name": record.FirstName,
194                         "last_name":  record.LastName,
195                         "is_active":  strconv.FormatBool(record.Active),
196                         "is_admin":   strconv.FormatBool(record.Admin),
197                 })
198                 if err != nil {
199                         return false, fmt.Errorf("error creating user %q: %s", record.Email, err)
200                 }
201                 updateRequired = true
202                 log.Printf("Created user %q", record.Email)
203         }
204         if record.Active != user.IsActive {
205                 updateRequired = true
206                 if record.Active {
207                         // Here we assume the 'setup' is done elsewhere if needed.
208                         err := UpdateUser(cfg.Client, user.UUID, &user, map[string]string{
209                                 "is_active": wantedActiveStatus,
210                                 "is_admin":  wantedAdminStatus, // Just in case it needs to be changed.
211                         })
212                         if err != nil {
213                                 return false, fmt.Errorf("error updating user %q: %s", record.Email, err)
214                         }
215                 } else {
216                         err := UnsetupUser(cfg.Client, user.UUID, &user)
217                         if err != nil {
218                                 return false, fmt.Errorf("error deactivating user %q: %s", record.Email, err)
219                         }
220                 }
221         }
222         // Inactive users cannot be admins.
223         if user.IsActive && record.Admin != user.IsAdmin {
224                 updateRequired = true
225                 err := UpdateUser(cfg.Client, user.UUID, &user, map[string]string{
226                         "is_admin": wantedAdminStatus,
227                 })
228                 if err != nil {
229                         return false, fmt.Errorf("error updating user %q: %s", record.Email, err)
230                 }
231         }
232         allUsers[record.Email] = user
233         if updateRequired {
234                 log.Printf("Updated user %q", record.Email)
235         }
236
237         return updateRequired, nil
238 }
239
240 // LoadInputFile reads the input file and returns a list of user records
241 func LoadInputFile(f *os.File) (loadedRecords []userRecord, err error) {
242         lineNo := 0
243         csvReader := csv.NewReader(f)
244         loadedRecords = make([]userRecord, 0)
245
246         for {
247                 record, e := csvReader.Read()
248                 if e == io.EOF {
249                         break
250                 }
251                 lineNo++
252                 if e != nil {
253                         err = fmt.Errorf("parsing error at line %d: %s", lineNo, e)
254                         return
255                 }
256                 if len(record) != 5 {
257                         err = fmt.Errorf("parsing error at line %d: expected 5 fields, found %d", lineNo, len(record))
258                         return
259                 }
260                 email := strings.ToLower(strings.TrimSpace(record[0]))
261                 firstName := strings.TrimSpace(record[1])
262                 lastName := strings.TrimSpace(record[2])
263                 active := strings.TrimSpace(record[3])
264                 admin := strings.TrimSpace(record[4])
265                 if email == "" || firstName == "" || lastName == "" || active == "" || admin == "" {
266                         err = fmt.Errorf("parsing error at line %d: fields cannot be empty", lineNo)
267                         return
268                 }
269                 activeBool, err := strconv.ParseBool(active)
270                 if err != nil {
271                         return nil, fmt.Errorf("parsing error at line %d: active status not recognized", lineNo)
272                 }
273                 adminBool, err := strconv.ParseBool(admin)
274                 if err != nil {
275                         return nil, fmt.Errorf("parsing error at line %d: admin status not recognized", lineNo)
276                 }
277                 loadedRecords = append(loadedRecords, userRecord{
278                         Email:     email,
279                         FirstName: firstName,
280                         LastName:  lastName,
281                         Active:    activeBool,
282                         Admin:     adminBool,
283                 })
284         }
285         return loadedRecords, nil
286 }
287
288 // GetAll adds all objects of type 'resource' to the 'allItems' list
289 func GetAll(c *arvados.Client, res string, params arvados.ResourceListParams, page resourceList) (allItems []interface{}, err error) {
290         // Use the maximum page size the server allows
291         limit := 1<<31 - 1
292         params.Limit = &limit
293         params.Offset = 0
294         params.Order = "uuid"
295         for {
296                 if err = GetResourceList(c, &page, res, params); err != nil {
297                         return allItems, err
298                 }
299                 // Have we finished paging?
300                 if page.Len() == 0 {
301                         break
302                 }
303                 allItems = append(allItems, page.GetItems()...)
304                 params.Offset += page.Len()
305         }
306         return allItems, nil
307 }
308
309 func jsonReader(rscName string, ob interface{}) io.Reader {
310         j, err := json.Marshal(ob)
311         if err != nil {
312                 panic(err)
313         }
314         v := url.Values{}
315         v[rscName] = []string{string(j)}
316         return bytes.NewBufferString(v.Encode())
317 }
318
319 // GetResourceList fetches res list using params
320 func GetResourceList(c *arvados.Client, dst *resourceList, res string, params interface{}) error {
321         return c.RequestAndDecode(dst, "GET", "/arvados/v1/"+res, nil, params)
322 }
323
324 // CreateUser creates a user with userData parameters, assigns it to dst
325 func CreateUser(c *arvados.Client, dst *arvados.User, userData map[string]string) error {
326         return c.RequestAndDecode(dst, "POST", "/arvados/v1/users", jsonReader("user", userData), nil)
327 }
328
329 // UpdateUser updates a user with userData parameters
330 func UpdateUser(c *arvados.Client, userUUID string, dst *arvados.User, userData map[string]string) error {
331         return c.RequestAndDecode(&dst, "PUT", "/arvados/v1/users/"+userUUID, jsonReader("user", userData), nil)
332 }
333
334 // UnsetupUser deactivates a user
335 func UnsetupUser(c *arvados.Client, userUUID string, dst *arvados.User) error {
336         return c.RequestAndDecode(&dst, "POST", "/arvados/v1/users/"+userUUID+"/unsetup", nil, nil)
337 }