diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 61b45deb..1509924e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,7 +7,7 @@ jobs: docker-build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Build the Docker image run: docker build . --file Dockerfile --tag linode/cli:$(date +%s) --build-arg="github_token=$GITHUB_TOKEN" env: @@ -17,7 +17,7 @@ jobs: runs-on: ubuntu-latest steps: - name: checkout repo - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: setup python 3 uses: actions/setup-python@v5 @@ -37,7 +37,7 @@ jobs: python-version: [ "3.9","3.10","3.11", "3.12", "3.13" ] steps: - name: Clone Repository - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Setup Python uses: actions/setup-python@v5 @@ -59,7 +59,7 @@ jobs: runs-on: windows-latest steps: - name: Clone Repository - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Setup Python uses: actions/setup-python@v5 diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index e2ccc10d..330afa4b 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -23,7 +23,7 @@ jobs: build-mode: none steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Initialize CodeQL uses: github/codeql-action/init@v3 diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index bf9f46d8..ec737e92 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -12,7 +12,7 @@ jobs: runs-on: ubuntu-latest steps: - name: 'Checkout repository' - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: 'Dependency Review' uses: actions/dependency-review-action@v4 with: diff --git a/.github/workflows/e2e-suite-windows.yml b/.github/workflows/e2e-suite-windows.yml index 6e98e23b..23def45b 100644 --- a/.github/workflows/e2e-suite-windows.yml +++ b/.github/workflows/e2e-suite-windows.yml @@ -31,7 +31,7 @@ jobs: steps: # Check out merge commit - name: Checkout PR - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: ref: ${{ inputs.sha }} @@ -109,7 +109,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: fetch-depth: 0 submodules: 'recursive' diff --git a/.github/workflows/e2e-suite.yml b/.github/workflows/e2e-suite.yml index 3149645b..1134195e 100644 --- a/.github/workflows/e2e-suite.yml +++ b/.github/workflows/e2e-suite.yml @@ -59,7 +59,7 @@ jobs: steps: - name: Checkout Repository with SHA if: ${{ inputs.sha != '' }} - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: fetch-depth: 0 submodules: 'recursive' @@ -67,7 +67,7 @@ jobs: - name: Checkout Repository without SHA if: ${{ inputs.sha == '' }} - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: fetch-depth: 0 submodules: 'recursive' @@ -170,7 +170,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: fetch-depth: 0 submodules: 'recursive' @@ -237,13 +237,13 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: fetch-depth: 0 submodules: 'recursive' - name: Download test report - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v5 with: name: test-report-file diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml index 30bcb195..7a3ee5f3 100644 --- a/.github/workflows/labeler.yml +++ b/.github/workflows/labeler.yml @@ -18,7 +18,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Run Labeler uses: crazy-max/ghaction-github-labeler@24d110aa46a59976b8a7f35518cb7f14f434c916 diff --git a/.github/workflows/nightly-smoke-tests.yml b/.github/workflows/nightly-smoke-tests.yml index 97bb2130..736da0f2 100644 --- a/.github/workflows/nightly-smoke-tests.yml +++ b/.github/workflows/nightly-smoke-tests.yml @@ -18,7 +18,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: fetch-depth: 0 submodules: 'recursive' diff --git a/.github/workflows/publish-wiki.yml b/.github/workflows/publish-wiki.yml index b063d021..666f7884 100644 --- a/.github/workflows/publish-wiki.yml +++ b/.github/workflows/publish-wiki.yml @@ -14,5 +14,5 @@ jobs: publish-wiki: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: Andrew-Chen-Wang/github-wiki-action@50650fccf3a10f741995523cf9708c53cec8912a # pin@v4.4.0 + - uses: actions/checkout@v5 + - uses: Andrew-Chen-Wang/github-wiki-action@2c80c13ee98aa43683bd77973ef4916e2eedf817 # pin@v5.0.1 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3fe99418..b38702c0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -28,7 +28,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Clone Repository - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: setup python 3 uses: actions/setup-python@v5 @@ -45,7 +45,7 @@ jobs: uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # pin@v3.11.1 - name: Login to Docker Hub - uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # pin@v3.4.0 + uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # pin@v3.5.0 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} @@ -86,7 +86,7 @@ jobs: environment: pypi-release steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Setup Python uses: actions/setup-python@v5 diff --git a/.github/workflows/remote-release-trigger.yml b/.github/workflows/remote-release-trigger.yml index 6031a7fd..a101ceeb 100644 --- a/.github/workflows/remote-release-trigger.yml +++ b/.github/workflows/remote-release-trigger.yml @@ -15,7 +15,7 @@ jobs: private_key: ${{ secrets.CLI_RELEASE_PRIVATE_KEY }} - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: # We want to checkout the main branch ref: 'main' diff --git a/linodecli/api_request.py b/linodecli/api_request.py index aa04a924..89b922f5 100644 --- a/linodecli/api_request.py +++ b/linodecli/api_request.py @@ -18,8 +18,8 @@ from linodecli.helpers import API_CA_PATH, API_VERSION_OVERRIDE from .baked.operation import ( - ExplicitEmptyDictValue, ExplicitEmptyListValue, + ExplicitJsonValue, ExplicitNullValue, OpenAPIOperation, ) @@ -314,14 +314,14 @@ def _traverse_request_body(o: Any) -> Any: result[k] = [] continue - if isinstance(v, ExplicitEmptyDictValue): - result[k] = {} - continue - if isinstance(v, ExplicitNullValue): result[k] = None continue + if isinstance(v, ExplicitJsonValue): + result[k] = v.json_value + continue + value = _traverse_request_body(v) # We should exclude implicit empty lists diff --git a/linodecli/baked/operation.py b/linodecli/baked/operation.py index cf93279a..336fc25e 100644 --- a/linodecli/baked/operation.py +++ b/linodecli/baked/operation.py @@ -10,9 +10,11 @@ import re import sys from collections import defaultdict +from collections.abc import Callable +from dataclasses import dataclass from getpass import getpass from os import environ, path -from typing import Any, Dict, List, Optional, Tuple, Union +from typing import Any, Dict, List, Optional, Tuple from urllib.parse import urlparse import openapi3.paths @@ -49,46 +51,12 @@ def parse_boolean(value: str) -> bool: raise argparse.ArgumentTypeError("Expected a boolean value") -def parse_dict( - value: str, -) -> Union[Dict[str, Any], "ExplicitEmptyDictValue", "ExplicitEmptyListValue"]: - """ - A helper function to decode incoming JSON data as python dicts. This is - intended to be passed to the `type=` kwarg for ArgumentParaser.add_argument. - - :param value: The json string to be parsed into dict. - :type value: str - - :returns: The dict value of the input. - :rtype: dict, ExplicitEmptyDictValue, or ExplicitEmptyListValue - """ - if not isinstance(value, str): - raise argparse.ArgumentTypeError("Expected a JSON string") - - try: - result = json.loads(value) - except Exception as e: - raise argparse.ArgumentTypeError("Expected a JSON string") from e - - # This is necessary because empty dicts and lists are excluded from requests - # by default, but we still want to support user-defined empty dict - # strings. This is particularly helpful when updating LKE node pool - # labels and taints. - if isinstance(result, dict) and result == {}: - return ExplicitEmptyDictValue() - - if isinstance(result, list) and result == []: - return ExplicitEmptyListValue() - - return result - - TYPES = { "string": str, "integer": int, "boolean": parse_boolean, "array": list, - "object": parse_dict, + "object": lambda value: ExplicitJsonValue(json_value=json.loads(value)), "number": float, } @@ -106,13 +74,16 @@ class ExplicitEmptyListValue: """ -class ExplicitEmptyDictValue: +@dataclass +class ExplicitJsonValue: """ - A special type used to explicitly pass empty dictionaries to the API. + A special type used to explicitly pass raw JSON from user input as is. """ + json_value: Any + -def wrap_parse_nullable_value(arg_type: str) -> TYPES: +def wrap_parse_nullable_value(arg_type: str) -> Callable[[Any], Any]: """ A helper function to parse `null` as None for nullable CLI args. This is intended to be called and passed to the `type=` kwarg for ArgumentParser.add_argument. diff --git a/linodecli/configuration/config.py b/linodecli/configuration/config.py index 969adeb0..0094e00b 100644 --- a/linodecli/configuration/config.py +++ b/linodecli/configuration/config.py @@ -126,7 +126,7 @@ def print_users(self): default_user = self.default_username() for sec in self.config.sections(): - if sec != "DEFAULT": + if sec not in ("DEFAULT", "custom_aliases"): print(f'{"*" if sec == default_user else " "} {sec}') sys.exit(ExitCodes.SUCCESS) diff --git a/tests/integration/account/test_account.py b/tests/integration/account/test_account.py index 0d3a0689..e3d99c3c 100644 --- a/tests/integration/account/test_account.py +++ b/tests/integration/account/test_account.py @@ -196,6 +196,7 @@ def test_account_setting_view(): "longview_subscription", "network_helper", "interfaces_for_new_linodes", + "maintenance_policy", ] settings_text = exec_test_command( @@ -343,7 +344,15 @@ def test_maintenance_list(): ) lines = res.splitlines() - headers = ["entity.type", "entity.label"] + headers = [ + "complete_time", + "entity.type", + "entity.label", + "maintenance_policy_set", + "not_before", + "source", + "start_time", + ] assert_headers_in_lines(headers, lines) diff --git a/tests/integration/database/test_database_engine_config.py b/tests/integration/database/test_database_engine_config.py index 973d23b4..92fc6b7e 100644 --- a/tests/integration/database/test_database_engine_config.py +++ b/tests/integration/database/test_database_engine_config.py @@ -497,7 +497,7 @@ def test_mysql_engine_config_view(): binlog_retention = mysql_config[0]["binlog_retention_period"] assert binlog_retention["type"] == "integer" assert binlog_retention["minimum"] == 600 - assert binlog_retention["maximum"] == 86400 + assert binlog_retention["maximum"] == 604800 assert binlog_retention["requires_restart"] is False mysql_settings = mysql_config[0]["mysql"] diff --git a/tests/integration/helpers.py b/tests/integration/helpers.py index 0408da72..c58aad09 100644 --- a/tests/integration/helpers.py +++ b/tests/integration/helpers.py @@ -16,9 +16,9 @@ # TypeVars for generic type hints below T = TypeVar("T") - MODULES = [ "account", + "alerts", "domains", "linodes", "nodebalancers", @@ -33,7 +33,9 @@ "linodes", "lke", "longview", + "maintenance", "managed", + "monitor", "networking", "obj", "object-storage", @@ -100,8 +102,41 @@ def exec_failing_test_command( # Delete/Remove helper functions (mainly used in clean-ups after tests) -def delete_target_id(target: str, id: str, delete_command: str = "delete"): - command = ["linode-cli", target, delete_command, id] +def delete_target_id( + target: str, + id: str, + delete_command: str = "delete", + service_type: str = None, + use_retry: bool = False, + retries: int = 3, + delay: int = 80, +): + if service_type: + command = ["linode-cli", target, delete_command, service_type, id] + else: + command = ["linode-cli", target, delete_command, id] + + if use_retry: + last_exc = None + for attempt in range(retries): + try: + subprocess.run( + command, + check=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + return # success + except Exception as e: + last_exc = e + if attempt < retries - 1: + time.sleep(delay) + # If all retries fail, raise + raise RuntimeError( + f"Error executing command '{' '.join(command)}' after {retries} retries: {last_exc}" + ) + try: subprocess.run( command, diff --git a/tests/integration/linodes/test_linodes.py b/tests/integration/linodes/test_linodes.py index c21b09fd..da7db20a 100644 --- a/tests/integration/linodes/test_linodes.py +++ b/tests/integration/linodes/test_linodes.py @@ -176,7 +176,7 @@ def test_disk_view(test_linode_instance): def test_create_linode_disk_encryption_enabled(linode_cloud_firewall): test_region = get_random_region_with_caps( - required_capabilities=["Linodes", "LA Disk Encryption"] + required_capabilities=["Linodes", "Disk Encryption"] ) linode_id = create_linode( firewall_id=linode_cloud_firewall, @@ -205,7 +205,7 @@ def test_create_linode_disk_encryption_enabled(linode_cloud_firewall): def test_create_linode_disk_encryption_disabled(linode_cloud_firewall): test_region = get_random_region_with_caps( - required_capabilities=["Linodes", "LA Disk Encryption"] + required_capabilities=["Linodes", "Disk Encryption"] ) linode_id = create_linode( diff --git a/tests/integration/lke/test_clusters.py b/tests/integration/lke/test_clusters.py index bfd629a9..f1796a7d 100644 --- a/tests/integration/lke/test_clusters.py +++ b/tests/integration/lke/test_clusters.py @@ -52,6 +52,8 @@ def test_deploy_an_lke_cluster(): '[{"type":"ext4","size":1024}]', "--k8s_version", lke_version, + "--tier", + "standard", "--text", "--delimiter", ",", @@ -75,7 +77,15 @@ def test_lke_cluster_list(): ) lines = res.splitlines() - headers = ["label", "k8s_version"] + headers = [ + "label", + "k8s_version", + "tier", + "apl_enabled", + "vpc_id", + "subnet_id", + "stack_type", + ] assert_headers_in_lines(headers, lines) @@ -87,7 +97,15 @@ def test_view_lke_cluster(lke_cluster): + ["cluster-view", cluster_id, "--text", "--delimiter=,"] ) lines = res.splitlines() - headers = ["label", "k8s_version"] + headers = [ + "label", + "k8s_version", + "tier", + "apl_enabled", + "vpc_id", + "subnet_id", + "stack_type", + ] assert_headers_in_lines(headers, lines) @@ -160,7 +178,7 @@ def test_view_pool(lke_cluster): ) lines = res.splitlines() - headers = ["type", "labels"] + headers = ["type", "labels", "k8s_version", "label"] assert_headers_in_lines(headers, lines) diff --git a/tests/integration/maintenance/test_maintenance_policies.py b/tests/integration/maintenance/test_maintenance_policies.py new file mode 100644 index 00000000..f5bb86da --- /dev/null +++ b/tests/integration/maintenance/test_maintenance_policies.py @@ -0,0 +1,27 @@ +from tests.integration.helpers import ( + BASE_CMDS, + assert_headers_in_lines, + exec_test_command, +) + + +def test_maintenance_policies_list(): + res = exec_test_command( + BASE_CMDS["maintenance"] + ["policies-list", "--text", "--delimiter=,"] + ) + lines = res.splitlines() + headers = [ + "description", + "is_default", + "label", + "is_default", + "slug", + "type", + ] + assert_headers_in_lines(headers, lines) + rows = [line.split(",") for line in lines[1:] if line.strip()] + + type_index = headers.index("type") + types = [row[type_index].strip() for row in rows] + + assert set(types) == {"migrate", "power_off_on"} diff --git a/tests/integration/monitor/conftest.py b/tests/integration/monitor/conftest.py new file mode 100644 index 00000000..c56a92b1 --- /dev/null +++ b/tests/integration/monitor/conftest.py @@ -0,0 +1,24 @@ +import pytest + +from tests.integration.helpers import ( + BASE_CMDS, + exec_test_command, +) + + +@pytest.fixture +def get_service_type(): + service_ids = exec_test_command( + BASE_CMDS["monitor"] + + [ + "service-list", + "--text", + "--no-headers", + "--delimiter", + ",", + "--format", + "service_type", + ] + ).splitlines() + first_id = service_ids[0].split(",")[0] + yield first_id diff --git a/tests/integration/monitor/test_alerts.py b/tests/integration/monitor/test_alerts.py new file mode 100644 index 00000000..d03471bd --- /dev/null +++ b/tests/integration/monitor/test_alerts.py @@ -0,0 +1,152 @@ +import pytest + +from tests.integration.helpers import ( + BASE_CMDS, + assert_headers_in_lines, + delete_target_id, + exec_test_command, + get_random_text, + retry_exec_test_command_with_delay, +) + + +def test_channels_list(): + res = exec_test_command( + BASE_CMDS["alerts"] + ["channels-list", "--text", "--delimiter=,"] + ) + lines = res.splitlines() + headers = [ + "channel_type", + "content.email.email_addresses", + "id", + "label", + "type", + "updated", + ] + assert_headers_in_lines(headers, lines) + + +@pytest.fixture +def get_channel_id(): + channel_ids = exec_test_command( + BASE_CMDS["alerts"] + + [ + "channels-list", + "--text", + "--no-headers", + "--delimiter", + ",", + "--format", + "id", + ] + ).splitlines() + first_id = channel_ids[0].split(",")[0] + yield first_id + + +def test_alerts_definition_create(get_channel_id, get_service_type): + label = get_random_text(8) + "_alert" + exec_test_command( + BASE_CMDS["alerts"] + + [ + "definition-create", + "--channel_ids", + get_channel_id, + "--label", + label, + "--rule_criteria.rules.metric", + "cpu_usage", + "--rule_criteria.rules.operator", + "eq", + "--rule_criteria.rules.threshold", + "80", + "--rule_criteria.rules.aggregate_function", + "avg", + "--severity", + "1", + "--trigger_conditions.criteria_condition", + "ALL", + "--trigger_conditions.evaluation_period_seconds", + "300", + "--trigger_conditions.polling_interval_seconds", + "300", + "--trigger_conditions.trigger_occurrences", + "3", + get_service_type, + ] + ) + + +def test_alerts_list(): + res = exec_test_command( + BASE_CMDS["alerts"] + + ["definitions-list-all", "--text", "--delimiter=,"] + ) + lines = res.splitlines() + headers = ["class", "created", "label", "severity", "service_type"] + assert_headers_in_lines(headers, lines) + + +@pytest.fixture +def get_alert_id(): + alert_id = exec_test_command( + BASE_CMDS["alerts"] + + [ + "definitions-list-all", + "--text", + "--no-headers", + "--delimiter", + ",", + "--format", + "id", + ] + ).splitlines() + first_id = alert_id[-1] + yield first_id + + +def test_alert_view(get_alert_id, get_service_type): + alert_id = get_alert_id + service_type = get_service_type + res = exec_test_command( + BASE_CMDS["alerts"] + + [ + "definition-view", + service_type, + alert_id, + "--text", + "--delimiter=,", + ] + ) + lines = res.splitlines() + + headers = ["class", "created", "label", "severity", "service_type"] + assert_headers_in_lines(headers, lines) + + +def test_alert_update(get_alert_id, get_service_type): + alert_id = get_alert_id + service_type = get_service_type + new_label = get_random_text(8) + "_updated" + updated_label = retry_exec_test_command_with_delay( + BASE_CMDS["alerts"] + + [ + "definition-update", + service_type, + alert_id, + "--label", + new_label, + "--text", + "--no-headers", + "--format=label", + ], + delay=50, + ) + assert updated_label == new_label + delete_target_id( + target="alerts", + delete_command="definition-delete", + service_type=service_type, + id=alert_id, + use_retry=True, + ) diff --git a/tests/integration/monitor/test_metrics.py b/tests/integration/monitor/test_metrics.py new file mode 100644 index 00000000..38742e18 --- /dev/null +++ b/tests/integration/monitor/test_metrics.py @@ -0,0 +1,119 @@ +import pytest + +from tests.integration.helpers import ( + BASE_CMDS, + assert_headers_in_lines, + exec_test_command, +) + + +def test_dashboard_list(): + res = exec_test_command( + BASE_CMDS["monitor"] + + ["dashboards-list-all", "--text", "--delimiter=,"] + ) + lines = res.splitlines() + headers = ["created", "id", "label", "service_type", "type", "updated"] + assert_headers_in_lines(headers, lines) + + +@pytest.fixture +def get_dashboard_id(): + dashboard_ids = exec_test_command( + BASE_CMDS["monitor"] + + [ + "dashboards-list-all", + "--text", + "--no-headers", + "--delimiter", + ",", + "--format", + "id", + ] + ).splitlines() + first_id = dashboard_ids[0].split(",")[0] + yield first_id + + +def test_dashboard_view(get_dashboard_id): + dashboard_id = get_dashboard_id + res = exec_test_command( + BASE_CMDS["monitor"] + + [ + "dashboards-view", + dashboard_id, + "--text", + "--delimiter=,", + ] + ) + lines = res.splitlines() + + headers = ["created", "id", "label", "service_type", "type", "updated"] + assert_headers_in_lines(headers, lines) + + +def test_service_list(): + res = exec_test_command( + BASE_CMDS["monitor"] + ["service-list", "--text", "--delimiter=,"] + ) + lines = res.splitlines() + headers = ["label", "service_type"] + assert_headers_in_lines(headers, lines) + + +def test_service_view(get_service_type): + dashboard_id = get_service_type + res = exec_test_command( + BASE_CMDS["monitor"] + + [ + "service-view", + dashboard_id, + "--text", + "--delimiter=,", + ] + ) + lines = res.splitlines() + + headers = ["label", "service_type"] + assert_headers_in_lines(headers, lines) + + +def test_dashboard_service_type_list(get_service_type): + dashboard_id = get_service_type + res = exec_test_command( + BASE_CMDS["monitor"] + + [ + "dashboards-list", + dashboard_id, + "--text", + "--delimiter=,", + ] + ) + lines = res.splitlines() + + headers = ["created", "id", "label", "service_type", "type", "updated"] + assert_headers_in_lines(headers, lines) + + +def test_metrics_list(get_service_type): + dashboard_id = get_service_type + res = exec_test_command( + BASE_CMDS["monitor"] + + [ + "metrics-list", + dashboard_id, + "--text", + "--delimiter=,", + ] + ) + lines = res.splitlines() + + headers = [ + "available_aggregate_functions", + "is_alertable", + "label", + "metric", + "metric_type", + "scrape_interval", + ] + assert_headers_in_lines(headers, lines) diff --git a/tests/integration/nodebalancers/fixtures.py b/tests/integration/nodebalancers/fixtures.py index 5ce2d332..ff07d0e5 100644 --- a/tests/integration/nodebalancers/fixtures.py +++ b/tests/integration/nodebalancers/fixtures.py @@ -165,3 +165,161 @@ def nodebalancer_with_default_conf(linode_cloud_firewall): res_arr = result.split(",") nodebalancer_id = res_arr[0] delete_target_id(target="nodebalancers", id=nodebalancer_id) + + +@pytest.fixture(scope="function") +def nodebalancer_with_udp_config_and_node(linode_cloud_firewall): + nodebalancer_id = exec_test_command( + BASE_CMDS["nodebalancers"] + + [ + "create", + "--region", + "us-ord", + "--client_conn_throttle", + "20", + "--firewall_id", + linode_cloud_firewall, + "--text", + "--delimiter", + ",", + "--format", + "id", + "--no-headers", + ] + ) + config_id = exec_test_command( + BASE_CMDS["nodebalancers"] + + [ + "config-create", + nodebalancer_id, + "--port", + "80", + "--protocol", + "udp", + "--algorithm", + "roundrobin", + "--check_interval", + "90", + "--check_timeout", + "10", + "--check_attempts", + "3", + "--check_path", + "/test", + "--check_body", + "it works", + "--delimiter", + ",", + "--text", + "--no-headers", + "--format", + "id", + ] + ) + + linode_create = exec_test_command( + BASE_CMDS["linodes"] + + [ + "create", + "--root_pass", + "aComplex@Password", + "--booted", + "true", + "--region", + "us-ord", + "--type", + "g6-nanode-1", + "--private_ip", + "true", + "--image", + DEFAULT_TEST_IMAGE, + "--firewall_id", + linode_cloud_firewall, + "--text", + "--delimiter", + ",", + "--format", + "id,ipv4", + "--no-header", + "--suppress-warnings", + ] + ) + linode_arr = linode_create.split(",") + linode_id = linode_arr[0] + ip_arr = linode_arr[1].split(" ") + node_ip = ip_arr[1] + node_label = "defaultnode1" + node_id = exec_test_command( + BASE_CMDS["nodebalancers"] + + [ + "node-create", + nodebalancer_id, + config_id, + "--address", + node_ip + ":80", + "--label", + node_label, + "--weight", + "100", + "--delimiter", + ",", + "--text", + "--no-headers", + "--format", + "id", + ] + ) + + yield nodebalancer_id, config_id, node_id, node_ip + + delete_target_id(target="nodebalancers", id=nodebalancer_id) + delete_target_id(target="linodes", id=linode_id) + + +@pytest.fixture(scope="module") +def simple_nodebalancer_with_config(linode_cloud_firewall): + nodebalancer_id = exec_test_command( + BASE_CMDS["nodebalancers"] + + [ + "create", + "--region", + "us-ord", + "--client_conn_throttle", + "20", + "--firewall_id", + linode_cloud_firewall, + "--text", + "--delimiter", + ",", + "--format", + "id", + "--no-headers", + ] + ) + config_id = exec_test_command( + BASE_CMDS["nodebalancers"] + + [ + "config-create", + nodebalancer_id, + "--port", + "81", + "--protocol", + "http", + "--algorithm", + "leastconn", + "--check_path", + "/test", + "--check_body", + "it works", + "--text", + "--no-headers", + "--delimiter", + ",", + "--format", + "id", + ] + ) + + yield nodebalancer_id, config_id + + delete_target_id(target="nodebalancers", id=nodebalancer_id) diff --git a/tests/integration/nodebalancers/test_node_balancers.py b/tests/integration/nodebalancers/test_node_balancers.py index 4ebb8b6d..f4136206 100644 --- a/tests/integration/nodebalancers/test_node_balancers.py +++ b/tests/integration/nodebalancers/test_node_balancers.py @@ -12,6 +12,8 @@ linode_to_add, nodebalancer_w_config_and_node, nodebalancer_with_default_conf, + nodebalancer_with_udp_config_and_node, + simple_nodebalancer_with_config, ) @@ -340,5 +342,207 @@ def test_list_multiple_configuration_profile(nodebalancer_w_config_and_node): ) +def test_update_node_balancer_udp_configuration( + simple_nodebalancer_with_config, +): + nodebalancer_id = simple_nodebalancer_with_config[0] + config_id = simple_nodebalancer_with_config[1] + + result = exec_test_command( + BASE_CMDS["nodebalancers"] + + [ + "config-update", + nodebalancer_id, + config_id, + "--port", + "80", + "--protocol", + "udp", + "--algorithm", + "roundrobin", + "--check_interval", + "80", + "--check_timeout", + "15", + "--check_attempts", + "2", + "--check_path", + "/testUpdate", + "--check_body", + "OK", + "--check_passive", + "False", + "--delimiter", + ",", + "--text", + "--no-headers", + ] + ) + assert result == config_id + ",80,udp,roundrobin,none,False,none,," + + +def test_rebuild_node_balancer_udp_configuration( + nodebalancer_with_udp_config_and_node, +): + nodebalancer_id = nodebalancer_with_udp_config_and_node[0] + config_id = nodebalancer_with_udp_config_and_node[1] + + result = exec_test_command( + BASE_CMDS["nodebalancers"] + + [ + "config-rebuild", + nodebalancer_id, + config_id, + "--port", + "80", + "--protocol", + "udp", + "--algorithm", + "ring_hash", + "--nodes.label", + "defaultnode1", + "--nodes.address", + nodebalancer_with_udp_config_and_node[3] + ":80", + "--nodes.weight", + "50", + "--delimiter", + ",", + "--text", + "--no-headers", + ] + ) + assert result == config_id + ",80,udp,ring_hash,session,False,none,," + + +def test_list_node_balancer_configurations_with_udp_type( + nodebalancer_with_udp_config_and_node, +): + nodebalancer_id = nodebalancer_with_udp_config_and_node[0] + config_id = nodebalancer_with_udp_config_and_node[1] + + result = exec_test_command( + BASE_CMDS["nodebalancers"] + + [ + "configs-list", + nodebalancer_id, + "--delimiter", + ",", + "--text", + "--no-headers", + ] + ) + assert result == config_id + ",80,udp,roundrobin,session,False,none,," + + +def test_view_node_balancer_udp_configuration( + nodebalancer_with_udp_config_and_node, +): + nodebalancer_id = nodebalancer_with_udp_config_and_node[0] + config_id = nodebalancer_with_udp_config_and_node[1] + + result = exec_test_command( + BASE_CMDS["nodebalancers"] + + [ + "config-view", + nodebalancer_id, + config_id, + "--delimiter", + ",", + "--text", + "--no-headers", + ] + ) + assert result == config_id + ",80,udp,roundrobin,session,False,none,," + + +def test_update_node_for_node_balancer_udp_configuration( + nodebalancer_with_udp_config_and_node, +): + nodebalancer_id = nodebalancer_with_udp_config_and_node[0] + config_id = nodebalancer_with_udp_config_and_node[1] + node_id = nodebalancer_with_udp_config_and_node[2] + + result = exec_test_command( + BASE_CMDS["nodebalancers"] + + [ + "node-update", + nodebalancer_id, + config_id, + node_id, + "--weight", + "30", + "--delimiter", + ",", + "--text", + "--no-headers", + ] + ) + assert ( + result + == node_id + + ",defaultnode1," + + nodebalancer_with_udp_config_and_node[3] + + ":80,Unknown,30,none" + ) + + +def test_list_nodes_for_node_balancer_udp_configuration( + nodebalancer_with_udp_config_and_node, +): + nodebalancer_id = nodebalancer_with_udp_config_and_node[0] + config_id = nodebalancer_with_udp_config_and_node[1] + node_id = nodebalancer_with_udp_config_and_node[2] + + result = exec_test_command( + BASE_CMDS["nodebalancers"] + + [ + "nodes-list", + nodebalancer_id, + config_id, + "--delimiter", + ",", + "--text", + "--no-headers", + ] + ) + + assert ( + result + == node_id + + ",defaultnode1," + + nodebalancer_with_udp_config_and_node[3] + + ":80,Unknown,100,none" + ) + + +def test_view_node_for_node_balancer_udp_configuration( + nodebalancer_with_udp_config_and_node, +): + nodebalancer_id = nodebalancer_with_udp_config_and_node[0] + config_id = nodebalancer_with_udp_config_and_node[1] + node_id = nodebalancer_with_udp_config_and_node[2] + + result = exec_test_command( + BASE_CMDS["nodebalancers"] + + [ + "node-view", + nodebalancer_id, + config_id, + node_id, + "--delimiter", + ",", + "--text", + "--no-headers", + ] + ) + assert ( + result + == node_id + + ",defaultnode1," + + nodebalancer_with_udp_config_and_node[3] + + ":80,Unknown,100,none" + ) + + def nodebalancer_created(): return "[0-9]+,balancer[0-9]+,us-ord,[0-9]+-[0-9]+-[0-9]+-[0-9]+.ip.linodeusercontent.com,0" diff --git a/tests/unit/test_api_request.py b/tests/unit/test_api_request.py index 253f0385..a222d60d 100644 --- a/tests/unit/test_api_request.py +++ b/tests/unit/test_api_request.py @@ -14,8 +14,8 @@ from linodecli import ExitCodes, api_request from linodecli.baked.operation import ( - ExplicitEmptyDictValue, ExplicitEmptyListValue, + ExplicitJsonValue, ExplicitNullValue, ) @@ -667,7 +667,7 @@ def test_traverse_request_body(self): "baz": ExplicitNullValue(), }, "cool": [], - "pretty_cool": ExplicitEmptyDictValue(), + "pretty_cool": ExplicitJsonValue(json_value={}), "cooler": ExplicitEmptyListValue(), "coolest": ExplicitNullValue(), } diff --git a/tests/unit/test_configuration.py b/tests/unit/test_configuration.py index 474b083f..14b704d0 100644 --- a/tests/unit/test_configuration.py +++ b/tests/unit/test_configuration.py @@ -113,6 +113,7 @@ def test_print_users(self): Test CLIConfig.print_users() """ conf = self._build_test_config() + conf.config.add_section("custom_aliases") f = io.StringIO() @@ -122,6 +123,7 @@ def test_print_users(self): assert err.value.code == 0 assert "* cli-dev" in f.getvalue() + assert "custom_aliases" not in f.getvalue() def test_set_default_user(self): """ diff --git a/tests/unit/test_operation.py b/tests/unit/test_operation.py index 960502e2..1e3118e2 100644 --- a/tests/unit/test_operation.py +++ b/tests/unit/test_operation.py @@ -6,8 +6,8 @@ from linodecli.baked import operation from linodecli.baked.operation import ( TYPES, - ExplicitEmptyDictValue, ExplicitEmptyListValue, + ExplicitJsonValue, ExplicitNullValue, OpenAPIOperation, ) @@ -195,7 +195,7 @@ def test_parse_args_object_list(self, create_operation): "field_string": "test1", "field_int": 123, "field_dict": {"nested_string": "test2", "nested_int": 789}, - "field_array": ["foo", "bar"], + "field_array": ExplicitJsonValue(json_value=["foo", "bar"]), "nullable_string": None, # We expect this to be filtered out later }, {"field_int": 456, "field_dict": {"nested_string": "test3"}}, @@ -216,7 +216,7 @@ def test_parse_args_object_list_json(self, create_operation): ["--object_list", json.dumps(expected)] ) - assert result.object_list == expected + assert result.object_list.json_value == expected def test_parse_args_conflicting_parent_child(self, create_operation): stderr_buf = io.StringIO() @@ -296,19 +296,27 @@ def test_object_arg_action_basic(self): # User specifies a normal object (dict) result = parser.parse_args(["--foo", '{"test-key": "test-value"}']) - assert getattr(result, "foo") == {"test-key": "test-value"} + foo = getattr(result, "foo") + assert isinstance(foo, ExplicitJsonValue) + assert foo.json_value == {"test-key": "test-value"} # User specifies a normal object (list) result = parser.parse_args(["--foo", '[{"test-key": "test-value"}]']) - assert getattr(result, "foo") == [{"test-key": "test-value"}] + foo = getattr(result, "foo") + assert isinstance(foo, ExplicitJsonValue) + assert foo.json_value == [{"test-key": "test-value"}] # User wants an explicitly empty object (dict) result = parser.parse_args(["--foo", "{}"]) - assert isinstance(getattr(result, "foo"), ExplicitEmptyDictValue) + foo = getattr(result, "foo") + assert isinstance(foo, ExplicitJsonValue) + assert foo.json_value == {} # User wants an explicitly empty object (list) result = parser.parse_args(["--foo", "[]"]) - assert isinstance(getattr(result, "foo"), ExplicitEmptyListValue) + foo = getattr(result, "foo") + assert isinstance(foo, ExplicitJsonValue) + assert foo.json_value == [] # User doesn't specify the list result = parser.parse_args([])