|
4 | 4 | """ |
5 | 5 | import getpass |
6 | 6 | import os |
| 7 | +import re |
7 | 8 | import socket |
8 | 9 | import sys |
9 | 10 | import time |
10 | 11 | from argparse import ArgumentParser |
11 | 12 | from contextlib import suppress |
12 | 13 | from datetime import datetime |
13 | 14 | from math import ceil |
14 | | -from typing import List |
| 15 | +from typing import List, Optional |
15 | 16 |
|
| 17 | +from pytimeparse import parse as parse_time |
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 |
@@ -322,6 +323,11 @@ def get_obj_args_parser(): |
322 | 323 | type=str, |
323 | 324 | help="The cluster to use for the operation", |
324 | 325 | ) |
| 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 | + ) |
325 | 331 |
|
326 | 332 | return parser |
327 | 333 |
|
@@ -400,8 +406,10 @@ def call( |
400 | 406 | access_key = None |
401 | 407 | secret_key = None |
402 | 408 |
|
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 |
404 | 410 | if not is_help: |
| 411 | + if not parsed.skip_key_cleanup: |
| 412 | + _cleanup_keys(context.client) |
405 | 413 | access_key, secret_key = get_credentials(context.client) |
406 | 414 |
|
407 | 415 | cluster = parsed.cluster |
@@ -591,3 +599,155 @@ def _configure_plugin(client: CLI): |
591 | 599 | if cluster: |
592 | 600 | client.config.plugin_set_value("cluster", cluster) |
593 | 601 | client.config.write_config() |
| 602 | + |
| 603 | + |
| 604 | +def _cleanup_keys(client: CLI) -> None: |
| 605 | + """ |
| 606 | + Cleans up stale linode-cli generated object storage keys. |
| 607 | + """ |
| 608 | + try: |
| 609 | + if not _should_perform_key_cleanup(client): |
| 610 | + return |
| 611 | + |
| 612 | + current_timestamp = int(time.time()) |
| 613 | + last_cleanup = client.config.plugin_get_value( |
| 614 | + "last-key-cleanup-timestamp" |
| 615 | + ) |
| 616 | + if ( |
| 617 | + last_cleanup |
| 618 | + and int(last_cleanup) > current_timestamp - 24 * 60 * 60 |
| 619 | + ): |
| 620 | + # if we did a cleanup in the last 24 hours, skip |
| 621 | + return |
| 622 | + |
| 623 | + print( |
| 624 | + "Cleaning up old linode-cli Object Storage keys\n", file=sys.stderr |
| 625 | + ) |
| 626 | + status, keys = client.call_operation("object-storage", "keys-list") |
| 627 | + if status != 200: |
| 628 | + print( |
| 629 | + "Failed to list object storage keys for cleanup\n", |
| 630 | + file=sys.stderr, |
| 631 | + ) |
| 632 | + return |
| 633 | + |
| 634 | + key_lifespan = _get_key_lifespan(client) |
| 635 | + key_rotation_period = _get_key_rotation_period(client) |
| 636 | + cleanup_batch_size = _get_cleanup_batch_size(client) |
| 637 | + |
| 638 | + linode_cli_keys = _get_linode_cli_keys( |
| 639 | + keys["data"], key_lifespan, key_rotation_period, current_timestamp |
| 640 | + ) |
| 641 | + _rotate_current_key_if_needed(client, linode_cli_keys) |
| 642 | + _delete_stale_keys(client, linode_cli_keys, cleanup_batch_size) |
| 643 | + |
| 644 | + client.config.plugin_set_value( |
| 645 | + "last-key-cleanup-timestamp", str(current_timestamp) |
| 646 | + ) |
| 647 | + client.config.write_config() |
| 648 | + |
| 649 | + except Exception as e: |
| 650 | + print( |
| 651 | + "Unable to clean up stale linode-cli Object Storage keys\n", |
| 652 | + e, |
| 653 | + file=sys.stderr, |
| 654 | + ) |
| 655 | + |
| 656 | + |
| 657 | +def _should_perform_key_cleanup(client: CLI) -> bool: |
| 658 | + return client.config.plugin_get_config_value_or_set_default( |
| 659 | + "perform-key-cleanup", True, bool |
| 660 | + ) |
| 661 | + |
| 662 | + |
| 663 | +def _get_key_lifespan(client) -> str: |
| 664 | + return client.config.plugin_get_config_value_or_set_default( |
| 665 | + "key-lifespan-days", "30d" |
| 666 | + ) |
| 667 | + |
| 668 | + |
| 669 | +def _get_key_rotation_period(client) -> str: |
| 670 | + return client.config.plugin_get_config_value_or_set_default( |
| 671 | + "key-rotation-period-days", "10d" |
| 672 | + ) |
| 673 | + |
| 674 | + |
| 675 | +def _get_cleanup_batch_size(client) -> int: |
| 676 | + return client.config.plugin_get_config_value_or_set_default( |
| 677 | + "key-cleanup-batch-size", 10, int |
| 678 | + ) |
| 679 | + |
| 680 | + |
| 681 | +def _get_linode_cli_keys( |
| 682 | + keys_data: list, |
| 683 | + key_lifespan: str, |
| 684 | + key_rotation_period: str, |
| 685 | + current_timestamp: int, |
| 686 | +) -> list: |
| 687 | + |
| 688 | + stale_threshold = current_timestamp - parse_time(key_lifespan) |
| 689 | + rotation_threshold = current_timestamp - parse_time(key_rotation_period) |
| 690 | + |
| 691 | + def extract_key_info(key: dict) -> Optional[dict]: |
| 692 | + match = re.match(r"^linode-cli-.+@.+-(\d{10})$", key["label"]) |
| 693 | + if not match: |
| 694 | + return None |
| 695 | + created_timestamp = int(match.group(1)) |
| 696 | + return { |
| 697 | + "id": key["id"], |
| 698 | + "label": key["label"], |
| 699 | + "access_key": key["access_key"], |
| 700 | + "created_timestamp": created_timestamp, |
| 701 | + "is_stale": created_timestamp <= stale_threshold, |
| 702 | + "needs_rotation": created_timestamp <= rotation_threshold, |
| 703 | + } |
| 704 | + |
| 705 | + return sorted( |
| 706 | + [info for key in keys_data if (info := extract_key_info(key))], |
| 707 | + key=lambda k: k["created_timestamp"], |
| 708 | + ) |
| 709 | + |
| 710 | + |
| 711 | +def _rotate_current_key_if_needed(client: CLI, linode_cli_keys: list) -> None: |
| 712 | + current_access_key = client.config.plugin_get_value("access-key") |
| 713 | + key_to_rotate = next( |
| 714 | + ( |
| 715 | + key_info |
| 716 | + for key_info in linode_cli_keys |
| 717 | + if key_info["access_key"] == current_access_key |
| 718 | + and key_info["needs_rotation"] |
| 719 | + ), |
| 720 | + None, |
| 721 | + ) |
| 722 | + if key_to_rotate: |
| 723 | + _delete_key(client, key_to_rotate["id"], key_to_rotate["label"]) |
| 724 | + linode_cli_keys.remove(key_to_rotate) |
| 725 | + client.config.plugin_remove_option("access-key") |
| 726 | + client.config.plugin_remove_option("secret-key") |
| 727 | + client.config.write_config() |
| 728 | + |
| 729 | + |
| 730 | +def _delete_stale_keys( |
| 731 | + client: CLI, linode_cli_keys: list, batch_size: int |
| 732 | +) -> None: |
| 733 | + stale_keys = [k for k in linode_cli_keys if k["is_stale"]] |
| 734 | + for key_info in stale_keys[:batch_size]: |
| 735 | + _delete_key(client, key_info["id"], key_info["label"]) |
| 736 | + |
| 737 | + |
| 738 | +def _delete_key(client: CLI, key_id: str, label: str) -> None: |
| 739 | + try: |
| 740 | + print(f"Deleting linode-cli Object Storage key: {label}\n") |
| 741 | + status, _ = client.call_operation( |
| 742 | + "object-storage", "keys-delete", [str(key_id)] |
| 743 | + ) |
| 744 | + if status != 200: |
| 745 | + print( |
| 746 | + f"Failed to delete key: {label}; status {status}\n", |
| 747 | + file=sys.stderr, |
| 748 | + ) |
| 749 | + except Exception as e: |
| 750 | + print( |
| 751 | + f"Exception occurred while deleting key: {label}; {e}\n", |
| 752 | + file=sys.stderr, |
| 753 | + ) |
0 commit comments