diff --git a/linode_api4/groups/linode.py b/linode_api4/groups/linode.py index 48f0d43b..d27d8680 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, ): """ @@ -230,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, @@ -293,9 +327,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 +349,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 +367,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 b9cad485..b16d12d9 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 @@ -180,6 +182,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 b13fac51..6667cba8 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 c9a622ed..51a16eae 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 c70dd796..6eb389e7 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, @@ -653,6 +662,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: https://techdocs.akamai.com/linode-api/reference/post-upgrade-linode-interfaces + """ + + 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. @@ -686,6 +722,7 @@ class Instance(Base): "disk_encryption": Property(), "lke_cluster_id": Property(), "capabilities": Property(unordered=True), + "interface_generation": Property(), } @property @@ -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,213 @@ 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. + Linode interfaces are not interchangeable with Config interfaces. + + NOTE: Linode interfaces may not currently be available to all users. + + API Documentation: https://techdocs.akamai.com/linode-api/reference/post-linode-interface + + Example: Creating a simple public interface for this Linode:: + + interface = instance.interface_create( + default_route=LinodeInterfaceDefaultRouteOptions( + ipv4=True, + 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. + 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. + :rtype: LinodeInterface + """ + + 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. + + 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 + # 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, + **kwargs, + ) -> 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. + + NOTE: Linode interfaces may not currently be available to all users. + + API Documentation: https://techdocs.akamai.com/linode-api/reference/post-upgrade-linode-interfaces + + :param config: The configuration profile the legacy interfaces to + 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 + """ + params = {"config_id": config, "dry_run": dry_run} + + params.update(kwargs) + + 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 00000000..f12865c9 --- /dev/null +++ b/linode_api4/objects/linode_interfaces.py @@ -0,0 +1,477 @@ +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. + + NOTE: Linode interfaces may not currently be available to all users. + """ + + 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. + + NOTE: Linode interfaces may not currently be available to all users. + """ + + 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: https://techdocs.akamai.com/linode-api/reference/get-linode-interface-settings + + NOTE: Linode interfaces may not currently be available to all users. + """ + + api_endpoint = "/linode/instances/{id}/interfaces/settings" + + 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. + + NOTE: Linode interfaces may not currently be available to all users. + """ + + 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. + + NOTE: Linode interfaces may not currently be available to all users. + """ + + 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. + + NOTE: Linode interfaces may not currently be available to all users. + """ + + range: str = "" + + +@dataclass +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 + ranges: Optional[List[LinodeInterfaceVPCIPv4RangeOptions]] = None + + +@dataclass +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 + 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. + + NOTE: Linode interfaces may not currently be available to all users. + """ + + address: str = "" + primary: Optional[bool] = None + + +@dataclass +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 + + +@dataclass +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 = "" + + +@dataclass +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 + + +@dataclass +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 + ipv6: Optional[LinodeInterfacePublicIPv6Options] = None + + +@dataclass +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 = "" + ipam_address: Optional[str] = None + + +@dataclass +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 = { + # 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. + + NOTE: Linode interfaces may not currently be available to all users. + """ + + 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. + + NOTE: Linode interfaces may not currently be available to all users. + """ + + 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. + + NOTE: Linode interfaces may not currently be available to all users. + """ + + put_class = LinodeInterfaceVPCIPv4RangeOptions + + range: str = "" + + +@dataclass +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 + + 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. + + NOTE: Linode interfaces may not currently be available to all users. + """ + + 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. + + NOTE: Linode interfaces may not currently be available to all users. + """ + + 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. + + NOTE: Linode interfaces may not currently be available to all users. + """ + + address: str = "" + linode_id: int = 0 + + +@dataclass +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 + + 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. + + NOTE: Linode interfaces may not currently be available to all users. + """ + + address: str = "" + prefix: int = 0 + + +@dataclass +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 = "" + route_target: Optional[str] = None + + +@dataclass +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 + + range: str = "" + route_target: Optional[str] = None + + +@dataclass +class LinodeInterfacePublicIPv6(JSONObject): + """ + The IPv6 configuration of a Linode Interface. + + NOTE: Linode interfaces may not currently be available to all users. + """ + + 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. + + NOTE: Linode interfaces may not currently be available to all users. + """ + + put_class = LinodeInterfacePublicOptions + + ipv4: Optional[LinodeInterfacePublicIPv4] = None + ipv6: Optional[LinodeInterfacePublicIPv6] = None + + +@dataclass +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 + + vlan_label: str = "" + ipam_address: Optional[str] = None + + +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:: + + # 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. + Linode interfaces are not interchangeable with Config interfaces. + + NOTE: Linode interfaces may not currently be available to all users. + + :param filters: Any number of filters to apply to this query. + See :doc:`Filtering Collections` + for more details on filtering. + + :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: https://techdocs.akamai.com/linode-api/reference/get-linode-interface-firewalls + """ + + 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 74a9ab28..ca7758a7 100644 --- a/linode_api4/objects/networking.py +++ b/linode_api4/objects/networking.py @@ -98,9 +98,35 @@ def linode(self): if not hasattr(self, "_linode"): self._set("_linode", Instance(self._client, self.linode_id)) + return self._linode - # 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 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 .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 def to(self, linode): """ @@ -209,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. """ @@ -359,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. """ diff --git a/test/fixtures/linode_instances.json b/test/fixtures/linode_instances.json index 38a3cf91..cefda000 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 00000000..6c059ba4 --- /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 00000000..a0ffddef --- /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 00000000..33382369 --- /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 00000000..17a4a919 --- /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 00000000..7fc4f56f --- /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 00000000..d533b8e2 --- /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 00000000..b454c438 --- /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 00000000..12340c4a --- /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 8c7d44a5..dfa01abe 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 00000000..6a81bb8b --- /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 ade4ca5e..e254218e 100644 --- a/test/integration/models/linode/test_linode.py +++ b/test/integration/models/linode/test_linode.py @@ -16,6 +16,8 @@ ConfigInterfaceIPv4, Disk, Instance, + InterfaceGeneration, + LinodeInterface, Type, ) from linode_api4.objects.linode import InstanceDiskEncryptionType, MigrationType @@ -66,8 +68,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 +80,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 +88,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 +615,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 +822,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 +843,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 +861,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 +886,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 +900,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 +909,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 +943,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 +957,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 +974,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 8112a5d9..7644cfa1 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 00000000..db0232c9 --- /dev/null +++ b/test/unit/objects/linode_interface_test.py @@ -0,0 +1,307 @@ +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_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 6016d277..6b491783 100644 --- a/test/unit/objects/linode_test.py +++ b/test/unit/objects/linode_test.py @@ -1,7 +1,17 @@ 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 import ( + InstanceDiskEncryptionType, + InterfaceGeneration, + NetworkInterface, +) from linode_api4.objects import ( Config, ConfigInterface, @@ -463,6 +473,169 @@ 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) + + assert instance.interface_generation == InterfaceGeneration.LINODE + + 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): """