From 5739a51e804a1ccbccf4e8ade293181207854f4e Mon Sep 17 00:00:00 2001 From: Lena Garber Date: Mon, 5 May 2025 16:24:05 -0400 Subject: [PATCH 01/11] Add support for Linode-related endpoints and fields --- linode_api4/groups/linode.py | 32 +- linode_api4/groups/networking.py | 58 +++ linode_api4/objects/__init__.py | 1 + linode_api4/objects/base.py | 1 + linode_api4/objects/linode.py | 199 ++++++++- linode_api4/objects/linode_interfaces.py | 414 ++++++++++++++++++ linode_api4/objects/networking.py | 28 ++ test/fixtures/linode_instances.json | 49 ++- test/fixtures/linode_instances_124.json | 43 ++ .../linode_instances_124_interfaces.json | 103 +++++ .../linode_instances_124_interfaces_123.json | 53 +++ ...nstances_124_interfaces_123_firewalls.json | 56 +++ .../linode_instances_124_interfaces_456.json | 28 ++ .../linode_instances_124_interfaces_789.json | 14 + ...ode_instances_124_interfaces_settings.json | 16 + ...node_instances_124_upgrade-interfaces.json | 105 +++++ test/integration/conftest.py | 81 +++- .../linode/interfaces/test_interfaces.py | 343 +++++++++++++++ test/integration/models/linode/test_linode.py | 199 ++++++++- test/unit/groups/linode_test.py | 41 +- test/unit/objects/linode_interface_test.py | 329 ++++++++++++++ test/unit/objects/linode_test.py | 167 +++++++ 22 files changed, 2320 insertions(+), 40 deletions(-) create mode 100644 linode_api4/objects/linode_interfaces.py create mode 100644 test/fixtures/linode_instances_124.json create mode 100644 test/fixtures/linode_instances_124_interfaces.json create mode 100644 test/fixtures/linode_instances_124_interfaces_123.json create mode 100644 test/fixtures/linode_instances_124_interfaces_123_firewalls.json create mode 100644 test/fixtures/linode_instances_124_interfaces_456.json create mode 100644 test/fixtures/linode_instances_124_interfaces_789.json create mode 100644 test/fixtures/linode_instances_124_interfaces_settings.json create mode 100644 test/fixtures/linode_instances_124_upgrade-interfaces.json create mode 100644 test/integration/models/linode/interfaces/test_interfaces.py create mode 100644 test/unit/objects/linode_interface_test.py diff --git a/linode_api4/groups/linode.py b/linode_api4/groups/linode.py index 48f0d43b6..f1b326bc2 100644 --- a/linode_api4/groups/linode.py +++ b/linode_api4/groups/linode.py @@ -1,13 +1,11 @@ import base64 import os -from collections.abc import Iterable -from typing import Any, Dict, Optional, Union +from typing import Any, Dict, List, Optional, Union from linode_api4.common import load_and_validate_keys from linode_api4.errors import UnexpectedResponseError from linode_api4.groups import Group from linode_api4.objects import ( - ConfigInterface, Firewall, Instance, InstanceDiskEncryptionType, @@ -21,8 +19,13 @@ from linode_api4.objects.linode import ( Backup, InstancePlacementGroupAssignment, + InterfaceGeneration, + NetworkInterface, _expand_placement_group_assignment, ) +from linode_api4.objects.linode_interfaces import ( + LinodeInterfaceOptions, +) from linode_api4.util import drop_null_keys @@ -153,6 +156,13 @@ def instance_create( int, ] ] = None, + interfaces: Optional[ + List[ + Union[LinodeInterfaceOptions, NetworkInterface, Dict[str, Any]], + ] + ] = None, + interface_generation: Optional[Union[InterfaceGeneration, str]] = None, + network_helper: Optional[bool] = None, **kwargs, ): """ @@ -293,9 +303,13 @@ def instance_create( :type disk_encryption: InstanceDiskEncryptionType or str :param interfaces: An array of Network Interfaces to add to this Linode’s Configuration Profile. At least one and up to three Interface objects can exist in this array. - :type interfaces: list[ConfigInterface] or list[dict[str, Any]] + :type interfaces: List[LinodeInterfaceOptions], List[NetworkInterface], or List[dict[str, Any]] :param placement_group: A Placement Group to create this Linode under. :type placement_group: Union[InstancePlacementGroupAssignment, PlacementGroup, Dict[str, Any], int] + :param interface_generation: The generation of network interfaces this Linode uses. + :type interface_generation: InterfaceGeneration or str + :param network_helper: Whether this instance should have Network Helper enabled. + :type network_helper: bool :returns: A new Instance object, or a tuple containing the new Instance and the generated password. @@ -311,13 +325,6 @@ def instance_create( ret_pass = Instance.generate_root_password() kwargs["root_pass"] = ret_pass - interfaces = kwargs.get("interfaces", None) - if interfaces is not None and isinstance(interfaces, Iterable): - kwargs["interfaces"] = [ - i._serialize() if isinstance(i, ConfigInterface) else i - for i in interfaces - ] - params = { "type": ltype, "region": region, @@ -336,6 +343,9 @@ def instance_create( if placement_group else None ), + "interfaces": interfaces, + "interface_generation": interface_generation, + "network_helper": network_helper, } params.update(kwargs) diff --git a/linode_api4/groups/networking.py b/linode_api4/groups/networking.py index b9cad485d..69b4cf673 100644 --- a/linode_api4/groups/networking.py +++ b/linode_api4/groups/networking.py @@ -180,6 +180,64 @@ def ipv6_ranges(self, *filters): """ return self.client._get_and_filter(IPv6Range, *filters) + def ipv6_range_allocate( + self, + prefix_length: int, + route_target: Optional[str] = None, + linode: Optional[Union[Instance, int]] = None, + **kwargs, + ) -> IPv6Range: + """ + Creates an IPv6 Range and assigns it based on the provided Linode or route target IPv6 SLAAC address. + + API Documentation: https://techdocs.akamai.com/linode-api/reference/post-ipv6-range + + Create an IPv6 range assigned to a Linode by ID:: + + range = client.networking.ipv6_range_allocate(64, linode_id=123) + + + Create an IPv6 range assigned to a Linode by SLAAC:: + + range = client.networking.ipv6_range_allocate( + 64, + route_target=instance.ipv6.split("/")[0] + ) + + :param prefix_length: The prefix length of the IPv6 range. + :type prefix_length: int + :param route_target: The IPv6 SLAAC address to assign this range to. Required if linode is not specified. + :type route_target: str + :param linode: The ID of the Linode to assign this range to. + The SLAAC address for the provided Linode is used as the range's route_target. + Required if linode is not specified. + :type linode: Instance or int + + :returns: The new IPAddress. + :rtype: IPAddress + """ + + params = { + "prefix_length": prefix_length, + "route_target": route_target, + "linode_id": linode, + } + + params.update(**kwargs) + + result = self.client.post( + "/networking/ipv6/ranges", + data=drop_null_keys(_flatten_request_body_recursive(params)), + ) + + if not "range" in result: + raise UnexpectedResponseError( + "Unexpected response when allocating IPv6 range!", json=result + ) + + result = IPv6Range(self.client, result["range"], result) + return result + def ipv6_pools(self, *filters): """ Returns a list of IPv6 pools on this account. diff --git a/linode_api4/objects/__init__.py b/linode_api4/objects/__init__.py index b13fac51a..6667cba8d 100644 --- a/linode_api4/objects/__init__.py +++ b/linode_api4/objects/__init__.py @@ -6,6 +6,7 @@ from .region import Region from .image import Image from .linode import * +from .linode_interfaces import * from .volume import * from .domain import * from .account import * diff --git a/linode_api4/objects/base.py b/linode_api4/objects/base.py index c9a622edc..51a16eae0 100644 --- a/linode_api4/objects/base.py +++ b/linode_api4/objects/base.py @@ -239,6 +239,7 @@ def __setattr__(self, name, value): """ Enforces allowing editing of only Properties defined as mutable """ + if name in type(self).properties.keys(): if not type(self).properties[name].mutable: raise AttributeError( diff --git a/linode_api4/objects/linode.py b/linode_api4/objects/linode.py index c70dd7965..6609af2aa 100644 --- a/linode_api4/objects/linode.py +++ b/linode_api4/objects/linode.py @@ -1,6 +1,7 @@ +import copy import string import sys -from dataclasses import dataclass +from dataclasses import dataclass, field from datetime import datetime from enum import Enum from os import urandom @@ -19,6 +20,14 @@ from linode_api4.objects.dbase import DerivedBase from linode_api4.objects.filtering import FilterableAttribute from linode_api4.objects.image import Image +from linode_api4.objects.linode_interfaces import ( + LinodeInterface, + LinodeInterfaceDefaultRouteOptions, + LinodeInterfacePublicOptions, + LinodeInterfacesSettings, + LinodeInterfaceVLANOptions, + LinodeInterfaceVPCOptions, +) from linode_api4.objects.networking import ( Firewall, IPAddress, @@ -460,6 +469,7 @@ class Config(DerivedBase): "virt_mode": Property(mutable=True), "memory_limit": Property(mutable=True), "interfaces": Property(mutable=True, json_object=ConfigInterface), + "interface_generation": Property(), } @property @@ -653,6 +663,33 @@ class MigrationType: WARM = "warm" +class InterfaceGeneration(StrEnum): + """ + A string enum representing which interface generation a Linode is using. + """ + + LEGACY_CONFIG = "legacy_config" + LINODE = "linode" + + +@dataclass +class UpgradeInterfacesResult(JSONObject): + """ + Contains information about an Linode Interface upgrade operation. + + NOTE: If dry_run is True, each returned interface will be of type Dict[str, Any]. + Otherwise, each returned interface will be of type LinodeInterface. + + API Documentation: Not yet available. + """ + + dry_run: bool = False + config_id: int = 0 + interfaces: List[Union[Dict[str, Any], LinodeInterface]] = field( + default_factory=list + ) + + class Instance(Base): """ A Linode Instance. @@ -696,8 +733,8 @@ def ips(self): API Documentation: https://techdocs.akamai.com/linode-api/reference/get-linode-ips - :returns: A List of the ips of the Linode Instance. - :rtype: List[IPAddress] + :returns: Information about the IP addresses assigned to this instance. + :rtype: MappedObject """ if not hasattr(self, "_ips"): result = self._client.get( @@ -962,6 +999,9 @@ def invalidate(self): if hasattr(self, "_placement_group"): del self._placement_group + if hasattr(self, "_interfaces"): + del self._interfaces + Base.invalidate(self) def boot(self, config=None): @@ -1846,6 +1886,159 @@ def stats_for(self, dt): model=self, ) + def interface_create( + self, + firewall: Optional[Union[Firewall, int]] = None, + default_route: Optional[ + Union[Dict[str, Any], LinodeInterfaceDefaultRouteOptions] + ] = None, + public: Optional[ + Union[Dict[str, Any], LinodeInterfacePublicOptions] + ] = None, + vlan: Optional[ + Union[Dict[str, Any], LinodeInterfaceVLANOptions] + ] = None, + vpc: Optional[Union[Dict[str, Any], LinodeInterfaceVPCOptions]] = None, + **kwargs, + ) -> LinodeInterface: + """ + Creates a new interface under this Linode. + + :param firewall: The firewall this interface should be assigned to. + :param default_route: The desired default route configuration of the new interface. + :param public: The public-specific configuration of the new interface. + If set, the new instance will be a public interface. + :param vlan: The VLAN-specific configuration of the new interface. + If set, the new instance will be a VLAN interface. + :param vpc: The VPC-specific configuration of the new interface. + If set, the new instance will be a VPC interface. + + :returns: The newly created Linode Interface. + """ + + params = { + "firewall_id": firewall, + "default_route": default_route, + "public": public, + "vlan": vlan, + "vpc": vpc, + } + + params.update(kwargs) + + result = self._client.post( + "{}/interfaces".format(Instance.api_endpoint), + model=self, + data=drop_null_keys(_flatten_request_body_recursive(params)), + ) + + if not "id" in result: + raise UnexpectedResponseError( + "Unexpected response creating config!", json=result + ) + + return LinodeInterface(self._client, result["id"], self.id, json=result) + + @property + def interfaces_settings(self) -> LinodeInterfacesSettings: + """ + The settings for all interfaces under this Linode. + + :returns: The settings for instance-level interface settings for this Linode. + """ + + # NOTE: We do not implement this as a Property because Property does + # not currently have a mechanism for 1:1 sub-entities. + + if not hasattr(self, "_interfaces_settings"): + self._set( + "_interfaces_settings", + # We don't use lazy loading here because it can trigger a known issue + # where setting fields for updates before the entity has been lazy loaded + # causes the user's value to be discarded. + self._client.load(LinodeInterfacesSettings, self.id), + ) + + return self._interfaces_settings + + @property + def interfaces(self) -> List[LinodeInterface]: + """ + All interfaces for this Linode. + + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-linode-interface + + :returns: An ordered list of interfaces under this Linode. + """ + + if not hasattr(self, "_interfaces"): + result = self._client.get( + "{}/interfaces".format(Instance.api_endpoint), + model=self, + ) + if "interfaces" not in result: + raise UnexpectedResponseError( + "Got unexpected response when retrieving Linode interfaces", + json=result, + ) + + self._set( + "_interfaces", + [ + LinodeInterface( + self._client, iface["id"], self.id, json=iface + ) + for iface in result["interfaces"] + ], + ) + + return self._interfaces + + def upgrade_interfaces( + self, + config: Optional[Union[Config, int]] = None, + dry_run: bool = False, + ) -> UpgradeInterfacesResult: + """ + Automatically upgrades all legacy config interfaces of a + single configuration profile to Linode interfaces. + + NOTE: If dry_run is True, interfaces in the result will be + of type MappedObject rather than LinodeInterface. + + API Documentation: Not yet available. + + :returns: Information about the newly upgraded interfaces. + :rtype: UpgradeInterfacesResult + """ + params = {"config_id": config, "dry_run": dry_run} + + result = self._client.post( + "{}/upgrade-interfaces".format(Instance.api_endpoint), + model=self, + data=_flatten_request_body_recursive(drop_null_keys(params)), + ) + + # This resolves an edge case where `result["interfaces"]` persists across + # multiple calls, which can cause parsing errors when expanding them below. + result = copy.deepcopy(result) + + self.invalidate() + + # We don't convert interface dicts to LinodeInterface objects on dry runs + # actual API entities aren't created. + if dry_run: + result["interfaces"] = [ + MappedObject(**iface) for iface in result["interfaces"] + ] + else: + result["interfaces"] = [ + LinodeInterface(self._client, iface["id"], self.id, iface) + for iface in result["interfaces"] + ] + + return UpgradeInterfacesResult.from_json(result) + class UserDefinedFieldType(Enum): text = 1 diff --git a/linode_api4/objects/linode_interfaces.py b/linode_api4/objects/linode_interfaces.py new file mode 100644 index 000000000..9a6361a14 --- /dev/null +++ b/linode_api4/objects/linode_interfaces.py @@ -0,0 +1,414 @@ +from dataclasses import dataclass, field +from typing import List, Optional + +from linode_api4.objects.base import Base, Property +from linode_api4.objects.dbase import DerivedBase +from linode_api4.objects.networking import Firewall +from linode_api4.objects.serializable import JSONObject + + +@dataclass +class LinodeInterfacesSettingsDefaultRouteOptions(JSONObject): + """ + The options used to configure the default route settings for a Linode's network interfaces. + """ + + ipv4_interface_id: Optional[int] = None + ipv6_interface_id: Optional[int] = None + + +@dataclass +class LinodeInterfacesSettingsDefaultRoute(JSONObject): + """ + The default route settings for a Linode's network interfaces. + """ + + put_class = LinodeInterfacesSettingsDefaultRouteOptions + + ipv4_interface_id: Optional[int] = None + ipv4_eligible_interface_ids: List[int] = field(default_factory=list) + ipv6_interface_id: Optional[int] = None + ipv6_eligible_interface_ids: List[int] = field(default_factory=list) + + +class LinodeInterfacesSettings(Base): + """ + The settings related to a Linode's network interfaces. + + API Documentation: Not yet available. + """ + + api_endpoint = "/linode/instances/{id}/interfaces/settings" + + properties = { + "id": Property(identifier=True), + "network_helper": Property(mutable=True), + "default_route": Property( + mutable=True, json_object=LinodeInterfacesSettingsDefaultRoute + ), + } + + +# Interface POST Options +@dataclass +class LinodeInterfaceDefaultRouteOptions(JSONObject): + """ + Options accepted when creating or updating a Linode Interface's default route settings. + """ + + ipv4: Optional[bool] = None + ipv6: Optional[bool] = None + + +@dataclass +class LinodeInterfaceVPCIPv4AddressOptions(JSONObject): + """ + Options accepted for a single address when creating or updating the IPv4 configuration of a VPC Linode Interface. + """ + + address: Optional[str] = None + primary: Optional[bool] = None + nat_1_1_address: Optional[str] = None + + +@dataclass +class LinodeInterfaceVPCIPv4RangeOptions(JSONObject): + """ + Options accepted for a single range when creating or updating the IPv4 configuration of a VPC Linode Interface. + """ + + range: str = "" + + +@dataclass +class LinodeInterfaceVPCIPv4Options(JSONObject): + """ + Options accepted when creating or updating the IPv4 configuration of a VPC Linode Interface. + """ + + addresses: Optional[List[LinodeInterfaceVPCIPv4AddressOptions]] = None + ranges: Optional[List[LinodeInterfaceVPCIPv4RangeOptions]] = None + + +@dataclass +class LinodeInterfaceVPCOptions(JSONObject): + """ + VPC-exclusive options accepted when creating or updating a Linode Interface. + """ + + subnet_id: int = 0 + ipv4: Optional[LinodeInterfaceVPCIPv4Options] = None + + +@dataclass +class LinodeInterfacePublicIPv4AddressOptions(JSONObject): + """ + Options accepted for a single address when creating or updating the IPv4 configuration of a public Linode Interface. + """ + + address: str = "" + primary: Optional[bool] = None + + +@dataclass +class LinodeInterfacePublicIPv4Options(JSONObject): + """ + Options accepted when creating or updating the IPv4 configuration of a public Linode Interface. + """ + + addresses: Optional[List[LinodeInterfacePublicIPv4AddressOptions]] = None + + +@dataclass +class LinodeInterfacePublicIPv6RangeOptions(JSONObject): + """ + Options accepted for a single range when creating or updating the IPv6 configuration of a public Linode Interface. + """ + + range: str = "" + + +@dataclass +class LinodeInterfacePublicIPv6Options(JSONObject): + """ + Options accepted when creating or updating the IPv6 configuration of a public Linode Interface. + """ + + ranges: Optional[List[LinodeInterfacePublicIPv6RangeOptions]] = None + + +@dataclass +class LinodeInterfacePublicOptions(JSONObject): + """ + Public-exclusive options accepted when creating or updating a Linode Interface. + """ + + ipv4: Optional[LinodeInterfacePublicIPv4Options] = None + ipv6: Optional[LinodeInterfacePublicIPv6Options] = None + + +@dataclass +class LinodeInterfaceVLANOptions(JSONObject): + """ + VLAN-exclusive options accepted when creating or updating a Linode Interface. + """ + + vlan_label: str = "" + ipam_address: Optional[str] = None + + +@dataclass +class LinodeInterfaceOptions(JSONObject): + """ + Options accepted when creating or updating a Linode Interface. + """ + + always_include = { + # If a default firewall_id isn't configured, the API requires that + # firewall_id is defined in the LinodeInterface POST body. + "firewall_id" + } + + firewall_id: Optional[int] = None + default_route: Optional[LinodeInterfaceDefaultRouteOptions] = None + vpc: Optional[LinodeInterfaceVPCOptions] = None + public: Optional[LinodeInterfacePublicOptions] = None + vlan: Optional[LinodeInterfaceVLANOptions] = None + + +# Interface GET Response + + +@dataclass +class LinodeInterfaceDefaultRoute(JSONObject): + """ + The default route configuration of a Linode Interface. + """ + + put_class = LinodeInterfaceDefaultRouteOptions + + ipv4: bool = False + ipv6: bool = False + + +@dataclass +class LinodeInterfaceVPCIPv4Address(JSONObject): + """ + A single address under the IPv4 configuration of a VPC Linode Interface. + """ + + put_class = LinodeInterfaceVPCIPv4AddressOptions + + address: str = "" + primary: bool = False + nat_1_1_address: Optional[str] = None + + +@dataclass +class LinodeInterfaceVPCIPv4Range(JSONObject): + """ + A single range under the IPv4 configuration of a VPC Linode Interface. + """ + + put_class = LinodeInterfaceVPCIPv4RangeOptions + + range: str = "" + + +@dataclass +class LinodeInterfaceVPCIPv4(JSONObject): + """ + A single address under the IPv4 configuration of a VPC Linode Interface. + """ + + put_class = LinodeInterfaceVPCIPv4Options + + addresses: List[LinodeInterfaceVPCIPv4Address] = field(default_factory=list) + ranges: List[LinodeInterfaceVPCIPv4Range] = field(default_factory=list) + + +@dataclass +class LinodeInterfaceVPC(JSONObject): + """ + VPC-specific configuration field for a Linode Interface. + """ + + put_class = LinodeInterfaceVPCOptions + + vpc_id: int = 0 + subnet_id: int = 0 + + ipv4: Optional[LinodeInterfaceVPCIPv4] = None + + +@dataclass +class LinodeInterfacePublicIPv4Address(JSONObject): + """ + A single address under the IPv4 configuration of a public Linode Interface. + """ + + put_class = LinodeInterfacePublicIPv4AddressOptions + + address: str = "" + primary: bool = False + + +@dataclass +class LinodeInterfacePublicIPv4Shared(JSONObject): + """ + A single shared address under the IPv4 configuration of a public Linode Interface. + """ + + address: str = "" + linode_id: int = 0 + + +@dataclass +class LinodeInterfacePublicIPv4(JSONObject): + """ + The IPv4 configuration of a public Linode Interface. + """ + + put_class = LinodeInterfacePublicIPv4Options + + addresses: List[LinodeInterfacePublicIPv4Address] = field( + default_factory=list + ) + shared: List[LinodeInterfacePublicIPv4Shared] = field(default_factory=list) + + +@dataclass +class LinodeInterfacePublicIPv6SLAAC(JSONObject): + """ + A single SLAAC entry under the IPv6 configuration of a public Linode Interface. + """ + + address: str = "" + prefix: int = 0 + + +@dataclass +class LinodeInterfacePublicIPv6Shared(JSONObject): + """ + A single shared range under the IPv6 configuration of a public Linode Interface. + """ + + range: str = "" + route_target: Optional[str] = None + + +@dataclass +class LinodeInterfacePublicIPv6Range(JSONObject): + """ + A single range under the IPv6 configuration of a public Linode Interface. + """ + + put_class = LinodeInterfacePublicIPv6RangeOptions + + range: str = "" + route_target: Optional[str] = None + + +@dataclass +class LinodeInterfacePublicIPv6(JSONObject): + """ + The IPv6 configuration of a Linode Interface. + """ + + put_class = LinodeInterfacePublicIPv6Options + + slaac: List[LinodeInterfacePublicIPv6SLAAC] = field(default_factory=list) + shared: List[LinodeInterfacePublicIPv6Shared] = field(default_factory=list) + ranges: List[LinodeInterfacePublicIPv6Range] = field(default_factory=list) + + +@dataclass +class LinodeInterfacePublic(JSONObject): + """ + Public-specific configuration fields for a Linode Interface. + """ + + put_class = LinodeInterfacePublicOptions + + ipv4: Optional[LinodeInterfacePublicIPv4] = None + ipv6: Optional[LinodeInterfacePublicIPv6] = None + + +@dataclass +class LinodeInterfaceVLAN(JSONObject): + """ + VLAN-specific configuration fields for a Linode Interface. + """ + + put_class = LinodeInterfaceVLANOptions + + vlan_label: str = "" + ipam_address: Optional[str] = None + + +class LinodeInterface(DerivedBase): + """ + A Linode's network interface. + + NOTE: When using the ``save()`` method, certain local fields with computed values will + not be refreshed on the local object until after ``invalidate()`` has been called:: + + # Automatically assign an IPv4 address from the associated VPC Subnet + interface.vpc.ipv4.addresses[0].address = "auto" + + # Save the interface + interface.save() + + # Invalidate the interface + interface.invalidate() + + # Access the new address + print(interface.vpc.ipv4.addresses[0].address) + + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-linode-interface + """ + + api_endpoint = "/linode/instances/{linode_id}/interfaces/{id}" + derived_url_path = "interfaces" + parent_id_name = "linode_id" + + properties = { + "linode_id": Property(identifier=True), + "id": Property(identifier=True), + "mac_address": Property(), + "created": Property(is_datetime=True), + "updated": Property(is_datetime=True), + "version": Property(), + "default_route": Property( + mutable=True, + json_object=LinodeInterfaceDefaultRoute, + ), + "public": Property(mutable=True, json_object=LinodeInterfacePublic), + "vlan": Property(mutable=True, json_object=LinodeInterfaceVLAN), + "vpc": Property(mutable=True, json_object=LinodeInterfaceVPC), + } + + def firewalls(self, *filters) -> List[Firewall]: + """ + Retrieves a list of Firewalls for this Linode Interface. + + :param filters: Any number of filters to apply to this query. + See :doc:`Filtering Collections` + for more details on filtering. + + :returns: A List of Firewalls for this Linode Interface. + :rtype: List[Firewall] + + NOTE: Caching is disabled on this method and each call will make + an additional Linode API request. + + API Documentation: Not yet available. + """ + + return self._client._get_and_filter( + Firewall, + *filters, + endpoint="{}/firewalls".format(LinodeInterface.api_endpoint).format( + **vars(self) + ), + ) diff --git a/linode_api4/objects/networking.py b/linode_api4/objects/networking.py index 74a9ab283..756c28ef8 100644 --- a/linode_api4/objects/networking.py +++ b/linode_api4/objects/networking.py @@ -98,9 +98,37 @@ def linode(self): if not hasattr(self, "_linode"): self._set("_linode", Instance(self._client, self.linode_id)) + return self._linode +<<<<<<< Updated upstream # TODO (Enhanced Interfaces): Add `interface` property method +======= + @property + def interface(self) -> Optional["LinodeInterface"]: + """ + Returns the Linode Interface associated with this IP address. + + NOTE: This function will only work with Linode-level interfaces. + + :returns: The Linode Interface associated with this IP address. + :rtype: LinodeInterface + """ + + from .linode_interfaces import LinodeInterface # pylint: disable-all + + if self.interface_id in (None, 0): + self._set("_interface", None) + elif not hasattr(self, "_interface"): + self._set( + "_interface", + LinodeInterface( + self._client, self.linode_id, self.interface_id + ), + ) + + return self._interface +>>>>>>> Stashed changes def to(self, linode): """ diff --git a/test/fixtures/linode_instances.json b/test/fixtures/linode_instances.json index 38a3cf912..cefda000d 100644 --- a/test/fixtures/linode_instances.json +++ b/test/fixtures/linode_instances.json @@ -38,7 +38,9 @@ ], "updated": "2017-01-01T00:00:00", "image": "linode/ubuntu17.04", - "tags": ["something"], + "tags": [ + "something" + ], "host_uuid": "3a3ddd59d9a78bb8de041391075df44de62bfec8", "watchdog_enabled": true, "disk_encryption": "disabled", @@ -91,6 +93,51 @@ "disk_encryption": "enabled", "lke_cluster_id": 18881, "placement_group": null + }, + { + "id": 124, + "status": "running", + "type": "g6-standard-1", + "alerts": { + "network_in": 5, + "network_out": 5, + "cpu": 90, + "transfer_quota": 80, + "io": 5000 + }, + "group": "test", + "hypervisor": "kvm", + "label": "linode124", + "backups": { + "enabled": true, + "schedule": { + "window": "W02", + "day": "Scheduling" + } + }, + "specs": { + "memory": 2048, + "disk": 30720, + "vcpus": 1, + "transfer": 2000 + }, + "ipv6": "1235:abcd::1234:abcd:89ef:67cd/64", + "created": "2017-01-01T00:00:00", + "region": "us-east-1", + "ipv4": [ + "124.45.67.89" + ], + "updated": "2017-01-01T00:00:00", + "image": "linode/ubuntu24.04", + "tags": [ + "something" + ], + "host_uuid": "3b3ddd59d9a78bb8de041391075df44de62bfec8", + "watchdog_enabled": true, + "disk_encryption": "disabled", + "lke_cluster_id": null, + "placement_group": null, + "interface_generation": "linode" } ] } diff --git a/test/fixtures/linode_instances_124.json b/test/fixtures/linode_instances_124.json new file mode 100644 index 000000000..6c059ba41 --- /dev/null +++ b/test/fixtures/linode_instances_124.json @@ -0,0 +1,43 @@ +{ + "id": 124, + "status": "running", + "type": "g6-standard-1", + "alerts": { + "network_in": 5, + "network_out": 5, + "cpu": 90, + "transfer_quota": 80, + "io": 5000 + }, + "group": "test", + "hypervisor": "kvm", + "label": "linode124", + "backups": { + "enabled": true, + "schedule": { + "window": "W02", + "day": "Scheduling" + } + }, + "specs": { + "memory": 2048, + "disk": 30720, + "vcpus": 1, + "transfer": 2000 + }, + "ipv6": "1235:abcd::1234:abcd:89ef:67cd/64", + "created": "2017-01-01T00:00:00", + "region": "us-east-1", + "ipv4": [ + "124.45.67.89" + ], + "updated": "2017-01-01T00:00:00", + "image": "linode/ubuntu24.04", + "tags": ["something"], + "host_uuid": "3b3ddd59d9a78bb8de041391075df44de62bfec8", + "watchdog_enabled": true, + "disk_encryption": "disabled", + "lke_cluster_id": null, + "placement_group": null, + "interface_generation": "linode" +} \ No newline at end of file diff --git a/test/fixtures/linode_instances_124_interfaces.json b/test/fixtures/linode_instances_124_interfaces.json new file mode 100644 index 000000000..a0ffddef6 --- /dev/null +++ b/test/fixtures/linode_instances_124_interfaces.json @@ -0,0 +1,103 @@ +{ + "interfaces": [ + { + "created": "2025-01-01T00:01:01", + "default_route": { + "ipv4": true, + "ipv6": true + }, + "id": 123, + "mac_address": "22:00:AB:CD:EF:01", + "public": { + "ipv4": { + "addresses": [ + { + "address": "172.30.0.50", + "primary": true + } + ], + "shared": [ + { + "address": "172.30.0.51", + "linode_id": 125 + } + ] + }, + "ipv6": { + "ranges": [ + { + "range": "2600:3cO9:e001:59::/64", + "route_target": "2600:3cO9::ff:feab:cdef" + }, + { + "range": "2600:3cO9:e001:5a::/64", + "route_target": "2600:3cO9::ff:feab:cdef" + } + ], + "shared": [ + { + "range": "2600:3cO9:e001:2a::/64", + "route_target": null + } + ], + "slaac": [ + { + "address": "2600:3cO9::ff:feab:cdef", + "prefix": 64 + } + ] + } + }, + "updated": "2025-01-01T00:01:01", + "version": 1, + "vlan": null, + "vpc": null + }, + { + "id": 456, + "mac_address": "22:00:AB:CD:EF:01", + "created": "2024-01-01T00:01:01", + "updated": "2024-01-01T00:01:01", + "default_route": { + "ipv4": true + }, + "version": 1, + "vpc": { + "vpc_id": 123456, + "subnet_id": 789, + "ipv4": { + "addresses": [ + { + "address": "192.168.22.3", + "primary": true + } + ], + "ranges": [ + { + "range": "192.168.22.16/28" + }, + { + "range": "192.168.22.32/28" + } + ] + } + }, + "public": null, + "vlan": null + }, + { + "id": 789, + "mac_address": "22:00:AB:CD:EF:01", + "created": "2024-01-01T00:01:01", + "updated": "2024-01-01T00:01:01", + "default_route": {}, + "version": 1, + "vpc": null, + "public": null, + "vlan": { + "vlan_label": "my_vlan", + "ipam_address": "10.0.0.1/24" + } + } + ] +} \ No newline at end of file diff --git a/test/fixtures/linode_instances_124_interfaces_123.json b/test/fixtures/linode_instances_124_interfaces_123.json new file mode 100644 index 000000000..333823698 --- /dev/null +++ b/test/fixtures/linode_instances_124_interfaces_123.json @@ -0,0 +1,53 @@ +{ + "created": "2025-01-01T00:01:01", + "default_route": { + "ipv4": true, + "ipv6": true + }, + "id": 123, + "mac_address": "22:00:AB:CD:EF:01", + "public": { + "ipv4": { + "addresses": [ + { + "address": "172.30.0.50", + "primary": true + } + ], + "shared": [ + { + "address": "172.30.0.51", + "linode_id": 125 + } + ] + }, + "ipv6": { + "ranges": [ + { + "range": "2600:3cO9:e001:59::/64", + "route_target": "2600:3cO9::ff:feab:cdef" + }, + { + "range": "2600:3cO9:e001:5a::/64", + "route_target": "2600:3cO9::ff:feab:cdef" + } + ], + "shared": [ + { + "range": "2600:3cO9:e001:2a::/64", + "route_target": null + } + ], + "slaac": [ + { + "address": "2600:3cO9::ff:feab:cdef", + "prefix": 64 + } + ] + } + }, + "updated": "2025-01-01T00:01:01", + "version": 1, + "vlan": null, + "vpc": null +} \ No newline at end of file diff --git a/test/fixtures/linode_instances_124_interfaces_123_firewalls.json b/test/fixtures/linode_instances_124_interfaces_123_firewalls.json new file mode 100644 index 000000000..17a4a9199 --- /dev/null +++ b/test/fixtures/linode_instances_124_interfaces_123_firewalls.json @@ -0,0 +1,56 @@ +{ + "data": [ + { + "created": "2018-01-01T00:01:01", + "id": 123, + "label": "firewall123", + "rules": { + "inbound": [ + { + "action": "ACCEPT", + "addresses": { + "ipv4": [ + "192.0.2.0/24" + ], + "ipv6": [ + "2001:DB8::/32" + ] + }, + "description": "An example firewall rule description.", + "label": "firewallrule123", + "ports": "22-24, 80, 443", + "protocol": "TCP" + } + ], + "inbound_policy": "DROP", + "outbound": [ + { + "action": "ACCEPT", + "addresses": { + "ipv4": [ + "192.0.2.0/24" + ], + "ipv6": [ + "2001:DB8::/32" + ] + }, + "description": "An example firewall rule description.", + "label": "firewallrule123", + "ports": "22-24, 80, 443", + "protocol": "TCP" + } + ], + "outbound_policy": "DROP" + }, + "status": "enabled", + "tags": [ + "example tag", + "another example" + ], + "updated": "2018-01-02T00:01:01" + } + ], + "page": 1, + "pages": 1, + "results": 1 +} diff --git a/test/fixtures/linode_instances_124_interfaces_456.json b/test/fixtures/linode_instances_124_interfaces_456.json new file mode 100644 index 000000000..7fc4f56f8 --- /dev/null +++ b/test/fixtures/linode_instances_124_interfaces_456.json @@ -0,0 +1,28 @@ +{ + "id": 456, + "mac_address": "22:00:AB:CD:EF:01", + "created": "2024-01-01T00:01:01", + "updated": "2024-01-01T00:01:01", + "default_route": { + "ipv4":true + }, + "version": 1, + "vpc": { + "vpc_id": 123456, + "subnet_id": 789, + "ipv4" : { + "addresses": [ + { + "address": "192.168.22.3", + "primary": true + } + ], + "ranges": [ + { "range": "192.168.22.16/28"}, + { "range": "192.168.22.32/28"} + ] + } + }, + "public": null, + "vlan": null +} diff --git a/test/fixtures/linode_instances_124_interfaces_789.json b/test/fixtures/linode_instances_124_interfaces_789.json new file mode 100644 index 000000000..d533b8e21 --- /dev/null +++ b/test/fixtures/linode_instances_124_interfaces_789.json @@ -0,0 +1,14 @@ +{ + "id": 789, + "mac_address": "22:00:AB:CD:EF:01", + "created": "2024-01-01T00:01:01", + "updated": "2024-01-01T00:01:01", + "default_route": {}, + "version": 1, + "vpc": null, + "public": null, + "vlan": { + "vlan_label": "my_vlan", + "ipam_address": "10.0.0.1/24" + } +} diff --git a/test/fixtures/linode_instances_124_interfaces_settings.json b/test/fixtures/linode_instances_124_interfaces_settings.json new file mode 100644 index 000000000..b454c438e --- /dev/null +++ b/test/fixtures/linode_instances_124_interfaces_settings.json @@ -0,0 +1,16 @@ +{ + "network_helper": true, + "default_route": { + "ipv4_interface_id": 123, + "ipv4_eligible_interface_ids": [ + 123, + 456, + 789 + ], + "ipv6_interface_id": 456, + "ipv6_eligible_interface_ids": [ + 123, + 456 + ] + } +} \ No newline at end of file diff --git a/test/fixtures/linode_instances_124_upgrade-interfaces.json b/test/fixtures/linode_instances_124_upgrade-interfaces.json new file mode 100644 index 000000000..12340c4a3 --- /dev/null +++ b/test/fixtures/linode_instances_124_upgrade-interfaces.json @@ -0,0 +1,105 @@ +{ + "dry_run": true, + "config_id": 123, + "interfaces": [ + { + "created": "2025-01-01T00:01:01", + "default_route": { + "ipv4": true, + "ipv6": true + }, + "id": 123, + "mac_address": "22:00:AB:CD:EF:01", + "public": { + "ipv4": { + "addresses": [ + { + "address": "172.30.0.50", + "primary": true + } + ], + "shared": [ + { + "address": "172.30.0.51", + "linode_id": 125 + } + ] + }, + "ipv6": { + "ranges": [ + { + "range": "2600:3cO9:e001:59::/64", + "route_target": "2600:3cO9::ff:feab:cdef" + }, + { + "range": "2600:3cO9:e001:5a::/64", + "route_target": "2600:3cO9::ff:feab:cdef" + } + ], + "shared": [ + { + "range": "2600:3cO9:e001:2a::/64", + "route_target": null + } + ], + "slaac": [ + { + "address": "2600:3cO9::ff:feab:cdef", + "prefix": 64 + } + ] + } + }, + "updated": "2025-01-01T00:01:01", + "version": 1, + "vlan": null, + "vpc": null + }, + { + "id": 456, + "mac_address": "22:00:AB:CD:EF:01", + "created": "2024-01-01T00:01:01", + "updated": "2024-01-01T00:01:01", + "default_route": { + "ipv4": true + }, + "version": 1, + "vpc": { + "vpc_id": 123456, + "subnet_id": 789, + "ipv4": { + "addresses": [ + { + "address": "192.168.22.3", + "primary": true + } + ], + "ranges": [ + { + "range": "192.168.22.16/28" + }, + { + "range": "192.168.22.32/28" + } + ] + } + }, + "public": null, + "vlan": null + }, + { + "id": 789, + "mac_address": "22:00:AB:CD:EF:01", + "created": "2024-01-01T00:01:01", + "updated": "2024-01-01T00:01:01", + "default_route": {}, + "version": 1, + "vpc": null, + "public": null, + "vlan": { + "vlan_label": "my_vlan", + "ipam_address": "10.0.0.1/24" + } + } + ] +} \ No newline at end of file diff --git a/test/integration/conftest.py b/test/integration/conftest.py index 8c7d44a57..dfa01abed 100644 --- a/test/integration/conftest.py +++ b/test/integration/conftest.py @@ -12,7 +12,16 @@ import requests from requests.exceptions import ConnectionError, RequestException -from linode_api4 import PlacementGroupPolicy, PlacementGroupType +from linode_api4 import ( + InterfaceGeneration, + LinodeInterfaceDefaultRouteOptions, + LinodeInterfaceOptions, + LinodeInterfacePublicOptions, + LinodeInterfaceVLANOptions, + LinodeInterfaceVPCOptions, + PlacementGroupPolicy, + PlacementGroupType, +) from linode_api4.linode_client import LinodeClient from linode_api4.objects import Region @@ -521,3 +530,73 @@ def linode_for_vlan_tests(test_linode_client, e2e_test_firewall): yield linode_instance linode_instance.delete() + + +@pytest.fixture(scope="function") +def linode_with_interface_generation_linode( + test_linode_client, + e2e_test_firewall, + # We won't be using this all the time, but it's + # necessary for certain consumers of this fixture + create_vpc_with_subnet, +): + client = test_linode_client + + label = get_test_label() + + instance = client.linode.instance_create( + "g6-nanode-1", + create_vpc_with_subnet[0].region, + label=label, + interface_generation=InterfaceGeneration.LINODE, + booted=False, + ) + + yield instance + + instance.delete() + + +@pytest.fixture(scope="function") +def linode_with_linode_interfaces( + test_linode_client, e2e_test_firewall, create_vpc_with_subnet +): + client = test_linode_client + vpc, subnet = create_vpc_with_subnet + + # Are there regions where VPCs are supported but Linode Interfaces aren't? + region = vpc.region + label = get_test_label() + + instance, _ = client.linode.instance_create( + "g6-nanode-1", + region, + image="linode/debian12", + label=label, + booted=False, + interface_generation=InterfaceGeneration.LINODE, + interfaces=[ + LinodeInterfaceOptions( + firewall_id=e2e_test_firewall.id, + default_route=LinodeInterfaceDefaultRouteOptions( + ipv4=True, + ipv6=True, + ), + public=LinodeInterfacePublicOptions(), + ), + LinodeInterfaceOptions( + vpc=LinodeInterfaceVPCOptions( + subnet_id=subnet.id, + ), + ), + LinodeInterfaceOptions( + vlan=LinodeInterfaceVLANOptions( + vlan_label="test-vlan", ipam_address="10.0.0.5/32" + ), + ), + ], + ) + + yield instance + + instance.delete() diff --git a/test/integration/models/linode/interfaces/test_interfaces.py b/test/integration/models/linode/interfaces/test_interfaces.py new file mode 100644 index 000000000..6a81bb8bc --- /dev/null +++ b/test/integration/models/linode/interfaces/test_interfaces.py @@ -0,0 +1,343 @@ +import copy +import ipaddress + +import pytest + +from linode_api4 import ( + ApiError, + Instance, + LinodeInterface, + LinodeInterfaceDefaultRouteOptions, + LinodeInterfacePublicIPv4AddressOptions, + LinodeInterfacePublicIPv4Options, + LinodeInterfacePublicIPv6Options, + LinodeInterfacePublicIPv6RangeOptions, + LinodeInterfacePublicOptions, + LinodeInterfaceVLANOptions, + LinodeInterfaceVPCIPv4Options, + LinodeInterfaceVPCIPv4RangeOptions, + LinodeInterfaceVPCOptions, +) + + +def test_linode_create_with_linode_interfaces( + create_vpc_with_subnet, + linode_with_linode_interfaces, +): + instance: Instance = linode_with_linode_interfaces + vpc, subnet = create_vpc_with_subnet + + def __assert_base(iface: LinodeInterface): + assert iface.id is not None + assert iface.linode_id == instance.id + + assert iface.created is not None + assert iface.updated is not None + + assert isinstance(iface.mac_address, str) + assert iface.version + + def __assert_public(iface: LinodeInterface): + __assert_base(iface) + + assert iface.default_route.ipv4 + assert iface.default_route.ipv6 + + assert iface.public.ipv4.addresses[0].address == instance.ipv4[0] + assert iface.public.ipv4.addresses[0].primary + assert len(iface.public.ipv4.shared) == 0 + + assert iface.public.ipv6.slaac[0].address == instance.ipv6.split("/")[0] + assert iface.public.ipv6.slaac[0].prefix == 64 + assert len(iface.public.ipv6.shared) == 0 + assert len(iface.public.ipv6.ranges) == 0 + + def __assert_vpc(iface: LinodeInterface): + __assert_base(iface) + + assert not iface.default_route.ipv4 + assert not iface.default_route.ipv6 + + assert iface.vpc.vpc_id == vpc.id + assert iface.vpc.subnet_id == subnet.id + + assert ipaddress.ip_address( + iface.vpc.ipv4.addresses[0].address + ) in ipaddress.ip_network(subnet.ipv4) + assert iface.vpc.ipv4.addresses[0].primary + assert iface.vpc.ipv4.addresses[0].nat_1_1_address is None + + assert len(iface.vpc.ipv4.ranges) == 0 + + def __assert_vlan(iface: LinodeInterface): + __assert_base(iface) + + assert not iface.default_route.ipv4 + assert not iface.default_route.ipv6 + + assert iface.vlan.vlan_label == "test-vlan" + assert iface.vlan.ipam_address == "10.0.0.5/32" + + __assert_public(instance.interfaces[0]) + __assert_vpc(instance.interfaces[1]) + __assert_vlan(instance.interfaces[2]) + + instance.invalidate() + + __assert_public(instance.interfaces[0]) + __assert_vpc(instance.interfaces[1]) + __assert_vlan(instance.interfaces[2]) + + +@pytest.fixture +def linode_interface_public( + test_linode_client, + e2e_test_firewall, + linode_with_interface_generation_linode, +): + instance: Instance = linode_with_interface_generation_linode + + ipv6_range = test_linode_client.networking.ipv6_range_allocate( + 64, linode=instance.id + ) + + yield instance.interface_create( + firewall_id=e2e_test_firewall.id, + default_route=LinodeInterfaceDefaultRouteOptions( + ipv4=True, + ipv6=True, + ), + public=LinodeInterfacePublicOptions( + ipv4=LinodeInterfacePublicIPv4Options( + addresses=[ + LinodeInterfacePublicIPv4AddressOptions( + address=instance.ips.ipv4.public[0].address, + primary=True, + ) + ] + ), + ipv6=LinodeInterfacePublicIPv6Options( + ranges=[ + LinodeInterfacePublicIPv6RangeOptions( + range=ipv6_range.range, + ) + ] + ), + ), + ), instance, ipv6_range + + +@pytest.fixture +def linode_interface_vpc( + test_linode_client, + e2e_test_firewall, + linode_with_interface_generation_linode, + create_vpc_with_subnet, +): + instance: Instance = linode_with_interface_generation_linode + vpc, subnet = create_vpc_with_subnet + + yield instance.interface_create( + firewall_id=e2e_test_firewall.id, + default_route=LinodeInterfaceDefaultRouteOptions( + ipv4=True, + ), + vpc=LinodeInterfaceVPCOptions( + subnet_id=subnet.id, + ipv4=LinodeInterfaceVPCIPv4Options( + # TODO (Enhanced Interfaces): Not currently working as expected + # addresses=[ + # LinodeInterfaceVPCIPv4AddressOptions( + # address="auto", + # primary=True, + # nat_1_1_address="any", + # ) + # ], + ranges=[ + LinodeInterfaceVPCIPv4RangeOptions( + range="/29", + ) + ] + ), + ), + ), instance, vpc, subnet + + +@pytest.fixture +def linode_interface_vlan( + test_linode_client, + e2e_test_firewall, + linode_with_interface_generation_linode, + create_vpc_with_subnet, +): + instance: Instance = linode_with_interface_generation_linode + + yield instance.interface_create( + vlan=LinodeInterfaceVLANOptions( + vlan_label="test-vlan", ipam_address="10.0.0.5/32" + ), + ), instance + + +def test_linode_interface_create_public(linode_interface_public): + iface, instance, ipv6_range = linode_interface_public + + assert iface.id is not None + assert iface.linode_id == instance.id + + assert iface.created is not None + assert iface.updated is not None + + assert isinstance(iface.mac_address, str) + assert iface.version + + assert iface.default_route.ipv4 + assert iface.default_route.ipv6 + + assert ( + iface.public.ipv4.addresses[0].address + == instance.ips.ipv4.public[0].address + ) + assert iface.public.ipv4.addresses[0].primary + assert len(iface.public.ipv4.shared) == 0 + + assert iface.public.ipv6.ranges[0].range == ipv6_range.range + assert ( + iface.public.ipv6.ranges[0].route_target == instance.ipv6.split("/")[0] + ) + assert iface.public.ipv6.slaac[0].address == instance.ipv6.split("/")[0] + assert iface.public.ipv6.slaac[0].prefix == 64 + assert len(iface.public.ipv6.shared) == 0 + + +def test_linode_interface_update_public(linode_interface_public): + iface, instance, ipv6_range = linode_interface_public + + old_public_ipv4 = copy.deepcopy(iface.public.ipv4) + + iface.public.ipv4.addresses += [ + LinodeInterfacePublicIPv4AddressOptions(address="auto", primary=True) + ] + iface.public.ipv4.addresses[0].primary = False + + iface.public.ipv6.ranges[0].range = "/64" + + iface.save() + + iface.invalidate() + + assert len(iface.public.ipv4.addresses) == 2 + + address = iface.public.ipv4.addresses[0] + assert address.address == old_public_ipv4.addresses[0].address + assert not address.primary + + address = iface.public.ipv4.addresses[1] + assert ipaddress.ip_address(address.address) + assert address.primary + + assert len(iface.public.ipv6.ranges) == 1 + + range = iface.public.ipv6.ranges[0] + assert len(range.range) > 0 + assert ipaddress.ip_network(range.range) + + +def test_linode_interface_create_vpc(linode_interface_vpc): + iface, instance, vpc, subnet = linode_interface_vpc + + assert iface.id is not None + assert iface.linode_id == instance.id + + assert iface.created is not None + assert iface.updated is not None + + assert isinstance(iface.mac_address, str) + assert iface.version + + assert iface.default_route.ipv4 + assert not iface.default_route.ipv6 + + assert iface.vpc.vpc_id == vpc.id + assert iface.vpc.subnet_id == subnet.id + + assert len(iface.vpc.ipv4.addresses[0].address) > 0 + assert iface.vpc.ipv4.addresses[0].primary + assert iface.vpc.ipv4.addresses[0].nat_1_1_address is None + + assert iface.vpc.ipv4.ranges[0].range.split("/")[1] == "29" + + +def test_linode_interface_update_vpc(linode_interface_vpc): + iface, instance, vpc, subnet = linode_interface_vpc + + iface.vpc.subnet_id = 0 + + try: + iface.save() + except ApiError: + pass + else: + raise Exception("Expected error when updating subnet_id to 0") + + iface.invalidate() + + old_ipv4 = copy.deepcopy(iface.vpc.ipv4) + + iface.vpc.ipv4.addresses[0].address = "auto" + iface.vpc.ipv4.ranges += [ + LinodeInterfaceVPCIPv4RangeOptions( + range="/32", + ) + ] + + iface.save() + iface.invalidate() + + address = iface.vpc.ipv4.addresses[0] + assert ipaddress.ip_address(address.address) + + range = iface.vpc.ipv4.ranges[0] + assert ipaddress.ip_network(range.range) + assert range.range == old_ipv4.ranges[0].range + + range = iface.vpc.ipv4.ranges[1] + assert ipaddress.ip_network(range.range) + assert range.range != old_ipv4.ranges[0].range + + +def test_linode_interface_create_vlan( + linode_interface_vlan, +): + iface, instance = linode_interface_vlan + + assert iface.id is not None + assert iface.linode_id == instance.id + + assert iface.created is not None + assert iface.updated is not None + + assert isinstance(iface.mac_address, str) + assert iface.version + + assert not iface.default_route.ipv4 + assert not iface.default_route.ipv6 + + assert iface.vlan.vlan_label == "test-vlan" + assert iface.vlan.ipam_address == "10.0.0.5/32" + + +# NOTE: VLAN interface updates current aren't supported + + +def test_linode_interface_firewalls(e2e_test_firewall, linode_interface_public): + iface, instance, ipv6_range = linode_interface_public + + assert iface.id is not None + assert iface.linode_id == instance.id + + firewalls = iface.firewalls() + + firewall = firewalls[0] + assert firewall.id == e2e_test_firewall.id + assert firewall.label == e2e_test_firewall.label diff --git a/test/integration/models/linode/test_linode.py b/test/integration/models/linode/test_linode.py index ade4ca5ed..c02a1b1fc 100644 --- a/test/integration/models/linode/test_linode.py +++ b/test/integration/models/linode/test_linode.py @@ -9,6 +9,13 @@ import pytest +<<<<<<< Updated upstream +======= +from linode_api4 import ( + InterfaceGeneration, + LinodeInterface, +) +>>>>>>> Stashed changes from linode_api4.errors import ApiError from linode_api4.objects import ( Config, @@ -66,8 +73,8 @@ def linode_with_volume_firewall(test_linode_client): linode_instance.delete() -@pytest.fixture(scope="session") -def linode_for_network_interface_tests(test_linode_client, e2e_test_firewall): +@pytest.fixture(scope="function") +def linode_for_legacy_interface_tests(test_linode_client, e2e_test_firewall): client = test_linode_client region = get_region(client, {"Linodes", "Cloud Firewall"}, site_type="core") label = get_test_label(length=8) @@ -78,6 +85,7 @@ def linode_for_network_interface_tests(test_linode_client, e2e_test_firewall): image="linode/debian12", label=label, firewall=e2e_test_firewall, + interface_generation=InterfaceGeneration.LEGACY_CONFIG, ) yield linode_instance @@ -85,6 +93,29 @@ def linode_for_network_interface_tests(test_linode_client, e2e_test_firewall): linode_instance.delete() +@pytest.fixture(scope="function") +def linode_and_vpc_for_legacy_interface_tests_offline( + test_linode_client, create_vpc_with_subnet, e2e_test_firewall +): + vpc, subnet = create_vpc_with_subnet + + label = get_test_label(length=8) + + instance, password = test_linode_client.linode.instance_create( + "g6-standard-1", + vpc.region, + booted=False, + image="linode/debian11", + label=label, + firewall=e2e_test_firewall, + interface_generation=InterfaceGeneration.LEGACY_CONFIG, + ) + + yield vpc, subnet, instance, password + + instance.delete() + + @pytest.fixture(scope="session") def linode_for_vpu_tests(test_linode_client, e2e_test_firewall): client = test_linode_client @@ -589,6 +620,130 @@ def test_linode_initate_migration(test_linode_client, e2e_test_firewall): assert res +def test_linode_upgrade_interfaces( + linode_for_legacy_interface_tests, + linode_and_vpc_for_legacy_interface_tests_offline, +): + vpc, subnet, linode, _ = linode_and_vpc_for_legacy_interface_tests_offline + config = linode.configs[0] + + new_interfaces = [ + {"purpose": "public"}, + ConfigInterface( + purpose="vlan", label="cool-vlan", ipam_address="10.0.0.4/32" + ), + ConfigInterface( + purpose="vpc", + subnet_id=subnet.id, + primary=True, + ipv4=ConfigInterfaceIPv4(vpc="10.0.0.2", nat_1_1="any"), + ip_ranges=["10.0.0.5/32"], + ), + ] + config.interfaces = new_interfaces + + config.save() + + def __assert_base(iface: LinodeInterface): + assert iface.id is not None + assert iface.created is not None + assert iface.updated is not None + assert iface.version is not None + + assert len(iface.mac_address) > 0 + + def __assert_public(iface: LinodeInterface): + __assert_base(iface) + + assert not iface.default_route.ipv4 + assert iface.default_route.ipv6 + + assert len(iface.public.ipv4.addresses) == 0 + assert len(iface.public.ipv4.shared) == 0 + + assert len(iface.public.ipv6.slaac) == 1 + assert iface.public.ipv6.slaac[0].address == linode.ipv6.split("/")[0] + + assert len(iface.public.ipv6.ranges) == 0 + assert len(iface.public.ipv6.shared) == 0 + + def __assert_vpc(iface: LinodeInterface): + __assert_base(iface) + + assert iface.default_route.ipv4 + assert not iface.default_route.ipv6 + + assert iface.vpc.vpc_id == vpc.id + assert iface.vpc.subnet_id == subnet.id + + assert len(iface.vpc.ipv4.addresses) == 1 + assert iface.vpc.ipv4.addresses[0].address == "10.0.0.2" + assert iface.vpc.ipv4.addresses[0].primary + assert iface.vpc.ipv4.addresses[0].nat_1_1_address is not None + + assert len(iface.vpc.ipv4.ranges) == 1 + assert iface.vpc.ipv4.ranges[0].range == "10.0.0.5/32" + + def __assert_vlan(iface: LinodeInterface): + __assert_base(iface) + + assert not iface.default_route.ipv4 + assert not iface.default_route.ipv6 + + assert iface.vlan.vlan_label == "cool-vlan" + assert iface.vlan.ipam_address == "10.0.0.4/32" + + result = linode.upgrade_interfaces(dry_run=True) + + assert result.dry_run + assert result.config_id == config.id + + __assert_public(result.interfaces[0]) + __assert_vlan(result.interfaces[1]) + __assert_vpc(result.interfaces[2]) + + result = linode.upgrade_interfaces(config=config) + + assert not result.dry_run + assert result.config_id == config.id + + __assert_public(result.interfaces[0]) + __assert_vlan(result.interfaces[1]) + __assert_vpc(result.interfaces[2]) + + __assert_public(linode.interfaces[0]) + __assert_vlan(linode.interfaces[1]) + __assert_vpc(linode.interfaces[2]) + + +def test_linode_interfaces_settings(linode_with_linode_interfaces): + linode = linode_with_linode_interfaces + settings = linode.interfaces_settings + + assert settings.network_helper is not None + assert settings.default_route.ipv4_interface_id == linode.interfaces[0].id + assert settings.default_route.ipv4_eligible_interface_ids == [ + linode.interfaces[0].id, + linode.interfaces[1].id, + ] + + assert settings.default_route.ipv6_interface_id == linode.interfaces[0].id + assert settings.default_route.ipv6_eligible_interface_ids == [ + linode.interfaces[0].id + ] + + # Arbitrary updates + settings.network_helper = True + settings.default_route.ipv4_interface_id = linode.interfaces[1].id + + settings.save() + settings.invalidate() + + # Assert updates + assert settings.network_helper is not None + assert settings.default_route.ipv4_interface_id == linode.interfaces[1].id + + def test_config_update_interfaces(create_linode): linode = create_linode config = linode.configs[0] @@ -672,8 +827,8 @@ def test_save_linode_force(test_linode_client, create_linode): class TestNetworkInterface: - def test_list(self, linode_for_network_interface_tests): - linode = linode_for_network_interface_tests + def test_list(self, linode_for_legacy_interface_tests): + linode = linode_for_legacy_interface_tests config: Config = linode.configs[0] @@ -693,8 +848,8 @@ def test_list(self, linode_for_network_interface_tests): assert interface[1].label == label assert interface[1].ipam_address == "10.0.0.3/32" - def test_create_public(self, linode_for_network_interface_tests): - linode = linode_for_network_interface_tests + def test_create_public(self, linode_for_legacy_interface_tests): + linode = linode_for_legacy_interface_tests config: Config = linode.configs[0] @@ -711,8 +866,8 @@ def test_create_public(self, linode_for_network_interface_tests): assert interface.purpose == "public" assert interface.primary - def test_create_vlan(self, linode_for_network_interface_tests): - linode = linode_for_network_interface_tests + def test_create_vlan(self, linode_for_legacy_interface_tests): + linode = linode_for_legacy_interface_tests config: Config = linode.configs[0] @@ -736,10 +891,11 @@ def test_create_vpu(self, test_linode_client, linode_for_vpu_tests): def test_create_vpc( self, test_linode_client, - linode_for_network_interface_tests, - create_vpc_with_subnet_and_linode, + linode_and_vpc_for_legacy_interface_tests_offline, ): - vpc, subnet, linode, _ = create_vpc_with_subnet_and_linode + vpc, subnet, linode, _ = ( + linode_and_vpc_for_legacy_interface_tests_offline + ) config: Config = linode.configs[0] @@ -749,7 +905,7 @@ def test_create_vpc( interface = config.interface_create_vpc( subnet=subnet, primary=True, - ipv4=ConfigInterfaceIPv4(vpc="10.0.0.2", nat_1_1="any"), + ipv4=ConfigInterfaceIPv4(vpc="10.0.0.3", nat_1_1="any"), ip_ranges=["10.0.0.5/32"], ) @@ -758,7 +914,7 @@ def test_create_vpc( assert interface.id == config.interfaces[0].id assert interface.subnet.id == subnet.id assert interface.purpose == "vpc" - assert interface.ipv4.vpc == "10.0.0.2" + assert interface.ipv4.vpc == "10.0.0.3" assert interface.ipv4.nat_1_1 == linode.ipv4[0] assert interface.ip_ranges == ["10.0.0.5/32"] @@ -792,10 +948,11 @@ def test_create_vpc( def test_update_vpc( self, - linode_for_network_interface_tests, - create_vpc_with_subnet_and_linode, + linode_and_vpc_for_legacy_interface_tests_offline, ): - vpc, subnet, linode, _ = create_vpc_with_subnet_and_linode + vpc, subnet, linode, _ = ( + linode_and_vpc_for_legacy_interface_tests_offline + ) config: Config = linode.configs[0] @@ -805,11 +962,11 @@ def test_update_vpc( interface = config.interface_create_vpc( subnet=subnet, primary=True, - ip_ranges=["10.0.0.5/32"], + ip_ranges=["10.0.0.8/32"], ) interface.primary = False - interface.ip_ranges = ["10.0.0.6/32"] + interface.ip_ranges = ["10.0.0.9/32"] interface.ipv4.vpc = "10.0.0.3" interface.ipv4.nat_1_1 = "any" @@ -822,10 +979,10 @@ def test_update_vpc( assert interface.purpose == "vpc" assert interface.ipv4.vpc == "10.0.0.3" assert interface.ipv4.nat_1_1 == linode.ipv4[0] - assert interface.ip_ranges == ["10.0.0.6/32"] + assert interface.ip_ranges == ["10.0.0.9/32"] - def test_reorder(self, linode_for_network_interface_tests): - linode = linode_for_network_interface_tests + def test_reorder(self, linode_for_legacy_interface_tests): + linode = linode_for_legacy_interface_tests config: Config = linode.configs[0] diff --git a/test/unit/groups/linode_test.py b/test/unit/groups/linode_test.py index 8112a5d93..7644cfa1d 100644 --- a/test/unit/groups/linode_test.py +++ b/test/unit/groups/linode_test.py @@ -1,6 +1,14 @@ from test.unit.base import ClientBaseCase - -from linode_api4 import InstancePlacementGroupAssignment +from test.unit.objects.linode_interface_test import ( + build_interface_options_public, + build_interface_options_vlan, + build_interface_options_vpc, +) + +from linode_api4 import ( + InstancePlacementGroupAssignment, + InterfaceGeneration, +) from linode_api4.objects import ConfigInterface @@ -32,7 +40,7 @@ def test_instance_create_with_user_data(self): }, ) - def test_instance_create_with_interfaces(self): + def test_instance_create_with_interfaces_legacy(self): """ Tests that user can pass a list of interfaces on Linode create. """ @@ -46,6 +54,7 @@ def test_instance_create_with_interfaces(self): self.client.linode.instance_create( "us-southeast", "g6-nanode-1", + interface_generation=InterfaceGeneration.LEGACY_CONFIG, interfaces=interfaces, ) @@ -96,6 +105,32 @@ def test_create_with_placement_group(self): m.call_data["placement_group"], {"id": 123, "compliant_only": True} ) + def test_instance_create_with_interfaces_linode(self): + """ + Tests that a Linode can be created alongside multiple LinodeInterfaces. + """ + + interfaces = [ + build_interface_options_public(), + build_interface_options_vpc(), + build_interface_options_vlan(), + ] + + with self.mock_post("linode/instances/124") as m: + self.client.linode.instance_create( + "g6-nanode-1", + "us-mia", + interface_generation=InterfaceGeneration.LINODE, + interfaces=interfaces, + ) + + assert m.call_data == { + "region": "us-mia", + "type": "g6-nanode-1", + "interface_generation": "linode", + "interfaces": [iface._serialize() for iface in interfaces], + } + class TypeTest(ClientBaseCase): def test_get_types(self): diff --git a/test/unit/objects/linode_interface_test.py b/test/unit/objects/linode_interface_test.py new file mode 100644 index 000000000..a7b18034f --- /dev/null +++ b/test/unit/objects/linode_interface_test.py @@ -0,0 +1,329 @@ +from datetime import datetime +from test.unit.base import ClientBaseCase + +from linode_api4 import ( + LinodeInterface, + LinodeInterfaceDefaultRouteOptions, + LinodeInterfaceOptions, + LinodeInterfacePublicIPv4AddressOptions, + LinodeInterfacePublicIPv4Options, + LinodeInterfacePublicIPv6Options, + LinodeInterfacePublicIPv6RangeOptions, + LinodeInterfacePublicOptions, + LinodeInterfaceVLANOptions, + LinodeInterfaceVPCIPv4AddressOptions, + LinodeInterfaceVPCIPv4Options, + LinodeInterfaceVPCIPv4RangeOptions, + LinodeInterfaceVPCOptions, +) + + +def build_interface_options_public(): + return LinodeInterfaceOptions( + firewall_id=123, + default_route=LinodeInterfaceDefaultRouteOptions( + ipv4=True, + ipv6=True, + ), + public=LinodeInterfacePublicOptions( + ipv4=LinodeInterfacePublicIPv4Options( + addresses=[ + LinodeInterfacePublicIPv4AddressOptions( + address="172.30.0.50", primary=True + ) + ], + ), + ipv6=LinodeInterfacePublicIPv6Options( + ranges=[ + LinodeInterfacePublicIPv6RangeOptions( + range="2600:3cO9:e001:59::/64" + ) + ] + ), + ), + ) + + +def build_interface_options_vpc(): + return LinodeInterfaceOptions( + firewall_id=123, + default_route=LinodeInterfaceDefaultRouteOptions( + ipv4=True, + ), + vpc=LinodeInterfaceVPCOptions( + subnet_id=123, + ipv4=LinodeInterfaceVPCIPv4Options( + addresses=[ + LinodeInterfaceVPCIPv4AddressOptions( + address="192.168.22.3", + primary=True, + nat_1_1_address="any", + ) + ], + ranges=[ + LinodeInterfaceVPCIPv4RangeOptions(range="192.168.22.16/28") + ], + ), + ), + ) + + +def build_interface_options_vlan(): + return LinodeInterfaceOptions( + vlan=LinodeInterfaceVLANOptions( + vlan_label="my_vlan", ipam_address="10.0.0.1/24" + ), + ) + + +class LinodeInterfaceTest(ClientBaseCase): + """ + Tests methods of the LinodeInterface class + """ + + @staticmethod + def assert_linode_124_interface_123(iface: LinodeInterface): + assert iface.id == 123 + + assert isinstance(iface.created, datetime) + assert isinstance(iface.updated, datetime) + + assert iface.default_route.ipv4 + assert iface.default_route.ipv6 + + assert iface.mac_address == "22:00:AB:CD:EF:01" + assert iface.version == 1 + + assert iface.vlan is None + assert iface.vpc is None + + # public.ipv4 assertions + assert iface.public.ipv4.addresses[0].address == "172.30.0.50" + assert iface.public.ipv4.addresses[0].primary + + assert iface.public.ipv4.shared[0].address == "172.30.0.51" + assert iface.public.ipv4.shared[0].linode_id == 125 + + # public.ipv6 assertions + assert iface.public.ipv6.ranges[0].range == "2600:3cO9:e001:59::/64" + assert ( + iface.public.ipv6.ranges[0].route_target + == "2600:3cO9::ff:feab:cdef" + ) + + assert iface.public.ipv6.ranges[1].range == "2600:3cO9:e001:5a::/64" + assert ( + iface.public.ipv6.ranges[1].route_target + == "2600:3cO9::ff:feab:cdef" + ) + + assert iface.public.ipv6.shared[0].range == "2600:3cO9:e001:2a::/64" + assert iface.public.ipv6.shared[0].route_target is None + + assert iface.public.ipv6.slaac[0].address == "2600:3cO9::ff:feab:cdef" + assert iface.public.ipv6.slaac[0].prefix == 64 + + @staticmethod + def assert_linode_124_interface_456(iface: LinodeInterface): + assert iface.id == 456 + + assert isinstance(iface.created, datetime) + assert isinstance(iface.updated, datetime) + + assert iface.default_route.ipv4 + assert not iface.default_route.ipv6 + + assert iface.mac_address == "22:00:AB:CD:EF:01" + assert iface.version == 1 + + assert iface.vlan is None + assert iface.public is None + + # vpc assertions + assert iface.vpc.vpc_id == 123456 + assert iface.vpc.subnet_id == 789 + + assert iface.vpc.ipv4.addresses[0].address == "192.168.22.3" + assert iface.vpc.ipv4.addresses[0].primary + + assert iface.vpc.ipv4.ranges[0].range == "192.168.22.16/28" + assert iface.vpc.ipv4.ranges[1].range == "192.168.22.32/28" + + @staticmethod + def assert_linode_124_interface_789(iface: LinodeInterface): + assert iface.id == 789 + + assert isinstance(iface.created, datetime) + assert isinstance(iface.updated, datetime) + + assert iface.default_route.ipv4 is None + assert iface.default_route.ipv6 is None + + assert iface.mac_address == "22:00:AB:CD:EF:01" + assert iface.version == 1 + + assert iface.public is None + assert iface.vpc is None + + # vlan assertions + assert iface.vlan.vlan_label == "my_vlan" + assert iface.vlan.ipam_address == "10.0.0.1/24" + + def test_get_public(self): + iface = LinodeInterface(self.client, 123, 124) + + self.assert_linode_124_interface_123(iface) + iface.invalidate() + self.assert_linode_124_interface_123(iface) + + def test_get_vpc(self): + iface = LinodeInterface(self.client, 456, 124) + + self.assert_linode_124_interface_456(iface) + iface.invalidate() + self.assert_linode_124_interface_456(iface) + + def test_get_vlan(self): + iface = LinodeInterface(self.client, 789, 124) + + self.assert_linode_124_interface_789(iface) + iface.invalidate() + self.assert_linode_124_interface_789(iface) + + def test_update_public(self): + iface = LinodeInterface(self.client, 123, 124) + + self.assert_linode_124_interface_123(iface) + + iface.default_route.ipv4 = False + iface.default_route.ipv6 = False + + iface.public.ipv4.addresses = [ + LinodeInterfacePublicIPv4AddressOptions( + address="172.30.0.51", + primary=False, + ) + ] + + iface.public.ipv6.ranges = [ + LinodeInterfacePublicIPv6RangeOptions( + range="2600:3cO9:e001:58::/64" + ) + ] + + with self.mock_put("/linode/instances/124/interfaces/123") as m: + iface.save() + + assert m.called + + assert m.call_data == { + "default_route": { + "ipv4": False, + "ipv6": False, + }, + "public": { + "ipv4": { + "addresses": [ + { + "address": "172.30.0.51", + "primary": False, + }, + ] + }, + "ipv6": { + "ranges": [ + { + "range": "2600:3cO9:e001:58::/64", + } + ] + }, + }, + } + + def test_update_vpc(self): + iface = LinodeInterface(self.client, 456, 124) + + self.assert_linode_124_interface_456(iface) + + iface.default_route.ipv4 = False + + iface.vpc.subnet_id = 456 + + iface.vpc.ipv4.addresses = [ + LinodeInterfaceVPCIPv4AddressOptions( + address="192.168.22.4", primary=False, nat_1_1_address="auto" + ) + ] + + iface.vpc.ipv4.ranges = [ + LinodeInterfaceVPCIPv4RangeOptions( + range="192.168.22.17/28", + ) + ] + + with self.mock_put("/linode/instances/124/interfaces/456") as m: + iface.save() + + assert m.called + + assert m.call_data == { + "default_route": { + "ipv4": False, + }, + "vpc": { + "subnet_id": 456, + "ipv4": { + "addresses": [ + { + "address": "192.168.22.4", + "primary": False, + "nat_1_1_address": "auto", + }, + ], + "ranges": [{"range": "192.168.22.17/28"}], + }, + }, + } + + def test_update_vlan(self): + iface = LinodeInterface(self.client, 789, 124) + + self.assert_linode_124_interface_789(iface) + + iface.vlan.ipam_address = "10.0.0.2/24" + iface.vlan.vlan_label = "my_vlan_updated" + + with self.mock_put("/linode/instances/124/interfaces/789") as m: + iface.save() + + assert m.called + + assert m.call_data == { + # TODO (Enhanced Interfaces): Ensure this doesn't cause an API validation error. + "default_route": {}, + "vlan": { + "vlan_label": "my_vlan_updated", + "ipam_address": "10.0.0.2/24", + }, + } + + def test_delete(self): + iface = LinodeInterface(self.client, 123, 124) + + with self.mock_delete() as m: + iface.delete() + assert m.called + + def test_firewalls(self): + iface = LinodeInterface(self.client, 123, 124) + + firewalls = iface.firewalls() + + assert len(firewalls) == 1 + + assert firewalls[0].id == 123 + + # Check a few fields to make sure the Firewall object was populated + assert firewalls[0].label == "firewall123" + assert firewalls[0].rules.inbound[0].action == "ACCEPT" + assert firewalls[0].status == "enabled" diff --git a/test/unit/objects/linode_test.py b/test/unit/objects/linode_test.py index 6016d2776..2f0307363 100644 --- a/test/unit/objects/linode_test.py +++ b/test/unit/objects/linode_test.py @@ -1,5 +1,11 @@ from datetime import datetime from test.unit.base import ClientBaseCase +from test.unit.objects.linode_interface_test import ( + LinodeInterfaceTest, + build_interface_options_public, + build_interface_options_vlan, + build_interface_options_vpc, +) from linode_api4 import InstanceDiskEncryptionType, NetworkInterface from linode_api4.objects import ( @@ -463,6 +469,167 @@ def test_get_placement_group(self): assert pg.label == "test" assert pg.placement_group_type == "anti_affinity:local" + def test_get_interfaces(self): + # Local import to avoid circular dependency + from linode_interface_test import ( # pylint: disable=import-outside-toplevel + LinodeInterfaceTest, + ) + + instance = Instance(self.client, 124) + + interfaces = instance.interfaces + + LinodeInterfaceTest.assert_linode_124_interface_123( + next(iface for iface in interfaces if iface.id == 123) + ) + + LinodeInterfaceTest.assert_linode_124_interface_456( + next(iface for iface in interfaces if iface.id == 456) + ) + + LinodeInterfaceTest.assert_linode_124_interface_789( + next(iface for iface in interfaces if iface.id == 789) + ) + + def test_get_interfaces_settings(self): + instance = Instance(self.client, 124) + iface_settings = instance.interfaces_settings + + assert iface_settings.network_helper + + assert iface_settings.default_route.ipv4_interface_id == 123 + assert iface_settings.default_route.ipv4_eligible_interface_ids == [ + 123, + 456, + 789, + ] + + assert iface_settings.default_route.ipv6_interface_id == 456 + assert iface_settings.default_route.ipv6_eligible_interface_ids == [ + 123, + 456, + ] + + def test_update_interfaces_settings(self): + instance = Instance(self.client, 124) + iface_settings = instance.interfaces_settings + + iface_settings.network_helper = False + iface_settings.default_route.ipv4_interface_id = 456 + iface_settings.default_route.ipv6_interface_id = 123 + + print(vars(iface_settings)) + + with self.mock_put("/linode/instances/124/interfaces/settings") as m: + iface_settings.save() + + assert m.call_data == { + "network_helper": False, + "default_route": { + "ipv4_interface_id": 456, + "ipv6_interface_id": 123, + }, + } + + def test_upgrade_interfaces(self): + # Local import to avoid circular dependency + from linode_interface_test import ( # pylint: disable=import-outside-toplevel + LinodeInterfaceTest, + ) + + instance = Instance(self.client, 124) + + with self.mock_post("/linode/instances/124/upgrade-interfaces") as m: + result = instance.upgrade_interfaces(123) + + assert m.called + assert m.call_data == {"config_id": 123, "dry_run": False} + + assert result.config_id == 123 + assert result.dry_run + + # We don't use the assertion helpers here because dry runs return + # a MappedObject. + LinodeInterfaceTest.assert_linode_124_interface_123( + result.interfaces[0] + ) + LinodeInterfaceTest.assert_linode_124_interface_456( + result.interfaces[1] + ) + LinodeInterfaceTest.assert_linode_124_interface_789( + result.interfaces[2] + ) + + def test_upgrade_interfaces_dry(self): + instance = Instance(self.client, 124) + + with self.mock_post("/linode/instances/124/upgrade-interfaces") as m: + result = instance.upgrade_interfaces(123, dry_run=True) + + assert m.called + assert m.call_data == { + "config_id": 123, + "dry_run": True, + } + + assert result.config_id == 123 + assert result.dry_run + + # We don't use the assertion helpers here because dry runs return + # a MappedObject. + assert result.interfaces[0].id == 123 + assert result.interfaces[0].public is not None + + assert result.interfaces[1].id == 456 + assert result.interfaces[1].vpc is not None + + assert result.interfaces[2].id == 789 + assert result.interfaces[2].vlan is not None + + def test_create_interface_public(self): + instance = Instance(self.client, 124) + + iface = build_interface_options_public() + + with self.mock_post("/linode/instances/124/interfaces/123") as m: + result = instance.interface_create(**vars(iface)) + + assert m.call_data == { + "firewall_id": iface.firewall_id, + "default_route": iface.default_route._serialize(), + "public": iface.public._serialize(), + } + + LinodeInterfaceTest.assert_linode_124_interface_123(result) + + def test_create_interface_vpc(self): + instance = Instance(self.client, 124) + + iface = build_interface_options_vpc() + + with self.mock_post("/linode/instances/124/interfaces/456") as m: + result = instance.interface_create(**vars(iface)) + + assert m.call_data == { + "firewall_id": iface.firewall_id, + "default_route": iface.default_route._serialize(), + "vpc": iface.vpc._serialize(), + } + + LinodeInterfaceTest.assert_linode_124_interface_456(result) + + def test_create_interface_vlan(self): + instance = Instance(self.client, 124) + + iface = build_interface_options_vlan() + + with self.mock_post("/linode/instances/124/interfaces/789") as m: + result = instance.interface_create(**vars(iface)) + + assert m.call_data == {"vlan": iface.vlan._serialize()} + + LinodeInterfaceTest.assert_linode_124_interface_789(result) + class DiskTest(ClientBaseCase): """ From eb2566c8b97221006545f5e669ed0ea60bd7b022 Mon Sep 17 00:00:00 2001 From: Lena Garber Date: Mon, 5 May 2025 16:27:16 -0400 Subject: [PATCH 02/11] oops --- linode_api4/objects/networking.py | 4 ---- test/integration/models/linode/test_linode.py | 9 ++------- 2 files changed, 2 insertions(+), 11 deletions(-) diff --git a/linode_api4/objects/networking.py b/linode_api4/objects/networking.py index 756c28ef8..74703fb91 100644 --- a/linode_api4/objects/networking.py +++ b/linode_api4/objects/networking.py @@ -101,9 +101,6 @@ def linode(self): return self._linode -<<<<<<< Updated upstream - # TODO (Enhanced Interfaces): Add `interface` property method -======= @property def interface(self) -> Optional["LinodeInterface"]: """ @@ -128,7 +125,6 @@ def interface(self) -> Optional["LinodeInterface"]: ) return self._interface ->>>>>>> Stashed changes def to(self, linode): """ diff --git a/test/integration/models/linode/test_linode.py b/test/integration/models/linode/test_linode.py index c02a1b1fc..e254218ea 100644 --- a/test/integration/models/linode/test_linode.py +++ b/test/integration/models/linode/test_linode.py @@ -9,13 +9,6 @@ import pytest -<<<<<<< Updated upstream -======= -from linode_api4 import ( - InterfaceGeneration, - LinodeInterface, -) ->>>>>>> Stashed changes from linode_api4.errors import ApiError from linode_api4.objects import ( Config, @@ -23,6 +16,8 @@ ConfigInterfaceIPv4, Disk, Instance, + InterfaceGeneration, + LinodeInterface, Type, ) from linode_api4.objects.linode import InstanceDiskEncryptionType, MigrationType From 79a9798f3946531a04c6d0ee20a8537f0b288db8 Mon Sep 17 00:00:00 2001 From: Lena Garber Date: Mon, 5 May 2025 16:50:30 -0400 Subject: [PATCH 03/11] tiny fixes --- linode_api4/objects/linode.py | 33 ++++++++++++++++++++++++--------- 1 file changed, 24 insertions(+), 9 deletions(-) diff --git a/linode_api4/objects/linode.py b/linode_api4/objects/linode.py index 6609af2aa..b3699e9ec 100644 --- a/linode_api4/objects/linode.py +++ b/linode_api4/objects/linode.py @@ -1902,16 +1902,28 @@ def interface_create( **kwargs, ) -> LinodeInterface: """ - Creates a new interface under this Linode. + Creates a new interface under this Linode. - :param firewall: The firewall this interface should be assigned to. - :param default_route: The desired default route configuration of the new interface. - :param public: The public-specific configuration of the new interface. - If set, the new instance will be a public interface. - :param vlan: The VLAN-specific configuration of the new interface. - If set, the new instance will be a VLAN interface. - :param vpc: The VPC-specific configuration of the new interface. - If set, the new instance will be a VPC interface. + API Documentation: https://techdocs.akamai.com/linode-api/reference/post-linode-interface + + Example: Creating a simple public interface for this Linode:: + + interface = instance.interface_create( + default_route=LinodeInterfaceDefaultRouteOptions( + ipv4=True, + ipv6 + ), + public=LinodeInterfacePublicOptions() + ) + + :param firewall: The firewall this interface should be assigned to. + :param default_route: The desired default route configuration of the new interface. + :param public: The public-specific configuration of the new interface. + If set, the new instance will be a public interface. + :param vlan: The VLAN-specific configuration of the new interface. + If set, the new instance will be a VLAN interface. + :param vpc: The VPC-specific configuration of the new interface. + If set, the new instance will be a VPC interface. :returns: The newly created Linode Interface. """ @@ -1998,6 +2010,7 @@ def upgrade_interfaces( self, config: Optional[Union[Config, int]] = None, dry_run: bool = False, + **kwargs, ) -> UpgradeInterfacesResult: """ Automatically upgrades all legacy config interfaces of a @@ -2013,6 +2026,8 @@ def upgrade_interfaces( """ params = {"config_id": config, "dry_run": dry_run} + params.update(kwargs) + result = self._client.post( "{}/upgrade-interfaces".format(Instance.api_endpoint), model=self, From b1a8215009802e09db3df2bd5b75ff98ad5db833 Mon Sep 17 00:00:00 2001 From: Lena Garber Date: Mon, 5 May 2025 16:54:30 -0400 Subject: [PATCH 04/11] fix docsa --- linode_api4/objects/linode.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/linode_api4/objects/linode.py b/linode_api4/objects/linode.py index b3699e9ec..90ddd98c3 100644 --- a/linode_api4/objects/linode.py +++ b/linode_api4/objects/linode.py @@ -2021,6 +2021,14 @@ def upgrade_interfaces( API Documentation: Not yet available. + :param config: The configuration profile the legacy interfaces to + upgrade are under. + :type config: Config or int + :param dry_run: Whether this operation should be a dry run, + which will return the interfaces that would be + created if the operation were completed. + :type dry_run: bool + :returns: Information about the newly upgraded interfaces. :rtype: UpgradeInterfacesResult """ From d5d7c3547f7aadb6f058e22b3a3b70fa2dfe6818 Mon Sep 17 00:00:00 2001 From: Lena Garber Date: Mon, 5 May 2025 17:09:28 -0400 Subject: [PATCH 05/11] Add docs examples --- linode_api4/objects/linode.py | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/linode_api4/objects/linode.py b/linode_api4/objects/linode.py index 90ddd98c3..4f53a64fc 100644 --- a/linode_api4/objects/linode.py +++ b/linode_api4/objects/linode.py @@ -1911,11 +1911,33 @@ def interface_create( interface = instance.interface_create( default_route=LinodeInterfaceDefaultRouteOptions( ipv4=True, - ipv6 + ipv6=True ), public=LinodeInterfacePublicOptions() ) + Example: Creating a simple VPC interface for this Linode:: + + interface = instance.interface_create( + default_route=LinodeInterfaceDefaultRouteOptions( + ipv4=True + ), + vpc=LinodeInterfaceVPCOptions( + subnet_id=12345 + ) + ) + + Example: Creating a simple VLAN interface for this Linode:: + + interface = instance.interface_create( + default_route=LinodeInterfaceDefaultRouteOptions( + ipv4=True + ), + vlan=LinodeInterfaceVLANOptions( + vlan_label="my-vlan" + ) + ) + :param firewall: The firewall this interface should be assigned to. :param default_route: The desired default route configuration of the new interface. :param public: The public-specific configuration of the new interface. From 543145acd0267dc4b3709b5e7ae90cd452b8c11f Mon Sep 17 00:00:00 2001 From: Lena Garber Date: Mon, 5 May 2025 17:20:17 -0400 Subject: [PATCH 06/11] Docs fixes --- linode_api4/groups/linode.py | 24 ++++++++++++++++++++++++ linode_api4/objects/linode.py | 2 ++ 2 files changed, 26 insertions(+) diff --git a/linode_api4/groups/linode.py b/linode_api4/groups/linode.py index f1b326bc2..d27d8680c 100644 --- a/linode_api4/groups/linode.py +++ b/linode_api4/groups/linode.py @@ -240,6 +240,30 @@ def instance_create( "us-east", backup=snapshot) + **Create an Instance with explicit interfaces:** + + To create a new Instance with explicit interfaces, provide list of + LinodeInterfaceOptions objects or dicts to the "interfaces" field:: + + linode, password = client.linode.instance_create( + "g6-standard-1", + "us-mia", + image="linode/ubuntu24.04", + + # This can be configured as an account-wide default + interface_generation=InterfaceGeneration.LINODE, + + interfaces=[ + LinodeInterfaceOptions( + default_route=LinodeInterfaceDefaultRouteOptions( + ipv4=True, + ipv6=True + ), + public=LinodeInterfacePublicOptions + ) + ] + ) + **Create an empty Instance** If you want to create an empty Instance that you will configure manually, diff --git a/linode_api4/objects/linode.py b/linode_api4/objects/linode.py index 4f53a64fc..b12633c53 100644 --- a/linode_api4/objects/linode.py +++ b/linode_api4/objects/linode.py @@ -1904,6 +1904,8 @@ def interface_create( """ Creates a new interface under this Linode. + NOTE: Linode Interfaces are not interchangeable with Interfaces/Network Interfaces. + API Documentation: https://techdocs.akamai.com/linode-api/reference/post-linode-interface Example: Creating a simple public interface for this Linode:: From bbb021e23216225d28b1780323f1994f9c80aff7 Mon Sep 17 00:00:00 2001 From: Lena Garber Date: Tue, 6 May 2025 09:32:31 -0400 Subject: [PATCH 07/11] oops --- linode_api4/objects/linode.py | 2 +- test/unit/objects/linode_test.py | 8 +++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/linode_api4/objects/linode.py b/linode_api4/objects/linode.py index b12633c53..4b0628803 100644 --- a/linode_api4/objects/linode.py +++ b/linode_api4/objects/linode.py @@ -469,7 +469,6 @@ class Config(DerivedBase): "virt_mode": Property(mutable=True), "memory_limit": Property(mutable=True), "interfaces": Property(mutable=True, json_object=ConfigInterface), - "interface_generation": Property(), } @property @@ -723,6 +722,7 @@ class Instance(Base): "disk_encryption": Property(), "lke_cluster_id": Property(), "capabilities": Property(unordered=True), + "interface_generation": Property(), } @property diff --git a/test/unit/objects/linode_test.py b/test/unit/objects/linode_test.py index 2f0307363..6b491783e 100644 --- a/test/unit/objects/linode_test.py +++ b/test/unit/objects/linode_test.py @@ -7,7 +7,11 @@ build_interface_options_vpc, ) -from linode_api4 import InstanceDiskEncryptionType, NetworkInterface +from linode_api4 import ( + InstanceDiskEncryptionType, + InterfaceGeneration, + NetworkInterface, +) from linode_api4.objects import ( Config, ConfigInterface, @@ -477,6 +481,8 @@ def test_get_interfaces(self): instance = Instance(self.client, 124) + assert instance.interface_generation == InterfaceGeneration.LINODE + interfaces = instance.interfaces LinodeInterfaceTest.assert_linode_124_interface_123( From 3e1c32508164e76cf7daad97b4ad903d11b210a5 Mon Sep 17 00:00:00 2001 From: Lena Garber Date: Tue, 6 May 2025 10:09:38 -0400 Subject: [PATCH 08/11] Remove irrelevant test --- test/unit/objects/linode_interface_test.py | 22 ---------------------- 1 file changed, 22 deletions(-) diff --git a/test/unit/objects/linode_interface_test.py b/test/unit/objects/linode_interface_test.py index a7b18034f..db0232c9e 100644 --- a/test/unit/objects/linode_interface_test.py +++ b/test/unit/objects/linode_interface_test.py @@ -285,28 +285,6 @@ def test_update_vpc(self): }, } - def test_update_vlan(self): - iface = LinodeInterface(self.client, 789, 124) - - self.assert_linode_124_interface_789(iface) - - iface.vlan.ipam_address = "10.0.0.2/24" - iface.vlan.vlan_label = "my_vlan_updated" - - with self.mock_put("/linode/instances/124/interfaces/789") as m: - iface.save() - - assert m.called - - assert m.call_data == { - # TODO (Enhanced Interfaces): Ensure this doesn't cause an API validation error. - "default_route": {}, - "vlan": { - "vlan_label": "my_vlan_updated", - "ipam_address": "10.0.0.2/24", - }, - } - def test_delete(self): iface = LinodeInterface(self.client, 123, 124) From 6b9d55bcd88b607b1ec1a9f619f402355610b573 Mon Sep 17 00:00:00 2001 From: Lena Garber Date: Tue, 6 May 2025 10:20:02 -0400 Subject: [PATCH 09/11] Add LA notices --- linode_api4/objects/linode.py | 7 ++- linode_api4/objects/linode_interfaces.py | 63 ++++++++++++++++++++++++ linode_api4/objects/networking.py | 4 +- 3 files changed, 72 insertions(+), 2 deletions(-) diff --git a/linode_api4/objects/linode.py b/linode_api4/objects/linode.py index 4b0628803..506dbdec3 100644 --- a/linode_api4/objects/linode.py +++ b/linode_api4/objects/linode.py @@ -1903,8 +1903,9 @@ def interface_create( ) -> LinodeInterface: """ Creates a new interface under this Linode. + Linode interfaces are not interchangeable with Config interfaces. - NOTE: Linode Interfaces are not interchangeable with Interfaces/Network Interfaces. + NOTE: Linode interfaces may not currently be available to all users. API Documentation: https://techdocs.akamai.com/linode-api/reference/post-linode-interface @@ -1980,6 +1981,8 @@ def interfaces_settings(self) -> LinodeInterfacesSettings: """ The settings for all interfaces under this Linode. + NOTE: Linode interfaces may not currently be available to all users. + :returns: The settings for instance-level interface settings for this Linode. """ @@ -2043,6 +2046,8 @@ def upgrade_interfaces( NOTE: If dry_run is True, interfaces in the result will be of type MappedObject rather than LinodeInterface. + NOTE: Linode interfaces may not currently be available to all users. + API Documentation: Not yet available. :param config: The configuration profile the legacy interfaces to diff --git a/linode_api4/objects/linode_interfaces.py b/linode_api4/objects/linode_interfaces.py index 9a6361a14..20cb6ebfa 100644 --- a/linode_api4/objects/linode_interfaces.py +++ b/linode_api4/objects/linode_interfaces.py @@ -11,6 +11,8 @@ class LinodeInterfacesSettingsDefaultRouteOptions(JSONObject): """ The options used to configure the default route settings for a Linode's network interfaces. + + NOTE: Linode interfaces may not currently be available to all users. """ ipv4_interface_id: Optional[int] = None @@ -21,6 +23,8 @@ class LinodeInterfacesSettingsDefaultRouteOptions(JSONObject): class LinodeInterfacesSettingsDefaultRoute(JSONObject): """ The default route settings for a Linode's network interfaces. + + NOTE: Linode interfaces may not currently be available to all users. """ put_class = LinodeInterfacesSettingsDefaultRouteOptions @@ -36,6 +40,8 @@ class LinodeInterfacesSettings(Base): The settings related to a Linode's network interfaces. API Documentation: Not yet available. + + NOTE: Linode interfaces may not currently be available to all users. """ api_endpoint = "/linode/instances/{id}/interfaces/settings" @@ -54,6 +60,8 @@ class LinodeInterfacesSettings(Base): class LinodeInterfaceDefaultRouteOptions(JSONObject): """ Options accepted when creating or updating a Linode Interface's default route settings. + + NOTE: Linode interfaces may not currently be available to all users. """ ipv4: Optional[bool] = None @@ -64,6 +72,8 @@ class LinodeInterfaceDefaultRouteOptions(JSONObject): class LinodeInterfaceVPCIPv4AddressOptions(JSONObject): """ Options accepted for a single address when creating or updating the IPv4 configuration of a VPC Linode Interface. + + NOTE: Linode interfaces may not currently be available to all users. """ address: Optional[str] = None @@ -75,6 +85,8 @@ class LinodeInterfaceVPCIPv4AddressOptions(JSONObject): class LinodeInterfaceVPCIPv4RangeOptions(JSONObject): """ Options accepted for a single range when creating or updating the IPv4 configuration of a VPC Linode Interface. + + NOTE: Linode interfaces may not currently be available to all users. """ range: str = "" @@ -84,6 +96,8 @@ class LinodeInterfaceVPCIPv4RangeOptions(JSONObject): class LinodeInterfaceVPCIPv4Options(JSONObject): """ Options accepted when creating or updating the IPv4 configuration of a VPC Linode Interface. + + NOTE: Linode interfaces may not currently be available to all users. """ addresses: Optional[List[LinodeInterfaceVPCIPv4AddressOptions]] = None @@ -94,6 +108,8 @@ class LinodeInterfaceVPCIPv4Options(JSONObject): class LinodeInterfaceVPCOptions(JSONObject): """ VPC-exclusive options accepted when creating or updating a Linode Interface. + + NOTE: Linode interfaces may not currently be available to all users. """ subnet_id: int = 0 @@ -104,6 +120,8 @@ class LinodeInterfaceVPCOptions(JSONObject): class LinodeInterfacePublicIPv4AddressOptions(JSONObject): """ Options accepted for a single address when creating or updating the IPv4 configuration of a public Linode Interface. + + NOTE: Linode interfaces may not currently be available to all users. """ address: str = "" @@ -114,6 +132,8 @@ class LinodeInterfacePublicIPv4AddressOptions(JSONObject): class LinodeInterfacePublicIPv4Options(JSONObject): """ Options accepted when creating or updating the IPv4 configuration of a public Linode Interface. + + NOTE: Linode interfaces may not currently be available to all users. """ addresses: Optional[List[LinodeInterfacePublicIPv4AddressOptions]] = None @@ -123,6 +143,8 @@ class LinodeInterfacePublicIPv4Options(JSONObject): class LinodeInterfacePublicIPv6RangeOptions(JSONObject): """ Options accepted for a single range when creating or updating the IPv6 configuration of a public Linode Interface. + + NOTE: Linode interfaces may not currently be available to all users. """ range: str = "" @@ -132,6 +154,8 @@ class LinodeInterfacePublicIPv6RangeOptions(JSONObject): class LinodeInterfacePublicIPv6Options(JSONObject): """ Options accepted when creating or updating the IPv6 configuration of a public Linode Interface. + + NOTE: Linode interfaces may not currently be available to all users. """ ranges: Optional[List[LinodeInterfacePublicIPv6RangeOptions]] = None @@ -141,6 +165,8 @@ class LinodeInterfacePublicIPv6Options(JSONObject): class LinodeInterfacePublicOptions(JSONObject): """ Public-exclusive options accepted when creating or updating a Linode Interface. + + NOTE: Linode interfaces may not currently be available to all users. """ ipv4: Optional[LinodeInterfacePublicIPv4Options] = None @@ -151,6 +177,8 @@ class LinodeInterfacePublicOptions(JSONObject): class LinodeInterfaceVLANOptions(JSONObject): """ VLAN-exclusive options accepted when creating or updating a Linode Interface. + + NOTE: Linode interfaces may not currently be available to all users. """ vlan_label: str = "" @@ -161,6 +189,8 @@ class LinodeInterfaceVLANOptions(JSONObject): class LinodeInterfaceOptions(JSONObject): """ Options accepted when creating or updating a Linode Interface. + + NOTE: Linode interfaces may not currently be available to all users. """ always_include = { @@ -183,6 +213,8 @@ class LinodeInterfaceOptions(JSONObject): class LinodeInterfaceDefaultRoute(JSONObject): """ The default route configuration of a Linode Interface. + + NOTE: Linode interfaces may not currently be available to all users. """ put_class = LinodeInterfaceDefaultRouteOptions @@ -195,6 +227,8 @@ class LinodeInterfaceDefaultRoute(JSONObject): class LinodeInterfaceVPCIPv4Address(JSONObject): """ A single address under the IPv4 configuration of a VPC Linode Interface. + + NOTE: Linode interfaces may not currently be available to all users. """ put_class = LinodeInterfaceVPCIPv4AddressOptions @@ -208,6 +242,8 @@ class LinodeInterfaceVPCIPv4Address(JSONObject): class LinodeInterfaceVPCIPv4Range(JSONObject): """ A single range under the IPv4 configuration of a VPC Linode Interface. + + NOTE: Linode interfaces may not currently be available to all users. """ put_class = LinodeInterfaceVPCIPv4RangeOptions @@ -219,6 +255,8 @@ class LinodeInterfaceVPCIPv4Range(JSONObject): class LinodeInterfaceVPCIPv4(JSONObject): """ A single address under the IPv4 configuration of a VPC Linode Interface. + + NOTE: Linode interfaces may not currently be available to all users. """ put_class = LinodeInterfaceVPCIPv4Options @@ -231,6 +269,8 @@ class LinodeInterfaceVPCIPv4(JSONObject): class LinodeInterfaceVPC(JSONObject): """ VPC-specific configuration field for a Linode Interface. + + NOTE: Linode interfaces may not currently be available to all users. """ put_class = LinodeInterfaceVPCOptions @@ -245,6 +285,8 @@ class LinodeInterfaceVPC(JSONObject): class LinodeInterfacePublicIPv4Address(JSONObject): """ A single address under the IPv4 configuration of a public Linode Interface. + + NOTE: Linode interfaces may not currently be available to all users. """ put_class = LinodeInterfacePublicIPv4AddressOptions @@ -257,6 +299,8 @@ class LinodeInterfacePublicIPv4Address(JSONObject): class LinodeInterfacePublicIPv4Shared(JSONObject): """ A single shared address under the IPv4 configuration of a public Linode Interface. + + NOTE: Linode interfaces may not currently be available to all users. """ address: str = "" @@ -267,6 +311,8 @@ class LinodeInterfacePublicIPv4Shared(JSONObject): class LinodeInterfacePublicIPv4(JSONObject): """ The IPv4 configuration of a public Linode Interface. + + NOTE: Linode interfaces may not currently be available to all users. """ put_class = LinodeInterfacePublicIPv4Options @@ -281,6 +327,8 @@ class LinodeInterfacePublicIPv4(JSONObject): class LinodeInterfacePublicIPv6SLAAC(JSONObject): """ A single SLAAC entry under the IPv6 configuration of a public Linode Interface. + + NOTE: Linode interfaces may not currently be available to all users. """ address: str = "" @@ -291,6 +339,8 @@ class LinodeInterfacePublicIPv6SLAAC(JSONObject): class LinodeInterfacePublicIPv6Shared(JSONObject): """ A single shared range under the IPv6 configuration of a public Linode Interface. + + NOTE: Linode interfaces may not currently be available to all users. """ range: str = "" @@ -301,6 +351,8 @@ class LinodeInterfacePublicIPv6Shared(JSONObject): class LinodeInterfacePublicIPv6Range(JSONObject): """ A single range under the IPv6 configuration of a public Linode Interface. + + NOTE: Linode interfaces may not currently be available to all users. """ put_class = LinodeInterfacePublicIPv6RangeOptions @@ -313,6 +365,8 @@ class LinodeInterfacePublicIPv6Range(JSONObject): class LinodeInterfacePublicIPv6(JSONObject): """ The IPv6 configuration of a Linode Interface. + + NOTE: Linode interfaces may not currently be available to all users. """ put_class = LinodeInterfacePublicIPv6Options @@ -326,6 +380,8 @@ class LinodeInterfacePublicIPv6(JSONObject): class LinodeInterfacePublic(JSONObject): """ Public-specific configuration fields for a Linode Interface. + + NOTE: Linode interfaces may not currently be available to all users. """ put_class = LinodeInterfacePublicOptions @@ -338,6 +394,8 @@ class LinodeInterfacePublic(JSONObject): class LinodeInterfaceVLAN(JSONObject): """ VLAN-specific configuration fields for a Linode Interface. + + NOTE: Linode interfaces may not currently be available to all users. """ put_class = LinodeInterfaceVLANOptions @@ -350,6 +408,8 @@ class LinodeInterface(DerivedBase): """ A Linode's network interface. + NOTE: Linode interfaces may not currently be available to all users. + NOTE: When using the ``save()`` method, certain local fields with computed values will not be refreshed on the local object until after ``invalidate()`` has been called:: @@ -391,6 +451,9 @@ class LinodeInterface(DerivedBase): def firewalls(self, *filters) -> List[Firewall]: """ Retrieves a list of Firewalls for this Linode Interface. + Linode interfaces are not interchangeable with Config interfaces. + + NOTE: Linode interfaces may not currently be available to all users. :param filters: Any number of filters to apply to this query. See :doc:`Filtering Collections` diff --git a/linode_api4/objects/networking.py b/linode_api4/objects/networking.py index 74703fb91..3f1a95234 100644 --- a/linode_api4/objects/networking.py +++ b/linode_api4/objects/networking.py @@ -106,7 +106,9 @@ def interface(self) -> Optional["LinodeInterface"]: """ Returns the Linode Interface associated with this IP address. - NOTE: This function will only work with Linode-level interfaces. + NOTE: This function will only return Linode interfaces, not Config interfaces. + + NOTE: Linode interfaces may not currently be available to all users. :returns: The Linode Interface associated with this IP address. :rtype: LinodeInterface From 6408429f10aa4c507add4b6d49a2f3caf87a3302 Mon Sep 17 00:00:00 2001 From: Lena Garber Date: Wed, 14 May 2025 14:08:00 -0400 Subject: [PATCH 10/11] Fill in API documentation URLs --- linode_api4/groups/networking.py | 6 ++++-- linode_api4/objects/linode.py | 4 ++-- linode_api4/objects/linode_interfaces.py | 4 ++-- linode_api4/objects/networking.py | 4 ++-- 4 files changed, 10 insertions(+), 8 deletions(-) diff --git a/linode_api4/groups/networking.py b/linode_api4/groups/networking.py index 69b4cf673..b16d12d9a 100644 --- a/linode_api4/groups/networking.py +++ b/linode_api4/groups/networking.py @@ -119,7 +119,7 @@ def firewall_templates(self, *filters): """ Returns a list of Firewall Templates available to the current user. - API Documentation: Not yet available. + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-firewall-templates NOTE: This feature may not currently be available to all users. @@ -135,7 +135,9 @@ def firewall_templates(self, *filters): def firewall_settings(self) -> FirewallSettings: """ Returns an object representing the Linode Firewall settings for the current user. - API Documentation: Not yet available. + + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-firewall-settings + NOTE: This feature may not currently be available to all users. :returns: An object representing the Linode Firewall settings for the current user. :rtype: FirewallSettings diff --git a/linode_api4/objects/linode.py b/linode_api4/objects/linode.py index 506dbdec3..64c134638 100644 --- a/linode_api4/objects/linode.py +++ b/linode_api4/objects/linode.py @@ -679,7 +679,7 @@ class UpgradeInterfacesResult(JSONObject): NOTE: If dry_run is True, each returned interface will be of type Dict[str, Any]. Otherwise, each returned interface will be of type LinodeInterface. - API Documentation: Not yet available. + API Documentation: https://techdocs.akamai.com/linode-api/reference/post-upgrade-linode-interfaces """ dry_run: bool = False @@ -2048,7 +2048,7 @@ def upgrade_interfaces( NOTE: Linode interfaces may not currently be available to all users. - API Documentation: Not yet available. + API Documentation: https://techdocs.akamai.com/linode-api/reference/post-upgrade-linode-interfaces :param config: The configuration profile the legacy interfaces to upgrade are under. diff --git a/linode_api4/objects/linode_interfaces.py b/linode_api4/objects/linode_interfaces.py index 20cb6ebfa..f12865c99 100644 --- a/linode_api4/objects/linode_interfaces.py +++ b/linode_api4/objects/linode_interfaces.py @@ -39,7 +39,7 @@ class LinodeInterfacesSettings(Base): """ The settings related to a Linode's network interfaces. - API Documentation: Not yet available. + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-linode-interface-settings NOTE: Linode interfaces may not currently be available to all users. """ @@ -465,7 +465,7 @@ def firewalls(self, *filters) -> List[Firewall]: NOTE: Caching is disabled on this method and each call will make an additional Linode API request. - API Documentation: Not yet available. + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-linode-interface-firewalls """ return self._client._get_and_filter( diff --git a/linode_api4/objects/networking.py b/linode_api4/objects/networking.py index 3f1a95234..ca7758a76 100644 --- a/linode_api4/objects/networking.py +++ b/linode_api4/objects/networking.py @@ -235,7 +235,7 @@ class FirewallSettings(Base): """ Represents the Firewall settings for the current user. - API Documentation: Not yet available. + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-firewall-settings NOTE: This feature may not currently be available to all users. """ @@ -385,7 +385,7 @@ class FirewallTemplate(Base): """ Represents a single Linode Firewall template. - API documentation: Not yet available. + API documentation: https://techdocs.akamai.com/linode-api/reference/get-firewall-template NOTE: This feature may not currently be available to all users. """ From 36cbfcb8552334c3506078fe19d6c074442c5bae Mon Sep 17 00:00:00 2001 From: Lena Garber Date: Wed, 14 May 2025 15:12:09 -0400 Subject: [PATCH 11/11] Add return types --- linode_api4/objects/linode.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/linode_api4/objects/linode.py b/linode_api4/objects/linode.py index 64c134638..6eb389e7f 100644 --- a/linode_api4/objects/linode.py +++ b/linode_api4/objects/linode.py @@ -1951,6 +1951,7 @@ def interface_create( If set, the new instance will be a VPC interface. :returns: The newly created Linode Interface. + :rtype: LinodeInterface """ params = { @@ -1984,6 +1985,7 @@ def interfaces_settings(self) -> LinodeInterfacesSettings: NOTE: Linode interfaces may not currently be available to all users. :returns: The settings for instance-level interface settings for this Linode. + :rtype: LinodeInterfacesSettings """ # NOTE: We do not implement this as a Property because Property does