From f3dfef609a1f74e4c9fd4b846b70238d3ddefc81 Mon Sep 17 00:00:00 2001 From: Adam Milazzo Date: Tue, 7 Apr 2020 18:06:26 -0700 Subject: [PATCH 01/13] Add useful model extensions --- src/KubernetesClient/ModelExtensions.cs | 239 ++++++++++++++++++++++++ 1 file changed, 239 insertions(+) create mode 100644 src/KubernetesClient/ModelExtensions.cs diff --git a/src/KubernetesClient/ModelExtensions.cs b/src/KubernetesClient/ModelExtensions.cs new file mode 100644 index 000000000..a8e853062 --- /dev/null +++ b/src/KubernetesClient/ModelExtensions.cs @@ -0,0 +1,239 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; + +namespace k8s.Models +{ + public static class ModelExtensions + { + /// Extracts the Kubernetes API group from the . + public static string ApiGroup(this IKubernetesObject obj) + { + if(obj == null) throw new ArgumentNullException(nameof(obj)); + if(obj.ApiVersion == null) return null; + int slash = obj.ApiVersion.IndexOf('/'); + return slash < 0 ? string.Empty : obj.ApiVersion.Substring(0, slash); + } + + /// Extracts the Kubernetes API version (excluding the group) from the . + public static string ApiGroupVersion(this IKubernetesObject obj) + { + if(obj == null) throw new ArgumentNullException(nameof(obj)); + if(obj.ApiVersion == null) return null; + int slash = obj.ApiVersion.IndexOf('/'); + return slash < 0 ? obj.ApiVersion : obj.ApiVersion.Substring(slash+1); + } + + /// Splits the Kubernetes API version into the group and version. + public static (string, string) ApiGroupAndVersion(this IKubernetesObject obj) + { + string group, version; + obj.GetApiGroupAndVersion(out group, out version); + return (group, version); + } + + /// Splits the Kubernetes API version into the group and version. + public static void GetApiGroupAndVersion(this IKubernetesObject obj, out string group, out string version) + { + if(obj == null) throw new ArgumentNullException(nameof(obj)); + if(obj.ApiVersion == null) + { + group = version = null; + } + else + { + int slash = obj.ApiVersion.IndexOf('/'); + if(slash < 0) (group, version) = (string.Empty, obj.ApiVersion); + else (group, version) = (obj.ApiVersion.Substring(0, slash), obj.ApiVersion.Substring(slash+1)); + } + } + + /// Gets the continuation token version of a Kubernetes list. + public static string Continue(this IMetadata list) => list.Metadata?.ContinueProperty; + + /// Ensures that the metadata field is set, and returns it. + public static V1ListMeta EnsureMetadata(this IMetadata obj) + { + if(obj.Metadata == null) obj.Metadata = new V1ListMeta(); + return obj.Metadata; + } + + /// Gets the resource version of a Kubernetes list. + public static string ResourceVersion(this IMetadata list) => list.Metadata?.ResourceVersion; + + /// Adds an owner reference to the object. No attempt is made to ensure the reference is correct or fits with the + /// other references. + /// + public static void AddOwnerReference(this IMetadata obj, V1OwnerReference ownerRef) + { + if(obj == null) throw new ArgumentNullException(nameof(obj)); + if(ownerRef == null) throw new ArgumentNullException(nameof(ownerRef)); + if(obj.EnsureMetadata().OwnerReferences == null) obj.Metadata.OwnerReferences = new List(); + obj.Metadata.OwnerReferences.Add(ownerRef); + } + + /// Gets the annotations of a Kubernetes object. + public static IDictionary Annotations(this IMetadata obj) => obj.Metadata?.Annotations; + + /// Gets the creation time of a Kubernetes object, or null if it hasn't been created yet. + public static DateTime? CreationTimestamp(this IMetadata obj) => obj.Metadata?.CreationTimestamp; + + /// Gets the deletion time of a Kubernetes object, or null if it hasn't been scheduled for deletion. + public static DateTime? DeletionTimestamp(this IMetadata obj) => obj.Metadata?.DeletionTimestamp; + + /// Ensures that the metadata field is set, and returns it. + public static V1ObjectMeta EnsureMetadata(this IMetadata obj) + { + if(obj.Metadata == null) obj.Metadata = new V1ObjectMeta(); + return obj.Metadata; + } + + /// Gets the index of the that matches the given object, or -1 if no such + /// reference could be found. + /// + public static int FindOwnerRef(this IMetadata obj, IKubernetesObject owner) + { + var ownerRefs = obj.OwnerReferences(); + if(ownerRefs != null) + { + for(int i = 0; i < ownerRefs.Count; i++) + { + if(ownerRefs[i].Matches(owner)) return i; + } + } + return -1; + } + + /// Gets the generation a Kubernetes object. + public static long? Generation(this IMetadata obj) => obj.Metadata?.Generation; + + /// Returns the given annotation from a Kubernetes object or null if the annotation was not found. + public static string GetAnnotation(this IMetadata obj, string key) + { + if(obj == null) throw new ArgumentNullException(nameof(obj)); + if(key == null) throw new ArgumentNullException(nameof(key)); + IDictionary annotations = obj.Annotations(); + return annotations != null && annotations.TryGetValue(key, out string value) ? value : null; + } + + /// Gets the for the controller of this object, or null if it couldn't be found. + public static V1OwnerReference GetController(this IMetadata obj) => + obj.OwnerReferences()?.FirstOrDefault(r => r.Controller.GetValueOrDefault()); + + /// Returns the given label from a Kubernetes object or null if the label was not found. + public static string GetLabel(this IMetadata obj, string key) + { + if(obj == null) throw new ArgumentNullException(nameof(obj)); + if(key == null) throw new ArgumentNullException(nameof(key)); + IDictionary labels = obj.Labels(); + return labels != null && labels.TryGetValue(key, out string value) ? value : null; + } + + /// Creates a that refers to the given object. + public static V1ObjectReference GetObjectReference(this T obj) where T : IKubernetesObject, IMetadata + { + if(obj == null) throw new ArgumentNullException(nameof(obj)); + string apiVersion = obj.ApiVersion, kind = obj.Kind; // default to using the API version and kind from the object + if(string.IsNullOrEmpty(apiVersion) || string.IsNullOrEmpty(kind)) // but if either of them is missing... + { + object[] attrs = typeof(T).GetCustomAttributes(typeof(KubernetesEntityAttribute), true); + if(attrs.Length == 0) throw new ArgumentException("Unable to determine the object's API version and Kind."); + var attr = (KubernetesEntityAttribute)attrs[0]; + (apiVersion, kind) = (string.IsNullOrEmpty(attr.Group) ? attr.ApiVersion : attr.Group + "/" + attr.ApiVersion, attr.Kind); + } + return new V1ObjectReference() + { + ApiVersion = apiVersion, Kind = kind, Name = obj.Name(), NamespaceProperty = obj.Namespace(), Uid = obj.Uid(), + ResourceVersion = obj.ResourceVersion() + }; + } + + /// Gets the labels of a Kubernetes object. + public static IDictionary Labels(this IMetadata obj) => obj.Metadata?.Labels; + + /// Gets the name of a Kubernetes object. + public static string Name(this IMetadata obj) => obj.Metadata?.Name; + + /// Gets the namespace of a Kubernetes object. + public static string Namespace(this IMetadata obj) => obj.Metadata?.NamespaceProperty; + + /// Gets the owner references of a Kubernetes object. + public static IList OwnerReferences(this IMetadata obj) => obj.Metadata?.OwnerReferences; + + /// Gets the resource version of a Kubernetes object. + public static string ResourceVersion(this IMetadata obj) => obj.Metadata?.ResourceVersion; + + /// Sets or removes an annotation on a Kubernetes object. + public static void SetAnnotation(this IMetadata obj, string key, string value) + { + if(obj == null) throw new ArgumentNullException(nameof(obj)); + if(key == null) throw new ArgumentNullException(nameof(key)); + if(value != null) obj.EnsureMetadata().EnsureAnnotations()[key] = value; + else obj.Metadata?.Annotations?.Remove(key); + } + + /// Sets or removes a label on a Kubernetes object. + public static void SetLabel(this IMetadata obj, string key, string value) + { + if(obj == null) throw new ArgumentNullException(nameof(obj)); + if(key == null) throw new ArgumentNullException(nameof(key)); + if(value != null) obj.EnsureMetadata().EnsureLabels()[key] = value; + else obj.Metadata?.Labels?.Remove(key); + } + + /// Gets the unique ID of a Kubernetes object. + public static string Uid(this IMetadata obj) => obj.Metadata?.Uid; + + /// Ensures that the field is not null, and returns it. + public static IDictionary EnsureAnnotations(this V1ObjectMeta meta) + { + if(meta.Annotations == null) meta.Annotations = new Dictionary(); + return meta.Annotations; + } + + /// Ensures that the field is not null, and returns it. + public static IDictionary EnsureLabels(this V1ObjectMeta meta) + { + if(meta.Labels == null) meta.Labels = new Dictionary(); + return meta.Labels; + } + + /// Gets the namespace from Kubernetes metadata. + public static string Namespace(this V1ObjectMeta meta) => meta.NamespaceProperty; + + /// Sets the namespace from Kubernetes metadata. + public static void SetNamespace(this V1ObjectMeta meta, string ns) => meta.NamespaceProperty = ns; + + /// Determines whether an object reference references the given object. + public static bool Matches(this V1ObjectReference objref, IKubernetesObject obj) + { + if(objref == null) throw new ArgumentNullException(nameof(objref)); + if(obj == null) throw new ArgumentNullException(nameof(obj)); + return objref.ApiVersion == obj.ApiVersion && objref.Kind == obj.Kind && objref.Name == obj.Name() && objref.Uid == obj.Uid() && + objref.NamespaceProperty == obj.Namespace(); + } + + /// Determines whether an owner reference references the given object. + public static bool Matches(this V1OwnerReference owner, IKubernetesObject obj) + { + if(owner == null) throw new ArgumentNullException(nameof(owner)); + if(obj == null) throw new ArgumentNullException(nameof(obj)); + return owner.ApiVersion == obj.ApiVersion && owner.Kind == obj.Kind && owner.Name == obj.Name() && owner.Uid == obj.Uid(); + } + } + + public partial class V1Status + { + /// Converts a object representing an error into a short description of the error. + public override string ToString() + { + string reason = Reason; + if(string.IsNullOrEmpty(reason) && Code.GetValueOrDefault() != 0) + { + reason = ((HttpStatusCode)Code.Value).ToString(); + } + return string.IsNullOrEmpty(Message) ? reason : string.IsNullOrEmpty(reason) ? Message : $"{reason} - {Message}"; + } + } +} From aa07378f5165c5a7c38affe5cda6111f1f7155e5 Mon Sep 17 00:00:00 2001 From: Adam Milazzo Date: Tue, 7 Apr 2020 18:51:31 -0700 Subject: [PATCH 02/13] Rename a function --- src/KubernetesClient/ModelExtensions.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/KubernetesClient/ModelExtensions.cs b/src/KubernetesClient/ModelExtensions.cs index a8e853062..246ed01e6 100644 --- a/src/KubernetesClient/ModelExtensions.cs +++ b/src/KubernetesClient/ModelExtensions.cs @@ -92,7 +92,7 @@ public static V1ObjectMeta EnsureMetadata(this IMetadata obj) /// Gets the index of the that matches the given object, or -1 if no such /// reference could be found. /// - public static int FindOwnerRef(this IMetadata obj, IKubernetesObject owner) + public static int FindOwnerReference(this IMetadata obj, IKubernetesObject owner) { var ownerRefs = obj.OwnerReferences(); if(ownerRefs != null) @@ -131,13 +131,13 @@ public static string GetLabel(this IMetadata obj, string key) } /// Creates a that refers to the given object. - public static V1ObjectReference GetObjectReference(this T obj) where T : IKubernetesObject, IMetadata + public static V1ObjectReference GetObjectReference(this IKubernetesObject obj) { if(obj == null) throw new ArgumentNullException(nameof(obj)); string apiVersion = obj.ApiVersion, kind = obj.Kind; // default to using the API version and kind from the object if(string.IsNullOrEmpty(apiVersion) || string.IsNullOrEmpty(kind)) // but if either of them is missing... { - object[] attrs = typeof(T).GetCustomAttributes(typeof(KubernetesEntityAttribute), true); + object[] attrs = obj.GetType().GetCustomAttributes(typeof(KubernetesEntityAttribute), true); if(attrs.Length == 0) throw new ArgumentException("Unable to determine the object's API version and Kind."); var attr = (KubernetesEntityAttribute)attrs[0]; (apiVersion, kind) = (string.IsNullOrEmpty(attr.Group) ? attr.ApiVersion : attr.Group + "/" + attr.ApiVersion, attr.Kind); From 6602a4cfc30b34bd2226ba4d6c7dc10bc391c42f Mon Sep 17 00:00:00 2001 From: Adam Milazzo Date: Wed, 8 Apr 2020 14:28:00 -0700 Subject: [PATCH 03/13] Add more model extensions; add unit tests --- src/KubernetesClient/ModelExtensions.cs | 93 ++++++++++ .../ModelExtensionTests.cs | 171 ++++++++++++++++++ 2 files changed, 264 insertions(+) create mode 100644 tests/KubernetesClient.Tests/ModelExtensionTests.cs diff --git a/src/KubernetesClient/ModelExtensions.cs b/src/KubernetesClient/ModelExtensions.cs index 246ed01e6..9c2f26630 100644 --- a/src/KubernetesClient/ModelExtensions.cs +++ b/src/KubernetesClient/ModelExtensions.cs @@ -7,6 +7,17 @@ namespace k8s.Models { public static class ModelExtensions { + /// Adds the given finalizer to a Kubernetes object if it doesn't already exist. + /// Returns true if the finalizer was added and false if it already existed. + public static bool AddFinalizer(this IMetadata obj, string finalizer) + { + if(string.IsNullOrEmpty(finalizer)) throw new ArgumentNullException(nameof(finalizer)); + if(obj.EnsureMetadata().Finalizers == null) obj.Metadata.Finalizers = new List(); + if(obj.Metadata.Finalizers.Contains(finalizer)) return false; + obj.Metadata.Finalizers.Add(finalizer); + return true; + } + /// Extracts the Kubernetes API group from the . public static string ApiGroup(this IKubernetesObject obj) { @@ -76,6 +87,24 @@ public static void AddOwnerReference(this IMetadata obj, V1OwnerRe /// Gets the annotations of a Kubernetes object. public static IDictionary Annotations(this IMetadata obj) => obj.Metadata?.Annotations; + /// Creates a that refers to the given object. + public static V1OwnerReference CreateOwnerReference(this IKubernetesObject obj, bool? controller = null, bool? blockDeletion = null) + { + if(obj == null) throw new ArgumentNullException(nameof(obj)); + string apiVersion = obj.ApiVersion, kind = obj.Kind; // default to using the API version and kind from the object + if(string.IsNullOrEmpty(apiVersion) || string.IsNullOrEmpty(kind)) // but if either of them is missing... + { + object[] attrs = obj.GetType().GetCustomAttributes(typeof(KubernetesEntityAttribute), true); + if(attrs.Length == 0) throw new ArgumentException("Unable to determine the object's API version and Kind."); + var attr = (KubernetesEntityAttribute)attrs[0]; + (apiVersion, kind) = (string.IsNullOrEmpty(attr.Group) ? attr.ApiVersion : attr.Group + "/" + attr.ApiVersion, attr.Kind); + } + return new V1OwnerReference() + { + ApiVersion = apiVersion, Kind = kind, Name = obj.Name(), Uid = obj.Uid(), Controller = controller, BlockOwnerDeletion = blockDeletion + }; + } + /// Gets the creation time of a Kubernetes object, or null if it hasn't been created yet. public static DateTime? CreationTimestamp(this IMetadata obj) => obj.Metadata?.CreationTimestamp; @@ -89,6 +118,9 @@ public static V1ObjectMeta EnsureMetadata(this IMetadata obj) return obj.Metadata; } + /// Gets the of a Kubernetes object. + public static IList Finalizers(this IMetadata obj) => obj.Metadata?.Finalizers; + /// Gets the index of the that matches the given object, or -1 if no such /// reference could be found. /// @@ -149,6 +181,13 @@ public static V1ObjectReference GetObjectReference(this IKubernetesObjectDetermines whether the Kubernetes object has the given finalizer. + public static bool HasFinalizer(this IMetadata obj, string finalizer) + { + if(string.IsNullOrEmpty(finalizer)) throw new ArgumentNullException(nameof(finalizer)); + return obj.Finalizers() != null && obj.Metadata.Finalizers.Contains(finalizer); + } + /// Gets the labels of a Kubernetes object. public static IDictionary Labels(this IMetadata obj) => obj.Metadata?.Labels; @@ -161,6 +200,53 @@ public static V1ObjectReference GetObjectReference(this IKubernetesObjectGets the owner references of a Kubernetes object. public static IList OwnerReferences(this IMetadata obj) => obj.Metadata?.OwnerReferences; + /// Removes the given finalizer from a Kubernetes object if it exists. + /// Returns true if the finalizer was removed and false if it didn't exist. + public static bool RemoveFinalizer(this IMetadata obj, string finalizer) + { + if(string.IsNullOrEmpty(finalizer)) throw new ArgumentNullException(nameof(finalizer)); + return obj.Finalizers() != null && obj.Metadata.Finalizers.Remove(finalizer); + } + + /// Removes the first that matches the given object and returns it, or returns null if no + /// matching reference could be found. + /// + public static V1OwnerReference RemoveOwnerReference(this IMetadata obj, IKubernetesObject owner) + { + int index = obj.FindOwnerReference(owner); + V1OwnerReference ownerRef = index >= 0 ? obj.Metadata.OwnerReferences[index] : null; + if(index >= 0) obj.Metadata.OwnerReferences.RemoveAt(index); + return ownerRef; + } + + /// Removes all owner references that match the given predicate, and returns true if + /// any were removed. + /// + public static bool RemoveOwnerReferences(this IMetadata obj, Predicate predicate) + { + if(predicate == null) throw new ArgumentNullException(nameof(predicate)); + bool removed = false; + IList refs = obj.Metadata?.OwnerReferences; + if(refs != null) + { + for(int i = refs.Count-1; i >= 0; i--) + { + if(predicate(refs[i])) + { + refs.RemoveAt(i); + removed = true; + } + } + } + return removed; + } + + /// Removes all owner references that match the given object, and returns true if + /// any were removed. + /// + public static bool RemoveOwnerReferences(this IMetadata obj, IKubernetesObject owner) => + RemoveOwnerReferences(obj, r => r.Matches(owner)); + /// Gets the resource version of a Kubernetes object. public static string ResourceVersion(this IMetadata obj) => obj.Metadata?.ResourceVersion; @@ -192,6 +278,13 @@ public static IDictionary EnsureAnnotations(this V1ObjectMeta me return meta.Annotations; } + /// Ensures that the field is not null, and returns it. + public static IList EnsureFinalizers(this V1ObjectMeta meta) + { + if(meta.Finalizers == null) meta.Finalizers = new List(); + return meta.Finalizers; + } + /// Ensures that the field is not null, and returns it. public static IDictionary EnsureLabels(this V1ObjectMeta meta) { diff --git a/tests/KubernetesClient.Tests/ModelExtensionTests.cs b/tests/KubernetesClient.Tests/ModelExtensionTests.cs new file mode 100644 index 000000000..13e220982 --- /dev/null +++ b/tests/KubernetesClient.Tests/ModelExtensionTests.cs @@ -0,0 +1,171 @@ +using System; +using k8s.Models; +using Xunit; + +namespace k8s.Tests +{ + public class ModelExtensionTests + { + [Fact] + public void TestMetadata() + { + // test getters on null metadata + var pod = new V1Pod(); + Assert.Null(pod.Annotations()); + Assert.Null(pod.ApiGroup()); + var (g, v) = pod.ApiGroupAndVersion(); + Assert.Null(g); + Assert.Null(v); + Assert.Null(pod.ApiGroupVersion()); + Assert.Null(pod.CreationTimestamp()); + Assert.Null(pod.DeletionTimestamp()); + Assert.Null(pod.Finalizers()); + Assert.Null(pod.Generation()); + Assert.Null(pod.GetAnnotation("x")); + Assert.Null(pod.GetController()); + Assert.Null(pod.GetLabel("x")); + Assert.False(pod.HasFinalizer("x")); + Assert.Null(pod.Labels()); + Assert.Null(pod.Name()); + Assert.Null(pod.Namespace()); + Assert.Null(pod.OwnerReferences()); + Assert.Null(pod.ResourceVersion()); + Assert.Null(pod.Uid()); + Assert.Null(pod.Metadata); + + // test API version stuff + pod = new V1Pod() { ApiVersion = "v1" }; + Assert.Equal("", pod.ApiGroup()); + (g, v) = pod.ApiGroupAndVersion(); + Assert.Equal("", g); + Assert.Equal("v1", v); + Assert.Equal("v1", pod.ApiGroupVersion()); + pod.ApiVersion = "abc/v2"; + Assert.Equal("abc", pod.ApiGroup()); + (g, v) = pod.ApiGroupAndVersion(); + Assert.Equal("abc", g); + Assert.Equal("v2", v); + Assert.Equal("v2", pod.ApiGroupVersion()); + + // test the Ensure*() functions + Assert.NotNull(pod.EnsureMetadata()); + Assert.NotNull(pod.Metadata); + Assert.NotNull(pod.Metadata.EnsureAnnotations()); + Assert.NotNull(pod.Metadata.Annotations); + Assert.NotNull(pod.Metadata.EnsureFinalizers()); + Assert.NotNull(pod.Metadata.Finalizers); + Assert.NotNull(pod.Metadata.EnsureLabels()); + Assert.NotNull(pod.Metadata.Labels); + + // test getters with non-null values + DateTime ts = DateTime.UtcNow, ts2 = DateTime.Now; + pod.Metadata = new V1ObjectMeta() + { + CreationTimestamp = ts, DeletionTimestamp = ts2, Generation = 1, Name = "name", NamespaceProperty = "ns", ResourceVersion = "42", Uid = "id" + }; + Assert.Equal(ts, pod.CreationTimestamp().Value); + Assert.Equal(ts2, pod.DeletionTimestamp().Value); + Assert.Equal(1, pod.Generation().Value); + Assert.Equal("name", pod.Name()); + Assert.Equal("ns", pod.Namespace()); + Assert.Equal("42", pod.ResourceVersion()); + Assert.Equal("id", pod.Uid()); + + // test annotations and labels + pod.SetAnnotation("x", "y"); + pod.SetLabel("a", "b"); + Assert.Equal(1, pod.Annotations().Count); + Assert.Equal(1, pod.Labels().Count); + Assert.Equal("y", pod.GetAnnotation("x")); + Assert.Equal("y", pod.Metadata.Annotations["x"]); + Assert.Null(pod.GetAnnotation("a")); + Assert.Equal("b", pod.GetLabel("a")); + Assert.Equal("b", pod.Metadata.Labels["a"]); + Assert.Null(pod.GetLabel("x")); + pod.SetAnnotation("x", null); + Assert.Equal(0, pod.Annotations().Count); + pod.SetLabel("a", null); + Assert.Equal(0, pod.Labels().Count); + + // test finalizers + Assert.False(pod.HasFinalizer("abc")); + Assert.True(pod.AddFinalizer("abc")); + Assert.True(pod.HasFinalizer("abc")); + Assert.False(pod.AddFinalizer("abc")); + Assert.False(pod.HasFinalizer("xyz")); + Assert.False(pod.RemoveFinalizer("xyz")); + Assert.True(pod.RemoveFinalizer("abc")); + Assert.False(pod.HasFinalizer("abc")); + Assert.False(pod.RemoveFinalizer("abc")); + } + + [Fact] + public void TestReferences() + { + // test object references + var pod = new V1Pod() { ApiVersion = "abc/xyz", Kind = "sometimes" }; + pod.Metadata = new V1ObjectMeta() { Name = "name", NamespaceProperty = "ns", ResourceVersion = "ver", Uid = "id" }; + var objr = pod.GetObjectReference(); + Assert.Equal(pod.ApiVersion, objr.ApiVersion); + Assert.Equal(pod.Kind, objr.Kind); + Assert.Equal(pod.Name(), objr.Name); + Assert.Equal(pod.Namespace(), objr.NamespaceProperty); + Assert.Equal(pod.ResourceVersion(), objr.ResourceVersion); + Assert.Equal(pod.Uid(), objr.Uid); + Assert.True(objr.Matches(pod)); + + (pod.ApiVersion, pod.Kind) = (null, null); + objr = pod.GetObjectReference(); + Assert.Equal("v1", objr.ApiVersion); + Assert.Equal("Pod", objr.Kind); + Assert.False(objr.Matches(pod)); + (pod.ApiVersion, pod.Kind) = (objr.ApiVersion, objr.Kind); + Assert.True(objr.Matches(pod)); + pod.Metadata.Name = "nome"; + Assert.False(objr.Matches(pod)); + + // test owner references + (pod.ApiVersion, pod.Kind) = ("abc/xyz", "sometimes"); + var ownr = pod.CreateOwnerReference(true, false); + Assert.Equal(pod.ApiVersion, ownr.ApiVersion); + Assert.Equal(pod.Kind, ownr.Kind); + Assert.Equal(pod.Name(), ownr.Name); + Assert.Equal(pod.Uid(), ownr.Uid); + Assert.True(ownr.Controller.Value); + Assert.False(ownr.BlockOwnerDeletion.Value); + Assert.True(ownr.Matches(pod)); + + (pod.ApiVersion, pod.Kind) = (null, null); + Assert.False(ownr.Matches(pod)); + ownr = pod.CreateOwnerReference(); + Assert.Equal("v1", ownr.ApiVersion); + Assert.Equal("Pod", ownr.Kind); + Assert.Null(ownr.Controller); + Assert.Null(ownr.BlockOwnerDeletion); + Assert.False(ownr.Matches(pod)); + (pod.ApiVersion, pod.Kind) = (ownr.ApiVersion, ownr.Kind); + Assert.True(ownr.Matches(pod)); + ownr.Name = "nim"; + Assert.False(ownr.Matches(pod)); + ownr.Name = pod.Name(); + + var svc = new V1Service(); + svc.AddOwnerReference(ownr); + Assert.Equal(0, svc.FindOwnerReference(pod)); + Assert.Equal(-1, svc.FindOwnerReference(svc)); + Assert.Null(svc.GetController()); + svc.OwnerReferences()[0].Controller = true; + Assert.Same(ownr, svc.GetController()); + Assert.Same(ownr, svc.RemoveOwnerReference(pod)); + Assert.Equal(0, svc.OwnerReferences().Count); + svc.AddOwnerReference(pod.CreateOwnerReference(true)); + svc.AddOwnerReference(pod.CreateOwnerReference(false)); + svc.AddOwnerReference(pod.CreateOwnerReference()); + Assert.Equal(3, svc.OwnerReferences().Count); + Assert.NotNull(svc.RemoveOwnerReference(pod)); + Assert.Equal(2, svc.OwnerReferences().Count); + Assert.True(svc.RemoveOwnerReferences(pod)); + Assert.Equal(0, svc.OwnerReferences().Count); + } + } +} From 58e135d79051b158597be0f73976a3ee6d7866ea Mon Sep 17 00:00:00 2001 From: Adam Milazzo Date: Thu, 9 Apr 2020 16:19:34 -0700 Subject: [PATCH 04/13] Add a couple more extensions requested in PR --- src/KubernetesClient/ModelExtensions.cs | 168 +++++++++++------- .../ModelExtensionTests.cs | 8 +- 2 files changed, 109 insertions(+), 67 deletions(-) diff --git a/src/KubernetesClient/ModelExtensions.cs b/src/KubernetesClient/ModelExtensions.cs index 9c2f26630..fe5a880be 100644 --- a/src/KubernetesClient/ModelExtensions.cs +++ b/src/KubernetesClient/ModelExtensions.cs @@ -5,15 +5,16 @@ namespace k8s.Models { + /// Adds convenient extensions for Kubernetes objects. public static class ModelExtensions { /// Adds the given finalizer to a Kubernetes object if it doesn't already exist. /// Returns true if the finalizer was added and false if it already existed. public static bool AddFinalizer(this IMetadata obj, string finalizer) { - if(string.IsNullOrEmpty(finalizer)) throw new ArgumentNullException(nameof(finalizer)); - if(obj.EnsureMetadata().Finalizers == null) obj.Metadata.Finalizers = new List(); - if(obj.Metadata.Finalizers.Contains(finalizer)) return false; + if (string.IsNullOrEmpty(finalizer)) throw new ArgumentNullException(nameof(finalizer)); + if (EnsureMetadata(obj).Finalizers == null) obj.Metadata.Finalizers = new List(); + if (obj.Metadata.Finalizers.Contains(finalizer)) return false; obj.Metadata.Finalizers.Add(finalizer); return true; } @@ -21,8 +22,8 @@ public static bool AddFinalizer(this IMetadata obj, string finaliz /// Extracts the Kubernetes API group from the . public static string ApiGroup(this IKubernetesObject obj) { - if(obj == null) throw new ArgumentNullException(nameof(obj)); - if(obj.ApiVersion == null) return null; + if (obj == null) throw new ArgumentNullException(nameof(obj)); + if (obj.ApiVersion == null) return null; int slash = obj.ApiVersion.IndexOf('/'); return slash < 0 ? string.Empty : obj.ApiVersion.Substring(0, slash); } @@ -30,8 +31,8 @@ public static string ApiGroup(this IKubernetesObject obj) /// Extracts the Kubernetes API version (excluding the group) from the . public static string ApiGroupVersion(this IKubernetesObject obj) { - if(obj == null) throw new ArgumentNullException(nameof(obj)); - if(obj.ApiVersion == null) return null; + if (obj == null) throw new ArgumentNullException(nameof(obj)); + if (obj.ApiVersion == null) return null; int slash = obj.ApiVersion.IndexOf('/'); return slash < 0 ? obj.ApiVersion : obj.ApiVersion.Substring(slash+1); } @@ -40,22 +41,22 @@ public static string ApiGroupVersion(this IKubernetesObject obj) public static (string, string) ApiGroupAndVersion(this IKubernetesObject obj) { string group, version; - obj.GetApiGroupAndVersion(out group, out version); + GetApiGroupAndVersion(obj, out group, out version); return (group, version); } /// Splits the Kubernetes API version into the group and version. public static void GetApiGroupAndVersion(this IKubernetesObject obj, out string group, out string version) { - if(obj == null) throw new ArgumentNullException(nameof(obj)); - if(obj.ApiVersion == null) + if (obj == null) throw new ArgumentNullException(nameof(obj)); + if (obj.ApiVersion == null) { group = version = null; } else { int slash = obj.ApiVersion.IndexOf('/'); - if(slash < 0) (group, version) = (string.Empty, obj.ApiVersion); + if (slash < 0) (group, version) = (string.Empty, obj.ApiVersion); else (group, version) = (obj.ApiVersion.Substring(0, slash), obj.ApiVersion.Substring(slash+1)); } } @@ -66,7 +67,8 @@ public static void GetApiGroupAndVersion(this IKubernetesObject obj, out string /// Ensures that the metadata field is set, and returns it. public static V1ListMeta EnsureMetadata(this IMetadata obj) { - if(obj.Metadata == null) obj.Metadata = new V1ListMeta(); + if (obj == null) throw new ArgumentNullException(nameof(obj)); + if (obj.Metadata == null) obj.Metadata = new V1ListMeta(); return obj.Metadata; } @@ -78,24 +80,49 @@ public static V1ListMeta EnsureMetadata(this IMetadata obj) /// public static void AddOwnerReference(this IMetadata obj, V1OwnerReference ownerRef) { - if(obj == null) throw new ArgumentNullException(nameof(obj)); - if(ownerRef == null) throw new ArgumentNullException(nameof(ownerRef)); - if(obj.EnsureMetadata().OwnerReferences == null) obj.Metadata.OwnerReferences = new List(); + if (ownerRef == null) throw new ArgumentNullException(nameof(ownerRef)); + if (EnsureMetadata(obj).OwnerReferences == null) obj.Metadata.OwnerReferences = new List(); obj.Metadata.OwnerReferences.Add(ownerRef); } + /// Adds an owner reference to the object. No attempt is made to ensure the reference is correct or fits with the + /// other references. + /// + public static void AddOwnerReference( + this IMetadata obj, IKubernetesObject owner, bool? controller = null, bool? blockDeletion = null) => + AddOwnerReference(obj, CreateOwnerReference(owner, controller, blockDeletion)); + /// Gets the annotations of a Kubernetes object. public static IDictionary Annotations(this IMetadata obj) => obj.Metadata?.Annotations; + /// Creates a that refers to the given object. + public static V1ObjectReference CreateObjectReference(this IKubernetesObject obj) + { + if (obj == null) throw new ArgumentNullException(nameof(obj)); + string apiVersion = obj.ApiVersion, kind = obj.Kind; // default to using the API version and kind from the object + if (string.IsNullOrEmpty(apiVersion) || string.IsNullOrEmpty(kind)) // but if either of them is missing... + { + object[] attrs = obj.GetType().GetCustomAttributes(typeof(KubernetesEntityAttribute), true); + if (attrs.Length == 0) throw new ArgumentException("Unable to determine the object's API version and Kind."); + var attr = (KubernetesEntityAttribute)attrs[0]; + (apiVersion, kind) = (string.IsNullOrEmpty(attr.Group) ? attr.ApiVersion : attr.Group + "/" + attr.ApiVersion, attr.Kind); + } + return new V1ObjectReference() + { + ApiVersion = apiVersion, Kind = kind, Name = obj.Name(), NamespaceProperty = obj.Namespace(), Uid = obj.Uid(), + ResourceVersion = obj.ResourceVersion() + }; + } + /// Creates a that refers to the given object. public static V1OwnerReference CreateOwnerReference(this IKubernetesObject obj, bool? controller = null, bool? blockDeletion = null) { - if(obj == null) throw new ArgumentNullException(nameof(obj)); + if (obj == null) throw new ArgumentNullException(nameof(obj)); string apiVersion = obj.ApiVersion, kind = obj.Kind; // default to using the API version and kind from the object - if(string.IsNullOrEmpty(apiVersion) || string.IsNullOrEmpty(kind)) // but if either of them is missing... + if (string.IsNullOrEmpty(apiVersion) || string.IsNullOrEmpty(kind)) // but if either of them is missing... { object[] attrs = obj.GetType().GetCustomAttributes(typeof(KubernetesEntityAttribute), true); - if(attrs.Length == 0) throw new ArgumentException("Unable to determine the object's API version and Kind."); + if (attrs.Length == 0) throw new ArgumentException("Unable to determine the object's API version and Kind."); var attr = (KubernetesEntityAttribute)attrs[0]; (apiVersion, kind) = (string.IsNullOrEmpty(attr.Group) ? attr.ApiVersion : attr.Group + "/" + attr.ApiVersion, attr.Kind); } @@ -114,7 +141,8 @@ public static V1OwnerReference CreateOwnerReference(this IKubernetesObjectEnsures that the metadata field is set, and returns it. public static V1ObjectMeta EnsureMetadata(this IMetadata obj) { - if(obj.Metadata == null) obj.Metadata = new V1ObjectMeta(); + if (obj == null) throw new ArgumentNullException(nameof(obj)); + if (obj.Metadata == null) obj.Metadata = new V1ObjectMeta(); return obj.Metadata; } @@ -124,14 +152,22 @@ public static V1ObjectMeta EnsureMetadata(this IMetadata obj) /// Gets the index of the that matches the given object, or -1 if no such /// reference could be found. /// - public static int FindOwnerReference(this IMetadata obj, IKubernetesObject owner) + public static int FindOwnerReference(this IMetadata obj, IKubernetesObject owner) => + FindOwnerReference(obj, r => r.Matches(owner)); + + /// Gets the index of the that matches the given predicate, or -1 if no such + /// reference could be found. + /// + public static int FindOwnerReference(this IMetadata obj, Predicate predicate) { + if (obj == null) throw new ArgumentNullException(nameof(obj)); + if (predicate == null) throw new ArgumentNullException(nameof(predicate)); var ownerRefs = obj.OwnerReferences(); - if(ownerRefs != null) + if (ownerRefs != null) { - for(int i = 0; i < ownerRefs.Count; i++) + for (int i = 0; i < ownerRefs.Count; i++) { - if(ownerRefs[i].Matches(owner)) return i; + if (predicate(ownerRefs[i])) return i; } } return -1; @@ -143,8 +179,8 @@ public static int FindOwnerReference(this IMetadata obj, IKubernet /// Returns the given annotation from a Kubernetes object or null if the annotation was not found. public static string GetAnnotation(this IMetadata obj, string key) { - if(obj == null) throw new ArgumentNullException(nameof(obj)); - if(key == null) throw new ArgumentNullException(nameof(key)); + if (obj == null) throw new ArgumentNullException(nameof(obj)); + if (key == null) throw new ArgumentNullException(nameof(key)); IDictionary annotations = obj.Annotations(); return annotations != null && annotations.TryGetValue(key, out string value) ? value : null; } @@ -156,38 +192,35 @@ public static V1OwnerReference GetController(this IMetadata obj) = /// Returns the given label from a Kubernetes object or null if the label was not found. public static string GetLabel(this IMetadata obj, string key) { - if(obj == null) throw new ArgumentNullException(nameof(obj)); - if(key == null) throw new ArgumentNullException(nameof(key)); + if (obj == null) throw new ArgumentNullException(nameof(obj)); + if (key == null) throw new ArgumentNullException(nameof(key)); IDictionary labels = obj.Labels(); return labels != null && labels.TryGetValue(key, out string value) ? value : null; } - /// Creates a that refers to the given object. - public static V1ObjectReference GetObjectReference(this IKubernetesObject obj) + /// Gets that matches the given object, or null if no matching reference exists. + public static V1OwnerReference GetOwnerReference(this IMetadata obj, IKubernetesObject owner) => + GetOwnerReference(obj, r => r.Matches(owner)); + + /// Gets the that matches the given predicate, or null if no matching reference exists. + public static V1OwnerReference GetOwnerReference(this IMetadata obj, Predicate predicate) { - if(obj == null) throw new ArgumentNullException(nameof(obj)); - string apiVersion = obj.ApiVersion, kind = obj.Kind; // default to using the API version and kind from the object - if(string.IsNullOrEmpty(apiVersion) || string.IsNullOrEmpty(kind)) // but if either of them is missing... - { - object[] attrs = obj.GetType().GetCustomAttributes(typeof(KubernetesEntityAttribute), true); - if(attrs.Length == 0) throw new ArgumentException("Unable to determine the object's API version and Kind."); - var attr = (KubernetesEntityAttribute)attrs[0]; - (apiVersion, kind) = (string.IsNullOrEmpty(attr.Group) ? attr.ApiVersion : attr.Group + "/" + attr.ApiVersion, attr.Kind); - } - return new V1ObjectReference() - { - ApiVersion = apiVersion, Kind = kind, Name = obj.Name(), NamespaceProperty = obj.Namespace(), Uid = obj.Uid(), - ResourceVersion = obj.ResourceVersion() - }; + int index = FindOwnerReference(obj, predicate); + return index >= 0 ? obj.Metadata.OwnerReferences[index] : null; } /// Determines whether the Kubernetes object has the given finalizer. public static bool HasFinalizer(this IMetadata obj, string finalizer) { - if(string.IsNullOrEmpty(finalizer)) throw new ArgumentNullException(nameof(finalizer)); + if (obj == null) throw new ArgumentNullException(nameof(obj)); + if (string.IsNullOrEmpty(finalizer)) throw new ArgumentNullException(nameof(finalizer)); return obj.Finalizers() != null && obj.Metadata.Finalizers.Contains(finalizer); } + /// Determines whether one object is owned by another. + public static bool IsOwnedBy(this IMetadata obj, IKubernetesObject owner) => + FindOwnerReference(obj, owner) >= 0; + /// Gets the labels of a Kubernetes object. public static IDictionary Labels(this IMetadata obj) => obj.Metadata?.Labels; @@ -204,7 +237,8 @@ public static bool HasFinalizer(this IMetadata obj, string finaliz /// Returns true if the finalizer was removed and false if it didn't exist. public static bool RemoveFinalizer(this IMetadata obj, string finalizer) { - if(string.IsNullOrEmpty(finalizer)) throw new ArgumentNullException(nameof(finalizer)); + if (obj == null) throw new ArgumentNullException(nameof(obj)); + if (string.IsNullOrEmpty(finalizer)) throw new ArgumentNullException(nameof(finalizer)); return obj.Finalizers() != null && obj.Metadata.Finalizers.Remove(finalizer); } @@ -213,9 +247,9 @@ public static bool RemoveFinalizer(this IMetadata obj, string fina /// public static V1OwnerReference RemoveOwnerReference(this IMetadata obj, IKubernetesObject owner) { - int index = obj.FindOwnerReference(owner); + int index = FindOwnerReference(obj, owner); V1OwnerReference ownerRef = index >= 0 ? obj.Metadata.OwnerReferences[index] : null; - if(index >= 0) obj.Metadata.OwnerReferences.RemoveAt(index); + if (index >= 0) obj.Metadata.OwnerReferences.RemoveAt(index); return ownerRef; } @@ -224,14 +258,15 @@ public static V1OwnerReference RemoveOwnerReference(this IMetadata /// public static bool RemoveOwnerReferences(this IMetadata obj, Predicate predicate) { - if(predicate == null) throw new ArgumentNullException(nameof(predicate)); + if (obj == null) throw new ArgumentNullException(nameof(obj)); + if (predicate == null) throw new ArgumentNullException(nameof(predicate)); bool removed = false; IList refs = obj.Metadata?.OwnerReferences; - if(refs != null) + if (refs != null) { - for(int i = refs.Count-1; i >= 0; i--) + for (int i = refs.Count-1; i >= 0; i--) { - if(predicate(refs[i])) + if (predicate(refs[i])) { refs.RemoveAt(i); removed = true; @@ -253,18 +288,18 @@ public static bool RemoveOwnerReferences(this IMetadata obj, IKube /// Sets or removes an annotation on a Kubernetes object. public static void SetAnnotation(this IMetadata obj, string key, string value) { - if(obj == null) throw new ArgumentNullException(nameof(obj)); - if(key == null) throw new ArgumentNullException(nameof(key)); - if(value != null) obj.EnsureMetadata().EnsureAnnotations()[key] = value; + if (obj == null) throw new ArgumentNullException(nameof(obj)); + if (key == null) throw new ArgumentNullException(nameof(key)); + if (value != null) obj.EnsureMetadata().EnsureAnnotations()[key] = value; else obj.Metadata?.Annotations?.Remove(key); } /// Sets or removes a label on a Kubernetes object. public static void SetLabel(this IMetadata obj, string key, string value) { - if(obj == null) throw new ArgumentNullException(nameof(obj)); - if(key == null) throw new ArgumentNullException(nameof(key)); - if(value != null) obj.EnsureMetadata().EnsureLabels()[key] = value; + if (obj == null) throw new ArgumentNullException(nameof(obj)); + if (key == null) throw new ArgumentNullException(nameof(key)); + if (value != null) obj.EnsureMetadata().EnsureLabels()[key] = value; else obj.Metadata?.Labels?.Remove(key); } @@ -274,21 +309,24 @@ public static void SetLabel(this IMetadata obj, string key, string /// Ensures that the field is not null, and returns it. public static IDictionary EnsureAnnotations(this V1ObjectMeta meta) { - if(meta.Annotations == null) meta.Annotations = new Dictionary(); + if (meta == null) throw new ArgumentNullException(nameof(meta)); + if (meta.Annotations == null) meta.Annotations = new Dictionary(); return meta.Annotations; } /// Ensures that the field is not null, and returns it. public static IList EnsureFinalizers(this V1ObjectMeta meta) { - if(meta.Finalizers == null) meta.Finalizers = new List(); + if (meta == null) throw new ArgumentNullException(nameof(meta)); + if (meta.Finalizers == null) meta.Finalizers = new List(); return meta.Finalizers; } /// Ensures that the field is not null, and returns it. public static IDictionary EnsureLabels(this V1ObjectMeta meta) { - if(meta.Labels == null) meta.Labels = new Dictionary(); + if (meta == null) throw new ArgumentNullException(nameof(meta)); + if (meta.Labels == null) meta.Labels = new Dictionary(); return meta.Labels; } @@ -301,8 +339,8 @@ public static IDictionary EnsureLabels(this V1ObjectMeta meta) /// Determines whether an object reference references the given object. public static bool Matches(this V1ObjectReference objref, IKubernetesObject obj) { - if(objref == null) throw new ArgumentNullException(nameof(objref)); - if(obj == null) throw new ArgumentNullException(nameof(obj)); + if (objref == null) throw new ArgumentNullException(nameof(objref)); + if (obj == null) throw new ArgumentNullException(nameof(obj)); return objref.ApiVersion == obj.ApiVersion && objref.Kind == obj.Kind && objref.Name == obj.Name() && objref.Uid == obj.Uid() && objref.NamespaceProperty == obj.Namespace(); } @@ -310,8 +348,8 @@ public static bool Matches(this V1ObjectReference objref, IKubernetesObjectDetermines whether an owner reference references the given object. public static bool Matches(this V1OwnerReference owner, IKubernetesObject obj) { - if(owner == null) throw new ArgumentNullException(nameof(owner)); - if(obj == null) throw new ArgumentNullException(nameof(obj)); + if (owner == null) throw new ArgumentNullException(nameof(owner)); + if (obj == null) throw new ArgumentNullException(nameof(obj)); return owner.ApiVersion == obj.ApiVersion && owner.Kind == obj.Kind && owner.Name == obj.Name() && owner.Uid == obj.Uid(); } } @@ -322,7 +360,7 @@ public partial class V1Status public override string ToString() { string reason = Reason; - if(string.IsNullOrEmpty(reason) && Code.GetValueOrDefault() != 0) + if (string.IsNullOrEmpty(reason) && Code.GetValueOrDefault() != 0) { reason = ((HttpStatusCode)Code.Value).ToString(); } diff --git a/tests/KubernetesClient.Tests/ModelExtensionTests.cs b/tests/KubernetesClient.Tests/ModelExtensionTests.cs index 13e220982..27ec02c75 100644 --- a/tests/KubernetesClient.Tests/ModelExtensionTests.cs +++ b/tests/KubernetesClient.Tests/ModelExtensionTests.cs @@ -20,10 +20,12 @@ public void TestMetadata() Assert.Null(pod.CreationTimestamp()); Assert.Null(pod.DeletionTimestamp()); Assert.Null(pod.Finalizers()); + Assert.Equal(-1, pod.FindOwnerReference(r => true)); Assert.Null(pod.Generation()); Assert.Null(pod.GetAnnotation("x")); Assert.Null(pod.GetController()); Assert.Null(pod.GetLabel("x")); + Assert.Null(pod.GetOwnerReference(r => true)); Assert.False(pod.HasFinalizer("x")); Assert.Null(pod.Labels()); Assert.Null(pod.Name()); @@ -105,7 +107,7 @@ public void TestReferences() // test object references var pod = new V1Pod() { ApiVersion = "abc/xyz", Kind = "sometimes" }; pod.Metadata = new V1ObjectMeta() { Name = "name", NamespaceProperty = "ns", ResourceVersion = "ver", Uid = "id" }; - var objr = pod.GetObjectReference(); + var objr = pod.CreateObjectReference(); Assert.Equal(pod.ApiVersion, objr.ApiVersion); Assert.Equal(pod.Kind, objr.Kind); Assert.Equal(pod.Name(), objr.Name); @@ -115,7 +117,7 @@ public void TestReferences() Assert.True(objr.Matches(pod)); (pod.ApiVersion, pod.Kind) = (null, null); - objr = pod.GetObjectReference(); + objr = pod.CreateObjectReference(); Assert.Equal("v1", objr.ApiVersion); Assert.Equal("Pod", objr.Kind); Assert.False(objr.Matches(pod)); @@ -153,6 +155,8 @@ public void TestReferences() svc.AddOwnerReference(ownr); Assert.Equal(0, svc.FindOwnerReference(pod)); Assert.Equal(-1, svc.FindOwnerReference(svc)); + Assert.Same(ownr, svc.GetOwnerReference(pod)); + Assert.Null(svc.GetOwnerReference(svc)); Assert.Null(svc.GetController()); svc.OwnerReferences()[0].Controller = true; Assert.Same(ownr, svc.GetController()); From b200147938e9b9b481bdd05ad0d00780d6f32d34 Mon Sep 17 00:00:00 2001 From: Adam Milazzo Date: Tue, 7 Apr 2020 22:11:07 -0700 Subject: [PATCH 05/13] Add a fluent Kubernetes API --- src/KubernetesClient/Kubernetes.ConfigInit.cs | 63 +- src/KubernetesClient/Kubernetes.Fluent.cs | 40 + src/KubernetesClient/Kubernetes.WebSocket.cs | 12 +- src/KubernetesClient/KubernetesRequest.cs | 713 ++++++++++++++++++ src/KubernetesClient/Scheme.cs | 173 +++++ 5 files changed, 975 insertions(+), 26 deletions(-) create mode 100644 src/KubernetesClient/Kubernetes.Fluent.cs create mode 100644 src/KubernetesClient/KubernetesRequest.cs create mode 100644 src/KubernetesClient/Scheme.cs diff --git a/src/KubernetesClient/Kubernetes.ConfigInit.cs b/src/KubernetesClient/Kubernetes.ConfigInit.cs index 16cc03380..4d7e04911 100644 --- a/src/KubernetesClient/Kubernetes.ConfigInit.cs +++ b/src/KubernetesClient/Kubernetes.ConfigInit.cs @@ -8,6 +8,7 @@ using k8s.Exceptions; using k8s.Models; using Microsoft.Rest; +using Newtonsoft.Json; namespace k8s { @@ -41,9 +42,8 @@ public Kubernetes(KubernetesClientConfiguration config, HttpClient httpClient) : public Kubernetes(KubernetesClientConfiguration config, HttpClient httpClient, bool disposeHttpClient) : this(httpClient, disposeHttpClient) { ValidateConfig(config); - CaCerts = config.SslCaCerts; - SkipTlsVerify = config.SkipTlsVerify; - SetCredentials(config); + this.config = config; + SetCredentials(); } /// @@ -59,11 +59,25 @@ public Kubernetes(KubernetesClientConfiguration config, params DelegatingHandler : this(handlers) { ValidateConfig(config); - CaCerts = config.SslCaCerts; - SkipTlsVerify = config.SkipTlsVerify; - InitializeFromConfig(config); + this.config = config; + InitializeFromConfig(); } + /// Gets or sets the used to map types to their Kubernetes groups, versions, and kinds. + /// The default is . + /// + /// Gets or sets the used to map types to their Kubernetes groups, version, and kinds. + public KubernetesScheme Scheme + { + get => _scheme; + set + { + if(value == null) throw new ArgumentNullException(nameof(Scheme)); + _scheme = value; + } + } + + private void ValidateConfig(KubernetesClientConfiguration config) { if (config == null) @@ -86,7 +100,7 @@ private void ValidateConfig(KubernetesClientConfiguration config) } } - private void InitializeFromConfig(KubernetesClientConfiguration config) + private void InitializeFromConfig() { if (BaseUri.Scheme == "https") { @@ -107,25 +121,25 @@ private void InitializeFromConfig(KubernetesClientConfiguration config) } else { - if (CaCerts == null) + if (config.SslCaCerts == null) { throw new KubeConfigException("A CA must be set when SkipTlsVerify === false"); } #if NET452 ((WebRequestHandler) HttpClientHandler).ServerCertificateValidationCallback = (sender, certificate, chain, sslPolicyErrors) => { - return Kubernetes.CertificateValidationCallBack(sender, CaCerts, certificate, chain, sslPolicyErrors); + return Kubernetes.CertificateValidationCallBack(sender, config.SslCaCerts, certificate, chain, sslPolicyErrors); }; #elif XAMARINIOS1_0 System.Net.ServicePointManager.ServerCertificateValidationCallback += (sender, certificate, chain, sslPolicyErrors) => { var cert = new X509Certificate2(certificate); - return Kubernetes.CertificateValidationCallBack(sender, CaCerts, cert, chain, sslPolicyErrors); + return Kubernetes.CertificateValidationCallBack(sender, config.SslCaCerts, cert, chain, sslPolicyErrors); }; #elif MONOANDROID8_1 var certList = new System.Collections.Generic.List(); - foreach (X509Certificate2 caCert in CaCerts) + foreach (X509Certificate2 caCert in config.SslCaCerts) { using (var certStream = new System.IO.MemoryStream(caCert.RawData)) { @@ -141,21 +155,17 @@ private void InitializeFromConfig(KubernetesClientConfiguration config) #else HttpClientHandler.ServerCertificateCustomValidationCallback = (sender, certificate, chain, sslPolicyErrors) => { - return Kubernetes.CertificateValidationCallBack(sender, CaCerts, certificate, chain, sslPolicyErrors); + return Kubernetes.CertificateValidationCallBack(sender, config.SslCaCerts, certificate, chain, sslPolicyErrors); }; #endif } } - // set credentails for the kubernetes client - SetCredentials(config); + // set credentials for the kubernetes client + SetCredentials(); config.AddCertificates(HttpClientHandler); } - private X509Certificate2Collection CaCerts { get; } - - private bool SkipTlsVerify { get; } - partial void CustomInitialize() { #if NET452 @@ -191,8 +201,7 @@ partial void CustomInitialize() /// /// Set credentials for the Client /// - /// k8s client configuration - private void SetCredentials(KubernetesClientConfiguration config) + private void SetCredentials() { // set the Credentails for token based auth if (!string.IsNullOrWhiteSpace(config.AccessToken)) @@ -209,6 +218,9 @@ private void SetCredentials(KubernetesClientConfiguration config) } } + internal readonly KubernetesClientConfiguration config; + private KubernetesScheme _scheme = KubernetesScheme.Default; + /// /// SSl Cert Validation Callback /// @@ -264,5 +276,16 @@ public static bool CertificateValidationCallBack( // In all other cases, return false. return false; } + + /// Creates the JSON serializer settings used for serializing request bodies and deserializing responses. + public static JsonSerializerSettings CreateSerializerSettings() + { + var settings = new JsonSerializerSettings() { NullValueHandling = NullValueHandling.Ignore }; + settings.Converters.Add(new Newtonsoft.Json.Converters.StringEnumConverter()); + return settings; + } + + /// Gets the used to serialize and deserialize Kubernetes objects. + internal static readonly JsonSerializerSettings DefaultJsonSettings = CreateSerializerSettings(); } } diff --git a/src/KubernetesClient/Kubernetes.Fluent.cs b/src/KubernetesClient/Kubernetes.Fluent.cs new file mode 100644 index 000000000..4b03f2e95 --- /dev/null +++ b/src/KubernetesClient/Kubernetes.Fluent.cs @@ -0,0 +1,40 @@ +using System; +using System.Net.Http; + +namespace k8s +{ + public partial class Kubernetes + { + /// Creates a new using the given + /// ( by default). + /// + public KubernetesRequest New(HttpMethod method = null) => new KubernetesRequest(this).Method(method); + + /// Creates a new using the given + /// and resource URI components. + /// + public KubernetesRequest New( + HttpMethod method, string type = null, string ns = null, string name = null, string group = null, string version = null) => + new KubernetesRequest(this).Method(method).Group(group).Version(version).Type(type).Namespace(ns).Name(name); + + /// Creates a new to access the given type of object. + public KubernetesRequest New(Type type) => new KubernetesRequest(this).GVK(type); + + /// Creates a new to access the given type of object with an optional name and namespace. + public KubernetesRequest New(HttpMethod method, Type type, string ns = null, string name = null) => + New(method).GVK(type).Namespace(ns).Name(name); + + /// Creates a new to access the given type of object with an optional name and namespace. + public KubernetesRequest New(string ns = null, string name = null) => New(null, typeof(T), ns, name); + + /// Creates a new to access the given type of object with an optional name and namespace. + public KubernetesRequest New(HttpMethod method, string ns = null, string name = null) => + New(method, typeof(T), ns, name); + + /// Creates a new to access the given object. + public KubernetesRequest New(IKubernetesObject obj, bool setBody = true) => new KubernetesRequest(this).Set(obj, setBody); + + /// Creates a new to access the given object. + public KubernetesRequest New(HttpMethod method, IKubernetesObject obj, bool setBody = true) => New(method).Set(obj, setBody); + } +} diff --git a/src/KubernetesClient/Kubernetes.WebSocket.cs b/src/KubernetesClient/Kubernetes.WebSocket.cs index 5da20c8c8..5cb4aaa14 100644 --- a/src/KubernetesClient/Kubernetes.WebSocket.cs +++ b/src/KubernetesClient/Kubernetes.WebSocket.cs @@ -277,19 +277,19 @@ public partial class Kubernetes } #if (NET452 || NETSTANDARD2_0) - if (this.CaCerts != null) + if (this.config.SslCaCerts != null) { webSocketBuilder.SetServerCertificateValidationCallback(this.ServerCertificateValidationCallback); } #endif #if NETCOREAPP2_1 - if (this.CaCerts != null) + if (this.config.SslCaCerts != null) { - webSocketBuilder.ExpectServerCertificate(this.CaCerts); + webSocketBuilder.ExpectServerCertificate(this.config.SslCaCerts); } - if (this.SkipTlsVerify) + if (this.config.SkipTlsVerify) { webSocketBuilder.SkipServerCertificateValidation(); } @@ -365,7 +365,7 @@ public partial class Kubernetes } #if (NET452 || NETSTANDARD2_0) - if (this.CaCerts != null) + if (this.config.SslCaCerts != null) { webSocketBuilder.CleanupServerCertificateValidationCallback(this.ServerCertificateValidationCallback); } @@ -377,7 +377,7 @@ public partial class Kubernetes #if (NET452 || NETSTANDARD2_0) internal bool ServerCertificateValidationCallback(object sender, X509Certificate certificate, X509Chain chain, SslPolicyErrors sslPolicyErrors) { - return Kubernetes.CertificateValidationCallBack(sender, this.CaCerts, certificate, chain, sslPolicyErrors); + return Kubernetes.CertificateValidationCallBack(sender, this.config.SslCaCerts, certificate, chain, sslPolicyErrors); } #endif } diff --git a/src/KubernetesClient/KubernetesRequest.cs b/src/KubernetesClient/KubernetesRequest.cs new file mode 100644 index 000000000..29c2dc1e8 --- /dev/null +++ b/src/KubernetesClient/KubernetesRequest.cs @@ -0,0 +1,713 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using k8s.Models; +using Newtonsoft.Json; + +namespace k8s +{ + #region KubernetesRequest + /// Represents a single request to Kubernetes. + public sealed class KubernetesRequest : ICloneable + { + /// Initializes a based on a . + public KubernetesRequest(Kubernetes client) : this(client.config, client.HttpClient, client.Scheme) { } + + /// Initializes a based on a and + /// . + /// + /// Any necessary SSL configuration must have already been applied to the . + public KubernetesRequest(KubernetesClientConfiguration config, HttpClient client, KubernetesScheme scheme = null) + { + if(config == null) throw new ArgumentNullException(nameof(config)); + if(string.IsNullOrEmpty(config.Host)) throw new ArgumentException("The kubernetes host must be provided."); + this.config = config; + this.client = client ?? throw new ArgumentNullException(nameof(client)); + Scheme(scheme); + } + + /// Gets the value of the Accept header, or null to use the default of application/json. + public string Accept() => _accept; + + /// Sets the value of the Accept header, or null or empty to use the default of application/json. + public KubernetesRequest Accept(string mediaType) { _accept = NormalizeEmpty(mediaType); return this; } + + /// Adds a query-string parameter to the request. Multiple headers with the same name can be set this way. + public KubernetesRequest AddHeader(string key, string value) => Add(ref headers, key, value); + + /// Adds a query-string parameter to the request. Multiple parameters with the same name can be set this way. + public KubernetesRequest AddQuery(string key, string value) => Add(ref query, key, value); + + /// Gets the body to be sent to the server. + public object Body() => _body; + + /// Sets the body to be sent to the server. If null, no body will be sent. If a string, byte array, or stream, the + /// contents will be sent directly. Otherwise, the body will be serialized into JSON and sent. + /// + public KubernetesRequest Body(object body) { _body = body; return this; } + + /// Clears all custom header values. + public KubernetesRequest ClearHeaders() + { + if(headers != null) headers.Clear(); + return this; + } + + /// Clears custom header values with the given name. + public KubernetesRequest ClearHeaders(string headerName) + { + if(headerName == null) throw new ArgumentNullException(nameof(headerName)); + if(headers != null) headers.Remove(headerName); + return this; + } + + /// Clears all query-string parameters. + public KubernetesRequest ClearQuery() + { + if(query != null) query.Clear(); + return this; + } + + /// Clears all query-string parameters with the given key. + public KubernetesRequest ClearQuery(string key) + { + if(key == null) throw new ArgumentNullException(nameof(key)); + if(query != null) query.Remove(key); + return this; + } + + /// Creates a deep copy of the . + public KubernetesRequest Clone() + { + var clone = (KubernetesRequest)MemberwiseClone(); + if(headers != null) + { + clone.headers = new Dictionary>(headers.Count); + foreach(KeyValuePair> pair in headers) clone.headers.Add(pair.Key, new List(pair.Value)); + } + if(query != null) + { + clone.query = new Dictionary>(query.Count); + foreach(KeyValuePair> pair in query) clone.query.Add(pair.Key, new List(pair.Value)); + } + return clone; + } + + /// Sets the to . + public KubernetesRequest Delete() => Method(HttpMethod.Delete); + + /// Sets the to . + public KubernetesRequest Get() => Method(HttpMethod.Get); + + /// Sets the to . + public KubernetesRequest Patch() => Method(new HttpMethod("PATCH")); + + /// Sets the to . + public KubernetesRequest Post() => Method(HttpMethod.Post); + + /// Sets the to . + public KubernetesRequest Put() => Method(HttpMethod.Put); + + /// Sets the value of the "dryRun" query-string parameter, as a boolean. + public bool DryRun() => string.IsNullOrEmpty(GetQuery("dryRun")); + + /// Sets the value of the "dryRun" query-string parameter to "All" or removes it. + public KubernetesRequest DryRun(bool dryRun) => SetQuery("dryRun", dryRun ? "All" : null); + + /// Executes the request and returns a . The request can be executed multiple times, + /// and can be executed multiple times in parallel. + /// + public async Task ExecuteAsync(CancellationToken cancelToken = default) + { + cancelToken.ThrowIfCancellationRequested(); + HttpRequestMessage req = CreateRequestMessage(); + // requests like watches may not send a body immediately, so return as soon as we've got the response headers + var completion = _streamResponse || _watchVersion != null ? + HttpCompletionOption.ResponseHeadersRead : HttpCompletionOption.ResponseContentRead; + return new KubernetesResponse(await client.SendAsync(req, completion, cancelToken).ConfigureAwait(false)); + } + + /// Executes the request and returns the deserialized response body (or the default value of type + /// if the response was 404 Not Found). + /// + /// Thrown if the response was any error besides 404 Not Found. + public Task ExecuteAsync(CancellationToken cancelToken = default) => ExecuteAsync(false, cancelToken); + + /// Executes the request and returns the deserialized response body. + /// If true and the response is 404 Not Found, an exception will be thrown. If false, the default + /// value of type will be returned in that case. The default is false. + /// + /// A that can be used to cancel the request + /// Thrown if the response was any error besides 404 Not Found. + public Task ExecuteAsync(bool failIfMissing, CancellationToken cancelToken = default) + { + if(_watchVersion != null) throw new InvalidOperationException("Watch requests cannot be deserialized all at once."); + cancelToken.ThrowIfCancellationRequested(); + return ExecuteMessageAsync(CreateRequestMessage(), failIfMissing, cancelToken); + } + + /// Gets the "fieldManager" query-string parameter, or null if there is no field manager. + public string FieldManager() => NormalizeEmpty(GetQuery("fieldManager")); + + /// Sets the "fieldManager" query-string parameter, or removes it if the value is null or empty. + public KubernetesRequest FieldManager(string manager) => + SetQuery("fieldManager", !string.IsNullOrEmpty(manager) ? manager : null); + + /// Gets the "fieldSelector" query-string parameter, or null if there is no field selector. + public string FieldSelector() => NormalizeEmpty(GetQuery("fieldSelector")); + + /// Sets the "fieldSelector" query-string parameter, or removes it if the selector is null or empty. + public KubernetesRequest FieldSelector(string selector) => + SetQuery("fieldSelector", !string.IsNullOrEmpty(selector) ? selector : null); + + /// Gets the value of the named custom header, or null if it doesn't exist. + /// Thrown if there are multiple custom headers with the given name + public string GetHeader(string key) + { + List values = null; + if(headers != null) headers.TryGetValue(key, out values); + return values == null || values.Count == 0 ? null : values.Count == 1 ? values[0] : + throw new InvalidOperationException($"There are multiple query-string parameters named '{key}'."); + } + + /// Gets the values of the named custom header, or null if it has no values. + /// The returned collection, if not null, can be mutated to change the set of values. + public List GetHeaderValues(string key) + { + List values = null; + if(headers != null) headers.TryGetValue(key, out values); + return values; + } + + /// Gets the value of the named query-string parameter, or null if it doesn't exist. + /// Thrown if there are multiple query-string parameters with the given name + public string GetQuery(string key) + { + List values = GetQueryValues(key); + return values == null || values.Count == 0 ? null : values.Count == 1 ? values[0] : + throw new InvalidOperationException($"There are multiple query-string parameters named '{key}'."); + } + + /// Gets the values of the named query-string parameter, or null if it has no values. + /// The returned collection, if not null, can be mutated to change the set of values. + public List GetQueryValues(string key) + { + List values = null; + if(query != null) query.TryGetValue(key, out values); + return values; + } + + /// Gets the Kubernetes API group to use, or null or empty to use the default, which is the core API group + /// unless a is given. + /// + public string Group() => _group; + + /// Sets the Kubernetes API group to use, or null or empty to use the default, which is the core API group + /// unless a is given. + /// + public KubernetesRequest Group(string group) { _group = NormalizeEmpty(group); return this; } + + /// Attempts to set the , , and based on an object. + /// The method calls with the object's type. Then, if + /// is set, it will override and . + /// + public KubernetesRequest GVK(IKubernetesObject obj) + { + if(obj == null) throw new ArgumentNullException(); + GVK(obj.GetType()); + if(!string.IsNullOrEmpty(obj.ApiVersion)) // if the object has an API version set, use it... + { + int slash = obj.ApiVersion.IndexOf('/'); // the ApiVersion field is in the form "version" or "group/version" + Group(slash >= 0 ? obj.ApiVersion.Substring(0, slash) : null).Version(obj.ApiVersion.Substring(slash+1)); + } + return this; + } + + /// Attempts to set the , , and based on a Kubernetes + /// group, version, and kind. The method uses heuristics and may not work in all cases. + /// + public KubernetesRequest GVK(string group, string version, string kind) => + Group(!string.IsNullOrEmpty(group) ? group : null).Version(!string.IsNullOrEmpty(version) ? version : null) + .Type(KubernetesScheme.GuessPath(kind)); + + /// Attempts to set the , , and based on a type of object, + /// such as . + /// + public KubernetesRequest GVK(Type type) + { + if(type == null) throw new ArgumentNullException(nameof(type)); + _scheme.GetGVK(type, out string group, out string version, out string kind, out string path); + return Group(NormalizeEmpty(group)).Version(version).Type(path); + } + + /// Attempts to set the , , and based on a type of object, + /// such as . + /// + public KubernetesRequest GVK() => GVK(typeof(T)); + + /// Gets the "labelSelector" query-string parameter, or null if there is no label selector. + public string LabelSelector() => NormalizeEmpty(GetQuery("labelSelector")); + + /// Sets the "labelSelector" query-string parameter, or removes it if the selecor is null or empty. + public KubernetesRequest LabelSelector(string selector) => + SetQuery("labelSelector", !string.IsNullOrEmpty(selector) ? selector : null); + + /// Gets the value of the Content-Type header, or null to use the default of application/json. + public string MediaType() => _mediaType; + + /// Sets the value of the Content-Type header, not including any parameters, or null or empty to use the default + /// of application/json. The header value will only be used if a is supplied. + /// + public KubernetesRequest MediaType(string mediaType) { _mediaType = NormalizeEmpty(mediaType); return this; } + + /// Gets the to use. + public HttpMethod Method() => _method ?? HttpMethod.Get; + + /// Sets the to use, or null to use the default of . + public KubernetesRequest Method(HttpMethod method) { _method = method; return this; } + + /// Gets the name of the top-level Kubernetes resource to access. + public string Name() => _name; + + /// Sets the name of the top-level Kubernetes resource to access, or null or empty to not access a specific object. + public KubernetesRequest Name(string name) { _name = name; return this; } + + /// Gets the Kubernetes namespace to access. + public string Namespace() => _ns; + + /// Sets the Kubernetes namespace to access, or null or empty to not access a namespaced object. + public KubernetesRequest Namespace(string ns) { _ns = ns; return this; } + + /// Gets the raw URL to access, relative to the configured Kubernetes host and not including the query string, or + /// null if the URL will be constructed piecemeal based on the other properties. + /// + public string RawUri() => _rawUri; + + /// Sets the raw URL to access, relative to the configured Kubernetes host and not including the query string, or + /// null or empty to construct the URI piecemeal based on the other properties. + /// + public KubernetesRequest RawUri(string uri) + { + uri = NormalizeEmpty(uri); + if(uri != null && uri[0] != '/') throw new ArgumentException("The URI must begin with a slash."); + _rawUri = uri; + return this; + } + + /// Performs an atomic get-modify-replace operation, using the GET method to read the object and the PUT method to + /// replace it. + /// + /// A function that modifies the resource, returning true if any changes were made and false if not + /// If true, an exception will be thrown if the object doesn't exist. If false, null will be + /// returned in that case. + /// + /// A that can be used to cancel the request + public Task ReplaceAsync(Func update, bool failIfMissing = false, CancellationToken cancelToken = default) + where T : class, IMetadata => ReplaceAsync(null, update, failIfMissing, cancelToken); + + /// Performs an atomic get-modify-replace operation, using the GET method to read the object and the PUT method to + /// replace it. + /// + /// A function that modifies the resource, returning true if any changes were made and false if not + /// If true, an exception will be thrown if the object doesn't exist. If false, null will be + /// returned in that case. + /// + /// A that can be used to cancel the request + public Task ReplaceAsync( + Func> update, bool failIfMissing = false, CancellationToken cancelToken = default) + where T : class, IMetadata => ReplaceAsync(null, update, failIfMissing, cancelToken); + + /// Performs an atomic get-modify-replace operation, using the GET method to read the object and the PUT method to + /// replace it. + /// + /// The initial value of the resource, or null if it should be retrieved with a GET request + /// A function that modifies the resource, returning true if any changes were made and false if not + /// If true, an exception will be thrown if the object doesn't exist. If false, null will be + /// returned in that case. + /// + /// A that can be used to cancel the request + public Task ReplaceAsync(T obj, Func modify, bool failIfMissing = false, CancellationToken cancelToken = default) + where T : class + { + if(modify == null) throw new ArgumentNullException(nameof(modify)); + return ReplaceAsync(obj, (o,ct) => Task.FromResult(modify(o)), failIfMissing, cancelToken); + } + + /// Performs an atomic get-modify-replace operation, using the GET method to read the object and the PUT method to + /// replace it. + /// + /// The initial value of the resource, or null if it should be retrieved with a GET request + /// A function that modifies the resource, returning true if any changes were made and false if not + /// If true, an exception will be thrown if the object doesn't exist. If false, null will be + /// returned in that case. + /// + /// A that can be used to cancel the request + public async Task ReplaceAsync( + T obj, Func> modify, bool failIfMissing = false, CancellationToken cancelToken = default) + where T : class + { + if(modify == null) throw new ArgumentNullException(nameof(modify)); + if(_watchVersion != null) throw new InvalidOperationException("Watches cannot be updated."); + while(true) + { + if(obj == null) // if we need to load the resource... + { + cancelToken.ThrowIfCancellationRequested(); + HttpRequestMessage getMsg = CreateRequestMessage(); // load it with a GET request + getMsg.Method = HttpMethod.Get; + obj = await ExecuteMessageAsync(getMsg, failIfMissing, cancelToken).ConfigureAwait(false); + } + cancelToken.ThrowIfCancellationRequested(); + // if the resource is missing or no changes are needed, return it as-is + if(obj == null || !await modify(obj, cancelToken).ConfigureAwait(false)) return obj; + HttpRequestMessage updateMsg = CreateRequestMessage(); // otherwise, update it with a PUT request + updateMsg.Method = HttpMethod.Put; + KubernetesResponse resp = new KubernetesResponse(await client.SendAsync(updateMsg, cancelToken).ConfigureAwait(false)); + if(resp.StatusCode != HttpStatusCode.Conflict) // if there was no conflict, return the result + { + if(resp.IsNotFound && !failIfMissing) return null; + else if(resp.IsError) throw new KubernetesException(await resp.GetErrorAsync().ConfigureAwait(false)); + else return await resp.GetBodyAsync().ConfigureAwait(false); + } + obj = null; // otherwise, there was a conflict, so reload the item + } + } + + /// Gets the used to map types to their Kubernetes groups, version, and kinds. + public KubernetesScheme Scheme() => _scheme; + + /// Sets the used to map types to their Kubernetes groups, version, and kinds, or null to + /// use the scheme. + /// + public KubernetesRequest Scheme(KubernetesScheme scheme) { _scheme = scheme ?? KubernetesScheme.Default; return this; } + + /// Attempts to set the , , , , + /// , and optionally the based on the given object. + /// + /// If the object implements of , it will be used to set the + /// and . The will be set if + /// is set (on the assumption that you're accessing an existing object), and cleared it's clear (on the assumption that you're + /// creating a new object and want to POST to its container). + /// + public KubernetesRequest Set(IKubernetesObject obj, bool setBody = true) + { + GVK(obj); + if(setBody) Body(obj); + var kobj = obj as IMetadata; + if(kobj != null) Namespace(kobj.Namespace()).Name(!string.IsNullOrEmpty(kobj.Uid()) ? kobj.Name() : null); + return this; + } + + /// Sets a custom header value, or deletes it if the value is null. + public KubernetesRequest SetHeader(string headerName, string value) + { + if(headerName == "Accept" || headerName == "Content-Type") + { + throw new ArgumentException("The header must be set using the corresponding property."); + } + return Set(ref headers, headerName, value); + } + + /// Sets a query-string value, or deletes it if the value is null. + public KubernetesRequest SetQuery(string key, string value) => Set(ref query, key, value); + + /// Sets the to "status", to get or set a resource's status. + public KubernetesRequest Status() => Subresource("status"); + + /// Gets whether the response must be streamed. If true, the response will be returned from + /// as soon as the headers are read and you will have to dispose the response. Otherwise, the entire response will be downloaded + /// before returns, and you will not have to dispose it. Note that regardless of the + /// value of this property, the response is always streamed when is not null. + /// + public bool StreamResponse() => _streamResponse; + + /// Sets whether the response must be streamed. If true, the response will be returned from + /// as soon as the headers are read and you will have to dispose the response. Otherwise, the entire response will be downloaded + /// before returns, and you will not have to dispose it. The default is false. Note that regardless of the + /// value of this property, the response is always streamed when is not null. + /// + public KubernetesRequest StreamResponse(bool stream) { _streamResponse = stream; return this; } + + /// Gets the URL-encoded subresource to access, or null to not access a subresource. + public string Subresource() => _subresource; + + /// Sets the subresource to access, or null or empty to not access a subresource. The value must be URL-encoded + /// already if necessary. + /// + public KubernetesRequest Subresource(string subresource) { _subresource = NormalizeEmpty(subresource); return this; } + + /// Sets the value of the by joining together one or more path segments. The + /// segments will be URL-escaped (and so should not be URL-escaped already). + /// + public KubernetesRequest Subresources(params string[] subresources) => + Subresource(subresources != null && subresources.Length != 0 ? + string.Join("/", subresources.Select(Uri.EscapeDataString)) : null); + + /// + public override string ToString() => Method().Method + " " + GetRequestUri(); + + /// Gets the resource type access (e.g. "pods"). + public string Type() => _type; + + /// Sets the resource type access (e.g. "pods"). + public KubernetesRequest Type(string type) { _type = NormalizeEmpty(type); return this; } + + /// Gets the Kubernetes API version to use, or null to use the default, which is "v1" + /// unless a is given. + /// + public string Version() => _version; + + /// Sets the Kubernetes API version to use, or null or empty to use the default, which is "v1" + /// unless a is given. + /// + public KubernetesRequest Version(string version) { _version = NormalizeEmpty(version); return this; } + + /// Gets the resource version to use when watching a resource, or empty to watch the current version, or null + /// to not execute a watch. + /// + public string WatchVersion() => _watchVersion; + + /// Sets the resource version to use when watching a resource, or empty to watch the current version, or null to not + /// execute a watch. The default is null. When set, the response is always streamed (as though + /// was true). + /// + public KubernetesRequest WatchVersion(string resourceVersion) { _watchVersion = resourceVersion; return this; } + + /// Adds a value to the query string or headers. + KubernetesRequest Add(ref Dictionary> dict, string key, string value) + { + if(string.IsNullOrEmpty(key)) throw new ArgumentNullException(nameof(key)); + if(dict == null) dict = new Dictionary>(); + if(!dict.TryGetValue(key, out List values)) dict[key] = values = new List(); + values.Add(value); + return this; + } + + /// Sets a value in the query string or headers. + KubernetesRequest Set(ref Dictionary> dict, string key, string value) + { + if(string.IsNullOrEmpty(key)) throw new ArgumentNullException(nameof(key)); + dict = dict ?? new Dictionary>(); + if(!dict.TryGetValue(key, out List values)) dict[key] = values = new List(); + values.Clear(); + values.Add(value); + return this; + } + + /// Creates an representing the current request. + HttpRequestMessage CreateRequestMessage() + { + var req = new HttpRequestMessage(Method(), GetRequestUri()); + + // add the headers + if(!string.IsNullOrEmpty(config.AccessToken)) + { + req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", config.AccessToken); + } + else if(!string.IsNullOrEmpty(config.Username)) + { + req.Headers.Authorization = new AuthenticationHeaderValue("Basic", + Convert.ToBase64String(Encoding.UTF8.GetBytes(config.Username + ":" + config.Password))); + } + + if(_accept != null) req.Headers.Add("Accept", _accept); + List>> contentHeaders = null; + if(headers != null && headers.Count != 0) // add custom headers + { + contentHeaders = new List>>(); // some headers must be added to .Content.Headers. track them + foreach(KeyValuePair> pair in headers) + { + if(!req.Headers.TryAddWithoutValidation(pair.Key, pair.Value)) // if it's not legal to set this header on the request... + { + contentHeaders.Add(new KeyValuePair>(pair.Key, pair.Value)); // assume we should set it on the content + break; + } + } + } + + // add the body, if any + if(_body != null) + { + if(_body is byte[] bytes) req.Content = new ByteArrayContent(bytes); + else if(_body is Stream stream) req.Content = new StreamContent(stream); + else + { + req.Content = new StringContent( + _body as string ?? JsonConvert.SerializeObject(_body, Kubernetes.DefaultJsonSettings), Encoding.UTF8); + } + req.Content.Headers.ContentType = new MediaTypeHeaderValue(_mediaType ?? "application/json") { CharSet = "UTF-8" }; + if(contentHeaders != null && contentHeaders.Count != 0) // go through the headers we couldn't set on the request + { + foreach(KeyValuePair> pair in contentHeaders) + { + if(!req.Content.Headers.TryAddWithoutValidation(pair.Key, pair.Value)) // if we can't set it on the content either... + { + throw new InvalidOperationException($"{pair.Value} is a response header and cannot be set on the request."); + } + } + } + } + return req; + } + + async Task ExecuteMessageAsync(HttpRequestMessage msg, bool failIfMissing, CancellationToken cancelToken) + { + cancelToken.ThrowIfCancellationRequested(); + KubernetesResponse resp = new KubernetesResponse(await client.SendAsync(msg, cancelToken).ConfigureAwait(false)); + if(resp.IsNotFound && !failIfMissing) return default(T); + else if(resp.IsError) throw new KubernetesException(await resp.GetErrorAsync().ConfigureAwait(false)); + else return await resp.GetBodyAsync().ConfigureAwait(false); + } + + string GetRequestUri() + { + if(_rawUri != null && (_group ?? _name ?? _ns ?? _subresource ?? _type ?? _version) != null) + { + throw new InvalidOperationException("You cannot use both raw and piecemeal URIs."); + } + + // construct the request URL + var sb = new StringBuilder(); + sb.Append(config.Host); + if(sb[sb.Length-1] != '/') sb.Append('/'); + if(_rawUri != null) // if a raw URL was given, use it + { + sb.Append(_rawUri); + } + else // otherwise, construct it piecemeal + { + if(_group != null) sb.Append("apis/").Append(_group); + else sb.Append("api"); + sb.Append('/').Append(_version ?? "v1"); + if(_ns != null) sb.Append("/namespaces/").Append(_ns); + sb.Append('/').Append(_type); + if(_name != null) sb.Append('/').Append(_name); + if(_subresource != null) sb.Append('/').Append(_subresource); + } + if(query != null) // then add the query string, if any + { + bool first = true; + foreach(KeyValuePair> pair in query) + { + string key = Uri.EscapeDataString(pair.Key); + foreach(string value in pair.Value) + { + sb.Append(first ? '?' : '&').Append(key).Append('='); + if(!string.IsNullOrEmpty(value)) sb.Append(Uri.EscapeDataString(value)); + first = false; + } + } + if(_watchVersion != null) + { + sb.Append(first ? '?' : '&').Append("watch=1"); + if(_watchVersion.Length != 0) sb.Append("&resourceVersion=").Append(_watchVersion); + } + } + return sb.ToString(); + } + + object ICloneable.Clone() => Clone(); + + readonly HttpClient client; + readonly KubernetesClientConfiguration config; + Dictionary> headers, query; + string _accept = "application/json", _mediaType = "application/json"; + string _group, _name, _ns, _rawUri, _subresource, _type, _version, _watchVersion; + object _body; + HttpMethod _method; + KubernetesScheme _scheme; + bool _streamResponse; + + static string NormalizeEmpty(string value) => string.IsNullOrEmpty(value) ? null : value; // normalizes empty strings to null + } + #endregion + + #region KubernetesResponse + /// Represents a response to a . + public sealed class KubernetesResponse : IDisposable + { + /// Initializes a new from an . + public KubernetesResponse(HttpResponseMessage message) => Message = message ?? throw new ArgumentNullException(nameof(message)); + + /// Indicates whether the server returned an error response. + public bool IsError => (int)StatusCode >= 400; + + /// Indicates whether the server returned a 404 Not Found response. + public bool IsNotFound => StatusCode == HttpStatusCode.NotFound; + + /// Gets the underlying . + public HttpResponseMessage Message { get; } + + /// Gets the of the response. + public HttpStatusCode StatusCode => Message.StatusCode; + + /// + public void Dispose() => Message.Dispose(); + + /// Deserializes the response body as a object, or creates one from the status code if the + /// response body is not a JSON object. + /// + public async Task GetErrorAsync() + { + try { return await GetBodyAsync().ConfigureAwait(false); } + catch(JsonException) { } + return new V1Status() + { + Status = IsError ? "Failure" : "Success", Code = (int)StatusCode, Reason = StatusCode.ToString(), Message = body + }; + } + + /// Returns the response body as a string. + public async Task GetBodyAsync() + { + if(body == null) + { + body = Message.Content != null ? await Message.Content.ReadAsStringAsync().ConfigureAwait(false) : string.Empty; + } + return body; + } + + /// Deserializes the response body from JSON as a value of the given type, or null if the response body is empty. + /// The type of object to return + /// If false, an empty response body will be returned as null. If true, an exception will be thrown if + /// the body is empty. The default is false. + /// + public async Task GetBodyAsync(Type type, bool failIfEmpty = false) + { + string body = await GetBodyAsync().ConfigureAwait(false); + if(string.IsNullOrWhiteSpace(body)) + { + if(!failIfEmpty) throw new InvalidOperationException("The response body was empty."); + return null; + } + return JsonConvert.DeserializeObject(body, type, Kubernetes.DefaultJsonSettings); + } + + /// Deserializes the response body from JSON as a value of type , or the default value of + /// type if the response body is empty. + /// + /// If false, an empty response body will be returned as the default value of type + /// . If true, an exception will be thrown if the body is empty. The default is false. + /// + public async Task GetBodyAsync(bool failIfEmpty = false) + { + string body = await GetBodyAsync().ConfigureAwait(false); + if(string.IsNullOrWhiteSpace(body)) + { + if(failIfEmpty) throw new InvalidOperationException("The response body was empty."); + return default(T); + } + return JsonConvert.DeserializeObject(body, Kubernetes.DefaultJsonSettings); + } + + string body; + } + #endregion +} diff --git a/src/KubernetesClient/Scheme.cs b/src/KubernetesClient/Scheme.cs new file mode 100644 index 000000000..7ce94318c --- /dev/null +++ b/src/KubernetesClient/Scheme.cs @@ -0,0 +1,173 @@ +using System; +using System.Collections.Generic; +using System.Reflection; + +namespace k8s +{ + /// Represents a map between object types and Kubernetes group, version, and kind triplets. + public sealed class KubernetesScheme + { + /// Gets the Kubernetes group, version, and kind for the given type of object. + public void GetGVK(Type type, out string group, out string version, out string kind) => + GetGVK(type, out group, out version, out kind, out string path); + + /// Gets the Kubernetes group, version, kind, and API path segment for the given type of object. + public void GetGVK(Type type, out string group, out string version, out string kind, out string path) + { + if(!TryGetGVK(type, out group, out version, out kind, out path)) + { + throw new ArgumentException($"The GVK of type {type.Name} is unknown."); + } + } + + /// Gets the Kubernetes group, version, and kind for the given type of object. + public void GetGVK(out string group, out string version, out string kind) => + GetGVK(typeof(T), out group, out version, out kind, out string path); + + /// Gets the Kubernetes group, version, kind, and API path segment for the given type of object. + public void GetGVK(out string group, out string version, out string kind, out string path) => + GetGVK(typeof(T), out group, out version, out kind, out path); + + /// Gets the Kubernetes API version (including the group, if any), and kind for the given type of object. + public void GetVK(Type type, out string apiVersion, out string kind) => GetVK(type, out apiVersion, out kind, out string path); + + /// Gets the Kubernetes API version (including the group, if any), kind, and API path segment for the given type of + /// object. + /// + public void GetVK(Type type, out string apiVersion, out string kind, out string path) + { + string group, version; + GetGVK(type, out group, out version, out kind, out path); + apiVersion = string.IsNullOrEmpty(group) ? version : group + "/" + version; + } + + /// Gets the Kubernetes API version (including the group, if any) and kind for the given type of object. + public void GetVK(out string apiVersion, out string kind) => GetVK(typeof(T), out apiVersion, out kind, out string path); + + /// Gets the Kubernetes API version (including the group, if any), kind, and API path segment for the given type of + /// object. + /// + public void GetVK(out string apiVersion, out string kind, out string path) => + GetVK(typeof(T), out apiVersion, out kind, out path); + + /// Removes GVK information about the given type of object. + public void RemoveGVK(Type type) + { + lock(gvks) gvks.Remove(type); + } + + /// Removes GVK information about the given type of object. + public void RemoveGVK() => RemoveGVK(typeof(T)); + + /// Sets GVK information for the given type of object. + public void SetGVK(Type type, string group, string version, string kind, string path) + { + if(type == null) throw new ArgumentNullException(nameof(type)); + if(string.IsNullOrEmpty(version)) throw new ArgumentNullException(nameof(version)); + if(string.IsNullOrEmpty(kind)) throw new ArgumentNullException(nameof(kind)); + if(string.IsNullOrEmpty(path)) throw new ArgumentNullException(nameof(path)); + lock(gvks) gvks[type] = Tuple.Create(group ?? string.Empty, version, kind, path); + } + + /// Sets GVK information for the given type of object. + public void SetGVK(string group, string version, string kind, string path) => + SetGVK(typeof(T), group, version, kind, path); + + /// Gets the Kubernetes group, version, and kind for the given type of object. + public bool TryGetGVK(Type type, out string group, out string version, out string kind) + => TryGetGVK(type, out group, out version, out kind, out string path); + + /// Gets the Kubernetes group, version, kind, and API path segment for the given type of object. + public bool TryGetGVK(Type type, out string group, out string version, out string kind, out string path) + { + if(type == null) throw new ArgumentNullException(nameof(type)); + lock(gvks) + { + if(!gvks.TryGetValue(type, out Tuple gvk)) + { + var attr = type.GetCustomAttribute(); // newer types have this attribute + if(attr != null) + { + gvk = Tuple.Create(attr.Group, attr.ApiVersion, attr.Kind, attr.PluralName); + } + else // some older types (and ours) just have static/const fields + { + const BindingFlags Flags = BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Static; + FieldInfo kindf = type.GetField("KubeKind", Flags), versionf = type.GetField("KubeApiVersion", Flags); + if(kindf != null && versionf != null) + { + FieldInfo groupf = type.GetField("KubeGroup", Flags); + string k = (string)kindf.GetValue(null); + gvk = Tuple.Create( + (string)groupf?.GetValue(null) ?? string.Empty, (string)versionf.GetValue(null), k, GuessPath(k)); + } + } + gvks[type] = gvk; + } + + if(gvk != null) + { + (group, version, kind, path) = gvk; + return true; + } + else + { + group = version = kind = path = null; + return false; + } + } + } + + /// Gets the Kubernetes group, version, and kind for the given type of object. + public bool TryGetGVK(out string group, out string version, out string kind) => + TryGetGVK(typeof(T), out group, out version, out kind, out string path); + + /// Gets the Kubernetes group, version, kind, and API path segment for the given type of object. + public bool TryGetGVK(out string group, out string version, out string kind, out string path) => + TryGetGVK(typeof(T), out group, out version, out kind, out path); + + /// Gets the Kubernetes API version (including the group, if any) and kind for the given type of object. + public bool TryGetVK(Type type, out string apiVersion, out string kind) => + TryGetVK(type, out apiVersion, out kind, out string path); + + /// Gets the Kubernetes API version (including the group, if any), kind, and API path segment for the given type of + /// object. + /// + public bool TryGetVK(Type type, out string apiVersion, out string kind, out string path) + { + if(TryGetGVK(type, out string group, out string version, out kind, out path)) + { + apiVersion = string.IsNullOrEmpty(group) ? version : group + "/" + version; + return true; + } + else + { + apiVersion = null; + return false; + } + } + + /// Gets the Kubernetes API version (including the group, if any) and kind for the given type of object. + public bool TryGetVK(out string apiVersion, out string kind) => TryGetVK(typeof(T), out apiVersion, out kind, out string path); + + /// Gets the Kubernetes API version (including the group, if any), kind, and API path segment for the given type of + /// object. + /// + public bool TryGetVK(out string apiVersion, out string kind, out string path) => + TryGetVK(typeof(T), out apiVersion, out kind, out path); + + /// Gets the default . + public static readonly KubernetesScheme Default = new KubernetesScheme(); + + /// Attempts to guess a type's API path segment based on its kind. + internal static string GuessPath(string kind) + { + if(string.IsNullOrEmpty(kind)) return null; + if(kind.Length > 4 && kind.EndsWith("List")) kind = kind.Substring(0, kind.Length-4); // e.g. PodList -> Pod + kind = kind.ToLowerInvariant(); // e.g. Pod -> pod + return kind + (kind[kind.Length-1] == 's' ? "es" : "s"); // e.g. pod -> pods + } + + readonly Dictionary> gvks = new Dictionary>(); + } +} From 861c3415dece191b9395975fa554a06d77d93a5c Mon Sep 17 00:00:00 2001 From: Adam Milazzo Date: Wed, 8 Apr 2020 12:25:14 -0700 Subject: [PATCH 06/13] Fix and add unit tests --- src/KubernetesClient/Kubernetes.ConfigInit.cs | 2 +- src/KubernetesClient/Kubernetes.Fluent.cs | 7 - src/KubernetesClient/Kubernetes.WebSocket.cs | 10 +- src/KubernetesClient/KubernetesRequest.cs | 174 ++++++++++++------ 4 files changed, 121 insertions(+), 72 deletions(-) diff --git a/src/KubernetesClient/Kubernetes.ConfigInit.cs b/src/KubernetesClient/Kubernetes.ConfigInit.cs index 4d7e04911..7bf4221b9 100644 --- a/src/KubernetesClient/Kubernetes.ConfigInit.cs +++ b/src/KubernetesClient/Kubernetes.ConfigInit.cs @@ -199,7 +199,7 @@ partial void CustomInitialize() } /// - /// Set credentials for the Client + /// Set credentials for the Client based on the config /// private void SetCredentials() { diff --git a/src/KubernetesClient/Kubernetes.Fluent.cs b/src/KubernetesClient/Kubernetes.Fluent.cs index 4b03f2e95..a921a37e6 100644 --- a/src/KubernetesClient/Kubernetes.Fluent.cs +++ b/src/KubernetesClient/Kubernetes.Fluent.cs @@ -27,14 +27,7 @@ public KubernetesRequest New(HttpMethod method, Type type, string ns = null, str /// Creates a new to access the given type of object with an optional name and namespace. public KubernetesRequest New(string ns = null, string name = null) => New(null, typeof(T), ns, name); - /// Creates a new to access the given type of object with an optional name and namespace. - public KubernetesRequest New(HttpMethod method, string ns = null, string name = null) => - New(method, typeof(T), ns, name); - /// Creates a new to access the given object. public KubernetesRequest New(IKubernetesObject obj, bool setBody = true) => new KubernetesRequest(this).Set(obj, setBody); - - /// Creates a new to access the given object. - public KubernetesRequest New(HttpMethod method, IKubernetesObject obj, bool setBody = true) => New(method).Set(obj, setBody); } } diff --git a/src/KubernetesClient/Kubernetes.WebSocket.cs b/src/KubernetesClient/Kubernetes.WebSocket.cs index 5cb4aaa14..275e6db55 100644 --- a/src/KubernetesClient/Kubernetes.WebSocket.cs +++ b/src/KubernetesClient/Kubernetes.WebSocket.cs @@ -277,19 +277,19 @@ public partial class Kubernetes } #if (NET452 || NETSTANDARD2_0) - if (this.config.SslCaCerts != null) + if (this.config?.SslCaCerts != null) { webSocketBuilder.SetServerCertificateValidationCallback(this.ServerCertificateValidationCallback); } #endif #if NETCOREAPP2_1 - if (this.config.SslCaCerts != null) + if (this.config?.SslCaCerts != null) { webSocketBuilder.ExpectServerCertificate(this.config.SslCaCerts); } - if (this.config.SkipTlsVerify) + if (this.config?.SkipTlsVerify == true) { webSocketBuilder.SkipServerCertificateValidation(); } @@ -365,7 +365,7 @@ public partial class Kubernetes } #if (NET452 || NETSTANDARD2_0) - if (this.config.SslCaCerts != null) + if (this.config?.SslCaCerts != null) { webSocketBuilder.CleanupServerCertificateValidationCallback(this.ServerCertificateValidationCallback); } @@ -377,7 +377,7 @@ public partial class Kubernetes #if (NET452 || NETSTANDARD2_0) internal bool ServerCertificateValidationCallback(object sender, X509Certificate certificate, X509Chain chain, SslPolicyErrors sslPolicyErrors) { - return Kubernetes.CertificateValidationCallBack(sender, this.config.SslCaCerts, certificate, chain, sslPolicyErrors); + return Kubernetes.CertificateValidationCallBack(sender, this.config?.SslCaCerts, certificate, chain, sslPolicyErrors); } #endif } diff --git a/src/KubernetesClient/KubernetesRequest.cs b/src/KubernetesClient/KubernetesRequest.cs index 29c2dc1e8..35ca368a9 100644 --- a/src/KubernetesClient/KubernetesRequest.cs +++ b/src/KubernetesClient/KubernetesRequest.cs @@ -18,18 +18,40 @@ namespace k8s public sealed class KubernetesRequest : ICloneable { /// Initializes a based on a . - public KubernetesRequest(Kubernetes client) : this(client.config, client.HttpClient, client.Scheme) { } + public KubernetesRequest(Kubernetes client) + { + if(client == null) throw new ArgumentNullException(nameof(client)); + (baseUri, config, this.client) = (client.BaseUri.ToString(), client.config, client.HttpClient); + Scheme(client.Scheme); + } /// Initializes a based on a and - /// . + /// an . /// + /// The used to connect to Kubernetes + /// The used to make the request, or null to use the default client + /// The used to map types to their Kubernetes group, version, and kind /// Any necessary SSL configuration must have already been applied to the . - public KubernetesRequest(KubernetesClientConfiguration config, HttpClient client, KubernetesScheme scheme = null) + public KubernetesRequest(KubernetesClientConfiguration config, HttpClient client = null, KubernetesScheme scheme = null) { - if(config == null) throw new ArgumentNullException(nameof(config)); - if(string.IsNullOrEmpty(config.Host)) throw new ArgumentException("The kubernetes host must be provided."); - this.config = config; - this.client = client ?? throw new ArgumentNullException(nameof(client)); + this.config = config ?? throw new ArgumentNullException(nameof(config)); + this.client = client ?? new HttpClient(); + this.baseUri = config.Host; + if(string.IsNullOrEmpty(this.baseUri)) throw new ArgumentException(nameof(config)+".Host"); + Scheme(scheme); + } + + /// Initializes a based on a and an . + /// The absolute base URI of the Kubernetes API server + /// The used to make the request, or null to use the default client + /// The used to map types to their Kubernetes group, version, and kind + /// Any necessary SSL configuration must have already been applied to the . + public KubernetesRequest(Uri baseUri, HttpClient client = null, KubernetesScheme scheme = null) + { + if(baseUri == null) throw new ArgumentNullException(nameof(baseUri)); + if(!baseUri.IsAbsoluteUri) throw new ArgumentException("The base URI must be absolute.", nameof(baseUri)); + this.baseUri = baseUri.ToString(); + this.client = client ?? new HttpClient(); Scheme(scheme); } @@ -39,12 +61,24 @@ public KubernetesRequest(KubernetesClientConfiguration config, HttpClient client /// Sets the value of the Accept header, or null or empty to use the default of application/json. public KubernetesRequest Accept(string mediaType) { _accept = NormalizeEmpty(mediaType); return this; } - /// Adds a query-string parameter to the request. Multiple headers with the same name can be set this way. - public KubernetesRequest AddHeader(string key, string value) => Add(ref headers, key, value); + /// Adds a header to the request. Multiple header values with the same name can be set this way. + public KubernetesRequest AddHeader(string key, string value) => Add(ref headers, CheckHeaderName(key), value); + + /// Adds a header to the request. + public KubernetesRequest AddHeader(string key, IEnumerable values) => Add(ref headers, CheckHeaderName(key), values); + + /// Adds a header to the request. + public KubernetesRequest AddHeader(string key, params string[] values) => Add(ref headers, CheckHeaderName(key), values); /// Adds a query-string parameter to the request. Multiple parameters with the same name can be set this way. public KubernetesRequest AddQuery(string key, string value) => Add(ref query, key, value); + /// Adds query-string parameters to the request. + public KubernetesRequest AddQuery(string key, IEnumerable values) => Add(ref query, key, values); + + /// Adds query-string parameters to the request. + public KubernetesRequest AddQuery(string key, params string[] values) => Add(ref query, key, values); + /// Gets the body to be sent to the server. public object Body() => _body; @@ -53,18 +87,19 @@ public KubernetesRequest(KubernetesClientConfiguration config, HttpClient client /// public KubernetesRequest Body(object body) { _body = body; return this; } - /// Clears all custom header values. - public KubernetesRequest ClearHeaders() + /// Clears custom header values with the given name. + public KubernetesRequest ClearHeader(string headerName) { - if(headers != null) headers.Clear(); + if(headerName == null) throw new ArgumentNullException(nameof(headerName)); + CheckHeaderName(headerName); + if(headers != null) headers.Remove(headerName); return this; } - /// Clears custom header values with the given name. - public KubernetesRequest ClearHeaders(string headerName) + /// Clears all custom header values. + public KubernetesRequest ClearHeaders() { - if(headerName == null) throw new ArgumentNullException(nameof(headerName)); - if(headers != null) headers.Remove(headerName); + if(headers != null) headers.Clear(); return this; } @@ -116,7 +151,7 @@ public KubernetesRequest Clone() public KubernetesRequest Put() => Method(HttpMethod.Put); /// Sets the value of the "dryRun" query-string parameter, as a boolean. - public bool DryRun() => string.IsNullOrEmpty(GetQuery("dryRun")); + public bool DryRun() => !string.IsNullOrEmpty(GetQuery("dryRun")); /// Sets the value of the "dryRun" query-string parameter to "All" or removes it. public KubernetesRequest DryRun(bool dryRun) => SetQuery("dryRun", dryRun ? "All" : null); @@ -277,13 +312,13 @@ public KubernetesRequest LabelSelector(string selector) => public string Name() => _name; /// Sets the name of the top-level Kubernetes resource to access, or null or empty to not access a specific object. - public KubernetesRequest Name(string name) { _name = name; return this; } + public KubernetesRequest Name(string name) { _name = NormalizeEmpty(name); return this; } /// Gets the Kubernetes namespace to access. public string Namespace() => _ns; /// Sets the Kubernetes namespace to access, or null or empty to not access a namespaced object. - public KubernetesRequest Namespace(string ns) { _ns = ns; return this; } + public KubernetesRequest Namespace(string ns) { _ns = NormalizeEmpty(ns); return this; } /// Gets the raw URL to access, relative to the configured Kubernetes host and not including the query string, or /// null if the URL will be constructed piecemeal based on the other properties. @@ -291,7 +326,7 @@ public KubernetesRequest LabelSelector(string selector) => public string RawUri() => _rawUri; /// Sets the raw URL to access, relative to the configured Kubernetes host and not including the query string, or - /// null or empty to construct the URI piecemeal based on the other properties. + /// null or empty to construct the URI piecemeal based on the other properties. The URI must begin with a slash. /// public KubernetesRequest RawUri(string uri) { @@ -355,6 +390,7 @@ public async Task ReplaceAsync( { if(modify == null) throw new ArgumentNullException(nameof(modify)); if(_watchVersion != null) throw new InvalidOperationException("Watches cannot be updated."); + KubernetesRequest putReq = null; while(true) { if(obj == null) // if we need to load the resource... @@ -367,13 +403,12 @@ public async Task ReplaceAsync( cancelToken.ThrowIfCancellationRequested(); // if the resource is missing or no changes are needed, return it as-is if(obj == null || !await modify(obj, cancelToken).ConfigureAwait(false)) return obj; - HttpRequestMessage updateMsg = CreateRequestMessage(); // otherwise, update it with a PUT request - updateMsg.Method = HttpMethod.Put; - KubernetesResponse resp = new KubernetesResponse(await client.SendAsync(updateMsg, cancelToken).ConfigureAwait(false)); + if(putReq == null) putReq = Clone().Put(); + KubernetesResponse resp = await putReq.Body(obj).ExecuteAsync(cancelToken).ConfigureAwait(false); // otherwise, update it if(resp.StatusCode != HttpStatusCode.Conflict) // if there was no conflict, return the result { if(resp.IsNotFound && !failIfMissing) return null; - else if(resp.IsError) throw new KubernetesException(await resp.GetErrorAsync().ConfigureAwait(false)); + else if(resp.IsError) throw new KubernetesException(await resp.GetStatusAsync().ConfigureAwait(false)); else return await resp.GetBodyAsync().ConfigureAwait(false); } obj = null; // otherwise, there was a conflict, so reload the item @@ -406,14 +441,7 @@ public KubernetesRequest Set(IKubernetesObject obj, bool setBody = true) } /// Sets a custom header value, or deletes it if the value is null. - public KubernetesRequest SetHeader(string headerName, string value) - { - if(headerName == "Accept" || headerName == "Content-Type") - { - throw new ArgumentException("The header must be set using the corresponding property."); - } - return Set(ref headers, headerName, value); - } + public KubernetesRequest SetHeader(string headerName, string value) => Set(ref headers, CheckHeaderName(headerName), value); /// Sets a query-string value, or deletes it if the value is null. public KubernetesRequest SetQuery(string key, string value) => Set(ref query, key, value); @@ -490,6 +518,19 @@ KubernetesRequest Add(ref Dictionary> dict, string key, stri return this; } + /// Adds a value to the query string or headers. + KubernetesRequest Add(ref Dictionary> dict, string key, IEnumerable values) + { + if(string.IsNullOrEmpty(key)) throw new ArgumentNullException(nameof(key)); + if(values != null) + { + if(dict == null) dict = new Dictionary>(); + if(!dict.TryGetValue(key, out List list)) dict[key] = list = new List(); + list.AddRange(values); + } + return this; + } + /// Sets a value in the query string or headers. KubernetesRequest Set(ref Dictionary> dict, string key, string value) { @@ -507,11 +548,11 @@ HttpRequestMessage CreateRequestMessage() var req = new HttpRequestMessage(Method(), GetRequestUri()); // add the headers - if(!string.IsNullOrEmpty(config.AccessToken)) + if(!string.IsNullOrEmpty(config?.AccessToken)) { req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", config.AccessToken); } - else if(!string.IsNullOrEmpty(config.Username)) + else if(!string.IsNullOrEmpty(config?.Username)) { req.Headers.Authorization = new AuthenticationHeaderValue("Basic", Convert.ToBase64String(Encoding.UTF8.GetBytes(config.Username + ":" + config.Password))); @@ -545,7 +586,7 @@ HttpRequestMessage CreateRequestMessage() req.Content.Headers.ContentType = new MediaTypeHeaderValue(_mediaType ?? "application/json") { CharSet = "UTF-8" }; if(contentHeaders != null && contentHeaders.Count != 0) // go through the headers we couldn't set on the request { - foreach(KeyValuePair> pair in contentHeaders) + foreach(KeyValuePair> pair in contentHeaders) { if(!req.Content.Headers.TryAddWithoutValidation(pair.Key, pair.Value)) // if we can't set it on the content either... { @@ -562,7 +603,7 @@ async Task ExecuteMessageAsync(HttpRequestMessage msg, bool failIfMissing, cancelToken.ThrowIfCancellationRequested(); KubernetesResponse resp = new KubernetesResponse(await client.SendAsync(msg, cancelToken).ConfigureAwait(false)); if(resp.IsNotFound && !failIfMissing) return default(T); - else if(resp.IsError) throw new KubernetesException(await resp.GetErrorAsync().ConfigureAwait(false)); + else if(resp.IsError) throw new KubernetesException(await resp.GetStatusAsync().ConfigureAwait(false)); else return await resp.GetBodyAsync().ConfigureAwait(false); } @@ -575,14 +616,15 @@ string GetRequestUri() // construct the request URL var sb = new StringBuilder(); - sb.Append(config.Host); - if(sb[sb.Length-1] != '/') sb.Append('/'); + sb.Append(baseUri); if(_rawUri != null) // if a raw URL was given, use it { + if(sb[sb.Length-1] == '/') sb.Length--; // the raw URI starts with a slash, so ensure the base URI doesn't end with one sb.Append(_rawUri); } else // otherwise, construct it piecemeal { + if(sb[sb.Length-1] != '/') sb.Append('/'); // ensure the base URI ends with a slash if(_group != null) sb.Append("apis/").Append(_group); else sb.Append("api"); sb.Append('/').Append(_version ?? "v1"); @@ -591,24 +633,24 @@ string GetRequestUri() if(_name != null) sb.Append('/').Append(_name); if(_subresource != null) sb.Append('/').Append(_subresource); } + bool firstParam = true; if(query != null) // then add the query string, if any { - bool first = true; foreach(KeyValuePair> pair in query) { string key = Uri.EscapeDataString(pair.Key); foreach(string value in pair.Value) { - sb.Append(first ? '?' : '&').Append(key).Append('='); + sb.Append(firstParam ? '?' : '&').Append(key).Append('='); if(!string.IsNullOrEmpty(value)) sb.Append(Uri.EscapeDataString(value)); - first = false; + firstParam = false; } } - if(_watchVersion != null) - { - sb.Append(first ? '?' : '&').Append("watch=1"); - if(_watchVersion.Length != 0) sb.Append("&resourceVersion=").Append(_watchVersion); - } + } + if(_watchVersion != null) + { + sb.Append(firstParam ? '?' : '&').Append("watch=1"); + if(_watchVersion.Length != 0) sb.Append("&resourceVersion=").Append(_watchVersion); } return sb.ToString(); } @@ -616,6 +658,7 @@ string GetRequestUri() object ICloneable.Clone() => Clone(); readonly HttpClient client; + readonly string baseUri; readonly KubernetesClientConfiguration config; Dictionary> headers, query; string _accept = "application/json", _mediaType = "application/json"; @@ -625,6 +668,15 @@ string GetRequestUri() KubernetesScheme _scheme; bool _streamResponse; + static string CheckHeaderName(string name) + { + if(name == "Accept" || name == "Content-Type") + { + throw new ArgumentException($"The {name} header must be set using the corresponding property."); + } + return name; + } + static string NormalizeEmpty(string value) => string.IsNullOrEmpty(value) ? null : value; // normalizes empty strings to null } #endregion @@ -651,19 +703,6 @@ public sealed class KubernetesResponse : IDisposable /// public void Dispose() => Message.Dispose(); - /// Deserializes the response body as a object, or creates one from the status code if the - /// response body is not a JSON object. - /// - public async Task GetErrorAsync() - { - try { return await GetBodyAsync().ConfigureAwait(false); } - catch(JsonException) { } - return new V1Status() - { - Status = IsError ? "Failure" : "Success", Code = (int)StatusCode, Reason = StatusCode.ToString(), Message = body - }; - } - /// Returns the response body as a string. public async Task GetBodyAsync() { @@ -707,6 +746,23 @@ public async Task GetBodyAsync(bool failIfEmpty = false) return JsonConvert.DeserializeObject(body, Kubernetes.DefaultJsonSettings); } + /// Deserializes the response body as a object, or creates one from the status code if the + /// response body is not a JSON object. + /// + public async Task GetStatusAsync() + { + try + { + var status = await GetBodyAsync().ConfigureAwait(false); + if(status != null && (status.Status == "Success" || status.Status == "Failure")) return status; + } + catch(JsonException) { } + return new V1Status() + { + Status = IsError ? "Failure" : "Success", Code = (int)StatusCode, Reason = StatusCode.ToString(), Message = body + }; + } + string body; } #endregion From 15a08840f8812be57d3a85a511fb0f54cc8a6f59 Mon Sep 17 00:00:00 2001 From: Adam Milazzo Date: Wed, 8 Apr 2020 12:39:25 -0700 Subject: [PATCH 07/13] Actually add the tests... --- .../Kubernetes.Fluent.Tests.cs | 508 ++++++++++++++++++ 1 file changed, 508 insertions(+) create mode 100644 tests/KubernetesClient.Tests/Kubernetes.Fluent.Tests.cs diff --git a/tests/KubernetesClient.Tests/Kubernetes.Fluent.Tests.cs b/tests/KubernetesClient.Tests/Kubernetes.Fluent.Tests.cs new file mode 100644 index 000000000..e9e4d7a5f --- /dev/null +++ b/tests/KubernetesClient.Tests/Kubernetes.Fluent.Tests.cs @@ -0,0 +1,508 @@ +using System; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using k8s.Models; +using Newtonsoft.Json; +using Xunit; + +namespace k8s.Tests +{ + public class FluentTests + { + [Fact] + public void TestRequestProperties() + { + var testScheme = new KubernetesScheme(); + var r = new KubernetesRequest(new Uri("http://somewhere"), new HttpClient(), testScheme); + + // verify the initial values + Assert.Equal("application/json", r.Accept()); + Assert.Null(r.Body()); + Assert.False(r.DryRun()); + Assert.Null(r.FieldManager()); + Assert.Null(r.FieldSelector()); + Assert.Null(r.Group()); + Assert.Null(r.LabelSelector()); + Assert.Equal("application/json", r.MediaType()); + Assert.Same(HttpMethod.Get, r.Method()); + Assert.Null(r.Name()); + Assert.Null(r.Namespace()); + Assert.Null(r.RawUri()); + Assert.Same(testScheme, r.Scheme()); + Assert.False(r.StreamResponse()); + Assert.Null(r.Type()); + Assert.Null(r.Version()); + Assert.Null(r.WatchVersion()); + + // test basic value setters + r.Accept("foo/bar") + .Body("abc") + .DryRun(true) + .FieldManager("joe") + .FieldSelector("fs") + .Group("mygroup") + .LabelSelector("x=y") + .MediaType("bar/baz") + .Method(HttpMethod.Post) + .Name("name") + .Namespace("ns") + .RawUri("/anywhere") + .StreamResponse(true) + .Subresource("exec") + .Type("mytype") + .Version("v2") + .WatchVersion("42"); + Assert.Equal("foo/bar", r.Accept()); + Assert.Equal("abc", (string)r.Body()); + Assert.True(r.DryRun()); + Assert.Equal("joe", r.FieldManager()); + Assert.Equal("fs", r.FieldSelector()); + Assert.Equal("mygroup", r.Group()); + Assert.Equal("x=y", r.LabelSelector()); + Assert.Equal("bar/baz", r.MediaType()); + Assert.Same(HttpMethod.Post, r.Method()); + Assert.Equal("name", r.Name()); + Assert.Equal("ns", r.Namespace()); + Assert.Equal("/anywhere", r.RawUri()); + Assert.True(r.StreamResponse()); + Assert.Equal("mytype", r.Type()); + Assert.Equal("v2", r.Version()); + Assert.Equal("42", r.WatchVersion()); + + // test value normalization + r.Accept("") + .FieldManager("") + .FieldSelector("") + .Group("") + .LabelSelector("") + .MediaType("") + .Method(null) + .Name("") + .Namespace("") + .RawUri("") + .Scheme(null) + .Subresource("") + .Type("") + .Version("") + .WatchVersion(""); + Assert.Null(r.Accept()); + Assert.Null(r.FieldManager()); + Assert.Null(r.FieldSelector()); + Assert.Null(r.Group()); + Assert.Null(r.LabelSelector()); + Assert.Null(r.MediaType()); + Assert.Same(HttpMethod.Get, r.Method()); + Assert.Null(r.Name()); + Assert.Null(r.Namespace()); + Assert.Null(r.RawUri()); + Assert.Same(KubernetesScheme.Default, r.Scheme()); + Assert.Null(r.Type()); + Assert.Null(r.Version()); + Assert.Equal("", r.WatchVersion()); + + // test exceptions from property setters + Assert.Throws(() => r.RawUri("foo")); + + // test more advanced/specific setters + r.Delete(); + Assert.Same(HttpMethod.Delete, r.Method()); + r.Get(); + Assert.Same(HttpMethod.Get, r.Method()); + r.Patch(); + Assert.Equal(new HttpMethod("PATCH"), r.Method()); + r.Post(); + Assert.Same(HttpMethod.Post, r.Method()); + r.Put(); + Assert.Same(HttpMethod.Put, r.Method()); + + r.Status(); + Assert.Equal("status", r.Subresource()); + r.Subresources("a", "b c"); + Assert.Equal("a/b%20c", r.Subresource()); + + r.GVK(); + Assert.Equal("v3", r.Version()); + Assert.Equal("cgrp", r.Group()); + Assert.Equal("newz", r.Type()); + + r.GVK(); + Assert.Equal("v0", r.Version()); + Assert.Equal("ogrp", r.Group()); + Assert.Equal("nos", r.Type()); + + r.GVK("grp", "v1", "PigList"); + Assert.Equal("v1", r.Version()); + Assert.Equal("grp", r.Group()); + Assert.Equal("pigs", r.Type()); + + r.GVK(new CustomOld()); + Assert.Equal("v0", r.Version()); + Assert.Equal("ogrp", r.Group()); + Assert.Equal("nos", r.Type()); + + var res = new CustomNew() { ApiVersion = "coolstuff/v7", Kind = "yep", Metadata = new V1ObjectMeta() { Name = "name", NamespaceProperty = "ns" } }; + r.GVK(res); + Assert.Equal("v7", r.Version()); + Assert.Equal("coolstuff", r.Group()); + Assert.Equal("newz", r.Type()); + Assert.Null(r.Name()); + Assert.Null(r.Namespace()); + + res.ApiVersion = "v7"; + r.Body(null).Set(res, setBody: false); + Assert.Equal("v7", r.Version()); + Assert.Null(r.Group()); + Assert.Equal("newz", r.Type()); + Assert.Null(r.Name()); + Assert.Equal("ns", r.Namespace()); + Assert.Null(r.Body()); + + (res.ApiVersion, res.Metadata.Uid) = ("", "id"); + r.Set(res); + Assert.Equal("v3", r.Version()); + Assert.Equal("cgrp", r.Group()); + Assert.Equal("newz", r.Type()); + Assert.Equal("name", r.Name()); + Assert.Equal("ns", r.Namespace()); + Assert.Same(res, r.Body()); + + // test with a custom scheme + var scheme = new KubernetesScheme(); + scheme.SetGVK(typeof(CustomOld), "group", "version", "Custom", "customs"); + r.Scheme(scheme).GVK(); + Assert.Equal("group", r.Group()); + Assert.Equal("version", r.Version()); + Assert.Equal("customs", r.Type()); + } + + [Fact] + public void TestRequestQuery() + { + var r = new KubernetesRequest(new Uri("http://somewhere"), new HttpClient()); + + // test basic query-string operations + Assert.Null(r.GetQuery("k")); + r.AddQuery("k", "y"); + Assert.Equal("y", r.GetQuery("k")); + r.AddQuery("k", "x"); + Assert.Throws(() => r.GetQuery("k")); + Assert.Equal(new[] { "y", "x" }, r.GetQueryValues("k")); + r.SetQuery("k", "z"); + Assert.Equal("z", r.GetQuery("k")); + r.SetQuery("k2", "a").ClearQuery("k"); + Assert.Null(r.GetQuery("k")); + Assert.Equal("a", r.GetQuery("k2")); + r.ClearQuery(); + Assert.Null(r.GetQuery("k2")); + r.AddQuery("k", "a", "b"); + Assert.Equal(new[] { "a", "b" }, r.GetQueryValues("k")); + r.SetQuery("k", "x"); + Assert.Equal("x", r.GetQuery("k")); + r.SetQuery("k", null); + Assert.Null(r.GetQuery("k")); + + // test property setters that work via the query string + r.DryRun(true).FieldManager("fm").FieldSelector("fs").LabelSelector("ls"); + Assert.Equal("All", r.GetQuery("dryRun")); + Assert.Equal("fm", r.GetQuery("fieldManager")); + Assert.Equal("fs", r.GetQuery("fieldSelector")); + Assert.Equal("ls", r.GetQuery("labelSelector")); + } + + [Fact] + public void TestRequestHeaders() + { + var r = new KubernetesRequest(new Uri("http://somewhere"), new HttpClient()); + + // test basic header operations + Assert.Null(r.GetHeader("k")); + r.AddHeader("k", "y"); + Assert.Equal("y", r.GetHeader("k")); + r.AddHeader("k", "x"); + Assert.Throws(() => r.GetHeader("k")); + Assert.Equal(new[] { "y", "x" }, r.GetHeaderValues("k")); + r.SetHeader("k", "z"); + Assert.Equal("z", r.GetHeader("k")); + r.SetHeader("k2", "a").ClearHeader("k"); + Assert.Null(r.GetHeader("k")); + Assert.Equal("a", r.GetHeader("k2")); + r.ClearHeaders(); + Assert.Null(r.GetHeader("k2")); + r.AddHeader("k", "a", "b"); + Assert.Equal(new[] { "a", "b" }, r.GetHeaderValues("k")); + r.SetHeader("k", "x"); + Assert.Equal("x", r.GetHeader("k")); + r.SetHeader("k", null); + Assert.Null(r.GetHeader("k")); + + // test exceptions + Assert.Null(r.GetHeader("Accept")); + Assert.Throws(() => r.AddHeader("Accept", "text/plain")); + Assert.Throws(() => r.AddHeader("Accept", Enumerable.Repeat("text/plain", 2))); + Assert.Throws(() => r.AddHeader("Accept", "text/plain", "text/fancy")); + Assert.Throws(() => r.ClearHeader("Content-Type")); + Assert.Throws(() => r.SetHeader("Content-Type", "text/plain")); + } + + [Fact] + public async Task TestExecution() + { + var h = new MockHandler(_ => new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("{\"apiVersion\":\"xyz\"}") }); + var c = new Kubernetes(new KubernetesClientConfiguration() { Host = "http://localhost:8080" }, new HttpClient(h)); + + await c.New().AddHeader("Test", "yes").AddQuery("x", "a", "b c").Body("hello").ExecuteAsync(); + Assert.Equal(HttpMethod.Get, h.Request.Method); + Assert.Equal(new Uri("http://localhost:8080/api/v1/pods?x=a&x=b%20c"), h.Request.RequestUri); + Assert.Equal("yes", h.Request.Headers.GetValues("Test").Single()); + Assert.Equal("application/json", h.Request.Headers.Accept.ToString()); + Assert.Equal("application/json; charset=UTF-8", h.Request.Content.Headers.ContentType.ToString()); + Assert.Equal("hello", await h.Request.Content.ReadAsStringAsync()); + + var res = new CustomNew() { ApiVersion = "abc" }; + await c.New("ns", "name") + .Accept("text/plain").MediaType("text/rtf").Delete().DryRun(true).Body(res).Status().ExecuteAsync(); + Assert.Equal(HttpMethod.Delete, h.Request.Method); + Assert.Equal(new Uri("http://localhost:8080/apis/cgrp/v3/namespaces/ns/newz/name/status?dryRun=All"), h.Request.RequestUri); + Assert.Equal("text/plain", h.Request.Headers.Accept.ToString()); + Assert.Equal("text/rtf; charset=UTF-8", h.Request.Content.Headers.ContentType.ToString()); + Assert.Equal("{\"apiVersion\":\"abc\"}", await h.Request.Content.ReadAsStringAsync()); + + await c.New().RawUri("/foobar").Post().LabelSelector("ls").WatchVersion("3").Body(Encoding.UTF8.GetBytes("bytes")).ExecuteAsync(); + Assert.Equal(HttpMethod.Post, h.Request.Method); + Assert.Equal(new Uri("http://localhost:8080/foobar?labelSelector=ls&watch=1&resourceVersion=3"), h.Request.RequestUri); + Assert.Equal("bytes", await h.Request.Content.ReadAsStringAsync()); + + await c.New().RawUri("/foobar/").WatchVersion("").Body(new MemoryStream(Encoding.UTF8.GetBytes("streaming"))).ExecuteAsync(); + Assert.Equal(new Uri("http://localhost:8080/foobar/?watch=1"), h.Request.RequestUri); + Assert.Equal("streaming", await h.Request.Content.ReadAsStringAsync()); + + await Assert.ThrowsAsync(() => c.New().Name("x").RawUri("/y").ExecuteAsync()); // can't use raw and non-raw + + c = new Kubernetes(new KubernetesClientConfiguration() { Host = "http://localhost:8080", AccessToken = "token" }, new HttpClient(h)); + await c.New().ExecuteAsync(); + Assert.Equal(new Uri("http://localhost:8080/api/v1/"), h.Request.RequestUri); + Assert.Equal("Bearer token", h.Request.Headers.Authorization.ToString()); + + c = new Kubernetes(new KubernetesClientConfiguration() { Host = "http://localhost:8080", Username = "joe" }, new HttpClient(h)); + await c.New().ExecuteAsync(); + Assert.Equal("Basic am9lOg==", h.Request.Headers.Authorization.ToString()); + + res = await c.New().ExecuteAsync(); + Assert.Equal("xyz", res.ApiVersion); + + h = new MockHandler(_ => new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("") }); + c = new Kubernetes(new KubernetesClientConfiguration() { Host = "http://localhost:8080" }, new HttpClient(h)); + res = await c.New().ExecuteAsync(); + Assert.Null(res); + res = await c.New().ExecuteAsync(failIfMissing: true); // missing only refers to 404 Not Found + Assert.Null(res); + + h = new MockHandler(_ => new HttpResponseMessage(HttpStatusCode.NotFound) { Content = new StringContent("{}") }); + c = new Kubernetes(new KubernetesClientConfiguration() { Host = "http://localhost:8080" }, new HttpClient(h)); + res = await c.New().ExecuteAsync(); + Assert.Null(res); + await Assert.ThrowsAsync(() => c.New().ExecuteAsync(failIfMissing: true)); + + h = new MockHandler(_ => new HttpResponseMessage(HttpStatusCode.BadRequest) { Content = new StringContent("{}") }); + c = new Kubernetes(new KubernetesClientConfiguration() { Host = "http://localhost:8080" }, new HttpClient(h)); + await Assert.ThrowsAsync(() => c.New().ExecuteAsync()); + } + + [Fact] + public void TestNew() + { + var c = new Kubernetes(new Uri("http://somewhere"), new Microsoft.Rest.TokenCredentials("token"), new MockHandler(null)); + c.Scheme = new KubernetesScheme(); + c.Scheme.SetGVK(typeof(CustomOld), "group", "version", "Custom", "customs"); + + // test New(HttpMethod = null) + var r = c.New(); + Assert.Same(HttpMethod.Get, r.Method()); + r = c.New(HttpMethod.Delete); + Assert.Same(HttpMethod.Delete, r.Method()); + + // test New(Type) + r = c.New(typeof(V1MutatingWebhookConfiguration)); + Assert.Equal("admissionregistration.k8s.io", r.Group()); + Assert.Equal("v1", r.Version()); + Assert.Equal("mutatingwebhookconfigurations", r.Type()); + r = c.New(typeof(CustomOld)); // ensure it defaults to the scheme from the client + Assert.Same(c.Scheme, r.Scheme()); + Assert.Equal("group", r.Group()); + Assert.Equal("version", r.Version()); + Assert.Equal("customs", r.Type()); + + // test c.New(obj, bool) + var res = new CustomNew() { ApiVersion = "coolstuff/v7", Kind = "yep", Metadata = new V1ObjectMeta() { Name = "name", NamespaceProperty = "ns" } }; + r = c.New(res); + Assert.Equal("coolstuff", r.Group()); + Assert.Equal("v7", r.Version()); + Assert.Equal("newz", r.Type()); + Assert.Null(r.Name()); + Assert.Equal("ns", r.Namespace()); + Assert.Same(res, r.Body()); + + res.Metadata.Uid = "id"; + r = c.New(res, setBody: false); + Assert.Equal("name", r.Name()); + Assert.Null(r.Body()); + + // test c.New(HttpMethod, Type, string, string) + r = c.New(null, typeof(V1PodList), "ns", "name"); + Assert.Same(HttpMethod.Get, r.Method()); + Assert.Null(r.Group()); + Assert.Equal("v1", r.Version()); + Assert.Equal("pods", r.Type()); + Assert.Equal("name", r.Name()); + Assert.Equal("ns", r.Namespace()); + r = c.New(HttpMethod.Delete, typeof(V1PodList), "ns", "name"); + Assert.Same(HttpMethod.Delete, r.Method()); + + // test c.New(HttpMethod, string, string, string, string, string) + r = c.New(HttpMethod.Put, "type", "ns", "name", "group", "version"); + Assert.Same(HttpMethod.Put, r.Method()); + Assert.Equal("type", r.Type()); + Assert.Equal("ns", r.Namespace()); + Assert.Equal("name", r.Name()); + Assert.Equal("group", r.Group()); + Assert.Equal("version", r.Version()); + + // test c.New(string, string) + c.Scheme = KubernetesScheme.Default; + r = c.New("ns", "name"); + Assert.Equal("v0", r.Version()); + Assert.Equal("ogrp", r.Group()); + Assert.Equal("nos", r.Type()); + Assert.Equal("ns", r.Namespace()); + Assert.Equal("name", r.Name()); + } + + [Fact] + public async Task TestReplace() + { + string value = "{}"; + bool conflict = true; + var h = new MockHandler(req => + { + if(value == null) + { + return new HttpResponseMessage(HttpStatusCode.NotFound); + } + else if(req.Method == HttpMethod.Get) + { + return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(value) }; + } + else if(req.Method == HttpMethod.Put) + { + if(conflict) + { + conflict = false; + return new HttpResponseMessage(HttpStatusCode.Conflict); + } + value = req.Content.ReadAsStringAsync().Result; + return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(value) }; + } + throw new Exception("i shouldn't exist"); + }); + var c = new Kubernetes(new KubernetesClientConfiguration() { Host = "http://localhost:8080" }, new HttpClient(h)); + + int i = 0; + var res = await c.New().ReplaceAsync(r => { r.SetAnnotation("a", (i++).ToString(CultureInfo.InvariantCulture)); return true; }); + Assert.Equal("1", res.GetAnnotation("a")); + + res = await c.New().ReplaceAsync(r => { r.SetAnnotation("b", "x"); return true; }); + Assert.Equal("1", res.GetAnnotation("a")); + Assert.Equal("x", res.GetAnnotation("b")); + + res = await c.New().ReplaceAsync(r => { r.SetAnnotation("c", "y"); return false; }); + Assert.Equal("x", res.GetAnnotation("b")); + Assert.Equal("y", res.GetAnnotation("c")); + + res = await c.New().ReplaceAsync(r => false); + Assert.Equal("x", res.GetAnnotation("b")); + Assert.Null(res.GetAnnotation("c")); + + value = null; + res = await c.New().ReplaceAsync(r => { r.SetAnnotation("a", "x"); return true; }); + Assert.Null(res); + } + + [Fact] + public async Task TestResponse() + { + var msg = new HttpResponseMessage(HttpStatusCode.Ambiguous) { Content = new StringContent("{\"ApiVersion\":\"123\"}") }; + var resp = new KubernetesResponse(msg); + Assert.False(resp.IsError); + Assert.False(resp.IsNotFound); + Assert.Same(msg, resp.Message); + Assert.Equal(HttpStatusCode.Ambiguous, resp.StatusCode); + Assert.Equal("{\"ApiVersion\":\"123\"}", await resp.GetBodyAsync()); + var cn = await resp.GetBodyAsync(); + Assert.Equal("123", cn.ApiVersion); + var status = await resp.GetStatusAsync(); + Assert.Equal("Success", status.Status); + Assert.Equal((int)resp.StatusCode, status.Code.Value); + Assert.Equal("{\"ApiVersion\":\"123\"}", status.Message); + + msg = new HttpResponseMessage(HttpStatusCode.NotFound); + resp = new KubernetesResponse(msg); + Assert.True(resp.IsError); + Assert.True(resp.IsNotFound); + Assert.Equal("", await resp.GetBodyAsync()); + Assert.Null(await resp.GetBodyAsync()); + await Assert.ThrowsAsync(() => resp.GetBodyAsync(failIfEmpty: true)); + status = await resp.GetStatusAsync(); + Assert.Equal("Failure", status.Status); + Assert.Equal((int)resp.StatusCode, status.Code.Value); + Assert.Equal("", status.Message); + + msg = new HttpResponseMessage(HttpStatusCode.BadRequest) { Content = new StringContent("It's bad, yo.") }; + resp = new KubernetesResponse(msg); + Assert.True(resp.IsError); + Assert.False(resp.IsNotFound); + Assert.Equal("It's bad, yo.", await resp.GetBodyAsync()); + await Assert.ThrowsAnyAsync(() => resp.GetBodyAsync()); + status = await resp.GetStatusAsync(); + Assert.Equal("Failure", status.Status); + Assert.Equal((int)resp.StatusCode, status.Code.Value); + Assert.Equal("It's bad, yo.", status.Message); + } + + [KubernetesEntity(ApiVersion = "v3", Group = "cgrp", Kind = "yes", PluralName = "newz")] + class CustomNew : IKubernetesObject + { + public string ApiVersion { get; set; } + public string Kind { get; set; } + public V1ObjectMeta Metadata { get; set; } + } + + class CustomOld : IKubernetesObject + { + public const string KubeApiVersion = "v0", KubeGroup = "ogrp", KubeKind = "no"; + public string ApiVersion { get; set; } + public string Kind { get; set; } + public V1ObjectMeta Metadata { get; set; } + } + + class MockHandler : HttpClientHandler + { + public MockHandler(Func respFunc) => this.respFunc = respFunc; + + public HttpRequestMessage Request; + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + Request = request; + return Task.FromResult(respFunc(request)); + } + + readonly Func respFunc; + } + } +} From 575eddfe0d002047708a3d785d76ec6e4e778d9b Mon Sep 17 00:00:00 2001 From: Adam Milazzo Date: Wed, 8 Apr 2020 14:31:45 -0700 Subject: [PATCH 08/13] Merge from modelExtensions --- src/KubernetesClient/ModelExtensions.cs | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/src/KubernetesClient/ModelExtensions.cs b/src/KubernetesClient/ModelExtensions.cs index fe5a880be..f16a0665a 100644 --- a/src/KubernetesClient/ModelExtensions.cs +++ b/src/KubernetesClient/ModelExtensions.cs @@ -102,10 +102,7 @@ public static V1ObjectReference CreateObjectReference(this IKubernetesObjectCreates a that refers to the given object. public static V1OwnerReference CreateOwnerReference(this IKubernetesObject obj, bool? controller = null, bool? blockDeletion = null) { - if (obj == null) throw new ArgumentNullException(nameof(obj)); + if(obj == null) throw new ArgumentNullException(nameof(obj)); string apiVersion = obj.ApiVersion, kind = obj.Kind; // default to using the API version and kind from the object - if (string.IsNullOrEmpty(apiVersion) || string.IsNullOrEmpty(kind)) // but if either of them is missing... + if(string.IsNullOrEmpty(apiVersion) || string.IsNullOrEmpty(kind)) // but if either of them is missing... { - object[] attrs = obj.GetType().GetCustomAttributes(typeof(KubernetesEntityAttribute), true); - if (attrs.Length == 0) throw new ArgumentException("Unable to determine the object's API version and Kind."); - var attr = (KubernetesEntityAttribute)attrs[0]; - (apiVersion, kind) = (string.IsNullOrEmpty(attr.Group) ? attr.ApiVersion : attr.Group + "/" + attr.ApiVersion, attr.Kind); + KubernetesScheme.Default.GetVK(obj.GetType(), out apiVersion, out kind); // get it from the default scheme } return new V1OwnerReference() { From e220d9778f1e97ae7ee0601cc93afacaf86ff490 Mon Sep 17 00:00:00 2001 From: Adam Milazzo Date: Thu, 9 Apr 2020 20:24:01 -0700 Subject: [PATCH 09/13] Address some PR comments --- src/KubernetesClient/Kubernetes.ConfigInit.cs | 31 ++-- src/KubernetesClient/Kubernetes.Fluent.cs | 30 ++- src/KubernetesClient/KubernetesRequest.cs | 66 ++++--- .../{Scheme.cs => KubernetesScheme.cs} | 26 +++ .../Kubernetes.Fluent.Tests.cs | 105 +++++------ .../KubernetesSchemeTests.cs | 172 ++++++++++++++++++ .../Mock/MockHttpHandler.cs | 22 +++ 7 files changed, 343 insertions(+), 109 deletions(-) rename src/KubernetesClient/{Scheme.cs => KubernetesScheme.cs} (87%) create mode 100644 tests/KubernetesClient.Tests/KubernetesSchemeTests.cs create mode 100644 tests/KubernetesClient.Tests/Mock/MockHttpHandler.cs diff --git a/src/KubernetesClient/Kubernetes.ConfigInit.cs b/src/KubernetesClient/Kubernetes.ConfigInit.cs index 7bf4221b9..aef8faaab 100644 --- a/src/KubernetesClient/Kubernetes.ConfigInit.cs +++ b/src/KubernetesClient/Kubernetes.ConfigInit.cs @@ -203,19 +203,7 @@ partial void CustomInitialize() /// private void SetCredentials() { - // set the Credentails for token based auth - if (!string.IsNullOrWhiteSpace(config.AccessToken)) - { - Credentials = new TokenCredentials(config.AccessToken); - } - else if (!string.IsNullOrWhiteSpace(config.Username) && !string.IsNullOrWhiteSpace(config.Password)) - { - Credentials = new BasicAuthenticationCredentials - { - UserName = config.Username, - Password = config.Password - }; - } + Credentials = CreateCredentials(config); } internal readonly KubernetesClientConfiguration config; @@ -285,6 +273,23 @@ public static JsonSerializerSettings CreateSerializerSettings() return settings; } + /// Creates from a Kubernetes configuration, or returns null if the configuration + /// contains no credentials of that type. + /// + internal static ServiceClientCredentials CreateCredentials(KubernetesClientConfiguration config) + { + if(config == null) throw new ArgumentNullException(nameof(config)); + if(!string.IsNullOrEmpty(config.AccessToken)) + { + return new TokenCredentials(config.AccessToken); + } + else if(!string.IsNullOrEmpty(config.Username)) + { + return new BasicAuthenticationCredentials() { UserName = config.Username, Password = config.Password }; + } + return null; + } + /// Gets the used to serialize and deserialize Kubernetes objects. internal static readonly JsonSerializerSettings DefaultJsonSettings = CreateSerializerSettings(); } diff --git a/src/KubernetesClient/Kubernetes.Fluent.cs b/src/KubernetesClient/Kubernetes.Fluent.cs index a921a37e6..e5f1eb831 100644 --- a/src/KubernetesClient/Kubernetes.Fluent.cs +++ b/src/KubernetesClient/Kubernetes.Fluent.cs @@ -1,33 +1,49 @@ using System; using System.Net.Http; +using k8s.Models; namespace k8s { public partial class Kubernetes { + /// Creates a new Kubernetes object of the given type and sets its and + /// . + /// + public T New() where T : IKubernetesObject, new() => Scheme.New(); + + /// Creates a new Kubernetes object of the given type and sets its , + /// , and . + /// + public T New(string name) where T : IKubernetesObject, new() => Scheme.New(name); + + /// Creates a new Kubernetes object of the given type and sets its , + /// , , and . + /// + public T New(string ns, string name) where T : IKubernetesObject, new() => Scheme.New(ns, name); + /// Creates a new using the given /// ( by default). /// - public KubernetesRequest New(HttpMethod method = null) => new KubernetesRequest(this).Method(method); + public KubernetesRequest Request(HttpMethod method = null) => new KubernetesRequest(this).Method(method); /// Creates a new using the given /// and resource URI components. /// - public KubernetesRequest New( + public KubernetesRequest Request( HttpMethod method, string type = null, string ns = null, string name = null, string group = null, string version = null) => new KubernetesRequest(this).Method(method).Group(group).Version(version).Type(type).Namespace(ns).Name(name); /// Creates a new to access the given type of object. - public KubernetesRequest New(Type type) => new KubernetesRequest(this).GVK(type); + public KubernetesRequest Request(Type type) => new KubernetesRequest(this).GVK(type); /// Creates a new to access the given type of object with an optional name and namespace. - public KubernetesRequest New(HttpMethod method, Type type, string ns = null, string name = null) => - New(method).GVK(type).Namespace(ns).Name(name); + public KubernetesRequest Request(HttpMethod method, Type type, string ns = null, string name = null) => + Request(method).GVK(type).Namespace(ns).Name(name); /// Creates a new to access the given type of object with an optional name and namespace. - public KubernetesRequest New(string ns = null, string name = null) => New(null, typeof(T), ns, name); + public KubernetesRequest Request(string ns = null, string name = null) => Request(null, typeof(T), ns, name); /// Creates a new to access the given object. - public KubernetesRequest New(IKubernetesObject obj, bool setBody = true) => new KubernetesRequest(this).Set(obj, setBody); + public KubernetesRequest Request(IKubernetesObject obj, bool setBody = true) => new KubernetesRequest(this).Set(obj, setBody); } } diff --git a/src/KubernetesClient/KubernetesRequest.cs b/src/KubernetesClient/KubernetesRequest.cs index 35ca368a9..3087400b8 100644 --- a/src/KubernetesClient/KubernetesRequest.cs +++ b/src/KubernetesClient/KubernetesRequest.cs @@ -9,6 +9,7 @@ using System.Threading; using System.Threading.Tasks; using k8s.Models; +using Microsoft.Rest; using Newtonsoft.Json; namespace k8s @@ -21,7 +22,7 @@ public sealed class KubernetesRequest : ICloneable public KubernetesRequest(Kubernetes client) { if(client == null) throw new ArgumentNullException(nameof(client)); - (baseUri, config, this.client) = (client.BaseUri.ToString(), client.config, client.HttpClient); + (baseUri, credentials, this.client) = (client.BaseUri.ToString(), client.Credentials, client.HttpClient); Scheme(client.Scheme); } @@ -29,29 +30,40 @@ public KubernetesRequest(Kubernetes client) /// an . /// /// The used to connect to Kubernetes - /// The used to make the request, or null to use the default client - /// The used to map types to their Kubernetes group, version, and kind + /// The used to make the request, or null to use the default client. The default + /// is null. + /// + /// The used to map types to their Kubernetes group, version, and kind, or + /// null to use the default scheme. The default is null. + /// /// Any necessary SSL configuration must have already been applied to the . public KubernetesRequest(KubernetesClientConfiguration config, HttpClient client = null, KubernetesScheme scheme = null) { - this.config = config ?? throw new ArgumentNullException(nameof(config)); - this.client = client ?? new HttpClient(); + if(config == null) throw new ArgumentNullException(nameof(config)); this.baseUri = config.Host; if(string.IsNullOrEmpty(this.baseUri)) throw new ArgumentException(nameof(config)+".Host"); + credentials = Kubernetes.CreateCredentials(config); + this.client = client ?? new HttpClient(); Scheme(scheme); } /// Initializes a based on a and an . /// The absolute base URI of the Kubernetes API server - /// The used to make the request, or null to use the default client - /// The used to map types to their Kubernetes group, version, and kind + /// The used to connect to Kubernetes, or null if no credentials + /// of that type are needed. The default is null. + /// + /// The used to make the request, or null to use the default client. The default + /// is null. + /// + /// The used to map types to their Kubernetes group, version, and kind, or + /// null to use the default scheme. The default is null. + /// /// Any necessary SSL configuration must have already been applied to the . - public KubernetesRequest(Uri baseUri, HttpClient client = null, KubernetesScheme scheme = null) + public KubernetesRequest(Uri baseUri, ServiceClientCredentials credentials = null, HttpClient client = null, KubernetesScheme scheme = null) { if(baseUri == null) throw new ArgumentNullException(nameof(baseUri)); if(!baseUri.IsAbsoluteUri) throw new ArgumentException("The base URI must be absolute.", nameof(baseUri)); - this.baseUri = baseUri.ToString(); - this.client = client ?? new HttpClient(); + (this.baseUri, this.credentials, this.client) = (baseUri.ToString(), credentials, client = client ?? new HttpClient()); Scheme(scheme); } @@ -162,7 +174,7 @@ public KubernetesRequest Clone() public async Task ExecuteAsync(CancellationToken cancelToken = default) { cancelToken.ThrowIfCancellationRequested(); - HttpRequestMessage req = CreateRequestMessage(); + HttpRequestMessage req = await CreateRequestMessage(cancelToken).ConfigureAwait(false); // requests like watches may not send a body immediately, so return as soon as we've got the response headers var completion = _streamResponse || _watchVersion != null ? HttpCompletionOption.ResponseHeadersRead : HttpCompletionOption.ResponseContentRead; @@ -181,11 +193,12 @@ public async Task ExecuteAsync(CancellationToken cancelToken /// /// A that can be used to cancel the request /// Thrown if the response was any error besides 404 Not Found. - public Task ExecuteAsync(bool failIfMissing, CancellationToken cancelToken = default) + public async Task ExecuteAsync(bool failIfMissing, CancellationToken cancelToken = default) { if(_watchVersion != null) throw new InvalidOperationException("Watch requests cannot be deserialized all at once."); cancelToken.ThrowIfCancellationRequested(); - return ExecuteMessageAsync(CreateRequestMessage(), failIfMissing, cancelToken); + HttpRequestMessage reqMsg = await CreateRequestMessage(cancelToken).ConfigureAwait(false); + return await ExecuteMessageAsync(reqMsg, failIfMissing, cancelToken).ConfigureAwait(false); } /// Gets the "fieldManager" query-string parameter, or null if there is no field manager. @@ -396,7 +409,7 @@ public async Task ReplaceAsync( if(obj == null) // if we need to load the resource... { cancelToken.ThrowIfCancellationRequested(); - HttpRequestMessage getMsg = CreateRequestMessage(); // load it with a GET request + HttpRequestMessage getMsg = await CreateRequestMessage(cancelToken).ConfigureAwait(false); // load it with a GET request getMsg.Method = HttpMethod.Get; obj = await ExecuteMessageAsync(getMsg, failIfMissing, cancelToken).ConfigureAwait(false); } @@ -543,21 +556,16 @@ KubernetesRequest Set(ref Dictionary> dict, string key, stri } /// Creates an representing the current request. - HttpRequestMessage CreateRequestMessage() +#if NETCOREAPP2_1 + async ValueTask CreateRequestMessage(CancellationToken cancelToken) +#else + async Task CreateRequestMessage(CancellationToken cancelToken) +#endif { var req = new HttpRequestMessage(Method(), GetRequestUri()); + if(credentials != null) await credentials.ProcessHttpRequestAsync(req, cancelToken).ConfigureAwait(false); // add the headers - if(!string.IsNullOrEmpty(config?.AccessToken)) - { - req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", config.AccessToken); - } - else if(!string.IsNullOrEmpty(config?.Username)) - { - req.Headers.Authorization = new AuthenticationHeaderValue("Basic", - Convert.ToBase64String(Encoding.UTF8.GetBytes(config.Username + ":" + config.Password))); - } - if(_accept != null) req.Headers.Add("Accept", _accept); List>> contentHeaders = null; if(headers != null && headers.Count != 0) // add custom headers @@ -659,7 +667,7 @@ string GetRequestUri() readonly HttpClient client; readonly string baseUri; - readonly KubernetesClientConfiguration config; + readonly ServiceClientCredentials credentials; Dictionary> headers, query; string _accept = "application/json", _mediaType = "application/json"; string _group, _name, _ns, _rawUri, _subresource, _type, _version, _watchVersion; @@ -679,9 +687,9 @@ static string CheckHeaderName(string name) static string NormalizeEmpty(string value) => string.IsNullOrEmpty(value) ? null : value; // normalizes empty strings to null } - #endregion +#endregion - #region KubernetesResponse +#region KubernetesResponse /// Represents a response to a . public sealed class KubernetesResponse : IDisposable { @@ -765,5 +773,5 @@ public async Task GetStatusAsync() string body; } - #endregion +#endregion } diff --git a/src/KubernetesClient/Scheme.cs b/src/KubernetesClient/KubernetesScheme.cs similarity index 87% rename from src/KubernetesClient/Scheme.cs rename to src/KubernetesClient/KubernetesScheme.cs index 7ce94318c..c2bce6e65 100644 --- a/src/KubernetesClient/Scheme.cs +++ b/src/KubernetesClient/KubernetesScheme.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Reflection; +using k8s.Models; namespace k8s { @@ -50,6 +51,31 @@ public void GetVK(Type type, out string apiVersion, out string kind, out string public void GetVK(out string apiVersion, out string kind, out string path) => GetVK(typeof(T), out apiVersion, out kind, out path); + /// Creates a new Kubernetes object of the given type and sets its and + /// . + /// + public T New() where T : IKubernetesObject, new() + { + string apiVersion, kind; + GetVK(typeof(T), out apiVersion, out kind); + return new T() { ApiVersion = apiVersion, Kind = kind }; + } + + /// Creates a new Kubernetes object of the given type and sets its , + /// , and . + /// + public T New(string name) where T : IKubernetesObject, new() => New(null, name); + + /// Creates a new Kubernetes object of the given type and sets its , + /// , , and . + /// + public T New(string ns, string name) where T : IKubernetesObject, new() + { + T obj = New(); + obj.Metadata = new V1ObjectMeta() { NamespaceProperty = ns, Name = name }; + return obj; + } + /// Removes GVK information about the given type of object. public void RemoveGVK(Type type) { diff --git a/tests/KubernetesClient.Tests/Kubernetes.Fluent.Tests.cs b/tests/KubernetesClient.Tests/Kubernetes.Fluent.Tests.cs index e9e4d7a5f..dfc682e35 100644 --- a/tests/KubernetesClient.Tests/Kubernetes.Fluent.Tests.cs +++ b/tests/KubernetesClient.Tests/Kubernetes.Fluent.Tests.cs @@ -5,9 +5,9 @@ using System.Net; using System.Net.Http; using System.Text; -using System.Threading; using System.Threading.Tasks; using k8s.Models; +using k8s.Tests.Mock; using Newtonsoft.Json; using Xunit; @@ -19,7 +19,7 @@ public class FluentTests public void TestRequestProperties() { var testScheme = new KubernetesScheme(); - var r = new KubernetesRequest(new Uri("http://somewhere"), new HttpClient(), testScheme); + var r = new KubernetesRequest(new Uri("http://somewhere"), scheme: testScheme); // verify the initial values Assert.Equal("application/json", r.Accept()); @@ -184,7 +184,7 @@ public void TestRequestProperties() [Fact] public void TestRequestQuery() { - var r = new KubernetesRequest(new Uri("http://somewhere"), new HttpClient()); + var r = new KubernetesRequest(new Uri("http://somewhere")); // test basic query-string operations Assert.Null(r.GetQuery("k")); @@ -218,7 +218,7 @@ public void TestRequestQuery() [Fact] public void TestRequestHeaders() { - var r = new KubernetesRequest(new Uri("http://somewhere"), new HttpClient()); + var r = new KubernetesRequest(new Uri("http://somewhere")); // test basic header operations Assert.Null(r.GetHeader("k")); @@ -253,10 +253,10 @@ public void TestRequestHeaders() [Fact] public async Task TestExecution() { - var h = new MockHandler(_ => new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("{\"apiVersion\":\"xyz\"}") }); + var h = new MockHttpHandler(_ => new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("{\"apiVersion\":\"xyz\"}") }); var c = new Kubernetes(new KubernetesClientConfiguration() { Host = "http://localhost:8080" }, new HttpClient(h)); - await c.New().AddHeader("Test", "yes").AddQuery("x", "a", "b c").Body("hello").ExecuteAsync(); + await c.Request().AddHeader("Test", "yes").AddQuery("x", "a", "b c").Body("hello").ExecuteAsync(); Assert.Equal(HttpMethod.Get, h.Request.Method); Assert.Equal(new Uri("http://localhost:8080/api/v1/pods?x=a&x=b%20c"), h.Request.RequestUri); Assert.Equal("yes", h.Request.Headers.GetValues("Test").Single()); @@ -265,7 +265,7 @@ public async Task TestExecution() Assert.Equal("hello", await h.Request.Content.ReadAsStringAsync()); var res = new CustomNew() { ApiVersion = "abc" }; - await c.New("ns", "name") + await c.Request("ns", "name") .Accept("text/plain").MediaType("text/rtf").Delete().DryRun(true).Body(res).Status().ExecuteAsync(); Assert.Equal(HttpMethod.Delete, h.Request.Method); Assert.Equal(new Uri("http://localhost:8080/apis/cgrp/v3/namespaces/ns/newz/name/status?dryRun=All"), h.Request.RequestUri); @@ -273,74 +273,74 @@ await c.New("ns", "name") Assert.Equal("text/rtf; charset=UTF-8", h.Request.Content.Headers.ContentType.ToString()); Assert.Equal("{\"apiVersion\":\"abc\"}", await h.Request.Content.ReadAsStringAsync()); - await c.New().RawUri("/foobar").Post().LabelSelector("ls").WatchVersion("3").Body(Encoding.UTF8.GetBytes("bytes")).ExecuteAsync(); + await c.Request().RawUri("/foobar").Post().LabelSelector("ls").WatchVersion("3").Body(Encoding.UTF8.GetBytes("bytes")).ExecuteAsync(); Assert.Equal(HttpMethod.Post, h.Request.Method); Assert.Equal(new Uri("http://localhost:8080/foobar?labelSelector=ls&watch=1&resourceVersion=3"), h.Request.RequestUri); Assert.Equal("bytes", await h.Request.Content.ReadAsStringAsync()); - await c.New().RawUri("/foobar/").WatchVersion("").Body(new MemoryStream(Encoding.UTF8.GetBytes("streaming"))).ExecuteAsync(); + await c.Request().RawUri("/foobar/").WatchVersion("").Body(new MemoryStream(Encoding.UTF8.GetBytes("streaming"))).ExecuteAsync(); Assert.Equal(new Uri("http://localhost:8080/foobar/?watch=1"), h.Request.RequestUri); Assert.Equal("streaming", await h.Request.Content.ReadAsStringAsync()); - await Assert.ThrowsAsync(() => c.New().Name("x").RawUri("/y").ExecuteAsync()); // can't use raw and non-raw + await Assert.ThrowsAsync(() => c.Request().Name("x").RawUri("/y").ExecuteAsync()); // can't use raw and non-raw c = new Kubernetes(new KubernetesClientConfiguration() { Host = "http://localhost:8080", AccessToken = "token" }, new HttpClient(h)); - await c.New().ExecuteAsync(); + await c.Request().ExecuteAsync(); Assert.Equal(new Uri("http://localhost:8080/api/v1/"), h.Request.RequestUri); Assert.Equal("Bearer token", h.Request.Headers.Authorization.ToString()); c = new Kubernetes(new KubernetesClientConfiguration() { Host = "http://localhost:8080", Username = "joe" }, new HttpClient(h)); - await c.New().ExecuteAsync(); + await c.Request().ExecuteAsync(); Assert.Equal("Basic am9lOg==", h.Request.Headers.Authorization.ToString()); - res = await c.New().ExecuteAsync(); + res = await c.Request().ExecuteAsync(); Assert.Equal("xyz", res.ApiVersion); - h = new MockHandler(_ => new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("") }); + h = new MockHttpHandler(_ => new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("") }); c = new Kubernetes(new KubernetesClientConfiguration() { Host = "http://localhost:8080" }, new HttpClient(h)); - res = await c.New().ExecuteAsync(); + res = await c.Request().ExecuteAsync(); Assert.Null(res); - res = await c.New().ExecuteAsync(failIfMissing: true); // missing only refers to 404 Not Found + res = await c.Request().ExecuteAsync(failIfMissing: true); // missing only refers to 404 Not Found Assert.Null(res); - h = new MockHandler(_ => new HttpResponseMessage(HttpStatusCode.NotFound) { Content = new StringContent("{}") }); + h = new MockHttpHandler(_ => new HttpResponseMessage(HttpStatusCode.NotFound) { Content = new StringContent("{}") }); c = new Kubernetes(new KubernetesClientConfiguration() { Host = "http://localhost:8080" }, new HttpClient(h)); - res = await c.New().ExecuteAsync(); + res = await c.Request().ExecuteAsync(); Assert.Null(res); - await Assert.ThrowsAsync(() => c.New().ExecuteAsync(failIfMissing: true)); + await Assert.ThrowsAsync(() => c.Request().ExecuteAsync(failIfMissing: true)); - h = new MockHandler(_ => new HttpResponseMessage(HttpStatusCode.BadRequest) { Content = new StringContent("{}") }); + h = new MockHttpHandler(_ => new HttpResponseMessage(HttpStatusCode.BadRequest) { Content = new StringContent("{}") }); c = new Kubernetes(new KubernetesClientConfiguration() { Host = "http://localhost:8080" }, new HttpClient(h)); - await Assert.ThrowsAsync(() => c.New().ExecuteAsync()); + await Assert.ThrowsAsync(() => c.Request().ExecuteAsync()); } [Fact] - public void TestNew() + public void TestNewRequest() { - var c = new Kubernetes(new Uri("http://somewhere"), new Microsoft.Rest.TokenCredentials("token"), new MockHandler(null)); + var c = new Kubernetes(new Uri("http://somewhere"), new Microsoft.Rest.TokenCredentials("token"), new MockHttpHandler(null)); c.Scheme = new KubernetesScheme(); c.Scheme.SetGVK(typeof(CustomOld), "group", "version", "Custom", "customs"); - // test New(HttpMethod = null) - var r = c.New(); + // test c.Request(HttpMethod = null) + var r = c.Request(); Assert.Same(HttpMethod.Get, r.Method()); - r = c.New(HttpMethod.Delete); + r = c.Request(HttpMethod.Delete); Assert.Same(HttpMethod.Delete, r.Method()); - // test New(Type) - r = c.New(typeof(V1MutatingWebhookConfiguration)); + // test c.Request(Type) + r = c.Request(typeof(V1MutatingWebhookConfiguration)); Assert.Equal("admissionregistration.k8s.io", r.Group()); Assert.Equal("v1", r.Version()); Assert.Equal("mutatingwebhookconfigurations", r.Type()); - r = c.New(typeof(CustomOld)); // ensure it defaults to the scheme from the client + r = c.Request(typeof(CustomOld)); // ensure it defaults to the scheme from the client Assert.Same(c.Scheme, r.Scheme()); Assert.Equal("group", r.Group()); Assert.Equal("version", r.Version()); Assert.Equal("customs", r.Type()); - // test c.New(obj, bool) + // test c.Request(obj, bool) var res = new CustomNew() { ApiVersion = "coolstuff/v7", Kind = "yep", Metadata = new V1ObjectMeta() { Name = "name", NamespaceProperty = "ns" } }; - r = c.New(res); + r = c.Request(res); Assert.Equal("coolstuff", r.Group()); Assert.Equal("v7", r.Version()); Assert.Equal("newz", r.Type()); @@ -349,23 +349,23 @@ public void TestNew() Assert.Same(res, r.Body()); res.Metadata.Uid = "id"; - r = c.New(res, setBody: false); + r = c.Request(res, setBody: false); Assert.Equal("name", r.Name()); Assert.Null(r.Body()); - // test c.New(HttpMethod, Type, string, string) - r = c.New(null, typeof(V1PodList), "ns", "name"); + // test c.Request(HttpMethod, Type, string, string) + r = c.Request(null, typeof(V1PodList), "ns", "name"); Assert.Same(HttpMethod.Get, r.Method()); Assert.Null(r.Group()); Assert.Equal("v1", r.Version()); Assert.Equal("pods", r.Type()); Assert.Equal("name", r.Name()); Assert.Equal("ns", r.Namespace()); - r = c.New(HttpMethod.Delete, typeof(V1PodList), "ns", "name"); + r = c.Request(HttpMethod.Delete, typeof(V1PodList), "ns", "name"); Assert.Same(HttpMethod.Delete, r.Method()); - // test c.New(HttpMethod, string, string, string, string, string) - r = c.New(HttpMethod.Put, "type", "ns", "name", "group", "version"); + // test c.Request(HttpMethod, string, string, string, string, string) + r = c.Request(HttpMethod.Put, "type", "ns", "name", "group", "version"); Assert.Same(HttpMethod.Put, r.Method()); Assert.Equal("type", r.Type()); Assert.Equal("ns", r.Namespace()); @@ -373,9 +373,9 @@ public void TestNew() Assert.Equal("group", r.Group()); Assert.Equal("version", r.Version()); - // test c.New(string, string) + // test c.Request(string, string) c.Scheme = KubernetesScheme.Default; - r = c.New("ns", "name"); + r = c.Request("ns", "name"); Assert.Equal("v0", r.Version()); Assert.Equal("ogrp", r.Group()); Assert.Equal("nos", r.Type()); @@ -388,7 +388,7 @@ public async Task TestReplace() { string value = "{}"; bool conflict = true; - var h = new MockHandler(req => + var h = new MockHttpHandler(req => { if(value == null) { @@ -413,23 +413,23 @@ public async Task TestReplace() var c = new Kubernetes(new KubernetesClientConfiguration() { Host = "http://localhost:8080" }, new HttpClient(h)); int i = 0; - var res = await c.New().ReplaceAsync(r => { r.SetAnnotation("a", (i++).ToString(CultureInfo.InvariantCulture)); return true; }); + var res = await c.Request().ReplaceAsync(r => { r.SetAnnotation("a", (i++).ToString(CultureInfo.InvariantCulture)); return true; }); Assert.Equal("1", res.GetAnnotation("a")); - res = await c.New().ReplaceAsync(r => { r.SetAnnotation("b", "x"); return true; }); + res = await c.Request().ReplaceAsync(r => { r.SetAnnotation("b", "x"); return true; }); Assert.Equal("1", res.GetAnnotation("a")); Assert.Equal("x", res.GetAnnotation("b")); - res = await c.New().ReplaceAsync(r => { r.SetAnnotation("c", "y"); return false; }); + res = await c.Request().ReplaceAsync(r => { r.SetAnnotation("c", "y"); return false; }); Assert.Equal("x", res.GetAnnotation("b")); Assert.Equal("y", res.GetAnnotation("c")); - res = await c.New().ReplaceAsync(r => false); + res = await c.Request().ReplaceAsync(r => false); Assert.Equal("x", res.GetAnnotation("b")); Assert.Null(res.GetAnnotation("c")); value = null; - res = await c.New().ReplaceAsync(r => { r.SetAnnotation("a", "x"); return true; }); + res = await c.Request().ReplaceAsync(r => { r.SetAnnotation("a", "x"); return true; }); Assert.Null(res); } @@ -489,20 +489,5 @@ class CustomOld : IKubernetesObject public string Kind { get; set; } public V1ObjectMeta Metadata { get; set; } } - - class MockHandler : HttpClientHandler - { - public MockHandler(Func respFunc) => this.respFunc = respFunc; - - public HttpRequestMessage Request; - - protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) - { - Request = request; - return Task.FromResult(respFunc(request)); - } - - readonly Func respFunc; - } } } diff --git a/tests/KubernetesClient.Tests/KubernetesSchemeTests.cs b/tests/KubernetesClient.Tests/KubernetesSchemeTests.cs new file mode 100644 index 000000000..9c2739286 --- /dev/null +++ b/tests/KubernetesClient.Tests/KubernetesSchemeTests.cs @@ -0,0 +1,172 @@ +using System; +using k8s.Models; +using Xunit; + +namespace k8s.Tests +{ + public class KubernetesSchemeTests + { + [Fact] + public void TestGVK() + { + Assert.NotNull(KubernetesScheme.Default); + + var s = new KubernetesScheme(); + + // test s.GetGVK + string g, v, k, p; + s.GetGVK(out g, out v, out k, out p); + Assert.Equal("", g); + Assert.Equal("v1", v); + Assert.Equal("Pod", k); + Assert.Equal("pods", p); + + s.GetGVK(out g, out v, out k, out p); + Assert.Equal("", g); + Assert.Equal("v1", v); + Assert.Equal("PodList", k); + Assert.Equal("pods", p); + + s.GetGVK(out g, out v, out k, out p); + Assert.Equal("cgrp", g); + Assert.Equal("v3", v); + Assert.Equal("Yes", k); + Assert.Equal("newz", p); + + s.GetGVK(out g, out v, out k, out p); + Assert.Equal("ogrp", g); + Assert.Equal("v0", v); + Assert.Equal("No", k); + Assert.Equal("nos", p); + + Assert.Throws(() => s.GetGVK(out g, out v, out k)); + + // test s.GetVK + s.GetVK(out v, out k, out p); + Assert.Equal("v1", v); + Assert.Equal("Pod", k); + Assert.Equal("pods", p); + + s.GetVK(out v, out k, out p); + Assert.Equal("v1", v); + Assert.Equal("PodList", k); + Assert.Equal("pods", p); + + s.GetVK(out v, out k, out p); + Assert.Equal("cgrp/v3", v); + Assert.Equal("Yes", k); + Assert.Equal("newz", p); + + s.GetVK(out v, out k, out p); + Assert.Equal("ogrp/v0", v); + Assert.Equal("No", k); + Assert.Equal("nos", p); + + Assert.Throws(() => s.GetVK(out v, out k)); + + // test s.TryGetGVK + Assert.True(s.TryGetGVK(out g, out v, out k, out p)); + Assert.Equal("", g); + Assert.Equal("v1", v); + Assert.Equal("Pod", k); + Assert.Equal("pods", p); + + Assert.False(s.TryGetGVK(out g, out v, out k, out p)); + + // test s.TryGetVK + Assert.True(s.TryGetVK(out v, out k, out p)); + Assert.Equal("v1", v); + Assert.Equal("Pod", k); + Assert.Equal("pods", p); + + Assert.True(s.TryGetVK(out v, out k, out p)); + Assert.Equal("ogrp/v0", v); + Assert.Equal("No", k); + Assert.Equal("nos", p); + + Assert.False(s.TryGetVK(out v, out k, out p)); + + // test s.SetGVK and s.RemoveGVK + s.SetGVK("g", "v", "k", "p"); + s.GetVK(out v, out k, out p); + Assert.Equal("g/v", v); + Assert.Equal("k", k); + Assert.Equal("p", p); + + s.GetGVK(out g, out v, out k, out p); + Assert.Equal("", g); + Assert.Equal("v1", v); + Assert.Equal("PodList", k); + Assert.Equal("pods", p); + + s.SetGVK("g2", "v2", "k2", "p2"); + s.GetVK(out v, out k, out p); + Assert.Equal("g2/v2", v); + Assert.Equal("k2", k); + Assert.Equal("p2", p); + + s.RemoveGVK(); + s.GetGVK(out g, out v, out k, out p); + Assert.Equal("", g); + Assert.Equal("v1", v); + Assert.Equal("Pod", k); + Assert.Equal("pods", p); + + s.RemoveGVK(); + Assert.Throws(() => s.GetGVK(out g, out v, out k)); + } + + [Fact] + public void TestNew() + { + var s = new KubernetesScheme(); + + var p = s.New(); + Assert.Equal("v1", p.ApiVersion); + Assert.Equal("Pod", p.Kind); + + var cn = s.New("name"); + Assert.Equal("cgrp/v3", cn.ApiVersion); + Assert.Equal("Yes", cn.Kind); + Assert.Equal("name", cn.Metadata.Name); + + var co = s.New("ns", "name"); + Assert.Equal("ogrp/v0", co.ApiVersion); + Assert.Equal("No", co.Kind); + Assert.Equal("name", co.Metadata.Name); + Assert.Equal("ns", co.Metadata.NamespaceProperty); + + Assert.Throws(() => s.New()); + s.SetGVK("g", "v", "k", "p"); + + var c = s.New("ns", "name"); + Assert.Equal("g/v", c.ApiVersion); + Assert.Equal("k", c.Kind); + Assert.Equal("name", c.Metadata.Name); + Assert.Equal("ns", c.Metadata.NamespaceProperty); + } + + [KubernetesEntity(ApiVersion = "v3", Group = "cgrp", Kind = "Yes", PluralName = "newz")] + class CustomNew : IKubernetesObject + { + public string ApiVersion { get; set; } + public string Kind { get; set; } + public V1ObjectMeta Metadata { get; set; } + } + + class CustomOld : IKubernetesObject + { + public const string KubeApiVersion = "v0", KubeGroup = "ogrp", KubeKind = "No"; + public string ApiVersion { get; set; } + public string Kind { get; set; } + public V1ObjectMeta Metadata { get; set; } + } + + class Custom : IKubernetesObject + { + public string ApiVersion { get; set; } + public string Kind { get; set; } + public V1ObjectMeta Metadata { get; set; } + } + } +} diff --git a/tests/KubernetesClient.Tests/Mock/MockHttpHandler.cs b/tests/KubernetesClient.Tests/Mock/MockHttpHandler.cs new file mode 100644 index 000000000..ad2a01999 --- /dev/null +++ b/tests/KubernetesClient.Tests/Mock/MockHttpHandler.cs @@ -0,0 +1,22 @@ +using System; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; + +namespace k8s.Tests.Mock +{ + class MockHttpHandler : HttpClientHandler + { + public MockHttpHandler(Func respFunc) => this.respFunc = respFunc; + + public HttpRequestMessage Request; + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + Request = request; + return Task.FromResult(respFunc(request)); + } + + readonly Func respFunc; + } +} From 05d4034439a8e1e999d13278c243d156a23b0fbf Mon Sep 17 00:00:00 2001 From: Adam Milazzo Date: Thu, 9 Apr 2020 20:35:08 -0700 Subject: [PATCH 10/13] Reformat --- src/KubernetesClient/Kubernetes.ConfigInit.cs | 8 +- src/KubernetesClient/KubernetesRequest.cs | 158 +++++++++--------- src/KubernetesClient/KubernetesScheme.cs | 32 ++-- 3 files changed, 99 insertions(+), 99 deletions(-) diff --git a/src/KubernetesClient/Kubernetes.ConfigInit.cs b/src/KubernetesClient/Kubernetes.ConfigInit.cs index aef8faaab..9b3b6eb8f 100644 --- a/src/KubernetesClient/Kubernetes.ConfigInit.cs +++ b/src/KubernetesClient/Kubernetes.ConfigInit.cs @@ -72,7 +72,7 @@ public KubernetesScheme Scheme get => _scheme; set { - if(value == null) throw new ArgumentNullException(nameof(Scheme)); + if (value == null) throw new ArgumentNullException(nameof(Scheme)); _scheme = value; } } @@ -278,12 +278,12 @@ public static JsonSerializerSettings CreateSerializerSettings() /// internal static ServiceClientCredentials CreateCredentials(KubernetesClientConfiguration config) { - if(config == null) throw new ArgumentNullException(nameof(config)); - if(!string.IsNullOrEmpty(config.AccessToken)) + if (config == null) throw new ArgumentNullException(nameof(config)); + if (!string.IsNullOrEmpty(config.AccessToken)) { return new TokenCredentials(config.AccessToken); } - else if(!string.IsNullOrEmpty(config.Username)) + else if (!string.IsNullOrEmpty(config.Username)) { return new BasicAuthenticationCredentials() { UserName = config.Username, Password = config.Password }; } diff --git a/src/KubernetesClient/KubernetesRequest.cs b/src/KubernetesClient/KubernetesRequest.cs index 3087400b8..d2b8fd927 100644 --- a/src/KubernetesClient/KubernetesRequest.cs +++ b/src/KubernetesClient/KubernetesRequest.cs @@ -21,7 +21,7 @@ public sealed class KubernetesRequest : ICloneable /// Initializes a based on a . public KubernetesRequest(Kubernetes client) { - if(client == null) throw new ArgumentNullException(nameof(client)); + if (client == null) throw new ArgumentNullException(nameof(client)); (baseUri, credentials, this.client) = (client.BaseUri.ToString(), client.Credentials, client.HttpClient); Scheme(client.Scheme); } @@ -39,9 +39,9 @@ public KubernetesRequest(Kubernetes client) /// Any necessary SSL configuration must have already been applied to the . public KubernetesRequest(KubernetesClientConfiguration config, HttpClient client = null, KubernetesScheme scheme = null) { - if(config == null) throw new ArgumentNullException(nameof(config)); + if (config == null) throw new ArgumentNullException(nameof(config)); this.baseUri = config.Host; - if(string.IsNullOrEmpty(this.baseUri)) throw new ArgumentException(nameof(config)+".Host"); + if (string.IsNullOrEmpty(this.baseUri)) throw new ArgumentException(nameof(config)+".Host"); credentials = Kubernetes.CreateCredentials(config); this.client = client ?? new HttpClient(); Scheme(scheme); @@ -61,8 +61,8 @@ public KubernetesRequest(KubernetesClientConfiguration config, HttpClient client /// Any necessary SSL configuration must have already been applied to the . public KubernetesRequest(Uri baseUri, ServiceClientCredentials credentials = null, HttpClient client = null, KubernetesScheme scheme = null) { - if(baseUri == null) throw new ArgumentNullException(nameof(baseUri)); - if(!baseUri.IsAbsoluteUri) throw new ArgumentException("The base URI must be absolute.", nameof(baseUri)); + if (baseUri == null) throw new ArgumentNullException(nameof(baseUri)); + if (!baseUri.IsAbsoluteUri) throw new ArgumentException("The base URI must be absolute.", nameof(baseUri)); (this.baseUri, this.credentials, this.client) = (baseUri.ToString(), credentials, client = client ?? new HttpClient()); Scheme(scheme); } @@ -102,31 +102,31 @@ public KubernetesRequest(Uri baseUri, ServiceClientCredentials credentials = nul /// Clears custom header values with the given name. public KubernetesRequest ClearHeader(string headerName) { - if(headerName == null) throw new ArgumentNullException(nameof(headerName)); + if (headerName == null) throw new ArgumentNullException(nameof(headerName)); CheckHeaderName(headerName); - if(headers != null) headers.Remove(headerName); + if (headers != null) headers.Remove(headerName); return this; } /// Clears all custom header values. public KubernetesRequest ClearHeaders() { - if(headers != null) headers.Clear(); + if (headers != null) headers.Clear(); return this; } /// Clears all query-string parameters. public KubernetesRequest ClearQuery() { - if(query != null) query.Clear(); + if (query != null) query.Clear(); return this; } /// Clears all query-string parameters with the given key. public KubernetesRequest ClearQuery(string key) { - if(key == null) throw new ArgumentNullException(nameof(key)); - if(query != null) query.Remove(key); + if (key == null) throw new ArgumentNullException(nameof(key)); + if (query != null) query.Remove(key); return this; } @@ -134,15 +134,15 @@ public KubernetesRequest ClearQuery(string key) public KubernetesRequest Clone() { var clone = (KubernetesRequest)MemberwiseClone(); - if(headers != null) + if (headers != null) { clone.headers = new Dictionary>(headers.Count); - foreach(KeyValuePair> pair in headers) clone.headers.Add(pair.Key, new List(pair.Value)); + foreach (KeyValuePair> pair in headers) clone.headers.Add(pair.Key, new List(pair.Value)); } - if(query != null) + if (query != null) { clone.query = new Dictionary>(query.Count); - foreach(KeyValuePair> pair in query) clone.query.Add(pair.Key, new List(pair.Value)); + foreach (KeyValuePair> pair in query) clone.query.Add(pair.Key, new List(pair.Value)); } return clone; } @@ -195,7 +195,7 @@ public async Task ExecuteAsync(CancellationToken cancelToken /// Thrown if the response was any error besides 404 Not Found. public async Task ExecuteAsync(bool failIfMissing, CancellationToken cancelToken = default) { - if(_watchVersion != null) throw new InvalidOperationException("Watch requests cannot be deserialized all at once."); + if (_watchVersion != null) throw new InvalidOperationException("Watch requests cannot be deserialized all at once."); cancelToken.ThrowIfCancellationRequested(); HttpRequestMessage reqMsg = await CreateRequestMessage(cancelToken).ConfigureAwait(false); return await ExecuteMessageAsync(reqMsg, failIfMissing, cancelToken).ConfigureAwait(false); @@ -220,7 +220,7 @@ public KubernetesRequest FieldSelector(string selector) => public string GetHeader(string key) { List values = null; - if(headers != null) headers.TryGetValue(key, out values); + if (headers != null) headers.TryGetValue(key, out values); return values == null || values.Count == 0 ? null : values.Count == 1 ? values[0] : throw new InvalidOperationException($"There are multiple query-string parameters named '{key}'."); } @@ -230,7 +230,7 @@ public string GetHeader(string key) public List GetHeaderValues(string key) { List values = null; - if(headers != null) headers.TryGetValue(key, out values); + if (headers != null) headers.TryGetValue(key, out values); return values; } @@ -248,7 +248,7 @@ public string GetQuery(string key) public List GetQueryValues(string key) { List values = null; - if(query != null) query.TryGetValue(key, out values); + if (query != null) query.TryGetValue(key, out values); return values; } @@ -268,9 +268,9 @@ public List GetQueryValues(string key) /// public KubernetesRequest GVK(IKubernetesObject obj) { - if(obj == null) throw new ArgumentNullException(); + if (obj == null) throw new ArgumentNullException(); GVK(obj.GetType()); - if(!string.IsNullOrEmpty(obj.ApiVersion)) // if the object has an API version set, use it... + if (!string.IsNullOrEmpty(obj.ApiVersion)) // if the object has an API version set, use it... { int slash = obj.ApiVersion.IndexOf('/'); // the ApiVersion field is in the form "version" or "group/version" Group(slash >= 0 ? obj.ApiVersion.Substring(0, slash) : null).Version(obj.ApiVersion.Substring(slash+1)); @@ -290,7 +290,7 @@ public KubernetesRequest GVK(string group, string version, string kind) => /// public KubernetesRequest GVK(Type type) { - if(type == null) throw new ArgumentNullException(nameof(type)); + if (type == null) throw new ArgumentNullException(nameof(type)); _scheme.GetGVK(type, out string group, out string version, out string kind, out string path); return Group(NormalizeEmpty(group)).Version(version).Type(path); } @@ -344,7 +344,7 @@ public KubernetesRequest LabelSelector(string selector) => public KubernetesRequest RawUri(string uri) { uri = NormalizeEmpty(uri); - if(uri != null && uri[0] != '/') throw new ArgumentException("The URI must begin with a slash."); + if (uri != null && uri[0] != '/') throw new ArgumentException("The URI must begin with a slash."); _rawUri = uri; return this; } @@ -384,7 +384,7 @@ public Task ReplaceAsync( public Task ReplaceAsync(T obj, Func modify, bool failIfMissing = false, CancellationToken cancelToken = default) where T : class { - if(modify == null) throw new ArgumentNullException(nameof(modify)); + if (modify == null) throw new ArgumentNullException(nameof(modify)); return ReplaceAsync(obj, (o,ct) => Task.FromResult(modify(o)), failIfMissing, cancelToken); } @@ -401,12 +401,12 @@ public async Task ReplaceAsync( T obj, Func> modify, bool failIfMissing = false, CancellationToken cancelToken = default) where T : class { - if(modify == null) throw new ArgumentNullException(nameof(modify)); - if(_watchVersion != null) throw new InvalidOperationException("Watches cannot be updated."); + if (modify == null) throw new ArgumentNullException(nameof(modify)); + if (_watchVersion != null) throw new InvalidOperationException("Watches cannot be updated."); KubernetesRequest putReq = null; - while(true) + while (true) { - if(obj == null) // if we need to load the resource... + if (obj == null) // if we need to load the resource... { cancelToken.ThrowIfCancellationRequested(); HttpRequestMessage getMsg = await CreateRequestMessage(cancelToken).ConfigureAwait(false); // load it with a GET request @@ -415,13 +415,13 @@ public async Task ReplaceAsync( } cancelToken.ThrowIfCancellationRequested(); // if the resource is missing or no changes are needed, return it as-is - if(obj == null || !await modify(obj, cancelToken).ConfigureAwait(false)) return obj; - if(putReq == null) putReq = Clone().Put(); + if (obj == null || !await modify(obj, cancelToken).ConfigureAwait(false)) return obj; + if (putReq == null) putReq = Clone().Put(); KubernetesResponse resp = await putReq.Body(obj).ExecuteAsync(cancelToken).ConfigureAwait(false); // otherwise, update it - if(resp.StatusCode != HttpStatusCode.Conflict) // if there was no conflict, return the result + if (resp.StatusCode != HttpStatusCode.Conflict) // if there was no conflict, return the result { - if(resp.IsNotFound && !failIfMissing) return null; - else if(resp.IsError) throw new KubernetesException(await resp.GetStatusAsync().ConfigureAwait(false)); + if (resp.IsNotFound && !failIfMissing) return null; + else if (resp.IsError) throw new KubernetesException(await resp.GetStatusAsync().ConfigureAwait(false)); else return await resp.GetBodyAsync().ConfigureAwait(false); } obj = null; // otherwise, there was a conflict, so reload the item @@ -447,9 +447,9 @@ public async Task ReplaceAsync( public KubernetesRequest Set(IKubernetesObject obj, bool setBody = true) { GVK(obj); - if(setBody) Body(obj); + if (setBody) Body(obj); var kobj = obj as IMetadata; - if(kobj != null) Namespace(kobj.Namespace()).Name(!string.IsNullOrEmpty(kobj.Uid()) ? kobj.Name() : null); + if (kobj != null) Namespace(kobj.Namespace()).Name(!string.IsNullOrEmpty(kobj.Uid()) ? kobj.Name() : null); return this; } @@ -524,9 +524,9 @@ public KubernetesRequest Subresources(params string[] subresources) => /// Adds a value to the query string or headers. KubernetesRequest Add(ref Dictionary> dict, string key, string value) { - if(string.IsNullOrEmpty(key)) throw new ArgumentNullException(nameof(key)); - if(dict == null) dict = new Dictionary>(); - if(!dict.TryGetValue(key, out List values)) dict[key] = values = new List(); + if (string.IsNullOrEmpty(key)) throw new ArgumentNullException(nameof(key)); + if (dict == null) dict = new Dictionary>(); + if (!dict.TryGetValue(key, out List values)) dict[key] = values = new List(); values.Add(value); return this; } @@ -534,11 +534,11 @@ KubernetesRequest Add(ref Dictionary> dict, string key, stri /// Adds a value to the query string or headers. KubernetesRequest Add(ref Dictionary> dict, string key, IEnumerable values) { - if(string.IsNullOrEmpty(key)) throw new ArgumentNullException(nameof(key)); - if(values != null) + if (string.IsNullOrEmpty(key)) throw new ArgumentNullException(nameof(key)); + if (values != null) { - if(dict == null) dict = new Dictionary>(); - if(!dict.TryGetValue(key, out List list)) dict[key] = list = new List(); + if (dict == null) dict = new Dictionary>(); + if (!dict.TryGetValue(key, out List list)) dict[key] = list = new List(); list.AddRange(values); } return this; @@ -547,9 +547,9 @@ KubernetesRequest Add(ref Dictionary> dict, string key, IEn /// Sets a value in the query string or headers. KubernetesRequest Set(ref Dictionary> dict, string key, string value) { - if(string.IsNullOrEmpty(key)) throw new ArgumentNullException(nameof(key)); + if (string.IsNullOrEmpty(key)) throw new ArgumentNullException(nameof(key)); dict = dict ?? new Dictionary>(); - if(!dict.TryGetValue(key, out List values)) dict[key] = values = new List(); + if (!dict.TryGetValue(key, out List values)) dict[key] = values = new List(); values.Clear(); values.Add(value); return this; @@ -563,17 +563,17 @@ async Task CreateRequestMessage(CancellationToken cancelToke #endif { var req = new HttpRequestMessage(Method(), GetRequestUri()); - if(credentials != null) await credentials.ProcessHttpRequestAsync(req, cancelToken).ConfigureAwait(false); + if (credentials != null) await credentials.ProcessHttpRequestAsync(req, cancelToken).ConfigureAwait(false); // add the headers - if(_accept != null) req.Headers.Add("Accept", _accept); + if (_accept != null) req.Headers.Add("Accept", _accept); List>> contentHeaders = null; - if(headers != null && headers.Count != 0) // add custom headers + if (headers != null && headers.Count != 0) // add custom headers { contentHeaders = new List>>(); // some headers must be added to .Content.Headers. track them - foreach(KeyValuePair> pair in headers) + foreach (KeyValuePair> pair in headers) { - if(!req.Headers.TryAddWithoutValidation(pair.Key, pair.Value)) // if it's not legal to set this header on the request... + if (!req.Headers.TryAddWithoutValidation(pair.Key, pair.Value)) // if it's not legal to set this header on the request... { contentHeaders.Add(new KeyValuePair>(pair.Key, pair.Value)); // assume we should set it on the content break; @@ -582,21 +582,21 @@ async Task CreateRequestMessage(CancellationToken cancelToke } // add the body, if any - if(_body != null) + if (_body != null) { - if(_body is byte[] bytes) req.Content = new ByteArrayContent(bytes); - else if(_body is Stream stream) req.Content = new StreamContent(stream); + if (_body is byte[] bytes) req.Content = new ByteArrayContent(bytes); + else if (_body is Stream stream) req.Content = new StreamContent(stream); else { req.Content = new StringContent( _body as string ?? JsonConvert.SerializeObject(_body, Kubernetes.DefaultJsonSettings), Encoding.UTF8); } req.Content.Headers.ContentType = new MediaTypeHeaderValue(_mediaType ?? "application/json") { CharSet = "UTF-8" }; - if(contentHeaders != null && contentHeaders.Count != 0) // go through the headers we couldn't set on the request + if (contentHeaders != null && contentHeaders.Count != 0) // go through the headers we couldn't set on the request { - foreach(KeyValuePair> pair in contentHeaders) + foreach (KeyValuePair> pair in contentHeaders) { - if(!req.Content.Headers.TryAddWithoutValidation(pair.Key, pair.Value)) // if we can't set it on the content either... + if (!req.Content.Headers.TryAddWithoutValidation(pair.Key, pair.Value)) // if we can't set it on the content either... { throw new InvalidOperationException($"{pair.Value} is a response header and cannot be set on the request."); } @@ -610,14 +610,14 @@ async Task ExecuteMessageAsync(HttpRequestMessage msg, bool failIfMissing, { cancelToken.ThrowIfCancellationRequested(); KubernetesResponse resp = new KubernetesResponse(await client.SendAsync(msg, cancelToken).ConfigureAwait(false)); - if(resp.IsNotFound && !failIfMissing) return default(T); - else if(resp.IsError) throw new KubernetesException(await resp.GetStatusAsync().ConfigureAwait(false)); + if (resp.IsNotFound && !failIfMissing) return default(T); + else if (resp.IsError) throw new KubernetesException(await resp.GetStatusAsync().ConfigureAwait(false)); else return await resp.GetBodyAsync().ConfigureAwait(false); } string GetRequestUri() { - if(_rawUri != null && (_group ?? _name ?? _ns ?? _subresource ?? _type ?? _version) != null) + if (_rawUri != null && (_group ?? _name ?? _ns ?? _subresource ?? _type ?? _version) != null) { throw new InvalidOperationException("You cannot use both raw and piecemeal URIs."); } @@ -625,40 +625,40 @@ string GetRequestUri() // construct the request URL var sb = new StringBuilder(); sb.Append(baseUri); - if(_rawUri != null) // if a raw URL was given, use it + if (_rawUri != null) // if a raw URL was given, use it { - if(sb[sb.Length-1] == '/') sb.Length--; // the raw URI starts with a slash, so ensure the base URI doesn't end with one + if (sb[sb.Length-1] == '/') sb.Length--; // the raw URI starts with a slash, so ensure the base URI doesn't end with one sb.Append(_rawUri); } else // otherwise, construct it piecemeal { - if(sb[sb.Length-1] != '/') sb.Append('/'); // ensure the base URI ends with a slash - if(_group != null) sb.Append("apis/").Append(_group); + if (sb[sb.Length-1] != '/') sb.Append('/'); // ensure the base URI ends with a slash + if (_group != null) sb.Append("apis/").Append(_group); else sb.Append("api"); sb.Append('/').Append(_version ?? "v1"); - if(_ns != null) sb.Append("/namespaces/").Append(_ns); + if (_ns != null) sb.Append("/namespaces/").Append(_ns); sb.Append('/').Append(_type); - if(_name != null) sb.Append('/').Append(_name); - if(_subresource != null) sb.Append('/').Append(_subresource); + if (_name != null) sb.Append('/').Append(_name); + if (_subresource != null) sb.Append('/').Append(_subresource); } bool firstParam = true; - if(query != null) // then add the query string, if any + if (query != null) // then add the query string, if any { - foreach(KeyValuePair> pair in query) + foreach (KeyValuePair> pair in query) { string key = Uri.EscapeDataString(pair.Key); - foreach(string value in pair.Value) + foreach (string value in pair.Value) { sb.Append(firstParam ? '?' : '&').Append(key).Append('='); - if(!string.IsNullOrEmpty(value)) sb.Append(Uri.EscapeDataString(value)); + if (!string.IsNullOrEmpty(value)) sb.Append(Uri.EscapeDataString(value)); firstParam = false; } } } - if(_watchVersion != null) + if (_watchVersion != null) { sb.Append(firstParam ? '?' : '&').Append("watch=1"); - if(_watchVersion.Length != 0) sb.Append("&resourceVersion=").Append(_watchVersion); + if (_watchVersion.Length != 0) sb.Append("&resourceVersion=").Append(_watchVersion); } return sb.ToString(); } @@ -678,7 +678,7 @@ string GetRequestUri() static string CheckHeaderName(string name) { - if(name == "Accept" || name == "Content-Type") + if (name == "Accept" || name == "Content-Type") { throw new ArgumentException($"The {name} header must be set using the corresponding property."); } @@ -714,7 +714,7 @@ public sealed class KubernetesResponse : IDisposable /// Returns the response body as a string. public async Task GetBodyAsync() { - if(body == null) + if (body == null) { body = Message.Content != null ? await Message.Content.ReadAsStringAsync().ConfigureAwait(false) : string.Empty; } @@ -729,9 +729,9 @@ public async Task GetBodyAsync() public async Task GetBodyAsync(Type type, bool failIfEmpty = false) { string body = await GetBodyAsync().ConfigureAwait(false); - if(string.IsNullOrWhiteSpace(body)) + if (string.IsNullOrWhiteSpace(body)) { - if(!failIfEmpty) throw new InvalidOperationException("The response body was empty."); + if (!failIfEmpty) throw new InvalidOperationException("The response body was empty."); return null; } return JsonConvert.DeserializeObject(body, type, Kubernetes.DefaultJsonSettings); @@ -746,9 +746,9 @@ public async Task GetBodyAsync(Type type, bool failIfEmpty = false) public async Task GetBodyAsync(bool failIfEmpty = false) { string body = await GetBodyAsync().ConfigureAwait(false); - if(string.IsNullOrWhiteSpace(body)) + if (string.IsNullOrWhiteSpace(body)) { - if(failIfEmpty) throw new InvalidOperationException("The response body was empty."); + if (failIfEmpty) throw new InvalidOperationException("The response body was empty."); return default(T); } return JsonConvert.DeserializeObject(body, Kubernetes.DefaultJsonSettings); @@ -762,9 +762,9 @@ public async Task GetStatusAsync() try { var status = await GetBodyAsync().ConfigureAwait(false); - if(status != null && (status.Status == "Success" || status.Status == "Failure")) return status; + if (status != null && (status.Status == "Success" || status.Status == "Failure")) return status; } - catch(JsonException) { } + catch (JsonException) { } return new V1Status() { Status = IsError ? "Failure" : "Success", Code = (int)StatusCode, Reason = StatusCode.ToString(), Message = body diff --git a/src/KubernetesClient/KubernetesScheme.cs b/src/KubernetesClient/KubernetesScheme.cs index c2bce6e65..2ea07a1a0 100644 --- a/src/KubernetesClient/KubernetesScheme.cs +++ b/src/KubernetesClient/KubernetesScheme.cs @@ -15,7 +15,7 @@ public void GetGVK(Type type, out string group, out string version, out string k /// Gets the Kubernetes group, version, kind, and API path segment for the given type of object. public void GetGVK(Type type, out string group, out string version, out string kind, out string path) { - if(!TryGetGVK(type, out group, out version, out kind, out path)) + if (!TryGetGVK(type, out group, out version, out kind, out path)) { throw new ArgumentException($"The GVK of type {type.Name} is unknown."); } @@ -79,7 +79,7 @@ public void GetVK(out string apiVersion, out string kind, out string path) => /// Removes GVK information about the given type of object. public void RemoveGVK(Type type) { - lock(gvks) gvks.Remove(type); + lock (gvks) gvks.Remove(type); } /// Removes GVK information about the given type of object. @@ -88,11 +88,11 @@ public void RemoveGVK(Type type) /// Sets GVK information for the given type of object. public void SetGVK(Type type, string group, string version, string kind, string path) { - if(type == null) throw new ArgumentNullException(nameof(type)); - if(string.IsNullOrEmpty(version)) throw new ArgumentNullException(nameof(version)); - if(string.IsNullOrEmpty(kind)) throw new ArgumentNullException(nameof(kind)); - if(string.IsNullOrEmpty(path)) throw new ArgumentNullException(nameof(path)); - lock(gvks) gvks[type] = Tuple.Create(group ?? string.Empty, version, kind, path); + if (type == null) throw new ArgumentNullException(nameof(type)); + if (string.IsNullOrEmpty(version)) throw new ArgumentNullException(nameof(version)); + if (string.IsNullOrEmpty(kind)) throw new ArgumentNullException(nameof(kind)); + if (string.IsNullOrEmpty(path)) throw new ArgumentNullException(nameof(path)); + lock (gvks) gvks[type] = Tuple.Create(group ?? string.Empty, version, kind, path); } /// Sets GVK information for the given type of object. @@ -106,13 +106,13 @@ public bool TryGetGVK(Type type, out string group, out string version, out strin /// Gets the Kubernetes group, version, kind, and API path segment for the given type of object. public bool TryGetGVK(Type type, out string group, out string version, out string kind, out string path) { - if(type == null) throw new ArgumentNullException(nameof(type)); - lock(gvks) + if (type == null) throw new ArgumentNullException(nameof(type)); + lock (gvks) { - if(!gvks.TryGetValue(type, out Tuple gvk)) + if (!gvks.TryGetValue(type, out Tuple gvk)) { var attr = type.GetCustomAttribute(); // newer types have this attribute - if(attr != null) + if (attr != null) { gvk = Tuple.Create(attr.Group, attr.ApiVersion, attr.Kind, attr.PluralName); } @@ -120,7 +120,7 @@ public bool TryGetGVK(Type type, out string group, out string version, out strin { const BindingFlags Flags = BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Static; FieldInfo kindf = type.GetField("KubeKind", Flags), versionf = type.GetField("KubeApiVersion", Flags); - if(kindf != null && versionf != null) + if (kindf != null && versionf != null) { FieldInfo groupf = type.GetField("KubeGroup", Flags); string k = (string)kindf.GetValue(null); @@ -131,7 +131,7 @@ public bool TryGetGVK(Type type, out string group, out string version, out strin gvks[type] = gvk; } - if(gvk != null) + if (gvk != null) { (group, version, kind, path) = gvk; return true; @@ -161,7 +161,7 @@ public bool TryGetVK(Type type, out string apiVersion, out string kind) => /// public bool TryGetVK(Type type, out string apiVersion, out string kind, out string path) { - if(TryGetGVK(type, out string group, out string version, out kind, out path)) + if (TryGetGVK(type, out string group, out string version, out kind, out path)) { apiVersion = string.IsNullOrEmpty(group) ? version : group + "/" + version; return true; @@ -188,8 +188,8 @@ public bool TryGetVK(out string apiVersion, out string kind, out string path) /// Attempts to guess a type's API path segment based on its kind. internal static string GuessPath(string kind) { - if(string.IsNullOrEmpty(kind)) return null; - if(kind.Length > 4 && kind.EndsWith("List")) kind = kind.Substring(0, kind.Length-4); // e.g. PodList -> Pod + if (string.IsNullOrEmpty(kind)) return null; + if (kind.Length > 4 && kind.EndsWith("List")) kind = kind.Substring(0, kind.Length-4); // e.g. PodList -> Pod kind = kind.ToLowerInvariant(); // e.g. Pod -> pod return kind + (kind[kind.Length-1] == 's' ? "es" : "s"); // e.g. pod -> pods } From fa8aaa8b9157e71790301202a53e46556a25f7a9 Mon Sep 17 00:00:00 2001 From: Adam Milazzo Date: Mon, 13 Apr 2020 10:13:28 -0700 Subject: [PATCH 11/13] Move to Kubernetes.Fluent namespace --- .../{ => Fluent}/Kubernetes.Fluent.cs | 26 +++--- .../{ => Fluent}/KubernetesRequest.cs | 90 +----------------- .../Fluent/KubernetesResponse.cs | 93 +++++++++++++++++++ 3 files changed, 107 insertions(+), 102 deletions(-) rename src/KubernetesClient/{ => Fluent}/Kubernetes.Fluent.cs (54%) rename src/KubernetesClient/{ => Fluent}/KubernetesRequest.cs (90%) create mode 100644 src/KubernetesClient/Fluent/KubernetesResponse.cs diff --git a/src/KubernetesClient/Kubernetes.Fluent.cs b/src/KubernetesClient/Fluent/Kubernetes.Fluent.cs similarity index 54% rename from src/KubernetesClient/Kubernetes.Fluent.cs rename to src/KubernetesClient/Fluent/Kubernetes.Fluent.cs index e5f1eb831..402ae1924 100644 --- a/src/KubernetesClient/Kubernetes.Fluent.cs +++ b/src/KubernetesClient/Fluent/Kubernetes.Fluent.cs @@ -2,48 +2,48 @@ using System.Net.Http; using k8s.Models; -namespace k8s +namespace k8s.Fluent { - public partial class Kubernetes + public static class KubernetesFluent { /// Creates a new Kubernetes object of the given type and sets its and /// . /// - public T New() where T : IKubernetesObject, new() => Scheme.New(); + public static T New(this Kubernetes client) where T : IKubernetesObject, new() => client.Scheme.New(); /// Creates a new Kubernetes object of the given type and sets its , /// , and . /// - public T New(string name) where T : IKubernetesObject, new() => Scheme.New(name); + public static T New(this Kubernetes client, string name) where T : IKubernetesObject, new() => client.Scheme.New(name); /// Creates a new Kubernetes object of the given type and sets its , /// , , and . /// - public T New(string ns, string name) where T : IKubernetesObject, new() => Scheme.New(ns, name); + public static T New(this Kubernetes client, string ns, string name) where T : IKubernetesObject, new() => client.Scheme.New(ns, name); /// Creates a new using the given /// ( by default). /// - public KubernetesRequest Request(HttpMethod method = null) => new KubernetesRequest(this).Method(method); + public static KubernetesRequest Request(this Kubernetes client, HttpMethod method = null) => new KubernetesRequest(client).Method(method); /// Creates a new using the given /// and resource URI components. /// - public KubernetesRequest Request( + public static KubernetesRequest Request(this Kubernetes client, HttpMethod method, string type = null, string ns = null, string name = null, string group = null, string version = null) => - new KubernetesRequest(this).Method(method).Group(group).Version(version).Type(type).Namespace(ns).Name(name); + new KubernetesRequest(client).Method(method).Group(group).Version(version).Type(type).Namespace(ns).Name(name); /// Creates a new to access the given type of object. - public KubernetesRequest Request(Type type) => new KubernetesRequest(this).GVK(type); + public static KubernetesRequest Request(this Kubernetes client, Type type) => new KubernetesRequest(client).GVK(type); /// Creates a new to access the given type of object with an optional name and namespace. - public KubernetesRequest Request(HttpMethod method, Type type, string ns = null, string name = null) => - Request(method).GVK(type).Namespace(ns).Name(name); + public static KubernetesRequest Request(this Kubernetes client, HttpMethod method, Type type, string ns = null, string name = null) => + Request(client, method).GVK(type).Namespace(ns).Name(name); /// Creates a new to access the given type of object with an optional name and namespace. - public KubernetesRequest Request(string ns = null, string name = null) => Request(null, typeof(T), ns, name); + public static KubernetesRequest Request(this Kubernetes client, string ns = null, string name = null) => Request(client, null, typeof(T), ns, name); /// Creates a new to access the given object. - public KubernetesRequest Request(IKubernetesObject obj, bool setBody = true) => new KubernetesRequest(this).Set(obj, setBody); + public static KubernetesRequest Request(this Kubernetes client, IKubernetesObject obj, bool setBody = true) => new KubernetesRequest(client).Set(obj, setBody); } } diff --git a/src/KubernetesClient/KubernetesRequest.cs b/src/KubernetesClient/Fluent/KubernetesRequest.cs similarity index 90% rename from src/KubernetesClient/KubernetesRequest.cs rename to src/KubernetesClient/Fluent/KubernetesRequest.cs index d2b8fd927..061ccc317 100644 --- a/src/KubernetesClient/KubernetesRequest.cs +++ b/src/KubernetesClient/Fluent/KubernetesRequest.cs @@ -12,9 +12,8 @@ using Microsoft.Rest; using Newtonsoft.Json; -namespace k8s +namespace k8s.Fluent { - #region KubernetesRequest /// Represents a single request to Kubernetes. public sealed class KubernetesRequest : ICloneable { @@ -687,91 +686,4 @@ static string CheckHeaderName(string name) static string NormalizeEmpty(string value) => string.IsNullOrEmpty(value) ? null : value; // normalizes empty strings to null } -#endregion - -#region KubernetesResponse - /// Represents a response to a . - public sealed class KubernetesResponse : IDisposable - { - /// Initializes a new from an . - public KubernetesResponse(HttpResponseMessage message) => Message = message ?? throw new ArgumentNullException(nameof(message)); - - /// Indicates whether the server returned an error response. - public bool IsError => (int)StatusCode >= 400; - - /// Indicates whether the server returned a 404 Not Found response. - public bool IsNotFound => StatusCode == HttpStatusCode.NotFound; - - /// Gets the underlying . - public HttpResponseMessage Message { get; } - - /// Gets the of the response. - public HttpStatusCode StatusCode => Message.StatusCode; - - /// - public void Dispose() => Message.Dispose(); - - /// Returns the response body as a string. - public async Task GetBodyAsync() - { - if (body == null) - { - body = Message.Content != null ? await Message.Content.ReadAsStringAsync().ConfigureAwait(false) : string.Empty; - } - return body; - } - - /// Deserializes the response body from JSON as a value of the given type, or null if the response body is empty. - /// The type of object to return - /// If false, an empty response body will be returned as null. If true, an exception will be thrown if - /// the body is empty. The default is false. - /// - public async Task GetBodyAsync(Type type, bool failIfEmpty = false) - { - string body = await GetBodyAsync().ConfigureAwait(false); - if (string.IsNullOrWhiteSpace(body)) - { - if (!failIfEmpty) throw new InvalidOperationException("The response body was empty."); - return null; - } - return JsonConvert.DeserializeObject(body, type, Kubernetes.DefaultJsonSettings); - } - - /// Deserializes the response body from JSON as a value of type , or the default value of - /// type if the response body is empty. - /// - /// If false, an empty response body will be returned as the default value of type - /// . If true, an exception will be thrown if the body is empty. The default is false. - /// - public async Task GetBodyAsync(bool failIfEmpty = false) - { - string body = await GetBodyAsync().ConfigureAwait(false); - if (string.IsNullOrWhiteSpace(body)) - { - if (failIfEmpty) throw new InvalidOperationException("The response body was empty."); - return default(T); - } - return JsonConvert.DeserializeObject(body, Kubernetes.DefaultJsonSettings); - } - - /// Deserializes the response body as a object, or creates one from the status code if the - /// response body is not a JSON object. - /// - public async Task GetStatusAsync() - { - try - { - var status = await GetBodyAsync().ConfigureAwait(false); - if (status != null && (status.Status == "Success" || status.Status == "Failure")) return status; - } - catch (JsonException) { } - return new V1Status() - { - Status = IsError ? "Failure" : "Success", Code = (int)StatusCode, Reason = StatusCode.ToString(), Message = body - }; - } - - string body; - } -#endregion } diff --git a/src/KubernetesClient/Fluent/KubernetesResponse.cs b/src/KubernetesClient/Fluent/KubernetesResponse.cs new file mode 100644 index 000000000..3d6c0ff87 --- /dev/null +++ b/src/KubernetesClient/Fluent/KubernetesResponse.cs @@ -0,0 +1,93 @@ +using System; +using System.Net; +using System.Net.Http; +using System.Threading.Tasks; +using k8s.Models; +using Newtonsoft.Json; + +namespace k8s.Fluent +{ + /// Represents a response to a . + public sealed class KubernetesResponse : IDisposable + { + /// Initializes a new from an . + public KubernetesResponse(HttpResponseMessage message) => Message = message ?? throw new ArgumentNullException(nameof(message)); + + /// Indicates whether the server returned an error response. + public bool IsError => (int)StatusCode >= 400; + + /// Indicates whether the server returned a 404 Not Found response. + public bool IsNotFound => StatusCode == HttpStatusCode.NotFound; + + /// Gets the underlying . + public HttpResponseMessage Message { get; } + + /// Gets the of the response. + public HttpStatusCode StatusCode => Message.StatusCode; + + /// + public void Dispose() => Message.Dispose(); + + /// Returns the response body as a string. + public async Task GetBodyAsync() + { + if (body == null) + { + body = Message.Content != null ? await Message.Content.ReadAsStringAsync().ConfigureAwait(false) : string.Empty; + } + return body; + } + + /// Deserializes the response body from JSON as a value of the given type, or null if the response body is empty. + /// The type of object to return + /// If false, an empty response body will be returned as null. If true, an exception will be thrown if + /// the body is empty. The default is false. + /// + public async Task GetBodyAsync(Type type, bool failIfEmpty = false) + { + string body = await GetBodyAsync().ConfigureAwait(false); + if (string.IsNullOrWhiteSpace(body)) + { + if (!failIfEmpty) throw new InvalidOperationException("The response body was empty."); + return null; + } + return JsonConvert.DeserializeObject(body, type, Kubernetes.DefaultJsonSettings); + } + + /// Deserializes the response body from JSON as a value of type , or the default value of + /// type if the response body is empty. + /// + /// If false, an empty response body will be returned as the default value of type + /// . If true, an exception will be thrown if the body is empty. The default is false. + /// + public async Task GetBodyAsync(bool failIfEmpty = false) + { + string body = await GetBodyAsync().ConfigureAwait(false); + if (string.IsNullOrWhiteSpace(body)) + { + if (failIfEmpty) throw new InvalidOperationException("The response body was empty."); + return default(T); + } + return JsonConvert.DeserializeObject(body, Kubernetes.DefaultJsonSettings); + } + + /// Deserializes the response body as a object, or creates one from the status code if the + /// response body is not a JSON object. + /// + public async Task GetStatusAsync() + { + try + { + var status = await GetBodyAsync().ConfigureAwait(false); + if (status != null && (status.Status == "Success" || status.Status == "Failure")) return status; + } + catch (JsonException) { } + return new V1Status() + { + Status = IsError ? "Failure" : "Success", Code = (int)StatusCode, Reason = StatusCode.ToString(), Message = body + }; + } + + string body; + } +} From 6e265bc358c1ef319c80b75828265297248e9a67 Mon Sep 17 00:00:00 2001 From: Adam Milazzo Date: Mon, 13 Apr 2020 12:48:43 -0700 Subject: [PATCH 12/13] Git add the test file too --- tests/KubernetesClient.Tests/Kubernetes.Fluent.Tests.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/KubernetesClient.Tests/Kubernetes.Fluent.Tests.cs b/tests/KubernetesClient.Tests/Kubernetes.Fluent.Tests.cs index dfc682e35..b6f846967 100644 --- a/tests/KubernetesClient.Tests/Kubernetes.Fluent.Tests.cs +++ b/tests/KubernetesClient.Tests/Kubernetes.Fluent.Tests.cs @@ -6,6 +6,7 @@ using System.Net.Http; using System.Text; using System.Threading.Tasks; +using k8s.Fluent; using k8s.Models; using k8s.Tests.Mock; using Newtonsoft.Json; From cf002140a16b57a38a2a7a3f4a83f16a7778dee4 Mon Sep 17 00:00:00 2001 From: Adam Milazzo Date: Tue, 14 Apr 2020 18:34:29 -0700 Subject: [PATCH 13/13] Simplify ReplaceAsync --- .../Fluent/KubernetesRequest.cs | 25 ++++++++----------- 1 file changed, 11 insertions(+), 14 deletions(-) diff --git a/src/KubernetesClient/Fluent/KubernetesRequest.cs b/src/KubernetesClient/Fluent/KubernetesRequest.cs index 061ccc317..4662b4f6e 100644 --- a/src/KubernetesClient/Fluent/KubernetesRequest.cs +++ b/src/KubernetesClient/Fluent/KubernetesRequest.cs @@ -402,25 +402,22 @@ public async Task ReplaceAsync( { if (modify == null) throw new ArgumentNullException(nameof(modify)); if (_watchVersion != null) throw new InvalidOperationException("Watches cannot be updated."); - KubernetesRequest putReq = null; - while (true) + KubernetesRequest req = Clone(); + while(true) { - if (obj == null) // if we need to load the resource... + if(obj == null) // if we need to load the resource... { - cancelToken.ThrowIfCancellationRequested(); - HttpRequestMessage getMsg = await CreateRequestMessage(cancelToken).ConfigureAwait(false); // load it with a GET request - getMsg.Method = HttpMethod.Get; - obj = await ExecuteMessageAsync(getMsg, failIfMissing, cancelToken).ConfigureAwait(false); + cancelToken.ThrowIfCancellationRequested(); // load it with a GET request + obj = await req.Get().Body(null).ExecuteAsync(failIfMissing, cancelToken).ConfigureAwait(false); } cancelToken.ThrowIfCancellationRequested(); - // if the resource is missing or no changes are needed, return it as-is - if (obj == null || !await modify(obj, cancelToken).ConfigureAwait(false)) return obj; - if (putReq == null) putReq = Clone().Put(); - KubernetesResponse resp = await putReq.Body(obj).ExecuteAsync(cancelToken).ConfigureAwait(false); // otherwise, update it - if (resp.StatusCode != HttpStatusCode.Conflict) // if there was no conflict, return the result + // if the resource is missing or no changes are needed, return it as-is. otherwise, update it with a PUT request + if(obj == null || !await modify(obj, cancelToken).ConfigureAwait(false)) return obj; + KubernetesResponse resp = await req.Put().Body(obj).ExecuteAsync(cancelToken).ConfigureAwait(false); + if(resp.StatusCode != HttpStatusCode.Conflict) // if there was no conflict, return the result { - if (resp.IsNotFound && !failIfMissing) return null; - else if (resp.IsError) throw new KubernetesException(await resp.GetStatusAsync().ConfigureAwait(false)); + if(resp.IsNotFound && !failIfMissing) return null; + else if(resp.IsError) throw new KubernetesException(await resp.GetStatusAsync().ConfigureAwait(false)); else return await resp.GetBodyAsync().ConfigureAwait(false); } obj = null; // otherwise, there was a conflict, so reload the item