From 27161d7d036fb6b4c70d41012531465ef49a47f5 Mon Sep 17 00:00:00 2001 From: Lena Garber <114949949+lgarber-akamai@users.noreply.github.com> Date: Fri, 17 Nov 2023 11:18:51 -0500 Subject: [PATCH 1/6] new: Support region availability endpoints (#349) * Add availability object * Add filter assertion * Avail docs --- linode_api4/groups/region.py | 20 + linode_api4/objects/region.py | 34 +- linode_api4/objects/serializable.py | 45 +- linode_api4/paginated_list.py | 6 + test/fixtures/regions_availability.json | 507 ++++++++++++++++++ .../regions_us-east_availability.json | 67 +++ test/unit/base.py | 7 + test/unit/objects/region_test.py | 60 ++- 8 files changed, 742 insertions(+), 4 deletions(-) create mode 100644 test/fixtures/regions_availability.json create mode 100644 test/fixtures/regions_us-east_availability.json diff --git a/linode_api4/groups/region.py b/linode_api4/groups/region.py index 4221c74a8..9ddc8fb63 100644 --- a/linode_api4/groups/region.py +++ b/linode_api4/groups/region.py @@ -1,5 +1,6 @@ from linode_api4.groups import Group from linode_api4.objects import Region +from linode_api4.objects.region import RegionAvailabilityEntry class RegionGroup(Group): @@ -23,3 +24,22 @@ def __call__(self, *filters): """ return self.client._get_and_filter(Region, *filters) + + def availability(self, *filters): + """ + Returns the availability of Linode plans within a Region. + + + API Documentation: https://www.linode.com/docs/api/regions/#regions-availability-list + + :param filters: Any number of filters to apply to this query. + See :doc:`Filtering Collections` + for more details on filtering. + + :returns: A list of entries describing the availability of a plan in a region. + :rtype: PaginatedList of RegionAvailabilityEntry + """ + + return self.client._get_and_filter( + RegionAvailabilityEntry, *filters, endpoint="/regions/availability" + ) diff --git a/linode_api4/objects/region.py b/linode_api4/objects/region.py index a9919f94b..7f48ea846 100644 --- a/linode_api4/objects/region.py +++ b/linode_api4/objects/region.py @@ -1,4 +1,10 @@ -from linode_api4.objects import Base, Property +from dataclasses import dataclass +from typing import List + +from linode_api4.errors import UnexpectedResponseError +from linode_api4.objects.base import Base, JSONObject, Property +from linode_api4.objects.filtering import FilterableAttribute +from linode_api4.objects.serializable import JSONFilterableMetaclass class Region(Base): @@ -17,3 +23,29 @@ class Region(Base): "resolvers": Property(), "label": Property(), } + + @property + def availability(self) -> List["RegionAvailabilityEntry"]: + result = self._client.get( + f"{self.api_endpoint}/availability", model=self + ) + + if result is None: + raise UnexpectedResponseError( + "Expected availability data, got None." + ) + + return [RegionAvailabilityEntry.from_json(v) for v in result] + + +@dataclass +class RegionAvailabilityEntry(JSONObject): + """ + Represents the availability of a Linode type within a region. + + API Documentation: https://www.linode.com/docs/api/regions/#region-availability-view + """ + + region: str = None + plan: str = None + available: bool = False diff --git a/linode_api4/objects/serializable.py b/linode_api4/objects/serializable.py index d0cf63282..e4199283b 100644 --- a/linode_api4/objects/serializable.py +++ b/linode_api4/objects/serializable.py @@ -1,10 +1,41 @@ import inspect from dataclasses import asdict, dataclass -from typing import Any, Dict, Optional, get_args, get_origin, get_type_hints +from types import SimpleNamespace +from typing import ( + Any, + ClassVar, + Dict, + Optional, + get_args, + get_origin, + get_type_hints, +) + +from linode_api4.objects.filtering import FilterableAttribute + +# Wraps the SimpleNamespace class and allows for +# SQLAlchemy-style filter generation on JSONObjects. +JSONFilterGroup = SimpleNamespace + + +class JSONFilterableMetaclass(type): + def __init__(cls, name, bases, dct): + setattr( + cls, + "filters", + JSONFilterGroup( + **{ + k: FilterableAttribute(k) + for k in cls.__annotations__.keys() + } + ), + ) + + super().__init__(name, bases, dct) @dataclass -class JSONObject: +class JSONObject(metaclass=JSONFilterableMetaclass): """ A simple helper class for serializable API objects. This is typically used for nested object values. @@ -13,6 +44,16 @@ class JSONObject: fields and static typing. """ + filters: ClassVar[JSONFilterGroup] = None + """ + A group containing FilterableAttributes used to create SQLAlchemy-style filters. + + Example usage:: + self.client.regions.availability( + RegionAvailabilityEntry.filters.plan == "premium4096.7" + ) + """ + def __init__(self): raise NotImplementedError( "JSONObject is not intended to be constructed directly" diff --git a/linode_api4/paginated_list.py b/linode_api4/paginated_list.py index 1db5bfc5d..b9421de6a 100644 --- a/linode_api4/paginated_list.py +++ b/linode_api4/paginated_list.py @@ -1,5 +1,7 @@ import math +from linode_api4.objects.serializable import JSONObject + class PaginatedList(object): """ @@ -205,6 +207,10 @@ def make_list(json_arr, client, cls, parent_id=None): for obj in json_arr: id_val = None + # Special handling for JSON objects + if issubclass(cls, JSONObject): + result.append(cls.from_json(obj)) + continue if "id" in obj: id_val = obj["id"] diff --git a/test/fixtures/regions_availability.json b/test/fixtures/regions_availability.json new file mode 100644 index 000000000..ff5122df8 --- /dev/null +++ b/test/fixtures/regions_availability.json @@ -0,0 +1,507 @@ +{ + "data": [ + { + "region": "us-central", + "plan": "gpu-rtx6000-1.1", + "available": false + }, + { + "region": "us-central", + "plan": "gpu-rtx6000-2.1", + "available": false + }, + { + "region": "us-central", + "plan": "gpu-rtx6000-3.1", + "available": false + }, + { + "region": "us-central", + "plan": "gpu-rtx6000-4.1", + "available": false + }, + { + "region": "us-central", + "plan": "premium131072.7", + "available": false + }, + { + "region": "us-central", + "plan": "premium16384.7", + "available": false + }, + { + "region": "us-central", + "plan": "premium262144.7", + "available": false + }, + { + "region": "us-central", + "plan": "premium32768.7", + "available": false + }, + { + "region": "us-central", + "plan": "premium4096.7", + "available": false + }, + { + "region": "us-central", + "plan": "premium524288.7", + "available": false + }, + { + "region": "us-central", + "plan": "premium65536.7", + "available": false + }, + { + "region": "us-central", + "plan": "premium8192.7", + "available": false + }, + { + "region": "us-central", + "plan": "premium98304.7", + "available": false + }, + { + "region": "us-west", + "plan": "gpu-rtx6000-1.1", + "available": false + }, + { + "region": "us-west", + "plan": "gpu-rtx6000-2.1", + "available": false + }, + { + "region": "us-west", + "plan": "gpu-rtx6000-3.1", + "available": false + }, + { + "region": "us-west", + "plan": "gpu-rtx6000-4.1", + "available": false + }, + { + "region": "us-west", + "plan": "premium131072.7", + "available": false + }, + { + "region": "us-west", + "plan": "premium16384.7", + "available": false + }, + { + "region": "us-west", + "plan": "premium262144.7", + "available": false + }, + { + "region": "us-west", + "plan": "premium32768.7", + "available": false + }, + { + "region": "us-west", + "plan": "premium4096.7", + "available": false + }, + { + "region": "us-west", + "plan": "premium524288.7", + "available": false + }, + { + "region": "us-west", + "plan": "premium65536.7", + "available": false + }, + { + "region": "us-west", + "plan": "premium8192.7", + "available": false + }, + { + "region": "us-west", + "plan": "premium98304.7", + "available": false + }, + { + "region": "us-southeast", + "plan": "gpu-rtx6000-1.1", + "available": false + }, + { + "region": "us-southeast", + "plan": "gpu-rtx6000-2.1", + "available": false + }, + { + "region": "us-southeast", + "plan": "gpu-rtx6000-3.1", + "available": false + }, + { + "region": "us-southeast", + "plan": "gpu-rtx6000-4.1", + "available": false + }, + { + "region": "us-southeast", + "plan": "premium131072.7", + "available": false + }, + { + "region": "us-southeast", + "plan": "premium16384.7", + "available": false + }, + { + "region": "us-southeast", + "plan": "premium262144.7", + "available": false + }, + { + "region": "us-southeast", + "plan": "premium32768.7", + "available": false + }, + { + "region": "us-southeast", + "plan": "premium4096.7", + "available": false + }, + { + "region": "us-southeast", + "plan": "premium524288.7", + "available": false + }, + { + "region": "us-southeast", + "plan": "premium65536.7", + "available": false + }, + { + "region": "us-southeast", + "plan": "premium8192.7", + "available": false + }, + { + "region": "us-southeast", + "plan": "premium98304.7", + "available": false + }, + { + "region": "us-east", + "plan": "gpu-rtx6000-1.1", + "available": false + }, + { + "region": "us-east", + "plan": "gpu-rtx6000-2.1", + "available": false + }, + { + "region": "us-east", + "plan": "gpu-rtx6000-3.1", + "available": false + }, + { + "region": "us-east", + "plan": "gpu-rtx6000-4.1", + "available": false + }, + { + "region": "us-east", + "plan": "premium131072.7", + "available": false + }, + { + "region": "us-east", + "plan": "premium16384.7", + "available": false + }, + { + "region": "us-east", + "plan": "premium262144.7", + "available": false + }, + { + "region": "us-east", + "plan": "premium32768.7", + "available": false + }, + { + "region": "us-east", + "plan": "premium4096.7", + "available": false + }, + { + "region": "us-east", + "plan": "premium524288.7", + "available": false + }, + { + "region": "us-east", + "plan": "premium65536.7", + "available": false + }, + { + "region": "us-east", + "plan": "premium8192.7", + "available": false + }, + { + "region": "us-east", + "plan": "premium98304.7", + "available": false + }, + { + "region": "eu-west", + "plan": "gpu-rtx6000-1.1", + "available": false + }, + { + "region": "eu-west", + "plan": "gpu-rtx6000-2.1", + "available": false + }, + { + "region": "eu-west", + "plan": "gpu-rtx6000-3.1", + "available": false + }, + { + "region": "eu-west", + "plan": "gpu-rtx6000-4.1", + "available": false + }, + { + "region": "eu-west", + "plan": "premium131072.7", + "available": false + }, + { + "region": "eu-west", + "plan": "premium16384.7", + "available": false + }, + { + "region": "eu-west", + "plan": "premium262144.7", + "available": false + }, + { + "region": "eu-west", + "plan": "premium32768.7", + "available": false + }, + { + "region": "eu-west", + "plan": "premium4096.7", + "available": false + }, + { + "region": "eu-west", + "plan": "premium524288.7", + "available": false + }, + { + "region": "eu-west", + "plan": "premium65536.7", + "available": false + }, + { + "region": "eu-west", + "plan": "premium8192.7", + "available": false + }, + { + "region": "eu-west", + "plan": "premium98304.7", + "available": false + }, + { + "region": "ap-south", + "plan": "gpu-rtx6000-1.1", + "available": false + }, + { + "region": "ap-south", + "plan": "gpu-rtx6000-2.1", + "available": false + }, + { + "region": "ap-south", + "plan": "gpu-rtx6000-3.1", + "available": false + }, + { + "region": "ap-south", + "plan": "gpu-rtx6000-4.1", + "available": false + }, + { + "region": "ap-south", + "plan": "premium131072.7", + "available": false + }, + { + "region": "ap-south", + "plan": "premium16384.7", + "available": false + }, + { + "region": "ap-south", + "plan": "premium262144.7", + "available": false + }, + { + "region": "ap-south", + "plan": "premium32768.7", + "available": false + }, + { + "region": "ap-south", + "plan": "premium4096.7", + "available": false + }, + { + "region": "ap-south", + "plan": "premium524288.7", + "available": false + }, + { + "region": "ap-south", + "plan": "premium65536.7", + "available": false + }, + { + "region": "ap-south", + "plan": "premium8192.7", + "available": false + }, + { + "region": "ap-south", + "plan": "premium98304.7", + "available": false + }, + { + "region": "eu-central", + "plan": "gpu-rtx6000-1.1", + "available": false + }, + { + "region": "eu-central", + "plan": "gpu-rtx6000-2.1", + "available": false + }, + { + "region": "eu-central", + "plan": "gpu-rtx6000-3.1", + "available": false + }, + { + "region": "eu-central", + "plan": "gpu-rtx6000-4.1", + "available": false + }, + { + "region": "eu-central", + "plan": "premium131072.7", + "available": false + }, + { + "region": "eu-central", + "plan": "premium16384.7", + "available": false + }, + { + "region": "eu-central", + "plan": "premium262144.7", + "available": false + }, + { + "region": "eu-central", + "plan": "premium32768.7", + "available": false + }, + { + "region": "eu-central", + "plan": "premium4096.7", + "available": false + }, + { + "region": "eu-central", + "plan": "premium524288.7", + "available": false + }, + { + "region": "eu-central", + "plan": "premium65536.7", + "available": false + }, + { + "region": "eu-central", + "plan": "premium8192.7", + "available": false + }, + { + "region": "eu-central", + "plan": "premium98304.7", + "available": false + }, + { + "region": "ap-west", + "plan": "gpu-rtx6000-1.1", + "available": false + }, + { + "region": "ap-west", + "plan": "gpu-rtx6000-2.1", + "available": false + }, + { + "region": "ap-west", + "plan": "gpu-rtx6000-3.1", + "available": false + }, + { + "region": "ap-west", + "plan": "gpu-rtx6000-4.1", + "available": false + }, + { + "region": "ap-west", + "plan": "premium131072.7", + "available": false + }, + { + "region": "ap-west", + "plan": "premium16384.7", + "available": false + }, + { + "region": "ap-west", + "plan": "premium262144.7", + "available": false + }, + { + "region": "ap-west", + "plan": "premium32768.7", + "available": false + }, + { + "region": "ap-west", + "plan": "premium4096.7", + "available": false + } + ], + "page": 1, + "pages": 3, + "results": 299 +} \ No newline at end of file diff --git a/test/fixtures/regions_us-east_availability.json b/test/fixtures/regions_us-east_availability.json new file mode 100644 index 000000000..f7dc11ea2 --- /dev/null +++ b/test/fixtures/regions_us-east_availability.json @@ -0,0 +1,67 @@ +[ + { + "region": "us-east", + "plan": "gpu-rtx6000-1.1", + "available": false + }, + { + "region": "us-east", + "plan": "gpu-rtx6000-2.1", + "available": false + }, + { + "region": "us-east", + "plan": "gpu-rtx6000-3.1", + "available": false + }, + { + "region": "us-east", + "plan": "gpu-rtx6000-4.1", + "available": false + }, + { + "region": "us-east", + "plan": "premium131072.7", + "available": false + }, + { + "region": "us-east", + "plan": "premium16384.7", + "available": false + }, + { + "region": "us-east", + "plan": "premium262144.7", + "available": false + }, + { + "region": "us-east", + "plan": "premium32768.7", + "available": false + }, + { + "region": "us-east", + "plan": "premium4096.7", + "available": false + }, + { + "region": "us-east", + "plan": "premium524288.7", + "available": false + }, + { + "region": "us-east", + "plan": "premium65536.7", + "available": false + }, + { + "region": "us-east", + "plan": "premium8192.7", + "available": false + }, + { + "region": "us-east", + "plan": "premium98304.7", + "available": false + } +] \ No newline at end of file diff --git a/test/unit/base.py b/test/unit/base.py index 1af94ff5e..e143f8f64 100644 --- a/test/unit/base.py +++ b/test/unit/base.py @@ -134,6 +134,13 @@ def called(self): """ return self.mock.called + @property + def call_count(self): + """ + A shortcut to check how many times the mock function was called. + """ + return self.mock.call_count + class ClientBaseCase(TestCase): def setUp(self): diff --git a/test/unit/objects/region_test.py b/test/unit/objects/region_test.py index 5fd1ee7a3..9c954a3da 100644 --- a/test/unit/objects/region_test.py +++ b/test/unit/objects/region_test.py @@ -1,6 +1,8 @@ +import json from test.unit.base import ClientBaseCase -from linode_api4.objects import Region +from linode_api4.objects import Region, Type +from linode_api4.objects.region import RegionAvailabilityEntry class RegionTest(ClientBaseCase): @@ -20,3 +22,59 @@ def test_get_region(self): self.assertEqual(region.label, "label7") self.assertEqual(region.status, "ok") self.assertIsNotNone(region.resolvers) + + def test_list_availability(self): + """ + Tests that region availability can be listed and filtered on. + """ + + with self.mock_get("/regions/availability") as m: + avail_entries = self.client.regions.availability( + RegionAvailabilityEntry.filters.region == "us-east", + RegionAvailabilityEntry.filters.plan == "premium4096.7", + ) + + assert len(avail_entries) > 0 + + for entry in avail_entries: + assert entry.region is not None + assert len(entry.region) > 0 + + assert entry.plan is not None + assert len(entry.plan) > 0 + + assert entry.available is not None + + # Ensure all three pages are read + assert m.call_count == 3 + assert m.mock.call_args_list[0].args[0] == "//regions/availability" + + assert ( + m.mock.call_args_list[1].args[0] + == "//regions/availability?page=2&page_size=100" + ) + assert ( + m.mock.call_args_list[2].args[0] + == "//regions/availability?page=3&page_size=100" + ) + + # Ensure the filter headers are correct + for k, call in m.mock.call_args_list: + assert json.loads(call.get("headers").get("X-Filter")) == { + "+and": [{"region": "us-east"}, {"plan": "premium4096.7"}] + } + + def test_region_availability(self): + """ + Tests that availability for a specific region can be listed and filtered on. + """ + avail_entries = Region(self.client, "us-east").availability + + for entry in avail_entries: + assert entry.region is not None + assert len(entry.region) > 0 + + assert entry.plan is not None + assert len(entry.plan) > 0 + + assert entry.available is not None From 78e2de616ef5d5afba54c9cbc009c09ed4b3cd6f Mon Sep 17 00:00:00 2001 From: mkaminsk-akamai <151915970+mkaminsk-akamai@users.noreply.github.com> Date: Mon, 27 Nov 2023 22:57:52 +0100 Subject: [PATCH 2/6] new: Migrating legacy expire endpoint /oauth/token/expire to /oauth/revoke (#352) --- linode_api4/login_client.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/linode_api4/login_client.py b/linode_api4/login_client.py index 765dbbe2e..2b69d9eed 100644 --- a/linode_api4/login_client.py +++ b/linode_api4/login_client.py @@ -490,9 +490,10 @@ def refresh_oauth_token(self, refresh_token): def expire_token(self, token): """ - Given a token, makes a request to the authentication server to expire - it immediately. This is considered a responsible way to log out a - user. If you simply remove the session your application has for the + Given a token, makes a request to the authentication server to expire both + access token and refresh token. + This is considered a responsible way to log out a user. + If you remove only the session your application has for the user without expiring their token, the user is not _really_ logged out. :param token: The OAuth token you wish to expire @@ -504,8 +505,9 @@ def expire_token(self, token): :raises ApiError: If the expiration attempt failed. """ r = requests.post( - self._login_uri("/oauth/token/expire"), + self._login_uri("/oauth/revoke"), data={ + "token_type_hint": "access_token", "client_id": self.client_id, "client_secret": self.client_secret, "token": token, From fdcc03fb7c4223c38cf02a7a4b3b76faeb720484 Mon Sep 17 00:00:00 2001 From: Youjung Kim <126618609+ykim-1@users.noreply.github.com> Date: Tue, 28 Nov 2023 13:25:47 -0800 Subject: [PATCH 3/6] test: evaluate skipped tests, fix failing tests, add step in e2e workflow to mark build as failed (#351) * update test_linode.py * evaluate skipped tests, add steps in e2e workflow, fix flaky tests * lint * fix flaky tests in lke and volume * add some safety wait until functions for reorder test * update test_delete_interface_containing_vpc, test_linode_initate_migration --- .github/workflows/e2e-test-pr.yml | 17 ++- test/integration/conftest.py | 5 +- test/integration/helpers.py | 2 +- test/integration/models/test_linode.py | 199 ++++++++++++++++--------- test/integration/models/test_lke.py | 23 +-- 5 files changed, 156 insertions(+), 90 deletions(-) diff --git a/.github/workflows/e2e-test-pr.yml b/.github/workflows/e2e-test-pr.yml index 00391cf19..de14c9669 100644 --- a/.github/workflows/e2e-test-pr.yml +++ b/.github/workflows/e2e-test-pr.yml @@ -19,6 +19,8 @@ jobs: runs-on: ubuntu-latest if: github.event_name == 'workflow_dispatch' && inputs.sha != '' + env: + EXIT_STATUS: 0 steps: - uses: actions-ecosystem/action-regex-match@v2 @@ -83,8 +85,8 @@ jobs: 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} --junitxml="${report_filename}"; then - echo "Tests failed, but attempting to upload results anyway" + if ! python3 -m pytest test/integration/${INTEGRATION_TEST_PATH} --disable-warnings --junitxml="${report_filename}"; then + echo "EXIT_STATUS=1" >> $GITHUB_ENV fi env: LINODE_TOKEN: ${{ secrets.LINODE_TOKEN }} @@ -135,4 +137,13 @@ jobs: status: 'completed', conclusion: process.env.conclusion }); - return result; \ No newline at end of file + return result; + + - 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/test/integration/conftest.py b/test/integration/conftest.py index a3e4b12e4..ae3550eb7 100644 --- a/test/integration/conftest.py +++ b/test/integration/conftest.py @@ -155,11 +155,10 @@ def test_domain(test_linode_client): def test_volume(test_linode_client): client = test_linode_client timestamp = str(time.time_ns()) + region = client.regions()[0] label = "TestSDK-" + timestamp - volume = client.volume_create( - label=label, region=get_region(client, {"Block Storage"}) - ) + volume = client.volume_create(label=label, region=region) yield volume diff --git a/test/integration/helpers.py b/test/integration/helpers.py index 2ea66464b..c178ad4dd 100644 --- a/test/integration/helpers.py +++ b/test/integration/helpers.py @@ -111,7 +111,7 @@ def send_request_when_resource_available( if time.time() - start_time > timeout: raise TimeoutError( "Timeout Error: resource is not available in" - + timeout + + str(timeout) + "seconds" ) time.sleep(10) diff --git a/test/integration/models/test_linode.py b/test/integration/models/test_linode.py index 5b68fac34..50497a706 100644 --- a/test/integration/models/test_linode.py +++ b/test/integration/models/test_linode.py @@ -2,6 +2,7 @@ from test.integration.helpers import ( get_test_label, retry_sending_request, + send_request_when_resource_available, wait_for_condition, ) @@ -65,6 +66,53 @@ def linode_with_volume_firewall(test_linode_client): linode_instance.delete() +@pytest.fixture(scope="session") +def linode_for_network_interface_tests(test_linode_client): + client = test_linode_client + available_regions = client.regions() + chosen_region = available_regions[0] + timestamp = str(time.time_ns()) + label = "TestSDK-" + timestamp + + linode_instance, password = client.linode.instance_create( + "g6-nanode-1", chosen_region, image="linode/debian10", label=label + ) + + yield linode_instance + + linode_instance.delete() + + +@pytest.fixture(scope="session", autouse=True) +def linode_for_disk_tests(test_linode_client): + client = test_linode_client + available_regions = client.regions() + chosen_region = available_regions[0] + label = get_test_label() + + linode_instance, password = client.linode.instance_create( + "g6-nanode-1", + chosen_region, + image="linode/debian10", + label=label + "_long_tests", + ) + + time.sleep(10) + + # Provisioning time + wait_for_condition(10, 300, get_status, linode_instance, "running") + + time.sleep(10) + + linode_instance.shutdown() + + wait_for_condition(10, 100, get_status, linode_instance, "offline") + + yield linode_instance + + linode_instance.delete() + + @pytest.mark.smoke @pytest.fixture def create_linode_for_long_running_tests(test_linode_client): @@ -289,22 +337,53 @@ def test_linode_volumes(linode_with_volume_firewall): assert "TestSDK" in volumes[0].label -def test_linode_disk_duplicate(test_linode_client, create_linode): - pytest.skip("Need to find out the space sizing when duplicating disks") - linode = create_linode +def wait_for_disk_status(disk: Disk, timeout): + start_time = time.time() + while True: + try: + if disk.status == "ready": + return disk.status + except ApiError: + if time.time() - start_time > timeout: + raise TimeoutError("Wait for condition timeout error") + + +@pytest.mark.dependency() +def test_disk_resize_and_duplicate(test_linode_client, linode_for_disk_tests): + linode = linode_for_disk_tests + + disk = linode.disks[0] + + disk.resize(5000) + + # Using hard sleep instead of wait as the status shows ready when it is resizing + time.sleep(120) disk = test_linode_client.load(Disk, linode.disks[0].id, linode.id) - try: - dup_disk = disk.duplicate() - assert dup_disk.linode_id == linode.id - except ApiError as e: - assert e.status == 400 - assert "Insufficient space" in str(e.json) + assert disk.size == 5000 + + dup_disk = disk.duplicate() + + time.sleep(40) + + wait_for_disk_status(dup_disk, 120) + + assert dup_disk.linode_id == linode.id + + +@pytest.mark.dependency(depends=["test_disk_resize_and_duplicate"]) +def test_linode_create_disk(test_linode_client, linode_for_disk_tests): + linode = test_linode_client.load(Instance, linode_for_disk_tests.id) + + disk = linode.disk_create(size=500) + + wait_for_disk_status(disk, 120) + + assert disk.linode_id == linode.id def test_linode_instance_password(create_linode_for_pass_reset): - pytest.skip("Failing due to mismatched request body") linode = create_linode_for_pass_reset[0] password = create_linode_for_pass_reset[1] @@ -343,25 +422,14 @@ def test_linode_initate_migration(test_linode_client): wait_for_condition(10, 100, get_status, linode, "running") # Says it could take up to ~6 hrs for migration to fully complete - linode.initiate_migration(region="us-central") - res = linode.delete() - - assert res - - -def test_linode_create_disk(create_linode): - pytest.skip( - "Pre-requisite for the test account need to comply with this test" + send_request_when_resource_available( + 300, linode.initiate_migration, "us-central" ) - linode = create_linode - disk, gen_pass = linode.disk_create() + res = linode.delete() -def test_disk_resize(): - pytest.skip( - "Pre-requisite for the test account need to comply with this test" - ) + assert res def test_config_update_interfaces(create_linode): @@ -387,19 +455,9 @@ def test_config_update_interfaces(create_linode): def test_get_config(test_linode_client, create_linode): - pytest.skip( - "Model get method: client.load(Config, 123, 123) does not work..." - ) linode = create_linode - json = test_linode_client.get( - "linode/instances/" - + str(linode.id) - + "/configs/" - + str(linode.configs[0].id) - ) - config = Config( - test_linode_client, linode.id, linode.configs[0].id, json=json - ) + + config = test_linode_client.load(Config, linode.configs[0].id, linode.id) assert config.id == linode.configs[0].id @@ -429,18 +487,6 @@ def test_get_linode_types_overrides(test_linode_client): assert linode_type.region_prices[0].monthly >= 0 -def test_get_linode_type_by_id(test_linode_client): - pytest.skip( - "Might need Type to match how other object models are behaving e.g. client.load(Type, 123)" - ) - - -def test_get_linode_type_gpu(): - pytest.skip( - "Might need Type to match how other object models are behaving e.g. client.load(Type, 123)" - ) - - def test_save_linode_noforce(test_linode_client, create_linode): linode = create_linode old_label = linode.label @@ -464,28 +510,29 @@ def test_save_linode_force(test_linode_client, create_linode): class TestNetworkInterface: - def test_list(self, create_linode): - linode = create_linode + def test_list(self, linode_for_network_interface_tests): + linode = linode_for_network_interface_tests config: Config = linode.configs[0] config.interface_create_public( primary=True, ) - config.interface_create_vlan( - label="testvlan", ipam_address="10.0.0.3/32" - ) + + label = str(time.time_ns()) + "vlabel" + + config.interface_create_vlan(label=label, ipam_address="10.0.0.3/32") interface = config.network_interfaces assert interface[0].purpose == "public" assert interface[0].primary assert interface[1].purpose == "vlan" - assert interface[1].label == "testvlan" + assert interface[1].label == label assert interface[1].ipam_address == "10.0.0.3/32" - def test_create_public(self, create_linode): - linode = create_linode + def test_create_public(self, linode_for_network_interface_tests): + linode = linode_for_network_interface_tests config: Config = linode.configs[0] @@ -502,8 +549,8 @@ def test_create_public(self, create_linode): assert interface.purpose == "public" assert interface.primary - def test_create_vlan(self, create_linode): - linode = create_linode + def test_create_vlan(self, linode_for_network_interface_tests): + linode = linode_for_network_interface_tests config: Config = linode.configs[0] @@ -521,7 +568,11 @@ def test_create_vlan(self, create_linode): assert interface.label == "testvlan" assert interface.ipam_address == "10.0.0.2/32" - def test_create_vpc(self, create_linode, create_vpc_with_subnet_and_linode): + def test_create_vpc( + self, + linode_for_network_interface_tests, + create_vpc_with_subnet_and_linode, + ): vpc, subnet, linode, _ = create_vpc_with_subnet_and_linode config: Config = linode.configs[0] @@ -545,7 +596,11 @@ def test_create_vpc(self, create_linode, create_vpc_with_subnet_and_linode): assert interface.ipv4.nat_1_1 == linode.ipv4[0] assert interface.ip_ranges == ["10.0.0.5/32"] - def test_update_vpc(self, create_linode, create_vpc_with_subnet_and_linode): + def test_update_vpc( + self, + linode_for_network_interface_tests, + create_vpc_with_subnet_and_linode, + ): vpc, subnet, linode, _ = create_vpc_with_subnet_and_linode config: Config = linode.configs[0] @@ -575,25 +630,31 @@ def test_update_vpc(self, create_linode, create_vpc_with_subnet_and_linode): assert interface.ipv4.nat_1_1 == linode.ipv4[0] assert interface.ip_ranges == ["10.0.0.6/32"] - def test_reorder(self, create_linode): - linode = create_linode + def test_reorder(self, linode_for_network_interface_tests): + linode = linode_for_network_interface_tests config: Config = linode.configs[0] pub_interface = config.interface_create_public( primary=True, ) + + label = str(time.time_ns()) + "vlabel" vlan_interface = config.interface_create_vlan( - label="testvlan", ipam_address="10.0.0.3/32" + label=label, ipam_address="10.0.0.3/32" ) + send_request_when_resource_available(300, linode.shutdown) + interfaces = config.network_interfaces interfaces.reverse() - config.interface_reorder(interfaces) + send_request_when_resource_available( + 300, config.interface_reorder, interfaces + ) config.invalidate() - assert [v.id for v in config.interfaces] == [ + assert [v.id for v in config.interfaces[:2]] == [ vlan_interface.id, pub_interface.id, ] @@ -606,7 +667,11 @@ def test_delete_interface_containing_vpc( config: Config = linode.configs[0] config.interfaces = [] - config.save() + + # must power off linode before saving + send_request_when_resource_available(300, linode.shutdown) + + send_request_when_resource_available(60, config.save) interface = config.interface_create_vpc( subnet=subnet, diff --git a/test/integration/models/test_lke.py b/test/integration/models/test_lke.py index 45b1ac8a1..04b479e8e 100644 --- a/test/integration/models/test_lke.py +++ b/test/integration/models/test_lke.py @@ -45,8 +45,7 @@ def test_get_lke_clusters(test_linode_client, lke_cluster): def test_get_lke_pool(test_linode_client, lke_cluster): - pytest.skip("client.load(LKENodePool, 123, 123) does not work") - + pytest.skip("TPT-2511") cluster = lke_cluster pool = test_linode_client.load(LKENodePool, cluster.pools[0].id, cluster.id) @@ -67,7 +66,9 @@ def test_cluster_dashboard_url_view(lke_cluster): def test_kubeconfig_delete(lke_cluster): cluster = lke_cluster - cluster.kubeconfig_delete() + res = send_request_when_resource_available(300, cluster.kubeconfig_delete) + + assert res is None def test_lke_node_view(lke_cluster): @@ -122,19 +123,9 @@ def test_lke_cluster_nodes_recycle(test_linode_client, lke_cluster): assert node.status == "not_ready" -def test_lke_cluster_regenerate(lke_cluster): - pytest.skip( - "Skipping reason: '400: At least one of kubeconfig or servicetoken is required.'" - ) - cluster = lke_cluster - - cluster.cluster_regenerate() - - def test_service_token_delete(lke_cluster): - pytest.skip( - "Skipping reason: '400: At least one of kubeconfig or servicetoken is required.'" - ) cluster = lke_cluster - cluster.service_token_delete() + res = cluster.service_token_delete() + + assert res is None From f7fb7bd11ccf9a1b5b0759802dfbc99881339719 Mon Sep 17 00:00:00 2001 From: okokes-akamai <126059888+okokes-akamai@users.noreply.github.com> Date: Fri, 1 Dec 2023 08:37:15 +0100 Subject: [PATCH 4/6] Preserve `client.session.verify` when set by user (#353) --- linode_api4/linode_client.py | 5 ++++- test/unit/linode_client_test.py | 22 ++++++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/linode_api4/linode_client.py b/linode_api4/linode_client.py index a6ceb0178..d55958884 100644 --- a/linode_api4/linode_client.py +++ b/linode_api4/linode_client.py @@ -276,7 +276,10 @@ def _api_call( body = json.dumps(data) response = method( - url, headers=headers, data=body, verify=self.ca_path or True + url, + headers=headers, + data=body, + verify=self.ca_path or self.session.verify, ) warning = response.headers.get("Warning", None) diff --git a/test/unit/linode_client_test.py b/test/unit/linode_client_test.py index 69af304f7..09e0f9755 100644 --- a/test/unit/linode_client_test.py +++ b/test/unit/linode_client_test.py @@ -280,6 +280,28 @@ def get_mock(*params, verify=True, **kwargs): assert called + def test_custom_verify(self): + """ + If we set a custom `verify` value on our session, + we want it preserved. + """ + called = False + + self.client.session.verify = False + old_get = self.client.session.get + + def get_mock(*params, verify=True, **kwargs): + nonlocal called + called = True + assert verify is False + return old_get(*params, **kwargs) + + self.client.session.get = get_mock + + self.client.linode.instances() + + assert called + class AccountGroupTest(ClientBaseCase): """ From 8c9fc482505d9839489a93d31ad2813d12929b6e Mon Sep 17 00:00:00 2001 From: Ye Chen <127243817+yec-akamai@users.noreply.github.com> Date: Mon, 4 Dec 2023 13:38:59 -0500 Subject: [PATCH 5/6] docs: Update API documentations for Beta Program (#355) --- linode_api4/groups/account.py | 4 ++-- linode_api4/groups/beta.py | 2 +- linode_api4/objects/account.py | 2 ++ linode_api4/objects/beta.py | 2 +- 4 files changed, 6 insertions(+), 4 deletions(-) diff --git a/linode_api4/groups/account.py b/linode_api4/groups/account.py index 4eeadcc11..50df1fc37 100644 --- a/linode_api4/groups/account.py +++ b/linode_api4/groups/account.py @@ -457,7 +457,7 @@ def enrolled_betas(self, *filters): """ Returns a list of all Beta Programs an account is enrolled in. - API doc: TBD + API doc: https://www.linode.com/docs/api/beta-programs/#enrolled-beta-programs-list :returns: a list of Beta Programs. :rtype: PaginatedList of AccountBetaProgram @@ -468,7 +468,7 @@ def join_beta_program(self, beta: Union[str, BetaProgram]): """ Enrolls an account into a beta program. - API doc: TBD + API doc: https://www.linode.com/docs/api/beta-programs/#beta-program-enroll :param beta: The object or id of a beta program to join. :type beta: BetaProgram or str diff --git a/linode_api4/groups/beta.py b/linode_api4/groups/beta.py index 18095dc03..1da34ee25 100644 --- a/linode_api4/groups/beta.py +++ b/linode_api4/groups/beta.py @@ -12,7 +12,7 @@ def betas(self, *filters): """ Returns a list of available active Beta Programs. - API Documentation: TBD + API Documentation: https://www.linode.com/docs/api/beta-programs/#beta-programs-list :param filters: Any number of filters to apply to this query. See :doc:`Filtering Collections` diff --git a/linode_api4/objects/account.py b/linode_api4/objects/account.py index 264c6ff60..f71dd0204 100644 --- a/linode_api4/objects/account.py +++ b/linode_api4/objects/account.py @@ -642,6 +642,8 @@ def save(self): class AccountBetaProgram(Base): """ The details and enrollment information of a Beta program that an account is enrolled in. + + API Documentation: https://www.linode.com/docs/api/beta-programs/#enrolled-beta-program-view """ api_endpoint = "/account/betas/{id}" diff --git a/linode_api4/objects/beta.py b/linode_api4/objects/beta.py index 3124f4f15..42a3eef85 100644 --- a/linode_api4/objects/beta.py +++ b/linode_api4/objects/beta.py @@ -6,7 +6,7 @@ class BetaProgram(Base): Beta program is a new product or service that's not generally available to all customers. User with permissions can enroll into a beta program and access the functionalities. - API Documentation: TBD + API Documentation: https://www.linode.com/docs/api/beta-programs/#beta-program-view """ api_endpoint = "/betas/{id}" From ddb0b9800b5a3b71cb0e470dd20d0597c120a3cd Mon Sep 17 00:00:00 2001 From: Youjung Kim <126618609+ykim-1@users.noreply.github.com> Date: Wed, 6 Dec 2023 14:34:53 -0800 Subject: [PATCH 6/6] test: add release info to xml file before uploading to obj storage (#357) * update script and workflow to include release info * update url * lint --- .github/workflows/e2e-test-pr.yml | 5 +--- test/script/add_to_xml_test_report.py | 34 +++++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 4 deletions(-) diff --git a/.github/workflows/e2e-test-pr.yml b/.github/workflows/e2e-test-pr.yml index de14c9669..ba0ff6cc4 100644 --- a/.github/workflows/e2e-test-pr.yml +++ b/.github/workflows/e2e-test-pr.yml @@ -91,14 +91,11 @@ jobs: env: LINODE_TOKEN: ${{ secrets.LINODE_TOKEN }} - - name: Set release version env - run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV - - name: Add additional information to XML report run: | filename=$(ls | grep -E '^[0-9]{12}_sdk_test_report\.xml$') python test/script/add_to_xml_test_report.py \ - --branch_name "${{ env.RELEASE_VERSION }}" \ + --branch_name "${GITHUB_REF#refs/*/}" \ --gha_run_id "$GITHUB_RUN_ID" \ --gha_run_number "$GITHUB_RUN_NUMBER" \ --xmlfile "${filename}" diff --git a/test/script/add_to_xml_test_report.py b/test/script/add_to_xml_test_report.py index d486028be..d978e396f 100644 --- a/test/script/add_to_xml_test_report.py +++ b/test/script/add_to_xml_test_report.py @@ -1,6 +1,35 @@ import argparse import xml.etree.ElementTree as ET +import requests + +latest_release_url = ( + "https://api.github.com/repos/linode/linode_api4-python/releases/latest" +) + + +def get_release_version(): + url = latest_release_url + + try: + response = requests.get(url) + response.raise_for_status() # Check for HTTP errors + + release_info = response.json() + version = release_info["tag_name"] + + # Remove 'v' prefix if it exists + if version.startswith("v"): + version = version[1:] + + return str(version) + + except requests.exceptions.RequestException as e: + print("Error:", e) + except KeyError: + print("Error: Unable to fetch release information from GitHub API.") + + # Parse command-line arguments parser = argparse.ArgumentParser( description="Modify XML with workflow information" @@ -8,6 +37,7 @@ parser.add_argument("--branch_name", required=True) parser.add_argument("--gha_run_id", required=True) parser.add_argument("--gha_run_number", required=True) +parser.add_argument("--release_tag", required=False) parser.add_argument( "--xmlfile", required=True ) # Added argument for XML file path @@ -29,10 +59,14 @@ gha_run_number_element = ET.Element("gha_run_number") gha_run_number_element.text = args.gha_run_number +gha_release_tag_element = ET.Element("release_tag") +gha_release_tag_element.text = get_release_version() + # Add the new elements to the root of the XML root.append(branch_name_element) root.append(gha_run_id_element) root.append(gha_run_number_element) +root.append(gha_release_tag_element) # Save the modified XML modified_xml_file_path = xml_file_path # Overwrite it