diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dd8eeea17..565cad0c2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,7 +12,35 @@ on: jobs: lint: runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read steps: + # Enforce TPT-1234: prefix on PR titles, with the following exemptions: + # - PRs labeled 'dependencies' (e.g. Dependabot PRs) + # - PRs labeled 'hotfix' (urgent fixes that may not have a ticket) + # - PRs labeled 'community-contribution' (external contributors without TPT tickets) + # - PRs labeled 'ignore-for-release' (release PRs that don't need a ticket prefix) + - name: Validate PR Title + if: github.event_name == 'pull_request' + uses: amannn/action-semantic-pull-request@v6 + with: + types: | + TPT-\d+ + requireScope: false + # Override the default header pattern to allow hyphens and digits in the type + # (e.g. "TPT-4298: Description"). The default pattern only matches word + # characters (\w) which excludes hyphens. + headerPattern: '^([\w-]+):\s?(.*)$' + headerPatternCorrespondence: type, subject + ignoreLabels: | + dependencies + hotfix + community-contribution + ignore-for-release + env: + GITHUB_TOKEN: ${{ github.token }} + - name: checkout repo uses: actions/checkout@v6 @@ -31,7 +59,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ['3.9', '3.10', '3.11', '3.12', '3.13'] + python-version: ['3.10', '3.11', '3.12', '3.13', '3.14'] steps: - uses: actions/checkout@v6 - uses: actions/setup-python@v6 diff --git a/.github/workflows/clean-release-notes.yml b/.github/workflows/clean-release-notes.yml new file mode 100644 index 000000000..9b7305275 --- /dev/null +++ b/.github/workflows/clean-release-notes.yml @@ -0,0 +1,37 @@ +name: Clean Release Notes + +on: + release: + types: [published] + +jobs: + clean-release-notes: + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - name: Remove ticket prefixes from release notes + uses: actions/github-script@v9 + with: + script: | + const release = context.payload.release; + + let body = release.body; + + if (!body) { + console.log("Release body empty, nothing to clean."); + return; + } + + // Remove ticket prefixes like "TPT-1234: " or "TPT-1234:" + body = body.replace(/TPT-\d+:\s*/g, ''); + + await github.rest.repos.updateRelease({ + owner: context.repo.owner, + repo: context.repo.repo, + release_id: release.id, + body: body + }); + + console.log("Release notes cleaned."); diff --git a/.github/workflows/e2e-test-pr.yml b/.github/workflows/e2e-test-pr.yml index f765b0a0d..de95ac92b 100644 --- a/.github/workflows/e2e-test-pr.yml +++ b/.github/workflows/e2e-test-pr.yml @@ -123,7 +123,7 @@ jobs: LINODE_CLI_OBJ_ACCESS_KEY: ${{ secrets.LINODE_CLI_OBJ_ACCESS_KEY }} LINODE_CLI_OBJ_SECRET_KEY: ${{ secrets.LINODE_CLI_OBJ_SECRET_KEY }} - - uses: actions/github-script@v8 + - uses: actions/github-script@v9 id: update-check-run if: ${{ inputs.pull_request_number != '' && fromJson(steps.commit-hash.outputs.data).repository.pullRequest.headRef.target.oid == inputs.sha }} env: diff --git a/.github/workflows/e2e-test.yml b/.github/workflows/e2e-test.yml index df1a41841..8a02599cc 100644 --- a/.github/workflows/e2e-test.yml +++ b/.github/workflows/e2e-test.yml @@ -55,8 +55,8 @@ on: - dev env: - DEFAULT_PYTHON_VERSION: "3.10" - EOL_PYTHON_VERSION: "3.9" + DEFAULT_PYTHON_VERSION: "3.13" + EOL_PYTHON_VERSION: "3.10" EXIT_STATUS: 0 jobs: @@ -105,7 +105,7 @@ jobs: - name: Upload Test Report as Artifact if: always() - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 with: name: test-report-file if-no-files-found: ignore @@ -241,7 +241,7 @@ jobs: steps: - name: Notify Slack id: main_message - uses: slackapi/slack-github-action@v2.1.1 + uses: slackapi/slack-github-action@v3 with: method: chat.postMessage token: ${{ secrets.SLACK_BOT_TOKEN }} @@ -273,7 +273,7 @@ jobs: - name: Test summary thread if: success() - uses: slackapi/slack-github-action@v2.1.1 + uses: slackapi/slack-github-action@v3 with: method: chat.postMessage token: ${{ secrets.SLACK_BOT_TOKEN }} diff --git a/.github/workflows/nightly-smoke-tests.yml b/.github/workflows/nightly-smoke-tests.yml index 644ea9ce4..c6697dc14 100644 --- a/.github/workflows/nightly-smoke-tests.yml +++ b/.github/workflows/nightly-smoke-tests.yml @@ -45,7 +45,7 @@ jobs: - name: Notify Slack if: always() && github.repository == 'linode/linode_api4-python' - uses: slackapi/slack-github-action@v2.1.1 + uses: slackapi/slack-github-action@v3 with: method: chat.postMessage token: ${{ secrets.SLACK_BOT_TOKEN }} diff --git a/.github/workflows/publish-pypi.yaml b/.github/workflows/publish-pypi.yaml index a791be4c9..86d854455 100644 --- a/.github/workflows/publish-pypi.yaml +++ b/.github/workflows/publish-pypi.yaml @@ -28,4 +28,4 @@ jobs: LINODE_SDK_VERSION: ${{ github.event.release.tag_name }} - name: Publish the release artifacts to PyPI - uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # pin@release/v1.13.0 + uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # pin@release/v1.14.0 diff --git a/.github/workflows/release-notify-slack.yml b/.github/workflows/release-notify-slack.yml index 4b01f094b..aa21d80e5 100644 --- a/.github/workflows/release-notify-slack.yml +++ b/.github/workflows/release-notify-slack.yml @@ -11,7 +11,7 @@ jobs: steps: - name: Notify Slack - Main Message id: main_message - uses: slackapi/slack-github-action@v2.1.1 + uses: slackapi/slack-github-action@v3 with: method: chat.postMessage token: ${{ secrets.SLACK_BOT_TOKEN }} diff --git a/CODEOWNERS b/CODEOWNERS index 69cb641ca..e023b0d14 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1,2 +1 @@ -* @linode/dx - +* @linode/dx @linode/dx-sdets diff --git a/linode_api4/groups/linode.py b/linode_api4/groups/linode.py index 2bd51fa97..a433a0dcf 100644 --- a/linode_api4/groups/linode.py +++ b/linode_api4/groups/linode.py @@ -322,7 +322,6 @@ def instance_create( :param firewall: The firewall to attach this Linode to. :type firewall: int or Firewall :param disk_encryption: The disk encryption policy for this Linode. - NOTE: Disk encryption may not currently be available to all users. :type disk_encryption: InstanceDiskEncryptionType or str :param interfaces: An array of Network Interfaces to add to this Linode’s Configuration Profile. At least one and up to three Interface objects can exist in this array. diff --git a/linode_api4/groups/lock.py b/linode_api4/groups/lock.py index 42cc58d80..2f19e2c1d 100644 --- a/linode_api4/groups/lock.py +++ b/linode_api4/groups/lock.py @@ -24,7 +24,7 @@ def __call__(self, *filters): locks = client.locks() - API Documentation: TBD + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-resource-locks :param filters: Any number of filters to apply to this query. See :doc:`Filtering Collections` @@ -44,7 +44,7 @@ def create( """ Creates a new Resource Lock for the specified entity. - API Documentation: TBD + API Documentation: https://techdocs.akamai.com/linode-api/reference/post-resource-lock :param entity_type: The type of entity to lock (e.g., "linode"). :type entity_type: str diff --git a/linode_api4/groups/monitor.py b/linode_api4/groups/monitor.py index 66943ade5..0d7f19ce8 100644 --- a/linode_api4/groups/monitor.py +++ b/linode_api4/groups/monitor.py @@ -1,4 +1,4 @@ -from typing import Any, Optional +from typing import Any, Optional, Union from linode_api4 import PaginatedList from linode_api4.errors import UnexpectedResponseError @@ -6,6 +6,8 @@ from linode_api4.objects import ( AlertChannel, AlertDefinition, + AlertDefinitionEntity, + AlertScope, MonitorDashboard, MonitorMetricsDefinition, MonitorService, @@ -202,7 +204,7 @@ def alert_channels(self, *filters) -> PaginatedList: .. 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 + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-notification-channels :param filters: Optional filter expressions to apply to the collection. See :doc:`Filtering Collections` for details. @@ -221,6 +223,8 @@ def create_alert_definition( trigger_conditions: dict, entity_ids: Optional[list[str]] = None, description: Optional[str] = None, + scope: Optional[Union[AlertScope, str]] = None, + regions: Optional[list[str]] = None, ) -> AlertDefinition: """ Create a new alert definition for a given service type. @@ -252,6 +256,10 @@ def create_alert_definition( :type entity_ids: Optional[list[str]] :param description: (Optional) Longer description for the alert definition. :type description: Optional[str] + :param scope: (Optional) Alert scope (for example: `account`, `entity`, or `region`). Defaults to `entity`. + :type scope: Optional[Union[AlertScope, str]] + :param regions: (Optional) Regions to monitor. + :type regions: Optional[list[str]] :returns: The newly created :class:`AlertDefinition`. :rtype: AlertDefinition @@ -267,10 +275,15 @@ def create_alert_definition( "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 + if description is not None: + params["description"] = description + if scope is not None: + params["scope"] = scope + if regions is not None: + params["regions"] = regions # API will validate service_type and return an error if missing result = self.client.post( @@ -284,3 +297,38 @@ def create_alert_definition( ) return AlertDefinition(self.client, result["id"], service_type, result) + + def alert_definition_entities( + self, + service_type: str, + id: int, + *filters, + ) -> PaginatedList: + """ + List entities associated with a specific alert definition. + + This endpoint supports pagination fields (`page`, `page_size`) in the API. + + .. note:: This endpoint is in beta and requires using the v4beta base URL. + + API Documentation: TODO + + :param service_type: Service type for the alert definition (e.g. `dbaas`). + :type service_type: str + :param id: Alert definition identifier. + :type id: int + :param filters: Optional filter expressions to apply to the collection. + See :doc:`Filtering Collections`. + + :returns: A paginated list of entities associated with the alert definition. + :rtype: PaginatedList[AlertDefinitionEntity] + """ + + endpoint = ( + f"/monitor/services/{service_type}/alert-definitions/{id}/entities" + ) + return self.client._get_and_filter( + AlertDefinitionEntity, + *filters, + endpoint=endpoint, + ) diff --git a/linode_api4/groups/object_storage.py b/linode_api4/groups/object_storage.py index 5ffab3ffc..d36690111 100644 --- a/linode_api4/groups/object_storage.py +++ b/linode_api4/groups/object_storage.py @@ -19,6 +19,7 @@ ObjectStorageACL, ObjectStorageBucket, ObjectStorageCluster, + ObjectStorageGlobalQuota, ObjectStorageKeyPermission, ObjectStorageKeys, ObjectStorageQuota, @@ -533,3 +534,18 @@ def quotas(self, *filters): :rtype: PaginatedList of ObjectStorageQuota """ return self.client._get_and_filter(ObjectStorageQuota, *filters) + + def global_quotas(self, *filters): + """ + Lists the active account-level Object Storage quotas applied to your account. + + 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 account-level Object Storage Quotas that matched the query. + :rtype: PaginatedList of ObjectStorageGlobalQuota + """ + return self.client._get_and_filter(ObjectStorageGlobalQuota, *filters) diff --git a/linode_api4/groups/volume.py b/linode_api4/groups/volume.py index 39d0aeaaa..847c3030c 100644 --- a/linode_api4/groups/volume.py +++ b/linode_api4/groups/volume.py @@ -48,7 +48,6 @@ def create(self, label, region=None, linode=None, size=20, **kwargs): :type tags: list[str] :param encryption: Whether the new Volume should opt in or out of disk encryption. :type encryption: str - Note: Block Storage Disk Encryption is not currently available to all users. :returns: The new Volume. :rtype: Volume """ diff --git a/linode_api4/objects/linode.py b/linode_api4/objects/linode.py index 3ffe4b232..bf2f12717 100644 --- a/linode_api4/objects/linode.py +++ b/linode_api4/objects/linode.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import copy import string import sys @@ -40,7 +42,11 @@ from linode_api4.objects.serializable import JSONObject, StrEnum from linode_api4.objects.vpc import VPC, VPCSubnet from linode_api4.paginated_list import PaginatedList -from linode_api4.util import drop_null_keys, generate_device_suffixes +from linode_api4.util import ( + drop_null_keys, + generate_device_suffixes, + normalize_as_list, +) PASSWORD_CHARS = string.ascii_letters + string.digits + string.punctuation MIN_DEVICE_LIMIT = 8 @@ -1246,14 +1252,14 @@ def _func(value): # create derived objects def config_create( self, - kernel=None, - label=None, - devices=[], - disks=[], - volumes=[], - interfaces=[], + kernel: Kernel | str | None = None, + label: str | None = None, + devices: "Disk | Volume | dict[str, Any] | list[Disk | Volume | dict[str, Any]] | None" = None, + disks: Disk | int | list[Disk | int] | None = None, + volumes: "Volume | int | list[Volume | int] | None" = None, + interfaces: list[ConfigInterface | dict[str, Any]] | None = None, **kwargs, - ): + ) -> Config: """ Creates a Linode Config with the given attributes. @@ -1263,10 +1269,13 @@ def config_create( :param label: The config label :param disks: The list of disks, starting at sda, to map to this config. :param volumes: The volumes, starting after the last disk, to map to this - config + config. :param devices: A list of devices to assign to this config, in device - index order. Values must be of type Disk or Volume. If this is - given, you may not include disks or volumes. + index order, a raw device mapping dict to pass directly to the API + (e.g. ``{"sda": {"disk_id": 123}, "sdb": Volume(...)}``), or + a single Disk or Volume. + If this is given, you may not include disks or volumes. + :param interfaces: A list of ConfigInterface objects or dicts to assign to this config. :param **kwargs: Any other arguments accepted by the api. :returns: A new Linode Config @@ -1274,6 +1283,8 @@ def config_create( # needed here to avoid circular imports from .volume import Volume # pylint: disable=import-outside-toplevel + interfaces = [] if interfaces is None else interfaces + hypervisor_prefix = "sd" if self.hypervisor == "kvm" else "xvd" device_limit = int( @@ -1288,52 +1299,83 @@ def config_create( for suffix in generate_device_suffixes(device_limit) ] - device_map = { - device_names[i]: None for i in range(0, len(device_names)) - } + def _flatten_device(device: Disk | Volume | dict | None): + if device is None: + return None + elif isinstance(device, Disk): + return {"disk_id": device.id} + elif isinstance(device, Volume): + return {"volume_id": device.id} + elif isinstance(device, dict): + return device + + raise TypeError("Disk, Volume, or dict expected!") + + def _device_entry(device: Disk | Volume | int, key: str): + if isinstance(device, (Disk, Volume)): + return _flatten_device(device) + + try: + device_id = int(device) + except (TypeError, ValueError): + raise TypeError( + "Disk, Volume, or integer ID expected!" + ) from None + + return {key: device_id} + + def _build_devices(): + # Devices is a dict, flatten and pass through + if isinstance(devices, dict): + return { + k: ( + _flatten_device(v) + if isinstance(v, (Disk, Volume)) + else v + ) + for k, v in devices.items() + } + + device_list = [] + + if devices: + device_list += [ + _flatten_device(device) + for device in normalize_as_list(devices) + ] + + if disks: + device_list += [ + _device_entry(disk, "disk_id") if disk is not None else None + for disk in normalize_as_list(disks) + ] + + if volumes: + device_list += [ + ( + _device_entry(volume, "volume_id") + if volume is not None + else None + ) + for volume in normalize_as_list(volumes) + ] + + return { + device_names[i]: device for i, device in enumerate(device_list) + } + # This validation is enforced for backwards compatibility but isn't + # technically needed anymore if devices and (disks or volumes): raise ValueError( 'You may not call config_create with "devices" and ' 'either of "disks" or "volumes" specified!' ) - if not devices: - if not isinstance(disks, list): - disks = [disks] - if not isinstance(volumes, list): - volumes = [volumes] - - devices = [] - - for d in disks: - if d is None: - devices.append(None) - elif isinstance(d, Disk): - devices.append(d) - else: - devices.append(Disk(self._client, int(d), self.id)) - - for v in volumes: - if v is None: - devices.append(None) - elif isinstance(v, Volume): - devices.append(v) - else: - devices.append(Volume(self._client, int(v))) - - if not devices: - raise ValueError("Must include at least one disk or volume!") + device_map = _build_devices() - for i, d in enumerate(devices): - if d is None: - pass - elif isinstance(d, Disk): - device_map[device_names[i]] = {"disk_id": d.id} - elif isinstance(d, Volume): - device_map[device_names[i]] = {"volume_id": d.id} - else: - raise TypeError("Disk or Volume expected!") + if len(device_map) < 1: + raise ValueError("Must include at least one disk or volume!") param_interfaces = [] for interface in interfaces: @@ -1406,7 +1448,6 @@ def disk_create( should already be set up, see :any:`ProfileGroup.ssh_keys` for details. :param disk_encryption: The disk encryption policy for this Linode. - NOTE: Disk encryption may not currently be available to all users. :type disk_encryption: InstanceDiskEncryptionType or str :param stackscript: A StackScript object, or the ID of one, to deploy to this disk. Requires deploying a compatible image. @@ -1600,7 +1641,6 @@ def rebuild( the key. :type authorized_keys: list or str :param disk_encryption: The disk encryption policy for this Linode. - NOTE: Disk encryption may not currently be available to all users. :type disk_encryption: InstanceDiskEncryptionType or str :returns: The newly generated password, if one was not provided @@ -1845,8 +1885,8 @@ def clone( to_linode=None, region=None, instance_type=None, - configs=[], - disks=[], + configs=None, + disks=None, label=None, group=None, with_backups=None, @@ -1902,7 +1942,10 @@ def clone( 'You may only specify one of "to_linode" and "region"' ) - if region and not type: + configs = [] if configs is None else configs + disks = [] if disks is None else disks + + if region and not instance_type: raise ValueError('Specifying a region requires a "service" as well') if not isinstance(configs, list) and not isinstance( @@ -2012,8 +2055,6 @@ def interface_create( Creates a new interface under this Linode. Linode interfaces are not interchangeable with Config interfaces. - NOTE: Linode interfaces may not currently be available to all users. - API Documentation: https://techdocs.akamai.com/linode-api/reference/post-linode-interface Example: Creating a simple public interface for this Linode:: @@ -2089,8 +2130,6 @@ def interfaces_settings(self) -> LinodeInterfacesSettings: """ The settings for all interfaces under this Linode. - NOTE: Linode interfaces may not currently be available to all users. - :returns: The settings for instance-level interface settings for this Linode. :rtype: LinodeInterfacesSettings """ @@ -2159,8 +2198,6 @@ def upgrade_interfaces( NOTE: If dry_run is True, interfaces in the result will be of type MappedObject rather than LinodeInterface. - NOTE: Linode interfaces may not currently be available to all users. - API Documentation: https://techdocs.akamai.com/linode-api/reference/post-upgrade-linode-interfaces :param config: The configuration profile the legacy interfaces to diff --git a/linode_api4/objects/linode_interfaces.py b/linode_api4/objects/linode_interfaces.py index 0598d1f3c..69cebca23 100644 --- a/linode_api4/objects/linode_interfaces.py +++ b/linode_api4/objects/linode_interfaces.py @@ -11,8 +11,6 @@ class LinodeInterfacesSettingsDefaultRouteOptions(JSONObject): """ The options used to configure the default route settings for a Linode's network interfaces. - - NOTE: Linode interfaces may not currently be available to all users. """ ipv4_interface_id: Optional[int] = None @@ -23,8 +21,6 @@ class LinodeInterfacesSettingsDefaultRouteOptions(JSONObject): class LinodeInterfacesSettingsDefaultRoute(JSONObject): """ The default route settings for a Linode's network interfaces. - - NOTE: Linode interfaces may not currently be available to all users. """ put_class = LinodeInterfacesSettingsDefaultRouteOptions @@ -40,8 +36,6 @@ class LinodeInterfacesSettings(Base): The settings related to a Linode's network interfaces. API Documentation: https://techdocs.akamai.com/linode-api/reference/get-linode-interface-settings - - NOTE: Linode interfaces may not currently be available to all users. """ api_endpoint = "/linode/instances/{id}/interfaces/settings" @@ -60,8 +54,6 @@ class LinodeInterfacesSettings(Base): class LinodeInterfaceDefaultRouteOptions(JSONObject): """ Options accepted when creating or updating a Linode Interface's default route settings. - - NOTE: Linode interfaces may not currently be available to all users. """ ipv4: Optional[bool] = None @@ -72,8 +64,6 @@ class LinodeInterfaceDefaultRouteOptions(JSONObject): class LinodeInterfaceVPCIPv4AddressOptions(JSONObject): """ Options accepted for a single address when creating or updating the IPv4 configuration of a VPC Linode Interface. - - NOTE: Linode interfaces may not currently be available to all users. """ address: Optional[str] = None @@ -85,8 +75,6 @@ class LinodeInterfaceVPCIPv4AddressOptions(JSONObject): class LinodeInterfaceVPCIPv4RangeOptions(JSONObject): """ Options accepted for a single range when creating or updating the IPv4 configuration of a VPC Linode Interface. - - NOTE: Linode interfaces may not currently be available to all users. """ range: str = "" @@ -96,8 +84,6 @@ class LinodeInterfaceVPCIPv4RangeOptions(JSONObject): class LinodeInterfaceVPCIPv4Options(JSONObject): """ Options accepted when creating or updating the IPv4 configuration of a VPC Linode Interface. - - NOTE: Linode interfaces may not currently be available to all users. """ addresses: Optional[List[LinodeInterfaceVPCIPv4AddressOptions]] = None @@ -108,8 +94,6 @@ class LinodeInterfaceVPCIPv4Options(JSONObject): class LinodeInterfaceVPCIPv6SLAACOptions(JSONObject): """ Options accepted for a single SLAAC when creating or updating the IPv6 configuration of a VPC Linode Interface. - - NOTE: Linode interfaces may not currently be available to all users. """ range: Optional[str] = None @@ -119,8 +103,6 @@ class LinodeInterfaceVPCIPv6SLAACOptions(JSONObject): class LinodeInterfaceVPCIPv6RangeOptions(JSONObject): """ Options accepted for a single range when creating or updating the IPv6 configuration of a VPC Linode Interface. - - NOTE: Linode interfaces may not currently be available to all users. """ range: Optional[str] = None @@ -130,8 +112,6 @@ class LinodeInterfaceVPCIPv6RangeOptions(JSONObject): class LinodeInterfaceVPCIPv6Options(JSONObject): """ Options accepted when creating or updating the IPv6 configuration of a VPC Linode Interface. - - NOTE: Linode interfaces may not currently be available to all users. """ is_public: Optional[bool] = None @@ -143,8 +123,6 @@ class LinodeInterfaceVPCIPv6Options(JSONObject): class LinodeInterfaceVPCOptions(JSONObject): """ VPC-exclusive options accepted when creating or updating a Linode Interface. - - NOTE: Linode interfaces may not currently be available to all users. """ subnet_id: int = 0 @@ -156,8 +134,6 @@ class LinodeInterfaceVPCOptions(JSONObject): class LinodeInterfacePublicIPv4AddressOptions(JSONObject): """ Options accepted for a single address when creating or updating the IPv4 configuration of a public Linode Interface. - - NOTE: Linode interfaces may not currently be available to all users. """ address: str = "" @@ -168,8 +144,6 @@ class LinodeInterfacePublicIPv4AddressOptions(JSONObject): class LinodeInterfacePublicIPv4Options(JSONObject): """ Options accepted when creating or updating the IPv4 configuration of a public Linode Interface. - - NOTE: Linode interfaces may not currently be available to all users. """ addresses: Optional[List[LinodeInterfacePublicIPv4AddressOptions]] = None @@ -179,8 +153,6 @@ class LinodeInterfacePublicIPv4Options(JSONObject): class LinodeInterfacePublicIPv6RangeOptions(JSONObject): """ Options accepted for a single range when creating or updating the IPv6 configuration of a public Linode Interface. - - NOTE: Linode interfaces may not currently be available to all users. """ range: str = "" @@ -190,8 +162,6 @@ class LinodeInterfacePublicIPv6RangeOptions(JSONObject): class LinodeInterfacePublicIPv6Options(JSONObject): """ Options accepted when creating or updating the IPv6 configuration of a public Linode Interface. - - NOTE: Linode interfaces may not currently be available to all users. """ ranges: Optional[List[LinodeInterfacePublicIPv6RangeOptions]] = None @@ -201,8 +171,6 @@ class LinodeInterfacePublicIPv6Options(JSONObject): class LinodeInterfacePublicOptions(JSONObject): """ Public-exclusive options accepted when creating or updating a Linode Interface. - - NOTE: Linode interfaces may not currently be available to all users. """ ipv4: Optional[LinodeInterfacePublicIPv4Options] = None @@ -213,8 +181,6 @@ class LinodeInterfacePublicOptions(JSONObject): class LinodeInterfaceVLANOptions(JSONObject): """ VLAN-exclusive options accepted when creating or updating a Linode Interface. - - NOTE: Linode interfaces may not currently be available to all users. """ vlan_label: str = "" @@ -225,8 +191,6 @@ class LinodeInterfaceVLANOptions(JSONObject): class LinodeInterfaceOptions(JSONObject): """ Options accepted when creating or updating a Linode Interface. - - NOTE: Linode interfaces may not currently be available to all users. """ always_include = { @@ -249,8 +213,6 @@ class LinodeInterfaceOptions(JSONObject): class LinodeInterfaceDefaultRoute(JSONObject): """ The default route configuration of a Linode Interface. - - NOTE: Linode interfaces may not currently be available to all users. """ put_class = LinodeInterfaceDefaultRouteOptions @@ -263,8 +225,6 @@ class LinodeInterfaceDefaultRoute(JSONObject): class LinodeInterfaceVPCIPv4Address(JSONObject): """ A single address under the IPv4 configuration of a VPC Linode Interface. - - NOTE: Linode interfaces may not currently be available to all users. """ put_class = LinodeInterfaceVPCIPv4AddressOptions @@ -278,8 +238,6 @@ class LinodeInterfaceVPCIPv4Address(JSONObject): class LinodeInterfaceVPCIPv4Range(JSONObject): """ A single range under the IPv4 configuration of a VPC Linode Interface. - - NOTE: Linode interfaces may not currently be available to all users. """ put_class = LinodeInterfaceVPCIPv4RangeOptions @@ -291,8 +249,6 @@ class LinodeInterfaceVPCIPv4Range(JSONObject): class LinodeInterfaceVPCIPv4(JSONObject): """ A single address under the IPv4 configuration of a VPC Linode Interface. - - NOTE: Linode interfaces may not currently be available to all users. """ put_class = LinodeInterfaceVPCIPv4Options @@ -305,8 +261,6 @@ class LinodeInterfaceVPCIPv4(JSONObject): class LinodeInterfaceVPCIPv6SLAAC(JSONObject): """ A single SLAAC entry under the IPv6 configuration of a VPC Linode Interface. - - NOTE: Linode interfaces may not currently be available to all users. """ range: str = "" @@ -317,8 +271,6 @@ class LinodeInterfaceVPCIPv6SLAAC(JSONObject): class LinodeInterfaceVPCIPv6Range(JSONObject): """ A single range under the IPv6 configuration of a VPC Linode Interface. - - NOTE: Linode interfaces may not currently be available to all users. """ range: str = "" @@ -328,8 +280,6 @@ class LinodeInterfaceVPCIPv6Range(JSONObject): class LinodeInterfaceVPCIPv6(JSONObject): """ A single address under the IPv6 configuration of a VPC Linode Interface. - - NOTE: Linode interfaces may not currently be available to all users. """ put_class = LinodeInterfaceVPCIPv6Options @@ -343,8 +293,6 @@ class LinodeInterfaceVPCIPv6(JSONObject): class LinodeInterfaceVPC(JSONObject): """ VPC-specific configuration field for a Linode Interface. - - NOTE: Linode interfaces may not currently be available to all users. """ put_class = LinodeInterfaceVPCOptions @@ -360,8 +308,6 @@ class LinodeInterfaceVPC(JSONObject): class LinodeInterfacePublicIPv4Address(JSONObject): """ A single address under the IPv4 configuration of a public Linode Interface. - - NOTE: Linode interfaces may not currently be available to all users. """ put_class = LinodeInterfacePublicIPv4AddressOptions @@ -374,8 +320,6 @@ class LinodeInterfacePublicIPv4Address(JSONObject): class LinodeInterfacePublicIPv4Shared(JSONObject): """ A single shared address under the IPv4 configuration of a public Linode Interface. - - NOTE: Linode interfaces may not currently be available to all users. """ address: str = "" @@ -386,8 +330,6 @@ class LinodeInterfacePublicIPv4Shared(JSONObject): class LinodeInterfacePublicIPv4(JSONObject): """ The IPv4 configuration of a public Linode Interface. - - NOTE: Linode interfaces may not currently be available to all users. """ put_class = LinodeInterfacePublicIPv4Options @@ -402,8 +344,6 @@ class LinodeInterfacePublicIPv4(JSONObject): class LinodeInterfacePublicIPv6SLAAC(JSONObject): """ A single SLAAC entry under the IPv6 configuration of a public Linode Interface. - - NOTE: Linode interfaces may not currently be available to all users. """ address: str = "" @@ -414,8 +354,6 @@ class LinodeInterfacePublicIPv6SLAAC(JSONObject): class LinodeInterfacePublicIPv6Shared(JSONObject): """ A single shared range under the IPv6 configuration of a public Linode Interface. - - NOTE: Linode interfaces may not currently be available to all users. """ range: str = "" @@ -426,8 +364,6 @@ class LinodeInterfacePublicIPv6Shared(JSONObject): class LinodeInterfacePublicIPv6Range(JSONObject): """ A single range under the IPv6 configuration of a public Linode Interface. - - NOTE: Linode interfaces may not currently be available to all users. """ put_class = LinodeInterfacePublicIPv6RangeOptions @@ -440,8 +376,6 @@ class LinodeInterfacePublicIPv6Range(JSONObject): class LinodeInterfacePublicIPv6(JSONObject): """ The IPv6 configuration of a Linode Interface. - - NOTE: Linode interfaces may not currently be available to all users. """ put_class = LinodeInterfacePublicIPv6Options @@ -455,8 +389,6 @@ class LinodeInterfacePublicIPv6(JSONObject): class LinodeInterfacePublic(JSONObject): """ Public-specific configuration fields for a Linode Interface. - - NOTE: Linode interfaces may not currently be available to all users. """ put_class = LinodeInterfacePublicOptions @@ -469,8 +401,6 @@ class LinodeInterfacePublic(JSONObject): class LinodeInterfaceVLAN(JSONObject): """ VLAN-specific configuration fields for a Linode Interface. - - NOTE: Linode interfaces may not currently be available to all users. """ put_class = LinodeInterfaceVLANOptions @@ -483,8 +413,6 @@ class LinodeInterface(DerivedBase): """ A Linode's network interface. - NOTE: Linode interfaces may not currently be available to all users. - NOTE: When using the ``save()`` method, certain local fields with computed values will not be refreshed on the local object until after ``invalidate()`` has been called:: @@ -528,8 +456,6 @@ def firewalls(self, *filters) -> List[Firewall]: Retrieves a list of Firewalls for this Linode Interface. Linode interfaces are not interchangeable with Config interfaces. - NOTE: Linode interfaces may not currently be available to all users. - :param filters: Any number of filters to apply to this query. See :doc:`Filtering Collections` for more details on filtering. diff --git a/linode_api4/objects/lke.py b/linode_api4/objects/lke.py index 0864052f1..aa506a606 100644 --- a/linode_api4/objects/lke.py +++ b/linode_api4/objects/lke.py @@ -8,6 +8,7 @@ Base, DerivedBase, Instance, + InstanceDiskEncryptionType, JSONObject, MappedObject, Property, @@ -422,6 +423,9 @@ def node_pool_create( ] = None, update_strategy: Optional[str] = None, label: str = None, + disk_encryption: Optional[ + Union[str, InstanceDiskEncryptionType] + ] = None, **kwargs, ): """ @@ -443,6 +447,9 @@ def node_pool_create( :param update_strategy: The strategy to use when updating this node pool. NOTE: This field is specific to enterprise clusters. :type update_strategy: str + :param disk_encryption: Local disk encryption setting for this LKE node pool. + One of 'enabled' or 'disabled'. Defaults to 'disabled'. + :type disk_encryption: str or InstanceDiskEncryptionType :param kwargs: Any other arguments to pass to the API. See the API docs for possible values. @@ -459,6 +466,7 @@ def node_pool_create( "taints": taints, "k8s_version": k8s_version, "update_strategy": update_strategy, + "disk_encryption": disk_encryption, } params.update(kwargs) diff --git a/linode_api4/objects/lock.py b/linode_api4/objects/lock.py index 9cee64517..3a1cd32d2 100644 --- a/linode_api4/objects/lock.py +++ b/linode_api4/objects/lock.py @@ -10,7 +10,7 @@ class LockType(StrEnum): """ LockType defines valid values for resource lock types. - API Documentation: TBD + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-resource-lock """ cannot_delete = "cannot_delete" @@ -22,7 +22,7 @@ class LockEntity(JSONObject): """ Represents the entity that is locked. - API Documentation: TBD + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-resource-lock """ id: int = 0 @@ -35,7 +35,7 @@ class Lock(Base): """ A resource lock that prevents deletion or modification of a resource. - API Documentation: TBD + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-resource-lock """ api_endpoint = "/locks/{id}" diff --git a/linode_api4/objects/monitor.py b/linode_api4/objects/monitor.py index ca8f83921..7e0f4ae4d 100644 --- a/linode_api4/objects/monitor.py +++ b/linode_api4/objects/monitor.py @@ -7,11 +7,13 @@ __all__ = [ "AggregateFunction", - "Alert", "AlertChannel", "AlertDefinition", + "AlertDefinitionChannel", + "AlertDefinitionEntity", + "AlertEntities", + "AlertScope", "AlertType", - "Alerts", "MonitorDashboard", "MonitorMetricsDefinition", "MonitorService", @@ -341,15 +343,15 @@ class RuleCriteria(JSONObject): @dataclass -class Alert(JSONObject): +class AlertDefinitionChannel(JSONObject): """ - Represents an alert definition reference within an AlertChannel. + Represents the notification channel set up for use with an alert. 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 - Unique identifier for this notification channel. + - label: str - Human-readable name for the alert channel. + - type: str - Type of notification used with the channel. For a user alert definition, only `email` is supported. + - url: str - URL for the channel that ends in the channel's id. """ id: int = 0 @@ -358,18 +360,6 @@ class Alert(JSONObject): 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. @@ -387,6 +377,43 @@ class AlertType(StrEnum): user = "user" +class AlertScope(StrEnum): + """ + Scope values supported for alert definitions. + """ + + entity = "entity" + region = "region" + account = "account" + + +@dataclass +class AlertEntities(JSONObject): + """ + Represents entity metadata for an alert definition. + + For entity scoped alerts, `entities` envelope contains the URL to list entities, + a count, and a has_more_resources flag. + For region/account scoped alerts, the `entities` are returned as an empty object. + """ + + url: str = "" + count: int = 0 + has_more_resources: bool = False + + +@dataclass +class AlertDefinitionEntity(JSONObject): + """ + Represents an entity associated with an alert definition. + """ + + id: str = "" + label: str = "" + url: str = "" + _type: str = field(default="", metadata={"json_key": "type"}) + + class AlertDefinition(DerivedBase): """ Represents an alert definition for a monitor service. @@ -406,12 +433,11 @@ class AlertDefinition(DerivedBase): "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), + "alert_channels": Property(json_object=AlertDefinitionChannel), "created": Property(is_datetime=True), "updated": Property(is_datetime=True), "updated_by": Property(), @@ -419,28 +445,13 @@ class AlertDefinition(DerivedBase): "entity_ids": Property(mutable=True), "description": Property(mutable=True), "service_class": Property(alias_of="class"), + "scope": Property(AlertScope), + "regions": Property(mutable=True), + "entities": Property(json_object=AlertEntities), + "channel_ids": Property(mutable=True), } -@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. - - @dataclass class EmailDetails(JSONObject): """ @@ -481,7 +492,7 @@ class AlertChannel(Base): 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 + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-notification-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. @@ -499,7 +510,6 @@ class AlertChannel(Base): "channel_type": Property(), "details": Property(mutable=False, json_object=ChannelDetails), "alerts": Property(mutable=False, json_object=AlertInfo), - "content": Property(mutable=False, json_object=ChannelContent), "created": Property(is_datetime=True), "updated": Property(is_datetime=True), "created_by": Property(), diff --git a/linode_api4/objects/networking.py b/linode_api4/objects/networking.py index ed975ab71..44e4599b2 100644 --- a/linode_api4/objects/networking.py +++ b/linode_api4/objects/networking.py @@ -108,8 +108,6 @@ def interface(self) -> Optional["LinodeInterface"]: NOTE: This function will only return Linode interfaces, not Config interfaces. - NOTE: Linode interfaces may not currently be available to all users. - :returns: The Linode Interface associated with this IP address. :rtype: LinodeInterface """ diff --git a/linode_api4/objects/object_storage.py b/linode_api4/objects/object_storage.py index a2e61405f..fdb91e180 100644 --- a/linode_api4/objects/object_storage.py +++ b/linode_api4/objects/object_storage.py @@ -596,6 +596,8 @@ class ObjectStorageQuota(Base): "description": Property(), "quota_limit": Property(), "resource_metric": Property(), + "quota_type": Property(), + "has_usage": Property(), } def usage(self): @@ -614,3 +616,41 @@ def usage(self): ) return ObjectStorageQuotaUsage.from_json(result) + + +class ObjectStorageGlobalQuota(Base): + """ + An account-level Object Storage quota. + + API documentation: TBD + """ + + api_endpoint = "/object-storage/global-quotas/{quota_id}" + id_attribute = "quota_id" + + properties = { + "quota_id": Property(identifier=True), + "quota_type": Property(), + "quota_name": Property(), + "description": Property(), + "resource_metric": Property(), + "quota_limit": Property(), + "has_usage": Property(), + } + + def usage(self): + """ + Gets usage data for a specific account-level Object Storage quota. + + API documentation: https://techdocs.akamai.com/linode-api/reference/get-object-storage-global-quota-usage + + :returns: The Object Storage Global Quota usage. + :rtype: ObjectStorageQuotaUsage + """ + + result = self._client.get( + f"{type(self).api_endpoint}/usage", + model=self, + ) + + return ObjectStorageQuotaUsage.from_json(result) diff --git a/linode_api4/util.py b/linode_api4/util.py index f661367af..0ba6b8e09 100644 --- a/linode_api4/util.py +++ b/linode_api4/util.py @@ -3,7 +3,7 @@ """ import string -from typing import Any, Dict +from typing import Any, Dict, List, Tuple, Union def drop_null_keys(data: Dict[Any, Any], recursive=True) -> Dict[Any, Any]: @@ -30,6 +30,13 @@ def recursive_helper(value: Any) -> Any: return recursive_helper(data) +def normalize_as_list(value: Any) -> Union[List, Tuple]: + """ + Returns the value wrapped in a list if it isn't already a list or tuple. + """ + return value if isinstance(value, (list, tuple)) else [value] + + def generate_device_suffixes(n: int) -> list[str]: """ Generate n alphabetical suffixes starting with a, b, c, etc. diff --git a/pyproject.toml b/pyproject.toml index 4d8542cfa..7f3129d39 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ name = "linode_api4" authors = [{ name = "Linode", email = "devs@linode.com" }] description = "The official Python SDK for Linode API v4" readme = "README.rst" -requires-python = ">=3.9" +requires-python = ">=3.10" keywords = [ "akamai", "Akamai Connected Cloud", @@ -25,10 +25,11 @@ classifiers = [ "License :: OSI Approved :: BSD License", "Programming Language :: Python", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", ] dependencies = ["requests", "polling", "deprecated"] dynamic = ["version"] @@ -78,7 +79,7 @@ line_length = 80 [tool.black] line-length = 80 -target-version = ["py38", "py39", "py310", "py311", "py312"] +target-version = ["py310", "py311", "py312", "py313", "py314"] [tool.autoflake] expand-star-imports = true diff --git a/test/fixtures/monitor_alert-channels.json b/test/fixtures/monitor_alert-channels.json new file mode 100644 index 000000000..753c53431 --- /dev/null +++ b/test/fixtures/monitor_alert-channels.json @@ -0,0 +1,31 @@ +{ + "data": [ + { + "id": 123, + "label": "alert notification channel", + "type": "user", + "channel_type": "email", + "details": { + "email": { + "usernames": [ + "admin-user1", + "admin-user2" + ], + "recipient_type": "user" + } + }, + "alerts": { + "url": "/monitor/alert-channels/123/alerts", + "type": "alerts-definitions", + "alert_count": 0 + }, + "created": "2024-01-01T00:00:00", + "updated": "2024-01-01T00:00:00", + "created_by": "tester", + "updated_by": "tester" + } + ], + "page": 1, + "pages": 1, + "results": 1 +} \ No newline at end of file diff --git a/test/fixtures/monitor_alert-definitions.json b/test/fixtures/monitor_alert-definitions.json index 92b6e0e4c..2e040605f 100644 --- a/test/fixtures/monitor_alert-definitions.json +++ b/test/fixtures/monitor_alert-definitions.json @@ -7,13 +7,24 @@ "severity": 1, "type": "user", "description": "A test alert for dbaas service", - "entity_ids": ["13217"], - "alert_channels": [], - "has_more_resources": false, + "scope": "entity", + "regions": [], + "entities": { + "url": "/monitor/services/dbaas/alert-definitions/12345/entities", + "count": 1, + "has_more_resources": false + }, + "alert_channels": [ + { + "id": 10000, + "label": "Read-Write Channel", + "type": "email", + "url": "/monitor/alert-channels/10000" + } + ], "rule_criteria": null, "trigger_conditions": null, "class": "alert", - "notification_groups": [], "status": "active", "created": "2024-01-01T00:00:00", "updated": "2024-01-01T00:00:00", diff --git a/test/fixtures/monitor_services_dbaas_alert-definitions.json b/test/fixtures/monitor_services_dbaas_alert-definitions.json index 0c7067a8a..67ea9d2ab 100644 --- a/test/fixtures/monitor_services_dbaas_alert-definitions.json +++ b/test/fixtures/monitor_services_dbaas_alert-definitions.json @@ -7,11 +7,21 @@ "severity": 1, "type": "user", "description": "A test alert for dbaas service", - "entity_ids": [ - "13217" + "scope": "entity", + "regions": [], + "entities": { + "url": "/monitor/services/dbaas/alert-definitions/12345/entities", + "count": 1, + "has_more_resources": false + }, + "alert_channels": [ + { + "id": 10000, + "label": "Read-Write Channel", + "type": "email", + "url": "/monitor/alert-channels/10000" + } ], - "alert_channels": [], - "has_more_resources": false, "rule_criteria": { "rules": [ { @@ -39,7 +49,6 @@ "trigger_occurrences": 3 }, "class": "alert", - "notification_groups": [], "status": "active", "created": "2024-01-01T00:00:00", "updated": "2024-01-01T00:00:00", diff --git a/test/fixtures/monitor_services_dbaas_alert-definitions_12345.json b/test/fixtures/monitor_services_dbaas_alert-definitions_12345.json index 822e18b24..4d70f66b1 100644 --- a/test/fixtures/monitor_services_dbaas_alert-definitions_12345.json +++ b/test/fixtures/monitor_services_dbaas_alert-definitions_12345.json @@ -5,11 +5,21 @@ "severity": 1, "type": "user", "description": "A test alert for dbaas service", - "entity_ids": [ - "13217" + "scope": "entity", + "regions": [], + "entities": { + "url": "/monitor/services/dbaas/alert-definitions/12345/entities", + "count": 1, + "has_more_resources": false + }, + "alert_channels": [ + { + "id": 10000, + "label": "Read-Write Channel", + "type": "email", + "url": "/monitor/alert-channels/10000" + } ], - "alert_channels": [], - "has_more_resources": false, "rule_criteria": { "rules": [ { diff --git a/test/fixtures/monitor_services_dbaas_alert-definitions_12345_entities.json b/test/fixtures/monitor_services_dbaas_alert-definitions_12345_entities.json new file mode 100644 index 000000000..16dad4b7c --- /dev/null +++ b/test/fixtures/monitor_services_dbaas_alert-definitions_12345_entities.json @@ -0,0 +1,25 @@ +{ + "data": [ + { + "id": "1", + "label": "mydatabase-1", + "url": "/v4/databases/mysql/instances/1", + "type": "dbaas" + }, + { + "id": "2", + "label": "mydatabase-2", + "url": "/v4/databases/mysql/instances/2", + "type": "dbaas" + }, + { + "id": "3", + "label": "mydatabase-3", + "url": "/v4/databases/mysql/instances/3", + "type": "dbaas" + } + ], + "page": 1, + "pages": 1, + "results": 3 +} diff --git a/test/fixtures/object-storage_global-quotas.json b/test/fixtures/object-storage_global-quotas.json new file mode 100644 index 000000000..c9cc73b8c --- /dev/null +++ b/test/fixtures/object-storage_global-quotas.json @@ -0,0 +1,25 @@ +{ + "data": [ + { + "quota_id": "obj-access-keys-per-account", + "quota_type": "obj-access-keys", + "quota_name": "Object Storage Access Keys per Account", + "description": "Maximum number of access keys this customer is allowed to have on their account.", + "resource_metric": "access_key", + "quota_limit": 100, + "has_usage": true + }, + { + "quota_id": "obj-total-capacity-per-account", + "quota_type": "obj-total-capacity", + "quota_name": "Object Storage Total Capacity per Account", + "description": "Maximum total storage capacity in bytes this customer is allowed on their account.", + "resource_metric": "byte", + "quota_limit": 1099511627776, + "has_usage": true + } + ], + "page": 1, + "pages": 1, + "results": 2 +} diff --git a/test/fixtures/object-storage_global-quotas_obj-access-keys-per-account.json b/test/fixtures/object-storage_global-quotas_obj-access-keys-per-account.json new file mode 100644 index 000000000..b3f167550 --- /dev/null +++ b/test/fixtures/object-storage_global-quotas_obj-access-keys-per-account.json @@ -0,0 +1,9 @@ +{ + "quota_id": "obj-access-keys-per-account", + "quota_type": "obj-access-keys", + "quota_name": "Object Storage Access Keys per Account", + "description": "Maximum number of access keys this customer is allowed to have on their account.", + "resource_metric": "access_key", + "quota_limit": 100, + "has_usage": true +} diff --git a/test/fixtures/object-storage_global-quotas_obj-access-keys-per-account_usage.json b/test/fixtures/object-storage_global-quotas_obj-access-keys-per-account_usage.json new file mode 100644 index 000000000..ae3be8a3c --- /dev/null +++ b/test/fixtures/object-storage_global-quotas_obj-access-keys-per-account_usage.json @@ -0,0 +1,4 @@ +{ + "quota_limit": 100, + "usage": 25 +} diff --git a/test/fixtures/object-storage_quotas.json b/test/fixtures/object-storage_quotas.json index e831d7303..e6b11554a 100644 --- a/test/fixtures/object-storage_quotas.json +++ b/test/fixtures/object-storage_quotas.json @@ -7,7 +7,9 @@ "endpoint_type": "E1", "s3_endpoint": "us-iad-1.linodeobjects.com", "quota_limit": 50, - "resource_metric": "object" + "resource_metric": "object", + "quota_type": "obj-objects", + "has_usage": true }, { "quota_id": "obj-bucket-us-ord-1", @@ -16,7 +18,9 @@ "endpoint_type": "E1", "s3_endpoint": "us-iad-1.linodeobjects.com", "quota_limit": 50, - "resource_metric": "bucket" + "resource_metric": "bucket", + "quota_type": "obj-bucket", + "has_usage": true } ], "page": 1, diff --git a/test/fixtures/object-storage_quotas_obj-objects-us-ord-1.json b/test/fixtures/object-storage_quotas_obj-objects-us-ord-1.json index e01d743c3..fe216e776 100644 --- a/test/fixtures/object-storage_quotas_obj-objects-us-ord-1.json +++ b/test/fixtures/object-storage_quotas_obj-objects-us-ord-1.json @@ -5,5 +5,7 @@ "endpoint_type": "E1", "s3_endpoint": "us-iad-1.linodeobjects.com", "quota_limit": 50, - "resource_metric": "object" + "resource_metric": "object", + "quota_type": "obj-objects", + "has_usage": true } \ No newline at end of file diff --git a/test/integration/conftest.py b/test/integration/conftest.py index a5c832f4f..0058dfcec 100644 --- a/test/integration/conftest.py +++ b/test/integration/conftest.py @@ -16,7 +16,6 @@ from requests.exceptions import ConnectionError, RequestException from linode_api4 import ( - ExplicitNullValue, InterfaceGeneration, LinodeInterfaceDefaultRouteOptions, LinodeInterfaceOptions, @@ -645,7 +644,7 @@ def linode_with_linode_interfaces( public=LinodeInterfacePublicOptions(), ), LinodeInterfaceOptions( - firewall_id=ExplicitNullValue, + firewall_id=e2e_test_firewall.id, vpc=LinodeInterfaceVPCOptions( subnet_id=subnet.id, ), diff --git a/test/integration/models/linode/test_linode.py b/test/integration/models/linode/test_linode.py index 9f6194fa9..512b6c513 100644 --- a/test/integration/models/linode/test_linode.py +++ b/test/integration/models/linode/test_linode.py @@ -790,6 +790,15 @@ def test_get_config(test_linode_client, create_linode): assert config.id == linode.configs[0].id +def test_config_create_without_devices_raises_error(create_linode): + linode = create_linode + + with pytest.raises(ValueError) as err: + linode.config_create(label="test-config-no-devices") + + assert "Must include at least one disk or volume!" in str(err.value) + + def test_get_linode_types(test_linode_client): types = test_linode_client.linode.types() diff --git a/test/integration/models/lke/test_lke.py b/test/integration/models/lke/test_lke.py index 116665df6..96ab1d3cc 100644 --- a/test/integration/models/lke/test_lke.py +++ b/test/integration/models/lke/test_lke.py @@ -211,6 +211,21 @@ def _to_comparable(p: LKENodePool) -> Dict[str, Any]: ) +def test_node_pool_create_with_disk_encryption(test_linode_client, lke_cluster): + node_type = test_linode_client.linode.types()[1] + + pool = lke_cluster.node_pool_create( + node_type, + 1, + disk_encryption=InstanceDiskEncryptionType.enabled, + ) + + try: + assert pool.disk_encryption == InstanceDiskEncryptionType.enabled + finally: + pool.delete() + + def test_cluster_dashboard_url_view(lke_cluster): cluster = lke_cluster diff --git a/test/integration/models/monitor/test_monitor.py b/test/integration/models/monitor/test_monitor.py index 908ac1a44..ceb9fdc3a 100644 --- a/test/integration/models/monitor/test_monitor.py +++ b/test/integration/models/monitor/test_monitor.py @@ -7,9 +7,10 @@ import pytest -from linode_api4 import LinodeClient +from linode_api4 import LinodeClient, PaginatedList from linode_api4.objects import ( AlertDefinition, + AlertDefinitionEntity, ApiError, MonitorDashboard, MonitorMetricsDefinition, @@ -256,12 +257,14 @@ def wait_for_alert_ready(alert_id, service_type: str): assert created.id assert getattr(created, "label", None) == label + assert getattr(created, "entities", None) is not None created = wait_for_alert_ready(created.id, service_type) updated = client.load(AlertDefinition, created.id, service_type) updated.label = f"{label}-updated" updated.save() + assert getattr(updated, "entities", None) is not None updated = wait_for_alert_ready(updated.id, service_type) @@ -275,3 +278,36 @@ def wait_for_alert_ready(alert_id, service_type: str): AlertDefinition, created.id, service_type ) delete_alert.delete() + + +def test_alert_definition_entities(test_linode_client): + """Test listing entities associated with an alert definition. + + This test first retrieves alert definitions for a service type, then lists entities for the first alert definition. + It asserts that the returned entities have expected fields. + """ + client = test_linode_client + service_type = "dbaas" + + alert_definitions = client.monitor.alert_definitions( + service_type=service_type + ) + + if len(alert_definitions) == 0: + pytest.fail("No alert definitions available for dbaas service type") + + assert getattr(alert_definitions[0], "entities", None) is not None + + alert_def = alert_definitions[0] + entities = client.monitor.alert_definition_entities( + service_type, alert_def.id + ) + + assert isinstance(entities, PaginatedList) + if len(entities) > 0: + entity = entities[0] + assert isinstance(entity, AlertDefinitionEntity) + assert entity.id + assert entity.label + assert entity.url + assert entity._type == service_type diff --git a/test/integration/models/object_storage/test_obj_quotas.py b/test/integration/models/object_storage/test_obj_quotas.py index 10a546bc7..d9be84c3d 100644 --- a/test/integration/models/object_storage/test_obj_quotas.py +++ b/test/integration/models/object_storage/test_obj_quotas.py @@ -1,6 +1,8 @@ import pytest +from linode_api4.errors import ApiError from linode_api4.objects.object_storage import ( + ObjectStorageGlobalQuota, ObjectStorageQuota, ObjectStorageQuotaUsage, ) @@ -25,6 +27,8 @@ def test_list_and_get_obj_storage_quotas(test_linode_client): assert found_quota.description == get_quota.description assert found_quota.quota_limit == get_quota.quota_limit assert found_quota.resource_metric == get_quota.resource_metric + assert found_quota.quota_type == get_quota.quota_type + assert found_quota.has_usage == get_quota.has_usage def test_get_obj_storage_quota_usage(test_linode_client): @@ -33,7 +37,21 @@ def test_get_obj_storage_quota_usage(test_linode_client): if len(quotas) < 1: pytest.skip("No available quota for testing. Skipping now...") - quota_id = quotas[0].quota_id + quota_with_usage = next( + (quota for quota in quotas if quota.has_usage), None + ) + + if quota_with_usage is None: + quota_id = quotas[0].quota_id + quota = test_linode_client.load(ObjectStorageQuota, quota_id) + + # quota without usage should return an API error on usage retrieval + with pytest.raises(ApiError): + quota.usage() + + return + + quota_id = quota_with_usage.quota_id quota = test_linode_client.load(ObjectStorageQuota, quota_id) quota_usage = quota.usage() @@ -43,3 +61,56 @@ def test_get_obj_storage_quota_usage(test_linode_client): if quota_usage.usage is not None: assert quota_usage.usage >= 0 + + +def test_list_and_get_obj_storage_global_quotas(test_linode_client): + quotas = test_linode_client.object_storage.global_quotas() + + if len(quotas) < 1: + pytest.skip("No available global quota for testing. Skipping now...") + + found_quota = quotas[0] + + get_quota = test_linode_client.load( + ObjectStorageGlobalQuota, found_quota.quota_id + ) + + assert found_quota.quota_id == get_quota.quota_id + assert found_quota.quota_type == get_quota.quota_type + assert found_quota.quota_name == get_quota.quota_name + assert found_quota.description == get_quota.description + assert found_quota.resource_metric == get_quota.resource_metric + assert found_quota.quota_limit == get_quota.quota_limit + assert found_quota.has_usage == get_quota.has_usage + + +def test_get_obj_storage_global_quota_usage(test_linode_client): + quotas = test_linode_client.object_storage.global_quotas() + + if len(quotas) < 1: + pytest.skip("No available global quota for testing. Skipping now...") + + quota_with_usage = next( + (quota for quota in quotas if quota.has_usage), None + ) + + if quota_with_usage is None: + quota_id = quotas[0].quota_id + quota = test_linode_client.load(ObjectStorageGlobalQuota, quota_id) + + # quota without usage should return an API error on usage retrieval + with pytest.raises(ApiError): + quota.usage() + + return + + quota_id = quota_with_usage.quota_id + quota = test_linode_client.load(ObjectStorageGlobalQuota, quota_id) + + quota_usage = quota.usage() + + assert isinstance(quota_usage, ObjectStorageQuotaUsage) + assert quota_usage.quota_limit >= 0 + + if quota_usage.usage is not None: + assert quota_usage.usage >= 0 diff --git a/test/integration/models/volume/test_blockstorage.py b/test/integration/models/volume/test_blockstorage.py index 8dac88e18..e382f4a2a 100644 --- a/test/integration/models/volume/test_blockstorage.py +++ b/test/integration/models/volume/test_blockstorage.py @@ -38,3 +38,30 @@ def test_config_create_with_extended_volume_limit(test_linode_client): linode.delete() for v in volumes: retry_sending_request(3, v.delete) + + +def test_config_create_with_device_map(test_linode_client): + client = test_linode_client + + region = get_region(client, {"Linodes", "Block Storage"}, site_type="core") + label = get_test_label() + + linode, _ = client.linode.instance_create( + "g6-standard-6", + region, + image="linode/debian12", + label=label, + ) + + disk_id = linode.disks[0].id + devices = { + "sdl": {"disk_id": disk_id}, + } + + config = linode.config_create(label=f"{label}-config", devices=devices) + + result_devices = config._raw_json["devices"] + assert result_devices["sdl"] is not None + assert result_devices["sdl"]["disk_id"] == disk_id + + linode.delete() diff --git a/test/unit/groups/monitor_api_test.py b/test/unit/groups/monitor_api_test.py index 9515895ae..fdc93060c 100644 --- a/test/unit/groups/monitor_api_test.py +++ b/test/unit/groups/monitor_api_test.py @@ -4,6 +4,8 @@ from linode_api4.objects import ( AggregateFunction, AlertDefinition, + AlertDefinitionChannel, + AlertDefinitionEntity, EntityMetricOptions, ) @@ -71,6 +73,20 @@ def test_alert_definition(self): # assert collection and element types assert isinstance(alert, PaginatedList) assert isinstance(alert[0], AlertDefinition) + assert alert[0].scope == "entity" + assert alert[0].regions == [] + assert alert[0].entities.url.endswith( + "/alert-definitions/12345/entities" + ) + assert alert[0].entities.count == 1 + assert alert[0].entities.has_more_resources is False + assert isinstance(alert[0].alert_channels, list) + assert len(alert[0].alert_channels) == 1 + assert isinstance( + alert[0].alert_channels[0], AlertDefinitionChannel + ) + assert alert[0].alert_channels[0].id == 10000 + assert alert[0].alert_channels[0]._type == "email" # fetch the raw JSON from the client and assert its fields raw = self.client.get(url) @@ -90,6 +106,11 @@ def test_create_alert_definition(self): "service_type": service_type, "severity": 1, "status": "active", + "entities": { + "url": f"/monitor/services/dbaas/alert-definitions/67890/entities", + "count": 1, + "has_more_resources": False, + }, } with self.mock_post(result) as mock_post: @@ -100,6 +121,8 @@ def test_create_alert_definition(self): channel_ids=[1, 2], rule_criteria={"rules": []}, trigger_conditions={"criteria_condition": "ALL"}, + scope="entity", + regions=[], entity_ids=["13217"], description="created via test", ) @@ -109,10 +132,51 @@ def test_create_alert_definition(self): assert mock_post.call_data["label"] == "Created Alert" assert mock_post.call_data["severity"] == 1 assert "channel_ids" in mock_post.call_data + assert mock_post.call_data["scope"] == "entity" + assert mock_post.call_data["regions"] == [] assert isinstance(alert, AlertDefinition) assert alert.id == 67890 + assert alert.entities.url.endswith( + "/alert-definitions/67890/entities" + ) + assert alert.entities.count == 1 + assert alert.entities.has_more_resources is False # fetch the same response from the client and assert resp = self.client.post(url, data={}) assert resp["label"] == "Created Alert" + + def test_alert_definition_entities(self): + service_type = "dbaas" + id = 12345 + url = ( + f"/monitor/services/{service_type}/alert-definitions/{id}/entities" + ) + + with self.mock_get(url) as mock_get: + entities = self.client.monitor.alert_definition_entities( + service_type, id + ) + + assert mock_get.call_url == url + assert isinstance(entities, PaginatedList) + assert len(entities) == 3 + + assert isinstance(entities[0], AlertDefinitionEntity) + assert entities[0].id == "1" + assert entities[0].label == "mydatabase-1" + assert entities[0].url == "/v4/databases/mysql/instances/1" + assert entities[0]._type == "dbaas" + + assert isinstance(entities[1], AlertDefinitionEntity) + assert entities[1].id == "2" + assert entities[1].label == "mydatabase-2" + assert entities[1].url == "/v4/databases/mysql/instances/2" + assert entities[1]._type == "dbaas" + + assert isinstance(entities[2], AlertDefinitionEntity) + assert entities[2].id == "3" + assert entities[2].label == "mydatabase-3" + assert entities[2].url == "/v4/databases/mysql/instances/3" + assert entities[2]._type == "dbaas" diff --git a/test/unit/objects/linode_test.py b/test/unit/objects/linode_test.py index 40bbb5069..1c31f8109 100644 --- a/test/unit/objects/linode_test.py +++ b/test/unit/objects/linode_test.py @@ -459,6 +459,47 @@ def test_create_disk(self): assert disk.id == 12345 assert disk.disk_encryption == InstanceDiskEncryptionType.disabled + def test_create_config_with_device_map(self): + """ + Tests that config_create passes through a raw device map unchanged. + """ + linode = Instance(self.client, 123) + devices = { + "sda": {"disk_id": 111}, + "sdb": {"volume_id": 222}, + "sdc": None, + } + + with self.mock_post( + {"id": 456, "devices": devices, "interfaces": []} + ) as m: + config = linode.config_create(label="test-config", devices=devices) + + self.assertEqual(m.call_url, "/linode/instances/123/configs") + self.assertEqual( + m.call_data, + { + "label": "test-config", + "devices": devices, + "interfaces": [], + }, + ) + + self.assertEqual(config.id, 456) + + def test_create_config_without_devices_raises_error(self): + """ + Tests that config_create raises ValueError when no devices, disks, or volumes are specified. + """ + linode = Instance(self.client, 123) + + with self.assertRaises(ValueError) as context: + linode.config_create(label="test-config") + + assert "Must include at least one disk or volume!" in str( + context.exception + ) + def test_get_placement_group(self): """ Tests that you can get the placement group for a Linode diff --git a/test/unit/objects/monitor_test.py b/test/unit/objects/monitor_test.py index 329a09063..5913b3b28 100644 --- a/test/unit/objects/monitor_test.py +++ b/test/unit/objects/monitor_test.py @@ -1,7 +1,7 @@ import datetime from test.unit.base import ClientBaseCase -from linode_api4.objects import MonitorDashboard, MonitorService +from linode_api4.objects import AlertChannel, MonitorDashboard, MonitorService class MonitorTest(ClientBaseCase): @@ -146,3 +146,26 @@ def test_create_token(self): service_type="linode", entity_ids=["compute-instance-1"] ) self.assertEqual(m.return_dct["token"], "abcdefhjigkfghh") + + def test_alert_channels(self): + channels = self.client.monitor.alert_channels() + + self.assertEqual(len(channels), 1) + self.assertIsInstance(channels[0], AlertChannel) + self.assertEqual(channels[0].id, 123) + self.assertEqual(channels[0].label, "alert notification channel") + self.assertEqual(channels[0].type, "user") + self.assertEqual(channels[0].channel_type, "email") + self.assertIsNotNone(channels[0].details) + self.assertIsNotNone(channels[0].details.email) + self.assertEqual( + channels[0].details.email.usernames, + ["admin-user1", "admin-user2"], + ) + self.assertEqual(channels[0].details.email.recipient_type, "user") + self.assertIsNotNone(channels[0].alerts) + self.assertEqual( + channels[0].alerts.url, + "/monitor/alert-channels/123/alerts", + ) + self.assertEqual(channels[0].alerts.alert_count, 0) diff --git a/test/unit/objects/object_storage_test.py b/test/unit/objects/object_storage_test.py index b7ff7e49c..e0deb4211 100644 --- a/test/unit/objects/object_storage_test.py +++ b/test/unit/objects/object_storage_test.py @@ -6,6 +6,7 @@ ObjectStorageACL, ObjectStorageBucket, ObjectStorageCluster, + ObjectStorageGlobalQuota, ObjectStorageQuota, ) @@ -306,6 +307,8 @@ def test_quota_get_and_list(self): self.assertEqual(quota.s3_endpoint, "us-iad-1.linodeobjects.com") self.assertEqual(quota.quota_limit, 50) self.assertEqual(quota.resource_metric, "object") + self.assertEqual(quota.quota_type, "obj-objects") + self.assertTrue(quota.has_usage) quota_usage_url = "/object-storage/quotas/obj-objects-us-ord-1/usage" with self.mock_get(quota_usage_url) as m: @@ -335,3 +338,59 @@ def test_quota_get_and_list(self): ) self.assertEqual(quotas[0].quota_limit, 50) self.assertEqual(quotas[0].resource_metric, "object") + self.assertEqual(quotas[0].quota_type, "obj-objects") + self.assertTrue(quotas[0].has_usage) + + def test_global_quota_get_and_list(self): + """ + Test that you can get and list account-level Object Storage global quotas and usage. + """ + quota = ObjectStorageGlobalQuota( + self.client, + "obj-access-keys-per-account", + ) + + self.assertIsNotNone(quota) + self.assertEqual(quota.quota_id, "obj-access-keys-per-account") + self.assertEqual(quota.quota_type, "obj-access-keys") + self.assertEqual( + quota.quota_name, + "Object Storage Access Keys per Account", + ) + self.assertEqual( + quota.description, + "Maximum number of access keys this customer is allowed to have on their account.", + ) + self.assertEqual(quota.resource_metric, "access_key") + self.assertEqual(quota.quota_limit, 100) + self.assertTrue(quota.has_usage) + + usage_url = ( + "/object-storage/global-quotas/obj-access-keys-per-account/usage" + ) + with self.mock_get(usage_url) as m: + usage = quota.usage() + self.assertIsNotNone(usage) + self.assertEqual(m.call_url, usage_url) + self.assertEqual(usage.quota_limit, 100) + self.assertEqual(usage.usage, 25) + + list_url = "/object-storage/global-quotas" + with self.mock_get(list_url) as m: + quotas = self.client.object_storage.global_quotas() + self.assertIsNotNone(quotas) + self.assertEqual(m.call_url, list_url) + self.assertEqual(len(quotas), 2) + self.assertEqual(quotas[0].quota_id, "obj-access-keys-per-account") + self.assertEqual(quotas[0].quota_type, "obj-access-keys") + self.assertEqual( + quotas[0].quota_name, + "Object Storage Access Keys per Account", + ) + self.assertEqual( + quotas[0].description, + "Maximum number of access keys this customer is allowed to have on their account.", + ) + self.assertEqual(quotas[0].resource_metric, "access_key") + self.assertEqual(quotas[0].quota_limit, 100) + self.assertTrue(quotas[0].has_usage)