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,16 @@ 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+ )
331+ parser .add_argument (
332+ "--force-key-cleanup" ,
333+ action = "store_true" ,
334+ help = "Force cleanup of old linode-cli generated Object Storage keys" ,
335+ )
325336
326337 return parser
327338
@@ -400,8 +411,10 @@ def call(
400411 access_key = None
401412 secret_key = None
402413
403- # make a client, but only if we weren't printing help
414+ # make a client and clean-up keys , but only if we weren't printing help
404415 if not is_help :
416+ if not parsed .skip_key_cleanup :
417+ _cleanup_keys (context .client , parsed .force_key_cleanup )
405418 access_key , secret_key = get_credentials (context .client )
406419
407420 cluster = parsed .cluster
@@ -580,7 +593,7 @@ def _get_s3_creds(client: CLI, force: bool = False):
580593
581594def _configure_plugin (client : CLI ):
582595 """
583- Configures a default cluster value .
596+ Configures Object Storage plugin .
584597 """
585598
586599 cluster = _default_text_input ( # pylint: disable=protected-access
@@ -590,4 +603,211 @@ def _configure_plugin(client: CLI):
590603
591604 if cluster :
592605 client .config .plugin_set_value ("cluster" , cluster )
606+
607+ perform_key_cleanup = _default_text_input ( # pylint: disable=protected-access
608+ "Perform automatic cleanup of old linode-cli generated Object Storage keys? (Y/n)" ,
609+ default = "y" ,
610+ validator = lambda x : (
611+ "Please enter 'y' or 'n'"
612+ if x .lower () not in ("y" , "n" , "yes" , "no" )
613+ else None
614+ ),
615+ )
616+ perform_key_cleanup = (
617+ "yes" if perform_key_cleanup .lower () in ("y" , "yes" ) else "no"
618+ )
619+ client .config .plugin_set_value ("perform-key-cleanup" , perform_key_cleanup )
620+ if perform_key_cleanup == "yes" :
621+ key_lifespan = _default_text_input ( # pylint: disable=protected-access
622+ "Linode-cli Object Storage key lifespan (e.g. `30d` for 30 days)" ,
623+ default = "30d" ,
624+ validator = lambda x : (
625+ "Please enter a valid time duration"
626+ if parse_time (x ) is None
627+ else None
628+ ),
629+ )
630+ client .config .plugin_set_value ("key-lifespan" , key_lifespan )
631+
632+ key_rotation_period = _default_text_input ( # pylint: disable=protected-access
633+ "Linode-cli Object Storage key rotation period (e.g. `10d` for 10 days)" ,
634+ default = "10d" ,
635+ validator = lambda x : (
636+ "Please enter a valid time duration"
637+ if parse_time (x ) is None
638+ else None
639+ ),
640+ )
641+ client .config .plugin_set_value (
642+ "key-rotation-period-days" , key_rotation_period
643+ )
644+
645+ cleanup_batch_size = _default_text_input ( # pylint: disable=protected-access
646+ "Number of old linode-cli Object Storage keys to clean up at once" ,
647+ default = "10" ,
648+ validator = lambda x : (
649+ "Please enter a valid integer" if not x .isdigit () else None
650+ ),
651+ )
652+ client .config .plugin_set_value (
653+ "key-cleanup-batch-size" , str (int (cleanup_batch_size ))
654+ )
655+
593656 client .config .write_config ()
657+
658+
659+ def _cleanup_keys (client : CLI , force_key_cleanup : bool ) -> None :
660+ """
661+ Cleans up stale linode-cli generated object storage keys.
662+ """
663+ try :
664+ if not _should_perform_key_cleanup (client ):
665+ return
666+
667+ current_timestamp = int (time .time ())
668+ last_cleanup = client .config .plugin_get_value (
669+ "last-key-cleanup-timestamp"
670+ )
671+ if (
672+ not force_key_cleanup
673+ and last_cleanup
674+ and int (last_cleanup ) > current_timestamp - 24 * 60 * 60
675+ ):
676+ # if we did a cleanup in the last 24 hours, skip
677+ return
678+
679+ print (
680+ "Cleaning up old linode-cli Object Storage keys."
681+ " To disable this, use the --skip-key-cleanup option." ,
682+ file = sys .stderr ,
683+ )
684+ status , keys = client .call_operation ("object-storage" , "keys-list" )
685+ if status != 200 :
686+ print (
687+ "Failed to list object storage keys for cleanup" ,
688+ file = sys .stderr ,
689+ )
690+ return
691+
692+ key_lifespan = _get_key_lifespan (client )
693+ key_rotation_period = _get_key_rotation_period (client )
694+ cleanup_batch_size = _get_cleanup_batch_size (client )
695+
696+ linode_cli_keys = _get_linode_cli_keys (
697+ keys ["data" ], key_lifespan , key_rotation_period , current_timestamp
698+ )
699+ _rotate_current_key_if_needed (client , linode_cli_keys )
700+ _delete_stale_keys (client , linode_cli_keys , cleanup_batch_size )
701+
702+ client .config .plugin_set_value (
703+ "last-key-cleanup-timestamp" , str (current_timestamp )
704+ )
705+ client .config .write_config ()
706+
707+ except Exception as e :
708+ print (
709+ "Unable to clean up stale linode-cli Object Storage keys" ,
710+ e ,
711+ file = sys .stderr ,
712+ )
713+
714+
715+ def _should_perform_key_cleanup (client : CLI ) -> bool :
716+ return client .config .plugin_get_config_value_or_set_default (
717+ "perform-key-cleanup" , True , bool
718+ )
719+
720+
721+ def _get_key_lifespan (client ) -> str :
722+ return client .config .plugin_get_config_value_or_set_default (
723+ "key-lifespan" , "30d"
724+ )
725+
726+
727+ def _get_key_rotation_period (client ) -> str :
728+ return client .config .plugin_get_config_value_or_set_default (
729+ "key-rotation-period-days" , "10d"
730+ )
731+
732+
733+ def _get_cleanup_batch_size (client ) -> int :
734+ return client .config .plugin_get_config_value_or_set_default (
735+ "key-cleanup-batch-size" , 10 , int
736+ )
737+
738+
739+ def _get_linode_cli_keys (
740+ keys_data : list ,
741+ key_lifespan : str ,
742+ key_rotation_period : str ,
743+ current_timestamp : int ,
744+ ) -> list :
745+
746+ stale_threshold = current_timestamp - parse_time (key_lifespan )
747+ rotation_threshold = current_timestamp - parse_time (key_rotation_period )
748+
749+ def extract_key_info (key : dict ) -> Optional [dict ]:
750+ match = re .match (r"^linode-cli-.+@.+-(\d{10})$" , key ["label" ])
751+ if not match :
752+ return None
753+ created_timestamp = int (match .group (1 ))
754+ is_stale = created_timestamp <= stale_threshold
755+ needs_rotation = is_stale or created_timestamp <= rotation_threshold
756+ return {
757+ "id" : key ["id" ],
758+ "label" : key ["label" ],
759+ "access_key" : key ["access_key" ],
760+ "created_timestamp" : created_timestamp ,
761+ "is_stale" : is_stale ,
762+ "needs_rotation" : needs_rotation ,
763+ }
764+
765+ return sorted (
766+ [info for key in keys_data if (info := extract_key_info (key ))],
767+ key = lambda k : k ["created_timestamp" ],
768+ )
769+
770+
771+ def _rotate_current_key_if_needed (client : CLI , linode_cli_keys : list ) -> None :
772+ current_access_key = client .config .plugin_get_value ("access-key" )
773+ key_to_rotate = next (
774+ (
775+ key_info
776+ for key_info in linode_cli_keys
777+ if key_info ["access_key" ] == current_access_key
778+ and key_info ["needs_rotation" ]
779+ ),
780+ None ,
781+ )
782+ if key_to_rotate :
783+ _delete_key (client , key_to_rotate ["id" ], key_to_rotate ["label" ])
784+ linode_cli_keys .remove (key_to_rotate )
785+ client .config .plugin_remove_option ("access-key" )
786+ client .config .plugin_remove_option ("secret-key" )
787+ client .config .write_config ()
788+
789+
790+ def _delete_stale_keys (
791+ client : CLI , linode_cli_keys : list , batch_size : int
792+ ) -> None :
793+ stale_keys = [k for k in linode_cli_keys if k ["is_stale" ]]
794+ for key_info in stale_keys [:batch_size ]:
795+ _delete_key (client , key_info ["id" ], key_info ["label" ])
796+
797+
798+ def _delete_key (client : CLI , key_id : str , label : str ) -> None :
799+ try :
800+ print (f"Deleting linode-cli Object Storage key: { label } " )
801+ status , _ = client .call_operation (
802+ "object-storage" , "keys-delete" , [str (key_id )]
803+ )
804+ if status != 200 :
805+ print (
806+ f"Failed to delete key: { label } ; status { status } " ,
807+ file = sys .stderr ,
808+ )
809+ except Exception as e :
810+ print (
811+ f"Exception occurred while deleting key: { label } ; { e } " ,
812+ file = sys .stderr ,
813+ )
0 commit comments