diff --git a/linodecli/overrides.py b/linodecli/overrides.py index dff913877..6dbb8494f 100644 --- a/linodecli/overrides.py +++ b/linodecli/overrides.py @@ -71,6 +71,18 @@ def handle_databases_postgres_config_view( return databases_postgres_config_view_output(json_data) +@output_override("databases", "valkey-config-view", OutputMode.table) +def handle_databases_valkey_config_view( + operation, output_handler, json_data +) -> bool: + # pylint: disable=unused-argument + """ + Override the output of 'linode-cli databases valkey-config-view' + to properly display the valkey engine config. + """ + return databases_valkey_config_view_output(json_data) + + @output_override("domains", "zone-file", OutputMode.delimited) def handle_domains_zone_file(operation, output_handler, json_data) -> bool: # pylint: disable=unused-argument @@ -317,11 +329,26 @@ def pg_view_output(json_data) -> bool: return False -def add_param_row(output, param_name, param_data): +def _format_param_type(param_type) -> str: + """ + Format a DB Config parameter type, which may be a list for nullable + parameters (e.g. ["integer", "null"] -> "integer | null"). + """ + if isinstance(param_type, list): + return " | ".join(str(t) for t in param_type) + + return str(param_type) + + +def add_param_row(output, param_name, param_data, format_nullable_type=False): """ Construct and add a row to the output table for DB Config view overrides. """ - param_type = str(param_data.get("type", "")) + param_type = param_data.get("type", "") + if format_nullable_type: + param_type = _format_param_type(param_type) + else: + param_type = str(param_type) example = str(param_data.get("example", "")) minimum = str(param_data.get("minimum", "")) maximum = str(param_data.get("maximum", "")) @@ -409,3 +436,29 @@ def databases_postgres_config_view_output(json_data) -> bool: console.print(output) return False + + +def databases_valkey_config_view_output(json_data) -> bool: + """ + Parse and format the Valkey configuration output table. + """ + output = Table(header_style="bold", show_lines=True) + + output.add_column("Parameter", style="bold") + output.add_column("Type", justify="center") + output.add_column("Example", justify="center") + output.add_column("Min", justify="center") + output.add_column("Max", justify="center") + output.add_column("Min Length", justify="center") + output.add_column("Max Length", justify="center") + output.add_column("Pattern", justify="center") + output.add_column("Requires Restart", justify="center") + output.add_column("Description", style="dim") + + for field, params in json_data.items(): + add_param_row(output, field, params, format_nullable_type=True) + + console = Console() + console.print(output) + + return False diff --git a/tests/integration/database/fixtures.py b/tests/integration/database/fixtures.py index c897b7fcd..14ec89d78 100644 --- a/tests/integration/database/fixtures.py +++ b/tests/integration/database/fixtures.py @@ -1,7 +1,11 @@ +import json + import pytest +from tests.integration.database.helpers import wait_for_database_status from tests.integration.helpers import ( BASE_CMDS, + DEFAULT_REGION, delete_target_id, exec_test_command, get_random_text, @@ -76,3 +80,96 @@ def mysql_cluster(): delete_target_id( target="databases", delete_command="mysql-delete", id=database_id ) + + +@pytest.fixture(scope="session") +def valkey_cluster(): + commands = BASE_CMDS["databases"] + engines = json.loads(exec_test_command(commands + ["engines", "--json"])) + engine = next( + (item["id"] for item in engines if item["id"].startswith("valkey/")), + None, + ) + assert engine is not None, "No Valkey engine is available" + + types = json.loads(exec_test_command(commands + ["types", "--json"])) + plans = [ + (item["id"], item["engines"]["price"]["hourly"]) + for item in types + if item.get("_split") == "valkey" and item["engines"]["quantity"] == 1 + ] + assert plans, "No single-node Valkey database type is available" + plan = min(plans, key=lambda entry: entry[1])[0] + + regions = json.loads( + exec_test_command(["linode-cli", "regions", "list", "--json"]) + ) + supported_regions = [ + item["id"] + for item in regions + if "Managed Databases" in item["capabilities"] + and item.get("status") == "ok" + ] + region = ( + DEFAULT_REGION + if DEFAULT_REGION in supported_regions + else next(iter(supported_regions), None) + ) + assert region is not None, "No available Managed Databases region found" + + label = get_random_text(8) + "-valkey" + created = json.loads( + exec_test_command( + commands + + [ + "valkey-create", + "--engine", + engine, + "--label", + label, + "--region", + region, + "--type", + plan, + "--engine_config.valkey_timeout", + "301", + "--engine_config.valkey_active_expire_effort", + "2", + "--engine_config.valkey_lfu_decay_time", + "2", + "--engine_config.valkey_lfu_log_factor", + "11", + "--engine_config.valkey_maxmemory_policy", + "allkeys-lru", + "--no-defaults", + "--json", + ] + ) + )[0] + database_id = created["id"] + + try: + wait_for_database_status("valkey", database_id, "active") + yield { + "id": database_id, + "engine": engine, + "label": label, + "region": region, + "type": plan, + "engine_config": { + "valkey_timeout": 301, + "valkey_active_expire_effort": 2, + "valkey_lfu_decay_time": 2, + "valkey_lfu_log_factor": 11, + "valkey_maxmemory_policy": "allkeys-lru", + }, + } + finally: + delete_target_id( + target="databases", + delete_command="valkey-delete", + id=str(database_id), + use_retry=True, + retries=5, + delay=15, + ) diff --git a/tests/integration/database/helpers.py b/tests/integration/database/helpers.py index 4ea37e2aa..33b8e363e 100644 --- a/tests/integration/database/helpers.py +++ b/tests/integration/database/helpers.py @@ -1,9 +1,46 @@ +import json +import subprocess + from tests.integration.helpers import ( BASE_CMDS, exec_test_command, + wait_for_condition, ) +def database_command(action, *args): + return BASE_CMDS["databases"] + [action, *map(str, args)] + + +def database_json(action, *args): + return json.loads( + exec_test_command(database_command(action, *args, "--json")) + ) + + +def private_database_json(action, database_id): + process = subprocess.run( + database_command(action, database_id, "--json"), + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + check=False, + ) + assert ( + process.returncode == 0 + ), f"{action} failed (exit {process.returncode})" + return json.loads(process.stdout)[0] + + +def wait_for_database_status(engine, database_id, status): + wait_for_condition( + 15, + 900, + lambda: database_json(f"{engine}-view", database_id)[0]["status"] + == status, + ) + + def get_db_type_id(): db_type_ids = exec_test_command( BASE_CMDS["databases"] diff --git a/tests/integration/database/test_database.py b/tests/integration/database/test_database.py index a80a4fe05..9f1a1eeb7 100644 --- a/tests/integration/database/test_database.py +++ b/tests/integration/database/test_database.py @@ -1,12 +1,27 @@ +import os + +import pytest + from tests.integration.database.fixtures import ( # noqa: F401 mysql_cluster, postgresql_cluster, + valkey_cluster, +) +from tests.integration.database.helpers import ( + database_command, + database_json, + get_db_type_id, + get_engine_id, + private_database_json, + wait_for_database_status, ) -from tests.integration.database.helpers import get_db_type_id, get_engine_id from tests.integration.helpers import ( BASE_CMDS, assert_headers_in_lines, + delete_target_id, exec_test_command, + get_random_text, + wait_for_condition, ) @@ -49,6 +64,26 @@ def test_postgresql_suspend_resume(postgresql_cluster): assert "Request failed: 400" not in res +def test_valkey_suspend_resume(valkey_cluster): + database_id = str(valkey_cluster["id"]) + wait_for_database_status("valkey", database_id, "active") + res = exec_test_command( + BASE_CMDS["databases"] + + ["valkey-suspend", database_id, "--text", "--delimiter=,"] + ) + assert "Request failed: 400" not in res + + try: + wait_for_database_status("valkey", database_id, "suspended") + finally: + res = exec_test_command( + BASE_CMDS["databases"] + + ["valkey-resume", database_id, "--text", "--delimiter=,"] + ) + assert "Request failed: 400" not in res + wait_for_database_status("valkey", database_id, "active") + + def test_engines_view(): engine_id = get_engine_id() res = exec_test_command( @@ -97,6 +132,18 @@ def test_postgresql_list(): assert_headers_in_lines(headers, lines) +def test_valkey_list(): + res = exec_test_command( + BASE_CMDS["databases"] + ["valkey-list", "--text", "--delimiter=,"] + ) + + lines = res.splitlines() + + headers = ["id", "label", "region"] + + assert_headers_in_lines(headers, lines) + + def test_databases_types(): res = exec_test_command( BASE_CMDS["databases"] + ["types", "--text", "--delimiter=,"] @@ -116,3 +163,98 @@ def test_databases_type_view(): headers = ["id", "label", "_split"] assert_headers_in_lines(headers, lines) + + +def test_valkey_discovery(valkey_cluster): + engine = valkey_cluster["engine"] + engines = database_json("engines") + assert any(item["id"] == engine for item in engines) + assert database_json("engine-view", engine)[0]["id"] == engine + + database_type = valkey_cluster["type"] + types = database_json("types") + assert any( + item["id"] == database_type and item["_split"] == "valkey" + for item in types + ) + type_details = database_json("type-view", database_type) + assert any(item["_split"] == "valkey" for item in type_details) + + +def test_valkey_in_databases_list(valkey_cluster): + assert any( + item["id"] == valkey_cluster["id"] for item in database_json("list") + ) + + +def test_valkey_credentials_and_ssl(valkey_cluster): + database_id = valkey_cluster["id"] + wait_for_database_status("valkey", database_id, "active") + credentials = private_database_json("valkey-creds-view", database_id) + assert isinstance(credentials.get("username"), str) + assert credentials["username"] + assert isinstance(credentials.get("password"), str) + assert credentials["password"] + + certificate = private_database_json("valkey-ssl-cert", database_id) + assert isinstance(certificate.get("ca_certificate"), str) + assert certificate["ca_certificate"] + + exec_test_command(database_command("valkey-creds-reset", database_id)) + wait_for_condition( + 15, + 300, + lambda: private_database_json("valkey-creds-view", database_id).get( + "password" + ) + not in (None, "", credentials["password"]), + ) + + +def test_valkey_patch(valkey_cluster): + database_id = valkey_cluster["id"] + wait_for_database_status("valkey", database_id, "active") + exec_test_command(database_command("valkey-patch", database_id)) + assert database_json("valkey-view", database_id)[0]["id"] == database_id + + +@pytest.mark.skipif( + os.environ.get("RUN_LONG_TESTS") != "True", + reason="Set RUN_LONG_TESTS=True to test Valkey snapshot restore", +) +def test_valkey_fork_from_snapshot(valkey_cluster): + database_id = valkey_cluster["id"] + wait_for_condition( + 30, + 1800, + lambda: bool( + database_json("valkey-view", database_id)[0][ + "available_restore_times" + ] + ), + ) + restore_time = database_json("valkey-view", database_id)[0][ + "available_restore_times" + ][-1] + fork = database_json( + "valkey-create", + "--fork.source", + database_id, + "--fork.restore_time", + restore_time, + "--label", + get_random_text(8) + "-valkey-fork", + "--no-defaults", + )[0] + try: + wait_for_database_status("valkey", fork["id"], "active") + assert database_json("valkey-view", fork["id"])[0]["engine"] == "valkey" + finally: + delete_target_id( + "databases", + str(fork["id"]), + delete_command="valkey-delete", + use_retry=True, + retries=5, + delay=15, + ) diff --git a/tests/integration/database/test_database_engine_config.py b/tests/integration/database/test_database_engine_config.py index f0611901c..78c595276 100644 --- a/tests/integration/database/test_database_engine_config.py +++ b/tests/integration/database/test_database_engine_config.py @@ -2,7 +2,10 @@ import pytest +from tests.integration.database.fixtures import valkey_cluster # noqa: F401 from tests.integration.database.helpers import ( + database_command, + database_json, get_expected_keys_pg_engine_config, ) from tests.integration.helpers import ( @@ -10,9 +13,114 @@ delete_target_id, exec_test_command, get_random_text, + wait_for_condition, ) +def test_valkey_engine_config_view(monkeypatch): + config = database_json("valkey-config-view")[0] + expected_types = { + "backup_hour": {"integer", "null"}, + "backup_minute": {"integer", "null"}, + "frequent_snapshots": {"boolean"}, + "valkey_acl_channels_default": {"string"}, + "valkey_active_expire_effort": {"integer"}, + "valkey_activedefrag": {"boolean"}, + "valkey_lfu_decay_time": {"integer"}, + "valkey_lfu_log_factor": {"integer"}, + "valkey_maxmemory_policy": {"string", "null"}, + "valkey_number_of_databases": {"integer"}, + "valkey_persistence": {"string"}, + "valkey_pubsub_client_output_buffer_limit": {"integer"}, + "valkey_timeout": {"integer"}, + } + for key, types in expected_types.items(): + actual_type = config[key]["type"] + assert ( + set(actual_type if isinstance(actual_type, list) else [actual_type]) + == types + ) + assert isinstance(config[key]["requires_restart"], bool) + + assert config["backup_hour"]["minimum"] == 0 + assert config["backup_hour"]["maximum"] == 23 + assert config["backup_minute"]["minimum"] == 0 + assert config["backup_minute"]["maximum"] == 59 + assert config["valkey_active_expire_effort"]["minimum"] == 1 + assert config["valkey_active_expire_effort"]["maximum"] == 10 + assert config["valkey_lfu_decay_time"]["minimum"] == 1 + assert config["valkey_lfu_decay_time"]["maximum"] == 120 + assert config["valkey_lfu_log_factor"]["minimum"] == 0 + assert config["valkey_lfu_log_factor"]["maximum"] == 100 + assert config["valkey_number_of_databases"]["minimum"] == 1 + assert config["valkey_number_of_databases"]["maximum"] == 128 + assert config["valkey_number_of_databases"]["requires_restart"] is True + assert set(config["valkey_persistence"]["enum"]) == {"off", "rdb"} + assert config["valkey_persistence"]["requires_restart"] is True + assert "allkeys-lru" in config["valkey_maxmemory_policy"]["enum"] + assert config["valkey_timeout"]["minimum"] == 0 + assert config["valkey_timeout"]["maximum"] == 2073600 + assert config["valkey_timeout"]["requires_restart"] is False + + monkeypatch.setenv("COLUMNS", "240") + table = exec_test_command(database_command("valkey-config-view")) + for key in expected_types: + assert key in table + + +def test_valkey_list_with_engine_config(valkey_cluster): + database_id = valkey_cluster["id"] + databases = database_json("valkey-list") + listed = next(item for item in databases if item["id"] == database_id) + for key, value in valkey_cluster["engine_config"].items(): + assert listed["engine_config"][key] == value + + +def test_valkey_db_view_with_engine_config(valkey_cluster): + database = database_json("valkey-view", valkey_cluster["id"])[0] + assert database["engine"] == "valkey" + assert database["label"] == valkey_cluster["label"] + assert database["region"] == valkey_cluster["region"] + assert database["type"] == valkey_cluster["type"] + for key, value in valkey_cluster["engine_config"].items(): + assert database["engine_config"][key] == value + + +def test_valkey_db_engine_config_update(valkey_cluster): + database_id = valkey_cluster["id"] + database_json( + "valkey-update", + database_id, + "--engine_config.valkey_timeout", + "302", + "--engine_config.valkey_active_expire_effort", + "3", + "--engine_config.valkey_lfu_decay_time", + "3", + "--engine_config.valkey_lfu_log_factor", + "12", + "--engine_config.valkey_maxmemory_policy", + "allkeys-lfu", + "--no-defaults", + ) + updated_config = { + "valkey_timeout": 302, + "valkey_active_expire_effort": 3, + "valkey_lfu_decay_time": 3, + "valkey_lfu_log_factor": 12, + "valkey_maxmemory_policy": "allkeys-lfu", + } + + def config_matches(): + config = database_json("valkey-view", database_id)[0]["engine_config"] + return all( + config.get(key) == value for key, value in updated_config.items() + ) + + wait_for_condition(15, 900, config_matches) + valkey_cluster["engine_config"].update(updated_config) + + @pytest.fixture(scope="session") def postgresql_db_engine_config(linode_cloud_firewall): label = get_random_text(5) + "-postgresql-db" diff --git a/tests/unit/test_overrides.py b/tests/unit/test_overrides.py index f5533b6b6..6387334cb 100644 --- a/tests/unit/test_overrides.py +++ b/tests/unit/test_overrides.py @@ -3,7 +3,29 @@ from unittest.mock import patch from linodecli import OutputMode -from linodecli.overrides import OUTPUT_OVERRIDES +from linodecli.overrides import ( + OUTPUT_OVERRIDES, + databases_valkey_config_view_output, +) + + +class MockConsole: + """ + Mock rich console that captures the most recently printed table. + """ + + table = None + + def print(self, output): + MockConsole.table = output + + +def _get_printed_parameter_names(): + return list(MockConsole.table.columns[0]._cells) + + +def _get_printed_parameter_types(): + return list(MockConsole.table.columns[1]._cells) class TestOverrides: @@ -115,3 +137,35 @@ def test_types_region_prices_list( rows = stdout_buf.getvalue().split("\n") # assert that the overridden table has the new columns assert len(rows[1].split(rows[1][0])) == 15 + + def test_valkey_config_output_handles_flat_parameters(self): + response_json = { + "backup_minute": { + "type": ["integer", "null"], + "example": 30, + "minimum": 0, + "maximum": 59, + "requires_restart": False, + "description": "Backup minute.", + }, + "valkey_persistence": { + "type": "string", + "enum": ["off", "rdb"], + "requires_restart": True, + "description": "Persistence mode.", + }, + } + + with patch("linodecli.overrides.Console", return_value=MockConsole()): + assert databases_valkey_config_view_output(response_json) is False + + assert _get_printed_parameter_names() == [ + "backup_minute", + "valkey_persistence", + ] + assert _get_printed_parameter_types() == ["integer | null", "string"] + assert ( + "databases", + "valkey-config-view", + OutputMode.table, + ) in OUTPUT_OVERRIDES