diff --git a/.github/workflows/e2e-suite.yml b/.github/workflows/e2e-suite.yml index 61ec0fd8b..0e662792a 100644 --- a/.github/workflows/e2e-suite.yml +++ b/.github/workflows/e2e-suite.yml @@ -302,7 +302,7 @@ jobs: steps: - name: Notify Slack id: main_message - uses: slackapi/slack-github-action@v2 + uses: slackapi/slack-github-action@v3 with: method: chat.postMessage token: ${{ secrets.SLACK_BOT_TOKEN }} @@ -334,7 +334,7 @@ jobs: - name: Test summary thread if: success() - uses: slackapi/slack-github-action@v2 + uses: slackapi/slack-github-action@v3 with: method: chat.postMessage token: ${{ secrets.SLACK_BOT_TOKEN }} diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml index 843a41c4d..14e770b11 100644 --- a/.github/workflows/labeler.yml +++ b/.github/workflows/labeler.yml @@ -21,7 +21,7 @@ jobs: uses: actions/checkout@v6 - name: Run Labeler - uses: crazy-max/ghaction-github-labeler@24d110aa46a59976b8a7f35518cb7f14f434c916 + uses: crazy-max/ghaction-github-labeler@548a7c3603594ec17c819e1239f281a3b801ab4d with: github-token: ${{ secrets.GITHUB_TOKEN }} yaml-file: .github/labels.yml diff --git a/.github/workflows/nightly-smoke-tests.yml b/.github/workflows/nightly-smoke-tests.yml index 22db9347e..77d34b653 100644 --- a/.github/workflows/nightly-smoke-tests.yml +++ b/.github/workflows/nightly-smoke-tests.yml @@ -46,7 +46,7 @@ jobs: - name: Notify Slack if: always() && github.repository == 'linode/linode-cli' # Run even if integration tests fail and only on main repository - uses: slackapi/slack-github-action@v2 + uses: slackapi/slack-github-action@v3 with: method: chat.postMessage token: ${{ secrets.SLACK_BOT_TOKEN }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 35c3b7c74..3b9cd5c29 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,7 +11,7 @@ jobs: steps: - name: Notify Slack - Main Message id: main_message - uses: slackapi/slack-github-action@v2 + uses: slackapi/slack-github-action@v3 with: method: chat.postMessage token: ${{ secrets.SLACK_BOT_TOKEN }} @@ -67,7 +67,7 @@ jobs: result-encoding: string - name: Build and push to DockerHub - uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # pin@v7.0.0 + uses: docker/build-push-action@v7 with: context: . file: Dockerfile diff --git a/linodecli/cli.py b/linodecli/cli.py index 419b5e06e..b1b76db81 100644 --- a/linodecli/cli.py +++ b/linodecli/cli.py @@ -305,8 +305,56 @@ def _load_openapi_spec(spec_location: str) -> OpenAPI: with CLI._get_spec_file_reader(spec_location) as f: parsed = CLI._parse_spec_file(f) + CLI._normalize_content_parameters(parsed) + return OpenAPI(parsed) + @staticmethod + def _normalize_content_parameters(parsed: Dict[str, Any]): + """ + The openapi3 library does not support the OpenAPI 3.0 ``content`` + form for Parameter objects. This method converts any such + parameters (in components and inline on paths/operations) to use + a top-level ``schema`` field so they can be parsed normally. + + :param parsed: The raw spec dict to mutate in-place. + """ + + def _fix_param(param): + if not isinstance(param, dict): + return + if "content" in param and "schema" not in param: + content = param.pop("content") + for media_obj in content.values(): + if isinstance(media_obj, dict) and "schema" in media_obj: + param["schema"] = media_obj["schema"] + break + + for param in ( + parsed.get("components", {}).get("parameters", {}).values() + ): + _fix_param(param) + + for path_item in parsed.get("paths", {}).values(): + if not isinstance(path_item, dict): + continue + for p in path_item.get("parameters", []): + _fix_param(p) + for method in ( + "get", + "put", + "post", + "delete", + "options", + "head", + "patch", + "trace", + ): + operation = path_item.get(method) + if isinstance(operation, dict): + for p in operation.get("parameters", []): + _fix_param(p) + @staticmethod @contextlib.contextmanager def _get_spec_file_reader( diff --git a/tests/integration/linodes/fixtures.py b/tests/integration/linodes/fixtures.py index a0f0dfc2c..85ec179ed 100644 --- a/tests/integration/linodes/fixtures.py +++ b/tests/integration/linodes/fixtures.py @@ -662,3 +662,41 @@ def linode_with_label(linode_cloud_firewall): res_arr = result.split(",") linode_id = res_arr[4] delete_target_id(target="linodes", id=linode_id) + + +@pytest.fixture(scope="module") +def linode_with_authorization_key(linode_cloud_firewall): + label = "cli" + get_random_text(5) + test_region = get_random_region_with_caps( + required_capabilities=["Linodes", "Disk Encryption"] + ) + result = exec_test_command( + BASE_CMDS["linodes"] + + [ + "create", + "--type", + "g6-nanode-1", + "--region", + test_region, + "--image", + DEFAULT_TEST_IMAGE, + "--label", + label, + "--authorized_keys", + "ssh-rsa", + "--kernel", + "linode/latest-64bit", + "--boot_size", + "9000", + "--text", + "--delimiter", + ",", + "--no-headers", + "--no-defaults", + "--format", + "id,type", + ] + ).split(",") + + yield result + delete_target_id(target="linodes", id=result[0]) diff --git a/tests/integration/linodes/helpers.py b/tests/integration/linodes/helpers.py index b07f0794a..88155338a 100644 --- a/tests/integration/linodes/helpers.py +++ b/tests/integration/linodes/helpers.py @@ -304,3 +304,33 @@ def get_disk_id(test_linode_instance): ).splitlines() first_id = disk_id[0].split(",")[0] return first_id + + +def wait_for_disk_status( + linode_id: "str", disk_id: "str", timeout, status: "str", period=10 +): + must_end = time.time() + timeout + while time.time() < must_end: + time.sleep(period) + try: + result = exec_test_command( + [ + "linode-cli", + "linodes", + "disk-view", + linode_id, + disk_id, + "--format", + "status", + "--text", + "--no-headers", + ] + ) + except RuntimeError as response_error: + if "Not found" in str(response_error): + continue + else: + raise RuntimeError(response_error) + if status == result: + return True + return False diff --git a/tests/integration/linodes/test_linodes.py b/tests/integration/linodes/test_linodes.py index da7db20aa..9b0d361f3 100644 --- a/tests/integration/linodes/test_linodes.py +++ b/tests/integration/linodes/test_linodes.py @@ -11,9 +11,12 @@ exec_failing_test_command, exec_test_command, get_random_region_with_caps, + get_random_text, + retry_exec_test_command_with_delay, ) from tests.integration.linodes.fixtures import ( # noqa: F401 linode_min_req, + linode_with_authorization_key, linode_with_label, linode_wo_image, test_linode_instance, @@ -23,6 +26,7 @@ DEFAULT_TEST_IMAGE, create_linode, get_disk_id, + wait_for_disk_status, wait_until, ) @@ -42,6 +46,110 @@ def test_create_linodes_with_a_label(linode_with_label): ) +def test_expected_error_if_fields_authorized_users_authorized_keys_root_pass_are_not_set(): + test_region = get_random_region_with_caps( + required_capabilities=["Linodes", "Disk Encryption"] + ) + result = exec_failing_test_command( + BASE_CMDS["linodes"] + + [ + "create", + "--type", + "g6-nanode-1", + "--region", + test_region, + "--image", + DEFAULT_TEST_IMAGE, + "--label", + "cli-negative-test-case", + "--kernel", + "linode/latest-64bit", + "--boot_size", + "9000", + "--text", + "--delimiter", + ",", + "--no-headers", + "--format", + "label,region,type,image,id", + "--no-defaults", + ], + expected_code=ExitCodes.REQUEST_FAILED, + ) + assert "Request failed: 400" in result + assert ( + "Must provide valid root_pass, authorized_keys, or authorized_users" + in result + ) + + +def test_create_linode_with_kernel_and_boot_size_then_add_disk_and_rebuild( + linode_with_authorization_key, +): + result_create = linode_with_authorization_key + assert result_create[1] == "g6-nanode-1" + assert wait_until( + linode_id=result_create[0], timeout=180, status="running" + ), "linode failed to change status to running from creating.." + + response_create_disk = ( + retry_exec_test_command_with_delay( + BASE_CMDS["linodes"] + + [ + "disk-create", + result_create[0], + "--size", + "2000", + "--label", + "cli" + get_random_text(5), + "--image", + "linode/debian12", + "--root_pass", + "aComplex@Password123", + "--text", + "--no-headers", + "--delimiter", + ",", + ], + retries=3, + delay=10, + ) + .splitlines()[0] + .split(",") + ) + assert "not ready" in response_create_disk + assert wait_for_disk_status( + linode_id=result_create[0], + disk_id=response_create_disk[0], + timeout=90, + status="ready", + ), "linode failed to change disk status to ready after disk creation.." + + result_rebuild = ( + exec_test_command( + BASE_CMDS["linodes"] + + [ + "rebuild", + "--image", + DEFAULT_TEST_IMAGE, + "--authorized_keys", + "ssh-rsa-sha2-512", + "--text", + "--no-headers", + "--delimiter", + ",", + result_create[0], + ] + ) + .splitlines()[0] + .split(",") + ) + assert DEFAULT_TEST_IMAGE in result_rebuild + assert wait_until( + linode_id=result_create[0], timeout=180, status="running" + ), "linode failed to change status to running from rebuilding.." + + @pytest.mark.smoke def test_view_linode_configuration(test_linode_instance): linode_id = test_linode_instance @@ -75,26 +183,6 @@ def test_create_linode_with_min_required_props(linode_min_req): assert re.search("[0-9]+,us-ord,g6-nanode-1", result) -def test_create_linodes_fails_without_a_root_pass(): - result = exec_failing_test_command( - BASE_CMDS["linodes"] - + [ - "create", - "--type", - "g6-nanode-1", - "--region", - "us-ord", - "--image", - DEFAULT_TEST_IMAGE, - "--text", - "--no-headers", - ], - ExitCodes.REQUEST_FAILED, - ) - assert "Request failed: 400" in result - assert "root_pass root_pass is required" in result - - def test_create_linode_without_image_and_not_boot(linode_wo_image): linode_id = linode_wo_image diff --git a/tests/integration/lke/helpers.py b/tests/integration/lke/helpers.py index 58634ba3c..3aa814904 100644 --- a/tests/integration/lke/helpers.py +++ b/tests/integration/lke/helpers.py @@ -76,6 +76,22 @@ def get_lke_enterprise_id(): return enterprise_ti.get("id") +def get_lke_standard_id(): + standard_versions_list = exec_test_command( + BASE_CMDS["lke"] + + [ + "versions-list", + "--json", + ] + ) + + parsed = json.loads(standard_versions_list) + + standard_ti = parsed[0] + + return standard_ti.get("id") + + def get_cluster_id(label: str): cluster_id = exec_test_command( [ diff --git a/tests/integration/lke/test_lke_enterprise.py b/tests/integration/lke/test_lke_enterprise.py index c59a184c1..bcb09a3cb 100644 --- a/tests/integration/lke/test_lke_enterprise.py +++ b/tests/integration/lke/test_lke_enterprise.py @@ -11,7 +11,11 @@ get_random_region_with_caps, get_random_text, ) -from tests.integration.lke.helpers import get_cluster_id, get_lke_enterprise_id +from tests.integration.lke.helpers import ( + get_cluster_id, + get_lke_enterprise_id, + get_lke_standard_id, +) def test_enterprise_tier_available_in_types(monkeypatch: MonkeyPatch): @@ -116,6 +120,7 @@ def test_lke_tiered_versions_list(): def test_lke_tiered_versions_view(): enterprise_id = get_lke_enterprise_id() + standard_id = get_lke_standard_id() enterprise_tier_info = exec_test_command( BASE_CMDS["lke"] + [ @@ -138,7 +143,7 @@ def test_lke_tiered_versions_view(): + [ "tiered-version-view", "standard", - "1.33", + standard_id, "--json", ] ) diff --git a/tests/integration/monitor/test_plugin_get_metrics.py b/tests/integration/monitor/test_plugin_get_metrics.py index 4076095bd..064cf4c95 100644 --- a/tests/integration/monitor/test_plugin_get_metrics.py +++ b/tests/integration/monitor/test_plugin_get_metrics.py @@ -3,6 +3,7 @@ """ import os +from unittest import mock import pytest @@ -85,6 +86,7 @@ def test_missing_time_params(self): ) assert "Time duration required" in stderr + @mock.patch.dict(os.environ, {"JWE_TOKEN": "fake-token"}) def test_invalid_service(self): """Test that an unknown service name hits the API and fails""" stderr = exec_failing_test_command( diff --git a/tests/integration/nodebalancers/test_node_balancers.py b/tests/integration/nodebalancers/test_node_balancers.py index f41362061..1a0bac919 100644 --- a/tests/integration/nodebalancers/test_node_balancers.py +++ b/tests/integration/nodebalancers/test_node_balancers.py @@ -482,7 +482,7 @@ def test_update_node_for_node_balancer_udp_configuration( == node_id + ",defaultnode1," + nodebalancer_with_udp_config_and_node[3] - + ":80,Unknown,30,none" + + ":80,Unknown,30,none," ) @@ -511,7 +511,7 @@ def test_list_nodes_for_node_balancer_udp_configuration( == node_id + ",defaultnode1," + nodebalancer_with_udp_config_and_node[3] - + ":80,Unknown,100,none" + + ":80,Unknown,100,none," ) @@ -540,7 +540,7 @@ def test_view_node_for_node_balancer_udp_configuration( == node_id + ",defaultnode1," + nodebalancer_with_udp_config_and_node[3] - + ":80,Unknown,100,none" + + ":80,Unknown,100,none," ) diff --git a/tests/integration/ssh/test_plugin_ssh.py b/tests/integration/ssh/test_plugin_ssh.py index e8b765735..a1953fd6b 100644 --- a/tests/integration/ssh/test_plugin_ssh.py +++ b/tests/integration/ssh/test_plugin_ssh.py @@ -16,7 +16,7 @@ ) TEST_REGION = get_random_region_with_caps(required_capabilities=["Linodes"]) -TEST_IMAGE = "linode/ubuntu24.10" +TEST_IMAGE = "linode/arch" TEST_TYPE = "g6-nanode-1" TEST_ROOT_PASS = "r00tp@ss!long-long-and-longer"