From 7e420db4009cf6807370ae217995b147d92d62f7 Mon Sep 17 00:00:00 2001 From: Lena Garber <114949949+lgarber-akamai@users.noreply.github.com> Date: Tue, 7 Oct 2025 09:10:47 -0400 Subject: [PATCH 1/3] project: DBaaS/VPC Integration (#608) * Added support for VPC DBaaS Integration (#560) * Implemented support for VPC DBaaS Integration * Added unit tests * Add support for VPCSubnet.databases field (#607) * Add support for VPCSubnet.databases field * ipv6_range -> ipv6_ranges * ipv6_range -> ipv6_ranges (list) --------- Co-authored-by: Erik Zilber --- linode_api4/groups/database.py | 9 ++++ linode_api4/objects/database.py | 21 +++++++++ linode_api4/objects/vpc.py | 8 ++++ test/fixtures/databases_instances.json | 7 ++- test/fixtures/databases_mysql_instances.json | 5 ++ .../databases_postgresql_instances.json | 5 ++ test/fixtures/vpcs_123456_subnets.json | 9 ++++ test/fixtures/vpcs_123456_subnets_789.json | 9 ++++ test/unit/groups/database_test.py | 11 +++++ test/unit/objects/database_test.py | 46 +++++++++++++++++++ test/unit/objects/vpc_test.py | 4 ++ 11 files changed, 133 insertions(+), 1 deletion(-) diff --git a/linode_api4/groups/database.py b/linode_api4/groups/database.py index 9de02ac35..9546100a8 100644 --- a/linode_api4/groups/database.py +++ b/linode_api4/groups/database.py @@ -9,6 +9,7 @@ from linode_api4.objects import ( Database, DatabaseEngine, + DatabasePrivateNetwork, DatabaseType, MySQLDatabase, PostgreSQLDatabase, @@ -126,6 +127,7 @@ def mysql_create( engine, ltype, engine_config: Union[MySQLDatabaseConfigOptions, Dict[str, Any]] = None, + private_network: Union[DatabasePrivateNetwork, Dict[str, Any]] = None, **kwargs, ): """ @@ -159,6 +161,8 @@ def mysql_create( :type ltype: str or Type :param engine_config: The configuration options for this MySQL cluster :type engine_config: Dict[str, Any] or MySQLDatabaseConfigOptions + :param private_network: The private network settings to use for this cluster + :type private_network: Dict[str, Any] or DatabasePrivateNetwork """ params = { @@ -167,6 +171,7 @@ def mysql_create( "engine": engine, "type": ltype, "engine_config": engine_config, + "private_network": private_network, } params.update(kwargs) @@ -262,6 +267,7 @@ def postgresql_create( engine_config: Union[ PostgreSQLDatabaseConfigOptions, Dict[str, Any] ] = None, + private_network: Union[DatabasePrivateNetwork, Dict[str, Any]] = None, **kwargs, ): """ @@ -295,6 +301,8 @@ def postgresql_create( :type ltype: str or Type :param engine_config: The configuration options for this PostgreSQL cluster :type engine_config: Dict[str, Any] or PostgreSQLDatabaseConfigOptions + :param private_network: The private network settings to use for this cluster + :type private_network: Dict[str, Any] or DatabasePrivateNetwork """ params = { @@ -303,6 +311,7 @@ def postgresql_create( "engine": engine, "type": ltype, "engine_config": engine_config, + "private_network": private_network, } params.update(kwargs) diff --git a/linode_api4/objects/database.py b/linode_api4/objects/database.py index 39249bbf9..979990e8e 100644 --- a/linode_api4/objects/database.py +++ b/linode_api4/objects/database.py @@ -74,6 +74,18 @@ def invalidate(self): Base.invalidate(self) +@dataclass +class DatabasePrivateNetwork(JSONObject): + """ + DatabasePrivateNetwork is used to specify + a Database Cluster's private network settings during its creation. + """ + + vpc_id: Optional[int] = None + subnet_id: Optional[int] = None + public_access: Optional[bool] = None + + @deprecated( reason="Backups are not supported for non-legacy database clusters." ) @@ -304,6 +316,9 @@ class MySQLDatabase(Base): "engine_config": Property( mutable=True, json_object=MySQLDatabaseConfigOptions ), + "private_network": Property( + mutable=True, json_object=DatabasePrivateNetwork, nullable=True + ), } @property @@ -470,6 +485,9 @@ class PostgreSQLDatabase(Base): "engine_config": Property( mutable=True, json_object=PostgreSQLDatabaseConfigOptions ), + "private_network": Property( + mutable=True, json_object=DatabasePrivateNetwork, nullable=True + ), } @property @@ -636,6 +654,9 @@ class Database(Base): "updated": Property(), "updates": Property(), "version": Property(), + "private_network": Property( + json_object=DatabasePrivateNetwork, nullable=True + ), } @property diff --git a/linode_api4/objects/vpc.py b/linode_api4/objects/vpc.py index 94c0302f0..52fdacbce 100644 --- a/linode_api4/objects/vpc.py +++ b/linode_api4/objects/vpc.py @@ -21,6 +21,13 @@ class VPCSubnetLinode(JSONObject): interfaces: Optional[List[VPCSubnetLinodeInterface]] = None +@dataclass +class VPCSubnetDatabase(JSONObject): + id: int = 0 + ipv4_range: Optional[str] = None + ipv6_ranges: Optional[List[str]] = None + + class VPCSubnet(DerivedBase): """ An instance of a VPC subnet. @@ -37,6 +44,7 @@ class VPCSubnet(DerivedBase): "label": Property(mutable=True), "ipv4": Property(), "linodes": Property(json_object=VPCSubnetLinode, unordered=True), + "databases": Property(json_object=VPCSubnetDatabase, unordered=True), "created": Property(is_datetime=True), "updated": Property(is_datetime=True), } diff --git a/test/fixtures/databases_instances.json b/test/fixtures/databases_instances.json index 3b3f4d602..5e92515a5 100644 --- a/test/fixtures/databases_instances.json +++ b/test/fixtures/databases_instances.json @@ -27,7 +27,12 @@ "hour_of_day": 0, "week_of_month": null }, - "version": "8.0.26" + "version": "8.0.26", + "private_network": { + "vpc_id": 1234, + "subnet_id": 5678, + "public_access": true + } } ], "page": 1, diff --git a/test/fixtures/databases_mysql_instances.json b/test/fixtures/databases_mysql_instances.json index d6e3f2e64..e60bfe019 100644 --- a/test/fixtures/databases_mysql_instances.json +++ b/test/fixtures/databases_mysql_instances.json @@ -61,6 +61,11 @@ "tmp_table_size": 16777216, "wait_timeout": 28800 } + }, + "private_network": { + "vpc_id": 1234, + "subnet_id": 5678, + "public_access": true } } ], diff --git a/test/fixtures/databases_postgresql_instances.json b/test/fixtures/databases_postgresql_instances.json index 92d5ce945..47573aa12 100644 --- a/test/fixtures/databases_postgresql_instances.json +++ b/test/fixtures/databases_postgresql_instances.json @@ -83,6 +83,11 @@ }, "shared_buffers_percentage": 41.5, "work_mem": 4 + }, + "private_network": { + "vpc_id": 1234, + "subnet_id": 5678, + "public_access": true } } ], diff --git a/test/fixtures/vpcs_123456_subnets.json b/test/fixtures/vpcs_123456_subnets.json index 37537efb2..a24642d78 100644 --- a/test/fixtures/vpcs_123456_subnets.json +++ b/test/fixtures/vpcs_123456_subnets.json @@ -21,6 +21,15 @@ ] } ], + "databases": [ + { + "id": 12345, + "ipv4_range": "10.0.0.0/24", + "ipv6_ranges": [ + "2001:db8::/64" + ] + } + ], "created": "2018-01-01T00:01:01", "updated": "2018-01-01T00:01:01" } diff --git a/test/fixtures/vpcs_123456_subnets_789.json b/test/fixtures/vpcs_123456_subnets_789.json index 7fac495c4..43a77cd02 100644 --- a/test/fixtures/vpcs_123456_subnets_789.json +++ b/test/fixtures/vpcs_123456_subnets_789.json @@ -19,6 +19,15 @@ ] } ], + "databases": [ + { + "id": 12345, + "ipv4_range": "10.0.0.0/24", + "ipv6_ranges": [ + "2001:db8::/64" + ] + } + ], "created": "2018-01-01T00:01:01", "updated": "2018-01-01T00:01:01" } \ No newline at end of file diff --git a/test/unit/groups/database_test.py b/test/unit/groups/database_test.py index 9647fed82..5e2964c8d 100644 --- a/test/unit/groups/database_test.py +++ b/test/unit/groups/database_test.py @@ -61,6 +61,9 @@ def test_get_databases(self): self.assertEqual(dbs[0].region, "us-east") self.assertEqual(dbs[0].updates.duration, 3) self.assertEqual(dbs[0].version, "8.0.26") + self.assertEqual(dbs[0].private_network.vpc_id, 1234) + self.assertEqual(dbs[0].private_network.subnet_id, 5678) + self.assertEqual(dbs[0].private_network.public_access, True) def test_database_instance(self): """ @@ -1338,6 +1341,10 @@ def test_get_mysql_instances(self): self.assertEqual(dbs[0].engine_config.mysql.tmp_table_size, 16777216) self.assertEqual(dbs[0].engine_config.mysql.wait_timeout, 28800) + self.assertEqual(dbs[0].private_network.vpc_id, 1234) + self.assertEqual(dbs[0].private_network.subnet_id, 5678) + self.assertEqual(dbs[0].private_network.public_access, True) + def test_get_postgresql_instances(self): """ Test that postgresql instances can be retrieved properly @@ -1452,3 +1459,7 @@ def test_get_postgresql_instances(self): self.assertEqual(dbs[0].engine_config.pg.track_io_timing, "off") self.assertEqual(dbs[0].engine_config.pg.wal_sender_timeout, 60000) self.assertEqual(dbs[0].engine_config.pg.wal_writer_delay, 50) + + self.assertEqual(dbs[0].private_network.vpc_id, 1234) + self.assertEqual(dbs[0].private_network.subnet_id, 5678) + self.assertEqual(dbs[0].private_network.public_access, True) diff --git a/test/unit/objects/database_test.py b/test/unit/objects/database_test.py index c5abe3a58..535b2a336 100644 --- a/test/unit/objects/database_test.py +++ b/test/unit/objects/database_test.py @@ -2,6 +2,7 @@ from test.unit.base import ClientBaseCase from linode_api4 import ( + DatabasePrivateNetwork, MySQLDatabaseConfigMySQLOptions, MySQLDatabaseConfigOptions, PostgreSQLDatabase, @@ -41,6 +42,11 @@ def test_create(self): ), binlog_retention_period=200, ), + private_network=DatabasePrivateNetwork( + vpc_id=1234, + subnet_id=5678, + public_access=True, + ), ) except Exception as e: logger.warning( @@ -61,6 +67,12 @@ def test_create(self): m.call_data["engine_config"]["binlog_retention_period"], 200 ) + self.assertEqual(m.call_data["private_network"]["vpc_id"], 1234) + self.assertEqual(m.call_data["private_network"]["subnet_id"], 5678) + self.assertEqual( + m.call_data["private_network"]["public_access"], True + ) + def test_update(self): """ Test that the MySQL database can be updated @@ -78,6 +90,11 @@ def test_update(self): mysql=MySQLDatabaseConfigMySQLOptions(connect_timeout=20), binlog_retention_period=200, ) + db.private_network = DatabasePrivateNetwork( + vpc_id=1234, + subnet_id=5678, + public_access=True, + ) db.save() @@ -93,6 +110,12 @@ def test_update(self): m.call_data["engine_config"]["binlog_retention_period"], 200 ) + self.assertEqual(m.call_data["private_network"]["vpc_id"], 1234) + self.assertEqual(m.call_data["private_network"]["subnet_id"], 5678) + self.assertEqual( + m.call_data["private_network"]["public_access"], True + ) + def test_list_backups(self): """ Test that MySQL backups list properly @@ -259,6 +282,11 @@ def test_create(self): ), work_mem=4, ), + private_network=DatabasePrivateNetwork( + vpc_id=1234, + subnet_id=5678, + public_access=True, + ), ) except Exception: pass @@ -302,6 +330,12 @@ def test_create(self): ) self.assertEqual(m.call_data["engine_config"]["work_mem"], 4) + self.assertEqual(m.call_data["private_network"]["vpc_id"], 1234) + self.assertEqual(m.call_data["private_network"]["subnet_id"], 5678) + self.assertEqual( + m.call_data["private_network"]["public_access"], True + ) + def test_update(self): """ Test that the PostgreSQL database can be updated @@ -322,6 +356,12 @@ def test_update(self): work_mem=4, ) + db.private_network = DatabasePrivateNetwork( + vpc_id=1234, + subnet_id=5678, + public_access=True, + ) + db.save() self.assertEqual(m.method, "put") @@ -337,6 +377,12 @@ def test_update(self): ) self.assertEqual(m.call_data["engine_config"]["work_mem"], 4) + self.assertEqual(m.call_data["private_network"]["vpc_id"], 1234) + self.assertEqual(m.call_data["private_network"]["subnet_id"], 5678) + self.assertEqual( + m.call_data["private_network"]["public_access"], True + ) + def test_list_backups(self): """ Test that PostgreSQL backups list properly diff --git a/test/unit/objects/vpc_test.py b/test/unit/objects/vpc_test.py index 7888bc101..f69066aa5 100644 --- a/test/unit/objects/vpc_test.py +++ b/test/unit/objects/vpc_test.py @@ -124,6 +124,10 @@ def validate_vpc_subnet_789(self, subnet: VPCSubnet): assert subnet.created == expected_dt assert subnet.updated == expected_dt + assert subnet.databases[0].id == 12345 + assert subnet.databases[0].ipv4_range == "10.0.0.0/24" + assert subnet.databases[0].ipv6_ranges == ["2001:db8::/64"] + assert subnet.linodes[0].interfaces[0].id == 678 assert subnet.linodes[0].interfaces[0].active assert subnet.linodes[0].interfaces[0].config_id is None From 9a4ef976af8ecc3a89035c20672fcf21834075f0 Mon Sep 17 00:00:00 2001 From: Lena Garber <114949949+lgarber-akamai@users.noreply.github.com> Date: Tue, 7 Oct 2025 09:11:07 -0400 Subject: [PATCH 2/3] project: VPC Dual Stack (#601) * Enhanced Interfaces: Add support for Firewall templates (#529) * Add support for Firewall Templates * oops * Add LA notices * Enhanced Interfaces: Add account-related fields (#525) * Enhanced Interfaces: Add account-related fields * Add setting enum * Add LA notice * Drop residual print * Enhanced Interfaces: Implement endpoints & fields related to VPCs and non-interface networking (#526) * Implement endpoints & fields related to VPCs and non-interface networking * Add LA notices * Implement support for VPC Dual Stack (#524) * Enhanced Interfaces: Add support for Linode-related endpoints and fields (#533) * Add support for Linode-related endpoints and fields * oops * tiny fixes * fix docsa * Add docs examples * Docs fixes * oops * Remove irrelevant test * Add LA notices * Fill in API documentation URLs * Add return types * Enable `include_none_values` in FirewallSettingsDefaultFirewallIDs (#558) * VPC Dual Stack: Support changes related to Linode Interfaces (#559) * Implementation; needs tests * Add integration tests * vpctest * removeprint * test * Fix conflicts * Fix missed conflict --------- Co-authored-by: Zhiwei Liang Co-authored-by: Zhiwei Liang <121905282+zliang-akamai@users.noreply.github.com> Co-authored-by: vshanthe Co-authored-by: Vinay <143587840+vshanthe@users.noreply.github.com> --- linode_api4/groups/vpc.py | 20 ++- linode_api4/objects/linode.py | 120 +++++++++++++--- linode_api4/objects/linode_interfaces.py | 91 ++++++++++-- linode_api4/objects/networking.py | 9 ++ 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 ++++-- .../linode_instances_124_interfaces.json | 14 ++ .../linode_instances_124_interfaces_456.json | 18 ++- ...node_instances_124_upgrade-interfaces.json | 14 ++ 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/integration/conftest.py | 13 +- .../linode/interfaces/test_interfaces.py | 24 +++- test/integration/models/linode/test_linode.py | 88 +++++++++--- test/integration/models/vpc/test_vpc.py | 43 +++++- test/unit/objects/linode_interface_test.py | 25 ++++ test/unit/objects/linode_test.py | 123 +++++++++++++++- test/unit/objects/vpc_test.py | 12 ++ 25 files changed, 853 insertions(+), 230 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/linode.py b/linode_api4/objects/linode.py index d14261d74..df2694f66 100644 --- a/linode_api4/objects/linode.py +++ b/linode_api4/objects/linode.py @@ -300,10 +300,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. @@ -329,6 +402,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), } @@ -400,7 +474,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 @@ -409,7 +486,7 @@ class ConfigInterface(JSONObject): def __repr__(self): return f"Interface: {self.purpose}" - def _serialize(self, *args, **kwargs): + def _serialize(self, is_put: bool = False): purpose_formats = { "public": {"purpose": "public", "primary": self.primary}, "vlan": { @@ -421,11 +498,8 @@ def _serialize(self, *args, **kwargs): "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, }, } @@ -435,11 +509,14 @@ def _serialize(self, *args, **kwargs): 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): @@ -580,6 +657,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: """ @@ -593,6 +671,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. @@ -603,19 +683,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]]): """ @@ -2018,6 +2095,7 @@ def linode_interfaces(self) -> Optional[list[LinodeInterface]]: if self.interface_generation != InterfaceGeneration.LINODE: return None + if not hasattr(self, "_interfaces"): result = self._client.get( "{}/interfaces".format(Instance.api_endpoint), diff --git a/linode_api4/objects/linode_interfaces.py b/linode_api4/objects/linode_interfaces.py index 391cb8650..0598d1f3c 100644 --- a/linode_api4/objects/linode_interfaces.py +++ b/linode_api4/objects/linode_interfaces.py @@ -1,7 +1,7 @@ from dataclasses import dataclass, field -from typing import List, Optional, Union +from typing import List, Optional -from linode_api4.objects.base import Base, ExplicitNullValue, Property +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 @@ -104,6 +104,41 @@ class LinodeInterfaceVPCIPv4Options(JSONObject): ranges: Optional[List[LinodeInterfaceVPCIPv4RangeOptions]] = None +@dataclass +class LinodeInterfaceVPCIPv6SLAACOptions(JSONObject): + """ + Options accepted for a single SLAAC when creating or updating the IPv6 configuration of a VPC Linode Interface. + + NOTE: Linode interfaces may not currently be available to all users. + """ + + range: Optional[str] = None + + +@dataclass +class LinodeInterfaceVPCIPv6RangeOptions(JSONObject): + """ + Options accepted for a single range when creating or updating the IPv6 configuration of a VPC Linode Interface. + + NOTE: Linode interfaces may not currently be available to all users. + """ + + range: Optional[str] = None + + +@dataclass +class LinodeInterfaceVPCIPv6Options(JSONObject): + """ + Options accepted when creating or updating the IPv6 configuration of a VPC Linode Interface. + + NOTE: Linode interfaces may not currently be available to all users. + """ + + is_public: Optional[bool] = None + slaac: Optional[List[LinodeInterfaceVPCIPv6SLAACOptions]] = None + ranges: Optional[List[LinodeInterfaceVPCIPv6RangeOptions]] = None + + @dataclass class LinodeInterfaceVPCOptions(JSONObject): """ @@ -114,6 +149,7 @@ class LinodeInterfaceVPCOptions(JSONObject): subnet_id: int = 0 ipv4: Optional[LinodeInterfaceVPCIPv4Options] = None + ipv6: Optional[LinodeInterfaceVPCIPv6Options] = None @dataclass @@ -193,13 +229,13 @@ class LinodeInterfaceOptions(JSONObject): NOTE: Linode interfaces may not currently be available to all users. """ - # If a default firewall_id isn't configured, the API requires that - # firewall_id is defined in the LinodeInterface POST body. - # - # To create a Linode Interface without a firewall, this field should - # be set to `ExplicitNullValue()`. - firewall_id: Union[int, ExplicitNullValue, None] = None + 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 @@ -265,6 +301,44 @@ class LinodeInterfaceVPCIPv4(JSONObject): ranges: List[LinodeInterfaceVPCIPv4Range] = field(default_factory=list) +@dataclass +class LinodeInterfaceVPCIPv6SLAAC(JSONObject): + """ + A single SLAAC entry under the IPv6 configuration of a VPC Linode Interface. + + NOTE: Linode interfaces may not currently be available to all users. + """ + + range: str = "" + address: str = "" + + +@dataclass +class LinodeInterfaceVPCIPv6Range(JSONObject): + """ + A single range under the IPv6 configuration of a VPC Linode Interface. + + NOTE: Linode interfaces may not currently be available to all users. + """ + + range: str = "" + + +@dataclass +class LinodeInterfaceVPCIPv6(JSONObject): + """ + A single address under the IPv6 configuration of a VPC Linode Interface. + + NOTE: Linode interfaces may not currently be available to all users. + """ + + put_class = LinodeInterfaceVPCIPv6Options + + is_public: bool = False + slaac: List[LinodeInterfaceVPCIPv6SLAAC] = field(default_factory=list) + ranges: List[LinodeInterfaceVPCIPv6Range] = field(default_factory=list) + + @dataclass class LinodeInterfaceVPC(JSONObject): """ @@ -279,6 +353,7 @@ class LinodeInterfaceVPC(JSONObject): subnet_id: int = 0 ipv4: Optional[LinodeInterfaceVPCIPv4] = None + ipv6: Optional[LinodeInterfaceVPCIPv6] = None @dataclass diff --git a/linode_api4/objects/networking.py b/linode_api4/objects/networking.py index bf4d42989..ed975ab71 100644 --- a/linode_api4/objects/networking.py +++ b/linode_api4/objects/networking.py @@ -156,6 +156,11 @@ def delete(self): return True +@dataclass +class VPCIPAddressIPv6(JSONObject): + slaac_address: str = "" + + @dataclass class VPCIPAddress(JSONObject): """ @@ -181,6 +186,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/vpc.py b/linode_api4/objects/vpc.py index 52fdacbce..4adecc2e3 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 @@ -43,6 +86,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), "databases": Property(json_object=VPCSubnetDatabase, unordered=True), "created": Property(is_datetime=True), @@ -64,6 +108,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), @@ -73,6 +118,9 @@ def subnet_create( self, label: str, ipv4: Optional[str] = None, + ipv6: Optional[ + List[Union[VPCSubnetIPv6RangeOptions, Dict[str, Any]]] + ] = None, **kwargs, ) -> VPCSubnet: """ @@ -85,19 +133,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/linode_instances_124_interfaces.json b/test/fixtures/linode_instances_124_interfaces.json index 890e5c84d..dbb6f79fb 100644 --- a/test/fixtures/linode_instances_124_interfaces.json +++ b/test/fixtures/linode_instances_124_interfaces.json @@ -80,6 +80,20 @@ "range": "192.168.22.32/28" } ] + }, + "ipv6": { + "is_public": true, + "slaac": [ + { + "range": "1234::/64", + "address": "1234::5678" + } + ], + "ranges": [ + { + "range": "4321::/64" + } + ] } }, "public": null, diff --git a/test/fixtures/linode_instances_124_interfaces_456.json b/test/fixtures/linode_instances_124_interfaces_456.json index 7fc4f56f8..8ec4abd3d 100644 --- a/test/fixtures/linode_instances_124_interfaces_456.json +++ b/test/fixtures/linode_instances_124_interfaces_456.json @@ -10,7 +10,7 @@ "vpc": { "vpc_id": 123456, "subnet_id": 789, - "ipv4" : { + "ipv4": { "addresses": [ { "address": "192.168.22.3", @@ -20,7 +20,21 @@ "ranges": [ { "range": "192.168.22.16/28"}, { "range": "192.168.22.32/28"} - ] + ] + }, + "ipv6": { + "is_public": true, + "slaac": [ + { + "range": "1234::/64", + "address": "1234::5678" + } + ], + "ranges": [ + { + "range": "4321::/64" + } + ] } }, "public": null, diff --git a/test/fixtures/linode_instances_124_upgrade-interfaces.json b/test/fixtures/linode_instances_124_upgrade-interfaces.json index ad1b3d035..fa1015029 100644 --- a/test/fixtures/linode_instances_124_upgrade-interfaces.json +++ b/test/fixtures/linode_instances_124_upgrade-interfaces.json @@ -82,6 +82,20 @@ "range": "192.168.22.32/28" } ] + }, + "ipv6": { + "is_public": true, + "slaac": [ + { + "range": "1234::/64", + "address": "1234::5678" + } + ], + "ranges": [ + { + "range": "4321::/64" + } + ] } }, "public": null, 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 a24642d78..8239daec2 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 43a77cd02..199156130 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/integration/conftest.py b/test/integration/conftest.py index 9d2ec0eca..3692269dc 100644 --- a/test/integration/conftest.py +++ b/test/integration/conftest.py @@ -406,9 +406,12 @@ def create_vpc(test_linode_client): label = get_test_label(length=10) vpc = client.vpcs.create( - label, - get_region(test_linode_client, {"VPCs"}), + label=label, + region=get_region( + test_linode_client, {"VPCs", "VPC IPv6 Stack", "Linode Interfaces"} + ), description="test description", + ipv6=[{"range": "auto"}], ) yield vpc @@ -417,7 +420,11 @@ def create_vpc(test_linode_client): @pytest.fixture(scope="session") def create_vpc_with_subnet(test_linode_client, create_vpc): - subnet = create_vpc.subnet_create("test-subnet", ipv4="10.0.0.0/24") + subnet = create_vpc.subnet_create( + label="test-subnet", + ipv4="10.0.0.0/24", + ipv6=[{"range": "auto"}], + ) yield create_vpc, subnet diff --git a/test/integration/models/linode/interfaces/test_interfaces.py b/test/integration/models/linode/interfaces/test_interfaces.py index 7ec33d957..650a9cb6c 100644 --- a/test/integration/models/linode/interfaces/test_interfaces.py +++ b/test/integration/models/linode/interfaces/test_interfaces.py @@ -70,6 +70,13 @@ def __assert_vpc(iface: LinodeInterface): assert len(iface.vpc.ipv4.ranges) == 0 + slaac_entry = iface.vpc.ipv6.slaac[0] + assert ipaddress.ip_address( + slaac_entry.address + ) in ipaddress.ip_network(slaac_entry.range) + assert not iface.vpc.ipv6.is_public + assert len(iface.vpc.ipv6.ranges) == 0 + def __assert_vlan(iface: LinodeInterface): __assert_base(iface) @@ -150,7 +157,7 @@ def linode_interface_vpc( LinodeInterfaceVPCIPv4AddressOptions( address="auto", primary=True, - nat_1_1_address="auto", + nat_1_1_address=None, ) ], ranges=[ @@ -256,17 +263,28 @@ def test_linode_interface_create_vpc(linode_interface_vpc): assert iface.version assert iface.default_route.ipv4 - assert not iface.default_route.ipv6 + assert 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 not None + + assert iface.vpc.ipv4.addresses[0].nat_1_1_address is None assert iface.vpc.ipv4.ranges[0].range.split("/")[1] == "32" + assert iface.default_route.ipv6 + ipv6 = iface.vpc.ipv6 + assert ipv6 and ipv6.is_public is False + + if ipv6.slaac: + assert ipv6.ranges == [] and len(ipv6.slaac) == 1 + assert ipv6.slaac[0].range and ipv6.slaac[0].address + elif ipv6.ranges: + assert ipv6.slaac == [] and len(ipv6.ranges) > 0 + def test_linode_interface_update_vpc(linode_interface_vpc): iface, instance, vpc, subnet = linode_interface_vpc diff --git a/test/integration/models/linode/test_linode.py b/test/integration/models/linode/test_linode.py index adb237559..1413e12d5 100644 --- a/test/integration/models/linode/test_linode.py +++ b/test/integration/models/linode/test_linode.py @@ -1,3 +1,4 @@ +import ipaddress import time from test.integration.conftest import get_region from test.integration.helpers import ( @@ -651,7 +652,7 @@ def __assert_public(iface: LinodeInterface): __assert_base(iface) assert not iface.default_route.ipv4 - assert iface.default_route.ipv6 + assert not iface.default_route.ipv6 assert len(iface.public.ipv4.addresses) == 0 assert len(iface.public.ipv4.shared) == 0 @@ -666,7 +667,7 @@ def __assert_vpc(iface: LinodeInterface): __assert_base(iface) assert iface.default_route.ipv4 - assert not iface.default_route.ipv6 + assert iface.default_route.ipv6 assert iface.vpc.vpc_id == vpc.id assert iface.vpc.subnet_id == subnet.id @@ -679,6 +680,14 @@ def __assert_vpc(iface: LinodeInterface): assert len(iface.vpc.ipv4.ranges) == 1 assert iface.vpc.ipv4.ranges[0].range == "10.0.0.5/32" + assert len(iface.vpc.ipv6.slaac) == 1 + + ipaddress.IPv6Network(iface.vpc.ipv6.slaac[0].range) + ipaddress.IPv6Address(iface.vpc.ipv6.slaac[0].address) + + assert len(iface.vpc.ipv6.ranges) == 0 + assert iface.vpc.ipv6.is_public is False + def __assert_vlan(iface: LinodeInterface): __assert_base(iface) @@ -702,10 +711,6 @@ def __assert_vlan(iface: LinodeInterface): 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.linode_interfaces[0]) __assert_vlan(linode.linode_interfaces[1]) __assert_vpc(linode.linode_interfaces[2]) @@ -716,21 +721,15 @@ def test_linode_interfaces_settings(linode_with_linode_interfaces): settings = linode.interfaces_settings assert settings.network_helper is not None - assert ( - settings.default_route.ipv4_interface_id - == linode.linode_interfaces[0].id - ) + assert settings.default_route.ipv4_interface_id == linode.interfaces[0].id assert settings.default_route.ipv4_eligible_interface_ids == [ - linode.linode_interfaces[0].id, - linode.linode_interfaces[1].id, + linode.interfaces[0].id, + linode.interfaces[1].id, ] - assert ( - settings.default_route.ipv6_interface_id - == linode.linode_interfaces[0].id - ) + assert settings.default_route.ipv6_interface_id == linode.interfaces[0].id assert settings.default_route.ipv6_eligible_interface_ids == [ - linode.linode_interfaces[0].id + linode.interfaces[0].id ] # Arbitrary updates @@ -936,11 +935,30 @@ def test_create_vpc( assert vpc_range_ip.address_range == "10.0.0.5/32" assert not vpc_range_ip.active + assert isinstance(vpc.ipv6, list) + assert len(vpc.ipv6) > 0 + assert isinstance(vpc.ipv6[0].range, str) + assert ":" in vpc.ipv6[0].range + # TODO:: Add `VPCIPAddress.filters.linode_id == linode.id` filter back # Attempt to resolve the IP from /vpcs/ips all_vpc_ips = test_linode_client.vpcs.ips() - assert all_vpc_ips[0].dict == vpc_ip.dict + matched_ip = next( + ( + ip + for ip in all_vpc_ips + if ip.address == vpc_ip.address + and ip.vpc_id == vpc_ip.vpc_id + and ip.linode_id == vpc_ip.linode_id + ), + None, + ) + + assert ( + matched_ip is not None + ), f"Expected VPC IP {vpc_ip.address} not found in /vpcs/ips" + assert matched_ip.dict == vpc_ip.dict # Test getting the ips under this specific VPC vpc_ips = vpc.ips @@ -950,6 +968,40 @@ def test_create_vpc( assert vpc_ips[0].linode_id == linode.id assert vpc_ips[0].nat_1_1 == linode.ips.ipv4.public[0].address + # Validate VPC IPv6 IPs from /vpcs/ips + all_vpc_ipv6 = test_linode_client.get("/vpcs/ipv6s")["data"] + + # Find matching VPC IPv6 entry + matched_ipv6 = next( + ( + ip + for ip in all_vpc_ipv6 + if ip["vpc_id"] == vpc.id + and ip["linode_id"] == linode.id + and ip["interface_id"] == interface.id + and ip["subnet_id"] == subnet.id + ), + None, + ) + + assert ( + matched_ipv6 + ), f"No VPC IPv6 found for Linode {linode.id} in VPC {vpc.id}" + + assert matched_ipv6["ipv6_range"].count(":") >= 2 + assert not matched_ipv6["ipv6_is_public"] + + ipv6_addresses = matched_ipv6.get("ipv6_addresses", []) + assert ( + isinstance(ipv6_addresses, list) and ipv6_addresses + ), "No IPv6 addresses found" + + slaac = ipv6_addresses[0] + assert ( + isinstance(slaac.get("slaac_address"), str) + and ":" in slaac["slaac_address"] + ) + def test_update_vpc( self, linode_and_vpc_for_legacy_interface_tests_offline, diff --git a/test/integration/models/vpc/test_vpc.py b/test/integration/models/vpc/test_vpc.py index 0e9d27aff..ee35929b0 100644 --- a/test/integration/models/vpc/test_vpc.py +++ b/test/integration/models/vpc/test_vpc.py @@ -10,6 +10,7 @@ def test_get_vpc(test_linode_client, create_vpc): vpc = test_linode_client.load(VPC, create_vpc.id) test_linode_client.vpcs() assert vpc.id == create_vpc.id + assert isinstance(vpc.ipv6[0].range, str) @pytest.mark.smoke @@ -31,7 +32,11 @@ def test_update_vpc(test_linode_client, create_vpc): def test_get_subnet(test_linode_client, create_vpc_with_subnet): vpc, subnet = create_vpc_with_subnet loaded_subnet = test_linode_client.load(VPCSubnet, subnet.id, vpc.id) - + assert loaded_subnet.ipv4 == subnet.ipv4 + assert loaded_subnet.ipv6 is not None + assert loaded_subnet.ipv6[0].range.startswith( + vpc.ipv6[0].range.split("::")[0] + ) assert loaded_subnet.id == subnet.id @@ -86,6 +91,9 @@ def test_fails_create_subnet_invalid_data(create_vpc): create_vpc.subnet_create("test-subnet", ipv4=invalid_ipv4) assert excinfo.value.status == 400 + error_msg = str(excinfo.value.json) + + assert "Must be an IPv4 network" in error_msg def test_fails_update_subnet_invalid_data(create_vpc_with_subnet): @@ -97,3 +105,36 @@ def test_fails_update_subnet_invalid_data(create_vpc_with_subnet): subnet.save() assert excinfo.value.status == 400 + assert "Label must include only ASCII" in str(excinfo.value.json) + + +def test_fails_create_subnet_with_invalid_ipv6_range(create_vpc): + valid_ipv4 = "10.0.0.0/24" + invalid_ipv6 = [{"range": "2600:3c11:e5b9::/5a"}] + + with pytest.raises(ApiError) as excinfo: + create_vpc.subnet_create( + label="bad-ipv6-subnet", + ipv4=valid_ipv4, + ipv6=invalid_ipv6, + ) + + assert excinfo.value.status == 400 + error = excinfo.value.json["errors"] + + assert any( + e["field"] == "ipv6[0].range" + and "Must be an IPv6 network" in e["reason"] + for e in error + ) + + +def test_get_vpc_ipv6s(test_linode_client): + ipv6s = test_linode_client.get("/vpcs/ipv6s")["data"] + + assert isinstance(ipv6s, list) + + for ipv6 in ipv6s: + assert "vpc_id" in ipv6 + assert isinstance(ipv6["ipv6_range"], str) + assert isinstance(ipv6["ipv6_addresses"], list) diff --git a/test/unit/objects/linode_interface_test.py b/test/unit/objects/linode_interface_test.py index 421cfbf55..c021334e1 100644 --- a/test/unit/objects/linode_interface_test.py +++ b/test/unit/objects/linode_interface_test.py @@ -14,6 +14,7 @@ LinodeInterfaceVPCIPv4AddressOptions, LinodeInterfaceVPCIPv4Options, LinodeInterfaceVPCIPv4RangeOptions, + LinodeInterfaceVPCIPv6SLAACOptions, LinodeInterfaceVPCOptions, ) @@ -149,6 +150,13 @@ def assert_linode_124_interface_456(iface: LinodeInterface): assert iface.vpc.ipv4.ranges[0].range == "192.168.22.16/28" assert iface.vpc.ipv4.ranges[1].range == "192.168.22.32/28" + assert iface.vpc.ipv6.is_public + + assert iface.vpc.ipv6.slaac[0].range == "1234::/64" + assert iface.vpc.ipv6.slaac[0].address == "1234::5678" + + assert iface.vpc.ipv6.ranges[0].range == "4321::/64" + @staticmethod def assert_linode_124_interface_789(iface: LinodeInterface): assert iface.id == 789 @@ -261,6 +269,18 @@ def test_update_vpc(self): ) ] + iface.vpc.ipv6.is_public = False + + iface.vpc.ipv6.slaac = [ + LinodeInterfaceVPCIPv6SLAACOptions( + range="1233::/64", + ) + ] + + iface.vpc.ipv6.ranges = [ + LinodeInterfacePublicIPv6RangeOptions(range="9876::/64") + ] + with self.mock_put("/linode/instances/124/interfaces/456") as m: iface.save() @@ -282,6 +302,11 @@ def test_update_vpc(self): ], "ranges": [{"range": "192.168.22.17/28"}], }, + "ipv6": { + "is_public": False, + "slaac": [{"range": "1233::/64"}], + "ranges": [{"range": "9876::/64"}], + }, }, } diff --git a/test/unit/objects/linode_test.py b/test/unit/objects/linode_test.py index d20b9f1c0..40bbb5069 100644 --- a/test/unit/objects/linode_test.py +++ b/test/unit/objects/linode_test.py @@ -16,6 +16,12 @@ Config, ConfigInterface, ConfigInterfaceIPv4, + ConfigInterfaceIPv6, + ConfigInterfaceIPv6Options, + ConfigInterfaceIPv6Range, + ConfigInterfaceIPv6RangeOptions, + ConfigInterfaceIPv6SLAAC, + ConfigInterfaceIPv6SLAACOptions, Disk, Image, Instance, @@ -477,6 +483,11 @@ def test_get_placement_group(self): 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 @@ -534,6 +545,11 @@ def test_update_interfaces_settings(self): } 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: @@ -665,15 +681,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") @@ -703,6 +766,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. @@ -906,6 +987,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"], ) @@ -919,6 +1005,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"], } @@ -927,8 +1018,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): @@ -936,6 +1038,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"] @@ -953,6 +1056,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"], } @@ -973,8 +1081,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 f69066aa5..90ec348da 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 @@ -136,6 +138,8 @@ def validate_vpc_subnet_789(self, subnet: VPCSubnet): assert not subnet.linodes[0].interfaces[1].active assert subnet.linodes[0].interfaces[1].config_id is None + 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. @@ -160,3 +164,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" + ) From 4f74fae126ad2bb24282052a3e389069203d0263 Mon Sep 17 00:00:00 2001 From: Lena Garber <114949949+lgarber-akamai@users.noreply.github.com> Date: Tue, 7 Oct 2025 11:30:16 -0400 Subject: [PATCH 3/3] Fix conflict oversight (#610) --- test/integration/models/linode/test_linode.py | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/test/integration/models/linode/test_linode.py b/test/integration/models/linode/test_linode.py index 1413e12d5..c485dd19c 100644 --- a/test/integration/models/linode/test_linode.py +++ b/test/integration/models/linode/test_linode.py @@ -721,15 +721,22 @@ def test_linode_interfaces_settings(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_interface_id + == linode.linode_interfaces[0].id + ) assert settings.default_route.ipv4_eligible_interface_ids == [ - linode.interfaces[0].id, - linode.interfaces[1].id, + linode.linode_interfaces[0].id, + linode.linode_interfaces[1].id, ] - assert settings.default_route.ipv6_interface_id == linode.interfaces[0].id + assert ( + settings.default_route.ipv6_interface_id + == linode.linode_interfaces[0].id + ) assert settings.default_route.ipv6_eligible_interface_ids == [ - linode.interfaces[0].id + linode.linode_interfaces[0].id, + linode.linode_interfaces[1].id, ] # Arbitrary updates