13
13
- `delete` a CILogon client application when a hub is removed or changes auth methods
14
14
- `get` details about an existing hub CILogon client
15
15
- `get-all` existing 2i2c CILogon client applications
16
+ - `cleanup` duplicated CILogon applications
16
17
"""
17
18
18
19
import base64
19
20
import json
21
+ from collections import Counter
20
22
from pathlib import Path
21
23
22
24
import requests
27
29
from deployer .cli_app import cilogon_client_app
28
30
from deployer .utils .file_acquisition import (
29
31
build_absolute_path_to_hub_encrypted_config_file ,
32
+ find_absolute_path_to_cluster_file ,
33
+ get_cluster_names_list ,
30
34
get_decrypted_file ,
31
35
persist_config_in_encrypted_file ,
32
36
remove_jupyterhub_hub_config_key_from_encrypted_file ,
@@ -264,52 +268,19 @@ def get_client(admin_id, admin_secret, cluster_name, hub_name, client_id=None):
264
268
return client_details
265
269
266
270
267
- def delete_client (admin_id , admin_secret , cluster_name , hub_name , client_id = None ):
268
- """Deletes the client associated with the id.
269
-
270
- Args:
271
- id (str): Id of the client to delete
271
+ def delete_client (admin_id , admin_secret , client_id = None ):
272
+ """Deletes a CILogon client.
272
273
273
274
Returns status code if response.ok
274
275
or None if the `delete` request returned a status code not in the range 200-299.
275
276
276
277
See: https://github.com/ncsa/OA4MP/blob/HEAD/oa4mp-server-admin-oauth2/src/main/scripts/oidc-cm-scripts/cm-delete.sh
277
278
"""
278
- config_filename = build_absolute_path_to_hub_encrypted_config_file (
279
- cluster_name , hub_name
280
- )
281
-
282
- if not client_id :
283
- if Path (config_filename ).is_file ():
284
- client_id = load_client_id_from_file (config_filename )
285
- # Nothing to do if no client has been found
286
- if not client_id :
287
- print_colour (
288
- "No `client_id` to delete was provided and couldn't find any in `config_filename`" ,
289
- "red" ,
290
- )
291
- return
292
- else :
293
- print_colour (
294
- f"No `client_id` to delete was provided and couldn't find any { config_filename } file" ,
295
- "red" ,
296
- )
297
- return
279
+ if client_id is None :
280
+ print ("Deleting a CILogon client for unknown ID" )
298
281
else :
299
- if not stored_client_id_same_with_cilogon_records (
300
- admin_id ,
301
- admin_secret ,
302
- cluster_name ,
303
- hub_name ,
304
- client_id ,
305
- ):
306
- print_colour (
307
- "CILogon records are different than the client app stored in the configuration file. Consider updating the file." ,
308
- "red" ,
309
- )
310
- return
282
+ print (f"Deleting the CILogon client details for { client_id } ..." )
311
283
312
- print (f"Deleting the CILogon client details for { client_id } ..." )
313
284
headers = build_request_headers (admin_id , admin_secret )
314
285
response = requests .delete (build_request_url (client_id ), headers = headers , timeout = 5 )
315
286
if not response .ok :
@@ -318,19 +289,6 @@ def delete_client(admin_id, admin_secret, cluster_name, hub_name, client_id=None
318
289
319
290
print_colour ("Done!" )
320
291
321
- # Delete client credentials from config file also if file exists
322
- if Path (config_filename ).is_file ():
323
- print (f"Deleting the CILogon client details from the { config_filename } also..." )
324
- key = "CILogonOAuthenticator"
325
- try :
326
- remove_jupyterhub_hub_config_key_from_encrypted_file (config_filename , key )
327
- except KeyError :
328
- print_colour (f"No { key } found to delete from { config_filename } " , "yellow" )
329
- return
330
- print_colour (f"CILogonAuthenticator config removed from { config_filename } " )
331
- if not Path (config_filename ).is_file ():
332
- print_colour (f"Empty { config_filename } file also deleted." , "yellow" )
333
-
334
292
335
293
def get_all_clients (admin_id , admin_secret ):
336
294
print ("Getting all existing CILogon client applications..." )
@@ -344,8 +302,67 @@ def get_all_clients(admin_id, admin_secret):
344
302
return
345
303
346
304
clients = response .json ()
347
- for c in clients ["clients" ]:
348
- print (c )
305
+ return [c for c in clients ["clients" ]]
306
+
307
+
308
+ def find_duplicated_clients (clients ):
309
+ """Determine duplicated CILogon clients by comparing client names
310
+
311
+ Args:
312
+ clients (list[dict]): A list of dictionaries containing information about
313
+ the existing CILogon clients. Generated by get_all_clients function.
314
+
315
+ Returns:
316
+ list: A list of duplicated client names
317
+ """
318
+ client_names = [c ["name" ] for c in clients ]
319
+ client_names_count = Counter (client_names )
320
+ return [k for k , v in client_names_count .items () if v > 1 ]
321
+
322
+
323
+ def find_orphaned_clients (clients ):
324
+ """Find CILogon clients for which an associated cluster or hub no longer
325
+ exists and can safely be deleted.
326
+
327
+ Args:
328
+ clients (list[dict]): A list of existing CILogon client info
329
+
330
+ Returns:
331
+ list[dict]: A list of 'orphaned' CILogon clients which don't have an
332
+ associated cluster or hub, which can be deleted
333
+ """
334
+ clients_to_be_deleted = []
335
+ clusters = get_cluster_names_list ()
336
+
337
+ for client in clients :
338
+ cluster = next ((cl for cl in clusters if cl in client ["name" ]), "" )
339
+
340
+ if cluster :
341
+ cluster_config_file = find_absolute_path_to_cluster_file (cluster )
342
+ with open (cluster_config_file ) as f :
343
+ cluster_config = yaml .load (f )
344
+
345
+ hub = next (
346
+ (
347
+ hub ["name" ]
348
+ for hub in cluster_config ["hubs" ]
349
+ if hub ["name" ] in client ["name" ]
350
+ ),
351
+ "" ,
352
+ )
353
+
354
+ if not hub :
355
+ print (
356
+ f"A hub pertaining to client { client ['name' ]} does NOT exist. Marking client for deletion."
357
+ )
358
+ clients_to_be_deleted .append (client )
359
+ else :
360
+ print (
361
+ f"A cluster pertaining to client { client ['name' ]} does NOT exist. Marking client for deletion."
362
+ )
363
+ clients_to_be_deleted .append (client )
364
+
365
+ return clients_to_be_deleted
349
366
350
367
351
368
def get_2i2c_cilogon_admin_credentials ():
@@ -415,7 +432,13 @@ def get(
415
432
def get_all ():
416
433
"""Retrieve details about all existing 2i2c CILogon clients."""
417
434
admin_id , admin_secret = get_2i2c_cilogon_admin_credentials ()
418
- get_all_clients (admin_id , admin_secret )
435
+ clients = get_all_clients (admin_id , admin_secret )
436
+ for c in clients :
437
+ print (c )
438
+
439
+ # Our plan with CILogon only permits 100 clients, so provide feedback on that
440
+ # number here. Change this if our plan updates.
441
+ print_colour (f"{ len (clients )} / 100 clients used" , "yellow" )
419
442
420
443
421
444
@cilogon_client_app .command ()
@@ -432,7 +455,123 @@ def delete(
432
455
""" ,
433
456
),
434
457
):
435
- """Delete an existing CILogon client. This deletes both the CILogon client application,
436
- and the client credentials from the configuration file."""
458
+ """
459
+ Delete an existing CILogon client. This deletes both the CILogon client application,
460
+ and the client credentials from the configuration file.
461
+ """
462
+ config_filename = build_absolute_path_to_hub_encrypted_config_file (
463
+ cluster_name , hub_name
464
+ )
437
465
admin_id , admin_secret = get_2i2c_cilogon_admin_credentials ()
466
+
467
+ if not client_id :
468
+ if Path (config_filename ).is_file ():
469
+ client_id = load_client_id_from_file (config_filename )
470
+ # Nothing to do if no client has been found
471
+ if not client_id :
472
+ print_colour (
473
+ "No `client_id` to delete was provided and couldn't find any in `config_filename`" ,
474
+ "red" ,
475
+ )
476
+ return
477
+ else :
478
+ print_colour (
479
+ f"No `client_id` to delete was provided and couldn't find any { config_filename } file" ,
480
+ "red" ,
481
+ )
482
+ return
483
+ else :
484
+ if not stored_client_id_same_with_cilogon_records (
485
+ admin_id ,
486
+ admin_secret ,
487
+ cluster_name ,
488
+ hub_name ,
489
+ client_id ,
490
+ ):
491
+ print_colour (
492
+ "CILogon records are different than the client app stored in the configuration file. Consider updating the file." ,
493
+ "red" ,
494
+ )
495
+ return
496
+
438
497
delete_client (admin_id , admin_secret , cluster_name , hub_name , client_id )
498
+
499
+ # Delete client credentials from config file also if file exists
500
+ if Path (config_filename ).is_file ():
501
+ print (f"Deleting the CILogon client details from the { config_filename } also..." )
502
+ key = "CILogonOAuthenticator"
503
+ try :
504
+ remove_jupyterhub_hub_config_key_from_encrypted_file (config_filename , key )
505
+ except KeyError :
506
+ print_colour (f"No { key } found to delete from { config_filename } " , "yellow" )
507
+ return
508
+ print_colour (f"CILogonAuthenticator config removed from { config_filename } " )
509
+ if not Path (config_filename ).is_file ():
510
+ print_colour (f"Empty { config_filename } file also deleted." , "yellow" )
511
+
512
+
513
+ @cilogon_client_app .command ()
514
+ def cleanup (
515
+ delete : bool = typer .Option (
516
+ False , help = "Proceed with deleting duplicated CILogon apps"
517
+ )
518
+ ):
519
+ """Identify duplicated CILogon clients and which ID is being actively used in config,
520
+ and optionally delete unused duplicates.
521
+
522
+ Args:
523
+ delete (bool, optional): Delete unused duplicate CILogon apps. Defaults to False.
524
+ """
525
+ clients_to_be_deleted = []
526
+
527
+ admin_id , admin_secret = get_2i2c_cilogon_admin_credentials ()
528
+ clients = get_all_clients (admin_id , admin_secret )
529
+ duplicated_clients = find_duplicated_clients (clients )
530
+
531
+ # Cycle over each duplicated client name
532
+ for duped_client in duplicated_clients :
533
+ # Finds all the client IDs associated with a duplicated name
534
+ ids = [c ["client_id" ] for c in clients if c ["name" ] == duped_client ]
535
+
536
+ # Establish the cluster and hub name from the client name and build the
537
+ # absolute path to the encrypted hub values file
538
+ cluster_name , hub_name = duped_client .split ("-" )
539
+ config_filename = build_absolute_path_to_hub_encrypted_config_file (
540
+ cluster_name , hub_name
541
+ )
542
+
543
+ with get_decrypted_file (config_filename ) as decrypted_path :
544
+ with open (decrypted_path ) as f :
545
+ secret_config = yaml .load (f )
546
+
547
+ if (
548
+ "CILogonOAuthenticator"
549
+ not in secret_config ["jupyterhub" ]["hub" ]["config" ].keys ()
550
+ ):
551
+ print (
552
+ f"Hub { hub_name } on cluster { cluster_name } doesn't use CILogonOAuthenticator."
553
+ )
554
+ else :
555
+ # Extract the client ID *currently in use* from the encrypted config and remove it from the list of IDs
556
+ config_client_id = secret_config ["jupyterhub" ]["hub" ]["config" ][
557
+ "CILogonOAuthenticator"
558
+ ]["client_id" ]
559
+ ids .remove (config_client_id )
560
+
561
+ clients_to_be_deleted .extend (
562
+ [{"client_name" : duped_client , "client_id" : id } for id in ids ]
563
+ )
564
+
565
+ # Remove the duplicated clients from the client list
566
+ clients = [c for c in clients if c ["name" ] != duped_client ]
567
+
568
+ orphaned_clients = find_orphaned_clients (clients )
569
+ clients_to_be_deleted .extend (orphaned_clients )
570
+
571
+ print_colour ("CILogon clients to be deleted..." )
572
+ for c in clients_to_be_deleted :
573
+ print (c )
574
+
575
+ if delete :
576
+ for c in clients_to_be_deleted :
577
+ delete_client (admin_id , admin_secret , client_id = c ["client_id" ])
0 commit comments