diff --git a/linodecli/plugins/obj/__init__.py b/linodecli/plugins/obj/__init__.py index 6952487f9..ecbd39456 100644 --- a/linodecli/plugins/obj/__init__.py +++ b/linodecli/plugins/obj/__init__.py @@ -15,6 +15,7 @@ from math import ceil from typing import List, Optional +from packaging import version from pytimeparse import parse as parse_time from rich import print as rprint from rich.table import Table @@ -32,6 +33,7 @@ ENV_ACCESS_KEY_NAME, ENV_SECRET_KEY_NAME, INCOMING_DATE_FORMAT, + MIN_BOTOCORE_VERSION, MULTIPART_UPLOAD_CHUNK_SIZE_DEFAULT, NO_ACCESS_ERROR, NO_SCOPES_ERROR, @@ -59,6 +61,7 @@ try: import boto3 + import botocore from botocore.config import Config from botocore.exceptions import ClientError @@ -68,6 +71,29 @@ CLUSTER_KEY = "cluster" + + +def _check_botocore_version(): + """ + Exits with an upgrade hint if the installed botocore is too old for the + options the S3 client is configured with. + """ + if version.parse(botocore.__version__) >= version.parse( + MIN_BOTOCORE_VERSION + ): + return + + print( + f"This plugin requires boto3 {MIN_BOTOCORE_VERSION} or later, but " + f"botocore {botocore.__version__} is installed. Please upgrade it by " + "running 'pip3 install --upgrade boto3', or reinstall the CLI with " + "its Object Storage dependencies: " + "'pip3 install --upgrade \"linode-cli[obj]\"'", + file=sys.stderr, + ) + sys.exit(ExitCodes.REQUEST_FAILED) + + KEY_CLEANUP_ENABLED_KEY = "key-cleanup-enabled" KEY_LIFESPAN_KEY = "key-lifespan" KEY_ROTATION_PERIOD_KEY = "key-rotation-period" @@ -430,6 +456,8 @@ def call( ExitCodes.REQUEST_FAILED ) # requirements not met - we can't go on + _check_botocore_version() + parser = get_obj_args_parser() parsed, args = parser.parse_known_args(args) diff --git a/linodecli/plugins/obj/config.py b/linodecli/plugins/obj/config.py index 0e287f56c..a879ef73f 100644 --- a/linodecli/plugins/obj/config.py +++ b/linodecli/plugins/obj/config.py @@ -28,6 +28,10 @@ # for help commands PLUGIN_BASE = "linode-cli obj" +# the oldest botocore that accepts the checksum options the S3 client is +# configured with; boto3 releases use the matching botocore version +MIN_BOTOCORE_VERSION = "1.36.0" + columns = shutil.get_terminal_size(fallback=(80, 24)).columns PROGRESS_BAR_WIDTH = columns - 20 if columns > 30 else columns diff --git a/tests/unit/test_plugin_obj.py b/tests/unit/test_plugin_obj.py index 608508099..affb80812 100644 --- a/tests/unit/test_plugin_obj.py +++ b/tests/unit/test_plugin_obj.py @@ -1,9 +1,11 @@ import time -from unittest.mock import patch +from unittest.mock import Mock, patch +import pytest from pytest import CaptureFixture from linodecli import CLI, plugins +from linodecli.exit_codes import ExitCodes from linodecli.plugins import obj from linodecli.plugins.obj import get_obj_args_parser, helpers, print_help @@ -336,3 +338,40 @@ def call_operation_side_effect(resource, action, *args, **kwargs): assert ( not delete_calls ), "Cleanup should not be performed when key-cleanup-enabled is False" + + +@pytest.mark.parametrize("botocore_version", ["1.36.0", "1.43.1"]) +def test_check_botocore_version_accepts_supported(botocore_version: str): + with patch.object(obj.botocore, "__version__", botocore_version): + obj._check_botocore_version() + + +def test_check_botocore_version_rejects_outdated(capsys: CaptureFixture): + with ( + patch.object(obj.botocore, "__version__", "1.35.99"), + pytest.raises(SystemExit) as exc, + ): + obj._check_botocore_version() + + assert exc.value.code == ExitCodes.REQUEST_FAILED + err = capsys.readouterr().err + assert "requires boto3 1.36.0 or later" in err + assert "botocore 1.35.99 is installed" in err + assert "pip3 install --upgrade boto3" in err + + +def test_obj_action_with_outdated_botocore_exits_before_client(): + command = Mock() + with ( + patch.object(obj.botocore, "__version__", "1.35.99"), + patch("linodecli.plugins.obj.CLI") as MockCLI, + patch.dict(obj.COMMAND_MAP, {"ls": command}), + pytest.raises(SystemExit) as exc, + ): + obj.call( + ["ls", "bucket"], + plugins.PluginContext("12345", MockCLI.return_value), + ) + + assert exc.value.code == ExitCodes.REQUEST_FAILED + command.assert_not_called()