From b08fa48b59fa2ab3536d935ef69e676a009f40ca Mon Sep 17 00:00:00 2001 From: Jacob Tomlinson Date: Mon, 19 Jun 2023 09:46:44 +0100 Subject: [PATCH 1/6] Implement apply --- kr8s/_objects.py | 14 +++++++++++++- kr8s/tests/test_objects.py | 14 ++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/kr8s/_objects.py b/kr8s/_objects.py index 689b5d27..fd693be9 100644 --- a/kr8s/_objects.py +++ b/kr8s/_objects.py @@ -176,7 +176,7 @@ async def _exists(self, ensure=False) -> bool: raise NotFoundError(f"Object {self.name} does not exist") return False - async def create(self) -> None: + async def _create(self) -> None: """Create this object in Kubernetes.""" async with self.api.call_api( "POST", @@ -187,6 +187,18 @@ async def create(self) -> None: ) as resp: self.raw = await resp.json() + async def create(self) -> None: + """Create this object in Kubernetes.""" + await self._create() + + async def apply(self) -> None: + """Apply this object to Kubernetes.""" + if await self.exists(): + # await self._patch(self.raw) + await self.patch({"spec": self.spec, "metadata": self.metadata}) + else: + await self._create() + async def delete(self, propagation_policy: str = None) -> None: """Delete this object from Kubernetes.""" data = {} diff --git a/kr8s/tests/test_objects.py b/kr8s/tests/test_objects.py index 20634c01..6ceadfcc 100644 --- a/kr8s/tests/test_objects.py +++ b/kr8s/tests/test_objects.py @@ -183,6 +183,20 @@ async def test_pod_metadata(example_pod_spec): await pod.delete() +async def test_pod_apply(example_pod_spec): + pod = await Pod(example_pod_spec) + await pod.apply() + assert "name" in pod.metadata + assert "hello" in pod.labels + assert "foo" not in pod.labels + + pod.raw["metadata"]["labels"]["foo"] = "bar" + await pod.apply() + assert "foo" in pod.labels + + await pod.delete() + + async def test_pod_missing_labels_annotations(example_pod_spec): del example_pod_spec["metadata"]["labels"] del example_pod_spec["metadata"]["annotations"] From 612f7b6ba96359f1c09bf05e2250a7e47e749e25 Mon Sep 17 00:00:00 2001 From: Jacob Tomlinson Date: Mon, 19 Jun 2023 16:42:12 +0100 Subject: [PATCH 2/6] Ensure apply works --- kr8s/_objects.py | 6 ++++-- kr8s/tests/test_objects.py | 1 + 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/kr8s/_objects.py b/kr8s/_objects.py index fd693be9..fd6fb3d1 100644 --- a/kr8s/_objects.py +++ b/kr8s/_objects.py @@ -194,8 +194,10 @@ async def create(self) -> None: async def apply(self) -> None: """Apply this object to Kubernetes.""" if await self.exists(): - # await self._patch(self.raw) - await self.patch({"spec": self.spec, "metadata": self.metadata}) + metadata = { + k: v for k, v in self.metadata.items() if k in ["labels", "annotations"] + } + await self.patch({"spec": self.spec, "metadata": metadata}) else: await self._create() diff --git a/kr8s/tests/test_objects.py b/kr8s/tests/test_objects.py index 6ceadfcc..0f981d5d 100644 --- a/kr8s/tests/test_objects.py +++ b/kr8s/tests/test_objects.py @@ -192,6 +192,7 @@ async def test_pod_apply(example_pod_spec): pod.raw["metadata"]["labels"]["foo"] = "bar" await pod.apply() + await pod.refresh() assert "foo" in pod.labels await pod.delete() From 00678b6cbeba27fa97ba54fb5ba1a4f251e5f86e Mon Sep 17 00:00:00 2001 From: Jacob Tomlinson Date: Tue, 20 Jun 2023 09:12:13 +0100 Subject: [PATCH 3/6] Start implementing kubectl-ng apply --- examples/kubectl-ng/kubectl_ng/_apply.py | 46 ++++++++++++++++++++++++ examples/kubectl-ng/kubectl_ng/cli.py | 2 ++ kr8s/_objects.py | 3 +- 3 files changed, 50 insertions(+), 1 deletion(-) create mode 100644 examples/kubectl-ng/kubectl_ng/_apply.py diff --git a/examples/kubectl-ng/kubectl_ng/_apply.py b/examples/kubectl-ng/kubectl_ng/_apply.py new file mode 100644 index 00000000..d018dc25 --- /dev/null +++ b/examples/kubectl-ng/kubectl_ng/_apply.py @@ -0,0 +1,46 @@ +# SPDX-FileCopyrightText: Copyright (c) 2023, Dask Developers, NVIDIA +# SPDX-License-Identifier: BSD 3-Clause License + +import json + +import typer +from rich.console import Console + +import kr8s.asyncio +from kr8s.asyncio.objects import objects_from_files + +console = Console() + + +async def apply( + filename: str = typer.Option( + "", + "--filename", + "-f", + help="Filename, directory, or URL to files identifying the resource to wait on", + ), +): + api = await kr8s.asyncio.api() + try: + objs = await objects_from_files(filename, api) + except Exception as e: + console.print(f"[red]Error loading objects from {filename}[/red]: {e}") + raise typer.Exit(1) + successful = True + for obj in objs: + if not obj.annotations: + obj.raw["metadata"]["annotations"] = {} + obj.raw["metadata"]["annotations"][ + "kubectl.kubernetes.io/last-applied-configuration" + ] = json.dumps(obj.raw) + try: + await obj.apply() + # TODO detect if created, modified or unchanged + console.print(f"[green]{obj.singular}/{obj.name} applied[/green]") + except Exception as e: + console.print(f"[red]{obj.singular}/{obj.name} failed[/red]: {e}") + breakpoint() + successful = False + continue + if not successful: + raise typer.Exit(1) diff --git a/examples/kubectl-ng/kubectl_ng/cli.py b/examples/kubectl-ng/kubectl_ng/cli.py index cc3cd03f..63c75866 100644 --- a/examples/kubectl-ng/kubectl_ng/cli.py +++ b/examples/kubectl-ng/kubectl_ng/cli.py @@ -6,6 +6,7 @@ import typer from ._api_resources import api_resources +from ._apply import apply from ._get import get from ._version import version from ._wait import wait @@ -30,6 +31,7 @@ def register(app, func): register(app, get) register(app, version) register(app, wait) +register(app, apply) def go(): diff --git a/kr8s/_objects.py b/kr8s/_objects.py index fd6fb3d1..e0083e97 100644 --- a/kr8s/_objects.py +++ b/kr8s/_objects.py @@ -197,7 +197,8 @@ async def apply(self) -> None: metadata = { k: v for k, v in self.metadata.items() if k in ["labels", "annotations"] } - await self.patch({"spec": self.spec, "metadata": metadata}) + # TODO compare the remote spec and local spec and only patch the difference + await self._patch({"spec": self.spec, "metadata": metadata}) else: await self._create() From 18b991081b0213396f90c2e6b9ff89c1fdf72dcc Mon Sep 17 00:00:00 2001 From: Jacob Tomlinson Date: Thu, 22 Jun 2023 17:26:01 +0100 Subject: [PATCH 4/6] Continue working on apply --- examples/kubectl-ng/kubectl_ng/_apply.py | 1 - kr8s/_objects.py | 5 +++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/examples/kubectl-ng/kubectl_ng/_apply.py b/examples/kubectl-ng/kubectl_ng/_apply.py index d018dc25..f424dfac 100644 --- a/examples/kubectl-ng/kubectl_ng/_apply.py +++ b/examples/kubectl-ng/kubectl_ng/_apply.py @@ -39,7 +39,6 @@ async def apply( console.print(f"[green]{obj.singular}/{obj.name} applied[/green]") except Exception as e: console.print(f"[red]{obj.singular}/{obj.name} failed[/red]: {e}") - breakpoint() successful = False continue if not successful: diff --git a/kr8s/_objects.py b/kr8s/_objects.py index e0083e97..2dbbbef9 100644 --- a/kr8s/_objects.py +++ b/kr8s/_objects.py @@ -198,7 +198,7 @@ async def apply(self) -> None: k: v for k, v in self.metadata.items() if k in ["labels", "annotations"] } # TODO compare the remote spec and local spec and only patch the difference - await self._patch({"spec": self.spec, "metadata": metadata}) + await self._patch({"spec": self.spec, "metadata": metadata}, force=True) else: await self._create() @@ -242,7 +242,7 @@ async def patch(self, patch, *, subresource=None) -> None: """Patch this object in Kubernetes.""" return await self._patch(patch, subresource=subresource) - async def _patch(self, patch: Dict, *, subresource=None) -> None: + async def _patch(self, patch: Dict, *, subresource=None, **kwargs) -> None: """Patch this object in Kubernetes.""" url = f"{self.endpoint}/{self.name}" if subresource: @@ -254,6 +254,7 @@ async def _patch(self, patch: Dict, *, subresource=None) -> None: namespace=self.namespace, data=json.dumps(patch), headers={"Content-Type": "application/merge-patch+json"}, + **kwargs, ) as resp: self.raw = await resp.json() From cb2f4af28174b281e3262cf5a4196fed21853769 Mon Sep 17 00:00:00 2001 From: Jacob Tomlinson Date: Mon, 11 Sep 2023 14:25:55 +0100 Subject: [PATCH 5/6] Try and simplify apply patch --- examples/kubectl-ng/kubectl_ng/_apply.py | 4 +-- kr8s/_data_utils.py | 25 ++++++++++++++ kr8s/_objects.py | 42 ++++++++++++++++++++---- kr8s/tests/test_data_utils.py | 22 ++++++++++++- 4 files changed, 83 insertions(+), 10 deletions(-) diff --git a/examples/kubectl-ng/kubectl_ng/_apply.py b/examples/kubectl-ng/kubectl_ng/_apply.py index f424dfac..3798bffa 100644 --- a/examples/kubectl-ng/kubectl_ng/_apply.py +++ b/examples/kubectl-ng/kubectl_ng/_apply.py @@ -34,9 +34,9 @@ async def apply( "kubectl.kubernetes.io/last-applied-configuration" ] = json.dumps(obj.raw) try: - await obj.apply() + status = await obj.apply() # TODO detect if created, modified or unchanged - console.print(f"[green]{obj.singular}/{obj.name} applied[/green]") + console.print(f"[green]{obj.singular}/{obj.name} {status}[/green]") except Exception as e: console.print(f"[red]{obj.singular}/{obj.name} failed[/red]: {e}") successful = False diff --git a/kr8s/_data_utils.py b/kr8s/_data_utils.py index e590ee1d..cffbad5c 100644 --- a/kr8s/_data_utils.py +++ b/kr8s/_data_utils.py @@ -66,3 +66,28 @@ def dict_to_selector(selector_dict: Dict) -> str: A Kubernetes selector string. """ return ",".join(f"{k}={v}" for k, v in selector_dict.items()) + + +def diff_nested_dicts(dict1: dict, dict2: dict) -> dict: + """Return the difference between two nested dictionaries. + + Parameters + ---------- + dict1 : dict + The first dictionary to compare. + dict2 : dict + The second dictionary to compare. + + Returns + ------- + dict + A dictionary containing the differences between the two input + dictionaries. + """ + diff = {} + for key in dict1.keys() | dict2.keys(): + if isinstance(dict1.get(key), dict) and isinstance(dict2.get(key), dict): + diff[key] = diff_nested_dicts(dict1[key], dict2[key]) + elif dict1.get(key) != dict2.get(key): + diff[key] = dict2.get(key) + return diff diff --git a/kr8s/_objects.py b/kr8s/_objects.py index 1fc330e7..5825bf73 100644 --- a/kr8s/_objects.py +++ b/kr8s/_objects.py @@ -8,7 +8,7 @@ import pathlib import re import time -from typing import Any, Dict, List, Optional, Type, Union +from typing import Any, Dict, List, Literal, Optional, Type, Union import anyio import httpx @@ -19,7 +19,12 @@ import kr8s import kr8s.asyncio from kr8s._api import Api -from kr8s._data_utils import dict_to_selector, dot_to_nested_dict, list_dict_unpack +from kr8s._data_utils import ( + dict_to_selector, + diff_nested_dicts, + dot_to_nested_dict, + list_dict_unpack, +) from kr8s._exceptions import NotFoundError from kr8s.asyncio.portforward import PortForward as AsyncPortForward from kr8s.portforward import PortForward as SyncPortForward @@ -242,16 +247,39 @@ async def create(self) -> None: """Create this object in Kubernetes.""" await self._create() - async def apply(self) -> None: + async def apply(self) -> Literal["created", "modified", "unchanged"]: """Apply this object to Kubernetes.""" - if await self.exists(): + try: + existing = await self.get(self.name, self.namespace) + print(existing.raw) + print(self.raw) + if ( + existing.spec.to_dict() == self.spec.to_dict() + and existing.annotations == self.annotations + and existing.labels == self.labels + ): + return "unchanged" metadata = { k: v for k, v in self.metadata.items() if k in ["labels", "annotations"] } - # TODO compare the remote spec and local spec and only patch the difference - await self._patch({"spec": self.spec, "metadata": metadata}, force=True) - else: + existing_metadata = { + k: v + for k, v in existing.metadata.items() + if k in ["labels", "annotations"] + } + patch = diff_nested_dicts( + {"spec": existing.spec.to_dict(), "metadata": existing_metadata}, + {"spec": self.spec.to_dict(), "metadata": metadata}, + ) + print(patch) + await self._patch( + patch, + force=True, + ) + return "modified" + except NotFoundError: await self._create() + return "created" async def delete(self, propagation_policy: str = None) -> None: """Delete this object from Kubernetes.""" diff --git a/kr8s/tests/test_data_utils.py b/kr8s/tests/test_data_utils.py index d73ceda9..463ac78e 100644 --- a/kr8s/tests/test_data_utils.py +++ b/kr8s/tests/test_data_utils.py @@ -1,7 +1,12 @@ # SPDX-FileCopyrightText: Copyright (c) 2023, Dask Developers, NVIDIA # SPDX-License-Identifier: BSD 3-Clause License -from kr8s._data_utils import dict_to_selector, dot_to_nested_dict, list_dict_unpack +from kr8s._data_utils import ( + dict_to_selector, + diff_nested_dicts, + dot_to_nested_dict, + list_dict_unpack, +) def test_list_dict_unpack(): @@ -22,3 +27,18 @@ def test_dot_to_nested_dict(): def test_dict_to_selector(): assert dict_to_selector({"foo": "bar"}) == "foo=bar" assert dict_to_selector({"foo": "bar", "baz": "qux"}) == "foo=bar,baz=qux" + + +def test_diff_nested_dicts(): + assert diff_nested_dicts({"foo": "bar"}, {"foo": "bar"}) == {} + assert diff_nested_dicts({"foo": "bar"}, {"foo": "baz"}) == {"foo": "baz"} + assert diff_nested_dicts({"foo": "bar"}, {"foo": "bar", "baz": "qux"}) == { + "baz": "qux" + } + assert diff_nested_dicts({"foo": [{"bar": "baz"}]}, {"foo": [{"bar": "qux"}]}) == { + "foo": [{"bar": "qux"}] + } + assert diff_nested_dicts( + {"foo": [{"bar": "baz"}, {"fizz": "buzz"}]}, + {"foo": [{"bar": "qux"}, {"fizz": "buzz"}]}, + ) == {"foo": [{"bar": "qux"}, {"fizz": "buzz"}]} From 5bfcc63b4dcb01ae6787f722f7ed41d28b6d059b Mon Sep 17 00:00:00 2001 From: Jacob Tomlinson Date: Wed, 13 Sep 2023 14:46:22 +0100 Subject: [PATCH 6/6] Raise more useful error --- kr8s/_api.py | 6 +++++- kr8s/_exceptions.py | 9 +++++++++ kr8s/_objects.py | 4 +++- 3 files changed, 17 insertions(+), 2 deletions(-) diff --git a/kr8s/_api.py b/kr8s/_api.py index d6c88deb..7f2041a5 100644 --- a/kr8s/_api.py +++ b/kr8s/_api.py @@ -14,6 +14,7 @@ from ._auth import KubeAuth from ._data_utils import dict_to_selector +from ._exceptions import ServerStatusError ALL = "all" @@ -141,7 +142,10 @@ async def call_api( await self._create_session() continue else: - raise + status = e.response.json() + raise ServerStatusError( + e.response.status_code, status["message"] + ) from e except ssl.SSLCertVerificationError: # In some rare edge cases the SSL verification fails, so we try again # a few times before giving up. diff --git a/kr8s/_exceptions.py b/kr8s/_exceptions.py index ae0dbce6..9b24a501 100644 --- a/kr8s/_exceptions.py +++ b/kr8s/_exceptions.py @@ -4,3 +4,12 @@ class NotFoundError(Exception): class ConnectionClosedError(Exception): """A connection has been closed.""" + + +class ServerStatusError(Exception): + """The server returned an error status code.""" + + def __init__(self, status_code, message): + self.status_code = status_code + self.message = message + super().__init__(message) diff --git a/kr8s/_objects.py b/kr8s/_objects.py index 5825bf73..1b559094 100644 --- a/kr8s/_objects.py +++ b/kr8s/_objects.py @@ -25,7 +25,7 @@ dot_to_nested_dict, list_dict_unpack, ) -from kr8s._exceptions import NotFoundError +from kr8s._exceptions import NotFoundError, ServerStatusError from kr8s.asyncio.portforward import PortForward as AsyncPortForward from kr8s.portforward import PortForward as SyncPortForward @@ -342,6 +342,8 @@ async def _patch(self, patch: Dict, *, subresource=None, **kwargs) -> None: if e.response.status_code == 404: raise NotFoundError(f"Object {self.name} does not exist") from e raise e + except ServerStatusError as e: + raise e async def scale(self, replicas: int = None) -> None: """Scale this object in Kubernetes."""