diff --git a/.github/workflows/e2e-test-pr.yml b/.github/workflows/e2e-test-pr.yml index de95ac92b..373a19f42 100644 --- a/.github/workflows/e2e-test-pr.yml +++ b/.github/workflows/e2e-test-pr.yml @@ -2,6 +2,14 @@ on: pull_request: workflow_dispatch: inputs: + run_aclp_logs_stream_tests: + description: 'Set this parameter to "true" to run ACLP logs stream related test cases' + required: false + default: 'false' + type: choice + options: + - 'true' + - 'false' run_db_fork_tests: description: 'Set this parameter to "true" to run fork database related test cases' required: false @@ -104,7 +112,7 @@ jobs: run: | timestamp=$(date +'%Y%m%d%H%M') report_filename="${timestamp}_sdk_test_report.xml" - make test-int RUN_DB_FORK_TESTS=${{ github.event.inputs.run_db_fork_tests }} RUN_DB_TESTS=${{ github.event.inputs.run_db_tests }} TEST_ARGS="--junitxml=${report_filename}" TEST_SUITE="${{ github.event.inputs.test_suite }}" + make test-int RUN_DB_FORK_TESTS=${{ github.event.inputs.run_db_fork_tests }} RUN_DB_TESTS=${{ github.event.inputs.run_db_tests }} RUN_ACLP_LOGS_STREAM_TESTS=${{ github.event.inputs.run_aclp_logs_stream_tests }} TEST_ARGS="--junitxml=${report_filename}" TEST_SUITE="${{ github.event.inputs.test_suite }}" env: LINODE_TOKEN: ${{ secrets.LINODE_TOKEN }} diff --git a/.github/workflows/e2e-test.yml b/.github/workflows/e2e-test.yml index 8a02599cc..a0350f2c3 100644 --- a/.github/workflows/e2e-test.yml +++ b/.github/workflows/e2e-test.yml @@ -3,6 +3,14 @@ name: Integration Tests on: workflow_dispatch: inputs: + run_aclp_logs_stream_tests: + description: 'Set this parameter to "true" to run ACLP logs stream related test cases' + required: false + default: 'false' + type: choice + options: + - 'true' + - 'false' run_db_fork_tests: description: 'Set this parameter to "true" to run fork database related test cases' required: false @@ -99,7 +107,7 @@ jobs: run: | timestamp=$(date +'%Y%m%d%H%M') report_filename="${timestamp}_sdk_test_report.xml" - make test-int RUN_DB_FORK_TESTS=${{ github.event.inputs.run_db_fork_tests }} RUN_DB_TESTS=${{ github.event.inputs.run_db_tests }} TEST_SUITE="${{ github.event.inputs.test_suite }}" TEST_ARGS="--junitxml=${report_filename}" + make test-int RUN_DB_FORK_TESTS=${{ github.event.inputs.run_db_fork_tests }} RUN_DB_TESTS=${{ github.event.inputs.run_db_tests }} RUN_ACLP_LOGS_STREAM_TESTS=${{ github.event.inputs.run_aclp_logs_stream_tests }} TEST_SUITE="${{ github.event.inputs.test_suite }}" TEST_ARGS="--junitxml=${report_filename}" env: LINODE_TOKEN: ${{ env.LINODE_TOKEN }} diff --git a/conftest.py b/conftest.py new file mode 100644 index 000000000..2047317e8 --- /dev/null +++ b/conftest.py @@ -0,0 +1,6 @@ +import os +import sys + +# Ensure the repo root is on sys.path so that `from test.unit.base import ...` +# works regardless of which directory pytest is invoked from. +sys.path.insert(0, os.path.dirname(__file__)) diff --git a/linode_api4/groups/linode.py b/linode_api4/groups/linode.py index e9f3da148..3ec99ddf0 100644 --- a/linode_api4/groups/linode.py +++ b/linode_api4/groups/linode.py @@ -166,6 +166,7 @@ def instance_create( kernel: Optional[str] = None, boot_size: Optional[int] = None, authorized_users: Optional[List[str]] = None, + ipv4: Optional[List[str]] = None, **kwargs, ): """ @@ -355,6 +356,9 @@ def instance_create( :param boot_size: The size of the boot disk in MB. If provided, this will be used to create the boot disk for the Instance. :type boot_size: int + :param ipv4: A list of reserved IPv4 addresses to assign to this Instance. + NOTE: Reserved IP feature may not currently be available to all users. + :type ipv4: list[str] :returns: A new Instance object :rtype: Instance @@ -401,6 +405,7 @@ def instance_create( "network_helper": network_helper, "kernel": kernel, "boot_size": boot_size, + "ipv4": ipv4, } params.update(kwargs) diff --git a/linode_api4/groups/monitor.py b/linode_api4/groups/monitor.py index 0d7f19ce8..375225f80 100644 --- a/linode_api4/groups/monitor.py +++ b/linode_api4/groups/monitor.py @@ -8,11 +8,21 @@ AlertDefinition, AlertDefinitionEntity, AlertScope, + LogsDestination, + LogsDestinationType, + LogsStream, + LogsStreamStatus, + LogsStreamType, MonitorDashboard, MonitorMetricsDefinition, MonitorService, MonitorServiceToken, ) +from linode_api4.objects.monitor import ( + AkamaiObjectStorageLogsDestinationDetails, + CustomHTTPSLogsDestinationDetails, + LogsStreamDetails, +) __all__ = [ "MonitorGroup", @@ -37,8 +47,6 @@ def dashboards( dashboard = client.load(MonitorDashboard, 1) dashboards_by_service = client.monitor.dashboards(service_type="dbaas") - .. note:: This endpoint is in beta. This will only function if base_url is set to `https://api.linode.com/v4beta`. - API Documentation: - All Dashboards: https://techdocs.akamai.com/linode-api/reference/get-dashboards-all - Dashboards by Service: https://techdocs.akamai.com/linode-api/reference/get-dashboards @@ -73,8 +81,6 @@ def services( supported_services = client.monitor.services() service_details = client.monitor.load(MonitorService, "dbaas") - .. note:: This endpoint is in beta. This will only function if base_url is set to `https://api.linode.com/v4beta`. - API Documentation: https://techdocs.akamai.com/linode-api/reference/get-monitor-services API Documentation: https://techdocs.akamai.com/linode-api/reference/get-monitor-services-for-service-type @@ -100,7 +106,6 @@ def metric_definitions( Returns metrics for a specific service type. metrics = client.monitor.list_metric_definitions(service_type="dbaas") - .. note:: This endpoint is in beta. This will only function if base_url is set to `https://api.linode.com/v4beta`. API Documentation: https://techdocs.akamai.com/linode-api/reference/get-monitor-information @@ -126,8 +131,6 @@ def create_token( Returns a JWE Token for a specific service type. token = client.monitor.create_token(service_type="dbaas", entity_ids=[1234]) - .. note:: This endpoint is in beta. This will only function if base_url is set to `https://api.linode.com/v4beta`. - API Documentation: https://techdocs.akamai.com/linode-api/reference/post-get-token :param service_type: The service type to create token for. @@ -165,7 +168,6 @@ def alert_definitions( 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 @@ -202,8 +204,6 @@ def alert_channels(self, *filters) -> PaginatedList: 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-notification-channels :param filters: Optional filter expressions to apply to the collection. @@ -232,8 +232,6 @@ def create_alert_definition( 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 @@ -309,9 +307,7 @@ def alert_definition_entities( 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 + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-alert-definition-entities :param service_type: Service type for the alert definition (e.g. `dbaas`). :type service_type: str @@ -332,3 +328,206 @@ def alert_definition_entities( *filters, endpoint=endpoint, ) + + def destinations(self, *filters) -> PaginatedList: + """ + List available logs destinations. + + Returns a paginated collection of :class:`LogsDestination` objects which + describe logs destinations. By default, this method returns all available + destinations; you can supply optional filter expressions to restrict + the results, for example:: + + # Get destinations created by username and with id 111 + destinations = client.monitor.destinations(LogsDestination.created_by == "username", + LogsDestination.id == 111) + + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-destinations + + :param filters: Any number of filters to apply to this query. + See :doc:`Filtering Collections` + for more details on filtering. + + :returns: A list of :class:`LogsDestination` objects matching the query. + :rtype: PaginatedList of LogsDestination + """ + + return self.client._get_and_filter(LogsDestination, *filters) + + def destination_create( + self, + label: str, + type: Union[LogsDestinationType, str], + details: Union[ + AkamaiObjectStorageLogsDestinationDetails, + CustomHTTPSLogsDestinationDetails, + ], + ) -> LogsDestination: + """ + Creates a new :any:`LogsDestination` for logs on this account. + + For an ``akamai_object_storage`` destination:: + + client = LinodeClient(TOKEN) + + new_destination = client.monitor.destination_create( + label="OBJ_logs_destination", + type="akamai_object_storage", + details=AkamaiObjectStorageLogsDestinationDetails( + access_key_id="1ABCD23EFG4HIJKLMNO5", + access_key_secret="1aB2CD3e4fgHi5JK6lmnop7qR8STU9VxYzabcdefHh", + bucket_name="primary-bucket", + host="primary-bucket-1.us-east-12.linodeobjects.com", + path="audit-logs", + ) + ) + + For a ``custom_https`` destination:: + + new_destination = client.monitor.destination_create( + label="custom_logs_destination", + type="custom_https", + details=CustomHTTPSLogsDestinationDetails( + endpoint_url="https://my-site.com/log-storage/basicAuth", + authentication=DestinationAuthentication( + type="basic", + details=BasicAuthenticationDetails( + basic_authentication_user="user", + basic_authentication_password="pass", + ), + ), + data_compression="gzip", + content_type="application/json", + ) + ) + + API Documentation: https://techdocs.akamai.com/linode-api/reference/post-destination + + :param label: The name for this logs destination. + :type label: str + :param type: The type of destination — ``akamai_object_storage`` or ``custom_https``. + :type type: str or LogsDestinationType + :param details: A typed details object matching the destination type. + Use :class:`AkamaiObjectStorageLogsDestinationDetails` for + ``akamai_object_storage`` or :class:`CustomHTTPSLogsDestinationDetails` + for ``custom_https``. + :type details: AkamaiObjectStorageLogsDestinationDetails or CustomHTTPSLogsDestinationDetails + + :returns: The newly created logs destination. + :rtype: LogsDestination + """ + + params = { + "label": label, + "type": type, + "details": details.dict, + } + + result = self.client.post("/monitor/streams/destinations", data=params) + + if "id" not in result: + raise UnexpectedResponseError( + "Unexpected response when creating destination!", + json=result, + ) + + return LogsDestination(self.client, result["id"], result) + + def streams(self, *filters) -> PaginatedList: + """ + List available logs streams. + + Returns a paginated collection of :class:`LogsStream` objects which + describe logs streams. By default, this method returns all available + streams; you can supply optional filter expressions to restrict + the results, for example:: + + # Get all streams with status ``provisioning`` + provisioning_streams = client.monitor.streams(LogsStream.status == "provisioning") + + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-streams + + :param filters: Any number of filters to apply to this query. + See :doc:`Filtering Collections` + for more details on filtering. + :returns: A list of :class:`LogsStream` objects matching the query. + :rtype: PaginatedList of LogsStream + """ + + return self.client._get_and_filter(LogsStream, *filters) + + def stream_create( + self, + destinations: list[int], + label: str, + type: Union[LogsStreamType, str], + status: Optional[Union[LogsStreamStatus, str]] = None, + details: Optional[LogsStreamDetails] = None, + ) -> LogsStream: + """ + Creates a new :any:`LogsStream` for logs on this account. For example:: + + client = LinodeClient(TOKEN) + + # audit_logs stream (no details required) + new_stream = client.monitor.stream_create( + destinations=[1234], + label="Linode_services", + status="active", + type="audit_logs" + ) + + # lke_audit_logs stream with specific clusters + lke_stream = client.monitor.stream_create( + destinations=[1234], + label="LKE_audit_stream", + type="lke_audit_logs", + details=LogsStreamDetails( + cluster_ids=[1111, 2222], + is_auto_add_all_clusters_enabled=False, + ) + ) + + API Documentation: https://techdocs.akamai.com/linode-api/reference/post-stream + + :param destinations: The unique identifier for the sync point that will receive logs data. + Run the List destinations operation and store the id values for each applicable destination. + At the moment only single destination is supported. + :type destinations: list[int] + :param label: The name of the stream. This is used for display purposes in Akamai Cloud Manager. + :type label: str + :param type: The type of stream — ``audit_logs`` for Linode control plane logs, + or ``lke_audit_logs`` for LKE enterprise cluster audit logs. + :type type: str or LogsStreamType + :param status: (Optional) The availability status of the stream. Possible values are: ``active``, ``inactive``. + Defaults to ``active``. + :type status: str + :param details: (Optional) Additional stream details. Only applicable for + ``lke_audit_logs`` streams. Omit for ``audit_logs`` streams. + :type details: LogsStreamDetails + + :returns: The newly created logs stream. + :rtype: LogsStream + """ + + params = { + "label": label, + "type": type, + "destinations": destinations, + } + + if status is not None: + params["status"] = status + + if details is not None: + params["details"] = details.dict + + result = self.client.post("/monitor/streams", data=params) + + if "id" not in result: + raise UnexpectedResponseError( + "Unexpected response when creating logs stream!", + json=result, + ) + + return LogsStream(self.client, result["id"], result) diff --git a/linode_api4/groups/networking.py b/linode_api4/groups/networking.py index 502b7f68e..bdcb5b7cb 100644 --- a/linode_api4/groups/networking.py +++ b/linode_api4/groups/networking.py @@ -1,4 +1,4 @@ -from typing import Any, Dict, Optional, Union +from typing import Any, Dict, List, Optional, Union from linode_api4.errors import UnexpectedResponseError from linode_api4.groups import Group @@ -17,6 +17,8 @@ Region, ) from linode_api4.objects.base import _flatten_request_body_recursive +from linode_api4.objects.networking import ReservedIPAddress, ReservedIPType +from linode_api4.paginated_list import PaginatedList from linode_api4.util import drop_null_keys @@ -328,10 +330,23 @@ def ips_assign(self, region, *assignments): }, ) - def ip_allocate(self, linode, public=True): + def ip_allocate( + self, + linode: Optional[Union[Instance, int]] = None, + public: bool = True, + reserved: bool = False, + region: Optional[Union[Region, str]] = None, + ) -> IPAddress: """ - Allocates an IP to a Instance you own. Additional IPs must be requested - by opening a support ticket first. + Allocates an IP to an Instance you own, or reserves a new IP address. + + When ``reserved`` is False (default), ``linode`` is required and an + ephemeral IP is allocated and assigned to that Instance. + + When ``reserved`` is True, either ``region`` or ``linode`` must be + provided. Passing only ``region`` creates an unassigned reserved IP. + Passing ``linode`` (with or without ``region``) creates a reserved IP + in the Instance's region and assigns it to that Instance. API Documentation: https://techdocs.akamai.com/linode-api/reference/post-allocate-ip @@ -339,18 +354,42 @@ def ip_allocate(self, linode, public=True): :type linode: Instance or int :param public: If True, allocate a public IP address. Defaults to True. :type public: bool + :param reserved: If True, reserve the new IP address. + NOTE: Reserved IP feature may not currently be available to all users. + :type reserved: bool + :param region: The region for the reserved IP (required when reserved=True and linode is not set). + NOTE: Reserved IP feature may not currently be available to all users. + :type region: str or Region :returns: The new IPAddress. :rtype: IPAddress """ - result = self.client.post( - "/networking/ips/", - data={ - "linode_id": linode.id if isinstance(linode, Base) else linode, - "type": "ipv4", - "public": public, - }, - ) + if not reserved and linode is None: + raise ValueError("linode is required when reserved is False.") + if reserved and linode is None and region is None: + raise ValueError( + "Either linode or region must be provided when reserved is True." + ) + if not reserved and region is not None: + raise ValueError("region is only valid when reserved is True.") + + data = { + "type": "ipv4", + "public": public, + } + + if linode is not None: + data["linode_id"] = ( + linode.id if isinstance(linode, Base) else linode + ) + + if reserved: + data["reserved"] = True + + if region is not None: + data["region"] = region.id if isinstance(region, Base) else region + + result = self.client.post("/networking/ips/", data=data) if not "address" in result: raise UnexpectedResponseError( @@ -510,3 +549,76 @@ def delete_vlan(self, vlan, region): return False return True + + def reserved_ips(self, *filters) -> PaginatedList: + """ + Returns a list of reserved IPv4 addresses on your account. + + NOTE: Reserved IP feature may not currently be available to all users. + + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-reserved-ips + + :param filters: Any number of filters to apply to this query. + See :doc:`Filtering Collections` + for more details on filtering. + + :returns: A list of reserved IP addresses on the account. + :rtype: PaginatedList of ReservedIPAddress + """ + return self.client._get_and_filter(ReservedIPAddress, *filters) + + def reserved_ip_create( + self, + region: Union[Region, str], + tags: Optional[List[str]] = None, + **kwargs, + ) -> ReservedIPAddress: + """ + Reserves a new IPv4 address in the given region. + + NOTE: Reserved IP feature may not currently be available to all users. + + API Documentation: https://techdocs.akamai.com/linode-api/reference/post-reserved-ip + + :param region: The region in which to reserve the IP. + :type region: str or Region + :param tags: Tags to apply to the reserved IP. + :type tags: list of str + + :returns: The new reserved IP address. + :rtype: ReservedIPAddress + """ + params = { + "region": region.id if isinstance(region, Region) else region, + } + if tags is not None: + params["tags"] = tags + params.update(kwargs) + + result = self.client.post("/networking/reserved/ips", data=params) + + if "address" not in result: + raise UnexpectedResponseError( + "Unexpected response when reserving IP address!", json=result + ) + + return ReservedIPAddress(self.client, result["address"], result) + + def reserved_ip_types(self, *filters) -> PaginatedList: + """ + Returns a list of reserved IP types with pricing information. + + NOTE: Reserved IP feature may not currently be available to all users. + + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-reserved-ip-types + + :param filters: Any number of filters to apply to this query. + See :doc:`Filtering Collections` + for more details on filtering. + + :returns: A list of reserved IP types. + :rtype: PaginatedList of ReservedIPType + """ + return self.client._get_and_filter( + ReservedIPType, *filters, endpoint="/networking/reserved/ips/types" + ) diff --git a/linode_api4/groups/nodebalancer.py b/linode_api4/groups/nodebalancer.py index 57830c8c4..20252efaa 100644 --- a/linode_api4/groups/nodebalancer.py +++ b/linode_api4/groups/nodebalancer.py @@ -32,20 +32,26 @@ def create(self, region, **kwargs): :param region: The Region in which to create the NodeBalancer. :type region: Region or str + :param ipv4: A reserved IPv4 address to assign to this NodeBalancer. + NOTE: Reserved IP feature may not currently be available to all users. + :type ipv4: str :returns: The new NodeBalancer :rtype: NodeBalancer """ + ipv4 = kwargs.pop("ipv4", None) params = { "region": region.id if isinstance(region, Base) else region, } + if ipv4 is not None: + params["ipv4"] = ipv4 params.update(kwargs) result = self.client.post("/nodebalancers", data=params) if not "id" in result: raise UnexpectedResponseError( - "Unexpected response when creating Nodebalaner!", json=result + "Unexpected response when creating NodeBalancer!", json=result ) n = NodeBalancer(self.client, result["id"], result) diff --git a/linode_api4/groups/object_storage.py b/linode_api4/groups/object_storage.py index d36690111..d54e1cb5a 100644 --- a/linode_api4/groups/object_storage.py +++ b/linode_api4/groups/object_storage.py @@ -539,7 +539,7 @@ def global_quotas(self, *filters): """ Lists the active account-level Object Storage quotas applied to your account. - API Documentation: TBD + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-object-storage-global-quotas :param filters: Any number of filters to apply to this query. See :doc:`Filtering Collections` diff --git a/linode_api4/groups/tag.py b/linode_api4/groups/tag.py index 5948b513b..45bbc4b82 100644 --- a/linode_api4/groups/tag.py +++ b/linode_api4/groups/tag.py @@ -32,7 +32,8 @@ def create( domains=None, nodebalancers=None, volumes=None, - entities=[], + entities=None, + reserved_ipv4_addresses=None, ): """ Creates a new Tag and optionally applies it to the given entities. @@ -61,10 +62,14 @@ def create( :param volumes: A list of Volumes to apply this Tag to upon creation :type volumes: list of Volumes or list of int + :param reserved_ipv4_addresses: A list of reserved IPv4 addresses to apply + this Tag to upon creation. + :type reserved_ipv4_addresses: list of str :returns: The new Tag :rtype: Tag """ + entities = entities or [] linode_ids, nodebalancer_ids, domain_ids, volume_ids = [], [], [], [] # filter input into lists of ids @@ -103,6 +108,7 @@ def create( "nodebalancers": nodebalancer_ids or None, "domains": domain_ids or None, "volumes": volume_ids or None, + "reserved_ipv4_addresses": reserved_ipv4_addresses or None, } result = self.client.post("/tags", data=params) diff --git a/linode_api4/objects/linode.py b/linode_api4/objects/linode.py index f27fac472..31678f4e8 100644 --- a/linode_api4/objects/linode.py +++ b/linode_api4/objects/linode.py @@ -1584,7 +1584,7 @@ def snapshot(self, label=None): b = Backup(self._client, result["id"], self.id, result) return b - def ip_allocate(self, public=False): + def ip_allocate(self, public=False, address=None): """ Allocates a new :any:`IPAddress` for this Instance. Additional public IPs require justification, and you may need to open a :any:`SupportTicket` @@ -1596,17 +1596,26 @@ def ip_allocate(self, public=False): :param public: If the new IP should be public or private. Defaults to private. :type public: bool + :param address: A reserved IPv4 address to assign to this Instance instead + of allocating a new ephemeral IP. The address must be an + unassigned reserved IP owned by this account. + NOTE: Reserved IP feature may not currently be available to all users. + :type address: str :returns: The new IPAddress :rtype: IPAddress """ + data = { + "type": "ipv4", + "public": public, + } + if address is not None: + data["address"] = address + result = self._client.post( "{}/ips".format(Instance.api_endpoint), model=self, - data={ - "type": "ipv4", - "public": public, - }, + data=data, ) if not "address" in result: diff --git a/linode_api4/objects/monitor.py b/linode_api4/objects/monitor.py index 7e0f4ae4d..0ce4e6d05 100644 --- a/linode_api4/objects/monitor.py +++ b/linode_api4/objects/monitor.py @@ -1,5 +1,5 @@ from dataclasses import dataclass, field -from typing import List, Optional, Union +from typing import Any, Dict, List, Optional, Union from linode_api4.objects import DerivedBase from linode_api4.objects.base import Base, Property @@ -20,6 +20,26 @@ "MonitorServiceToken", "RuleCriteria", "TriggerConditions", + "AkamaiObjectStorageLogsDestinationDetails", + "AuthenticationType", + "BasicAuthenticationDetails", + "ClientCertificateDetails", + "ContentType", + "CustomHeader", + "CustomHTTPSLogsDestinationDetails", + "DataCompressionType", + "DestinationAuthentication", + "LogsDestinationDetailsBase", + "LogsDestination", + "LogsDestinationHistory", + "LogsDestinationStatus", + "LogsDestinationType", + "LogsStream", + "LogsStreamHistory", + "LogsStreamType", + "LogsStreamStatus", + "LogsStreamDetails", + "LogsStreamDestination", ] @@ -131,6 +151,35 @@ class AlertStatus(StrEnum): AlertDefinitionStatusFailed = "failed" +class LogsDestinationType(StrEnum): + """ + The type of destination for logs data sync. + """ + + akamai_object_storage = "akamai_object_storage" + custom_https = "custom_https" + + +class AuthenticationType(StrEnum): + none = "none" + basic = "basic" + + +class DataCompressionType(StrEnum): + gzip = "gzip" + none = "none" + + +class ContentType(StrEnum): + json = "application/json" + json_utf8 = "application/json; charset=utf-8" + + +class LogsDestinationStatus(StrEnum): + active = "active" + inactive = "inactive" + + @dataclass class Filter(JSONObject): """ @@ -229,6 +278,7 @@ class MonitorDashboard(Base): "label": Property(), "service_type": Property(ServiceType), "type": Property(DashboardType), + "group_by": Property(), "widgets": Property(json_object=DashboardWidget), "updated": Property(is_datetime=True), } @@ -515,3 +565,341 @@ class AlertChannel(Base): "created_by": Property(), "updated_by": Property(), } + + +@dataclass +class BasicAuthenticationDetails(JSONObject): + """ + Includes additional parameters necessary to define basic authentication. + """ + + basic_authentication_user: Optional[str] = None + basic_authentication_password: Optional[str] = None + + +@dataclass +class DestinationAuthentication(JSONObject): + """ + Authentication details required to access the endpoint_url. + """ + + type: Optional[AuthenticationType] = None + details: Optional[BasicAuthenticationDetails] = None + + +@dataclass +class CustomHeader(JSONObject): + """ + Pairs of parameters used to optionally include custom headers in the request. + """ + + name: str = "" + value: str = "" + + +@dataclass +class ClientCertificateDetails(JSONObject): + """ + Contains TLS client certificate information to additionally secure the connection. + """ + + client_ca_certificate: Optional[str] = None + client_certificate: Optional[str] = None + client_private_key: Optional[str] = None + tls_hostname: Optional[str] = None + + +@dataclass +class LogsDestinationDetailsBase(JSONObject): + """ + Base class for Logs Destination details. + Use the factory method to instantiate the correct subclass based on destination type. + """ + + @classmethod + def load_by_type( + cls, dest_type: str, json_dict: dict + ) -> Optional["LogsDestinationDetailsBase"]: + """ + Factory method that instantiates the correct details subclass + based on the destination type string. + + :param dest_type: The destination type (e.g. "akamai_object_storage", "custom_https"). + :param json_dict: The raw JSON dict for the details block. + :returns: A populated subclass instance, or None if json_dict is empty/None. + """ + if not json_dict: + return None + + if dest_type == LogsDestinationType.akamai_object_storage: + return AkamaiObjectStorageLogsDestinationDetails.from_json( + json_dict + ) + elif dest_type == LogsDestinationType.custom_https: + return CustomHTTPSLogsDestinationDetails.from_json(json_dict) + + return None + + +@dataclass +class CustomHTTPSLogsDestinationDetails(LogsDestinationDetailsBase): + """ + Represents the details block for custom_https LogsDestination type. + """ + + endpoint_url: str = "" + authentication: Optional[DestinationAuthentication] = None + data_compression: Optional[DataCompressionType] = None + content_type: Optional[ContentType] = None + custom_headers: Optional[List[CustomHeader]] = None + client_certificate_details: Optional[ClientCertificateDetails] = None + + +@dataclass +class AkamaiObjectStorageLogsDestinationDetails(LogsDestinationDetailsBase): + """ + Represents the details block for Akamai Object Storage LogsDestination type. + Fields: + - access_key_id: str - The unique identifier assigned to the Object Storage key required for authentication to the bucket. + - bucket_name: str - The name of the Object Storage bucket. + - host: str - The hostname where the Object Storage bucket can be accessed. + - path: Optional[str] - The specific path in an Object Storage bucket where audit logs files are uploaded. May be absent or None in API responses. + """ + + access_key_id: str = "" + access_key_secret: Optional[str] = None + bucket_name: str = "" + host: str = "" + path: Optional[str] = None + + +class LogsDestinationHistory(Base): + """ + Represents a read-only historical snapshot of a Logs Destination. + + API documentation: https://techdocs.akamai.com/linode-api/reference/get-destination-history + """ + + properties = { + "created": Property(is_datetime=True), + "created_by": Property(), + "details": Property(), + "id": Property(identifier=True), + "label": Property(), + "status": Property(), + "type": Property(), + "updated": Property(is_datetime=True), + "updated_by": Property(), + "version": Property(), + } + + def _populate(self, json): + super()._populate(json) + + if json and "details" in json and "type" in json: + self._set( + "details", + LogsDestinationDetailsBase.load_by_type( + json["type"], json["details"] + ), + ) + + +class LogsDestination(Base): + """ + Represents a logs destination object. + + API documentation: https://techdocs.akamai.com/linode-api/reference/get-destination + """ + + api_endpoint = "/monitor/streams/destinations/{id}" + + properties = { + "created": Property(is_datetime=True), + "created_by": Property(), + "details": Property(mutable=True), + "id": Property(identifier=True), + "label": Property(mutable=True), + "status": Property(), + "type": Property(), + "updated": Property(is_datetime=True), + "updated_by": Property(), + "version": Property(), + } + + def _populate(self, json): + super()._populate(json) + + if json and "details" in json and "type" in json: + self._set( + "details", + LogsDestinationDetailsBase.load_by_type( + json["type"], json["details"] + ), + ) + + @property + def history(self): + """ + Retrieves the version history for this LogsDestination. + + API documentation: https://techdocs.akamai.com/linode-api/reference/get-destination-history + """ + + return self._client._get_objects( + "{}/history".format( + LogsDestination.api_endpoint.format(id=self.id) + ), + LogsDestinationHistory, + ) + + +class LogsStreamStatus(StrEnum): + active = "active" + inactive = "inactive" + provisioning = "provisioning" + deactivating = "deactivating" + + +class LogsStreamType(StrEnum): + audit_logs = "audit_logs" + lke_audit_logs = "lke_audit_logs" + + +@dataclass +class LogsStreamDetails(JSONObject): + """ + Additional details for a logs stream. + + This object only applies to streams with a ``type`` of ``lke_audit_logs``. + Leave it out of requests that use a ``type`` of ``audit_logs``. + + .. note:: + When updating a stream, any existing settings need to be included to + maintain them. For example, if you're adding new ``cluster_ids`` to the + stream, you also need to include any existing ones to maintain them. + Run the Get a stream operation to review the existing ``details`` + settings for a stream before submitting an update. + + Fields: + - cluster_ids: List of LKE enterprise cluster IDs to include in the stream. + Cannot be used when ``is_auto_add_all_clusters_enabled`` is ``True``. + - is_auto_add_all_clusters_enabled: When ``True``, newly added LKE enterprise + clusters on the account are automatically + included in the stream. + """ + + cluster_ids: Optional[List[int]] = None + is_auto_add_all_clusters_enabled: bool = False + + +@dataclass +class LogsStreamDestination(JSONObject): + """ + Represents a destination attached to a LogsStream. + """ + + id: int = 0 + label: str = "" + type: Optional[LogsDestinationType] = None + details: Optional[LogsDestinationDetailsBase] = None + + @classmethod + def from_json( + cls, json: Dict[str, Any] + ) -> Optional["LogsStreamDestination"]: + if json is None: + return None + + obj = super().from_json(json) + + if obj and json.get("type"): + obj.details = LogsDestinationDetailsBase.load_by_type( + json["type"], json.get("details") + ) + + return obj + + +class LogsStreamHistory(Base): + """ + Represents a read-only historical snapshot of a logs stream. + + API documentation: https://techdocs.akamai.com/linode-api/reference/get-stream-history + """ + + properties = { + "created": Property(is_datetime=True), + "created_by": Property(), + "destinations": Property(json_object=LogsStreamDestination), + "details": Property(json_object=LogsStreamDetails), + "id": Property(identifier=True), + "label": Property(), + "status": Property(), + "type": Property(), + "updated": Property(is_datetime=True), + "updated_by": Property(), + "version": Property(), + } + + +class LogsStream(Base): + """ + Represents a logs stream object. + + API documentation: https://techdocs.akamai.com/linode-api/reference/get-stream + """ + + api_endpoint = "/monitor/streams/{id}" + + properties = { + "created": Property(is_datetime=True), + "created_by": Property(), + "destinations": Property(json_object=LogsStreamDestination), + "details": Property(mutable=True, json_object=LogsStreamDetails), + "id": Property(identifier=True), + "label": Property(mutable=True), + "status": Property(mutable=True), + "type": Property(), + "updated": Property(is_datetime=True), + "updated_by": Property(), + "version": Property(), + } + + def update_destinations(self, destinations: List[int]): + """ + Updates the sync points that receive logs data for this stream. + Replaces existing destinations with the provided list. + + :param destinations: A list of destination IDs. + At the moment only single destination per stream is supported. + Passing more than one element in the list will result in an error from the API. + :type destinations: list[int] + + :returns: True if the update was successful. + :rtype: bool + """ + if not destinations: + raise ValueError("A destination id must be provided.") + payload = {"destinations": destinations} + + # The Linode API PUT request expects the flat list of IDs + result = self._client.put( + self.api_endpoint.format(id=self.id), data=payload + ) + self._populate(result) + + return True + + @property + def history(self): + """ + Retrieves the version history for this LogsStream. + + API documentation: https://techdocs.akamai.com/linode-api/reference/get-stream-history + """ + + return self._client._get_objects( + "{}/history".format(LogsStream.api_endpoint.format(id=self.id)), + LogsStreamHistory, + ) diff --git a/linode_api4/objects/networking.py b/linode_api4/objects/networking.py index 44e4599b2..7693953d5 100644 --- a/linode_api4/objects/networking.py +++ b/linode_api4/objects/networking.py @@ -57,6 +57,20 @@ class InstanceIPNAT1To1(JSONObject): vpc_id: int = 0 +@dataclass +class ReservedIPAssignedEntity(JSONObject): + """ + Represents the entity that a reserved IP is assigned to. + + NOTE: Reserved IP feature may not currently be available to all users. + """ + + id: int = 0 + label: str = "" + type: str = "" + url: str = "" + + class IPAddress(Base): """ note:: This endpoint is in beta. This will only function if base_url is set to `https://api.linode.com/v4beta`. @@ -90,6 +104,9 @@ class IPAddress(Base): "interface_id": Property(), "region": Property(slug_relationship=Region), "vpc_nat_1_1": Property(json_object=InstanceIPNAT1To1), + "reserved": Property(mutable=True), + "tags": Property(mutable=True, unordered=True), + "assigned_entity": Property(json_object=ReservedIPAssignedEntity), } @property @@ -154,6 +171,40 @@ def delete(self): return True +class ReservedIPAddress(Base): + """ + .. note:: This endpoint is in beta. This will only function if base_url is set to ``https://api.linode.com/v4beta``. + + Represents a Linode Reserved IPv4 Address. + + Update tags on a reserved IP by mutating the ``tags`` attribute and calling ``save()``. + + NOTE: Reserved IP feature may not currently be available to all users. + + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-reserved-ip + """ + + api_endpoint = "/networking/reserved/ips/{address}" + id_attribute = "address" + + properties = { + "address": Property(identifier=True), + "gateway": Property(), + "linode_id": Property(), + "prefix": Property(), + "public": Property(), + "rdns": Property(), + "region": Property(slug_relationship=Region), + "reserved": Property(), + "subnet_mask": Property(), + "tags": Property(mutable=True, unordered=True), + "type": Property(), + "assigned_entity": Property(json_object=ReservedIPAssignedEntity), + "interface_id": Property(), + "vpc_nat_1_1": Property(json_object=InstanceIPNAT1To1), + } + + @dataclass class VPCIPAddressIPv6(JSONObject): slaac_address: str = "" @@ -422,3 +473,20 @@ class NetworkTransferPrice(Base): "region_prices": Property(json_object=RegionPrice), "transfer": Property(), } + + +class ReservedIPType(Base): + """ + Represents a reserved IP type with pricing information. + + NOTE: Reserved IP feature may not currently be available to all users. + + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-reserved-ip-types + """ + + properties = { + "id": Property(identifier=True), + "label": Property(), + "price": Property(json_object=Price), + "region_prices": Property(json_object=RegionPrice), + } diff --git a/linode_api4/objects/object_storage.py b/linode_api4/objects/object_storage.py index fdb91e180..e4b027c4f 100644 --- a/linode_api4/objects/object_storage.py +++ b/linode_api4/objects/object_storage.py @@ -622,7 +622,7 @@ class ObjectStorageGlobalQuota(Base): """ An account-level Object Storage quota. - API documentation: TBD + API documentation: https://techdocs.akamai.com/linode-api/reference/get-object-storage-global-quota """ api_endpoint = "/object-storage/global-quotas/{quota_id}" diff --git a/linode_api4/objects/region.py b/linode_api4/objects/region.py index 9a77dc485..ce4ea3894 100644 --- a/linode_api4/objects/region.py +++ b/linode_api4/objects/region.py @@ -39,6 +39,7 @@ class Capability(StrEnum): lke_control_plane_acl = "LKE Network Access Control List (IP ACL)" aclb = "Akamai Cloud Load Balancer" support_ticket_severity = "Support Ticket Severity" + support_live_chat = "Support Live Chat" backups = "Backups" placement_group = "Placement Group" disk_encryption = "Disk Encryption" @@ -58,6 +59,7 @@ class Capability(StrEnum): maintenance_policy = "Maintenance Policy" vpc_dual_stack = "VPC Dual Stack" vpc_ipv6_stack = "VPC IPv6 Stack" + vpc_custom_ipv4_ranges = "Custom VPC IPv4 Ranges" nlb = "Network LoadBalancer" natgateway = "NAT Gateway" lke_e_byovpc = "Kubernetes Enterprise BYO VPC" diff --git a/linode_api4/objects/tag.py b/linode_api4/objects/tag.py index 4f2e7b1cb..5f698ba0b 100644 --- a/linode_api4/objects/tag.py +++ b/linode_api4/objects/tag.py @@ -6,6 +6,7 @@ Property, Volume, ) +from linode_api4.objects.networking import ReservedIPAddress from linode_api4.paginated_list import PaginatedList CLASS_MAP = { @@ -13,6 +14,7 @@ "domain": Domain, "nodebalancer": NodeBalancer, "volume": Volume, + "reserved_ipv4_address": ReservedIPAddress, } @@ -124,7 +126,8 @@ def make_instance(cls, id, client, parent_id=None, json=None): # discard the envelope real_json = json["data"] - real_id = real_json["id"] + id_attr = getattr(make_cls, "id_attribute", "id") + real_id = real_json[id_attr] # make the real object type return Base.make( diff --git a/pyproject.toml b/pyproject.toml index 7f3129d39..3b3d982b7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -91,6 +91,8 @@ remove-all-unused-imports = true remove-duplicate-keys = false [tool.pytest.ini_options] +testpaths = ["test"] +python_files = ["*_test.py", "test_*.py"] markers = [ "smoke: mark a test as a smoke test", "flaky: mark a test as a flaky test for rerun" diff --git a/test/fixtures/monitor_dashboards.json b/test/fixtures/monitor_dashboards.json index 5e56923a1..996a3d400 100644 --- a/test/fixtures/monitor_dashboards.json +++ b/test/fixtures/monitor_dashboards.json @@ -1,41 +1,404 @@ { "data": [ - { - "created": "2024-10-10T05:01:58", - "id": 1, - "label": "Resource Usage", - "service_type": "dbaas", - "type": "standard", - "updated": "2024-10-10T05:01:58", - "widgets": [ - { - "aggregate_function": "sum", - "chart_type": "area", - "color": "default", - "label": "CPU Usage", - "metric": "cpu_usage", - "size": 12, - "unit": "%", - "y_label": "cpu_usage", - "group_by": ["entity_id"], - "filters": null - }, - { - "aggregate_function": "sum", - "chart_type": "area", - "color": "default", - "label": "Disk I/O Write", - "metric": "write_iops", - "size": 6, - "unit": "IOPS", - "y_label": "write_iops", - "group_by": ["entity_id"], - "filters": null - } - ] - } + { + "id": 1, + "type": "standard", + "service_type": "dbaas", + "label": "Resource Usage", + "group_by": [ + "entity_id" + ], + "created": "2025-02-27T07:59:40", + "updated": "2025-10-07T01:16:40", + "widgets": [ + { + "metric": "cpu_usage", + "unit": "%", + "label": "CPU Usage", + "color": "default", + "size": 12, + "chart_type": "area", + "y_label": "cpu_usage", + "aggregate_function": "avg" + }, + { + "metric": "memory_usage", + "unit": "%", + "label": "Memory Usage", + "color": "default", + "size": 6, + "chart_type": "area", + "y_label": "memory_usage", + "aggregate_function": "avg" + }, + { + "metric": "available_memory", + "unit": "GB", + "label": "Available Memory", + "color": "default", + "size": 6, + "chart_type": "area", + "y_label": "available_memory", + "aggregate_function": "avg" + }, + { + "metric": "disk_usage", + "unit": "%", + "label": "Disk Space Usage", + "color": "default", + "size": 6, + "chart_type": "area", + "y_label": "disk_usage", + "aggregate_function": "avg" + }, + { + "metric": "available_disk", + "unit": "GB", + "label": "Available Disk Space", + "color": "default", + "size": 6, + "chart_type": "area", + "y_label": "available_disk", + "aggregate_function": "avg" + }, + { + "metric": "read_iops", + "unit": "IOPS", + "label": "Disk I/O Read", + "color": "default", + "size": 6, + "chart_type": "area", + "y_label": "read_iops", + "aggregate_function": "avg" + }, + { + "metric": "write_iops", + "unit": "IOPS", + "label": "Disk I/O Write", + "color": "default", + "size": 6, + "chart_type": "area", + "y_label": "write_iops", + "aggregate_function": "avg" + } + ] + }, + { + "id": 2, + "type": "standard", + "service_type": "linode", + "label": "Overview", + "group_by": [ + "entity_id" + ], + "created": "2025-05-15T07:05:56", + "updated": "2026-04-08T04:48:43", + "widgets": [ + { + "metric": "vm_cpu_time_total", + "unit": "%", + "label": "CPU Usage by Instance", + "color": "default", + "size": 12, + "chart_type": "area", + "y_label": "vm_cpu_time_total", + "aggregate_function": "avg" + }, + { + "metric": "vm_local_disk_iops_total", + "unit": "IOPS", + "label": "Local Disk I/O by Instance", + "color": "default", + "size": 12, + "chart_type": "area", + "y_label": "vm_local_disk_iops_total", + "aggregate_function": "avg" + }, + { + "metric": "vm_network_bytes_total", + "unit": "Kbps", + "label": "Network Traffic In by Instance", + "color": "default", + "size": 12, + "chart_type": "area", + "y_label": "vm_network_bytes_total", + "aggregate_function": "avg", + "filters": [ + { + "dimension_label": "pattern", + "operator": "in", + "value": "publicin,privatein" + } + ] + }, + { + "metric": "vm_network_bytes_total", + "unit": "Kbps", + "label": "Network Traffic Out by Instance", + "color": "default", + "size": 12, + "chart_type": "area", + "y_label": "vm_network_bytes_total", + "aggregate_function": "avg", + "filters": [ + { + "dimension_label": "pattern", + "operator": "in", + "value": "publicout,privateout" + } + ] + } + ] + }, + { + "id": 3, + "type": "standard", + "service_type": "nodebalancer", + "label": "Traffic Overview", + "group_by": [ + "entity_id" + ], + "created": "2025-06-05T05:02:14", + "updated": "2026-04-08T04:48:43", + "widgets": [ + { + "metric": "nb_ingress_traffic_rate", + "unit": "Bps", + "label": "Ingress Traffic Rate", + "color": "default", + "size": 12, + "chart_type": "line", + "y_label": "nb_ingress_traffic_rate", + "aggregate_function": "sum" + }, + { + "metric": "nb_egress_traffic_rate", + "unit": "Bps", + "label": "Egress Traffic Rate", + "color": "default", + "size": 12, + "chart_type": "line", + "y_label": "nb_egress_traffic_rate", + "aggregate_function": "sum" + } + ] + }, + { + "id": 4, + "type": "standard", + "service_type": "firewall", + "label": "Connection Metrics", + "group_by": [ + "entity_id", + "linode_id", + "interface_id" + ], + "created": "2025-06-25T01:24:14", + "updated": "2026-04-08T04:48:43", + "widgets": [ + { + "metric": "fw_active_connections", + "unit": "Count", + "label": "Current Connections", + "color": "default", + "size": 12, + "chart_type": "line", + "y_label": "fw_active_connections", + "aggregate_function": "avg" + } + ] + }, + { + "id": 5, + "type": "standard", + "service_type": "netloadbalancer", + "label": "Traffic Overview", + "group_by": [ + "entity_id" + ], + "created": "2025-06-25T01:25:37", + "updated": "2026-04-08T04:48:43", + "widgets": [ + { + "metric": "nlb_ingress_traffic", + "unit": "Bps", + "label": "Ingress Traffic Rate", + "color": "default", + "size": 12, + "chart_type": "line", + "y_label": "nlb_ingress_traffic", + "aggregate_function": "sum" + }, + { + "metric": "nlb_backend_ingress_traffic", + "unit": "Bps", + "label": "Ingress Traffic Rate Per Backend Node", + "color": "default", + "size": 12, + "chart_type": "line", + "y_label": "nlb_backend_ingress_traffic", + "group_by": [ + "node_id" + ], + "aggregate_function": "sum" + } + ] + }, + { + "id": 6, + "type": "standard", + "service_type": "objectstorage", + "label": "Bucket Activity", + "group_by": [ + "entity_id" + ], + "created": "2025-09-08T06:54:54", + "updated": "2026-04-08T06:01:02", + "widgets": [ + { + "metric": "obj_bucket_size", + "unit": "Bytes", + "label": "Content Stored", + "color": "default", + "size": 6, + "chart_type": "line", + "y_label": "obj_bucket_size", + "aggregate_function": "sum" + }, + { + "metric": "obj_requests_rps", + "unit": "Count", + "label": "Requests per second", + "color": "default", + "size": 6, + "chart_type": "line", + "y_label": "obj_requests_rps", + "group_by": [ + "request_type" + ], + "aggregate_function": "sum" + } + ] + }, + { + "id": 7, + "type": "standard", + "service_type": "blockstorage", + "label": "Storage Performance", + "group_by": [ + "entity_id", + "linode_id" + ], + "created": "2025-09-30T03:34:57", + "updated": "2026-04-08T04:48:43", + "widgets": [ + { + "metric": "volume_read_ops", + "unit": "Count", + "label": "Volume Read Operations", + "color": "default", + "size": 6, + "chart_type": "line", + "y_label": "volume_read_ops", + "aggregate_function": "sum" + } + ] + }, + { + "id": 8, + "type": "standard", + "service_type": "firewall", + "label": "Ingress Activity", + "group_by": [ + "entity_id", + "nodebalancer_id" + ], + "created": "2025-10-07T01:11:59", + "updated": "2026-04-08T04:48:43", + "widgets": [ + { + "metric": "nb_ingress_bytes_accepted", + "unit": "Bps", + "label": "Accepted Bytes", + "color": "default", + "size": 12, + "chart_type": "line", + "y_label": "nb_ingress_bytes_accepted", + "aggregate_function": "sum" + } + ] + }, + { + "id": 9, + "type": "standard", + "service_type": "lke", + "label": "Cluster status", + "group_by": [ + "entity_id" + ], + "created": "2025-11-07T08:51:48", + "updated": "2025-11-07T08:51:48", + "widgets": [ + { + "metric": "lke_e_ready_worker_nodes", + "unit": "Count", + "label": "Ready Worker Nodes", + "color": "default", + "size": 6, + "chart_type": "line", + "y_label": "lke_e_ready_worker_nodes", + "aggregate_function": "max" + } + ] + }, + { + "id": 10, + "type": "standard", + "service_type": "objectstorage", + "label": "Endpoint Activity", + "group_by": [ + "endpoint" + ], + "created": "2025-11-19T06:15:28", + "updated": "2026-04-08T06:01:03", + "widgets": [ + { + "metric": "obj_bucket_size", + "unit": "Bytes", + "label": "Content Stored", + "color": "default", + "size": 6, + "chart_type": "line", + "y_label": "obj_bucket_size", + "aggregate_function": "sum" + } + ] + }, + { + "id": 11, + "type": "standard", + "service_type": "logs", + "label": "Log Delivery Status", + "group_by": [ + "entity_id" + ], + "created": "2026-03-12T04:24:34", + "updated": "2026-04-07T05:54:21", + "widgets": [ + { + "metric": "success_upload_count", + "unit": "Count", + "label": "Successful Uploads", + "color": "default", + "size": 6, + "chart_type": "area", + "y_label": "success_upload_count", + "aggregate_function": "sum" + } + ] + } ], "page": 1, "pages": 1, - "results": 1 - } \ No newline at end of file + "results": 11 +} diff --git a/test/fixtures/monitor_dashboards_1.json b/test/fixtures/monitor_dashboards_1.json index afb5d71ee..32e20fe1f 100644 --- a/test/fixtures/monitor_dashboards_1.json +++ b/test/fixtures/monitor_dashboards_1.json @@ -1,34 +1,83 @@ { - "created": "2024-10-10T05:01:58", "id": 1, - "label": "Resource Usage", - "service_type": "dbaas", "type": "standard", - "updated": "2024-10-10T05:01:58", + "service_type": "dbaas", + "label": "Resource Usage", + "group_by": [ + "entity_id" + ], + "created": "2025-02-27T07:59:40", + "updated": "2025-10-07T01:16:40", "widgets": [ - { - "aggregate_function": "sum", - "chart_type": "area", - "color": "default", - "label": "CPU Usage", - "metric": "cpu_usage", - "size": 12, - "unit": "%", - "y_label": "cpu_usage", - "group_by": ["entity_id"], - "filters": null - }, - { - "aggregate_function": "sum", - "chart_type": "area", - "color": "default", - "label": "Available Memory", - "metric": "available_memory", - "size": 6, - "unit": "GB", - "y_label": "available_memory", - "group_by": ["entity_id"], - "filters": null - } + { + "metric": "cpu_usage", + "unit": "%", + "label": "CPU Usage", + "color": "default", + "size": 12, + "chart_type": "area", + "y_label": "cpu_usage", + "aggregate_function": "avg" + }, + { + "metric": "memory_usage", + "unit": "%", + "label": "Memory Usage", + "color": "default", + "size": 6, + "chart_type": "area", + "y_label": "memory_usage", + "aggregate_function": "avg" + }, + { + "metric": "available_memory", + "unit": "GB", + "label": "Available Memory", + "color": "default", + "size": 6, + "chart_type": "area", + "y_label": "available_memory", + "aggregate_function": "avg" + }, + { + "metric": "disk_usage", + "unit": "%", + "label": "Disk Space Usage", + "color": "default", + "size": 6, + "chart_type": "area", + "y_label": "disk_usage", + "aggregate_function": "avg" + }, + { + "metric": "available_disk", + "unit": "GB", + "label": "Available Disk Space", + "color": "default", + "size": 6, + "chart_type": "area", + "y_label": "available_disk", + "aggregate_function": "avg" + }, + { + "metric": "read_iops", + "unit": "IOPS", + "label": "Disk I/O Read", + "color": "default", + "size": 6, + "chart_type": "area", + "y_label": "read_iops", + "aggregate_function": "avg" + }, + { + "metric": "write_iops", + "unit": "IOPS", + "label": "Disk I/O Write", + "color": "default", + "size": 6, + "chart_type": "area", + "y_label": "write_iops", + "aggregate_function": "avg" + } ] - } \ 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 e39a231b2..1620c4453 100644 --- a/test/fixtures/monitor_services_dbaas_dashboards.json +++ b/test/fixtures/monitor_services_dbaas_dashboards.json @@ -1,48 +1,90 @@ { "data": [ - { - "created": "2024-10-10T05:01:58", - "id": 1, - "label": "Resource Usage", - "service_type": "dbaas", - "type": "standard", - "updated": "2024-10-10T05:01:58", - "widgets": [ - { - "aggregate_function": "sum", - "chart_type": "area", - "color": "default", - "label": "CPU Usage", - "metric": "cpu_usage", - "size": 12, - "unit": "%", - "y_label": "cpu_usage", - "group_by": ["entity_id"], - "filters": null - }, - { - "aggregate_function": "sum", - "chart_type": "area", - "color": "default", - "label": "Memory Usage", - "metric": "memory_usage", - "size": 6, - "unit": "%", - "y_label": "memory_usage", - "group_by": ["entity_id"], - "filters": [ - { - "dimension_label": "pattern", - "operator": "in", - "value": "publicout,privateout" - } + { + "id": 1, + "type": "standard", + "service_type": "dbaas", + "label": "Resource Usage", + "group_by": [ + "entity_id" + ], + "created": "2025-02-27T07:59:40", + "updated": "2025-10-07T01:16:40", + "widgets": [ + { + "metric": "cpu_usage", + "unit": "%", + "label": "CPU Usage", + "color": "default", + "size": 12, + "chart_type": "area", + "y_label": "cpu_usage", + "aggregate_function": "avg" + }, + { + "metric": "memory_usage", + "unit": "%", + "label": "Memory Usage", + "color": "default", + "size": 6, + "chart_type": "area", + "y_label": "memory_usage", + "aggregate_function": "avg" + }, + { + "metric": "available_memory", + "unit": "GB", + "label": "Available Memory", + "color": "default", + "size": 6, + "chart_type": "area", + "y_label": "available_memory", + "aggregate_function": "avg" + }, + { + "metric": "disk_usage", + "unit": "%", + "label": "Disk Space Usage", + "color": "default", + "size": 6, + "chart_type": "area", + "y_label": "disk_usage", + "aggregate_function": "avg" + }, + { + "metric": "available_disk", + "unit": "GB", + "label": "Available Disk Space", + "color": "default", + "size": 6, + "chart_type": "area", + "y_label": "available_disk", + "aggregate_function": "avg" + }, + { + "metric": "read_iops", + "unit": "IOPS", + "label": "Disk I/O Read", + "color": "default", + "size": 6, + "chart_type": "area", + "y_label": "read_iops", + "aggregate_function": "avg" + }, + { + "metric": "write_iops", + "unit": "IOPS", + "label": "Disk I/O Write", + "color": "default", + "size": 6, + "chart_type": "area", + "y_label": "write_iops", + "aggregate_function": "avg" + } ] - - } - ] - } + } ], "page": 1, "pages": 1, "results": 1 - } \ No newline at end of file +} diff --git a/test/fixtures/monitor_streams.json b/test/fixtures/monitor_streams.json new file mode 100644 index 000000000..def47b365 --- /dev/null +++ b/test/fixtures/monitor_streams.json @@ -0,0 +1,31 @@ +{ + "data": [ + { + "id": 1, + "label": "my-logs-stream", + "type": "audit_logs", + "status": "active", + "destinations": [ + { + "id": 1, + "label": "my-logs-destination", + "type": "akamai_object_storage", + "details": { + "access_key_id": "1ABCD23EFG4HIJKLMNO5", + "bucket_name": "primary-bucket", + "host": "primary-bucket.us-east-1.linodeobjects.com", + "path": "audit-logs" + } + } + ], + "created": "2024-06-01T12:00:00", + "updated": "2024-06-01T12:00:00", + "created_by": "tester", + "updated_by": "tester", + "version": 1 + } + ], + "page": 1, + "pages": 1, + "results": 1 +} diff --git a/test/fixtures/monitor_streams_1_history.json b/test/fixtures/monitor_streams_1_history.json new file mode 100644 index 000000000..8f536303e --- /dev/null +++ b/test/fixtures/monitor_streams_1_history.json @@ -0,0 +1,31 @@ +{ + "data": [ + { + "id": 1, + "label": "my-logs-stream", + "type": "audit_logs", + "status": "active", + "destinations": [ + { + "id": 1, + "label": "my-logs-destination", + "type": "akamai_object_storage", + "details": { + "access_key_id": "1ABCD23EFG4HIJKLMNO5", + "bucket_name": "primary-bucket", + "host": "primary-bucket.us-east-1.linodeobjects.com", + "path": "audit-logs" + } + } + ], + "created": "2024-06-01T12:00:00", + "updated": "2024-06-02T09:00:00", + "created_by": "tester", + "updated_by": "tester", + "version": 2 + } + ], + "page": 1, + "pages": 1, + "results": 1 +} diff --git a/test/fixtures/monitor_streams_2.json b/test/fixtures/monitor_streams_2.json new file mode 100644 index 000000000..aa0a2b5cd --- /dev/null +++ b/test/fixtures/monitor_streams_2.json @@ -0,0 +1,43 @@ +{ + "id": 2, + "label": "my-custom-https-stream", + "type": "audit_logs", + "status": "active", + "destinations": [ + { + "id": 2, + "label": "my-custom-https-destination", + "type": "custom_https", + "details": { + "endpoint_url": "https://my-site.com/log-storage/basicAuth", + "authentication": { + "type": "basic", + "details": { + "basic_authentication_user": "John_Q", + "basic_authentication_password": "p@$$w0Rd" + } + }, + "data_compression": "gzip", + "content_type": "application/json", + "custom_headers": [ + { + "name": "Cache-Control", + "value": "max-age=0" + } + ], + "client_certificate_details": { + "client_ca_certificate": "-----BEGIN CERTIFICATE-----\nMIIBIjANBgkq...\n-----END CERTIFICATE-----", + "client_certificate": "-----BEGIN CERTIFICATE-----\nMIIBIjANBgkq...\n-----END CERTIFICATE-----", + "client_private_key": "-----BEGIN PRIVATE KEY-----\nMIIBIjANBgkq...\n-----END PRIVATE KEY-----", + "tls_hostname": "my-site.com" + } + } + } + ], + "created": "2024-08-01T12:00:00", + "updated": "2024-08-01T12:00:00", + "created_by": "tester", + "updated_by": "tester", + "version": 1 +} + diff --git a/test/fixtures/monitor_streams_3.json b/test/fixtures/monitor_streams_3.json new file mode 100644 index 000000000..a584dde45 --- /dev/null +++ b/test/fixtures/monitor_streams_3.json @@ -0,0 +1,29 @@ +{ + "id": 3, + "label": "my-lke-audit-logs-stream", + "type": "lke_audit_logs", + "status": "active", + "destinations": [ + { + "id": 1, + "label": "my-logs-destination", + "type": "akamai_object_storage", + "details": { + "access_key_id": "1ABCD23EFG4HIJKLMNO5", + "bucket_name": "primary-bucket", + "host": "primary-bucket.us-east-1.linodeobjects.com", + "path": "audit-logs" + } + } + ], + "details": { + "cluster_ids": [1234, 5678], + "is_auto_add_all_clusters_enabled": false + }, + "created": "2024-09-01T12:00:00", + "updated": "2024-09-01T12:00:00", + "created_by": "tester", + "updated_by": "tester", + "version": 1 +} + diff --git a/test/fixtures/monitor_streams_destinations.json b/test/fixtures/monitor_streams_destinations.json new file mode 100644 index 000000000..0e1365e26 --- /dev/null +++ b/test/fixtures/monitor_streams_destinations.json @@ -0,0 +1,24 @@ +{ + "data": [ + { + "id": 1, + "label": "my-logs-destination", + "type": "akamai_object_storage", + "status": "active", + "details": { + "access_key_id": "1ABCD23EFG4HIJKLMNO5", + "bucket_name": "primary-bucket", + "host": "primary-bucket.us-east-1.linodeobjects.com", + "path": "audit-logs" + }, + "created": "2024-06-01T12:00:00", + "updated": "2024-06-01T12:00:00", + "created_by": "tester", + "updated_by": "tester", + "version": 1 + } + ], + "page": 1, + "pages": 1, + "results": 1 +} diff --git a/test/fixtures/monitor_streams_destinations_1_history.json b/test/fixtures/monitor_streams_destinations_1_history.json new file mode 100644 index 000000000..11f262c81 --- /dev/null +++ b/test/fixtures/monitor_streams_destinations_1_history.json @@ -0,0 +1,24 @@ +{ + "data": [ + { + "id": 1, + "label": "my-logs-destination", + "type": "akamai_object_storage", + "status": "active", + "details": { + "access_key_id": "1ABCD23EFG4HIJKLMNO5", + "bucket_name": "primary-bucket", + "host": "primary-bucket.us-east-1.linodeobjects.com", + "path": "audit-logs" + }, + "created": "2024-06-01T12:00:00", + "updated": "2024-06-02T09:00:00", + "created_by": "tester", + "updated_by": "tester", + "version": 2 + } + ], + "page": 1, + "pages": 1, + "results": 1 +} diff --git a/test/fixtures/monitor_streams_destinations_2.json b/test/fixtures/monitor_streams_destinations_2.json new file mode 100644 index 000000000..215b90297 --- /dev/null +++ b/test/fixtures/monitor_streams_destinations_2.json @@ -0,0 +1,36 @@ +{ + "id": 2, + "label": "my-custom-https-destination", + "type": "custom_https", + "status": "active", + "details": { + "endpoint_url": "https://my-site.com/log-storage/basicAuth", + "authentication": { + "type": "basic", + "details": { + "basic_authentication_user": "John_Q", + "basic_authentication_password": "p@$$w0Rd" + } + }, + "data_compression": "gzip", + "content_type": "application/json", + "custom_headers": [ + { + "name": "Cache-Control", + "value": "max-age=0" + } + ], + "client_certificate_details": { + "client_ca_certificate": "-----BEGIN CERTIFICATE-----\nMIIBIjANBgkq...\n-----END CERTIFICATE-----", + "client_certificate": "-----BEGIN CERTIFICATE-----\nMIIBIjANBgkq...\n-----END CERTIFICATE-----", + "client_private_key": "-----BEGIN PRIVATE KEY-----\nMIIBIjANBgkq...\n-----END PRIVATE KEY-----", + "tls_hostname": "my-site.com" + } + }, + "created": "2024-08-01T12:00:00", + "updated": "2024-08-01T12:00:00", + "created_by": "tester", + "updated_by": "tester", + "version": 1 +} + diff --git a/test/fixtures/networking_reserved_ips.json b/test/fixtures/networking_reserved_ips.json new file mode 100644 index 000000000..05eb145eb --- /dev/null +++ b/test/fixtures/networking_reserved_ips.json @@ -0,0 +1,35 @@ +{ + "page": 1, + "pages": 1, + "results": 2, + "data": [ + { + "address": "66.175.209.100", + "gateway": "66.175.209.1", + "linode_id": null, + "prefix": 24, + "public": true, + "rdns": "66-175-209-100.ip.linodeusercontent.com", + "region": "us-east", + "reserved": true, + "subnet_mask": "255.255.255.0", + "tags": ["lb"], + "type": "ipv4", + "assigned_entity": null + }, + { + "address": "66.175.209.101", + "gateway": "66.175.209.1", + "linode_id": null, + "prefix": 24, + "public": true, + "rdns": "66-175-209-101.ip.linodeusercontent.com", + "region": "us-east", + "reserved": true, + "subnet_mask": "255.255.255.0", + "tags": [], + "type": "ipv4", + "assigned_entity": null + } + ] +} diff --git a/test/fixtures/networking_reserved_ips_types.json b/test/fixtures/networking_reserved_ips_types.json new file mode 100644 index 000000000..e233adb4e --- /dev/null +++ b/test/fixtures/networking_reserved_ips_types.json @@ -0,0 +1,27 @@ +{ + "page": 1, + "pages": 1, + "results": 1, + "data": [ + { + "id": "ipv4", + "label": "IPv4 Address", + "price": { + "hourly": 0.005, + "monthly": 2.0 + }, + "region_prices": [ + { + "id": "us-east", + "hourly": 0.005, + "monthly": 2.0 + }, + { + "id": "br-gru", + "hourly": 0.006, + "monthly": 3.0 + } + ] + } + ] +} diff --git a/test/integration/conftest.py b/test/integration/conftest.py index 74c7a8fd5..f1e20c3a0 100644 --- a/test/integration/conftest.py +++ b/test/integration/conftest.py @@ -16,7 +16,9 @@ from requests.exceptions import ConnectionError, RequestException from linode_api4 import ( + Instance, InterfaceGeneration, + IPAddress, LinodeInterfaceDefaultRouteOptions, LinodeInterfaceOptions, LinodeInterfacePublicOptions, @@ -25,6 +27,7 @@ PlacementGroupPolicy, PlacementGroupType, PostgreSQLDatabase, + ReservedIPAddress, ) from linode_api4.errors import ApiError from linode_api4.linode_client import LinodeClient, MonitorClient @@ -310,6 +313,12 @@ def test_domain(test_linode_client): domain=domain_addr, soa_email=soa_email, tags=["test-tag"] ) + def get_domain_status(): + domain.invalidate() + return domain.status == "active" + + wait_for_condition(3, 30, get_domain_status) + # Create a SRV record domain.record_create( "SRV", @@ -333,6 +342,12 @@ def test_volume(test_linode_client): volume = client.volume_create(label=label, region=region) + def get_volume_status(): + volume.invalidate() + return volume.status == "active" + + wait_for_condition(5, 45, get_volume_status) + yield volume send_request_when_resource_available(timeout=100, func=volume.delete) @@ -733,3 +748,48 @@ def test_monitor_client(get_monitor_token_for_db_entities): ) return client, entity_ids + + +@pytest.fixture +def create_reserved_ip(test_linode_client): + client = test_linode_client + region = get_region(client, {"Linodes", "Cloud Firewall"}, site_type="core") + reserved_ip = client.networking.reserved_ip_create( + region=region, tags=["test"] + ) + + yield reserved_ip + + # Delete only if IP exists (some tests delete it earlier) + if client.networking.reserved_ips( + ReservedIPAddress.address == reserved_ip.address + ): + reserved_ip.delete() + + +@pytest.fixture +def create_reserved_ip_assigned(test_linode_client, create_linode): + client = test_linode_client + linode = create_linode + reserved_ip = client.networking.reserved_ip_create( + region=linode.region, + tags=["test", "assigned"], + ) + + client.networking.ip_addresses_assign( + assignments=[{"address": reserved_ip.address, "linode_id": linode.id}], + region=linode.region, + ) + + linode = client.load(Instance, linode.id) + reserved_ip = test_linode_client.load( + ReservedIPAddress, reserved_ip.address + ) + + yield linode, reserved_ip + + # Delete assigned IP address completely + if address := client.networking.ips( + IPAddress.address == reserved_ip.address + ): + address[0].delete() diff --git a/test/integration/linode_client/test_linode_client.py b/test/integration/linode_client/test_linode_client.py index 762462220..0cacdf437 100644 --- a/test/integration/linode_client/test_linode_client.py +++ b/test/integration/linode_client/test_linode_client.py @@ -1,7 +1,7 @@ import re import time from test.integration.conftest import get_region -from test.integration.helpers import get_test_label +from test.integration.helpers import get_test_label, wait_for_condition import pytest @@ -9,6 +9,13 @@ from linode_api4.objects import ConfigInterface, ObjectStorageKeys, Region +def is_tag_created(client, tag_label): + tags = client.tags() + tag_label_list = [i.label for i in tags] + + return tag_label in tag_label_list + + @pytest.fixture(scope="session") def setup_client_and_linode(test_linode_client, e2e_test_firewall): client = test_linode_client @@ -55,10 +62,11 @@ def test_fails_to_create_domain_without_soa_email(setup_client_and_linode): timestamp = str(time.time_ns()) domain_addr = timestamp + "example.com" - try: - domain = client.domain_create(domain=domain_addr) - except ApiError as e: - assert e.status == 400 + + with pytest.raises(ApiError) as exc_info: + client.domain_create(domain=domain_addr) + + assert exc_info.value.status == 400 @pytest.mark.smoke @@ -90,11 +98,16 @@ def test_get_regions(test_linode_client): def test_image_create(setup_client_and_linode): client = setup_client_and_linode[0] linode = setup_client_and_linode[1] - label = get_test_label() description = "Test description" tags = ["test"] - usable_disk = [v for v in linode.disks if v.filesystem != "swap"] + + def linode_disks_are_ready(linode_instance): + linode_instance.invalidate() + disks = [d for d in linode_instance.disks if d.filesystem != "swap"] + return disks if disks else None + + usable_disk = wait_for_condition(5, 120, linode_disks_are_ready, linode) image = client.image_create( disk=usable_disk[0].id, label=label, description=description, tags=tags @@ -116,10 +129,11 @@ def test_fails_to_create_image_with_non_existing_disk_id( description = "Test description" disk_id = 111111 - try: + with pytest.raises(ApiError) as exc_info: client.image_create(disk=disk_id, label=label, description=description) - except ApiError as e: - assert 400 <= e.status < 500 + + # TODO: Specific status code may be used when defect is solved: ARB-7797 + assert 400 <= exc_info.value.status < 500 def test_fails_to_delete_predefined_images(setup_client_and_linode): @@ -127,12 +141,11 @@ def test_fails_to_delete_predefined_images(setup_client_and_linode): images = client.images() - try: + with pytest.raises(ApiError, match="Unauthorized") as exc_info: # new images go on top of the list thus choose last image images.last().delete() - except ApiError as e: - assert "Unauthorized" in str(e.json) - assert e.status == 403 + + assert exc_info.value.status == 403 def test_get_volume(test_linode_client, test_volume): @@ -150,11 +163,7 @@ def test_get_tag(test_linode_client, test_tag): client = test_linode_client label = test_tag.label - tags = client.tags() - - tag_label_list = [i.label for i in tags] - - assert label in tag_label_list + assert wait_for_condition(3, 30, is_tag_created, client, label) def test_create_tag_with_id( @@ -176,15 +185,10 @@ def test_create_tag_with_id( volumes=[volume.id, volume], ) - # Get tags after creation - tags = client.tags() - - tag_label_list = [i.label for i in tags] + assert wait_for_condition(3, 30, is_tag_created, client, label) tag.delete() - assert label in tag_label_list - @pytest.mark.smoke def test_create_tag_with_entities( @@ -202,15 +206,10 @@ def test_create_tag_with_entities( label, entities=[linode, domain, nodebalancer, volume] ) - # Get tags after creation - tags = client.tags() - - tag_label_list = [i.label for i in tags] + assert wait_for_condition(3, 30, is_tag_created, client, label) tag.delete() - assert label in tag_label_list - # AccountGroupTests def test_get_account_settings(test_linode_client): @@ -345,16 +344,15 @@ def test_fails_to_create_cluster_with_invalid_version(test_linode_client): client = test_linode_client region = get_region(client, {"Kubernetes"}).id - try: - cluster = client.lke.cluster_create( + with pytest.raises(ApiError, match="not valid") as exc_info: + client.lke.cluster_create( region, "example-cluster", invalid_version, {"type": "g6-standard-1", "count": 3}, ) - except ApiError as e: - assert "not valid" in str(e.json) - assert e.status == 400 + + assert exc_info.value.status == 400 # ObjectStorageGroupTests diff --git a/test/integration/models/account/test_account.py b/test/integration/models/account/test_account.py index 2bb3c48f0..286e9de06 100644 --- a/test/integration/models/account/test_account.py +++ b/test/integration/models/account/test_account.py @@ -108,10 +108,11 @@ def test_latest_get_event(test_linode_client, e2e_test_firewall): ) def get_linode_status(): + linode.invalidate() return linode.status == "running" # To ensure the Linode is running and the 'event' key has been populated - wait_for_condition(3, 100, get_linode_status) + wait_for_condition(5, 150, get_linode_status) events = client.load(Event, "") latest_events = events._raw_json.get("data")[:15] diff --git a/test/integration/models/domain/test_domain.py b/test/integration/models/domain/test_domain.py index d7956d421..0ca674f1a 100644 --- a/test/integration/models/domain/test_domain.py +++ b/test/integration/models/domain/test_domain.py @@ -43,10 +43,9 @@ def test_clone(test_linode_client, test_domain): dom = "example.clone-" + timestamp + "-inttestsdk.org" domain.clone(dom) - time.sleep(1) + time.sleep(3) ds = test_linode_client.domains() - domains = [i.domain for i in ds] assert dom in domains diff --git a/test/integration/models/linode/interfaces/test_interfaces.py b/test/integration/models/linode/interfaces/test_interfaces.py index 650a9cb6c..f5e4f1963 100644 --- a/test/integration/models/linode/interfaces/test_interfaces.py +++ b/test/integration/models/linode/interfaces/test_interfaces.py @@ -1,13 +1,16 @@ import copy import ipaddress +from test.integration.helpers import get_test_label import pytest from linode_api4 import ( ApiError, Instance, + InterfaceGeneration, LinodeInterface, LinodeInterfaceDefaultRouteOptions, + LinodeInterfaceOptions, LinodeInterfacePublicIPv4AddressOptions, LinodeInterfacePublicIPv4Options, LinodeInterfacePublicIPv6Options, @@ -18,9 +21,54 @@ LinodeInterfaceVPCIPv4Options, LinodeInterfaceVPCIPv4RangeOptions, LinodeInterfaceVPCOptions, + ReservedIPAddress, ) +def build_interface_public_ipv4(firewall, ip_address): + return LinodeInterfaceOptions( + firewall_id=firewall, + default_route=LinodeInterfaceDefaultRouteOptions( + ipv4=True, + ), + public=LinodeInterfacePublicOptions( + ipv4=LinodeInterfacePublicIPv4Options( + addresses=[ + LinodeInterfacePublicIPv4AddressOptions( + address=ip_address, primary=True + ) + ], + ), + ), + ) + + +def create_linode_with_legacy_config(client, ip_address, label, firewall): + linode, _ = client.linode.instance_create( + "g6-nanode-1", + ip_address.region, + image="linode/debian12", + label=label, + firewall=firewall, + interface_generation=InterfaceGeneration.LEGACY_CONFIG, + ipv4=[ip_address.address], + ) + return linode + + +def create_linode_with_standard_interfaces(client, ip_address, label, firewall): + interface = build_interface_public_ipv4(firewall.id, ip_address.address) + linode, _ = client.linode.instance_create( + "g6-nanode-1", + ip_address.region, + image="linode/debian12", + label=label, + interface_generation=InterfaceGeneration.LINODE, + interfaces=[interface], + ) + return linode + + def test_linode_create_with_linode_interfaces( create_vpc_with_subnet, linode_with_linode_interfaces, @@ -359,3 +407,42 @@ def test_linode_interface_firewalls(e2e_test_firewall, linode_interface_public): firewall = firewalls[0] assert firewall.id == e2e_test_firewall.id assert firewall.label == e2e_test_firewall.label + + +@pytest.mark.parametrize( + "create_linode_fn", + [create_linode_with_legacy_config, create_linode_with_standard_interfaces], + ids=["legacy_config", "standard_interfaces"], +) +def test_linode_interfaces_with_reserved_ips( + test_linode_client, e2e_test_firewall, create_reserved_ip, create_linode_fn +): + client = test_linode_client + reserved_ip = create_reserved_ip + label = get_test_label(length=8) + + linode = create_linode_fn(client, reserved_ip, label, e2e_test_firewall) + + try: + linode_ips = linode.ips.ipv4.public + assert len(linode_ips) == 1 + assert linode_ips[0].address == reserved_ip.address + assert linode_ips[0].reserved == True + assert linode_ips[0].linode_id == linode.id + assert linode_ips[0].assigned_entity.id == linode.id + assert linode_ips[0].assigned_entity.type == "linode" + assert linode_ips[0].assigned_entity.label == linode.label + assert ( + linode_ips[0].assigned_entity.url + == f"/v4/linode/instances/{linode.id}" + ) + finally: + linode.delete() + + reserved_ips_list = client.networking.reserved_ips( + ReservedIPAddress.address == reserved_ip.address + ) + assert len(reserved_ips_list) == 1 + assert reserved_ips_list[0].reserved == True + assert reserved_ips_list[0].linode_id is None + assert reserved_ips_list[0].assigned_entity is None diff --git a/test/integration/models/linode/test_linode.py b/test/integration/models/linode/test_linode.py index f73fbfc0a..1ce382cf1 100644 --- a/test/integration/models/linode/test_linode.py +++ b/test/integration/models/linode/test_linode.py @@ -19,6 +19,7 @@ Instance, InterfaceGeneration, LinodeInterface, + ReservedIPAddress, Type, ) from linode_api4.objects.linode import InstanceDiskEncryptionType, MigrationType @@ -304,7 +305,7 @@ def test_linode_rebuild(test_linode_client): root_pass="aComplex@Password123", ) - wait_for_condition(10, 100, get_status, linode, "running") + wait_for_condition(10, 150, get_status, linode, "running") retry_sending_request( 3, @@ -1243,3 +1244,42 @@ def test_create_linode_with_kernel_and_boot_size_then_add_disk_and_rebuild( assert linode_create.status == "rebuilding" wait_for_condition(10, 300, get_status, linode_create, "running") assert linode_create.image.id == "linode/debian12" + + +def test_update_linode_with_reserved_ip_in_address( + test_linode_client, e2e_test_firewall, create_reserved_ip +): + label = get_test_label(length=8) + client = test_linode_client + reserved_ip = create_reserved_ip + + linode, _ = client.linode.instance_create( + "g6-nanode-1", + reserved_ip.region, + image="linode/debian12", + label=label, + firewall=e2e_test_firewall, + ) + + linode_ips = linode.ips.ipv4.public + assert len(linode_ips) == 1 + assert linode_ips[0].address != reserved_ip.address + + linode.ip_allocate(True, reserved_ip.address) + delattr(linode, "_ips") + linode_ips = linode.ips.ipv4.public + assert len(linode_ips) == 2 + assert reserved_ip.address in [ip.address for ip in linode_ips] + + reserved_ip = client.networking.reserved_ips( + ReservedIPAddress.address == reserved_ip.address + )[0] + assert reserved_ip.linode_id == linode.id + assert reserved_ip.assigned_entity.id == linode.id + assert reserved_ip.assigned_entity.type == "linode" + assert reserved_ip.assigned_entity.label == linode.label + assert ( + reserved_ip.assigned_entity.url == f"/v4/linode/instances/{linode.id}" + ) + + linode.delete() diff --git a/test/integration/models/monitor/test_monitor_logs.py b/test/integration/models/monitor/test_monitor_logs.py new file mode 100644 index 000000000..9d4148c17 --- /dev/null +++ b/test/integration/models/monitor/test_monitor_logs.py @@ -0,0 +1,521 @@ +import os +import urllib.request +from test.integration.conftest import get_region +from test.integration.helpers import ( + get_test_label, + send_request_when_resource_available, + wait_for_condition, +) + +import pytest + +from linode_api4 import LinodeClient, LogsStreamType, PaginatedList, Region +from linode_api4.objects import ( + ObjectStorageACL, + ObjectStorageBucket, + ObjectStorageKeys, +) +from linode_api4.objects.monitor import ( + AkamaiObjectStorageLogsDestinationDetails, + LogsDestination, + LogsStream, + LogsStreamStatus, +) + +_RUN_ACLP_LOGS_STREAM_TESTS = "RUN_ACLP_LOGS_STREAM_TESTS" +_SKIP_STREAM_TESTS = pytest.mark.skipif( + os.getenv(_RUN_ACLP_LOGS_STREAM_TESTS, "").strip().lower() + not in {"yes", "true"}, + reason=f"{_RUN_ACLP_LOGS_STREAM_TESTS} environment variable must be set to 'yes' or 'true'", +) +_STREAM_FIXTURE_CLEANUP_WAIT = 2700 +_STREAM_FIXTURE_PROVISIONING_WAIT = 3600 + + +@pytest.fixture(scope="session") +def region(test_linode_client: LinodeClient): + region = get_region(test_linode_client, {"Object Storage"}) + yield region + + +@pytest.fixture(scope="session") +def create_object_storage_key(test_linode_client: LinodeClient, region: Region): + key = test_linode_client.object_storage.keys_create( + label=get_test_label(), regions=[region.id] + ) + yield key + key.delete() + + +@pytest.fixture(scope="session") +def create_destination( + test_linode_client: LinodeClient, + create_object_storage_key: ObjectStorageKeys, + region: Region, +): + dest, bucket = _create_destination_with_bucket( + test_linode_client, create_object_storage_key, region + ) + yield dest + _delete_destination_with_bucket(test_linode_client, dest, bucket) + + +def _create_destination_with_bucket( + client: LinodeClient, key: ObjectStorageKeys, region: Region +): + """Helper that creates an OBJ bucket and a logs destination backed by it.""" + bucket = client.object_storage.bucket_create( + cluster_or_region=region.id, + label=get_test_label(), + acl=ObjectStorageACL.PRIVATE, + cors_enabled=False, + ) + dest = client.monitor.destination_create( + label=get_test_label(), + type="akamai_object_storage", + details=AkamaiObjectStorageLogsDestinationDetails( + access_key_id=key.access_key, + access_key_secret=key.secret_key, + bucket_name=bucket.label, + host=bucket.hostname, + ), + ) + return dest, bucket + + +def _delete_destination_with_bucket( + client: LinodeClient, dest: LogsDestination, bucket: ObjectStorageBucket +): + """Helper that deletes a logs destination and its backing OBJ bucket.""" + + def no_stream_attached(): + streams = client.monitor.streams() + return all( + all(d.id != dest.id for d in s.destinations) for s in streams + ) + + wait_for_condition(30, _STREAM_FIXTURE_CLEANUP_WAIT, no_stream_attached) + send_request_when_resource_available(timeout=100, func=dest.delete) + _empty_bucket(client, bucket) + send_request_when_resource_available(timeout=100, func=bucket.delete) + + +def _skip_if_streams_exist(client: LinodeClient): + """Skip the current test if any streams already exist on the account. + Only one stream can be present per account at a time.""" + existing_streams = client.monitor.streams() + if len(existing_streams) > 0: + stream_labels = [s.label for s in existing_streams] + pytest.skip( + f"Skipping: existing stream(s) found on this account " + f"(labels: {stream_labels}). Only one stream can be present per account." + ) + + +def _empty_bucket(client: LinodeClient, bucket: ObjectStorageBucket): + """ + Helper function clearing objects in the test bucket so it can be deleted. + """ + for obj in bucket.contents(): + signed = client.object_storage.object_url_create( + cluster_or_region_id=bucket.region, + bucket=bucket.label, + method="DELETE", + name=obj.name, + ) + urllib.request.urlopen( + urllib.request.Request(signed.url, method="DELETE") + ) + + +def test_list_destinations( + test_linode_client: LinodeClient, create_destination: LogsDestination +): + """ + Test that listing destinations returns a PaginatedList containing the previously created destination. + """ + destinations = test_linode_client.monitor.destinations() + + assert isinstance(destinations, PaginatedList) + assert len(destinations) > 0 + assert all(isinstance(d, LogsDestination) for d in destinations) + + ids = [d.id for d in destinations] + assert create_destination.id in ids + + +def test_get_destination_by_id( + test_linode_client: LinodeClient, create_destination: LogsDestination +): + """ + Test that fetching destination with id filter returns correct destination. + """ + destination_by_id = test_linode_client.load( + LogsDestination, create_destination.id + ) + + assert isinstance(destination_by_id, LogsDestination) + assert destination_by_id.id == create_destination.id + assert destination_by_id.label == create_destination.label + assert destination_by_id.type == create_destination.type + + +def test_update_destination_label_and_version_history( + test_linode_client: LinodeClient, + create_destination: LogsDestination, + create_object_storage_key: ObjectStorageKeys, +): + """ + Test that a LogsDestination label can be updated via save(), + and that history reflects both states. + """ + new_label = create_destination.label + "-upd" + new_path = "updated/logs/path/" + + dest = test_linode_client.load(LogsDestination, create_destination.id) + original_version = dest.version + dest.label = new_label + dest.details.path = new_path + dest.details.access_key_secret = create_object_storage_key.secret_key + dest.save() + + updated = test_linode_client.load(LogsDestination, create_destination.id) + assert updated.label == new_label + assert updated.details.path == new_path + + history = updated.history + assert history is not None + assert len(history) >= 2 + + snapshot_original = next( + snap for snap in history if snap.version == original_version + ) + snapshot_updated = next( + snap for snap in history if snap.version == updated.version + ) + + assert snapshot_updated.label == new_label + assert snapshot_updated.details.path == new_path + assert snapshot_updated.id == create_destination.id + + assert snapshot_original.label == create_destination.label + assert snapshot_original.details.path is None + assert snapshot_original.id == create_destination.id + + +def test_fails_to_create_destination_invalid_secret( + test_linode_client: LinodeClient, +): + """ + Test that a destination create request with invalid access key results in a 400 ApiError. + """ + from linode_api4.errors import ApiError + + with pytest.raises(ApiError) as excinfo: + test_linode_client.monitor.destination_create( + label=get_test_label(), + type="akamai_object_storage", + details=AkamaiObjectStorageLogsDestinationDetails( + access_key_id="1", + access_key_secret="1", + bucket_name="some-bucket", + host="some-bucket.us-southeast-1.linodeobjects.com", + ), + ) + assert excinfo.value.status == 400 + assert excinfo.value.errors == ["Invalid access key id or secret key"] + + +def test_fails_to_create_destination_invalid_type( + test_linode_client: LinodeClient, +): + """ + Test that a destination create request with an unsupported type + results in a 400 ApiError. + """ + from linode_api4.errors import ApiError + + with pytest.raises(ApiError) as excinfo: + test_linode_client.monitor.destination_create( + label=get_test_label(), + type="invalid_type", + details=AkamaiObjectStorageLogsDestinationDetails( + access_key_id="SOMEACCESSKEY", + access_key_secret="SOMESECRETKEY", + bucket_name="some-bucket", + host="some-bucket.us-southeast-1.linodeobjects.com", + ), + ) + assert excinfo.value.status == 400 + assert excinfo.value.errors == [ + "Must be one of akamai_object_storage, custom_https" + ] + + +def test_fails_to_create_destination_empty_required_fields( + test_linode_client: LinodeClient, +): + """ + Test that a destination create request with missing required fields + results in a 400 ApiError. + """ + from linode_api4.errors import ApiError + + with pytest.raises(ApiError) as excinfo: + test_linode_client.monitor.destination_create( + label=get_test_label(), + type="akamai_object_storage", + details=AkamaiObjectStorageLogsDestinationDetails( + access_key_id="", + access_key_secret="", + bucket_name="", + host="", + ), + ) + assert excinfo.value.status == 400 + assert len(excinfo.value.errors) == 4 + assert all( + error == "Length must be 1-255 characters" + for error in excinfo.value.errors + ) + + +@pytest.fixture(scope="session") +def invalid_destination_error(test_linode_client: LinodeClient): + """ + Session-scoped fixture to attempt invalid stream creation deterministically + before any valid streams are created. Yields the resulting exception so + assertions can be handled safely within the test case. + """ + from linode_api4.errors import ApiError + + _skip_if_streams_exist(test_linode_client) + + try: + test_linode_client.monitor.stream_create( + label=get_test_label(), + type=LogsStreamType.audit_logs, + destinations=[999999999], + ) + yield None + except ApiError as excinfo: + yield excinfo + + +@_SKIP_STREAM_TESTS +def test_fails_to_create_stream_invalid_destination(invalid_destination_error): + """ + Test that creating a stream with a non-existent destination ID results in a 400 ApiError. + Requires no other streams to be present on account. + """ + assert ( + invalid_destination_error is not None + ), "Expected an ApiError but none was raised" + + assert invalid_destination_error.status == 400 + assert invalid_destination_error.errors == ["Destination not found"] + + +@pytest.fixture(scope="session") +def create_secondary_destination( + test_linode_client: LinodeClient, + create_object_storage_key: ObjectStorageKeys, + region: Region, +): + dest, bucket = _create_destination_with_bucket( + test_linode_client, create_object_storage_key, region + ) + yield dest + _delete_destination_with_bucket(test_linode_client, dest, bucket) + + +@pytest.fixture(scope="session") +def create_stream( + test_linode_client: LinodeClient, + create_destination: LogsDestination, + invalid_destination_error, # This ensures run order to keep negative test case deterministic + create_secondary_destination: LogsDestination, # This ensures teardown order - stream must be deleted before its destinations can be deleted +): + _skip_if_streams_exist(test_linode_client) + + stream = test_linode_client.monitor.stream_create( + label=get_test_label(), + destinations=[create_destination.id], + type=LogsStreamType.audit_logs, + ) + assert stream.id is not None + assert stream.status == LogsStreamStatus.provisioning + yield stream + _stream_teardown(test_linode_client, stream) + + +def _wait_for_stream_updatable(client: LinodeClient, stream_id: int): + """ + Blocks until the stream with the given id reaches active or inactive status. + Updating destinations or other attributes puts the stream + back into a transitional state, and attempting to delete or modify it while + transitioning results in [400] errors. + """ + + def is_stream_updatable(): + stream = client.load(LogsStream, stream_id) + return stream.status in ( + LogsStreamStatus.active, + LogsStreamStatus.inactive, + ) + + wait_for_condition( + 30, _STREAM_FIXTURE_PROVISIONING_WAIT, is_stream_updatable + ) + + +def _stream_teardown(test_linode_client: LinodeClient, stream: LogsStream): + _wait_for_stream_updatable(test_linode_client, stream.id) + send_request_when_resource_available(timeout=100, func=stream.delete) + + # The delete request returns 200 but stream deletion is async on the backend. + # Wait until the stream is fully gone before teardown continues, so that + # dependent fixtures (e.g. create_secondary_destination) can proceed with teardown. + def is_stream_deleted(): + existing = test_linode_client.monitor.streams() + return all(s.id != stream.id for s in existing) + + wait_for_condition(30, _STREAM_FIXTURE_CLEANUP_WAIT, is_stream_deleted) + + +@pytest.fixture(scope="session") +def provisioned_stream( + test_linode_client: LinodeClient, create_stream: LogsStream +): + """ + Waits until the stream transitions out of provisioning state. + NOTE: Stream provisioning can take up to 60 minutes to finish. + """ + + def is_stream_provisioned(): + stream = test_linode_client.load(LogsStream, create_stream.id) + return stream.status in ( + LogsStreamStatus.active, + LogsStreamStatus.inactive, + ) + + wait_for_condition( + 60, _STREAM_FIXTURE_PROVISIONING_WAIT, is_stream_provisioned + ) + + yield test_linode_client.load(LogsStream, create_stream.id) + + +@pytest.fixture(scope="function") +def wait_for_updatable_status( + test_linode_client: LinodeClient, provisioned_stream: LogsStream +): + """ + Waits for the stream to be in an active or inactive state before a test runs. + Streams can switch to `provisioning` state between updates. This makes sure the previous update is fully finished. + """ + _wait_for_stream_updatable(test_linode_client, provisioned_stream.id) + + +@_SKIP_STREAM_TESTS +def test_list_streams( + test_linode_client: LinodeClient, provisioned_stream: LogsStream +): + """ + Test that listing streams returns a PaginatedList containing the previously created stream. + """ + streams = test_linode_client.monitor.streams() + + assert isinstance(streams, PaginatedList) + assert len(streams) > 0 + assert all(isinstance(s, LogsStream) for s in streams) + + ids = [s.id for s in streams] + assert provisioned_stream.id in ids + + +@_SKIP_STREAM_TESTS +def test_get_stream_by_id( + test_linode_client: LinodeClient, provisioned_stream: LogsStream +): + """ + Test that loading a stream by ID returns the correct stream with expected fields. + """ + stream = test_linode_client.load(LogsStream, provisioned_stream.id) + + assert isinstance(stream, LogsStream) + assert stream.id == provisioned_stream.id + assert provisioned_stream.label in stream.label + assert stream.status in (LogsStreamStatus.active, LogsStreamStatus.inactive) + assert len(stream.destinations) == 1 + + +@_SKIP_STREAM_TESTS +@pytest.mark.usefixtures("wait_for_updatable_status") +def test_update_stream_label_and_status( + test_linode_client: LinodeClient, provisioned_stream: LogsStream +): + """ + Test that a LogsStream label and status can both be updated via save(), and that + the version history reflects label changes across versions. + """ + stream = test_linode_client.load(LogsStream, provisioned_stream.id) + original_label = stream.label + original_status = stream.status + version_before = stream.version + + new_label = original_label + "-upd" + new_status = ( + [LogsStreamStatus.inactive, LogsStreamStatus.deactivating] + if original_status == LogsStreamStatus.active + else [LogsStreamStatus.active, LogsStreamStatus.provisioning] + ) + + stream.label = new_label + stream.status = new_status[0] + result = stream.save() + assert result is True + + updated = test_linode_client.load(LogsStream, provisioned_stream.id) + assert updated.label == new_label + assert updated.status in new_status + + history = updated.history + snapshot_original = next(h for h in history if h.version == version_before) + snapshot_updated = next(h for h in history if h.version == updated.version) + + assert snapshot_original.label == original_label + assert snapshot_updated.label == new_label + assert snapshot_updated.id == provisioned_stream.id + + +@_SKIP_STREAM_TESTS +@pytest.mark.usefixtures("wait_for_updatable_status") +def test_update_stream_destinations( + test_linode_client: LinodeClient, + provisioned_stream: LogsStream, + create_secondary_destination: LogsDestination, +): + """ + Test that a stream destination can be replaced via update_destinations(), + and that history reflects the change. The API allows exactly one destination per stream. + """ + stream = test_linode_client.load(LogsStream, provisioned_stream.id) + original_destinations = [stream.destinations[0].id] + version_before = stream.version + + result = stream.update_destinations([create_secondary_destination.id]) + assert result is True + + updated = test_linode_client.load(LogsStream, provisioned_stream.id) + assert len(updated.destinations) == 1 + assert updated.destinations[0].id == create_secondary_destination.id + + history = updated.history + snapshot_original = next(h for h in history if h.version == version_before) + snapshot_updated = next(h for h in history if h.version == updated.version) + + assert snapshot_original.destinations[0].id == original_destinations[0] + assert ( + snapshot_updated.destinations[0].id == create_secondary_destination.id + ) diff --git a/test/integration/models/networking/test_networking.py b/test/integration/models/networking/test_networking.py index 47eeaf0e6..f2bdf00a3 100644 --- a/test/integration/models/networking/test_networking.py +++ b/test/integration/models/networking/test_networking.py @@ -1,3 +1,4 @@ +import ipaddress import time from test.integration.conftest import ( get_api_ca_file, @@ -12,9 +13,20 @@ ) import pytest +import requests -from linode_api4 import Instance, LinodeClient -from linode_api4.objects import Config, ConfigInterfaceIPv4, Firewall, IPAddress +from linode_api4 import ( + ApiError, + Instance, + LinodeClient, +) +from linode_api4.objects import ( + Config, + ConfigInterfaceIPv4, + Firewall, + IPAddress, + ReservedIPAddress, +) from linode_api4.objects.networking import ( FirewallCreateDevicesOptions, NetworkTransferPrice, @@ -352,3 +364,198 @@ def test_ip_info(test_linode_client, create_linode): assert ip_info.subnet_mask is not None assert ip_info.type == "ipv4" assert ip_info.vpc_nat_1_1 is None + + +def verify_reserved_ip(reserved_ip): + assert isinstance( + ipaddress.ip_address(reserved_ip.address), ipaddress.IPv4Address + ) + assert reserved_ip.type == "ipv4" + assert reserved_ip.public == True + assert reserved_ip.reserved == True + assert reserved_ip.linode_id is None + assert reserved_ip.assigned_entity is None + + +def verify_reserved_ip_assigned(reserved_ip, resource): + assert isinstance( + ipaddress.ip_address(reserved_ip.address), ipaddress.IPv4Address + ) + assert reserved_ip.type == "ipv4" + assert reserved_ip.public == True + assert reserved_ip.reserved == True + assert reserved_ip.linode_id == resource.id + assert reserved_ip.region.id == resource.region.id + assert reserved_ip.assigned_entity.id == resource.id + assert reserved_ip.assigned_entity.type == "linode" + assert reserved_ip.assigned_entity.label == resource.label + assert ( + reserved_ip.assigned_entity.url == f"/v4/linode/instances/{resource.id}" + ) + + +@pytest.mark.smoke +@pytest.mark.parametrize( + "region, tags, expected", + [ + (TEST_REGION, ["test"], ["test"]), + (TEST_REGION, None, []), + ], +) +def test_create_reserved_ip( + request, test_linode_client, region, tags, expected +): + client = test_linode_client + reserved_ip = client.networking.reserved_ip_create(region=region, tags=tags) + request.addfinalizer(reserved_ip.delete) + + verify_reserved_ip(reserved_ip) + assert reserved_ip.tags == expected + + +def test_create_reserved_ip_wo_region_fail(test_linode_client): + client = test_linode_client + + with pytest.raises(ApiError) as exc_info: + client.networking.reserved_ip_create(region=None, tags=["test"]) + + error_msg = str(exc_info.value.json) + assert exc_info.value.status == 400 + assert "region is required" in error_msg + + +def test_update_reserved_ip_tags(test_linode_client, create_reserved_ip): + client = test_linode_client + reserved_ip = create_reserved_ip + verify_reserved_ip(reserved_ip) + assert reserved_ip.tags == ["test"] + + reserved_ip.tags = ["updated"] + reserved_ip.save() + reserved_ip = client.networking.reserved_ips( + ReservedIPAddress.address == reserved_ip.address + )[0] + verify_reserved_ip(reserved_ip) + assert reserved_ip.tags == ["updated"] + + +def test_create_reserved_ip_assigned( + test_linode_client, create_reserved_ip_assigned +): + client = test_linode_client + linode, reserved_ip = create_reserved_ip_assigned + verify_reserved_ip_assigned(reserved_ip, linode) + assert sorted(reserved_ip.tags) == ["assigned", "test"] + + reserved_ips_list = client.networking.reserved_ips() + assert reserved_ip.address in [ip.address for ip in reserved_ips_list] + + linode_ips = linode.ips.ipv4.public + assert len(linode_ips) == 2 + assert any([ip.reserved for ip in linode_ips]) + + reserved_ip.delete() + reserved_ips_list = client.networking.reserved_ips() + assert reserved_ip.address not in [ip.address for ip in reserved_ips_list] + + reserved_ips_list = client.networking.reserved_ips( + ReservedIPAddress.address == reserved_ip.address + ) + assert len(reserved_ips_list) == 0 + + delattr(linode, "_ips") + linode_ips = linode.ips.ipv4.public + assert len(linode_ips) == 2 + assert not any([ip.reserved for ip in linode_ips]) + assert not any([ip.tags for ip in linode_ips]) # Tags should be removed + + +def test_get_reserved_ip_types(test_linode_client): + client = test_linode_client + endpoint = client.base_url + "/networking/reserved/ips/types" + types = requests.get(endpoint).json()[ + "data" + ] # Pricing should be publicly available + + assert isinstance(types, list) + assert types[0]["id"] == "reserved-ipv4" + assert types[0]["label"] == "Reserved IPv4" + assert "hourly" in types[0]["price"] + assert "monthly" in types[0]["price"] + assert any(price != 0 for price in list(types[0]["price"].values())) + assert isinstance(types[0]["region_prices"], list) + + +@pytest.mark.smoke +def test_create_reserved_ip_with_allocate_and_region(test_linode_client): + client = test_linode_client + reserved_ip = client.networking.ip_allocate( + reserved=True, region=TEST_REGION + ) + + verify_reserved_ip(reserved_ip) + assert reserved_ip.tags == [] + + # clean-up + reserved_ip = client.load(ReservedIPAddress, reserved_ip.address) + reserved_ip.delete() + + +@pytest.mark.smoke +def test_create_reserved_ip_with_allocate_and_linode( + test_linode_client, create_linode +): + client = test_linode_client + linode = create_linode + reserved_ip = client.networking.ip_allocate(reserved=True, linode=linode.id) + + verify_reserved_ip_assigned(reserved_ip, linode) + assert reserved_ip.tags == [] + + # clean-up + reserved_ip = client.load(ReservedIPAddress, reserved_ip.address) + reserved_ip.delete() + + # Delete assigned IP address completely + if address := client.networking.ips( + IPAddress.address == reserved_ip.address + ): + address[0].delete() + + +def test_reserve_ephemeral_ip(test_linode_client, create_linode): + client = test_linode_client + linode = create_linode + + ip_address = client.load(IPAddress, linode.ipv4[0]) + assert ip_address.linode_id == linode.id + assert ip_address.reserved == False + + ip_address.reserved = True + ip_address.save() + ip_address = client.load(IPAddress, linode.ipv4[0]) + assert ip_address.linode_id == linode.id + assert ip_address.reserved == True + + ip_address.reserved = False + ip_address.save() + ip_address = client.load(IPAddress, linode.ipv4[0]) + assert ip_address.linode_id == linode.id + assert ip_address.reserved == False + + +def test_convert_unassigned_reserved_ip_to_ephemeral( + test_linode_client, create_reserved_ip +): + client = test_linode_client + reserved_ip = create_reserved_ip + verify_reserved_ip(reserved_ip) + + ip_address = client.load(IPAddress, reserved_ip.address) + ip_address.reserved = False + ip_address.save() + + reserved_ips_list = client.networking.reserved_ips( + ReservedIPAddress.address == reserved_ip.address + ) + assert len(reserved_ips_list) == 0 diff --git a/test/integration/models/nodebalancer/test_nodebalancer.py b/test/integration/models/nodebalancer/test_nodebalancer.py index 039259c68..4ddefb15c 100644 --- a/test/integration/models/nodebalancer/test_nodebalancer.py +++ b/test/integration/models/nodebalancer/test_nodebalancer.py @@ -15,6 +15,7 @@ NodeBalancerNode, NodeBalancerType, RegionPrice, + ReservedIPAddress, ) TEST_REGION = get_region( @@ -114,6 +115,33 @@ def test_create_nb(test_linode_client, e2e_test_firewall): nb.delete() +def test_create_nb_with_reserved_ip( + test_linode_client, e2e_test_firewall, create_reserved_ip +): + client = test_linode_client + reserved_ip = create_reserved_ip + label = get_test_label(8) + + nb = client.nodebalancer_create( + region=reserved_ip.region, + label=label, + firewall=e2e_test_firewall.id, + client_udp_sess_throttle=5, + ipv4=reserved_ip.address, + ) + + assert label == nb.label + assert nb.ipv4.address == reserved_ip.address + assert nb.ipv4.public == True + assert nb.ipv4.reserved == True + + nb.delete() + reserved_ip = client.networking.reserved_ips( + ReservedIPAddress.address == reserved_ip.address + )[0] + assert reserved_ip.assigned_entity is None + + def test_get_nodebalancer_config(test_linode_client, create_nb_config): config = test_linode_client.load( NodeBalancerConfig, diff --git a/test/integration/models/placement/test_placement.py b/test/integration/models/placement/test_placement.py index 21c6519f5..1a7e38e27 100644 --- a/test/integration/models/placement/test_placement.py +++ b/test/integration/models/placement/test_placement.py @@ -92,7 +92,7 @@ def test_pg_migration( # Says it could take up to ~6 hrs for migration to fully complete send_request_when_resource_available( - 300, + 400, linode.initiate_migration, placement_group=pg_inbound.id, migration_type=MigrationType.COLD, diff --git a/test/integration/models/tag/test_tag.py b/test/integration/models/tag/test_tag.py index d2edf84c5..5875adf51 100644 --- a/test/integration/models/tag/test_tag.py +++ b/test/integration/models/tag/test_tag.py @@ -2,7 +2,7 @@ import pytest -from linode_api4.objects import Tag +from linode_api4.objects import ReservedIPAddress, Tag @pytest.fixture @@ -15,8 +15,37 @@ def test_tag(test_linode_client): tag.delete() +@pytest.fixture +def create_tag_with_reserved_ip(test_linode_client, create_reserved_ip): + unique_tag = get_test_label() + "_tag" + reserved_ip = create_reserved_ip + + tag = test_linode_client.tags.create( + unique_tag, reserved_ipv4_addresses=[reserved_ip.address] + ) + reserved_ip = test_linode_client.networking.reserved_ips( + ReservedIPAddress.address == reserved_ip.address + )[0] + + yield tag, reserved_ip + + tag.delete() + + @pytest.mark.smoke def test_get_tag(test_linode_client, test_tag): tag = test_linode_client.load(Tag, test_tag.id) assert tag.id == test_tag.id + + +def test_get_tag_with_reserved_ip( + test_linode_client, create_tag_with_reserved_ip +): + tag, reserved_ip = create_tag_with_reserved_ip + tag = test_linode_client.load(Tag, tag.id).objects[0] + + assert isinstance(tag, ReservedIPAddress) + assert tag.address == reserved_ip.address + assert tag.reserved == reserved_ip.reserved + assert tag.tags == reserved_ip.tags diff --git a/test/unit/groups/networking_test.py b/test/unit/groups/networking_test.py index 72cc95cda..6503b426f 100644 --- a/test/unit/groups/networking_test.py +++ b/test/unit/groups/networking_test.py @@ -1,6 +1,8 @@ -from test.unit.base import ClientBaseCase +from test.unit.base import ClientBaseCase, MethodMock from test.unit.objects.firewall_test import FirewallTemplatesTest +from linode_api4.objects.networking import ReservedIPAddress + class NetworkingGroupTest(ClientBaseCase): """ @@ -15,3 +17,224 @@ def test_get_templates(self): assert templates[1].slug == "vpc" FirewallTemplatesTest.assert_rules(templates[1].rules) + + def test_reserved_ips_list(self): + """ + Tests that reserved IPs are listed correctly. + """ + reserved = self.client.networking.reserved_ips() + + assert len(reserved) == 2 + assert reserved[0].address == "66.175.209.100" + assert reserved[0].region.id == "us-east" + assert reserved[0].reserved is True + assert reserved[0].tags == ["lb"] + assert reserved[1].address == "66.175.209.101" + assert reserved[1].tags == [] + + def test_reserved_ip_create(self): + """ + Tests that reserved_ip_create sends the correct request body and returns a + ReservedIPAddress. + """ + with MethodMock( + "post", + { + "address": "66.175.209.200", + "gateway": "66.175.209.1", + "linode_id": None, + "prefix": 24, + "public": True, + "rdns": "66-175-209-200.ip.linodeusercontent.com", + "region": "us-east", + "reserved": True, + "subnet_mask": "255.255.255.0", + "tags": ["lb"], + "type": "ipv4", + "assigned_entity": None, + }, + ) as m: + result = self.client.networking.reserved_ip_create( + "us-east", tags=["lb"] + ) + + assert m.call_url == "/networking/reserved/ips" + body = m.call_data + assert body["region"] == "us-east" + assert body["tags"] == ["lb"] + + assert isinstance(result, ReservedIPAddress) + assert result.address == "66.175.209.200" + assert result.reserved is True + assert result.tags == ["lb"] + assert result.assigned_entity is None + + def test_reserved_ip_create_no_tags(self): + """ + Tests that reserved_ip_create omits tags from the request when not provided. + """ + with MethodMock( + "post", + { + "address": "66.175.209.201", + "gateway": "66.175.209.1", + "linode_id": None, + "prefix": 24, + "public": True, + "rdns": "66-175-209-201.ip.linodeusercontent.com", + "region": "us-east", + "reserved": True, + "subnet_mask": "255.255.255.0", + "tags": [], + "type": "ipv4", + }, + ) as m: + self.client.networking.reserved_ip_create("us-east") + + body = m.call_data + assert "tags" not in body + + def test_reserved_ip_types(self): + """ + Tests that reserved IP types are listed with pricing data. + """ + types = self.client.networking.reserved_ip_types() + + assert len(types) == 1 + assert types[0].id == "ipv4" + assert types[0].label == "IPv4 Address" + assert types[0].price.hourly == 0.005 + assert types[0].price.monthly == 2.0 + assert len(types[0].region_prices) == 2 + assert types[0].region_prices[0].id == "us-east" + + def test_ip_allocate_reserved_with_region(self): + """ + Tests that ip_allocate with reserved=True and a region creates an unassigned reserved IP. + """ + with MethodMock( + "post", + { + "address": "66.175.209.200", + "gateway": "66.175.209.1", + "linode_id": None, + "prefix": 24, + "public": True, + "rdns": "", + "region": "us-east", + "subnet_mask": "255.255.255.0", + "type": "ipv4", + "reserved": True, + "tags": [], + }, + ) as m: + ip = self.client.networking.ip_allocate( + reserved=True, region="us-east" + ) + + assert m.call_url == "/networking/ips/" + body = m.call_data + assert body["type"] == "ipv4" + assert body["public"] is True + assert body["reserved"] is True + assert body["region"] == "us-east" + assert "linode_id" not in body + assert ip.address == "66.175.209.200" + assert ip.reserved is True + + def test_ip_allocate_reserved_with_linode(self): + """ + Tests that ip_allocate with reserved=True and a linode assigns a reserved IP to that Instance. + """ + with MethodMock( + "post", + { + "address": "66.175.209.201", + "gateway": "66.175.209.1", + "linode_id": 123, + "prefix": 24, + "public": True, + "rdns": "", + "region": "us-east", + "subnet_mask": "255.255.255.0", + "type": "ipv4", + "reserved": True, + "tags": [], + }, + ) as m: + ip = self.client.networking.ip_allocate(linode=123, reserved=True) + + body = m.call_data + assert body["linode_id"] == 123 + assert body["reserved"] is True + assert "region" not in body + assert ip.linode_id == 123 + assert ip.reserved is True + + def test_ip_allocate_ephemeral(self): + """ + Tests that ip_allocate without reserved= sends the classic ephemeral request. + """ + with MethodMock( + "post", + { + "address": "198.51.100.1", + "gateway": "198.51.100.254", + "linode_id": 456, + "prefix": 24, + "public": True, + "rdns": "", + "region": "us-east", + "subnet_mask": "255.255.255.0", + "type": "ipv4", + "reserved": False, + "tags": [], + }, + ) as m: + ip = self.client.networking.ip_allocate(linode=456) + + body = m.call_data + assert body["linode_id"] == 456 + assert body["type"] == "ipv4" + assert "reserved" not in body + assert ip.linode_id == 456 + assert ip.reserved is False + + def test_ip_allocate_requires_linode_when_not_reserved(self): + """ + Tests that ip_allocate rejects ephemeral allocation without a linode. + """ + with MethodMock("post", {}) as m: + with self.assertRaises(ValueError) as ctx: + self.client.networking.ip_allocate() + + assert str(ctx.exception) == ( + "linode is required when reserved is False." + ) + assert m.called is False + + def test_ip_allocate_requires_linode_or_region_when_reserved(self): + """ + Tests that ip_allocate rejects reserved allocation without a linode or region. + """ + with MethodMock("post", {}) as m: + with self.assertRaises(ValueError) as ctx: + self.client.networking.ip_allocate(reserved=True) + + assert str(ctx.exception) == ( + "Either linode or region must be provided when reserved is True." + ) + assert m.called is False + + def test_ip_allocate_rejects_region_when_not_reserved(self): + """ + Tests that ip_allocate rejects region when reserved is False. + """ + with MethodMock("post", {}) as m: + with self.assertRaises(ValueError) as ctx: + self.client.networking.ip_allocate(region="us-east", linode=456) + + assert str(ctx.exception) == ( + "region is only valid when reserved is True." + ) + assert m.called is False diff --git a/test/unit/objects/monitor_test.py b/test/unit/objects/monitor_test.py index 5913b3b28..c0999e485 100644 --- a/test/unit/objects/monitor_test.py +++ b/test/unit/objects/monitor_test.py @@ -1,7 +1,23 @@ import datetime from test.unit.base import ClientBaseCase -from linode_api4.objects import AlertChannel, MonitorDashboard, MonitorService +from linode_api4.objects import ( + AlertChannel, + LogsDestination, + LogsDestinationHistory, + LogsStream, + LogsStreamDestination, + MonitorDashboard, + MonitorService, +) +from linode_api4.objects.monitor import ( + AkamaiObjectStorageLogsDestinationDetails, + CustomHTTPSLogsDestinationDetails, + DestinationAuthentication, + LogsDestinationDetailsBase, + LogsStreamDetails, + LogsStreamType, +) class MonitorTest(ClientBaseCase): @@ -25,15 +41,17 @@ def test_dashboard_by_ID(self): dashboard = self.client.load(MonitorDashboard, 1) self.assertEqual(dashboard.type, "standard") self.assertEqual( - dashboard.created, datetime.datetime(2024, 10, 10, 5, 1, 58) + dashboard.created, datetime.datetime(2025, 2, 27, 7, 59, 40) ) self.assertEqual(dashboard.id, 1) self.assertEqual(dashboard.label, "Resource Usage") self.assertEqual(dashboard.service_type, "dbaas") self.assertEqual( - dashboard.updated, datetime.datetime(2024, 10, 10, 5, 1, 58) + dashboard.updated, datetime.datetime(2025, 10, 7, 1, 16, 40) ) - self.assertEqual(dashboard.widgets[0].aggregate_function, "sum") + self.assertEqual(dashboard.group_by, ["entity_id"]) + self.assertEqual(len(dashboard.widgets), 7) + self.assertEqual(dashboard.widgets[0].aggregate_function, "avg") self.assertEqual(dashboard.widgets[0].chart_type, "area") self.assertEqual(dashboard.widgets[0].color, "default") self.assertEqual(dashboard.widgets[0].label, "CPU Usage") @@ -41,22 +59,24 @@ 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].group_by) self.assertIsNone(dashboard.widgets[0].filters) def test_dashboard_by_service_type(self): dashboards = self.client.monitor.dashboards(service_type="dbaas") self.assertEqual(dashboards[0].type, "standard") self.assertEqual( - dashboards[0].created, datetime.datetime(2024, 10, 10, 5, 1, 58) + dashboards[0].created, datetime.datetime(2025, 2, 27, 7, 59, 40) ) self.assertEqual(dashboards[0].id, 1) self.assertEqual(dashboards[0].label, "Resource Usage") self.assertEqual(dashboards[0].service_type, "dbaas") self.assertEqual( - dashboards[0].updated, datetime.datetime(2024, 10, 10, 5, 1, 58) + dashboards[0].updated, datetime.datetime(2025, 10, 7, 1, 16, 40) ) - self.assertEqual(dashboards[0].widgets[0].aggregate_function, "sum") + self.assertEqual(dashboards[0].group_by, ["entity_id"]) + self.assertEqual(len(dashboards[0].widgets), 7) + self.assertEqual(dashboards[0].widgets[0].aggregate_function, "avg") self.assertEqual(dashboards[0].widgets[0].chart_type, "area") self.assertEqual(dashboards[0].widgets[0].color, "default") self.assertEqual(dashboards[0].widgets[0].label, "CPU Usage") @@ -64,35 +84,30 @@ 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].group_by) self.assertIsNone(dashboards[0].widgets[0].filters) - # Test the second widget which has filters + # Test the second widget (memory_usage, no 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" - ) + self.assertEqual(dashboards[0].widgets[1].aggregate_function, "avg") + self.assertIsNone(dashboards[0].widgets[1].group_by) + self.assertIsNone(dashboards[0].widgets[1].filters) def test_get_all_dashboards(self): dashboards = self.client.monitor.dashboards() + self.assertEqual(len(dashboards), 11) self.assertEqual(dashboards[0].type, "standard") self.assertEqual( - dashboards[0].created, datetime.datetime(2024, 10, 10, 5, 1, 58) + dashboards[0].created, datetime.datetime(2025, 2, 27, 7, 59, 40) ) self.assertEqual(dashboards[0].id, 1) self.assertEqual(dashboards[0].label, "Resource Usage") self.assertEqual(dashboards[0].service_type, "dbaas") self.assertEqual( - dashboards[0].updated, datetime.datetime(2024, 10, 10, 5, 1, 58) + dashboards[0].updated, datetime.datetime(2025, 10, 7, 1, 16, 40) ) - self.assertEqual(dashboards[0].widgets[0].aggregate_function, "sum") + self.assertEqual(dashboards[0].group_by, ["entity_id"]) + self.assertEqual(dashboards[0].widgets[0].aggregate_function, "avg") self.assertEqual(dashboards[0].widgets[0].chart_type, "area") self.assertEqual(dashboards[0].widgets[0].color, "default") self.assertEqual(dashboards[0].widgets[0].label, "CPU Usage") @@ -100,8 +115,13 @@ 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].group_by) self.assertIsNone(dashboards[0].widgets[0].filters) + # Verify a dashboard with multiple group_by values (id=4, firewall) + self.assertEqual(dashboards[3].id, 4) + self.assertEqual( + dashboards[3].group_by, ["entity_id", "linode_id", "interface_id"] + ) def test_specific_service_details(self): data = self.client.load(MonitorService, "dbaas") @@ -169,3 +189,573 @@ def test_alert_channels(self): "/monitor/alert-channels/123/alerts", ) self.assertEqual(channels[0].alerts.alert_count, 0) + + +class LogsDestinationTest(ClientBaseCase): + """ + Tests methods for LogsDestination class + """ + + def test_list_destinations(self): + """ + Test that listing destinations returns LogsDestination objects with all fields populated. + """ + destinations = self.client.monitor.destinations() + + self.assertEqual(len(destinations), 1) + dest = destinations[0] + self.assertIsInstance(dest, LogsDestination) + self.assertEqual(dest.id, 1) + self.assertEqual(dest.label, "my-logs-destination") + self.assertEqual(dest.type, "akamai_object_storage") + self.assertEqual(dest.status, "active") + self.assertEqual(dest.version, 1) + self.assertEqual(dest.created, datetime.datetime(2024, 6, 1, 12, 0, 0)) + self.assertEqual(dest.updated, datetime.datetime(2024, 6, 1, 12, 0, 0)) + self.assertEqual(dest.created_by, "tester") + self.assertEqual(dest.updated_by, "tester") + + def test_list_destinations_details(self): + """ + Test that the nested LogsDestinationDetails are deserialized correctly. + """ + dest = self.client.load(LogsDestination, 1) + + self.assertIsNotNone(dest.details) + self.assertEqual(dest.details.access_key_id, "1ABCD23EFG4HIJKLMNO5") + self.assertEqual(dest.details.bucket_name, "primary-bucket") + self.assertEqual( + dest.details.host, "primary-bucket.us-east-1.linodeobjects.com" + ) + self.assertEqual(dest.details.path, "audit-logs") + + self.assertIsNone(dest.details.access_key_secret) + + def test_destination_history(self): + """ + Test that the history property returns LogsDestinationHistory objects. + """ + dest = self.client.load(LogsDestination, 1) + history = dest.history + + self.assertEqual(len(history), 1) + snapshot = history[0] + self.assertIsInstance(snapshot, LogsDestinationHistory) + self.assertEqual(snapshot.id, 1) + self.assertEqual(snapshot.label, "my-logs-destination") + self.assertEqual(snapshot.type, "akamai_object_storage") + self.assertEqual(snapshot.status, "active") + self.assertEqual(snapshot.version, 2) + self.assertEqual( + snapshot.updated, datetime.datetime(2024, 6, 2, 9, 0, 0) + ) + self.assertIsNotNone(snapshot.details) + self.assertEqual(snapshot.details.bucket_name, "primary-bucket") + + def test_create_destination_akamai_object_storage(self): + """ + Test that destination_create with type=akamai_object_storage sends the right + payload and returns a LogsDestination object. + """ + create_response = { + "id": 2, + "label": "new-dest", + "type": "akamai_object_storage", + "status": "active", + "details": { + "access_key_id": "KEYID999", + "bucket_name": "new-bucket", + "host": "new-bucket.us-east-1.linodeobjects.com", + "path": "logs/audit", + }, + "created": "2024-07-01T00:00:00", + "updated": "2024-07-01T00:00:00", + "created_by": "tester", + "updated_by": "tester", + "version": 1, + } + + with self.mock_post(create_response) as m: + result = self.client.monitor.destination_create( + label="new-dest", + type="akamai_object_storage", + details=AkamaiObjectStorageLogsDestinationDetails( + access_key_id="KEYID999", + access_key_secret="SUPERSECRET", + bucket_name="new-bucket", + host="new-bucket.us-east-1.linodeobjects.com", + path="logs/audit", + ), + ) + + self.assertEqual(m.call_url, "/monitor/streams/destinations") + self.assertEqual(m.call_data["label"], "new-dest") + self.assertEqual(m.call_data["type"], "akamai_object_storage") + self.assertEqual(m.call_data["details"]["access_key_id"], "KEYID999") + self.assertEqual( + m.call_data["details"]["access_key_secret"], "SUPERSECRET" + ) + self.assertEqual(m.call_data["details"]["bucket_name"], "new-bucket") + self.assertEqual( + m.call_data["details"]["host"], + "new-bucket.us-east-1.linodeobjects.com", + ) + self.assertEqual(m.call_data["details"]["path"], "logs/audit") + + self.assertIsInstance(result, LogsDestination) + self.assertEqual(result.id, 2) + self.assertEqual(result.label, "new-dest") + + def test_update_destination(self): + """ + Test that mutating a LogsDestination's mutable fields and calling save() + sends a PUT to the correct endpoint with the updated values. + """ + dest = self.client.load(LogsDestination, 1) + + updated_response = { + "id": 1, + "label": "renamed-destination", + "type": "akamai_object_storage", + "status": "active", + "details": { + "access_key_id": "1ABCD23EFG4HIJKLMNO5", + "bucket_name": "primary-bucket", + "host": "primary-bucket.us-east-1.linodeobjects.com", + "path": "audit-logs", + }, + "created": "2024-06-01T12:00:00", + "updated": "2024-06-03T08:00:00", + "created_by": "tester", + "updated_by": "tester", + "version": 2, + } + + with self.mock_put(updated_response) as m: + dest.label = "renamed-destination" + dest.save() + + self.assertEqual(m.call_url, "/monitor/streams/destinations/1") + self.assertEqual(m.call_data["label"], "renamed-destination") + + def test_delete_destination(self): + """ + Test that deleting a LogsDestination issues a DELETE to the correct URL. + """ + dest = self.client.load(LogsDestination, 1) + + with self.mock_delete() as m: + dest.delete() + + self.assertEqual(m.call_url, "/monitor/streams/destinations/1") + + +class CustomHTTPSLogsDestinationTest(ClientBaseCase): + """ + Tests for custom_https type LogsDestination and the load_by_type factory. + """ + + def test_load_by_type_factory(self): + """load_by_type dispatches to the correct details class based on type.""" + akamai = LogsDestinationDetailsBase.load_by_type( + "akamai_object_storage", + {"access_key_id": "K", "bucket_name": "b", "host": "h.com"}, + ) + self.assertIsInstance(akamai, AkamaiObjectStorageLogsDestinationDetails) + self.assertEqual(akamai.access_key_id, "K") + + custom = LogsDestinationDetailsBase.load_by_type( + "custom_https", + { + "endpoint_url": "https://x.com", + "authentication": {"type": "none"}, + "data_compression": "gzip", + "content_type": "application/json", + }, + ) + self.assertIsInstance(custom, CustomHTTPSLogsDestinationDetails) + self.assertEqual(custom.endpoint_url, "https://x.com") + + self.assertIsNone( + LogsDestinationDetailsBase.load_by_type("custom_https", None) + ) + self.assertIsNone( + LogsDestinationDetailsBase.load_by_type("custom_https", {}) + ) + self.assertIsNone( + LogsDestinationDetailsBase.load_by_type("unknown", {"x": 1}) + ) + + def test_load_custom_https_destination(self): + """ + Loading a custom_https destination deserializes all nested fields correctly. + """ + dest = self.client.load(LogsDestination, 2) + + self.assertIsInstance(dest.details, CustomHTTPSLogsDestinationDetails) + self.assertEqual( + dest.details.endpoint_url, + "https://my-site.com/log-storage/basicAuth", + ) + self.assertEqual(dest.details.data_compression, "gzip") + self.assertEqual(dest.details.content_type, "application/json") + self.assertEqual(dest.details.authentication.type, "basic") + self.assertEqual( + dest.details.authentication.details.basic_authentication_user, + "John_Q", + ) + self.assertEqual(dest.details.custom_headers[0].name, "Cache-Control") + self.assertEqual( + dest.details.client_certificate_details.tls_hostname, "my-site.com" + ) + + def test_stream_with_custom_https_destination(self): + """ + A LogsStreamDestination with type custom_https is deserialized correctly. + """ + stream = self.client.load(LogsStream, 2) + details = stream.destinations[0].details + + self.assertIsInstance(details, CustomHTTPSLogsDestinationDetails) + self.assertEqual( + details.endpoint_url, "https://my-site.com/log-storage/basicAuth" + ) + self.assertEqual(details.authentication.type, "basic") + self.assertEqual(details.custom_headers[0].name, "Cache-Control") + + def test_create_custom_https_destination(self): + """ + destination_create with type=custom_https sends the correct payload. + """ + create_response = { + "id": 3, + "label": "new-custom-dest", + "type": "custom_https", + "status": "active", + "details": { + "endpoint_url": "https://example.com/logs", + "authentication": {"type": "none"}, + "data_compression": "none", + "content_type": "application/json", + }, + "created": "2024-09-01T00:00:00", + "updated": "2024-09-01T00:00:00", + "created_by": "tester", + "updated_by": "tester", + "version": 1, + } + + with self.mock_post(create_response) as m: + result = self.client.monitor.destination_create( + label="new-custom-dest", + type="custom_https", + details=CustomHTTPSLogsDestinationDetails( + endpoint_url="https://example.com/logs", + authentication=DestinationAuthentication(type="none"), + data_compression="none", + content_type="application/json", + ), + ) + + self.assertEqual(m.call_url, "/monitor/streams/destinations") + self.assertEqual(m.call_data["type"], "custom_https") + self.assertEqual( + m.call_data["details"]["endpoint_url"], "https://example.com/logs" + ) + self.assertIsInstance(result, LogsDestination) + self.assertEqual(result.id, 3) + self.assertIsInstance(result.details, CustomHTTPSLogsDestinationDetails) + + +class LogsStreamTest(ClientBaseCase): + """ + Tests methods for LogsStream class. + """ + + def test_list_streams(self): + """ + Test that listing streams returns LogsStream objects with all fields populated. + """ + streams = self.client.monitor.streams() + + self.assertEqual(len(streams), 1) + stream = streams[0] + self.assertIsInstance(stream, LogsStream) + self.assertEqual(stream.id, 1) + self.assertEqual(stream.label, "my-logs-stream") + self.assertEqual(stream.type, "audit_logs") + self.assertEqual(stream.status, "active") + self.assertEqual(stream.version, 1) + self.assertEqual( + stream.created, datetime.datetime(2024, 6, 1, 12, 0, 0) + ) + self.assertEqual( + stream.updated, datetime.datetime(2024, 6, 1, 12, 0, 0) + ) + self.assertEqual(stream.created_by, "tester") + self.assertEqual(stream.updated_by, "tester") + + def test_list_streams_destinations(self): + """ + Test that the nested destinations are deserialized as LogsStreamDestination objects. + """ + stream = self.client.load(LogsStream, 1) + + self.assertIsNotNone(stream.destinations) + self.assertEqual(len(stream.destinations), 1) + dest = stream.destinations[0] + self.assertIsInstance(dest, LogsStreamDestination) + self.assertEqual(dest.id, 1) + self.assertEqual(dest.label, "my-logs-destination") + self.assertEqual(dest.type, "akamai_object_storage") + self.assertIsNotNone(dest.details) + self.assertEqual(dest.details.bucket_name, "primary-bucket") + self.assertEqual(dest.details.access_key_id, "1ABCD23EFG4HIJKLMNO5") + self.assertEqual( + dest.details.host, "primary-bucket.us-east-1.linodeobjects.com" + ) + self.assertEqual(dest.details.path, "audit-logs") + + def test_stream_history(self): + """ + Test that the history property returns LogsStreamHistory objects. + """ + stream = self.client.load(LogsStream, 1) + history = stream.history + + self.assertEqual(len(history), 1) + snapshot = history[0] + self.assertEqual(snapshot.id, 1) + self.assertEqual(snapshot.label, "my-logs-stream") + self.assertEqual(snapshot.type, "audit_logs") + self.assertEqual(snapshot.status, "active") + self.assertEqual(snapshot.version, 2) + self.assertEqual( + snapshot.updated, datetime.datetime(2024, 6, 2, 9, 0, 0) + ) + self.assertIsNotNone(snapshot.destinations) + + def test_create_stream(self): + """ + Test that stream_create sends the correct payload and returns a LogsStream object. + """ + create_response = { + "id": 2, + "label": "new-stream", + "type": "audit_logs", + "status": "active", + "destinations": [ + { + "id": 1, + "label": "my-logs-destination", + "type": "akamai_object_storage", + "details": {}, + } + ], + "created": "2024-07-01T00:00:00", + "updated": "2024-07-01T00:00:00", + "created_by": "tester", + "updated_by": "tester", + "version": 1, + } + + with self.mock_post(create_response) as m: + result = self.client.monitor.stream_create( + destinations=[1], + label="new-stream", + status="active", + type="audit_logs", + ) + + self.assertEqual(m.call_url, "/monitor/streams") + self.assertEqual(m.call_data["label"], "new-stream") + self.assertEqual(m.call_data["type"], "audit_logs") + self.assertEqual(m.call_data["status"], "active") + self.assertEqual(m.call_data["destinations"], [1]) + + self.assertIsInstance(result, LogsStream) + self.assertEqual(result.id, 2) + self.assertEqual(result.label, "new-stream") + + def test_update_stream_save(self): + """ + Test that mutating a LogsStream's mutable fields and calling save() + sends a PUT with correct payload. + """ + stream = self.client.load(LogsStream, 1) + + updated_response = { + "id": 1, + "label": "renamed-stream", + "type": "audit_logs", + "status": "inactive", + "destinations": [ + { + "id": 1, + "label": "my-logs-destination", + "type": "akamai_object_storage", + "details": {}, + } + ], + "created": "2024-06-01T12:00:00", + "updated": "2024-06-03T08:00:00", + "created_by": "tester", + "updated_by": "tester", + "version": 2, + } + + with self.mock_put(updated_response) as m: + stream.label = "renamed-stream" + stream.status = "inactive" + stream.save() + + self.assertEqual(m.call_url, "/monitor/streams/1") + self.assertEqual(m.call_data["label"], "renamed-stream") + self.assertEqual(m.call_data["status"], "inactive") + + def test_update_stream_destinations(self): + """ + Test that update_destinations sends PUT request with flat destination ids list. + """ + stream = self.client.load(LogsStream, 1) + + with self.mock_put({}) as m: + result = stream.update_destinations([1]) + + self.assertEqual(m.call_url, "/monitor/streams/1") + self.assertEqual(m.call_data["destinations"], [1]) + self.assertTrue(result) + + def test_fail_update_stream_destinations_when_no_destination_ids_passed( + self, + ): + """ + Test that update_destinations raises exception and doesn't send PUT request when id list is empty. + """ + stream = self.client.load(LogsStream, 1) + with self.mock_put({}) as m: + with self.assertRaises(ValueError) as context: + stream.update_destinations([]) + + self.assertFalse(m.called) + self.assertIn( + "A destination id must be provided.", str(context.exception) + ) + + def test_delete_stream(self): + """ + Test that deleting a LogsStream issues a DELETE to the correct URL. + """ + stream = self.client.load(LogsStream, 1) + + with self.mock_delete() as m: + stream.delete() + + self.assertEqual(m.call_url, "/monitor/streams/1") + + +class LkeAuditLogsStreamTest(ClientBaseCase): + """ + Tests for lke_audit_logs stream type and LogsStreamDetails model. + """ + + def test_logs_stream_type_enum(self): + """LogsStreamType exposes both audit_logs and lke_audit_logs values.""" + self.assertEqual(LogsStreamType.audit_logs, "audit_logs") + self.assertEqual(LogsStreamType.lke_audit_logs, "lke_audit_logs") + + def test_load_lke_audit_logs_stream(self): + """ + Loading an lke_audit_logs stream deserializes type and details correctly. + """ + stream = self.client.load(LogsStream, 3) + + self.assertEqual(stream.id, 3) + self.assertEqual(stream.type, "lke_audit_logs") + self.assertIsInstance(stream.details, LogsStreamDetails) + self.assertEqual(stream.details.cluster_ids, [1234, 5678]) + self.assertFalse(stream.details.is_auto_add_all_clusters_enabled) + + def test_audit_logs_stream_details_is_none(self): + """An audit_logs stream has no details block.""" + stream = self.client.load(LogsStream, 1) + self.assertIsNone(stream.details) + + def test_create_lke_audit_logs_stream(self): + """ + stream_create with lke_audit_logs sends details in the payload. + """ + create_response = { + "id": 4, + "label": "new-lke-stream", + "type": "lke_audit_logs", + "status": "active", + "destinations": [ + { + "id": 1, + "label": "d", + "type": "akamai_object_storage", + "details": {}, + } + ], + "details": { + "cluster_ids": [1111, 2222], + "is_auto_add_all_clusters_enabled": False, + }, + "created": "2024-10-01T12:00:00", + "updated": "2024-10-01T12:00:00", + "created_by": "tester", + "updated_by": "tester", + "version": 1, + } + + with self.mock_post(create_response) as m: + result = self.client.monitor.stream_create( + destinations=[1], + label="new-lke-stream", + type=LogsStreamType.lke_audit_logs, + details=LogsStreamDetails( + cluster_ids=[1111, 2222], + is_auto_add_all_clusters_enabled=False, + ), + ) + + self.assertEqual(m.call_data["type"], "lke_audit_logs") + self.assertEqual(m.call_data["details"]["cluster_ids"], [1111, 2222]) + self.assertFalse( + m.call_data["details"]["is_auto_add_all_clusters_enabled"] + ) + self.assertIsInstance(result.details, LogsStreamDetails) + + def test_create_audit_logs_stream_omits_details(self): + """ + stream_create without details does not include a details key in the payload. + """ + create_response = { + "id": 5, + "label": "new-audit-stream", + "type": "audit_logs", + "status": "active", + "destinations": [ + { + "id": 1, + "label": "d", + "type": "akamai_object_storage", + "details": {}, + } + ], + "created": "2024-10-01T12:00:00", + "updated": "2024-10-01T12:00:00", + "created_by": "tester", + "updated_by": "tester", + "version": 1, + } + + with self.mock_post(create_response) as m: + self.client.monitor.stream_create( + destinations=[1], + label="new-audit-stream", + type=LogsStreamType.audit_logs, + ) + + self.assertNotIn("details", m.call_data) diff --git a/test/unit/objects/networking_test.py b/test/unit/objects/networking_test.py index cd2e1b15e..245767214 100644 --- a/test/unit/objects/networking_test.py +++ b/test/unit/objects/networking_test.py @@ -1,7 +1,11 @@ -from test.unit.base import ClientBaseCase +from test.unit.base import ClientBaseCase, MethodMock from linode_api4 import VLAN, ExplicitNullValue, Instance, Region from linode_api4.objects import Firewall, IPAddress, IPv6Range +from linode_api4.objects.networking import ( + ReservedIPAddress, + ReservedIPAssignedEntity, +) class NetworkingTest(ClientBaseCase): @@ -171,3 +175,283 @@ def test_delete_vlan(self): self.assertEqual( m.call_url, "/networking/vlans/us-southeast/vlan-test" ) + + def test_ip_address_reserved_and_tags(self): + """ + Tests that IPAddress exposes the reserved and tags fields. + """ + with self.mock_get( + { + "address": "127.0.0.1", + "gateway": "127.0.0.1", + "linode_id": 123, + "interface_id": 456, + "prefix": 24, + "public": True, + "rdns": "test.example.org", + "region": "us-east", + "subnet_mask": "255.255.255.0", + "type": "ipv4", + "vpc_nat_1_1": None, + "reserved": True, + "tags": ["lb"], + } + ): + ip = IPAddress(self.client, "127.0.0.1") + assert ip.reserved is True + assert ip.tags == ["lb"] + + def test_reserved_ip_address_save_tags(self): + """ + Tests that saving a ReservedIPAddress sends tags in the PUT body. + """ + reserved_ip = ReservedIPAddress( + self.client, + "66.175.209.100", + { + "address": "66.175.209.100", + "gateway": "66.175.209.1", + "linode_id": None, + "prefix": 24, + "public": True, + "rdns": "66-175-209-100.ip.linodeusercontent.com", + "region": "us-east", + "reserved": True, + "subnet_mask": "255.255.255.0", + "tags": ["lb"], + "type": "ipv4", + }, + ) + + with MethodMock( + "put", + { + "address": "66.175.209.100", + "gateway": "66.175.209.1", + "linode_id": None, + "prefix": 24, + "public": True, + "rdns": "66-175-209-100.ip.linodeusercontent.com", + "region": "us-east", + "reserved": True, + "subnet_mask": "255.255.255.0", + "tags": ["lb", "team:infra"], + "type": "ipv4", + "assigned_entity": None, + }, + ) as m: + reserved_ip.tags = ["lb", "team:infra"] + reserved_ip.save() + + assert m.call_url == "/networking/reserved/ips/66.175.209.100" + body = m.call_data + assert body["tags"] == ["lb", "team:infra"] + assert reserved_ip.assigned_entity is None + + def test_reserved_ip_address_delete(self): + """ + Tests that deleting a ReservedIPAddress calls the correct endpoint. + """ + with self.mock_delete() as m: + reserved_ip = ReservedIPAddress(self.client, "66.175.209.100") + reserved_ip.delete() + + self.assertEqual( + m.call_url, "/networking/reserved/ips/66.175.209.100" + ) + + def test_ip_address_assigned_entity(self): + """ + Tests that IPAddress deserializes the assigned_entity field. + """ + with self.mock_get( + { + "address": "66.175.209.100", + "gateway": "66.175.209.1", + "linode_id": 123, + "interface_id": None, + "prefix": 24, + "public": True, + "rdns": "", + "region": "us-east", + "subnet_mask": "255.255.255.0", + "type": "ipv4", + "vpc_nat_1_1": None, + "reserved": True, + "tags": ["lb"], + "assigned_entity": { + "id": 123, + "label": "my-linode", + "type": "linode", + "url": "/v4/linode/instances/123", + }, + } + ): + ip = IPAddress(self.client, "66.175.209.100") + assert ip.assigned_entity is not None + assert isinstance(ip.assigned_entity, ReservedIPAssignedEntity) + assert ip.assigned_entity.id == 123 + assert ip.assigned_entity.label == "my-linode" + assert ip.assigned_entity.type == "linode" + assert ip.assigned_entity.url == "/v4/linode/instances/123" + + def test_ip_address_assigned_entity_null(self): + """ + Tests that IPAddress handles a null assigned_entity field. + """ + with self.mock_get( + { + "address": "66.175.209.101", + "gateway": "66.175.209.1", + "linode_id": None, + "interface_id": None, + "prefix": 24, + "public": True, + "rdns": "", + "region": "us-east", + "subnet_mask": "255.255.255.0", + "type": "ipv4", + "vpc_nat_1_1": None, + "reserved": True, + "tags": [], + "assigned_entity": None, + } + ): + ip = IPAddress(self.client, "66.175.209.101") + assert ip.assigned_entity is None + + def test_ip_address_reserved_mutable(self): + """ + Tests that IPAddress.reserved can be set and saved (convert ephemeral <-> reserved). + """ + with self.mock_get( + { + "address": "66.175.209.100", + "gateway": "66.175.209.1", + "linode_id": 123, + "interface_id": None, + "prefix": 24, + "public": True, + "rdns": "", + "region": "us-east", + "subnet_mask": "255.255.255.0", + "type": "ipv4", + "vpc_nat_1_1": None, + "reserved": False, + "tags": [], + "assigned_entity": None, + } + ): + ip = IPAddress(self.client, "66.175.209.100") + assert ip.reserved is False + + with MethodMock( + "put", + { + "address": "66.175.209.100", + "gateway": "66.175.209.1", + "linode_id": 123, + "prefix": 24, + "public": True, + "rdns": "", + "region": "us-east", + "subnet_mask": "255.255.255.0", + "type": "ipv4", + "reserved": True, + "tags": [], + }, + ) as m: + ip.reserved = True + ip.save() + + assert m.call_url == "/networking/ips/66.175.209.100" + assert m.call_data["reserved"] is True + + def test_reserved_ip_address_assigned_entity(self): + """ + Tests that ReservedIPAddress deserializes the assigned_entity field. + """ + reserved_ip = ReservedIPAddress( + self.client, + "66.175.209.100", + { + "address": "66.175.209.100", + "gateway": "66.175.209.1", + "linode_id": 5678, + "prefix": 24, + "public": True, + "rdns": "", + "region": "us-east", + "reserved": True, + "subnet_mask": "255.255.255.0", + "tags": ["lb"], + "type": "ipv4", + "assigned_entity": { + "id": 5678, + "label": "my-nodebalancer", + "type": "nodebalancer", + "url": "/v4/nodebalancers/5678", + }, + }, + ) + assert reserved_ip.assigned_entity is not None + assert isinstance(reserved_ip.assigned_entity, ReservedIPAssignedEntity) + assert reserved_ip.assigned_entity.id == 5678 + assert reserved_ip.assigned_entity.label == "my-nodebalancer" + assert reserved_ip.assigned_entity.type == "nodebalancer" + assert reserved_ip.assigned_entity.url == "/v4/nodebalancers/5678" + + def test_instance_ip_allocate_with_address(self): + """ + Tests that Instance.ip_allocate sends the address field when provided. + """ + with MethodMock( + "post", + { + "address": "66.175.209.100", + "gateway": "66.175.209.1", + "linode_id": 123, + "prefix": 24, + "public": True, + "rdns": "", + "region": "us-east", + "subnet_mask": "255.255.255.0", + "type": "ipv4", + "reserved": True, + "tags": [], + }, + ) as m: + instance = Instance(self.client, 123) + ip = instance.ip_allocate(public=True, address="66.175.209.100") + + assert m.call_url == "/linode/instances/123/ips" + assert m.call_data["address"] == "66.175.209.100" + assert m.call_data["type"] == "ipv4" + assert m.call_data["public"] is True + assert ip.address == "66.175.209.100" + + def test_instance_ip_allocate_without_address(self): + """ + Tests that Instance.ip_allocate omits address when not provided. + """ + with MethodMock( + "post", + { + "address": "198.51.100.5", + "gateway": "198.51.100.1", + "linode_id": 123, + "prefix": 24, + "public": True, + "rdns": "", + "region": "us-east", + "subnet_mask": "255.255.255.0", + "type": "ipv4", + "reserved": False, + "tags": [], + }, + ) as m: + instance = Instance(self.client, 123) + instance.ip_allocate(public=True) + + assert m.call_url == "/linode/instances/123/ips" + assert "address" not in m.call_data diff --git a/test/unit/objects/tag_test.py b/test/unit/objects/tag_test.py index 137d11deb..53fc53b63 100644 --- a/test/unit/objects/tag_test.py +++ b/test/unit/objects/tag_test.py @@ -1,6 +1,7 @@ -from test.unit.base import ClientBaseCase +from test.unit.base import ClientBaseCase, MethodMock from linode_api4.objects import Tag +from linode_api4.objects.networking import ReservedIPAddress class TagTest(ClientBaseCase): @@ -44,3 +45,59 @@ def test_delete_tag(self): self.assertEqual(result, True) self.assertEqual(m.call_url, "/tags/nothing") + + def test_tagged_reserved_ipv4_address(self): + """ + Tests that a tagged reserved_ipv4_address object is correctly resolved + to a ReservedIPAddress instance. + """ + with self.mock_get( + { + "page": 1, + "pages": 1, + "results": 1, + "data": [ + { + "type": "reserved_ipv4_address", + "data": { + "address": "66.175.209.100", + "gateway": "66.175.209.1", + "linode_id": None, + "prefix": 24, + "public": True, + "rdns": "66-175-209-100.ip.linodeusercontent.com", + "region": "us-east", + "reserved": True, + "subnet_mask": "255.255.255.0", + "tags": ["lb"], + "type": "ipv4", + }, + } + ], + } + ): + tag = self.client.load(Tag, "lb") + objects = tag.objects + + self.assertEqual(len(objects), 1) + self.assertIsInstance(objects[0], ReservedIPAddress) + self.assertEqual(objects[0].address, "66.175.209.100") + self.assertEqual(objects[0].region.id, "us-east") + self.assertTrue(objects[0].reserved) + self.assertEqual(objects[0].tags, ["lb"]) + + def test_create_tag_with_reserved_ipv4_addresses(self): + """ + Tests that creating a tag with reserved_ipv4_addresses sends them in + the request body. + """ + with MethodMock("post", {"label": "lb"}) as m: + self.client.tags.create( + "lb", reserved_ipv4_addresses=["66.175.209.100"] + ) + + body = m.call_data + self.assertEqual(body["label"], "lb") + self.assertEqual( + body["reserved_ipv4_addresses"], ["66.175.209.100"] + )