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