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/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"