|
2 | 2 | """ |
3 | 3 | CLI Plugin for handling OBJ |
4 | 4 | """ |
| 5 | +import concurrent.futures |
5 | 6 | import getpass |
6 | 7 | import os |
| 8 | +import re |
7 | 9 | import socket |
8 | 10 | import sys |
9 | 11 | import time |
10 | 12 | from argparse import ArgumentParser |
11 | 13 | from contextlib import suppress |
12 | 14 | from datetime import datetime |
13 | 15 | from math import ceil |
14 | | -from typing import List |
| 16 | +from typing import List, Optional, Type, TypeVar, cast |
15 | 17 |
|
16 | 18 | from rich import print as rprint |
17 | 19 | from rich.table import Table |
18 | 20 |
|
19 | 21 | from linodecli.cli import CLI |
20 | | -from linodecli.configuration import _do_get_request |
21 | 22 | from linodecli.configuration.helpers import _default_text_input |
22 | 23 | from linodecli.exit_codes import ExitCodes |
23 | 24 | from linodecli.help_formatter import SortingHelpFormatter |
|
65 | 66 | HAS_BOTO = False |
66 | 67 |
|
67 | 68 |
|
| 69 | +T = TypeVar("T") |
| 70 | + |
| 71 | + |
68 | 72 | def generate_url(get_client, args, **kwargs): # pylint: disable=unused-argument |
69 | 73 | """ |
70 | 74 | Generates a URL to an object |
@@ -400,9 +404,10 @@ def call( |
400 | 404 | access_key = None |
401 | 405 | secret_key = None |
402 | 406 |
|
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 |
404 | 408 | if not is_help: |
405 | 409 | access_key, secret_key = get_credentials(context.client) |
| 410 | + _cleanup_keys(context.client) |
406 | 411 |
|
407 | 412 | cluster = parsed.cluster |
408 | 413 | if context.client.defaults: |
@@ -591,3 +596,125 @@ def _configure_plugin(client: CLI): |
591 | 596 | if cluster: |
592 | 597 | client.config.plugin_set_value("cluster", cluster) |
593 | 598 | 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