diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json deleted file mode 100644 index c818a304..00000000 --- a/.config/dotnet-tools.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "version": 1, - "isRoot": true, - "tools": { - "docfx": { - "version": "2.78.3", - "commands": ["docfx"] - } - } -} diff --git a/KubeOps.sln b/KubeOps.sln index 2731c267..c8ea81f9 100644 --- a/KubeOps.sln +++ b/KubeOps.sln @@ -59,6 +59,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WebhookOperator", "examples EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "KubeOps.Templates", "src\KubeOps.Templates\KubeOps.Templates.csproj", "{26237038-7172-4D01-B5E1-2A5E3F6B369E}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "KubeOps.Abstractions.Test", "test\KubeOps.Abstractions.Test\KubeOps.Abstractions.Test.csproj", "{4E9EEFD6-DA44-41F2-AF02-8A7F2150AC0C}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -85,6 +87,7 @@ Global {7F7744B2-CF3F-4309-9C2D-037278017D49} = {C587731F-8191-4A19-8662-B89A60FE79A1} {0BFE2297-9537-49BE-8B1F-431A8ACD654D} = {DC760E69-D0EA-417F-AE38-B12D0B04DE39} {26237038-7172-4D01-B5E1-2A5E3F6B369E} = {4DB01062-6DC5-4028-BB72-C0619C2F5F2E} + {4E9EEFD6-DA44-41F2-AF02-8A7F2150AC0C} = {C587731F-8191-4A19-8662-B89A60FE79A1} EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {E9A0B04E-D90E-4B94-90E0-DD3666B098FF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU @@ -155,5 +158,9 @@ Global {26237038-7172-4D01-B5E1-2A5E3F6B369E}.Debug|Any CPU.Build.0 = Debug|Any CPU {26237038-7172-4D01-B5E1-2A5E3F6B369E}.Release|Any CPU.ActiveCfg = Release|Any CPU {26237038-7172-4D01-B5E1-2A5E3F6B369E}.Release|Any CPU.Build.0 = Release|Any CPU + {4E9EEFD6-DA44-41F2-AF02-8A7F2150AC0C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4E9EEFD6-DA44-41F2-AF02-8A7F2150AC0C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4E9EEFD6-DA44-41F2-AF02-8A7F2150AC0C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4E9EEFD6-DA44-41F2-AF02-8A7F2150AC0C}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection EndGlobal diff --git a/src/KubeOps.Abstractions/Entities/JsonPatchExtensions.cs b/src/KubeOps.Abstractions/Entities/JsonPatchExtensions.cs new file mode 100644 index 00000000..5f0025c2 --- /dev/null +++ b/src/KubeOps.Abstractions/Entities/JsonPatchExtensions.cs @@ -0,0 +1,67 @@ +using System.Text; +using System.Text.Json.Nodes; + +using Json.More; +using Json.Patch; + +using k8s; +using k8s.Models; + +namespace KubeOps.Abstractions.Entities; + +/// +/// Method extensions for JSON diffing between two entities (). +/// +public static class JsonPatchExtensions +{ + /// + /// Convert a into a . + /// + /// The entity to convert. + /// Either the json node, or null if it failed. + public static JsonNode? ToNode(this IKubernetesObject entity) => + JsonNode.Parse(KubernetesJson.Serialize(entity)); + + /// + /// Computes the JSON Patch diff between two Kubernetes entities implementing . + /// This method serializes both entities to JSON and calculates the difference as a JSON Patch document. + /// + /// The source entity to compare from. + /// The target entity to compare to. + /// A representing the JSON Patch diff between the two entities. + /// Thrown if the diff could not be created. + public static JsonPatch CreateJsonPatch( + this IKubernetesObject from, + IKubernetesObject to) + { + var fromNode = from.ToNode(); + var toNode = to.ToNode(); + var patch = fromNode.CreatePatch(toNode); + + return patch; + } + + /// + /// Create a out of a . + /// This can be used to apply the patch to a Kubernetes entity using the Kubernetes client. + /// + /// The patch that should be converted. + /// A that may be applied to Kubernetes objects. + public static V1Patch ToKubernetesPatch(this JsonPatch patch) => + new(patch.ToJsonString(), V1Patch.PatchType.JsonPatch); + + /// + /// Create the unformatted JSON string representation of a . + /// + /// The to convert. + /// A string that represents the unformatted JSON representation of the patch. + public static string ToJsonString(this JsonPatch patch) => patch.ToJsonDocument().RootElement.GetRawText(); + + /// + /// Create the base 64 representation of a . + /// + /// The patch to convert. + /// The base64 encoded representation of the patch. + public static string ToBase64String(this JsonPatch patch) => + Convert.ToBase64String(Encoding.UTF8.GetBytes(patch.ToJsonString())); +} diff --git a/src/KubeOps.Abstractions/Entities/Extensions.cs b/src/KubeOps.Abstractions/Entities/KubernetesExtensions.cs similarity index 95% rename from src/KubeOps.Abstractions/Entities/Extensions.cs rename to src/KubeOps.Abstractions/Entities/KubernetesExtensions.cs index cd69d1c8..bb61dee9 100644 --- a/src/KubeOps.Abstractions/Entities/Extensions.cs +++ b/src/KubeOps.Abstractions/Entities/KubernetesExtensions.cs @@ -4,9 +4,10 @@ namespace KubeOps.Abstractions.Entities; /// -/// Method extensions for . +/// Basic extensions for . +/// Extensions that target the Kubernetes Object and its metadata. /// -public static class Extensions +public static class KubernetesExtensions { /// /// Sets the resource version of the specified Kubernetes object to the specified value. diff --git a/src/KubeOps.Abstractions/KubeOps.Abstractions.csproj b/src/KubeOps.Abstractions/KubeOps.Abstractions.csproj index 93e54942..80c92d49 100644 --- a/src/KubeOps.Abstractions/KubeOps.Abstractions.csproj +++ b/src/KubeOps.Abstractions/KubeOps.Abstractions.csproj @@ -14,6 +14,7 @@ + diff --git a/src/KubeOps.KubernetesClient/IKubernetesClient.cs b/src/KubeOps.KubernetesClient/IKubernetesClient.cs index 0be236dd..ae923d18 100644 --- a/src/KubeOps.KubernetesClient/IKubernetesClient.cs +++ b/src/KubeOps.KubernetesClient/IKubernetesClient.cs @@ -1,4 +1,6 @@ -using k8s; +using Json.Patch; + +using k8s; using k8s.Models; using KubeOps.Abstractions.Entities; @@ -320,6 +322,142 @@ Task UpdateStatusAsync(TEntity entity, CancellationToken cance TEntity UpdateStatus(TEntity entity) where TEntity : IKubernetesObject; + /// + /// Patch a given entity on the Kubernetes API by calculating the diff between the current entity and the provided entity. + /// This method fetches the current entity from the API, computes the patch, and applies it. + /// + /// The type of the Kubernetes entity. + /// The entity containing the desired updates. + /// Cancellation token to monitor for cancellation requests. + /// The patched entity. + /// Thrown if the entity to be patched does not exist on the API. + Task PatchAsync(TEntity entity, CancellationToken cancellationToken = default) + where TEntity : IKubernetesObject + { + var currentEntity = Get(entity.Name(), entity.Namespace()); + if (currentEntity is null) + { + throw new InvalidOperationException( + $"Cannot patch entity {typeof(TEntity).Name} with name {entity.Name()} in namespace {entity.Namespace()}: Entity does not exist."); + } + + return PatchAsync( + currentEntity, + entity.WithResourceVersion(currentEntity.ResourceVersion()), + cancellationToken); + } + + /// + /// Patch a given entity on the Kubernetes API by calculating the diff between two provided entities. + /// + /// The type of the Kubernetes entity. + /// The current/original entity. + /// The updated entity with desired changes. + /// Cancellation token to monitor for cancellation requests. + /// The patched entity. + Task PatchAsync(TEntity from, TEntity to, CancellationToken cancellationToken = default) + where TEntity : IKubernetesObject => + PatchAsync(from, from.CreateJsonPatch(to), cancellationToken); + + /// + /// Patch a given entity on the Kubernetes API using a object. + /// + /// The type of the Kubernetes entity. + /// The entity to patch. + /// The representing the changes to apply. + /// Cancellation token to monitor for cancellation requests. + /// The patched entity. + Task PatchAsync(TEntity entity, JsonPatch patch, CancellationToken cancellationToken = default) + where TEntity : IKubernetesObject => + PatchAsync(entity, patch.ToKubernetesPatch(), cancellationToken); + + /// + /// Patch a given entity on the Kubernetes API using a object. + /// + /// The type of the Kubernetes entity. + /// The entity to patch. + /// The representing the changes to apply. + /// Cancellation token to monitor for cancellation requests. + /// The patched entity. + Task PatchAsync(TEntity entity, V1Patch patch, CancellationToken cancellationToken = default) + where TEntity : IKubernetesObject => + PatchAsync(patch, entity.Name(), entity.Namespace(), cancellationToken); + + /// + /// Patch a given entity on the Kubernetes API by name and namespace using a object. + /// + /// The type of the Kubernetes entity. + /// The representing the changes to apply. + /// The name of the entity to patch. + /// The namespace of the entity to patch (if applicable). + /// Cancellation token to monitor for cancellation requests. + /// The patched entity. + Task PatchAsync( + V1Patch patch, + string name, + string? @namespace = null, + CancellationToken cancellationToken = default) + where TEntity : IKubernetesObject; + + /// + /// Patch a given entity on the Kubernetes API by calculating the diff between the current entity and the provided entity. + /// + /// The type of the Kubernetes entity. + /// The entity containing the desired updates. + /// The patched entity. + /// Thrown if the entity to be patched does not exist on the API. + TEntity Patch(TEntity entity) + where TEntity : IKubernetesObject + => PatchAsync(entity).GetAwaiter().GetResult(); + + /// + /// Patch a given entity on the Kubernetes API by calculating the diff between two provided entities. + /// + /// The type of the Kubernetes entity. + /// The current/original entity. + /// The updated entity with desired changes. + /// The patched entity. + TEntity Patch(TEntity from, TEntity to) + where TEntity : IKubernetesObject + => PatchAsync(from, to).GetAwaiter().GetResult(); + + /// + /// Patch a given entity on the Kubernetes API using a object. + /// + /// The type of the Kubernetes entity. + /// The entity to patch. + /// The representing the changes to apply. + /// The patched entity. + TEntity Patch(TEntity entity, JsonPatch patch) + where TEntity : IKubernetesObject + => PatchAsync(entity, patch).GetAwaiter().GetResult(); + + /// + /// Patch a given entity on the Kubernetes API using a object. + /// + /// The type of the Kubernetes entity. + /// The entity to patch. + /// The representing the changes to apply. + /// The patched entity. + TEntity Patch(TEntity entity, V1Patch patch) + where TEntity : IKubernetesObject + => PatchAsync(entity, patch).GetAwaiter().GetResult(); + + /// + /// Patch a given entity on the Kubernetes API by name and namespace using a object. + /// + /// The type of the Kubernetes entity. + /// The representing the changes to apply. + /// The name of the entity to patch. + /// The namespace of the entity to patch (if applicable). + /// The patched entity. + TEntity Patch( + V1Patch patch, + string name, + string? @namespace = null) + where TEntity : IKubernetesObject + => PatchAsync(patch, name, @namespace).GetAwaiter().GetResult(); + /// /// A task that completes when the call was made. Task DeleteAsync(TEntity entity, CancellationToken cancellationToken = default) diff --git a/src/KubeOps.KubernetesClient/KubernetesClient.cs b/src/KubeOps.KubernetesClient/KubernetesClient.cs index ac2bbb4f..b15d1bf1 100644 --- a/src/KubeOps.KubernetesClient/KubernetesClient.cs +++ b/src/KubeOps.KubernetesClient/KubernetesClient.cs @@ -309,6 +309,24 @@ public TEntity UpdateStatus(TEntity entity) }; } + /// + public async Task PatchAsync( + V1Patch patch, + string name, + string? @namespace = null, + CancellationToken cancellationToken = default) + where TEntity : IKubernetesObject + { + ThrowIfDisposed(); + + using var client = CreateGenericClient(); + return await (@namespace switch + { + not null => client.PatchNamespacedAsync(patch, @namespace, name, cancellationToken), + null => client.PatchAsync(patch, name, cancellationToken), + }); + } + /// public async Task DeleteAsync( string name, diff --git a/src/KubeOps.Operator.Web/KubeOps.Operator.Web.csproj b/src/KubeOps.Operator.Web/KubeOps.Operator.Web.csproj index 1d77f27c..20c02e22 100644 --- a/src/KubeOps.Operator.Web/KubeOps.Operator.Web.csproj +++ b/src/KubeOps.Operator.Web/KubeOps.Operator.Web.csproj @@ -27,7 +27,6 @@ - diff --git a/src/KubeOps.Operator.Web/Webhooks/Admission/Mutation/JsonDiffer.cs b/src/KubeOps.Operator.Web/Webhooks/Admission/Mutation/JsonDiffer.cs deleted file mode 100644 index 283066bb..00000000 --- a/src/KubeOps.Operator.Web/Webhooks/Admission/Mutation/JsonDiffer.cs +++ /dev/null @@ -1,27 +0,0 @@ -using System.Text; -using System.Text.Json.JsonDiffPatch; -using System.Text.Json.JsonDiffPatch.Diffs.Formatters; -using System.Text.Json.Nodes; - -using k8s; - -namespace KubeOps.Operator.Web.Webhooks.Admission.Mutation; - -internal static class JsonDiffer -{ - public static string Base64Diff(this JsonNode from, object? to) - { - var formatter = new JsonPatchDeltaFormatter(); - - var toToken = GetNode(to); - var patch = from.Diff(toToken, formatter)!; - - return Convert.ToBase64String(Encoding.UTF8.GetBytes(patch.ToString())); - } - - public static JsonNode? GetNode(object? o) - { - var json = KubernetesJson.Serialize(o); - return JsonNode.Parse(json); - } -} diff --git a/src/KubeOps.Operator.Web/Webhooks/Admission/Mutation/MutationResult.cs b/src/KubeOps.Operator.Web/Webhooks/Admission/Mutation/MutationResult.cs index eb48944d..267c0ff1 100644 --- a/src/KubeOps.Operator.Web/Webhooks/Admission/Mutation/MutationResult.cs +++ b/src/KubeOps.Operator.Web/Webhooks/Admission/Mutation/MutationResult.cs @@ -1,8 +1,12 @@ using System.Text.Json.Nodes; +using Json.Patch; + using k8s; using k8s.Models; +using KubeOps.Abstractions.Entities; + using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; @@ -70,7 +74,9 @@ await response.WriteAsJsonAsync( Status = Status, Warnings = Warnings.ToArray(), PatchType = ModifiedObject is null ? null : JsonPatch, - Patch = ModifiedObject is null ? null : OriginalObject!.Base64Diff(ModifiedObject), + Patch = ModifiedObject is null + ? null + : OriginalObject!.CreatePatch(ModifiedObject.ToNode()).ToBase64String(), }, }); } diff --git a/src/KubeOps.Operator.Web/Webhooks/Admission/Mutation/MutationWebhook{TEntity}.cs b/src/KubeOps.Operator.Web/Webhooks/Admission/Mutation/MutationWebhook{TEntity}.cs index c17c08f8..4f1eaa01 100644 --- a/src/KubeOps.Operator.Web/Webhooks/Admission/Mutation/MutationWebhook{TEntity}.cs +++ b/src/KubeOps.Operator.Web/Webhooks/Admission/Mutation/MutationWebhook{TEntity}.cs @@ -1,6 +1,8 @@ using k8s; using k8s.Models; +using KubeOps.Abstractions.Entities; + using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; @@ -104,11 +106,11 @@ public async Task Mutate( [FromBody] AdmissionRequest request, CancellationToken cancellationToken) { - var original = JsonDiffer.GetNode(request.Request.Operation switch + var original = request.Request.Operation switch { - CreateOperation or UpdateOperation => request.Request.Object!, - _ => request.Request.OldObject!, - }); + CreateOperation or UpdateOperation => request.Request.Object!.ToNode(), + _ => request.Request.OldObject!.ToNode(), + }; var result = request.Request.Operation switch { diff --git a/src/KubeOps.Operator/KubeOps.Operator.csproj b/src/KubeOps.Operator/KubeOps.Operator.csproj index a120b9ba..5361c6e6 100644 --- a/src/KubeOps.Operator/KubeOps.Operator.csproj +++ b/src/KubeOps.Operator/KubeOps.Operator.csproj @@ -17,7 +17,6 @@ - diff --git a/test/KubeOps.Abstractions.Test/Entities/JsonPatchExtensions.Test.cs b/test/KubeOps.Abstractions.Test/Entities/JsonPatchExtensions.Test.cs new file mode 100644 index 00000000..8cc3fbca --- /dev/null +++ b/test/KubeOps.Abstractions.Test/Entities/JsonPatchExtensions.Test.cs @@ -0,0 +1,187 @@ +using FluentAssertions; + +using k8s.Models; + +using KubeOps.Abstractions.Entities; + +namespace KubeOps.Abstractions.Test.Entities; + +public class JsonPatchExtensionsTest +{ + [Fact] + public void GetJsonDiff_Adds_Property_In_Spec() + { + var from = new V1Deployment + { + Metadata = new V1ObjectMeta { Name = "test" }, + Spec = new V1DeploymentSpec { Replicas = 1 }, + }; + var to = new V1Deployment + { + Metadata = new V1ObjectMeta { Name = "test" }, + Spec = new V1DeploymentSpec { Replicas = 1, RevisionHistoryLimit = 2 }, + }; + var diff = from.CreateJsonPatch(to); + diff.ToJsonString().Should() + .Contain("/spec/revisionHistoryLimit") + .And.Contain("2") + .And.Contain("add"); + } + + [Fact] + public void GetJsonDiff_Updates_Property_In_Spec() + { + var from = new V1Deployment + { + Metadata = new V1ObjectMeta { Name = "test" }, + Spec = new V1DeploymentSpec { Replicas = 1 }, + }; + var to = new V1Deployment + { + Metadata = new V1ObjectMeta { Name = "test" }, + Spec = new V1DeploymentSpec { Replicas = 2 }, + }; + var diff = from.CreateJsonPatch(to); + diff.ToJsonString().Should() + .Contain("replace") + .And.Contain("/spec/replicas") + .And.Contain("2"); + } + + [Fact] + public void GetJsonDiff_Removes_Property_In_Spec() + { + var from = new V1Deployment + { + Metadata = new V1ObjectMeta { Name = "test" }, + Spec = new V1DeploymentSpec { Replicas = 1, RevisionHistoryLimit = 2 }, + }; + var to = new V1Deployment + { + Metadata = new V1ObjectMeta { Name = "test" }, + Spec = new V1DeploymentSpec { Replicas = 1 }, + }; + var diff = from.CreateJsonPatch(to); + diff.ToJsonString().Should().Contain("/spec/revisionHistoryLimit").And.Contain("remove"); + } + + [Fact] + public void GetJsonDiff_Adds_Object_To_Containers_List() + { + var from = new V1Deployment + { + Metadata = new V1ObjectMeta { Name = "test" }, + Spec = new V1DeploymentSpec + { + Template = new V1PodTemplateSpec + { + Spec = new V1PodSpec { Containers = new List() }, + }, + }, + }; + var to = new V1Deployment + { + Metadata = new V1ObjectMeta { Name = "test" }, + Spec = new V1DeploymentSpec + { + Template = new V1PodTemplateSpec + { + Spec = new V1PodSpec + { + Containers = new List + { + new V1Container { Name = "nginx", Image = "nginx:latest" }, + }, + }, + }, + }, + }; + var diff = from.CreateJsonPatch(to); + diff.ToJsonString().Should().Contain("/spec/template/spec/containers/0"); + diff.ToJsonString().Should().Contain("nginx:latest"); + } + + [Fact] + public void GetJsonDiff_Updates_Object_In_Containers_List() + { + var from = new V1Deployment + { + Metadata = new V1ObjectMeta { Name = "test" }, + Spec = new V1DeploymentSpec + { + Template = new V1PodTemplateSpec + { + Spec = new V1PodSpec + { + Containers = new List + { + new V1Container { Name = "nginx", Image = "nginx:1.14" }, + }, + }, + }, + }, + }; + var to = new V1Deployment + { + Metadata = new V1ObjectMeta { Name = "test" }, + Spec = new V1DeploymentSpec + { + Template = new V1PodTemplateSpec + { + Spec = new V1PodSpec + { + Containers = new List + { + new V1Container { Name = "nginx", Image = "nginx:1.16" }, + }, + }, + }, + }, + }; + var diff = from.CreateJsonPatch(to); + diff.ToJsonString().Should().Contain("replace").And.Contain("/spec/template/spec/containers/0/image"); + } + + [Fact] + public void GetJsonDiff_Removes_Object_From_Containers_List() + { + var from = new V1Deployment + { + Metadata = new V1ObjectMeta { Name = "test" }, + Spec = new V1DeploymentSpec + { + Template = new V1PodTemplateSpec + { + Spec = new V1PodSpec + { + Containers = new List + { + new V1Container { Name = "nginx", Image = "nginx:latest" }, + new V1Container { Name = "nginx2", Image = "nginx:latest" }, + }, + }, + }, + }, + }; + var to = new V1Deployment + { + Metadata = new V1ObjectMeta { Name = "test" }, + Spec = new V1DeploymentSpec + { + Template = new V1PodTemplateSpec + { + Spec = new V1PodSpec + { + Containers = new List + { + new V1Container { Name = "nginx", Image = "nginx:latest" }, + }, + }, + }, + }, + }; + var diff = from.CreateJsonPatch(to); + diff.ToJsonString().Should().Contain("/spec/template/spec/containers/1"); + diff.ToJsonString().Should().Contain("remove"); + } +} diff --git a/test/KubeOps.Abstractions.Test/KubeOps.Abstractions.Test.csproj b/test/KubeOps.Abstractions.Test/KubeOps.Abstractions.Test.csproj new file mode 100644 index 00000000..7bc34335 --- /dev/null +++ b/test/KubeOps.Abstractions.Test/KubeOps.Abstractions.Test.csproj @@ -0,0 +1,5 @@ + + + + + diff --git a/test/KubeOps.KubernetesClient.Test/KubernetesClient.Test.cs b/test/KubeOps.KubernetesClient.Test/KubernetesClient.Test.cs index 3adfdfa8..bdedf2ac 100644 --- a/test/KubeOps.KubernetesClient.Test/KubernetesClient.Test.cs +++ b/test/KubeOps.KubernetesClient.Test/KubernetesClient.Test.cs @@ -163,6 +163,43 @@ public void Should_Not_Throw_On_Not_Found_Delete() _client.Delete(config); } + [Fact] + public void Should_Patch_ConfigMap_Sync() + { + // Add + var config = _client.Create( + new V1ConfigMap + { + Kind = V1ConfigMap.KubeKind, + ApiVersion = V1ConfigMap.KubeApiVersion, + Metadata = new(name: RandomName(), namespaceProperty: "default"), + Data = new Dictionary { { "foo", "bar" } }, + }); + _objects.Add(config); + + // Add a new key using Patch(config) + config.Data["hello"] = "world"; + config = _client.Patch(config); + config.Data.Should().ContainKey("hello").And.ContainValue("world"); + + // Replace a value using Patch(from, to) + var from = config; + var to = new V1ConfigMap + { + Kind = V1ConfigMap.KubeKind, + ApiVersion = V1ConfigMap.KubeApiVersion, + Metadata = from.Metadata, + Data = new Dictionary { { "foo", "baz" }, { "hello", "world" } }, + }; + config = _client.Patch(from, to); + config.Data["foo"].Should().Be("baz"); + + // Remove a key using Patch(config) + config.Data.Remove("hello"); + config = _client.Patch(config); + config.Data.Should().NotContainKey("hello"); + } + public void Dispose() { _client.Delete(_objects); diff --git a/test/KubeOps.KubernetesClient.Test/KubernetesClientAsync.Test.cs b/test/KubeOps.KubernetesClient.Test/KubernetesClientAsync.Test.cs index 2c6440eb..0a2a0d00 100644 --- a/test/KubeOps.KubernetesClient.Test/KubernetesClientAsync.Test.cs +++ b/test/KubeOps.KubernetesClient.Test/KubernetesClientAsync.Test.cs @@ -163,6 +163,69 @@ public async Task Should_Not_Throw_On_Not_Found_Delete() await _client.DeleteAsync(config); } + [Fact] + public async Task Should_Patch_ConfigMap_Async() + { + // Add + var config = await _client.CreateAsync( + new V1ConfigMap + { + Kind = V1ConfigMap.KubeKind, + ApiVersion = V1ConfigMap.KubeApiVersion, + Metadata = new(name: RandomName(), namespaceProperty: "default"), + Data = new Dictionary { { "foo", "bar" } }, + }); + _objects.Add(config); + + // Add a new key using PatchAsync(config) + config.Data["hello"] = "world"; + config = await _client.PatchAsync(config); + config.Data.Should().ContainKey("hello").And.ContainValue("world"); + + // Replace a value using PatchAsync(from, to) + var from = config; + var to = new V1ConfigMap + { + Kind = V1ConfigMap.KubeKind, + ApiVersion = V1ConfigMap.KubeApiVersion, + Metadata = from.Metadata, + Data = new Dictionary { { "foo", "baz" }, { "hello", "world" } }, + }; + config = await _client.PatchAsync(from, to); + config.Data["foo"].Should().Be("baz"); + + // Remove a key using PatchAsync(config) + config.Data.Remove("hello"); + config = await _client.PatchAsync(config); + config.Data.Should().NotContainKey("hello"); + } + + [Fact] + public async Task Should_Patch_ConfigMap_With_Stale_Base_Async() + { + // Step 1: Create the ConfigMap + var original = await _client.CreateAsync( + new V1ConfigMap + { + Kind = V1ConfigMap.KubeKind, + ApiVersion = V1ConfigMap.KubeApiVersion, + Metadata = new(name: RandomName(), namespaceProperty: "default"), + Data = new Dictionary { { "foo", "bar" } }, + }); + _objects.Add(original); + + // Step 2: Update the ConfigMap via client (simulate external change) + original.Data["hello"] = "world"; + var updated = await _client.UpdateAsync(original); + + // Step 3: Patch using the original object as the base, adding another key + original.Data["newkey"] = "newvalue"; + var patched = await _client.PatchAsync(original); + + patched.Data.Should().ContainKey("hello").And.ContainKey("newkey"); + patched.Data["newkey"].Should().Be("newvalue"); + } + public void Dispose() { _client.Delete(_objects);