44"""
55import getpass
66import os
7+ import re
78import socket
89import sys
910import time
1011from argparse import ArgumentParser
1112from contextlib import suppress
1213from datetime import datetime
1314from math import ceil
14- from typing import List
15+ from typing import List , Optional
1516
17+ from pytimeparse import parse as parse_time
1618from rich import print as rprint
1719from rich .table import Table
1820
1921from linodecli .cli import CLI
20- from linodecli .configuration import _do_get_request
2122from linodecli .configuration .helpers import _default_text_input
2223from linodecli .exit_codes import ExitCodes
2324from linodecli .help_formatter import SortingHelpFormatter
@@ -322,6 +323,11 @@ def get_obj_args_parser():
322323 type = str ,
323324 help = "The cluster to use for the operation" ,
324325 )
326+ parser .add_argument (
327+ "--skip-key-cleanup" ,
328+ action = "store_true" ,
329+ help = "Skip cleanup of old linode-cli generated object storage keys" ,
330+ )
325331
326332 return parser
327333
@@ -400,8 +406,10 @@ def call(
400406 access_key = None
401407 secret_key = None
402408
403- # make a client, but only if we weren't printing help
409+ # make a client and clean-up keys , but only if we weren't printing help
404410 if not is_help :
411+ if not parsed .skip_key_cleanup :
412+ _cleanup_keys (context .client )
405413 access_key , secret_key = get_credentials (context .client )
406414
407415 cluster = parsed .cluster
@@ -580,7 +588,7 @@ def _get_s3_creds(client: CLI, force: bool = False):
580588
581589def _configure_plugin (client : CLI ):
582590 """
583- Configures a default cluster value .
591+ Configures Object Storage plugin .
584592 """
585593
586594 cluster = _default_text_input ( # pylint: disable=protected-access
@@ -590,4 +598,209 @@ def _configure_plugin(client: CLI):
590598
591599 if cluster :
592600 client .config .plugin_set_value ("cluster" , cluster )
601+
602+ perform_key_cleanup = _default_text_input ( # pylint: disable=protected-access
603+ "Perform automatic cleanup of old linode-cli generated Object Storage keys? (Y/n)" ,
604+ default = "y" ,
605+ validator = lambda x : (
606+ "Please enter 'y' or 'n'"
607+ if x .lower () in ("y" , "n" , "yes" , "no" )
608+ else None
609+ ),
610+ )
611+ perform_key_cleanup = (
612+ "yes" if perform_key_cleanup .lower () in ("y" , "yes" ) else "no"
613+ )
614+ client .config .plugin_set_value ("perform-key-cleanup" , perform_key_cleanup )
615+ if perform_key_cleanup == "yes" :
616+ key_lifespan = _default_text_input ( # pylint: disable=protected-access
617+ "Linode-cli Object Storage key lifespan (e.g. `30d` for 30 days)" ,
618+ default = "30d" ,
619+ validator = lambda x : (
620+ "Please enter a valid time duration"
621+ if parse_time (x ) is None
622+ else None
623+ ),
624+ )
625+ client .config .plugin_set_value ("key-lifespan" , key_lifespan )
626+
627+ key_rotation_period = _default_text_input ( # pylint: disable=protected-access
628+ "Linode-cli Object Storage key rotation period (e.g. `10d` for 10 days)" ,
629+ default = "10d" ,
630+ validator = lambda x : (
631+ "Please enter a valid time duration"
632+ if parse_time (x ) is None
633+ else None
634+ ),
635+ )
636+ client .config .plugin_set_value (
637+ "key-rotation-period-days" , key_rotation_period
638+ )
639+
640+ cleanup_batch_size = _default_text_input ( # pylint: disable=protected-access
641+ "Number of old linode-cli Object Storage keys to clean up at once" ,
642+ default = "10" ,
643+ validator = lambda x : (
644+ "Please enter a valid integer" if not x .isdigit () else None
645+ ),
646+ )
647+ client .config .plugin_set_value (
648+ "key-cleanup-batch-size" , str (int (cleanup_batch_size ))
649+ )
650+
593651 client .config .write_config ()
652+
653+
654+ def _cleanup_keys (client : CLI ) -> None :
655+ """
656+ Cleans up stale linode-cli generated object storage keys.
657+ """
658+ try :
659+ if not _should_perform_key_cleanup (client ):
660+ return
661+
662+ current_timestamp = int (time .time ())
663+ last_cleanup = client .config .plugin_get_value (
664+ "last-key-cleanup-timestamp"
665+ )
666+ if (
667+ last_cleanup
668+ and int (last_cleanup ) > current_timestamp - 24 * 60 * 60
669+ ):
670+ # if we did a cleanup in the last 24 hours, skip
671+ return
672+
673+ print (
674+ "Cleaning up old linode-cli Object Storage keys. To disable this, use the --skip-key-cleanup option." ,
675+ file = sys .stderr ,
676+ )
677+ status , keys = client .call_operation ("object-storage" , "keys-list" )
678+ if status != 200 :
679+ print (
680+ "Failed to list object storage keys for cleanup" ,
681+ file = sys .stderr ,
682+ )
683+ return
684+
685+ key_lifespan = _get_key_lifespan (client )
686+ key_rotation_period = _get_key_rotation_period (client )
687+ cleanup_batch_size = _get_cleanup_batch_size (client )
688+
689+ linode_cli_keys = _get_linode_cli_keys (
690+ keys ["data" ], key_lifespan , key_rotation_period , current_timestamp
691+ )
692+ _rotate_current_key_if_needed (client , linode_cli_keys )
693+ _delete_stale_keys (client , linode_cli_keys , cleanup_batch_size )
694+
695+ client .config .plugin_set_value (
696+ "last-key-cleanup-timestamp" , str (current_timestamp )
697+ )
698+ client .config .write_config ()
699+
700+ except Exception as e :
701+ print (
702+ "Unable to clean up stale linode-cli Object Storage keys" ,
703+ e ,
704+ file = sys .stderr ,
705+ )
706+
707+
708+ def _should_perform_key_cleanup (client : CLI ) -> bool :
709+ return client .config .plugin_get_config_value_or_set_default (
710+ "perform-key-cleanup" , True , bool
711+ )
712+
713+
714+ def _get_key_lifespan (client ) -> str :
715+ return client .config .plugin_get_config_value_or_set_default (
716+ "key-lifespan" , "30d"
717+ )
718+
719+
720+ def _get_key_rotation_period (client ) -> str :
721+ return client .config .plugin_get_config_value_or_set_default (
722+ "key-rotation-period-days" , "10d"
723+ )
724+
725+
726+ def _get_cleanup_batch_size (client ) -> int :
727+ return client .config .plugin_get_config_value_or_set_default (
728+ "key-cleanup-batch-size" , 10 , int
729+ )
730+
731+
732+ def _get_linode_cli_keys (
733+ keys_data : list ,
734+ key_lifespan : str ,
735+ key_rotation_period : str ,
736+ current_timestamp : int ,
737+ ) -> list :
738+
739+ stale_threshold = current_timestamp - parse_time (key_lifespan )
740+ rotation_threshold = current_timestamp - parse_time (key_rotation_period )
741+
742+ def extract_key_info (key : dict ) -> Optional [dict ]:
743+ match = re .match (r"^linode-cli-.+@.+-(\d{10})$" , key ["label" ])
744+ if not match :
745+ return None
746+ created_timestamp = int (match .group (1 ))
747+ is_stale = created_timestamp <= stale_threshold
748+ needs_rotation = is_stale or created_timestamp <= rotation_threshold
749+ return {
750+ "id" : key ["id" ],
751+ "label" : key ["label" ],
752+ "access_key" : key ["access_key" ],
753+ "created_timestamp" : created_timestamp ,
754+ "is_stale" : is_stale ,
755+ "needs_rotation" : needs_rotation ,
756+ }
757+
758+ return sorted (
759+ [info for key in keys_data if (info := extract_key_info (key ))],
760+ key = lambda k : k ["created_timestamp" ],
761+ )
762+
763+
764+ def _rotate_current_key_if_needed (client : CLI , linode_cli_keys : list ) -> None :
765+ current_access_key = client .config .plugin_get_value ("access-key" )
766+ key_to_rotate = next (
767+ (
768+ key_info
769+ for key_info in linode_cli_keys
770+ if key_info ["access_key" ] == current_access_key
771+ and key_info ["needs_rotation" ]
772+ ),
773+ None ,
774+ )
775+ if key_to_rotate :
776+ _delete_key (client , key_to_rotate ["id" ], key_to_rotate ["label" ])
777+ linode_cli_keys .remove (key_to_rotate )
778+ client .config .plugin_remove_option ("access-key" )
779+ client .config .plugin_remove_option ("secret-key" )
780+ client .config .write_config ()
781+
782+
783+ def _delete_stale_keys (
784+ client : CLI , linode_cli_keys : list , batch_size : int
785+ ) -> None :
786+ stale_keys = [k for k in linode_cli_keys if k ["is_stale" ]]
787+ for key_info in stale_keys [:batch_size ]:
788+ _delete_key (client , key_info ["id" ], key_info ["label" ])
789+
790+
791+ def _delete_key (client : CLI , key_id : str , label : str ) -> None :
792+ try :
793+ print (f"Deleting linode-cli Object Storage key: { label } " )
794+ status , _ = client .call_operation (
795+ "object-storage" , "keys-delete" , [str (key_id )]
796+ )
797+ if status != 200 :
798+ print (
799+ f"Failed to delete key: { label } ; status { status } " ,
800+ file = sys .stderr ,
801+ )
802+ except Exception as e :
803+ print (
804+ f"Exception occurred while deleting key: { label } ; { e } " ,
805+ file = sys .stderr ,
806+ )
0 commit comments