From f2dffdc90d609ee5cf2e41522ec5700d69ce6c0b Mon Sep 17 00:00:00 2001 From: ezilber-akamai Date: Wed, 10 Jun 2026 16:45:03 -0400 Subject: [PATCH 1/4] Added support for Configurable VPC IPv4 Prefixes --- linode_api4/groups/vpc.py | 18 +++++++- linode_api4/objects/vpc.py | 30 +++++++++++- test/fixtures/vpcs.json | 5 ++ test/fixtures/vpcs_123456.json | 5 ++ test/fixtures/vpcs_default-ranges.json | 10 ++++ test/integration/models/vpc/test_vpc.py | 61 ++++++++++++++++++++++++- test/unit/groups/vpc_test.py | 18 +++++++- test/unit/objects/vpc_test.py | 1 + 8 files changed, 144 insertions(+), 4 deletions(-) create mode 100644 test/fixtures/vpcs_default-ranges.json diff --git a/linode_api4/groups/vpc.py b/linode_api4/groups/vpc.py index eda931292..fc4fd8547 100644 --- a/linode_api4/groups/vpc.py +++ b/linode_api4/groups/vpc.py @@ -2,7 +2,7 @@ from linode_api4.errors import UnexpectedResponseError from linode_api4.groups import Group -from linode_api4.objects import VPC, Region, VPCIPAddress, VPCIPv6RangeOptions +from linode_api4.objects import VPC, Region, VPCIPAddress, VPCIPv4DefaultRange, VPCIPv4RangeOptions, 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 @@ -36,6 +36,7 @@ def create( description: Optional[str] = None, subnets: Optional[List[Dict[str, Any]]] = None, ipv6: Optional[List[Union[VPCIPv6RangeOptions, Dict[str, Any]]]] = None, + ipv4: Optional[List[Union[VPCIPv4RangeOptions, Dict[str, Any]]]] = None, **kwargs, ) -> VPC: """ @@ -53,6 +54,8 @@ def create( :type subnets: List[Dict[str, Any]] :param ipv6: The IPv6 address ranges for this VPC. :type ipv6: List[Union[VPCIPv6RangeOptions, Dict[str, Any]]] + :param ipv4: The IPv4 address ranges for this VPC. + :type ipv4: List[Union[VPCIPv4RangeOptions, Dict[str, Any]]] :returns: The new VPC object. :rtype: VPC @@ -61,6 +64,7 @@ def create( "label": label, "region": region.id if isinstance(region, Region) else region, "description": description, + "ipv4": ipv4, "ipv6": ipv6, "subnets": subnets, } @@ -108,3 +112,15 @@ def ips(self, *filters) -> PaginatedList: return self.client._get_and_filter( VPCIPAddress, *filters, endpoint="/vpcs/ips" ) + + def default_ranges(self) -> VPCIPv4DefaultRange: + """ + Retrieve the default settings for the internal and forbidden IPv4 address ranges in VPCs. + + API Documentation: TODO + + :returns: The default IPv4 ranges for VPCs. + :rtype: VPCIPv4DefaultRange + """ + result = self.client.get("/vpcs/default-ranges") + return VPCIPv4DefaultRange.from_json(result) diff --git a/linode_api4/objects/vpc.py b/linode_api4/objects/vpc.py index 4adecc2e3..db4e55c77 100644 --- a/linode_api4/objects/vpc.py +++ b/linode_api4/objects/vpc.py @@ -10,6 +10,34 @@ from linode_api4.util import drop_null_keys +@dataclass +class VPCIPv4DefaultRange(JSONObject): + """ + VPCIPv4DefaultRange represents the default settings for the internal and forbidden IPv4 address ranges in VPCs. + """ + + ipv4_ranges: Optional[List[str]] = None + forbidden_ipv4_ranges: Optional[List[str]] = None + +@dataclass +class VPCIPv4RangeOptions(JSONObject): + """ + VPCIPv4RangeOptions is used to specify an IPv4 range when creating or updating a VPC. + """ + + range: Optional[str] = None + + +@dataclass +class VPCIPv4Range(JSONObject): + """ + VPCIPv4Range represents a single VPC IPv4 range. + """ + + put_class = VPCIPv4RangeOptions + + range: str = "" + @dataclass class VPCIPv6RangeOptions(JSONObject): """ @@ -30,7 +58,6 @@ class VPCIPv6Range(JSONObject): range: str = "" - @dataclass class VPCSubnetIPv6RangeOptions(JSONObject): """ @@ -108,6 +135,7 @@ class VPC(Base): "label": Property(mutable=True), "description": Property(mutable=True), "region": Property(slug_relationship=Region), + "ipv4": Property(json_object=VPCIPv4Range, mutable=True, unordered=True), "ipv6": Property(json_object=VPCIPv6Range, unordered=True), "subnets": Property(derived_class=VPCSubnet), "created": Property(is_datetime=True), diff --git a/test/fixtures/vpcs.json b/test/fixtures/vpcs.json index 822f3bae1..22f1fe362 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", + "ipv4": [ + { + "range": "10.0.0.0/8" + } + ], "ipv6": [ { "range": "fd71:1140:a9d0::/52" diff --git a/test/fixtures/vpcs_123456.json b/test/fixtures/vpcs_123456.json index af6d2cff8..71a250e50 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", + "ipv4": [ + { + "range": "10.0.0.0/8" + } + ], "ipv6": [ { "range": "fd71:1140:a9d0::/52" diff --git a/test/fixtures/vpcs_default-ranges.json b/test/fixtures/vpcs_default-ranges.json new file mode 100644 index 000000000..11500c396 --- /dev/null +++ b/test/fixtures/vpcs_default-ranges.json @@ -0,0 +1,10 @@ +{ + "ipv4_ranges": [ + "10.0.0.0/8", + "192.168.0.0/17" + ], + "forbidden_ipv4_ranges": [ + "172.17.0.0/16" + ] +} + diff --git a/test/integration/models/vpc/test_vpc.py b/test/integration/models/vpc/test_vpc.py index 85d32d858..0a44ae13e 100644 --- a/test/integration/models/vpc/test_vpc.py +++ b/test/integration/models/vpc/test_vpc.py @@ -1,8 +1,9 @@ from test.integration.conftest import get_region +from test.integration.helpers import get_test_label import pytest -from linode_api4 import VPC, ApiError, VPCSubnet +from linode_api4 import VPC, ApiError, VPCIPv4DefaultRange, VPCSubnet @pytest.mark.smoke @@ -138,3 +139,61 @@ def test_get_vpc_ipv6s(test_linode_client): assert "vpc_id" in ipv6 assert isinstance(ipv6["ipv6_range"], str) assert isinstance(ipv6["ipv6_addresses"], list) + + +def test_get_vpc_default_ranges(test_linode_client): + """ + Tests that VPC default IPv4 ranges can be retrieved. + """ + result = test_linode_client.vpcs.default_ranges() + + assert isinstance(result, VPCIPv4DefaultRange) + assert isinstance(result.ipv4_ranges, list) + assert len(result.ipv4_ranges) > 0 + assert isinstance(result.forbidden_ipv4_ranges, list) + assert len(result.forbidden_ipv4_ranges) > 0 + + +def test_vpc_with_ipv4(test_linode_client): + """ + Tests creating a VPC with ipv4 ranges, getting/listing it, + updating the ipv4, and deleting it. + """ + client = test_linode_client + label = get_test_label(length=10) + region = get_region(client, {"VPCs", "Custom VPC IPv4 Ranges"}) + + # Create + vpc = client.vpcs.create( + label=label, + region=region, + description="integration test vpc with ipv4", + ipv4=[{"range": "10.0.0.0/8"}], + ) + + try: + assert vpc.id is not None + assert vpc.label == label + assert len(vpc.ipv4) > 0 + assert vpc.ipv4[0].range == "10.0.0.0/8" + + # Get by ID + loaded_vpc = client.load(VPC, vpc.id) + assert loaded_vpc.id == vpc.id + assert loaded_vpc.ipv4[0].range == "10.0.0.0/8" + + # List and verify present + all_vpcs = client.vpcs() + vpc_ids = [v.id for v in all_vpcs] + assert vpc.id in vpc_ids + + # Update ipv4 + vpc.ipv4 = [{"range": "192.168.0.0/17"}] + vpc.save() + + updated_vpc = client.load(VPC, vpc.id) + assert updated_vpc.ipv4[0].range == "192.168.0.0/17" + finally: + # Delete + vpc.delete() + diff --git a/test/unit/groups/vpc_test.py b/test/unit/groups/vpc_test.py index 7b8c985d2..86530c093 100644 --- a/test/unit/groups/vpc_test.py +++ b/test/unit/groups/vpc_test.py @@ -1,7 +1,7 @@ import datetime from test.unit.base import ClientBaseCase -from linode_api4 import DATE_FORMAT, VPC, VPCSubnet +from linode_api4 import DATE_FORMAT, VPC, VPCSubnet, VPCIPv4DefaultRange class VPCTest(ClientBaseCase): @@ -95,6 +95,8 @@ def validate_vpc_123456(self, vpc: VPC): self.assertEqual(vpc.created, expected_dt) self.assertEqual(vpc.updated, expected_dt) + self.assertEqual(vpc.ipv4[0].range, "10.0.0.0/8") + def validate_vpc_subnet_789(self, subnet: VPCSubnet): expected_dt = datetime.datetime.strptime( "2018-01-01T00:01:01", DATE_FORMAT @@ -105,3 +107,17 @@ def validate_vpc_subnet_789(self, subnet: VPCSubnet): self.assertEqual(subnet.linodes[0].id, 12345) self.assertEqual(subnet.created, expected_dt) self.assertEqual(subnet.updated, expected_dt) + + def test_default_ranges(self): + """ + Tests that VPC default ranges can be retrieved. + """ + + with self.mock_get("/vpcs/default-ranges") as m: + result = self.client.vpcs.default_ranges() + + self.assertEqual(m.call_url, "/vpcs/default-ranges") + self.assertIsInstance(result, VPCIPv4DefaultRange) + self.assertEqual(result.ipv4_ranges, ["10.0.0.0/8", "192.168.0.0/17"]) + self.assertEqual(result.forbidden_ipv4_ranges, ["172.17.0.0/16"]) + diff --git a/test/unit/objects/vpc_test.py b/test/unit/objects/vpc_test.py index 90ec348da..b3a79b5b2 100644 --- a/test/unit/objects/vpc_test.py +++ b/test/unit/objects/vpc_test.py @@ -113,6 +113,7 @@ def validate_vpc_123456(self, vpc: VPC): self.assertEqual(vpc.created, expected_dt) self.assertEqual(vpc.updated, expected_dt) + self.assertEqual(vpc.ipv4[0].range, "10.0.0.0/8") self.assertEqual(vpc.ipv6[0].range, "fd71:1140:a9d0::/52") def validate_vpc_subnet_789(self, subnet: VPCSubnet): From 141b12066db5b13fc025096a3f77b574590e42c1 Mon Sep 17 00:00:00 2001 From: ezilber-akamai Date: Wed, 10 Jun 2026 16:54:42 -0400 Subject: [PATCH 2/4] Fix lint --- linode_api4/groups/vpc.py | 9 ++++++++- test/unit/groups/vpc_test.py | 2 +- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/linode_api4/groups/vpc.py b/linode_api4/groups/vpc.py index fc4fd8547..a7ebc57a5 100644 --- a/linode_api4/groups/vpc.py +++ b/linode_api4/groups/vpc.py @@ -2,7 +2,14 @@ from linode_api4.errors import UnexpectedResponseError from linode_api4.groups import Group -from linode_api4.objects import VPC, Region, VPCIPAddress, VPCIPv4DefaultRange, VPCIPv4RangeOptions, VPCIPv6RangeOptions +from linode_api4.objects import ( + VPC, + Region, + VPCIPAddress, + VPCIPv4DefaultRange, + VPCIPv4RangeOptions, + 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 diff --git a/test/unit/groups/vpc_test.py b/test/unit/groups/vpc_test.py index 86530c093..ec645920c 100644 --- a/test/unit/groups/vpc_test.py +++ b/test/unit/groups/vpc_test.py @@ -1,7 +1,7 @@ import datetime from test.unit.base import ClientBaseCase -from linode_api4 import DATE_FORMAT, VPC, VPCSubnet, VPCIPv4DefaultRange +from linode_api4 import DATE_FORMAT, VPC, VPCIPv4DefaultRange, VPCSubnet class VPCTest(ClientBaseCase): From 5df765bdf8d57256d0d60ad0f8f5ac97c463d4b8 Mon Sep 17 00:00:00 2001 From: ezilber-akamai Date: Thu, 11 Jun 2026 11:00:10 -0400 Subject: [PATCH 3/4] Fix lint --- linode_api4/objects/vpc.py | 7 ++++++- test/integration/models/vpc/test_vpc.py | 1 - test/unit/groups/vpc_test.py | 1 - 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/linode_api4/objects/vpc.py b/linode_api4/objects/vpc.py index db4e55c77..a89daf371 100644 --- a/linode_api4/objects/vpc.py +++ b/linode_api4/objects/vpc.py @@ -19,6 +19,7 @@ class VPCIPv4DefaultRange(JSONObject): ipv4_ranges: Optional[List[str]] = None forbidden_ipv4_ranges: Optional[List[str]] = None + @dataclass class VPCIPv4RangeOptions(JSONObject): """ @@ -38,6 +39,7 @@ class VPCIPv4Range(JSONObject): range: str = "" + @dataclass class VPCIPv6RangeOptions(JSONObject): """ @@ -58,6 +60,7 @@ class VPCIPv6Range(JSONObject): range: str = "" + @dataclass class VPCSubnetIPv6RangeOptions(JSONObject): """ @@ -135,7 +138,9 @@ class VPC(Base): "label": Property(mutable=True), "description": Property(mutable=True), "region": Property(slug_relationship=Region), - "ipv4": Property(json_object=VPCIPv4Range, mutable=True, unordered=True), + "ipv4": Property( + json_object=VPCIPv4Range, mutable=True, unordered=True + ), "ipv6": Property(json_object=VPCIPv6Range, unordered=True), "subnets": Property(derived_class=VPCSubnet), "created": Property(is_datetime=True), diff --git a/test/integration/models/vpc/test_vpc.py b/test/integration/models/vpc/test_vpc.py index 0a44ae13e..477a629d3 100644 --- a/test/integration/models/vpc/test_vpc.py +++ b/test/integration/models/vpc/test_vpc.py @@ -196,4 +196,3 @@ def test_vpc_with_ipv4(test_linode_client): finally: # Delete vpc.delete() - diff --git a/test/unit/groups/vpc_test.py b/test/unit/groups/vpc_test.py index ec645920c..02d95a5a8 100644 --- a/test/unit/groups/vpc_test.py +++ b/test/unit/groups/vpc_test.py @@ -120,4 +120,3 @@ def test_default_ranges(self): self.assertIsInstance(result, VPCIPv4DefaultRange) self.assertEqual(result.ipv4_ranges, ["10.0.0.0/8", "192.168.0.0/17"]) self.assertEqual(result.forbidden_ipv4_ranges, ["172.17.0.0/16"]) - From 28289e93de545f26389d5753166047064d948dcf Mon Sep 17 00:00:00 2001 From: ezilber-akamai Date: Thu, 11 Jun 2026 11:21:35 -0400 Subject: [PATCH 4/4] Add LA notices --- linode_api4/groups/vpc.py | 2 +- linode_api4/objects/vpc.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/linode_api4/groups/vpc.py b/linode_api4/groups/vpc.py index a7ebc57a5..5893b73c8 100644 --- a/linode_api4/groups/vpc.py +++ b/linode_api4/groups/vpc.py @@ -61,7 +61,7 @@ def create( :type subnets: List[Dict[str, Any]] :param ipv6: The IPv6 address ranges for this VPC. :type ipv6: List[Union[VPCIPv6RangeOptions, Dict[str, Any]]] - :param ipv4: The IPv4 address ranges for this VPC. + :param ipv4: The IPv4 address ranges for this VPC. Note that IPv4 VPCs may not currently be available to all users. :type ipv4: List[Union[VPCIPv4RangeOptions, Dict[str, Any]]] :returns: The new VPC object. diff --git a/linode_api4/objects/vpc.py b/linode_api4/objects/vpc.py index a89daf371..50b258bad 100644 --- a/linode_api4/objects/vpc.py +++ b/linode_api4/objects/vpc.py @@ -138,6 +138,7 @@ class VPC(Base): "label": Property(mutable=True), "description": Property(mutable=True), "region": Property(slug_relationship=Region), + # Note that IPv4 VPCs may not currently be available to all users. "ipv4": Property( json_object=VPCIPv4Range, mutable=True, unordered=True ),