Skip to content

Commit 71036c4

Browse files
authored
Merge pull request #4971 from sgibson91/cilogon-cleanup
Cilogon cleanup
2 parents 942feef + ef38052 commit 71036c4

File tree

3 files changed

+206
-58
lines changed

3 files changed

+206
-58
lines changed

deployer/README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -369,9 +369,9 @@ This allows us to validate that all required values are present and have the cor
369369
### The `cilogon-client` sub-command for CILogon OAuth client management
370370
Deployer sub-command for managing CILogon clients for 2i2c hubs.
371371

372-
#### `cilogon-client create/delete/get/get-all/update`
372+
#### `cilogon-client create/delete/get/get-all/update/cleanup`
373373

374-
create/delete/get/get-all/update/ CILogon clients using the 2i2c administrative client provided by CILogon.
374+
create/delete/get/get-all/update/cleanup CILogon clients using the 2i2c administrative client provided by CILogon.
375375

376376
### The `exec` sub-command for executing shells and debugging commands
377377

deployer/commands/cilogon.py

Lines changed: 195 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,12 @@
1313
- `delete` a CILogon client application when a hub is removed or changes auth methods
1414
- `get` details about an existing hub CILogon client
1515
- `get-all` existing 2i2c CILogon client applications
16+
- `cleanup` duplicated CILogon applications
1617
"""
1718

1819
import base64
1920
import json
21+
from collections import Counter
2022
from pathlib import Path
2123

2224
import requests
@@ -27,6 +29,8 @@
2729
from deployer.cli_app import cilogon_client_app
2830
from deployer.utils.file_acquisition import (
2931
build_absolute_path_to_hub_encrypted_config_file,
32+
find_absolute_path_to_cluster_file,
33+
get_cluster_names_list,
3034
get_decrypted_file,
3135
persist_config_in_encrypted_file,
3236
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):
264268
return client_details
265269

266270

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.
272273
273274
Returns status code if response.ok
274275
or None if the `delete` request returned a status code not in the range 200-299.
275276
276277
See: https://github.com/ncsa/OA4MP/blob/HEAD/oa4mp-server-admin-oauth2/src/main/scripts/oidc-cm-scripts/cm-delete.sh
277278
"""
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")
298281
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}...")
311283

312-
print(f"Deleting the CILogon client details for {client_id}...")
313284
headers = build_request_headers(admin_id, admin_secret)
314285
response = requests.delete(build_request_url(client_id), headers=headers, timeout=5)
315286
if not response.ok:
@@ -318,19 +289,6 @@ def delete_client(admin_id, admin_secret, cluster_name, hub_name, client_id=None
318289

319290
print_colour("Done!")
320291

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-
334292

335293
def get_all_clients(admin_id, admin_secret):
336294
print("Getting all existing CILogon client applications...")
@@ -344,8 +302,67 @@ def get_all_clients(admin_id, admin_secret):
344302
return
345303

346304
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
349366

350367

351368
def get_2i2c_cilogon_admin_credentials():
@@ -415,7 +432,13 @@ def get(
415432
def get_all():
416433
"""Retrieve details about all existing 2i2c CILogon clients."""
417434
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")
419442

420443

421444
@cilogon_client_app.command()
@@ -432,7 +455,123 @@ def delete(
432455
""",
433456
),
434457
):
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+
)
437465
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+
438497
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"])

deployer/utils/file_acquisition.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -248,3 +248,12 @@ def get_all_cluster_yaml_files():
248248
for path in CONFIG_CLUSTERS_PATH.glob("**/cluster.yaml")
249249
if "templates" not in path.as_posix()
250250
}
251+
252+
253+
def get_cluster_names_list():
254+
"""
255+
Returns a list of all the clusters currently listed under config/clusters
256+
"""
257+
return [
258+
d.name for d, _, _ in CONFIG_CLUSTERS_PATH.walk() if "templates" not in str(d)
259+
]

0 commit comments

Comments
 (0)