Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import sys
import os

# Ensure the repo root is on sys.path so that `from test.unit.base import ...`
# works regardless of which directory pytest is invoked from.
sys.path.insert(0, os.path.dirname(__file__))
5 changes: 5 additions & 0 deletions linode_api4/groups/linode.py
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,7 @@ def instance_create(
interface_generation: Optional[Union[InterfaceGeneration, str]] = None,
network_helper: Optional[bool] = None,
maintenance_policy: Optional[str] = None,
ipv4: Optional[List[str]] = None,
**kwargs,
):
"""
Expand Down Expand Up @@ -336,6 +337,9 @@ def instance_create(
:param maintenance_policy: The slug of the maintenance policy to apply during maintenance.
If not provided, the default policy (linode/migrate) will be applied.
:type maintenance_policy: str
:param ipv4: A list of reserved IPv4 addresses to assign to this Instance.
NOTE: Reserved IP feature may not currently be available to all users.
:type ipv4: list[str]

:returns: A new Instance object, or a tuple containing the new Instance and
the generated password.
Expand Down Expand Up @@ -373,6 +377,7 @@ def instance_create(
"interfaces": interfaces,
"interface_generation": interface_generation,
"network_helper": network_helper,
"ipv4": ipv4,
}

params.update(kwargs)
Expand Down
115 changes: 104 additions & 11 deletions linode_api4/groups/networking.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
Region,
)
from linode_api4.objects.base import _flatten_request_body_recursive
from linode_api4.objects.networking import ReservedIPAddress, ReservedIPType
from linode_api4.util import drop_null_keys


Expand Down Expand Up @@ -328,29 +329,53 @@ def ips_assign(self, region, *assignments):
},
)

def ip_allocate(self, linode, public=True):
def ip_allocate(
self, linode=None, public=True, reserved=False, region=None
):
"""
Allocates an IP to a Instance you own. Additional IPs must be requested
by opening a support ticket first.
Allocates an IP to a Instance you own, or reserves a new IP address.

When ``reserved`` is False (default), ``linode`` is required and an
ephemeral IP is allocated and assigned to that Instance.

When ``reserved`` is True, either ``region`` or ``linode`` must be
provided. Passing only ``region`` creates an unassigned reserved IP.
Passing ``linode`` (with or without ``region``) creates a reserved IP
in the Instance's region and assigns it to that Instance.

API Documentation: https://techdocs.akamai.com/linode-api/reference/post-allocate-ip

:param linode: The Instance to allocate the new IP for.
:type linode: Instance or int
:param public: If True, allocate a public IP address. Defaults to True.
:type public: bool
:param reserved: If True, reserve the new IP address.
NOTE: Reserved IP feature may not currently be available to all users.
:type reserved: bool
:param region: The region for the reserved IP (required when reserved=True and linode is not set).
NOTE: Reserved IP feature may not currently be available to all users.
:type region: str or Region

:returns: The new IPAddress.
:rtype: IPAddress
"""
result = self.client.post(
"/networking/ips/",
data={
"linode_id": linode.id if isinstance(linode, Base) else linode,
"type": "ipv4",
"public": public,
},
)
data = {
"type": "ipv4",
"public": public,
}

Comment on lines +362 to +366
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ip_allocate's docstring describes required arguments (linode required when reserved=False; region or linode required when reserved=True), but the implementation never validates these combinations. This allows requests to be sent without linode_id/region (or with region while reserved is False), which will fail at the API with a less actionable error. Add explicit argument validation (raise ValueError) to enforce the documented contract before building/sending the request body.

Copilot uses AI. Check for mistakes.
if linode is not None:
data["linode_id"] = (
linode.id if isinstance(linode, Base) else linode
)

if reserved:
data["reserved"] = True

if region is not None:
data["region"] = region.id if isinstance(region, Base) else region

result = self.client.post("/networking/ips/", data=data)

if not "address" in result:
raise UnexpectedResponseError(
Expand Down Expand Up @@ -510,3 +535,71 @@ def delete_vlan(self, vlan, region):
return False

return True

def reserved_ips(self, *filters):
"""
Returns a list of reserved IPv4 addresses on your account.

NOTE: Reserved IP feature may not currently be available to all users.

API Documentation: https://techdocs.akamai.com/linode-api/reference/get-reserved-ips

:param filters: Any number of filters to apply to this query.
See :doc:`Filtering Collections</linode_api4/objects/filtering>`
for more details on filtering.

:returns: A list of reserved IP addresses on the account.
:rtype: PaginatedList of ReservedIPAddress
"""
return self.client._get_and_filter(ReservedIPAddress, *filters)

def reserved_ip_create(self, region, tags=None, **kwargs):
"""
Reserves a new IPv4 address in the given region.

NOTE: Reserved IP feature may not currently be available to all users.

API Documentation: https://techdocs.akamai.com/linode-api/reference/post-reserve-ip

:param region: The region in which to reserve the IP.
:type region: str or Region
:param tags: Tags to apply to the reserved IP.
:type tags: list of str

:returns: The new reserved IP address.
:rtype: ReservedIPAddress
"""
params = {
"region": region.id if isinstance(region, Region) else region,
}
if tags is not None:
params["tags"] = tags
params.update(kwargs)

result = self.client.post("/networking/reserved/ips", data=params)

if "address" not in result:
raise UnexpectedResponseError(
"Unexpected response when reserving IP address!", json=result
)

return ReservedIPAddress(self.client, result["address"], result)

def reserved_ip_types(self, *filters):
"""
Returns a list of reserved IP types with pricing information.

NOTE: Reserved IP feature may not currently be available to all users.

API Documentation: https://techdocs.akamai.com/linode-api/reference/get-reserved-iptypes

:param filters: Any number of filters to apply to this query.
See :doc:`Filtering Collections</linode_api4/objects/filtering>`
for more details on filtering.

:returns: A list of reserved IP types.
:rtype: PaginatedList of ReservedIPType
"""
return self.client._get_and_filter(
ReservedIPType, *filters, endpoint="/networking/reserved/ips/types"
)
7 changes: 6 additions & 1 deletion linode_api4/groups/nodebalancer.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,21 +24,26 @@ def __call__(self, *filters):
"""
return self.client._get_and_filter(NodeBalancer, *filters)

def create(self, region, **kwargs):
def create(self, region, ipv4=None, **kwargs):
"""
Creates a new NodeBalancer in the given Region.

API Documentation: https://techdocs.akamai.com/linode-api/reference/post-node-balancer

:param region: The Region in which to create the NodeBalancer.
:type region: Region or str
:param ipv4: A reserved IPv4 address to assign to this NodeBalancer.
NOTE: Reserved IP feature may not currently be available to all users.
:type ipv4: str

:returns: The new NodeBalancer
:rtype: NodeBalancer
"""
params = {
"region": region.id if isinstance(region, Base) else region,
}
if ipv4 is not None:
params["ipv4"] = ipv4
params.update(kwargs)

result = self.client.post("/nodebalancers", data=params)
Expand Down
5 changes: 5 additions & 0 deletions linode_api4/groups/tag.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ def create(
domains=None,
nodebalancers=None,
volumes=None,
reserved_ipv4_addresses=None,
entities=[],
):
Comment on lines 36 to 37
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

entities uses a mutable default ([]). Even though this method currently only iterates it, mutable defaults are easy to misuse later and are discouraged because they can create cross-call shared state if mutated. Prefer entities=None and then set entities = entities or [] inside the function.

Copilot uses AI. Check for mistakes.
"""
Expand Down Expand Up @@ -61,6 +62,9 @@ def create(
:param volumes: A list of Volumes to apply this Tag to upon
creation
:type volumes: list of Volumes or list of int
:param reserved_ipv4_addresses: A list of reserved IPv4 addresses to apply
this Tag to upon creation.
:type reserved_ipv4_addresses: list of str

:returns: The new Tag
:rtype: Tag
Expand Down Expand Up @@ -103,6 +107,7 @@ def create(
"nodebalancers": nodebalancer_ids or None,
"domains": domain_ids or None,
"volumes": volume_ids or None,
"reserved_ipv4_addresses": reserved_ipv4_addresses or None,
}

result = self.client.post("/tags", data=params)
Expand Down
19 changes: 14 additions & 5 deletions linode_api4/objects/linode.py
Original file line number Diff line number Diff line change
Expand Up @@ -1539,7 +1539,7 @@ def snapshot(self, label=None):
b = Backup(self._client, result["id"], self.id, result)
return b

def ip_allocate(self, public=False):
def ip_allocate(self, public=False, address=None):
"""
Allocates a new :any:`IPAddress` for this Instance. Additional public
IPs require justification, and you may need to open a :any:`SupportTicket`
Expand All @@ -1551,17 +1551,26 @@ def ip_allocate(self, public=False):
:param public: If the new IP should be public or private. Defaults to
private.
:type public: bool
:param address: A reserved IPv4 address to assign to this Instance instead
of allocating a new ephemeral IP. The address must be an
unassigned reserved IP owned by this account.
NOTE: Reserved IP feature may not currently be available to all users.
:type address: str

:returns: The new IPAddress
:rtype: IPAddress
"""
data = {
"type": "ipv4",
"public": public,
}
if address is not None:
data["address"] = address

result = self._client.post(
"{}/ips".format(Instance.api_endpoint),
model=self,
data={
"type": "ipv4",
"public": public,
},
data=data,
)

if not "address" in result:
Expand Down
66 changes: 66 additions & 0 deletions linode_api4/objects/networking.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,20 @@ class InstanceIPNAT1To1(JSONObject):
vpc_id: int = 0


@dataclass
class ReservedIPAssignedEntity(JSONObject):
"""
Represents the entity that a reserved IP is assigned to.

NOTE: Reserved IP feature may not currently be available to all users.
"""

id: int = 0
label: str = ""
type: str = ""
url: str = ""


class IPAddress(Base):
"""
note:: This endpoint is in beta. This will only function if base_url is set to `https://api.linode.com/v4beta`.
Expand Down Expand Up @@ -90,6 +104,9 @@ class IPAddress(Base):
"interface_id": Property(),
"region": Property(slug_relationship=Region),
"vpc_nat_1_1": Property(json_object=InstanceIPNAT1To1),
"reserved": Property(mutable=True),
"tags": Property(mutable=True, unordered=True),
"assigned_entity": Property(json_object=ReservedIPAssignedEntity),
}

@property
Expand Down Expand Up @@ -156,6 +173,38 @@ def delete(self):
return True


class ReservedIPAddress(Base):
"""
.. note:: This endpoint is in beta. This will only function if base_url is set to ``https://api.linode.com/v4beta``.

Represents a Linode Reserved IPv4 Address.

Update tags on a reserved IP by mutating the ``tags`` attribute and calling ``save()``.

NOTE: Reserved IP feature may not currently be available to all users.

API Documentation: https://techdocs.akamai.com/linode-api/reference/get-reserved-ip
"""

api_endpoint = "/networking/reserved/ips/{address}"
id_attribute = "address"

properties = {
"address": Property(identifier=True),
"gateway": Property(),
"linode_id": Property(),
"prefix": Property(),
"public": Property(),
"rdns": Property(),
"region": Property(slug_relationship=Region),
"reserved": Property(),
"subnet_mask": Property(),
"tags": Property(mutable=True, unordered=True),
"type": Property(),
"assigned_entity": Property(json_object=ReservedIPAssignedEntity),
}


@dataclass
class VPCIPAddressIPv6(JSONObject):
slaac_address: str = ""
Expand Down Expand Up @@ -424,3 +473,20 @@ class NetworkTransferPrice(Base):
"region_prices": Property(json_object=RegionPrice),
"transfer": Property(),
}


class ReservedIPType(Base):
"""
Represents a reserved IP type with pricing information.

NOTE: Reserved IP feature may not currently be available to all users.

API Documentation: https://techdocs.akamai.com/linode-api/reference/get-reserved-iptype
"""

properties = {
"id": Property(identifier=True),
"label": Property(),
"price": Property(json_object=Price),
"region_prices": Property(json_object=RegionPrice),
}
5 changes: 4 additions & 1 deletion linode_api4/objects/tag.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,15 @@
Property,
Volume,
)
from linode_api4.objects.networking import ReservedIPAddress
from linode_api4.paginated_list import PaginatedList

CLASS_MAP = {
"linode": Instance,
"domain": Domain,
"nodebalancer": NodeBalancer,
"volume": Volume,
"reserved_ipv4_address": ReservedIPAddress,
}


Expand Down Expand Up @@ -124,7 +126,8 @@ def make_instance(cls, id, client, parent_id=None, json=None):

# discard the envelope
real_json = json["data"]
real_id = real_json["id"]
id_attr = getattr(make_cls, "id_attribute", "id")
real_id = real_json[id_attr]

# make the real object type
return Base.make(
Expand Down
6 changes: 6 additions & 0 deletions pytest.ini
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
[pytest]
testpaths = test
markers =
smoke: mark a test as a smoke test
flaky: mark a test as a flaky test for rerun
python_files = *_test.py test_*.py
Loading
Loading