From 82a2772225dae763a443c8ca71779f9b710d0eb9 Mon Sep 17 00:00:00 2001 From: Lena Garber <114949949+lgarber-akamai@users.noreply.github.com> Date: Fri, 18 Apr 2025 15:00:56 -0400 Subject: [PATCH 01/12] Enhanced Interfaces: Add support for Firewall templates (#529) * Add support for Firewall Templates * oops * Add LA notices --- linode_api4/groups/networking.py | 18 ++++ linode_api4/objects/networking.py | 16 ++++ .../networking_firewalls_templates.json | 93 +++++++++++++++++++ ...networking_firewalls_templates_public.json | 43 +++++++++ .../networking_firewalls_templates_vpc.json | 43 +++++++++ .../firewall/test_firewall_templates.py | 33 +++++++ test/unit/groups/networking_test.py | 17 ++++ test/unit/objects/firewall_test.py | 41 ++++++++ 8 files changed, 304 insertions(+) create mode 100644 test/fixtures/networking_firewalls_templates.json create mode 100644 test/fixtures/networking_firewalls_templates_public.json create mode 100644 test/fixtures/networking_firewalls_templates_vpc.json create mode 100644 test/integration/models/firewall/test_firewall_templates.py create mode 100644 test/unit/groups/networking_test.py diff --git a/linode_api4/groups/networking.py b/linode_api4/groups/networking.py index ba1e656bd..1051d48f3 100644 --- a/linode_api4/groups/networking.py +++ b/linode_api4/groups/networking.py @@ -4,6 +4,7 @@ VLAN, Base, Firewall, + FirewallTemplate, Instance, IPAddress, IPv6Pool, @@ -94,6 +95,23 @@ def firewall_create(self, label, rules, **kwargs): f = Firewall(self.client, result["id"], result) return f + def firewall_templates(self, *filters): + """ + Returns a list of Firewall Templates available to the current user. + + API Documentation: Not yet available. + + NOTE: This feature may not currently be available to all users. + + :param filters: Any number of filters to apply to this query. + See :doc:`Filtering Collections` + for more details on filtering. + + :returns: A list of Firewall Templates available to the current user. + :rtype: PaginatedList of FirewallTemplate + """ + return self.client._get_and_filter(FirewallTemplate, *filters) + def ips(self, *filters): """ Returns a list of IP addresses on this account, excluding private addresses. diff --git a/linode_api4/objects/networking.py b/linode_api4/objects/networking.py index b7a16ae90..0fc1c4fd2 100644 --- a/linode_api4/objects/networking.py +++ b/linode_api4/objects/networking.py @@ -307,6 +307,22 @@ def device_create(self, id, type="linode", **kwargs): return c +class FirewallTemplate(Base): + """ + Represents a single Linode Firewall template. + + API documentation: Not yet available. + + NOTE: This feature may not currently be available to all users. + """ + + api_endpoint = "/networking/firewalls/templates/{slug}" + + id_attribute = "slug" + + properties = {"slug": Property(identifier=True), "rules": Property()} + + class NetworkTransferPrice(Base): """ An NetworkTransferPrice represents the structure of a valid network transfer price. diff --git a/test/fixtures/networking_firewalls_templates.json b/test/fixtures/networking_firewalls_templates.json new file mode 100644 index 000000000..b0267c7b4 --- /dev/null +++ b/test/fixtures/networking_firewalls_templates.json @@ -0,0 +1,93 @@ +{ + "data": [ + { + "slug": "public", + "rules": { + "outbound": [ + { + "action": "ACCEPT", + "addresses": { + "ipv4": [ + "192.0.2.0/24", + "198.51.100.2/32" + ], + "ipv6": [ + "2001:DB8::/128" + ] + }, + "description": "test", + "label": "test-rule", + "ports": "22-24, 80, 443", + "protocol": "TCP" + } + ], + "outbound_policy": "DROP", + "inbound": [ + { + "action": "ACCEPT", + "addresses": { + "ipv4": [ + "192.0.2.0/24", + "198.51.100.2/32" + ], + "ipv6": [ + "2001:DB8::/128" + ] + }, + "description": "test", + "label": "test-rule", + "ports": "22-24, 80, 443", + "protocol": "TCP" + } + ], + "inbound_policy": "DROP" + } + }, + { + "slug": "vpc", + "rules": { + "outbound": [ + { + "action": "ACCEPT", + "addresses": { + "ipv4": [ + "192.0.2.0/24", + "198.51.100.2/32" + ], + "ipv6": [ + "2001:DB8::/128" + ] + }, + "description": "test", + "label": "test-rule", + "ports": "22-24, 80, 443", + "protocol": "TCP" + } + ], + "outbound_policy": "DROP", + "inbound": [ + { + "action": "ACCEPT", + "addresses": { + "ipv4": [ + "192.0.2.0/24", + "198.51.100.2/32" + ], + "ipv6": [ + "2001:DB8::/128" + ] + }, + "description": "test", + "label": "test-rule", + "ports": "22-24, 80, 443", + "protocol": "TCP" + } + ], + "inbound_policy": "DROP" + } + } + ], + "page": 1, + "pages": 1, + "results": 2 +} \ No newline at end of file diff --git a/test/fixtures/networking_firewalls_templates_public.json b/test/fixtures/networking_firewalls_templates_public.json new file mode 100644 index 000000000..6b33e9f73 --- /dev/null +++ b/test/fixtures/networking_firewalls_templates_public.json @@ -0,0 +1,43 @@ +{ + "slug": "public", + "rules": { + "outbound": [ + { + "action": "ACCEPT", + "addresses": { + "ipv4": [ + "192.0.2.0/24", + "198.51.100.2/32" + ], + "ipv6": [ + "2001:DB8::/128" + ] + }, + "description": "test", + "label": "test-rule", + "ports": "22-24, 80, 443", + "protocol": "TCP" + } + ], + "outbound_policy": "DROP", + "inbound": [ + { + "action": "ACCEPT", + "addresses": { + "ipv4": [ + "192.0.2.0/24", + "198.51.100.2/32" + ], + "ipv6": [ + "2001:DB8::/128" + ] + }, + "description": "test", + "label": "test-rule", + "ports": "22-24, 80, 443", + "protocol": "TCP" + } + ], + "inbound_policy": "DROP" + } +} \ No newline at end of file diff --git a/test/fixtures/networking_firewalls_templates_vpc.json b/test/fixtures/networking_firewalls_templates_vpc.json new file mode 100644 index 000000000..839bd6824 --- /dev/null +++ b/test/fixtures/networking_firewalls_templates_vpc.json @@ -0,0 +1,43 @@ +{ + "slug": "vpc", + "rules": { + "outbound": [ + { + "action": "ACCEPT", + "addresses": { + "ipv4": [ + "192.0.2.0/24", + "198.51.100.2/32" + ], + "ipv6": [ + "2001:DB8::/128" + ] + }, + "description": "test", + "label": "test-rule", + "ports": "22-24, 80, 443", + "protocol": "TCP" + } + ], + "outbound_policy": "DROP", + "inbound": [ + { + "action": "ACCEPT", + "addresses": { + "ipv4": [ + "192.0.2.0/24", + "198.51.100.2/32" + ], + "ipv6": [ + "2001:DB8::/128" + ] + }, + "description": "test", + "label": "test-rule", + "ports": "22-24, 80, 443", + "protocol": "TCP" + } + ], + "inbound_policy": "DROP" + } +} \ No newline at end of file diff --git a/test/integration/models/firewall/test_firewall_templates.py b/test/integration/models/firewall/test_firewall_templates.py new file mode 100644 index 000000000..11d6ccb6f --- /dev/null +++ b/test/integration/models/firewall/test_firewall_templates.py @@ -0,0 +1,33 @@ +from linode_api4 import FirewallTemplate, MappedObject + + +def __assert_firewall_template_rules(rules: MappedObject): + # We can't confidently say that these rules will not be changed + # in the future, so we can just do basic assertions here. + assert isinstance(rules.inbound_policy, str) + assert len(rules.inbound_policy) > 0 + + assert isinstance(rules.outbound_policy, str) + assert len(rules.outbound_policy) > 0 + + assert isinstance(rules.outbound, list) + assert isinstance(rules.inbound, list) + + +def test_list_firewall_templates(test_linode_client): + templates = test_linode_client.networking.firewall_templates() + assert len(templates) > 0 + + for template in templates: + assert isinstance(template.slug, str) + assert len(template.slug) > 0 + + __assert_firewall_template_rules(template.rules) + + +def test_get_firewall_template(test_linode_client): + template = test_linode_client.load(FirewallTemplate, "vpc") + + assert template.slug == "vpc" + + __assert_firewall_template_rules(template.rules) diff --git a/test/unit/groups/networking_test.py b/test/unit/groups/networking_test.py new file mode 100644 index 000000000..72cc95cda --- /dev/null +++ b/test/unit/groups/networking_test.py @@ -0,0 +1,17 @@ +from test.unit.base import ClientBaseCase +from test.unit.objects.firewall_test import FirewallTemplatesTest + + +class NetworkingGroupTest(ClientBaseCase): + """ + Tests methods under the NetworkingGroup class. + """ + + def test_get_templates(self): + templates = self.client.networking.firewall_templates() + + assert templates[0].slug == "public" + FirewallTemplatesTest.assert_rules(templates[0].rules) + + assert templates[1].slug == "vpc" + FirewallTemplatesTest.assert_rules(templates[1].rules) diff --git a/test/unit/objects/firewall_test.py b/test/unit/objects/firewall_test.py index a46ea2750..c3b54bf87 100644 --- a/test/unit/objects/firewall_test.py +++ b/test/unit/objects/firewall_test.py @@ -1,5 +1,6 @@ from test.unit.base import ClientBaseCase +from linode_api4 import FirewallTemplate, MappedObject from linode_api4.objects import Firewall, FirewallDevice @@ -81,3 +82,43 @@ def test_get_device(self): self.assertEqual(device.entity.url, "/v4/linode/instances/123") self.assertEqual(device._populated, True) + + +class FirewallTemplatesTest(ClientBaseCase): + @staticmethod + def assert_rules(rules: MappedObject): + assert rules.outbound_policy == "DROP" + assert len(rules.outbound) == 1 + + assert rules.inbound_policy == "DROP" + assert len(rules.inbound) == 1 + + outbound_rule = rules.outbound[0] + assert outbound_rule.action == "ACCEPT" + assert outbound_rule.addresses.ipv4[0] == "192.0.2.0/24" + assert outbound_rule.addresses.ipv4[1] == "198.51.100.2/32" + assert outbound_rule.addresses.ipv6[0] == "2001:DB8::/128" + assert outbound_rule.description == "test" + assert outbound_rule.label == "test-rule" + assert outbound_rule.ports == "22-24, 80, 443" + assert outbound_rule.protocol == "TCP" + + inbound_rule = rules.outbound[0] + assert inbound_rule.action == "ACCEPT" + assert inbound_rule.addresses.ipv4[0] == "192.0.2.0/24" + assert inbound_rule.addresses.ipv4[1] == "198.51.100.2/32" + assert inbound_rule.addresses.ipv6[0] == "2001:DB8::/128" + assert inbound_rule.description == "test" + assert inbound_rule.label == "test-rule" + assert inbound_rule.ports == "22-24, 80, 443" + assert inbound_rule.protocol == "TCP" + + def test_get_public(self): + template = self.client.load(FirewallTemplate, "public") + assert template.slug == "public" + self.assert_rules(template.rules) + + def test_get_vpc(self): + template = self.client.load(FirewallTemplate, "vpc") + assert template.slug == "vpc" + self.assert_rules(template.rules) From f42c79f53172e297a5d6852387f2d0c1502e53b8 Mon Sep 17 00:00:00 2001 From: Lena Garber <114949949+lgarber-akamai@users.noreply.github.com> Date: Tue, 22 Apr 2025 17:02:50 -0400 Subject: [PATCH 02/12] Enhanced Interfaces: Add account-related fields (#525) * Enhanced Interfaces: Add account-related fields * Add setting enum * Add LA notice * Drop residual print --- linode_api4/objects/account.py | 20 ++++++++++++++ test/fixtures/account.json | 3 ++- test/fixtures/account_settings.json | 3 ++- .../models/account/test_account.py | 1 + test/unit/linode_client_test.py | 8 +++++- test/unit/objects/account_test.py | 27 +++++++++++++++++++ 6 files changed, 59 insertions(+), 3 deletions(-) diff --git a/linode_api4/objects/account.py b/linode_api4/objects/account.py index 375e5fc03..44aeaa715 100644 --- a/linode_api4/objects/account.py +++ b/linode_api4/objects/account.py @@ -16,6 +16,7 @@ from linode_api4.objects.networking import Firewall from linode_api4.objects.nodebalancer import NodeBalancer from linode_api4.objects.profile import PersonalAccessToken +from linode_api4.objects.serializable import StrEnum from linode_api4.objects.support import SupportTicket from linode_api4.objects.volume import Volume from linode_api4.objects.vpc import VPC @@ -179,6 +180,24 @@ class Login(Base): } +class AccountSettingsInterfacesForNewLinodes(StrEnum): + """ + A string enum corresponding to valid values + for the AccountSettings(...).interfaces_for_new_linodes field. + + NOTE: This feature may not currently be available to all users. + """ + + legacy_config_only = "legacy_config_only" + legacy_config_default_but_linode_allowed = ( + "legacy_config_default_but_linode_allowed" + ) + linode_default_but_legacy_config_allowed = ( + "linode_default_but_legacy_config_allowed" + ) + linode_only = "linode_only" + + class AccountSettings(Base): """ Information related to your Account settings. @@ -197,6 +216,7 @@ class AccountSettings(Base): ), "object_storage": Property(), "backups_enabled": Property(mutable=True), + "interfaces_for_new_linodes": Property(mutable=True), } diff --git a/test/fixtures/account.json b/test/fixtures/account.json index 1d823798b..001d7adad 100644 --- a/test/fixtures/account.json +++ b/test/fixtures/account.json @@ -16,7 +16,8 @@ "Linodes", "NodeBalancers", "Block Storage", - "Object Storage" + "Object Storage", + "Linode Interfaces" ], "active_promotions": [ { diff --git a/test/fixtures/account_settings.json b/test/fixtures/account_settings.json index 77a2fdac3..02b711aa6 100644 --- a/test/fixtures/account_settings.json +++ b/test/fixtures/account_settings.json @@ -3,5 +3,6 @@ "managed": false, "network_helper": false, "object_storage": "active", - "backups_enabled": true + "backups_enabled": true, + "interfaces_for_new_linodes": "linode_default_but_legacy_config_allowed" } diff --git a/test/integration/models/account/test_account.py b/test/integration/models/account/test_account.py index decad434f..1ee700495 100644 --- a/test/integration/models/account/test_account.py +++ b/test/integration/models/account/test_account.py @@ -59,6 +59,7 @@ def test_get_account_settings(test_linode_client): assert "longview_subscription" in str(account_settings._raw_json) assert "backups_enabled" in str(account_settings._raw_json) assert "object_storage" in str(account_settings._raw_json) + assert isinstance(account_settings.interfaces_for_new_linodes, str) @pytest.mark.smoke diff --git a/test/unit/linode_client_test.py b/test/unit/linode_client_test.py index c79c0a88d..d2237a366 100644 --- a/test/unit/linode_client_test.py +++ b/test/unit/linode_client_test.py @@ -44,7 +44,13 @@ def test_get_account(self): self.assertEqual(a.balance, 0) self.assertEqual( a.capabilities, - ["Linodes", "NodeBalancers", "Block Storage", "Object Storage"], + [ + "Linodes", + "NodeBalancers", + "Block Storage", + "Object Storage", + "Linode Interfaces", + ], ) def test_get_regions(self): diff --git a/test/unit/objects/account_test.py b/test/unit/objects/account_test.py index 1f9da98fb..650874f45 100644 --- a/test/unit/objects/account_test.py +++ b/test/unit/objects/account_test.py @@ -3,6 +3,7 @@ from datetime import datetime from test.unit.base import ClientBaseCase +from linode_api4 import AccountSettingsInterfacesForNewLinodes from linode_api4.objects import ( Account, AccountAvailability, @@ -97,6 +98,7 @@ def test_get_account(self): self.assertEqual(account.balance_uninvoiced, 145) self.assertEqual(account.billing_source, "akamai") self.assertEqual(account.euuid, "E1AF5EEC-526F-487D-B317EBEB34C87D71") + self.assertIn("Linode Interfaces", account.capabilities) def test_get_login(self): """ @@ -121,6 +123,31 @@ def test_get_account_settings(self): self.assertEqual(settings.network_helper, False) self.assertEqual(settings.object_storage, "active") self.assertEqual(settings.backups_enabled, True) + self.assertEqual( + settings.interfaces_for_new_linodes, + AccountSettingsInterfacesForNewLinodes.linode_default_but_legacy_config_allowed, + ) + + def test_post_account_settings(self): + """ + Tests that account settings can be updated successfully + """ + settings = self.client.account.settings() + + settings.network_helper = True + settings.backups_enabled = False + settings.interfaces_for_new_linodes = ( + AccountSettingsInterfacesForNewLinodes.linode_only + ) + + with self.mock_put("/account/settings") as m: + settings.save() + + assert m.call_data == { + "network_helper": True, + "backups_enabled": False, + "interfaces_for_new_linodes": AccountSettingsInterfacesForNewLinodes.linode_only, + } def test_get_event(self): """ From 9ce77ef30efdb31d80cb17979907035a43f323c0 Mon Sep 17 00:00:00 2001 From: Lena Garber <114949949+lgarber-akamai@users.noreply.github.com> Date: Fri, 25 Apr 2025 15:34:04 -0400 Subject: [PATCH 03/12] 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 --- linode_api4/groups/networking.py | 42 ++++++++- linode_api4/objects/networking.py | 52 ++++++++++- linode_api4/objects/vpc.py | 1 + .../networking_firewalls_123_devices.json | 13 ++- .../networking_firewalls_123_devices_456.json | 11 +++ .../networking_firewalls_settings.json | 8 ++ test/fixtures/networking_ips_127.0.0.1.json | 1 + test/fixtures/regions.json | 33 ++++--- test/fixtures/vpcs_123456_subnets.json | 6 +- test/fixtures/vpcs_123456_subnets_789.json | 6 +- .../linode_client/test_linode_client.py | 6 +- .../models/networking/test_networking.py | 87 ++++++++++++++++++- test/unit/linode_client_test.py | 63 +++++++++++++- test/unit/objects/firewall_test.py | 38 +++++++- test/unit/objects/networking_test.py | 23 ++++- test/unit/objects/region_test.py | 4 +- test/unit/objects/vpc_test.py | 18 ++-- 17 files changed, 376 insertions(+), 36 deletions(-) create mode 100644 test/fixtures/networking_firewalls_123_devices_456.json create mode 100644 test/fixtures/networking_firewalls_settings.json diff --git a/linode_api4/groups/networking.py b/linode_api4/groups/networking.py index 1051d48f3..b9cad485d 100644 --- a/linode_api4/groups/networking.py +++ b/linode_api4/groups/networking.py @@ -1,9 +1,13 @@ +from typing import Any, Dict, Optional, Union + from linode_api4.errors import UnexpectedResponseError from linode_api4.groups import Group from linode_api4.objects import ( VLAN, Base, Firewall, + FirewallCreateDevicesOptions, + FirewallSettings, FirewallTemplate, Instance, IPAddress, @@ -12,6 +16,8 @@ NetworkTransferPrice, Region, ) +from linode_api4.objects.base import _flatten_request_body_recursive +from linode_api4.util import drop_null_keys class NetworkingGroup(Group): @@ -34,7 +40,15 @@ def firewalls(self, *filters): """ return self.client._get_and_filter(Firewall, *filters) - def firewall_create(self, label, rules, **kwargs): + def firewall_create( + self, + label: str, + rules: Dict[str, Any], + devices: Optional[ + Union[FirewallCreateDevicesOptions, Dict[str, Any]] + ] = None, + **kwargs, + ): """ Creates a new Firewall, either in the given Region or attached to the given Instance. @@ -45,6 +59,8 @@ def firewall_create(self, label, rules, **kwargs): :type label: str :param rules: The rules to apply to the new Firewall. For more information on Firewall rules, see our `Firewalls Documentation`_. :type rules: dict + :param devices: Represents devices to create created alongside a Linode Firewall. + :type devices: Optional[Union[FirewallCreateDevicesOptions, Dict[str, Any]]] :returns: The new Firewall. :rtype: Firewall @@ -82,10 +98,14 @@ def firewall_create(self, label, rules, **kwargs): params = { "label": label, "rules": rules, + "devices": devices, } params.update(kwargs) - result = self.client.post("/networking/firewalls", data=params) + result = self.client.post( + "/networking/firewalls", + data=drop_null_keys(_flatten_request_body_recursive(params)), + ) if not "id" in result: raise UnexpectedResponseError( @@ -112,6 +132,24 @@ def firewall_templates(self, *filters): """ return self.client._get_and_filter(FirewallTemplate, *filters) + def firewall_settings(self) -> FirewallSettings: + """ + Returns an object representing the Linode Firewall settings for the current user. + API Documentation: Not yet available. + NOTE: This feature may not currently be available to all users. + :returns: An object representing the Linode Firewall settings for the current user. + :rtype: FirewallSettings + """ + result = self.client.get("/networking/firewalls/settings") + + if "default_firewall_ids" not in result: + raise UnexpectedResponseError( + "Unexpected response when getting firewall settings!", + json=result, + ) + + return FirewallSettings(self.client, None, result) + def ips(self, *filters): """ Returns a list of IP addresses on this account, excluding private addresses. diff --git a/linode_api4/objects/networking.py b/linode_api4/objects/networking.py index 0fc1c4fd2..74a9ab283 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 dataclasses import dataclass, field +from typing import List, Optional from linode_api4.common import Price, RegionPrice from linode_api4.errors import UnexpectedResponseError @@ -87,6 +87,7 @@ class IPAddress(Base): "public": Property(), "rdns": Property(mutable=True), "linode_id": Property(), + "interface_id": Property(), "region": Property(slug_relationship=Region), "vpc_nat_1_1": Property(json_object=InstanceIPNAT1To1), } @@ -99,6 +100,8 @@ def linode(self): self._set("_linode", Instance(self._client, self.linode_id)) return self._linode + # TODO (Enhanced Interfaces): Add `interface` property method + def to(self, linode): """ This is a helper method for ip-assign, and should not be used outside @@ -176,6 +179,51 @@ class VLAN(Base): } +@dataclass +class FirewallCreateDevicesOptions(JSONObject): + """ + Represents devices to create created alongside a Linode Firewall. + """ + + linodes: List[int] = field(default_factory=list) + nodebalancers: List[int] = field(default_factory=list) + interfaces: List[int] = field(default_factory=list) + + +@dataclass +class FirewallSettingsDefaultFirewallIDs(JSONObject): + """ + Contains the IDs of Linode Firewalls that should be used by default + when creating various interface types. + + NOTE: This feature may not currently be available to all users. + """ + + vpc_interface: Optional[int] = None + public_interface: Optional[int] = None + linode: Optional[int] = None + nodebalancer: Optional[int] = None + + +class FirewallSettings(Base): + """ + Represents the Firewall settings for the current user. + + API Documentation: Not yet available. + + NOTE: This feature may not currently be available to all users. + """ + + api_endpoint = "/networking/firewalls/settings" + + properties = { + "default_firewall_ids": Property( + json_object=FirewallSettingsDefaultFirewallIDs, + mutable=True, + ), + } + + class FirewallDevice(DerivedBase): """ An object representing the assignment between a Linode Firewall and another Linode resource. diff --git a/linode_api4/objects/vpc.py b/linode_api4/objects/vpc.py index 3c9a4aaba..94c0302f0 100644 --- a/linode_api4/objects/vpc.py +++ b/linode_api4/objects/vpc.py @@ -11,6 +11,7 @@ @dataclass class VPCSubnetLinodeInterface(JSONObject): id: int = 0 + config_id: Optional[int] = None active: bool = False diff --git a/test/fixtures/networking_firewalls_123_devices.json b/test/fixtures/networking_firewalls_123_devices.json index ae4efe2d0..e43e3725a 100644 --- a/test/fixtures/networking_firewalls_123_devices.json +++ b/test/fixtures/networking_firewalls_123_devices.json @@ -10,9 +10,20 @@ }, "id": 123, "updated": "2018-01-02T00:01:01" + }, + { + "created": "2018-01-01T00:01:01", + "entity": { + "id": 123, + "label": null, + "type": "interface", + "url": "/v4/linode/instances/123/interfaces/123" + }, + "id": 456, + "updated": "2018-01-02T00:01:01" } ], "page": 1, "pages": 1, - "results": 1 + "results": 2 } \ No newline at end of file diff --git a/test/fixtures/networking_firewalls_123_devices_456.json b/test/fixtures/networking_firewalls_123_devices_456.json new file mode 100644 index 000000000..aa76901ee --- /dev/null +++ b/test/fixtures/networking_firewalls_123_devices_456.json @@ -0,0 +1,11 @@ +{ + "created": "2018-01-01T00:01:01", + "entity": { + "id": 123, + "label": null, + "type": "interface", + "url": "/v4/linode/instances/123/interfaces/123" + }, + "id": 456, + "updated": "2018-01-02T00:01:01" +} \ No newline at end of file diff --git a/test/fixtures/networking_firewalls_settings.json b/test/fixtures/networking_firewalls_settings.json new file mode 100644 index 000000000..bfb7b2853 --- /dev/null +++ b/test/fixtures/networking_firewalls_settings.json @@ -0,0 +1,8 @@ +{ + "default_firewall_ids": { + "vpc_interface": 123, + "public_interface": 456, + "linode": 789, + "nodebalancer": 321 + } +} \ No newline at end of file diff --git a/test/fixtures/networking_ips_127.0.0.1.json b/test/fixtures/networking_ips_127.0.0.1.json index 9d3cfb449..7abb0fabd 100644 --- a/test/fixtures/networking_ips_127.0.0.1.json +++ b/test/fixtures/networking_ips_127.0.0.1.json @@ -2,6 +2,7 @@ "address": "127.0.0.1", "gateway": "127.0.0.1", "linode_id": 123, + "interface_id": 456, "prefix": 24, "public": true, "rdns": "test.example.org", diff --git a/test/fixtures/regions.json b/test/fixtures/regions.json index 5fe55e200..b58db045d 100644 --- a/test/fixtures/regions.json +++ b/test/fixtures/regions.json @@ -6,7 +6,8 @@ "capabilities": [ "Linodes", "NodeBalancers", - "Block Storage" + "Block Storage", + "Linode Interfaces" ], "status": "ok", "resolvers": { @@ -26,7 +27,8 @@ "capabilities": [ "Linodes", "NodeBalancers", - "Block Storage" + "Block Storage", + "Linode Interfaces" ], "status": "ok", "resolvers": { @@ -46,7 +48,8 @@ "capabilities": [ "Linodes", "NodeBalancers", - "Block Storage" + "Block Storage", + "Linode Interfaces" ], "status": "ok", "resolvers": { @@ -62,7 +65,8 @@ "capabilities": [ "Linodes", "NodeBalancers", - "Block Storage" + "Block Storage", + "Linode Interfaces" ], "status": "ok", "resolvers": { @@ -82,7 +86,8 @@ "capabilities": [ "Linodes", "NodeBalancers", - "Block Storage" + "Block Storage", + "Linode Interfaces" ], "status": "ok", "resolvers": { @@ -102,7 +107,8 @@ "capabilities": [ "Linodes", "NodeBalancers", - "Block Storage" + "Block Storage", + "Linode Interfaces" ], "status": "ok", "resolvers": { @@ -123,7 +129,8 @@ "Linodes", "NodeBalancers", "Block Storage", - "Object Storage" + "Object Storage", + "Linode Interfaces" ], "status": "ok", "resolvers": { @@ -143,7 +150,8 @@ "capabilities": [ "Linodes", "NodeBalancers", - "Block Storage" + "Block Storage", + "Linode Interfaces" ], "status": "ok", "resolvers": { @@ -164,7 +172,8 @@ "Linodes", "NodeBalancers", "Block Storage", - "Object Storage" + "Object Storage", + "Linode Interfaces" ], "status": "ok", "resolvers": { @@ -185,7 +194,8 @@ "Linodes", "NodeBalancers", "Block Storage", - "Object Storage" + "Object Storage", + "Linode Interfaces" ], "status": "ok", "resolvers": { @@ -205,7 +215,8 @@ "capabilities": [ "Linodes", "NodeBalancers", - "Block Storage" + "Block Storage", + "Linode Interfaces" ], "status": "ok", "resolvers": { diff --git a/test/fixtures/vpcs_123456_subnets.json b/test/fixtures/vpcs_123456_subnets.json index f846399df..37537efb2 100644 --- a/test/fixtures/vpcs_123456_subnets.json +++ b/test/fixtures/vpcs_123456_subnets.json @@ -10,11 +10,13 @@ "interfaces": [ { "id": 678, - "active": true + "active": true, + "config_id": null }, { "id": 543, - "active": false + "active": false, + "config_id": null } ] } diff --git a/test/fixtures/vpcs_123456_subnets_789.json b/test/fixtures/vpcs_123456_subnets_789.json index ba6973472..7fac495c4 100644 --- a/test/fixtures/vpcs_123456_subnets_789.json +++ b/test/fixtures/vpcs_123456_subnets_789.json @@ -8,11 +8,13 @@ "interfaces": [ { "id": 678, - "active": true + "active": true, + "config_id": null }, { "id": 543, - "active": false + "active": false, + "config_id": null } ] } diff --git a/test/integration/linode_client/test_linode_client.py b/test/integration/linode_client/test_linode_client.py index eb1b06369..da7e93cef 100644 --- a/test/integration/linode_client/test_linode_client.py +++ b/test/integration/linode_client/test_linode_client.py @@ -6,7 +6,11 @@ import pytest from linode_api4 import ApiError -from linode_api4.objects import ConfigInterface, ObjectStorageKeys, Region +from linode_api4.objects import ( + ConfigInterface, + ObjectStorageKeys, + Region, +) @pytest.fixture(scope="session") diff --git a/test/integration/models/networking/test_networking.py b/test/integration/models/networking/test_networking.py index b92cdfadc..bb77d4f0a 100644 --- a/test/integration/models/networking/test_networking.py +++ b/test/integration/models/networking/test_networking.py @@ -15,7 +15,11 @@ from linode_api4 import Instance, LinodeClient from linode_api4.objects import Config, ConfigInterfaceIPv4, Firewall, IPAddress -from linode_api4.objects.networking import NetworkTransferPrice, Price +from linode_api4.objects.networking import ( + FirewallCreateDevicesOptions, + NetworkTransferPrice, + Price, +) TEST_REGION = get_region( LinodeClient( @@ -73,6 +77,47 @@ def test_get_networking_rules(test_linode_client, test_firewall): assert "outbound_policy" in str(rules) +@pytest.fixture +def create_linode_without_firewall(test_linode_client): + """ + WARNING: This is specifically reserved for Firewall testing. + Don't use this if the Linode will not be assigned to a firewall. + """ + + client = test_linode_client + region = get_region(client, {"Cloud Firewall"}, "core").id + + label = get_test_label() + + instance = client.linode.instance_create( + "g6-nanode-1", + region, + label=label, + ) + + yield client, instance + + instance.delete() + + +@pytest.fixture +def create_firewall_with_device(create_linode_without_firewall): + client, target_instance = create_linode_without_firewall + + firewall = client.networking.firewall_create( + get_test_label(), + rules={ + "inbound_policy": "DROP", + "outbound_policy": "DROP", + }, + devices=FirewallCreateDevicesOptions(linodes=[target_instance.id]), + ) + + yield firewall, target_instance + + firewall.delete() + + def test_get_networking_rule_versions(test_linode_client, test_firewall): firewall = test_linode_client.load(Firewall, test_firewall.id) @@ -263,3 +308,43 @@ def test_create_and_delete_vlan(test_linode_client, linode_for_vlan_tests): ) assert is_deleted is True + + +def test_create_firewall_with_linode_device(create_firewall_with_device): + firewall, target_instance = create_firewall_with_device + + devices = firewall.devices + + assert len(devices) == 1 + assert devices[0].entity.id == target_instance.id + + +# TODO (Enhanced Interfaces): Add test for interface device + + +def test_get_global_firewall_settings(test_linode_client): + settings = test_linode_client.networking.firewall_settings() + + assert settings.default_firewall_ids is not None + assert all( + k in {"vpc_interface", "public_interface", "linode", "nodebalancer"} + for k in vars(settings.default_firewall_ids).keys() + ) + + +def test_ip_info(test_linode_client, create_linode): + linode = create_linode + + ip_info = test_linode_client.load(IPAddress, linode.ipv4[0]) + + assert ip_info.address == linode.ipv4[0] + assert ip_info.gateway is not None + assert ip_info.linode_id == linode.id + assert ip_info.interface_id is None + assert ip_info.prefix == 24 + assert ip_info.public + assert ip_info.rdns is not None + assert ip_info.region.id == linode.region.id + assert ip_info.subnet_mask is not None + assert ip_info.type == "ipv4" + assert ip_info.vpc_nat_1_1 is None diff --git a/test/unit/linode_client_test.py b/test/unit/linode_client_test.py index d2237a366..c01ead3bd 100644 --- a/test/unit/linode_client_test.py +++ b/test/unit/linode_client_test.py @@ -1,7 +1,7 @@ from datetime import datetime from test.unit.base import ClientBaseCase -from linode_api4 import LongviewSubscription +from linode_api4 import FirewallCreateDevicesOptions, LongviewSubscription from linode_api4.objects.beta import BetaProgram from linode_api4.objects.linode import Instance from linode_api4.objects.networking import IPAddress @@ -69,12 +69,18 @@ def test_get_regions(self): "NodeBalancers", "Block Storage", "Object Storage", + "Linode Interfaces", ], ) else: self.assertEqual( region.capabilities, - ["Linodes", "NodeBalancers", "Block Storage"], + [ + "Linodes", + "NodeBalancers", + "Block Storage", + "Linode Interfaces", + ], ) self.assertEqual(region.status, "ok") self.assertIsNotNone(region.resolvers) @@ -1191,7 +1197,12 @@ def test_firewall_create(self): } f = self.client.networking.firewall_create( - "test-firewall-1", rules, status="enabled" + "test-firewall-1", + rules, + devices=FirewallCreateDevicesOptions( + linodes=[123], nodebalancers=[456], interfaces=[789] + ), + status="enabled", ) self.assertIsNotNone(f) @@ -1206,6 +1217,11 @@ def test_firewall_create(self): "label": "test-firewall-1", "status": "enabled", "rules": rules, + "devices": { + "linodes": [123], + "nodebalancers": [456], + "interfaces": [789], + }, }, ) @@ -1220,6 +1236,47 @@ def test_get_firewalls(self): self.assertEqual(firewall.id, 123) + def test_get_firewall_settings(self): + """ + Tests that firewall settings can be retrieved + """ + settings = self.client.networking.firewall_settings() + + assert settings.default_firewall_ids.vpc_interface == 123 + assert settings.default_firewall_ids.public_interface == 456 + assert settings.default_firewall_ids.linode == 789 + assert settings.default_firewall_ids.nodebalancer == 321 + + settings.invalidate() + + assert settings.default_firewall_ids.vpc_interface == 123 + assert settings.default_firewall_ids.public_interface == 456 + assert settings.default_firewall_ids.linode == 789 + assert settings.default_firewall_ids.nodebalancer == 321 + + def test_update_firewall_settings(self): + """ + Tests that firewall settings can be updated + """ + settings = self.client.networking.firewall_settings() + + settings.default_firewall_ids.vpc_interface = 321 + settings.default_firewall_ids.public_interface = 654 + settings.default_firewall_ids.linode = 987 + settings.default_firewall_ids.nodebalancer = 123 + + with self.mock_put("networking/firewalls/settings") as m: + settings.save() + + assert m.call_data == { + "default_firewall_ids": { + "vpc_interface": 321, + "public_interface": 654, + "linode": 987, + "nodebalancer": 123, + } + } + def test_ip_addresses_share(self): """ Tests that you can submit a correct ip addresses share api request. diff --git a/test/unit/objects/firewall_test.py b/test/unit/objects/firewall_test.py index c3b54bf87..471a8b649 100644 --- a/test/unit/objects/firewall_test.py +++ b/test/unit/objects/firewall_test.py @@ -55,6 +55,21 @@ def test_update_rules(self): self.assertEqual(m.call_data, new_rules) + def test_create_device(self): + """ + Tests that firewall devices can be created successfully + """ + + firewall = Firewall(self.client, 123) + + with self.mock_post("networking/firewalls/123/devices/123") as m: + firewall.device_create(123, "linode") + assert m.call_data == {"id": 123, "type": "linode"} + + with self.mock_post("networking/firewalls/123/devices/456") as m: + firewall.device_create(123, "interface") + assert m.call_data == {"id": 123, "type": "interface"} + class FirewallDevicesTest(ClientBaseCase): """ @@ -66,7 +81,28 @@ def test_get_devices(self): Tests that devices can be pulled from a firewall """ firewall = Firewall(self.client, 123) - self.assertEqual(len(firewall.devices), 1) + assert len(firewall.devices) == 2 + + assert firewall.devices[0].created is not None + assert firewall.devices[0].id == 123 + assert firewall.devices[0].updated is not None + + assert firewall.devices[0].entity.id == 123 + assert firewall.devices[0].entity.label == "my-linode" + assert firewall.devices[0].entity.type == "linode" + assert firewall.devices[0].entity.url == "/v4/linode/instances/123" + + assert firewall.devices[1].created is not None + assert firewall.devices[1].id == 456 + assert firewall.devices[1].updated is not None + + assert firewall.devices[1].entity.id == 123 + assert firewall.devices[1].entity.label is None + assert firewall.devices[1].entity.type == "interface" + assert ( + firewall.devices[1].entity.url + == "/v4/linode/instances/123/interfaces/123" + ) def test_get_device(self): """ diff --git a/test/unit/objects/networking_test.py b/test/unit/objects/networking_test.py index f982dd6f7..583e06e64 100644 --- a/test/unit/objects/networking_test.py +++ b/test/unit/objects/networking_test.py @@ -121,16 +121,31 @@ def test_rdns_reset(self): self.assertEqual(m.call_data_raw, '{"rdns": null}') - def test_vpc_nat_1_1(self): + def test_get_ip(self): """ Tests that the vpc_nat_1_1 of an IP can be retrieved. """ ip = IPAddress(self.client, "127.0.0.1") - self.assertEqual(ip.vpc_nat_1_1.vpc_id, 242) - self.assertEqual(ip.vpc_nat_1_1.subnet_id, 194) - self.assertEqual(ip.vpc_nat_1_1.address, "139.144.244.36") + def __validate_ip(_ip: IPAddress): + assert _ip.address == "127.0.0.1" + assert _ip.gateway == "127.0.0.1" + assert _ip.linode_id == 123 + assert _ip.interface_id == 456 + assert _ip.prefix == 24 + assert _ip.public + assert _ip.rdns == "test.example.org" + assert _ip.region.id == "us-east" + assert _ip.subnet_mask == "255.255.255.0" + assert _ip.type == "ipv4" + assert _ip.vpc_nat_1_1.vpc_id == 242 + assert _ip.vpc_nat_1_1.subnet_id == 194 + assert _ip.vpc_nat_1_1.address == "139.144.244.36" + + __validate_ip(ip) + ip.invalidate() + __validate_ip(ip) def test_delete_ip(self): """ diff --git a/test/unit/objects/region_test.py b/test/unit/objects/region_test.py index 0bc1afa9e..6ae503098 100644 --- a/test/unit/objects/region_test.py +++ b/test/unit/objects/region_test.py @@ -15,7 +15,6 @@ def test_get_region(self): region = Region(self.client, "us-east") self.assertEqual(region.id, "us-east") - self.assertIsNotNone(region.capabilities) self.assertEqual(region.country, "us") self.assertEqual(region.label, "label7") self.assertEqual(region.status, "ok") @@ -28,6 +27,9 @@ def test_get_region(self): region.placement_group_limits.maximum_linodes_per_pg, 5 ) + self.assertIsNotNone(region.capabilities) + self.assertIn("Linode Interfaces", region.capabilities) + def test_region_availability(self): """ Tests that availability for a specific region can be listed and filtered on. diff --git a/test/unit/objects/vpc_test.py b/test/unit/objects/vpc_test.py index 5e7be1b69..7888bc101 100644 --- a/test/unit/objects/vpc_test.py +++ b/test/unit/objects/vpc_test.py @@ -118,11 +118,19 @@ def validate_vpc_subnet_789(self, subnet: VPCSubnet): "2018-01-01T00:01:01", DATE_FORMAT ) - self.assertEqual(subnet.label, "test-subnet") - self.assertEqual(subnet.ipv4, "10.0.0.0/24") - self.assertEqual(subnet.linodes[0].id, 12345) - self.assertEqual(subnet.created, expected_dt) - self.assertEqual(subnet.updated, expected_dt) + assert subnet.label == "test-subnet" + assert subnet.ipv4 == "10.0.0.0/24" + assert subnet.linodes[0].id == 12345 + assert subnet.created == expected_dt + assert subnet.updated == expected_dt + + 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 + + assert subnet.linodes[0].interfaces[1].id == 543 + assert not subnet.linodes[0].interfaces[1].active + assert subnet.linodes[0].interfaces[1].config_id is None def test_list_vpc_ips(self): """ From eedfecee1ee7dd3da621d95b7fdf2789f3ed9ab8 Mon Sep 17 00:00:00 2001 From: Lena Garber <114949949+lgarber-akamai@users.noreply.github.com> Date: Tue, 29 Apr 2025 13:45:48 -0400 Subject: [PATCH 04/12] Implement support for VPC Dual Stack (#524) --- linode_api4/groups/vpc.py | 20 ++- linode_api4/objects/linode.py | 121 +++++++++++++--- linode_api4/objects/networking.py | 11 +- 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 ++ 16 files changed, 554 insertions(+), 198 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 c70dd7965..3051df000 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, *args, **kwargs): + def _serialize(self, is_put: bool = False): purpose_formats = { "public": {"purpose": "public", "primary": self.primary}, "vlan": { @@ -412,11 +489,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, }, } @@ -426,11 +500,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): @@ -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]]): """ 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/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" + ) From 6e9a2047e5a3cdae6909025c3fcf44a584517908 Mon Sep 17 00:00:00 2001 From: Lena Garber <114949949+lgarber-akamai@users.noreply.github.com> Date: Tue, 20 May 2025 10:19:10 -0400 Subject: [PATCH 05/12] 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 --- linode_api4/groups/linode.py | 56 +- linode_api4/groups/networking.py | 64 ++- linode_api4/objects/__init__.py | 1 + linode_api4/objects/base.py | 1 + linode_api4/objects/linode.py | 253 +++++++++- linode_api4/objects/linode_interfaces.py | 477 ++++++++++++++++++ linode_api4/objects/networking.py | 32 +- test/fixtures/linode_instances.json | 49 +- test/fixtures/linode_instances_124.json | 43 ++ .../linode_instances_124_interfaces.json | 103 ++++ .../linode_instances_124_interfaces_123.json | 53 ++ ...nstances_124_interfaces_123_firewalls.json | 56 ++ .../linode_instances_124_interfaces_456.json | 28 + .../linode_instances_124_interfaces_789.json | 14 + ...ode_instances_124_interfaces_settings.json | 16 + ...node_instances_124_upgrade-interfaces.json | 105 ++++ test/integration/conftest.py | 81 ++- .../linode/interfaces/test_interfaces.py | 343 +++++++++++++ test/integration/models/linode/test_linode.py | 194 ++++++- test/unit/groups/linode_test.py | 41 +- test/unit/objects/linode_interface_test.py | 307 +++++++++++ test/unit/objects/linode_test.py | 175 ++++++- 22 files changed, 2446 insertions(+), 46 deletions(-) create mode 100644 linode_api4/objects/linode_interfaces.py create mode 100644 test/fixtures/linode_instances_124.json create mode 100644 test/fixtures/linode_instances_124_interfaces.json create mode 100644 test/fixtures/linode_instances_124_interfaces_123.json create mode 100644 test/fixtures/linode_instances_124_interfaces_123_firewalls.json create mode 100644 test/fixtures/linode_instances_124_interfaces_456.json create mode 100644 test/fixtures/linode_instances_124_interfaces_789.json create mode 100644 test/fixtures/linode_instances_124_interfaces_settings.json create mode 100644 test/fixtures/linode_instances_124_upgrade-interfaces.json create mode 100644 test/integration/models/linode/interfaces/test_interfaces.py create mode 100644 test/unit/objects/linode_interface_test.py diff --git a/linode_api4/groups/linode.py b/linode_api4/groups/linode.py index 48f0d43b6..d27d8680c 100644 --- a/linode_api4/groups/linode.py +++ b/linode_api4/groups/linode.py @@ -1,13 +1,11 @@ import base64 import os -from collections.abc import Iterable -from typing import Any, Dict, Optional, Union +from typing import Any, Dict, List, Optional, Union from linode_api4.common import load_and_validate_keys from linode_api4.errors import UnexpectedResponseError from linode_api4.groups import Group from linode_api4.objects import ( - ConfigInterface, Firewall, Instance, InstanceDiskEncryptionType, @@ -21,8 +19,13 @@ from linode_api4.objects.linode import ( Backup, InstancePlacementGroupAssignment, + InterfaceGeneration, + NetworkInterface, _expand_placement_group_assignment, ) +from linode_api4.objects.linode_interfaces import ( + LinodeInterfaceOptions, +) from linode_api4.util import drop_null_keys @@ -153,6 +156,13 @@ def instance_create( int, ] ] = None, + interfaces: Optional[ + List[ + Union[LinodeInterfaceOptions, NetworkInterface, Dict[str, Any]], + ] + ] = None, + interface_generation: Optional[Union[InterfaceGeneration, str]] = None, + network_helper: Optional[bool] = None, **kwargs, ): """ @@ -230,6 +240,30 @@ def instance_create( "us-east", backup=snapshot) + **Create an Instance with explicit interfaces:** + + To create a new Instance with explicit interfaces, provide list of + LinodeInterfaceOptions objects or dicts to the "interfaces" field:: + + linode, password = client.linode.instance_create( + "g6-standard-1", + "us-mia", + image="linode/ubuntu24.04", + + # This can be configured as an account-wide default + interface_generation=InterfaceGeneration.LINODE, + + interfaces=[ + LinodeInterfaceOptions( + default_route=LinodeInterfaceDefaultRouteOptions( + ipv4=True, + ipv6=True + ), + public=LinodeInterfacePublicOptions + ) + ] + ) + **Create an empty Instance** If you want to create an empty Instance that you will configure manually, @@ -293,9 +327,13 @@ def instance_create( :type disk_encryption: InstanceDiskEncryptionType or str :param interfaces: An array of Network Interfaces to add to this Linode’s Configuration Profile. At least one and up to three Interface objects can exist in this array. - :type interfaces: list[ConfigInterface] or list[dict[str, Any]] + :type interfaces: List[LinodeInterfaceOptions], List[NetworkInterface], or List[dict[str, Any]] :param placement_group: A Placement Group to create this Linode under. :type placement_group: Union[InstancePlacementGroupAssignment, PlacementGroup, Dict[str, Any], int] + :param interface_generation: The generation of network interfaces this Linode uses. + :type interface_generation: InterfaceGeneration or str + :param network_helper: Whether this instance should have Network Helper enabled. + :type network_helper: bool :returns: A new Instance object, or a tuple containing the new Instance and the generated password. @@ -311,13 +349,6 @@ def instance_create( ret_pass = Instance.generate_root_password() kwargs["root_pass"] = ret_pass - interfaces = kwargs.get("interfaces", None) - if interfaces is not None and isinstance(interfaces, Iterable): - kwargs["interfaces"] = [ - i._serialize() if isinstance(i, ConfigInterface) else i - for i in interfaces - ] - params = { "type": ltype, "region": region, @@ -336,6 +367,9 @@ def instance_create( if placement_group else None ), + "interfaces": interfaces, + "interface_generation": interface_generation, + "network_helper": network_helper, } params.update(kwargs) diff --git a/linode_api4/groups/networking.py b/linode_api4/groups/networking.py index b9cad485d..b16d12d9a 100644 --- a/linode_api4/groups/networking.py +++ b/linode_api4/groups/networking.py @@ -119,7 +119,7 @@ def firewall_templates(self, *filters): """ Returns a list of Firewall Templates available to the current user. - API Documentation: Not yet available. + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-firewall-templates NOTE: This feature may not currently be available to all users. @@ -135,7 +135,9 @@ def firewall_templates(self, *filters): def firewall_settings(self) -> FirewallSettings: """ Returns an object representing the Linode Firewall settings for the current user. - API Documentation: Not yet available. + + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-firewall-settings + NOTE: This feature may not currently be available to all users. :returns: An object representing the Linode Firewall settings for the current user. :rtype: FirewallSettings @@ -180,6 +182,64 @@ def ipv6_ranges(self, *filters): """ return self.client._get_and_filter(IPv6Range, *filters) + def ipv6_range_allocate( + self, + prefix_length: int, + route_target: Optional[str] = None, + linode: Optional[Union[Instance, int]] = None, + **kwargs, + ) -> IPv6Range: + """ + Creates an IPv6 Range and assigns it based on the provided Linode or route target IPv6 SLAAC address. + + API Documentation: https://techdocs.akamai.com/linode-api/reference/post-ipv6-range + + Create an IPv6 range assigned to a Linode by ID:: + + range = client.networking.ipv6_range_allocate(64, linode_id=123) + + + Create an IPv6 range assigned to a Linode by SLAAC:: + + range = client.networking.ipv6_range_allocate( + 64, + route_target=instance.ipv6.split("/")[0] + ) + + :param prefix_length: The prefix length of the IPv6 range. + :type prefix_length: int + :param route_target: The IPv6 SLAAC address to assign this range to. Required if linode is not specified. + :type route_target: str + :param linode: The ID of the Linode to assign this range to. + The SLAAC address for the provided Linode is used as the range's route_target. + Required if linode is not specified. + :type linode: Instance or int + + :returns: The new IPAddress. + :rtype: IPAddress + """ + + params = { + "prefix_length": prefix_length, + "route_target": route_target, + "linode_id": linode, + } + + params.update(**kwargs) + + result = self.client.post( + "/networking/ipv6/ranges", + data=drop_null_keys(_flatten_request_body_recursive(params)), + ) + + if not "range" in result: + raise UnexpectedResponseError( + "Unexpected response when allocating IPv6 range!", json=result + ) + + result = IPv6Range(self.client, result["range"], result) + return result + def ipv6_pools(self, *filters): """ Returns a list of IPv6 pools on this account. diff --git a/linode_api4/objects/__init__.py b/linode_api4/objects/__init__.py index b13fac51a..6667cba8d 100644 --- a/linode_api4/objects/__init__.py +++ b/linode_api4/objects/__init__.py @@ -6,6 +6,7 @@ from .region import Region from .image import Image from .linode import * +from .linode_interfaces import * from .volume import * from .domain import * from .account import * diff --git a/linode_api4/objects/base.py b/linode_api4/objects/base.py index c9a622edc..51a16eae0 100644 --- a/linode_api4/objects/base.py +++ b/linode_api4/objects/base.py @@ -239,6 +239,7 @@ def __setattr__(self, name, value): """ Enforces allowing editing of only Properties defined as mutable """ + if name in type(self).properties.keys(): if not type(self).properties[name].mutable: raise AttributeError( diff --git a/linode_api4/objects/linode.py b/linode_api4/objects/linode.py index c70dd7965..6eb389e7f 100644 --- a/linode_api4/objects/linode.py +++ b/linode_api4/objects/linode.py @@ -1,6 +1,7 @@ +import copy import string import sys -from dataclasses import dataclass +from dataclasses import dataclass, field from datetime import datetime from enum import Enum from os import urandom @@ -19,6 +20,14 @@ from linode_api4.objects.dbase import DerivedBase from linode_api4.objects.filtering import FilterableAttribute from linode_api4.objects.image import Image +from linode_api4.objects.linode_interfaces import ( + LinodeInterface, + LinodeInterfaceDefaultRouteOptions, + LinodeInterfacePublicOptions, + LinodeInterfacesSettings, + LinodeInterfaceVLANOptions, + LinodeInterfaceVPCOptions, +) from linode_api4.objects.networking import ( Firewall, IPAddress, @@ -653,6 +662,33 @@ class MigrationType: WARM = "warm" +class InterfaceGeneration(StrEnum): + """ + A string enum representing which interface generation a Linode is using. + """ + + LEGACY_CONFIG = "legacy_config" + LINODE = "linode" + + +@dataclass +class UpgradeInterfacesResult(JSONObject): + """ + Contains information about an Linode Interface upgrade operation. + + NOTE: If dry_run is True, each returned interface will be of type Dict[str, Any]. + Otherwise, each returned interface will be of type LinodeInterface. + + API Documentation: https://techdocs.akamai.com/linode-api/reference/post-upgrade-linode-interfaces + """ + + dry_run: bool = False + config_id: int = 0 + interfaces: List[Union[Dict[str, Any], LinodeInterface]] = field( + default_factory=list + ) + + class Instance(Base): """ A Linode Instance. @@ -686,6 +722,7 @@ class Instance(Base): "disk_encryption": Property(), "lke_cluster_id": Property(), "capabilities": Property(unordered=True), + "interface_generation": Property(), } @property @@ -696,8 +733,8 @@ def ips(self): API Documentation: https://techdocs.akamai.com/linode-api/reference/get-linode-ips - :returns: A List of the ips of the Linode Instance. - :rtype: List[IPAddress] + :returns: Information about the IP addresses assigned to this instance. + :rtype: MappedObject """ if not hasattr(self, "_ips"): result = self._client.get( @@ -962,6 +999,9 @@ def invalidate(self): if hasattr(self, "_placement_group"): del self._placement_group + if hasattr(self, "_interfaces"): + del self._interfaces + Base.invalidate(self) def boot(self, config=None): @@ -1846,6 +1886,213 @@ def stats_for(self, dt): model=self, ) + def interface_create( + self, + firewall: Optional[Union[Firewall, int]] = None, + default_route: Optional[ + Union[Dict[str, Any], LinodeInterfaceDefaultRouteOptions] + ] = None, + public: Optional[ + Union[Dict[str, Any], LinodeInterfacePublicOptions] + ] = None, + vlan: Optional[ + Union[Dict[str, Any], LinodeInterfaceVLANOptions] + ] = None, + vpc: Optional[Union[Dict[str, Any], LinodeInterfaceVPCOptions]] = None, + **kwargs, + ) -> LinodeInterface: + """ + Creates a new interface under this Linode. + Linode interfaces are not interchangeable with Config interfaces. + + NOTE: Linode interfaces may not currently be available to all users. + + API Documentation: https://techdocs.akamai.com/linode-api/reference/post-linode-interface + + Example: Creating a simple public interface for this Linode:: + + interface = instance.interface_create( + default_route=LinodeInterfaceDefaultRouteOptions( + ipv4=True, + ipv6=True + ), + public=LinodeInterfacePublicOptions() + ) + + Example: Creating a simple VPC interface for this Linode:: + + interface = instance.interface_create( + default_route=LinodeInterfaceDefaultRouteOptions( + ipv4=True + ), + vpc=LinodeInterfaceVPCOptions( + subnet_id=12345 + ) + ) + + Example: Creating a simple VLAN interface for this Linode:: + + interface = instance.interface_create( + default_route=LinodeInterfaceDefaultRouteOptions( + ipv4=True + ), + vlan=LinodeInterfaceVLANOptions( + vlan_label="my-vlan" + ) + ) + + :param firewall: The firewall this interface should be assigned to. + :param default_route: The desired default route configuration of the new interface. + :param public: The public-specific configuration of the new interface. + If set, the new instance will be a public interface. + :param vlan: The VLAN-specific configuration of the new interface. + If set, the new instance will be a VLAN interface. + :param vpc: The VPC-specific configuration of the new interface. + If set, the new instance will be a VPC interface. + + :returns: The newly created Linode Interface. + :rtype: LinodeInterface + """ + + params = { + "firewall_id": firewall, + "default_route": default_route, + "public": public, + "vlan": vlan, + "vpc": vpc, + } + + params.update(kwargs) + + result = self._client.post( + "{}/interfaces".format(Instance.api_endpoint), + model=self, + data=drop_null_keys(_flatten_request_body_recursive(params)), + ) + + if not "id" in result: + raise UnexpectedResponseError( + "Unexpected response creating config!", json=result + ) + + return LinodeInterface(self._client, result["id"], self.id, json=result) + + @property + def interfaces_settings(self) -> LinodeInterfacesSettings: + """ + The settings for all interfaces under this Linode. + + NOTE: Linode interfaces may not currently be available to all users. + + :returns: The settings for instance-level interface settings for this Linode. + :rtype: LinodeInterfacesSettings + """ + + # NOTE: We do not implement this as a Property because Property does + # not currently have a mechanism for 1:1 sub-entities. + + if not hasattr(self, "_interfaces_settings"): + self._set( + "_interfaces_settings", + # We don't use lazy loading here because it can trigger a known issue + # where setting fields for updates before the entity has been lazy loaded + # causes the user's value to be discarded. + self._client.load(LinodeInterfacesSettings, self.id), + ) + + return self._interfaces_settings + + @property + def interfaces(self) -> List[LinodeInterface]: + """ + All interfaces for this Linode. + + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-linode-interface + + :returns: An ordered list of interfaces under this Linode. + """ + + if not hasattr(self, "_interfaces"): + result = self._client.get( + "{}/interfaces".format(Instance.api_endpoint), + model=self, + ) + if "interfaces" not in result: + raise UnexpectedResponseError( + "Got unexpected response when retrieving Linode interfaces", + json=result, + ) + + self._set( + "_interfaces", + [ + LinodeInterface( + self._client, iface["id"], self.id, json=iface + ) + for iface in result["interfaces"] + ], + ) + + return self._interfaces + + def upgrade_interfaces( + self, + config: Optional[Union[Config, int]] = None, + dry_run: bool = False, + **kwargs, + ) -> UpgradeInterfacesResult: + """ + Automatically upgrades all legacy config interfaces of a + single configuration profile to Linode interfaces. + + NOTE: If dry_run is True, interfaces in the result will be + of type MappedObject rather than LinodeInterface. + + NOTE: Linode interfaces may not currently be available to all users. + + API Documentation: https://techdocs.akamai.com/linode-api/reference/post-upgrade-linode-interfaces + + :param config: The configuration profile the legacy interfaces to + upgrade are under. + :type config: Config or int + :param dry_run: Whether this operation should be a dry run, + which will return the interfaces that would be + created if the operation were completed. + :type dry_run: bool + + :returns: Information about the newly upgraded interfaces. + :rtype: UpgradeInterfacesResult + """ + params = {"config_id": config, "dry_run": dry_run} + + params.update(kwargs) + + result = self._client.post( + "{}/upgrade-interfaces".format(Instance.api_endpoint), + model=self, + data=_flatten_request_body_recursive(drop_null_keys(params)), + ) + + # This resolves an edge case where `result["interfaces"]` persists across + # multiple calls, which can cause parsing errors when expanding them below. + result = copy.deepcopy(result) + + self.invalidate() + + # We don't convert interface dicts to LinodeInterface objects on dry runs + # actual API entities aren't created. + if dry_run: + result["interfaces"] = [ + MappedObject(**iface) for iface in result["interfaces"] + ] + else: + result["interfaces"] = [ + LinodeInterface(self._client, iface["id"], self.id, iface) + for iface in result["interfaces"] + ] + + return UpgradeInterfacesResult.from_json(result) + class UserDefinedFieldType(Enum): text = 1 diff --git a/linode_api4/objects/linode_interfaces.py b/linode_api4/objects/linode_interfaces.py new file mode 100644 index 000000000..f12865c99 --- /dev/null +++ b/linode_api4/objects/linode_interfaces.py @@ -0,0 +1,477 @@ +from dataclasses import dataclass, field +from typing import List, Optional + +from linode_api4.objects.base import Base, Property +from linode_api4.objects.dbase import DerivedBase +from linode_api4.objects.networking import Firewall +from linode_api4.objects.serializable import JSONObject + + +@dataclass +class LinodeInterfacesSettingsDefaultRouteOptions(JSONObject): + """ + The options used to configure the default route settings for a Linode's network interfaces. + + NOTE: Linode interfaces may not currently be available to all users. + """ + + ipv4_interface_id: Optional[int] = None + ipv6_interface_id: Optional[int] = None + + +@dataclass +class LinodeInterfacesSettingsDefaultRoute(JSONObject): + """ + The default route settings for a Linode's network interfaces. + + NOTE: Linode interfaces may not currently be available to all users. + """ + + put_class = LinodeInterfacesSettingsDefaultRouteOptions + + ipv4_interface_id: Optional[int] = None + ipv4_eligible_interface_ids: List[int] = field(default_factory=list) + ipv6_interface_id: Optional[int] = None + ipv6_eligible_interface_ids: List[int] = field(default_factory=list) + + +class LinodeInterfacesSettings(Base): + """ + The settings related to a Linode's network interfaces. + + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-linode-interface-settings + + NOTE: Linode interfaces may not currently be available to all users. + """ + + api_endpoint = "/linode/instances/{id}/interfaces/settings" + + properties = { + "id": Property(identifier=True), + "network_helper": Property(mutable=True), + "default_route": Property( + mutable=True, json_object=LinodeInterfacesSettingsDefaultRoute + ), + } + + +# Interface POST Options +@dataclass +class LinodeInterfaceDefaultRouteOptions(JSONObject): + """ + Options accepted when creating or updating a Linode Interface's default route settings. + + NOTE: Linode interfaces may not currently be available to all users. + """ + + ipv4: Optional[bool] = None + ipv6: Optional[bool] = None + + +@dataclass +class LinodeInterfaceVPCIPv4AddressOptions(JSONObject): + """ + Options accepted for a single address when creating or updating the IPv4 configuration of a VPC Linode Interface. + + NOTE: Linode interfaces may not currently be available to all users. + """ + + address: Optional[str] = None + primary: Optional[bool] = None + nat_1_1_address: Optional[str] = None + + +@dataclass +class LinodeInterfaceVPCIPv4RangeOptions(JSONObject): + """ + Options accepted for a single range when creating or updating the IPv4 configuration of a VPC Linode Interface. + + NOTE: Linode interfaces may not currently be available to all users. + """ + + range: str = "" + + +@dataclass +class LinodeInterfaceVPCIPv4Options(JSONObject): + """ + Options accepted when creating or updating the IPv4 configuration of a VPC Linode Interface. + + NOTE: Linode interfaces may not currently be available to all users. + """ + + addresses: Optional[List[LinodeInterfaceVPCIPv4AddressOptions]] = None + ranges: Optional[List[LinodeInterfaceVPCIPv4RangeOptions]] = None + + +@dataclass +class LinodeInterfaceVPCOptions(JSONObject): + """ + VPC-exclusive options accepted when creating or updating a Linode Interface. + + NOTE: Linode interfaces may not currently be available to all users. + """ + + subnet_id: int = 0 + ipv4: Optional[LinodeInterfaceVPCIPv4Options] = None + + +@dataclass +class LinodeInterfacePublicIPv4AddressOptions(JSONObject): + """ + Options accepted for a single address when creating or updating the IPv4 configuration of a public Linode Interface. + + NOTE: Linode interfaces may not currently be available to all users. + """ + + address: str = "" + primary: Optional[bool] = None + + +@dataclass +class LinodeInterfacePublicIPv4Options(JSONObject): + """ + Options accepted when creating or updating the IPv4 configuration of a public Linode Interface. + + NOTE: Linode interfaces may not currently be available to all users. + """ + + addresses: Optional[List[LinodeInterfacePublicIPv4AddressOptions]] = None + + +@dataclass +class LinodeInterfacePublicIPv6RangeOptions(JSONObject): + """ + Options accepted for a single range when creating or updating the IPv6 configuration of a public Linode Interface. + + NOTE: Linode interfaces may not currently be available to all users. + """ + + range: str = "" + + +@dataclass +class LinodeInterfacePublicIPv6Options(JSONObject): + """ + Options accepted when creating or updating the IPv6 configuration of a public Linode Interface. + + NOTE: Linode interfaces may not currently be available to all users. + """ + + ranges: Optional[List[LinodeInterfacePublicIPv6RangeOptions]] = None + + +@dataclass +class LinodeInterfacePublicOptions(JSONObject): + """ + Public-exclusive options accepted when creating or updating a Linode Interface. + + NOTE: Linode interfaces may not currently be available to all users. + """ + + ipv4: Optional[LinodeInterfacePublicIPv4Options] = None + ipv6: Optional[LinodeInterfacePublicIPv6Options] = None + + +@dataclass +class LinodeInterfaceVLANOptions(JSONObject): + """ + VLAN-exclusive options accepted when creating or updating a Linode Interface. + + NOTE: Linode interfaces may not currently be available to all users. + """ + + vlan_label: str = "" + ipam_address: Optional[str] = None + + +@dataclass +class LinodeInterfaceOptions(JSONObject): + """ + Options accepted when creating or updating a Linode Interface. + + NOTE: Linode interfaces may not currently be available to all users. + """ + + always_include = { + # If a default firewall_id isn't configured, the API requires that + # firewall_id is defined in the LinodeInterface POST body. + "firewall_id" + } + + firewall_id: Optional[int] = None + default_route: Optional[LinodeInterfaceDefaultRouteOptions] = None + vpc: Optional[LinodeInterfaceVPCOptions] = None + public: Optional[LinodeInterfacePublicOptions] = None + vlan: Optional[LinodeInterfaceVLANOptions] = None + + +# Interface GET Response + + +@dataclass +class LinodeInterfaceDefaultRoute(JSONObject): + """ + The default route configuration of a Linode Interface. + + NOTE: Linode interfaces may not currently be available to all users. + """ + + put_class = LinodeInterfaceDefaultRouteOptions + + ipv4: bool = False + ipv6: bool = False + + +@dataclass +class LinodeInterfaceVPCIPv4Address(JSONObject): + """ + A single address under the IPv4 configuration of a VPC Linode Interface. + + NOTE: Linode interfaces may not currently be available to all users. + """ + + put_class = LinodeInterfaceVPCIPv4AddressOptions + + address: str = "" + primary: bool = False + nat_1_1_address: Optional[str] = None + + +@dataclass +class LinodeInterfaceVPCIPv4Range(JSONObject): + """ + A single range under the IPv4 configuration of a VPC Linode Interface. + + NOTE: Linode interfaces may not currently be available to all users. + """ + + put_class = LinodeInterfaceVPCIPv4RangeOptions + + range: str = "" + + +@dataclass +class LinodeInterfaceVPCIPv4(JSONObject): + """ + A single address under the IPv4 configuration of a VPC Linode Interface. + + NOTE: Linode interfaces may not currently be available to all users. + """ + + put_class = LinodeInterfaceVPCIPv4Options + + addresses: List[LinodeInterfaceVPCIPv4Address] = field(default_factory=list) + ranges: List[LinodeInterfaceVPCIPv4Range] = field(default_factory=list) + + +@dataclass +class LinodeInterfaceVPC(JSONObject): + """ + VPC-specific configuration field for a Linode Interface. + + NOTE: Linode interfaces may not currently be available to all users. + """ + + put_class = LinodeInterfaceVPCOptions + + vpc_id: int = 0 + subnet_id: int = 0 + + ipv4: Optional[LinodeInterfaceVPCIPv4] = None + + +@dataclass +class LinodeInterfacePublicIPv4Address(JSONObject): + """ + A single address under the IPv4 configuration of a public Linode Interface. + + NOTE: Linode interfaces may not currently be available to all users. + """ + + put_class = LinodeInterfacePublicIPv4AddressOptions + + address: str = "" + primary: bool = False + + +@dataclass +class LinodeInterfacePublicIPv4Shared(JSONObject): + """ + A single shared address under the IPv4 configuration of a public Linode Interface. + + NOTE: Linode interfaces may not currently be available to all users. + """ + + address: str = "" + linode_id: int = 0 + + +@dataclass +class LinodeInterfacePublicIPv4(JSONObject): + """ + The IPv4 configuration of a public Linode Interface. + + NOTE: Linode interfaces may not currently be available to all users. + """ + + put_class = LinodeInterfacePublicIPv4Options + + addresses: List[LinodeInterfacePublicIPv4Address] = field( + default_factory=list + ) + shared: List[LinodeInterfacePublicIPv4Shared] = field(default_factory=list) + + +@dataclass +class LinodeInterfacePublicIPv6SLAAC(JSONObject): + """ + A single SLAAC entry under the IPv6 configuration of a public Linode Interface. + + NOTE: Linode interfaces may not currently be available to all users. + """ + + address: str = "" + prefix: int = 0 + + +@dataclass +class LinodeInterfacePublicIPv6Shared(JSONObject): + """ + A single shared range under the IPv6 configuration of a public Linode Interface. + + NOTE: Linode interfaces may not currently be available to all users. + """ + + range: str = "" + route_target: Optional[str] = None + + +@dataclass +class LinodeInterfacePublicIPv6Range(JSONObject): + """ + A single range under the IPv6 configuration of a public Linode Interface. + + NOTE: Linode interfaces may not currently be available to all users. + """ + + put_class = LinodeInterfacePublicIPv6RangeOptions + + range: str = "" + route_target: Optional[str] = None + + +@dataclass +class LinodeInterfacePublicIPv6(JSONObject): + """ + The IPv6 configuration of a Linode Interface. + + NOTE: Linode interfaces may not currently be available to all users. + """ + + put_class = LinodeInterfacePublicIPv6Options + + slaac: List[LinodeInterfacePublicIPv6SLAAC] = field(default_factory=list) + shared: List[LinodeInterfacePublicIPv6Shared] = field(default_factory=list) + ranges: List[LinodeInterfacePublicIPv6Range] = field(default_factory=list) + + +@dataclass +class LinodeInterfacePublic(JSONObject): + """ + Public-specific configuration fields for a Linode Interface. + + NOTE: Linode interfaces may not currently be available to all users. + """ + + put_class = LinodeInterfacePublicOptions + + ipv4: Optional[LinodeInterfacePublicIPv4] = None + ipv6: Optional[LinodeInterfacePublicIPv6] = None + + +@dataclass +class LinodeInterfaceVLAN(JSONObject): + """ + VLAN-specific configuration fields for a Linode Interface. + + NOTE: Linode interfaces may not currently be available to all users. + """ + + put_class = LinodeInterfaceVLANOptions + + vlan_label: str = "" + ipam_address: Optional[str] = None + + +class LinodeInterface(DerivedBase): + """ + A Linode's network interface. + + NOTE: Linode interfaces may not currently be available to all users. + + NOTE: When using the ``save()`` method, certain local fields with computed values will + not be refreshed on the local object until after ``invalidate()`` has been called:: + + # Automatically assign an IPv4 address from the associated VPC Subnet + interface.vpc.ipv4.addresses[0].address = "auto" + + # Save the interface + interface.save() + + # Invalidate the interface + interface.invalidate() + + # Access the new address + print(interface.vpc.ipv4.addresses[0].address) + + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-linode-interface + """ + + api_endpoint = "/linode/instances/{linode_id}/interfaces/{id}" + derived_url_path = "interfaces" + parent_id_name = "linode_id" + + properties = { + "linode_id": Property(identifier=True), + "id": Property(identifier=True), + "mac_address": Property(), + "created": Property(is_datetime=True), + "updated": Property(is_datetime=True), + "version": Property(), + "default_route": Property( + mutable=True, + json_object=LinodeInterfaceDefaultRoute, + ), + "public": Property(mutable=True, json_object=LinodeInterfacePublic), + "vlan": Property(mutable=True, json_object=LinodeInterfaceVLAN), + "vpc": Property(mutable=True, json_object=LinodeInterfaceVPC), + } + + def firewalls(self, *filters) -> List[Firewall]: + """ + Retrieves a list of Firewalls for this Linode Interface. + Linode interfaces are not interchangeable with Config interfaces. + + NOTE: Linode interfaces may not currently be available to all users. + + :param filters: Any number of filters to apply to this query. + See :doc:`Filtering Collections` + for more details on filtering. + + :returns: A List of Firewalls for this Linode Interface. + :rtype: List[Firewall] + + NOTE: Caching is disabled on this method and each call will make + an additional Linode API request. + + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-linode-interface-firewalls + """ + + return self._client._get_and_filter( + Firewall, + *filters, + endpoint="{}/firewalls".format(LinodeInterface.api_endpoint).format( + **vars(self) + ), + ) diff --git a/linode_api4/objects/networking.py b/linode_api4/objects/networking.py index 74a9ab283..ca7758a76 100644 --- a/linode_api4/objects/networking.py +++ b/linode_api4/objects/networking.py @@ -98,9 +98,35 @@ def linode(self): if not hasattr(self, "_linode"): self._set("_linode", Instance(self._client, self.linode_id)) + return self._linode - # TODO (Enhanced Interfaces): Add `interface` property method + @property + def interface(self) -> Optional["LinodeInterface"]: + """ + Returns the Linode Interface associated with this IP address. + + NOTE: This function will only return Linode interfaces, not Config interfaces. + + NOTE: Linode interfaces may not currently be available to all users. + + :returns: The Linode Interface associated with this IP address. + :rtype: LinodeInterface + """ + + from .linode_interfaces import LinodeInterface # pylint: disable-all + + if self.interface_id in (None, 0): + self._set("_interface", None) + elif not hasattr(self, "_interface"): + self._set( + "_interface", + LinodeInterface( + self._client, self.linode_id, self.interface_id + ), + ) + + return self._interface def to(self, linode): """ @@ -209,7 +235,7 @@ class FirewallSettings(Base): """ Represents the Firewall settings for the current user. - API Documentation: Not yet available. + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-firewall-settings NOTE: This feature may not currently be available to all users. """ @@ -359,7 +385,7 @@ class FirewallTemplate(Base): """ Represents a single Linode Firewall template. - API documentation: Not yet available. + API documentation: https://techdocs.akamai.com/linode-api/reference/get-firewall-template NOTE: This feature may not currently be available to all users. """ diff --git a/test/fixtures/linode_instances.json b/test/fixtures/linode_instances.json index 38a3cf912..cefda000d 100644 --- a/test/fixtures/linode_instances.json +++ b/test/fixtures/linode_instances.json @@ -38,7 +38,9 @@ ], "updated": "2017-01-01T00:00:00", "image": "linode/ubuntu17.04", - "tags": ["something"], + "tags": [ + "something" + ], "host_uuid": "3a3ddd59d9a78bb8de041391075df44de62bfec8", "watchdog_enabled": true, "disk_encryption": "disabled", @@ -91,6 +93,51 @@ "disk_encryption": "enabled", "lke_cluster_id": 18881, "placement_group": null + }, + { + "id": 124, + "status": "running", + "type": "g6-standard-1", + "alerts": { + "network_in": 5, + "network_out": 5, + "cpu": 90, + "transfer_quota": 80, + "io": 5000 + }, + "group": "test", + "hypervisor": "kvm", + "label": "linode124", + "backups": { + "enabled": true, + "schedule": { + "window": "W02", + "day": "Scheduling" + } + }, + "specs": { + "memory": 2048, + "disk": 30720, + "vcpus": 1, + "transfer": 2000 + }, + "ipv6": "1235:abcd::1234:abcd:89ef:67cd/64", + "created": "2017-01-01T00:00:00", + "region": "us-east-1", + "ipv4": [ + "124.45.67.89" + ], + "updated": "2017-01-01T00:00:00", + "image": "linode/ubuntu24.04", + "tags": [ + "something" + ], + "host_uuid": "3b3ddd59d9a78bb8de041391075df44de62bfec8", + "watchdog_enabled": true, + "disk_encryption": "disabled", + "lke_cluster_id": null, + "placement_group": null, + "interface_generation": "linode" } ] } diff --git a/test/fixtures/linode_instances_124.json b/test/fixtures/linode_instances_124.json new file mode 100644 index 000000000..6c059ba41 --- /dev/null +++ b/test/fixtures/linode_instances_124.json @@ -0,0 +1,43 @@ +{ + "id": 124, + "status": "running", + "type": "g6-standard-1", + "alerts": { + "network_in": 5, + "network_out": 5, + "cpu": 90, + "transfer_quota": 80, + "io": 5000 + }, + "group": "test", + "hypervisor": "kvm", + "label": "linode124", + "backups": { + "enabled": true, + "schedule": { + "window": "W02", + "day": "Scheduling" + } + }, + "specs": { + "memory": 2048, + "disk": 30720, + "vcpus": 1, + "transfer": 2000 + }, + "ipv6": "1235:abcd::1234:abcd:89ef:67cd/64", + "created": "2017-01-01T00:00:00", + "region": "us-east-1", + "ipv4": [ + "124.45.67.89" + ], + "updated": "2017-01-01T00:00:00", + "image": "linode/ubuntu24.04", + "tags": ["something"], + "host_uuid": "3b3ddd59d9a78bb8de041391075df44de62bfec8", + "watchdog_enabled": true, + "disk_encryption": "disabled", + "lke_cluster_id": null, + "placement_group": null, + "interface_generation": "linode" +} \ No newline at end of file diff --git a/test/fixtures/linode_instances_124_interfaces.json b/test/fixtures/linode_instances_124_interfaces.json new file mode 100644 index 000000000..a0ffddef6 --- /dev/null +++ b/test/fixtures/linode_instances_124_interfaces.json @@ -0,0 +1,103 @@ +{ + "interfaces": [ + { + "created": "2025-01-01T00:01:01", + "default_route": { + "ipv4": true, + "ipv6": true + }, + "id": 123, + "mac_address": "22:00:AB:CD:EF:01", + "public": { + "ipv4": { + "addresses": [ + { + "address": "172.30.0.50", + "primary": true + } + ], + "shared": [ + { + "address": "172.30.0.51", + "linode_id": 125 + } + ] + }, + "ipv6": { + "ranges": [ + { + "range": "2600:3cO9:e001:59::/64", + "route_target": "2600:3cO9::ff:feab:cdef" + }, + { + "range": "2600:3cO9:e001:5a::/64", + "route_target": "2600:3cO9::ff:feab:cdef" + } + ], + "shared": [ + { + "range": "2600:3cO9:e001:2a::/64", + "route_target": null + } + ], + "slaac": [ + { + "address": "2600:3cO9::ff:feab:cdef", + "prefix": 64 + } + ] + } + }, + "updated": "2025-01-01T00:01:01", + "version": 1, + "vlan": null, + "vpc": null + }, + { + "id": 456, + "mac_address": "22:00:AB:CD:EF:01", + "created": "2024-01-01T00:01:01", + "updated": "2024-01-01T00:01:01", + "default_route": { + "ipv4": true + }, + "version": 1, + "vpc": { + "vpc_id": 123456, + "subnet_id": 789, + "ipv4": { + "addresses": [ + { + "address": "192.168.22.3", + "primary": true + } + ], + "ranges": [ + { + "range": "192.168.22.16/28" + }, + { + "range": "192.168.22.32/28" + } + ] + } + }, + "public": null, + "vlan": null + }, + { + "id": 789, + "mac_address": "22:00:AB:CD:EF:01", + "created": "2024-01-01T00:01:01", + "updated": "2024-01-01T00:01:01", + "default_route": {}, + "version": 1, + "vpc": null, + "public": null, + "vlan": { + "vlan_label": "my_vlan", + "ipam_address": "10.0.0.1/24" + } + } + ] +} \ No newline at end of file diff --git a/test/fixtures/linode_instances_124_interfaces_123.json b/test/fixtures/linode_instances_124_interfaces_123.json new file mode 100644 index 000000000..333823698 --- /dev/null +++ b/test/fixtures/linode_instances_124_interfaces_123.json @@ -0,0 +1,53 @@ +{ + "created": "2025-01-01T00:01:01", + "default_route": { + "ipv4": true, + "ipv6": true + }, + "id": 123, + "mac_address": "22:00:AB:CD:EF:01", + "public": { + "ipv4": { + "addresses": [ + { + "address": "172.30.0.50", + "primary": true + } + ], + "shared": [ + { + "address": "172.30.0.51", + "linode_id": 125 + } + ] + }, + "ipv6": { + "ranges": [ + { + "range": "2600:3cO9:e001:59::/64", + "route_target": "2600:3cO9::ff:feab:cdef" + }, + { + "range": "2600:3cO9:e001:5a::/64", + "route_target": "2600:3cO9::ff:feab:cdef" + } + ], + "shared": [ + { + "range": "2600:3cO9:e001:2a::/64", + "route_target": null + } + ], + "slaac": [ + { + "address": "2600:3cO9::ff:feab:cdef", + "prefix": 64 + } + ] + } + }, + "updated": "2025-01-01T00:01:01", + "version": 1, + "vlan": null, + "vpc": null +} \ No newline at end of file diff --git a/test/fixtures/linode_instances_124_interfaces_123_firewalls.json b/test/fixtures/linode_instances_124_interfaces_123_firewalls.json new file mode 100644 index 000000000..17a4a9199 --- /dev/null +++ b/test/fixtures/linode_instances_124_interfaces_123_firewalls.json @@ -0,0 +1,56 @@ +{ + "data": [ + { + "created": "2018-01-01T00:01:01", + "id": 123, + "label": "firewall123", + "rules": { + "inbound": [ + { + "action": "ACCEPT", + "addresses": { + "ipv4": [ + "192.0.2.0/24" + ], + "ipv6": [ + "2001:DB8::/32" + ] + }, + "description": "An example firewall rule description.", + "label": "firewallrule123", + "ports": "22-24, 80, 443", + "protocol": "TCP" + } + ], + "inbound_policy": "DROP", + "outbound": [ + { + "action": "ACCEPT", + "addresses": { + "ipv4": [ + "192.0.2.0/24" + ], + "ipv6": [ + "2001:DB8::/32" + ] + }, + "description": "An example firewall rule description.", + "label": "firewallrule123", + "ports": "22-24, 80, 443", + "protocol": "TCP" + } + ], + "outbound_policy": "DROP" + }, + "status": "enabled", + "tags": [ + "example tag", + "another example" + ], + "updated": "2018-01-02T00:01:01" + } + ], + "page": 1, + "pages": 1, + "results": 1 +} diff --git a/test/fixtures/linode_instances_124_interfaces_456.json b/test/fixtures/linode_instances_124_interfaces_456.json new file mode 100644 index 000000000..7fc4f56f8 --- /dev/null +++ b/test/fixtures/linode_instances_124_interfaces_456.json @@ -0,0 +1,28 @@ +{ + "id": 456, + "mac_address": "22:00:AB:CD:EF:01", + "created": "2024-01-01T00:01:01", + "updated": "2024-01-01T00:01:01", + "default_route": { + "ipv4":true + }, + "version": 1, + "vpc": { + "vpc_id": 123456, + "subnet_id": 789, + "ipv4" : { + "addresses": [ + { + "address": "192.168.22.3", + "primary": true + } + ], + "ranges": [ + { "range": "192.168.22.16/28"}, + { "range": "192.168.22.32/28"} + ] + } + }, + "public": null, + "vlan": null +} diff --git a/test/fixtures/linode_instances_124_interfaces_789.json b/test/fixtures/linode_instances_124_interfaces_789.json new file mode 100644 index 000000000..d533b8e21 --- /dev/null +++ b/test/fixtures/linode_instances_124_interfaces_789.json @@ -0,0 +1,14 @@ +{ + "id": 789, + "mac_address": "22:00:AB:CD:EF:01", + "created": "2024-01-01T00:01:01", + "updated": "2024-01-01T00:01:01", + "default_route": {}, + "version": 1, + "vpc": null, + "public": null, + "vlan": { + "vlan_label": "my_vlan", + "ipam_address": "10.0.0.1/24" + } +} diff --git a/test/fixtures/linode_instances_124_interfaces_settings.json b/test/fixtures/linode_instances_124_interfaces_settings.json new file mode 100644 index 000000000..b454c438e --- /dev/null +++ b/test/fixtures/linode_instances_124_interfaces_settings.json @@ -0,0 +1,16 @@ +{ + "network_helper": true, + "default_route": { + "ipv4_interface_id": 123, + "ipv4_eligible_interface_ids": [ + 123, + 456, + 789 + ], + "ipv6_interface_id": 456, + "ipv6_eligible_interface_ids": [ + 123, + 456 + ] + } +} \ No newline at end of file diff --git a/test/fixtures/linode_instances_124_upgrade-interfaces.json b/test/fixtures/linode_instances_124_upgrade-interfaces.json new file mode 100644 index 000000000..12340c4a3 --- /dev/null +++ b/test/fixtures/linode_instances_124_upgrade-interfaces.json @@ -0,0 +1,105 @@ +{ + "dry_run": true, + "config_id": 123, + "interfaces": [ + { + "created": "2025-01-01T00:01:01", + "default_route": { + "ipv4": true, + "ipv6": true + }, + "id": 123, + "mac_address": "22:00:AB:CD:EF:01", + "public": { + "ipv4": { + "addresses": [ + { + "address": "172.30.0.50", + "primary": true + } + ], + "shared": [ + { + "address": "172.30.0.51", + "linode_id": 125 + } + ] + }, + "ipv6": { + "ranges": [ + { + "range": "2600:3cO9:e001:59::/64", + "route_target": "2600:3cO9::ff:feab:cdef" + }, + { + "range": "2600:3cO9:e001:5a::/64", + "route_target": "2600:3cO9::ff:feab:cdef" + } + ], + "shared": [ + { + "range": "2600:3cO9:e001:2a::/64", + "route_target": null + } + ], + "slaac": [ + { + "address": "2600:3cO9::ff:feab:cdef", + "prefix": 64 + } + ] + } + }, + "updated": "2025-01-01T00:01:01", + "version": 1, + "vlan": null, + "vpc": null + }, + { + "id": 456, + "mac_address": "22:00:AB:CD:EF:01", + "created": "2024-01-01T00:01:01", + "updated": "2024-01-01T00:01:01", + "default_route": { + "ipv4": true + }, + "version": 1, + "vpc": { + "vpc_id": 123456, + "subnet_id": 789, + "ipv4": { + "addresses": [ + { + "address": "192.168.22.3", + "primary": true + } + ], + "ranges": [ + { + "range": "192.168.22.16/28" + }, + { + "range": "192.168.22.32/28" + } + ] + } + }, + "public": null, + "vlan": null + }, + { + "id": 789, + "mac_address": "22:00:AB:CD:EF:01", + "created": "2024-01-01T00:01:01", + "updated": "2024-01-01T00:01:01", + "default_route": {}, + "version": 1, + "vpc": null, + "public": null, + "vlan": { + "vlan_label": "my_vlan", + "ipam_address": "10.0.0.1/24" + } + } + ] +} \ No newline at end of file diff --git a/test/integration/conftest.py b/test/integration/conftest.py index 8c7d44a57..dfa01abed 100644 --- a/test/integration/conftest.py +++ b/test/integration/conftest.py @@ -12,7 +12,16 @@ import requests from requests.exceptions import ConnectionError, RequestException -from linode_api4 import PlacementGroupPolicy, PlacementGroupType +from linode_api4 import ( + InterfaceGeneration, + LinodeInterfaceDefaultRouteOptions, + LinodeInterfaceOptions, + LinodeInterfacePublicOptions, + LinodeInterfaceVLANOptions, + LinodeInterfaceVPCOptions, + PlacementGroupPolicy, + PlacementGroupType, +) from linode_api4.linode_client import LinodeClient from linode_api4.objects import Region @@ -521,3 +530,73 @@ def linode_for_vlan_tests(test_linode_client, e2e_test_firewall): yield linode_instance linode_instance.delete() + + +@pytest.fixture(scope="function") +def linode_with_interface_generation_linode( + test_linode_client, + e2e_test_firewall, + # We won't be using this all the time, but it's + # necessary for certain consumers of this fixture + create_vpc_with_subnet, +): + client = test_linode_client + + label = get_test_label() + + instance = client.linode.instance_create( + "g6-nanode-1", + create_vpc_with_subnet[0].region, + label=label, + interface_generation=InterfaceGeneration.LINODE, + booted=False, + ) + + yield instance + + instance.delete() + + +@pytest.fixture(scope="function") +def linode_with_linode_interfaces( + test_linode_client, e2e_test_firewall, create_vpc_with_subnet +): + client = test_linode_client + vpc, subnet = create_vpc_with_subnet + + # Are there regions where VPCs are supported but Linode Interfaces aren't? + region = vpc.region + label = get_test_label() + + instance, _ = client.linode.instance_create( + "g6-nanode-1", + region, + image="linode/debian12", + label=label, + booted=False, + interface_generation=InterfaceGeneration.LINODE, + interfaces=[ + LinodeInterfaceOptions( + firewall_id=e2e_test_firewall.id, + default_route=LinodeInterfaceDefaultRouteOptions( + ipv4=True, + ipv6=True, + ), + public=LinodeInterfacePublicOptions(), + ), + LinodeInterfaceOptions( + vpc=LinodeInterfaceVPCOptions( + subnet_id=subnet.id, + ), + ), + LinodeInterfaceOptions( + vlan=LinodeInterfaceVLANOptions( + vlan_label="test-vlan", ipam_address="10.0.0.5/32" + ), + ), + ], + ) + + yield instance + + instance.delete() diff --git a/test/integration/models/linode/interfaces/test_interfaces.py b/test/integration/models/linode/interfaces/test_interfaces.py new file mode 100644 index 000000000..6a81bb8bc --- /dev/null +++ b/test/integration/models/linode/interfaces/test_interfaces.py @@ -0,0 +1,343 @@ +import copy +import ipaddress + +import pytest + +from linode_api4 import ( + ApiError, + Instance, + LinodeInterface, + LinodeInterfaceDefaultRouteOptions, + LinodeInterfacePublicIPv4AddressOptions, + LinodeInterfacePublicIPv4Options, + LinodeInterfacePublicIPv6Options, + LinodeInterfacePublicIPv6RangeOptions, + LinodeInterfacePublicOptions, + LinodeInterfaceVLANOptions, + LinodeInterfaceVPCIPv4Options, + LinodeInterfaceVPCIPv4RangeOptions, + LinodeInterfaceVPCOptions, +) + + +def test_linode_create_with_linode_interfaces( + create_vpc_with_subnet, + linode_with_linode_interfaces, +): + instance: Instance = linode_with_linode_interfaces + vpc, subnet = create_vpc_with_subnet + + def __assert_base(iface: LinodeInterface): + assert iface.id is not None + assert iface.linode_id == instance.id + + assert iface.created is not None + assert iface.updated is not None + + assert isinstance(iface.mac_address, str) + assert iface.version + + def __assert_public(iface: LinodeInterface): + __assert_base(iface) + + assert iface.default_route.ipv4 + assert iface.default_route.ipv6 + + assert iface.public.ipv4.addresses[0].address == instance.ipv4[0] + assert iface.public.ipv4.addresses[0].primary + assert len(iface.public.ipv4.shared) == 0 + + assert iface.public.ipv6.slaac[0].address == instance.ipv6.split("/")[0] + assert iface.public.ipv6.slaac[0].prefix == 64 + assert len(iface.public.ipv6.shared) == 0 + assert len(iface.public.ipv6.ranges) == 0 + + def __assert_vpc(iface: LinodeInterface): + __assert_base(iface) + + assert not iface.default_route.ipv4 + assert not iface.default_route.ipv6 + + assert iface.vpc.vpc_id == vpc.id + assert iface.vpc.subnet_id == subnet.id + + assert ipaddress.ip_address( + iface.vpc.ipv4.addresses[0].address + ) in ipaddress.ip_network(subnet.ipv4) + assert iface.vpc.ipv4.addresses[0].primary + assert iface.vpc.ipv4.addresses[0].nat_1_1_address is None + + assert len(iface.vpc.ipv4.ranges) == 0 + + def __assert_vlan(iface: LinodeInterface): + __assert_base(iface) + + assert not iface.default_route.ipv4 + assert not iface.default_route.ipv6 + + assert iface.vlan.vlan_label == "test-vlan" + assert iface.vlan.ipam_address == "10.0.0.5/32" + + __assert_public(instance.interfaces[0]) + __assert_vpc(instance.interfaces[1]) + __assert_vlan(instance.interfaces[2]) + + instance.invalidate() + + __assert_public(instance.interfaces[0]) + __assert_vpc(instance.interfaces[1]) + __assert_vlan(instance.interfaces[2]) + + +@pytest.fixture +def linode_interface_public( + test_linode_client, + e2e_test_firewall, + linode_with_interface_generation_linode, +): + instance: Instance = linode_with_interface_generation_linode + + ipv6_range = test_linode_client.networking.ipv6_range_allocate( + 64, linode=instance.id + ) + + yield instance.interface_create( + firewall_id=e2e_test_firewall.id, + default_route=LinodeInterfaceDefaultRouteOptions( + ipv4=True, + ipv6=True, + ), + public=LinodeInterfacePublicOptions( + ipv4=LinodeInterfacePublicIPv4Options( + addresses=[ + LinodeInterfacePublicIPv4AddressOptions( + address=instance.ips.ipv4.public[0].address, + primary=True, + ) + ] + ), + ipv6=LinodeInterfacePublicIPv6Options( + ranges=[ + LinodeInterfacePublicIPv6RangeOptions( + range=ipv6_range.range, + ) + ] + ), + ), + ), instance, ipv6_range + + +@pytest.fixture +def linode_interface_vpc( + test_linode_client, + e2e_test_firewall, + linode_with_interface_generation_linode, + create_vpc_with_subnet, +): + instance: Instance = linode_with_interface_generation_linode + vpc, subnet = create_vpc_with_subnet + + yield instance.interface_create( + firewall_id=e2e_test_firewall.id, + default_route=LinodeInterfaceDefaultRouteOptions( + ipv4=True, + ), + vpc=LinodeInterfaceVPCOptions( + subnet_id=subnet.id, + ipv4=LinodeInterfaceVPCIPv4Options( + # TODO (Enhanced Interfaces): Not currently working as expected + # addresses=[ + # LinodeInterfaceVPCIPv4AddressOptions( + # address="auto", + # primary=True, + # nat_1_1_address="any", + # ) + # ], + ranges=[ + LinodeInterfaceVPCIPv4RangeOptions( + range="/29", + ) + ] + ), + ), + ), instance, vpc, subnet + + +@pytest.fixture +def linode_interface_vlan( + test_linode_client, + e2e_test_firewall, + linode_with_interface_generation_linode, + create_vpc_with_subnet, +): + instance: Instance = linode_with_interface_generation_linode + + yield instance.interface_create( + vlan=LinodeInterfaceVLANOptions( + vlan_label="test-vlan", ipam_address="10.0.0.5/32" + ), + ), instance + + +def test_linode_interface_create_public(linode_interface_public): + iface, instance, ipv6_range = linode_interface_public + + assert iface.id is not None + assert iface.linode_id == instance.id + + assert iface.created is not None + assert iface.updated is not None + + assert isinstance(iface.mac_address, str) + assert iface.version + + assert iface.default_route.ipv4 + assert iface.default_route.ipv6 + + assert ( + iface.public.ipv4.addresses[0].address + == instance.ips.ipv4.public[0].address + ) + assert iface.public.ipv4.addresses[0].primary + assert len(iface.public.ipv4.shared) == 0 + + assert iface.public.ipv6.ranges[0].range == ipv6_range.range + assert ( + iface.public.ipv6.ranges[0].route_target == instance.ipv6.split("/")[0] + ) + assert iface.public.ipv6.slaac[0].address == instance.ipv6.split("/")[0] + assert iface.public.ipv6.slaac[0].prefix == 64 + assert len(iface.public.ipv6.shared) == 0 + + +def test_linode_interface_update_public(linode_interface_public): + iface, instance, ipv6_range = linode_interface_public + + old_public_ipv4 = copy.deepcopy(iface.public.ipv4) + + iface.public.ipv4.addresses += [ + LinodeInterfacePublicIPv4AddressOptions(address="auto", primary=True) + ] + iface.public.ipv4.addresses[0].primary = False + + iface.public.ipv6.ranges[0].range = "/64" + + iface.save() + + iface.invalidate() + + assert len(iface.public.ipv4.addresses) == 2 + + address = iface.public.ipv4.addresses[0] + assert address.address == old_public_ipv4.addresses[0].address + assert not address.primary + + address = iface.public.ipv4.addresses[1] + assert ipaddress.ip_address(address.address) + assert address.primary + + assert len(iface.public.ipv6.ranges) == 1 + + range = iface.public.ipv6.ranges[0] + assert len(range.range) > 0 + assert ipaddress.ip_network(range.range) + + +def test_linode_interface_create_vpc(linode_interface_vpc): + iface, instance, vpc, subnet = linode_interface_vpc + + assert iface.id is not None + assert iface.linode_id == instance.id + + assert iface.created is not None + assert iface.updated is not None + + assert isinstance(iface.mac_address, str) + assert iface.version + + assert iface.default_route.ipv4 + assert not iface.default_route.ipv6 + + assert iface.vpc.vpc_id == vpc.id + assert iface.vpc.subnet_id == subnet.id + + assert len(iface.vpc.ipv4.addresses[0].address) > 0 + assert iface.vpc.ipv4.addresses[0].primary + assert iface.vpc.ipv4.addresses[0].nat_1_1_address is None + + assert iface.vpc.ipv4.ranges[0].range.split("/")[1] == "29" + + +def test_linode_interface_update_vpc(linode_interface_vpc): + iface, instance, vpc, subnet = linode_interface_vpc + + iface.vpc.subnet_id = 0 + + try: + iface.save() + except ApiError: + pass + else: + raise Exception("Expected error when updating subnet_id to 0") + + iface.invalidate() + + old_ipv4 = copy.deepcopy(iface.vpc.ipv4) + + iface.vpc.ipv4.addresses[0].address = "auto" + iface.vpc.ipv4.ranges += [ + LinodeInterfaceVPCIPv4RangeOptions( + range="/32", + ) + ] + + iface.save() + iface.invalidate() + + address = iface.vpc.ipv4.addresses[0] + assert ipaddress.ip_address(address.address) + + range = iface.vpc.ipv4.ranges[0] + assert ipaddress.ip_network(range.range) + assert range.range == old_ipv4.ranges[0].range + + range = iface.vpc.ipv4.ranges[1] + assert ipaddress.ip_network(range.range) + assert range.range != old_ipv4.ranges[0].range + + +def test_linode_interface_create_vlan( + linode_interface_vlan, +): + iface, instance = linode_interface_vlan + + assert iface.id is not None + assert iface.linode_id == instance.id + + assert iface.created is not None + assert iface.updated is not None + + assert isinstance(iface.mac_address, str) + assert iface.version + + assert not iface.default_route.ipv4 + assert not iface.default_route.ipv6 + + assert iface.vlan.vlan_label == "test-vlan" + assert iface.vlan.ipam_address == "10.0.0.5/32" + + +# NOTE: VLAN interface updates current aren't supported + + +def test_linode_interface_firewalls(e2e_test_firewall, linode_interface_public): + iface, instance, ipv6_range = linode_interface_public + + assert iface.id is not None + assert iface.linode_id == instance.id + + firewalls = iface.firewalls() + + firewall = firewalls[0] + assert firewall.id == e2e_test_firewall.id + assert firewall.label == e2e_test_firewall.label diff --git a/test/integration/models/linode/test_linode.py b/test/integration/models/linode/test_linode.py index ade4ca5ed..e254218ea 100644 --- a/test/integration/models/linode/test_linode.py +++ b/test/integration/models/linode/test_linode.py @@ -16,6 +16,8 @@ ConfigInterfaceIPv4, Disk, Instance, + InterfaceGeneration, + LinodeInterface, Type, ) from linode_api4.objects.linode import InstanceDiskEncryptionType, MigrationType @@ -66,8 +68,8 @@ def linode_with_volume_firewall(test_linode_client): linode_instance.delete() -@pytest.fixture(scope="session") -def linode_for_network_interface_tests(test_linode_client, e2e_test_firewall): +@pytest.fixture(scope="function") +def linode_for_legacy_interface_tests(test_linode_client, e2e_test_firewall): client = test_linode_client region = get_region(client, {"Linodes", "Cloud Firewall"}, site_type="core") label = get_test_label(length=8) @@ -78,6 +80,7 @@ def linode_for_network_interface_tests(test_linode_client, e2e_test_firewall): image="linode/debian12", label=label, firewall=e2e_test_firewall, + interface_generation=InterfaceGeneration.LEGACY_CONFIG, ) yield linode_instance @@ -85,6 +88,29 @@ def linode_for_network_interface_tests(test_linode_client, e2e_test_firewall): linode_instance.delete() +@pytest.fixture(scope="function") +def linode_and_vpc_for_legacy_interface_tests_offline( + test_linode_client, create_vpc_with_subnet, e2e_test_firewall +): + vpc, subnet = create_vpc_with_subnet + + label = get_test_label(length=8) + + instance, password = test_linode_client.linode.instance_create( + "g6-standard-1", + vpc.region, + booted=False, + image="linode/debian11", + label=label, + firewall=e2e_test_firewall, + interface_generation=InterfaceGeneration.LEGACY_CONFIG, + ) + + yield vpc, subnet, instance, password + + instance.delete() + + @pytest.fixture(scope="session") def linode_for_vpu_tests(test_linode_client, e2e_test_firewall): client = test_linode_client @@ -589,6 +615,130 @@ def test_linode_initate_migration(test_linode_client, e2e_test_firewall): assert res +def test_linode_upgrade_interfaces( + linode_for_legacy_interface_tests, + linode_and_vpc_for_legacy_interface_tests_offline, +): + vpc, subnet, linode, _ = linode_and_vpc_for_legacy_interface_tests_offline + config = linode.configs[0] + + new_interfaces = [ + {"purpose": "public"}, + ConfigInterface( + purpose="vlan", label="cool-vlan", ipam_address="10.0.0.4/32" + ), + ConfigInterface( + purpose="vpc", + subnet_id=subnet.id, + primary=True, + ipv4=ConfigInterfaceIPv4(vpc="10.0.0.2", nat_1_1="any"), + ip_ranges=["10.0.0.5/32"], + ), + ] + config.interfaces = new_interfaces + + config.save() + + def __assert_base(iface: LinodeInterface): + assert iface.id is not None + assert iface.created is not None + assert iface.updated is not None + assert iface.version is not None + + assert len(iface.mac_address) > 0 + + def __assert_public(iface: LinodeInterface): + __assert_base(iface) + + assert not iface.default_route.ipv4 + assert iface.default_route.ipv6 + + assert len(iface.public.ipv4.addresses) == 0 + assert len(iface.public.ipv4.shared) == 0 + + assert len(iface.public.ipv6.slaac) == 1 + assert iface.public.ipv6.slaac[0].address == linode.ipv6.split("/")[0] + + assert len(iface.public.ipv6.ranges) == 0 + assert len(iface.public.ipv6.shared) == 0 + + def __assert_vpc(iface: LinodeInterface): + __assert_base(iface) + + assert iface.default_route.ipv4 + assert not iface.default_route.ipv6 + + assert iface.vpc.vpc_id == vpc.id + assert iface.vpc.subnet_id == subnet.id + + assert len(iface.vpc.ipv4.addresses) == 1 + assert iface.vpc.ipv4.addresses[0].address == "10.0.0.2" + assert iface.vpc.ipv4.addresses[0].primary + assert iface.vpc.ipv4.addresses[0].nat_1_1_address is not None + + assert len(iface.vpc.ipv4.ranges) == 1 + assert iface.vpc.ipv4.ranges[0].range == "10.0.0.5/32" + + def __assert_vlan(iface: LinodeInterface): + __assert_base(iface) + + assert not iface.default_route.ipv4 + assert not iface.default_route.ipv6 + + assert iface.vlan.vlan_label == "cool-vlan" + assert iface.vlan.ipam_address == "10.0.0.4/32" + + result = linode.upgrade_interfaces(dry_run=True) + + assert result.dry_run + assert result.config_id == config.id + + __assert_public(result.interfaces[0]) + __assert_vlan(result.interfaces[1]) + __assert_vpc(result.interfaces[2]) + + result = linode.upgrade_interfaces(config=config) + + assert not result.dry_run + assert result.config_id == config.id + + __assert_public(result.interfaces[0]) + __assert_vlan(result.interfaces[1]) + __assert_vpc(result.interfaces[2]) + + __assert_public(linode.interfaces[0]) + __assert_vlan(linode.interfaces[1]) + __assert_vpc(linode.interfaces[2]) + + +def test_linode_interfaces_settings(linode_with_linode_interfaces): + linode = linode_with_linode_interfaces + settings = linode.interfaces_settings + + assert settings.network_helper is not None + assert settings.default_route.ipv4_interface_id == linode.interfaces[0].id + assert settings.default_route.ipv4_eligible_interface_ids == [ + linode.interfaces[0].id, + linode.interfaces[1].id, + ] + + assert settings.default_route.ipv6_interface_id == linode.interfaces[0].id + assert settings.default_route.ipv6_eligible_interface_ids == [ + linode.interfaces[0].id + ] + + # Arbitrary updates + settings.network_helper = True + settings.default_route.ipv4_interface_id = linode.interfaces[1].id + + settings.save() + settings.invalidate() + + # Assert updates + assert settings.network_helper is not None + assert settings.default_route.ipv4_interface_id == linode.interfaces[1].id + + def test_config_update_interfaces(create_linode): linode = create_linode config = linode.configs[0] @@ -672,8 +822,8 @@ def test_save_linode_force(test_linode_client, create_linode): class TestNetworkInterface: - def test_list(self, linode_for_network_interface_tests): - linode = linode_for_network_interface_tests + def test_list(self, linode_for_legacy_interface_tests): + linode = linode_for_legacy_interface_tests config: Config = linode.configs[0] @@ -693,8 +843,8 @@ def test_list(self, linode_for_network_interface_tests): assert interface[1].label == label assert interface[1].ipam_address == "10.0.0.3/32" - def test_create_public(self, linode_for_network_interface_tests): - linode = linode_for_network_interface_tests + def test_create_public(self, linode_for_legacy_interface_tests): + linode = linode_for_legacy_interface_tests config: Config = linode.configs[0] @@ -711,8 +861,8 @@ def test_create_public(self, linode_for_network_interface_tests): assert interface.purpose == "public" assert interface.primary - def test_create_vlan(self, linode_for_network_interface_tests): - linode = linode_for_network_interface_tests + def test_create_vlan(self, linode_for_legacy_interface_tests): + linode = linode_for_legacy_interface_tests config: Config = linode.configs[0] @@ -736,10 +886,11 @@ def test_create_vpu(self, test_linode_client, linode_for_vpu_tests): def test_create_vpc( self, test_linode_client, - linode_for_network_interface_tests, - create_vpc_with_subnet_and_linode, + linode_and_vpc_for_legacy_interface_tests_offline, ): - vpc, subnet, linode, _ = create_vpc_with_subnet_and_linode + vpc, subnet, linode, _ = ( + linode_and_vpc_for_legacy_interface_tests_offline + ) config: Config = linode.configs[0] @@ -749,7 +900,7 @@ def test_create_vpc( interface = config.interface_create_vpc( subnet=subnet, primary=True, - ipv4=ConfigInterfaceIPv4(vpc="10.0.0.2", nat_1_1="any"), + ipv4=ConfigInterfaceIPv4(vpc="10.0.0.3", nat_1_1="any"), ip_ranges=["10.0.0.5/32"], ) @@ -758,7 +909,7 @@ def test_create_vpc( assert interface.id == config.interfaces[0].id assert interface.subnet.id == subnet.id assert interface.purpose == "vpc" - assert interface.ipv4.vpc == "10.0.0.2" + assert interface.ipv4.vpc == "10.0.0.3" assert interface.ipv4.nat_1_1 == linode.ipv4[0] assert interface.ip_ranges == ["10.0.0.5/32"] @@ -792,10 +943,11 @@ def test_create_vpc( def test_update_vpc( self, - linode_for_network_interface_tests, - create_vpc_with_subnet_and_linode, + linode_and_vpc_for_legacy_interface_tests_offline, ): - vpc, subnet, linode, _ = create_vpc_with_subnet_and_linode + vpc, subnet, linode, _ = ( + linode_and_vpc_for_legacy_interface_tests_offline + ) config: Config = linode.configs[0] @@ -805,11 +957,11 @@ def test_update_vpc( interface = config.interface_create_vpc( subnet=subnet, primary=True, - ip_ranges=["10.0.0.5/32"], + ip_ranges=["10.0.0.8/32"], ) interface.primary = False - interface.ip_ranges = ["10.0.0.6/32"] + interface.ip_ranges = ["10.0.0.9/32"] interface.ipv4.vpc = "10.0.0.3" interface.ipv4.nat_1_1 = "any" @@ -822,10 +974,10 @@ def test_update_vpc( assert interface.purpose == "vpc" assert interface.ipv4.vpc == "10.0.0.3" assert interface.ipv4.nat_1_1 == linode.ipv4[0] - assert interface.ip_ranges == ["10.0.0.6/32"] + assert interface.ip_ranges == ["10.0.0.9/32"] - def test_reorder(self, linode_for_network_interface_tests): - linode = linode_for_network_interface_tests + def test_reorder(self, linode_for_legacy_interface_tests): + linode = linode_for_legacy_interface_tests config: Config = linode.configs[0] diff --git a/test/unit/groups/linode_test.py b/test/unit/groups/linode_test.py index 8112a5d93..7644cfa1d 100644 --- a/test/unit/groups/linode_test.py +++ b/test/unit/groups/linode_test.py @@ -1,6 +1,14 @@ from test.unit.base import ClientBaseCase - -from linode_api4 import InstancePlacementGroupAssignment +from test.unit.objects.linode_interface_test import ( + build_interface_options_public, + build_interface_options_vlan, + build_interface_options_vpc, +) + +from linode_api4 import ( + InstancePlacementGroupAssignment, + InterfaceGeneration, +) from linode_api4.objects import ConfigInterface @@ -32,7 +40,7 @@ def test_instance_create_with_user_data(self): }, ) - def test_instance_create_with_interfaces(self): + def test_instance_create_with_interfaces_legacy(self): """ Tests that user can pass a list of interfaces on Linode create. """ @@ -46,6 +54,7 @@ def test_instance_create_with_interfaces(self): self.client.linode.instance_create( "us-southeast", "g6-nanode-1", + interface_generation=InterfaceGeneration.LEGACY_CONFIG, interfaces=interfaces, ) @@ -96,6 +105,32 @@ def test_create_with_placement_group(self): m.call_data["placement_group"], {"id": 123, "compliant_only": True} ) + def test_instance_create_with_interfaces_linode(self): + """ + Tests that a Linode can be created alongside multiple LinodeInterfaces. + """ + + interfaces = [ + build_interface_options_public(), + build_interface_options_vpc(), + build_interface_options_vlan(), + ] + + with self.mock_post("linode/instances/124") as m: + self.client.linode.instance_create( + "g6-nanode-1", + "us-mia", + interface_generation=InterfaceGeneration.LINODE, + interfaces=interfaces, + ) + + assert m.call_data == { + "region": "us-mia", + "type": "g6-nanode-1", + "interface_generation": "linode", + "interfaces": [iface._serialize() for iface in interfaces], + } + class TypeTest(ClientBaseCase): def test_get_types(self): diff --git a/test/unit/objects/linode_interface_test.py b/test/unit/objects/linode_interface_test.py new file mode 100644 index 000000000..db0232c9e --- /dev/null +++ b/test/unit/objects/linode_interface_test.py @@ -0,0 +1,307 @@ +from datetime import datetime +from test.unit.base import ClientBaseCase + +from linode_api4 import ( + LinodeInterface, + LinodeInterfaceDefaultRouteOptions, + LinodeInterfaceOptions, + LinodeInterfacePublicIPv4AddressOptions, + LinodeInterfacePublicIPv4Options, + LinodeInterfacePublicIPv6Options, + LinodeInterfacePublicIPv6RangeOptions, + LinodeInterfacePublicOptions, + LinodeInterfaceVLANOptions, + LinodeInterfaceVPCIPv4AddressOptions, + LinodeInterfaceVPCIPv4Options, + LinodeInterfaceVPCIPv4RangeOptions, + LinodeInterfaceVPCOptions, +) + + +def build_interface_options_public(): + return LinodeInterfaceOptions( + firewall_id=123, + default_route=LinodeInterfaceDefaultRouteOptions( + ipv4=True, + ipv6=True, + ), + public=LinodeInterfacePublicOptions( + ipv4=LinodeInterfacePublicIPv4Options( + addresses=[ + LinodeInterfacePublicIPv4AddressOptions( + address="172.30.0.50", primary=True + ) + ], + ), + ipv6=LinodeInterfacePublicIPv6Options( + ranges=[ + LinodeInterfacePublicIPv6RangeOptions( + range="2600:3cO9:e001:59::/64" + ) + ] + ), + ), + ) + + +def build_interface_options_vpc(): + return LinodeInterfaceOptions( + firewall_id=123, + default_route=LinodeInterfaceDefaultRouteOptions( + ipv4=True, + ), + vpc=LinodeInterfaceVPCOptions( + subnet_id=123, + ipv4=LinodeInterfaceVPCIPv4Options( + addresses=[ + LinodeInterfaceVPCIPv4AddressOptions( + address="192.168.22.3", + primary=True, + nat_1_1_address="any", + ) + ], + ranges=[ + LinodeInterfaceVPCIPv4RangeOptions(range="192.168.22.16/28") + ], + ), + ), + ) + + +def build_interface_options_vlan(): + return LinodeInterfaceOptions( + vlan=LinodeInterfaceVLANOptions( + vlan_label="my_vlan", ipam_address="10.0.0.1/24" + ), + ) + + +class LinodeInterfaceTest(ClientBaseCase): + """ + Tests methods of the LinodeInterface class + """ + + @staticmethod + def assert_linode_124_interface_123(iface: LinodeInterface): + assert iface.id == 123 + + assert isinstance(iface.created, datetime) + assert isinstance(iface.updated, datetime) + + assert iface.default_route.ipv4 + assert iface.default_route.ipv6 + + assert iface.mac_address == "22:00:AB:CD:EF:01" + assert iface.version == 1 + + assert iface.vlan is None + assert iface.vpc is None + + # public.ipv4 assertions + assert iface.public.ipv4.addresses[0].address == "172.30.0.50" + assert iface.public.ipv4.addresses[0].primary + + assert iface.public.ipv4.shared[0].address == "172.30.0.51" + assert iface.public.ipv4.shared[0].linode_id == 125 + + # public.ipv6 assertions + assert iface.public.ipv6.ranges[0].range == "2600:3cO9:e001:59::/64" + assert ( + iface.public.ipv6.ranges[0].route_target + == "2600:3cO9::ff:feab:cdef" + ) + + assert iface.public.ipv6.ranges[1].range == "2600:3cO9:e001:5a::/64" + assert ( + iface.public.ipv6.ranges[1].route_target + == "2600:3cO9::ff:feab:cdef" + ) + + assert iface.public.ipv6.shared[0].range == "2600:3cO9:e001:2a::/64" + assert iface.public.ipv6.shared[0].route_target is None + + assert iface.public.ipv6.slaac[0].address == "2600:3cO9::ff:feab:cdef" + assert iface.public.ipv6.slaac[0].prefix == 64 + + @staticmethod + def assert_linode_124_interface_456(iface: LinodeInterface): + assert iface.id == 456 + + assert isinstance(iface.created, datetime) + assert isinstance(iface.updated, datetime) + + assert iface.default_route.ipv4 + assert not iface.default_route.ipv6 + + assert iface.mac_address == "22:00:AB:CD:EF:01" + assert iface.version == 1 + + assert iface.vlan is None + assert iface.public is None + + # vpc assertions + assert iface.vpc.vpc_id == 123456 + assert iface.vpc.subnet_id == 789 + + assert iface.vpc.ipv4.addresses[0].address == "192.168.22.3" + assert iface.vpc.ipv4.addresses[0].primary + + assert iface.vpc.ipv4.ranges[0].range == "192.168.22.16/28" + assert iface.vpc.ipv4.ranges[1].range == "192.168.22.32/28" + + @staticmethod + def assert_linode_124_interface_789(iface: LinodeInterface): + assert iface.id == 789 + + assert isinstance(iface.created, datetime) + assert isinstance(iface.updated, datetime) + + assert iface.default_route.ipv4 is None + assert iface.default_route.ipv6 is None + + assert iface.mac_address == "22:00:AB:CD:EF:01" + assert iface.version == 1 + + assert iface.public is None + assert iface.vpc is None + + # vlan assertions + assert iface.vlan.vlan_label == "my_vlan" + assert iface.vlan.ipam_address == "10.0.0.1/24" + + def test_get_public(self): + iface = LinodeInterface(self.client, 123, 124) + + self.assert_linode_124_interface_123(iface) + iface.invalidate() + self.assert_linode_124_interface_123(iface) + + def test_get_vpc(self): + iface = LinodeInterface(self.client, 456, 124) + + self.assert_linode_124_interface_456(iface) + iface.invalidate() + self.assert_linode_124_interface_456(iface) + + def test_get_vlan(self): + iface = LinodeInterface(self.client, 789, 124) + + self.assert_linode_124_interface_789(iface) + iface.invalidate() + self.assert_linode_124_interface_789(iface) + + def test_update_public(self): + iface = LinodeInterface(self.client, 123, 124) + + self.assert_linode_124_interface_123(iface) + + iface.default_route.ipv4 = False + iface.default_route.ipv6 = False + + iface.public.ipv4.addresses = [ + LinodeInterfacePublicIPv4AddressOptions( + address="172.30.0.51", + primary=False, + ) + ] + + iface.public.ipv6.ranges = [ + LinodeInterfacePublicIPv6RangeOptions( + range="2600:3cO9:e001:58::/64" + ) + ] + + with self.mock_put("/linode/instances/124/interfaces/123") as m: + iface.save() + + assert m.called + + assert m.call_data == { + "default_route": { + "ipv4": False, + "ipv6": False, + }, + "public": { + "ipv4": { + "addresses": [ + { + "address": "172.30.0.51", + "primary": False, + }, + ] + }, + "ipv6": { + "ranges": [ + { + "range": "2600:3cO9:e001:58::/64", + } + ] + }, + }, + } + + def test_update_vpc(self): + iface = LinodeInterface(self.client, 456, 124) + + self.assert_linode_124_interface_456(iface) + + iface.default_route.ipv4 = False + + iface.vpc.subnet_id = 456 + + iface.vpc.ipv4.addresses = [ + LinodeInterfaceVPCIPv4AddressOptions( + address="192.168.22.4", primary=False, nat_1_1_address="auto" + ) + ] + + iface.vpc.ipv4.ranges = [ + LinodeInterfaceVPCIPv4RangeOptions( + range="192.168.22.17/28", + ) + ] + + with self.mock_put("/linode/instances/124/interfaces/456") as m: + iface.save() + + assert m.called + + assert m.call_data == { + "default_route": { + "ipv4": False, + }, + "vpc": { + "subnet_id": 456, + "ipv4": { + "addresses": [ + { + "address": "192.168.22.4", + "primary": False, + "nat_1_1_address": "auto", + }, + ], + "ranges": [{"range": "192.168.22.17/28"}], + }, + }, + } + + def test_delete(self): + iface = LinodeInterface(self.client, 123, 124) + + with self.mock_delete() as m: + iface.delete() + assert m.called + + def test_firewalls(self): + iface = LinodeInterface(self.client, 123, 124) + + firewalls = iface.firewalls() + + assert len(firewalls) == 1 + + assert firewalls[0].id == 123 + + # Check a few fields to make sure the Firewall object was populated + assert firewalls[0].label == "firewall123" + assert firewalls[0].rules.inbound[0].action == "ACCEPT" + assert firewalls[0].status == "enabled" diff --git a/test/unit/objects/linode_test.py b/test/unit/objects/linode_test.py index 6016d2776..6b491783e 100644 --- a/test/unit/objects/linode_test.py +++ b/test/unit/objects/linode_test.py @@ -1,7 +1,17 @@ from datetime import datetime from test.unit.base import ClientBaseCase +from test.unit.objects.linode_interface_test import ( + LinodeInterfaceTest, + build_interface_options_public, + build_interface_options_vlan, + build_interface_options_vpc, +) -from linode_api4 import InstanceDiskEncryptionType, NetworkInterface +from linode_api4 import ( + InstanceDiskEncryptionType, + InterfaceGeneration, + NetworkInterface, +) from linode_api4.objects import ( Config, ConfigInterface, @@ -463,6 +473,169 @@ def test_get_placement_group(self): assert pg.label == "test" assert pg.placement_group_type == "anti_affinity:local" + def test_get_interfaces(self): + # Local import to avoid circular dependency + from linode_interface_test import ( # pylint: disable=import-outside-toplevel + LinodeInterfaceTest, + ) + + instance = Instance(self.client, 124) + + assert instance.interface_generation == InterfaceGeneration.LINODE + + interfaces = instance.interfaces + + LinodeInterfaceTest.assert_linode_124_interface_123( + next(iface for iface in interfaces if iface.id == 123) + ) + + LinodeInterfaceTest.assert_linode_124_interface_456( + next(iface for iface in interfaces if iface.id == 456) + ) + + LinodeInterfaceTest.assert_linode_124_interface_789( + next(iface for iface in interfaces if iface.id == 789) + ) + + def test_get_interfaces_settings(self): + instance = Instance(self.client, 124) + iface_settings = instance.interfaces_settings + + assert iface_settings.network_helper + + assert iface_settings.default_route.ipv4_interface_id == 123 + assert iface_settings.default_route.ipv4_eligible_interface_ids == [ + 123, + 456, + 789, + ] + + assert iface_settings.default_route.ipv6_interface_id == 456 + assert iface_settings.default_route.ipv6_eligible_interface_ids == [ + 123, + 456, + ] + + def test_update_interfaces_settings(self): + instance = Instance(self.client, 124) + iface_settings = instance.interfaces_settings + + iface_settings.network_helper = False + iface_settings.default_route.ipv4_interface_id = 456 + iface_settings.default_route.ipv6_interface_id = 123 + + print(vars(iface_settings)) + + with self.mock_put("/linode/instances/124/interfaces/settings") as m: + iface_settings.save() + + assert m.call_data == { + "network_helper": False, + "default_route": { + "ipv4_interface_id": 456, + "ipv6_interface_id": 123, + }, + } + + def test_upgrade_interfaces(self): + # Local import to avoid circular dependency + from linode_interface_test import ( # pylint: disable=import-outside-toplevel + LinodeInterfaceTest, + ) + + instance = Instance(self.client, 124) + + with self.mock_post("/linode/instances/124/upgrade-interfaces") as m: + result = instance.upgrade_interfaces(123) + + assert m.called + assert m.call_data == {"config_id": 123, "dry_run": False} + + assert result.config_id == 123 + assert result.dry_run + + # We don't use the assertion helpers here because dry runs return + # a MappedObject. + LinodeInterfaceTest.assert_linode_124_interface_123( + result.interfaces[0] + ) + LinodeInterfaceTest.assert_linode_124_interface_456( + result.interfaces[1] + ) + LinodeInterfaceTest.assert_linode_124_interface_789( + result.interfaces[2] + ) + + def test_upgrade_interfaces_dry(self): + instance = Instance(self.client, 124) + + with self.mock_post("/linode/instances/124/upgrade-interfaces") as m: + result = instance.upgrade_interfaces(123, dry_run=True) + + assert m.called + assert m.call_data == { + "config_id": 123, + "dry_run": True, + } + + assert result.config_id == 123 + assert result.dry_run + + # We don't use the assertion helpers here because dry runs return + # a MappedObject. + assert result.interfaces[0].id == 123 + assert result.interfaces[0].public is not None + + assert result.interfaces[1].id == 456 + assert result.interfaces[1].vpc is not None + + assert result.interfaces[2].id == 789 + assert result.interfaces[2].vlan is not None + + def test_create_interface_public(self): + instance = Instance(self.client, 124) + + iface = build_interface_options_public() + + with self.mock_post("/linode/instances/124/interfaces/123") as m: + result = instance.interface_create(**vars(iface)) + + assert m.call_data == { + "firewall_id": iface.firewall_id, + "default_route": iface.default_route._serialize(), + "public": iface.public._serialize(), + } + + LinodeInterfaceTest.assert_linode_124_interface_123(result) + + def test_create_interface_vpc(self): + instance = Instance(self.client, 124) + + iface = build_interface_options_vpc() + + with self.mock_post("/linode/instances/124/interfaces/456") as m: + result = instance.interface_create(**vars(iface)) + + assert m.call_data == { + "firewall_id": iface.firewall_id, + "default_route": iface.default_route._serialize(), + "vpc": iface.vpc._serialize(), + } + + LinodeInterfaceTest.assert_linode_124_interface_456(result) + + def test_create_interface_vlan(self): + instance = Instance(self.client, 124) + + iface = build_interface_options_vlan() + + with self.mock_post("/linode/instances/124/interfaces/789") as m: + result = instance.interface_create(**vars(iface)) + + assert m.call_data == {"vlan": iface.vlan._serialize()} + + LinodeInterfaceTest.assert_linode_124_interface_789(result) + class DiskTest(ClientBaseCase): """ From 06f8a5feb4ea02a2d5bb65f9377c3896f4e224ec Mon Sep 17 00:00:00 2001 From: Zhiwei Liang <121905282+zliang-akamai@users.noreply.github.com> Date: Wed, 28 May 2025 16:16:07 -0400 Subject: [PATCH 06/12] Enable `include_none_values` in FirewallSettingsDefaultFirewallIDs (#558) --- linode_api4/objects/networking.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/linode_api4/objects/networking.py b/linode_api4/objects/networking.py index ca7758a76..1219380fc 100644 --- a/linode_api4/objects/networking.py +++ b/linode_api4/objects/networking.py @@ -225,6 +225,8 @@ class FirewallSettingsDefaultFirewallIDs(JSONObject): NOTE: This feature may not currently be available to all users. """ + include_none_values = True + vpc_interface: Optional[int] = None public_interface: Optional[int] = None linode: Optional[int] = None From 8d042be98fb8369770cdb0557bed0c3c544e5cae Mon Sep 17 00:00:00 2001 From: Lena Garber <114949949+lgarber-akamai@users.noreply.github.com> Date: Tue, 17 Jun 2025 10:21:27 -0400 Subject: [PATCH 07/12] VPC Dual Stack: Support changes related to Linode Interfaces (#559) * Implementation; needs tests * Add integration tests --- linode_api4/objects/linode_interfaces.py | 75 +++++++++++++++++++ .../linode_instances_124_interfaces.json | 14 ++++ .../linode_instances_124_interfaces_456.json | 16 +++- ...node_instances_124_upgrade-interfaces.json | 14 ++++ test/unit/objects/linode_interface_test.py | 25 +++++++ 5 files changed, 143 insertions(+), 1 deletion(-) diff --git a/linode_api4/objects/linode_interfaces.py b/linode_api4/objects/linode_interfaces.py index f12865c99..0598d1f3c 100644 --- a/linode_api4/objects/linode_interfaces.py +++ b/linode_api4/objects/linode_interfaces.py @@ -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 @@ -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/test/fixtures/linode_instances_124_interfaces.json b/test/fixtures/linode_instances_124_interfaces.json index a0ffddef6..305beb1a9 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..0af734364 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", @@ -21,6 +21,20 @@ { "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 12340c4a3..bd033e90c 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/unit/objects/linode_interface_test.py b/test/unit/objects/linode_interface_test.py index db0232c9e..11c629302 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"}], + }, }, } From 4a3b56e7fd14157ab9a7c47f096424dbb25d79d9 Mon Sep 17 00:00:00 2001 From: vshanthe Date: Mon, 11 Aug 2025 15:54:23 +0530 Subject: [PATCH 08/12] vpctest --- test/integration/conftest.py | 11 ++- .../linode_client/test_linode_client.py | 6 +- test/integration/models/linode/test_linode.py | 88 +++++++++++++++++-- test/integration/models/vpc/test_vpc.py | 43 ++++++++- 4 files changed, 129 insertions(+), 19 deletions(-) diff --git a/test/integration/conftest.py b/test/integration/conftest.py index dfa01abed..7672ca0dd 100644 --- a/test/integration/conftest.py +++ b/test/integration/conftest.py @@ -402,9 +402,10 @@ 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"}), description="test description", + ipv6=[{"range": "auto"}], ) yield vpc @@ -413,7 +414,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/linode_client/test_linode_client.py b/test/integration/linode_client/test_linode_client.py index da7e93cef..eb1b06369 100644 --- a/test/integration/linode_client/test_linode_client.py +++ b/test/integration/linode_client/test_linode_client.py @@ -6,11 +6,7 @@ import pytest from linode_api4 import ApiError -from linode_api4.objects import ( - ConfigInterface, - ObjectStorageKeys, - Region, -) +from linode_api4.objects import ConfigInterface, ObjectStorageKeys, Region @pytest.fixture(scope="session") diff --git a/test/integration/models/linode/test_linode.py b/test/integration/models/linode/test_linode.py index e254218ea..a3944dfa9 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,8 +680,18 @@ 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) + print(iface.__dict__) + print(vars(iface.default_route)) assert not iface.default_route.ipv4 assert not iface.default_route.ipv6 @@ -888,9 +899,12 @@ def test_create_vpc( test_linode_client, linode_and_vpc_for_legacy_interface_tests_offline, ): - vpc, subnet, linode, _ = ( - linode_and_vpc_for_legacy_interface_tests_offline - ) + ( + vpc, + subnet, + linode, + _, + ) = linode_and_vpc_for_legacy_interface_tests_offline config: Config = linode.configs[0] @@ -927,11 +941,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 @@ -941,13 +974,50 @@ 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, ): - vpc, subnet, linode, _ = ( - linode_and_vpc_for_legacy_interface_tests_offline - ) + ( + vpc, + subnet, + linode, + _, + ) = linode_and_vpc_for_legacy_interface_tests_offline config: Config = linode.configs[0] diff --git a/test/integration/models/vpc/test_vpc.py b/test/integration/models/vpc/test_vpc.py index 5dd14b502..60396f33c 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 @@ -88,7 +93,9 @@ def test_fails_create_subnet_invalid_data(create_vpc): create_vpc.subnet_create("test-subnet", ipv4=invalid_ipv4) assert excinfo.value.status == 400 - assert "ipv4 must be an IPv4 network" in str(excinfo.value.json) + 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): @@ -101,3 +108,35 @@ def test_fails_update_subnet_invalid_data(create_vpc_with_subnet): 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) From fd5aec1add98a1dbf0bdec223be63835ff511a27 Mon Sep 17 00:00:00 2001 From: vshanthe Date: Mon, 11 Aug 2025 16:06:00 +0530 Subject: [PATCH 09/12] removeprint --- test/integration/models/linode/test_linode.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/test/integration/models/linode/test_linode.py b/test/integration/models/linode/test_linode.py index a3944dfa9..c5f4320d7 100644 --- a/test/integration/models/linode/test_linode.py +++ b/test/integration/models/linode/test_linode.py @@ -690,8 +690,6 @@ def __assert_vpc(iface: LinodeInterface): def __assert_vlan(iface: LinodeInterface): __assert_base(iface) - print(iface.__dict__) - print(vars(iface.default_route)) assert not iface.default_route.ipv4 assert not iface.default_route.ipv6 From beaa9020664be3f032908d8f1694166e81949883 Mon Sep 17 00:00:00 2001 From: vshanthe Date: Mon, 11 Aug 2025 17:05:20 +0530 Subject: [PATCH 10/12] test --- .../linode/interfaces/test_interfaces.py | 37 ++++++++++++++----- 1 file changed, 27 insertions(+), 10 deletions(-) diff --git a/test/integration/models/linode/interfaces/test_interfaces.py b/test/integration/models/linode/interfaces/test_interfaces.py index 6a81bb8bc..aae5f1bac 100644 --- a/test/integration/models/linode/interfaces/test_interfaces.py +++ b/test/integration/models/linode/interfaces/test_interfaces.py @@ -14,6 +14,7 @@ LinodeInterfacePublicIPv6RangeOptions, LinodeInterfacePublicOptions, LinodeInterfaceVLANOptions, + LinodeInterfaceVPCIPv4AddressOptions, LinodeInterfaceVPCIPv4Options, LinodeInterfaceVPCIPv4RangeOptions, LinodeInterfaceVPCOptions, @@ -69,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) @@ -145,19 +153,18 @@ def linode_interface_vpc( vpc=LinodeInterfaceVPCOptions( subnet_id=subnet.id, ipv4=LinodeInterfaceVPCIPv4Options( - # TODO (Enhanced Interfaces): Not currently working as expected - # addresses=[ - # LinodeInterfaceVPCIPv4AddressOptions( - # address="auto", - # primary=True, - # nat_1_1_address="any", - # ) - # ], + addresses=[ + LinodeInterfaceVPCIPv4AddressOptions( + address="auto", + primary=True, + nat_1_1_address=None, + ) + ], ranges=[ LinodeInterfaceVPCIPv4RangeOptions( range="/29", ) - ] + ], ), ), ), instance, vpc, subnet @@ -256,7 +263,7 @@ 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 @@ -267,6 +274,16 @@ def test_linode_interface_create_vpc(linode_interface_vpc): assert iface.vpc.ipv4.ranges[0].range.split("/")[1] == "29" + 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 From a233124bd92d1abfabf561759d1e3caff69de4e0 Mon Sep 17 00:00:00 2001 From: Lena Garber Date: Mon, 6 Oct 2025 17:25:47 -0400 Subject: [PATCH 11/12] Fix conflicts --- .../linode_instances_124_interfaces.json | 8 ++++---- .../linode_instances_124_interfaces_123.json | 2 +- .../linode_instances_124_upgrade-interfaces.json | 8 ++++---- test/integration/conftest.py | 4 +++- .../models/linode/interfaces/test_interfaces.py | 2 +- test/integration/models/linode/test_linode.py | 6 +----- test/unit/objects/linode_interface_test.py | 16 ++++++++-------- 7 files changed, 22 insertions(+), 24 deletions(-) diff --git a/test/fixtures/linode_instances_124_interfaces.json b/test/fixtures/linode_instances_124_interfaces.json index 0e39cccb0..dbb6f79fb 100644 --- a/test/fixtures/linode_instances_124_interfaces.json +++ b/test/fixtures/linode_instances_124_interfaces.json @@ -26,12 +26,12 @@ "ipv6": { "ranges": [ { - "range": "2600:3cO9:e001:59::/64", - "route_target": "2600:3cO9::ff:feab:cdef" + "range": "2600:3c09:e001:59::/64", + "route_target": "2600:3c09::ff:feab:cdef" }, { - "range": "2600:3cO9:e001:5a::/64", - "route_target": "2600:3cO9::ff:feab:cdef" + "range": "2600:3c09:e001:5a::/64", + "route_target": "2600:3c09::ff:feab:cdef" } ], "shared": [ diff --git a/test/fixtures/linode_instances_124_interfaces_123.json b/test/fixtures/linode_instances_124_interfaces_123.json index 97a18e2f5..2dc912812 100644 --- a/test/fixtures/linode_instances_124_interfaces_123.json +++ b/test/fixtures/linode_instances_124_interfaces_123.json @@ -34,7 +34,7 @@ ], "shared": [ { - "range": "2600:3cO9:e001:2a::/64", + "range": "2600:3c09:e001:2a::/64", "route_target": null } ], diff --git a/test/fixtures/linode_instances_124_upgrade-interfaces.json b/test/fixtures/linode_instances_124_upgrade-interfaces.json index 67a6a77c1..fa1015029 100644 --- a/test/fixtures/linode_instances_124_upgrade-interfaces.json +++ b/test/fixtures/linode_instances_124_upgrade-interfaces.json @@ -28,12 +28,12 @@ "ipv6": { "ranges": [ { - "range": "2600:3cO9:e001:59::/64", - "route_target": "2600:3cO9::ff:feab:cdef" + "range": "2600:3c09:e001:59::/64", + "route_target": "2600:3c09::ff:feab:cdef" }, { - "range": "2600:3cO9:e001:5a::/64", - "route_target": "2600:3cO9::ff:feab:cdef" + "range": "2600:3c09:e001:5a::/64", + "route_target": "2600:3c09::ff:feab:cdef" } ], "shared": [ diff --git a/test/integration/conftest.py b/test/integration/conftest.py index a6618b2ed..3692269dc 100644 --- a/test/integration/conftest.py +++ b/test/integration/conftest.py @@ -407,7 +407,9 @@ def create_vpc(test_linode_client): vpc = client.vpcs.create( label=label, - region=get_region(test_linode_client, {"VPCs"}), + region=get_region( + test_linode_client, {"VPCs", "VPC IPv6 Stack", "Linode Interfaces"} + ), description="test description", ipv6=[{"range": "auto"}], ) diff --git a/test/integration/models/linode/interfaces/test_interfaces.py b/test/integration/models/linode/interfaces/test_interfaces.py index c4fb6573f..650a9cb6c 100644 --- a/test/integration/models/linode/interfaces/test_interfaces.py +++ b/test/integration/models/linode/interfaces/test_interfaces.py @@ -263,7 +263,7 @@ 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 diff --git a/test/integration/models/linode/test_linode.py b/test/integration/models/linode/test_linode.py index 7acc0c8f1..1413e12d5 100644 --- a/test/integration/models/linode/test_linode.py +++ b/test/integration/models/linode/test_linode.py @@ -667,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 @@ -711,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]) diff --git a/test/unit/objects/linode_interface_test.py b/test/unit/objects/linode_interface_test.py index 2a3c56267..c021334e1 100644 --- a/test/unit/objects/linode_interface_test.py +++ b/test/unit/objects/linode_interface_test.py @@ -37,7 +37,7 @@ def build_interface_options_public(): ipv6=LinodeInterfacePublicIPv6Options( ranges=[ LinodeInterfacePublicIPv6RangeOptions( - range="2600:3cO9:e001:59::/64" + range="2600:3c09:e001:59::/64" ) ] ), @@ -106,22 +106,22 @@ def assert_linode_124_interface_123(iface: LinodeInterface): assert iface.public.ipv4.shared[0].linode_id == 125 # public.ipv6 assertions - assert iface.public.ipv6.ranges[0].range == "2600:3cO9:e001:59::/64" + assert iface.public.ipv6.ranges[0].range == "2600:3c09:e001:59::/64" assert ( iface.public.ipv6.ranges[0].route_target - == "2600:3cO9::ff:feab:cdef" + == "2600:3c09::ff:feab:cdef" ) - assert iface.public.ipv6.ranges[1].range == "2600:3cO9:e001:5a::/64" + assert iface.public.ipv6.ranges[1].range == "2600:3c09:e001:5a::/64" assert ( iface.public.ipv6.ranges[1].route_target - == "2600:3cO9::ff:feab:cdef" + == "2600:3c09::ff:feab:cdef" ) - assert iface.public.ipv6.shared[0].range == "2600:3cO9:e001:2a::/64" + assert iface.public.ipv6.shared[0].range == "2600:3c09:e001:2a::/64" assert iface.public.ipv6.shared[0].route_target is None - assert iface.public.ipv6.slaac[0].address == "2600:3cO9::ff:feab:cdef" + assert iface.public.ipv6.slaac[0].address == "2600:3c09::ff:feab:cdef" assert iface.public.ipv6.slaac[0].prefix == 64 @staticmethod @@ -215,7 +215,7 @@ def test_update_public(self): iface.public.ipv6.ranges = [ LinodeInterfacePublicIPv6RangeOptions( - range="2600:3cO9:e001:58::/64" + range="2600:3c09:e001:58::/64" ) ] From ff8396ceb24afcedc12aa8e2c637c821b02935c9 Mon Sep 17 00:00:00 2001 From: Lena Garber Date: Mon, 6 Oct 2025 17:29:26 -0400 Subject: [PATCH 12/12] Fix missed conflict --- linode_api4/groups/linode.py | 313 +++++++++++++++++------------------ 1 file changed, 155 insertions(+), 158 deletions(-) diff --git a/linode_api4/groups/linode.py b/linode_api4/groups/linode.py index 2d5754c95..e12e9cf48 100644 --- a/linode_api4/groups/linode.py +++ b/linode_api4/groups/linode.py @@ -167,190 +167,187 @@ def instance_create( **kwargs, ): """ - Creates a new Linode Instance. This function has several modes of operation: + Creates a new Linode Instance. This function has several modes of operation: - **Create an Instance from an Image** + **Create an Instance from an Image** - To create an Instance from an :any:`Image`, call `instance_create` with - a :any:`Type`, a :any:`Region`, and an :any:`Image`. All three of - these fields may be provided as either the ID or the appropriate object. - In this mode, a root password will be generated and returned with the - new Instance object. + To create an Instance from an :any:`Image`, call `instance_create` with + a :any:`Type`, a :any:`Region`, and an :any:`Image`. All three of + these fields may be provided as either the ID or the appropriate object. + In this mode, a root password will be generated and returned with the + new Instance object. - For example:: + For example:: - new_linode, password = client.linode.instance_create( - "g6-standard-2", - "us-east", - image="linode/debian9") + new_linode, password = client.linode.instance_create( + "g6-standard-2", + "us-east", + image="linode/debian9") - ltype = client.linode.types().first() - region = client.regions().first() - image = client.images().first() + ltype = client.linode.types().first() + region = client.regions().first() + image = client.images().first() - another_linode, password = client.linode.instance_create( - ltype, - region, - image=image) + another_linode, password = client.linode.instance_create( + ltype, + region, + image=image) - To output the password from the above example: - print(password) + To output the password from the above example: + print(password) - To output the first IPv4 address of the new Linode: - print(new_linode.ipv4[0]) + To output the first IPv4 address of the new Linode: + print(new_linode.ipv4[0]) - To delete the new_linode (WARNING: this immediately destroys the Linode): - new_linode.delete() + To delete the new_linode (WARNING: this immediately destroys the Linode): + new_linode.delete() - **Create an Instance from StackScript** + **Create an Instance from StackScript** - When creating an Instance from a :any:`StackScript`, an :any:`Image` that - the StackScript support must be provided.. You must also provide any - required StackScript data for the script's User Defined Fields.. For - example, if deploying `StackScript 10079`_ (which deploys a new Instance - with a user created from keys on `github`_:: + When creating an Instance from a :any:`StackScript`, an :any:`Image` that + the StackScript support must be provided.. You must also provide any + required StackScript data for the script's User Defined Fields.. For + example, if deploying `StackScript 10079`_ (which deploys a new Instance + with a user created from keys on `github`_:: - stackscript = StackScript(client, 10079) + stackscript = StackScript(client, 10079) - new_linode, password = client.linode.instance_create( - "g6-standard-2", - "us-east", - image="linode/debian9", - stackscript=stackscript, - stackscript_data={"gh_username": "example"}) + new_linode, password = client.linode.instance_create( + "g6-standard-2", + "us-east", + image="linode/debian9", + stackscript=stackscript, + stackscript_data={"gh_username": "example"}) - In the above example, "gh_username" is the name of a User Defined Field - in the chosen StackScript. For more information on StackScripts, see - the `StackScript guide`_. + In the above example, "gh_username" is the name of a User Defined Field + in the chosen StackScript. For more information on StackScripts, see + the `StackScript guide`_. - .. _`StackScript 10079`: https://www.linode.com/stackscripts/view/10079 - .. _`github`: https://github.com - .. _`StackScript guide`: https://www.linode.com/docs/platform/stackscripts/ + .. _`StackScript 10079`: https://www.linode.com/stackscripts/view/10079 + .. _`github`: https://github.com + .. _`StackScript guide`: https://www.linode.com/docs/platform/stackscripts/ - **Create an Instance from a Backup** + **Create an Instance from a Backup** - To create a new Instance by restoring a :any:`Backup` to it, provide a - :any:`Type`, a :any:`Region`, and the :any:`Backup` to restore. You - may provide either IDs or objects for all of these fields:: + To create a new Instance by restoring a :any:`Backup` to it, provide a + :any:`Type`, a :any:`Region`, and the :any:`Backup` to restore. You + may provide either IDs or objects for all of these fields:: - existing_linode = Instance(client, 123) - snapshot = existing_linode.available_backups.snapshot.current + existing_linode = Instance(client, 123) + snapshot = existing_linode.available_backups.snapshot.current - new_linode = client.linode.instance_create( - "g6-standard-2", - "us-east", - backup=snapshot) + new_linode = client.linode.instance_create( + "g6-standard-2", + "us-east", + backup=snapshot) - **Create an Instance with explicit interfaces:** + **Create an Instance with explicit interfaces:** - To create a new Instance with explicit interfaces, provide list of - LinodeInterfaceOptions objects or dicts to the "interfaces" field:: + To create a new Instance with explicit interfaces, provide list of + LinodeInterfaceOptions objects or dicts to the "interfaces" field:: - linode, password = client.linode.instance_create( - "g6-standard-1", - "us-mia", - image="linode/ubuntu24.04", + linode, password = client.linode.instance_create( + "g6-standard-1", + "us-mia", + image="linode/ubuntu24.04", - # This can be configured as an account-wide default - interface_generation=InterfaceGeneration.LINODE, + # This can be configured as an account-wide default + interface_generation=InterfaceGeneration.LINODE, - interfaces=[ - LinodeInterfaceOptions( - default_route=LinodeInterfaceDefaultRouteOptions( - ipv4=True, - ipv6=True - ), - public=LinodeInterfacePublicOptions - ) - ] + interfaces=[ + LinodeInterfaceOptions( + default_route=LinodeInterfaceDefaultRouteOptions( + ipv4=True, + ipv6=True + ), + public=LinodeInterfacePublicOptions ) + ] + ) - **Create an empty Instance** - - If you want to create an empty Instance that you will configure manually, - simply call `instance_create` with a :any:`Type` and a :any:`Region`:: - - empty_linode = client.linode.instance_create("g6-standard-2", "us-east") - - When created this way, the Instance will not be booted and cannot boot - successfully until disks and configs are created, or it is otherwise - configured. - - API Documentation: https://techdocs.akamai.com/linode-api/reference/post-linode-instance - - :param ltype: The Instance Type we are creating - :type ltype: str or Type - :param region: The Region in which we are creating the Instance - :type region: str or Region - :param image: The Image to deploy to this Instance. If this is provided - and no root_pass is given, a password will be generated - and returned along with the new Instance. - :type image: str or Image - :param stackscript: The StackScript to deploy to the new Instance. If - provided, "image" is required and must be compatible - with the chosen StackScript. - :type stackscript: int or StackScript - :param stackscript_data: Values for the User Defined Fields defined in - the chosen StackScript. Does nothing if - StackScript is not provided. - :type stackscript_data: dict - :param backup: The Backup to restore to the new Instance. May not be - provided if "image" is given. - :type backup: int of Backup - :param authorized_keys: The ssh public keys to install in the linode's - /root/.ssh/authorized_keys file. Each entry may - be a single key, or a path to a file containing - the key. - :type authorized_keys: list or str - :param label: The display label for the new Instance - :type label: str - :param group: The display group for the new Instance - :type group: str - :param booted: Whether the new Instance should be booted. This will - default to True if the Instance is deployed from an Image - or Backup. - :type booted: bool - :param tags: A list of tags to apply to the new instance. If any of the - tags included do not exist, they will be created as part of - this operation. - :type tags: list[str] - :param private_ip: Whether the new Instance should have private networking - enabled and assigned a private IPv4 address. - :type private_ip: bool - :param metadata: Metadata-related fields to use when creating the new Instance. - The contents of this field can be built using the - :any:`build_instance_metadata` method. - :type metadata: dict - :param firewall: The firewall to attach this Linode to. - :type firewall: int or Firewall - :param disk_encryption: The disk encryption policy for this Linode. - NOTE: Disk encryption may not currently be available to all users. - :type disk_encryption: InstanceDiskEncryptionType or str - :param interfaces: An array of Network Interfaces to add to this Linode’s Configuration Profile. - At least one and up to three Interface objects can exist in this array. - :type interfaces: List[LinodeInterfaceOptions], List[NetworkInterface], or List[dict[str, Any]] - :param placement_group: A Placement Group to create this Linode under. - :type placement_group: Union[InstancePlacementGroupAssignment, PlacementGroup, Dict[str, Any], int] - :param interface_generation: The generation of network interfaces this Linode uses. - :type interface_generation: InterfaceGeneration or str - :param network_helper: Whether this instance should have Network Helper enabled. - :type network_helper: bool - <<<<<<< HEAD - ======= - :param maintenance_policy: The slug of the maintenance policy to apply during maintenance. - If not provided, the default policy (linode/migrate) will be applied. - NOTE: This field is in beta and may only - function if base_url is set to `https://api.linode.com/v4beta`. - :type maintenance_policy: str - >>>>>>> fed436bb516bf1b08966058cb418ad3959e9b405 - - :returns: A new Instance object, or a tuple containing the new Instance and - the generated password. - :rtype: Instance or tuple(Instance, str) - :raises ApiError: If contacting the API fails - :raises UnexpectedResponseError: If the API response is somehow malformed. - This usually indicates that you are using - an outdated library. + **Create an empty Instance** + + If you want to create an empty Instance that you will configure manually, + simply call `instance_create` with a :any:`Type` and a :any:`Region`:: + + empty_linode = client.linode.instance_create("g6-standard-2", "us-east") + + When created this way, the Instance will not be booted and cannot boot + successfully until disks and configs are created, or it is otherwise + configured. + + API Documentation: https://techdocs.akamai.com/linode-api/reference/post-linode-instance + + :param ltype: The Instance Type we are creating + :type ltype: str or Type + :param region: The Region in which we are creating the Instance + :type region: str or Region + :param image: The Image to deploy to this Instance. If this is provided + and no root_pass is given, a password will be generated + and returned along with the new Instance. + :type image: str or Image + :param stackscript: The StackScript to deploy to the new Instance. If + provided, "image" is required and must be compatible + with the chosen StackScript. + :type stackscript: int or StackScript + :param stackscript_data: Values for the User Defined Fields defined in + the chosen StackScript. Does nothing if + StackScript is not provided. + :type stackscript_data: dict + :param backup: The Backup to restore to the new Instance. May not be + provided if "image" is given. + :type backup: int of Backup + :param authorized_keys: The ssh public keys to install in the linode's + /root/.ssh/authorized_keys file. Each entry may + be a single key, or a path to a file containing + the key. + :type authorized_keys: list or str + :param label: The display label for the new Instance + :type label: str + :param group: The display group for the new Instance + :type group: str + :param booted: Whether the new Instance should be booted. This will + default to True if the Instance is deployed from an Image + or Backup. + :type booted: bool + :param tags: A list of tags to apply to the new instance. If any of the + tags included do not exist, they will be created as part of + this operation. + :type tags: list[str] + :param private_ip: Whether the new Instance should have private networking + enabled and assigned a private IPv4 address. + :type private_ip: bool + :param metadata: Metadata-related fields to use when creating the new Instance. + The contents of this field can be built using the + :any:`build_instance_metadata` method. + :type metadata: dict + :param firewall: The firewall to attach this Linode to. + :type firewall: int or Firewall + :param disk_encryption: The disk encryption policy for this Linode. + NOTE: Disk encryption may not currently be available to all users. + :type disk_encryption: InstanceDiskEncryptionType or str + :param interfaces: An array of Network Interfaces to add to this Linode’s Configuration Profile. + At least one and up to three Interface objects can exist in this array. + :type interfaces: List[LinodeInterfaceOptions], List[NetworkInterface], or List[dict[str, Any]] + :param placement_group: A Placement Group to create this Linode under. + :type placement_group: Union[InstancePlacementGroupAssignment, PlacementGroup, Dict[str, Any], int] + :param interface_generation: The generation of network interfaces this Linode uses. + :type interface_generation: InterfaceGeneration or str + :param network_helper: Whether this instance should have Network Helper enabled. + :type network_helper: bool + :param maintenance_policy: The slug of the maintenance policy to apply during maintenance. + If not provided, the default policy (linode/migrate) will be applied. + NOTE: This field is in beta and may only + function if base_url is set to `https://api.linode.com/v4beta`. + :type maintenance_policy: str + + :returns: A new Instance object, or a tuple containing the new Instance and + the generated password. + :rtype: Instance or tuple(Instance, str) + :raises ApiError: If contacting the API fails + :raises UnexpectedResponseError: If the API response is somehow malformed. + This usually indicates that you are using + an outdated library. """ ret_pass = None