Merge branch '15522-arvmount-ops-encoding'
[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
15 def main():
16
17     parser = argparse.ArgumentParser(description='Migrate users to federated identity, see https://doc.arvados.org/admin/merge-remote-account.html')
18     parser.add_argument('--tokens', type=str, required=True)
19     group = parser.add_mutually_exclusive_group(required=True)
20     group.add_argument('--report', type=str, help="Generate report .csv file listing users by email address and their associated Arvados accounts")
21     group.add_argument('--migrate', type=str, help="Consume report .csv and migrate users to designated Arvados accounts")
22     group.add_argument('--check', action="store_true", help="Check that tokens are usable and the federation is well connected")
23     args = parser.parse_args()
24
25     clusters = {}
26     errors = []
27     print("Reading %s" % args.tokens)
28     with open(args.tokens, "rt") as f:
29         for r in csv.reader(f):
30             host = r[0]
31             token = r[1]
32             print("Contacting %s" % (host))
33             arv = arvados.api(host=host, token=token, cache=False)
34             try:
35                 cur = arv.users().current().execute()
36                 arv.api_client_authorizations().list(limit=1).execute()
37             except arvados.errors.ApiError as e:
38                 errors.append("checking token for %s: %s" % (host, e))
39                 errors.append('    This script requires a token issued to a trusted client in order to manipulate access tokens.')
40                 errors.append('    See "Trusted client setting" in https://doc.arvados.org/install/install-workbench-app.html')
41                 errors.append('    and https://doc.arvados.org/api/tokens.html')
42                 continue
43
44             if not cur["is_admin"]:
45                 errors.append("Not admin of %s" % host)
46                 continue
47
48             clusters[arv._rootDesc["uuidPrefix"]] = arv
49
50
51     print("Checking that the federation is well connected")
52     for v in clusters.values():
53         for r in clusters:
54             if r != v._rootDesc["uuidPrefix"] and r not in v._rootDesc["remoteHosts"]:
55                 errors.append("%s is missing from remoteHosts of %s" % (r, v._rootDesc["uuidPrefix"]))
56         for r in v._rootDesc["remoteHosts"]:
57             if r != "*" and r not in clusters:
58                 print("WARNING: %s is federated with %s but %s is missing from the tokens file or the token is invalid" % (v._rootDesc["uuidPrefix"], r, r))
59
60     if errors:
61         for e in errors:
62             print("ERROR: "+str(e))
63         exit(1)
64
65     if args.check:
66         print("Tokens file passed checks")
67         exit(0)
68
69     if args.report:
70         users = []
71         for c, arv in clusters.items():
72             print("Getting user list from %s" % c)
73             ul = arvados.util.list_all(arv.users().list)
74             for l in ul:
75                 if l["uuid"].startswith(c):
76                     users.append(l)
77
78         out = csv.writer(open(args.report, "wt"))
79
80         out.writerow(("email", "user uuid", "primary cluster/user"))
81
82         users = sorted(users, key=lambda u: u["email"]+"::"+u["uuid"])
83
84         accum = []
85         lastemail = None
86         for u in users:
87             if u["uuid"].endswith("-anonymouspublic") or u["uuid"].endswith("-000000000000000"):
88                 continue
89             if lastemail == None:
90                 lastemail = u["email"]
91             if u["email"] == lastemail:
92                 accum.append(u)
93             else:
94                 homeuuid = None
95                 for a in accum:
96                     if homeuuid is None:
97                         homeuuid = a["uuid"]
98                     if a["uuid"] != homeuuid:
99                         homeuuid = ""
100                 for a in accum:
101                     out.writerow((a["email"], a["uuid"], homeuuid[0:5]))
102                 lastemail = u["email"]
103                 accum = [u]
104
105         homeuuid = None
106         for a in accum:
107             if homeuuid is None:
108                 homeuuid = a["uuid"]
109             if a["uuid"] != homeuuid:
110                 homeuuid = ""
111         for a in accum:
112             out.writerow((a["email"], a["uuid"], homeuuid[0:5]))
113
114         print("Wrote %s" % args.report)
115
116     if args.migrate:
117         rows = []
118         by_email = {}
119         with open(args.migrate, "rt") as f:
120             for r in csv.reader(f):
121                 if r[0] == "email":
122                     continue
123                 by_email.setdefault(r[0], [])
124                 by_email[r[0]].append(r)
125                 rows.append(r)
126         for r in rows:
127             email = r[0]
128             old_user_uuid = r[1]
129             userhome = r[2]
130
131             if userhome == "":
132                 print("(%s) Skipping %s, no home cluster specified" % (email, old_user_uuid))
133             if old_user_uuid.startswith(userhome):
134                 continue
135             candidates = []
136             for b in by_email[email]:
137                 if b[1].startswith(userhome):
138                     candidates.append(b)
139             if len(candidates) == 0:
140                 if len(userhome) == 5 and userhome not in clusters:
141                     print("(%s) Cannot migrate %s, unknown home cluster %s (typo?)" % (email, old_user_uuid, userhome))
142                 else:
143                     print("(%s) No user listed with same email to migrate %s to %s" % (email, old_user_uuid, userhome))
144                 continue
145             if len(candidates) > 1:
146                 print("(%s) Multiple users listed to migrate %s to %s, use full uuid" % (email, old_user_uuid, userhome))
147                 continue
148             new_user_uuid = candidates[0][1]
149
150             # cluster where the migration is happening
151             migratecluster = old_user_uuid[0:5]
152             migratearv = clusters[migratecluster]
153
154             # the user's new home cluster
155             newhomecluster = userhome[0:5]
156             homearv = clusters[newhomecluster]
157
158             # create a token for the new user and salt it for the
159             # migration cluster, then use it to access the migration
160             # cluster as the new user once before merging to ensure
161             # the new user is known on that cluster.
162             try:
163                 newtok = homearv.api_client_authorizations().create(body={
164                     "api_client_authorization": {'owner_uuid': new_user_uuid}}).execute()
165             except arvados.errors.ApiError as e:
166                 print("(%s) Could not create API token for %s: %s" % (email, new_user_uuid, e))
167                 continue
168
169             salted = 'v2/' + newtok["uuid"] + '/' + hmac.new(newtok["api_token"].encode(),
170                                                              msg=migratecluster.encode(),
171                                                              digestmod='sha1').hexdigest()
172             try:
173                 ru = urllib.parse.urlparse(migratearv._rootDesc["rootUrl"])
174                 newuser = arvados.api(host=ru.netloc, token=salted).users().current().execute()
175             except arvados.errors.ApiError as e:
176                 print("(%s) Error getting user info for %s from %s: %s" % (email, new_user_uuid, migratecluster, e))
177                 continue
178
179             try:
180                 olduser = migratearv.users().get(uuid=old_user_uuid).execute()
181             except arvados.errors.ApiError as e:
182                 print("(%s) Could not retrieve user %s from %s, user may have already been migrated: %s" % (email, old_user_uuid, migratecluster, e))
183                 continue
184
185             if not newuser["is_active"]:
186                 print("(%s) Activating user %s on %s" % (email, new_user_uuid, migratecluster))
187                 try:
188                     migratearv.users().update(uuid=new_user_uuid, body={"is_active": True}).execute()
189                 except arvados.errors.ApiError as e:
190                     print("(%s) Could not activate user %s on %s: %s" % (email, new_user_uuid, migratecluster, e))
191                     continue
192
193             if olduser["is_admin"] and not newuser["is_admin"]:
194                 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))
195                 continue
196
197             print("(%s) Migrating %s to %s on %s" % (email, old_user_uuid, new_user_uuid, migratecluster))
198
199             try:
200                 grp = migratearv.groups().create(body={
201                     "owner_uuid": new_user_uuid,
202                     "name": "Migrated from %s (%s)" % (email, old_user_uuid),
203                     "group_class": "project"
204                 }, ensure_unique_name=True).execute()
205                 migratearv.users().merge(old_user_uuid=old_user_uuid,
206                                          new_user_uuid=new_user_uuid,
207                                          new_owner_uuid=grp["uuid"],
208                                          redirect_to_new_user=True).execute()
209             except arvados.errors.ApiError as e:
210                 print("(%s) Error migrating user: %s" % (email, e))
211
212 if __name__ == "__main__":
213     main()