Skip to content

Commit 31ebdaa

Browse files
new: STORIF-108: Added functionality to clean-up stale (e.g. longer than 30 days) Object Storage keys created by linode-cli.
1 parent 4d94db1 commit 31ebdaa

File tree

2 files changed

+156
-13
lines changed

2 files changed

+156
-13
lines changed

linodecli/configuration/config.py

Lines changed: 26 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -216,13 +216,8 @@ def plugin_set_value(self, key: str, value: Any):
216216
:param value: The value to set for this key
217217
:type value: any
218218
"""
219-
if self.running_plugin is None:
220-
raise RuntimeError(
221-
"No running plugin to retrieve configuration for!"
222-
)
223-
224219
username = self.username or self.default_username()
225-
self.config.set(username, f"plugin-{self.running_plugin}-{key}", value)
220+
self.config.set(username, self._get_plugin_key(key), value)
226221

227222
def plugin_get_value(self, key: str) -> Optional[Any]:
228223
"""
@@ -238,15 +233,27 @@ def plugin_get_value(self, key: str) -> Optional[Any]:
238233
:returns: The value for this plugin for this key, or None if not set
239234
:rtype: any
240235
"""
236+
username = self.username or self.default_username() or "DEFAULT"
237+
return self.config.get(
238+
username, self._get_plugin_key(key), fallback=None
239+
)
240+
241+
def plugin_remove_option(self, key: str):
242+
"""
243+
Removes a plugin configuration option.
244+
245+
:param key: The key of the option to remove
246+
"""
247+
username = self.username or self.default_username()
248+
self.config.remove_option(username, self._get_plugin_key(key))
249+
250+
def _get_plugin_key(self, key: str) -> str:
241251
if self.running_plugin is None:
242252
raise RuntimeError(
243253
"No running plugin to retrieve configuration for!"
244254
)
245255

246-
username = self.username or self.default_username() or "DEFAULT"
247-
full_key = f"plugin-{self.running_plugin}-{key}"
248-
249-
return self.config.get(username, full_key, fallback=None)
256+
return f"plugin-{self.running_plugin}-{key}"
250257

251258
# TODO: this is more of an argparsing function than it is a config function
252259
# might be better to move this to argparsing during refactor and just have
@@ -654,3 +661,12 @@ def get_custom_aliases(self) -> Dict[str, str]:
654661
if (self.config.has_section("custom_aliases"))
655662
else {}
656663
)
664+
665+
def parse_boolean(self, value: str) -> Optional[bool]:
666+
"""
667+
Parses a string config value into a boolean.
668+
669+
:param value: The string value to parse.
670+
:return: The parsed boolean value.
671+
"""
672+
return self.config.BOOLEAN_STATES.get(value.lower(), None)

linodecli/plugins/obj/__init__.py

Lines changed: 130 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,22 +2,23 @@
22
"""
33
CLI Plugin for handling OBJ
44
"""
5+
import concurrent.futures
56
import getpass
67
import os
8+
import re
79
import socket
810
import sys
911
import time
1012
from argparse import ArgumentParser
1113
from contextlib import suppress
1214
from datetime import datetime
1315
from math import ceil
14-
from typing import List
16+
from typing import List, Optional, Type, TypeVar, cast
1517

1618
from rich import print as rprint
1719
from rich.table import Table
1820

1921
from linodecli.cli import CLI
20-
from linodecli.configuration import _do_get_request
2122
from linodecli.configuration.helpers import _default_text_input
2223
from linodecli.exit_codes import ExitCodes
2324
from linodecli.help_formatter import SortingHelpFormatter
@@ -65,6 +66,9 @@
6566
HAS_BOTO = False
6667

6768

69+
T = TypeVar("T")
70+
71+
6872
def generate_url(get_client, args, **kwargs): # pylint: disable=unused-argument
6973
"""
7074
Generates a URL to an object
@@ -400,9 +404,10 @@ def call(
400404
access_key = None
401405
secret_key = None
402406

403-
# make a client, but only if we weren't printing help
407+
# make a client and clean-up keys, but only if we weren't printing help
404408
if not is_help:
405409
access_key, secret_key = get_credentials(context.client)
410+
_cleanup_keys(context.client)
406411

407412
cluster = parsed.cluster
408413
if context.client.defaults:
@@ -591,3 +596,125 @@ def _configure_plugin(client: CLI):
591596
if cluster:
592597
client.config.plugin_set_value("cluster", cluster)
593598
client.config.write_config()
599+
600+
601+
def _cleanup_keys(client: CLI):
602+
"""
603+
Cleans up stale linode-cli generated object storage keys.
604+
"""
605+
try:
606+
perform_key_cleanup = _get_config_value_or_set_default(
607+
client, "perform-key-cleanup", True, bool
608+
)
609+
if not perform_key_cleanup:
610+
return
611+
612+
status, keys = client.call_operation("object-storage", "keys-list")
613+
if status != 200:
614+
print(
615+
"Failed to list object storage keys for cleanup\n",
616+
file=sys.stderr,
617+
)
618+
return
619+
620+
key_lifespan_in_days = _get_config_value_or_set_default(
621+
client, "key-lifespan-in-days", 30, int
622+
)
623+
key_rotation_period = _get_config_value_or_set_default(
624+
client, "key-rotation-period", 10, int
625+
)
626+
cleanup_batch_size = _get_config_value_or_set_default(
627+
client, "key-cleanup-batch-size", 10, int
628+
)
629+
630+
def extract_key_info(key: dict) -> Optional[dict]:
631+
match = re.match(r"^linode-cli-.+@.+-(\d{10})$", key["label"])
632+
if not match:
633+
return None
634+
created_timestamp = int(match.group(1))
635+
current_timestamp = int(time.time())
636+
stale_threshold = (
637+
current_timestamp - key_lifespan_in_days * 24 * 60 * 60
638+
)
639+
rotation_threshold = (
640+
current_timestamp - key_rotation_period * 24 * 60 * 60
641+
)
642+
return {
643+
"id": key["id"],
644+
"label": key["label"],
645+
"access_key": key["access_key"],
646+
"created_timestamp": created_timestamp,
647+
"is_stale": created_timestamp <= stale_threshold,
648+
"needs_rotation": created_timestamp <= rotation_threshold,
649+
}
650+
651+
linode_cli_keys = sorted(
652+
[
653+
key_info
654+
for key in keys["data"]
655+
if (key_info := extract_key_info(key)) and key_info["is_stale"]
656+
],
657+
key=lambda x: x["created_timestamp"],
658+
)
659+
660+
current_access_key = client.config.plugin_get_value("access-key")
661+
current_key_needs_rotation = any(
662+
key_info["access_key"] == current_access_key
663+
and key_info["needs_rotation"]
664+
for key_info in linode_cli_keys
665+
)
666+
if current_key_needs_rotation:
667+
client.config.plugin_remove_option("access-key")
668+
client.config.plugin_remove_option("secret-key")
669+
client.config.write_config()
670+
671+
def delete_key(key_info):
672+
try:
673+
status, _ = client.call_operation(
674+
"object-storage", "keys-delete", [str(key_info["id"])]
675+
)
676+
if status != 200:
677+
print(
678+
f"Failed to delete key: {key_info['label']}; status {status}\n",
679+
file=sys.stderr,
680+
)
681+
except Exception as e:
682+
print(
683+
f"Exception occurred while deleting key: {key_info['label']}; {e}\n",
684+
file=sys.stderr,
685+
)
686+
687+
# concurrently delete keys up to the batch size
688+
with concurrent.futures.ThreadPoolExecutor() as executor:
689+
executor.map(delete_key, linode_cli_keys[:cleanup_batch_size])
690+
691+
except Exception as e:
692+
print(
693+
"Unable to clean up stale linode-cli Object Storage keys\n",
694+
e,
695+
file=sys.stderr,
696+
)
697+
698+
699+
def _get_config_value_or_set_default(
700+
client: CLI, key: str, default: T, value_type: Type[T] = str
701+
) -> T:
702+
"""
703+
Retrieves a plugin option value of the given type from the config. If the
704+
value is not set, sets it to the provided default value and returns that.
705+
"""
706+
value = client.config.plugin_get_value(key)
707+
708+
if value is None:
709+
# option not set - set to default and store it in the config file
710+
value_as_str = (
711+
("on" if default else "off") if value_type is bool else str(default)
712+
)
713+
client.config.plugin_set_value(key, value_as_str)
714+
client.config.write_config()
715+
return default
716+
717+
if value_type is bool:
718+
return client.config.parse_boolean(value)
719+
720+
return cast(T, value_type(value))

0 commit comments

Comments
 (0)