Skip to content

Commit 4140b46

Browse files
Added functionality to clean-up stale (e.g. longer than 30 days) Object Storage keys created by linode-cli.
1 parent 4d94db1 commit 4140b46

File tree

4 files changed

+526
-18
lines changed

4 files changed

+526
-18
lines changed

linodecli/configuration/config.py

Lines changed: 54 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
import argparse
66
import os
77
import sys
8-
from typing import Any, Dict, List, Optional
8+
from typing import Any, Dict, List, Optional, Type, TypeVar, cast
99

1010
from linodecli.exit_codes import ExitCodes
1111

@@ -27,6 +27,8 @@
2727

2828
ENV_TOKEN_NAME = "LINODE_CLI_TOKEN"
2929

30+
T = TypeVar("T")
31+
3032

3133
class CLIConfig:
3234
"""
@@ -216,13 +218,8 @@ def plugin_set_value(self, key: str, value: Any):
216218
:param value: The value to set for this key
217219
:type value: any
218220
"""
219-
if self.running_plugin is None:
220-
raise RuntimeError(
221-
"No running plugin to retrieve configuration for!"
222-
)
223-
224221
username = self.username or self.default_username()
225-
self.config.set(username, f"plugin-{self.running_plugin}-{key}", value)
222+
self.config.set(username, self._get_plugin_key(key), value)
226223

227224
def plugin_get_value(self, key: str) -> Optional[Any]:
228225
"""
@@ -238,15 +235,52 @@ def plugin_get_value(self, key: str) -> Optional[Any]:
238235
:returns: The value for this plugin for this key, or None if not set
239236
:rtype: any
240237
"""
238+
username = self.username or self.default_username() or "DEFAULT"
239+
return self.config.get(
240+
username, self._get_plugin_key(key), fallback=None
241+
)
242+
243+
def plugin_get_config_value_or_set_default(
244+
self, key: str, default: T, value_type: Type[T] = str
245+
) -> T:
246+
"""
247+
Retrieves a plugin option value of the given type from the config. If the
248+
value is not set, sets it to the provided default value and returns that.
249+
"""
250+
value = self.plugin_get_value(key)
251+
252+
if value is None:
253+
# option not set - set to default and store it in the config file
254+
value_as_str = (
255+
("yes" if default else "no")
256+
if value_type is bool
257+
else str(default)
258+
)
259+
self.plugin_set_value(key, value_as_str)
260+
self.write_config()
261+
return default
262+
263+
if value_type is bool:
264+
return self.parse_boolean(value)
265+
266+
return cast(T, value_type(value))
267+
268+
def plugin_remove_option(self, key: str):
269+
"""
270+
Removes a plugin configuration option.
271+
272+
:param key: The key of the option to remove
273+
"""
274+
username = self.username or self.default_username()
275+
self.config.remove_option(username, self._get_plugin_key(key))
276+
277+
def _get_plugin_key(self, key: str) -> str:
241278
if self.running_plugin is None:
242279
raise RuntimeError(
243280
"No running plugin to retrieve configuration for!"
244281
)
245282

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)
283+
return f"plugin-{self.running_plugin}-{key}"
250284

251285
# TODO: this is more of an argparsing function than it is a config function
252286
# might be better to move this to argparsing during refactor and just have
@@ -654,3 +688,12 @@ def get_custom_aliases(self) -> Dict[str, str]:
654688
if (self.config.has_section("custom_aliases"))
655689
else {}
656690
)
691+
692+
def parse_boolean(self, value: str) -> Optional[bool]:
693+
"""
694+
Parses a string config value into a boolean.
695+
696+
:param value: The string value to parse.
697+
:return: The parsed boolean value.
698+
"""
699+
return self.config.BOOLEAN_STATES.get(value.lower(), None)

linodecli/plugins/obj/__init__.py

Lines changed: 217 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,20 +4,21 @@
44
"""
55
import getpass
66
import os
7+
import re
78
import socket
89
import sys
910
import time
1011
from argparse import ArgumentParser
1112
from contextlib import suppress
1213
from datetime import datetime
1314
from math import ceil
14-
from typing import List
15+
from typing import List, Optional
1516

17+
from pytimeparse import parse as parse_time
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
@@ -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

581589
def _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+
)

pyproject.toml

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,11 @@ dependencies = [
1414
"openapi3",
1515
"requests",
1616
"PyYAML",
17-
"packaging",
18-
"rich",
17+
"packaging<25",
18+
"rich<14",
1919
"urllib3<3",
20-
"linode-metadata>=0.3.0"
20+
"linode-metadata>=0.3.0",
21+
"pytimeparse"
2122
]
2223
dynamic = ["version"]
2324

0 commit comments

Comments
 (0)