From b3ceb6487c8a8cc95bfdaca45a5d8ea8982987a9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 3 Jun 2026 09:09:29 -0400 Subject: [PATCH 01/32] build(deps): bump docker/setup-qemu-action from 4.0.0 to 4.1.0 (#897) 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 3b9cd5c29..dc1548de9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -39,7 +39,7 @@ jobs: run: make requirements - name: Set up QEMU - uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # pin@v4.0.0 + uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 # pin@v4.1.0 - name: Set up Docker Buildx uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # pin@v4.0.0 From 831279676dc028919dbaeab22d48b07ffd076757 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 3 Jun 2026 09:09:43 -0400 Subject: [PATCH 02/32] build(deps): bump pypa/gh-action-pypi-publish from 1.13.0 to 1.14.0 (#899) 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 dc1548de9..cd124735b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -106,4 +106,4 @@ jobs: LINODE_CLI_VERSION: ${{ github.event.release.tag_name }} - name: Publish the release artifacts to PyPI - uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # pin@release/v1.13.0 + uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # pin@release/v1.14.0 From 7c7bb1c0436d2195d8bd3024ab48b9e6f8366f3b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 4 Jun 2026 13:48:51 -0400 Subject: [PATCH 03/32] build(deps): bump docker/login-action from 4.0.0 to 4.2.0 (#898) 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/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cd124735b..e230efac5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -45,7 +45,7 @@ jobs: uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # pin@v4.0.0 - name: Login to Docker Hub - uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # pin@v4.0.0 + uses: docker/login-action@v4 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} From 5ad6f95a90f07dc4e2a9b9a3319bda3cdc43c4cd Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 1 Jul 2026 01:12:13 +0000 Subject: [PATCH 04/32] build(deps): bump actions/checkout from 6 to 7 Bumps [actions/checkout](https://github.com/actions/checkout) from 6 to 7. - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/v6...v7) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: '7' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .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 f7fd86cf9..8ed342fea 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@v6 + - uses: actions/checkout@v7 - name: Build the Docker image run: docker build . --file Dockerfile --tag linode/cli:$(date +%s) --build-arg="github_token=$GITHUB_TOKEN" env: @@ -45,7 +45,7 @@ jobs: GITHUB_TOKEN: ${{ github.token }} - name: checkout repo - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: setup python 3 uses: actions/setup-python@v6 @@ -65,7 +65,7 @@ jobs: python-version: [ "3.9","3.10","3.11", "3.12", "3.13" ] steps: - name: Clone Repository - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Setup Python uses: actions/setup-python@v6 @@ -87,7 +87,7 @@ jobs: runs-on: windows-latest steps: - name: Clone Repository - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Setup Python uses: actions/setup-python@v6 diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 3e7460f3d..4071a88c3 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@v6 + uses: actions/checkout@v7 - name: Initialize CodeQL uses: github/codeql-action/init@v4 diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index a427c8661..80a2556b4 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@v6 + uses: actions/checkout@v7 - 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 43843f8df..a4fd703fe 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@v6 + uses: actions/checkout@v7 with: ref: ${{ inputs.sha }} @@ -109,7 +109,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: fetch-depth: 0 submodules: 'recursive' diff --git a/.github/workflows/e2e-suite.yml b/.github/workflows/e2e-suite.yml index 0e662792a..6674e38f9 100644 --- a/.github/workflows/e2e-suite.yml +++ b/.github/workflows/e2e-suite.yml @@ -67,7 +67,7 @@ jobs: steps: - name: Checkout Repository with SHA if: ${{ inputs.sha != '' }} - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: fetch-depth: 0 submodules: 'recursive' @@ -75,7 +75,7 @@ jobs: - name: Checkout Repository without SHA if: ${{ inputs.sha == '' }} - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: fetch-depth: 0 submodules: 'recursive' @@ -178,7 +178,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: fetch-depth: 0 submodules: 'recursive' @@ -246,7 +246,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: fetch-depth: 0 submodules: 'recursive' diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml index 14e770b11..394ec5de9 100644 --- a/.github/workflows/labeler.yml +++ b/.github/workflows/labeler.yml @@ -18,7 +18,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Run Labeler uses: crazy-max/ghaction-github-labeler@548a7c3603594ec17c819e1239f281a3b801ab4d diff --git a/.github/workflows/nightly-smoke-tests.yml b/.github/workflows/nightly-smoke-tests.yml index 77d34b653..3d0014e9a 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@v6 + uses: actions/checkout@v7 with: fetch-depth: 0 submodules: 'recursive' diff --git a/.github/workflows/publish-wiki.yml b/.github/workflows/publish-wiki.yml index b846f089d..c56248101 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@v6 + - uses: actions/checkout@v7 - uses: Andrew-Chen-Wang/github-wiki-action@6448478bd55f1f3f752c93af8ac03207eccc3213 # pin@v5.0.3 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e230efac5..e60d17fd3 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@v6 + uses: actions/checkout@v7 - name: setup python 3 uses: actions/setup-python@v6 @@ -86,7 +86,7 @@ jobs: environment: pypi-release steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Setup Python uses: actions/setup-python@v6 diff --git a/.github/workflows/remote-release-trigger.yml b/.github/workflows/remote-release-trigger.yml index 2c05f2c04..195f21804 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@v6 + uses: actions/checkout@v7 with: # We want to checkout the main branch ref: 'main' From 3d8c703d213e510ed8b0e991f4b84617aa60ea19 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 1 Jul 2026 01:12:02 +0000 Subject: [PATCH 05/32] build(deps): bump softprops/action-gh-release from 2.5.0 to 3.0.1 Bumps [softprops/action-gh-release](https://github.com/softprops/action-gh-release) from 2.5.0 to 3.0.1. - [Release notes](https://github.com/softprops/action-gh-release/releases) - [Changelog](https://github.com/softprops/action-gh-release/blob/master/CHANGELOG.md) - [Commits](https://github.com/softprops/action-gh-release/compare/a06a81a03ee405af7f2048a818ed3f03bbf83c7b...718ea10b132b3b2eba29c1007bb80653f286566b) --- updated-dependencies: - dependency-name: softprops/action-gh-release dependency-version: 3.0.1 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/remote-release-trigger.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/remote-release-trigger.yml b/.github/workflows/remote-release-trigger.yml index 195f21804..13cf23b22 100644 --- a/.github/workflows/remote-release-trigger.yml +++ b/.github/workflows/remote-release-trigger.yml @@ -66,7 +66,7 @@ jobs: commit_sha: ${{ steps.calculate_head_sha.outputs.commit_sha }} - name: Release - uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # pin@v2.5.0 + uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # pin@v3.0.1 with: target_commitish: 'main' token: ${{ steps.generate_token.outputs.token }} From 1b9041dc0461aea06dabf894d9a6c6cb79ad7643 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 1 May 2026 01:31:44 +0000 Subject: [PATCH 06/32] build(deps): bump actions/github-script from 8 to 9 Bumps [actions/github-script](https://github.com/actions/github-script) from 8 to 9. - [Release notes](https://github.com/actions/github-script/releases) - [Commits](https://github.com/actions/github-script/compare/v8...v9) --- updated-dependencies: - dependency-name: actions/github-script dependency-version: '9' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/clean-release-notes.yml | 2 +- .github/workflows/e2e-suite-windows.yml | 2 +- .github/workflows/e2e-suite.yml | 2 +- .github/workflows/release.yml | 2 +- .github/workflows/remote-release-trigger.yml | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/clean-release-notes.yml b/.github/workflows/clean-release-notes.yml index 9006a5daf..9b7305275 100644 --- a/.github/workflows/clean-release-notes.yml +++ b/.github/workflows/clean-release-notes.yml @@ -12,7 +12,7 @@ jobs: steps: - name: Remove ticket prefixes from release notes - uses: actions/github-script@v8 + uses: actions/github-script@v9 with: script: | const release = context.payload.release; diff --git a/.github/workflows/e2e-suite-windows.yml b/.github/workflows/e2e-suite-windows.yml index a4fd703fe..a8919de0b 100644 --- a/.github/workflows/e2e-suite-windows.yml +++ b/.github/workflows/e2e-suite-windows.yml @@ -74,7 +74,7 @@ jobs: env: LINODE_CLI_TOKEN: ${{ secrets.LINODE_TOKEN_2 }} - - uses: actions/github-script@v8 + - uses: actions/github-script@v9 id: update-check-run if: ${{ inputs.pull_request_number != '' && fromJson(steps.commit-hash.outputs.data).repository.pullRequest.headRef.target.oid == inputs.sha }} env: diff --git a/.github/workflows/e2e-suite.yml b/.github/workflows/e2e-suite.yml index 6674e38f9..f4db2acc6 100644 --- a/.github/workflows/e2e-suite.yml +++ b/.github/workflows/e2e-suite.yml @@ -143,7 +143,7 @@ jobs: retention-days: 1 - name: Update PR Check Run - uses: actions/github-script@v8 + uses: actions/github-script@v9 id: update-check-run if: ${{ inputs.pull_request_number != '' && fromJson(steps.commit-hash.outputs.data).repository.pullRequest.headRef.target.oid == inputs.sha }} env: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e60d17fd3..d0120a8f8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -53,7 +53,7 @@ jobs: # This is necessary as we want to ensure that version tags # are properly formatted before passing them into the # DockerFile. - - uses: actions/github-script@v8 + - uses: actions/github-script@v9 id: cli_version with: script: | diff --git a/.github/workflows/remote-release-trigger.yml b/.github/workflows/remote-release-trigger.yml index 13cf23b22..d77682700 100644 --- a/.github/workflows/remote-release-trigger.yml +++ b/.github/workflows/remote-release-trigger.yml @@ -29,7 +29,7 @@ jobs: - name: Calculate the desired release version id: calculate_version - uses: actions/github-script@v8 + uses: actions/github-script@v9 env: SPEC_VERSION: ${{ github.event.client_payload.spec_version }} PREVIOUS_CLI_VERSION: ${{ steps.previoustag.outputs.tag }} From efe6dfb693ccf24065a14f19fa5e6359cc8baec0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 13 Jul 2026 12:16:32 -0400 Subject: [PATCH 07/32] build(deps): bump actions/dependency-review-action from 4 to 5 (#903) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/dependency-review.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index 80a2556b4..6bdc18320 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -14,6 +14,6 @@ jobs: - name: 'Checkout repository' uses: actions/checkout@v7 - name: 'Dependency Review' - uses: actions/dependency-review-action@v4 + uses: actions/dependency-review-action@v5 with: comment-summary-in-pr: on-failure From 35fb1d8b0d881e8d5813241da5a1698cc109266f Mon Sep 17 00:00:00 2001 From: Maciej Wilk Date: Tue, 14 Jul 2026 16:02:32 +0200 Subject: [PATCH 08/32] TPT-4280: Add integration tests for Reserved IP for IPv4 (#886) Co-authored-by: Erik Zilber --- tests/integration/linodes/helpers.py | 7 +- tests/integration/networking/fixtures.py | 70 +++++ .../integration/networking/test_networking.py | 255 +++++++++++++++--- tests/integration/tags/test_tags.py | 74 ++++- 4 files changed, 363 insertions(+), 43 deletions(-) create mode 100644 tests/integration/networking/fixtures.py diff --git a/tests/integration/linodes/helpers.py b/tests/integration/linodes/helpers.py index 88155338a..39ff206ad 100644 --- a/tests/integration/linodes/helpers.py +++ b/tests/integration/linodes/helpers.py @@ -1,4 +1,5 @@ import json +import os import time from tests.integration.helpers import ( @@ -7,7 +8,11 @@ ) DEFAULT_RANDOM_PASS = exec_test_command(["openssl", "rand", "-base64", "32"]) -DEFAULT_REGION = "us-ord" +DEFAULT_REGION = ( + "pl-labkrk-2" + if "devcloud" in os.getenv("LINODE_CLI_API_HOST", "") + else "us-ord" +) DEFAULT_TEST_IMAGE = exec_test_command( [ diff --git a/tests/integration/networking/fixtures.py b/tests/integration/networking/fixtures.py new file mode 100644 index 000000000..d6948417e --- /dev/null +++ b/tests/integration/networking/fixtures.py @@ -0,0 +1,70 @@ +import json + +import pytest + +from tests.integration.helpers import ( + BASE_CMDS, + delete_target_id, + exec_test_command, +) +from tests.integration.linodes.helpers import ( + DEFAULT_REGION, + create_linode, + create_linode_and_wait, +) + + +@pytest.fixture +def create_reserved_ip(request): + tags = getattr(request, "param", None) + command = BASE_CMDS["networking"] + [ + "reserved-ip-add", + "--region", + DEFAULT_REGION, + "--json", + ] + + if tags: + command += ["--tags", tags] + + result = json.loads(exec_test_command(command))[0] + + yield result + + delete_target_id("networking", result["address"], "reserved-ip-delete") + + +@pytest.fixture(scope="package") +def get_linode_id(linode_cloud_firewall): + linode_id = create_linode_and_wait(firewall_id=linode_cloud_firewall) + + yield linode_id + + delete_target_id(target="linodes", id=linode_id) + + +@pytest.fixture(scope="package") +def get_linode_ids_shared_ipv4(linode_cloud_firewall): + target_region = "us-mia" + + linode_ids = ( + create_linode( + test_region=target_region, firewall_id=linode_cloud_firewall + ), + create_linode( + test_region=target_region, firewall_id=linode_cloud_firewall + ), + ) + + yield linode_ids + + for id_num in linode_ids: + delete_target_id(target="linodes", id=id_num) + + +def get_command_heads_and_vals(command): + result = exec_test_command(command).splitlines() + headers = [item for item in result[0].split(",")] + values = [item for item in result[1].split(",")] + + return headers, values diff --git a/tests/integration/networking/test_networking.py b/tests/integration/networking/test_networking.py index f54907202..23904b956 100644 --- a/tests/integration/networking/test_networking.py +++ b/tests/integration/networking/test_networking.py @@ -1,3 +1,4 @@ +import ipaddress import json import re @@ -6,41 +7,31 @@ from tests.integration.helpers import ( BASE_CMDS, - delete_target_id, + assert_headers_in_lines, exec_test_command, ) -from tests.integration.linodes.helpers import ( - create_linode, - create_linode_and_wait, +from tests.integration.linodes.helpers import DEFAULT_REGION +from tests.integration.networking.fixtures import ( # noqa: F401 + create_reserved_ip, + get_command_heads_and_vals, + get_linode_id, + get_linode_ids_shared_ipv4, ) - -@pytest.fixture(scope="package") -def test_linode_id(linode_cloud_firewall): - linode_id = create_linode_and_wait(firewall_id=linode_cloud_firewall) - - yield linode_id - - delete_target_id(target="linodes", id=linode_id) - - -@pytest.fixture(scope="package") -def test_linode_id_shared_ipv4(linode_cloud_firewall): - target_region = "us-mia" - - linode_ids = ( - create_linode( - test_region=target_region, firewall_id=linode_cloud_firewall - ), - create_linode( - test_region=target_region, firewall_id=linode_cloud_firewall - ), - ) - - yield linode_ids - - for id in linode_ids: - delete_target_id(target="linodes", id=id) +RESERVED_IP_HEADERS = [ + "address", + "type", + "public", + "rdns", + "region", + "linode_id", + "interface_id", + "reserved", + "gateway", + "prefix", + "subnet_mask", + "tags", +] def has_shared_ip(linode_id: int, ip: str) -> bool: @@ -61,7 +52,18 @@ def has_shared_ip(linode_id: int, ip: str) -> bool: return False -def test_display_ips_for_available_linodes(test_linode_id): +def verify_reserved_ip(result): + assert isinstance( + ipaddress.ip_address(result["address"]), ipaddress.IPv4Address + ) + assert result["type"] == "ipv4" + assert result["public"] == True + assert result["region"] == DEFAULT_REGION + assert not result["linode_id"] + assert result["reserved"] == True + + +def test_display_ips_for_available_linodes(get_linode_id): result = exec_test_command( BASE_CMDS["networking"] + ["ips-list", "--text", "--no-headers", "--delimiter", ","] @@ -69,7 +71,7 @@ def test_display_ips_for_available_linodes(test_linode_id): assert re.search(r"^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}", result) assert re.search( - r"ipv4,True,[0-9]{1,3}\-[0-9]{1,3}\-[0-9]{1,3}\-[0-9]{1,3}\.ip.linodeusercontent.com,.*,[0-9][0-9][0-9][0-9][0-9][0-9][0-9]*", + r"ipv4,True,(False|True),[0-9]{1,3}\-[0-9]{1,3}\-[0-9]{1,3}\-[0-9]{1,3}\.ip\.linodeusercontent\.com,[0-9]*", result, ) assert re.search("ipv6,True,,.*,[0-9][0-9][0-9][0-9][0-9][0-9]*", result) @@ -80,8 +82,8 @@ def test_display_ips_for_available_linodes(test_linode_id): @pytest.mark.smoke -def test_view_an_ip_address(test_linode_id): - linode_id = test_linode_id +def test_view_an_ip_address(get_linode_id): + linode_id = get_linode_id linode_ipv4 = exec_test_command( [ "linode-cli", @@ -119,8 +121,8 @@ def test_view_an_ip_address(test_linode_id): ), f"`interface_id` is not None or int: {data['interface_id']}" -def test_allocate_additional_private_ipv4_address(test_linode_id): - linode_id = test_linode_id +def test_allocate_additional_private_ipv4_address(get_linode_id): + linode_id = get_linode_id result = exec_test_command( BASE_CMDS["networking"] @@ -145,10 +147,185 @@ def test_allocate_additional_private_ipv4_address(test_linode_id): ) +@pytest.mark.smoke +@pytest.mark.parametrize( + "create_reserved_ip, expected", + [("test", ["test"]), (None, [])], + indirect=["create_reserved_ip"], +) +def test_create_reserved_ip(create_reserved_ip, expected): + res_ip_data = create_reserved_ip + headers = list(res_ip_data.keys()) + + assert_headers_in_lines(RESERVED_IP_HEADERS, [headers]) + verify_reserved_ip(res_ip_data) + assert res_ip_data["tags"] == expected + + +@pytest.mark.parametrize("create_reserved_ip", ["test"], indirect=True) +def test_update_reserved_ip_tags(create_reserved_ip): + res_ip_data = create_reserved_ip + assert res_ip_data["tags"] == ["test"] + + result = json.loads( + exec_test_command( + BASE_CMDS["networking"] + + [ + "reserved-ip-update", + "--tags", + "updated", + "--tags", + "updated2", + res_ip_data["address"], + "--json", + ] + ) + )[0] + + verify_reserved_ip(result) + assert result["tags"] == ["updated", "updated2"] + + +def test_create_reserved_ip_assigned(create_reserved_ip, get_linode_id): + res_ip_data = create_reserved_ip + linode_id = get_linode_id + + exec_test_command( + BASE_CMDS["networking"] + + [ + "ip-assign", + "--assignments.linode_id", + linode_id, + "--assignments.address", + res_ip_data["address"], + "--region", + DEFAULT_REGION, + ] + ) + + result = json.loads( + exec_test_command( + BASE_CMDS["linodes"] + + [ + "ip-view", + linode_id, + res_ip_data["address"], + "--json", + ] + ) + )[0] + headers = list(result.keys()) + + assert_headers_in_lines(RESERVED_IP_HEADERS[:-1], [headers]) + assert result["address"] == res_ip_data["address"] + assert result["linode_id"] == int(linode_id) + assert result["reserved"] == True + assert "tags" not in headers + assert "assigned_entity" in headers + + +def test_get_reserved_ip_types(): + headers_exp = [ + "id", + "label", + "price", + "region_prices", + ] + result = json.loads( + exec_test_command( + BASE_CMDS["networking"] + + [ + "reserved-ip-types-list", + "--json", + ] + ) + )[0] + headers = list(result.keys()) + prices = [result["price"]["hourly"], result["price"]["monthly"]] + + assert_headers_in_lines(headers_exp, [headers]) + assert result["id"] == "reserved-ipv4" + assert result["label"] == "Reserved IPv4" + assert any(price != 0 for price in prices) + + +def test_get_reserved_ip_view(create_reserved_ip): + res_ip_data = create_reserved_ip + result = json.loads( + exec_test_command( + BASE_CMDS["networking"] + + [ + "reserved-ip-view", + res_ip_data["address"], + "--json", + ] + ) + )[0] + headers = list(result.keys()) + + assert_headers_in_lines(RESERVED_IP_HEADERS, [headers]) + verify_reserved_ip(result) + + +def test_get_reserved_ips_list(create_reserved_ip): + result = exec_test_command( + BASE_CMDS["networking"] + + [ + "reserved-ips-list", + "--text", + "--no-headers", + "--format", + "reserved", + ] + ).splitlines() + + assert all(item == "True" for item in result) + + +def test_update_ephemeral_to_reserved(get_linode_id): + linode_id = get_linode_id + + ephemeral_ip = exec_test_command( + BASE_CMDS["linodes"] + + [ + "view", + linode_id, + "--text", + "--no-headers", + "--format", + "ipv4", + ] + ).split(" ")[0] + + exec_test_command( + BASE_CMDS["networking"] + + [ + "ip-update", + ephemeral_ip, + "--reserved", + "true", + ] + ) + + is_reserved = exec_test_command( + BASE_CMDS["networking"] + + [ + "reserved-ip-view", + ephemeral_ip, + "--text", + "--no-headers", + "--format", + "reserved", + ] + ) + + assert is_reserved == "True" + + def test_share_ipv4_address( - test_linode_id_shared_ipv4, monkeypatch: MonkeyPatch + get_linode_ids_shared_ipv4, monkeypatch: MonkeyPatch ): - target_linode, parent_linode = test_linode_id_shared_ipv4 + target_linode, parent_linode = get_linode_ids_shared_ipv4 monkeypatch.setenv("LINODE_CLI_API_VERSION", "v4beta") # Allocate an IPv4 address on the parent Linode diff --git a/tests/integration/tags/test_tags.py b/tests/integration/tags/test_tags.py index a51e13629..4022cd746 100644 --- a/tests/integration/tags/test_tags.py +++ b/tests/integration/tags/test_tags.py @@ -1,17 +1,23 @@ +import json + import pytest from linodecli.exit_codes import ExitCodes from tests.integration.helpers import ( BASE_CMDS, + assert_headers_in_lines, delete_target_id, exec_failing_test_command, exec_test_command, get_random_text, ) +from tests.integration.networking.fixtures import ( # noqa: F401 + create_reserved_ip, +) @pytest.fixture(scope="session") -def test_tag_instance(): +def create_tag_instance(): unique_tag = get_random_text(5) + "-tag" exec_test_command( @@ -24,12 +30,38 @@ def test_tag_instance(): delete_target_id("tags", unique_tag) +@pytest.fixture +def create_tag_instance_for_reserved_ip(create_reserved_ip): + res_ip_data = create_reserved_ip + tag_label = get_random_text(5) + "-tag" + + exec_test_command( + BASE_CMDS["tags"] + + [ + "create", + "--label", + tag_label, + "--reserved_ipv4_addresses", + res_ip_data["address"], + ] + ) + + yield res_ip_data, tag_label + + result = exec_test_command( + BASE_CMDS["tags"] + ["list", "--text", "--no-headers"] + ) + + if tag_label in result: + delete_target_id("tags", tag_label) + + @pytest.mark.smoke -def test_view_unique_tag(test_tag_instance): +def test_view_unique_tag(create_tag_instance): result = exec_test_command( BASE_CMDS["tags"] + ["list", "--text", "--no-headers"] ) - assert test_tag_instance in result + assert create_tag_instance in result @pytest.mark.skip(reason="BUG = TPT-3650") @@ -42,3 +74,39 @@ def test_fail_to_create_tag_shorter_than_three_char(): ) assert "Request failed: 400" in result assert "Length must be 3-50 characters" in result + + +def test_create_delete_tag_for_reserved_ip(create_tag_instance_for_reserved_ip): + res_ip_data, tag_label = create_tag_instance_for_reserved_ip + + result = json.loads( + exec_test_command( + BASE_CMDS["tags"] + + [ + "get-tagged-objects", + tag_label, + "--json", + ] + ) + )[0] + headers = list(result.keys()) + + assert_headers_in_lines(["data", "type"], [headers]) + assert result["data"]["address"] == res_ip_data["address"] + assert result["data"]["reserved"] == True + assert len(result["data"]["tags"]) == 1 + assert result["data"]["tags"][0] == tag_label + assert result["type"] == "reserved_ipv4_address" + + exec_test_command( + BASE_CMDS["tags"] + + [ + "delete", + tag_label, + ] + ) + + result = exec_test_command( + BASE_CMDS["tags"] + ["list", "--text", "--no-headers"] + ) + assert tag_label not in result From 9e5b285291f08ef7ea2c8d5ef778be46c964a481 Mon Sep 17 00:00:00 2001 From: Maciej Wilk Date: Thu, 16 Jul 2026 15:22:50 +0200 Subject: [PATCH 09/32] TPT-4475: Add integration tests for Configurable VPC top-level IPv4 Prefixes (#905) --- tests/integration/vpc/conftest.py | 21 +++++++ tests/integration/vpc/test_vpc.py | 100 ++++++++++++++++++++++++++++++ 2 files changed, 121 insertions(+) diff --git a/tests/integration/vpc/conftest.py b/tests/integration/vpc/conftest.py index 9b1abace0..d4cbb033f 100644 --- a/tests/integration/vpc/conftest.py +++ b/tests/integration/vpc/conftest.py @@ -67,3 +67,24 @@ def test_subnet(test_vpc_wo_subnet): ) yield res, subnet_label + + +@pytest.fixture +def create_vpc_with_ipv4(request): + params = getattr(request, "param", None) + params = params.split() if params else [] + region = get_random_region_with_caps( + required_capabilities=["VPCs", "Custom VPC IPv4 Ranges"] + ) + label = get_random_text(5) + "-label" + + vpc_id = exec_test_command( + BASE_CMDS["vpcs"] + + ["create", "--label", label, "--region", region] + + params + + ["--no-headers", "--text", "--format=id"] + ) + + yield vpc_id + + delete_target_id(target="vpcs", id=vpc_id) diff --git a/tests/integration/vpc/test_vpc.py b/tests/integration/vpc/test_vpc.py index e56b4f55a..6616aa9f6 100644 --- a/tests/integration/vpc/test_vpc.py +++ b/tests/integration/vpc/test_vpc.py @@ -383,3 +383,103 @@ def test_list_vpc_ipv6s_address(): for header in headers: assert header in lines[0] + + +def test_get_vpc_default_ranges(): + headers = ["default_ipv4_ranges", "forbidden_ipv4_ranges"] + + result = json.loads( + exec_test_command(BASE_CMD + ["default-ranges-all-list", "--json"]) + )[0] + + assert all(header in result.keys() for header in headers) + assert isinstance(result[headers[0]], list) + assert isinstance(result[headers[1]], list) + + +@pytest.mark.parametrize( + "create_vpc_with_ipv4, expected", + [ + ("--ipv4.range 10.0.0.0/8", 1), + ("--ipv4.range 10.0.0.0/8 --ipv4.range 192.168.0.0/17", 2), + ], + indirect=["create_vpc_with_ipv4"], +) +def test_vpc_with_ipv4(create_vpc_with_ipv4, expected): + vpc_id = create_vpc_with_ipv4 + + result = exec_test_command( + BASE_CMDS["vpcs"] + ["list", "--text", "--format=id", "--no-headers"] + ) + assert vpc_id in result.splitlines() + + result = json.loads( + exec_test_command(BASE_CMDS["vpcs"] + ["view", vpc_id, "--json"]) + )[0] + assert len(result["ipv4"]) == expected + + +@pytest.mark.parametrize( + "create_vpc_with_ipv4, updated", + [ + ("--ipv4.range 10.0.0.0/8", "192.168.0.0/17"), + ], + indirect=["create_vpc_with_ipv4"], +) +def test_vpc_update_with_ipv4(create_vpc_with_ipv4, updated): + vpc_id = create_vpc_with_ipv4 + + exec_test_command( + BASE_CMDS["vpcs"] + + [ + "update", + vpc_id, + "--ipv4.range", + updated, + ] + ) + + result = json.loads( + exec_test_command(BASE_CMDS["vpcs"] + ["view", vpc_id, "--json"]) + )[0] + assert len(result["ipv4"]) == 1 + assert result["ipv4"][0]["range"] == updated + + +def test_vpc_with_forbidden_ipv4_fail(): + forbidden_ipv4 = exec_test_command( + BASE_CMD + + [ + "default-ranges-all-list", + "--text", + "--no-headers", + "--format=forbidden_ipv4_ranges", + ] + ).split()[0] + + region = get_random_region_with_caps( + required_capabilities=["VPCs", "Custom VPC IPv4 Ranges"] + ) + label = get_random_text(5) + "-label" + + result = exec_failing_test_command( + BASE_CMDS["vpcs"] + + [ + "create", + "--label", + label, + "--region", + region, + "--ipv4.range", + forbidden_ipv4, + "--text", + "--no-headers", + ], + ExitCodes.REQUEST_FAILED, + ) + + assert "Request failed: 400" in result + assert ( + f"The IPv4 range {forbidden_ipv4} overlaps with the forbidden IPv4 range {forbidden_ipv4}" + in result + ) From a9adfb7a7c5ba2f3671225862a2257f4065f9cad Mon Sep 17 00:00:00 2001 From: Zhiwei Liang <121905282+zliang-akamai@users.noreply.github.com> Date: Wed, 22 Jul 2026 11:32:44 -0400 Subject: [PATCH 10/32] TPT-4557: Add security release label (#907) --- .github/labels.yml | 3 +++ .github/release.yml | 3 +++ 2 files changed, 6 insertions(+) diff --git a/.github/labels.yml b/.github/labels.yml index 83989042c..f08a1ae6e 100644 --- a/.github/labels.yml +++ b/.github/labels.yml @@ -26,6 +26,9 @@ - name: breaking-change description: for breaking changes in the changelog. color: ff0000 +- name: security + description: for security fixes and improvements in the changelog. + color: AA1111 - name: ignore-for-release description: PRs you do not want to render in the changelog color: 7b8eac diff --git a/.github/release.yml b/.github/release.yml index a2318fa64..5bf07fc08 100644 --- a/.github/release.yml +++ b/.github/release.yml @@ -9,6 +9,9 @@ changelog: - title: ⚠️ Breaking Change labels: - breaking-change + - title: 🔒 Security Fixes + labels: + - security - title: 🐛 Bug Fixes labels: - bugfix From 496715612e3203e74b296aed0f9e8febab1b17fe Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 4 Aug 2026 13:22:25 -0400 Subject: [PATCH 11/32] build(deps): bump pypa/gh-action-pypi-publish from 1.14.0 to 1.14.2 (#915) 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 d0120a8f8..2e101b3ad 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -106,4 +106,4 @@ jobs: LINODE_CLI_VERSION: ${{ github.event.release.tag_name }} - name: Publish the release artifacts to PyPI - uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # pin@release/v1.14.0 + uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # pin@release/v1.14.2 From b983207ebb21aa8160de5b7c76dd33f7ea30009b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 4 Aug 2026 13:22:40 -0400 Subject: [PATCH 12/32] build(deps): bump actions/setup-python from 6 to 7 (#913) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 6 +++--- .github/workflows/e2e-suite-windows.yml | 4 ++-- .github/workflows/e2e-suite.yml | 6 +++--- .github/workflows/nightly-smoke-tests.yml | 2 +- .github/workflows/release.yml | 4 ++-- 5 files changed, 11 insertions(+), 11 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8ed342fea..dce3a73f8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -48,7 +48,7 @@ jobs: uses: actions/checkout@v7 - name: setup python 3 - uses: actions/setup-python@v6 + uses: actions/setup-python@v7 with: python-version: '3.x' @@ -68,7 +68,7 @@ jobs: uses: actions/checkout@v7 - name: Setup Python - uses: actions/setup-python@v6 + uses: actions/setup-python@v7 with: python-version: ${{ matrix.python-version }} @@ -90,7 +90,7 @@ jobs: uses: actions/checkout@v7 - name: Setup Python - uses: actions/setup-python@v6 + uses: actions/setup-python@v7 with: python-version: "3.x" diff --git a/.github/workflows/e2e-suite-windows.yml b/.github/workflows/e2e-suite-windows.yml index a8919de0b..1ce2c3934 100644 --- a/.github/workflows/e2e-suite-windows.yml +++ b/.github/workflows/e2e-suite-windows.yml @@ -61,7 +61,7 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Setup Python - uses: actions/setup-python@v6 + uses: actions/setup-python@v7 with: python-version: '3.x' @@ -135,7 +135,7 @@ jobs: steps: - name: Set up Python - uses: actions/setup-python@v6 + uses: actions/setup-python@v7 with: python-version: '3.x' diff --git a/.github/workflows/e2e-suite.yml b/.github/workflows/e2e-suite.yml index f4db2acc6..b9db66153 100644 --- a/.github/workflows/e2e-suite.yml +++ b/.github/workflows/e2e-suite.yml @@ -106,7 +106,7 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Setup Python - uses: actions/setup-python@v6 + uses: actions/setup-python@v7 with: python-version: ${{ inputs.run-eol-python-version == 'true' && env.EOL_PYTHON_VERSION || inputs.python-version || env.DEFAULT_PYTHON_VERSION }} @@ -208,7 +208,7 @@ jobs: steps: - name: Set up Python - uses: actions/setup-python@v6 + uses: actions/setup-python@v7 with: python-version: '3.x' @@ -257,7 +257,7 @@ jobs: name: test-report-file - name: Set up Python - uses: actions/setup-python@v6 + uses: actions/setup-python@v7 with: python-version: '3.x' diff --git a/.github/workflows/nightly-smoke-tests.yml b/.github/workflows/nightly-smoke-tests.yml index 3d0014e9a..0d7e48c2e 100644 --- a/.github/workflows/nightly-smoke-tests.yml +++ b/.github/workflows/nightly-smoke-tests.yml @@ -25,7 +25,7 @@ jobs: ref: ${{ github.event.inputs.sha || github.ref }} - name: Set up Python - uses: actions/setup-python@v6 + uses: actions/setup-python@v7 with: python-version: '3.x' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2e101b3ad..ecc8ff178 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -31,7 +31,7 @@ jobs: uses: actions/checkout@v7 - name: setup python 3 - uses: actions/setup-python@v6 + uses: actions/setup-python@v7 with: python-version: '3.x' @@ -89,7 +89,7 @@ jobs: uses: actions/checkout@v7 - name: Setup Python - uses: actions/setup-python@v6 + uses: actions/setup-python@v7 with: python-version: '3.x' From ce1e67e7ed5aa7138bbdeccbe2412d93faf24192 Mon Sep 17 00:00:00 2001 From: Zhiwei Liang <121905282+zliang-akamai@users.noreply.github.com> Date: Tue, 4 Aug 2026 16:42:14 -0400 Subject: [PATCH 13/32] TPT-4578: Add community contribution and hotfix labels (#910) --- .github/labels.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/labels.yml b/.github/labels.yml index f08a1ae6e..bc189cd77 100644 --- a/.github/labels.yml +++ b/.github/labels.yml @@ -29,6 +29,12 @@ - name: security description: for security fixes and improvements in the changelog. color: AA1111 +- name: community-contribution + description: contributions from the community. + color: 22ee47 +- name: hotfix + description: urgent fixes for production issues. + color: ff0000 - name: ignore-for-release description: PRs you do not want to render in the changelog color: 7b8eac From 447253244c8a6a77c9a9cc691894a2fcca5410a0 Mon Sep 17 00:00:00 2001 From: Zhiwei Liang <121905282+zliang-akamai@users.noreply.github.com> Date: Tue, 4 Aug 2026 16:57:43 -0400 Subject: [PATCH 14/32] TPT-4056: Fix GitHub Actions script injection vulnerabilities (#906) --- .github/workflows/e2e-suite-windows.yml | 4 +++- .github/workflows/e2e-suite.yml | 7 +++++-- .github/workflows/release.yml | 4 +++- 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/.github/workflows/e2e-suite-windows.yml b/.github/workflows/e2e-suite-windows.yml index 1ce2c3934..6d1395ce4 100644 --- a/.github/workflows/e2e-suite-windows.yml +++ b/.github/workflows/e2e-suite-windows.yml @@ -70,9 +70,11 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - run: make MODULE="${{ inputs.module }}" RUN_LONG_TESTS="${{ inputs.run_long_tests }}" test-int + - run: make MODULE="$env:MODULE" RUN_LONG_TESTS="$env:RUN_LONG_TESTS" test-int env: LINODE_CLI_TOKEN: ${{ secrets.LINODE_TOKEN_2 }} + MODULE: ${{ inputs.module }} + RUN_LONG_TESTS: ${{ inputs.run_long_tests }} - uses: actions/github-script@v9 id: update-check-run diff --git a/.github/workflows/e2e-suite.yml b/.github/workflows/e2e-suite.yml index b9db66153..dbe293c58 100644 --- a/.github/workflows/e2e-suite.yml +++ b/.github/workflows/e2e-suite.yml @@ -117,9 +117,10 @@ jobs: pip install .[obj,dev] - name: Install Package - run: make install SPEC="${{ inputs.OPENAPI_SPEC_URL }}" + run: make install SPEC="$OPENAPI_SPEC_URL" env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + OPENAPI_SPEC_URL: ${{ inputs.OPENAPI_SPEC_URL }} - name: Set LINODE_CLI_TOKEN run: | @@ -129,9 +130,11 @@ jobs: run: | timestamp=$(date +'%Y%m%d%H%M') report_filename="${timestamp}_cli_test_report.xml" - make test-int TEST_ARGS="--junitxml=${report_filename}" TEST_SUITE="${{ inputs.test_suite }}" RUN_LONG_TESTS="${{ inputs.run_long_tests }}" + make test-int TEST_ARGS="--junitxml=${report_filename}" TEST_SUITE="$TEST_SUITE" RUN_LONG_TESTS="$RUN_LONG_TESTS" env: LINODE_CLI_TOKEN: ${{ env.LINODE_CLI_TOKEN }} + TEST_SUITE: ${{ inputs.test_suite }} + RUN_LONG_TESTS: ${{ inputs.run_long_tests }} - name: Upload Test Report as Artifact if: always() diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ecc8ff178..110c2abbc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -55,9 +55,11 @@ jobs: # DockerFile. - uses: actions/github-script@v9 id: cli_version + env: + RELEASE_TAG_NAME: ${{ github.event.release.tag_name }} with: script: | - let tag_name = '${{ github.event.release.tag_name }}'; + let tag_name = process.env.RELEASE_TAG_NAME; if (tag_name.startsWith("v")) { tag_name = tag_name.slice(1); From ca0e887a92711fe382f9beb37ce72077d1cc03a4 Mon Sep 17 00:00:00 2001 From: Maciej Wilk Date: Thu, 6 Aug 2026 13:15:00 +0200 Subject: [PATCH 15/32] TPT-4560: Add merge readiness check (#911) Co-authored-by: Zhiwei Liang <121905282+zliang-akamai@users.noreply.github.com> --- .github/workflows/ci.yml | 6 ++++++ .github/workflows/labeler.yml | 9 ++++----- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dce3a73f8..2502a0d5a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,6 +3,7 @@ on: workflow_dispatch: null push: pull_request: + types: [opened, synchronize, reopened, labeled, unlabeled] jobs: docker-build: runs-on: ubuntu-latest @@ -58,6 +59,11 @@ jobs: - name: run linter run: make lint + - name: Check Merge Readiness + if: github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'do-not-merge') + run: | + echo "::error::This PR is labeled 'do-not-merge'." + exit 1 unit-tests-on-ubuntu: runs-on: ubuntu-latest strategy: diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml index 394ec5de9..ae22dada4 100644 --- a/.github/workflows/labeler.yml +++ b/.github/workflows/labeler.yml @@ -1,4 +1,4 @@ -name: labeler +name: Labeler on: push: @@ -16,11 +16,10 @@ jobs: labeler: runs-on: ubuntu-latest steps: - - - name: Checkout + - name: Checkout uses: actions/checkout@v7 - - - name: Run Labeler + + - name: Run Labeler uses: crazy-max/ghaction-github-labeler@548a7c3603594ec17c819e1239f281a3b801ab4d with: github-token: ${{ secrets.GITHUB_TOKEN }} From 6d7a3367a2f2f4b19e334b4a48a82840021e7a8f Mon Sep 17 00:00:00 2001 From: Zhiwei Liang <121905282+zliang-akamai@users.noreply.github.com> Date: Mon, 10 Aug 2026 19:24:17 -0400 Subject: [PATCH 16/32] TPT-4591: Add `AGENTS.md` files (#912) --- AGENTS.md | 50 ++++++++++++++++++++++++++++ linodecli/baked/AGENTS.md | 69 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 119 insertions(+) create mode 100644 AGENTS.md create mode 100644 linodecli/baked/AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..bdebb748c --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,50 @@ +# Repository Guide + +## Setup +- Use `make requirements` for local setup. It installs `.[dev,obj]`; plain `.[dev]` misses `boto3`, which the object-storage plugin and tests use. +- Use a virtualenv if possible; package metadata requires Python `>=3.9`, CI tests Python 3.9 through 3.13, and the Dockerfile builds on Python 3.13. +- On a clean machine, export `LINODE_CLI_TOKEN` before running CLI commands that should not enter interactive `configure()`. Unit tests avoid config prompts with `LINODE_CLI_TEST_MODE=1`. +- `make install` is the closest local equivalent to CI setup, but it is heavyweight: `check-prerequisites -> requirements -> build -> pip3 install --force dist/*.whl`. +- The wiki setup page is mostly useful, but the wiki testing page has stale target names. Trust the `Makefile`: use `make test-unit` and `make test-int`, not `make testunit` or `make testint`. + +## Generated Files +- This CLI is spec-driven. Runtime commands load the baked pickle at `linodecli/data-3`; `MANIFEST.in` packages that file into distributions. +- `make build`, `make install`, and `make lint` all run generation through `build`: `clean`, rewrite `linodecli/version.py` from `LINODE_CLI_VERSION` (default `0.0.0.dev`), regenerate root `data-3`, copy it to `linodecli/data-3`, and rebuild `dist/`. +- Prefer `make bake SPEC=/path/to/openapi.json` or `make bake SPEC_VERSION=`; it resolves the spec via `./resolve_spec_url` when needed, passes `$(BAKE_FLAGS)` (`--debug` by default), and copies root `data-3` to `linodecli/data-3`. +- If you rely on the default `SPEC_VERSION=latest`, set `GITHUB_TOKEN`; `resolve_spec_url` calls the GitHub releases API for `linode/linode-api-openapi` and can hit rate limits without it. +- Manual bake: `python3 -m linodecli bake --skip-config` writes root `data-3` only; copy it to `linodecli/data-3` yourself or the package keeps the old pickle. `--skip-config` is a hidden sentinel checked in `linodecli/__init__.py` before argparse/config bootstrapping. +- Never hand-edit `linodecli/data-3` or root `data-3`; change bake logic or the source spec/extensions and rebake. +- `CLI._load_openapi_spec()` mutates parsed specs with `_normalize_content_parameters()` before `openapi3.OpenAPI(...)`; this converts OpenAPI Parameter `content` forms to top-level `schema` because the `openapi3` package does not support parameter `content` directly. + +## Code Map +- Importing top-level `linodecli` has side effects: `linodecli/__init__.py` constructs a global `CLI`, loads baked ops, and may load/configure user state immediately. +- CLI entrypoints are `linodecli/__init__.py:main` and `linodecli/__main__.py`; console scripts `linode-cli`, `linode`, and `lin` all point to `linodecli:main`. +- `linodecli/cli.py` handles spec loading/baking, baked-op loading, command lookup, custom aliases, and dispatch. +- `linodecli/api_request.py` builds request URLs, request bodies, `X-Filter`, retries, version warnings, and error output. +- `linodecli/output/output_handler.py` handles table, ASCII table, delimited, JSON, and Markdown output. `linodecli/overrides.py` contains command/action/output-mode-specific display overrides. +- `linodecli/configuration/` owns config loading, interactive configuration, OAuth token flow, env token handling, and API URL overrides. +- `linodecli/plugins/` contains hand-written commands outside the generated OpenAPI surface. If you add or change a plugin, read `linodecli/plugins/README.md` for the `call(args, context)` interface and third-party `PLUGIN_NAME` requirement. +- If you touch `linodecli/baked/*.py`, read `linodecli/baked/AGENTS.md` first. Key constraint: baked model state must stay pickle-safe. + +## Tests +- `make test` only runs unit tests; it is an alias for `make test-unit`. +- Use `make test-unit` for normal unit verification. It sets `LINODE_CLI_TEST_MODE=1` and `XDG_CONFIG_HOME=/tmp/linode/.config` so imports do not trigger interactive config. +- Focused unit test: `LINODE_CLI_TEST_MODE=1 XDG_CONFIG_HOME=$(mktemp -d) pytest tests/unit/test_cli.py -k ''`. +- Unit tests for bake/parsing behavior use minimal OpenAPI fixtures in `tests/fixtures/` and helper fixtures in `tests/unit/conftest.py`; add or update a fixture there when changing generated argument/response behavior. +- Integration tests shell out to the installed `linode-cli` binary, not the source tree directly. Re-run `make install` after code changes before trusting integration results. +- Integration and smoke tests hit the real Linode API and create/destroy real resources. Do not run them casually against a personal account. +- `tests/integration/conftest.py` has a session-scoped autouse firewall fixture, so even focused integration runs can create a cloud firewall before the selected test body runs. +- Focused integration run: `make test-int TEST_SUITE=domains TEST_CASE=test_create_a_domain TEST_ARGS='-v'`. +- Integration and smoke tests require `LINODE_CLI_TOKEN`. Long-running cases are skipped unless `RUN_LONG_TESTS=True` exactly. Smoke tests are `make test-smoke`. + +## Lint And Format +- `make lint` is not a pure lint pass; it depends on `make build`, so it cleans, rebakes, rebuilds `dist/`, then runs `pylint`, `isort --check-only`, `autoflake --check`, `black --check`, and `twine check dist/*`. +- Because lint rebakes, default `SPEC_VERSION=latest` needs GitHub API access (and often `GITHUB_TOKEN`). Without `SPEC=...` or a token, `make lint` can fail on rate limits during bake, not only on style. +- For quick checks without generation side effects, run style tools directly, for example `black --check linodecli tests`, `isort --check-only linodecli tests`, or `autoflake --check linodecli tests`. +- Formatting uses Black/isort with an 80-character line length. `make format` runs `black`, then `isort`, then `autoflake` and rewrites files in place. +- Keep syntax compatible with Python 3.9 even if developing on a newer interpreter. + +## Workflow +- CI enforces PR titles matching `TPT-: ` unless the PR is labeled `dependencies`, `hotfix`, `community-contribution`, or `ignore-for-release`. +- `e2e_scripts/` is a git submodule used by CI/e2e workflows, not the core CLI package. Fresh clones leave it empty until `git submodule update --init`. +- If you change behavior, commands, generated-file flow, test/lint setup, or architecture described here, update this `AGENTS.md` in the same change. diff --git a/linodecli/baked/AGENTS.md b/linodecli/baked/AGENTS.md new file mode 100644 index 000000000..f92d13c6b --- /dev/null +++ b/linodecli/baked/AGENTS.md @@ -0,0 +1,69 @@ +# Baked Module Guide + +## Core Constraint +- `linodecli/baked/` converts OpenAPI objects into a pickled runtime command model. The output is `data-3`, copied to `linodecli/data-3` by `make bake` and loaded at CLI startup. +- Constructors and bake helpers may read `openapi3` `Operation`, `MediaType`, `Schema`, and `Parameter` objects, but anything stored on `OpenAPIOperation`, `OpenAPIRequest`, `OpenAPIResponse`, `OpenAPIRequestArg`, `OpenAPIResponseAttr`, or `OpenAPIOperationParameter` must be pickle-safe for runtime loading. +- Do not store raw `openapi3` objects on baked models. Extract strings, numbers, booleans, lists, dicts, `None`, or simple project classes during bake. +- The `openapi3` library exposes spec extensions without the `x-` prefix. Code looks up keys like `linode-cli-use-schema`, `linode-cli-display`, and `linode-filterable`. +- If you change behavior or architecture described in this file, update this `AGENTS.md` in the same change. + +## Bake And Runtime Flow +- `linodecli/cli.py:CLI.bake()` iterates spec paths and only `get/post/put/delete`, skips operations with `linode-cli-skip`, resolves command/action, constructs `OpenAPIOperation`, then pickles `self.ops`. +- `CLI._load_openapi_spec()` normalizes OpenAPI Parameter `content` forms into top-level `schema` before calling `openapi3.OpenAPI(...)`; keep this when changing spec loading because `openapi3` does not support parameter `content` directly. +- `OpenAPIOperation.__init__` coordinates bake-time extraction: response model, request/filter model, path parameters excluding `apiVersion`, URL components, docs URL, allowed defaults, action aliases, and CLI code samples. +- Runtime command execution does not parse OpenAPI. `OpenAPIOperation.parse_args()` builds argparse from baked attrs, `linodecli/api_request.py` builds URL/filter/body, and `OpenAPIOperation.process_response_json()` applies output overrides before `response_model.fix_json()` and `OutputHandler.print_response()`. +- `CLI.load_baked()` unpickles `linodecli/data-3`, pops metadata keys (`_base_url`, `_spec_version`, `_spec`) off the ops map, and stores them on the `CLI` instance (`base_url`, `spec_version`, `spec`). Do not assume those keys remain in the runtime command map. + +## File Roles +- `operation.py`: main baked operation object, argparse actions, URL/docs resolution, response dispatch. Special user input sentinels live here: `ExplicitNullValue`, `ExplicitEmptyListValue`, `ExplicitJsonValue`. +- `request.py`: turns JSON request schemas into flat CLI args. Arrays of objects get both a parent JSON arg and child dot-path args. +- `response.py`: turns response schemas into output attrs and normalizes API JSON via `fix_json()`. +- `util.py`: composition/property aggregation plus dot-path escaping. Use `escape_arg_segment()` and `get_path_segments()` when schema property names can contain periods. +- `parsing.py`: short help-text extraction and Markdown-to-Rich conversion for baked descriptions. + +## OpenAPI Extensions Used Here +- Path/operation routing: `linode-cli-command`, `linode-cli-action`, `linode-cli-skip`. +- RequestBody extension: `linode-cli-allowed-defaults` is read from `operation.requestBody.extensions`, not from the JSON schema or media-type schema. +- Request schema/media-type parsing: `linode-cli-format`, `linode-cli-use-schema`, `linode-cli-skip`. +- Response/output parsing: `linode-cli-display`, `linode-cli-color`, `linode-filterable`, `linode-cli-rows`, `linode-cli-nested-list`, `linode-cli-subtables`, `linode-cli-use-schema`, `linode-cli-skip`. +- Samples: operation extension `code-samples`; only entries whose `lang` lowercases to `cli` are stored. + +## Request Parsing Gotchas +- `_aggregate_schema_properties()` merges `oneOf`, `anyOf`, and `allOf`; a field is marked required only if every leaf schema that defines properties requires it. Schemas that only nest composition without their own `properties` do not increment the required-count denominator. +- `OpenAPIRequest.attr_routes` stores per-`oneOf` option args keyed by schema `title`; oneOf entries without titles raise `ValueError`. +- `OpenAPIOperation.arg_routes` returns `self.request.attr_routes` (a dict) when a request exists, but `[]` when there is no request. Treat the empty case as a type inconsistency (list vs dict); callers that always call `.items()` will break on the no-request path. +- Request and response attributes marked `linode-cli-skip` are omitted from generated args/output. +- `linode-cli-use-schema` on `application/json` media types swaps the schema before parsing; this is used for CLI-specific request or display shapes. +- `linode-cli-format: json` stops deeper request parsing and accepts raw JSON. Deeply nested arrays are also treated as JSON. +- For arrays of objects, `_parse_request_model()` adds a parent arg that accepts JSON plus child args for each object property. `ListArgumentAction` groups adjacent child values into list items before `_build_request_body()` expands dot paths. +- Parent list args and child list args are mutually exclusive at runtime; `operation.py` validates conflicts such as `--interfaces` with `--interfaces.purpose`. +- `null` only becomes an explicit JSON null for nullable args. Unspecified `None` values are dropped later by `_traverse_request_body()` in `api_request.py`. +- `OptionalFromFileAction` loads file content only if the value resolves to an existing file; on Windows it also expands glob patterns before checking. +- `PasswordPromptAction` reads an explicit value, then `LINODE_CLI_`, then prompts interactively. + +## Request Body And Filters +- `api_request._build_request_body()` excludes path params, applies config defaults from `operation.allowed_defaults` when defaults are enabled, expands escaped dot-path keys with `get_path_segments()`, then serializes after `_traverse_request_body()`. +- `--raw-body` is only valid for POST/PUT-style body actions and cannot be combined with generated action args. +- GET filter args are generated only from paginated response attrs marked `linode-filterable`. +- `--order-by` is restricted to filterable attrs and becomes `+order_by` in `X-Filter`; list filters serialize as `+and` entries. + +## Response And Output Gotchas +- Bake-time pagination detection is structural: top-level response schema must have exactly `pages`, `page`, `results`, and `data`; only `data.items` becomes display attrs (`is_paginated`). +- `OpenAPIResponse.fix_json()` handles `linode-cli-rows`, then `linode-cli-nested-list`, then unwraps via `"pages" in json` (not `self.is_paginated`), then wraps non-lists. Runtime unwrap is looser than bake-time pagination; do not tighten it to `is_paginated` without checking odd payloads that only include `pages`. +- `linode-cli-rows` paths are extracted from the raw API JSON and concatenated; list values extend the result, scalar/object values are appended. +- `linode-cli-nested-list` flattens configured nested lists and adds `_split` with the final path segment. +- `linode-cli-subtables` is stored on the response model, but table splitting is implemented in `linodecli/output/output_handler.py`. +- `linode-cli-display` controls default columns and ordering; if no columns are selected or displayed, `OutputHandler` falls back to all attrs. +- `linode-cli-color` rendering expects a `default_` color for values not present in the map. +- Response attrs keep full dot-path names for lookup. `OutputHandler` deep-copies attrs before subtable scoping because it mutates attr names and nesting depth while printing. +- JSON output intentionally ignores nested-list depth filtering so nested data can be displayed correctly. + +## URL And Docs +- `_get_api_url_components()` uses the operation server or root server, resolves the default API version from server path or the `apiVersion` path parameter, and inserts `/{apiVersion}` when the path lacks it. +- Runtime URL overrides and pagination query params are applied in `api_request._build_request_url()`, not during bake. API version can come from `LINODE_CLI_API_VERSION`, config `api_version`, or the baked default. +- Docs URLs prefer `operation.externalDocs.url`; legacy fallback derives a Linode docs anchor from the first tag and summary. + +## Tests +- Unit tests for this module use minimal OpenAPI fixtures under `tests/fixtures/` and fixtures in `tests/unit/conftest.py` that construct real `OpenAPIOperation` objects. +- For bake/request/response changes, add or update a fixture plus focused tests in `tests/unit/test_operation.py`, `test_request.py`, `test_response.py`, `test_api_request.py`, or `test_output.py` as appropriate. +- Focused run: `LINODE_CLI_TEST_MODE=1 XDG_CONFIG_HOME=$(mktemp -d) pytest tests/unit/test_request.py -k ''`. From 7b726505211331858ec4809a81909258a1f0e53e Mon Sep 17 00:00:00 2001 From: Zhiwei Liang <121905282+zliang-akamai@users.noreply.github.com> Date: Fri, 14 Aug 2026 17:16:00 -0400 Subject: [PATCH 17/32] TPT-4613: Remove unsupported parameters from test_interface_settings_update (#900) --- linodecli/configuration/auth.py | 11 ++++++----- linodecli/configuration/helpers.py | 7 ++++--- tests/integration/linodes/test_linode_interfaces.py | 4 ---- 3 files changed, 10 insertions(+), 12 deletions(-) diff --git a/linodecli/configuration/auth.py b/linodecli/configuration/auth.py index 1b633c7bb..a23db3f86 100644 --- a/linodecli/configuration/auth.py +++ b/linodecli/configuration/auth.py @@ -214,11 +214,12 @@ def _get_token_terminal(base_url: str) -> Tuple[str, str]: :returns: A tuple containing the user's username and token. :rtype: Tuple[str, str] """ - print(f""" -First, we need a Personal Access Token. To get one, please visit -{TOKEN_GENERATION_URL} and click -"Create a Personal Access Token". The CLI needs access to everything -on your account to work correctly.""") + print( + "First, we need a Personal Access Token. To get one, please visit\n" + f"{TOKEN_GENERATION_URL} and click\n" + '"Create a Personal Access Token". The CLI needs access to everything\n' + "on your account to work correctly." + ) while True: token = input("Personal Access Token: ") diff --git a/linodecli/configuration/helpers.py b/linodecli/configuration/helpers.py index 35be04047..b7768a97d 100644 --- a/linodecli/configuration/helpers.py +++ b/linodecli/configuration/helpers.py @@ -97,9 +97,10 @@ def _check_browsers() -> bool: # pylint: disable-next=protected-access if not KNOWN_GOOD_BROWSERS.intersection(webbrowser._tryorder): - print(""" -This tool defaults to web-based authentication, -however no known-working browsers were found.""") + print( + "This tool defaults to web-based authentication,\n" + "however no known-working browsers were found." + ) while True: r = input("Try it anyway? [y/N]: ") if r.lower() in "yn ": diff --git a/tests/integration/linodes/test_linode_interfaces.py b/tests/integration/linodes/test_linode_interfaces.py index f36ed1a50..319615dd7 100644 --- a/tests/integration/linodes/test_linode_interfaces.py +++ b/tests/integration/linodes/test_linode_interfaces.py @@ -146,10 +146,6 @@ def test_interface_settings_update( interface_id, "--default_route.ipv6_interface_id", interface_id, - "--default_route.ipv4_eligible_interface_ids", - interface_id, - "--default_route.ipv6_eligible_interface_ids", - interface_id, "--json", ] ) From b2b5e0dfefcd618282fec28f9bdad9ac5bae89a1 Mon Sep 17 00:00:00 2001 From: CodeBySayak Date: Sat, 15 Aug 2026 03:38:57 +0530 Subject: [PATCH 18/32] Update README install instructions to use pipx (#877) Co-authored-by: Michal Wojcik <32574975+mgwoj@users.noreply.github.com> Co-authored-by: Zhiwei Liang <121905282+zliang-akamai@users.noreply.github.com> --- README.md | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index ef5e786c0..afc8baf56 100644 --- a/README.md +++ b/README.md @@ -12,12 +12,21 @@ Visit the [Wiki](../../wiki) for more information. ## Install -Install via PyPI: +We recommend installing `linode-cli` with `pipx`, which installs each Python CLI tool into its own isolated environment and works on distributions where `pip install` fails because the system Python is marked as externally managed ([PEP 668](https://peps.python.org/pep-0668/)). If pipx isn't installed yet, follow the [pipx installation guide](https://pipx.pypa.io/latest/how-to/install-pipx.html). + +To install: + +```bash +pipx install linode-cli +``` + +To upgrade: + ```bash -pip3 install linode-cli +pipx upgrade linode-cli ``` -Visit the [Wiki](../../wiki/Installation) for more information. +The [Wiki](https://github.com/linode/linode-cli/wiki/Installation) covers other installation methods, including the Docker image, the GitHub Action, and building from source. ## Contributing From 41f8182d99110982d8adb29b7a03b32fa0206c0e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 14 Aug 2026 18:16:34 -0400 Subject: [PATCH 19/32] build(deps): bump Andrew-Chen-Wang/github-wiki-action from 5.0.3 to 5.0.6 (#875) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@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 c56248101..f9cf78364 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@v7 - - uses: Andrew-Chen-Wang/github-wiki-action@6448478bd55f1f3f752c93af8ac03207eccc3213 # pin@v5.0.3 + - uses: Andrew-Chen-Wang/github-wiki-action@1bbb4280446f9630e8e21a18012cbacf3b0f992e # pin@v5.0.6 From dc02f0c7f58c8b7cfe45da2b7e3b87ae7b0caa15 Mon Sep 17 00:00:00 2001 From: Ye Chen <127243817+yec-akamai@users.noreply.github.com> Date: Mon, 17 Aug 2026 10:45:08 -0400 Subject: [PATCH 20/32] TPT-4221: Enhance security of kubeconfig file permission (#919) --- linodecli/plugins/get-kubeconfig.py | 29 +++++++++++++- tests/unit/test_plugin_kubeconfig.py | 58 ++++++++++++++++++++++++++++ 2 files changed, 85 insertions(+), 2 deletions(-) diff --git a/linodecli/plugins/get-kubeconfig.py b/linodecli/plugins/get-kubeconfig.py index a839416eb..c0204ba71 100644 --- a/linodecli/plugins/get-kubeconfig.py +++ b/linodecli/plugins/get-kubeconfig.py @@ -8,6 +8,7 @@ import argparse import base64 +import os import sys from pathlib import Path @@ -19,6 +20,11 @@ PLUGIN_BASE = "linode-cli get-kubeconfig" +# Kubeconfigs contain credentials, so they should only be +# accessible by the user that created them. +KUBECONFIG_FILE_MODE = 0o600 +KUBECONFIG_DIR_MODE = 0o700 + def call(args, context): """ @@ -147,8 +153,27 @@ def _load_config(filepath): # Dumps data to a yaml file def _dump_config(filepath, data): - Path.mkdir(filepath.parent, exist_ok=True) - with open(filepath, "w", encoding="utf-8") as file_descriptor: + filepath.parent.mkdir(mode=KUBECONFIG_DIR_MODE, parents=True, exist_ok=True) + + # Create the file with restrictive permissions rather than chmod-ing it + # afterwards, so its contents are never briefly readable by other users. + # NOTE: The mode is only applied when the file is created. + def opener(path, flags): + return os.open(path, flags, mode=KUBECONFIG_FILE_MODE) + + with open( + filepath, "w", encoding="utf-8", opener=opener + ) as file_descriptor: + # Tighten the permissions of pre-existing files that are readable or + # writable by users other than the owner. + # NOTE: os.fchmod is not available on Windows, where POSIX file modes + # are not meaningful anyway. + if ( + hasattr(os, "fchmod") + and os.fstat(file_descriptor.fileno()).st_mode & 0o077 + ): + os.fchmod(file_descriptor.fileno(), KUBECONFIG_FILE_MODE) + yaml.dump(data, file_descriptor) diff --git a/tests/unit/test_plugin_kubeconfig.py b/tests/unit/test_plugin_kubeconfig.py index ff192f57b..7fdc0b8da 100644 --- a/tests/unit/test_plugin_kubeconfig.py +++ b/tests/unit/test_plugin_kubeconfig.py @@ -204,6 +204,64 @@ def test_merge(mock_cli, fake_kubeconfig_file): assert result["dictionary"] == yaml_a["dictionary"] +# Ensure newly created kubeconfig files are not world/group-readable +@pytest.mark.skipif( + os.name == "nt", reason="POSIX file modes are not supported on Windows" +) +def test_written_config_permissions(mock_cli): + mock_cli.call_operation = mock_call_operation + + with tempfile.TemporaryDirectory() as temp_dir: + file_path = os.path.join(temp_dir, "new_dir", "nested", "config") + + try: + plugin.call( + [ + "--label", + "nonempty_data", + "--kubeconfig", + file_path, + ], + PluginContext("REALTOKEN", mock_cli), + ) + except SystemExit as err: + assert err.code == 0 + + assert os.path.exists(file_path) + assert os.stat(file_path).st_mode & 0o777 == 0o600 + assert os.stat(os.path.dirname(file_path)).st_mode & 0o777 == 0o700 + + +# Ensure pre-existing world-readable kubeconfig files get tightened +@pytest.mark.skipif( + os.name == "nt", reason="POSIX file modes are not supported on Windows" +) +def test_existing_config_permissions_tightened(mock_cli): + mock_cli.call_operation = mock_call_operation + + with tempfile.TemporaryDirectory() as temp_dir: + file_path = os.path.join(temp_dir, "config") + + with open(file_path, "w", encoding="utf-8") as file: + file.write(TEST_YAML_CONTENT_A) + os.chmod(file_path, 0o644) + + try: + plugin.call( + [ + "--label", + "nonempty_data", + "--kubeconfig", + file_path, + ], + PluginContext("REALTOKEN", mock_cli), + ) + except SystemExit as err: + assert err.code == 0 + + assert os.stat(file_path).st_mode & 0o777 == 0o600 + + def test_merge_to_empty_config(mock_cli, fake_kubeconfig_file_without_entries): stdout_buf = io.StringIO() mock_cli.call_operation = mock_call_operation From 0448280ac347b6be6d6a8b1f453dad384dda3871 Mon Sep 17 00:00:00 2001 From: Zhiwei Liang <121905282+zliang-akamai@users.noreply.github.com> Date: Mon, 24 Aug 2026 17:59:16 -0400 Subject: [PATCH 21/32] TPT-4608: Update OCI publish workflow auth to OIDC (#918) --- .github/workflows/release.yml | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 110c2abbc..e44e1fb67 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -26,6 +26,9 @@ jobs: oci_publish: name: Build and publish the OCI image runs-on: ubuntu-latest + permissions: + id-token: write + contents: read steps: - name: Clone Repository uses: actions/checkout@v7 @@ -39,16 +42,23 @@ jobs: run: make requirements - name: Set up QEMU - uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 # pin@v4.1.0 + uses: docker/setup-qemu-action@v4 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # pin@v4.0.0 + uses: docker/setup-buildx-action@v4 + + - name: OIDC connections + id: docker_oidc + uses: docker/oidc-action@v1 + with: + connection-id: ${{ vars.DOCKER_OIDC_CONNECTION_ID }} + expires-in: 1800 - name: Login to Docker Hub uses: docker/login-action@v4 with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} + username: ${{ vars.DOCKER_ORGANIZATION_NAME }} + password: ${{ steps.docker_oidc.outputs.token }} # This is necessary as we want to ensure that version tags # are properly formatted before passing them into the From 8fe4a33731bab8f5173cf6c2f3a56f22ff88e8a4 Mon Sep 17 00:00:00 2001 From: Zhiwei Liang <121905282+zliang-akamai@users.noreply.github.com> Date: Thu, 3 Sep 2026 16:30:04 -0400 Subject: [PATCH 22/32] Using official naming conventions; use only login-action for OIDC (#921) --- .github/workflows/release.yml | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e44e1fb67..0d4706f35 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -47,18 +47,13 @@ jobs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@v4 - - name: OIDC connections - id: docker_oidc - uses: docker/oidc-action@v1 - with: - connection-id: ${{ vars.DOCKER_OIDC_CONNECTION_ID }} - expires-in: 1800 - - name: Login to Docker Hub uses: docker/login-action@v4 with: - username: ${{ vars.DOCKER_ORGANIZATION_NAME }} - password: ${{ steps.docker_oidc.outputs.token }} + username: ${{ vars.DOCKERHUB_ORGANIZATION }} + env: + DOCKERHUB_OIDC_CONNECTIONID: ${{ vars.DOCKERHUB_OIDC_CONNECTIONID }} + DOCKERHUB_OIDC_EXPIREIN: 1800 # This is necessary as we want to ensure that version tags # are properly formatted before passing them into the From d9a180454b1f5c6a32c42969bca0ce68cde8e758 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 9 Sep 2026 14:11:09 -0400 Subject: [PATCH 23/32] build(deps): bump slackapi/slack-github-action from 3 to 4 (#923) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/e2e-suite.yml | 4 ++-- .github/workflows/nightly-smoke-tests.yml | 2 +- .github/workflows/release.yml | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/e2e-suite.yml b/.github/workflows/e2e-suite.yml index dbe293c58..90b949869 100644 --- a/.github/workflows/e2e-suite.yml +++ b/.github/workflows/e2e-suite.yml @@ -305,7 +305,7 @@ jobs: steps: - name: Notify Slack id: main_message - uses: slackapi/slack-github-action@v3 + uses: slackapi/slack-github-action@v4 with: method: chat.postMessage token: ${{ secrets.SLACK_BOT_TOKEN }} @@ -337,7 +337,7 @@ jobs: - name: Test summary thread if: success() - uses: slackapi/slack-github-action@v3 + uses: slackapi/slack-github-action@v4 with: method: chat.postMessage token: ${{ secrets.SLACK_BOT_TOKEN }} diff --git a/.github/workflows/nightly-smoke-tests.yml b/.github/workflows/nightly-smoke-tests.yml index 0d7e48c2e..b559a25f8 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@v3 + uses: slackapi/slack-github-action@v4 with: method: chat.postMessage token: ${{ secrets.SLACK_BOT_TOKEN }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0d4706f35..61af292f9 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@v3 + uses: slackapi/slack-github-action@v4 with: method: chat.postMessage token: ${{ secrets.SLACK_BOT_TOKEN }} From a8cee697d2c46bb7c12fb2dfffecbf588faba3f7 Mon Sep 17 00:00:00 2001 From: ezilber-akamai Date: Fri, 11 Sep 2026 14:11:03 -0400 Subject: [PATCH 24/32] Fixed bug causing deeply nested fields to be improperly displayed in --help output --- linodecli/baked/request.py | 10 +++- .../api_request_test_foobar_post.yaml | 13 +++++ tests/unit/test_operation.py | 49 +++++++++++++++++++ 3 files changed, 71 insertions(+), 1 deletion(-) diff --git a/linodecli/baked/request.py b/linodecli/baked/request.py index 52b5be73d..195a96435 100644 --- a/linodecli/baked/request.py +++ b/linodecli/baked/request.py @@ -182,12 +182,20 @@ def _parse_request_model( depth=depth, ) - # Handle arrays of objects that not marked as JSON + # Handle arrays of objects that not marked as JSON. + # NOTE: We only expand an array of objects into individual child + # arguments when it is not already nested under another list + # (i.e. parent is None). The CLI can only associate one level of + # nested list objects, so a list of objects nested within another + # list must be treated as JSON instead. + # Otherwise, we would generate child arguments that can never + # be used because they always conflict with their implicit JSON parent. elif ( v.type == "array" and v.items and v.items.type == "object" and v.extensions.get("linode-cli-format") != "json" + and parent is None ): # handle lists of objects as a special case, where each property # of the object in the list is its own argument diff --git a/tests/fixtures/api_request_test_foobar_post.yaml b/tests/fixtures/api_request_test_foobar_post.yaml index 0dbb6e65b..b67e9f83e 100644 --- a/tests/fixtures/api_request_test_foobar_post.yaml +++ b/tests/fixtures/api_request_test_foobar_post.yaml @@ -119,6 +119,19 @@ components: description: An arbitrary deeply nested array. items: type: string + field_object_array: + type: array + description: An arbitrary array of objects nested within a list. + items: + type: object + description: An arbitrary deeply nested object. + properties: + nested_object_string: + type: string + description: A string on a deeply nested list object. + nested_object_int: + type: number + description: An int on a deeply nested list object. field_string: type: string description: An arbitrary field. diff --git a/tests/unit/test_operation.py b/tests/unit/test_operation.py index 1e3118e2b..5ccf072a7 100644 --- a/tests/unit/test_operation.py +++ b/tests/unit/test_operation.py @@ -196,6 +196,7 @@ def test_parse_args_object_list(self, create_operation): "field_int": 123, "field_dict": {"nested_string": "test2", "nested_int": 789}, "field_array": ExplicitJsonValue(json_value=["foo", "bar"]), + "field_object_array": None, # We expect this to be filtered out later "nullable_string": None, # We expect this to be filtered out later }, {"field_int": 456, "field_dict": {"nested_string": "test3"}}, @@ -218,6 +219,54 @@ def test_parse_args_object_list_json(self, create_operation): assert result.object_list.json_value == expected + def test_nested_object_list_treated_as_json(self, create_operation): + """ + An array of objects nested within another array of objects (e.g. + --object_list.field_object_array) can only be specified as JSON. + It should not be expanded into unusable child arguments (e.g. + --object_list.field_object_array.nested_object_string). + """ + args_by_path = {arg.path: arg for arg in create_operation.args} + + nested = args_by_path.get("object_list.field_object_array") + assert nested is not None + assert nested.format == "json" + assert nested.datatype == "object" + assert nested.is_child + assert nested.parent == "object_list" + assert not nested.is_parent + + # No child arguments should have been generated for its properties. + assert "object_list.field_object_array.nested_object_string" not in ( + args_by_path + ) + assert "object_list.field_object_array.nested_object_int" not in ( + args_by_path + ) + + def test_parse_args_nested_object_list_json(self, create_operation): + """ + A nested array of objects should be accepted as a JSON string value + associated with each entry of its parent list. + """ + result = create_operation.parse_args( + [ + "--object_list.field_string", + "test1", + "--object_list.field_object_array", + json.dumps([{"nested_object_string": "foo"}]), + ] + ) + + assert result.object_list == [ + { + "field_string": "test1", + "field_object_array": ExplicitJsonValue( + json_value=[{"nested_object_string": "foo"}] + ), + }, + ] + def test_parse_args_conflicting_parent_child(self, create_operation): stderr_buf = io.StringIO() From d0cda0fa770c7b29a5ab82bb4bb9227379238208 Mon Sep 17 00:00:00 2001 From: Maciej Wilk Date: Mon, 14 Sep 2026 11:11:37 +0200 Subject: [PATCH 25/32] TPT-4626: Parser fix for unknown nested-list objects (#920) --- linodecli/baked/response.py | 7 +++++ tests/unit/test_response.py | 54 +++++++++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+) diff --git a/linodecli/baked/response.py b/linodecli/baked/response.py index b1d3b5f1c..fd20fbc72 100644 --- a/linodecli/baked/response.py +++ b/linodecli/baked/response.py @@ -316,8 +316,15 @@ def _fix_nested_list(self, json): for cur in json: # Get the nested list using the path nlist_path = cur + for p in path_parts: nlist_path = nlist_path.get(p) + + # To avoid errors when nested list is not documented in + # the OpenAPI spec, but it is present in the API response + if nlist_path is None: + continue + nlist = nlist_path # For each item in the nested list, diff --git a/tests/unit/test_response.py b/tests/unit/test_response.py index 91cafca24..9f4670f42 100644 --- a/tests/unit/test_response.py +++ b/tests/unit/test_response.py @@ -21,6 +21,60 @@ def test_model_fix_json_nested(self, list_operation_for_response_test): {"_split": "cool", "foo": 321}, ] + def test_model_fix_json_nested_skips_unexpected_path( + self, list_operation_for_response_test + ): + model = list_operation_for_response_test.response_model + model.nested_list = "engines.foo, engines.bar" + + result = model.fix_json( + [ + { + "id": "id-number-1", + "engines": { + "foo": [{"quantity": 1}, {"quantity": 2}], + "bar": [{"quantity": 3}], + }, + }, + { + "id": "id-number-2", + "engines": { + "foo": [{"quantity": 4}], + }, + }, + { + "id": "id-number-3", + "engines": { + "xyz": [{"quantity": 5}], + }, + }, + ] + ) + result = sorted(result, key=lambda x: x["id"]) + + assert result == [ + { + "id": "id-number-1", + "_split": "foo", + "engines": {"quantity": 1}, + }, + { + "id": "id-number-1", + "_split": "foo", + "engines": {"quantity": 2}, + }, + { + "id": "id-number-1", + "_split": "bar", + "engines": {"quantity": 3}, + }, + { + "id": "id-number-2", + "_split": "foo", + "engines": {"quantity": 4}, + }, + ] + def test_attr_get_value(self, list_operation_for_response_test): model = {"data": {"foo": {"bar": "cool"}}} attr = list_operation_for_response_test.response_model.attrs[0] From ddf88c54dc95df60b2f40b046ef4f944a1ea0b23 Mon Sep 17 00:00:00 2001 From: Erik Zilber Date: Wed, 16 Sep 2026 09:29:57 -0400 Subject: [PATCH 26/32] Revert "Fixed bug causing deeply nested fields to be improperly displayed in --help output" (#926) --- linodecli/baked/request.py | 10 +--- .../api_request_test_foobar_post.yaml | 13 ----- tests/unit/test_operation.py | 49 ------------------- 3 files changed, 1 insertion(+), 71 deletions(-) diff --git a/linodecli/baked/request.py b/linodecli/baked/request.py index 195a96435..52b5be73d 100644 --- a/linodecli/baked/request.py +++ b/linodecli/baked/request.py @@ -182,20 +182,12 @@ def _parse_request_model( depth=depth, ) - # Handle arrays of objects that not marked as JSON. - # NOTE: We only expand an array of objects into individual child - # arguments when it is not already nested under another list - # (i.e. parent is None). The CLI can only associate one level of - # nested list objects, so a list of objects nested within another - # list must be treated as JSON instead. - # Otherwise, we would generate child arguments that can never - # be used because they always conflict with their implicit JSON parent. + # Handle arrays of objects that not marked as JSON elif ( v.type == "array" and v.items and v.items.type == "object" and v.extensions.get("linode-cli-format") != "json" - and parent is None ): # handle lists of objects as a special case, where each property # of the object in the list is its own argument diff --git a/tests/fixtures/api_request_test_foobar_post.yaml b/tests/fixtures/api_request_test_foobar_post.yaml index b67e9f83e..0dbb6e65b 100644 --- a/tests/fixtures/api_request_test_foobar_post.yaml +++ b/tests/fixtures/api_request_test_foobar_post.yaml @@ -119,19 +119,6 @@ components: description: An arbitrary deeply nested array. items: type: string - field_object_array: - type: array - description: An arbitrary array of objects nested within a list. - items: - type: object - description: An arbitrary deeply nested object. - properties: - nested_object_string: - type: string - description: A string on a deeply nested list object. - nested_object_int: - type: number - description: An int on a deeply nested list object. field_string: type: string description: An arbitrary field. diff --git a/tests/unit/test_operation.py b/tests/unit/test_operation.py index 5ccf072a7..1e3118e2b 100644 --- a/tests/unit/test_operation.py +++ b/tests/unit/test_operation.py @@ -196,7 +196,6 @@ def test_parse_args_object_list(self, create_operation): "field_int": 123, "field_dict": {"nested_string": "test2", "nested_int": 789}, "field_array": ExplicitJsonValue(json_value=["foo", "bar"]), - "field_object_array": None, # We expect this to be filtered out later "nullable_string": None, # We expect this to be filtered out later }, {"field_int": 456, "field_dict": {"nested_string": "test3"}}, @@ -219,54 +218,6 @@ def test_parse_args_object_list_json(self, create_operation): assert result.object_list.json_value == expected - def test_nested_object_list_treated_as_json(self, create_operation): - """ - An array of objects nested within another array of objects (e.g. - --object_list.field_object_array) can only be specified as JSON. - It should not be expanded into unusable child arguments (e.g. - --object_list.field_object_array.nested_object_string). - """ - args_by_path = {arg.path: arg for arg in create_operation.args} - - nested = args_by_path.get("object_list.field_object_array") - assert nested is not None - assert nested.format == "json" - assert nested.datatype == "object" - assert nested.is_child - assert nested.parent == "object_list" - assert not nested.is_parent - - # No child arguments should have been generated for its properties. - assert "object_list.field_object_array.nested_object_string" not in ( - args_by_path - ) - assert "object_list.field_object_array.nested_object_int" not in ( - args_by_path - ) - - def test_parse_args_nested_object_list_json(self, create_operation): - """ - A nested array of objects should be accepted as a JSON string value - associated with each entry of its parent list. - """ - result = create_operation.parse_args( - [ - "--object_list.field_string", - "test1", - "--object_list.field_object_array", - json.dumps([{"nested_object_string": "foo"}]), - ] - ) - - assert result.object_list == [ - { - "field_string": "test1", - "field_object_array": ExplicitJsonValue( - json_value=[{"nested_object_string": "foo"}] - ), - }, - ] - def test_parse_args_conflicting_parent_child(self, create_operation): stderr_buf = io.StringIO() From 573ec3c313343e51bd32f7d3765ad88c4f239356 Mon Sep 17 00:00:00 2001 From: Erik Zilber Date: Wed, 16 Sep 2026 10:30:30 -0400 Subject: [PATCH 27/32] Fix several issues with oneof responses (#924) --- linodecli/baked/response.py | 6 +- linodecli/baked/util.py | 76 ++++++++++++++- .../operation_oneof_property_overwrite.yaml | 97 +++++++++++++++++++ tests/unit/conftest.py | 22 +++++ tests/unit/test_response.py | 54 +++++++++++ 5 files changed, 252 insertions(+), 3 deletions(-) create mode 100644 tests/fixtures/operation_oneof_property_overwrite.yaml diff --git a/linodecli/baked/response.py b/linodecli/baked/response.py index fd20fbc72..901ece294 100644 --- a/linodecli/baked/response.py +++ b/linodecli/baked/response.py @@ -210,8 +210,10 @@ def _parse_response_model(schema, prefix=None, nested_list_depth=0): ) elif v.type == "object": attrs += _parse_response_model(v, prefix=pref) - elif v.type == "array" and v.items.type == "object": - # Parse arrays for objects recursively and increase the nesting depth + elif v.type == "array" and ( + v.items.type == "object" + or bool(_aggregate_schema_properties(v.items)[0]) + ): attrs += _parse_response_model( v.items, prefix=pref, diff --git a/linodecli/baked/util.py b/linodecli/baked/util.py index fef1a179c..d2106695c 100644 --- a/linodecli/baked/util.py +++ b/linodecli/baked/util.py @@ -8,6 +8,67 @@ from openapi3.schemas import Schema +# The maximum schema nesting depth `_schema_richness` will traverse before +# bailing out. This is purely a safety valve to guarantee termination on +# self-referential or pathologically deep schemas (which the recursion would +# otherwise follow forever); it is far deeper than any real Linode API response +# model, so it never affects scoring in practice. +_MAX_RICHNESS_DEPTH = 32 + + +def _schema_richness(schema: Any, _depth: int = 0) -> int: + """ + Estimates how complete a schema definition is, used to decide which + definition to keep when the same property appears in multiple composition + (oneOf/allOf/anyOf) branches. + + A branch that nulls a property out (e.g. ``{"type": "object", "nullable": + true}`` with no properties) should never overwrite a branch that fully + defines that property's nested structure. The score is a recursive measure + of how much structure a schema actually contains, so a fuller definition always outscores a + sparser one regardless of the + order the branches appear in. + + :param schema: The schema (or raw schema dict) to score. + :return: A non-negative integer; higher means more complete. + """ + + # Guard against pathologically deep or self-referential schemas. + if _depth > _MAX_RICHNESS_DEPTH: + return 0 + + def get(source: Any, attr: str) -> Any: + if isinstance(source, dict): + return source.get(attr) + return getattr(source, attr, None) + + score = 0 + + # Count each defined property, plus the richness of its own definition so + # that deeply-nested structure contributes to the total. + properties = get(schema, "properties") + if properties: + for _, prop in properties.items(): + score += 1 + _schema_richness(prop, _depth + 1) + + # Account for composite (oneOf/allOf/anyOf) definitions by summing the + # richness of each branch. + for composition_field in ("oneOf", "allOf", "anyOf"): + for branch in get(schema, composition_field) or []: + score += 1 + _schema_richness(branch, _depth + 1) + + # Account for array item schemas so arrays of objects are scored by their + # element structure. + array_items = get(schema, "items") + if array_items is not None: + score += _schema_richness(array_items, _depth + 1) + + additional_properties = get(schema, "additionalProperties") + if additional_properties is not None: + score += 1 + _schema_richness(additional_properties, _depth + 1) + + return score + def _aggregate_schema_properties( schema: Schema, @@ -48,7 +109,20 @@ def __inner( return # This is a valid option - properties.update(entry.properties) + for key, value in entry.properties.items(): + # When the same property is defined in multiple composition + # branches (e.g. a oneOf of interface variants that each define + # `public`, `vpc`, `vlan`, etc.), keep the most complete + # definition instead of letting a later, emptier branch overwrite + # it. Otherwise nested fields like `public.ipv6.ranges.range` + # would be silently dropped when a subsequent branch nulls the + # property out. + if key in properties and _schema_richness( + value + ) <= _schema_richness(properties[key]): + continue + + properties[key] = value nonlocal schema_count schema_count += 1 diff --git a/tests/fixtures/operation_oneof_property_overwrite.yaml b/tests/fixtures/operation_oneof_property_overwrite.yaml new file mode 100644 index 000000000..70bcb8f12 --- /dev/null +++ b/tests/fixtures/operation_oneof_property_overwrite.yaml @@ -0,0 +1,97 @@ +openapi: 3.0.1 +info: + title: API Specification + version: 1.0.0 +servers: + - url: http://localhost/v4 + +paths: + /foo/bar: + x-linode-cli-command: foo + put: + summary: Update something. + operationId: fooBarPut + description: This is description + requestBody: + description: Some description. + required: True + content: + application/json: + schema: + $ref: '#/components/schemas/Interface' + responses: + '200': + description: Successful response + content: + application/json: + schema: + $ref: '#/components/schemas/Interface' + +components: + schemas: + # This schema reproduces the real-world case where a response is a oneOf + # of variants, and every variant defines the SAME set of top-level keys, + # but only fully populates the one relevant to that variant while nulling + # out the others. A naive dict.update() merge lets the last branch + # overwrite the fully-populated definitions from earlier branches. + Interface: + oneOf: + - title: Variant A + type: object + properties: + variant_a: + type: object + properties: + ranges: + type: array + items: + type: object + properties: + range: + type: string + description: The variant A range. + variant_b: + type: object + nullable: true + # An array whose items are a oneOf of SCALAR types. This must be + # treated as a normal array attribute, not recursed into. + scalar_choices: + type: array + items: + oneOf: + - type: string + - type: integer + # This shared object is defined more richly in the LATER branch + # The fuller definition must win regardless of branch + # order, so all of its nested fields must survive aggregation. + shared_obj: + type: object + properties: + only_a: + type: string + description: Present in both branches. + - title: Variant B + type: object + properties: + variant_a: + type: object + nullable: true + variant_b: + type: object + properties: + label: + type: string + description: The variant B label. + shared_obj: + type: object + properties: + only_a: + type: string + description: Present in both branches. + extra_b1: + type: string + description: Only defined in the richer Variant B branch. + extra_b2: + type: string + description: Only defined in the richer Variant B branch. + diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py index 5a6dfc297..f8f9ef991 100644 --- a/tests/unit/conftest.py +++ b/tests/unit/conftest.py @@ -366,6 +366,28 @@ def post_operation_with_one_ofs() -> OpenAPIOperation: ) +@pytest.fixture +def put_operation_with_oneof_property_overwrite() -> OpenAPIOperation: + """ + Creates an OpenAPI operation whose request/response is a oneOf of variants + that each define the same top-level keys, but only fully populate the key + relevant to that variant (nulling the others). Used to verify that + aggregating oneOf branches does not let a later, emptier branch overwrite a + fully-defined property from an earlier branch. + """ + + spec = _get_parsed_spec("operation_oneof_property_overwrite.yaml") + + path = list(spec.paths.values())[0] + + return make_test_operation( + path.extensions.get("linode-cli-command", "default"), + getattr(path, "put"), + "put", + path.parameters, + ) + + @pytest.fixture def get_openapi_for_api_components_tests() -> OpenAPI: """ diff --git a/tests/unit/test_response.py b/tests/unit/test_response.py index 9f4670f42..f5e3632a3 100644 --- a/tests/unit/test_response.py +++ b/tests/unit/test_response.py @@ -118,6 +118,60 @@ def test_handle_one_ofs(self, post_operation_with_one_ofs): assert attr_map[k].datatype == v[0] assert attr_map[k].description == v[1] + def test_oneof_property_not_overwritten( + self, put_operation_with_oneof_property_overwrite + ): + """ + Regression test: when a response is a oneOf of variants that each define + the same top-level keys (fully populating only one per branch and nulling + the rest), aggregating the branches must not let a later, emptier branch + overwrite a fully-defined property from an earlier branch. + """ + model = put_operation_with_oneof_property_overwrite.response_model + + attr_paths = {attr.path for attr in model.attrs} + + # variant_a is fully defined only in the first branch and nulled in the + # second; its nested field must survive aggregation. + assert "variant_a.ranges.range" in attr_paths + # variant_b is fully defined only in the second branch. + assert "variant_b.label" in attr_paths + + def test_scalar_oneof_array_not_dropped( + self, put_operation_with_oneof_property_overwrite + ): + """ + Regression test: an array whose items are a oneOf of scalar types + (e.g. ``items: {oneOf: [{type: string}, {type: integer}]}``) aggregates + no object properties. It must remain a normal array attribute instead of + being recursed into and silently dropped from the response model. + """ + model = put_operation_with_oneof_property_overwrite.response_model + + attr_paths = {attr.path for attr in model.attrs} + + assert "scalar_choices" in attr_paths + + def test_richer_oneof_branch_wins_regardless_of_order( + self, put_operation_with_oneof_property_overwrite + ): + """ + Regression test: when the same object property is defined in multiple + branches, the branch with the most nested structure must win even if it + appears later. A presence-only richness score would tie and keep the + earlier, sparser definition, dropping the extra fields. + """ + model = put_operation_with_oneof_property_overwrite.response_model + + attr_paths = {attr.path for attr in model.attrs} + + # Defined in both branches. + assert "shared_obj.only_a" in attr_paths + # Only defined in the richer (later) Variant B branch; these would be + # missing if the earlier, sparser definition were kept. + assert "shared_obj.extra_b1" in attr_paths + assert "shared_obj.extra_b2" in attr_paths + def test_fix_json_string_type(self, list_operation_for_response_test): model = list_operation_for_response_test.response_model model.rows = ["foo.bar", "type"] From b4235419e55f13f6b0435e986f0d05522244e858 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 17 Sep 2026 10:12:31 -0400 Subject: [PATCH 28/32] build(deps): bump softprops/action-gh-release from 3.0.1 to 3.0.3 (#922) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Maciej Wilk --- .github/workflows/remote-release-trigger.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/remote-release-trigger.yml b/.github/workflows/remote-release-trigger.yml index d77682700..f3c042f8a 100644 --- a/.github/workflows/remote-release-trigger.yml +++ b/.github/workflows/remote-release-trigger.yml @@ -66,7 +66,7 @@ jobs: commit_sha: ${{ steps.calculate_head_sha.outputs.commit_sha }} - name: Release - uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # pin@v3.0.1 + uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # pin@v3.0.3 with: target_commitish: 'main' token: ${{ steps.generate_token.outputs.token }} From dea006e92aca5a140214ef252da6cb485c0f06c7 Mon Sep 17 00:00:00 2001 From: Erik Zilber Date: Thu, 17 Sep 2026 10:54:40 -0400 Subject: [PATCH 29/32] TPT-3719: Fixed bug causing deeply nested object lists to be improperly displayed (#925) --- linodecli/baked/request.py | 10 +++- .../api_request_test_foobar_post.yaml | 13 +++++ tests/unit/test_operation.py | 49 +++++++++++++++++++ 3 files changed, 71 insertions(+), 1 deletion(-) diff --git a/linodecli/baked/request.py b/linodecli/baked/request.py index 52b5be73d..bbd255288 100644 --- a/linodecli/baked/request.py +++ b/linodecli/baked/request.py @@ -182,12 +182,20 @@ def _parse_request_model( depth=depth, ) - # Handle arrays of objects that not marked as JSON + # Handle arrays of objects that are not marked as JSON. + # NOTE: We only expand an array of objects into individual child + # arguments when it is not already nested under another list + # (i.e. parent is None). The CLI can only associate one level of + # nested list objects, so a list of objects nested within another + # list must be treated as JSON instead. + # Otherwise, we would generate child arguments that can never + # be used because they always conflict with their implicit JSON parent. elif ( v.type == "array" and v.items and v.items.type == "object" and v.extensions.get("linode-cli-format") != "json" + and parent is None ): # handle lists of objects as a special case, where each property # of the object in the list is its own argument diff --git a/tests/fixtures/api_request_test_foobar_post.yaml b/tests/fixtures/api_request_test_foobar_post.yaml index 0dbb6e65b..b67e9f83e 100644 --- a/tests/fixtures/api_request_test_foobar_post.yaml +++ b/tests/fixtures/api_request_test_foobar_post.yaml @@ -119,6 +119,19 @@ components: description: An arbitrary deeply nested array. items: type: string + field_object_array: + type: array + description: An arbitrary array of objects nested within a list. + items: + type: object + description: An arbitrary deeply nested object. + properties: + nested_object_string: + type: string + description: A string on a deeply nested list object. + nested_object_int: + type: number + description: An int on a deeply nested list object. field_string: type: string description: An arbitrary field. diff --git a/tests/unit/test_operation.py b/tests/unit/test_operation.py index 1e3118e2b..5ccf072a7 100644 --- a/tests/unit/test_operation.py +++ b/tests/unit/test_operation.py @@ -196,6 +196,7 @@ def test_parse_args_object_list(self, create_operation): "field_int": 123, "field_dict": {"nested_string": "test2", "nested_int": 789}, "field_array": ExplicitJsonValue(json_value=["foo", "bar"]), + "field_object_array": None, # We expect this to be filtered out later "nullable_string": None, # We expect this to be filtered out later }, {"field_int": 456, "field_dict": {"nested_string": "test3"}}, @@ -218,6 +219,54 @@ def test_parse_args_object_list_json(self, create_operation): assert result.object_list.json_value == expected + def test_nested_object_list_treated_as_json(self, create_operation): + """ + An array of objects nested within another array of objects (e.g. + --object_list.field_object_array) can only be specified as JSON. + It should not be expanded into unusable child arguments (e.g. + --object_list.field_object_array.nested_object_string). + """ + args_by_path = {arg.path: arg for arg in create_operation.args} + + nested = args_by_path.get("object_list.field_object_array") + assert nested is not None + assert nested.format == "json" + assert nested.datatype == "object" + assert nested.is_child + assert nested.parent == "object_list" + assert not nested.is_parent + + # No child arguments should have been generated for its properties. + assert "object_list.field_object_array.nested_object_string" not in ( + args_by_path + ) + assert "object_list.field_object_array.nested_object_int" not in ( + args_by_path + ) + + def test_parse_args_nested_object_list_json(self, create_operation): + """ + A nested array of objects should be accepted as a JSON string value + associated with each entry of its parent list. + """ + result = create_operation.parse_args( + [ + "--object_list.field_string", + "test1", + "--object_list.field_object_array", + json.dumps([{"nested_object_string": "foo"}]), + ] + ) + + assert result.object_list == [ + { + "field_string": "test1", + "field_object_array": ExplicitJsonValue( + json_value=[{"nested_object_string": "foo"}] + ), + }, + ] + def test_parse_args_conflicting_parent_child(self, create_operation): stderr_buf = io.StringIO() From 2486abc3b6bad034bd39e7b38c7c7c4f802d7c55 Mon Sep 17 00:00:00 2001 From: Maciej Wilk Date: Thu, 17 Sep 2026 16:59:42 +0200 Subject: [PATCH 30/32] TPT-4457: Add integration tests for RDMA interfaces (#908) Co-authored-by: Erik Zilber --- tests/integration/vpc/conftest.py | 69 ++++++++-- tests/integration/vpc/test_vpc.py | 213 +++++++++++++++++++----------- 2 files changed, 195 insertions(+), 87 deletions(-) diff --git a/tests/integration/vpc/conftest.py b/tests/integration/vpc/conftest.py index d4cbb033f..673422405 100644 --- a/tests/integration/vpc/conftest.py +++ b/tests/integration/vpc/conftest.py @@ -11,7 +11,7 @@ @pytest.fixture -def test_vpc_w_subnet(): +def get_test_vpc_w_subnet(): vpc_json = create_vpc_w_subnet() vpc_id = str(vpc_json["id"]) @@ -21,9 +21,8 @@ def test_vpc_w_subnet(): @pytest.fixture -def test_vpc_wo_subnet(): +def get_test_vpc_wo_subnet(): region = get_random_region_with_caps(required_capabilities=["VPCs"]) - label = get_random_text(5) + "-label" vpc_id = exec_test_command( @@ -48,15 +47,43 @@ def test_vpc_wo_subnet(): @pytest.fixture -def test_subnet(test_vpc_wo_subnet): - vpc_id = test_vpc_wo_subnet - subnet_label = get_random_text(5) + "-label" - res = exec_test_command( +def get_test_vpc_w_rdma_type(): + # GPUDirect RDMA capability not available for now + # region = get_random_region_with_caps(required_capabilities=["VPCs", "GPUDirect RDMA"]) + region = get_random_region_with_caps(required_capabilities=["VPCs"]) + label = get_random_text(5) + "-test-rdma-vpc" + + vpc_id = exec_test_command( + BASE_CMDS["vpcs"] + + [ + "create", + "--label", + label, + "--region", + region, + "--vpc_type", + "rdma", + "--no-headers", + "--text", + "--format=id", + ] + ) + + yield vpc_id + + delete_target_id(target="vpcs", id=vpc_id) + + +@pytest.fixture +def get_test_subnet(get_test_vpc_wo_subnet): + vpc_id = get_test_vpc_wo_subnet + label = get_random_text(5) + "-label" + subnet_id = exec_test_command( BASE_CMDS["vpcs"] + [ "subnet-create", "--label", - subnet_label, + label, "--ipv4", "10.0.0.0/24", vpc_id, @@ -64,9 +91,31 @@ def test_subnet(test_vpc_wo_subnet): "--no-headers", "--delimiter=,", ] - ) + ).split(",")[0] + + yield vpc_id, subnet_id + + +@pytest.fixture +def get_test_subnet_w_rdma_type(get_test_vpc_w_rdma_type): + vpc_id = get_test_vpc_w_rdma_type + label = get_random_text(5) + "-test-rdma-subnet" + subnet_id = exec_test_command( + BASE_CMDS["vpcs"] + + [ + "subnet-create", + "--label", + label, + "--ipv4", + "10.0.0.0/24", + vpc_id, + "--text", + "--no-headers", + "--delimiter=,", + ] + ).split(",")[0] - yield res, subnet_label + yield vpc_id, subnet_id @pytest.fixture diff --git a/tests/integration/vpc/test_vpc.py b/tests/integration/vpc/test_vpc.py index 6616aa9f6..2c81afe12 100644 --- a/tests/integration/vpc/test_vpc.py +++ b/tests/integration/vpc/test_vpc.py @@ -1,4 +1,5 @@ import json +import os import re import pytest @@ -10,38 +11,63 @@ exec_test_command, get_random_region_with_caps, get_random_text, + wait_for_condition, ) -BASE_CMD = ["linode-cli", "vpcs"] +HEADERS_VPC = ["id", "label", "description", "region", "vpc_type"] +HEADERS_SUBNET = ["id", "label", "ipv4", "vpc_type"] # TODO: Remove this variable and @pytest.mark.skipif once VPC Dual Stack is ready to ship disable_vpc_dual_stack_tests = True -def test_list_vpcs(test_vpc_wo_subnet): - vpc_id = test_vpc_wo_subnet - res = exec_test_command(BASE_CMDS["vpcs"] + ["ls", "--text"]) - headers = ["id", "label", "description", "region"] +def get_vpcs_list(params: str = None): + params = params.split() if params else [] + command = BASE_CMDS["vpcs"] + ["ls", "--text"] + params + return exec_test_command(command) - for header in headers: - assert header in res - assert vpc_id in res +def get_vpc_view(vpc_id: int = None): + return json.loads( + exec_test_command(BASE_CMDS["vpcs"] + ["view", vpc_id, "--json"]) + )[0] -def test_view_vpc(test_vpc_wo_subnet): - vpc_id = test_vpc_wo_subnet - res = exec_test_command( - BASE_CMDS["vpcs"] + ["view", vpc_id, "--text", "--no-headers"] - ) +def get_subnets_list(vpc_id: int, params: str = None): + params = params.split() if params else [] + command = BASE_CMDS["vpcs"] + ["subnets-list", vpc_id] + params + return exec_test_command(command) + + +def get_subnet_view(vpc_id: int, subnet_id: int): + return json.loads( + exec_test_command( + BASE_CMDS["vpcs"] + ["subnet-view", vpc_id, subnet_id, "--json"] + ) + )[0] + + +def test_list_vpcs(get_test_vpc_wo_subnet): + vpc_id = get_test_vpc_wo_subnet + output = get_vpcs_list("--page-size 100") + + assert all(header in output for header in HEADERS_VPC) + assert vpc_id in output + + +def test_view_vpc(get_test_vpc_wo_subnet): + vpc_id = get_test_vpc_wo_subnet + output = get_vpc_view(vpc_id) - assert vpc_id in res + assert all([header in output.keys() for header in HEADERS_VPC]) + assert str(output["id"]) == vpc_id + assert output["vpc_type"] == "regular" @pytest.mark.smoke -def test_update_vpc(test_vpc_wo_subnet): - vpc_id = test_vpc_wo_subnet +def test_update_vpc(get_test_vpc_wo_subnet): + vpc_id = get_test_vpc_wo_subnet new_label = get_random_text(5) + "label" @@ -61,7 +87,7 @@ def test_update_vpc(test_vpc_wo_subnet): ) description = exec_test_command( - BASE_CMD + BASE_CMDS["vpcs"] + ["view", vpc_id, "--text", "--no-headers", "--format=description"] ) @@ -69,49 +95,46 @@ def test_update_vpc(test_vpc_wo_subnet): assert "new description" in description -def test_list_subnets(test_vpc_w_subnet): - vpc_id = test_vpc_w_subnet - - res = exec_test_command( - BASE_CMD + ["subnets-list", vpc_id, "--text", "--delimiter=,"] - ) - - lines = res.splitlines() +def test_vpc_with_rdma_type(get_test_vpc_w_rdma_type): + vpc_id = get_test_vpc_w_rdma_type - headers = ["id", "label", "ipv4"] + output = get_vpcs_list("--page-size 100") + assert all(header in output for header in HEADERS_VPC) + assert vpc_id in output - for header in headers: - assert header in lines[0] + output = get_vpcs_list("--page-size 100 --format=vpc_type --no-headers") + assert any(["rdma" in output.split()]) - for line in lines[1:]: - assert re.match( - r"^(\d+),(\w+),(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}/\d+)$", line - ), "String format does not match" + output = get_vpc_view(vpc_id) + assert str(output["id"]) == vpc_id + assert output["vpc_type"] == "rdma" -def test_view_subnet(test_vpc_wo_subnet, test_subnet): - # note calling test_subnet fixture will add subnet to test_vpc_wo_subnet - res, label = test_subnet +def test_list_subnets(get_test_vpc_w_subnet): + vpc_id = get_test_vpc_w_subnet - res = res.split(",") + output = get_subnets_list(vpc_id, "--text --delimiter=,").splitlines() + assert all(header in output[0] for header in HEADERS_SUBNET) - vpc_subnet_id = res[0] + for line in output[1:]: + assert re.match( + r"^(\d+),(\w+),(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}/\d+),(\w+)$", + line, + ), "String format does not match" - vpc_id = test_vpc_wo_subnet - output = exec_test_command( - BASE_CMDS["vpcs"] + ["subnet-view", vpc_id, vpc_subnet_id, "--text"] - ) +def test_view_subnet(get_test_subnet): + vpc_id, subnet_id = get_test_subnet + output = get_subnet_view(vpc_id, subnet_id) - headers = ["id", "label", "ipv4"] - for header in headers: - assert header in output - assert vpc_subnet_id in output + assert all(header in output.keys() for header in HEADERS_SUBNET) + assert str(output["id"]) == subnet_id + assert output["vpc_type"] == "regular" @pytest.mark.smoke -def test_update_subnet(test_vpc_w_subnet): - vpc_id = test_vpc_w_subnet +def test_update_subnet(get_test_vpc_w_subnet): + vpc_id = get_test_vpc_w_subnet new_label = get_random_text(5) + "label" @@ -121,7 +144,7 @@ def test_update_subnet(test_vpc_w_subnet): ) updated_label = exec_test_command( - BASE_CMD + BASE_CMDS["vpcs"] + [ "subnet-update", vpc_id, @@ -137,12 +160,29 @@ def test_update_subnet(test_vpc_w_subnet): assert new_label == updated_label +def test_subnet_with_rdma_type_vpc(get_test_subnet_w_rdma_type): + vpc_id, subnet_id = get_test_subnet_w_rdma_type + + output = get_subnets_list(vpc_id) + assert all(header in output for header in HEADERS_SUBNET) + assert subnet_id in output + + output = get_subnets_list(vpc_id, "--format=vpc_type --no-headers") + assert any(["rdma" in output.split()]) + + output = get_subnet_view(vpc_id, subnet_id) + assert str(output["id"]) == subnet_id + assert output["vpc_type"] == "rdma" + + +@pytest.mark.skip(reason="Defect: ARB-8019") def test_fails_to_create_vpc_invalid_label(): invalid_label = "invalid_label" region = get_random_region_with_caps(required_capabilities=["VPCs"]) res = exec_failing_test_command( - BASE_CMD + ["create", "--label", invalid_label, "--region", region], + BASE_CMDS["vpcs"] + + ["create", "--label", invalid_label, "--region", region], ExitCodes.REQUEST_FAILED, ) @@ -150,27 +190,29 @@ def test_fails_to_create_vpc_invalid_label(): assert "Must only use ASCII letters, numbers, and dashes" in res -def test_fails_to_create_vpc_duplicate_label(test_vpc_wo_subnet): - vpc_id = test_vpc_wo_subnet +def test_fails_to_create_vpc_duplicate_label(get_test_vpc_wo_subnet): + vpc_id = get_test_vpc_wo_subnet label = exec_test_command( - BASE_CMD + ["view", vpc_id, "--text", "--no-headers", "--format=label"] + BASE_CMDS["vpcs"] + + ["view", vpc_id, "--text", "--no-headers", "--format=label"] ) region = get_random_region_with_caps(required_capabilities=["VPCs"]) res = exec_failing_test_command( - BASE_CMD + ["create", "--label", label, "--region", region], + BASE_CMDS["vpcs"] + ["create", "--label", label, "--region", region], ExitCodes.REQUEST_FAILED, ) assert "Label must be unique among your VPCs" in res -def test_fails_to_update_vpc_invalid_label(test_vpc_wo_subnet): - vpc_id = test_vpc_wo_subnet +@pytest.mark.skip(reason="Defect: ARB-8019") +def test_fails_to_update_vpc_invalid_label(get_test_vpc_wo_subnet): + vpc_id = get_test_vpc_wo_subnet invalid_label = "invalid_label" res = exec_failing_test_command( - BASE_CMD + ["update", vpc_id, "--label", invalid_label], + BASE_CMDS["vpcs"] + ["update", vpc_id, "--label", invalid_label], ExitCodes.REQUEST_FAILED, ) @@ -178,12 +220,13 @@ def test_fails_to_update_vpc_invalid_label(test_vpc_wo_subnet): assert "Must only use ASCII letters, numbers, and dashes" in res -def test_fails_to_create_vpc_subnet_w_invalid_label(test_vpc_wo_subnet): - vpc_id = test_vpc_wo_subnet +@pytest.mark.skip(reason="Defect: ARB-8019") +def test_fails_to_create_vpc_subnet_w_invalid_label(get_test_vpc_wo_subnet): + vpc_id = get_test_vpc_wo_subnet invalid_label = "invalid_label" res = exec_failing_test_command( - BASE_CMD + BASE_CMDS["vpcs"] + [ "subnet-create", "--label", @@ -199,18 +242,19 @@ def test_fails_to_create_vpc_subnet_w_invalid_label(test_vpc_wo_subnet): assert "Must only use ASCII letters, numbers, and dashes" in res -def test_fails_to_update_vpc_subnet_w_invalid_label(test_vpc_w_subnet): - vpc_id = test_vpc_w_subnet +@pytest.mark.skip(reason="Defect: ARB-8019") +def test_fails_to_update_vpc_subnet_w_invalid_label(get_test_vpc_w_subnet): + vpc_id = get_test_vpc_w_subnet invalid_label = "invalid_label" subnet_id = exec_test_command( - BASE_CMD + BASE_CMDS["vpcs"] + ["subnets-list", vpc_id, "--text", "--format=id", "--no-headers"] ) res = exec_failing_test_command( - BASE_CMD + BASE_CMDS["vpcs"] + [ "subnet-update", vpc_id, @@ -236,7 +280,7 @@ def test_create_vpc_with_ipv6_auto(): label = get_random_text(5) + "-vpc" res = exec_test_command( - BASE_CMD + BASE_CMDS["vpcs"] + [ "create", "--label", @@ -269,7 +313,7 @@ def test_create_vpc_with_custom_ipv6_prefix_length(prefix_len): label = get_random_text(5) + f"-vpc{prefix_len}" res = exec_test_command( - BASE_CMD + BASE_CMDS["vpcs"] + [ "create", "--label", @@ -294,12 +338,12 @@ def test_create_vpc_with_custom_ipv6_prefix_length(prefix_len): @pytest.mark.skipif( disable_vpc_dual_stack_tests, reason="Dual-stack tests disabled" ) -def test_create_subnet_with_ipv6_auto(test_vpc_wo_subnet): - vpc_id = test_vpc_wo_subnet +def test_create_subnet_with_ipv6_auto(get_test_vpc_wo_subnet): + vpc_id = get_test_vpc_wo_subnet subnet_label = get_random_text(5) + "-ipv6subnet" res = exec_test_command( - BASE_CMD + BASE_CMDS["vpcs"] + [ "subnet-create", "--label", @@ -338,7 +382,7 @@ def test_fails_to_create_vpc_with_invalid_ipv6_range(): label = get_random_text(5) + "-invalidvpc" res = exec_failing_test_command( - BASE_CMD + BASE_CMDS["vpcs"] + [ "create", "--label", @@ -357,7 +401,7 @@ def test_fails_to_create_vpc_with_invalid_ipv6_range(): def test_list_vpc_ip_address(): res = exec_test_command( - BASE_CMD + ["ips-all-list", "--text", "--delimiter=,"] + BASE_CMDS["vpcs"] + ["ips-all-list", "--text", "--delimiter=,"] ) lines = res.splitlines() @@ -374,7 +418,7 @@ def test_list_vpc_ip_address(): def test_list_vpc_ipv6s_address(): res = exec_test_command( - BASE_CMD + ["ipv6s-all-list", "--text", "--delimiter=,"] + BASE_CMDS["vpcs"] + ["ipv6s-all-list", "--text", "--delimiter=,"] ) lines = res.splitlines() @@ -385,11 +429,17 @@ def test_list_vpc_ipv6s_address(): assert header in lines[0] +@pytest.mark.skipif( + os.environ.get("LINODE_CLI_API_VERSION", None) != "v4beta", + reason="At the moment default-ranges-all-list command is available on beta env only", +) def test_get_vpc_default_ranges(): headers = ["default_ipv4_ranges", "forbidden_ipv4_ranges"] result = json.loads( - exec_test_command(BASE_CMD + ["default-ranges-all-list", "--json"]) + exec_test_command( + BASE_CMDS["vpcs"] + ["default-ranges-all-list", "--json"] + ) )[0] assert all(header in result.keys() for header in headers) @@ -408,10 +458,15 @@ def test_get_vpc_default_ranges(): def test_vpc_with_ipv4(create_vpc_with_ipv4, expected): vpc_id = create_vpc_with_ipv4 - result = exec_test_command( - BASE_CMDS["vpcs"] + ["list", "--text", "--format=id", "--no-headers"] - ) - assert vpc_id in result.splitlines() + def vpc_ready(): + vpc_ids = exec_test_command( + BASE_CMDS["vpcs"] + + ["list", "--text", "--format=id", "--no-headers"] + ).splitlines() + + return vpc_id in vpc_ids + + wait_for_condition(3, 30, vpc_ready) result = json.loads( exec_test_command(BASE_CMDS["vpcs"] + ["view", vpc_id, "--json"]) @@ -446,9 +501,13 @@ def test_vpc_update_with_ipv4(create_vpc_with_ipv4, updated): assert result["ipv4"][0]["range"] == updated +@pytest.mark.skipif( + os.environ.get("LINODE_CLI_API_VERSION", None) != "v4beta", + reason="At the moment default-ranges-all-list command is available on beta env only", +) def test_vpc_with_forbidden_ipv4_fail(): forbidden_ipv4 = exec_test_command( - BASE_CMD + BASE_CMDS["vpcs"] + [ "default-ranges-all-list", "--text", From 1eb823b964cf9cd073c3289f081f5cacdf1b5ced Mon Sep 17 00:00:00 2001 From: Pawel <100145168+psnoch-akamai@users.noreply.github.com> Date: Thu, 17 Sep 2026 17:28:54 +0200 Subject: [PATCH 31/32] TPT-4494: Support CFW protocol "ALL" / numeric (#917) Co-authored-by: Maciej Wilk Co-authored-by: Erik Zilber --- tests/integration/firewalls/fixtures.py | 56 ++++++++++++++++ .../firewalls/test_firewalls_rules.py | 64 +++++++++++++++++++ 2 files changed, 120 insertions(+) diff --git a/tests/integration/firewalls/fixtures.py b/tests/integration/firewalls/fixtures.py index 72559555f..88812afe9 100644 --- a/tests/integration/firewalls/fixtures.py +++ b/tests/integration/firewalls/fixtures.py @@ -1,3 +1,5 @@ +import json + import pytest from tests.integration.helpers import ( @@ -32,3 +34,57 @@ def firewall_id(): yield firewall_id delete_target_id(target="firewalls", id=firewall_id) + + +@pytest.fixture(scope="session") +def firewall_protocol_all(): + firewall_id = exec_test_command( + BASE_CMDS["firewalls"] + + [ + "create", + "--label", + "fw-test-" + get_random_text(5), + "--rules.outbound_policy", + "ACCEPT", + "--rules.outbound", + '[{"protocol": "ALL", "addresses": {"ipv4": ["198.51.100.0/24"]}, "action": "ACCEPT", "label": "protocol_ALL_test"}]', + "--rules.inbound_policy", + "DROP", + "--text", + "--no-headers", + "--format", + "id", + ] + ) + + yield firewall_id + + delete_target_id(target="firewalls", id=firewall_id) + + +@pytest.fixture(scope="session") +def firewall_protocol_numeric_and_all(): + response = json.loads( + exec_test_command( + BASE_CMDS["firewalls"] + + [ + "create", + "--label", + "fw-test-" + get_random_text(5), + "--rules.inbound_policy", + "DROP", + "--rules.inbound", + '[{"protocol": "ALL", "addresses": {"ipv4": ["0.0.0.0/0"]}, "action": "ACCEPT", "label": "protocol_ALL_test"}]', + "--rules.outbound_policy", + "ACCEPT", + "--rules.outbound", + '[{"protocol": "40", "addresses": {"ipv4": ["198.51.100.0/24"]}, "action": "ACCEPT", "label": "protocol_numeric_test"}, ' + '{"protocol": "ALL", "addresses": {"ipv4": ["0.0.0.0/0"]}, "action": "ACCEPT", "label": "protocol_ALL_test"}]', + "--json", + ] + ) + ) + + yield response + + delete_target_id(target="firewalls", id=str(response[0]["id"])) diff --git a/tests/integration/firewalls/test_firewalls_rules.py b/tests/integration/firewalls/test_firewalls_rules.py index 781ca7725..dab6ac19e 100644 --- a/tests/integration/firewalls/test_firewalls_rules.py +++ b/tests/integration/firewalls/test_firewalls_rules.py @@ -3,6 +3,8 @@ from tests.integration.firewalls.fixtures import ( # noqa: F401 firewall_id, + firewall_protocol_all, + firewall_protocol_numeric_and_all, ) from tests.integration.helpers import ( BASE_CMDS, @@ -280,3 +282,65 @@ def test_list_rules_json_format(firewall_id): ) ) assert result[0]["inbound"][0]["label"] == "rules-list-test" + + +def test_create_firewall_protocol_numeric_and_all( + firewall_protocol_numeric_and_all, +): + output = firewall_protocol_numeric_and_all[0] + assert output["status"] == "enabled" + assert output["rules"]["inbound_policy"] == "DROP" + assert output["rules"]["inbound"][0]["action"] == "ACCEPT" + assert output["rules"]["inbound"][0]["protocol"] == "ALL" + assert output["rules"]["inbound"][0]["addresses"]["ipv4"] == ["0.0.0.0/0"] + assert output["rules"]["outbound_policy"] == "ACCEPT" + assert output["rules"]["outbound"][0]["action"] == "ACCEPT" + assert output["rules"]["outbound"][0]["protocol"] == "40" + assert output["rules"]["outbound"][0]["addresses"]["ipv4"] == [ + "198.51.100.0/24" + ] + assert output["rules"]["outbound"][1]["action"] == "ACCEPT" + assert output["rules"]["outbound"][1]["protocol"] == "ALL" + assert output["rules"]["outbound"][1]["addresses"]["ipv4"] == ["0.0.0.0/0"] + assert output["rules"]["version"] == 1 + + +def test_firewall_protocol_all_get_rules_list(firewall_protocol_all): + output = json.loads( + exec_test_command( + BASE_CMDS["firewalls"] + + [ + "rules-list", + firewall_protocol_all, + "--json", + ] + ) + ) + assert len(output[0]["inbound"]) == 0 + assert output[0]["inbound_policy"] == "DROP" + assert output[0]["outbound_policy"] == "ACCEPT" + assert output[0]["outbound"][0]["action"] == "ACCEPT" + assert output[0]["outbound"][0]["protocol"] == "ALL" + assert output[0]["outbound"][0]["addresses"]["ipv4"] == ["198.51.100.0/24"] + assert output[0]["version"] == 1 + + +def test_firewall_protocol_all_get_version_view(firewall_protocol_all): + output = json.loads( + exec_test_command( + BASE_CMDS["firewalls"] + + [ + "version-view", + firewall_protocol_all, + "1", + "--json", + ] + ) + ) + assert len(output[0]["inbound"]) == 0 + assert output[0]["inbound_policy"] == "DROP" + assert output[0]["outbound_policy"] == "ACCEPT" + assert output[0]["outbound"][0]["action"] == "ACCEPT" + assert output[0]["outbound"][0]["protocol"] == "ALL" + assert output[0]["outbound"][0]["addresses"]["ipv4"] == ["198.51.100.0/24"] + assert output[0]["version"] == 1 From 47f15ad3db90609e88474137cb53f25810cbef5f Mon Sep 17 00:00:00 2001 From: Maciej Wilk Date: Fri, 18 Sep 2026 11:28:13 +0200 Subject: [PATCH 32/32] TPT-4564: Refactor and fixes of integration tests (#909) Co-authored-by: Erik Zilber --- tests/integration/conftest.py | 27 +++++- tests/integration/database/fixtures.py | 2 +- .../database/test_database_engine_config.py | 2 +- tests/integration/firewalls/conftest.py | 53 +++++++---- .../firewalls/test_firewall_settings.py | 12 +-- tests/integration/firewalls/test_firewalls.py | 30 +++---- tests/integration/helpers.py | 7 ++ tests/integration/linodes/fixtures.py | 36 +++++++- tests/integration/linodes/helpers.py | 7 +- .../linodes/test_linode_interfaces.py | 2 - tests/integration/linodes/test_linodes.py | 38 ++++++++ tests/integration/monitor/test_alerts.py | 8 +- tests/integration/monitor/test_metrics.py | 6 +- tests/integration/networking/fixtures.py | 37 +------- .../integration/networking/test_networking.py | 42 +++++++-- tests/integration/nodebalancers/fixtures.py | 26 ++++++ .../nodebalancers/test_node_balancers.py | 18 ++++ tests/integration/obj/conftest.py | 10 ++- tests/integration/obj/test_object_storage.py | 6 +- tests/integration/support/test_support.py | 89 ++++++++++--------- tests/integration/tags/test_tags.py | 5 +- tests/integration/vpc/conftest.py | 34 ++++++- tests/integration/vpc/test_vpc.py | 85 ++++-------------- 23 files changed, 360 insertions(+), 222 deletions(-) diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 2f1e5ed76..dde6f9e6d 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -18,6 +18,7 @@ from linodecli import ENV_TOKEN_NAME from tests.integration.helpers import ( + DEFAULT_REGION, check_attribute_value, delete_target_id, exec_test_command, @@ -228,8 +229,8 @@ def create_vpc_w_subnet(): vpc_label, "--region", region, - # "--ipv6.range", TODO: Uncomment after VPC Dual Stack is ready to ship - # "auto", + "--ipv6.range", + "auto", "--subnets.ipv4", "10.0.0.0/24", "--subnets.label", @@ -248,3 +249,25 @@ def pytest_configure(config): config.addinivalue_line( "markers", "smoke: mark test as part of smoke test suite" ) + + +@pytest.fixture +def create_reserved_ip(request): + tags = getattr(request, "param", None) + command = [ + "linode-cli", + "networking", + "reserved-ip-add", + "--region", + DEFAULT_REGION, + "--json", + ] + + if tags: + command += ["--tags", tags] + + result = json.loads(exec_test_command(command))[0] + + yield result + + delete_target_id("networking", result["address"], "reserved-ip-delete") diff --git a/tests/integration/database/fixtures.py b/tests/integration/database/fixtures.py index 40212682d..c897b7fcd 100644 --- a/tests/integration/database/fixtures.py +++ b/tests/integration/database/fixtures.py @@ -58,7 +58,7 @@ def mysql_cluster(): "--label", mysql_database_label, "--engine", - "mysql/8", + "mysql/8.4", "--text", "--delimiter", ",", diff --git a/tests/integration/database/test_database_engine_config.py b/tests/integration/database/test_database_engine_config.py index 55c7fa8a9..f0611901c 100644 --- a/tests/integration/database/test_database_engine_config.py +++ b/tests/integration/database/test_database_engine_config.py @@ -404,7 +404,7 @@ def mysql_db_engine_config(linode_cloud_firewall): + [ "mysql-create", "--engine", - "mysql/8", + "mysql/8.4", "--label", label, "--region", diff --git a/tests/integration/firewalls/conftest.py b/tests/integration/firewalls/conftest.py index 3d8fc74a1..3b669bdc3 100644 --- a/tests/integration/firewalls/conftest.py +++ b/tests/integration/firewalls/conftest.py @@ -4,18 +4,32 @@ from tests.integration.helpers import ( BASE_CMDS, + check_attribute_value, delete_target_id, exec_test_command, get_random_text, + wait_for_condition, ) +def get_firewall_defaults(): + result = json.loads( + exec_test_command( + BASE_CMDS["firewalls"] + + [ + "firewall-settings-list", + "--json", + ], + ) + )[0]["default_firewall_ids"] + + return result + + @pytest.fixture(scope="function") def _firewall_id_and_label(): - # generate a unique label - label = "fw-" + get_random_text(5) - # create it and capture the ID - result = exec_test_command( + label = "test-fw-" + get_random_text(5) + firewall_id = exec_test_command( BASE_CMDS["firewalls"] + [ "create", @@ -31,20 +45,33 @@ def _firewall_id_and_label(): "id", ] ) - fw_id = result - yield fw_id, label - # cleanup - delete_target_id(target="firewalls", id=fw_id) + + # Verify firewall status is reachable before proceeding with tests + wait_for_condition( + 5, + 60, + check_attribute_value, + "firewalls", + "view", + firewall_id, + "status", + "enabled", + ) + + yield firewall_id, label + + # cleanup (possible for non-default firewalls only) + delete_target_id(target="firewalls", id=firewall_id) @pytest.fixture(scope="function") -def test_firewall_id(_firewall_id_and_label): +def get_firewall_id(_firewall_id_and_label): """Only the ID, so old tests keep working.""" return _firewall_id_and_label[0] @pytest.fixture(scope="function") -def test_firewall_label(_firewall_id_and_label): +def get_firewall_label(_firewall_id_and_label): """Only the label, for tests that need it explicitly.""" return _firewall_id_and_label[1] @@ -52,11 +79,7 @@ def test_firewall_label(_firewall_id_and_label): @pytest.fixture def restore_firewall_defaults(): # Fetch and store current default firewall settings - result = exec_test_command( - BASE_CMDS["firewalls"] + ["firewall-settings-list", "--json"] - ) - settings = json.loads(result) - original_defaults = settings[0]["default_firewall_ids"] + original_defaults = get_firewall_defaults() yield original_defaults diff --git a/tests/integration/firewalls/test_firewall_settings.py b/tests/integration/firewalls/test_firewall_settings.py index c89ecab4b..c6f77eef0 100644 --- a/tests/integration/firewalls/test_firewall_settings.py +++ b/tests/integration/firewalls/test_firewall_settings.py @@ -8,7 +8,7 @@ ) -def test_firewall_settings_defaults(test_firewall_id, test_firewall_label): +def test_firewall_settings_defaults(get_firewall_id, get_firewall_label): # list all firewalls and extract the IDs list_result = exec_test_command( BASE_CMDS["firewalls"] @@ -21,8 +21,8 @@ def test_firewall_settings_defaults(test_firewall_id, test_firewall_label): ] assert ( - test_firewall_id in firewall_ids - ), f"{test_firewall_id} not found in firewall list" + get_firewall_id in firewall_ids + ), f"{get_firewall_id} not found in firewall list" # get the default firewall settings settings_result = exec_test_command( @@ -57,7 +57,7 @@ def test_firewall_settings_defaults(test_firewall_id, test_firewall_label): ), f"{key} ID ({val}) not found in firewall list" -def test_update_firewall_defaults(test_firewall_id, restore_firewall_defaults): +def test_update_firewall_defaults(get_firewall_id, restore_firewall_defaults): # Fetch current default firewall settings settings = json.loads( exec_test_command( @@ -89,8 +89,8 @@ def test_update_firewall_defaults(test_firewall_id, restore_firewall_defaults): ] assert ( - test_firewall_id in firewall_ids - ), f"{test_firewall_id} not found in firewall list" + get_firewall_id in firewall_ids + ), f"{get_firewall_id} not found in firewall list" new_id = next( fid diff --git a/tests/integration/firewalls/test_firewalls.py b/tests/integration/firewalls/test_firewalls.py index 27baf3e2e..af963e174 100644 --- a/tests/integration/firewalls/test_firewalls.py +++ b/tests/integration/firewalls/test_firewalls.py @@ -195,20 +195,22 @@ def test_update_firewall(firewall_id): assert re.search(firewall_id + "," + updated_label + ",enabled", result) -@pytest.mark.skip("skip until there is a way to delete default firewall") -def test_firewall_settings_update_and_list(test_firewall_id): +@pytest.mark.skip( + reason="Test skipped until there is a way to delete default firewall" +) +def test_firewall_settings_update_and_list(get_firewall_id): for cmd in [ BASE_CMDS["firewalls"] + [ "firewall-settings-update", - "--default_firewall_ids.vpc_interfac", - test_firewall_id, + "--default_firewall_ids.vpc_interface", + get_firewall_id, "--default_firewall_ids.public_interface", - test_firewall_id, + get_firewall_id, "--default_firewall_ids.nodebalancer", - test_firewall_id, + get_firewall_id, "--default_firewall_ids.linode", - test_firewall_id, + get_firewall_id, "--json", ], BASE_CMDS["firewalls"] @@ -217,15 +219,11 @@ def test_firewall_settings_update_and_list(test_firewall_id): "--json", ], ]: - data = json.loads(exec_test_command(cmd).stdout.decode().rstrip()) - firewall_ids = data[0]["default_firewall_ids"] - for key in [ - "linode", - "nodebalancer", - "public_interface", - "vpc_interface", - ]: - assert firewall_ids[key] == int(test_firewall_id) + data = json.loads(exec_test_command(cmd)) + def_firewall_ids = data[0]["default_firewall_ids"] + + for key in def_firewall_ids: + assert def_firewall_ids[key] == int(get_firewall_id) def test_firewall_templates_list(monkeypatch: MonkeyPatch): diff --git a/tests/integration/helpers.py b/tests/integration/helpers.py index 03a0fc30b..a1ba7d6ef 100644 --- a/tests/integration/helpers.py +++ b/tests/integration/helpers.py @@ -1,4 +1,5 @@ import json +import os import random import re import subprocess @@ -14,6 +15,12 @@ FAILED_STATUS_CODE = 256 COMMAND_JSON_OUTPUT = ["--suppress-warnings", "--no-defaults", "--json"] +DEFAULT_REGION = ( + "pl-labkrk-2" + if "devcloud" in os.getenv("LINODE_CLI_API_HOST", "") + else "us-ord" +) + # TypeVars for generic type hints below T = TypeVar("T") diff --git a/tests/integration/linodes/fixtures.py b/tests/integration/linodes/fixtures.py index 85ec179ed..8e57df9a3 100644 --- a/tests/integration/linodes/fixtures.py +++ b/tests/integration/linodes/fixtures.py @@ -5,6 +5,7 @@ from tests.integration.helpers import ( BASE_CMDS, + DEFAULT_REGION, delete_target_id, exec_test_command, get_random_region_with_caps, @@ -16,7 +17,6 @@ DEFAULT_LABEL, DEFAULT_LINODE_TYPE, DEFAULT_RANDOM_PASS, - DEFAULT_REGION, DEFAULT_TEST_IMAGE, create_linode, create_linode_and_wait, @@ -474,7 +474,7 @@ def test_linode_instance(linode_cloud_firewall): "--type", "g6-nanode-1", "--region", - "us-ord", + DEFAULT_REGION, "--image", DEFAULT_TEST_IMAGE, "--label", @@ -487,8 +487,6 @@ def test_linode_instance(linode_cloud_firewall): "--delimiter", ",", "--no-headers", - "--format", - "id", "--no-defaults", "--format", "id", @@ -700,3 +698,33 @@ def linode_with_authorization_key(linode_cloud_firewall): yield result delete_target_id(target="linodes", id=result[0]) + + +@pytest.fixture +def linode_with_reserved_ip(linode_cloud_firewall, create_reserved_ip): + res_ip = create_reserved_ip["address"] + + linode_id = exec_test_command( + BASE_CMDS["linodes"] + + [ + "create", + "--type", + "g6-nanode-1", + "--region", + DEFAULT_REGION, + "--firewall_id", + linode_cloud_firewall, + "--ipv4", + res_ip, + "--text", + "--no-headers", + "--format", + "id", + ] + ) + + wait_until(linode_id=linode_id, timeout=180, status="offline") + + yield res_ip, linode_id + + delete_target_id(target="linodes", id=linode_id) diff --git a/tests/integration/linodes/helpers.py b/tests/integration/linodes/helpers.py index 39ff206ad..2714a6219 100644 --- a/tests/integration/linodes/helpers.py +++ b/tests/integration/linodes/helpers.py @@ -1,18 +1,13 @@ import json -import os import time from tests.integration.helpers import ( BASE_CMDS, + DEFAULT_REGION, exec_test_command, ) DEFAULT_RANDOM_PASS = exec_test_command(["openssl", "rand", "-base64", "32"]) -DEFAULT_REGION = ( - "pl-labkrk-2" - if "devcloud" in os.getenv("LINODE_CLI_API_HOST", "") - else "us-ord" -) DEFAULT_TEST_IMAGE = exec_test_command( [ diff --git a/tests/integration/linodes/test_linode_interfaces.py b/tests/integration/linodes/test_linode_interfaces.py index 319615dd7..ca861750a 100644 --- a/tests/integration/linodes/test_linode_interfaces.py +++ b/tests/integration/linodes/test_linode_interfaces.py @@ -157,8 +157,6 @@ def test_interface_settings_update( default_route = settings["default_route"] assert default_route["ipv4_interface_id"] == int(interface_id) assert default_route["ipv6_interface_id"] == int(interface_id) - assert default_route["ipv4_eligible_interface_ids"] == [int(interface_id)] - assert default_route["ipv6_eligible_interface_ids"] == [int(interface_id)] def test_interface_update(linode_interface_public, monkeypatch: MonkeyPatch): diff --git a/tests/integration/linodes/test_linodes.py b/tests/integration/linodes/test_linodes.py index 9b0d361f3..4bbb70bef 100644 --- a/tests/integration/linodes/test_linodes.py +++ b/tests/integration/linodes/test_linodes.py @@ -18,6 +18,7 @@ linode_min_req, linode_with_authorization_key, linode_with_label, + linode_with_reserved_ip, linode_wo_image, test_linode_instance, ) @@ -319,3 +320,40 @@ def test_create_linode_disk_encryption_disabled(linode_cloud_firewall): assert linode_id in res and "disabled" in res delete_target_id(target="linodes", id=linode_id) + + +def test_display_linode_with_res_ipv4(linode_with_reserved_ip): + res_ip, linode_id = linode_with_reserved_ip + result = exec_test_command( + BASE_CMDS["linodes"] + + ["view", linode_id, "--text", "--no-headers", "--no-defaults"] + ) + + assert linode_id in result + assert res_ip in result + + +def test_linode_allocate_res_ipv4(test_linode_instance, create_reserved_ip): + linode_id = test_linode_instance + res_ip = create_reserved_ip["address"] + new_headers = ["reserved", "tags"] + + result = exec_test_command( + BASE_CMDS["linodes"] + + [ + "ip-add", + linode_id, + "--address", + res_ip, + "--type", + "ipv4", + "--public", + "true", + "--text", + "--delimiter", + ",", + ] + ).splitlines() + + assert_headers_in_lines(new_headers, [result[0].split(",")]) + assert res_ip in result[1] diff --git a/tests/integration/monitor/test_alerts.py b/tests/integration/monitor/test_alerts.py index 15ff6449f..6d3328d3c 100644 --- a/tests/integration/monitor/test_alerts.py +++ b/tests/integration/monitor/test_alerts.py @@ -38,12 +38,18 @@ def test_channels_list(): ) lines = res.splitlines() headers = [ + "alerts.alert_count", + "alerts.type", + "alerts.url", "channel_type", - "content.email.email_addresses", + "created", + "created_by", + "details", "id", "label", "type", "updated", + "updated_by", ] assert_headers_in_lines(headers, lines) diff --git a/tests/integration/monitor/test_metrics.py b/tests/integration/monitor/test_metrics.py index ae19732da..66b06b7c1 100644 --- a/tests/integration/monitor/test_metrics.py +++ b/tests/integration/monitor/test_metrics.py @@ -1,3 +1,5 @@ +import json + import pytest from linodecli.exit_codes import ExitCodes @@ -128,8 +130,8 @@ def test_try_create_token_with_not_existing_entity(get_service_type): + [ "token-get", service_type, - "--entity_ids", - "99999999999", + "--raw-body", + json.dumps({"entity_ids": [99999999999]}), "--text", "--delimiter=,", ], diff --git a/tests/integration/networking/fixtures.py b/tests/integration/networking/fixtures.py index d6948417e..723694adb 100644 --- a/tests/integration/networking/fixtures.py +++ b/tests/integration/networking/fixtures.py @@ -1,39 +1,12 @@ -import json - import pytest -from tests.integration.helpers import ( - BASE_CMDS, - delete_target_id, - exec_test_command, -) +from tests.integration.helpers import delete_target_id from tests.integration.linodes.helpers import ( - DEFAULT_REGION, create_linode, create_linode_and_wait, ) -@pytest.fixture -def create_reserved_ip(request): - tags = getattr(request, "param", None) - command = BASE_CMDS["networking"] + [ - "reserved-ip-add", - "--region", - DEFAULT_REGION, - "--json", - ] - - if tags: - command += ["--tags", tags] - - result = json.loads(exec_test_command(command))[0] - - yield result - - delete_target_id("networking", result["address"], "reserved-ip-delete") - - @pytest.fixture(scope="package") def get_linode_id(linode_cloud_firewall): linode_id = create_linode_and_wait(firewall_id=linode_cloud_firewall) @@ -60,11 +33,3 @@ def get_linode_ids_shared_ipv4(linode_cloud_firewall): for id_num in linode_ids: delete_target_id(target="linodes", id=id_num) - - -def get_command_heads_and_vals(command): - result = exec_test_command(command).splitlines() - headers = [item for item in result[0].split(",")] - values = [item for item in result[1].split(",")] - - return headers, values diff --git a/tests/integration/networking/test_networking.py b/tests/integration/networking/test_networking.py index 23904b956..dae38471c 100644 --- a/tests/integration/networking/test_networking.py +++ b/tests/integration/networking/test_networking.py @@ -7,13 +7,12 @@ from tests.integration.helpers import ( BASE_CMDS, + DEFAULT_REGION, assert_headers_in_lines, + delete_target_id, exec_test_command, ) -from tests.integration.linodes.helpers import DEFAULT_REGION from tests.integration.networking.fixtures import ( # noqa: F401 - create_reserved_ip, - get_command_heads_and_vals, get_linode_id, get_linode_ids_shared_ipv4, ) @@ -69,12 +68,12 @@ def test_display_ips_for_available_linodes(get_linode_id): + ["ips-list", "--text", "--no-headers", "--delimiter", ","] ) - assert re.search(r"^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}", result) + assert re.search(r"^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}", result) assert re.search( - r"ipv4,True,(False|True),[0-9]{1,3}\-[0-9]{1,3}\-[0-9]{1,3}\-[0-9]{1,3}\.ip\.linodeusercontent\.com,[0-9]*", + r"ipv4,(False|True),\d{1,3}\-\d{1,3}\-\d{1,3}\-\d{1,3}\.ip\.linodeusercontent\.com,[a-zA-Z]{2}\-[a-zA-Z]{3}.*,\d*,\d*,(False|True)", result, ) - assert re.search("ipv6,True,,.*,[0-9][0-9][0-9][0-9][0-9][0-9]*", result) + assert re.search(r"ipv6,True,,[a-zA-Z]{2}\-[a-zA-Z]{3}.*,\d*", result) assert re.search( r"(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))", result, @@ -109,6 +108,7 @@ def test_view_an_ip_address(get_linode_id): data = json.loads(result) if isinstance(data, list): data = data[0] + # Validate that the address is a proper IPv4 address assert re.match(r"^[0-9]{1,3}(\.[0-9]{1,3}){3}$", data["address"]) @@ -143,7 +143,7 @@ def test_allocate_additional_private_ipv4_address(get_linode_id): assert re.search(r"^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}", result) assert re.search( - "ipv4,False,.*,[0-9][0-9][0-9][0-9][0-9][0-9][0-9]*", result + r"ipv4,False,.*,[0-9][0-9][0-9][0-9][0-9][0-9][0-9]*", result ) @@ -321,6 +321,34 @@ def test_update_ephemeral_to_reserved(get_linode_id): assert is_reserved == "True" + delete_target_id("networking", ephemeral_ip, "reserved-ip-delete") + + +def test_allocate_reserved_ipv4_address(get_linode_id): + linode_id = get_linode_id + + result = json.loads( + exec_test_command( + BASE_CMDS["networking"] + + [ + "ip-add", + "--type", + "ipv4", + "--public", + "true", + "--linode_id", + linode_id, + "--reserved", + "true", + "--json", + ] + ) + )[0] + + assert result["reserved"] == True + + delete_target_id("networking", result["address"], "reserved-ip-delete") + def test_share_ipv4_address( get_linode_ids_shared_ipv4, monkeypatch: MonkeyPatch diff --git a/tests/integration/nodebalancers/fixtures.py b/tests/integration/nodebalancers/fixtures.py index a1a973e66..958b802de 100644 --- a/tests/integration/nodebalancers/fixtures.py +++ b/tests/integration/nodebalancers/fixtures.py @@ -336,3 +336,29 @@ def simple_nodebalancer_with_config(linode_cloud_firewall): yield nodebalancer_id, config_id delete_target_id(target="nodebalancers", id=nodebalancer_id) + + +@pytest.fixture +def nodebalancer_with_reserved_ipv4(create_reserved_ip): + res_ip = create_reserved_ip["address"] + region = create_reserved_ip["region"] + nodebalancer_id = exec_test_command( + BASE_CMDS["nodebalancers"] + + [ + "create", + "--region", + region, + "--ipv4", + res_ip, + "--text", + "--delimiter", + ",", + "--no-headers", + "--format", + "id", + ] + ) + + yield res_ip, nodebalancer_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 1a0bac919..69564852a 100644 --- a/tests/integration/nodebalancers/test_node_balancers.py +++ b/tests/integration/nodebalancers/test_node_balancers.py @@ -12,6 +12,7 @@ linode_to_add, nodebalancer_w_config_and_node, nodebalancer_with_default_conf, + nodebalancer_with_reserved_ipv4, nodebalancer_with_udp_config_and_node, simple_nodebalancer_with_config, ) @@ -68,6 +69,23 @@ def test_display_public_ipv4_for_nodebalancer(nodebalancer_w_config_and_node): assert re.search(r"^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}", result) +def test_display_nodebalancer_with_res_ipv4(nodebalancer_with_reserved_ipv4): + res_ip, nb_id = nodebalancer_with_reserved_ipv4 + result = exec_test_command( + BASE_CMDS["nodebalancers"] + + [ + "view", + nb_id, + "--format", + "ipv4", + "--text", + "--no-headers", + ] + ) + + assert res_ip in result + + def test_fail_to_view_nodebalancer_with_invalid_id(): result = exec_failing_test_command( BASE_CMDS["nodebalancers"] + ["view", "535", "--text", "--no-headers"], diff --git a/tests/integration/obj/conftest.py b/tests/integration/obj/conftest.py index 87006fce7..42b9b06a3 100644 --- a/tests/integration/obj/conftest.py +++ b/tests/integration/obj/conftest.py @@ -1,4 +1,5 @@ import json +import logging from dataclasses import dataclass from pathlib import Path from typing import Callable, List, Optional @@ -58,14 +59,17 @@ def _create_bucket(bucket_name: Optional[str] = None): exec_test_command(BASE_CMD + ["mb", bucket_name]) created_buckets.add(bucket_name) + return bucket_name yield _create_bucket - for bk in created_buckets: + + for bucket in created_buckets: try: - delete_bucket(bk) + delete_bucket(bucket) + except Exception as e: - logging.exception(f"Failed to cleanup bucket: {bk}, {e}") + logging.exception(f"Failed to cleanup bucket {bucket}: {e}") @pytest.fixture diff --git a/tests/integration/obj/test_object_storage.py b/tests/integration/obj/test_object_storage.py index 1dde58b7c..c5845a002 100644 --- a/tests/integration/obj/test_object_storage.py +++ b/tests/integration/obj/test_object_storage.py @@ -1,7 +1,6 @@ import json from typing import Callable, Optional -import pytest from pytest import MonkeyPatch from tests.integration.helpers import ( @@ -263,14 +262,11 @@ def test_endpoints(): assert us_east["s3_endpoint"] == "us-east-1.linodeobjects.com" -@pytest.mark.skipif( - reason="Skipping until the command is fixed and aligned with techdocs example. Applicable for spec version after 4.197.1" -) def test_transfers(): data = exec_test_command( BASE_CMDS["object-storage"] + [ - "transfers", + "transfer-view", "--json", ] ) diff --git a/tests/integration/support/test_support.py b/tests/integration/support/test_support.py index 7ceb2bd63..068399bca 100644 --- a/tests/integration/support/test_support.py +++ b/tests/integration/support/test_support.py @@ -5,26 +5,52 @@ assert_headers_in_lines, delete_target_id, exec_test_command, - get_random_text, ) from tests.integration.linodes.helpers import create_linode +HEADERS = ["id", "summary", "opened_by", "opened", "description"] + @pytest.fixture def support_test_linode_id(linode_cloud_firewall): - label = "cli-" + get_random_text(5) - - linode_id = create_linode() + linode_id = create_linode( + firewall_id=linode_cloud_firewall, + booted=False, + ) yield linode_id delete_target_id(target="linodes", id=linode_id) -# this will create a support ticket on your account -@pytest.mark.skip(reason="this will create a support ticket") +@pytest.fixture +def get_ticket_id(): + res = exec_test_command( + BASE_CMDS["tickets"] + + [ + "list", + "--text", + "--no-headers", + "--delimiter", + ",", + "--format", + "id", + ] + ) + ticket_ids = res.splitlines() + + if not ticket_ids or ticket_ids == [""]: + pytest.skip("No support tickets available to test.") + + yield ticket_ids[0] + + +@pytest.mark.skip( + reason="Test skipped because it creates a support ticket on the account" +) def test_create_support_ticket(support_test_linode_id): linode_id = support_test_linode_id + exec_test_command( BASE_CMDS["tickets"] + [ @@ -34,7 +60,8 @@ def test_create_support_ticket(support_test_linode_id): "--linode_id", linode_id, "--summary", - "Testing ticket" "--text", + "Testing ticket", + "--text", "--no-headers", ] ) @@ -45,49 +72,30 @@ def test_tickets_list(): BASE_CMDS["tickets"] + ["list", "--text", "--delimiter=,"] ) lines = res.splitlines() - headers = ["summary", "opened_by", "opened"] - assert_headers_in_lines(headers, lines) + assert_headers_in_lines(HEADERS, lines) -@pytest.fixture -def tickets_id(): - res = exec_test_command( - BASE_CMDS["tickets"] - + [ - "list", - "--text", - "--no-headers", - "--delimiter", - ",", - "--format", - "id", - ] - ) - ticket_ids = res.splitlines() - if not ticket_ids or ticket_ids == [""]: - pytest.skip("No support tickets available to test.") - first_id = ticket_ids[0] - yield first_id - -def test_tickets_view(tickets_id): - if not tickets_id: +def test_tickets_view(get_ticket_id): + if not get_ticket_id: pytest.skip("No support tickets available to view.") - ticket_id = tickets_id + ticket_id = get_ticket_id + res = exec_test_command( BASE_CMDS["tickets"] + ["view", ticket_id, "--text", "--delimiter=,"] ) lines = res.splitlines() - headers = ["summary", "opened_by", "opened"] - assert_headers_in_lines(headers, lines) + + assert_headers_in_lines(HEADERS, lines) @pytest.mark.skip( - reason="Creation of tickets are skipped no way of currently testing this" + reason="Test skipped because ticket creation test is skipped as well" ) -def test_reply_support_ticket(tickets_id): - ticket_id = tickets_id +def test_reply_support_ticket(get_ticket_id): + ticket_id = get_ticket_id + exec_test_command( BASE_CMDS["tickets"] + [ @@ -101,14 +109,15 @@ def test_reply_support_ticket(tickets_id): ) -def test_view_replies_support_ticket(tickets_id): - if not tickets_id: +def test_view_replies_support_ticket(get_ticket_id): + if not get_ticket_id: pytest.skip("No support tickets available to view replies.") - ticket_id = tickets_id + ticket_id = get_ticket_id res = exec_test_command( BASE_CMDS["tickets"] + ["replies", ticket_id, "--text", "--delimiter=,"] ) lines = res.splitlines() + headers = ["created_by", "created"] assert_headers_in_lines(headers, lines) diff --git a/tests/integration/tags/test_tags.py b/tests/integration/tags/test_tags.py index 4022cd746..e85fc5708 100644 --- a/tests/integration/tags/test_tags.py +++ b/tests/integration/tags/test_tags.py @@ -11,9 +11,6 @@ exec_test_command, get_random_text, ) -from tests.integration.networking.fixtures import ( # noqa: F401 - create_reserved_ip, -) @pytest.fixture(scope="session") @@ -64,7 +61,7 @@ def test_view_unique_tag(create_tag_instance): assert create_tag_instance in result -@pytest.mark.skip(reason="BUG = TPT-3650") +@pytest.mark.skip(reason="Defect: ARB-8130") def test_fail_to_create_tag_shorter_than_three_char(): bad_tag = "aa" result = exec_failing_test_command( diff --git a/tests/integration/vpc/conftest.py b/tests/integration/vpc/conftest.py index 673422405..1bdef8ad7 100644 --- a/tests/integration/vpc/conftest.py +++ b/tests/integration/vpc/conftest.py @@ -1,3 +1,5 @@ +import json + import pytest from tests.integration.conftest import create_vpc_w_subnet @@ -33,8 +35,8 @@ def get_test_vpc_wo_subnet(): label, "--region", region, - # "--ipv6.range", TODO: Uncomment after VPC Dual Stack is ready to ship - # "auto", + "--ipv6.range", + "auto", "--no-headers", "--text", "--format=id", @@ -137,3 +139,31 @@ def create_vpc_with_ipv4(request): yield vpc_id delete_target_id(target="vpcs", id=vpc_id) + + +@pytest.fixture +def create_vpc_with_ipv6(request): + ipv6_range = getattr(request, "param", None) + ipv6_range = ipv6_range if ipv6_range else "auto" + label = get_random_text(5) + "-vpc-label" + region = get_random_region_with_caps(required_capabilities=["VPCs"]) + + vpc_data = json.loads( + exec_test_command( + BASE_CMDS["vpcs"] + + [ + "create", + "--label", + label, + "--region", + region, + "--ipv6.range", + ipv6_range, + "--json", + ] + ) + )[0] + + yield vpc_data + + delete_target_id(target="vpcs", id=str(vpc_data["id"])) diff --git a/tests/integration/vpc/test_vpc.py b/tests/integration/vpc/test_vpc.py index 2c81afe12..23a51f4d1 100644 --- a/tests/integration/vpc/test_vpc.py +++ b/tests/integration/vpc/test_vpc.py @@ -18,10 +18,6 @@ HEADERS_SUBNET = ["id", "label", "ipv4", "vpc_type"] -# TODO: Remove this variable and @pytest.mark.skipif once VPC Dual Stack is ready to ship -disable_vpc_dual_stack_tests = True - - def get_vpcs_list(params: str = None): params = params.split() if params else [] command = BASE_CMDS["vpcs"] + ["ls", "--text"] + params @@ -272,72 +268,29 @@ def test_fails_to_update_vpc_subnet_w_invalid_label(get_test_vpc_w_subnet): assert "Must only use ASCII letters, numbers, and dashes" in res -@pytest.mark.skipif( - disable_vpc_dual_stack_tests, reason="Dual-stack tests disabled" -) -def test_create_vpc_with_ipv6_auto(): - region = get_random_region_with_caps(required_capabilities=["VPCs"]) - label = get_random_text(5) + "-vpc" - - res = exec_test_command( - BASE_CMDS["vpcs"] - + [ - "create", - "--label", - label, - "--region", - region, - "--ipv6.range", - "auto", - "--json", - ] - ) - - vpc_data = json.loads(res)[0] - - assert "id" in vpc_data - assert "ipv6" in vpc_data - assert isinstance(vpc_data["ipv6"], list) - assert len(vpc_data["ipv6"]) > 0 - - ipv6_entry = vpc_data["ipv6"][0] - assert "range" in ipv6_entry - +def test_create_vpc_with_ipv6_auto(create_vpc_with_ipv6): + vpc_data = create_vpc_with_ipv6 + vpc_ipv6 = vpc_data["ipv6"] -@pytest.mark.parametrize("prefix_len", ["52"]) -@pytest.mark.skipif( - disable_vpc_dual_stack_tests, reason="Dual-stack tests disabled" -) -def test_create_vpc_with_custom_ipv6_prefix_length(prefix_len): - region = get_random_region_with_caps(required_capabilities=["VPCs"]) - label = get_random_text(5) + f"-vpc{prefix_len}" + assert isinstance(vpc_ipv6, list) + assert len(vpc_ipv6) > 0 + ipv6_range = vpc_ipv6[0]["range"] + assert isinstance(ipv6_range, str) + assert ipv6_range.endswith("/52") - res = exec_test_command( - BASE_CMDS["vpcs"] - + [ - "create", - "--label", - label, - "--region", - region, - "--ipv6.range", - f"/{prefix_len}", - "--json", - ] - ) - vpc_data = json.loads(res)[0] +@pytest.mark.parametrize("create_vpc_with_ipv6", ["/48"], indirect=True) +def test_create_vpc_with_custom_ipv6_prefix_length(create_vpc_with_ipv6): + vpc_data = create_vpc_with_ipv6 + vpc_ipv6 = vpc_data["ipv6"] - assert "ipv6" in vpc_data - ipv6_entry = vpc_data["ipv6"][0] - ipv6_range = ipv6_entry.get("range", "") + assert isinstance(vpc_ipv6, list) + assert len(vpc_ipv6) > 0 + ipv6_range = vpc_ipv6[0]["range"] assert isinstance(ipv6_range, str) - assert ipv6_range.endswith(f"/{prefix_len}") + assert ipv6_range.endswith("/48") -@pytest.mark.skipif( - disable_vpc_dual_stack_tests, reason="Dual-stack tests disabled" -) def test_create_subnet_with_ipv6_auto(get_test_vpc_wo_subnet): vpc_id = get_test_vpc_wo_subnet subnet_label = get_random_text(5) + "-ipv6subnet" @@ -374,9 +327,6 @@ def test_create_subnet_with_ipv6_auto(get_test_vpc_wo_subnet): assert "/" in ipv6_range, f"Unexpected IPv6 CIDR format: {ipv6_range}" -@pytest.mark.skipif( - disable_vpc_dual_stack_tests, reason="Dual-stack tests disabled" -) def test_fails_to_create_vpc_with_invalid_ipv6_range(): region = get_random_region_with_caps(required_capabilities=["VPCs"]) label = get_random_text(5) + "-invalidvpc" @@ -412,9 +362,6 @@ def test_list_vpc_ip_address(): assert header in lines[0] -@pytest.mark.skipif( - disable_vpc_dual_stack_tests, reason="Dual-stack tests disabled" -) def test_list_vpc_ipv6s_address(): res = exec_test_command(