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,210 @@ 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."
675+ " To disable this, use the --skip-key-cleanup option." ,
676+ file = sys .stderr ,
677+ )
678+ status , keys = client .call_operation ("object-storage" , "keys-list" )
679+ if status != 200 :
680+ print (
681+ "Failed to list object storage keys for cleanup" ,
682+ file = sys .stderr ,
683+ )
684+ return
685+
686+ key_lifespan = _get_key_lifespan (client )
687+ key_rotation_period = _get_key_rotation_period (client )
688+ cleanup_batch_size = _get_cleanup_batch_size (client )
689+
690+ linode_cli_keys = _get_linode_cli_keys (
691+ keys ["data" ], key_lifespan , key_rotation_period , current_timestamp
692+ )
693+ _rotate_current_key_if_needed (client , linode_cli_keys )
694+ _delete_stale_keys (client , linode_cli_keys , cleanup_batch_size )
695+
696+ client .config .plugin_set_value (
697+ "last-key-cleanup-timestamp" , str (current_timestamp )
698+ )
699+ client .config .write_config ()
700+
701+ except Exception as e :
702+ print (
703+ "Unable to clean up stale linode-cli Object Storage keys" ,
704+ e ,
705+ file = sys .stderr ,
706+ )
707+
708+
709+ def _should_perform_key_cleanup (client : CLI ) -> bool :
710+ return client .config .plugin_get_config_value_or_set_default (
711+ "perform-key-cleanup" , True , bool
712+ )
713+
714+
715+ def _get_key_lifespan (client ) -> str :
716+ return client .config .plugin_get_config_value_or_set_default (
717+ "key-lifespan" , "30d"
718+ )
719+
720+
721+ def _get_key_rotation_period (client ) -> str :
722+ return client .config .plugin_get_config_value_or_set_default (
723+ "key-rotation-period-days" , "10d"
724+ )
725+
726+
727+ def _get_cleanup_batch_size (client ) -> int :
728+ return client .config .plugin_get_config_value_or_set_default (
729+ "key-cleanup-batch-size" , 10 , int
730+ )
731+
732+
733+ def _get_linode_cli_keys (
734+ keys_data : list ,
735+ key_lifespan : str ,
736+ key_rotation_period : str ,
737+ current_timestamp : int ,
738+ ) -> list :
739+
740+ stale_threshold = current_timestamp - parse_time (key_lifespan )
741+ rotation_threshold = current_timestamp - parse_time (key_rotation_period )
742+
743+ def extract_key_info (key : dict ) -> Optional [dict ]:
744+ match = re .match (r"^linode-cli-.+@.+-(\d{10})$" , key ["label" ])
745+ if not match :
746+ return None
747+ created_timestamp = int (match .group (1 ))
748+ is_stale = created_timestamp <= stale_threshold
749+ needs_rotation = is_stale or created_timestamp <= rotation_threshold
750+ return {
751+ "id" : key ["id" ],
752+ "label" : key ["label" ],
753+ "access_key" : key ["access_key" ],
754+ "created_timestamp" : created_timestamp ,
755+ "is_stale" : is_stale ,
756+ "needs_rotation" : needs_rotation ,
757+ }
758+
759+ return sorted (
760+ [info for key in keys_data if (info := extract_key_info (key ))],
761+ key = lambda k : k ["created_timestamp" ],
762+ )
763+
764+
765+ def _rotate_current_key_if_needed (client : CLI , linode_cli_keys : list ) -> None :
766+ current_access_key = client .config .plugin_get_value ("access-key" )
767+ key_to_rotate = next (
768+ (
769+ key_info
770+ for key_info in linode_cli_keys
771+ if key_info ["access_key" ] == current_access_key
772+ and key_info ["needs_rotation" ]
773+ ),
774+ None ,
775+ )
776+ if key_to_rotate :
777+ _delete_key (client , key_to_rotate ["id" ], key_to_rotate ["label" ])
778+ linode_cli_keys .remove (key_to_rotate )
779+ client .config .plugin_remove_option ("access-key" )
780+ client .config .plugin_remove_option ("secret-key" )
781+ client .config .write_config ()
782+
783+
784+ def _delete_stale_keys (
785+ client : CLI , linode_cli_keys : list , batch_size : int
786+ ) -> None :
787+ stale_keys = [k for k in linode_cli_keys if k ["is_stale" ]]
788+ for key_info in stale_keys [:batch_size ]:
789+ _delete_key (client , key_info ["id" ], key_info ["label" ])
790+
791+
792+ def _delete_key (client : CLI , key_id : str , label : str ) -> None :
793+ try :
794+ print (f"Deleting linode-cli Object Storage key: { label } " )
795+ status , _ = client .call_operation (
796+ "object-storage" , "keys-delete" , [str (key_id )]
797+ )
798+ if status != 200 :
799+ print (
800+ f"Failed to delete key: { label } ; status { status } " ,
801+ file = sys .stderr ,
802+ )
803+ except Exception as e :
804+ print (
805+ f"Exception occurred while deleting key: { label } ; { e } " ,
806+ file = sys .stderr ,
807+ )
0 commit comments