Skip to content

Commit 5f1c1df

Browse files
new: Add support for LKE Control Plane ACLs (#406)
## 📝 Description This pull request adds support for configuring and viewing the ACL configuration for an LKE cluster's control plane. **NOTE: This PR does NOT include the PUT LKE cluster changes because the acl configuration is not returned in the GET LKE cluster response.** #### New Endpoint Methods * `control_plane_acl` - GET `/lke/clusters/{cluster_id}/control_plane_acl` * `control_plane_acl_update(...)` - PUT `/lke/clusters/{cluster_id}/control_plane_acl` * `control_plane_acl_delete()` - POST `/lke/clusters/{cluster_id}/control_plane_acl` #### Updated Endpoint Methods * `LKEGroup.cluster_create(...)` - Add control_plane field to method arguments #### Misc Changes * Added data classes for LKE cluster control plane and all substructures * Added logic to JSONObject to remove `Optional` values from the generated dict if None * Added a new `always_include` class var used to designate optional values that should always be included in the generated Dict * Updated test fixture framework to support underscores in path ## ✔️ How to Test The following test steps assume you have pulled down this PR locally and run `make install`. ### Unit Testing ``` make testunit ``` ### Integration Testing ``` make TEST_COMMAND=models/lke/test_lke.py testint ``` ### Manual Testing In a Python SDK sandbox environment (e.g. dx-devenv), run the following: ```python import os from linode_api4 import ( LinodeClient, LKEClusterControlPlaneOptions, LKEClusterControlPlaneACLOptions, LKEClusterControlPlaneACLAddressesOptions, ) client = LinodeClient(token=os.getenv("LINODE_TOKEN")) cluster = client.lke.cluster_create( "us-mia", "test-cluster", client.lke.node_pool("g6-standard-1", 1), "1.29", control_plane=LKEClusterControlPlaneOptions( acl=LKEClusterControlPlaneACLOptions( enabled=True, addresses=LKEClusterControlPlaneACLAddressesOptions( ipv4=["10.0.0.1/32"], ipv6=["1234::5678"] ), ) ), ) print("Original ACL:", cluster.control_plane_acl) cluster.control_plane_acl_update( LKEClusterControlPlaneACLOptions( addresses=LKEClusterControlPlaneACLAddressesOptions(ipv4=["10.0.0.2/32"]) ) ) print("Updated ACL:", cluster.control_plane_acl) cluster.control_plane_acl_delete() print("Deleted ACL:", cluster.control_plane_acl) ``` 2. Ensure the script runs successfully and the output matches the following: ``` Original ACL: LKEClusterControlPlaneACL(enabled=True, addresses=LKEClusterControlPlaneACLAddresses(ipv4=['10.0.0.1/32'], ipv6=['1234::5678/128'])) Updated ACL: LKEClusterControlPlaneACL(enabled=True, addresses=LKEClusterControlPlaneACLAddresses(ipv4=['10.0.0.2/32'], ipv6=None)) Deleted ACL: LKEClusterControlPlaneACL(enabled=False, addresses=None) ```
1 parent e4ffa17 commit 5f1c1df

File tree

9 files changed

+473
-11
lines changed

9 files changed

+473
-11
lines changed

linode_api4/groups/lke.py

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,15 @@
1+
from typing import Any, Dict, Union
2+
13
from linode_api4.errors import UnexpectedResponseError
24
from linode_api4.groups import Group
3-
from linode_api4.objects import Base, KubeVersion, LKECluster
5+
from linode_api4.objects import (
6+
Base,
7+
JSONObject,
8+
KubeVersion,
9+
LKECluster,
10+
LKEClusterControlPlaneOptions,
11+
drop_null_keys,
12+
)
413

514

615
class LKEGroup(Group):
@@ -47,7 +56,17 @@ def clusters(self, *filters):
4756
"""
4857
return self.client._get_and_filter(LKECluster, *filters)
4958

50-
def cluster_create(self, region, label, node_pools, kube_version, **kwargs):
59+
def cluster_create(
60+
self,
61+
region,
62+
label,
63+
node_pools,
64+
kube_version,
65+
control_plane: Union[
66+
LKEClusterControlPlaneOptions, Dict[str, Any]
67+
] = None,
68+
**kwargs,
69+
):
5170
"""
5271
Creates an :any:`LKECluster` on this account in the given region, with
5372
the given label, and with node pools as described. For example::
@@ -80,6 +99,8 @@ def cluster_create(self, region, label, node_pools, kube_version, **kwargs):
8099
formatted dicts.
81100
:param kube_version: The version of Kubernetes to use
82101
:type kube_version: KubeVersion or str
102+
:param control_plane: Dict[str, Any] or LKEClusterControlPlaneRequest
103+
:type control_plane: The control plane configuration of this LKE cluster.
83104
:param kwargs: Any other arguments to pass along to the API. See the API
84105
docs for possible values.
85106
@@ -112,10 +133,15 @@ def cluster_create(self, region, label, node_pools, kube_version, **kwargs):
112133
if issubclass(type(kube_version), Base)
113134
else kube_version
114135
),
136+
"control_plane": (
137+
control_plane.dict
138+
if issubclass(type(control_plane), JSONObject)
139+
else control_plane
140+
),
115141
}
116142
params.update(kwargs)
117143

118-
result = self.client.post("/lke/clusters", data=params)
144+
result = self.client.post("/lke/clusters", data=drop_null_keys(params))
119145

120146
if "id" not in result:
121147
raise UnexpectedResponseError(

linode_api4/objects/lke.py

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
1+
from dataclasses import dataclass
2+
from typing import Any, Dict, List, Optional, Union
13
from urllib import parse
24

35
from linode_api4.errors import UnexpectedResponseError
46
from linode_api4.objects import (
57
Base,
68
DerivedBase,
79
Instance,
10+
JSONObject,
811
MappedObject,
912
Property,
1013
Region,
@@ -26,6 +29,61 @@ class KubeVersion(Base):
2629
}
2730

2831

32+
@dataclass
33+
class LKEClusterControlPlaneACLAddressesOptions(JSONObject):
34+
"""
35+
LKEClusterControlPlaneACLAddressesOptions are options used to configure
36+
IP ranges that are explicitly allowed to access an LKE cluster's control plane.
37+
"""
38+
39+
ipv4: Optional[List[str]] = None
40+
ipv6: Optional[List[str]] = None
41+
42+
43+
@dataclass
44+
class LKEClusterControlPlaneACLOptions(JSONObject):
45+
"""
46+
LKEClusterControlPlaneACLOptions is used to set
47+
the ACL configuration of an LKE cluster's control plane.
48+
"""
49+
50+
enabled: Optional[bool] = None
51+
addresses: Optional[LKEClusterControlPlaneACLAddressesOptions] = None
52+
53+
54+
@dataclass
55+
class LKEClusterControlPlaneOptions(JSONObject):
56+
"""
57+
LKEClusterControlPlaneOptions is used to configure
58+
the control plane of an LKE cluster during its creation.
59+
"""
60+
61+
high_availability: Optional[bool] = None
62+
acl: Optional[LKEClusterControlPlaneACLOptions] = None
63+
64+
65+
@dataclass
66+
class LKEClusterControlPlaneACLAddresses(JSONObject):
67+
"""
68+
LKEClusterControlPlaneACLAddresses describes IP ranges that are explicitly allowed
69+
to access an LKE cluster's control plane.
70+
"""
71+
72+
ipv4: List[str] = None
73+
ipv6: List[str] = None
74+
75+
76+
@dataclass
77+
class LKEClusterControlPlaneACL(JSONObject):
78+
"""
79+
LKEClusterControlPlaneACL describes the ACL configuration of an LKE cluster's
80+
control plane.
81+
"""
82+
83+
enabled: bool = False
84+
addresses: LKEClusterControlPlaneACLAddresses = None
85+
86+
2987
class LKENodePoolNode:
3088
"""
3189
AN LKE Node Pool Node is a helper class that is used to populate the "nodes"
@@ -129,6 +187,21 @@ class LKECluster(Base):
129187
"control_plane": Property(mutable=True),
130188
}
131189

190+
def invalidate(self):
191+
"""
192+
Extends the default invalidation logic to drop cached properties.
193+
"""
194+
if hasattr(self, "_api_endpoints"):
195+
del self._api_endpoints
196+
197+
if hasattr(self, "_kubeconfig"):
198+
del self._kubeconfig
199+
200+
if hasattr(self, "_control_plane_acl"):
201+
del self._control_plane_acl
202+
203+
Base.invalidate(self)
204+
132205
@property
133206
def api_endpoints(self):
134207
"""
@@ -186,6 +259,26 @@ def kubeconfig(self):
186259

187260
return self._kubeconfig
188261

262+
@property
263+
def control_plane_acl(self) -> LKEClusterControlPlaneACL:
264+
"""
265+
Gets the ACL configuration of this cluster's control plane.
266+
267+
API Documentation: TODO
268+
269+
:returns: The cluster's control plane ACL configuration.
270+
:rtype: LKEClusterControlPlaneACL
271+
"""
272+
273+
if not hasattr(self, "_control_plane_acl"):
274+
result = self._client.get(
275+
f"{LKECluster.api_endpoint}/control_plane_acl", model=self
276+
)
277+
278+
self._control_plane_acl = result.get("acl")
279+
280+
return LKEClusterControlPlaneACL.from_json(self._control_plane_acl)
281+
189282
def node_pool_create(self, node_type, node_count, **kwargs):
190283
"""
191284
Creates a new :any:`LKENodePool` for this cluster.
@@ -335,3 +428,48 @@ def service_token_delete(self):
335428
self._client.delete(
336429
"{}/servicetoken".format(LKECluster.api_endpoint), model=self
337430
)
431+
432+
def control_plane_acl_update(
433+
self, acl: Union[LKEClusterControlPlaneACLOptions, Dict[str, Any]]
434+
) -> LKEClusterControlPlaneACL:
435+
"""
436+
Updates the ACL configuration for this cluster's control plane.
437+
438+
API Documentation: TODO
439+
440+
:param acl: The ACL configuration to apply to this cluster.
441+
:type acl: LKEClusterControlPlaneACLOptions or Dict[str, Any]
442+
443+
:returns: The updated control plane ACL configuration.
444+
:rtype: LKEClusterControlPlaneACL
445+
"""
446+
if isinstance(acl, LKEClusterControlPlaneACLOptions):
447+
acl = acl.dict
448+
449+
result = self._client.put(
450+
f"{LKECluster.api_endpoint}/control_plane_acl",
451+
model=self,
452+
data={"acl": acl},
453+
)
454+
455+
acl = result.get("acl")
456+
457+
self._control_plane_acl = result.get("acl")
458+
459+
return LKEClusterControlPlaneACL.from_json(acl)
460+
461+
def control_plane_acl_delete(self):
462+
"""
463+
Deletes the ACL configuration for this cluster's control plane.
464+
This has the same effect as calling control_plane_acl_update with the `enabled` field
465+
set to False. Access controls are disabled and all rules are deleted.
466+
467+
API Documentation: TODO
468+
"""
469+
self._client.delete(
470+
f"{LKECluster.api_endpoint}/control_plane_acl", model=self
471+
)
472+
473+
# Invalidate the cache so it is automatically refreshed on next access
474+
if hasattr(self, "_control_plane_acl"):
475+
del self._control_plane_acl

linode_api4/objects/serializable.py

Lines changed: 86 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
import inspect
2-
from dataclasses import asdict, dataclass
2+
from dataclasses import dataclass
33
from types import SimpleNamespace
44
from typing import (
55
Any,
66
ClassVar,
77
Dict,
8+
List,
89
Optional,
10+
Set,
11+
Union,
912
get_args,
1013
get_origin,
1114
get_type_hints,
@@ -54,28 +57,57 @@ class JSONObject(metaclass=JSONFilterableMetaclass):
5457
)
5558
"""
5659

60+
always_include: ClassVar[Set[str]] = {}
61+
"""
62+
A set of keys corresponding to fields that should always be
63+
included in the generated output regardless of whether their values
64+
are None.
65+
"""
66+
5767
def __init__(self):
5868
raise NotImplementedError(
5969
"JSONObject is not intended to be constructed directly"
6070
)
6171

6272
# TODO: Implement __repr__
73+
@staticmethod
74+
def _unwrap_type(field_type: type) -> type:
75+
args = get_args(field_type)
76+
origin_type = get_origin(field_type)
77+
78+
# We don't want to try to unwrap Dict, List, Set, etc. values
79+
if origin_type is not Union:
80+
return field_type
81+
82+
if len(args) == 0:
83+
raise TypeError("Expected type to have arguments, got none")
84+
85+
# Use the first type in the Union's args
86+
return JSONObject._unwrap_type(args[0])
6387

6488
@staticmethod
6589
def _try_from_json(json_value: Any, field_type: type):
6690
"""
6791
Determines whether a JSON dict is an instance of a field type.
6892
"""
93+
94+
field_type = JSONObject._unwrap_type(field_type)
95+
6996
if inspect.isclass(field_type) and issubclass(field_type, JSONObject):
7097
return field_type.from_json(json_value)
98+
7199
return json_value
72100

73101
@classmethod
74-
def _parse_attr_list(cls, json_value, field_type):
102+
def _parse_attr_list(cls, json_value: Any, field_type: type):
75103
"""
76104
Attempts to parse a list attribute with a given value and field type.
77105
"""
78106

107+
# Edge case for optional list values
108+
if json_value is None:
109+
return None
110+
79111
type_hint_args = get_args(field_type)
80112

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

88120
@classmethod
89-
def _parse_attr(cls, json_value, field_type):
121+
def _parse_attr(cls, json_value: Any, field_type: type):
90122
"""
91123
Attempts to parse an attribute with a given value and field type.
92124
"""
93125

126+
field_type = JSONObject._unwrap_type(field_type)
127+
94128
if list in (field_type, get_origin(field_type)):
95129
return cls._parse_attr_list(json_value, field_type)
96130

@@ -117,7 +151,55 @@ def _serialize(self) -> Dict[str, Any]:
117151
"""
118152
Serializes this object into a JSON dict.
119153
"""
120-
return asdict(self)
154+
cls = type(self)
155+
type_hints = get_type_hints(cls)
156+
157+
def attempt_serialize(value: Any) -> Any:
158+
"""
159+
Attempts to serialize the given value, else returns the value unchanged.
160+
"""
161+
if issubclass(type(value), JSONObject):
162+
return value._serialize()
163+
164+
return value
165+
166+
def should_include(key: str, value: Any) -> bool:
167+
"""
168+
Returns whether the given key/value pair should be included in the resulting dict.
169+
"""
170+
171+
if key in cls.always_include:
172+
return True
173+
174+
hint = type_hints.get(key)
175+
176+
# We want to exclude any Optional values that are None
177+
# NOTE: We need to check for Union here because Optional is an alias of Union.
178+
if (
179+
hint is None
180+
or get_origin(hint) is not Union
181+
or type(None) not in get_args(hint)
182+
):
183+
return True
184+
185+
return value is not None
186+
187+
result = {}
188+
189+
for k, v in vars(self).items():
190+
if not should_include(k, v):
191+
continue
192+
193+
if isinstance(v, List):
194+
v = [attempt_serialize(j) for j in v]
195+
elif isinstance(v, Dict):
196+
v = {k: attempt_serialize(j) for k, j in v.items()}
197+
else:
198+
v = attempt_serialize(v)
199+
200+
result[k] = v
201+
202+
return result
121203

122204
@property
123205
def dict(self) -> Dict[str, Any]:

linode_api4/objects/vpc.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@ def subnet_create(
100100
return d
101101

102102
@property
103-
def ips(self, *filters) -> PaginatedList:
103+
def ips(self) -> PaginatedList:
104104
"""
105105
Get all the IP addresses under this VPC.
106106
@@ -116,5 +116,5 @@ def ips(self, *filters) -> PaginatedList:
116116
)
117117

118118
return self._client._get_and_filter(
119-
VPCIPAddress, *filters, endpoint="/vpcs/{}/ips".format(self.id)
119+
VPCIPAddress, endpoint="/vpcs/{}/ips".format(self.id)
120120
)

0 commit comments

Comments
 (0)