3b3a7ee6655940755ed8744746abd7a8acadfd06
[arvados.git] / sdk / python / arvados / commands / federation_migrate.py
1 #!/usr/bin/env python3
2 # Copyright (C) The Arvados Authors. All rights reserved.
3 #
4 # SPDX-License-Identifier: Apache-2.0
5
6 import arvados
7 import arvados.util
8 import arvados.errors
9 import csv
10 import sys
11 import argparse
12 import hmac
13 import urllib.parse
14 import os
15
16 def main():
17
18     parser = argparse.ArgumentParser(description='Migrate users to federated identity, see https://doc.arvados.org/admin/merge-remote-account.html')
19     parser.add_argument('--tokens', type=str, required=False)
20     group = parser.add_mutually_exclusive_group(required=True)
21     group.add_argument('--report', type=str, help="Generate report .csv file listing users by email address and their associated Arvados accounts")
22     group.add_argument('--migrate', type=str, help="Consume report .csv and migrate users to designated Arvados accounts")
23     group.add_argument('--dry-run', type=str, help="Consume report .csv and report how user would be migrated to designated Arvados accounts")
24     group.add_argument('--check', action="store_true", help="Check that tokens are usable and the federation is well connected")
25     args = parser.parse_args()
26
27     clusters = {}
28     errors = []
29     loginCluster = None
30     if args.tokens:
31         print("Reading %s" % args.tokens)
32         with open(args.tokens, "rt") as f:
33             for r in csv.reader(f):
34                 host = r[0]
35                 token = r[1]
36                 print("Contacting %s" % (host))
37                 arv = arvados.api(host=host, token=token, cache=False)
38                 clusters[arv._rootDesc["uuidPrefix"]] = arv
39     else:
40         arv = arvados.api(cache=False)
41         rh = arv._rootDesc["remoteHosts"]
42         tok = arv.api_client_authorizations().current().execute()
43         token = "v2/%s/%s" % (tok["uuid"], tok["api_token"])
44
45         for k,v in rh.items():
46             arv = arvados.api(host=v, token=token, cache=False, insecure=os.environ.get("ARVADOS_API_HOST_INSECURE"))
47             config = arv.configs().get().execute()
48             if config["Login"]["LoginCluster"] != "" and loginCluster is None:
49                 loginCluster = config["Login"]["LoginCluster"]
50             clusters[k] = arv
51
52     print("Checking that the federation is well connected")
53     for arv in clusters.values():
54         config = arv.configs().get().execute()
55         if loginCluster and config["Login"]["LoginCluster"] != loginCluster and config["ClusterID"] != loginCluster:
56             errors.append("Inconsistent login cluster configuration, expected '%s' on %s but was '%s'" % (loginCluster, config["ClusterID"], config["Login"]["LoginCluster"]))
57             continue
58         try:
59             cur = arv.users().current().execute()
60             #arv.api_client_authorizations().list(limit=1).execute()
61         except arvados.errors.ApiError as e:
62             errors.append("checking token for %s   %s" % (arv._rootDesc["rootUrl"], e))
63             errors.append('    This script requires a token issued to a trusted client in order to manipulate access tokens.')
64             errors.append('    See "Trusted client setting" in https://doc.arvados.org/install/install-workbench-app.html')
65             errors.append('    and https://doc.arvados.org/api/tokens.html')
66             continue
67
68         if not cur["is_admin"]:
69             errors.append("Not admin of %s" % host)
70             continue
71
72         for r in clusters:
73             if r != arv._rootDesc["uuidPrefix"] and r not in arv._rootDesc["remoteHosts"]:
74                 errors.append("%s is missing from remoteHosts of %s" % (r, arv._rootDesc["uuidPrefix"]))
75         for r in arv._rootDesc["remoteHosts"]:
76             if r != "*" and r not in clusters:
77                 print("WARNING: %s is federated with %s but %s is missing from the tokens file or the token is invalid" % (arv._rootDesc["uuidPrefix"], r, r))
78
79     if errors:
80         for e in errors:
81             print("ERROR: "+str(e))
82         exit(1)
83
84     if args.check:
85         print("Tokens file passed checks")
86         exit(0)
87
88     rows = []
89     by_email = {}
90
91     users = []
92     for c, arv in clusters.items():
93         print("Getting user list from %s" % c)
94         ul = arvados.util.list_all(arv.users().list)
95         for l in ul:
96             if l["uuid"].startswith(c):
97                 users.append(l)
98
99     users = sorted(users, key=lambda u: u["email"]+"::"+(u["username"] or "")+"::"+u["uuid"])
100
101     accum = []
102     lastemail = None
103     for u in users:
104         if u["uuid"].endswith("-anonymouspublic") or u["uuid"].endswith("-000000000000000"):
105             continue
106         if lastemail == None:
107             lastemail = u["email"]
108         if u["email"] == lastemail:
109             accum.append(u)
110         else:
111             homeuuid = None
112             for a in accum:
113                 if homeuuid is None:
114                     homeuuid = a["uuid"]
115                 if a["uuid"] != homeuuid:
116                     homeuuid = ""
117             for a in accum:
118                 r = (a["email"], a["username"], a["uuid"], loginCluster or homeuuid[0:5])
119                 by_email.setdefault(a["email"], {})
120                 by_email[a["email"]][a["uuid"]] = r
121                 rows.append(r)
122             lastemail = u["email"]
123             accum = [u]
124
125     homeuuid = None
126     for a in accum:
127         if homeuuid is None:
128             homeuuid = a["uuid"]
129         if a["uuid"] != homeuuid:
130             homeuuid = ""
131     for a in accum:
132         r = (a["email"], a["username"], a["uuid"], loginCluster or homeuuid[0:5])
133         by_email.setdefault(a["email"], {})
134         by_email[a["email"]][a["uuid"]] = r
135         rows.append(r)
136
137     if args.report:
138         out = csv.writer(open(args.report, "wt"))
139         out.writerow(("email", "username", "user uuid", "home cluster"))
140         for r in rows:
141             out.writerow(r)
142         print("Wrote %s" % args.report)
143         return
144
145     if args.migrate or args.dry_run:
146         if args.dry_run:
147             print("Performing dry run")
148
149         rows = []
150
151         with open(args.migrate or args.dry_run, "rt") as f:
152             for r in csv.reader(f):
153                 if r[0] == "email":
154                     continue
155                 by_email.setdefault(r[0], {})
156                 by_email[r[0]][r[2]] = r
157                 rows.append(r)
158
159         for r in rows:
160             email = r[0]
161             username = r[1]
162             old_user_uuid = r[2]
163             userhome = r[3]
164
165             if userhome == "":
166                 print("(%s) Skipping %s, no home cluster specified" % (email, old_user_uuid))
167             if old_user_uuid.startswith(userhome):
168                 migratecluster = old_user_uuid[0:5]
169                 migratearv = clusters[migratecluster]
170                 if migratearv.users().get(uuid=old_user_uuid).execute()["username"] != username:
171                     print("(%s) Updating username of %s to '%s' on %s" % (email, old_user_uuid, username, migratecluster))
172                     if not args.dry_run:
173                         try:
174                             conflicts = migratearv.users().list(filters=[["username", "=", username]]).execute()
175                             if conflicts["items"]:
176                                 migratearv.users().update(uuid=conflicts["items"][0]["uuid"], body={"user": {"username": username+"migrate"}}).execute()
177                             migratearv.users().update(uuid=old_user_uuid, body={"user": {"username": username}}).execute()
178                         except arvados.errors.ApiError as e:
179                             print("(%s) Error updating username of %s to '%s' on %s: %s" % (email, old_user_uuid, username, migratecluster, e))
180                 continue
181             candidates = []
182             conflict = False
183             for b in by_email[email].values():
184                 if b[2].startswith(userhome):
185                     candidates.append(b)
186                 if b[1] != username and b[3] == userhome:
187                     print("(%s) Cannot migrate %s, conflicting usernames %s and %s" % (email, old_user_uuid, b[1], username))
188                     conflict = True
189                     break
190             if conflict:
191                 continue
192             if len(candidates) == 0:
193                 if len(userhome) == 5 and userhome not in clusters:
194                     print("(%s) Cannot migrate %s, unknown home cluster %s (typo?)" % (email, old_user_uuid, userhome))
195                     continue
196                 print("(%s) No user listed with same email to migrate %s to %s, will create new user with username '%s'" % (email, old_user_uuid, userhome, username))
197                 if not args.dry_run:
198                     newhomecluster = userhome[0:5]
199                     homearv = clusters[userhome]
200                     user = None
201                     try:
202                         conflicts = homearv.users().list(filters=[["username", "=", username]]).execute()
203                         if conflicts["items"]:
204                             homearv.users().update(uuid=conflicts["items"][0]["uuid"], body={"user": {"username": username+"migrate"}}).execute()
205                         user = homearv.users().create(body={"user": {"email": email, "username": username}}).execute()
206                     except arvados.errors.ApiError as e:
207                         print("(%s) Could not create user: %s" % (email, str(e)))
208                         continue
209
210                     tup = (email, username, user["uuid"], userhome)
211                 else:
212                     # dry run
213                     tup = (email, username, "%s-tpzed-xfakexfakexfake" % (userhome[0:5]), userhome)
214                 by_email[email][tup[2]] = tup
215                 candidates.append(tup)
216             if len(candidates) > 1:
217                 print("(%s) Multiple users listed to migrate %s to %s, use full uuid" % (email, old_user_uuid, userhome))
218                 continue
219             new_user_uuid = candidates[0][2]
220
221             # cluster where the migration is happening
222             for arv in clusters.values():
223                 migratecluster = arv._rootDesc["uuidPrefix"]
224                 migratearv = clusters[migratecluster]
225
226                 # the user's new home cluster
227                 newhomecluster = userhome[0:5]
228                 homearv = clusters[newhomecluster]
229
230                 # create a token for the new user and salt it for the
231                 # migration cluster, then use it to access the migration
232                 # cluster as the new user once before merging to ensure
233                 # the new user is known on that cluster.
234                 try:
235                     if not args.dry_run:
236                         newtok = homearv.api_client_authorizations().create(body={
237                             "api_client_authorization": {'owner_uuid': new_user_uuid}}).execute()
238                     else:
239                         newtok = {"uuid": "dry-run", "api_token": "12345"}
240                 except arvados.errors.ApiError as e:
241                     print("(%s) Could not create API token for %s: %s" % (email, new_user_uuid, e))
242                     continue
243
244                 salted = 'v2/' + newtok["uuid"] + '/' + hmac.new(newtok["api_token"].encode(),
245                                                                  msg=migratecluster.encode(),
246                                                                  digestmod='sha1').hexdigest()
247                 try:
248                     ru = urllib.parse.urlparse(migratearv._rootDesc["rootUrl"])
249                     if not args.dry_run:
250                         newuser = arvados.api(host=ru.netloc, token=salted, insecure=os.environ.get("ARVADOS_API_HOST_INSECURE")).users().current().execute()
251                     else:
252                         newuser = {"is_active": True, "username": username}
253                 except arvados.errors.ApiError as e:
254                     print("(%s) Error getting user info for %s from %s: %s" % (email, new_user_uuid, migratecluster, e))
255                     continue
256
257                 try:
258                     olduser = migratearv.users().get(uuid=old_user_uuid).execute()
259                 except arvados.errors.ApiError as e:
260                     if e.resp.status != 404:
261                         print("(%s) Could not retrieve user %s from %s, user may have already been migrated: %s" % (email, old_user_uuid, migratecluster, e))
262                     continue
263
264                 if not newuser["is_active"]:
265                     print("(%s) Activating user %s on %s" % (email, new_user_uuid, migratecluster))
266                     try:
267                         if not args.dry_run:
268                             migratearv.users().update(uuid=new_user_uuid, body={"is_active": True}).execute()
269                     except arvados.errors.ApiError as e:
270                         print("(%s) Could not activate user %s on %s: %s" % (email, new_user_uuid, migratecluster, e))
271                         continue
272
273                 if olduser["is_admin"] and not newuser["is_admin"]:
274                     print("(%s) Not migrating %s because user is admin but target user %s is not admin on %s" % (email, old_user_uuid, new_user_uuid, migratecluster))
275                     continue
276
277                 print("(%s) Migrating %s to %s on %s" % (email, old_user_uuid, new_user_uuid, migratecluster))
278
279                 try:
280                     if not args.dry_run:
281                         grp = migratearv.groups().create(body={
282                             "owner_uuid": new_user_uuid,
283                             "name": "Migrated from %s (%s)" % (email, old_user_uuid),
284                             "group_class": "project"
285                         }, ensure_unique_name=True).execute()
286                         migratearv.users().merge(old_user_uuid=old_user_uuid,
287                                                  new_user_uuid=new_user_uuid,
288                                                  new_owner_uuid=grp["uuid"],
289                                                  redirect_to_new_user=True).execute()
290                 except arvados.errors.ApiError as e:
291                     print("(%s) Error migrating user: %s" % (email, e))
292
293                 if newuser['username'] != username:
294                     try:
295                         if not args.dry_run:
296                             conflicts = migratearv.users().list(filters=[["username", "=", username]]).execute()
297                             if conflicts["items"]:
298                                 migratearv.users().update(uuid=conflicts["items"][0]["uuid"], body={"user": {"username": username+"migrate"}}).execute()
299                             migratearv.users().update(uuid=new_user_uuid, body={"user": {"username": username}}).execute()
300                     except arvados.errors.ApiError as e:
301                         print("(%s) Error updating username of %s to '%s' on %s: %s" % (email, new_user_uuid, username, migratecluster, e))
302
303 if __name__ == "__main__":
304     main()