From 86c26dff6f96df7ee918bafd136c9b74d4de8c17 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 30 Oct 2025 10:38:24 -0400 Subject: [PATCH 01/13] build(deps): bump actions/upload-artifact from 4 to 5 (#618) Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 4 to 5. - [Release notes](https://github.com/actions/upload-artifact/releases) - [Commits](https://github.com/actions/upload-artifact/compare/v4...v5) --- updated-dependencies: - dependency-name: actions/upload-artifact dependency-version: '5' 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 282914ebc..bde464c23 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@v4 + uses: actions/upload-artifact@v5 with: name: test-report-file if-no-files-found: ignore From e2652d8ead551d42db6a9e5e4a2b821316fda6a7 Mon Sep 17 00:00:00 2001 From: pmajali Date: Thu, 13 Nov 2025 20:33:58 +0530 Subject: [PATCH 02/13] adding monitors in region endpoint (#587) * adding monitors in region endpoint * updating with review comments * fixing build issues * fixing build issues * fixing build issue * fixing build issue * fixing lint * fixing filters * adding int-test for filter and groupby --- linode_api4/objects/monitor.py | 51 +++++++++++++++++-- linode_api4/objects/region.py | 13 +++++ test/fixtures/monitor_dashboards.json | 8 ++- test/fixtures/monitor_dashboards_1.json | 8 ++- .../monitor_services_dbaas_dashboards.json | 15 +++++- test/fixtures/regions.json | 8 +++ .../models/monitor/test_monitor.py | 51 +++++++++++++++++++ test/unit/objects/monitor_test.py | 27 +++++++++- test/unit/objects/region_test.py | 5 ++ 9 files changed, 175 insertions(+), 11 deletions(-) diff --git a/linode_api4/objects/monitor.py b/linode_api4/objects/monitor.py index ed6ce79a5..fb339a0fd 100644 --- a/linode_api4/objects/monitor.py +++ b/linode_api4/objects/monitor.py @@ -49,6 +49,7 @@ class ServiceType(StrEnum): firewall = "firewall" object_storage = "object_storage" aclb = "aclb" + net_load_balancer = "netloadbalancer" class MetricType(StrEnum): @@ -82,6 +83,10 @@ class MetricUnit(StrEnum): RATIO = "ratio" OPS_PER_SECOND = "ops_per_second" IOPS = "iops" + KILO_BYTES_PER_SECOND = "kilo_bytes_per_second" + SESSIONS_PER_SECOND = "sessions_per_second" + PACKETS_PER_SECOND = "packets_per_second" + KILO_BITS_PER_SECOND = "kilo_bits_per_second" class DashboardType(StrEnum): @@ -93,6 +98,17 @@ class DashboardType(StrEnum): custom = "custom" +@dataclass +class Filter(JSONObject): + """ + Represents a filter in the filters list of a dashboard widget. + """ + + dimension_label: str = "" + operator: str = "" + value: str = "" + + @dataclass class DashboardWidget(JSONObject): """ @@ -107,6 +123,34 @@ class DashboardWidget(JSONObject): chart_type: ChartType = "" y_label: str = "" aggregate_function: AggregateFunction = "" + group_by: Optional[List[str]] = None + _filters: Optional[List[Filter]] = field( + default=None, metadata={"json_key": "filters"} + ) + + def __getattribute__(self, name): + """Override to handle the filters attribute specifically to avoid metaclass conflict.""" + if name == "filters": + return object.__getattribute__(self, "_filters") + return object.__getattribute__(self, name) + + def __setattr__(self, name, value): + """Override to handle setting the filters attribute.""" + if name == "filters": + object.__setattr__(self, "_filters", value) + else: + object.__setattr__(self, name, value) + + +@dataclass +class ServiceAlert(JSONObject): + """ + Represents alert configuration options for a monitor service. + """ + + polling_interval_seconds: Optional[List[int]] = None + evaluation_period_seconds: Optional[List[int]] = None + scope: Optional[List[str]] = None @dataclass @@ -135,9 +179,7 @@ class MonitorMetricsDefinition(JSONObject): scrape_interval: int = 0 is_alertable: bool = False dimensions: Optional[List[Dimension]] = None - available_aggregate_functions: List[AggregateFunction] = field( - default_factory=list - ) + available_aggregate_functions: Optional[List[AggregateFunction]] = None class MonitorDashboard(Base): @@ -154,7 +196,7 @@ class MonitorDashboard(Base): "label": Property(), "service_type": Property(ServiceType), "type": Property(DashboardType), - "widgets": Property(List[DashboardWidget]), + "widgets": Property(json_object=DashboardWidget), "updated": Property(is_datetime=True), } @@ -171,6 +213,7 @@ class MonitorService(Base): properties = { "service_type": Property(ServiceType), "label": Property(), + "alert": Property(json_object=ServiceAlert), } diff --git a/linode_api4/objects/region.py b/linode_api4/objects/region.py index 34577c336..c9dc05099 100644 --- a/linode_api4/objects/region.py +++ b/linode_api4/objects/region.py @@ -16,6 +16,18 @@ class RegionPlacementGroupLimits(JSONObject): maximum_linodes_per_pg: int = 0 +@dataclass +class RegionMonitors(JSONObject): + """ + Represents the monitor services available in a region. + Lists the services in this region that support metrics and alerts + use with Akamai Cloud Pulse (ACLP). + """ + + alerts: Optional[list[str]] = None + metrics: Optional[list[str]] = None + + class Region(Base): """ A Region. Regions correspond to individual data centers, each located in a different geographical area. @@ -35,6 +47,7 @@ class Region(Base): "placement_group_limits": Property( json_object=RegionPlacementGroupLimits ), + "monitors": Property(json_object=RegionMonitors), } @property diff --git a/test/fixtures/monitor_dashboards.json b/test/fixtures/monitor_dashboards.json index 42de92b55..5e56923a1 100644 --- a/test/fixtures/monitor_dashboards.json +++ b/test/fixtures/monitor_dashboards.json @@ -16,7 +16,9 @@ "metric": "cpu_usage", "size": 12, "unit": "%", - "y_label": "cpu_usage" + "y_label": "cpu_usage", + "group_by": ["entity_id"], + "filters": null }, { "aggregate_function": "sum", @@ -26,7 +28,9 @@ "metric": "write_iops", "size": 6, "unit": "IOPS", - "y_label": "write_iops" + "y_label": "write_iops", + "group_by": ["entity_id"], + "filters": null } ] } diff --git a/test/fixtures/monitor_dashboards_1.json b/test/fixtures/monitor_dashboards_1.json index b78bf3447..afb5d71ee 100644 --- a/test/fixtures/monitor_dashboards_1.json +++ b/test/fixtures/monitor_dashboards_1.json @@ -14,7 +14,9 @@ "metric": "cpu_usage", "size": 12, "unit": "%", - "y_label": "cpu_usage" + "y_label": "cpu_usage", + "group_by": ["entity_id"], + "filters": null }, { "aggregate_function": "sum", @@ -24,7 +26,9 @@ "metric": "available_memory", "size": 6, "unit": "GB", - "y_label": "available_memory" + "y_label": "available_memory", + "group_by": ["entity_id"], + "filters": null } ] } \ No newline at end of file diff --git a/test/fixtures/monitor_services_dbaas_dashboards.json b/test/fixtures/monitor_services_dbaas_dashboards.json index 5fbb7e9db..e39a231b2 100644 --- a/test/fixtures/monitor_services_dbaas_dashboards.json +++ b/test/fixtures/monitor_services_dbaas_dashboards.json @@ -16,7 +16,9 @@ "metric": "cpu_usage", "size": 12, "unit": "%", - "y_label": "cpu_usage" + "y_label": "cpu_usage", + "group_by": ["entity_id"], + "filters": null }, { "aggregate_function": "sum", @@ -26,7 +28,16 @@ "metric": "memory_usage", "size": 6, "unit": "%", - "y_label": "memory_usage" + "y_label": "memory_usage", + "group_by": ["entity_id"], + "filters": [ + { + "dimension_label": "pattern", + "operator": "in", + "value": "publicout,privateout" + } + ] + } ] } diff --git a/test/fixtures/regions.json b/test/fixtures/regions.json index b58db045d..1482def37 100644 --- a/test/fixtures/regions.json +++ b/test/fixtures/regions.json @@ -132,6 +132,14 @@ "Object Storage", "Linode Interfaces" ], + "monitors": { + "alerts": [ + "Managed Databases" + ], + "metrics": [ + "Managed Databases" + ] + }, "status": "ok", "resolvers": { "ipv4": "66.228.42.5,96.126.106.5,50.116.53.5,50.116.58.5,50.116.61.5,50.116.62.5,66.175.211.5,97.107.133.4,207.192.69.4,207.192.69.5", diff --git a/test/integration/models/monitor/test_monitor.py b/test/integration/models/monitor/test_monitor.py index b458fd399..eed85ab14 100644 --- a/test/integration/models/monitor/test_monitor.py +++ b/test/integration/models/monitor/test_monitor.py @@ -35,6 +35,57 @@ def test_get_all_dashboards(test_linode_client): assert dashboards_by_svc[0].service_type == get_service_type +def test_filter_and_group_by(test_linode_client): + client = test_linode_client + dashboards_by_svc = client.monitor.dashboards(service_type="linode") + assert isinstance(dashboards_by_svc[0], MonitorDashboard) + + # Get the first dashboard for linode service type + dashboard = dashboards_by_svc[0] + assert dashboard.service_type == "linode" + + # Ensure the dashboard has widgets + assert hasattr( + dashboard, "widgets" + ), "Dashboard should have widgets attribute" + assert dashboard.widgets is not None, "Dashboard widgets should not be None" + assert ( + len(dashboard.widgets) > 0 + ), "Dashboard should have at least one widget" + + # Test the first widget's group_by and filters fields + widget = dashboard.widgets[0] + + # Test group_by field type + group_by = widget.group_by + assert group_by is None or isinstance( + group_by, list + ), "group_by should be None or list type" + if group_by is not None: + for item in group_by: + assert isinstance(item, str), "group_by items should be strings" + + # Test filters field type + filters = widget.filters + assert filters is None or isinstance( + filters, list + ), "filters should be None or list type" + if filters is not None: + from linode_api4.objects.monitor import Filter + + for filter_item in filters: + assert isinstance( + filter_item, Filter + ), "filter items should be Filter objects" + assert hasattr( + filter_item, "dimension_label" + ), "Filter should have dimension_label" + assert hasattr( + filter_item, "operator" + ), "Filter should have operator" + assert hasattr(filter_item, "value"), "Filter should have value" + + # List supported services def test_get_supported_services(test_linode_client): client = test_linode_client diff --git a/test/unit/objects/monitor_test.py b/test/unit/objects/monitor_test.py index a010514c2..329a09063 100644 --- a/test/unit/objects/monitor_test.py +++ b/test/unit/objects/monitor_test.py @@ -41,6 +41,8 @@ def test_dashboard_by_ID(self): self.assertEqual(dashboard.widgets[0].size, 12) self.assertEqual(dashboard.widgets[0].unit, "%") self.assertEqual(dashboard.widgets[0].y_label, "cpu_usage") + self.assertEqual(dashboard.widgets[0].group_by, ["entity_id"]) + self.assertIsNone(dashboard.widgets[0].filters) def test_dashboard_by_service_type(self): dashboards = self.client.monitor.dashboards(service_type="dbaas") @@ -62,6 +64,21 @@ def test_dashboard_by_service_type(self): self.assertEqual(dashboards[0].widgets[0].size, 12) self.assertEqual(dashboards[0].widgets[0].unit, "%") self.assertEqual(dashboards[0].widgets[0].y_label, "cpu_usage") + self.assertEqual(dashboards[0].widgets[0].group_by, ["entity_id"]) + self.assertIsNone(dashboards[0].widgets[0].filters) + + # Test the second widget which has filters + self.assertEqual(dashboards[0].widgets[1].label, "Memory Usage") + self.assertEqual(dashboards[0].widgets[1].group_by, ["entity_id"]) + self.assertIsNotNone(dashboards[0].widgets[1].filters) + self.assertEqual(len(dashboards[0].widgets[1].filters), 1) + self.assertEqual( + dashboards[0].widgets[1].filters[0].dimension_label, "pattern" + ) + self.assertEqual(dashboards[0].widgets[1].filters[0].operator, "in") + self.assertEqual( + dashboards[0].widgets[1].filters[0].value, "publicout,privateout" + ) def test_get_all_dashboards(self): dashboards = self.client.monitor.dashboards() @@ -83,12 +100,20 @@ def test_get_all_dashboards(self): self.assertEqual(dashboards[0].widgets[0].size, 12) self.assertEqual(dashboards[0].widgets[0].unit, "%") self.assertEqual(dashboards[0].widgets[0].y_label, "cpu_usage") + self.assertEqual(dashboards[0].widgets[0].group_by, ["entity_id"]) + self.assertIsNone(dashboards[0].widgets[0].filters) def test_specific_service_details(self): data = self.client.load(MonitorService, "dbaas") self.assertEqual(data.label, "Databases") self.assertEqual(data.service_type, "dbaas") + # Test alert configuration + self.assertIsNotNone(data.alert) + self.assertEqual(data.alert.polling_interval_seconds, [300]) + self.assertEqual(data.alert.evaluation_period_seconds, [300]) + self.assertEqual(data.alert.scope, ["entity"]) + def test_metric_definitions(self): metrics = self.client.monitor.metric_definitions(service_type="dbaas") @@ -96,7 +121,7 @@ def test_metric_definitions(self): metrics[0].available_aggregate_functions, ["max", "avg", "min", "sum"], ) - self.assertEqual(metrics[0].is_alertable, True) + self.assertTrue(metrics[0].is_alertable) self.assertEqual(metrics[0].label, "CPU Usage") self.assertEqual(metrics[0].metric, "cpu_usage") self.assertEqual(metrics[0].metric_type, "gauge") diff --git a/test/unit/objects/region_test.py b/test/unit/objects/region_test.py index 6ae503098..73fdc8f5d 100644 --- a/test/unit/objects/region_test.py +++ b/test/unit/objects/region_test.py @@ -27,6 +27,11 @@ def test_get_region(self): region.placement_group_limits.maximum_linodes_per_pg, 5 ) + # Test monitors section + self.assertIsNotNone(region.monitors) + self.assertEqual(region.monitors.alerts, ["Managed Databases"]) + self.assertEqual(region.monitors.metrics, ["Managed Databases"]) + self.assertIsNotNone(region.capabilities) self.assertIn("Linode Interfaces", region.capabilities) From 5d395d499f05675c85e253a6d382156c4de27e59 Mon Sep 17 00:00:00 2001 From: Zhiwei Liang <121905282+zliang-akamai@users.noreply.github.com> Date: Mon, 1 Dec 2025 10:02:14 -0500 Subject: [PATCH 03/13] Add alias support to Property class and related tests (#619) * Add alias support to Property class and related tests - Introduced `alias_of` parameter in Property to allow aliasing of API attributes. - Implemented `properties_with_alias` method in Base class to retrieve aliased properties. - Updated BetaProgram to include an aliased property for "class". - Added comprehensive tests for alias functionality in PropertyAliasTest. * Update linode_api4/objects/base.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Pre-compute keys * More readable condition * make format --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- linode_api4/objects/base.py | 158 ++++++++++++------- linode_api4/objects/beta.py | 1 + test/unit/objects/property_alias_test.py | 191 +++++++++++++++++++++++ 3 files changed, 290 insertions(+), 60 deletions(-) create mode 100644 test/unit/objects/property_alias_test.py diff --git a/linode_api4/objects/base.py b/linode_api4/objects/base.py index 9f2a55589..78e53fd45 100644 --- a/linode_api4/objects/base.py +++ b/linode_api4/objects/base.py @@ -1,5 +1,6 @@ import time from datetime import datetime, timedelta +from functools import cached_property from typing import Any, Dict, Optional from linode_api4.objects.serializable import JSONObject @@ -35,27 +36,43 @@ def __init__( nullable=False, unordered=False, json_object=None, + alias_of: Optional[str] = None, ): """ A Property is an attribute returned from the API, and defines metadata - about that value. These are expected to be used as the values of a + about that value. These are expected to be used as the values of a class-level dict named 'properties' in subclasses of Base. - mutable - This Property should be sent in a call to save() - identifier - This Property identifies the object in the API - volatile - Re-query for this Property if the local value is older than the - volatile refresh timeout - relationship - The API Object this Property represents - derived_class - The sub-collection type this Property represents - is_datetime - True if this Property should be parsed as a datetime.datetime - id_relationship - This Property should create a relationship with this key as the ID - (This should be used on fields ending with '_id' only) - slug_relationship - This property is a slug related for a given type. - nullable - This property can be explicitly null on PUT requests. - unordered - The order of this property is not significant. - NOTE: This field is currently only for annotations purposes - and does not influence any update or decoding/encoding logic. - json_object - The JSONObject class this property should be decoded into. + :param mutable: This Property should be sent in a call to save() + :type mutable: bool + :param identifier: This Property identifies the object in the API + :type identifier: bool + :param volatile: Re-query for this Property if the local value is older than the + volatile refresh timeout + :type volatile: bool + :param relationship: The API Object this Property represents + :type relationship: type or None + :param derived_class: The sub-collection type this Property represents + :type derived_class: type or None + :param is_datetime: True if this Property should be parsed as a datetime.datetime + :type is_datetime: bool + :param id_relationship: This Property should create a relationship with this key as the ID + (This should be used on fields ending with '_id' only) + :type id_relationship: type or None + :param slug_relationship: This property is a slug related for a given type + :type slug_relationship: type or None + :param nullable: This property can be explicitly null on PUT requests + :type nullable: bool + :param unordered: The order of this property is not significant. + NOTE: This field is currently only for annotations purposes + and does not influence any update or decoding/encoding logic. + :type unordered: bool + :param json_object: The JSONObject class this property should be decoded into + :type json_object: type or None + :param alias_of: The original API attribute name when the property key is aliased. + This is useful when the API attribute name is a Python reserved word, + allowing you to use a different key while preserving the original name. + :type alias_of: str or None """ self.mutable = mutable self.identifier = identifier @@ -68,6 +85,7 @@ def __init__( self.nullable = nullable self.unordered = unordered self.json_class = json_object + self.alias_of = alias_of class MappedObject: @@ -252,6 +270,21 @@ def __setattr__(self, name, value): self._set(name, value) + @cached_property + def properties_with_alias(self) -> dict[str, tuple[str, Property]]: + """ + Gets a dictionary of aliased properties for this object. + + :returns: A dict mapping original API attribute names to their alias names and + corresponding Property instances. + :rtype: dict[str, tuple[str, Property]] + """ + return { + prop.alias_of: (alias, prop) + for alias, prop in type(self).properties.items() + if prop.alias_of + } + def save(self, force=True) -> bool: """ Send this object's mutable values to the server in a PUT request. @@ -345,7 +378,8 @@ def _serialize(self, is_put: bool = False): ): value = None - result[k] = value + api_key = k if not v.alias_of else v.alias_of + result[api_key] = value # Resolve the underlying IDs of results for k, v in result.items(): @@ -373,55 +407,55 @@ def _populate(self, json): self._set("_raw_json", json) self._set("_updated", False) - for key in json: - if key in ( - k - for k in type(self).properties.keys() - if not type(self).properties[k].identifier - ): - if ( - type(self).properties[key].relationship - and not json[key] is None - ): - if isinstance(json[key], list): + valid_keys = set( + k + for k, v in type(self).properties.items() + if (not v.identifier) and (not v.alias_of) + ) | set(self.properties_with_alias.keys()) + + for api_key in json: + if api_key in valid_keys: + prop = type(self).properties.get(api_key) + prop_key = api_key + + if prop is None: + prop_key, prop = self.properties_with_alias[api_key] + + if prop.relationship and json[api_key] is not None: + if isinstance(json[api_key], list): objs = [] - for d in json[key]: + for d in json[api_key]: if not "id" in d: continue - new_class = type(self).properties[key].relationship + new_class = prop.relationship obj = new_class.make_instance( d["id"], getattr(self, "_client") ) if obj: obj._populate(d) objs.append(obj) - self._set(key, objs) + self._set(prop_key, objs) else: - if isinstance(json[key], dict): - related_id = json[key]["id"] + if isinstance(json[api_key], dict): + related_id = json[api_key]["id"] else: - related_id = json[key] - new_class = type(self).properties[key].relationship + related_id = json[api_key] + new_class = prop.relationship obj = new_class.make_instance( related_id, getattr(self, "_client") ) - if obj and isinstance(json[key], dict): - obj._populate(json[key]) - self._set(key, obj) - elif ( - type(self).properties[key].slug_relationship - and not json[key] is None - ): + if obj and isinstance(json[api_key], dict): + obj._populate(json[api_key]) + self._set(prop_key, obj) + elif prop.slug_relationship and json[api_key] is not None: # create an object of the expected type with the given slug self._set( - key, - type(self) - .properties[key] - .slug_relationship(self._client, json[key]), + prop_key, + prop.slug_relationship(self._client, json[api_key]), ) - elif type(self).properties[key].json_class: - json_class = type(self).properties[key].json_class - json_value = json[key] + elif prop.json_class: + json_class = prop.json_class + json_value = json[api_key] # build JSON object if isinstance(json_value, list): @@ -430,25 +464,29 @@ def _populate(self, json): else: value = json_class.from_json(json_value) - self._set(key, value) - elif type(json[key]) is dict: - self._set(key, MappedObject(**json[key])) - elif type(json[key]) is list: + self._set(prop_key, value) + elif type(json[api_key]) is dict: + self._set(prop_key, MappedObject(**json[api_key])) + elif type(json[api_key]) is list: # we're going to use MappedObject's behavior with lists to # expand these, then grab the resulting value to set - mapping = MappedObject(_list=json[key]) - self._set(key, mapping._list) # pylint: disable=no-member - elif type(self).properties[key].is_datetime: + mapping = MappedObject(_list=json[api_key]) + self._set( + prop_key, mapping._list + ) # pylint: disable=no-member + elif prop.is_datetime: try: - t = time.strptime(json[key], DATE_FORMAT) - self._set(key, datetime.fromtimestamp(time.mktime(t))) + t = time.strptime(json[api_key], DATE_FORMAT) + self._set( + prop_key, datetime.fromtimestamp(time.mktime(t)) + ) except: # if this came back, there's probably an issue with the # python library; a field was marked as a datetime but # wasn't in the expected format. - self._set(key, json[key]) + self._set(prop_key, json[api_key]) else: - self._set(key, json[key]) + self._set(prop_key, json[api_key]) self._set("_populated", True) self._set("_last_updated", datetime.now()) diff --git a/linode_api4/objects/beta.py b/linode_api4/objects/beta.py index c957aa584..45d5c5102 100644 --- a/linode_api4/objects/beta.py +++ b/linode_api4/objects/beta.py @@ -19,4 +19,5 @@ class BetaProgram(Base): "ended": Property(is_datetime=True), "greenlight_only": Property(), "more_info": Property(), + "beta_class": Property(alias_of="class"), } diff --git a/test/unit/objects/property_alias_test.py b/test/unit/objects/property_alias_test.py new file mode 100644 index 000000000..09efa0e7e --- /dev/null +++ b/test/unit/objects/property_alias_test.py @@ -0,0 +1,191 @@ +""" +Tests for Property alias_of functionality +""" + +from test.unit.base import ClientBaseCase + +from linode_api4.objects import Base, Property + + +class PropertyAliasTest(ClientBaseCase): + """Test cases for Property alias_of parameter""" + + def test_alias_populate_from_json(self): + """Test that aliased properties are populated correctly from JSON""" + + class TestModel(Base): + api_endpoint = "/test/{id}" + properties = { + "id": Property(identifier=True), + "service_class": Property(mutable=True, alias_of="class"), + "label": Property(mutable=True), + } + + json_data = { + "id": 123, + "class": "premium", + "label": "test-label", + } + + obj = TestModel(self.client, 123, json_data) + + # The aliased property should be set using the Python-friendly name + self.assertEqual(obj.service_class, "premium") + self.assertEqual(obj.label, "test-label") + + def test_alias_serialize(self): + """Test that aliased properties serialize back to original API names""" + + class TestModel(Base): + api_endpoint = "/test/{id}" + properties = { + "id": Property(identifier=True), + "service_class": Property(mutable=True, alias_of="class"), + "label": Property(mutable=True), + } + + obj = TestModel(self.client, 123) + obj._set("service_class", "premium") + obj._set("label", "test-label") + obj._set("_populated", True) + + result = obj._serialize() + + # The serialized output should use the original API attribute name + self.assertIn("class", result) + self.assertEqual(result["class"], "premium") + self.assertEqual(result["label"], "test-label") + # Should not contain the aliased name + self.assertNotIn("service_class", result) + + def test_properties_with_alias(self): + """Test that properties_with_alias returns correct mapping""" + + class TestModel(Base): + api_endpoint = "/test/{id}" + properties = { + "id": Property(identifier=True), + "service_class": Property(mutable=True, alias_of="class"), + "beta_type": Property(alias_of="type"), + "label": Property(mutable=True), + } + + obj = TestModel(self.client, 123) + + alias_map = obj.properties_with_alias + + # Should contain mappings for aliased properties + self.assertIn("class", alias_map) + self.assertIn("type", alias_map) + + # Should map to tuples of (alias_name, Property) + alias_name, prop = alias_map["class"] + self.assertEqual(alias_name, "service_class") + self.assertEqual(prop.alias_of, "class") + + alias_name, prop = alias_map["type"] + self.assertEqual(alias_name, "beta_type") + self.assertEqual(prop.alias_of, "type") + + # Non-aliased properties should not be in the map + self.assertNotIn("label", alias_map) + self.assertNotIn("id", alias_map) + + def test_alias_no_conflict_with_regular_properties(self): + """Test that aliased properties don't conflict with regular properties""" + + class TestModel(Base): + api_endpoint = "/test/{id}" + properties = { + "id": Property(identifier=True), + "service_class": Property(mutable=True, alias_of="class"), + "label": Property(mutable=True), + "status": Property(), + } + + json_data = { + "id": 123, + "class": "premium", + "label": "test-label", + "status": "active", + } + + obj = TestModel(self.client, 123, json_data) + + # All properties should be set correctly + self.assertEqual(obj.service_class, "premium") + self.assertEqual(obj.label, "test-label") + self.assertEqual(obj.status, "active") + + def test_multiple_aliases(self): + """Test handling multiple aliased properties""" + + class TestModel(Base): + api_endpoint = "/test/{id}" + properties = { + "id": Property(identifier=True), + "service_class": Property(mutable=True, alias_of="class"), + "beta_type": Property(mutable=True, alias_of="type"), + "import_data": Property(mutable=True, alias_of="import"), + } + + json_data = { + "id": 123, + "class": "premium", + "type": "beta", + "import": "data", + } + + obj = TestModel(self.client, 123, json_data) + + # All aliased properties should be populated + self.assertEqual(obj.service_class, "premium") + self.assertEqual(obj.beta_type, "beta") + self.assertEqual(obj.import_data, "data") + + # Serialization should use original names + obj._set("_populated", True) + result = obj._serialize() + + self.assertEqual(result["class"], "premium") + self.assertEqual(result["type"], "beta") + self.assertEqual(result["import"], "data") + + def test_alias_with_none_value(self): + """Test that aliased properties handle None values correctly""" + + class TestModel(Base): + api_endpoint = "/test/{id}" + properties = { + "id": Property(identifier=True), + "service_class": Property(mutable=True, alias_of="class"), + } + + json_data = { + "id": 123, + "class": None, + } + + obj = TestModel(self.client, 123, json_data) + + # The aliased property should be None + self.assertIsNone(obj.service_class) + + def test_alias_cached_property(self): + """Test that properties_with_alias is cached""" + + class TestModel(Base): + api_endpoint = "/test/{id}" + properties = { + "id": Property(identifier=True), + "service_class": Property(alias_of="class"), + } + + obj = TestModel(self.client, 123) + + # Access the cached property twice + result1 = obj.properties_with_alias + result2 = obj.properties_with_alias + + # Should return the same object (cached) + self.assertIs(result1, result2) From dc3164c10357c69c56131ff9be0d2b3966ccf880 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 1 Dec 2025 16:11:15 -0500 Subject: [PATCH 04/13] build(deps): bump actions/checkout from 5 to 6 (#620) Bumps [actions/checkout](https://github.com/actions/checkout) from 5 to 6. - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/v5...v6) --- updated-dependencies: - dependency-name: actions/checkout 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/ci.yml | 4 ++-- .github/workflows/codeql.yml | 2 +- .github/workflows/dependency-review.yml | 2 +- .github/workflows/e2e-test-pr.yml | 4 ++-- .github/workflows/e2e-test.yml | 8 ++++---- .github/workflows/labeler.yml | 2 +- .github/workflows/nightly-smoke-tests.yml | 2 +- .github/workflows/publish-pypi.yaml | 2 +- .github/workflows/release-cross-repo-test.yml | 4 ++-- 9 files changed, 15 insertions(+), 15 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c665358d7..dd8eeea17 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,7 +14,7 @@ jobs: runs-on: ubuntu-latest steps: - name: checkout repo - uses: actions/checkout@v5 + uses: actions/checkout@v6 - name: setup python 3 uses: actions/setup-python@v6 @@ -33,7 +33,7 @@ jobs: matrix: python-version: ['3.9', '3.10', '3.11', '3.12', '3.13'] steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 527950d61..d3fa1315f 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -23,7 +23,7 @@ jobs: build-mode: none steps: - name: Checkout repository - uses: actions/checkout@v5 + uses: actions/checkout@v6 - name: Initialize CodeQL uses: github/codeql-action/init@v3 diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index e31dcc975..ffba32062 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -11,7 +11,7 @@ jobs: runs-on: ubuntu-latest steps: - name: 'Checkout repository' - uses: actions/checkout@v5 + uses: actions/checkout@v6 - name: 'Dependency Review' uses: actions/dependency-review-action@v4 with: diff --git a/.github/workflows/e2e-test-pr.yml b/.github/workflows/e2e-test-pr.yml index e5973ebbe..86809d177 100644 --- a/.github/workflows/e2e-test-pr.yml +++ b/.github/workflows/e2e-test-pr.yml @@ -48,7 +48,7 @@ jobs: # Check out merge commit - name: Checkout PR - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: ref: ${{ inputs.sha }} fetch-depth: 0 @@ -150,7 +150,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: fetch-depth: 0 submodules: 'recursive' diff --git a/.github/workflows/e2e-test.yml b/.github/workflows/e2e-test.yml index bde464c23..2e62e6bd5 100644 --- a/.github/workflows/e2e-test.yml +++ b/.github/workflows/e2e-test.yml @@ -57,7 +57,7 @@ jobs: steps: - name: Clone Repository with SHA if: ${{ inputs.sha != '' }} - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: fetch-depth: 0 submodules: 'recursive' @@ -65,7 +65,7 @@ jobs: - name: Clone Repository without SHA if: ${{ inputs.sha == '' }} - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: fetch-depth: 0 submodules: 'recursive' @@ -111,7 +111,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: fetch-depth: 0 submodules: 'recursive' @@ -178,7 +178,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: fetch-depth: 0 submodules: 'recursive' diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml index 7a3ee5f37..843a41c4d 100644 --- a/.github/workflows/labeler.yml +++ b/.github/workflows/labeler.yml @@ -18,7 +18,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v5 + uses: actions/checkout@v6 - name: Run Labeler uses: crazy-max/ghaction-github-labeler@24d110aa46a59976b8a7f35518cb7f14f434c916 diff --git a/.github/workflows/nightly-smoke-tests.yml b/.github/workflows/nightly-smoke-tests.yml index d905a1265..644ea9ce4 100644 --- a/.github/workflows/nightly-smoke-tests.yml +++ b/.github/workflows/nightly-smoke-tests.yml @@ -19,7 +19,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: ref: dev diff --git a/.github/workflows/publish-pypi.yaml b/.github/workflows/publish-pypi.yaml index 87a002bbe..a791be4c9 100644 --- a/.github/workflows/publish-pypi.yaml +++ b/.github/workflows/publish-pypi.yaml @@ -12,7 +12,7 @@ jobs: environment: pypi-release steps: - name: Checkout - uses: actions/checkout@v5 + uses: actions/checkout@v6 - name: Setup Python uses: actions/setup-python@v6 diff --git a/.github/workflows/release-cross-repo-test.yml b/.github/workflows/release-cross-repo-test.yml index 62f4bea47..69bf8031f 100644 --- a/.github/workflows/release-cross-repo-test.yml +++ b/.github/workflows/release-cross-repo-test.yml @@ -13,7 +13,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout linode_api4 repository - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: fetch-depth: 0 submodules: 'recursive' @@ -30,7 +30,7 @@ jobs: python-version: '3.10' - name: Checkout ansible repo - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: repository: linode/ansible_linode path: .ansible/collections/ansible_collections/linode/cloud From 7faf88771bd21fefbbe9bc75c97f2276e093a32a Mon Sep 17 00:00:00 2001 From: srbhaakamai Date: Wed, 3 Dec 2025 03:06:35 +0530 Subject: [PATCH 05/13] Python SDK for ACLP Alerts (#589) * DI-26927 Python SDK code for GET Alert Definitions * DI-26927 Python SDK code modified for client code * DI-26927 Added Unit test cases and missing classes * DI-26927 made corrections to keep code consistent * DI-26927 Updated Unit and Integratoion Tests * [dev 51950af] DI-26927 Updated Unit and Integratoion Tests * Revert "DI-26927 Updated Unit and Integratoion Tests" This reverts commit 51950aff939f919024df5176b6cf5dcefb0164e6. * Revert "[dev 51950af] DI-26927 Updated Unit and Integratoion Tests" This reverts commit 6ca6a5ab0111fff988732096513c376388957c85. * DI-26927 Updated Unit and Integratoion Tests * Remove .venv from repo and add to .gitignore * DI-26927 reverted git ignore * DI-26927 reverted conftest,py * DI-26927 added accidentlly deleted file * DI-26927 Corrected Integration and Unit Test cases for Alerting APIs * DI-26927 Reverted conftest.py and check integration without those changes * DI-26927 fixed integration test for firewall and added time for alert update before deletion * DI-26927 fixed changing monitor.py as per review comments * DI-26927 fixed changing monitor.py as per review comments * DI-26927 fixed changing monitor.py as per review comments * DI-26927 Intermediate change to address internal review comments * DI-26927 CLosed review comments * DI-26927 CLosed review comments * DI-27156 closed review comments from Ketan * DI-27156 closed review comments from Ketan * DI-27156 fixed unit test cases post review comments fixes * DI-27156 Updated docstring and make unit test cases changes * DI-27156 Added unit test cases post review comments fixes * DI-27156 Added unit test cases post review comments fixes * tests(monitor): add MonitorAlertDefinitionsTest and update fixtures for alert-definitions * DI-27156 Added unit test cases post review comments fixes * DI-27156 Updated docstring * DI-27156 Updated unit test errors * removed unwanted files * added missing doxcstring * added missing doxcstring * added missing doxcstring * rmoved test files * closed final review comments * fixing integration test issues * Corrected Integration Test Case * DI-26927 Corrected json to object modifications issues * DI-26927 Corrected json to object modifications issues * DI-26927 Unit test corrections for mofied functions name * Delete test/fixtures/monitor/services/dbaas/alert-definitions.json Not Required * Delete test.py Not Required here required for local testing only * Delete test/fixtures/monitor/alert-definitions.json Not required here * DI-26927 reverted fixtures.py as its not necesary to change * DI-26927 reverted fixtures.py as its not necesary to change * DI-26927 reverted fixtures.py as its not necesary to change * DI-26927 fixed linting error * DI-26927 fixed linting error * DI-26927 fixed lint error caught in CI * Update test/unit/groups/monitor_api_test.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update test/integration/models/monitor/test_monitor.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update test/integration/models/monitor/test_monitor.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update test/integration/models/monitor/test_monitor.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update linode_api4/objects/monitor.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * DI-26927 fixed copilot comments * fixed review comments from APIv4 team * fixed review comments from APIv4 team * fixed review comments from APIv4 team * fixed review comments from APIv4 team * DI-26927 fixed review comments from API v4 team * fixed review comments from APIv4 team * fixed lint errors * Update linode_api4/groups/monitor.py Co-authored-by: Ye Chen <127243817+yec-akamai@users.noreply.github.com> * Update linode_api4/groups/monitor.py Co-authored-by: Ye Chen <127243817+yec-akamai@users.noreply.github.com> * fixed review errors * fixed unittest * fixed unittest * fixed unittest * fixed unittest * fixed unittest * fixed unittest * added update use case to integration test * Update linode_api4/objects/monitor.py Co-authored-by: Ye Chen <127243817+yec-akamai@users.noreply.github.com> * Update linode_api4/objects/monitor.py Co-authored-by: Ye Chen <127243817+yec-akamai@users.noreply.github.com> * Update linode_api4/objects/monitor.py Co-authored-by: Ye Chen <127243817+yec-akamai@users.noreply.github.com> * Update linode_api4/objects/monitor.py Co-authored-by: Ye Chen <127243817+yec-akamai@users.noreply.github.com> * fixed further review comments * Updated integration test * Updated integration test with more assert statements * Fix Linode interfaces property (#604) * Fix test for interfaces (#605) * Fix test * changed as per github-advanced-security comments * fixed formatting errors using python black * Migrate test fixtures discovery to be with pathlib (#599) * Migrate test fixtures discovery to be with pathlib * Apply suggestion from @Copilot Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Apply suggestion from @Copilot Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update linode_api4/objects/monitor.py Co-authored-by: Ye Chen <127243817+yec-akamai@users.noreply.github.com> * Fixed Copilot comments on unit and integration tests * corrected unit test assert statement * Added comment as per copilot suggestion for save() and delete() * resolved comments from copilot commented whereever not applicable * Test: verify automatic GPG signing is working * Incorporated copilot comments * Removed serialisabel file from commit * Fix Lint errors * fixed review comments fo mutable * fixed review comments to keep it in line with SDK guidelines * Addressed review comments on AlertDefinition class * Comprehensive monitor API improvements and code quality fixes - Removed unused imports and optimized import order - Updated test cases for monitor integration - Enhanced monitor objects with proper type annotations - Improved monitor groups with better error handling - Applied code review feedback and best practices * Applied review comments across * Comprehensive monitor API fixes and improvements - Fixed json_object parameter issues in AlertDefinition properties - Corrected list type annotations for AlertChannelEnvelope - Updated integration tests with proper status handling for alert definitions - Applied review comments for better code quality - Enhanced type annotations and import organization - Improved error handling in monitor group methods * change entity id type as per review comments * reverted copilot comments as per the suggestion unit and integration test passed after the changes * removed unused imports * removed unused imports * Refactored integration test * changed _class to alert_class as per review suggestions * changed alert_class to service_class as per review suggestions * Update linode_api4/objects/monitor.py * Update test/integration/models/monitor/test_monitor.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update test/integration/models/monitor/test_monitor.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update linode_api4/groups/monitor.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update test/integration/models/monitor/test_monitor.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update linode_api4/groups/monitor.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update test/fixtures/monitor_alert-definitions.json * Apply suggestion from @zliang-akamai * Apply suggestion from @zliang-akamai --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Ye Chen <127243817+yec-akamai@users.noreply.github.com> Co-authored-by: Zhiwei Liang <121905282+zliang-akamai@users.noreply.github.com> Co-authored-by: Zhiwei Liang --- linode_api4/groups/monitor.py | 145 ++++++++++- linode_api4/objects/monitor.py | 243 +++++++++++++++++- test/fixtures/monitor_alert-definitions.json | 26 ++ ...itor_services_dbaas_alert-definitions.json | 52 ++++ ...ervices_dbaas_alert-definitions_12345.json | 44 ++++ test/integration/conftest.py | 7 + .../models/monitor/test_monitor.py | 110 ++++++++ test/unit/groups/monitor_api_test.py | 76 +++++- 8 files changed, 689 insertions(+), 14 deletions(-) create mode 100644 test/fixtures/monitor_alert-definitions.json create mode 100644 test/fixtures/monitor_services_dbaas_alert-definitions.json create mode 100644 test/fixtures/monitor_services_dbaas_alert-definitions_12345.json diff --git a/linode_api4/groups/monitor.py b/linode_api4/groups/monitor.py index 2dbfd2285..66943ade5 100644 --- a/linode_api4/groups/monitor.py +++ b/linode_api4/groups/monitor.py @@ -1,18 +1,21 @@ -__all__ = [ - "MonitorGroup", -] from typing import Any, Optional from linode_api4 import PaginatedList from linode_api4.errors import UnexpectedResponseError from linode_api4.groups import Group from linode_api4.objects import ( + AlertChannel, + AlertDefinition, MonitorDashboard, MonitorMetricsDefinition, MonitorService, MonitorServiceToken, ) +__all__ = [ + "MonitorGroup", +] + class MonitorGroup(Group): """ @@ -145,3 +148,139 @@ def create_token( "Unexpected response when creating token!", json=result ) return MonitorServiceToken(token=result["token"]) + + def alert_definitions( + self, + *filters, + service_type: Optional[str] = None, + ) -> PaginatedList: + """ + Retrieve alert definitions. + + Returns a paginated collection of :class:`AlertDefinition` objects. If you + need to obtain a single :class:`AlertDefinition`, use :meth:`LinodeClient.load` + and supply the `service_type` as the parent identifier, for example: + + alerts = client.monitor.alert_definitions() + alerts_by_service = client.monitor.alert_definitions(service_type="dbaas") + .. note:: This endpoint is in beta and requires using the v4beta base URL. + + API Documentation: + https://techdocs.akamai.com/linode-api/reference/get-alert-definitions + https://techdocs.akamai.com/linode-api/reference/get-alert-definitions-for-service-type + + :param service_type: Optional service type to scope the query (e.g. ``"dbaas"``). + :type service_type: Optional[str] + :param filters: Optional filtering expressions to apply to the returned + collection. See :doc:`Filtering Collections`. + + :returns: A paginated list of :class:`AlertDefinition` objects. + :rtype: PaginatedList[AlertDefinition] + """ + + endpoint = "/monitor/alert-definitions" + if service_type: + endpoint = f"/monitor/services/{service_type}/alert-definitions" + + # Requesting a list + return self.client._get_and_filter( + AlertDefinition, *filters, endpoint=endpoint + ) + + def alert_channels(self, *filters) -> PaginatedList: + """ + List alert channels for the authenticated account. + + Returns a paginated collection of :class:`AlertChannel` objects which + describe destinations for alert notifications (for example: email + lists, webhooks, PagerDuty, Slack, etc.). By default this method + returns all channels visible to the authenticated account; you can + supply optional filter expressions to restrict the results. + + Examples: + channels = client.monitor.alert_channels() + + .. note:: This endpoint is in beta and requires using the v4beta base URL. + + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-alert-channels + + :param filters: Optional filter expressions to apply to the collection. + See :doc:`Filtering Collections` for details. + :returns: A paginated list of :class:`AlertChannel` objects. + :rtype: PaginatedList[AlertChannel] + """ + return self.client._get_and_filter(AlertChannel, *filters) + + def create_alert_definition( + self, + service_type: str, + label: str, + severity: int, + channel_ids: list[int], + rule_criteria: dict, + trigger_conditions: dict, + entity_ids: Optional[list[str]] = None, + description: Optional[str] = None, + ) -> AlertDefinition: + """ + Create a new alert definition for a given service type. + + The alert definition configures when alerts are fired and which channels + are notified. + + .. note:: This endpoint is in beta and requires using the v4beta base URL. + + API Documentation: https://techdocs.akamai.com/linode-api/reference/post-alert-definition-for-service-type + + :param service_type: Service type for which to create the alert definition + (e.g. ``"dbaas"``). + :type service_type: str + :param label: Human-readable label for the alert definition. + :type label: str + :param severity: Severity level for the alert (numeric severity used by API). + :type severity: int + :param channel_ids: List of alert channel IDs to notify when the alert fires. + :type channel_ids: list[int] + :param rule_criteria: Rule criteria that determine when the alert + should be evaluated. Structure depends on the service + metric definitions. + :type rule_criteria: dict + :param trigger_conditions: Trigger conditions that define when + the alert should transition state. + :type trigger_conditions: dict + :param entity_ids: (Optional) Restrict the alert to a subset of entity IDs. + :type entity_ids: Optional[list[str]] + :param description: (Optional) Longer description for the alert definition. + :type description: Optional[str] + + :returns: The newly created :class:`AlertDefinition`. + :rtype: AlertDefinition + + .. note:: + For updating an alert definition, use the ``save()`` method on the :class:`AlertDefinition` object. + For deleting an alert definition, use the ``delete()`` method directly on the :class:`AlertDefinition` object. + """ + params = { + "label": label, + "severity": severity, + "channel_ids": channel_ids, + "rule_criteria": rule_criteria, + "trigger_conditions": trigger_conditions, + } + if description is not None: + params["description"] = description + if entity_ids is not None: + params["entity_ids"] = entity_ids + + # API will validate service_type and return an error if missing + result = self.client.post( + f"/monitor/services/{service_type}/alert-definitions", data=params + ) + + if "id" not in result: + raise UnexpectedResponseError( + "Unexpected response when creating alert definition!", + json=result, + ) + + return AlertDefinition(self.client, result["id"], service_type, result) diff --git a/linode_api4/objects/monitor.py b/linode_api4/objects/monitor.py index fb339a0fd..4315e4c2e 100644 --- a/linode_api4/objects/monitor.py +++ b/linode_api4/objects/monitor.py @@ -1,15 +1,24 @@ +from dataclasses import dataclass, field +from typing import List, Optional, Union + +from linode_api4.objects import DerivedBase +from linode_api4.objects.base import Base, Property +from linode_api4.objects.serializable import JSONObject, StrEnum + __all__ = [ + "AggregateFunction", + "Alert", + "AlertChannel", + "AlertDefinition", + "AlertType", + "Alerts", "MonitorDashboard", "MonitorMetricsDefinition", "MonitorService", "MonitorServiceToken", - "AggregateFunction", + "RuleCriteria", + "TriggerConditions", ] -from dataclasses import dataclass, field -from typing import List, Optional - -from linode_api4.objects.base import Base, Property -from linode_api4.objects.serializable import JSONObject, StrEnum class AggregateFunction(StrEnum): @@ -63,6 +72,15 @@ class MetricType(StrEnum): summary = "summary" +class CriteriaCondition(StrEnum): + """ + Enum for supported CriteriaCondition + Currently, only ALL is supported. + """ + + all = "ALL" + + class MetricUnit(StrEnum): """ Enum for supported metric units. @@ -226,3 +244,216 @@ class MonitorServiceToken(JSONObject): """ token: str = "" + + +@dataclass +class TriggerConditions(JSONObject): + """ + Represents the trigger/evaluation configuration for an alert. + + Expected JSON example: + "trigger_conditions": { + "criteria_condition": "ALL", + "evaluation_period_seconds": 60, + "polling_interval_seconds": 10, + "trigger_occurrences": 3 + } + + Fields: + - criteria_condition: "ALL" (currently, only "ALL" is supported) + - evaluation_period_seconds: seconds over which the rule(s) are evaluated + - polling_interval_seconds: how often metrics are sampled (seconds) + - trigger_occurrences: how many consecutive evaluation periods must match to trigger + """ + + criteria_condition: CriteriaCondition = CriteriaCondition.all + evaluation_period_seconds: int = 0 + polling_interval_seconds: int = 0 + trigger_occurrences: int = 0 + + +@dataclass +class DimensionFilter(JSONObject): + """ + A single dimension filter used inside a Rule. + + Example JSON: + { + "dimension_label": "node_type", + "label": "Node Type", + "operator": "eq", + "value": "primary" + } + """ + + dimension_label: str = "" + label: str = "" + operator: str = "" + value: Optional[str] = None + + +@dataclass +class Rule(JSONObject): + """ + A single rule within RuleCriteria. + Example JSON: + { + "aggregate_function": "avg", + "dimension_filters": [ ... ], + "label": "Memory Usage", + "metric": "memory_usage", + "operator": "gt", + "threshold": 95, + "unit": "percent" + } + """ + + aggregate_function: Optional[Union[AggregateFunction, str]] = None + dimension_filters: Optional[List[DimensionFilter]] = None + label: str = "" + metric: str = "" + operator: str = "" + threshold: Optional[float] = None + unit: Optional[str] = None + + +@dataclass +class RuleCriteria(JSONObject): + """ + Container for a list of Rule objects, matching the JSON shape: + "rule_criteria": { "rules": [ { ... }, ... ] } + """ + + rules: Optional[List[Rule]] = None + + +@dataclass +class Alert(JSONObject): + """ + Represents an alert definition reference within an AlertChannel. + + Fields: + - id: int - Unique identifier of the alert definition. + - label: str - Human-readable name for the alert definition. + - type: str - Type of the alert (e.g., 'alerts-definitions'). + - url: str - API URL for the alert definition. + """ + + id: int = 0 + label: str = "" + _type: str = field(default="", metadata={"json_key": "type"}) + url: str = "" + + +@dataclass +class Alerts(JSONObject): + """ + Represents a collection of alert definitions within an AlertChannel. + + Fields: + - items: List[Alert] - List of alert definitions. + """ + + items: List[Alert] = field(default_factory=list) + + +class AlertType(StrEnum): + """ + Enumeration of alert origin types used by alert definitions. + + Values: + - system: Alerts that originate from the system (built-in or platform-managed). + - user: Alerts created and managed by users (custom alerts). + + The API uses this value in the `type` field of alert-definition responses. + This enum can be used to compare or validate the `type` value when + processing alert definitions. + """ + + system = "system" + user = "user" + + +class AlertDefinition(DerivedBase): + """ + Represents an alert definition for a monitor service. + + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-alert-definition + """ + + api_endpoint = "/monitor/services/{service_type}/alert-definitions/{id}" + derived_url_path = "alert-definitions" + parent_id_name = "service_type" + id_attribute = "id" + + properties = { + "id": Property(identifier=True), + "service_type": Property(identifier=True), + "label": Property(mutable=True), + "severity": Property(mutable=True), + "type": Property(mutable=True), + "status": Property(mutable=True), + "has_more_resources": Property(mutable=True), + "rule_criteria": Property(mutable=True, json_object=RuleCriteria), + "trigger_conditions": Property( + mutable=True, json_object=TriggerConditions + ), + "alert_channels": Property(mutable=True, json_object=Alerts), + "created": Property(is_datetime=True), + "updated": Property(is_datetime=True), + "updated_by": Property(), + "created_by": Property(), + "entity_ids": Property(mutable=True), + "description": Property(mutable=True), + "service_class": Property(alias_of="class"), + } + + +@dataclass +class EmailChannelContent(JSONObject): + """ + Represents the content for an email alert channel. + """ + + email_addresses: Optional[List[str]] = None + + +@dataclass +class ChannelContent(JSONObject): + """ + Represents the content block for an AlertChannel, which varies by channel type. + """ + + email: Optional[EmailChannelContent] = None + # Other channel types like 'webhook', 'slack' could be added here as Optional fields. + + +class AlertChannel(Base): + """ + Represents an alert channel used to deliver notifications when alerts + fire. Alert channels define a destination and configuration for + notifications (for example: email lists, webhooks, PagerDuty, Slack, etc.). + + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-alert-channels + + This class maps to the Monitor API's `/monitor/alert-channels` resource + and is used by the SDK to list, load, and inspect channels. + + NOTE: Only read operations are supported for AlertChannel at this time. + Create, update, and delete (CRUD) operations are not allowed. + """ + + api_endpoint = "/monitor/alert-channels/{id}" + + properties = { + "id": Property(identifier=True), + "label": Property(), + "type": Property(), + "channel_type": Property(), + "alerts": Property(mutable=False, json_object=Alerts), + "content": Property(mutable=False, json_object=ChannelContent), + "created": Property(is_datetime=True), + "updated": Property(is_datetime=True), + "created_by": Property(), + "updated_by": Property(), + } diff --git a/test/fixtures/monitor_alert-definitions.json b/test/fixtures/monitor_alert-definitions.json new file mode 100644 index 000000000..92b6e0e4c --- /dev/null +++ b/test/fixtures/monitor_alert-definitions.json @@ -0,0 +1,26 @@ +{ + "data": [ + { + "id": 12345, + "label": "Test Alert for DBAAS", + "service_type": "dbaas", + "severity": 1, + "type": "user", + "description": "A test alert for dbaas service", + "entity_ids": ["13217"], + "alert_channels": [], + "has_more_resources": false, + "rule_criteria": null, + "trigger_conditions": null, + "class": "alert", + "notification_groups": [], + "status": "active", + "created": "2024-01-01T00:00:00", + "updated": "2024-01-01T00:00:00", + "updated_by": "tester" + } + ], + "page": 1, + "pages": 1, + "results": 1 +} diff --git a/test/fixtures/monitor_services_dbaas_alert-definitions.json b/test/fixtures/monitor_services_dbaas_alert-definitions.json new file mode 100644 index 000000000..0c7067a8a --- /dev/null +++ b/test/fixtures/monitor_services_dbaas_alert-definitions.json @@ -0,0 +1,52 @@ +{ + "data": [ + { + "id": 12345, + "label": "Test Alert for DBAAS", + "service_type": "dbaas", + "severity": 1, + "type": "user", + "description": "A test alert for dbaas service", + "entity_ids": [ + "13217" + ], + "alert_channels": [], + "has_more_resources": false, + "rule_criteria": { + "rules": [ + { + "aggregate_function": "avg", + "dimension_filters": [ + { + "dimension_label": "node_type", + "label": "Node Type", + "operator": "eq", + "value": "primary" + } + ], + "label": "High CPU Usage", + "metric": "cpu_usage", + "operator": "gt", + "threshold": 90, + "unit": "percent" + } + ] + }, + "trigger_conditions": { + "criteria_condition": "ALL", + "evaluation_period_seconds": 300, + "polling_interval_seconds": 60, + "trigger_occurrences": 3 + }, + "class": "alert", + "notification_groups": [], + "status": "active", + "created": "2024-01-01T00:00:00", + "updated": "2024-01-01T00:00:00", + "updated_by": "tester" + } + ], + "page": 1, + "pages": 1, + "results": 1 +} diff --git a/test/fixtures/monitor_services_dbaas_alert-definitions_12345.json b/test/fixtures/monitor_services_dbaas_alert-definitions_12345.json new file mode 100644 index 000000000..822e18b24 --- /dev/null +++ b/test/fixtures/monitor_services_dbaas_alert-definitions_12345.json @@ -0,0 +1,44 @@ +{ + "id": 12345, + "label": "Test Alert for DBAAS", + "service_type": "dbaas", + "severity": 1, + "type": "user", + "description": "A test alert for dbaas service", + "entity_ids": [ + "13217" + ], + "alert_channels": [], + "has_more_resources": false, + "rule_criteria": { + "rules": [ + { + "aggregate_function": "avg", + "dimension_filters": [ + { + "dimension_label": "node_type", + "label": "Node Type", + "operator": "eq", + "value": "primary" + } + ], + "label": "High CPU Usage", + "metric": "cpu_usage", + "operator": "gt", + "threshold": 90, + "unit": "percent" + } + ] + }, + "trigger_conditions": { + "criteria_condition": "ALL", + "evaluation_period_seconds": 300, + "polling_interval_seconds": 60, + "trigger_occurrences": 3 + }, + "class": "alert", + "status": "active", + "created": "2024-01-01T00:00:00", + "updated": "2024-01-01T00:00:00", + "updated_by": "tester" +} diff --git a/test/integration/conftest.py b/test/integration/conftest.py index 3692269dc..caac7ca01 100644 --- a/test/integration/conftest.py +++ b/test/integration/conftest.py @@ -34,6 +34,7 @@ ENV_REGION_OVERRIDE = "LINODE_TEST_REGION_OVERRIDE" ENV_API_CA_NAME = "LINODE_API_CA" RUN_LONG_TESTS = "RUN_LONG_TESTS" +SKIP_E2E_FIREWALL = "SKIP_E2E_FIREWALL" def get_token(): @@ -85,6 +86,12 @@ def run_long_tests(): @pytest.fixture(autouse=True, scope="session") def e2e_test_firewall(test_linode_client): + # Allow skipping firewall creation for local runs: set SKIP_E2E_FIREWALL=1 + if os.environ.get(SKIP_E2E_FIREWALL): + # Yield None so fixtures depending on this receive a falsy value but the session continues. + yield None + return + def is_valid_ipv4(address): try: ipaddress.IPv4Address(address) diff --git a/test/integration/models/monitor/test_monitor.py b/test/integration/models/monitor/test_monitor.py index eed85ab14..b6cf40b54 100644 --- a/test/integration/models/monitor/test_monitor.py +++ b/test/integration/models/monitor/test_monitor.py @@ -1,3 +1,4 @@ +import time from test.integration.helpers import ( get_test_label, send_request_when_resource_available, @@ -8,6 +9,8 @@ from linode_api4 import LinodeClient from linode_api4.objects import ( + AlertDefinition, + ApiError, MonitorDashboard, MonitorMetricsDefinition, MonitorService, @@ -163,3 +166,110 @@ def test_my_db_functionality(test_linode_client, test_create_and_test_db): assert isinstance(token, MonitorServiceToken) assert len(token.token) > 0, "Token should not be empty" assert hasattr(token, "token"), "Response object has no 'token' attribute" + + +def test_integration_create_get_update_delete_alert_definition( + test_linode_client, +): + """E2E: create an alert definition, fetch it, update it, then delete it. + + This test attempts to be resilient: it cleans up the created definition + in a finally block so CI doesn't leak resources. + """ + client = test_linode_client + service_type = "dbaas" + label = get_test_label() + "-e2e-alert" + + rule_criteria = { + "rules": [ + { + "aggregate_function": "avg", + "dimension_filters": [ + { + "dimension_label": "node_type", + "label": "Node Type", + "operator": "eq", + "value": "primary", + } + ], + "label": "Memory Usage", + "metric": "memory_usage", + "operator": "gt", + "threshold": 90, + "unit": "percent", + } + ] + } + trigger_conditions = { + "criteria_condition": "ALL", + "evaluation_period_seconds": 300, + "polling_interval_seconds": 300, + "trigger_occurrences": 1, + } + + # Make the label unique and ensure it begins/ends with an alphanumeric char + label = f"{label}-{int(time.time())}" + description = "E2E alert created by SDK integration test" + + # Pick an existing alert channel to attach to the definition; skip if none + channels = list(client.monitor.alert_channels()) + if not channels: + pytest.skip( + "No alert channels available on account for creating alert definitions" + ) + + created = None + + def wait_for_alert_ready(alert_id, service_type: str): + timeout = 360 # maximum time in seconds to wait for alert creation + initial_timeout = 1 + start = time.time() + interval = initial_timeout + alert = client.load(AlertDefinition, alert_id, service_type) + while ( + getattr(alert, "status", None) == "in progress" + and (time.time() - start) < timeout + ): + time.sleep(interval) + interval *= 2 + try: + alert._api_get() + except ApiError as e: + # transient errors while polling; continue until timeout + if e.status != 404: + raise + return alert + + try: + # Create the alert definition using API-compliant top-level fields + created = client.monitor.create_alert_definition( + service_type=service_type, + label=label, + severity=1, + description=description, + channel_ids=[channels[0].id], + rule_criteria=rule_criteria, + trigger_conditions=trigger_conditions, + ) + + assert created.id + assert getattr(created, "label", None) == label + + created = wait_for_alert_ready(created.id, service_type) + + updated = client.load(AlertDefinition, created.id, service_type) + updated.label = f"{label}-updated" + updated.save() + + updated = wait_for_alert_ready(updated.id, service_type) + + assert created.id == updated.id + assert updated.label == f"{label}-updated" + + finally: + if created: + # Best-effort cleanup; allow transient errors. + delete_alert = client.load( + AlertDefinition, created.id, service_type + ) + delete_alert.delete() diff --git a/test/unit/groups/monitor_api_test.py b/test/unit/groups/monitor_api_test.py index c34db068f..9515895ae 100644 --- a/test/unit/groups/monitor_api_test.py +++ b/test/unit/groups/monitor_api_test.py @@ -1,6 +1,11 @@ -from test.unit.base import MonitorClientBaseCase +from test.unit.base import ClientBaseCase, MonitorClientBaseCase -from linode_api4.objects import AggregateFunction, EntityMetricOptions +from linode_api4 import PaginatedList +from linode_api4.objects import ( + AggregateFunction, + AlertDefinition, + EntityMetricOptions, +) class MonitorAPITest(MonitorClientBaseCase): @@ -11,7 +16,7 @@ class MonitorAPITest(MonitorClientBaseCase): def test_fetch_metrics(self): service_type = "dbaas" url = f"/monitor/services/{service_type}/metrics" - with self.mock_post(url) as m: + with self.mock_post(url) as mock_post: metrics = self.client.metrics.fetch_metrics( service_type, entity_ids=[13217, 13316], @@ -26,8 +31,8 @@ def test_fetch_metrics(self): ) # assert call data - assert m.call_url == url - assert m.call_data == { + assert mock_post.call_url == url + assert mock_post.call_data == { "entity_ids": [13217, 13316], "metrics": [ {"name": "avg_read_iops", "aggregate_function": "avg"}, @@ -50,3 +55,64 @@ def test_fetch_metrics(self): assert metrics.stats.executionTimeMsec == 21 assert metrics.stats.seriesFetched == "2" assert not metrics.isPartial + + +class MonitorAlertDefinitionsTest(ClientBaseCase): + def test_alert_definition(self): + service_type = "dbaas" + url = f"/monitor/services/{service_type}/alert-definitions" + with self.mock_get(url) as mock_get: + alert = self.client.monitor.alert_definitions( + service_type=service_type + ) + + assert mock_get.call_url == url + + # assert collection and element types + assert isinstance(alert, PaginatedList) + assert isinstance(alert[0], AlertDefinition) + + # fetch the raw JSON from the client and assert its fields + raw = self.client.get(url) + # raw is a paginated response; check first item's fields + first = raw["data"][0] + assert first["label"] == "Test Alert for DBAAS" + assert first["service_type"] == "dbaas" + assert first["status"] == "active" + assert first["created"] == "2024-01-01T00:00:00" + + def test_create_alert_definition(self): + service_type = "dbaas" + url = f"/monitor/services/{service_type}/alert-definitions" + result = { + "id": 67890, + "label": "Created Alert", + "service_type": service_type, + "severity": 1, + "status": "active", + } + + with self.mock_post(result) as mock_post: + alert = self.client.monitor.create_alert_definition( + service_type=service_type, + label="Created Alert", + severity=1, + channel_ids=[1, 2], + rule_criteria={"rules": []}, + trigger_conditions={"criteria_condition": "ALL"}, + entity_ids=["13217"], + description="created via test", + ) + + assert mock_post.call_url == url + # payload should include the provided fields + assert mock_post.call_data["label"] == "Created Alert" + assert mock_post.call_data["severity"] == 1 + assert "channel_ids" in mock_post.call_data + + assert isinstance(alert, AlertDefinition) + assert alert.id == 67890 + + # fetch the same response from the client and assert + resp = self.client.post(url, data={}) + assert resp["label"] == "Created Alert" 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 06/13] 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 07/13] 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 08/13] 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 09/13] 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 10/13] 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 11/13] 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 12/13] 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 13/13] 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): """