From 6e87a01b92a81139ef25fa3c88429d230e51b407 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 8 Aug 2025 15:57:02 -0400 Subject: [PATCH 1/9] build(deps): bump Andrew-Chen-Wang/github-wiki-action from 4.4.0 to 5.0.1 (#790) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Zhiwei Liang <121905282+zliang-akamai@users.noreply.github.com> --- .github/workflows/publish-wiki.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish-wiki.yml b/.github/workflows/publish-wiki.yml index b063d0217..e47eb45f2 100644 --- a/.github/workflows/publish-wiki.yml +++ b/.github/workflows/publish-wiki.yml @@ -15,4 +15,4 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: Andrew-Chen-Wang/github-wiki-action@50650fccf3a10f741995523cf9708c53cec8912a # pin@v4.4.0 + - uses: Andrew-Chen-Wang/github-wiki-action@2c80c13ee98aa43683bd77973ef4916e2eedf817 # pin@v5.0.1 From 254a70f5b71b9edc759cd8625ded71ff335dea53 Mon Sep 17 00:00:00 2001 From: Zhiwei Liang <121905282+zliang-akamai@users.noreply.github.com> Date: Tue, 19 Aug 2025 10:22:15 -0400 Subject: [PATCH 2/9] Pass input JSON value parameter as-is to the API call (#807) --- linodecli/api_request.py | 10 +++---- linodecli/baked/operation.py | 49 +++++++--------------------------- tests/unit/test_api_request.py | 4 +-- tests/unit/test_operation.py | 22 ++++++++++----- 4 files changed, 32 insertions(+), 53 deletions(-) diff --git a/linodecli/api_request.py b/linodecli/api_request.py index aa04a924b..89b922f53 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 cf93279a1..336fc25e3 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/tests/unit/test_api_request.py b/tests/unit/test_api_request.py index 253f0385b..a222d60db 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_operation.py b/tests/unit/test_operation.py index 960502e24..1e3118e2b 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([]) From 2e745e7fc81486d76c8442fe965534c5f4a589c4 Mon Sep 17 00:00:00 2001 From: Pawel <100145168+PawelSnoch@users.noreply.github.com> Date: Wed, 27 Aug 2025 12:01:09 +0200 Subject: [PATCH 3/9] Adds integration tests for node balancer with UDP configuration (#801) --- tests/integration/nodebalancers/fixtures.py | 158 ++++++++++++++ .../nodebalancers/test_node_balancers.py | 203 ++++++++++++++++++ 2 files changed, 361 insertions(+) diff --git a/tests/integration/nodebalancers/fixtures.py b/tests/integration/nodebalancers/fixtures.py index 5ce2d3327..ce453c12a 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="module") +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 4ebb8b6db..5fc9c181f 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,206 @@ 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" From 5075d209e0667ebadd3ddee35db90c3f60f1a665 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 3 Sep 2025 15:44:24 -0400 Subject: [PATCH 4/9] build(deps): bump actions/download-artifact from 4 to 5 (#813) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/e2e-suite.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/e2e-suite.yml b/.github/workflows/e2e-suite.yml index 3149645b3..417501101 100644 --- a/.github/workflows/e2e-suite.yml +++ b/.github/workflows/e2e-suite.yml @@ -243,7 +243,7 @@ jobs: submodules: 'recursive' - name: Download test report - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v5 with: name: test-report-file From fdc22188be71a272ab70d1ef53b30b641ca0351d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 3 Sep 2025 15:44:32 -0400 Subject: [PATCH 5/9] build(deps): bump docker/login-action from 3.4.0 to 3.5.0 (#811) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3fe99418a..f240440b2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 }} From d131bf7f49fb1323baf1c0147a6fb3a0f55370ff Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 4 Sep 2025 10:05:17 -0400 Subject: [PATCH 6/9] build(deps): bump actions/checkout from 4 to 5 (#812) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Zhiwei Liang <121905282+zliang-akamai@users.noreply.github.com> --- .github/workflows/ci.yml | 8 ++++---- .github/workflows/codeql.yml | 2 +- .github/workflows/dependency-review.yml | 2 +- .github/workflows/e2e-suite-windows.yml | 4 ++-- .github/workflows/e2e-suite.yml | 8 ++++---- .github/workflows/labeler.yml | 2 +- .github/workflows/nightly-smoke-tests.yml | 2 +- .github/workflows/publish-wiki.yml | 2 +- .github/workflows/release.yml | 4 ++-- .github/workflows/remote-release-trigger.yml | 2 +- 10 files changed, 18 insertions(+), 18 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 61b45deb2..1509924ed 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 e2ccc10da..330afa4bb 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 bf9f46d87..ec737e92e 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 6e98e23b9..23def45b3 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 417501101..1134195ed 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,7 +237,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/labeler.yml b/.github/workflows/labeler.yml index 30bcb1956..7a3ee5f37 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 97bb21301..736da0f25 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 e47eb45f2..666f78840 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: 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 f240440b2..b38702c0d 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 @@ -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 6031a7fdd..a101ceeb8 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' From c78fbc699531fb2630a45439aeb4790eefc15485 Mon Sep 17 00:00:00 2001 From: James Rouzier Date: Fri, 5 Sep 2025 16:13:38 -0400 Subject: [PATCH 7/9] Don't show custom_aliases as a user (#793) Co-authored-by: Jacob Riddle <87780794+jriddle-linode@users.noreply.github.com> Co-authored-by: Zhiwei Liang <121905282+zliang-akamai@users.noreply.github.com> --- linodecli/configuration/config.py | 2 +- tests/unit/test_configuration.py | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/linodecli/configuration/config.py b/linodecli/configuration/config.py index 969adeb00..0094e00b1 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/unit/test_configuration.py b/tests/unit/test_configuration.py index 474b083f3..14b704d0c 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): """ From 2230585bfcd472e98977a4c344274443b3128a26 Mon Sep 17 00:00:00 2001 From: Lena Garber <114949949+lgarber-akamai@users.noreply.github.com> Date: Tue, 9 Sep 2025 01:29:28 -0400 Subject: [PATCH 8/9] Fix various integration tests (2025-09-08) (#815) --- tests/integration/database/test_database_engine_config.py | 2 +- tests/integration/linodes/test_linodes.py | 4 ++-- tests/integration/nodebalancers/fixtures.py | 2 +- tests/integration/nodebalancers/test_node_balancers.py | 1 + 4 files changed, 5 insertions(+), 4 deletions(-) diff --git a/tests/integration/database/test_database_engine_config.py b/tests/integration/database/test_database_engine_config.py index 973d23b4c..92fc6b7e5 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/linodes/test_linodes.py b/tests/integration/linodes/test_linodes.py index c21b09fdf..da7db20aa 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/nodebalancers/fixtures.py b/tests/integration/nodebalancers/fixtures.py index ce453c12a..ff07d0e57 100644 --- a/tests/integration/nodebalancers/fixtures.py +++ b/tests/integration/nodebalancers/fixtures.py @@ -167,7 +167,7 @@ def nodebalancer_with_default_conf(linode_cloud_firewall): delete_target_id(target="nodebalancers", id=nodebalancer_id) -@pytest.fixture(scope="module") +@pytest.fixture(scope="function") def nodebalancer_with_udp_config_and_node(linode_cloud_firewall): nodebalancer_id = exec_test_command( BASE_CMDS["nodebalancers"] diff --git a/tests/integration/nodebalancers/test_node_balancers.py b/tests/integration/nodebalancers/test_node_balancers.py index 5fc9c181f..f41362061 100644 --- a/tests/integration/nodebalancers/test_node_balancers.py +++ b/tests/integration/nodebalancers/test_node_balancers.py @@ -505,6 +505,7 @@ def test_list_nodes_for_node_balancer_udp_configuration( "--no-headers", ] ) + assert ( result == node_id From be6e62f8a22f9e3ac47b439cee94292d6a4233e0 Mon Sep 17 00:00:00 2001 From: Vinay <143587840+vshanthe@users.noreply.github.com> Date: Wed, 10 Sep 2025 19:01:40 +0530 Subject: [PATCH 9/9] Integration tests (#814) --- tests/integration/account/test_account.py | 11 +- tests/integration/helpers.py | 41 ++++- tests/integration/lke/test_clusters.py | 24 ++- .../maintenance/test_maintenance_policies.py | 27 ++++ tests/integration/monitor/conftest.py | 24 +++ tests/integration/monitor/test_alerts.py | 152 ++++++++++++++++++ tests/integration/monitor/test_metrics.py | 119 ++++++++++++++ 7 files changed, 391 insertions(+), 7 deletions(-) create mode 100644 tests/integration/maintenance/test_maintenance_policies.py create mode 100644 tests/integration/monitor/conftest.py create mode 100644 tests/integration/monitor/test_alerts.py create mode 100644 tests/integration/monitor/test_metrics.py diff --git a/tests/integration/account/test_account.py b/tests/integration/account/test_account.py index 0d3a06896..e3d99c3c5 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/helpers.py b/tests/integration/helpers.py index 0408da723..c58aad09f 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/lke/test_clusters.py b/tests/integration/lke/test_clusters.py index bfd629a91..f1796a7dd 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 000000000..f5bb86dab --- /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 000000000..c56a92b10 --- /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 000000000..d03471bde --- /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 000000000..38742e18e --- /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)