Skip to content

Commit f61526f

Browse files
authored
feat(client): Add Patch functionality and patch creation (#919)
To easily patch kubernetes objects and entites, the IKubernetesClient now also supports patch (in many variants). Which internally creates a JSON Patch and encapsulates it into a V1Patch object (json patch). This closes #559.
1 parent 2681965 commit f61526f

File tree

16 files changed

+540
-47
lines changed

16 files changed

+540
-47
lines changed

.config/dotnet-tools.json

Lines changed: 0 additions & 10 deletions
This file was deleted.

KubeOps.sln

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WebhookOperator", "examples
5959
EndProject
6060
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "KubeOps.Templates", "src\KubeOps.Templates\KubeOps.Templates.csproj", "{26237038-7172-4D01-B5E1-2A5E3F6B369E}"
6161
EndProject
62+
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "KubeOps.Abstractions.Test", "test\KubeOps.Abstractions.Test\KubeOps.Abstractions.Test.csproj", "{4E9EEFD6-DA44-41F2-AF02-8A7F2150AC0C}"
63+
EndProject
6264
Global
6365
GlobalSection(SolutionConfigurationPlatforms) = preSolution
6466
Debug|Any CPU = Debug|Any CPU
@@ -85,6 +87,7 @@ Global
8587
{7F7744B2-CF3F-4309-9C2D-037278017D49} = {C587731F-8191-4A19-8662-B89A60FE79A1}
8688
{0BFE2297-9537-49BE-8B1F-431A8ACD654D} = {DC760E69-D0EA-417F-AE38-B12D0B04DE39}
8789
{26237038-7172-4D01-B5E1-2A5E3F6B369E} = {4DB01062-6DC5-4028-BB72-C0619C2F5F2E}
90+
{4E9EEFD6-DA44-41F2-AF02-8A7F2150AC0C} = {C587731F-8191-4A19-8662-B89A60FE79A1}
8891
EndGlobalSection
8992
GlobalSection(ProjectConfigurationPlatforms) = postSolution
9093
{E9A0B04E-D90E-4B94-90E0-DD3666B098FF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
@@ -155,5 +158,9 @@ Global
155158
{26237038-7172-4D01-B5E1-2A5E3F6B369E}.Debug|Any CPU.Build.0 = Debug|Any CPU
156159
{26237038-7172-4D01-B5E1-2A5E3F6B369E}.Release|Any CPU.ActiveCfg = Release|Any CPU
157160
{26237038-7172-4D01-B5E1-2A5E3F6B369E}.Release|Any CPU.Build.0 = Release|Any CPU
161+
{4E9EEFD6-DA44-41F2-AF02-8A7F2150AC0C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
162+
{4E9EEFD6-DA44-41F2-AF02-8A7F2150AC0C}.Debug|Any CPU.Build.0 = Debug|Any CPU
163+
{4E9EEFD6-DA44-41F2-AF02-8A7F2150AC0C}.Release|Any CPU.ActiveCfg = Release|Any CPU
164+
{4E9EEFD6-DA44-41F2-AF02-8A7F2150AC0C}.Release|Any CPU.Build.0 = Release|Any CPU
158165
EndGlobalSection
159166
EndGlobal
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
using System.Text;
2+
using System.Text.Json.Nodes;
3+
4+
using Json.More;
5+
using Json.Patch;
6+
7+
using k8s;
8+
using k8s.Models;
9+
10+
namespace KubeOps.Abstractions.Entities;
11+
12+
/// <summary>
13+
/// Method extensions for JSON diffing between two entities (<see cref="IKubernetesObject{TMetadata}"/>).
14+
/// </summary>
15+
public static class JsonPatchExtensions
16+
{
17+
/// <summary>
18+
/// Convert a <see cref="IKubernetesObject{TMetadata}"/> into a <see cref="JsonNode"/>.
19+
/// </summary>
20+
/// <param name="entity">The entity to convert.</param>
21+
/// <returns>Either the json node, or null if it failed.</returns>
22+
public static JsonNode? ToNode(this IKubernetesObject<V1ObjectMeta> entity) =>
23+
JsonNode.Parse(KubernetesJson.Serialize(entity));
24+
25+
/// <summary>
26+
/// Computes the JSON Patch diff between two Kubernetes entities implementing <see cref="IKubernetesObject{V1ObjectMeta}"/>.
27+
/// This method serializes both entities to JSON and calculates the difference as a JSON Patch document.
28+
/// </summary>
29+
/// <param name="from">The source entity to compare from.</param>
30+
/// <param name="to">The target entity to compare to.</param>
31+
/// <returns>A <see cref="JsonNode"/> representing the JSON Patch diff between the two entities.</returns>
32+
/// <exception cref="InvalidOperationException">Thrown if the diff could not be created.</exception>
33+
public static JsonPatch CreateJsonPatch(
34+
this IKubernetesObject<V1ObjectMeta> from,
35+
IKubernetesObject<V1ObjectMeta> to)
36+
{
37+
var fromNode = from.ToNode();
38+
var toNode = to.ToNode();
39+
var patch = fromNode.CreatePatch(toNode);
40+
41+
return patch;
42+
}
43+
44+
/// <summary>
45+
/// Create a <see cref="V1Patch"/> out of a <see cref="JsonPatch"/>.
46+
/// This can be used to apply the patch to a Kubernetes entity using the Kubernetes client.
47+
/// </summary>
48+
/// <param name="patch">The patch that should be converted.</param>
49+
/// <returns>A <see cref="V1Patch"/> that may be applied to Kubernetes objects.</returns>
50+
public static V1Patch ToKubernetesPatch(this JsonPatch patch) =>
51+
new(patch.ToJsonString(), V1Patch.PatchType.JsonPatch);
52+
53+
/// <summary>
54+
/// Create the unformatted JSON string representation of a <see cref="JsonPatch"/>.
55+
/// </summary>
56+
/// <param name="patch">The <see cref="JsonPatch"/> to convert.</param>
57+
/// <returns>A string that represents the unformatted JSON representation of the patch.</returns>
58+
public static string ToJsonString(this JsonPatch patch) => patch.ToJsonDocument().RootElement.GetRawText();
59+
60+
/// <summary>
61+
/// Create the base 64 representation of a <see cref="JsonPatch"/>.
62+
/// </summary>
63+
/// <param name="patch">The patch to convert.</param>
64+
/// <returns>The base64 encoded representation of the patch.</returns>
65+
public static string ToBase64String(this JsonPatch patch) =>
66+
Convert.ToBase64String(Encoding.UTF8.GetBytes(patch.ToJsonString()));
67+
}

src/KubeOps.Abstractions/Entities/Extensions.cs renamed to src/KubeOps.Abstractions/Entities/KubernetesExtensions.cs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,10 @@
44
namespace KubeOps.Abstractions.Entities;
55

66
/// <summary>
7-
/// Method extensions for <see cref="IKubernetesObject{TMetadata}"/>.
7+
/// Basic extensions for <see cref="IKubernetesObject{TMetadata}"/>.
8+
/// Extensions that target the Kubernetes Object and its metadata.
89
/// </summary>
9-
public static class Extensions
10+
public static class KubernetesExtensions
1011
{
1112
/// <summary>
1213
/// Sets the resource version of the specified Kubernetes object to the specified value.

src/KubeOps.Abstractions/KubeOps.Abstractions.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
</PropertyGroup>
1515

1616
<ItemGroup>
17+
<PackageReference Include="JsonPatch.Net" Version="3.3.0" />
1718
<PackageReference Include="KubernetesClient" Version="16.0.7" />
1819
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="9.0.6"/>
1920
<PackageReference Include="ZiggyCreatures.FusionCache" Version="2.3.0" />

src/KubeOps.KubernetesClient/IKubernetesClient.cs

Lines changed: 139 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1-
using k8s;
1+
using Json.Patch;
2+
3+
using k8s;
24
using k8s.Models;
35

46
using KubeOps.Abstractions.Entities;
@@ -320,6 +322,142 @@ Task<TEntity> UpdateStatusAsync<TEntity>(TEntity entity, CancellationToken cance
320322
TEntity UpdateStatus<TEntity>(TEntity entity)
321323
where TEntity : IKubernetesObject<V1ObjectMeta>;
322324

325+
/// <summary>
326+
/// Patch a given entity on the Kubernetes API by calculating the diff between the current entity and the provided entity.
327+
/// This method fetches the current entity from the API, computes the patch, and applies it.
328+
/// </summary>
329+
/// <typeparam name="TEntity">The type of the Kubernetes entity.</typeparam>
330+
/// <param name="entity">The entity containing the desired updates.</param>
331+
/// <param name="cancellationToken">Cancellation token to monitor for cancellation requests.</param>
332+
/// <returns>The patched entity.</returns>
333+
/// <exception cref="InvalidOperationException">Thrown if the entity to be patched does not exist on the API.</exception>
334+
Task<TEntity> PatchAsync<TEntity>(TEntity entity, CancellationToken cancellationToken = default)
335+
where TEntity : IKubernetesObject<V1ObjectMeta>
336+
{
337+
var currentEntity = Get<TEntity>(entity.Name(), entity.Namespace());
338+
if (currentEntity is null)
339+
{
340+
throw new InvalidOperationException(
341+
$"Cannot patch entity {typeof(TEntity).Name} with name {entity.Name()} in namespace {entity.Namespace()}: Entity does not exist.");
342+
}
343+
344+
return PatchAsync(
345+
currentEntity,
346+
entity.WithResourceVersion(currentEntity.ResourceVersion()),
347+
cancellationToken);
348+
}
349+
350+
/// <summary>
351+
/// Patch a given entity on the Kubernetes API by calculating the diff between two provided entities.
352+
/// </summary>
353+
/// <typeparam name="TEntity">The type of the Kubernetes entity.</typeparam>
354+
/// <param name="from">The current/original entity.</param>
355+
/// <param name="to">The updated entity with desired changes.</param>
356+
/// <param name="cancellationToken">Cancellation token to monitor for cancellation requests.</param>
357+
/// <returns>The patched entity.</returns>
358+
Task<TEntity> PatchAsync<TEntity>(TEntity from, TEntity to, CancellationToken cancellationToken = default)
359+
where TEntity : IKubernetesObject<V1ObjectMeta> =>
360+
PatchAsync(from, from.CreateJsonPatch(to), cancellationToken);
361+
362+
/// <summary>
363+
/// Patch a given entity on the Kubernetes API using a <see cref="JsonPatch"/> object.
364+
/// </summary>
365+
/// <typeparam name="TEntity">The type of the Kubernetes entity.</typeparam>
366+
/// <param name="entity">The entity to patch.</param>
367+
/// <param name="patch">The <see cref="JsonPatch"/> representing the changes to apply.</param>
368+
/// <param name="cancellationToken">Cancellation token to monitor for cancellation requests.</param>
369+
/// <returns>The patched entity.</returns>
370+
Task<TEntity> PatchAsync<TEntity>(TEntity entity, JsonPatch patch, CancellationToken cancellationToken = default)
371+
where TEntity : IKubernetesObject<V1ObjectMeta> =>
372+
PatchAsync(entity, patch.ToKubernetesPatch(), cancellationToken);
373+
374+
/// <summary>
375+
/// Patch a given entity on the Kubernetes API using a <see cref="V1Patch"/> object.
376+
/// </summary>
377+
/// <typeparam name="TEntity">The type of the Kubernetes entity.</typeparam>
378+
/// <param name="entity">The entity to patch.</param>
379+
/// <param name="patch">The <see cref="V1Patch"/> representing the changes to apply.</param>
380+
/// <param name="cancellationToken">Cancellation token to monitor for cancellation requests.</param>
381+
/// <returns>The patched entity.</returns>
382+
Task<TEntity> PatchAsync<TEntity>(TEntity entity, V1Patch patch, CancellationToken cancellationToken = default)
383+
where TEntity : IKubernetesObject<V1ObjectMeta> =>
384+
PatchAsync<TEntity>(patch, entity.Name(), entity.Namespace(), cancellationToken);
385+
386+
/// <summary>
387+
/// Patch a given entity on the Kubernetes API by name and namespace using a <see cref="V1Patch"/> object.
388+
/// </summary>
389+
/// <typeparam name="TEntity">The type of the Kubernetes entity.</typeparam>
390+
/// <param name="patch">The <see cref="V1Patch"/> representing the changes to apply.</param>
391+
/// <param name="name">The name of the entity to patch.</param>
392+
/// <param name="namespace">The namespace of the entity to patch (if applicable).</param>
393+
/// <param name="cancellationToken">Cancellation token to monitor for cancellation requests.</param>
394+
/// <returns>The patched entity.</returns>
395+
Task<TEntity> PatchAsync<TEntity>(
396+
V1Patch patch,
397+
string name,
398+
string? @namespace = null,
399+
CancellationToken cancellationToken = default)
400+
where TEntity : IKubernetesObject<V1ObjectMeta>;
401+
402+
/// <summary>
403+
/// Patch a given entity on the Kubernetes API by calculating the diff between the current entity and the provided entity.
404+
/// </summary>
405+
/// <typeparam name="TEntity">The type of the Kubernetes entity.</typeparam>
406+
/// <param name="entity">The entity containing the desired updates.</param>
407+
/// <returns>The patched entity.</returns>
408+
/// <exception cref="InvalidOperationException">Thrown if the entity to be patched does not exist on the API.</exception>
409+
TEntity Patch<TEntity>(TEntity entity)
410+
where TEntity : IKubernetesObject<V1ObjectMeta>
411+
=> PatchAsync(entity).GetAwaiter().GetResult();
412+
413+
/// <summary>
414+
/// Patch a given entity on the Kubernetes API by calculating the diff between two provided entities.
415+
/// </summary>
416+
/// <typeparam name="TEntity">The type of the Kubernetes entity.</typeparam>
417+
/// <param name="from">The current/original entity.</param>
418+
/// <param name="to">The updated entity with desired changes.</param>
419+
/// <returns>The patched entity.</returns>
420+
TEntity Patch<TEntity>(TEntity from, TEntity to)
421+
where TEntity : IKubernetesObject<V1ObjectMeta>
422+
=> PatchAsync(from, to).GetAwaiter().GetResult();
423+
424+
/// <summary>
425+
/// Patch a given entity on the Kubernetes API using a <see cref="JsonPatch"/> object.
426+
/// </summary>
427+
/// <typeparam name="TEntity">The type of the Kubernetes entity.</typeparam>
428+
/// <param name="entity">The entity to patch.</param>
429+
/// <param name="patch">The <see cref="JsonPatch"/> representing the changes to apply.</param>
430+
/// <returns>The patched entity.</returns>
431+
TEntity Patch<TEntity>(TEntity entity, JsonPatch patch)
432+
where TEntity : IKubernetesObject<V1ObjectMeta>
433+
=> PatchAsync(entity, patch).GetAwaiter().GetResult();
434+
435+
/// <summary>
436+
/// Patch a given entity on the Kubernetes API using a <see cref="V1Patch"/> object.
437+
/// </summary>
438+
/// <typeparam name="TEntity">The type of the Kubernetes entity.</typeparam>
439+
/// <param name="entity">The entity to patch.</param>
440+
/// <param name="patch">The <see cref="V1Patch"/> representing the changes to apply.</param>
441+
/// <returns>The patched entity.</returns>
442+
TEntity Patch<TEntity>(TEntity entity, V1Patch patch)
443+
where TEntity : IKubernetesObject<V1ObjectMeta>
444+
=> PatchAsync(entity, patch).GetAwaiter().GetResult();
445+
446+
/// <summary>
447+
/// Patch a given entity on the Kubernetes API by name and namespace using a <see cref="V1Patch"/> object.
448+
/// </summary>
449+
/// <typeparam name="TEntity">The type of the Kubernetes entity.</typeparam>
450+
/// <param name="patch">The <see cref="V1Patch"/> representing the changes to apply.</param>
451+
/// <param name="name">The name of the entity to patch.</param>
452+
/// <param name="namespace">The namespace of the entity to patch (if applicable).</param>
453+
/// <returns>The patched entity.</returns>
454+
TEntity Patch<TEntity>(
455+
V1Patch patch,
456+
string name,
457+
string? @namespace = null)
458+
where TEntity : IKubernetesObject<V1ObjectMeta>
459+
=> PatchAsync<TEntity>(patch, name, @namespace).GetAwaiter().GetResult();
460+
323461
/// <inheritdoc cref="Delete{TEntity}(TEntity)"/>
324462
/// <returns>A task that completes when the call was made.</returns>
325463
Task DeleteAsync<TEntity>(TEntity entity, CancellationToken cancellationToken = default)

src/KubeOps.KubernetesClient/KubernetesClient.cs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -309,6 +309,24 @@ public TEntity UpdateStatus<TEntity>(TEntity entity)
309309
};
310310
}
311311

312+
/// <inheritdoc />
313+
public async Task<TEntity> PatchAsync<TEntity>(
314+
V1Patch patch,
315+
string name,
316+
string? @namespace = null,
317+
CancellationToken cancellationToken = default)
318+
where TEntity : IKubernetesObject<V1ObjectMeta>
319+
{
320+
ThrowIfDisposed();
321+
322+
using var client = CreateGenericClient<TEntity>();
323+
return await (@namespace switch
324+
{
325+
not null => client.PatchNamespacedAsync<TEntity>(patch, @namespace, name, cancellationToken),
326+
null => client.PatchAsync<TEntity>(patch, name, cancellationToken),
327+
});
328+
}
329+
312330
/// <inheritdoc />
313331
public async Task DeleteAsync<TEntity>(
314332
string name,

src/KubeOps.Operator.Web/KubeOps.Operator.Web.csproj

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,6 @@
2727

2828
<ItemGroup>
2929
<PackageReference Include="Localtunnel" Version="2.0.0" NoWarn="NU5104" />
30-
<PackageReference Include="SystemTextJson.JsonDiffPatch" Version="2.0.0" />
3130
</ItemGroup>
3231

3332
</Project>

src/KubeOps.Operator.Web/Webhooks/Admission/Mutation/JsonDiffer.cs

Lines changed: 0 additions & 27 deletions
This file was deleted.

src/KubeOps.Operator.Web/Webhooks/Admission/Mutation/MutationResult.cs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
11
using System.Text.Json.Nodes;
22

3+
using Json.Patch;
4+
35
using k8s;
46
using k8s.Models;
57

8+
using KubeOps.Abstractions.Entities;
9+
610
using Microsoft.AspNetCore.Http;
711
using Microsoft.AspNetCore.Mvc;
812

@@ -70,7 +74,9 @@ await response.WriteAsJsonAsync(
7074
Status = Status,
7175
Warnings = Warnings.ToArray(),
7276
PatchType = ModifiedObject is null ? null : JsonPatch,
73-
Patch = ModifiedObject is null ? null : OriginalObject!.Base64Diff(ModifiedObject),
77+
Patch = ModifiedObject is null
78+
? null
79+
: OriginalObject!.CreatePatch(ModifiedObject.ToNode()).ToBase64String(),
7480
},
7581
});
7682
}

0 commit comments

Comments
 (0)