Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support getting unknown types #432

Merged
merged 4 commits into from
Jul 3, 2024
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
19 changes: 13 additions & 6 deletions docs/object.md
Original file line number Diff line number Diff line change
Expand Up @@ -305,7 +305,11 @@ Instead we have focused on making the API extensible so that if there isn't a bu

### Extending the objects API

To create your own objects you can subclass [](#kr8s.objects.APIObject), however we recommend you use the {py:func}`new_class <kr8s.objects.new_class>` class factory to ensure all of the required attributes are set. These will be used when constructing API calls by the API client.
To create your own objects which will be a subclass of [](#kr8s.objects.APIObject), however we recommend you use the {py:func}`new_class <kr8s.objects.new_class>` class factory to ensure all of the required attributes are set. These will be used when constructing API calls by the API client.

```{danger}
Manually subclassing `APIObject` is considered an advanced topic and requires strong understanding of `kr8s` internals and how the sync/async wrapping works. For now it is recommended that you do not do this.
```

```python
from kr8s.objects import new_class
Expand All @@ -314,9 +318,7 @@ CustomObject = new_class(
kind="CustomObject",
version="example.org",
namespaced=True,
asyncio=False,
)

```

The [](#kr8s.objects.APIObject) base class contains helper methods such as `.create()`, `.delete()`, `.patch()`, `.exists()`, etc.
Expand All @@ -332,15 +334,20 @@ CustomScalableObject = new_class(
namespaced=True,
scalable=True,
scalable_spec="replicas", # The spec key to patch when scaling
asyncio=False,
)
```

### Using custom objects with other `kr8s` functions

When using the [`kr8s` API](client) some methods such as `kr8s.get("pods")` will want to return kr8s objects, in this case a `Pod`. The API client handles this by looking up all of the subclasses of [`APIObject`](#kr8s.objects.APIObject) and matching the `kind` against the kind returned by the API. If the API returns a kind of object that there is no kr8s object to deserialize into it will raise an exception.
When using the [`kr8s` API](client) some methods such as `kr8s.get("pods")` will want to return kr8s objects, in this case a `Pod`. The API client handles this by looking up all of the subclasses of [`APIObject`](#kr8s.objects.APIObject) and matching the `kind` against the kind returned by the API. If the API returns a kind of object that there is no kr8s object to deserialize into it will create a new class for you automatically.

```python
import kr8s

cos = kr8s.get("customobjects") # If a resource called `customobjects` exists on the server a class will be created dynamically for it
```

When you create your own custom objects that subclass [`APIObject`](#kr8s.objects.APIObject) the client is then able to use those objects in its response.
When you create your own custom objects with `new_class` the client is then able to use those objects in its response.

```python
import kr8s
Expand Down
87 changes: 78 additions & 9 deletions kr8s/_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -181,9 +181,14 @@ async def call_api(
continue
else:
if e.response.status_code >= 400 and e.response.status_code < 500:
error = e.response.json()
try:
error = e.response.json()
error_message = error["message"]
except json.JSONDecodeError:
error = e.response.text
error_message = str(e)
raise ServerError(
error["message"], status=error, response=e.response
error_message, status=error, response=e.response
) from e
elif e.response.status_code >= 500:
raise ServerError(
Expand Down Expand Up @@ -289,6 +294,57 @@ async def async_whoami(self):
[name] = cert.subject.get_attributes_for_oid(x509.OID_COMMON_NAME)
return name.value

async def async_lookup_kind(self, kind) -> Tuple[str, bool]:
"""Lookup a Kubernetes resource kind."""
from ._objects import parse_kind

resources = await self.async_api_resources()
kind, group, version = parse_kind(kind)
if group:
version = f"{group}/{version}"
for resource in resources:
if (not version or version in resource["version"]) and (
kind == resource["name"]
or kind == resource["kind"]
or kind == resource["singularName"]
or ("shortNames" in resource and kind in resource["shortNames"])
):
if "/" in resource["version"]:
return (
f"{resource['singularName']}.{resource['version']}",
resource["namespaced"],
)
return (
f"{resource['singularName']}/{resource['version']}",
resource["namespaced"],
)
raise ValueError(f"Kind {kind} not found.")

async def lookup_kind(self, kind) -> Tuple[str, bool]:
"""Lookup a Kubernetes resource kind.

Check whether a resource kind exists on the remote server.

Parameters
----------
kind : str
The kind of resource to lookup.

Returns
-------
str
The kind of resource.
bool
Whether the resource is namespaced

Raises
------

ValueError
If the kind is not found.
"""
return await self.async_lookup_kind(kind)

@contextlib.asynccontextmanager
async def async_get_kind(
self,
Expand All @@ -298,10 +354,11 @@ async def async_get_kind(
field_selector: Optional[Union[str, Dict]] = None,
params: Optional[dict] = None,
watch: bool = False,
allow_unknown_type: bool = True,
**kwargs,
) -> AsyncGenerator[Tuple[Type[APIObject], httpx.Response], None]:
"""Get a Kubernetes resource."""
from ._objects import get_class
from ._objects import get_class, new_class

if not namespace:
namespace = self.namespace
Expand All @@ -324,15 +381,19 @@ async def async_get_kind(
obj_cls = kind
else:
try:
resources = await self.async_api_resources()
for resource in resources:
if "shortNames" in resource and kind in resource["shortNames"]:
kind = resource["name"]
break
kind, namespaced = await self.async_lookup_kind(kind)
except ServerError as e:
warnings.warn(str(e))
if isinstance(kind, str):
obj_cls = get_class(kind, _asyncio=self._asyncio)
try:
obj_cls = get_class(kind, _asyncio=self._asyncio)
except KeyError as e:
if allow_unknown_type:
obj_cls = new_class(
kind, namespaced=namespaced, asyncio=self._asyncio
)
else:
raise e
params = params or None
async with self.call_api(
method="GET",
Expand All @@ -352,6 +413,7 @@ async def get(
label_selector: Optional[Union[str, Dict]] = None,
field_selector: Optional[Union[str, Dict]] = None,
as_object: Optional[Type[APIObject]] = None,
allow_unknown_type: bool = True,
**kwargs,
) -> Union[APIObject, List[APIObject]]:
"""
Expand All @@ -371,6 +433,8 @@ async def get(
The field selector to filter the resources by.
as_object : object, optional
The object to return the resources as.
allow_unknown_type:
Automatically create a class for the resource if none exists, default True.
**kwargs
Additional keyword arguments to pass to the API call.

Expand All @@ -386,6 +450,7 @@ async def get(
label_selector=label_selector,
field_selector=field_selector,
as_object=as_object,
allow_unknown_type=allow_unknown_type,
**kwargs,
)

Expand All @@ -397,6 +462,7 @@ async def async_get(
label_selector: Optional[Union[str, Dict]] = None,
field_selector: Optional[Union[str, Dict]] = None,
as_object: Optional[Type[APIObject]] = None,
allow_unknown_type: bool = True,
**kwargs,
) -> Union[APIObject, List[APIObject]]:
headers = {}
Expand All @@ -411,6 +477,7 @@ async def async_get(
label_selector=label_selector,
field_selector=field_selector,
headers=headers or None,
allow_unknown_type=allow_unknown_type,
**kwargs,
) as (obj_cls, response):
resourcelist = response.json()
Expand Down Expand Up @@ -454,6 +521,7 @@ async def async_watch(
label_selector: Optional[Union[str, Dict]] = None,
field_selector: Optional[Union[str, Dict]] = None,
since: Optional[str] = None,
allow_unknown_type: bool = True,
) -> AsyncGenerator[Tuple[str, object], None]:
"""Watch a Kubernetes resource."""
async with self.async_get_kind(
Expand All @@ -464,6 +532,7 @@ async def async_watch(
params={"resourceVersion": since} if since else None,
watch=True,
timeout=None,
allow_unknown_type=allow_unknown_type,
) as (obj_cls, response):
async for line in response.aiter_lines():
event = json.loads(line)
Expand Down
38 changes: 38 additions & 0 deletions kr8s/_objects.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
List,
Literal,
Optional,
Tuple,
Type,
Union,
)
Expand Down Expand Up @@ -1772,3 +1773,40 @@ async def objects_from_files(
await obj
objects.append(obj)
return objects


def parse_kind(kind: str) -> Tuple[str, str, str]:
"""Parse a Kubernetes resource kind into a tuple of (kind, group, version).

Args:
kind: The Kubernetes resource kind.

Returns:
A tuple of (kind, group, version).

Example:
>>> parse_kind("Pod")
("pod", "", "")
>>> parse_kind("deployment")
("deployment", "", "")
>>> parse_kind("services/v1")
("services", "", "v1")
>>> parse_kind("ingress.networking.k8s.io/v1")
("ingress", "networking.k8s.io", "v1")
>>> parse_kind("role.v1.rbac.authorization.k8s.io")
("role", "rbac.authorization.k8s.io", "v1")
"""
if "/" in kind:
kind, version = kind.split("/", 1)
else:
version = ""
if "." in kind:
kind, group = kind.split(".", 1)
else:
group = ""
if "." in group:
first, potential_group = group.split(".", 1)
if re.match(r"v\d[a-z0-9]*", first):
version = first
group = potential_group
return kind.lower(), group.lower(), version.lower()
4 changes: 4 additions & 0 deletions kr8s/asyncio/_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ async def get(
label_selector: Optional[Union[str, Dict]] = None,
field_selector: Optional[Union[str, Dict]] = None,
as_object: Optional[object] = None,
allow_unknown_type: bool = True,
api=None,
_asyncio=True,
**kwargs,
Expand All @@ -34,6 +35,8 @@ async def get(
The field selector to filter the resources by
as_object : object, optional
The object to populate with the resource data
allow_unknown_type : bool, optional
Automatically create a class for the resource if none exists
api : Api, optional
The api to use to get the resource

Expand Down Expand Up @@ -69,6 +72,7 @@ async def get(
label_selector=label_selector,
field_selector=field_selector,
as_object=as_object,
allow_unknown_type=allow_unknown_type,
**kwargs,
)

Expand Down
57 changes: 57 additions & 0 deletions kr8s/tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -312,3 +312,60 @@ async def test_api_timeout() -> None:
api.timeout = 0.00001
with pytest.raises(APITimeoutError):
await api.version()


async def test_lookup_kind():
api = await kr8s.asyncio.api()

assert await api.lookup_kind("no") == ("node/v1", False)
assert await api.lookup_kind("nodes") == ("node/v1", False)
assert await api.lookup_kind("po") == ("pod/v1", True)
assert await api.lookup_kind("pods/v1") == ("pod/v1", True)
assert await api.lookup_kind("role") == ("role.rbac.authorization.k8s.io/v1", True)
assert await api.lookup_kind("roles") == ("role.rbac.authorization.k8s.io/v1", True)
assert await api.lookup_kind("roles.v1.rbac.authorization.k8s.io") == (
"role.rbac.authorization.k8s.io/v1",
True,
)
assert await api.lookup_kind("roles.rbac.authorization.k8s.io") == (
"role.rbac.authorization.k8s.io/v1",
True,
)


async def test_nonexisting_resource_type():
api = await kr8s.asyncio.api()

with pytest.raises(ValueError):
await api.get("foo.bar.baz/v1")


@pytest.mark.parametrize(
"kind",
[
"csr",
"certificatesigningrequest",
"certificatesigningrequests",
"certificatesigningrequest.certificates.k8s.io",
"certificatesigningrequests.certificates.k8s.io",
"certificatesigningrequest.v1.certificates.k8s.io",
"certificatesigningrequests.v1.certificates.k8s.io",
"certificatesigningrequest.certificates.k8s.io/v1",
"certificatesigningrequests.certificates.k8s.io/v1",
],
)
async def test_dynamic_classes(kind):
api = await kr8s.asyncio.api()

if not any(["istio.io" in r["version"] for r in await api.api_resources()]):
pytest.skip("Istio not installed")

from kr8s.asyncio.objects import get_class

with pytest.raises(KeyError):
get_class("certificatesigningrequest", "certificates.k8s.io/v1")

with pytest.raises(KeyError):
await api.get(kind, allow_unknown_type=False)

await api.get(kind)
41 changes: 41 additions & 0 deletions kr8s/tests/test_objects.py
Original file line number Diff line number Diff line change
Expand Up @@ -1124,3 +1124,44 @@ def test_object_setter_from_old_spec(example_pod_spec):
assert new_po.raw["spec"]["containers"][0]["name"] != "bar"
new_po.raw["spec"] = po.raw["spec"]
assert new_po.raw["spec"]["containers"][0]["name"] == "bar"


def test_parse_kind():
from kr8s._objects import parse_kind

assert parse_kind("Pod") == ("pod", "", "")
assert parse_kind("Pods") == ("pods", "", "")
assert parse_kind("pod/v1") == ("pod", "", "v1")
assert parse_kind("deploy") == ("deploy", "", "")
assert parse_kind("gateway") == ("gateway", "", "")
assert parse_kind("gateways") == ("gateways", "", "")
assert parse_kind("gateway.networking.istio.io") == (
"gateway",
"networking.istio.io",
"",
)
assert parse_kind("gateways.networking.istio.io") == (
"gateways",
"networking.istio.io",
"",
)
assert parse_kind("gateway.v1.networking.istio.io") == (
"gateway",
"networking.istio.io",
"v1",
)
assert parse_kind("gateways.v1.networking.istio.io") == (
"gateways",
"networking.istio.io",
"v1",
)
assert parse_kind("gateway.networking.istio.io/v1") == (
"gateway",
"networking.istio.io",
"v1",
)
assert parse_kind("gateways.networking.istio.io/v1") == (
"gateways",
"networking.istio.io",
"v1",
)
Loading