1 // Copyright (C) The Arvados Authors. All rights reserved.
3 // SPDX-License-Identifier: AGPL-3.0
21 "git.arvados.org/arvados.git/lib/cmd"
22 "git.arvados.org/arvados.git/sdk/go/arvados"
27 type resourceList interface {
29 GetItems() []interface{}
32 // UserList implements resourceList interface
33 type UserList struct {
37 // Len returns the amount of items this list holds
38 func (l UserList) Len() int {
42 // GetItems returns the list of items
43 func (l UserList) GetItems() (out []interface{}) {
44 for _, item := range l.Items {
45 out = append(out, item)
51 cfg, err := GetConfig()
56 if err := doMain(&cfg); err != nil {
61 type ConfigParams struct {
62 Client *arvados.Client
64 CurrentUser arvados.User
65 DeactivateUnlisted bool
72 func ParseFlags(cfg *ConfigParams) error {
73 flags := flag.NewFlagSet(os.Args[0], flag.ExitOnError)
74 flags.Usage = func() {
75 usageStr := `Synchronize remote users into Arvados from a CSV format file with 5 columns:
79 * 4th: Active status (0 or 1)
80 * 5th: Admin status (0 or 1)`
81 fmt.Fprintf(flags.Output(), "%s\n\n", usageStr)
82 fmt.Fprintf(flags.Output(), "Usage:\n%s [OPTIONS] <input-file.csv>\n\n", os.Args[0])
83 fmt.Fprintf(flags.Output(), "Options:\n")
87 deactivateUnlisted := flags.Bool(
88 "deactivate-unlisted",
90 "Deactivate users that are not in the input file.")
91 verbose := flags.Bool(
94 "Log informational messages. Off by default.")
95 getVersion := flags.Bool(
98 "Print version information and exit.")
100 if ok, code := cmd.ParseFlags(flags, os.Args[0], os.Args[1:], "input-file.csv", os.Stderr); !ok {
102 } else if *getVersion {
103 fmt.Printf("%s %s\n", os.Args[0], version)
107 // Input file as a required positional argument
108 if flags.NArg() == 0 {
109 return fmt.Errorf("please provide a path to an input file")
111 srcPath := &os.Args[flags.NFlag()+1]
115 return fmt.Errorf("input file path invalid")
118 cfg.DeactivateUnlisted = *deactivateUnlisted
120 cfg.Verbose = *verbose
125 // GetConfig sets up a ConfigParams struct
126 func GetConfig() (cfg ConfigParams, err error) {
127 err = ParseFlags(&cfg)
132 cfg.Client = arvados.NewClientFromEnv()
134 // Check current user permissions
135 u, err := cfg.Client.CurrentUser()
137 return cfg, fmt.Errorf("error getting the current user: %s", err)
140 return cfg, fmt.Errorf("current user %q is not an admin user", u.UUID)
143 log.Printf("Running as admin user %q (%s)", u.Email, u.UUID)
153 err = cfg.Client.RequestAndDecode(&ac, "GET", "arvados/v1/config", nil, nil)
155 return cfg, fmt.Errorf("error getting the exported config: %s", err)
157 if ac.Login.LoginCluster != "" && ac.Login.LoginCluster != ac.ClusterID {
158 return cfg, fmt.Errorf("cannot run on a cluster other than the login cluster")
160 cfg.SysUserUUID = ac.ClusterID + "-tpzed-000000000000000"
161 cfg.AnonUserUUID = ac.ClusterID + "-tpzed-anonymouspublic"
162 cfg.ClusterID = ac.ClusterID
167 func doMain(cfg *ConfigParams) error {
168 // Try opening the input file early, just in case there's a problem.
169 f, err := os.Open(cfg.Path)
171 return fmt.Errorf("error opening input file: %s", err)
175 allUsers := make(map[string]arvados.User)
176 dupedEmails := make(map[string][]arvados.User)
177 processedUsers := make(map[string]bool)
178 results, err := GetAll(cfg.Client, "users", arvados.ResourceListParams{}, &UserList{})
180 return fmt.Errorf("error getting all users: %s", err)
182 log.Printf("Found %d users in cluster %q", len(results), cfg.ClusterID)
183 localUserUuidRegex := regexp.MustCompile(fmt.Sprintf("^%s-tpzed-[0-9a-z]{15}$", cfg.ClusterID))
184 for _, item := range results {
185 u := item.(arvados.User)
187 if !localUserUuidRegex.MatchString(u.UUID) {
189 log.Printf("Remote user %q (%s) won't be considered for processing", u.Email, u.UUID)
193 // Duplicated user's email check
194 email := strings.ToLower(u.Email)
195 if ul, ok := dupedEmails[email]; ok {
196 log.Printf("Duplicated email %q found in user %s", email, u.UUID)
197 dupedEmails[email] = append(ul, u)
200 if eu, ok := allUsers[email]; ok {
201 log.Printf("Duplicated email %q found in users %s and %s", email, eu.UUID, u.UUID)
202 dupedEmails[email] = []arvados.User{eu, u}
203 delete(allUsers, email)
207 processedUsers[email] = false
210 loadedRecords, err := LoadInputFile(f)
212 return fmt.Errorf("reading input file %q: %s", cfg.Path, err)
214 log.Printf("Loaded %d records from input file", len(loadedRecords))
216 updatesSucceeded := map[string]bool{}
217 updatesFailed := map[string]bool{}
218 updatesSkipped := map[string]bool{}
220 for _, record := range loadedRecords {
221 processedUsers[record.Email] = true
222 if record.Email == cfg.CurrentUser.Email {
223 updatesSkipped[record.Email] = true
224 log.Printf("Skipping current user %q (%s) from processing", record.Email, cfg.CurrentUser.UUID)
227 if updated, err := ProcessRecord(cfg, record, allUsers); err != nil {
228 log.Printf("error processing record %q: %s", record.Email, err)
229 updatesFailed[record.Email] = true
231 updatesSucceeded[record.Email] = true
235 if cfg.DeactivateUnlisted {
236 for email, user := range allUsers {
237 if shouldSkip(cfg, user) {
238 updatesSkipped[email] = true
239 log.Printf("Skipping unlisted user %q (%s) from deactivating", user.Email, user.UUID)
242 if !processedUsers[email] && allUsers[email].IsActive {
244 log.Printf("Deactivating unlisted user %q (%s)", user.Email, user.UUID)
246 var updatedUser arvados.User
247 if err := UnsetupUser(cfg.Client, user.UUID, &updatedUser); err != nil {
248 log.Printf("error deactivating unlisted user %q: %s", user.UUID, err)
249 updatesFailed[email] = true
251 allUsers[email] = updatedUser
252 updatesSucceeded[email] = true
258 log.Printf("User update successes: %d, skips: %d, failures: %d", len(updatesSucceeded), len(updatesSkipped), len(updatesFailed))
260 // Report duplicated emails detection
261 if len(dupedEmails) > 0 {
262 emails := make([]string, len(dupedEmails))
264 for e := range dupedEmails {
268 return fmt.Errorf("skipped %d duplicated email address(es) in the cluster's local user list: %v", len(dupedEmails), emails)
274 func shouldSkip(cfg *ConfigParams, user arvados.User) bool {
276 case cfg.SysUserUUID, cfg.AnonUserUUID:
278 case cfg.CurrentUser.UUID:
284 type userRecord struct {
292 // ProcessRecord creates or updates a user based on the given record
293 func ProcessRecord(cfg *ConfigParams, record userRecord, allUsers map[string]arvados.User) (bool, error) {
295 log.Printf("Processing record for user %q", record.Email)
298 wantedActiveStatus := strconv.FormatBool(record.Active)
299 wantedAdminStatus := strconv.FormatBool(record.Admin)
300 createRequired := false
301 updateRequired := false
302 // Check if user exists, set its active & admin status.
303 var user arvados.User
304 user, ok := allUsers[record.Email]
307 log.Printf("User %q does not exist, creating", record.Email)
309 createRequired = true
310 err := CreateUser(cfg.Client, &user, map[string]string{
311 "email": record.Email,
312 "first_name": record.FirstName,
313 "last_name": record.LastName,
314 "is_active": wantedActiveStatus,
315 "is_admin": wantedAdminStatus,
318 return false, fmt.Errorf("error creating user %q: %s", record.Email, err)
321 if record.Active != user.IsActive {
322 updateRequired = true
325 log.Printf("User %q is inactive, activating", record.Email)
327 // Here we assume the 'setup' is done elsewhere if needed.
328 err := UpdateUser(cfg.Client, user.UUID, &user, map[string]string{
329 "is_active": wantedActiveStatus,
330 "is_admin": wantedAdminStatus, // Just in case it needs to be changed.
333 return false, fmt.Errorf("error updating user %q: %s", record.Email, err)
337 log.Printf("User %q is active, deactivating", record.Email)
339 err := UnsetupUser(cfg.Client, user.UUID, &user)
341 return false, fmt.Errorf("error deactivating user %q: %s", record.Email, err)
345 // Inactive users cannot be admins.
346 if user.IsActive && record.Admin != user.IsAdmin {
348 log.Printf("User %q is active, changing admin status to %v", record.Email, record.Admin)
350 updateRequired = true
351 err := UpdateUser(cfg.Client, user.UUID, &user, map[string]string{
352 "is_admin": wantedAdminStatus,
355 return false, fmt.Errorf("error updating user %q: %s", record.Email, err)
358 allUsers[record.Email] = user
360 log.Printf("Created user %q", record.Email)
363 log.Printf("Updated user %q", record.Email)
366 return createRequired || updateRequired, nil
369 // LoadInputFile reads the input file and returns a list of user records
370 func LoadInputFile(f *os.File) (loadedRecords []userRecord, err error) {
372 csvReader := csv.NewReader(f)
373 loadedRecords = make([]userRecord, 0)
376 record, e := csvReader.Read()
382 err = fmt.Errorf("parsing error at line %d: %s", lineNo, e)
385 if len(record) != 5 {
386 err = fmt.Errorf("parsing error at line %d: expected 5 fields, found %d", lineNo, len(record))
389 email := strings.ToLower(strings.TrimSpace(record[0]))
390 firstName := strings.TrimSpace(record[1])
391 lastName := strings.TrimSpace(record[2])
392 active := strings.TrimSpace(record[3])
393 admin := strings.TrimSpace(record[4])
394 if email == "" || firstName == "" || lastName == "" || active == "" || admin == "" {
395 err = fmt.Errorf("parsing error at line %d: fields cannot be empty", lineNo)
398 activeBool, err := strconv.ParseBool(active)
400 return nil, fmt.Errorf("parsing error at line %d: active status not recognized", lineNo)
402 adminBool, err := strconv.ParseBool(admin)
404 return nil, fmt.Errorf("parsing error at line %d: admin status not recognized", lineNo)
406 loadedRecords = append(loadedRecords, userRecord{
408 FirstName: firstName,
414 return loadedRecords, nil
417 // GetAll adds all objects of type 'resource' to the 'allItems' list
418 func GetAll(c *arvados.Client, res string, params arvados.ResourceListParams, page resourceList) (allItems []interface{}, err error) {
419 // Use the maximum page size the server allows
421 params.Limit = &limit
423 params.Order = "uuid"
425 if err = GetResourceList(c, &page, res, params); err != nil {
428 // Have we finished paging?
432 allItems = append(allItems, page.GetItems()...)
433 params.Offset += page.Len()
438 func jsonReader(rscName string, ob interface{}) io.Reader {
439 j, err := json.Marshal(ob)
444 v[rscName] = []string{string(j)}
445 return bytes.NewBufferString(v.Encode())
448 // GetResourceList fetches res list using params
449 func GetResourceList(c *arvados.Client, dst *resourceList, res string, params interface{}) error {
450 return c.RequestAndDecode(dst, "GET", "/arvados/v1/"+res, nil, params)
453 // CreateUser creates a user with userData parameters, assigns it to dst
454 func CreateUser(c *arvados.Client, dst *arvados.User, userData map[string]string) error {
455 return c.RequestAndDecode(dst, "POST", "/arvados/v1/users", jsonReader("user", userData), nil)
458 // UpdateUser updates a user with userData parameters
459 func UpdateUser(c *arvados.Client, userUUID string, dst *arvados.User, userData map[string]string) error {
460 return c.RequestAndDecode(&dst, "PUT", "/arvados/v1/users/"+userUUID, jsonReader("user", userData), nil)
463 // UnsetupUser deactivates a user
464 func UnsetupUser(c *arvados.Client, userUUID string, dst *arvados.User) error {
465 return c.RequestAndDecode(&dst, "POST", "/arvados/v1/users/"+userUUID+"/unsetup", nil, nil)