From 886c194a3f771eeababa4228369a1d8682e4fd82 Mon Sep 17 00:00:00 2001 From: Zhiwei Liang <121905282+zliang-akamai@users.noreply.github.com> Date: Fri, 8 Mar 2024 14:50:02 -0500 Subject: [PATCH 1/9] new: Add support for Parent/Child account switching (#373) * Changes for parent/child account project * Improved test --- linode_api4/groups/account.py | 13 +++++++ linode_api4/objects/account.py | 35 ++++++++++++++++++ test/fixtures/account_child-accounts.json | 36 +++++++++++++++++++ .../account_child-accounts_123456.json | 29 +++++++++++++++ .../account_child-accounts_123456_token.json | 8 +++++ test/integration/models/test_account.py | 10 ++++++ test/unit/objects/account_test.py | 19 ++++++++++ 7 files changed, 150 insertions(+) create mode 100644 test/fixtures/account_child-accounts.json create mode 100644 test/fixtures/account_child-accounts_123456.json create mode 100644 test/fixtures/account_child-accounts_123456_token.json diff --git a/linode_api4/groups/account.py b/linode_api4/groups/account.py index 55eab9436..0f20cf311 100644 --- a/linode_api4/groups/account.py +++ b/linode_api4/groups/account.py @@ -8,6 +8,7 @@ AccountBetaProgram, AccountSettings, BetaProgram, + ChildAccount, Event, Invoice, Login, @@ -18,6 +19,7 @@ ServiceTransfer, User, ) +from linode_api4.objects.profile import PersonalAccessToken class AccountGroup(Group): @@ -496,3 +498,14 @@ def availabilities(self, *filters): :rtype: PaginatedList of AccountAvailability """ return self.client._get_and_filter(AccountAvailability, *filters) + + def child_accounts(self, *filters): + """ + Returns a list of all child accounts under the this parent account. + + API doc: TBD + + :returns: a list of all child accounts. + :rtype: PaginatedList of ChildAccount + """ + return self.client._get_and_filter(ChildAccount, *filters) diff --git a/linode_api4/objects/account.py b/linode_api4/objects/account.py index f50c4da32..69a30db79 100644 --- a/linode_api4/objects/account.py +++ b/linode_api4/objects/account.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from datetime import datetime import requests @@ -16,6 +18,7 @@ ) from linode_api4.objects.longview import LongviewClient, LongviewSubscription from linode_api4.objects.nodebalancer import NodeBalancer +from linode_api4.objects.profile import PersonalAccessToken from linode_api4.objects.support import SupportTicket @@ -53,6 +56,37 @@ class Account(Base): } +class ChildAccount(Account): + """ + A child account under a parent account. + + API Documentation: TBD + """ + + api_endpoint = "/account/child-accounts/{euuid}" + id_attribute = "euuid" + + def create_token(self, **kwargs): + """ + Create a ephemeral token for accessing the child account. + + API Documentation: TBD + """ + resp = self._client.post( + "{}/token".format(self.api_endpoint), + model=self, + data=kwargs, + ) + + if "errors" in resp: + raise UnexpectedResponseError( + "Unexpected response when creating a token for the child account!", + json=resp, + ) + + return PersonalAccessToken(self._client, resp["id"], resp) + + class ServiceTransfer(Base): """ A transfer request for transferring a service between Linode accounts. @@ -476,6 +510,7 @@ class User(Base): properties = { "email": Property(), "username": Property(identifier=True, mutable=True), + "user_type": Property(), "restricted": Property(mutable=True), "ssh_keys": Property(), "tfa_enabled": Property(), diff --git a/test/fixtures/account_child-accounts.json b/test/fixtures/account_child-accounts.json new file mode 100644 index 000000000..e7e9aca43 --- /dev/null +++ b/test/fixtures/account_child-accounts.json @@ -0,0 +1,36 @@ +{ + "data": [ + { + "active_since": "2018-01-01T00:01:01", + "address_1": "123 Main Street", + "address_2": "Suite A", + "balance": 200, + "balance_uninvoiced": 145, + "billing_source": "external", + "capabilities": [ + "Linodes", + "NodeBalancers", + "Block Storage", + "Object Storage" + ], + "city": "Philadelphia", + "company": "Linode LLC", + "country": "US", + "credit_card": { + "expiry": "11/2022", + "last_four": 1111 + }, + "email": "john.smith@linode.com", + "euuid": "E1AF5EEC-526F-487D-B317EBEB34C87D71", + "first_name": "John", + "last_name": "Smith", + "phone": "215-555-1212", + "state": "PA", + "tax_id": "ATU99999999", + "zip": "19102-1234" + } + ], + "page": 1, + "pages": 1, + "results": 1 +} diff --git a/test/fixtures/account_child-accounts_123456.json b/test/fixtures/account_child-accounts_123456.json new file mode 100644 index 000000000..8ce264693 --- /dev/null +++ b/test/fixtures/account_child-accounts_123456.json @@ -0,0 +1,29 @@ +{ + "active_since": "2018-01-01T00:01:01", + "address_1": "123 Main Street", + "address_2": "Suite A", + "balance": 200, + "balance_uninvoiced": 145, + "billing_source": "external", + "capabilities": [ + "Linodes", + "NodeBalancers", + "Block Storage", + "Object Storage" + ], + "city": "Philadelphia", + "company": "Linode LLC", + "country": "US", + "credit_card": { + "expiry": "11/2022", + "last_four": 1111 + }, + "email": "john.smith@linode.com", + "euuid": "E1AF5EEC-526F-487D-B317EBEB34C87D71", + "first_name": "John", + "last_name": "Smith", + "phone": "215-555-1212", + "state": "PA", + "tax_id": "ATU99999999", + "zip": "19102-1234" +} \ No newline at end of file diff --git a/test/fixtures/account_child-accounts_123456_token.json b/test/fixtures/account_child-accounts_123456_token.json new file mode 100644 index 000000000..44afea72b --- /dev/null +++ b/test/fixtures/account_child-accounts_123456_token.json @@ -0,0 +1,8 @@ +{ + "created": "2024-01-01T00:01:01", + "expiry": "2024-01-01T13:46:32", + "id": 123, + "label": "cool_customer_proxy", + "scopes": "*", + "token": "abcdefghijklmnop" +} \ No newline at end of file diff --git a/test/integration/models/test_account.py b/test/integration/models/test_account.py index 3d5fa2d97..651268b82 100644 --- a/test/integration/models/test_account.py +++ b/test/integration/models/test_account.py @@ -11,6 +11,7 @@ OAuthClient, User, ) +from linode_api4.objects.account import ChildAccount @pytest.mark.smoke @@ -101,3 +102,12 @@ def test_get_user(test_linode_client): assert username == user.username assert "email" in user._raw_json assert "email" in user._raw_json + + +def test_list_child_accounts(test_linode_client): + client = test_linode_client + child_accounts = client.account.child_accounts() + if len(child_accounts) > 0: + child_account = ChildAccount(client, child_accounts[0].euuid) + child_account._api_get() + child_account.create_token() diff --git a/test/unit/objects/account_test.py b/test/unit/objects/account_test.py index 0f53240f4..1ec344a7f 100644 --- a/test/unit/objects/account_test.py +++ b/test/unit/objects/account_test.py @@ -24,6 +24,7 @@ Volume, get_obj_grants, ) +from linode_api4.objects.account import ChildAccount class InvoiceTest(ClientBaseCase): @@ -278,3 +279,21 @@ def test_account_availability_api_get(self): self.assertEqual(availability.unavailable, []) self.assertEqual(m.call_url, account_availability_url) + + +class ChildAccountTest(ClientBaseCase): + """ + Test methods of the ChildAccount + """ + + def test_child_account_api_list(self): + result = self.client.account.child_accounts() + self.assertEqual(len(result), 1) + self.assertEqual(result[0].euuid, "E1AF5EEC-526F-487D-B317EBEB34C87D71") + + def test_child_account_create_token(self): + child_account = self.client.load(ChildAccount, 123456) + with self.mock_post("/account/child-accounts/123456/token") as m: + token = child_account.create_token() + self.assertEqual(token.token, "abcdefghijklmnop") + self.assertEqual(m.call_data, {}) From a931a60ce7c37a9ab1478e1e50c765d44e6ac81f Mon Sep 17 00:00:00 2001 From: Youjung Kim <126618609+ykim-1@users.noreply.github.com> Date: Wed, 24 Apr 2024 11:48:31 -0700 Subject: [PATCH 2/9] test: refactor integration test directory structure and improve test command usage (#397) * change directory structure and makefile usage * remove unnecessary init files * remove unnecessary init files --- Makefile | 15 +++++++++------ README.rst | 9 +++------ test/integration/conftest.py | 5 ++++- .../models/{ => account}/test_account.py | 0 .../models/{ => database}/test_database.py | 0 .../models/{ => domain}/test_domain.py | 0 .../models/{ => firewall}/test_firewall.py | 0 test/integration/models/{ => image}/test_image.py | 0 .../models/{ => linode}/test_linode.py | 0 test/integration/models/{ => lke}/test_lke.py | 0 .../models/{ => longview}/test_longview.py | 0 .../models/{ => networking}/test_networking.py | 0 .../{ => nodebalancer}/test_nodebalancer.py | 0 test/integration/models/{ => tag}/test_tag.py | 0 .../models/{ => volume}/test_volume.py | 0 test/integration/models/{ => vpc}/test_vpc.py | 0 16 files changed, 16 insertions(+), 13 deletions(-) rename test/integration/models/{ => account}/test_account.py (100%) rename test/integration/models/{ => database}/test_database.py (100%) rename test/integration/models/{ => domain}/test_domain.py (100%) rename test/integration/models/{ => firewall}/test_firewall.py (100%) rename test/integration/models/{ => image}/test_image.py (100%) rename test/integration/models/{ => linode}/test_linode.py (100%) rename test/integration/models/{ => lke}/test_lke.py (100%) rename test/integration/models/{ => longview}/test_longview.py (100%) rename test/integration/models/{ => networking}/test_networking.py (100%) rename test/integration/models/{ => nodebalancer}/test_nodebalancer.py (100%) rename test/integration/models/{ => tag}/test_tag.py (100%) rename test/integration/models/{ => volume}/test_volume.py (100%) rename test/integration/models/{ => vpc}/test_vpc.py (100%) diff --git a/Makefile b/Makefile index 32d31cf20..e37fec3bf 100644 --- a/Makefile +++ b/Makefile @@ -1,19 +1,22 @@ PYTHON ?= python3 -INTEGRATION_TEST_PATH := TEST_CASE_COMMAND := -MODEL_COMMAND := +TEST_SUITE := LINODE_SDK_VERSION ?= "0.0.0.dev" VERSION_MODULE_DOCSTRING ?= \"\"\"\nThe version of this linode_api4 package.\n\"\"\"\n\n VERSION_FILE := ./linode_api4/version.py ifdef TEST_CASE -TEST_CASE_COMMAND = -k $(TEST_CASE) + TEST_CASE_COMMAND = -k $(TEST_CASE) endif -ifdef TEST_MODEL -MODEL_COMMAND = models/$(TEST_MODEL) +ifdef TEST_SUITE + ifneq ($(TEST_SUITE),linode_client) + TEST_COMMAND = models/$(TEST_SUITE) + else + TEST_COMMAND = linode_client + endif endif .PHONY: clean @@ -67,7 +70,7 @@ lint: build .PHONY: testint testint: - $(PYTHON) -m pytest test/integration/${INTEGRATION_TEST_PATH}${MODEL_COMMAND} ${TEST_CASE_COMMAND} + $(PYTHON) -m pytest test/integration/${TEST_COMMAND} ${TEST_CASE_COMMAND} .PHONY: testunit testunit: diff --git a/README.rst b/README.rst index 7030f59df..bbbfeb31a 100644 --- a/README.rst +++ b/README.rst @@ -150,13 +150,10 @@ Run the tests locally using the make command. Run the entire test suite using co make testint -To run a specific package, use environment variable `INTEGRATION_TEST_PATH` with `testint` command:: +To run a specific package/suite, use the environment variable `TEST_SUITE` using directory names in `integration/...` folder :: - make INTEGRATION_TEST_PATH="linode_client" testint - -To run a specific model test suite, set the environment variable `TEST_MODEL` using file name in `integration/models`:: - - make TEST_MODEL="test_account.py" testint + make TEST_SUITE="account" testint // Runs tests in `integration/models/account` directory + make TEST_SUITE="linode_client" testint // Runs tests in `integration/linode_client` directory Lastly to run a specific test case use environment variable `TEST_CASE` with `testint` command:: diff --git a/test/integration/conftest.py b/test/integration/conftest.py index 3ba7d2b40..99670c56c 100644 --- a/test/integration/conftest.py +++ b/test/integration/conftest.py @@ -252,8 +252,11 @@ def test_firewall(test_linode_client): "inbound_policy": "ACCEPT", } + timestamp = str(time.time_ns()) + label = "firewall_" + timestamp + firewall = client.networking.firewall_create( - "test-firewall", rules=rules, status="enabled" + label=label, rules=rules, status="enabled" ) yield firewall diff --git a/test/integration/models/test_account.py b/test/integration/models/account/test_account.py similarity index 100% rename from test/integration/models/test_account.py rename to test/integration/models/account/test_account.py diff --git a/test/integration/models/test_database.py b/test/integration/models/database/test_database.py similarity index 100% rename from test/integration/models/test_database.py rename to test/integration/models/database/test_database.py diff --git a/test/integration/models/test_domain.py b/test/integration/models/domain/test_domain.py similarity index 100% rename from test/integration/models/test_domain.py rename to test/integration/models/domain/test_domain.py diff --git a/test/integration/models/test_firewall.py b/test/integration/models/firewall/test_firewall.py similarity index 100% rename from test/integration/models/test_firewall.py rename to test/integration/models/firewall/test_firewall.py diff --git a/test/integration/models/test_image.py b/test/integration/models/image/test_image.py similarity index 100% rename from test/integration/models/test_image.py rename to test/integration/models/image/test_image.py diff --git a/test/integration/models/test_linode.py b/test/integration/models/linode/test_linode.py similarity index 100% rename from test/integration/models/test_linode.py rename to test/integration/models/linode/test_linode.py diff --git a/test/integration/models/test_lke.py b/test/integration/models/lke/test_lke.py similarity index 100% rename from test/integration/models/test_lke.py rename to test/integration/models/lke/test_lke.py diff --git a/test/integration/models/test_longview.py b/test/integration/models/longview/test_longview.py similarity index 100% rename from test/integration/models/test_longview.py rename to test/integration/models/longview/test_longview.py diff --git a/test/integration/models/test_networking.py b/test/integration/models/networking/test_networking.py similarity index 100% rename from test/integration/models/test_networking.py rename to test/integration/models/networking/test_networking.py diff --git a/test/integration/models/test_nodebalancer.py b/test/integration/models/nodebalancer/test_nodebalancer.py similarity index 100% rename from test/integration/models/test_nodebalancer.py rename to test/integration/models/nodebalancer/test_nodebalancer.py diff --git a/test/integration/models/test_tag.py b/test/integration/models/tag/test_tag.py similarity index 100% rename from test/integration/models/test_tag.py rename to test/integration/models/tag/test_tag.py diff --git a/test/integration/models/test_volume.py b/test/integration/models/volume/test_volume.py similarity index 100% rename from test/integration/models/test_volume.py rename to test/integration/models/volume/test_volume.py diff --git a/test/integration/models/test_vpc.py b/test/integration/models/vpc/test_vpc.py similarity index 100% rename from test/integration/models/test_vpc.py rename to test/integration/models/vpc/test_vpc.py From ab50d06f24f1cb611561494a41bfa61b0775e9d6 Mon Sep 17 00:00:00 2001 From: Youjung Kim <126618609+ykim-1@users.noreply.github.com> Date: Fri, 26 Apr 2024 08:36:54 -0700 Subject: [PATCH 3/9] test: Add new tests for Oauth and Login Client (#403) * add tests for oauth and login client * remove duplicate lines --- .../login_client/test_login_client.py | 102 ++++++++++++++++++ .../models/account/test_account.py | 18 +--- 2 files changed, 103 insertions(+), 17 deletions(-) create mode 100644 test/integration/login_client/test_login_client.py diff --git a/test/integration/login_client/test_login_client.py b/test/integration/login_client/test_login_client.py new file mode 100644 index 000000000..0a24a4433 --- /dev/null +++ b/test/integration/login_client/test_login_client.py @@ -0,0 +1,102 @@ +import pytest + +from linode_api4 import OAuthScopes +from linode_api4.login_client import LinodeLoginClient +from linode_api4.objects import OAuthClient + + +@pytest.fixture +def linode_login_client(test_oauth_client): + client_id = test_oauth_client.id + client_secret = test_oauth_client.secret + + login_client = LinodeLoginClient(client_id, client_secret) + + yield login_client + + +@pytest.fixture +def test_oauth_client_two(test_linode_client): + client = test_linode_client + oauth_client = client.account.oauth_client_create( + "test-oauth-client-two", "https://localhost/oauth/callback" + ) + + yield oauth_client + + oauth_client.delete() + + +def test_get_oathclient(test_linode_client, test_oauth_client): + client = test_linode_client + + oauth_client = client.load(OAuthClient, test_oauth_client.id) + + assert "test-oauth-client" == oauth_client.label + assert "https://localhost/oauth/callback" == oauth_client.redirect_uri + + +def test_get_oauth_clients( + test_linode_client, test_oauth_client, test_oauth_client_two +): + oauth_clients = test_linode_client.account.oauth_clients() + + id_list = [o_cli.id for o_cli in oauth_clients] + + assert str(test_oauth_client.id) in id_list + assert str(test_oauth_client_two.id) in id_list + + +def test_get_oauth_clients_dont_reveal_secret(test_linode_client): + oauth_client_secret = test_linode_client.account.oauth_clients()[0].secret + + assert oauth_client_secret == "" + + +def test_edit_oauth_client_details(test_linode_client, test_oauth_client_two): + test_oauth_client_two.redirect_uri = ( + "https://localhost/oauth/callback_changed" + ) + test_oauth_client_two.label = "new_oauthclient_label" + test_oauth_client_two.save() + + oau_client = test_linode_client.load(OAuthClient, test_oauth_client_two.id) + + assert oau_client.redirect_uri == "https://localhost/oauth/callback_changed" + assert oau_client.label == "new_oauthclient_label" + + +def test_oauth_client_reset_secrets(test_oauth_client_two): + old_secret = test_oauth_client_two.secret + + new_secret = test_oauth_client_two.reset_secret() + + assert old_secret != new_secret + + +def test_linode_login_client_generate_default_login_url(linode_login_client): + client_id = linode_login_client.client_id + url = linode_login_client.generate_login_url() + + assert ( + "https://login.linode.com/oauth/authorize?client_id=" + + str(client_id) + + "&response_type=code" + == url + ) + + +def test_linode_login_client_generate_login_url_with_scope(linode_login_client): + url = linode_login_client.generate_login_url( + scopes=OAuthScopes.Linodes.read_write + ) + + assert "scopes=linodes%3Aread_write" in url + + +def test_linode_login_client_expire_token( + linode_login_client, test_oauth_client +): + result = linode_login_client.expire_token(token=test_oauth_client.secret) + + assert result is True diff --git a/test/integration/models/account/test_account.py b/test/integration/models/account/test_account.py index 3d5fa2d97..c2d2ffb8a 100644 --- a/test/integration/models/account/test_account.py +++ b/test/integration/models/account/test_account.py @@ -3,14 +3,7 @@ import pytest -from linode_api4.objects import ( - Account, - AccountSettings, - Event, - Login, - OAuthClient, - User, -) +from linode_api4.objects import Account, AccountSettings, Event, Login, User @pytest.mark.smoke @@ -80,15 +73,6 @@ def test_latest_get_event(test_linode_client): assert label in latest_event["entity"]["label"] -def test_get_oathclient(test_linode_client, test_oauth_client): - client = test_linode_client - - oauth_client = client.load(OAuthClient, test_oauth_client.id) - - assert "test-oauth-client" == oauth_client.label - assert "https://localhost/oauth/callback" == oauth_client.redirect_uri - - def test_get_user(test_linode_client): client = test_linode_client From 098d050666a1cc0130f1e5c61dc7929c70f2e9cb Mon Sep 17 00:00:00 2001 From: Jacob Riddle <87780794+jriddle-linode@users.noreply.github.com> Date: Mon, 29 Apr 2024 14:27:01 -0400 Subject: [PATCH 4/9] update pyenv for local environments (#404) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 📝 Description **What does this PR do and why is this change necessary?** Updates `pyenv` to point to local environment setup by devenv. --- .python-version | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.python-version b/.python-version index d20cc2bf0..6905745d0 100644 --- a/.python-version +++ b/.python-version @@ -1 +1 @@ -3.8.10 +linode_api4-python From e4ffa17a97c85d8c3f0a32082132aac79e47cd40 Mon Sep 17 00:00:00 2001 From: Youjung Kim <126618609+ykim-1@users.noreply.github.com> Date: Wed, 8 May 2024 12:31:09 -0700 Subject: [PATCH 5/9] CI: replace test execution handler with conditional (#405) * replace execution handler with conditional * adding if statement --- .github/workflows/e2e-test.yml | 24 +++++------------------- Makefile | 3 ++- 2 files changed, 7 insertions(+), 20 deletions(-) diff --git a/.github/workflows/e2e-test.yml b/.github/workflows/e2e-test.yml index 166b70e75..0d22f5dd9 100644 --- a/.github/workflows/e2e-test.yml +++ b/.github/workflows/e2e-test.yml @@ -36,14 +36,12 @@ jobs: run: | timestamp=$(date +'%Y%m%d%H%M') report_filename="${timestamp}_sdk_test_report.xml" - status=0 - if ! python3 -m pytest test/integration/${INTEGRATION_TEST_PATH} --disable-warnings --junitxml="${report_filename}"; then - echo "EXIT_STATUS=1" >> $GITHUB_ENV - fi + make testint TEST_ARGS="--junitxml=${report_filename}" env: LINODE_TOKEN: ${{ secrets.LINODE_TOKEN }} - - name: Add additional information to XML report + - name: Upload test results + if: always() run: | filename=$(ls | grep -E '^[0-9]{12}_sdk_test_report\.xml$') python tod_scripts/add_to_xml_test_report.py \ @@ -51,20 +49,8 @@ jobs: --gha_run_id "$GITHUB_RUN_ID" \ --gha_run_number "$GITHUB_RUN_NUMBER" \ --xmlfile "${filename}" - - - name: Upload test results - run: | - report_filename=$(ls | grep -E '^[0-9]{12}_sdk_test_report\.xml$') - python3 tod_scripts/test_report_upload_script.py "${report_filename}" + sync + python3 tod_scripts/test_report_upload_script.py "${filename}" env: LINODE_CLI_OBJ_ACCESS_KEY: ${{ secrets.LINODE_CLI_OBJ_ACCESS_KEY }} LINODE_CLI_OBJ_SECRET_KEY: ${{ secrets.LINODE_CLI_OBJ_SECRET_KEY }} - - - name: Test Execution Status Handler - run: | - if [[ "$EXIT_STATUS" != 0 ]]; then - echo "Test execution contains failure(s)" - exit $EXIT_STATUS - else - echo "Tests passed!" - fi \ No newline at end of file diff --git a/Makefile b/Makefile index e37fec3bf..e0d3da25f 100644 --- a/Makefile +++ b/Makefile @@ -2,6 +2,7 @@ PYTHON ?= python3 TEST_CASE_COMMAND := TEST_SUITE := +TEST_ARGS := LINODE_SDK_VERSION ?= "0.0.0.dev" VERSION_MODULE_DOCSTRING ?= \"\"\"\nThe version of this linode_api4 package.\n\"\"\"\n\n @@ -70,7 +71,7 @@ lint: build .PHONY: testint testint: - $(PYTHON) -m pytest test/integration/${TEST_COMMAND} ${TEST_CASE_COMMAND} + $(PYTHON) -m pytest test/integration/${TEST_COMMAND} ${TEST_CASE_COMMAND} ${TEST_ARGS} .PHONY: testunit testunit: From 5f1c1df6c10965a16fdea2cb40e295e73b24ca3b Mon Sep 17 00:00:00 2001 From: Lena Garber <114949949+lgarber-akamai@users.noreply.github.com> Date: Thu, 23 May 2024 15:00:13 -0400 Subject: [PATCH 6/9] new: Add support for LKE Control Plane ACLs (#406) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 📝 Description This pull request adds support for configuring and viewing the ACL configuration for an LKE cluster's control plane. **NOTE: This PR does NOT include the PUT LKE cluster changes because the acl configuration is not returned in the GET LKE cluster response.** #### New Endpoint Methods * `control_plane_acl` - GET `/lke/clusters/{cluster_id}/control_plane_acl` * `control_plane_acl_update(...)` - PUT `/lke/clusters/{cluster_id}/control_plane_acl` * `control_plane_acl_delete()` - POST `/lke/clusters/{cluster_id}/control_plane_acl` #### Updated Endpoint Methods * `LKEGroup.cluster_create(...)` - Add control_plane field to method arguments #### Misc Changes * Added data classes for LKE cluster control plane and all substructures * Added logic to JSONObject to remove `Optional` values from the generated dict if None * Added a new `always_include` class var used to designate optional values that should always be included in the generated Dict * Updated test fixture framework to support underscores in path ## ✔️ How to Test The following test steps assume you have pulled down this PR locally and run `make install`. ### Unit Testing ``` make testunit ``` ### Integration Testing ``` make TEST_COMMAND=models/lke/test_lke.py testint ``` ### Manual Testing In a Python SDK sandbox environment (e.g. dx-devenv), run the following: ```python import os from linode_api4 import ( LinodeClient, LKEClusterControlPlaneOptions, LKEClusterControlPlaneACLOptions, LKEClusterControlPlaneACLAddressesOptions, ) client = LinodeClient(token=os.getenv("LINODE_TOKEN")) cluster = client.lke.cluster_create( "us-mia", "test-cluster", client.lke.node_pool("g6-standard-1", 1), "1.29", control_plane=LKEClusterControlPlaneOptions( acl=LKEClusterControlPlaneACLOptions( enabled=True, addresses=LKEClusterControlPlaneACLAddressesOptions( ipv4=["10.0.0.1/32"], ipv6=["1234::5678"] ), ) ), ) print("Original ACL:", cluster.control_plane_acl) cluster.control_plane_acl_update( LKEClusterControlPlaneACLOptions( addresses=LKEClusterControlPlaneACLAddressesOptions(ipv4=["10.0.0.2/32"]) ) ) print("Updated ACL:", cluster.control_plane_acl) cluster.control_plane_acl_delete() print("Deleted ACL:", cluster.control_plane_acl) ``` 2. Ensure the script runs successfully and the output matches the following: ``` Original ACL: LKEClusterControlPlaneACL(enabled=True, addresses=LKEClusterControlPlaneACLAddresses(ipv4=['10.0.0.1/32'], ipv6=['1234::5678/128'])) Updated ACL: LKEClusterControlPlaneACL(enabled=True, addresses=LKEClusterControlPlaneACLAddresses(ipv4=['10.0.0.2/32'], ipv6=None)) Deleted ACL: LKEClusterControlPlaneACL(enabled=False, addresses=None) ``` --- linode_api4/groups/lke.py | 32 +++- linode_api4/objects/lke.py | 138 ++++++++++++++++++ linode_api4/objects/serializable.py | 90 +++++++++++- linode_api4/objects/vpc.py | 4 +- ...ke_clusters_18881_control__plane__acl.json | 13 ++ test/integration/models/lke/test_lke.py | 56 +++++++ test/unit/fixtures.py | 9 +- test/unit/objects/lke_test.py | 114 ++++++++++++++- test/unit/objects/serializable_test.py | 28 ++++ 9 files changed, 473 insertions(+), 11 deletions(-) create mode 100644 test/fixtures/lke_clusters_18881_control__plane__acl.json create mode 100644 test/unit/objects/serializable_test.py diff --git a/linode_api4/groups/lke.py b/linode_api4/groups/lke.py index 60ec480b5..0e2785939 100644 --- a/linode_api4/groups/lke.py +++ b/linode_api4/groups/lke.py @@ -1,6 +1,15 @@ +from typing import Any, Dict, Union + from linode_api4.errors import UnexpectedResponseError from linode_api4.groups import Group -from linode_api4.objects import Base, KubeVersion, LKECluster +from linode_api4.objects import ( + Base, + JSONObject, + KubeVersion, + LKECluster, + LKEClusterControlPlaneOptions, + drop_null_keys, +) class LKEGroup(Group): @@ -47,7 +56,17 @@ def clusters(self, *filters): """ return self.client._get_and_filter(LKECluster, *filters) - def cluster_create(self, region, label, node_pools, kube_version, **kwargs): + def cluster_create( + self, + region, + label, + node_pools, + kube_version, + control_plane: Union[ + LKEClusterControlPlaneOptions, Dict[str, Any] + ] = None, + **kwargs, + ): """ Creates an :any:`LKECluster` on this account in the given region, with the given label, and with node pools as described. For example:: @@ -80,6 +99,8 @@ def cluster_create(self, region, label, node_pools, kube_version, **kwargs): formatted dicts. :param kube_version: The version of Kubernetes to use :type kube_version: KubeVersion or str + :param control_plane: Dict[str, Any] or LKEClusterControlPlaneRequest + :type control_plane: The control plane configuration of this LKE cluster. :param kwargs: Any other arguments to pass along to the API. See the API docs for possible values. @@ -112,10 +133,15 @@ def cluster_create(self, region, label, node_pools, kube_version, **kwargs): if issubclass(type(kube_version), Base) else kube_version ), + "control_plane": ( + control_plane.dict + if issubclass(type(control_plane), JSONObject) + else control_plane + ), } params.update(kwargs) - result = self.client.post("/lke/clusters", data=params) + result = self.client.post("/lke/clusters", data=drop_null_keys(params)) if "id" not in result: raise UnexpectedResponseError( diff --git a/linode_api4/objects/lke.py b/linode_api4/objects/lke.py index f1769685a..55dd0372e 100644 --- a/linode_api4/objects/lke.py +++ b/linode_api4/objects/lke.py @@ -1,3 +1,5 @@ +from dataclasses import dataclass +from typing import Any, Dict, List, Optional, Union from urllib import parse from linode_api4.errors import UnexpectedResponseError @@ -5,6 +7,7 @@ Base, DerivedBase, Instance, + JSONObject, MappedObject, Property, Region, @@ -26,6 +29,61 @@ class KubeVersion(Base): } +@dataclass +class LKEClusterControlPlaneACLAddressesOptions(JSONObject): + """ + LKEClusterControlPlaneACLAddressesOptions are options used to configure + IP ranges that are explicitly allowed to access an LKE cluster's control plane. + """ + + ipv4: Optional[List[str]] = None + ipv6: Optional[List[str]] = None + + +@dataclass +class LKEClusterControlPlaneACLOptions(JSONObject): + """ + LKEClusterControlPlaneACLOptions is used to set + the ACL configuration of an LKE cluster's control plane. + """ + + enabled: Optional[bool] = None + addresses: Optional[LKEClusterControlPlaneACLAddressesOptions] = None + + +@dataclass +class LKEClusterControlPlaneOptions(JSONObject): + """ + LKEClusterControlPlaneOptions is used to configure + the control plane of an LKE cluster during its creation. + """ + + high_availability: Optional[bool] = None + acl: Optional[LKEClusterControlPlaneACLOptions] = None + + +@dataclass +class LKEClusterControlPlaneACLAddresses(JSONObject): + """ + LKEClusterControlPlaneACLAddresses describes IP ranges that are explicitly allowed + to access an LKE cluster's control plane. + """ + + ipv4: List[str] = None + ipv6: List[str] = None + + +@dataclass +class LKEClusterControlPlaneACL(JSONObject): + """ + LKEClusterControlPlaneACL describes the ACL configuration of an LKE cluster's + control plane. + """ + + enabled: bool = False + addresses: LKEClusterControlPlaneACLAddresses = None + + class LKENodePoolNode: """ AN LKE Node Pool Node is a helper class that is used to populate the "nodes" @@ -129,6 +187,21 @@ class LKECluster(Base): "control_plane": Property(mutable=True), } + def invalidate(self): + """ + Extends the default invalidation logic to drop cached properties. + """ + if hasattr(self, "_api_endpoints"): + del self._api_endpoints + + if hasattr(self, "_kubeconfig"): + del self._kubeconfig + + if hasattr(self, "_control_plane_acl"): + del self._control_plane_acl + + Base.invalidate(self) + @property def api_endpoints(self): """ @@ -186,6 +259,26 @@ def kubeconfig(self): return self._kubeconfig + @property + def control_plane_acl(self) -> LKEClusterControlPlaneACL: + """ + Gets the ACL configuration of this cluster's control plane. + + API Documentation: TODO + + :returns: The cluster's control plane ACL configuration. + :rtype: LKEClusterControlPlaneACL + """ + + if not hasattr(self, "_control_plane_acl"): + result = self._client.get( + f"{LKECluster.api_endpoint}/control_plane_acl", model=self + ) + + self._control_plane_acl = result.get("acl") + + return LKEClusterControlPlaneACL.from_json(self._control_plane_acl) + def node_pool_create(self, node_type, node_count, **kwargs): """ Creates a new :any:`LKENodePool` for this cluster. @@ -335,3 +428,48 @@ def service_token_delete(self): self._client.delete( "{}/servicetoken".format(LKECluster.api_endpoint), model=self ) + + def control_plane_acl_update( + self, acl: Union[LKEClusterControlPlaneACLOptions, Dict[str, Any]] + ) -> LKEClusterControlPlaneACL: + """ + Updates the ACL configuration for this cluster's control plane. + + API Documentation: TODO + + :param acl: The ACL configuration to apply to this cluster. + :type acl: LKEClusterControlPlaneACLOptions or Dict[str, Any] + + :returns: The updated control plane ACL configuration. + :rtype: LKEClusterControlPlaneACL + """ + if isinstance(acl, LKEClusterControlPlaneACLOptions): + acl = acl.dict + + result = self._client.put( + f"{LKECluster.api_endpoint}/control_plane_acl", + model=self, + data={"acl": acl}, + ) + + acl = result.get("acl") + + self._control_plane_acl = result.get("acl") + + return LKEClusterControlPlaneACL.from_json(acl) + + def control_plane_acl_delete(self): + """ + Deletes the ACL configuration for this cluster's control plane. + This has the same effect as calling control_plane_acl_update with the `enabled` field + set to False. Access controls are disabled and all rules are deleted. + + API Documentation: TODO + """ + self._client.delete( + f"{LKECluster.api_endpoint}/control_plane_acl", model=self + ) + + # Invalidate the cache so it is automatically refreshed on next access + if hasattr(self, "_control_plane_acl"): + del self._control_plane_acl diff --git a/linode_api4/objects/serializable.py b/linode_api4/objects/serializable.py index e4199283b..15494cdce 100644 --- a/linode_api4/objects/serializable.py +++ b/linode_api4/objects/serializable.py @@ -1,11 +1,14 @@ import inspect -from dataclasses import asdict, dataclass +from dataclasses import dataclass from types import SimpleNamespace from typing import ( Any, ClassVar, Dict, + List, Optional, + Set, + Union, get_args, get_origin, get_type_hints, @@ -54,28 +57,57 @@ class JSONObject(metaclass=JSONFilterableMetaclass): ) """ + always_include: ClassVar[Set[str]] = {} + """ + A set of keys corresponding to fields that should always be + included in the generated output regardless of whether their values + are None. + """ + def __init__(self): raise NotImplementedError( "JSONObject is not intended to be constructed directly" ) # TODO: Implement __repr__ + @staticmethod + def _unwrap_type(field_type: type) -> type: + args = get_args(field_type) + origin_type = get_origin(field_type) + + # We don't want to try to unwrap Dict, List, Set, etc. values + if origin_type is not Union: + return field_type + + if len(args) == 0: + raise TypeError("Expected type to have arguments, got none") + + # Use the first type in the Union's args + return JSONObject._unwrap_type(args[0]) @staticmethod def _try_from_json(json_value: Any, field_type: type): """ Determines whether a JSON dict is an instance of a field type. """ + + field_type = JSONObject._unwrap_type(field_type) + if inspect.isclass(field_type) and issubclass(field_type, JSONObject): return field_type.from_json(json_value) + return json_value @classmethod - def _parse_attr_list(cls, json_value, field_type): + def _parse_attr_list(cls, json_value: Any, field_type: type): """ Attempts to parse a list attribute with a given value and field type. """ + # Edge case for optional list values + if json_value is None: + return None + type_hint_args = get_args(field_type) if len(type_hint_args) < 1: @@ -86,11 +118,13 @@ def _parse_attr_list(cls, json_value, field_type): ] @classmethod - def _parse_attr(cls, json_value, field_type): + def _parse_attr(cls, json_value: Any, field_type: type): """ Attempts to parse an attribute with a given value and field type. """ + field_type = JSONObject._unwrap_type(field_type) + if list in (field_type, get_origin(field_type)): return cls._parse_attr_list(json_value, field_type) @@ -117,7 +151,55 @@ def _serialize(self) -> Dict[str, Any]: """ Serializes this object into a JSON dict. """ - return asdict(self) + cls = type(self) + type_hints = get_type_hints(cls) + + def attempt_serialize(value: Any) -> Any: + """ + Attempts to serialize the given value, else returns the value unchanged. + """ + if issubclass(type(value), JSONObject): + return value._serialize() + + return value + + def should_include(key: str, value: Any) -> bool: + """ + Returns whether the given key/value pair should be included in the resulting dict. + """ + + if key in cls.always_include: + return True + + hint = type_hints.get(key) + + # We want to exclude any Optional values that are None + # NOTE: We need to check for Union here because Optional is an alias of Union. + if ( + hint is None + or get_origin(hint) is not Union + or type(None) not in get_args(hint) + ): + return True + + return value is not None + + result = {} + + for k, v in vars(self).items(): + if not should_include(k, v): + continue + + if isinstance(v, List): + v = [attempt_serialize(j) for j in v] + elif isinstance(v, Dict): + v = {k: attempt_serialize(j) for k, j in v.items()} + else: + v = attempt_serialize(v) + + result[k] = v + + return result @property def dict(self) -> Dict[str, Any]: diff --git a/linode_api4/objects/vpc.py b/linode_api4/objects/vpc.py index 3f80b0925..e44eebcdc 100644 --- a/linode_api4/objects/vpc.py +++ b/linode_api4/objects/vpc.py @@ -100,7 +100,7 @@ def subnet_create( return d @property - def ips(self, *filters) -> PaginatedList: + def ips(self) -> PaginatedList: """ Get all the IP addresses under this VPC. @@ -116,5 +116,5 @@ def ips(self, *filters) -> PaginatedList: ) return self._client._get_and_filter( - VPCIPAddress, *filters, endpoint="/vpcs/{}/ips".format(self.id) + VPCIPAddress, endpoint="/vpcs/{}/ips".format(self.id) ) diff --git a/test/fixtures/lke_clusters_18881_control__plane__acl.json b/test/fixtures/lke_clusters_18881_control__plane__acl.json new file mode 100644 index 000000000..f4da34393 --- /dev/null +++ b/test/fixtures/lke_clusters_18881_control__plane__acl.json @@ -0,0 +1,13 @@ +{ + "acl": { + "enabled": true, + "addresses": { + "ipv4": [ + "10.0.0.1/32" + ], + "ipv6": [ + "1234::5678" + ] + } + } +} \ No newline at end of file diff --git a/test/integration/models/lke/test_lke.py b/test/integration/models/lke/test_lke.py index 1cff9ec44..c711aef92 100644 --- a/test/integration/models/lke/test_lke.py +++ b/test/integration/models/lke/test_lke.py @@ -7,6 +7,11 @@ import pytest +from linode_api4 import ( + LKEClusterControlPlaneACLAddressesOptions, + LKEClusterControlPlaneACLOptions, + LKEClusterControlPlaneOptions, +) from linode_api4.errors import ApiError from linode_api4.objects import LKECluster, LKENodePool, LKENodePoolNode @@ -28,6 +33,34 @@ def lke_cluster(test_linode_client): cluster.delete() +@pytest.fixture(scope="session") +def lke_cluster_with_acl(test_linode_client): + node_type = test_linode_client.linode.types()[1] # g6-standard-1 + version = test_linode_client.lke.versions()[0] + region = test_linode_client.regions().first() + node_pools = test_linode_client.lke.node_pool(node_type, 1) + label = get_test_label() + "_cluster" + + cluster = test_linode_client.lke.cluster_create( + region, + label, + node_pools, + version, + control_plane=LKEClusterControlPlaneOptions( + acl=LKEClusterControlPlaneACLOptions( + enabled=True, + addresses=LKEClusterControlPlaneACLAddressesOptions( + ipv4=["10.0.0.1/32"], ipv6=["1234::5678"] + ), + ) + ), + ) + + yield cluster + + cluster.delete() + + def get_cluster_status(cluster: LKECluster, status: str): return cluster._raw_json["status"] == status @@ -147,3 +180,26 @@ def test_service_token_delete(lke_cluster): res = cluster.service_token_delete() assert res is None + + +def test_lke_cluster_acl(lke_cluster_with_acl): + cluster = lke_cluster_with_acl + + assert cluster.control_plane_acl.enabled + assert cluster.control_plane_acl.addresses.ipv4 == ["10.0.0.1/32"] + assert cluster.control_plane_acl.addresses.ipv6 == ["1234::5678/128"] + + acl = cluster.control_plane_acl_update( + LKEClusterControlPlaneACLOptions( + addresses=LKEClusterControlPlaneACLAddressesOptions( + ipv4=["10.0.0.2/32"] + ) + ) + ) + + assert acl == cluster.control_plane_acl + assert acl.addresses.ipv4 == ["10.0.0.2/32"] + + cluster.control_plane_acl_delete() + + assert not cluster.control_plane_acl.enabled diff --git a/test/unit/fixtures.py b/test/unit/fixtures.py index a4609b22c..52d41d84c 100644 --- a/test/unit/fixtures.py +++ b/test/unit/fixtures.py @@ -1,9 +1,14 @@ import json import os +import re import sys FIXTURES_DIR = sys.path[0] + "/test/fixtures" +# This regex is useful for finding individual underscore characters, +# which is necessary to allow us to use underscores in URL paths. +PATH_REPLACEMENT_REGEX = re.compile(r"(? Date: Tue, 28 May 2024 12:28:33 -0700 Subject: [PATCH 7/9] test: Fix failure in oauth test due to missing fixture initialization (#407) * add login_client in Makefile and add initial fixture to oauth test * lint --- Makefile | 6 +++++- test/integration/login_client/test_login_client.py | 4 +++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/Makefile b/Makefile index e0d3da25f..03a527169 100644 --- a/Makefile +++ b/Makefile @@ -14,7 +14,11 @@ endif ifdef TEST_SUITE ifneq ($(TEST_SUITE),linode_client) - TEST_COMMAND = models/$(TEST_SUITE) + ifneq ($(TEST_SUITE),login_client) + TEST_COMMAND = models/$(TEST_SUITE) + else + TEST_COMMAND = login_client + endif else TEST_COMMAND = linode_client endif diff --git a/test/integration/login_client/test_login_client.py b/test/integration/login_client/test_login_client.py index 0a24a4433..8631c2617 100644 --- a/test/integration/login_client/test_login_client.py +++ b/test/integration/login_client/test_login_client.py @@ -47,7 +47,9 @@ def test_get_oauth_clients( assert str(test_oauth_client_two.id) in id_list -def test_get_oauth_clients_dont_reveal_secret(test_linode_client): +def test_get_oauth_clients_dont_reveal_secret( + test_linode_client, test_oauth_client +): oauth_client_secret = test_linode_client.account.oauth_clients()[0].secret assert oauth_client_secret == "" From ddffa753b441677f8e54076b258164713b908440 Mon Sep 17 00:00:00 2001 From: Jacob Riddle Date: Mon, 3 Jun 2024 15:30:12 -0400 Subject: [PATCH 8/9] lint --- test/integration/models/account/test_account.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/test/integration/models/account/test_account.py b/test/integration/models/account/test_account.py index d7ec41224..693d5bb82 100644 --- a/test/integration/models/account/test_account.py +++ b/test/integration/models/account/test_account.py @@ -3,7 +3,14 @@ import pytest -from linode_api4.objects import Account, AccountSettings, ChildAccount, Event, Login, User +from linode_api4.objects import ( + Account, + AccountSettings, + ChildAccount, + Event, + Login, + User, +) @pytest.mark.smoke From 90b43abd09208aaed8dbc8e8fd1d3bd94732efc5 Mon Sep 17 00:00:00 2001 From: Youjung Kim <126618609+ykim-1@users.noreply.github.com> Date: Tue, 4 Jun 2024 10:12:45 -0700 Subject: [PATCH 9/9] test: Address flaky tests in linode_client and lke (#412) * unskip lke test, update test_lke_node_recycle, filter swap disk for image create, skip parent child account test * skip pc account test * remove comment * update with cleaner syntax --- test/integration/linode_client/test_linode_client.py | 4 ++-- test/integration/models/account/test_account.py | 1 + test/integration/models/lke/test_lke.py | 12 ++++++------ 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/test/integration/linode_client/test_linode_client.py b/test/integration/linode_client/test_linode_client.py index bc5d31292..3d26bdbb1 100644 --- a/test/integration/linode_client/test_linode_client.py +++ b/test/integration/linode_client/test_linode_client.py @@ -86,10 +86,10 @@ def test_image_create(setup_client_and_linode): label = get_test_label() description = "Test description" - disk_id = linode.disks.first().id + usable_disk = [v for v in linode.disks if v.filesystem != "swap"] image = client.image_create( - disk=disk_id, label=label, description=description + disk=usable_disk[0].id, label=label, description=description ) assert image.label == label diff --git a/test/integration/models/account/test_account.py b/test/integration/models/account/test_account.py index 693d5bb82..337718709 100644 --- a/test/integration/models/account/test_account.py +++ b/test/integration/models/account/test_account.py @@ -95,6 +95,7 @@ def test_get_user(test_linode_client): def test_list_child_accounts(test_linode_client): + pytest.skip("Configure test account settings for Parent child") client = test_linode_client child_accounts = client.account.child_accounts() if len(child_accounts) > 0: diff --git a/test/integration/models/lke/test_lke.py b/test/integration/models/lke/test_lke.py index c711aef92..bbf87bedf 100644 --- a/test/integration/models/lke/test_lke.py +++ b/test/integration/models/lke/test_lke.py @@ -78,12 +78,11 @@ def test_get_lke_clusters(test_linode_client, lke_cluster): def test_get_lke_pool(test_linode_client, lke_cluster): - pytest.skip("TPT-2511") cluster = lke_cluster pool = test_linode_client.load(LKENodePool, cluster.pools[0].id, cluster.id) - assert cluster.pools[0]._raw_json == pool + assert cluster.pools[0].id == pool.id def test_cluster_dashboard_url_view(lke_cluster): @@ -147,10 +146,11 @@ def test_lke_node_recycle(test_linode_client, lke_cluster): "ready", ) - node_pool = test_linode_client.load( - LKENodePool, cluster.pools[0].id, cluster.id - ) - node = node_pool.nodes[0] + # Reload cluster + cluster = test_linode_client.load(LKECluster, lke_cluster.id) + + node = cluster.pools[0].nodes[0] + assert node.status == "ready"