15531: Federation migrate script wip
[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         for k,v in rh.items():
43             arv = arvados.api(host=v, token=os.environ["ARVADOS_API_TOKEN"], cache=False)
44             config = arv.configs().get().execute()
45             if config["Login"]["LoginCluster"] != "" and loginCluster is None:
46                 loginCluster = config["Login"]["LoginCluster"]
47             clusters[k] = arv
48
49     print("Checking that the federation is well connected")
50     for arv in clusters.values():
51         config = arv.configs().get().execute()
52         if loginCluster and config["Login"]["LoginCluster"] != loginCluster and config["ClusterID"] != loginCluster:
53             errors.append("Inconsistent login cluster configuration, expected '%s' on %s but was '%s'" % (loginCluster, config["ClusterID"], config["Login"]["LoginCluster"]))
54             continue
55         try:
56             cur = arv.users().current().execute()
57             #arv.api_client_authorizations().list(limit=1).execute()
58         except arvados.errors.ApiError as e:
59             errors.append("checking token for %s   %s" % (arv._rootDesc["rootUrl"], e))
60             errors.append('    This script requires a token issued to a trusted client in order to manipulate access tokens.')
61             errors.append('    See "Trusted client setting" in https://doc.arvados.org/install/install-workbench-app.html')
62             errors.append('    and https://doc.arvados.org/api/tokens.html')
63             continue
64
65         if not cur["is_admin"]:
66             errors.append("Not admin of %s" % host)
67             continue
68
69         for r in clusters:
70             if r != arv._rootDesc["uuidPrefix"] and r not in arv._rootDesc["remoteHosts"]:
71                 errors.append("%s is missing from remoteHosts of %s" % (r, arv._rootDesc["uuidPrefix"]))
72         for r in arv._rootDesc["remoteHosts"]:
73             if r != "*" and r not in clusters:
74                 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))
75
76     if errors:
77         for e in errors:
78             print("ERROR: "+str(e))
79         exit(1)
80
81     if args.check:
82         print("Tokens file passed checks")
83         exit(0)
84
85     if args.report:
86         users = []
87         for c, arv in clusters.items():
88             print("Getting user list from %s" % c)
89             ul = arvados.util.list_all(arv.users().list)
90             for l in ul:
91                 if l["uuid"].startswith(c):
92                     users.append(l)
93
94         out = csv.writer(open(args.report, "wt"))
95
96         out.writerow(("email", "username", "user uuid", "home cluster"))
97
98         users = sorted(users, key=lambda u: u["email"]+"::"+(u["username"] or "")+"::"+u["uuid"])
99
100         accum = []
101         lastemail = None
102         for u in users:
103             if u["uuid"].endswith("-anonymouspublic") or u["uuid"].endswith("-000000000000000"):
104                 continue
105             if lastemail == None:
106                 lastemail = u["email"]
107             if u["email"] == lastemail:
108                 accum.append(u)
109             else:
110                 homeuuid = None
111                 for a in accum:
112                     if homeuuid is None:
113                         homeuuid = a["uuid"]
114                     if a["uuid"] != homeuuid:
115                         homeuuid = ""
116                 for a in accum:
117                     out.writerow((a["email"], a["username"], a["uuid"], loginCluster or homeuuid[0:5]))
118                 lastemail = u["email"]
119                 accum = [u]
120
121         homeuuid = None
122         for a in accum:
123             if homeuuid is None:
124                 homeuuid = a["uuid"]
125             if a["uuid"] != homeuuid:
126                 homeuuid = ""
127         for a in accum:
128             out.writerow((a["email"], a["username"], a["uuid"], loginCluster or homeuuid[0:5]))
129
130         print("Wrote %s" % args.report)
131
132     if args.migrate or args.dry_run:
133         if args.dry_run:
134             print("Performing dry run")
135
136         rows = []
137         by_email = {}
138         with open(args.migrate or args.dry_run, "rt") as f:
139             for r in csv.reader(f):
140                 if r[0] == "email":
141                     continue
142                 by_email.setdefault(r[0], [])
143                 by_email[r[0]].append(r)
144                 rows.append(r)
145         for r in rows:
146             email = r[0]
147             username = r[1]
148             old_user_uuid = r[2]
149             userhome = r[3]
150
151             if userhome == "":
152                 print("(%s) Skipping %s, no home cluster specified" % (email, old_user_uuid))
153             if old_user_uuid.startswith(userhome):
154                 continue
155             candidates = []
156             for b in by_email[email]:
157                 if b[2].startswith(userhome):
158                     candidates.append(b)
159             if len(candidates) == 0:
160                 if len(userhome) == 5 and userhome not in clusters:
161                     print("(%s) Cannot migrate %s, unknown home cluster %s (typo?)" % (email, old_user_uuid, userhome))
162                     continue
163                 print("(%s) No user listed with same email to migrate %s to %s, will create new user" % (email, old_user_uuid, userhome))
164                 if not args.dry_run:
165                     newhomecluster = userhome[0:5]
166                     homearv = clusters[userhome]
167                     user = homearv.users().create({"email": email, "username": username}).execute()
168                     candidates.append((email, username, user["uuid"], userhome))
169                 else:
170                     candidates.append((email, username, "%s-tpzed-xfakexfakexfake" % (userhome[0:5]), userhome))
171             if len(candidates) > 1:
172                 print("(%s) Multiple users listed to migrate %s to %s, use full uuid" % (email, old_user_uuid, userhome))
173                 continue
174             new_user_uuid = candidates[0][2]
175
176             # cluster where the migration is happening
177             for arv in clusters.values():
178                 migratecluster = arv._rootDesc["uuidPrefix"]
179                 migratearv = clusters[migratecluster]
180
181                 # the user's new home cluster
182                 newhomecluster = userhome[0:5]
183                 homearv = clusters[newhomecluster]
184
185                 # create a token for the new user and salt it for the
186                 # migration cluster, then use it to access the migration
187                 # cluster as the new user once before merging to ensure
188                 # the new user is known on that cluster.
189                 try:
190                     if not args.dry_run:
191                         newtok = homearv.api_client_authorizations().create(body={
192                             "api_client_authorization": {'owner_uuid': new_user_uuid}}).execute()
193                     else:
194                         newtok = {"uuid": "dry-run", "api_token": "12345"}
195                 except arvados.errors.ApiError as e:
196                     print("(%s) Could not create API token for %s: %s" % (email, new_user_uuid, e))
197                     continue
198
199                 salted = 'v2/' + newtok["uuid"] + '/' + hmac.new(newtok["api_token"].encode(),
200                                                                  msg=migratecluster.encode(),
201                                                                  digestmod='sha1').hexdigest()
202                 try:
203                     ru = urllib.parse.urlparse(migratearv._rootDesc["rootUrl"])
204                     if not args.dry_run:
205                         newuser = arvados.api(host=ru.netloc, token=salted).users().current().execute()
206                     else:
207                         newuser = {"is_active": True}
208                 except arvados.errors.ApiError as e:
209                     print("(%s) Error getting user info for %s from %s: %s" % (email, new_user_uuid, migratecluster, e))
210                     continue
211
212                 try:
213                     olduser = migratearv.users().get(uuid=old_user_uuid).execute()
214                 except arvados.errors.ApiError as e:
215                     if e.resp.status != 404:
216                         print("(%s) Could not retrieve user %s from %s, user may have already been migrated: %s" % (email, old_user_uuid, migratecluster, e))
217                     continue
218
219                 if not newuser["is_active"]:
220                     print("(%s) Activating user %s on %s" % (email, new_user_uuid, migratecluster))
221                     try:
222                         if not args.dry_run:
223                             migratearv.users().update(uuid=new_user_uuid, body={"is_active": True}).execute()
224                     except arvados.errors.ApiError as e:
225                         print("(%s) Could not activate user %s on %s: %s" % (email, new_user_uuid, migratecluster, e))
226                         continue
227
228                 if olduser["is_admin"] and not newuser["is_admin"]:
229                     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))
230                     continue
231
232
233                 print("(%s) Migrating %s to %s on %s" % (email, old_user_uuid, new_user_uuid, migratecluster))
234
235                 try:
236                     if not args.dry_run:
237                         grp = migratearv.groups().create(body={
238                             "owner_uuid": new_user_uuid,
239                             "name": "Migrated from %s (%s)" % (email, old_user_uuid),
240                             "group_class": "project"
241                         }, ensure_unique_name=True).execute()
242                         migratearv.users().merge(old_user_uuid=old_user_uuid,
243                                                  new_user_uuid=new_user_uuid,
244                                                  new_owner_uuid=grp["uuid"],
245                                                  redirect_to_new_user=True).execute()
246                 except arvados.errors.ApiError as e:
247                     print("(%s) Error migrating user: %s" % (email, e))
248
249 if __name__ == "__main__":
250     main()