Skip to content
Merged
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
32 changes: 29 additions & 3 deletions linode_api4/groups/lke.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,15 @@
from typing import Any, Dict, Union

from linode_api4.errors import UnexpectedResponseError
from linode_api4.groups import Group
from linode_api4.objects import Base, KubeVersion, LKECluster
from linode_api4.objects import (
Base,
JSONObject,
KubeVersion,
LKECluster,
LKEClusterControlPlaneOptions,
drop_null_keys,
)


class LKEGroup(Group):
Expand Down Expand Up @@ -47,7 +56,17 @@ def clusters(self, *filters):
"""
return self.client._get_and_filter(LKECluster, *filters)

def cluster_create(self, region, label, node_pools, kube_version, **kwargs):
def cluster_create(
self,
region,
label,
node_pools,
kube_version,
control_plane: Union[
LKEClusterControlPlaneOptions, Dict[str, Any]
] = None,
**kwargs,
):
"""
Creates an :any:`LKECluster` on this account in the given region, with
the given label, and with node pools as described. For example::
Expand Down Expand Up @@ -80,6 +99,8 @@ def cluster_create(self, region, label, node_pools, kube_version, **kwargs):
formatted dicts.
:param kube_version: The version of Kubernetes to use
:type kube_version: KubeVersion or str
:param control_plane: Dict[str, Any] or LKEClusterControlPlaneRequest
:type control_plane: The control plane configuration of this LKE cluster.
:param kwargs: Any other arguments to pass along to the API. See the API
docs for possible values.

Expand Down Expand Up @@ -112,10 +133,15 @@ def cluster_create(self, region, label, node_pools, kube_version, **kwargs):
if issubclass(type(kube_version), Base)
else kube_version
),
"control_plane": (
control_plane.dict
if issubclass(type(control_plane), JSONObject)
else control_plane
),
}
params.update(kwargs)

result = self.client.post("/lke/clusters", data=params)
result = self.client.post("/lke/clusters", data=drop_null_keys(params))

if "id" not in result:
raise UnexpectedResponseError(
Expand Down
138 changes: 138 additions & 0 deletions linode_api4/objects/lke.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
from dataclasses import dataclass
from typing import Any, Dict, List, Optional, Union
from urllib import parse

from linode_api4.errors import UnexpectedResponseError
from linode_api4.objects import (
Base,
DerivedBase,
Instance,
JSONObject,
MappedObject,
Property,
Region,
Expand All @@ -26,6 +29,61 @@ class KubeVersion(Base):
}


@dataclass
class LKEClusterControlPlaneACLAddressesOptions(JSONObject):
"""
LKEClusterControlPlaneACLAddressesOptions are options used to configure
IP ranges that are explicitly allowed to access an LKE cluster's control plane.
"""

ipv4: Optional[List[str]] = None
ipv6: Optional[List[str]] = None


@dataclass
class LKEClusterControlPlaneACLOptions(JSONObject):
"""
LKEClusterControlPlaneACLOptions is used to set
the ACL configuration of an LKE cluster's control plane.
"""

enabled: Optional[bool] = None
addresses: Optional[LKEClusterControlPlaneACLAddressesOptions] = None


@dataclass
class LKEClusterControlPlaneOptions(JSONObject):
"""
LKEClusterControlPlaneOptions is used to configure
the control plane of an LKE cluster during its creation.
"""

high_availability: Optional[bool] = None
acl: Optional[LKEClusterControlPlaneACLOptions] = None


@dataclass
class LKEClusterControlPlaneACLAddresses(JSONObject):
"""
LKEClusterControlPlaneACLAddresses describes IP ranges that are explicitly allowed
to access an LKE cluster's control plane.
"""

ipv4: List[str] = None
ipv6: List[str] = None


@dataclass
class LKEClusterControlPlaneACL(JSONObject):
"""
LKEClusterControlPlaneACL describes the ACL configuration of an LKE cluster's
control plane.
"""

enabled: bool = False
addresses: LKEClusterControlPlaneACLAddresses = None


class LKENodePoolNode:
"""
AN LKE Node Pool Node is a helper class that is used to populate the "nodes"
Expand Down Expand Up @@ -129,6 +187,21 @@ class LKECluster(Base):
"control_plane": Property(mutable=True),
}

def invalidate(self):
"""
Extends the default invalidation logic to drop cached properties.
"""
if hasattr(self, "_api_endpoints"):
del self._api_endpoints

if hasattr(self, "_kubeconfig"):
del self._kubeconfig

if hasattr(self, "_control_plane_acl"):
del self._control_plane_acl

Base.invalidate(self)

@property
def api_endpoints(self):
"""
Expand Down Expand Up @@ -186,6 +259,26 @@ def kubeconfig(self):

return self._kubeconfig

@property
def control_plane_acl(self) -> LKEClusterControlPlaneACL:
"""
Gets the ACL configuration of this cluster's control plane.

API Documentation: TODO

:returns: The cluster's control plane ACL configuration.
:rtype: LKEClusterControlPlaneACL
"""

if not hasattr(self, "_control_plane_acl"):
result = self._client.get(
f"{LKECluster.api_endpoint}/control_plane_acl", model=self
)

self._control_plane_acl = result.get("acl")

return LKEClusterControlPlaneACL.from_json(self._control_plane_acl)

def node_pool_create(self, node_type, node_count, **kwargs):
"""
Creates a new :any:`LKENodePool` for this cluster.
Expand Down Expand Up @@ -335,3 +428,48 @@ def service_token_delete(self):
self._client.delete(
"{}/servicetoken".format(LKECluster.api_endpoint), model=self
)

def control_plane_acl_update(

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This pattern is not consistent with the rest of the codebase, but unfortunately we cannot implement it using the traditional properties because ACL is not returned from the LKE cluster GET endpoint.

self, acl: Union[LKEClusterControlPlaneACLOptions, Dict[str, Any]]
) -> LKEClusterControlPlaneACL:
"""
Updates the ACL configuration for this cluster's control plane.

API Documentation: TODO

:param acl: The ACL configuration to apply to this cluster.
:type acl: LKEClusterControlPlaneACLOptions or Dict[str, Any]

:returns: The updated control plane ACL configuration.
:rtype: LKEClusterControlPlaneACL
"""
if isinstance(acl, LKEClusterControlPlaneACLOptions):
acl = acl.dict

result = self._client.put(
f"{LKECluster.api_endpoint}/control_plane_acl",
model=self,
data={"acl": acl},
)

acl = result.get("acl")

self._control_plane_acl = result.get("acl")

return LKEClusterControlPlaneACL.from_json(acl)

def control_plane_acl_delete(self):
Comment thread
lgarber-akamai marked this conversation as resolved.
"""
Deletes the ACL configuration for this cluster's control plane.
This has the same effect as calling control_plane_acl_update with the `enabled` field
set to False. Access controls are disabled and all rules are deleted.

API Documentation: TODO
"""
self._client.delete(
f"{LKECluster.api_endpoint}/control_plane_acl", model=self
)

# Invalidate the cache so it is automatically refreshed on next access
if hasattr(self, "_control_plane_acl"):
del self._control_plane_acl
90 changes: 86 additions & 4 deletions linode_api4/objects/serializable.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
import inspect
from dataclasses import asdict, dataclass
from dataclasses import dataclass
from types import SimpleNamespace
from typing import (
Any,
ClassVar,
Dict,
List,
Optional,
Set,
Union,
get_args,
get_origin,
get_type_hints,
Expand Down Expand Up @@ -54,28 +57,57 @@ class JSONObject(metaclass=JSONFilterableMetaclass):
)
"""

always_include: ClassVar[Set[str]] = {}
"""
A set of keys corresponding to fields that should always be
included in the generated output regardless of whether their values
are None.
"""
Comment on lines +60 to +65

@lgarber-akamai lgarber-akamai May 15, 2024

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I was considering implementing something more similar to omitempty in Go, but in this case I think there are far fewer instances where we would want to include null values rather than excluding them. I'd definitely appreciate some feedback on this!

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Yes, I agree that in the most of the case we just want to omit empty values.


def __init__(self):
raise NotImplementedError(
"JSONObject is not intended to be constructed directly"
)

# TODO: Implement __repr__
@staticmethod
def _unwrap_type(field_type: type) -> type:
args = get_args(field_type)
origin_type = get_origin(field_type)

# We don't want to try to unwrap Dict, List, Set, etc. values
if origin_type is not Union:
return field_type

if len(args) == 0:
raise TypeError("Expected type to have arguments, got none")

# Use the first type in the Union's args
return JSONObject._unwrap_type(args[0])

@staticmethod
def _try_from_json(json_value: Any, field_type: type):
"""
Determines whether a JSON dict is an instance of a field type.
"""

field_type = JSONObject._unwrap_type(field_type)

if inspect.isclass(field_type) and issubclass(field_type, JSONObject):
return field_type.from_json(json_value)

return json_value

@classmethod
def _parse_attr_list(cls, json_value, field_type):
def _parse_attr_list(cls, json_value: Any, field_type: type):
"""
Attempts to parse a list attribute with a given value and field type.
"""

# Edge case for optional list values
if json_value is None:
return None

type_hint_args = get_args(field_type)

if len(type_hint_args) < 1:
Expand All @@ -86,11 +118,13 @@ def _parse_attr_list(cls, json_value, field_type):
]

@classmethod
def _parse_attr(cls, json_value, field_type):
def _parse_attr(cls, json_value: Any, field_type: type):
"""
Attempts to parse an attribute with a given value and field type.
"""

field_type = JSONObject._unwrap_type(field_type)

if list in (field_type, get_origin(field_type)):
return cls._parse_attr_list(json_value, field_type)

Expand All @@ -117,7 +151,55 @@ def _serialize(self) -> Dict[str, Any]:
"""
Serializes this object into a JSON dict.
"""
return asdict(self)
cls = type(self)
type_hints = get_type_hints(cls)

def attempt_serialize(value: Any) -> Any:
"""
Attempts to serialize the given value, else returns the value unchanged.
"""
if issubclass(type(value), JSONObject):
return value._serialize()

return value

def should_include(key: str, value: Any) -> bool:
"""
Returns whether the given key/value pair should be included in the resulting dict.
"""

if key in cls.always_include:
return True

hint = type_hints.get(key)

# We want to exclude any Optional values that are None
# NOTE: We need to check for Union here because Optional is an alias of Union.
if (
hint is None
or get_origin(hint) is not Union
or type(None) not in get_args(hint)
):
return True

return value is not None

result = {}

for k, v in vars(self).items():
if not should_include(k, v):
continue

if isinstance(v, List):
v = [attempt_serialize(j) for j in v]
elif isinstance(v, Dict):
v = {k: attempt_serialize(j) for k, j in v.items()}
else:
v = attempt_serialize(v)

result[k] = v

return result
Comment on lines +154 to +202

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Unfortunately we have to reinvent the wheel here because we don't have access to type hints or always_include for nested JSONObjects in the asdict dict_factory method.

Ideally I'd want to break this out into a separate PR but excluding optional fields from generated request bodies is a requirement for LKEClusterControlPlaneACL.


@property
def dict(self) -> Dict[str, Any]:
Expand Down
4 changes: 2 additions & 2 deletions linode_api4/objects/vpc.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ def subnet_create(
return d

@property
def ips(self, *filters) -> PaginatedList:
def ips(self) -> PaginatedList:

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This isn't directly related to this change but it was causing the linter to fail. This shouldn't be a breaking change since arguments could already not be passed into ips 馃憤

"""
Get all the IP addresses under this VPC.

Expand All @@ -116,5 +116,5 @@ def ips(self, *filters) -> PaginatedList:
)

return self._client._get_and_filter(
VPCIPAddress, *filters, endpoint="/vpcs/{}/ips".format(self.id)
VPCIPAddress, endpoint="/vpcs/{}/ips".format(self.id)
)
Loading