From d7ddd4aee8f35fdb3c7ef0613f1c6f18fddd5235 Mon Sep 17 00:00:00 2001 From: Lena Garber Date: Wed, 2 Apr 2025 10:36:53 -0400 Subject: [PATCH] Implement support for VPC Dual Stack --- linode_api4/groups/vpc.py | 20 ++- linode_api4/objects/account.py | 4 +- linode_api4/objects/base.py | 26 ++-- linode_api4/objects/linode.py | 131 +++++++++++++---- linode_api4/objects/networking.py | 11 +- linode_api4/objects/serializable.py | 24 +++- linode_api4/objects/vpc.py | 63 ++++++-- .../linode_instances_123_configs.json | 56 +++++--- .../linode_instances_123_configs_456789.json | 136 ++++++++++-------- ...stances_123_configs_456789_interfaces.json | 74 ++++++---- ...ces_123_configs_456789_interfaces_123.json | 40 ++++-- test/fixtures/vpcs.json | 5 + test/fixtures/vpcs_123456.json | 5 + test/fixtures/vpcs_123456_ips.json | 70 +++++---- test/fixtures/vpcs_123456_subnets.json | 5 + test/fixtures/vpcs_123456_subnets_789.json | 5 + test/fixtures/vpcs_ips.json | 10 ++ test/unit/objects/linode_test.py | 119 ++++++++++++++- test/unit/objects/vpc_test.py | 12 ++ 19 files changed, 598 insertions(+), 218 deletions(-) diff --git a/linode_api4/groups/vpc.py b/linode_api4/groups/vpc.py index fa8066cea..eda931292 100644 --- a/linode_api4/groups/vpc.py +++ b/linode_api4/groups/vpc.py @@ -2,8 +2,10 @@ from linode_api4.errors import UnexpectedResponseError from linode_api4.groups import Group -from linode_api4.objects import VPC, Region, VPCIPAddress +from linode_api4.objects import VPC, Region, VPCIPAddress, VPCIPv6RangeOptions +from linode_api4.objects.base import _flatten_request_body_recursive from linode_api4.paginated_list import PaginatedList +from linode_api4.util import drop_null_keys class VPCGroup(Group): @@ -33,6 +35,7 @@ def create( region: Union[Region, str], description: Optional[str] = None, subnets: Optional[List[Dict[str, Any]]] = None, + ipv6: Optional[List[Union[VPCIPv6RangeOptions, Dict[str, Any]]]] = None, **kwargs, ) -> VPC: """ @@ -48,6 +51,8 @@ def create( :type description: Optional[str] :param subnets: A list of subnets to create under this VPC. :type subnets: List[Dict[str, Any]] + :param ipv6: The IPv6 address ranges for this VPC. + :type ipv6: List[Union[VPCIPv6RangeOptions, Dict[str, Any]]] :returns: The new VPC object. :rtype: VPC @@ -55,11 +60,11 @@ def create( params = { "label": label, "region": region.id if isinstance(region, Region) else region, + "description": description, + "ipv6": ipv6, + "subnets": subnets, } - if description is not None: - params["description"] = description - if subnets is not None and len(subnets) > 0: for subnet in subnets: if not isinstance(subnet, dict): @@ -67,11 +72,12 @@ def create( f"Unsupported type for subnet: {type(subnet)}" ) - params["subnets"] = subnets - params.update(kwargs) - result = self.client.post("/vpcs", data=params) + result = self.client.post( + "/vpcs", + data=drop_null_keys(_flatten_request_body_recursive(params)), + ) if not "id" in result: raise UnexpectedResponseError( diff --git a/linode_api4/objects/account.py b/linode_api4/objects/account.py index 375e5fc03..c7318d871 100644 --- a/linode_api4/objects/account.py +++ b/linode_api4/objects/account.py @@ -601,7 +601,7 @@ def entity(self): ) return self.cls(self._client, self.id) - def _serialize(self): + def _serialize(self, *args, **kwargs): """ Returns this grant in as JSON the api will accept. This is only relevant in the context of UserGrants.save @@ -668,7 +668,7 @@ def _grants_dict(self): return grants - def _serialize(self): + def _serialize(self, *args, **kwargs): """ Returns the user grants in as JSON the api will accept. This is only relevant in the context of UserGrants.save diff --git a/linode_api4/objects/base.py b/linode_api4/objects/base.py index 6c9b1bece..c9a622edc 100644 --- a/linode_api4/objects/base.py +++ b/linode_api4/objects/base.py @@ -114,6 +114,9 @@ def _flatten_base_subclass(obj: "Base") -> Optional[Dict[str, Any]]: @property def dict(self): + return self._serialize() + + def _serialize(self, is_put: bool = False) -> Dict[str, Any]: result = vars(self).copy() cls = type(self) @@ -123,7 +126,7 @@ def dict(self): elif isinstance(v, list): result[k] = [ ( - item.dict + item._serialize(is_put=is_put) if isinstance(item, (cls, JSONObject)) else ( self._flatten_base_subclass(item) @@ -136,7 +139,7 @@ def dict(self): elif isinstance(v, Base): result[k] = self._flatten_base_subclass(v) elif isinstance(v, JSONObject): - result[k] = v.dict + result[k] = v._serialize(is_put=is_put) return result @@ -278,9 +281,9 @@ def save(self, force=True) -> bool: data[key] = None # Ensure we serialize any values that may not be already serialized - data = _flatten_request_body_recursive(data) + data = _flatten_request_body_recursive(data, is_put=True) else: - data = self._serialize() + data = self._serialize(is_put=True) resp = self._client.put(type(self).api_endpoint, model=self, data=data) @@ -316,7 +319,7 @@ def invalidate(self): self._set("_populated", False) - def _serialize(self): + def _serialize(self, is_put: bool = False): """ A helper method to build a dict of all mutable Properties of this object @@ -345,7 +348,7 @@ def _serialize(self): # Resolve the underlying IDs of results for k, v in result.items(): - result[k] = _flatten_request_body_recursive(v) + result[k] = _flatten_request_body_recursive(v, is_put=is_put) return result @@ -503,7 +506,7 @@ def make_instance(cls, id, client, parent_id=None, json=None): return Base.make(id, client, cls, parent_id=parent_id, json=json) -def _flatten_request_body_recursive(data: Any) -> Any: +def _flatten_request_body_recursive(data: Any, is_put: bool = False) -> Any: """ This is a helper recursively flatten the given data for use in an API request body. @@ -515,15 +518,18 @@ def _flatten_request_body_recursive(data: Any) -> Any: """ if isinstance(data, dict): - return {k: _flatten_request_body_recursive(v) for k, v in data.items()} + return { + k: _flatten_request_body_recursive(v, is_put=is_put) + for k, v in data.items() + } if isinstance(data, list): - return [_flatten_request_body_recursive(v) for v in data] + return [_flatten_request_body_recursive(v, is_put=is_put) for v in data] if isinstance(data, Base): return data.id if isinstance(data, MappedObject) or issubclass(type(data), JSONObject): - return data.dict + return data._serialize(is_put=is_put) return data diff --git a/linode_api4/objects/linode.py b/linode_api4/objects/linode.py index 46af5d970..08f7c9897 100644 --- a/linode_api4/objects/linode.py +++ b/linode_api4/objects/linode.py @@ -1,6 +1,6 @@ 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 @@ -291,10 +291,83 @@ def _populate(self, json): @dataclass class ConfigInterfaceIPv4(JSONObject): + """ + ConfigInterfaceIPv4 represents the IPv4 configuration of a VPC interface. + """ + vpc: str = "" nat_1_1: str = "" +@dataclass +class ConfigInterfaceIPv6SLAACOptions(JSONObject): + """ + ConfigInterfaceIPv6SLAACOptions is used to set a single IPv6 SLAAC configuration of a VPC interface. + """ + + range: str = "" + + +@dataclass +class ConfigInterfaceIPv6RangeOptions(JSONObject): + """ + ConfigInterfaceIPv6RangeOptions is used to set a single IPv6 range configuration of a VPC interface. + """ + + range: str = "" + + +@dataclass +class ConfigInterfaceIPv6Options(JSONObject): + """ + ConfigInterfaceIPv6Options is used to set the IPv6 configuration of a VPC interface. + """ + + slaac: List[ConfigInterfaceIPv6SLAACOptions] = field( + default_factory=lambda: [] + ) + ranges: List[ConfigInterfaceIPv6RangeOptions] = field( + default_factory=lambda: [] + ) + is_public: bool = False + + +@dataclass +class ConfigInterfaceIPv6SLAAC(JSONObject): + """ + ConfigInterfaceIPv6SLAAC represents a single SLAAC address under a VPC interface's IPv6 configuration. + """ + + put_class = ConfigInterfaceIPv6SLAACOptions + + range: str = "" + address: str = "" + + +@dataclass +class ConfigInterfaceIPv6Range(JSONObject): + """ + ConfigInterfaceIPv6Range represents a single IPv6 address under a VPC interface's IPv6 configuration. + """ + + put_class = ConfigInterfaceIPv6RangeOptions + + range: str = "" + + +@dataclass +class ConfigInterfaceIPv6(JSONObject): + """ + ConfigInterfaceIPv6 represents the IPv6 configuration of a VPC interface. + """ + + put_class = ConfigInterfaceIPv6Options + + slaac: List[ConfigInterfaceIPv6SLAAC] = field(default_factory=lambda: []) + ranges: List[ConfigInterfaceIPv6Range] = field(default_factory=lambda: []) + is_public: bool = False + + class NetworkInterface(DerivedBase): """ This class represents a Configuration Profile's network interface object. @@ -320,6 +393,7 @@ class NetworkInterface(DerivedBase): "vpc_id": Property(id_relationship=VPC), "subnet_id": Property(), "ipv4": Property(mutable=True, json_object=ConfigInterfaceIPv4), + "ipv6": Property(mutable=True, json_object=ConfigInterfaceIPv6), "ip_ranges": Property(mutable=True), } @@ -391,7 +465,10 @@ class ConfigInterface(JSONObject): # VPC-specific vpc_id: Optional[int] = None subnet_id: Optional[int] = None + ipv4: Optional[Union[ConfigInterfaceIPv4, Dict[str, Any]]] = None + ipv6: Optional[Union[ConfigInterfaceIPv6, Dict[str, Any]]] = None + ip_ranges: Optional[List[str]] = None # Computed @@ -400,7 +477,7 @@ class ConfigInterface(JSONObject): def __repr__(self): return f"Interface: {self.purpose}" - def _serialize(self): + def _serialize(self, is_put: bool = False): purpose_formats = { "public": {"purpose": "public", "primary": self.primary}, "vlan": { @@ -412,11 +489,8 @@ def _serialize(self): "purpose": "vpc", "primary": self.primary, "subnet_id": self.subnet_id, - "ipv4": ( - self.ipv4.dict - if isinstance(self.ipv4, ConfigInterfaceIPv4) - else self.ipv4 - ), + "ipv4": self.ipv4, + "ipv6": self.ipv6, "ip_ranges": self.ip_ranges, }, } @@ -426,11 +500,14 @@ def _serialize(self): f"Unknown interface purpose: {self.purpose}", ) - return { - k: v - for k, v in purpose_formats[self.purpose].items() - if v is not None - } + return _flatten_request_body_recursive( + { + k: v + for k, v in purpose_formats[self.purpose].items() + if v is not None + }, + is_put=is_put, + ) class Config(DerivedBase): @@ -510,16 +587,16 @@ def _populate(self, json): self._set("devices", MappedObject(**devices)) - def _serialize(self): + def _serialize(self, *args, **kwargs): """ Overrides _serialize to transform interfaces into json """ - partial = DerivedBase._serialize(self) + partial = DerivedBase._serialize(self, *args, **kwargs) interfaces = [] for c in self.interfaces: if isinstance(c, ConfigInterface): - interfaces.append(c._serialize()) + interfaces.append(c._serialize(*args, **kwargs)) else: interfaces.append(c) @@ -571,6 +648,7 @@ def interface_create_vpc( subnet: Union[int, VPCSubnet], primary=False, ipv4: Union[Dict[str, Any], ConfigInterfaceIPv4] = None, + ipv6: Union[Dict[str, Any], ConfigInterfaceIPv6Options] = None, ip_ranges: Optional[List[str]] = None, ) -> NetworkInterface: """ @@ -584,6 +662,8 @@ def interface_create_vpc( :type primary: bool :param ipv4: The IPv4 configuration of the interface for the associated subnet. :type ipv4: Dict or ConfigInterfaceIPv4 + :param ipv6: The IPv6 configuration of the interface for the associated subnet. + :type ipv6: Dict or ConfigInterfaceIPv6Options :param ip_ranges: A list of IPs or IP ranges in the VPC subnet. Packets to these CIDRs are routed through the VPC network interface. @@ -594,19 +674,16 @@ def interface_create_vpc( """ params = { "purpose": "vpc", - "subnet_id": subnet.id if isinstance(subnet, VPCSubnet) else subnet, + "subnet_id": subnet, "primary": primary, + "ipv4": ipv4, + "ipv6": ipv6, + "ip_ranges": ip_ranges, } - if ipv4 is not None: - params["ipv4"] = ( - ipv4.dict if isinstance(ipv4, ConfigInterfaceIPv4) else ipv4 - ) - - if ip_ranges is not None: - params["ip_ranges"] = ip_ranges - - return self._interface_create(params) + return self._interface_create( + drop_null_keys(_flatten_request_body_recursive(params)) + ) def interface_reorder(self, interfaces: List[Union[int, NetworkInterface]]): """ @@ -1927,8 +2004,8 @@ def _populate(self, json): ndist = [Image(self._client, d) for d in self.images] self._set("images", ndist) - def _serialize(self): - dct = Base._serialize(self) + def _serialize(self, *args, **kwargs): + dct = Base._serialize(self, *args, **kwargs) dct["images"] = [d.id for d in self.images] return dct diff --git a/linode_api4/objects/networking.py b/linode_api4/objects/networking.py index b7a16ae90..17b31e230 100644 --- a/linode_api4/objects/networking.py +++ b/linode_api4/objects/networking.py @@ -1,5 +1,5 @@ from dataclasses import dataclass -from typing import Optional +from typing import List, Optional from linode_api4.common import Price, RegionPrice from linode_api4.errors import UnexpectedResponseError @@ -127,6 +127,11 @@ def delete(self): return True +@dataclass +class VPCIPAddressIPv6(JSONObject): + slaac_address: str = "" + + @dataclass class VPCIPAddress(JSONObject): """ @@ -152,6 +157,10 @@ class VPCIPAddress(JSONObject): address_range: Optional[str] = None nat_1_1: Optional[str] = None + ipv6_range: Optional[str] = None + ipv6_is_public: Optional[bool] = None + ipv6_addresses: Optional[List[VPCIPAddressIPv6]] = None + class VLAN(Base): """ diff --git a/linode_api4/objects/serializable.py b/linode_api4/objects/serializable.py index fea682f43..e33179a60 100644 --- a/linode_api4/objects/serializable.py +++ b/linode_api4/objects/serializable.py @@ -1,5 +1,5 @@ import inspect -from dataclasses import dataclass +from dataclasses import dataclass, fields from enum import Enum from types import SimpleNamespace from typing import ( @@ -9,6 +9,7 @@ List, Optional, Set, + Type, Union, get_args, get_origin, @@ -71,6 +72,13 @@ class JSONObject(metaclass=JSONFilterableMetaclass): are None. """ + put_class: ClassVar[Optional[Type["JSONObject"]]] = None + """ + An alternative JSONObject class to use as the schema for PUT requests. + This prevents read-only fields from being included in PUT request bodies, + which in theory will result in validation errors from the API. + """ + def __init__(self): raise NotImplementedError( "JSONObject is not intended to be constructed directly" @@ -154,11 +162,17 @@ def from_json(cls, json: Dict[str, Any]) -> Optional["JSONObject"]: return obj - def _serialize(self) -> Dict[str, Any]: + def _serialize(self, is_put: bool = False) -> Dict[str, Any]: """ Serializes this object into a JSON dict. """ cls = type(self) + + if is_put and cls.put_class is not None: + cls = cls.put_class + + cls_field_keys = {field.name for field in fields(cls)} + type_hints = get_type_hints(cls) def attempt_serialize(value: Any) -> Any: @@ -166,7 +180,7 @@ def attempt_serialize(value: Any) -> Any: Attempts to serialize the given value, else returns the value unchanged. """ if issubclass(type(value), JSONObject): - return value._serialize() + return value._serialize(is_put=is_put) return value @@ -175,6 +189,10 @@ def should_include(key: str, value: Any) -> bool: Returns whether the given key/value pair should be included in the resulting dict. """ + # During PUT operations, keys not present in the put_class should be excluded + if key not in cls_field_keys: + return False + if cls.include_none_values or key in cls.always_include: return True diff --git a/linode_api4/objects/vpc.py b/linode_api4/objects/vpc.py index 3c9a4aaba..5b4850453 100644 --- a/linode_api4/objects/vpc.py +++ b/linode_api4/objects/vpc.py @@ -1,11 +1,54 @@ from dataclasses import dataclass -from typing import List, Optional +from typing import Any, Dict, List, Optional, Union from linode_api4.errors import UnexpectedResponseError from linode_api4.objects import Base, DerivedBase, Property, Region +from linode_api4.objects.base import _flatten_request_body_recursive from linode_api4.objects.networking import VPCIPAddress from linode_api4.objects.serializable import JSONObject from linode_api4.paginated_list import PaginatedList +from linode_api4.util import drop_null_keys + + +@dataclass +class VPCIPv6RangeOptions(JSONObject): + """ + VPCIPv6RangeOptions is used to specify an IPv6 range when creating or updating a VPC. + """ + + range: str = "" + allocation_class: Optional[str] = None + + +@dataclass +class VPCIPv6Range(JSONObject): + """ + VPCIPv6Range represents a single VPC IPv6 range. + """ + + put_class = VPCIPv6RangeOptions + + range: str = "" + + +@dataclass +class VPCSubnetIPv6RangeOptions(JSONObject): + """ + VPCSubnetIPv6RangeOptions is used to specify an IPv6 range when creating or updating a VPC subnet. + """ + + range: str = "" + + +@dataclass +class VPCSubnetIPv6Range(JSONObject): + """ + VPCSubnetIPv6Range represents a single VPC subnet IPv6 range. + """ + + put_class = VPCSubnetIPv6RangeOptions + + range: str = "" @dataclass @@ -35,6 +78,7 @@ class VPCSubnet(DerivedBase): "id": Property(identifier=True), "label": Property(mutable=True), "ipv4": Property(), + "ipv6": Property(json_object=VPCSubnetIPv6Range, unordered=True), "linodes": Property(json_object=VPCSubnetLinode, unordered=True), "created": Property(is_datetime=True), "updated": Property(is_datetime=True), @@ -55,6 +99,7 @@ class VPC(Base): "label": Property(mutable=True), "description": Property(mutable=True), "region": Property(slug_relationship=Region), + "ipv6": Property(json_object=VPCIPv6Range, unordered=True), "subnets": Property(derived_class=VPCSubnet), "created": Property(is_datetime=True), "updated": Property(is_datetime=True), @@ -64,6 +109,9 @@ def subnet_create( self, label: str, ipv4: Optional[str] = None, + ipv6: Optional[ + List[Union[VPCSubnetIPv6RangeOptions, Dict[str, Any]]] + ] = None, **kwargs, ) -> VPCSubnet: """ @@ -76,19 +124,16 @@ def subnet_create( :param ipv4: The IPv4 range of this subnet in CIDR format. :type ipv4: str :param ipv6: The IPv6 range of this subnet in CIDR format. - :type ipv6: str + :type ipv6: List[Union[VPCSubnetIPv6RangeOptions, Dict[str, Any]]] """ - params = { - "label": label, - } - - if ipv4 is not None: - params["ipv4"] = ipv4 + params = {"label": label, "ipv4": ipv4, "ipv6": ipv6} params.update(kwargs) result = self._client.post( - "{}/subnets".format(VPC.api_endpoint), model=self, data=params + "{}/subnets".format(VPC.api_endpoint), + model=self, + data=drop_null_keys(_flatten_request_body_recursive(params)), ) self.invalidate() diff --git a/test/fixtures/linode_instances_123_configs.json b/test/fixtures/linode_instances_123_configs.json index 581b84caa..082f8eefd 100644 --- a/test/fixtures/linode_instances_123_configs.json +++ b/test/fixtures/linode_instances_123_configs.json @@ -16,31 +16,45 @@ "id": 456789, "interfaces": [ { - "id": 456, - "purpose": "public", - "primary": true + "id": 456, + "purpose": "public", + "primary": true }, { - "id": 123, - "purpose": "vpc", - "primary": true, - "active": true, - "vpc_id": 123456, - "subnet_id": 789, - "ipv4": { - "vpc": "10.0.0.2", - "nat_1_1": "any" - }, - "ip_ranges": [ - "10.0.0.0/24" - ] + "id": 123, + "purpose": "vpc", + "primary": true, + "active": true, + "vpc_id": 123456, + "subnet_id": 789, + "ipv4": { + "vpc": "10.0.0.2", + "nat_1_1": "any" + }, + "ipv6": { + "slaac": [ + { + "range": "1234::5678/64", + "address": "1234::5678" + } + ], + "ranges": [ + { + "range": "1234::5678/64" + } + ], + "is_public": true + }, + "ip_ranges": [ + "10.0.0.0/24" + ] }, { - "id": 321, - "primary": false, - "ipam_address":"10.0.0.2", - "label":"test-interface", - "purpose":"vlan" + "id": 321, + "primary": false, + "ipam_address": "10.0.0.2", + "label": "test-interface", + "purpose": "vlan" } ], "run_level": "default", diff --git a/test/fixtures/linode_instances_123_configs_456789.json b/test/fixtures/linode_instances_123_configs_456789.json index 93e41f86b..8f4387af9 100644 --- a/test/fixtures/linode_instances_123_configs_456789.json +++ b/test/fixtures/linode_instances_123_configs_456789.json @@ -1,65 +1,79 @@ { - "root_device":"/dev/sda", - "comments":"", - "helpers":{ - "updatedb_disabled":true, - "modules_dep":true, - "devtmpfs_automount":true, - "distro":true, - "network":false - }, - "label":"My Ubuntu 17.04 LTS Profile", - "created":"2014-10-07T20:04:00", - "memory_limit":0, - "id":456789, - "interfaces": [ - { - "id": 456, - "purpose": "public", - "primary": true + "root_device": "/dev/sda", + "comments": "", + "helpers": { + "updatedb_disabled": true, + "modules_dep": true, + "devtmpfs_automount": true, + "distro": true, + "network": false + }, + "label": "My Ubuntu 17.04 LTS Profile", + "created": "2014-10-07T20:04:00", + "memory_limit": 0, + "id": 456789, + "interfaces": [ + { + "id": 456, + "purpose": "public", + "primary": true + }, + { + "id": 123, + "purpose": "vpc", + "primary": true, + "active": true, + "vpc_id": 123456, + "subnet_id": 789, + "ipv4": { + "vpc": "10.0.0.2", + "nat_1_1": "any" }, - { - "id": 123, - "purpose": "vpc", - "primary": true, - "active": true, - "vpc_id": 123456, - "subnet_id": 789, - "ipv4": { - "vpc": "10.0.0.2", - "nat_1_1": "any" - }, - "ip_ranges": [ - "10.0.0.0/24" - ] + "ipv6": { + "slaac": [ + { + "range": "1234::5678/64", + "address": "1234::5678" + } + ], + "ranges": [ + { + "range": "1234::5678/64" + } + ], + "is_public": true }, - { - "id": 321, - "primary": false, - "ipam_address":"10.0.0.2", - "label":"test-interface", - "purpose":"vlan" - } - ], - "run_level":"default", - "initrd":null, - "virt_mode":"paravirt", - "kernel":"linode/latest-64bit", - "updated":"2014-10-07T20:04:00", - "devices":{ - "sda":{ - "disk_id":12345, - "volume_id":null - }, - "sdc":null, - "sde":null, - "sdh":null, - "sdg":null, - "sdb":{ - "disk_id":12346, - "volume_id":null - }, - "sdf":null, - "sdd":null - } + "ip_ranges": [ + "10.0.0.0/24" + ] + }, + { + "id": 321, + "primary": false, + "ipam_address": "10.0.0.2", + "label": "test-interface", + "purpose": "vlan" + } + ], + "run_level": "default", + "initrd": null, + "virt_mode": "paravirt", + "kernel": "linode/latest-64bit", + "updated": "2014-10-07T20:04:00", + "devices": { + "sda": { + "disk_id": 12345, + "volume_id": null + }, + "sdc": null, + "sde": null, + "sdh": null, + "sdg": null, + "sdb": { + "disk_id": 12346, + "volume_id": null + }, + "sdf": null, + "sdd": null + } } \ No newline at end of file diff --git a/test/fixtures/linode_instances_123_configs_456789_interfaces.json b/test/fixtures/linode_instances_123_configs_456789_interfaces.json index 86c709071..120551365 100644 --- a/test/fixtures/linode_instances_123_configs_456789_interfaces.json +++ b/test/fixtures/linode_instances_123_configs_456789_interfaces.json @@ -1,34 +1,48 @@ { - "data": [ - { - "id": 456, - "purpose": "public", - "primary": true + "data": [ + { + "id": 456, + "purpose": "public", + "primary": true + }, + { + "id": 123, + "purpose": "vpc", + "primary": true, + "active": true, + "vpc_id": 123456, + "subnet_id": 789, + "ipv4": { + "vpc": "10.0.0.2", + "nat_1_1": "any" }, - { - "id": 123, - "purpose": "vpc", - "primary": true, - "active": true, - "vpc_id": 123456, - "subnet_id": 789, - "ipv4": { - "vpc": "10.0.0.2", - "nat_1_1": "any" - }, - "ip_ranges": [ - "10.0.0.0/24" - ] + "ipv6": { + "slaac": [ + { + "range": "1234::5678/64", + "address": "1234::5678" + } + ], + "ranges": [ + { + "range": "1234::5678/64" + } + ], + "is_public": true }, - { - "id": 321, - "primary": false, - "ipam_address":"10.0.0.2", - "label":"test-interface", - "purpose":"vlan" - } - ], - "page": 1, - "pages": 1, - "results": 1 + "ip_ranges": [ + "10.0.0.0/24" + ] + }, + { + "id": 321, + "primary": false, + "ipam_address": "10.0.0.2", + "label": "test-interface", + "purpose": "vlan" + } + ], + "page": 1, + "pages": 1, + "results": 1 } \ No newline at end of file diff --git a/test/fixtures/linode_instances_123_configs_456789_interfaces_123.json b/test/fixtures/linode_instances_123_configs_456789_interfaces_123.json index d02673aeb..c120905b2 100644 --- a/test/fixtures/linode_instances_123_configs_456789_interfaces_123.json +++ b/test/fixtures/linode_instances_123_configs_456789_interfaces_123.json @@ -1,15 +1,29 @@ { - "id": 123, - "purpose": "vpc", - "primary": true, - "active": true, - "vpc_id": 123456, - "subnet_id": 789, - "ipv4": { - "vpc": "10.0.0.2", - "nat_1_1": "any" - }, - "ip_ranges": [ - "10.0.0.0/24" - ] + "id": 123, + "purpose": "vpc", + "primary": true, + "active": true, + "vpc_id": 123456, + "subnet_id": 789, + "ipv4": { + "vpc": "10.0.0.2", + "nat_1_1": "any" + }, + "ipv6": { + "slaac": [ + { + "range": "1234::5678/64", + "address": "1234::5678" + } + ], + "ranges": [ + { + "range": "1234::5678/64" + } + ], + "is_public": true + }, + "ip_ranges": [ + "10.0.0.0/24" + ] } \ No newline at end of file diff --git a/test/fixtures/vpcs.json b/test/fixtures/vpcs.json index 9a7cc5038..822f3bae1 100644 --- a/test/fixtures/vpcs.json +++ b/test/fixtures/vpcs.json @@ -5,6 +5,11 @@ "id": 123456, "description": "A very real VPC.", "region": "us-southeast", + "ipv6": [ + { + "range": "fd71:1140:a9d0::/52" + } + ], "created": "2018-01-01T00:01:01", "updated": "2018-01-01T00:01:01" } diff --git a/test/fixtures/vpcs_123456.json b/test/fixtures/vpcs_123456.json index e4c16437a..af6d2cff8 100644 --- a/test/fixtures/vpcs_123456.json +++ b/test/fixtures/vpcs_123456.json @@ -3,6 +3,11 @@ "id": 123456, "description": "A very real VPC.", "region": "us-southeast", + "ipv6": [ + { + "range": "fd71:1140:a9d0::/52" + } + ], "created": "2018-01-01T00:01:01", "updated": "2018-01-01T00:01:01" } \ No newline at end of file diff --git a/test/fixtures/vpcs_123456_ips.json b/test/fixtures/vpcs_123456_ips.json index 70b4b8a60..10cb94f3c 100644 --- a/test/fixtures/vpcs_123456_ips.json +++ b/test/fixtures/vpcs_123456_ips.json @@ -1,34 +1,44 @@ { - "data": [ + "data": [ + { + "address": "10.0.0.2", + "address_range": null, + "vpc_id": 123456, + "subnet_id": 654321, + "region": "us-ord", + "linode_id": 111, + "config_id": 222, + "interface_id": 333, + "active": true, + "nat_1_1": null, + "gateway": "10.0.0.1", + "prefix": 8, + "subnet_mask": "255.0.0.0" + }, + { + "address": "10.0.0.3", + "address_range": null, + "vpc_id": 41220, + "subnet_id": 41184, + "region": "us-ord", + "linode_id": 56323949, + "config_id": 59467106, + "interface_id": 1248358, + "active": true, + "nat_1_1": null, + "gateway": "10.0.0.1", + "prefix": 8, + "subnet_mask": "255.0.0.0" + }, + { + "ipv6_range": "fd71:1140:a9d0::/52", + "ipv6_is_public": true, + "ipv6_addresses": [ { - "address": "10.0.0.2", - "address_range": null, - "vpc_id": 123456, - "subnet_id": 654321, - "region": "us-ord", - "linode_id": 111, - "config_id": 222, - "interface_id": 333, - "active": true, - "nat_1_1": null, - "gateway": "10.0.0.1", - "prefix": 8, - "subnet_mask": "255.0.0.0" - }, - { - "address": "10.0.0.3", - "address_range": null, - "vpc_id": 41220, - "subnet_id": 41184, - "region": "us-ord", - "linode_id": 56323949, - "config_id": 59467106, - "interface_id": 1248358, - "active": true, - "nat_1_1": null, - "gateway": "10.0.0.1", - "prefix": 8, - "subnet_mask": "255.0.0.0" + "slaac_address": "fd71:1140:a9d0::/52" } - ] + ], + "vpc_id": 123456 + } + ] } diff --git a/test/fixtures/vpcs_123456_subnets.json b/test/fixtures/vpcs_123456_subnets.json index f846399df..3eb4ce018 100644 --- a/test/fixtures/vpcs_123456_subnets.json +++ b/test/fixtures/vpcs_123456_subnets.json @@ -4,6 +4,11 @@ "label": "test-subnet", "id": 789, "ipv4": "10.0.0.0/24", + "ipv6": [ + { + "range": "fd71:1140:a9d0::/52" + } + ], "linodes": [ { "id": 12345, diff --git a/test/fixtures/vpcs_123456_subnets_789.json b/test/fixtures/vpcs_123456_subnets_789.json index ba6973472..65d970e56 100644 --- a/test/fixtures/vpcs_123456_subnets_789.json +++ b/test/fixtures/vpcs_123456_subnets_789.json @@ -2,6 +2,11 @@ "label": "test-subnet", "id": 789, "ipv4": "10.0.0.0/24", + "ipv6": [ + { + "range": "fd71:1140:a9d0::/52" + } + ], "linodes": [ { "id": 12345, diff --git a/test/fixtures/vpcs_ips.json b/test/fixtures/vpcs_ips.json index d6f16c2e9..7849f5d76 100644 --- a/test/fixtures/vpcs_ips.json +++ b/test/fixtures/vpcs_ips.json @@ -14,6 +14,16 @@ "gateway": "10.0.0.1", "prefix": 24, "subnet_mask": "255.255.255.0" + }, + { + "ipv6_range": "fd71:1140:a9d0::/52", + "ipv6_is_public": true, + "ipv6_addresses": [ + { + "slaac_address": "fd71:1140:a9d0::/52" + } + ], + "vpc_id": 123456 } ], "page": 1, diff --git a/test/unit/objects/linode_test.py b/test/unit/objects/linode_test.py index 6016d2776..68242736e 100644 --- a/test/unit/objects/linode_test.py +++ b/test/unit/objects/linode_test.py @@ -1,11 +1,21 @@ from datetime import datetime from test.unit.base import ClientBaseCase -from linode_api4 import InstanceDiskEncryptionType, NetworkInterface +from linode_api4 import ( + ConfigInterfaceIPv6SLAAC, + InstanceDiskEncryptionType, + NetworkInterface, +) from linode_api4.objects import ( Config, ConfigInterface, ConfigInterfaceIPv4, + ConfigInterfaceIPv6, + ConfigInterfaceIPv6Options, + ConfigInterfaceIPv6Range, + ConfigInterfaceIPv6RangeOptions, + ConfigInterfaceIPv6SLAAC, + ConfigInterfaceIPv6SLAACOptions, Disk, Image, Instance, @@ -503,15 +513,62 @@ def test_update_interfaces(self): new_interfaces = [ {"purpose": "public", "primary": True}, ConfigInterface("vlan", label="cool-vlan"), + ConfigInterface( + "vpc", + vpc_id=18881, + subnet_id=123, + ipv4=ConfigInterfaceIPv4(vpc="10.0.0.4", nat_1_1="any"), + ipv6=ConfigInterfaceIPv6( + slaac=[ + ConfigInterfaceIPv6SLAAC( + range="1234::5678/64", address="1234::5678" + ) + ], + ranges=[ + ConfigInterfaceIPv6Range(range="1234::5678/64") + ], + is_public=True, + ), + ), ] - expected_body = [new_interfaces[0], new_interfaces[1]._serialize()] config.interfaces = new_interfaces config.save() - self.assertEqual(m.call_url, "/linode/instances/123/configs/456789") - self.assertEqual(m.call_data.get("interfaces"), expected_body) + assert m.call_url == "/linode/instances/123/configs/456789" + assert m.call_data.get("interfaces") == [ + { + "purpose": "public", + "primary": True, + }, + { + "purpose": "vlan", + "label": "cool-vlan", + }, + { + "purpose": "vpc", + "subnet_id": 123, + "ipv4": { + "vpc": "10.0.0.4", + "nat_1_1": "any", + }, + "ipv6": { + "slaac": [ + { + "range": "1234::5678/64", + # NOTE: Address is read-only so it shouldn't be specified here + } + ], + "ranges": [ + { + "range": "1234::5678/64", + } + ], + "is_public": True, + }, + }, + ] def test_get_config(self): json = self.client.get("/linode/instances/123/configs/456789") @@ -541,6 +598,24 @@ def test_interface_ipv4(self): self.assertEqual(ipv4.vpc, "10.0.0.1") self.assertEqual(ipv4.nat_1_1, "any") + def test_interface_ipv6(self): + json = { + "slaac": [{"range": "1234::5678/64", "address": "1234::5678"}], + "ranges": [{"range": "1234::5678/64"}], + "is_public": True, + } + + ipv6 = ConfigInterfaceIPv6.from_json(json) + + assert len(ipv6.slaac) == 1 + assert ipv6.slaac[0].range == "1234::5678/64" + assert ipv6.slaac[0].address == "1234::5678" + + assert len(ipv6.ranges) == 1 + assert ipv6.ranges[0].range == "1234::5678/64" + + assert ipv6.is_public + def test_config_devices_unwrap(self): """ Tests that config devices can be successfully converted to a dict. @@ -744,6 +819,11 @@ def test_create_interface_vpc(self): subnet=VPCSubnet(self.client, 789, 123456), primary=True, ipv4=ConfigInterfaceIPv4(vpc="10.0.0.4", nat_1_1="any"), + ipv6=ConfigInterfaceIPv6Options( + slaac=[ConfigInterfaceIPv6SLAACOptions(range="auto")], + ranges=[ConfigInterfaceIPv6RangeOptions(range="auto")], + is_public=True, + ), ip_ranges=["10.0.0.0/24"], ) @@ -757,6 +837,11 @@ def test_create_interface_vpc(self): "primary": True, "subnet_id": 789, "ipv4": {"vpc": "10.0.0.4", "nat_1_1": "any"}, + "ipv6": { + "slaac": [{"range": "auto"}], + "ranges": [{"range": "auto"}], + "is_public": True, + }, "ip_ranges": ["10.0.0.0/24"], } @@ -765,8 +850,19 @@ def test_create_interface_vpc(self): assert interface.primary assert interface.vpc.id == 123456 assert interface.subnet.id == 789 + assert interface.ipv4.vpc == "10.0.0.2" assert interface.ipv4.nat_1_1 == "any" + + assert len(interface.ipv6.slaac) == 1 + assert interface.ipv6.slaac[0].range == "1234::5678/64" + assert interface.ipv6.slaac[0].address == "1234::5678" + + assert len(interface.ipv6.ranges) == 1 + assert interface.ipv6.ranges[0].range == "1234::5678/64" + + assert interface.ipv6.is_public + assert interface.ip_ranges == ["10.0.0.0/24"] def test_update(self): @@ -774,6 +870,7 @@ def test_update(self): interface._api_get() interface.ipv4.vpc = "10.0.0.3" + interface.ipv6.is_public = False interface.primary = False interface.ip_ranges = ["10.0.0.2/32"] @@ -791,6 +888,11 @@ def test_update(self): assert m.call_data == { "primary": False, "ipv4": {"vpc": "10.0.0.3", "nat_1_1": "any"}, + "ipv6": { + "slaac": [{"range": "1234::5678/64"}], + "ranges": [{"range": "1234::5678/64"}], + "is_public": False, + }, "ip_ranges": ["10.0.0.2/32"], } @@ -811,8 +913,17 @@ def test_get_vpc(self): self.assertEqual(interface.purpose, "vpc") self.assertEqual(interface.vpc.id, 123456) self.assertEqual(interface.subnet.id, 789) + self.assertEqual(interface.ipv4.vpc, "10.0.0.2") self.assertEqual(interface.ipv4.nat_1_1, "any") + + self.assertEqual(len(interface.ipv6.slaac), 1) + self.assertEqual(interface.ipv6.slaac[0].range, "1234::5678/64") + self.assertEqual(interface.ipv6.slaac[0].address, "1234::5678") + self.assertEqual(len(interface.ipv6.ranges), 1) + self.assertEqual(interface.ipv6.ranges[0].range, "1234::5678/64") + self.assertEqual(interface.ipv6.is_public, True) + self.assertEqual(interface.ip_ranges, ["10.0.0.0/24"]) self.assertEqual(interface.active, True) diff --git a/test/unit/objects/vpc_test.py b/test/unit/objects/vpc_test.py index 5e7be1b69..c88cff08a 100644 --- a/test/unit/objects/vpc_test.py +++ b/test/unit/objects/vpc_test.py @@ -113,6 +113,8 @@ def validate_vpc_123456(self, vpc: VPC): self.assertEqual(vpc.created, expected_dt) self.assertEqual(vpc.updated, expected_dt) + self.assertEqual(vpc.ipv6[0].range, "fd71:1140:a9d0::/52") + def validate_vpc_subnet_789(self, subnet: VPCSubnet): expected_dt = datetime.datetime.strptime( "2018-01-01T00:01:01", DATE_FORMAT @@ -124,6 +126,8 @@ def validate_vpc_subnet_789(self, subnet: VPCSubnet): self.assertEqual(subnet.created, expected_dt) self.assertEqual(subnet.updated, expected_dt) + self.assertEqual(subnet.ipv6[0].range, "fd71:1140:a9d0::/52") + def test_list_vpc_ips(self): """ Test that the ips under a specific VPC can be listed. @@ -148,3 +152,11 @@ def test_list_vpc_ips(self): self.assertEqual(vpc_ip.gateway, "10.0.0.1") self.assertEqual(vpc_ip.prefix, 8) self.assertEqual(vpc_ip.subnet_mask, "255.0.0.0") + + vpc_ip_2 = vpc_ips[2] + + self.assertEqual(vpc_ip_2.ipv6_range, "fd71:1140:a9d0::/52") + self.assertEqual(vpc_ip_2.ipv6_is_public, True) + self.assertEqual( + vpc_ip_2.ipv6_addresses[0].slaac_address, "fd71:1140:a9d0::/52" + )