From bbb6e71657144d9f789fc583d29ce2638e20d839 Mon Sep 17 00:00:00 2001 From: Zhiwei Liang <121905282+zliang-akamai@users.noreply.github.com> Date: Wed, 3 Dec 2025 12:26:10 -0500 Subject: [PATCH 01/16] Prevent recursive build artifact inclusion (#621) --- MANIFEST.in | 5 ++++- pyproject.toml | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/MANIFEST.in b/MANIFEST.in index 96c48f6d8..d15ca4b00 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,3 +1,6 @@ +# Include all files under test/ directory in source distribution only graft test + +# Exclude Python bytecode global-exclude *.pyc -include baked_version \ No newline at end of file +global-exclude __pycache__ diff --git a/pyproject.toml b/pyproject.toml index 5098027af..4d8542cfa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -70,7 +70,7 @@ Repository = "https://github.com/linode/linode_api4-python.git" version = { attr = "linode_api4.version.__version__" } [tool.setuptools.packages.find] -exclude = ['contrib', 'docs', 'test', 'test.*'] +exclude = ['contrib', 'docs', 'build', 'build.*', 'test', 'test.*'] [tool.isort] profile = "black" From db09cc956ac6c01768c19c452e9893fbfc4134cc Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 15 Dec 2025 14:42:17 -0500 Subject: [PATCH 02/16] build(deps): bump github/codeql-action from 3 to 4 (#614) Bumps [github/codeql-action](https://github.com/github/codeql-action) from 3 to 4. - [Release notes](https://github.com/github/codeql-action/releases) - [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/github/codeql-action/compare/v3...v4) --- updated-dependencies: - dependency-name: github/codeql-action dependency-version: '4' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/codeql.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index d3fa1315f..c7b208528 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -26,13 +26,13 @@ jobs: uses: actions/checkout@v6 - name: Initialize CodeQL - uses: github/codeql-action/init@v3 + uses: github/codeql-action/init@v4 with: languages: ${{ matrix.language }} build-mode: ${{ matrix.build-mode }} queries: security-and-quality - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v3 + uses: github/codeql-action/analyze@v4 with: category: "/language:${{matrix.language}}" From 99cd773262a5b7147b0c0a064e8c3d352b027486 Mon Sep 17 00:00:00 2001 From: Zhiwei Liang <121905282+zliang-akamai@users.noreply.github.com> Date: Tue, 16 Dec 2025 12:06:39 -0500 Subject: [PATCH 03/16] Replace 'secondary' with 'standby' in database instance configurations and tests (#622) --- test/fixtures/databases_instances.json | 2 +- test/fixtures/databases_mysql_instances.json | 2 +- test/fixtures/databases_postgresql_instances.json | 2 +- test/unit/groups/database_test.py | 6 +++--- test/unit/objects/database_test.py | 8 ++++---- 5 files changed, 10 insertions(+), 10 deletions(-) diff --git a/test/fixtures/databases_instances.json b/test/fixtures/databases_instances.json index 5e92515a5..d2e6f0cf9 100644 --- a/test/fixtures/databases_instances.json +++ b/test/fixtures/databases_instances.json @@ -11,7 +11,7 @@ "engine": "mysql", "hosts": { "primary": "lin-123-456-mysql-mysql-primary.servers.linodedb.net", - "secondary": "lin-123-456-mysql-primary-private.servers.linodedb.net" + "standby": "lin-123-456-mysql-primary-private.servers.linodedb.net" }, "id": 123, "instance_uri": "/v4/databases/mysql/instances/123", diff --git a/test/fixtures/databases_mysql_instances.json b/test/fixtures/databases_mysql_instances.json index e60bfe019..c442b8345 100644 --- a/test/fixtures/databases_mysql_instances.json +++ b/test/fixtures/databases_mysql_instances.json @@ -11,7 +11,7 @@ "engine": "mysql", "hosts": { "primary": "lin-123-456-mysql-mysql-primary.servers.linodedb.net", - "secondary": "lin-123-456-mysql-primary-private.servers.linodedb.net" + "standby": "lin-123-456-mysql-primary-private.servers.linodedb.net" }, "id": 123, "label": "example-db", diff --git a/test/fixtures/databases_postgresql_instances.json b/test/fixtures/databases_postgresql_instances.json index 47573aa12..7e22cbbc1 100644 --- a/test/fixtures/databases_postgresql_instances.json +++ b/test/fixtures/databases_postgresql_instances.json @@ -11,7 +11,7 @@ "engine": "postgresql", "hosts": { "primary": "lin-0000-000-pgsql-primary.servers.linodedb.net", - "secondary": "lin-0000-000-pgsql-primary-private.servers.linodedb.net" + "standby": "lin-0000-000-pgsql-primary-private.servers.linodedb.net" }, "id": 123, "label": "example-db", diff --git a/test/unit/groups/database_test.py b/test/unit/groups/database_test.py index 5e2964c8d..8038e8c6b 100644 --- a/test/unit/groups/database_test.py +++ b/test/unit/groups/database_test.py @@ -54,7 +54,7 @@ def test_get_databases(self): "lin-123-456-mysql-mysql-primary.servers.linodedb.net", ) self.assertEqual( - dbs[0].hosts.secondary, + dbs[0].hosts.standby, "lin-123-456-mysql-primary-private.servers.linodedb.net", ) self.assertEqual(dbs[0].id, 123) @@ -1280,7 +1280,7 @@ def test_get_mysql_instances(self): "lin-123-456-mysql-mysql-primary.servers.linodedb.net", ) self.assertEqual( - dbs[0].hosts.secondary, + dbs[0].hosts.standby, "lin-123-456-mysql-primary-private.servers.linodedb.net", ) self.assertEqual(dbs[0].id, 123) @@ -1361,7 +1361,7 @@ def test_get_postgresql_instances(self): "lin-0000-000-pgsql-primary.servers.linodedb.net", ) self.assertEqual( - dbs[0].hosts.secondary, + dbs[0].hosts.standby, "lin-0000-000-pgsql-primary-private.servers.linodedb.net", ) self.assertEqual(dbs[0].id, 123) diff --git a/test/unit/objects/database_test.py b/test/unit/objects/database_test.py index 535b2a336..10cb8fc78 100644 --- a/test/unit/objects/database_test.py +++ b/test/unit/objects/database_test.py @@ -143,7 +143,7 @@ def test_create_backup(self): # We don't care about errors here; we just want to # validate the request. try: - db.backup_create("mybackup", target="secondary") + db.backup_create("mybackup", target="standby") except Exception as e: logger.warning( "An error occurred while validating the request: %s", e @@ -154,7 +154,7 @@ def test_create_backup(self): m.call_url, "/databases/mysql/instances/123/backups" ) self.assertEqual(m.call_data["label"], "mybackup") - self.assertEqual(m.call_data["target"], "secondary") + self.assertEqual(m.call_data["target"], "standby") def test_backup_restore(self): """ @@ -410,7 +410,7 @@ def test_create_backup(self): # We don't care about errors here; we just want to # validate the request. try: - db.backup_create("mybackup", target="secondary") + db.backup_create("mybackup", target="standby") except Exception as e: logger.warning( "An error occurred while validating the request: %s", e @@ -421,7 +421,7 @@ def test_create_backup(self): m.call_url, "/databases/postgresql/instances/123/backups" ) self.assertEqual(m.call_data["label"], "mybackup") - self.assertEqual(m.call_data["target"], "secondary") + self.assertEqual(m.call_data["target"], "standby") def test_backup_restore(self): """ From 331eb70d24f46b24f1e45160747782b717bc5e5d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 16 Dec 2025 12:07:09 -0500 Subject: [PATCH 04/16] build(deps): bump actions/upload-artifact from 5 to 6 (#628) Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 5 to 6. - [Release notes](https://github.com/actions/upload-artifact/releases) - [Commits](https://github.com/actions/upload-artifact/compare/v5...v6) --- updated-dependencies: - dependency-name: actions/upload-artifact dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/e2e-test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/e2e-test.yml b/.github/workflows/e2e-test.yml index 2e62e6bd5..93fa491bb 100644 --- a/.github/workflows/e2e-test.yml +++ b/.github/workflows/e2e-test.yml @@ -97,7 +97,7 @@ jobs: - name: Upload Test Report as Artifact if: always() - uses: actions/upload-artifact@v5 + uses: actions/upload-artifact@v6 with: name: test-report-file if-no-files-found: ignore From 7e65e04fafca9a2e4d844a30179698b3e0bb7a1c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 16 Dec 2025 12:07:23 -0500 Subject: [PATCH 05/16] build(deps): bump actions/download-artifact from 5 to 7 (#627) Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 5 to 7. - [Release notes](https://github.com/actions/download-artifact/releases) - [Commits](https://github.com/actions/download-artifact/compare/v5...v7) --- updated-dependencies: - dependency-name: actions/download-artifact dependency-version: '7' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/e2e-test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/e2e-test.yml b/.github/workflows/e2e-test.yml index 93fa491bb..e2762ff95 100644 --- a/.github/workflows/e2e-test.yml +++ b/.github/workflows/e2e-test.yml @@ -184,7 +184,7 @@ jobs: submodules: 'recursive' - name: Download test report - uses: actions/download-artifact@v5 + uses: actions/download-artifact@v7 with: name: test-report-file From 6ed9f7da65a7360ecb33686e44d3c2a95d7deb61 Mon Sep 17 00:00:00 2001 From: Zhiwei Liang <121905282+zliang-akamai@users.noreply.github.com> Date: Mon, 12 Jan 2026 10:03:33 -0500 Subject: [PATCH 06/16] Add support for resource lock (#624) * Add support for resource lock * Add `__all__` for lock types * Lock group and tests * Fix tests * Cleanup * Cleanup * Cleanup and fix * Bring union back * Update doc * Fix test * Remove default lock type to match API schema; fix tests * make format * Remove unused var in test --- docs/linode_api4/linode_client.rst | 9 ++ linode_api4/groups/__init__.py | 1 + linode_api4/groups/lock.py | 72 +++++++++++ linode_api4/linode_client.py | 4 + linode_api4/objects/__init__.py | 1 + linode_api4/objects/linode.py | 1 + linode_api4/objects/lock.py | 47 +++++++ test/fixtures/locks.json | 27 ++++ test/fixtures/locks_1.json | 10 ++ test/integration/models/lock/__init__.py | 1 + test/integration/models/lock/test_lock.py | 151 ++++++++++++++++++++++ test/unit/groups/lock_test.py | 66 ++++++++++ test/unit/objects/lock_test.py | 34 +++++ 13 files changed, 424 insertions(+) create mode 100644 linode_api4/groups/lock.py create mode 100644 linode_api4/objects/lock.py create mode 100644 test/fixtures/locks.json create mode 100644 test/fixtures/locks_1.json create mode 100644 test/integration/models/lock/__init__.py create mode 100644 test/integration/models/lock/test_lock.py create mode 100644 test/unit/groups/lock_test.py create mode 100644 test/unit/objects/lock_test.py diff --git a/docs/linode_api4/linode_client.rst b/docs/linode_api4/linode_client.rst index 9e8d135c6..8a602f1c8 100644 --- a/docs/linode_api4/linode_client.rst +++ b/docs/linode_api4/linode_client.rst @@ -125,6 +125,15 @@ Includes methods for interacting with our Longview service. :members: :special-members: +LockGroup +^^^^^^^^^^^^^ + +Includes methods for interacting with our Lock service. + +.. autoclass:: linode_api4.linode_client.LockGroup + :members: + :special-members: + NetworkingGroup ^^^^^^^^^^^^^^^ diff --git a/linode_api4/groups/__init__.py b/linode_api4/groups/__init__.py index 6f87eeb65..3c1bc9a7f 100644 --- a/linode_api4/groups/__init__.py +++ b/linode_api4/groups/__init__.py @@ -9,6 +9,7 @@ from .linode import * from .lke import * from .lke_tier import * +from .lock import * from .longview import * from .maintenance import * from .monitor import * diff --git a/linode_api4/groups/lock.py b/linode_api4/groups/lock.py new file mode 100644 index 000000000..42cc58d80 --- /dev/null +++ b/linode_api4/groups/lock.py @@ -0,0 +1,72 @@ +from typing import Union + +from linode_api4.errors import UnexpectedResponseError +from linode_api4.groups import Group +from linode_api4.objects import Lock, LockType + +__all__ = ["LockGroup"] + + +class LockGroup(Group): + """ + Encapsulates methods for interacting with Resource Locks. + + Resource locks prevent deletion or modification of resources. + Currently, only Linode instances can be locked. + """ + + def __call__(self, *filters): + """ + Returns a list of all Resource Locks on the account. + + This is intended to be called off of the :any:`LinodeClient` + class, like this:: + + locks = client.locks() + + API Documentation: TBD + + :param filters: Any number of filters to apply to this query. + See :doc:`Filtering Collections` + for more details on filtering. + + :returns: A list of Resource Locks on the account. + :rtype: PaginatedList of Lock + """ + return self.client._get_and_filter(Lock, *filters) + + def create( + self, + entity_type: str, + entity_id: Union[int, str], + lock_type: Union[LockType, str], + ) -> Lock: + """ + Creates a new Resource Lock for the specified entity. + + API Documentation: TBD + + :param entity_type: The type of entity to lock (e.g., "linode"). + :type entity_type: str + :param entity_id: The ID of the entity to lock. + :type entity_id: int | str + :param lock_type: The type of lock to create. Defaults to "cannot_delete". + :type lock_type: LockType | str + + :returns: The newly created Resource Lock. + :rtype: Lock + """ + params = { + "entity_type": entity_type, + "entity_id": entity_id, + "lock_type": lock_type, + } + + result = self.client.post("/locks", data=params) + + if "id" not in result: + raise UnexpectedResponseError( + "Unexpected response when creating lock!", json=result + ) + + return Lock(self.client, result["id"], result) diff --git a/linode_api4/linode_client.py b/linode_api4/linode_client.py index 1d9f0bba4..73a33e6a4 100644 --- a/linode_api4/linode_client.py +++ b/linode_api4/linode_client.py @@ -18,6 +18,7 @@ ImageGroup, LinodeGroup, LKEGroup, + LockGroup, LongviewGroup, MaintenanceGroup, MetricsGroup, @@ -454,6 +455,9 @@ def __init__( self.monitor = MonitorGroup(self) + #: Access methods related to Resource Locks - See :any:`LockGroup` for more information. + self.locks = LockGroup(self) + super().__init__( token=token, base_url=base_url, diff --git a/linode_api4/objects/__init__.py b/linode_api4/objects/__init__.py index 9f120310c..98d1c7a7d 100644 --- a/linode_api4/objects/__init__.py +++ b/linode_api4/objects/__init__.py @@ -24,3 +24,4 @@ from .placement import * from .monitor import * from .monitor_api import * +from .lock import * diff --git a/linode_api4/objects/linode.py b/linode_api4/objects/linode.py index df2694f66..fae0926d5 100644 --- a/linode_api4/objects/linode.py +++ b/linode_api4/objects/linode.py @@ -803,6 +803,7 @@ class Instance(Base): "maintenance_policy": Property( mutable=True ), # Note: This field is only available when using v4beta. + "locks": Property(unordered=True), } @property diff --git a/linode_api4/objects/lock.py b/linode_api4/objects/lock.py new file mode 100644 index 000000000..b6552da7b --- /dev/null +++ b/linode_api4/objects/lock.py @@ -0,0 +1,47 @@ +from dataclasses import dataclass + +from linode_api4.objects.base import Base, Property +from linode_api4.objects.serializable import JSONObject, StrEnum + +__all__ = ["LockType", "LockEntity", "Lock"] + + +class LockType(StrEnum): + """ + LockType defines valid values for resource lock types. + + API Documentation: https://techdocs.akamai.com/linode-api/reference/post-lock + """ + + cannot_delete = "cannot_delete" + cannot_delete_with_subresources = "cannot_delete_with_subresources" + + +@dataclass +class LockEntity(JSONObject): + """ + Represents the entity that is locked. + + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-lock + """ + + id: int = 0 + type: str = "" + label: str = "" + url: str = "" + + +class Lock(Base): + """ + A resource lock that prevents deletion or modification of a resource. + + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-lock + """ + + api_endpoint = "/locks/{id}" + + properties = { + "id": Property(identifier=True), + "lock_type": Property(), + "entity": Property(json_object=LockEntity), + } diff --git a/test/fixtures/locks.json b/test/fixtures/locks.json new file mode 100644 index 000000000..b84056b6b --- /dev/null +++ b/test/fixtures/locks.json @@ -0,0 +1,27 @@ +{ + "data": [ + { + "id": 1, + "lock_type": "cannot_delete", + "entity": { + "id": 123, + "type": "linode", + "label": "test-linode", + "url": "/v4/linode/instances/123" + } + }, + { + "id": 2, + "lock_type": "cannot_delete_with_subresources", + "entity": { + "id": 456, + "type": "linode", + "label": "another-linode", + "url": "/v4/linode/instances/456" + } + } + ], + "page": 1, + "pages": 1, + "results": 2 +} diff --git a/test/fixtures/locks_1.json b/test/fixtures/locks_1.json new file mode 100644 index 000000000..ed7a802bf --- /dev/null +++ b/test/fixtures/locks_1.json @@ -0,0 +1,10 @@ +{ + "id": 1, + "lock_type": "cannot_delete", + "entity": { + "id": 123, + "type": "linode", + "label": "test-linode", + "url": "/v4/linode/instances/123" + } +} diff --git a/test/integration/models/lock/__init__.py b/test/integration/models/lock/__init__.py new file mode 100644 index 000000000..1e07a34ee --- /dev/null +++ b/test/integration/models/lock/__init__.py @@ -0,0 +1 @@ +# This file is intentionally left empty to make the directory a Python package. diff --git a/test/integration/models/lock/test_lock.py b/test/integration/models/lock/test_lock.py new file mode 100644 index 000000000..f2139a176 --- /dev/null +++ b/test/integration/models/lock/test_lock.py @@ -0,0 +1,151 @@ +from test.integration.conftest import get_region +from test.integration.helpers import ( + get_test_label, + send_request_when_resource_available, +) + +import pytest + +from linode_api4.objects import Lock, LockType + + +@pytest.fixture(scope="function") +def linode_for_lock(test_linode_client, e2e_test_firewall): + """ + Create a Linode instance for testing locks. + """ + client = test_linode_client + region = get_region(client, {"Linodes", "Cloud Firewall"}, site_type="core") + label = get_test_label(length=8) + + linode_instance, _ = client.linode.instance_create( + "g6-nanode-1", + region, + image="linode/debian12", + label=label, + firewall=e2e_test_firewall, + ) + + yield linode_instance + + # Clean up any locks on the Linode before deleting it + locks = client.locks() + for lock in locks: + if ( + lock.entity.id == linode_instance.id + and lock.entity.type == "linode" + ): + lock.delete() + + send_request_when_resource_available( + timeout=100, func=linode_instance.delete + ) + + +@pytest.fixture(scope="function") +def test_lock(test_linode_client, linode_for_lock): + """ + Create a lock for testing. + """ + lock = test_linode_client.locks.create( + entity_type="linode", + entity_id=linode_for_lock.id, + lock_type=LockType.cannot_delete, + ) + + yield lock + + # Clean up lock if it still exists + try: + lock.delete() + except Exception: + pass # Lock may have been deleted by the test + + +@pytest.mark.smoke +def test_get_lock(test_linode_client, test_lock): + """ + Test that a lock can be retrieved by ID. + """ + lock = test_linode_client.load(Lock, test_lock.id) + + assert lock.id == test_lock.id + assert lock.lock_type == "cannot_delete" + assert lock.entity is not None + assert lock.entity.type == "linode" + + +def test_list_locks(test_linode_client, test_lock): + """ + Test that locks can be listed. + """ + locks = test_linode_client.locks() + + assert len(locks) > 0 + + # Verify our test lock is in the list + lock_ids = [lock.id for lock in locks] + assert test_lock.id in lock_ids + + +def test_create_lock_cannot_delete(test_linode_client, linode_for_lock): + """ + Test creating a cannot_delete lock. + """ + lock = test_linode_client.locks.create( + entity_type="linode", + entity_id=linode_for_lock.id, + lock_type=LockType.cannot_delete, + ) + + assert lock.id is not None + assert lock.lock_type == "cannot_delete" + assert lock.entity.id == linode_for_lock.id + assert lock.entity.type == "linode" + assert lock.entity.label == linode_for_lock.label + + # Clean up + lock.delete() + + +def test_create_lock_cannot_delete_with_subresources( + test_linode_client, linode_for_lock +): + """ + Test creating a cannot_delete_with_subresources lock. + """ + lock = test_linode_client.locks.create( + entity_type="linode", + entity_id=linode_for_lock.id, + lock_type=LockType.cannot_delete_with_subresources, + ) + + assert lock.id is not None + assert lock.lock_type == "cannot_delete_with_subresources" + assert lock.entity.id == linode_for_lock.id + assert lock.entity.type == "linode" + + # Clean up + lock.delete() + + +def test_delete_lock(test_linode_client, linode_for_lock): + """ + Test that a lock can be deleted using the Lock object's delete method. + """ + # Create a lock + lock = test_linode_client.locks.create( + entity_type="linode", + entity_id=linode_for_lock.id, + lock_type=LockType.cannot_delete, + ) + + lock_id = lock.id + + # Delete the lock using the object method + lock.delete() + + # Verify the lock no longer exists + locks = test_linode_client.locks() + lock_ids = [lk.id for lk in locks] + assert lock_id not in lock_ids diff --git a/test/unit/groups/lock_test.py b/test/unit/groups/lock_test.py new file mode 100644 index 000000000..a1e3af26a --- /dev/null +++ b/test/unit/groups/lock_test.py @@ -0,0 +1,66 @@ +from test.unit.base import ClientBaseCase + +from linode_api4.objects import LockType + + +class LockGroupTest(ClientBaseCase): + """ + Tests methods of the LockGroup class + """ + + def test_list_locks(self): + """ + Tests that locks can be retrieved using client.locks() + """ + locks = self.client.locks() + + self.assertEqual(len(locks), 2) + self.assertEqual(locks[0].id, 1) + self.assertEqual(locks[0].lock_type, LockType.cannot_delete) + self.assertEqual(locks[0].entity.id, 123) + self.assertEqual(locks[0].entity.type, "linode") + self.assertEqual(locks[1].id, 2) + self.assertEqual( + locks[1].lock_type, LockType.cannot_delete_with_subresources + ) + self.assertEqual(locks[1].entity.id, 456) + + def test_create_lock(self): + """ + Tests that a lock can be created using client.locks.create() + """ + with self.mock_post("/locks/1") as m: + lock = self.client.locks.create( + entity_type="linode", + entity_id=123, + lock_type=LockType.cannot_delete, + ) + + self.assertEqual(m.call_url, "/locks") + self.assertEqual(m.call_data["entity_type"], "linode") + self.assertEqual(m.call_data["entity_id"], 123) + self.assertEqual(m.call_data["lock_type"], LockType.cannot_delete) + + self.assertEqual(lock.id, 1) + self.assertEqual(lock.lock_type, LockType.cannot_delete) + self.assertIsNotNone(lock.entity) + self.assertEqual(lock.entity.id, 123) + + def test_create_lock_with_subresources(self): + """ + Tests that a lock with subresources can be created + """ + with self.mock_post("/locks/1") as m: + self.client.locks.create( + entity_type="linode", + entity_id=456, + lock_type=LockType.cannot_delete_with_subresources, + ) + + self.assertEqual(m.call_url, "/locks") + self.assertEqual(m.call_data["entity_type"], "linode") + self.assertEqual(m.call_data["entity_id"], 456) + self.assertEqual( + m.call_data["lock_type"], + LockType.cannot_delete_with_subresources, + ) diff --git a/test/unit/objects/lock_test.py b/test/unit/objects/lock_test.py new file mode 100644 index 000000000..ce630d0b6 --- /dev/null +++ b/test/unit/objects/lock_test.py @@ -0,0 +1,34 @@ +from test.unit.base import ClientBaseCase + +from linode_api4.objects.lock import Lock, LockEntity + + +class LockTest(ClientBaseCase): + """ + Tests methods of the Lock class + """ + + def test_get_lock(self): + """ + Tests that a lock is loaded correctly by ID + """ + lock = Lock(self.client, 1) + + self.assertEqual(lock.id, 1) + self.assertEqual(lock.lock_type, "cannot_delete") + self.assertIsInstance(lock.entity, LockEntity) + self.assertEqual(lock.entity.id, 123) + self.assertEqual(lock.entity.type, "linode") + self.assertEqual(lock.entity.label, "test-linode") + self.assertEqual(lock.entity.url, "/v4/linode/instances/123") + + def test_delete_lock(self): + """ + Tests that a lock can be deleted using the Lock object's delete method + """ + lock = Lock(self.client, 1) + + with self.mock_delete() as m: + lock.delete() + + self.assertEqual(m.call_url, "/locks/1") From 43d8ec323e3281cf7b287ef9a896a4c6726a0eb7 Mon Sep 17 00:00:00 2001 From: Zhiwei Liang <121905282+zliang-akamai@users.noreply.github.com> Date: Thu, 15 Jan 2026 13:44:35 -0500 Subject: [PATCH 07/16] Filter regions based on account availabilities in get_regions function (#625) * Filter regions based on account availabilities in get_regions function * Bypass account's region availabilities check when the token has no account access * Make ALL_ACCOUNT_AVAILABILITIES a global constant * Optimization --- test/integration/conftest.py | 44 +++++++++++++++++++++++++++++++++++- 1 file changed, 43 insertions(+), 1 deletion(-) diff --git a/test/integration/conftest.py b/test/integration/conftest.py index caac7ca01..a5c832f4f 100644 --- a/test/integration/conftest.py +++ b/test/integration/conftest.py @@ -1,4 +1,5 @@ import ipaddress +import logging import os import random import time @@ -26,6 +27,7 @@ PlacementGroupType, PostgreSQLDatabase, ) +from linode_api4.errors import ApiError from linode_api4.linode_client import LinodeClient, MonitorClient from linode_api4.objects import Region @@ -36,6 +38,15 @@ RUN_LONG_TESTS = "RUN_LONG_TESTS" SKIP_E2E_FIREWALL = "SKIP_E2E_FIREWALL" +ALL_ACCOUNT_AVAILABILITIES = { + "Linodes", + "NodeBalancers", + "Block Storage", + "Kubernetes", +} + +logger = logging.getLogger(__name__) + def get_token(): return os.environ.get(ENV_TOKEN_NAME, None) @@ -58,9 +69,40 @@ def get_regions( regions = client.regions() + account_regional_availabilities = {} + try: + account_availabilities = client.account.availabilities() + for availability in account_availabilities: + account_regional_availabilities[availability.region] = ( + availability.available + ) + except ApiError: + logger.warning( + "Failed to retrieve account availabilities for regions. " + "Assuming required capabilities are available in all regions for this account. " + "Tests may fail if the account lacks access to necessary capabilities in the selected region." + ) + if capabilities is not None: + required_capabilities = set(capabilities) + required_account_capabilities = required_capabilities.intersection( + ALL_ACCOUNT_AVAILABILITIES + ) + regions = [ - v for v in regions if set(capabilities).issubset(v.capabilities) + v + for v in regions + if required_capabilities.issubset(v.capabilities) + and required_account_capabilities.issubset( + account_regional_availabilities.get( + v.id, + ( + [] + if account_regional_availabilities + else ALL_ACCOUNT_AVAILABILITIES + ), + ) + ) ] if site_type is not None: From f08c0cd6b4f403e1af02a7ea232bee228cb3fe16 Mon Sep 17 00:00:00 2001 From: Erik Zilber Date: Fri, 16 Jan 2026 10:33:27 -0500 Subject: [PATCH 08/16] Project: Private Image Sharing (#633) * Added support for Private Image Sharing features and unit tests * Addressed PR comments * Integration tests for private image sharing (#632) * Create integration tests for share groups - part 1 * Create test test_try_to_add_member_invalid_token * Update integration tests for private image sharing feature * Apply code review sugestions --------- Co-authored-by: Erik Zilber --------- Co-authored-by: Pawel <100145168+psnoch-akamai@users.noreply.github.com> --- linode_api4/groups/__init__.py | 1 + linode_api4/groups/image_share_group.py | 142 ++++++++ linode_api4/groups/linode.py | 4 +- linode_api4/linode_client.py | 4 + linode_api4/objects/__init__.py | 1 + linode_api4/objects/image.py | 34 ++ linode_api4/objects/image_share_group.py | 344 ++++++++++++++++++ test/fixtures/images.json | 22 +- .../images_private_1234_sharegroups.json | 19 + test/fixtures/images_sharegroups.json | 31 ++ test/fixtures/images_sharegroups_1234.json | 12 + .../images_sharegroups_1234_images.json | 45 +++ ...ages_sharegroups_1234_images_shared_1.json | 41 +++ .../images_sharegroups_1234_members.json | 15 + ...mages_sharegroups_1234_members_abc123.json | 8 + test/fixtures/images_sharegroups_tokens.json | 18 + .../images_sharegroups_tokens_abc123.json | 12 + ..._sharegroups_tokens_abc123_sharegroup.json | 9 + ...roups_tokens_abc123_sharegroup_images.json | 45 +++ .../linode_client/test_linode_client.py | 6 +- .../models/sharegroups/test_sharegroups.py | 251 +++++++++++++ test/unit/groups/image_share_group_test.py | 153 ++++++++ test/unit/groups/linode_test.py | 5 +- test/unit/objects/image_share_group_test.py | 295 +++++++++++++++ test/unit/objects/image_test.py | 2 + 25 files changed, 1503 insertions(+), 16 deletions(-) create mode 100644 linode_api4/groups/image_share_group.py create mode 100644 linode_api4/objects/image_share_group.py create mode 100644 test/fixtures/images_private_1234_sharegroups.json create mode 100644 test/fixtures/images_sharegroups.json create mode 100644 test/fixtures/images_sharegroups_1234.json create mode 100644 test/fixtures/images_sharegroups_1234_images.json create mode 100644 test/fixtures/images_sharegroups_1234_images_shared_1.json create mode 100644 test/fixtures/images_sharegroups_1234_members.json create mode 100644 test/fixtures/images_sharegroups_1234_members_abc123.json create mode 100644 test/fixtures/images_sharegroups_tokens.json create mode 100644 test/fixtures/images_sharegroups_tokens_abc123.json create mode 100644 test/fixtures/images_sharegroups_tokens_abc123_sharegroup.json create mode 100644 test/fixtures/images_sharegroups_tokens_abc123_sharegroup_images.json create mode 100644 test/integration/models/sharegroups/test_sharegroups.py create mode 100644 test/unit/groups/image_share_group_test.py create mode 100644 test/unit/objects/image_share_group_test.py diff --git a/linode_api4/groups/__init__.py b/linode_api4/groups/__init__.py index 3c1bc9a7f..c835972bc 100644 --- a/linode_api4/groups/__init__.py +++ b/linode_api4/groups/__init__.py @@ -6,6 +6,7 @@ from .database import * from .domain import * from .image import * +from .image_share_group import * from .linode import * from .lke import * from .lke_tier import * diff --git a/linode_api4/groups/image_share_group.py b/linode_api4/groups/image_share_group.py new file mode 100644 index 000000000..e932f400b --- /dev/null +++ b/linode_api4/groups/image_share_group.py @@ -0,0 +1,142 @@ +from typing import Optional + +from linode_api4.groups import Group +from linode_api4.objects import ( + ImageShareGroup, + ImageShareGroupImagesToAdd, + ImageShareGroupToken, +) +from linode_api4.objects.base import _flatten_request_body_recursive +from linode_api4.util import drop_null_keys + + +class ImageShareGroupAPIGroup(Group): + """ + Collections related to Private Image Sharing. + + NOTE: Private Image Sharing features are in beta and may not be generally available. + """ + + def __call__(self, *filters): + """ + Retrieves a list of Image Share Groups created by the user (producer). + You can filter this query to retrieve only Image Share Groups + relevant to a specific query, for example:: + + filtered_share_groups = client.sharegroups( + ImageShareGroup.label == "my-label") + + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-sharegroups + + :param filters: Any number of filters to apply to this query. + See :doc:`Filtering Collections` + for more details on filtering. + + :returns: A list of Image Share Groups. + :rtype: PaginatedList of ImageShareGroup + """ + return self.client._get_and_filter(ImageShareGroup, *filters) + + def sharegroups_by_image_id(self, image_id: str): + """ + Retrieves a list of Image Share Groups that share a specific Private Image. + + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-images-sharegroups-image + + :param image_id: The ID of the Image to query for. + :type image_id: str + + :returns: A list of Image Share Groups sharing the specified Image. + :rtype: PaginatedList of ImageShareGroup + """ + return self.client._get_and_filter( + ImageShareGroup, endpoint="/images/{}/sharegroups".format(image_id) + ) + + def tokens(self, *filters): + """ + Retrieves a list of Image Share Group Tokens created by the user (consumer). + You can filter this query to retrieve only Image Share Group Tokens + relevant to a specific query, for example:: + + filtered_share_group_tokens = client.sharegroups.tokens( + ImageShareGroupToken.label == "my-label") + + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-user-tokens + + :param filters: Any number of filters to apply to this query. + See :doc:`Filtering Collections` + for more details on filtering. + + :returns: A list of Image Share Group Tokens. + :rtype: PaginatedList of ImageShareGroupToken + """ + return self.client._get_and_filter(ImageShareGroupToken, *filters) + + def create_sharegroup( + self, + label: Optional[str] = None, + description: Optional[str] = None, + images: Optional[ImageShareGroupImagesToAdd] = None, + ): + """ + Creates a new Image Share Group. + + API Documentation: https://techdocs.akamai.com/linode-api/reference/post-sharegroups + + :param label: The label for the resulting Image Share Group. + :type label: str + :param description: The description for the new Image Share Group. + :type description: str + :param images: A list of Images to share in the new Image Share Group, formatted in JSON. + :type images: Optional[ImageShareGroupImagesToAdd] + + :returns: The new Image Share Group. + :rtype: ImageShareGroup + """ + params = { + "label": label, + "description": description, + } + + if images: + params["images"] = images + + result = self.client.post( + "/images/sharegroups", + data=_flatten_request_body_recursive(drop_null_keys(params)), + ) + + return ImageShareGroup(self.client, result["id"], result) + + def create_token( + self, valid_for_sharegroup_uuid: str, label: Optional[str] = None + ): + """ + Creates a new Image Share Group Token and returns the token value. + + API Documentation: https://techdocs.akamai.com/linode-api/reference/post-sharegroup-tokens + + :param valid_for_sharegroup_uuid: The UUID of the Image Share Group that this token will be valid for. + :type valid_for_sharegroup_uuid: Optional[str] + :param label: The label for the resulting Image Share Group Token. + :type label: str + + :returns: The new Image Share Group Token object and the one-time use token itself. + :rtype: (ImageShareGroupToken, str) + """ + params = {"valid_for_sharegroup_uuid": valid_for_sharegroup_uuid} + + if label: + params["label"] = label + + result = self.client.post( + "/images/sharegroups/tokens", + data=_flatten_request_body_recursive(drop_null_keys(params)), + ) + + token_value = result.pop("token", None) + token_obj = ImageShareGroupToken( + self.client, result["token_uuid"], result + ) + return token_obj, token_value diff --git a/linode_api4/groups/linode.py b/linode_api4/groups/linode.py index e12e9cf48..f88808e64 100644 --- a/linode_api4/groups/linode.py +++ b/linode_api4/groups/linode.py @@ -23,9 +23,7 @@ NetworkInterface, _expand_placement_group_assignment, ) -from linode_api4.objects.linode_interfaces import ( - LinodeInterfaceOptions, -) +from linode_api4.objects.linode_interfaces import LinodeInterfaceOptions from linode_api4.util import drop_null_keys diff --git a/linode_api4/linode_client.py b/linode_api4/linode_client.py index 73a33e6a4..0e89142b3 100644 --- a/linode_api4/linode_client.py +++ b/linode_api4/linode_client.py @@ -16,6 +16,7 @@ DatabaseGroup, DomainGroup, ImageGroup, + ImageShareGroupAPIGroup, LinodeGroup, LKEGroup, LockGroup, @@ -441,6 +442,9 @@ def __init__( #: Access methods related to Images - See :any:`ImageGroup` for more information. self.images = ImageGroup(self) + #: Access methods related to Image Share Groups - See :any:`ImageShareGroupAPIGroup` for more information. + self.sharegroups = ImageShareGroupAPIGroup(self) + #: Access methods related to VPCs - See :any:`VPCGroup` for more information. self.vpcs = VPCGroup(self) diff --git a/linode_api4/objects/__init__.py b/linode_api4/objects/__init__.py index 98d1c7a7d..009e9436e 100644 --- a/linode_api4/objects/__init__.py +++ b/linode_api4/objects/__init__.py @@ -24,4 +24,5 @@ from .placement import * from .monitor import * from .monitor_api import * +from .image_share_group import * from .lock import * diff --git a/linode_api4/objects/image.py b/linode_api4/objects/image.py index 1215c422c..50dc23f74 100644 --- a/linode_api4/objects/image.py +++ b/linode_api4/objects/image.py @@ -30,6 +30,38 @@ class ImageRegion(JSONObject): status: Optional[ReplicationStatus] = None +@dataclass +class ImageSharingSharedWith(JSONObject): + """ + Data representing who an Image has been shared with. + """ + + sharegroup_count: Optional[int] = None + sharegroup_list_url: Optional[str] = None + + +@dataclass +class ImageSharingSharedBy(JSONObject): + """ + Data representing who shared an Image. + """ + + sharegroup_id: Optional[int] = None + sharegroup_uuid: Optional[str] = None + sharegroup_label: Optional[str] = None + source_image_id: Optional[str] = None + + +@dataclass +class ImageSharing(JSONObject): + """ + The Image Sharing status of an Image. + """ + + shared_with: Optional[ImageSharingSharedWith] = None + shared_by: Optional[ImageSharingSharedBy] = None + + class Image(Base): """ An Image is something a Linode Instance or Disk can be deployed from. @@ -51,6 +83,7 @@ class Image(Base): "updated": Property(is_datetime=True), "type": Property(), "is_public": Property(), + "is_shared": Property(), "vendor": Property(), "size": Property(), "deprecated": Property(), @@ -60,6 +93,7 @@ class Image(Base): "tags": Property(mutable=True, unordered=True), "total_size": Property(), "regions": Property(json_object=ImageRegion, unordered=True), + "image_sharing": Property(json_object=ImageSharing), } def replicate(self, regions: Union[List[str], List[Region]]): diff --git a/linode_api4/objects/image_share_group.py b/linode_api4/objects/image_share_group.py new file mode 100644 index 000000000..6c75fc7f9 --- /dev/null +++ b/linode_api4/objects/image_share_group.py @@ -0,0 +1,344 @@ +__all__ = [ + "ImageShareGroupImageToAdd", + "ImageShareGroupImagesToAdd", + "ImageShareGroupImageToUpdate", + "ImageShareGroupMemberToAdd", + "ImageShareGroupMemberToUpdate", + "ImageShareGroup", + "ImageShareGroupToken", +] +from dataclasses import dataclass +from typing import List, Optional + +from linode_api4.objects import Base, MappedObject, Property +from linode_api4.objects.serializable import JSONObject + + +@dataclass +class ImageShareGroupImageToAdd(JSONObject): + """ + Data representing an Image to add to an Image Share Group. + """ + + id: str + label: Optional[str] = None + description: Optional[str] = None + + def to_dict(self): + d = {"id": self.id} + if self.label is not None: + d["label"] = self.label + if self.description is not None: + d["description"] = self.description + return d + + +@dataclass +class ImageShareGroupImagesToAdd(JSONObject): + """ + Data representing a list of Images to add to an Image Share Group. + """ + + images: List[ImageShareGroupImageToAdd] + + +@dataclass +class ImageShareGroupImageToUpdate(JSONObject): + """ + Data to update an Image shared in an Image Share Group. + """ + + image_share_id: str + label: Optional[str] = None + description: Optional[str] = None + + def to_dict(self): + d = {"image_share_id": self.image_share_id} + if self.label is not None: + d["label"] = self.label + if self.description is not None: + d["description"] = self.description + return d + + +@dataclass +class ImageShareGroupMemberToAdd(JSONObject): + """ + Data representing a Member to add to an Image Share Group. + """ + + token: str + label: str + + +@dataclass +class ImageShareGroupMemberToUpdate(JSONObject): + """ + Data to update a Member in an Image Share Group. + """ + + token_uuid: str + label: str + + +class ImageShareGroup(Base): + """ + An Image Share Group is a group to share private images with other users. This class is intended + to be used by a Producer of an Image Share Group, and not a Consumer. + + NOTE: Private Image Sharing features are in beta and may not be generally available. + + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-sharegroup + """ + + api_endpoint = "/images/sharegroups/{id}" + + properties = { + "id": Property(identifier=True), + "uuid": Property(), + "label": Property(mutable=True), + "description": Property(mutable=True), + "is_suspended": Property(), + "images_count": Property(), + "members_count": Property(), + "created": Property(is_datetime=True), + "updated": Property(is_datetime=True), + "expiry": Property(is_datetime=True), + } + + def add_images(self, images: ImageShareGroupImagesToAdd): + """ + Add private images to be shared in the Image Share Group. + + API Documentation: https://techdocs.akamai.com/linode-api/reference/post-sharegroup-images + + :param images: A list of Images to share in the Image Share Group, formatted in JSON. + :type images: ImageShareGroupImagesToAdd + + :returns: A list of the new Image shares. + :rtype: List of MappedObject + """ + params = {"images": [img.to_dict() for img in images.images]} + + result = self._client.post( + "{}/images".format(self.api_endpoint), model=self, data=params + ) + + # Sync this object to reflect the new images added to the share group. + self.invalidate() + + # Expect result to be a dict with a 'data' key + image_list = result.get("data", []) + return [MappedObject(**item) for item in image_list] + + def get_image_shares(self): + """ + Retrieves a list of images shared in the Image Share Group. + + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-sharegroup-images + + :returns: A list of the Image shares. + :rtype: List of MappedObject + """ + result = self._client.get( + "{}/images".format(self.api_endpoint), + model=self, + ) + image_list = result.get("data", []) + return [MappedObject(**item) for item in image_list] + + def update_image_share(self, image: ImageShareGroupImageToUpdate): + """ + Update the label and description of an Image shared in the Image Share Group. + Note that the ID provided in the image parameter must be the shared ID of an + Image already shared in the Image Share Group, not the private ID. + + API Documentation: https://techdocs.akamai.com/linode-api/reference/put-sharegroup-imageshare + + :param image: The Image to update, formatted in JSON. + :type image: ImageShareGroupImageToUpdate + + :returns: The updated Image share. + :rtype: MappedObject + """ + params = image.to_dict() + + result = self._client.put( + "{}/images/{}".format(self.api_endpoint, image.image_share_id), + model=self, + data=params, + ) + + return MappedObject(**result) + + def revoke_image_share(self, image_share_id: str): + """ + Revoke an Image shared in the Image Share Group. + + API Documentation: https://techdocs.akamai.com/linode-api/reference/delete-sharegroup-imageshare + + :param image_share_id: The ID of the Image share to revoke. + :type image_share_id: str + """ + self._client.delete( + "{}/images/{}".format(self.api_endpoint, image_share_id), model=self + ) + + # Sync this object to reflect the revoked image share. + self.invalidate() + + def add_member(self, member: ImageShareGroupMemberToAdd): + """ + Add a Member to the Image Share Group. + + API Documentation: https://techdocs.akamai.com/linode-api/reference/post-sharegroup-members + + :param member: The Member to add, formatted in JSON. + :type member: ImageShareGroupMemberToAdd + + :returns: The new Member. + :rtype: MappedObject + """ + params = { + "token": member.token, + "label": member.label, + } + + result = self._client.post( + "{}/members".format(self.api_endpoint), model=self, data=params + ) + + # Sync this object to reflect the new member added to the share group. + self.invalidate() + + return MappedObject(**result) + + def get_members(self): + """ + Retrieves a list of members in the Image Share Group. + + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-sharegroup-members + + :returns: List of members. + :rtype: List of MappedObject + """ + result = self._client.get( + "{}/members".format(self.api_endpoint), + model=self, + ) + member_list = result.get("data", []) + return [MappedObject(**item) for item in member_list] + + def get_member(self, token_uuid: str): + """ + Get a Member in the Image Share Group. + + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-sharegroup-member-token + + :param token_uuid: The UUID of the token corresponding to the Member to retrieve. + :type token_uuid: str + + :returns: The requested Member. + :rtype: MappedObject + """ + result = self._client.get( + "{}/members/{}".format(self.api_endpoint, token_uuid), model=self + ) + + return MappedObject(**result) + + def update_member(self, member: ImageShareGroupMemberToUpdate): + """ + Update the label of a Member in the Image Share Group. + + API Documentation: https://techdocs.akamai.com/linode-api/reference/put-sharegroup-member-token + + :param member: The Member to update, formatted in JSON. + :type member: ImageShareGroupMemberToUpdate + + :returns: The updated Member. + :rtype: MappedObject + """ + params = { + "label": member.label, + } + + result = self._client.put( + "{}/members/{}".format(self.api_endpoint, member.token_uuid), + model=self, + data=params, + ) + + return MappedObject(**result) + + def remove_member(self, token_uuid: str): + """ + Remove a Member from the Image Share Group. + + API Documentation: https://techdocs.akamai.com/linode-api/reference/delete-sharegroup-member-token + + :param token_uuid: The UUID of the token corresponding to the Member to remove. + :type token_uuid: str + """ + self._client.delete( + "{}/members/{}".format(self.api_endpoint, token_uuid), model=self + ) + + # Sync this object to reflect the removed member. + self.invalidate() + + +class ImageShareGroupToken(Base): + """ + An Image Share Group Token is a token that can be used to access the Images shared in an Image Share Group. + This class is intended to be used by a Consumer of an Image Share Group, and not a Producer. + + NOTE: Private Image Sharing features are in beta and may not be generally available. + + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-sharegroup-token + """ + + api_endpoint = "/images/sharegroups/tokens/{token_uuid}" + id_attribute = "token_uuid" + properties = { + "token_uuid": Property(identifier=True), + "status": Property(), + "label": Property(mutable=True), + "valid_for_sharegroup_uuid": Property(), + "created": Property(is_datetime=True), + "updated": Property(is_datetime=True), + "expiry": Property(is_datetime=True), + "sharegroup_uuid": Property(), + "sharegroup_label": Property(), + } + + def get_sharegroup(self): + """ + Gets details about the Image Share Group that this token provides access to. + + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-sharegroup-by-token + + :returns: The requested Image Share Group. + :rtype: MappedObject + """ + result = self._client.get( + "{}/sharegroup".format(self.api_endpoint), model=self + ) + + return MappedObject(**result) + + def get_images(self): + """ + Retrieves a paginated list of images shared in the Image Share Group that this token provides access to. + + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-sharegroup-images-by-token + + :returns: List of images. + :rtype: List of MappedObject + """ + result = self._client.get( + "{}/sharegroup/images".format(self.api_endpoint), + model=self, + ) + image_list = result.get("data", []) + return [MappedObject(**item) for item in image_list] diff --git a/test/fixtures/images.json b/test/fixtures/images.json index 357110bc7..37b31445f 100644 --- a/test/fixtures/images.json +++ b/test/fixtures/images.json @@ -26,7 +26,9 @@ "region": "us-east", "status": "available" } - ] + ], + "is_shared": false, + "image_sharing": null }, { "created": "2017-01-01T00:01:01", @@ -55,7 +57,9 @@ "region": "us-mia", "status": "pending" } - ] + ], + "is_shared": false, + "image_sharing": null }, { "created": "2017-01-01T00:01:01", @@ -72,7 +76,9 @@ "eol": "2026-07-01T04:00:00", "expiry": "2026-08-01T04:00:00", "updated": "2020-07-01T04:00:00", - "capabilities": [] + "capabilities": [], + "is_shared": false, + "image_sharing": null }, { "created": "2017-08-20T14:01:01", @@ -89,7 +95,15 @@ "eol": "2026-07-01T04:00:00", "expiry": "2026-08-01T04:00:00", "updated": "2020-07-01T04:00:00", - "capabilities": ["cloud-init"] + "capabilities": ["cloud-init"], + "is_shared": false, + "image_sharing": { + "shared_by": null, + "shared_with": { + "sharegroup_count": 0, + "sharegroup_list_url": "/images/private/123/sharegroups" + } + } } ] } \ No newline at end of file diff --git a/test/fixtures/images_private_1234_sharegroups.json b/test/fixtures/images_private_1234_sharegroups.json new file mode 100644 index 000000000..925b12627 --- /dev/null +++ b/test/fixtures/images_private_1234_sharegroups.json @@ -0,0 +1,19 @@ +{ + "data": [ + { + "created": "2025-04-14T22:44:02", + "description": "My group of images to share with my team.", + "expiry": null, + "id": 1, + "images_count": 1, + "is_suspended": false, + "label": "My Shared Images", + "members_count": 0, + "updated": null, + "uuid": "1533863e-16a4-47b5-b829-ac0f35c13278" + } + ], + "page": 1, + "pages": 1, + "results": 1 +} diff --git a/test/fixtures/images_sharegroups.json b/test/fixtures/images_sharegroups.json new file mode 100644 index 000000000..53b54c07a --- /dev/null +++ b/test/fixtures/images_sharegroups.json @@ -0,0 +1,31 @@ +{ + "data": [ + { + "created": "2025-04-14T22:44:02", + "description": "My group of images to share with my team.", + "expiry": null, + "id": 1, + "images_count": 0, + "is_suspended": false, + "label": "My Shared Images", + "members_count": 0, + "updated": null, + "uuid": "1533863e-16a4-47b5-b829-ac0f35c13278" + }, + { + "created": "2025-04-14T22:44:03", + "description": "My other group of images to share with my team.", + "expiry": null, + "id": 2, + "images_count": 1, + "is_suspended": false, + "label": "My other Shared Images", + "members_count": 3, + "updated": null, + "uuid": "30ee6599-eb0f-478c-9e55-4073c6c24a39" + } + ], + "page": 1, + "pages": 1, + "results": 2 +} diff --git a/test/fixtures/images_sharegroups_1234.json b/test/fixtures/images_sharegroups_1234.json new file mode 100644 index 000000000..9817ea3d9 --- /dev/null +++ b/test/fixtures/images_sharegroups_1234.json @@ -0,0 +1,12 @@ +{ + "created": "2025-04-14T22:44:02", + "description": "My group of images to share with my team.", + "expiry": null, + "id": 1234, + "images_count": 0, + "is_suspended": false, + "label": "My Shared Images", + "members_count": 0, + "updated": null, + "uuid": "1533863e-16a4-47b5-b829-ac0f35c13278" +} \ No newline at end of file diff --git a/test/fixtures/images_sharegroups_1234_images.json b/test/fixtures/images_sharegroups_1234_images.json new file mode 100644 index 000000000..f63e52392 --- /dev/null +++ b/test/fixtures/images_sharegroups_1234_images.json @@ -0,0 +1,45 @@ +{ + "data": [ + { + "capabilities": [ + "cloud-init", + "distributed-sites" + ], + "created": "2021-08-14T22:44:02", + "created_by": null, + "deprecated": false, + "description": "Example image description.", + "eol": "2026-07-01T04:00:00", + "expiry": null, + "id": "shared/1", + "is_public": true, + "is_shared": null, + "label": "Debian 11", + "regions": [ + { + "region": "us-iad", + "status": "available" + } + ], + "size": 2500, + "status": "available", + "tags": [ + "repair-image", + "fix-1" + ], + "total_size": 1234567, + "type": "manual", + "updated": "2021-08-14T22:44:02", + "vendor": null, + "image_sharing": { + "shared_with": null, + "shared_by": { + "sharegroup_id": 1234, + "sharegroup_uuid": "0ee8e1c1-b19b-4052-9487-e3b13faac111", + "sharegroup_label": "test-group-minecraft-1", + "source_image_id": null + } + } + } + ] +} \ No newline at end of file diff --git a/test/fixtures/images_sharegroups_1234_images_shared_1.json b/test/fixtures/images_sharegroups_1234_images_shared_1.json new file mode 100644 index 000000000..1b1179c93 --- /dev/null +++ b/test/fixtures/images_sharegroups_1234_images_shared_1.json @@ -0,0 +1,41 @@ +{ + "capabilities": [ + "cloud-init", + "distributed-sites" + ], + "created": "2021-08-14T22:44:02", + "created_by": null, + "deprecated": false, + "description": "Example image description.", + "eol": "2026-07-01T04:00:00", + "expiry": null, + "id": "shared/1", + "is_public": true, + "is_shared": null, + "label": "Debian 11", + "regions": [ + { + "region": "us-iad", + "status": "available" + } + ], + "size": 2500, + "status": "available", + "tags": [ + "repair-image", + "fix-1" + ], + "total_size": 1234567, + "type": "manual", + "updated": "2021-08-14T22:44:02", + "vendor": null, + "image_sharing": { + "shared_with": null, + "shared_by": { + "sharegroup_id": 1234, + "sharegroup_uuid": "0ee8e1c1-b19b-4052-9487-e3b13faac111", + "sharegroup_label": "test-group-minecraft-1", + "source_image_id": null + } + } +} diff --git a/test/fixtures/images_sharegroups_1234_members.json b/test/fixtures/images_sharegroups_1234_members.json new file mode 100644 index 000000000..424f8b23c --- /dev/null +++ b/test/fixtures/images_sharegroups_1234_members.json @@ -0,0 +1,15 @@ +{ + "data": [ + { + "created": "2025-08-04T10:07:59", + "expiry": null, + "label": "New Member", + "status": "active", + "token_uuid": "4591075e-4ba8-43c9-a521-928c3d4a135d", + "updated": null + } + ], + "page": 1, + "pages": 1, + "results": 1 +} \ No newline at end of file diff --git a/test/fixtures/images_sharegroups_1234_members_abc123.json b/test/fixtures/images_sharegroups_1234_members_abc123.json new file mode 100644 index 000000000..156458ccc --- /dev/null +++ b/test/fixtures/images_sharegroups_1234_members_abc123.json @@ -0,0 +1,8 @@ +{ + "created": "2025-08-04T10:07:59", + "expiry": null, + "label": "New Member", + "status": "active", + "token_uuid": "abc123", + "updated": null +} \ No newline at end of file diff --git a/test/fixtures/images_sharegroups_tokens.json b/test/fixtures/images_sharegroups_tokens.json new file mode 100644 index 000000000..916ae8ae6 --- /dev/null +++ b/test/fixtures/images_sharegroups_tokens.json @@ -0,0 +1,18 @@ +{ + "data": [ + { + "created": "2025-08-04T10:09:09", + "expiry": null, + "label": "My Sharegroup Token", + "sharegroup_label": "A Sharegroup", + "sharegroup_uuid": "e1d0e58b-f89f-4237-84ab-b82077342359", + "status": "active", + "token_uuid": "13428362-5458-4dad-b14b-8d0d4d648f8c", + "updated": null, + "valid_for_sharegroup_uuid": "e1d0e58b-f89f-4237-84ab-b82077342359" + } + ], + "page": 1, + "pages": 1, + "results": 1 +} diff --git a/test/fixtures/images_sharegroups_tokens_abc123.json b/test/fixtures/images_sharegroups_tokens_abc123.json new file mode 100644 index 000000000..d7d4d045d --- /dev/null +++ b/test/fixtures/images_sharegroups_tokens_abc123.json @@ -0,0 +1,12 @@ +{ + "created": "2025-08-04T10:09:09", + "expiry": null, + "label": "My Sharegroup Token", + "sharegroup_label": "A Sharegroup", + "sharegroup_uuid": "e1d0e58b-f89f-4237-84ab-b82077342359", + "status": "active", + "token_uuid": "abc123", + "updated": null, + "valid_for_sharegroup_uuid": "e1d0e58b-f89f-4237-84ab-b82077342359", + "token": "asupersecrettoken" +} \ No newline at end of file diff --git a/test/fixtures/images_sharegroups_tokens_abc123_sharegroup.json b/test/fixtures/images_sharegroups_tokens_abc123_sharegroup.json new file mode 100644 index 000000000..2dfd5e928 --- /dev/null +++ b/test/fixtures/images_sharegroups_tokens_abc123_sharegroup.json @@ -0,0 +1,9 @@ +{ + "created": "2025-04-14T22:44:02", + "description": "Group of base operating system images and engineers used for CI/CD pipelines and infrastructure automation", + "id": 1234, + "is_suspended": false, + "label": "DevOps Base Images", + "updated": null, + "uuid": "1533863e-16a4-47b5-b829-ac0f35c13278" +} \ No newline at end of file diff --git a/test/fixtures/images_sharegroups_tokens_abc123_sharegroup_images.json b/test/fixtures/images_sharegroups_tokens_abc123_sharegroup_images.json new file mode 100644 index 000000000..f63e52392 --- /dev/null +++ b/test/fixtures/images_sharegroups_tokens_abc123_sharegroup_images.json @@ -0,0 +1,45 @@ +{ + "data": [ + { + "capabilities": [ + "cloud-init", + "distributed-sites" + ], + "created": "2021-08-14T22:44:02", + "created_by": null, + "deprecated": false, + "description": "Example image description.", + "eol": "2026-07-01T04:00:00", + "expiry": null, + "id": "shared/1", + "is_public": true, + "is_shared": null, + "label": "Debian 11", + "regions": [ + { + "region": "us-iad", + "status": "available" + } + ], + "size": 2500, + "status": "available", + "tags": [ + "repair-image", + "fix-1" + ], + "total_size": 1234567, + "type": "manual", + "updated": "2021-08-14T22:44:02", + "vendor": null, + "image_sharing": { + "shared_with": null, + "shared_by": { + "sharegroup_id": 1234, + "sharegroup_uuid": "0ee8e1c1-b19b-4052-9487-e3b13faac111", + "sharegroup_label": "test-group-minecraft-1", + "source_image_id": null + } + } + } + ] +} \ No newline at end of file diff --git a/test/integration/linode_client/test_linode_client.py b/test/integration/linode_client/test_linode_client.py index da7e93cef..eb1b06369 100644 --- a/test/integration/linode_client/test_linode_client.py +++ b/test/integration/linode_client/test_linode_client.py @@ -6,11 +6,7 @@ import pytest from linode_api4 import ApiError -from linode_api4.objects import ( - ConfigInterface, - ObjectStorageKeys, - Region, -) +from linode_api4.objects import ConfigInterface, ObjectStorageKeys, Region @pytest.fixture(scope="session") diff --git a/test/integration/models/sharegroups/test_sharegroups.py b/test/integration/models/sharegroups/test_sharegroups.py new file mode 100644 index 000000000..9c66bad90 --- /dev/null +++ b/test/integration/models/sharegroups/test_sharegroups.py @@ -0,0 +1,251 @@ +import datetime +from test.integration.conftest import get_region +from test.integration.helpers import ( + get_test_label, +) + +import pytest + +from linode_api4.objects import ( + Image, + ImageShareGroup, + ImageShareGroupImagesToAdd, + ImageShareGroupImageToAdd, + ImageShareGroupImageToUpdate, + ImageShareGroupMemberToAdd, + ImageShareGroupMemberToUpdate, + ImageShareGroupToken, +) + + +def wait_for_image_status( + test_linode_client, image_id, expected_status, timeout=360, interval=5 +): + import time + + get_image = test_linode_client.load(Image, image_id) + timer = 0 + while get_image.status != expected_status and timer < timeout: + time.sleep(interval) + timer += interval + get_image = test_linode_client.load(Image, image_id) + if timer >= timeout: + raise TimeoutError( + f"Created image did not reach status '{expected_status}' within {timeout} seconds." + ) + + +@pytest.fixture(scope="class") +def sample_linode(test_linode_client, e2e_test_firewall): + client = test_linode_client + region = get_region(client, {"Linodes", "Cloud Firewall"}, site_type="core") + label = get_test_label(length=8) + + linode_instance, password = client.linode.instance_create( + "g6-nanode-1", + region, + image="linode/alpine3.19", + label=label + "_modlinode", + ) + yield linode_instance + linode_instance.delete() + + +@pytest.fixture(scope="class") +def create_image_id(test_linode_client, sample_linode): + create_image = test_linode_client.images.create( + sample_linode.disks[0], + label="linode-api4python-test-image-sharing-image", + ) + wait_for_image_status(test_linode_client, create_image.id, "available") + yield create_image.id + create_image.delete() + + +@pytest.fixture(scope="function") +def share_group_id(test_linode_client): + group_label = get_test_label(8) + "_sharegroup_api4_test" + group = test_linode_client.sharegroups.create_sharegroup( + label=group_label, + description="Test api4python", + ) + yield group.id + group.delete() + + +def test_get_share_groups(test_linode_client, share_group_id): + response = test_linode_client.sharegroups() + sharegroups_list = response.lists[0] + assert len(sharegroups_list) > 0 + assert sharegroups_list[0].api_endpoint == "/images/sharegroups/{id}" + assert sharegroups_list[0].id > 0 + assert sharegroups_list[0].description != "" + assert isinstance(sharegroups_list[0].images_count, int) + assert not sharegroups_list[0].is_suspended + assert sharegroups_list[0].label != "" + assert isinstance(sharegroups_list[0].members_count, int) + assert sharegroups_list[0].uuid != "" + assert isinstance(sharegroups_list[0].created, datetime.date) + assert not sharegroups_list[0].expiry + + +def test_add_update_remove_share_group(test_linode_client): + group_label = get_test_label(8) + "_sharegroup_api4_test" + share_group = test_linode_client.sharegroups.create_sharegroup( + label=group_label, + description="Test api4python create", + ) + assert share_group.api_endpoint == "/images/sharegroups/{id}" + assert share_group.id > 0 + assert share_group.description == "Test api4python create" + assert isinstance(share_group.images_count, int) + assert not share_group.is_suspended + assert share_group.label == group_label + assert isinstance(share_group.members_count, int) + assert share_group.uuid != "" + assert isinstance(share_group.created, datetime.date) + assert not share_group.updated + assert not share_group.expiry + + load_share_group = test_linode_client.load(ImageShareGroup, share_group.id) + assert load_share_group.id == share_group.id + assert load_share_group.description == "Test api4python create" + + load_share_group.label = "Updated Sharegroup Label" + load_share_group.description = "Test update description" + load_share_group.save() + load_share_group_after_update = test_linode_client.load( + ImageShareGroup, share_group.id + ) + assert load_share_group_after_update.id == share_group.id + assert load_share_group_after_update.label == "Updated Sharegroup Label" + assert ( + load_share_group_after_update.description == "Test update description" + ) + + share_group.delete() + with pytest.raises(RuntimeError) as err: + test_linode_client.load(ImageShareGroup, share_group.id) + assert "[404] Not found" in str(err.value) + + +def test_add_get_update_revoke_image_to_share_group( + test_linode_client, create_image_id, share_group_id +): + share_group = test_linode_client.load(ImageShareGroup, share_group_id) + add_image_response = share_group.add_images( + ImageShareGroupImagesToAdd( + images=[ + ImageShareGroupImageToAdd(id=create_image_id), + ] + ) + ) + assert 0 < len(add_image_response) + assert ( + add_image_response[0].image_sharing.shared_by.sharegroup_id + == share_group.id + ) + assert ( + add_image_response[0].image_sharing.shared_by.source_image_id + == create_image_id + ) + + get_response = share_group.get_image_shares() + assert 0 < len(get_response) + assert ( + get_response[0].image_sharing.shared_by.sharegroup_id == share_group.id + ) + assert ( + get_response[0].image_sharing.shared_by.source_image_id + == create_image_id + ) + assert get_response[0].description == "" + + update_response = share_group.update_image_share( + ImageShareGroupImageToUpdate( + image_share_id=get_response[0].id, description="Description update" + ) + ) + assert update_response.description == "Description update" + + share_groups_by_image_id_response = ( + test_linode_client.sharegroups.sharegroups_by_image_id(create_image_id) + ) + assert 0 < len(share_groups_by_image_id_response.lists) + assert share_groups_by_image_id_response.lists[0][0].id == share_group.id + + share_group.revoke_image_share(get_response[0].id) + get_after_revoke_response = share_group.get_image_shares() + assert len(get_after_revoke_response) == 0 + + +def test_list_tokens(test_linode_client): + response = test_linode_client.sharegroups.tokens() + assert response.page_endpoint == "images/sharegroups/tokens" + assert len(response.lists[0]) >= 0 + + +def test_create_token_to_own_share_group_error(test_linode_client): + group_label = get_test_label(8) + "_sharegroup_api4_test" + response_create_share_group = ( + test_linode_client.sharegroups.create_sharegroup( + label=group_label, + description="Test api4python create", + ) + ) + with pytest.raises(RuntimeError) as err: + test_linode_client.sharegroups.create_token( + response_create_share_group.uuid + ) + assert "[400] valid_for_sharegroup_uuid" in str(err.value) + assert "You may not create a token for your own sharegroup" in str( + err.value + ) + + response_create_share_group.delete() + + +def test_get_invalid_token(test_linode_client): + with pytest.raises(RuntimeError) as err: + test_linode_client.load(ImageShareGroupToken, "36b0-4d52_invalid") + assert "[404] Not found" in str(err.value) + + +def test_try_to_add_member_invalid_token(test_linode_client, share_group_id): + share_group = test_linode_client.load(ImageShareGroup, share_group_id) + with pytest.raises(RuntimeError) as err: + share_group.add_member( + ImageShareGroupMemberToAdd( + token="not_existing_token", + label="New Member", + ) + ) + assert "[500] Invalid token format" in str(err.value) + + +def test_list_share_group_members(test_linode_client, share_group_id): + share_group = test_linode_client.load(ImageShareGroup, share_group_id) + response = share_group.get_members() + assert 0 == len(response) + + +def test_try_to_get_update_revoke_share_group_member_by_invalid_token( + test_linode_client, share_group_id +): + share_group = test_linode_client.load(ImageShareGroup, share_group_id) + with pytest.raises(RuntimeError) as err: + share_group.get_member("not_existing_token") + assert "[404] Not found" in str(err.value) + + with pytest.raises(RuntimeError) as err: + share_group.update_member( + ImageShareGroupMemberToUpdate( + token_uuid="not_existing_token", + label="Update Member", + ) + ) + assert "[404] Not found" in str(err.value) + + with pytest.raises(RuntimeError) as err: + share_group.remove_member("not_existing_token") + assert "[404] Not found" in str(err.value) diff --git a/test/unit/groups/image_share_group_test.py b/test/unit/groups/image_share_group_test.py new file mode 100644 index 000000000..c9787264f --- /dev/null +++ b/test/unit/groups/image_share_group_test.py @@ -0,0 +1,153 @@ +from test.unit.base import ClientBaseCase + + +class ImageTest(ClientBaseCase): + """ + Tests methods of the ImageShareGroupAPIGroup class + """ + + def test_image_share_groups(self): + """ + Test that Image Share Groups can be retrieved successfully. + """ + sharegroups = self.client.sharegroups() + self.assertEqual(len(sharegroups), 2) + + self.assertEqual(sharegroups[0].id, 1) + self.assertEqual( + sharegroups[0].description, + "My group of images to share with my team.", + ) + self.assertEqual(sharegroups[0].images_count, 0) + self.assertEqual(sharegroups[0].is_suspended, False) + self.assertEqual(sharegroups[0].label, "My Shared Images") + self.assertEqual(sharegroups[0].members_count, 0) + self.assertEqual( + sharegroups[0].uuid, "1533863e-16a4-47b5-b829-ac0f35c13278" + ) + + self.assertEqual(sharegroups[1].id, 2) + self.assertEqual( + sharegroups[1].description, + "My other group of images to share with my team.", + ) + self.assertEqual(sharegroups[1].images_count, 1) + self.assertEqual(sharegroups[1].is_suspended, False) + self.assertEqual(sharegroups[1].label, "My other Shared Images") + self.assertEqual(sharegroups[1].members_count, 3) + self.assertEqual( + sharegroups[1].uuid, "30ee6599-eb0f-478c-9e55-4073c6c24a39" + ) + + def test_image_share_groups_by_image_id(self): + """ + Test that Image Share Groups where a given private image is currently shared can be retrieved successfully. + """ + + sharegroups = self.client.sharegroups.sharegroups_by_image_id( + "private/1234" + ) + self.assertEqual(len(sharegroups), 1) + + self.assertEqual(sharegroups[0].id, 1) + self.assertEqual( + sharegroups[0].description, + "My group of images to share with my team.", + ) + self.assertEqual(sharegroups[0].images_count, 1) + self.assertEqual(sharegroups[0].is_suspended, False) + self.assertEqual(sharegroups[0].label, "My Shared Images") + self.assertEqual(sharegroups[0].members_count, 0) + self.assertEqual( + sharegroups[0].uuid, "1533863e-16a4-47b5-b829-ac0f35c13278" + ) + + def test_image_share_group_tokens(self): + """ + Test that Image Share Group tokens can be retrieved successfully. + """ + + tokens = self.client.sharegroups.tokens() + self.assertEqual(len(tokens), 1) + + self.assertEqual( + tokens[0].token_uuid, "13428362-5458-4dad-b14b-8d0d4d648f8c" + ) + self.assertEqual(tokens[0].label, "My Sharegroup Token") + self.assertEqual(tokens[0].sharegroup_label, "A Sharegroup") + self.assertEqual( + tokens[0].sharegroup_uuid, "e1d0e58b-f89f-4237-84ab-b82077342359" + ) + self.assertEqual( + tokens[0].valid_for_sharegroup_uuid, + "e1d0e58b-f89f-4237-84ab-b82077342359", + ) + self.assertEqual(tokens[0].status, "active") + + def test_image_share_group_create(self): + """ + Test that an Image Share Group can be created successfully. + """ + + with self.mock_post("/images/sharegroups/1234") as m: + sharegroup = self.client.sharegroups.create_sharegroup( + label="My Shared Images", + description="My group of images to share with my team.", + ) + + assert m.call_url == "/images/sharegroups" + + self.assertEqual( + m.call_data, + { + "label": "My Shared Images", + "description": "My group of images to share with my team.", + }, + ) + + self.assertEqual(sharegroup.id, 1234) + self.assertEqual( + sharegroup.description, + "My group of images to share with my team.", + ) + self.assertEqual(sharegroup.images_count, 0) + self.assertEqual(sharegroup.is_suspended, False) + self.assertEqual(sharegroup.label, "My Shared Images") + self.assertEqual(sharegroup.members_count, 0) + self.assertEqual( + sharegroup.uuid, "1533863e-16a4-47b5-b829-ac0f35c13278" + ) + + def test_image_share_group_token_create(self): + """ + Test that an Image Share Group token can be created successfully. + """ + + with self.mock_post("/images/sharegroups/tokens/abc123") as m: + token = self.client.sharegroups.create_token( + label="My Sharegroup Token", + valid_for_sharegroup_uuid="e1d0e58b-f89f-4237-84ab-b82077342359", + ) + + assert m.call_url == "/images/sharegroups/tokens" + + self.assertEqual( + m.call_data, + { + "label": "My Sharegroup Token", + "valid_for_sharegroup_uuid": "e1d0e58b-f89f-4237-84ab-b82077342359", + }, + ) + + self.assertEqual(token[0].token_uuid, "abc123") + self.assertEqual(token[0].label, "My Sharegroup Token") + self.assertEqual(token[0].sharegroup_label, "A Sharegroup") + self.assertEqual( + token[0].sharegroup_uuid, "e1d0e58b-f89f-4237-84ab-b82077342359" + ) + self.assertEqual( + token[0].valid_for_sharegroup_uuid, + "e1d0e58b-f89f-4237-84ab-b82077342359", + ) + self.assertEqual(token[0].status, "active") + self.assertEqual(token[1], "asupersecrettoken") diff --git a/test/unit/groups/linode_test.py b/test/unit/groups/linode_test.py index a495284fd..03278f03b 100644 --- a/test/unit/groups/linode_test.py +++ b/test/unit/groups/linode_test.py @@ -5,10 +5,7 @@ build_interface_options_vpc, ) -from linode_api4 import ( - InstancePlacementGroupAssignment, - InterfaceGeneration, -) +from linode_api4 import InstancePlacementGroupAssignment, InterfaceGeneration from linode_api4.objects import ConfigInterface diff --git a/test/unit/objects/image_share_group_test.py b/test/unit/objects/image_share_group_test.py new file mode 100644 index 000000000..e02f0672c --- /dev/null +++ b/test/unit/objects/image_share_group_test.py @@ -0,0 +1,295 @@ +from test.unit.base import ClientBaseCase + +from linode_api4.objects import ( + ImageShareGroup, + ImageShareGroupImagesToAdd, + ImageShareGroupImageToAdd, + ImageShareGroupImageToUpdate, + ImageShareGroupMemberToAdd, + ImageShareGroupMemberToUpdate, + ImageShareGroupToken, +) + + +class ImageShareGroupTest(ClientBaseCase): + """ + Tests the methods of ImageShareGroup class + """ + + def test_get_sharegroup(self): + """ + Tests that an Image Share Group is loaded correctly by ID + """ + sharegroup = ImageShareGroup(self.client, 1234) + + self.assertEqual(sharegroup.id, 1234) + self.assertEqual( + sharegroup.description, "My group of images to share with my team." + ) + self.assertEqual(sharegroup.images_count, 0) + self.assertEqual(sharegroup.is_suspended, False) + self.assertEqual(sharegroup.label, "My Shared Images") + self.assertEqual(sharegroup.members_count, 0) + self.assertEqual( + sharegroup.uuid, "1533863e-16a4-47b5-b829-ac0f35c13278" + ) + + def test_update_sharegroup(self): + """ + Tests that an Image Share Group can be updated + """ + with self.mock_put("/images/sharegroups/1234") as m: + sharegroup = self.client.load(ImageShareGroup, 1234) + sharegroup.label = "Updated Sharegroup Label" + sharegroup.description = "Updated description for my sharegroup." + sharegroup.save() + self.assertEqual(m.call_url, "/images/sharegroups/1234") + self.assertEqual( + m.call_data, + { + "label": "Updated Sharegroup Label", + "description": "Updated description for my sharegroup.", + }, + ) + + def test_delete_sharegroup(self): + """ + Tests that deleting an Image Share Group creates the correct api request + """ + with self.mock_delete() as m: + sharegroup = ImageShareGroup(self.client, 1234) + sharegroup.delete() + + self.assertEqual(m.call_url, "/images/sharegroups/1234") + + def test_add_images_to_sharegroup(self): + """ + Tests that Images can be added to an Image Share Group + """ + with self.mock_post("/images/sharegroups/1234/images") as m: + sharegroup = self.client.load(ImageShareGroup, 1234) + sharegroup.add_images( + ImageShareGroupImagesToAdd( + images=[ + ImageShareGroupImageToAdd(id="private/123"), + ] + ) + ) + + self.assertEqual(m.call_url, "/images/sharegroups/1234/images") + self.assertEqual( + m.call_data, + { + "images": [ + {"id": "private/123"}, + ] + }, + ) + + def test_get_image_shares_in_sharegroup(self): + """ + Tests that Image Shares in an Image Share Group can be retrieved + """ + with self.mock_get("/images/sharegroups/1234/images") as m: + sharegroup = self.client.load(ImageShareGroup, 1234) + images = sharegroup.get_image_shares() + + self.assertEqual(m.call_url, "/images/sharegroups/1234/images") + self.assertEqual(len(images), 1) + self.assertEqual(images[0].id, "shared/1") + + def test_update_image_in_sharegroup(self): + """ + Tests that an Image shared in an Image Share Group can be updated + """ + with self.mock_put("/images/sharegroups/1234/images/shared/1") as m: + sharegroup = self.client.load(ImageShareGroup, 1234) + sharegroup.update_image_share( + ImageShareGroupImageToUpdate(image_share_id="shared/1") + ) + + self.assertEqual( + m.call_url, "/images/sharegroups/1234/images/shared/1" + ) + self.assertEqual( + m.call_data, + { + "image_share_id": "shared/1", + }, + ) + + def test_remove_image_from_sharegroup(self): + """ + Tests that an Image can be removed from an Image Share Group + """ + with self.mock_delete() as m: + sharegroup = self.client.load(ImageShareGroup, 1234) + sharegroup.revoke_image_share("shared/1") + + self.assertEqual( + m.call_url, "/images/sharegroups/1234/images/shared/1" + ) + + def test_add_members_to_sharegroup(self): + """ + Tests that members can be added to an Image Share Group + """ + with self.mock_post("/images/sharegroups/1234/members") as m: + sharegroup = self.client.load(ImageShareGroup, 1234) + sharegroup.add_member( + ImageShareGroupMemberToAdd( + token="secrettoken", + label="New Member", + ) + ) + + self.assertEqual(m.call_url, "/images/sharegroups/1234/members") + self.assertEqual( + m.call_data, + { + "token": "secrettoken", + "label": "New Member", + }, + ) + + def test_get_members_in_sharegroup(self): + """ + Tests that members in an Image Share Group can be retrieved + """ + with self.mock_get("/images/sharegroups/1234/members") as m: + sharegroup = self.client.load(ImageShareGroup, 1234) + members = sharegroup.get_members() + + self.assertEqual(m.call_url, "/images/sharegroups/1234/members") + self.assertEqual(len(members), 1) + self.assertEqual( + members[0].token_uuid, "4591075e-4ba8-43c9-a521-928c3d4a135d" + ) + + def test_get_member_in_sharegroup(self): + """ + Tests that a specific member in an Image Share Group can be retrieved + """ + with self.mock_get("/images/sharegroups/1234/members/abc123") as m: + sharegroup = self.client.load(ImageShareGroup, 1234) + member = sharegroup.get_member("abc123") + + self.assertEqual( + m.call_url, "/images/sharegroups/1234/members/abc123" + ) + self.assertEqual(member.token_uuid, "abc123") + + def test_update_member_in_sharegroup(self): + """ + Tests that a member in an Image Share Group can be updated + """ + with self.mock_put("/images/sharegroups/1234/members/abc123") as m: + sharegroup = self.client.load(ImageShareGroup, 1234) + sharegroup.update_member( + ImageShareGroupMemberToUpdate( + token_uuid="abc123", + label="Updated Member Label", + ) + ) + + self.assertEqual( + m.call_url, "/images/sharegroups/1234/members/abc123" + ) + self.assertEqual( + m.call_data, + { + "label": "Updated Member Label", + }, + ) + + def test_remove_member_from_sharegroup(self): + """ + Tests that a member can be removed from an Image Share Group + """ + with self.mock_delete() as m: + sharegroup = self.client.load(ImageShareGroup, 1234) + sharegroup.remove_member("abc123") + + self.assertEqual( + m.call_url, "/images/sharegroups/1234/members/abc123" + ) + + +class ImageShareGroupTokenTest(ClientBaseCase): + """ + Tests the methods of ImageShareGroupToken class + """ + + def test_get_sharegroup_token(self): + """ + Tests that an Image Share Group Token is loaded correctly by UUID + """ + token = self.client.load(ImageShareGroupToken, "abc123") + + self.assertEqual(token.token_uuid, "abc123") + self.assertEqual(token.label, "My Sharegroup Token") + self.assertEqual(token.sharegroup_label, "A Sharegroup") + self.assertEqual( + token.sharegroup_uuid, "e1d0e58b-f89f-4237-84ab-b82077342359" + ) + self.assertEqual(token.status, "active") + self.assertEqual( + token.valid_for_sharegroup_uuid, + "e1d0e58b-f89f-4237-84ab-b82077342359", + ) + + def test_update_sharegroup_token(self): + """ + Tests that an Image Share Group Token can be updated + """ + with self.mock_put("/images/sharegroups/tokens/abc123") as m: + token = self.client.load(ImageShareGroupToken, "abc123") + token.label = "Updated Token Label" + token.save() + self.assertEqual(m.call_url, "/images/sharegroups/tokens/abc123") + self.assertEqual( + m.call_data, + { + "label": "Updated Token Label", + }, + ) + + def test_delete_sharegroup_token(self): + """ + Tests that deleting an Image Share Group Token creates the correct api request + """ + with self.mock_delete() as m: + token = ImageShareGroupToken(self.client, "abc123") + token.delete() + + self.assertEqual(m.call_url, "/images/sharegroups/tokens/abc123") + + def test_sharegroup_token_get_sharegroup(self): + """ + Tests that the Image Share Group associated with a Token can be retrieved + """ + with self.mock_get("/images/sharegroups/tokens/abc123/sharegroup") as m: + token = self.client.load(ImageShareGroupToken, "abc123") + sharegroup = token.get_sharegroup() + + self.assertEqual( + m.call_url, "/images/sharegroups/tokens/abc123/sharegroup" + ) + self.assertEqual(sharegroup.id, 1234) + + def test_sharegroup_token_get_images(self): + """ + Tests that the Images associated with a Token can be retrieved + """ + with self.mock_get( + "/images/sharegroups/tokens/abc123/sharegroup/images" + ) as m: + token = self.client.load(ImageShareGroupToken, "abc123") + images = token.get_images() + + self.assertEqual( + m.call_url, + "/images/sharegroups/tokens/abc123/sharegroup/images", + ) + self.assertEqual(len(images), 1) + self.assertEqual(images[0].id, "shared/1") diff --git a/test/unit/objects/image_test.py b/test/unit/objects/image_test.py index 0869919d6..1ea2fd66e 100644 --- a/test/unit/objects/image_test.py +++ b/test/unit/objects/image_test.py @@ -55,6 +55,8 @@ def test_get_image(self): self.assertEqual(image.total_size, 1100) self.assertEqual(image.regions[0].region, "us-east") self.assertEqual(image.regions[0].status, "available") + self.assertEqual(image.is_shared, False) + self.assertIsNone(image.image_sharing) def test_image_create_upload(self): """ From 0442a8637e8d5a078a59e0e23b8d162e4aef7a0c Mon Sep 17 00:00:00 2001 From: rammanoj Date: Fri, 16 Jan 2026 14:37:27 -0500 Subject: [PATCH 09/16] Make NodePool optional for LKE-E in python sdk (#630) * make nodepools optional in cluster_create * Update linode_api4/groups/lke.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * address feedback --------- Co-authored-by: Erik Zilber Co-authored-by: Zhiwei Liang <121905282+zliang-akamai@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- linode_api4/groups/lke.py | 11 +++- .../linode_client/test_linode_client.py | 2 +- test/integration/models/lke/test_lke.py | 10 ++-- test/unit/groups/lke_test.py | 52 ++++++++++++++++++- test/unit/linode_client_test.py | 4 +- test/unit/objects/lke_test.py | 4 +- 6 files changed, 71 insertions(+), 12 deletions(-) diff --git a/linode_api4/groups/lke.py b/linode_api4/groups/lke.py index c3d6fdc5d..330c1d378 100644 --- a/linode_api4/groups/lke.py +++ b/linode_api4/groups/lke.py @@ -62,8 +62,8 @@ def cluster_create( self, region, label, - node_pools, kube_version, + node_pools: Optional[list] = None, control_plane: Union[ LKEClusterControlPlaneOptions, Dict[str, Any] ] = None, @@ -119,6 +119,15 @@ def cluster_create( :returns: The new LKE Cluster :rtype: LKECluster """ + if node_pools is None: + node_pools = [] + + if len(node_pools) == 0 and ( + tier is None or tier.lower() != "enterprise" + ): + raise ValueError( + "LKE standard clusters must have at least one node pool." + ) params = { "label": label, diff --git a/test/integration/linode_client/test_linode_client.py b/test/integration/linode_client/test_linode_client.py index eb1b06369..9935fc345 100644 --- a/test/integration/linode_client/test_linode_client.py +++ b/test/integration/linode_client/test_linode_client.py @@ -350,8 +350,8 @@ def test_fails_to_create_cluster_with_invalid_version(test_linode_client): cluster = client.lke.cluster_create( region, "example-cluster", - {"type": "g6-standard-1", "count": 3}, invalid_version, + {"type": "g6-standard-1", "count": 3}, ) except ApiError as e: assert "not valid" in str(e.json) diff --git a/test/integration/models/lke/test_lke.py b/test/integration/models/lke/test_lke.py index 71ebc1ff2..116665df6 100644 --- a/test/integration/models/lke/test_lke.py +++ b/test/integration/models/lke/test_lke.py @@ -38,7 +38,7 @@ def lke_cluster(test_linode_client): label = get_test_label() + "_cluster" cluster = test_linode_client.lke.cluster_create( - region, label, node_pools, version + region, label, version, node_pools ) yield cluster @@ -57,8 +57,8 @@ def lke_cluster_with_acl(test_linode_client): cluster = test_linode_client.lke.cluster_create( region, label, - node_pools, version, + node_pools, control_plane=LKEClusterControlPlaneOptions( acl=LKEClusterControlPlaneACLOptions( enabled=True, @@ -103,7 +103,7 @@ def lke_cluster_with_labels_and_taints(test_linode_client): label = get_test_label() + "_cluster" cluster = test_linode_client.lke.cluster_create( - region, label, node_pools, version + region, label, version, node_pools ) yield cluster @@ -124,8 +124,8 @@ def lke_cluster_with_apl(test_linode_client): cluster = test_linode_client.lke.cluster_create( region, label, - node_pools, version, + node_pools, control_plane=LKEClusterControlPlaneOptions( high_availability=True, ), @@ -160,8 +160,8 @@ def lke_cluster_enterprise(e2e_test_firewall, test_linode_client): cluster = test_linode_client.lke.cluster_create( region, label, - node_pools, version, + node_pools, tier="enterprise", ) diff --git a/test/unit/groups/lke_test.py b/test/unit/groups/lke_test.py index a39db81a6..802960192 100644 --- a/test/unit/groups/lke_test.py +++ b/test/unit/groups/lke_test.py @@ -21,8 +21,8 @@ def test_cluster_create_with_acl(self): self.client.lke.cluster_create( "us-mia", "test-acl-cluster", - [self.client.lke.node_pool("g6-nanode-1", 3)], "1.29", + [self.client.lke.node_pool("g6-nanode-1", 3)], control_plane=LKEClusterControlPlaneOptions( acl=LKEClusterControlPlaneACLOptions( enabled=True, @@ -41,3 +41,53 @@ def test_cluster_create_with_acl(self): assert m.call_data["control_plane"]["acl"]["addresses"]["ipv6"] == [ "1234::5678" ] + + def test_cluster_create_enterprise_without_node_pools(self): + """ + Tests that an enterprise LKE cluster can be created without node pools. + """ + with self.mock_post("lke/clusters") as m: + self.client.lke.cluster_create( + "us-west", + "test-enterprise-cluster", + "1.29", + tier="enterprise", + ) + + assert m.call_data["region"] == "us-west" + assert m.call_data["label"] == "test-enterprise-cluster" + assert m.call_data["k8s_version"] == "1.29" + assert m.call_data["tier"] == "enterprise" + assert m.call_data["node_pools"] == [] + + def test_cluster_create_enterprise_case_insensitive(self): + """ + Tests that tier comparison is case-insensitive for enterprise tier. + """ + with self.mock_post("lke/clusters") as m: + self.client.lke.cluster_create( + "us-west", + "test-enterprise-cluster", + "1.29", + tier="ENTERPRISE", + ) + + assert m.call_data["tier"] == "ENTERPRISE" + assert m.call_data["node_pools"] == [] + + def test_cluster_create_standard_without_node_pools_raises_error(self): + """ + Tests that creating a standard LKE cluster without node pools raises ValueError. + """ + with self.assertRaises(ValueError) as context: + self.client.lke.cluster_create( + "us-east", + "test-standard-cluster", + "1.29", + tier="standard", + ) + + self.assertIn( + "LKE standard clusters must have at least one node pool", + str(context.exception), + ) diff --git a/test/unit/linode_client_test.py b/test/unit/linode_client_test.py index d87e08894..e82f3562d 100644 --- a/test/unit/linode_client_test.py +++ b/test/unit/linode_client_test.py @@ -817,7 +817,7 @@ def test_cluster_create_with_api_objects(self): node_pools = self.client.lke.node_pool(node_type, 3) with self.mock_post("lke/clusters") as m: cluster = self.client.lke.cluster_create( - region, "example-cluster", node_pools, version + region, "example-cluster", version, node_pools ) self.assertEqual(m.call_data["region"], "ap-west") self.assertEqual( @@ -850,8 +850,8 @@ def test_cluster_create_with_string_repr(self): cluster = self.client.lke.cluster_create( "ap-west", "example-cluster", - {"type": "g6-standard-1", "count": 3}, "1.19", + {"type": "g6-standard-1", "count": 3}, ) self.assertEqual(m.call_data["region"], "ap-west") self.assertEqual( diff --git a/test/unit/objects/lke_test.py b/test/unit/objects/lke_test.py index 10284a0c9..91f9ed3fe 100644 --- a/test/unit/objects/lke_test.py +++ b/test/unit/objects/lke_test.py @@ -302,6 +302,7 @@ def test_cluster_create_with_labels_and_taints(self): self.client.lke.cluster_create( "us-mia", "test-acl-cluster", + "1.29", [ self.client.lke.node_pool( "g6-nanode-1", @@ -317,7 +318,6 @@ def test_cluster_create_with_labels_and_taints(self): ], ) ], - "1.29", ) assert m.call_data["node_pools"][0] == { @@ -339,13 +339,13 @@ def test_cluster_create_with_apl(self): cluster = self.client.lke.cluster_create( "us-mia", "test-aapl-cluster", + "1.29", [ self.client.lke.node_pool( "g6-dedicated-4", 3, ) ], - "1.29", apl_enabled=True, control_plane=LKEClusterControlPlaneOptions( high_availability=True, From 7708f871b5b61edf7106e7c51a554eb1adf99abc Mon Sep 17 00:00:00 2001 From: Zhiwei Liang <121905282+zliang-akamai@users.noreply.github.com> Date: Tue, 20 Jan 2026 16:09:38 -0500 Subject: [PATCH 10/16] Remove non-existent doc links from AI imaginations. (#631) --- linode_api4/objects/lock.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/linode_api4/objects/lock.py b/linode_api4/objects/lock.py index b6552da7b..9cee64517 100644 --- a/linode_api4/objects/lock.py +++ b/linode_api4/objects/lock.py @@ -10,7 +10,7 @@ class LockType(StrEnum): """ LockType defines valid values for resource lock types. - API Documentation: https://techdocs.akamai.com/linode-api/reference/post-lock + API Documentation: TBD """ cannot_delete = "cannot_delete" @@ -22,7 +22,7 @@ class LockEntity(JSONObject): """ Represents the entity that is locked. - API Documentation: https://techdocs.akamai.com/linode-api/reference/get-lock + API Documentation: TBD """ id: int = 0 @@ -35,7 +35,7 @@ class Lock(Base): """ A resource lock that prevents deletion or modification of a resource. - API Documentation: https://techdocs.akamai.com/linode-api/reference/get-lock + API Documentation: TBD """ api_endpoint = "/locks/{id}" From 365c7d5380e6f24a04b9c58fdfe19153151433e6 Mon Sep 17 00:00:00 2001 From: Maciej Wilk Date: Thu, 5 Feb 2026 09:47:45 +0100 Subject: [PATCH 11/16] Regression fixes (#636) * Fix of: test_cluster_create_with_api_objects * Revert changes in lke.py, pass [node_pools] to cluster_create in int test instead * Rename node_pools to node_pool as it is single element --- test/integration/linode_client/test_linode_client.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/integration/linode_client/test_linode_client.py b/test/integration/linode_client/test_linode_client.py index 9935fc345..4060064d3 100644 --- a/test/integration/linode_client/test_linode_client.py +++ b/test/integration/linode_client/test_linode_client.py @@ -328,10 +328,10 @@ def test_cluster_create_with_api_objects(test_linode_client): node_type = client.linode.types()[1] # g6-standard-1 version = client.lke.versions()[0] region = get_region(client, {"Kubernetes"}) - node_pools = client.lke.node_pool(node_type, 3) + node_pool = client.lke.node_pool(node_type, 3) label = get_test_label() - cluster = client.lke.cluster_create(region, label, node_pools, version) + cluster = client.lke.cluster_create(region, label, version, [node_pool]) assert cluster.region.id == region.id assert cluster.k8s_version.id == version.id From ce2a79ff7ebfe11aee65be792295776d186a2ef3 Mon Sep 17 00:00:00 2001 From: Erik Zilber Date: Tue, 10 Feb 2026 09:13:45 -0500 Subject: [PATCH 12/16] Removed v4beta notices from Maintenance Policy (#643) * Removed v4beta notices from Maintenance Policy fields/methods * Fix lint --- linode_api4/groups/linode.py | 2 -- linode_api4/groups/maintenance.py | 2 -- linode_api4/objects/account.py | 6 ++---- linode_api4/objects/linode.py | 4 +--- test/integration/models/linode/test_linode.py | 3 +-- 5 files changed, 4 insertions(+), 13 deletions(-) diff --git a/linode_api4/groups/linode.py b/linode_api4/groups/linode.py index f88808e64..e32a284f1 100644 --- a/linode_api4/groups/linode.py +++ b/linode_api4/groups/linode.py @@ -335,8 +335,6 @@ def instance_create( :type network_helper: bool :param maintenance_policy: The slug of the maintenance policy to apply during maintenance. If not provided, the default policy (linode/migrate) will be applied. - NOTE: This field is in beta and may only - function if base_url is set to `https://api.linode.com/v4beta`. :type maintenance_policy: str :returns: A new Instance object, or a tuple containing the new Instance and diff --git a/linode_api4/groups/maintenance.py b/linode_api4/groups/maintenance.py index 7d56cec6e..63cb424df 100644 --- a/linode_api4/groups/maintenance.py +++ b/linode_api4/groups/maintenance.py @@ -9,8 +9,6 @@ class MaintenanceGroup(Group): def maintenance_policies(self): """ - .. note:: This endpoint is in beta. This will only function if base_url is set to `https://api.linode.com/v4beta`. - Returns a collection of MaintenancePolicy objects representing available maintenance policies that can be applied to Linodes diff --git a/linode_api4/objects/account.py b/linode_api4/objects/account.py index 54298ed11..a4aca1848 100644 --- a/linode_api4/objects/account.py +++ b/linode_api4/objects/account.py @@ -218,9 +218,7 @@ class AccountSettings(Base): "object_storage": Property(), "backups_enabled": Property(mutable=True), "interfaces_for_new_linodes": Property(mutable=True), - "maintenance_policy": Property( - mutable=True - ), # Note: This field is only available when using v4beta. + "maintenance_policy": Property(mutable=True), } @@ -249,7 +247,7 @@ class Event(Base): "duration": Property(), "secondary_entity": Property(), "message": Property(), - "maintenance_policy_set": Property(), # Note: This field is only available when using v4beta. + "maintenance_policy_set": Property(), "description": Property(), "source": Property(), "not_before": Property(is_datetime=True), diff --git a/linode_api4/objects/linode.py b/linode_api4/objects/linode.py index fae0926d5..1edf4e014 100644 --- a/linode_api4/objects/linode.py +++ b/linode_api4/objects/linode.py @@ -800,9 +800,7 @@ class Instance(Base): "lke_cluster_id": Property(), "capabilities": Property(unordered=True), "interface_generation": Property(), - "maintenance_policy": Property( - mutable=True - ), # Note: This field is only available when using v4beta. + "maintenance_policy": Property(mutable=True), "locks": Property(unordered=True), } diff --git a/test/integration/models/linode/test_linode.py b/test/integration/models/linode/test_linode.py index c485dd19c..574d5d9d2 100644 --- a/test/integration/models/linode/test_linode.py +++ b/test/integration/models/linode/test_linode.py @@ -1101,8 +1101,7 @@ def test_delete_interface_containing_vpc( def test_create_linode_with_maintenance_policy(test_linode_client): client = test_linode_client - # TODO: Replace with random region after GA - region = "ap-south" + region = get_region(client, {"Linodes", "Cloud Firewall"}, site_type="core") label = get_test_label() policies = client.maintenance.maintenance_policies() From f67187addcb32f24a48da85283b7d545b8ea023d Mon Sep 17 00:00:00 2001 From: Zhiwei Liang <121905282+zliang-akamai@users.noreply.github.com> Date: Tue, 10 Feb 2026 16:43:24 -0500 Subject: [PATCH 13/16] Remove preview section from PR template (#638) Removed the preview section from the pull request template. --- .github/pull_request_template.md | 4 ---- 1 file changed, 4 deletions(-) diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 5bea77b2c..d97f93452 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -7,7 +7,3 @@ **What are the steps to reproduce the issue or verify the changes?** **How do I run the relevant unit/integration tests?** - -## 📷 Preview - -**If applicable, include a screenshot or code snippet of this change. Otherwise, please remove this section.** \ No newline at end of file From f30ac54ad21217e0022d659c44ec088c84548b79 Mon Sep 17 00:00:00 2001 From: Zhiwei Liang <121905282+zliang-akamai@users.noreply.github.com> Date: Tue, 10 Feb 2026 16:44:18 -0500 Subject: [PATCH 14/16] Add resource lock support for NodeBalancer (#637) --- linode_api4/objects/nodebalancer.py | 1 + test/fixtures/nodebalancers.json | 6 ++++-- test/fixtures/nodebalancers_123456.json | 3 +++ test/unit/objects/nodebalancers_test.py | 18 ++++++++++++++++++ 4 files changed, 26 insertions(+), 2 deletions(-) diff --git a/linode_api4/objects/nodebalancer.py b/linode_api4/objects/nodebalancer.py index f02dda269..cb6e566f7 100644 --- a/linode_api4/objects/nodebalancer.py +++ b/linode_api4/objects/nodebalancer.py @@ -252,6 +252,7 @@ class NodeBalancer(Base): "transfer": Property(), "tags": Property(mutable=True, unordered=True), "client_udp_sess_throttle": Property(mutable=True), + "locks": Property(unordered=True), } # create derived objects diff --git a/test/fixtures/nodebalancers.json b/test/fixtures/nodebalancers.json index 85eec186b..9b4dc8dae 100644 --- a/test/fixtures/nodebalancers.json +++ b/test/fixtures/nodebalancers.json @@ -10,7 +10,8 @@ "updated": "2018-01-01T00:01:01", "label": "balancer123456", "client_conn_throttle": 0, - "tags": ["something"] + "tags": ["something"], + "locks": ["cannot_delete_with_subresources"] }, { "created": "2018-01-01T00:01:01", @@ -22,7 +23,8 @@ "updated": "2018-01-01T00:01:01", "label": "balancer123457", "client_conn_throttle": 0, - "tags": [] + "tags": [], + "locks": [] } ], "results": 2, diff --git a/test/fixtures/nodebalancers_123456.json b/test/fixtures/nodebalancers_123456.json index e965d4379..a78c8d3e3 100644 --- a/test/fixtures/nodebalancers_123456.json +++ b/test/fixtures/nodebalancers_123456.json @@ -10,5 +10,8 @@ "client_conn_throttle": 0, "tags": [ "something" + ], + "locks": [ + "cannot_delete_with_subresources" ] } \ No newline at end of file diff --git a/test/unit/objects/nodebalancers_test.py b/test/unit/objects/nodebalancers_test.py index ed0f0c320..c02b40ea3 100644 --- a/test/unit/objects/nodebalancers_test.py +++ b/test/unit/objects/nodebalancers_test.py @@ -175,6 +175,24 @@ def test_update(self): }, ) + def test_locks_not_in_put(self): + """ + Test that locks are not included in PUT request when updating a NodeBalancer. + Locks are managed through the separate /v4/locks endpoint. + """ + nb = NodeBalancer(self.client, 123456) + # Access locks to ensure it's loaded + self.assertEqual(nb.locks, ["cannot_delete_with_subresources"]) + + nb.label = "new-label" + + with self.mock_put("nodebalancers/123456") as m: + nb.save() + self.assertEqual(m.call_url, "/nodebalancers/123456") + # Verify locks is NOT in the PUT data + self.assertNotIn("locks", m.call_data) + self.assertEqual(m.call_data["label"], "new-label") + def test_firewalls(self): """ Test that you can get the firewalls for the requested NodeBalancer. From 8b8c61985730e08581cc736d7102ee41b98b9a33 Mon Sep 17 00:00:00 2001 From: Maciej Wilk Date: Wed, 11 Feb 2026 14:15:25 +0100 Subject: [PATCH 15/16] Align GHA workflows in the scope of report uploads (#642) * Add logic to upload test results for manual runs on demand only * Modify test_report_upload type to boolean * Set test_upload_report to choice type in e2e-test.yml --- .github/workflows/e2e-test-pr.yml | 10 +++++++++- .github/workflows/e2e-test.yml | 13 +++++++++++-- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/.github/workflows/e2e-test-pr.yml b/.github/workflows/e2e-test-pr.yml index 86809d177..f765b0a0d 100644 --- a/.github/workflows/e2e-test-pr.yml +++ b/.github/workflows/e2e-test-pr.yml @@ -27,6 +27,14 @@ on: pull_request_number: description: 'The number of the PR.' required: false + test_report_upload: + description: 'Indicates whether to upload the test report to object storage. Defaults to "false"' + required: false + default: 'false' + type: choice + options: + - 'true' + - 'false' name: PR E2E Tests @@ -101,7 +109,7 @@ jobs: LINODE_TOKEN: ${{ secrets.LINODE_TOKEN }} - name: Upload test results - if: always() + if: always() && github.repository == 'linode/linode_api4-python' && (github.event_name == 'pull_request' || (github.event_name == 'workflow_dispatch' && inputs.test_report_upload == 'true')) run: | filename=$(ls | grep -E '^[0-9]{12}_sdk_test_report\.xml$') python3 e2e_scripts/tod_scripts/xml_to_obj_storage/scripts/add_gha_info_to_xml.py \ diff --git a/.github/workflows/e2e-test.yml b/.github/workflows/e2e-test.yml index e2762ff95..5c24361d0 100644 --- a/.github/workflows/e2e-test.yml +++ b/.github/workflows/e2e-test.yml @@ -41,6 +41,14 @@ on: options: - 'true' - 'false' + test_report_upload: + description: 'Indicates whether to upload the test report to object storage. Defaults to "false"' + type: choice + required: false + default: 'false' + options: + - 'true' + - 'false' push: branches: - main @@ -172,7 +180,8 @@ jobs: process-upload-report: runs-on: ubuntu-latest needs: [integration-tests] - if: always() && github.repository == 'linode/linode_api4-python' # Run even if integration tests fail and only on main repository + # Run even if integration tests fail on main repository AND push event OR test_report_upload is true in case of manual run + if: always() && github.repository == 'linode/linode_api4-python' && (github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.test_report_upload == 'true')) outputs: summary: ${{ steps.set-test-summary.outputs.summary }} @@ -271,4 +280,4 @@ jobs: payload: | channel: ${{ secrets.SLACK_CHANNEL_ID }} thread_ts: "${{ steps.main_message.outputs.ts }}" - text: "${{ needs.process-upload-report.outputs.summary }}" + text: "${{ needs.process-upload-report.outputs.summary }}" \ No newline at end of file From 6dc0564a4a0f3b83bf70cdb6c468d6b7725a9e35 Mon Sep 17 00:00:00 2001 From: Maciej Wilk Date: Thu, 12 Feb 2026 08:38:42 +0100 Subject: [PATCH 16/16] Fix test_lke_cluster_model_filter. Modify lke_cluster_instance fixture (#641) --- test/integration/filters/fixtures.py | 4 ++-- test/integration/filters/model_filters_test.py | 5 +++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/test/integration/filters/fixtures.py b/test/integration/filters/fixtures.py index 31b7edcbf..e753236dd 100644 --- a/test/integration/filters/fixtures.py +++ b/test/integration/filters/fixtures.py @@ -25,11 +25,11 @@ def lke_cluster_instance(test_linode_client): region = get_region(test_linode_client, {"Kubernetes", "Disk Encryption"}) - node_pools = test_linode_client.lke.node_pool(node_type, 3) + node_pool = test_linode_client.lke.node_pool(node_type, 3) label = get_test_label() + "_cluster" cluster = test_linode_client.lke.cluster_create( - region, label, node_pools, version + region, label, version, [node_pool] ) yield cluster diff --git a/test/integration/filters/model_filters_test.py b/test/integration/filters/model_filters_test.py index 22bb8299e..55bed6ac3 100644 --- a/test/integration/filters/model_filters_test.py +++ b/test/integration/filters/model_filters_test.py @@ -63,12 +63,13 @@ def test_linode_type_model_filter(test_linode_client): def test_lke_cluster_model_filter(test_linode_client, lke_cluster_instance): client = test_linode_client + lke_cluster = lke_cluster_instance filtered_cluster = client.lke.clusters( - LKECluster.label.contains(lke_cluster_instance.label) + LKECluster.label.contains(lke_cluster.label) ) - assert filtered_cluster[0].id == lke_cluster_instance.id + assert filtered_cluster[0].id == lke_cluster.id def test_networking_firewall_model_filter(