diff --git a/src/KubernetesClient/KubernetesClient.csproj b/src/KubernetesClient/KubernetesClient.csproj index 758f5549f..3e54e61a0 100644 --- a/src/KubernetesClient/KubernetesClient.csproj +++ b/src/KubernetesClient/KubernetesClient.csproj @@ -45,4 +45,8 @@ + + + + \ No newline at end of file diff --git a/src/KubernetesClient/Util/Common/CallGeneratorParams.cs b/src/KubernetesClient/Util/Common/CallGeneratorParams.cs new file mode 100644 index 000000000..fbdc6ac2d --- /dev/null +++ b/src/KubernetesClient/Util/Common/CallGeneratorParams.cs @@ -0,0 +1,16 @@ +namespace k8s.Util.Common +{ + public class CallGeneratorParams + { + public bool Watch { get; } + public string ResourceVersion { get; } + public int? TimeoutSeconds { get; } + + public CallGeneratorParams(bool watch, string resourceVersion, int? timeoutSeconds) + { + Watch = watch; + ResourceVersion = resourceVersion; + TimeoutSeconds = timeoutSeconds; + } + } +} diff --git a/src/KubernetesClient/Util/Common/CollectionsExtensions.cs b/src/KubernetesClient/Util/Common/CollectionsExtensions.cs new file mode 100644 index 000000000..4043f53df --- /dev/null +++ b/src/KubernetesClient/Util/Common/CollectionsExtensions.cs @@ -0,0 +1,43 @@ +using System; +using System.Collections.Generic; + +namespace k8s.Util.Common +{ + internal static class CollectionsExtensions + { + public static void AddRange(this HashSet hashSet, ICollection items) + { + if (items == null) + { + return; + } + + foreach (var item in items) + { + hashSet?.Add(item); + } + } + + internal static TValue ComputeIfAbsent(this IDictionary dictionary, TKey key, Func mappingFunction) + { + if (dictionary is null) + { + throw new ArgumentNullException(nameof(dictionary)); + } + + if (dictionary.TryGetValue(key, out var value)) + { + return value; + } + + if (mappingFunction == null) + { + throw new ArgumentNullException(nameof(mappingFunction)); + } + + var newKey = mappingFunction(key); + dictionary[key] = newKey; + return newKey; + } + } +} diff --git a/src/KubernetesClient/Util/Informer/Cache/Cache.cs b/src/KubernetesClient/Util/Informer/Cache/Cache.cs new file mode 100644 index 000000000..b1ae42aad --- /dev/null +++ b/src/KubernetesClient/Util/Informer/Cache/Cache.cs @@ -0,0 +1,431 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using k8s.Models; +using k8s.Util.Common; + +namespace k8s.Util.Informer.Cache +{ + /// + /// Cache is a C# port of Java's Cache which is a port of k/client-go's ThreadSafeStore. It basically saves and indexes all the entries. + /// + /// The type of K8s object to save + public class Cache : IIndexer + where TApiType : class, IKubernetesObject + { + /// + /// keyFunc defines how to map index objects into indices + /// + private Func _keyFunc; + + /// + /// indexers stores index functions by their names + /// + /// The indexer name(string) is a label marking the different ways it can be calculated. + /// The default label is "namespace". The default func is to look in the object's metadata and combine the + /// namespace and name values, as namespace/name. + /// + private readonly Dictionary>> _indexers = new Dictionary>>(); + + /// + /// indices stores objects' keys by their indices + /// + /// Similar to 'indexers', an indice has the same label as its corresponding indexer except it's value + /// is the result of the func. + /// if the indexer func is to calculate the namespace and name values as namespace/name, then the indice HashSet + /// holds those values. + /// + private Dictionary>> _indices = new Dictionary>>(); + + /// + /// items stores object instances + /// + /// Indices hold the HashSet of calculated keys (namespace/name) for a given resource and items map each of + /// those keys to actual K8s object that was originally returned. + private Dictionary _items = new Dictionary(); + + /// + /// object used to track locking + /// + /// methods interacting with the store need to lock to secure the thread for race conditions, + /// learn more: https://docs.microsoft.com/en-us/dotnet/csharp/language-reference/keywords/lock-statement + private readonly object _lock = new object(); + + public Cache() + : this(Caches.NamespaceIndex, Caches.MetaNamespaceIndexFunc, Caches.DeletionHandlingMetaNamespaceKeyFunc) + { + } + + /// + /// Initializes a new instance of the class. + /// Constructor. + /// + /// the index name, an unique name representing the index + /// the index func by which we map multiple object to an index for querying + /// the key func by which we map one object to an unique key for storing + public Cache(string indexName, Func> indexFunc, Func keyFunc) + { + _indexers[indexName] = indexFunc; + _keyFunc = keyFunc; + _indices[indexName] = new Dictionary>(); + } + + /// + /// Add objects. + /// + /// the obj + public void Add(TApiType obj) + { + var key = _keyFunc(obj); + + lock (_lock) + { + var oldObj = _items.GetValueOrDefault(key); + _items[key] = obj; + UpdateIndices(oldObj, obj, key); + } + } + + /// + /// Update the object. + /// + /// the obj + public void Update(TApiType obj) + { + var key = _keyFunc(obj); + + lock (_lock) + { + var oldObj = _items.GetValueOrDefault(key); + _items[key] = obj; + UpdateIndices(oldObj, obj, key); + } + } + + /// + /// Delete the object. + /// + /// the obj + public void Delete(TApiType obj) + { + var key = _keyFunc(obj); + lock (_lock) + { + if (!_items.TryGetValue(key, out var value)) + { + return; + } + + DeleteFromIndices(value, key); + _items.Remove(key); + } + } + + /// + /// Replace the content in the cache completely. + /// + /// the list + /// optional, unused param from interface + /// list is null + public void Replace(IEnumerable list, string resourceVersion = default) + { + if (list is null) + { + throw new ArgumentNullException(nameof(list)); + } + + var newItems = new Dictionary(); + foreach (var item in list) + { + var key = _keyFunc(item); + newItems[key] = item; + } + + lock (_lock) + { + _items = newItems; + + // rebuild any index + _indices = new Dictionary>>(); + foreach (var (key, value) in _items) + { + UpdateIndices(default, value, key); + } + } + } + + /// + /// Resync. + /// + public void Resync() + { + // Do nothing by default + } + + /// + /// List keys. + /// + /// the list + public IEnumerable ListKeys() + { + return _items.Select(item => item.Key); + } + + /// + /// Get object t. + /// + /// the obj + /// the t + public TApiType Get(TApiType obj) + { + var key = _keyFunc(obj); + + lock (_lock) + { + // Todo: to make this lock striped or reader/writer (or use ConcurrentDictionary) + return _items.GetValueOrDefault(key); + } + } + + /// + /// List all objects in the cache. + /// + /// all items + public IEnumerable List() + { + lock (_lock) + { + return _items.Select(item => item.Value); + } + } + + /// + /// Get object t. + /// + /// the key + /// the get by key + public TApiType GetByKey(string key) + { + lock (_lock) + { + _items.TryGetValue(key, out var value); + return value; + } + } + + /// + /// Get objects. + /// + /// the index name + /// the obj + /// the list + /// indexers does not contain the provided index name + public IEnumerable Index(string indexName, TApiType obj) + { + if (!_indexers.ContainsKey(indexName)) + { + throw new ArgumentException($"index {indexName} doesn't exist!", nameof(indexName)); + } + + lock (_lock) + { + var indexFunc = _indexers[indexName]; + var indexKeys = indexFunc(obj); + var index = _indices.GetValueOrDefault(indexName); + if (index is null || index.Count == 0) + { + return new List(); + } + + var returnKeySet = new HashSet(); + foreach (var set in indexKeys.Select(indexKey => index.GetValueOrDefault(indexKey)).Where(set => set != null && set.Count != 0)) + { + returnKeySet.AddRange(set); + } + + var items = new List(returnKeySet.Count); + items.AddRange(returnKeySet.Select(absoluteKey => _items[absoluteKey])); + + return items; + } + } + + /// + /// Index keys list. + /// + /// the index name + /// the index key + /// the list + /// indexers does not contain the provided index name + /// indices collection does not contain the provided index name + public IEnumerable IndexKeys(string indexName, string indexKey) + { + if (!_indexers.ContainsKey(indexName)) + { + throw new ArgumentException($"index {indexName} doesn't exist!", nameof(indexName)); + } + + lock (_lock) + { + var index = _indices.GetValueOrDefault(indexName); + + if (index is null) + { + throw new KeyNotFoundException($"no value could be found for name '{indexName}'"); + } + + return index[indexKey]; + } + } + + /// + /// By index list. + /// + /// the index name + /// the index key + /// the list + /// indexers does not contain the provided index name + /// indices collection does not contain the provided index name + public IEnumerable ByIndex(string indexName, string indexKey) + { + if (!_indexers.ContainsKey(indexName)) + { + throw new ArgumentException($"index {indexName} doesn't exist!", nameof(indexName)); + } + + var index = _indices.GetValueOrDefault(indexName); + + if (index is null) + { + throw new KeyNotFoundException($"no value could be found for name '{indexName}'"); + } + + var set = index[indexKey]; + return set is null ? new List() : set.Select(key => _items[key]); + } + + /// + /// Return the indexers registered with the cache. + /// + /// registered indexers + public IDictionary>> GetIndexers() => _indexers; + + /// + /// Add additional indexers to the cache. + /// + /// indexers to add + /// newIndexers is null + /// items collection is not empty + /// conflict between keys in existing index and new indexers provided + public void AddIndexers(IDictionary>> newIndexers) + { + if (newIndexers is null) + { + throw new ArgumentNullException(nameof(newIndexers)); + } + + if (_items.Any()) + { + throw new InvalidOperationException("cannot add indexers to a non-empty cache"); + } + + var oldKeys = _indexers.Keys; + var newKeys = newIndexers.Keys; + var intersection = oldKeys.Intersect(newKeys); + + if (intersection.Any()) + { + throw new ArgumentException("indexer conflict: " + intersection); + } + + foreach (var (key, value) in newIndexers) + { + AddIndexFunc(key, value); + } + } + + /// + /// UpdateIndices modifies the objects location in the managed indexes, if this is an update, you + /// must provide an oldObj. + /// + /// UpdateIndices must be called from a function that already has a lock on the cache. + /// the old obj + /// the new obj + /// the key + private void UpdateIndices(TApiType oldObj, TApiType newObj, string key) + { + // if we got an old object, we need to remove it before we can add + // it again. + if (oldObj != null) + { + DeleteFromIndices(oldObj, key); + } + + foreach (var (indexName, indexFunc) in _indexers) + { + var indexValues = indexFunc(newObj); + if (indexValues is null || indexValues.Count == 0) + { + continue; + } + + var index = _indices.ComputeIfAbsent(indexName, _ => new Dictionary>()); + + foreach (var indexValue in indexValues) + { + HashSet indexSet = index.ComputeIfAbsent(indexValue, k => new HashSet()); + indexSet.Add(key); + + index[indexValue] = indexSet; + } + } + } + + /// + /// DeleteFromIndices removes the object from each of the managed indexes. + /// + /// It is intended to be called from a function that already has a lock on the cache. + /// the old obj + /// the key + private void DeleteFromIndices(TApiType oldObj, string key) + { + foreach (var (s, indexFunc) in _indexers) + { + var indexValues = indexFunc(oldObj); + if (indexValues is null || indexValues.Count == 0) + { + continue; + } + + var index = _indices.GetValueOrDefault(s); + if (index is null) + { + continue; + } + + foreach (var indexSet in indexValues.Select(indexValue => index[indexValue])) + { + indexSet?.Remove(key); + } + } + } + + /// + /// Add index func. + /// + /// the index name + /// the index func + public void AddIndexFunc(string indexName, Func> indexFunc) + { + _indices[indexName] = new Dictionary>(); + _indexers[indexName] = indexFunc; + } + + public Func KeyFunc => _keyFunc; + + public void SetKeyFunc(Func keyFunc) + { + _keyFunc = keyFunc; + } + } +} diff --git a/src/KubernetesClient/Util/Informer/Cache/Caches.cs b/src/KubernetesClient/Util/Informer/Cache/Caches.cs new file mode 100644 index 000000000..74e31739a --- /dev/null +++ b/src/KubernetesClient/Util/Informer/Cache/Caches.cs @@ -0,0 +1,85 @@ +using System; +using System.Collections.Generic; +using k8s.Models; + +namespace k8s.Util.Informer.Cache +{ + /// + /// A set of helper utilities for constructing a cache. + /// + public static class Caches + { + /// + /// NamespaceIndex is the default index function for caching objects + /// + public const string NamespaceIndex = "namespace"; + + /// + /// deletionHandlingMetaNamespaceKeyFunc checks for DeletedFinalStateUnknown objects before calling + /// metaNamespaceKeyFunc. + /// + /// specific object + /// the type parameter + /// if obj is null + /// the key + public static string DeletionHandlingMetaNamespaceKeyFunc(TApiType obj) + where TApiType : class, IKubernetesObject + { + if (obj is null) + { + throw new ArgumentNullException(nameof(obj)); + } + + if (obj.GetType() == typeof(DeletedFinalStateUnknown)) + { + var deleteObj = obj as DeletedFinalStateUnknown; + return deleteObj.GetKey(); + } + + return MetaNamespaceKeyFunc(obj); + } + + /// + /// MetaNamespaceKeyFunc is a convenient default KeyFunc which knows how to make keys for API + /// objects which implement V1ObjectMeta Interface. The key uses the format <namespace>/<name> + /// unless <namespace> is empty, then it's just <name>. + /// + /// specific object + /// the key + /// if obj is null + /// if metadata can't be found on obj + public static string MetaNamespaceKeyFunc(IKubernetesObject obj) + { + if (obj is null) + { + throw new ArgumentNullException(nameof(obj)); + } + + if (!string.IsNullOrEmpty(obj.Metadata.NamespaceProperty)) + { + return obj.Metadata.NamespaceProperty + "/" + obj.Metadata.Name; + } + + return obj.Metadata.Name; + } + + /// + /// MetaNamespaceIndexFunc is a default index function that indexes based on an object's namespace. + /// + /// specific object + /// the type parameter + /// the indexed value + /// if obj is null + /// if metadata can't be found on obj + public static List MetaNamespaceIndexFunc(TApiType obj) + where TApiType : IKubernetesObject + { + if (obj is null) + { + throw new ArgumentNullException(nameof(obj)); + } + + return obj.Metadata is null ? new List() : new List() { obj.Metadata.NamespaceProperty }; + } + } +} diff --git a/src/KubernetesClient/Util/Informer/Cache/DeletedFinalStateUnknown.cs b/src/KubernetesClient/Util/Informer/Cache/DeletedFinalStateUnknown.cs new file mode 100644 index 000000000..7e8a99553 --- /dev/null +++ b/src/KubernetesClient/Util/Informer/Cache/DeletedFinalStateUnknown.cs @@ -0,0 +1,47 @@ +using k8s.Models; + +namespace k8s.Util.Informer.Cache +{ + // DeletedFinalStateUnknown is placed into a DeltaFIFO in the case where + // an object was deleted but the watch deletion event was missed. In this + // case we don't know the final "resting" state of the object, so there's + // a chance the included `Obj` is stale. + public class DeletedFinalStateUnknown : IKubernetesObject + where TApi : class, IKubernetesObject + { + private readonly string _key; + private readonly TApi _obj; + + public DeletedFinalStateUnknown(string key, TApi obj) + { + _key = key; + _obj = obj; + } + + public string GetKey() => _key; + + /// + /// Gets get obj. + /// + /// the get obj + public TApi GetObj() => _obj; + + public V1ObjectMeta Metadata + { + get => _obj.Metadata; + set => _obj.Metadata = value; + } + + public string ApiVersion + { + get => _obj.ApiVersion; + set => _obj.ApiVersion = value; + } + + public string Kind + { + get => _obj.Kind; + set => _obj.Kind = value; + } + } +} diff --git a/src/KubernetesClient/Util/Informer/Cache/DeltaType.cs b/src/KubernetesClient/Util/Informer/Cache/DeltaType.cs new file mode 100644 index 000000000..b8f10c4fa --- /dev/null +++ b/src/KubernetesClient/Util/Informer/Cache/DeltaType.cs @@ -0,0 +1,30 @@ +namespace k8s.Util.Informer.Cache +{ + public enum DeltaType + { + /// + /// Item added + /// + Added, + + /// + /// Item updated + /// + Updated, + + /// + /// Item deleted + /// + Deleted, + + /// + /// Item synchronized + /// + Sync, + + /// + /// Item replaced + /// + Replaced, + } +} diff --git a/src/KubernetesClient/Util/Informer/Cache/IIndexer.cs b/src/KubernetesClient/Util/Informer/Cache/IIndexer.cs new file mode 100644 index 000000000..7da66d248 --- /dev/null +++ b/src/KubernetesClient/Util/Informer/Cache/IIndexer.cs @@ -0,0 +1,46 @@ +using System; +using System.Collections.Generic; +using k8s.Models; + +namespace k8s.Util.Informer.Cache +{ + public interface IIndexer : IStore + where TApiType : class, IKubernetesObject + { + /// + /// Retrieve list of objects that match on the named indexing function. + /// + /// specific indexing function + /// . + /// matched objects + IEnumerable Index(string indexName, TApiType obj); + + /// + /// IndexKeys returns the set of keys that match on the named indexing function. + /// + /// specific indexing function + /// specific index key + /// matched keys + IEnumerable IndexKeys(string indexName, string indexKey); + + /// + /// ByIndex lists object that match on the named indexing function with the exact key. + /// + /// specific indexing function + /// specific index key + /// matched objects + IEnumerable ByIndex(string indexName, string indexKey); + + /// + /// Return the indexers registered with the store. + /// + /// registered indexers + IDictionary>> GetIndexers(); + + /// + /// Add additional indexers to the store. + /// + /// indexers to add + void AddIndexers(IDictionary>> indexers); + } +} diff --git a/src/KubernetesClient/Util/Informer/Cache/IStore.cs b/src/KubernetesClient/Util/Informer/Cache/IStore.cs new file mode 100644 index 000000000..8fb935a57 --- /dev/null +++ b/src/KubernetesClient/Util/Informer/Cache/IStore.cs @@ -0,0 +1,65 @@ +using System.Collections.Generic; +using k8s.Models; + +namespace k8s.Util.Informer.Cache +{ + public interface IStore + where TApiType : class, IKubernetesObject + { + /// + /// add inserts an item into the store. + /// + /// specific obj + void Add(TApiType obj); + + /// + /// update sets an item in the store to its updated state. + /// + /// specific obj + void Update(TApiType obj); + + /// + /// delete removes an item from the store. + /// + /// specific obj + void Delete(TApiType obj); + + /// + /// Replace will delete the contents of 'c', using instead the given list. + /// + /// list of objects + /// specific resource version + void Replace(IEnumerable list, string resourceVersion); + + /// + /// resync will send a resync event for each item. + /// + void Resync(); + + /// + /// listKeys returns a list of all the keys of the object currently in the store. + /// + /// list of all keys + IEnumerable ListKeys(); + + /// + /// get returns the requested item. + /// + /// specific obj + /// the requested item if exist + TApiType Get(TApiType obj); + + /// + /// getByKey returns the request item with specific key. + /// + /// specific key + /// the request item + TApiType GetByKey(string key); + + /// + /// list returns a list of all the items. + /// + /// list of all the items + IEnumerable List(); + } +} diff --git a/src/KubernetesClient/Util/Informer/Cache/Lister.cs b/src/KubernetesClient/Util/Informer/Cache/Lister.cs new file mode 100644 index 000000000..4e11e99be --- /dev/null +++ b/src/KubernetesClient/Util/Informer/Cache/Lister.cs @@ -0,0 +1,45 @@ +using System.Collections.Generic; +using k8s.Models; + +namespace k8s.Util.Informer.Cache +{ + /// + /// Lister interface is used to list cached items from a running informer. + /// + /// the type + public class Lister + where TApiType : class, IKubernetesObject + { + private readonly string _namespace; + private readonly string _indexName; + private readonly IIndexer _indexer; + + public Lister(IIndexer indexer, string @namespace = default, string indexName = Caches.NamespaceIndex) + { + _indexer = indexer; + _namespace = @namespace; + _indexName = indexName; + } + + public IEnumerable List() + { + return string.IsNullOrEmpty(_namespace) ? _indexer.List() : _indexer.ByIndex(_indexName, _namespace); + } + + public TApiType Get(string name) + { + var key = name; + if (!string.IsNullOrEmpty(_namespace)) + { + key = _namespace + "/" + name; + } + + return _indexer.GetByKey(key); + } + + public Lister Namespace(string @namespace) + { + return new Lister(_indexer, @namespace, Caches.NamespaceIndex); + } + } +} diff --git a/src/KubernetesClient/Util/Informer/Cache/MutablePair.cs b/src/KubernetesClient/Util/Informer/Cache/MutablePair.cs new file mode 100644 index 000000000..03851112d --- /dev/null +++ b/src/KubernetesClient/Util/Informer/Cache/MutablePair.cs @@ -0,0 +1,55 @@ +using System; +using System.Collections.Generic; + +namespace k8s.Util.Informer.Cache +{ + public class MutablePair + { + protected bool Equals(MutablePair other) + { + if (other is null) + { + throw new ArgumentNullException(nameof(other)); + } + + return EqualityComparer.Default.Equals(Left, other.Left) && EqualityComparer.Default.Equals(Right, other.Right); + } + + public override bool Equals(object obj) + { + if (ReferenceEquals(null, obj)) + { + return false; + } + + if (ReferenceEquals(this, obj)) + { + return true; + } + + return obj.GetType() == this.GetType() && Equals((MutablePair)obj); + } + + public override int GetHashCode() + { + unchecked + { + return (EqualityComparer.Default.GetHashCode(Left) * 397) ^ EqualityComparer.Default.GetHashCode(Right); + } + } + + public TRight Right { get; } + + public TLeft Left { get; } + + public MutablePair() + { + } + + public MutablePair(TLeft left, TRight right) + { + Left = left; + Right = right; + } + } +} diff --git a/tests/KubernetesClient.Tests/KubernetesClient.Tests.csproj b/tests/KubernetesClient.Tests/KubernetesClient.Tests.csproj index af8efadc6..eaa58ae7d 100755 --- a/tests/KubernetesClient.Tests/KubernetesClient.Tests.csproj +++ b/tests/KubernetesClient.Tests/KubernetesClient.Tests.csproj @@ -8,6 +8,7 @@ + @@ -41,4 +42,8 @@ + + + + diff --git a/tests/KubernetesClient.Tests/Util/Informer/Cache/CacheTest.cs b/tests/KubernetesClient.Tests/Util/Informer/Cache/CacheTest.cs new file mode 100644 index 000000000..9eae0e9ce --- /dev/null +++ b/tests/KubernetesClient.Tests/Util/Informer/Cache/CacheTest.cs @@ -0,0 +1,336 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using FluentAssertions; +using k8s.Util.Informer.Cache; +using k8s.Models; +using Xunit; + +namespace k8s.Tests.Util.Informer.Cache +{ + public class CacheTest + { + [Fact(DisplayName = "Create default cache success")] + private void CreateCacheSuccess() + { + var cache = new Cache(); + cache.Should().NotBeNull(); + cache.GetIndexers().ContainsKey(Caches.NamespaceIndex).Should().BeTrue(); + // Todo: validate all defaults gor set up + } + + [Fact(DisplayName = "Add cache item success")] + private void AddCacheItemSuccess() + { + var aPod = Util.CreatePods(1).First(); + var cache = new Cache(); + + cache.Add(aPod); + + cache.Get(aPod).Equals(aPod).Should().BeTrue(); + } + + [Fact(DisplayName = "Update cache item success")] + private void UpdateCacheItemSuccess() + { + var aPod = Util.CreatePods(1).First(); + + var cache = new Cache(); + + cache.Add(aPod); + aPod.Kind = "another-kind"; + cache.Update(aPod); + + cache.Get(aPod).Kind.Equals(aPod.Kind).Should().BeTrue(); + } + + [Fact(DisplayName = "Delete cache item success")] + private void DeleteCacheItemSuccess() + { + var aPod = Util.CreatePods(1).First(); + + var cache = new Cache(); + + cache.Add(aPod); + cache.Delete(aPod); + + // Todo: check indices for removed item + cache.Get(aPod).Should().BeNull(); + } + + [Fact(DisplayName = "Replace cache items success")] + private void ReplaceCacheItemsSuccess() + { + var pods = Util.CreatePods(3); + var aPod = pods.First(); + var anotherPod = pods.Skip(1).First(); + var yetAnotherPod = pods.Skip(2).First(); + + var cache = new Cache(); + + cache.Add(aPod); + cache.Replace(new[] { anotherPod, yetAnotherPod }); + + // Todo: check indices for replaced items + cache.Get(anotherPod).Should().NotBeNull(); + cache.Get(yetAnotherPod).Should().NotBeNull(); + } + + [Fact(DisplayName = "List item keys success")] + public void ListItemKeysSuccess() + { + var pods = Util.CreatePods(3); + var aPod = pods.First(); + var anotherPod = pods.Skip(1).First(); + var cache = new Cache(); + + cache.Add(aPod); + cache.Add(anotherPod); + + var keys = cache.ListKeys(); + + keys.Should().Contain($"{aPod.Metadata.NamespaceProperty}/{aPod.Metadata.Name}"); + keys.Should().Contain($"{anotherPod.Metadata.NamespaceProperty}/{anotherPod.Metadata.Name}"); + } + + [Fact(DisplayName = "Get item doesn't exist")] + public void GetItemNotExist() + { + var aPod = Util.CreatePods(1).First(); + var cache = new Cache(); + + var item = cache.Get(aPod); + item.Should().BeNull(); + } + + [Fact(DisplayName = "Get item success")] + public void GetItemSuccess() + { + var aPod = Util.CreatePods(1).First(); + var cache = new Cache(); + + cache.Add(aPod); + var item = cache.Get(aPod); + item.Equals(aPod).Should().BeTrue(); + } + + [Fact(DisplayName = "List items success")] + public void ListItemSuccess() + { + var pods = Util.CreatePods(3); + var aPod = pods.First(); + var anotherPod = pods.Skip(1).First(); + var yetAnotherPod = pods.Skip(2).First(); + + var cache = new Cache(); + + cache.Add(aPod); + cache.Add(anotherPod); + cache.Add(yetAnotherPod); + + var items = cache.List(); + items.Should().HaveCount(3); + items.Should().Contain(aPod); + items.Should().Contain(anotherPod); + items.Should().Contain(yetAnotherPod); + } + + [Fact(DisplayName = "Get item by key success")] + public void GetItemByKeySuccess() + { + var pod = Util.CreatePods(1).First(); + var cache = new Cache(); + + cache.Add(pod); + var item = cache.GetByKey($"{pod.Metadata.NamespaceProperty}/{pod.Metadata.Name}"); + item.Should().NotBeNull(); + } + + [Fact(DisplayName = "Index items no index")] + public void IndexItemsNoIndex() + { + var pod = Util.CreatePods(1).First(); + + var cache = new Cache(); + + cache.Add(pod); + + Assert.Throws(() => { cache.Index("asdf", pod); }); + } + + [Fact(DisplayName = "Index items success")] + public void IndexItemsSuccess() + { + var pod = Util.CreatePods(1).First(); + + var cache = new Cache(); + + cache.Add(pod); + + var items = cache.Index("namespace", pod); + + items.Should().Contain(pod); + } + + [Fact(DisplayName = "Get index keys no index")] + public void GetIndexKeysNoIndex() + { + var cache = new Cache(); + + Assert.Throws(() => { cache.IndexKeys("a", "b"); }); + } + + [Fact(DisplayName = "Get index keys no indice item")] + public void GetIndexKeysNoIndiceItem() + { + var cache = new Cache(); + + Assert.Throws(() => { cache.IndexKeys("namespace", "b"); }); + } + + [Fact(DisplayName = "Get index keys success")] + public void GetIndexKeysSuccess() + { + var pod = Util.CreatePods(1).First(); + + var cache = new Cache(); + + cache.Add(pod); + var keys = cache.IndexKeys("namespace", pod.Metadata.NamespaceProperty); + + keys.Should().NotBeNull(); + keys.Should().Contain(Caches.MetaNamespaceKeyFunc(pod)); + } + + [Fact(DisplayName = "List by index no index")] + public void ListByIndexNoIndex() + { + var cache = new Cache(); + + Assert.Throws(() => { cache.ByIndex("a", "b"); }); + } + + [Fact(DisplayName = "List by index no indice item")] + public void ListByIndexNoIndiceItem() + { + var cache = new Cache(); + + Assert.Throws(() => { cache.ByIndex("namespace", "b"); }); + } + + [Fact(DisplayName = "List by index success")] + public void ListByIndexSuccess() + { + var pod = Util.CreatePods(1).First(); + + var cache = new Cache(); + + cache.Add(pod); + var items = cache.ByIndex("namespace", pod.Metadata.NamespaceProperty); + + items.Should().Contain(pod); + } + + /* Add Indexers */ + [Fact(DisplayName = "Add null indexers")] + public void AddNullIndexers() + { + var cache = new Cache(); + Assert.Throws(() => { cache.AddIndexers(null); }); + } + + [Fact(DisplayName = "Add indexers with conflict")] + public void AddIndexersConflict() + { + var cache = new Cache(); + Dictionary>> initialIndexers = new Dictionary>>() + { + { "1", pod => new List() }, + { "2", pod => new List() }, + }; + Dictionary>> conflictIndexers = new Dictionary>>() + { + { "1", pod => new List() }, + }; + + cache.AddIndexers(initialIndexers); + Assert.Throws(() => { cache.AddIndexers(conflictIndexers); }); + } + + [Fact(DisplayName = "Add indexers success")] + public void AddIndexersSuccess() + { + var cache = new Cache(); + Dictionary>> indexers = new Dictionary>>() + { + { "2", pod => new List() { pod.Name() } }, + { "3", pod => new List() { pod.Name() } }, + }; + + cache.AddIndexers(indexers); + + var savedIndexers = cache.GetIndexers(); + savedIndexers.Should().HaveCount(indexers.Count + 1); // blank cache constructor will add a default index + savedIndexers.Should().Contain(indexers); + + // Todo: check indicies collection for new indexname keys + } + + /* Add Index Function */ + [Fact(DisplayName = "Add index function success")] + public void AddIndexFuncSuccess() + { + var cache = new Cache(); + cache.AddIndexFunc("1", pod => new List() { pod.Name() }); + + var savedIndexers = cache.GetIndexers(); + savedIndexers.Should().HaveCount(2); + + // Todo: check indicies collection for new indexname keys + } + + /* Get Key Function */ + [Fact(DisplayName = "Get default key function success")] + public void GetDefaultKeyFuncSuccess() + { + var pod = new V1Pod() + { + Metadata = new V1ObjectMeta() + { + Name = "a-name", + NamespaceProperty = "the-namespace", + }, + }; + var cache = new Cache(); + var defaultReturnValue = Caches.DeletionHandlingMetaNamespaceKeyFunc(pod); + + var funcReturnValue = cache.KeyFunc(pod); + + Assert.True(defaultReturnValue.Equals(funcReturnValue)); + } + + /* Set Key Function */ + [Fact(DisplayName = "Set key function success")] + public void SetKeyFuncSuccess() + { + var aPod = new V1Pod() + { + Kind = "some-kind", + Metadata = new V1ObjectMeta() + { + Name = "a-name", + NamespaceProperty = "the-namespace", + }, + }; + var cache = new Cache(); + var newFunc = new Func((pod) => pod.Kind); + var defaultReturnValue = newFunc(aPod); + + cache.SetKeyFunc(newFunc); + + var funcReturnValue = cache.KeyFunc(aPod); + + Assert.True(defaultReturnValue.Equals(funcReturnValue)); + } + } +} diff --git a/tests/KubernetesClient.Tests/Util/Informer/Cache/CachesTest.cs b/tests/KubernetesClient.Tests/Util/Informer/Cache/CachesTest.cs new file mode 100644 index 000000000..dd2cfb730 --- /dev/null +++ b/tests/KubernetesClient.Tests/Util/Informer/Cache/CachesTest.cs @@ -0,0 +1,60 @@ +using System; +using System.Linq; +using FluentAssertions; +using k8s.Models; +using Xunit; +using k8s.Util.Informer.Cache; + +namespace k8s.Tests.Util.Informer.Cache +{ + public class CachesTest + { + [Fact(DisplayName = "Check for default DeletedFinalStateUnknown")] + public void CheckDefaultDeletedFinalStateUnknown() + { + var aPod = Util.CreatePods(1).First(); + Caches.DeletionHandlingMetaNamespaceKeyFunc(aPod).Should().Be($"{aPod.Metadata.NamespaceProperty}/{aPod.Metadata.Name}"); + } + + [Fact(DisplayName = "Check for obj DeletedFinalStateUnknown")] + public void CheckObjDeletedFinalStateUnknown() + { + var aPod = Util.CreatePods(1).First(); + var key = "a-key"; + var deletedPod = new DeletedFinalStateUnknown(key, aPod); + + var returnKey = Caches.DeletionHandlingMetaNamespaceKeyFunc(deletedPod); + + // returnKey.Should().Be(key); + } + + [Fact(DisplayName = "Get default namespace key null")] + public void GetDefaultNamespaceKeyNull() + { + Assert.Throws(() => { Caches.MetaNamespaceKeyFunc(null); }); + } + + [Fact(DisplayName = "Get default namespace key success")] + public void GetDefaultNamespaceKeySuccess() + { + var aPod = Util.CreatePods(1).First(); + Caches.MetaNamespaceKeyFunc(aPod).Should().Be($"{aPod.Metadata.NamespaceProperty}/{aPod.Metadata.Name}"); + } + + [Fact(DisplayName = "Get default namespace index null")] + public void GetDefaultNamespaceIndexNull() + { + Assert.Throws(() => { Caches.MetaNamespaceIndexFunc(null); }); + } + + [Fact(DisplayName = "Get default namespace index success")] + public void GetDefaultNamespaceIndexSuccess() + { + var aPod = Util.CreatePods(1).First(); + var indexes = Caches.MetaNamespaceIndexFunc(aPod); + + indexes.Should().NotBeNull(); + indexes.Should().Contain(aPod.Metadata.NamespaceProperty); + } + } +} diff --git a/tests/KubernetesClient.Tests/Util/Informer/Cache/ListerTest.cs b/tests/KubernetesClient.Tests/Util/Informer/Cache/ListerTest.cs new file mode 100644 index 000000000..1d1860e80 --- /dev/null +++ b/tests/KubernetesClient.Tests/Util/Informer/Cache/ListerTest.cs @@ -0,0 +1,95 @@ +using System.Linq; +using FluentAssertions; +using k8s.Models; +using Xunit; +using k8s.Util.Informer.Cache; + +namespace k8s.Tests.Util.Informer.Cache +{ + public class ListerTest + { + [Fact(DisplayName = "Create default lister success")] + private void CreateListerDefaultsSuccess() + { + var cache = new Cache(); + var lister = new Lister(cache); + + lister.Should().NotBeNull(); + } + + [Fact(DisplayName = "List with null namespace success")] + private void ListNullNamespaceSuccess() + { + var aPod = Util.CreatePods(1).First(); + var cache = new Cache(); + var lister = new Lister(cache); + + cache.Add(aPod); + var pods = lister.List(); + + pods.Should().HaveCount(1); + pods.Should().Contain(aPod); + // Can't 'Get' the pod due to no namespace specified in Lister constructor + } + + [Fact(DisplayName = "List with custom namespace success")] + private void ListCustomNamespaceSuccess() + { + var aPod = Util.CreatePods(1).First(); + var cache = new Cache(); + var lister = new Lister(cache, aPod.Metadata.NamespaceProperty); + + cache.Add(aPod); + var pods = lister.List(); + + pods.Should().HaveCount(1); + pods.Should().Contain(aPod); + lister.Get(aPod.Metadata.Name).Should().Be(aPod); + } + + [Fact(DisplayName = "Get with null namespace success")] + private void GetNullNamespaceSuccess() + { + var aPod = Util.CreatePods(1).First(); + var cache = new Cache(); + var lister = new Lister(cache); + + cache.Add(aPod); + var pod = lister.Get(aPod.Metadata.Name); + + // it's null because the namespace was not set in Lister constructor, but the pod did have a namespace. + // So it can't build the right key name for lookup in Cache + pod.Should().BeNull(); + } + + [Fact(DisplayName = "Get with custom namespace success")] + private void GetCustomNamespaceSuccess() + { + var aPod = Util.CreatePods(1).First(); + var cache = new Cache(); + var lister = new Lister(cache, aPod.Metadata.NamespaceProperty); + + cache.Add(aPod); + var pod = lister.Get(aPod.Metadata.Name); + + pod.Should().Be(aPod); + } + + [Fact(DisplayName = "Set custom namespace success")] + private void SetCustomNamespaceSuccess() + { + var aPod = Util.CreatePods(1).First(); + var cache = new Cache(); + var lister = new Lister(cache); + + cache.Add(aPod); + var pod = lister.Get(aPod.Metadata.Name); + pod.Should().BeNull(); + + lister = lister.Namespace(aPod.Metadata.NamespaceProperty); + + pod = lister.Get(aPod.Metadata.Name); + pod.Should().Be(aPod); + } + } +} diff --git a/tests/KubernetesClient.Tests/Util/Informer/Cache/ReflectorTest.cs b/tests/KubernetesClient.Tests/Util/Informer/Cache/ReflectorTest.cs new file mode 100644 index 000000000..da7f18778 --- /dev/null +++ b/tests/KubernetesClient.Tests/Util/Informer/Cache/ReflectorTest.cs @@ -0,0 +1,33 @@ +using FluentAssertions; +using k8s.Models; +using k8s.Util.Informer.Cache; +using Microsoft.Extensions.Logging; +using Xunit; +using Xunit.Abstractions; +using Xunit.Sdk; + +namespace k8s.Tests.Util.Informer.Cache +{ + public class ReflectorTest + { + private readonly ITestOutputHelper _ouputHelper; + + public ReflectorTest(ITestOutputHelper outputHelper) + { + _ouputHelper = outputHelper; + } + + [Fact(DisplayName = "Create default reflector success")] + public void CreateReflectorSuccess() + { + /*using var apiClient = new Kubernetes(_clientConfiguration); + var cache = new Cache(); + var queue = new DeltaFifo(Caches.MetaNamespaceKeyFunc, cache, _deltasLogger); + var listerWatcher = new ListWatcher(apiClient, ListAllPods); + var logger = LoggerFactory.Create(builder => builder.AddXUnit(_ouputHelper).SetMinimumLevel(LogLevel.Trace)).CreateLogger(); + var reflector = new k8s.Util.Cache.Reflector(listerWatcher, queue, logger); + + reflector.Should().NotBeNull();*/ + } + } +} diff --git a/tests/KubernetesClient.Tests/Util/Informer/Cache/Util.cs b/tests/KubernetesClient.Tests/Util/Informer/Cache/Util.cs new file mode 100644 index 000000000..7b10c14ae --- /dev/null +++ b/tests/KubernetesClient.Tests/Util/Informer/Cache/Util.cs @@ -0,0 +1,45 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using k8s.Models; + +namespace k8s.Tests.Util.Informer.Cache +{ + internal static class Util + { + internal static IEnumerable CreatePods(int cnt) + { + var pods = new List(); + for (var i = 0; i < cnt; i++) + { + pods.Add(new V1Pod() + { + ApiVersion = "Pod/V1", + Kind = "Pod", + Metadata = new V1ObjectMeta() + { + Name = Guid.NewGuid().ToString(), + NamespaceProperty = "the-namespace", + ResourceVersion = "1", + }, + }); + } + + return pods; + } + + internal static V1PodList CreatePostList(int cnt) + { + return new V1PodList() + { + ApiVersion = "Pod/V1", + Kind = "Pod", + Metadata = new V1ListMeta() + { + ResourceVersion = "1", + }, + Items = CreatePods(cnt).ToList(), + }; + } + } +} diff --git a/util/src/KubernetesClient.Util/KubernetesClient.Util.csproj b/util/src/KubernetesClient.Util/KubernetesClient.Util.csproj new file mode 100644 index 000000000..febeee354 --- /dev/null +++ b/util/src/KubernetesClient.Util/KubernetesClient.Util.csproj @@ -0,0 +1,42 @@ + + + + 9.0 + The Kubernetes Project Authors + 2017 The Kubernetes Project Authors + Supprting utilities for the kubernetes open source container orchestrator client library. + + Apache-2.0 + https://github.com/kubernetes-client/csharp + https://raw.githubusercontent.com/kubernetes/kubernetes/master/logo/logo.png + kubernetes;docker;containers; + + netstandard2.1;net5.0 + k8s.Util + true + true + + + true + + + true + snupkg + true + $(AllowedOutputExtensionsInPackageBuildOutputFolder);.pdb + + + + + + + + + + + + + + + + diff --git a/util/src/KubernetesClient.Util/KubernetesClient.Util.csproj.DotSettings b/util/src/KubernetesClient.Util/KubernetesClient.Util.csproj.DotSettings new file mode 100644 index 000000000..6162834d1 --- /dev/null +++ b/util/src/KubernetesClient.Util/KubernetesClient.Util.csproj.DotSettings @@ -0,0 +1,2 @@ + + CSharp90 \ No newline at end of file diff --git a/util/tests/KubernetesClient.Util.Tests/KubernetesClient.Util.Tests.csproj b/util/tests/KubernetesClient.Util.Tests/KubernetesClient.Util.Tests.csproj new file mode 100644 index 000000000..ccd876b5a --- /dev/null +++ b/util/tests/KubernetesClient.Util.Tests/KubernetesClient.Util.Tests.csproj @@ -0,0 +1,34 @@ + + + + net5.0 + + false + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + + +