diff --git a/KubeOps.sln b/KubeOps.sln index c121685e..fa42e10c 100644 --- a/KubeOps.sln +++ b/KubeOps.sln @@ -47,6 +47,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "KubeOps.Transpiler.Test", " EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "KubeOps.Transpiler", "src\KubeOps.Transpiler\KubeOps.Transpiler.csproj", "{A793FC08-E76C-448B-BE93-88C137D2C7AB}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "KubeOps.KubernetesClient", "src\KubeOps.KubernetesClient\KubeOps.KubernetesClient.csproj", "{C2C6FF06-2B9D-4FAC-A039-3DC1E007DE3B}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "KubeOps.KubernetesClient.Test", "test\KubeOps.KubernetesClient.Test\KubeOps.KubernetesClient.Test.csproj", "{25F767E5-7A74-459B-83CC-39519461F38B}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -68,6 +72,8 @@ Global {BC6E6D3C-6404-4B01-9D72-AA16FEEBFF5C} = {C587731F-8191-4A19-8662-B89A60FE79A1} {47914451-147D-427E-B150-9C47DBF28F2C} = {C587731F-8191-4A19-8662-B89A60FE79A1} {A793FC08-E76C-448B-BE93-88C137D2C7AB} = {4DB01062-6DC5-4028-BB72-C0619C2F5F2E} + {C2C6FF06-2B9D-4FAC-A039-3DC1E007DE3B} = {4DB01062-6DC5-4028-BB72-C0619C2F5F2E} + {25F767E5-7A74-459B-83CC-39519461F38B} = {C587731F-8191-4A19-8662-B89A60FE79A1} EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {E9A0B04E-D90E-4B94-90E0-DD3666B098FF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU @@ -118,5 +124,13 @@ Global {A793FC08-E76C-448B-BE93-88C137D2C7AB}.Debug|Any CPU.Build.0 = Debug|Any CPU {A793FC08-E76C-448B-BE93-88C137D2C7AB}.Release|Any CPU.ActiveCfg = Release|Any CPU {A793FC08-E76C-448B-BE93-88C137D2C7AB}.Release|Any CPU.Build.0 = Release|Any CPU + {C2C6FF06-2B9D-4FAC-A039-3DC1E007DE3B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C2C6FF06-2B9D-4FAC-A039-3DC1E007DE3B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C2C6FF06-2B9D-4FAC-A039-3DC1E007DE3B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C2C6FF06-2B9D-4FAC-A039-3DC1E007DE3B}.Release|Any CPU.Build.0 = Release|Any CPU + {25F767E5-7A74-459B-83CC-39519461F38B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {25F767E5-7A74-459B-83CC-39519461F38B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {25F767E5-7A74-459B-83CC-39519461F38B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {25F767E5-7A74-459B-83CC-39519461F38B}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection EndGlobal diff --git a/src/KubeOps.Abstractions/Builder/IOperatorBuilder.cs b/src/KubeOps.Abstractions/Builder/IOperatorBuilder.cs index 649a98a8..bfba5682 100644 --- a/src/KubeOps.Abstractions/Builder/IOperatorBuilder.cs +++ b/src/KubeOps.Abstractions/Builder/IOperatorBuilder.cs @@ -19,14 +19,14 @@ public interface IOperatorBuilder IServiceCollection Services { get; } /// - /// Add metadata for an entity to the operator. + /// Add an entity with its metadata to the operator. /// Metadata must be added for each entity to be used in /// controllers and other elements. /// /// The metadata of the entity. /// The type of the entity. /// The builder for chaining. - IOperatorBuilder AddEntityMetadata(EntityMetadata metadata) + IOperatorBuilder AddEntity(EntityMetadata metadata) where TEntity : IKubernetesObject; /// @@ -48,7 +48,7 @@ IOperatorBuilder AddController() /// Implementation type of the controller. /// Entity type. /// The builder for chaining. - IOperatorBuilder AddController(EntityMetadata metadata) + IOperatorBuilder AddControllerWithEntity(EntityMetadata metadata) where TImplementation : class, IEntityController where TEntity : IKubernetesObject; } diff --git a/src/KubeOps.Abstractions/Entities/EntityList.cs b/src/KubeOps.Abstractions/Entities/EntityList.cs new file mode 100644 index 00000000..5071e7eb --- /dev/null +++ b/src/KubeOps.Abstractions/Entities/EntityList.cs @@ -0,0 +1,22 @@ +using k8s; +using k8s.Models; + +namespace KubeOps.Abstractions.Entities; + +/// +/// Type for a list of entities. +/// +/// Type for the list entries. +public class EntityList : KubernetesObject + where T : IKubernetesObject +{ + /// + /// Official list metadata object of kubernetes. + /// + public V1ListMeta Metadata { get; set; } = new(); + + /// + /// The list of items. + /// + public IList Items { get; set; } = new List(); +} diff --git a/src/KubeOps.Abstractions/Entities/Extensions.cs b/src/KubeOps.Abstractions/Entities/Extensions.cs new file mode 100644 index 00000000..06045853 --- /dev/null +++ b/src/KubeOps.Abstractions/Entities/Extensions.cs @@ -0,0 +1,42 @@ +using k8s; +using k8s.Models; + +namespace KubeOps.Abstractions.Entities; + +/// +/// Method extensions for . +/// +public static class Extensions +{ + /// + /// Sets the resource version of the specified Kubernetes object to the specified value. + /// + /// The type of the Kubernetes object. + /// The Kubernetes object. + /// The resource version to set. + /// The Kubernetes object with the updated resource version. + public static TEntity WithResourceVersion( + this TEntity entity, + string resourceVersion) + where TEntity : IKubernetesObject + { + entity.EnsureMetadata().ResourceVersion = resourceVersion; + return entity; + } + + /// + /// Sets the resource version of the specified Kubernetes object to the resource version of another object. + /// + /// The type of the Kubernetes object. + /// The Kubernetes object. + /// The other Kubernetes object. + /// The Kubernetes object with the updated resource version. + public static TEntity WithResourceVersion( + this TEntity entity, + TEntity other) + where TEntity : IKubernetesObject + { + entity.EnsureMetadata().ResourceVersion = other.ResourceVersion(); + return entity; + } +} diff --git a/src/KubeOps.Generator/EntityDefinitions/EntityDefinitionGenerator.cs b/src/KubeOps.Generator/EntityDefinitions/EntityDefinitionGenerator.cs index 5eceb383..240b7a7e 100644 --- a/src/KubeOps.Generator/EntityDefinitions/EntityDefinitionGenerator.cs +++ b/src/KubeOps.Generator/EntityDefinitions/EntityDefinitionGenerator.cs @@ -1,3 +1,5 @@ +using KubeOps.Generator.SyntaxReceiver; + using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Syntax; @@ -93,7 +95,7 @@ public void Execute(GeneratorExecutionContext context) MemberAccessExpression( SyntaxKind.SimpleMemberAccessExpression, IdentifierName("builder"), - GenericName(Identifier("AddEntityMetadata")) + GenericName(Identifier("AddEntity")) .WithTypeArgumentList( TypeArgumentList( SingletonSeparatedList( diff --git a/src/KubeOps.Generator/EntityDefinitions/AttributedEntity.cs b/src/KubeOps.Generator/SyntaxReceiver/AttributedEntity.cs similarity index 77% rename from src/KubeOps.Generator/EntityDefinitions/AttributedEntity.cs rename to src/KubeOps.Generator/SyntaxReceiver/AttributedEntity.cs index b8758231..58674eff 100644 --- a/src/KubeOps.Generator/EntityDefinitions/AttributedEntity.cs +++ b/src/KubeOps.Generator/SyntaxReceiver/AttributedEntity.cs @@ -1,10 +1,10 @@ -using Microsoft.CodeAnalysis.CSharp.Syntax; - -namespace KubeOps.Generator.EntityDefinitions; - -public record struct AttributedEntity( - ClassDeclarationSyntax Class, - string Kind, - string Version, - string? Group, - string? Plural); +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace KubeOps.Generator.SyntaxReceiver; + +public record struct AttributedEntity( + ClassDeclarationSyntax Class, + string Kind, + string Version, + string? Group, + string? Plural); diff --git a/src/KubeOps.Generator/EntityDefinitions/KubernetesEntitySyntaxReceiver.cs b/src/KubeOps.Generator/SyntaxReceiver/KubernetesEntitySyntaxReceiver.cs similarity index 94% rename from src/KubeOps.Generator/EntityDefinitions/KubernetesEntitySyntaxReceiver.cs rename to src/KubeOps.Generator/SyntaxReceiver/KubernetesEntitySyntaxReceiver.cs index 2eaa0218..37cfe3ac 100644 --- a/src/KubeOps.Generator/EntityDefinitions/KubernetesEntitySyntaxReceiver.cs +++ b/src/KubeOps.Generator/SyntaxReceiver/KubernetesEntitySyntaxReceiver.cs @@ -1,38 +1,40 @@ -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.CSharp.Syntax; - -namespace KubeOps.Generator.EntityDefinitions; - -public class KubernetesEntitySyntaxReceiver : ISyntaxContextReceiver -{ - private const string KindName = "Kind"; - private const string GroupName = "Group"; - private const string PluralName = "Plural"; - private const string VersionName = "ApiVersion"; - private const string DefaultVersion = "v1"; - - public List Entities { get; } = new(); - - public void OnVisitSyntaxNode(GeneratorSyntaxContext context) - { - if (context.Node is not ClassDeclarationSyntax { AttributeLists.Count: > 0 } cls || - cls.AttributeLists.SelectMany(a => a.Attributes) - .FirstOrDefault(a => a.Name.ToString() == "KubernetesEntity") is not { } attr) - { - return; - } - - Entities.Add(new( - cls, - GetArgumentValue(attr, KindName) ?? cls.Identifier.ToString(), - GetArgumentValue(attr, VersionName) ?? DefaultVersion, - GetArgumentValue(attr, GroupName), - GetArgumentValue(attr, PluralName))); - } - - private static string? GetArgumentValue(AttributeSyntax attr, string argName) => - attr.ArgumentList?.Arguments.FirstOrDefault(a => a.NameEquals?.Name.ToString() == argName) is - { Expression: LiteralExpressionSyntax { Token.ValueText: { } value } } - ? value - : null; -} +using KubeOps.Generator.EntityDefinitions; + +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace KubeOps.Generator.SyntaxReceiver; + +public class KubernetesEntitySyntaxReceiver : ISyntaxContextReceiver +{ + private const string KindName = "Kind"; + private const string GroupName = "Group"; + private const string PluralName = "Plural"; + private const string VersionName = "ApiVersion"; + private const string DefaultVersion = "v1"; + + public List Entities { get; } = new(); + + public void OnVisitSyntaxNode(GeneratorSyntaxContext context) + { + if (context.Node is not ClassDeclarationSyntax { AttributeLists.Count: > 0 } cls || + cls.AttributeLists.SelectMany(a => a.Attributes) + .FirstOrDefault(a => a.Name.ToString() == "KubernetesEntity") is not { } attr) + { + return; + } + + Entities.Add(new( + cls, + GetArgumentValue(attr, KindName) ?? cls.Identifier.ToString(), + GetArgumentValue(attr, VersionName) ?? DefaultVersion, + GetArgumentValue(attr, GroupName), + GetArgumentValue(attr, PluralName))); + } + + private static string? GetArgumentValue(AttributeSyntax attr, string argName) => + attr.ArgumentList?.Arguments.FirstOrDefault(a => a.NameEquals?.Name.ToString() == argName) is + { Expression: LiteralExpressionSyntax { Token.ValueText: { } value } } + ? value + : null; +} diff --git a/src/KubeOps.KubernetesClient/IKubernetesClient.cs b/src/KubeOps.KubernetesClient/IKubernetesClient.cs index 3476667d..68a966c5 100644 --- a/src/KubeOps.KubernetesClient/IKubernetesClient.cs +++ b/src/KubeOps.KubernetesClient/IKubernetesClient.cs @@ -1,21 +1,19 @@ using k8s; using k8s.Models; +using KubeOps.Abstractions.Entities; using KubeOps.KubernetesClient.LabelSelectors; namespace KubeOps.KubernetesClient; /// /// Client for the Kubernetes API. Contains various methods to manage Kubernetes entities. +/// This client is specific to an entity of type . /// -public interface IKubernetesClient +/// The type of the Kubernetes entity. +public interface IKubernetesClient + where TEntity : IKubernetesObject { - /// - /// Represents the "original" kubernetes client from the - /// "KubernetesClient" package. - /// - IKubernetes ApiClient { get; } - /// /// Return the base URI of the currently used KubernetesClient. /// @@ -50,35 +48,25 @@ public interface IKubernetesClient Task GetCurrentNamespace(string downwardApiEnvName = "POD_NAMESPACE"); /// - /// Fetch and return the actual Kubernetes (aka. Server Version). - /// - /// The of the current server. - Task GetServerVersion(); - - /// - /// Fetch and return a entity from the Kubernetes API. + /// Fetch and return an entity from the Kubernetes API. /// /// The name of the entity (metadata.name). /// /// Optional namespace. If this is set, the entity must be a namespaced entity. /// If it is omitted, the entity must be a cluster wide entity. /// - /// The concrete type of the entity. /// The found entity of the given type, or null otherwise. - Task Get(string name, string? @namespace = null) - where TEntity : class, IKubernetesObject; + Task Get(string name, string? @namespace = null); /// /// Fetch and return a list of entities from the Kubernetes API. /// /// If the entities are namespaced, provide the name of the namespace. /// A string, representing an optional label selector for filtering fetched objects. - /// The concrete type of the entity. /// A list of Kubernetes entities. - Task> List( + Task> List( string? @namespace = null, - string? labelSelector = null) - where TEntity : IKubernetesObject; + string? labelSelector = null); /// /// Fetch and return a list of entities from the Kubernetes API. @@ -87,93 +75,78 @@ Task> List( /// If only entities in a given namespace should be listed, provide the namespace here. /// /// A list of label-selectors to apply to the search. - /// The concrete type of the entity. /// A list of Kubernetes entities. - Task> List( + Task> List( string? @namespace = null, - params ILabelSelector[] labelSelectors) - where TEntity : IKubernetesObject; + params LabelSelector[] labelSelectors); /// /// Create or Update a entity. This first fetches the entity from the Kubernetes API /// and if it does exist, updates the entity. Otherwise, the entity is created. /// /// The entity in question. - /// The concrete type of the entity. /// The saved instance of the entity. - Task Save(TEntity entity) - where TEntity : class, IKubernetesObject; + async Task Save(TEntity entity) => await Get(entity.Name(), entity.Namespace()) switch + { + { } e => await Update(entity.WithResourceVersion(e)), + _ => await Create(entity), + }; /// /// Create the given entity on the Kubernetes API. /// /// The entity instance. - /// The concrete type of the entity. /// The created instance of the entity. - Task Create(TEntity entity) - where TEntity : IKubernetesObject; + Task Create(TEntity entity); /// /// Update the given entity on the Kubernetes API. /// /// The entity instance. - /// The concrete type of the entity. /// The updated instance of the entity. - Task Update(TEntity entity) - where TEntity : IKubernetesObject; + Task Update(TEntity entity); /// /// Update the status object of a given entity on the Kubernetes API. /// /// The entity that contains a status object. - /// The concrete type of the entity. /// A task that completes when the call was made. - public Task UpdateStatus(TEntity entity) - where TEntity : IKubernetesObject; + public Task UpdateStatus(TEntity entity); /// /// Delete a given entity from the Kubernetes API. /// /// The entity in question. - /// The concrete type of the entity. /// A task that completes when the call was made. - Task Delete(TEntity entity) - where TEntity : IKubernetesObject; + Task Delete(TEntity entity); /// /// Delete a given list of entities from the Kubernetes API. /// /// The entities in question. - /// The concrete type of the entity. /// A task that completes when the calls were made. - Task Delete(IEnumerable entities) - where TEntity : IKubernetesObject; + Task Delete(IEnumerable entities); /// /// Delete a given list of entities from the Kubernetes API. /// /// The entities in question. - /// The concrete type of the entity. /// A task that completes when the calls were made. - Task Delete(params TEntity[] entities) - where TEntity : IKubernetesObject; + Task Delete(params TEntity[] entities); /// /// Delete a given entity by name from the Kubernetes API. /// /// The name of the entity. /// The optional namespace of the entity. - /// The concrete type of the entity. /// A task that completes when the call was made. - Task Delete(string name, string? @namespace = null) - where TEntity : IKubernetesObject; + Task Delete(string name, string? @namespace = null); /// /// Create a entity watcher on the Kubernetes API. /// The entity watcher fires events for entity-events on /// Kubernetes (events: . /// - /// The timeout which the watcher has (after this timeout, the server will close the connection). /// Action that is called when an event occurs. /// Action that handles exceptions. /// Action that handles closed connections. @@ -181,26 +154,24 @@ Task Delete(string name, string? @namespace = null) /// The namespace to watch for entities (if needed). /// If the namespace is omitted, all entities on the cluster are watched. /// + /// The timeout which the watcher has (after this timeout, the server will close the connection). /// Cancellation-Token. /// A list of label-selectors to apply to the search. - /// The concrete type of the entity. /// A entity watcher for the given entity. - Task> Watch( - TimeSpan timeout, + Watcher Watch( Action onEvent, Action? onError = null, Action? onClose = null, string? @namespace = null, + TimeSpan? timeout = null, CancellationToken cancellationToken = default, - params ILabelSelector[] labelSelectors) - where TEntity : IKubernetesObject; + params LabelSelector[] labelSelectors); /// /// Create a entity watcher on the Kubernetes API. /// The entity watcher fires events for entity-events on /// Kubernetes (events: . /// - /// The timeout which the watcher has (after this timeout, the server will close the connection). /// Action that is called when an event occurs. /// Action that handles exceptions. /// Action that handles closed connections. @@ -208,17 +179,16 @@ Task> Watch( /// The namespace to watch for entities (if needed). /// If the namespace is omitted, all entities on the cluster are watched. /// - /// Cancellation-Token. + /// The timeout which the watcher has (after this timeout, the server will close the connection). /// A string, representing an optional label selector for filtering watched objects. - /// The concrete type of the entity. + /// Cancellation-Token. /// A entity watcher for the given entity. - Task> Watch( - TimeSpan timeout, + Watcher Watch( Action onEvent, Action? onError = null, Action? onClose = null, string? @namespace = null, - CancellationToken cancellationToken = default, - string? labelSelector = null) - where TEntity : IKubernetesObject; + TimeSpan? timeout = null, + string? labelSelector = null, + CancellationToken cancellationToken = default); } diff --git a/src/KubeOps.KubernetesClient/KubernetesClient.cs b/src/KubeOps.KubernetesClient/KubernetesClient.cs index 5afc0ba6..1840e546 100644 --- a/src/KubeOps.KubernetesClient/KubernetesClient.cs +++ b/src/KubeOps.KubernetesClient/KubernetesClient.cs @@ -4,263 +4,259 @@ using k8s.Autorest; using k8s.Models; +using KubeOps.Abstractions.Entities; using KubeOps.KubernetesClient.LabelSelectors; namespace KubeOps.KubernetesClient; -public class KubernetesClient : IKubernetesClient +/// +public class KubernetesClient : IKubernetesClient + where TEntity : IKubernetesObject { private const string DownwardApiNamespaceFile = "/var/run/secrets/kubernetes.io/serviceaccount/namespace"; private const string DefaultNamespace = "default"; + private readonly EntityMetadata _metadata; private readonly KubernetesClientConfiguration _clientConfig; private readonly IKubernetes _client; - - public KubernetesClient() - : this(KubernetesClientConfiguration.BuildDefaultConfig()) + private readonly GenericClient _genericClient; + + /// + /// Create a new Kubernetes client for the given entity. + /// The client will use the default configuration. + /// + /// The metadata of the entity. + public KubernetesClient(EntityMetadata metadata) + : this(metadata, KubernetesClientConfiguration.BuildDefaultConfig()) { } - public KubernetesClient(KubernetesClientConfiguration clientConfig) - : this(clientConfig, new Kubernetes(clientConfig)) + /// + /// Create a new Kubernetes client for the given entity with a custom client configuration. + /// + /// The metadata of the entity. + /// The config for the underlying Kubernetes client. + public KubernetesClient(EntityMetadata metadata, KubernetesClientConfiguration clientConfig) + : this(metadata, clientConfig, new Kubernetes(clientConfig)) { } - public KubernetesClient(KubernetesClientConfiguration clientConfig, IKubernetes client) + /// + /// Create a new Kubernetes client for the given entity with a custom client configuration and client. + /// + /// The metadata of the entity. + /// The config for the underlying Kubernetes client. + /// The underlying client. + public KubernetesClient(EntityMetadata metadata, KubernetesClientConfiguration clientConfig, IKubernetes client) { + _metadata = metadata; _clientConfig = clientConfig; _client = client; + _genericClient = metadata.Group switch + { + null => new GenericClient( + client, + metadata.Version, + metadata.PluralName), + _ => new GenericClient( + client, + metadata.Group, + metadata.Version, + metadata.PluralName), + }; } - /// - public IKubernetes ApiClient => _client; - /// public Uri BaseUri => _client.BaseUri; /// - public Task GetCurrentNamespace(string downwardApiEnvName = "POD_NAMESPACE") + public async Task GetCurrentNamespace(string downwardApiEnvName = "POD_NAMESPACE") { - var result = DefaultNamespace; - - if (_clientConfig.Namespace != null) + if (_clientConfig.Namespace is { } configValue) { - result = _clientConfig.Namespace; + return configValue; } - if (Environment.GetEnvironmentVariable(downwardApiEnvName) != null) + if (Environment.GetEnvironmentVariable(downwardApiEnvName) is { } envValue) { - result = Environment.GetEnvironmentVariable(downwardApiEnvName) ?? string.Empty; + return envValue; } if (File.Exists(DownwardApiNamespaceFile)) { - var ns = File.ReadAllText(DownwardApiNamespaceFile); - result = ns.Trim(); + var ns = await File.ReadAllTextAsync(DownwardApiNamespaceFile); + return ns.Trim(); } - return Task.FromResult(result); + return DefaultNamespace; } /// - public Task GetServerVersion() => _client.Version.GetCodeAsync(); - - /// - public async Task Get( - string name, - string? @namespace = null) - where TResource : class, IKubernetesObject + public async Task Get(string name, string? @namespace = null) { - try + var list = @namespace switch { - var client = CreateClient(); + null => await _client.CustomObjects.ListClusterCustomObjectAsync>( + _metadata.Group ?? string.Empty, + _metadata.Version, + _metadata.PluralName), + _ => await _client.CustomObjects.ListNamespacedCustomObjectAsync>( + _metadata.Group ?? string.Empty, + _metadata.Version, + @namespace, + _metadata.PluralName), + }; - return await (string.IsNullOrWhiteSpace(@namespace) - ? client.ReadAsync(name) - : client.ReadNamespacedAsync(@namespace, name)); - } - catch (HttpOperationException e) when (e.Response.StatusCode == HttpStatusCode.NotFound) + return list switch { - return null; - } + { Items: [var existing] } => existing, + _ => default, + }; } /// - public async Task> List(string? @namespace = null, string? labelSelector = null) - where TResource : IKubernetesObject - { - var definition = EntityDefinition.FromType(); - var result = await (string.IsNullOrWhiteSpace(@namespace) - ? _client.CustomObjects.ListClusterCustomObjectWithHttpMessagesAsync( - definition.Group, - definition.Version, - definition.Plural, - labelSelector: labelSelector) - : _client.CustomObjects.ListNamespacedCustomObjectWithHttpMessagesAsync( - definition.Group, - definition.Version, + public async Task> List(string? @namespace = null, string? labelSelector = null) + => (@namespace switch + { + null => await _client.CustomObjects.ListClusterCustomObjectAsync>( + _metadata.Group ?? string.Empty, + _metadata.Version, + _metadata.PluralName, + labelSelector: labelSelector), + _ => await _client.CustomObjects.ListNamespacedCustomObjectAsync>( + _metadata.Group ?? string.Empty, + _metadata.Version, @namespace, - definition.Plural, - labelSelector: labelSelector)); - var list = KubernetesJson.Deserialize>(result.Body.ToString()); - return list.Items; - } - - /// - public Task> List( - string? @namespace = null, - params ILabelSelector[] labelSelectors) - where TResource : IKubernetesObject => - List(@namespace, labelSelectors.ToExpression()); + _metadata.PluralName, + labelSelector: labelSelector), + }).Items; /// - public Task Save(TResource resource) - where TResource : class, IKubernetesObject => - resource.Uid() is null - ? Create(resource) - : Update(resource); + public Task> List(string? @namespace = null, params LabelSelector[] labelSelectors) + => List(@namespace, labelSelectors.ToExpression()); /// - public async Task Create(TResource resource) - where TResource : IKubernetesObject - { - var client = CreateClient(); - - return await (string.IsNullOrWhiteSpace(resource.Namespace()) - ? client.CreateAsync(resource) - : client.CreateNamespacedAsync(resource, resource.Namespace())); - } + public Task Create(TEntity entity) + => entity.Namespace() switch + { + { } ns => _genericClient.CreateNamespacedAsync(entity, ns), + null => _genericClient.CreateAsync(entity), + }; /// - public async Task Update(TResource resource) - where TResource : IKubernetesObject - { - var client = CreateClient(); - - return await (string.IsNullOrWhiteSpace(resource.Namespace()) - ? client.ReplaceAsync(resource, resource.Name()) - : client.ReplaceNamespacedAsync(resource, resource.Namespace(), resource.Name())); - } + public Task Update(TEntity entity) + => entity.Namespace() switch + { + { } ns => _genericClient.ReplaceNamespacedAsync(entity, ns, entity.Name()), + null => _genericClient.ReplaceAsync(entity, entity.Name()), + }; /// - public async Task UpdateStatus(TResource resource) - where TResource : IKubernetesObject - { - var definition = EntityDefinition.FromType(); - await (string.IsNullOrWhiteSpace(resource.Namespace()) - ? _client.CustomObjects.ReplaceClusterCustomObjectStatusAsync( - resource, - definition.Group, - definition.Version, - definition.Plural, - resource.Name()) - : _client.CustomObjects.ReplaceNamespacedCustomObjectStatusAsync( - resource, - definition.Group, - definition.Version, - resource.Namespace(), - definition.Plural, - resource.Name())); - } + public Task UpdateStatus(TEntity entity) + => entity.Namespace() switch + { + { } ns => _client.CustomObjects.ReplaceNamespacedCustomObjectStatusAsync( + entity, + _metadata.Group ?? string.Empty, + _metadata.Version, + ns, + _metadata.PluralName, + entity.Name()), + _ => _client.CustomObjects.ReplaceClusterCustomObjectStatusAsync( + entity, + _metadata.Group ?? string.Empty, + _metadata.Version, + _metadata.PluralName, + entity.Name()), + }; /// - public Task Delete(TResource resource) - where TResource : IKubernetesObject => Delete( - resource.Name(), - resource.Namespace()); + public Task Delete(TEntity entity) => Delete( + entity.Name(), + entity.Namespace()); /// - public Task Delete(IEnumerable resources) - where TResource : IKubernetesObject => - Task.WhenAll(resources.Select(Delete)); + public Task Delete(IEnumerable entities) => + Task.WhenAll(entities.Select(Delete)); /// - public Task Delete(params TResource[] resources) - where TResource : IKubernetesObject => - Task.WhenAll(resources.Select(Delete)); + public Task Delete(params TEntity[] entities) => + Task.WhenAll(entities.Select(Delete)); /// - public async Task Delete(string name, string? @namespace = null) - where TResource : IKubernetesObject + public async Task Delete(string name, string? @namespace = null) { - var client = CreateClient(); - try { - await (string.IsNullOrWhiteSpace(@namespace) - ? client.DeleteAsync(name) - : client.DeleteNamespacedAsync(@namespace, name)); + switch (@namespace) + { + case not null: + await _genericClient.DeleteNamespacedAsync(@namespace, name); + break; + default: + await _genericClient.DeleteAsync(name); + break; + } } catch (HttpOperationException e) when (e.Response.StatusCode == HttpStatusCode.NotFound) { + // The resource was not found. We can ignore this. } } /// - public Task> Watch( - TimeSpan timeout, - Action onEvent, + public Watcher Watch( + Action onEvent, Action? onError = null, Action? onClose = null, string? @namespace = null, + TimeSpan? timeout = null, CancellationToken cancellationToken = default, - params ILabelSelector[] labelSelectors) - where TResource : IKubernetesObject + params LabelSelector[] labelSelectors) => Watch( - timeout, onEvent, onError, onClose, @namespace, - cancellationToken, - string.Join(",", labelSelectors.Select(l => l.ToExpression()))); + timeout, + labelSelectors.ToExpression(), + cancellationToken); /// - public Task> Watch( - TimeSpan timeout, - Action onEvent, + public Watcher Watch( + Action onEvent, Action? onError = null, Action? onClose = null, string? @namespace = null, - CancellationToken cancellationToken = default, - string? labelSelector = null) - where TResource : IKubernetesObject - { - var crd = EntityDefinition.FromType(); - var result = string.IsNullOrWhiteSpace(@namespace) - ? _client.CustomObjects.ListClusterCustomObjectWithHttpMessagesAsync( - crd.Group, - crd.Version, - crd.Plural, + TimeSpan? timeout = null, + string? labelSelector = null, + CancellationToken cancellationToken = default) + => (@namespace switch + { + not null => _client.CustomObjects.ListNamespacedCustomObjectWithHttpMessagesAsync( + _metadata.Group ?? string.Empty, + _metadata.Version, + @namespace, + _metadata.PluralName, labelSelector: labelSelector, - timeoutSeconds: (int)timeout.TotalSeconds, + timeoutSeconds: timeout switch + { + null => null, + _ => (int?)timeout.Value.TotalSeconds, + }, watch: true, - cancellationToken: cancellationToken) - : _client.CustomObjects.ListNamespacedCustomObjectWithHttpMessagesAsync( - crd.Group, - crd.Version, - @namespace, - crd.Plural, + cancellationToken: cancellationToken), + _ => _client.CustomObjects.ListClusterCustomObjectWithHttpMessagesAsync( + _metadata.Group ?? string.Empty, + _metadata.Version, + _metadata.PluralName, labelSelector: labelSelector, - timeoutSeconds: (int)timeout.TotalSeconds, + timeoutSeconds: timeout switch + { + null => null, + _ => (int?)timeout.Value.TotalSeconds, + }, watch: true, - cancellationToken: cancellationToken); - - return Task.FromResult( - result.Watch( - onEvent, - onError, - onClose)); - } - - private GenericClient CreateClient() - where TResource : IKubernetesObject - { - var definition = EntityDefinition.FromType(); - return definition.Group switch - { - "" => new GenericClient(_client, definition.Version, definition.Plural), - _ => new GenericClient(_client, definition.Group, definition.Version, definition.Plural), - }; - } + cancellationToken: cancellationToken), + }).Watch(onEvent, onError, onClose); } diff --git a/src/KubeOps.KubernetesClient/LabelSelectors/EqualsSelector.cs b/src/KubeOps.KubernetesClient/LabelSelectors/EqualsSelector.cs index ca6aacae..0e313b2f 100644 --- a/src/KubeOps.KubernetesClient/LabelSelectors/EqualsSelector.cs +++ b/src/KubeOps.KubernetesClient/LabelSelectors/EqualsSelector.cs @@ -5,13 +5,9 @@ /// a specific value (out of a list of values). /// Note that "label in (value)" is the same as "label == value". /// -public record EqualsSelector : ILabelSelector +/// The label that needs to equal to one of the values. +/// The possible values. +public record EqualsSelector(string Label, params string[] Values) : LabelSelector { - public EqualsSelector(string label, params string[] values) => (Label, Values) = (label, values); - - public string Label { get; } - - public IEnumerable Values { get; } - - public string ToExpression() => $"{Label} in ({string.Join(",", Values)})"; + protected override string ToExpression() => $"{Label} in ({string.Join(",", Values)})"; } diff --git a/src/KubeOps.KubernetesClient/LabelSelectors/ExistsSelector.cs b/src/KubeOps.KubernetesClient/LabelSelectors/ExistsSelector.cs index 4f7c8305..ed40e73c 100644 --- a/src/KubeOps.KubernetesClient/LabelSelectors/ExistsSelector.cs +++ b/src/KubeOps.KubernetesClient/LabelSelectors/ExistsSelector.cs @@ -3,7 +3,8 @@ /// /// Selector that checks if a certain label exists. /// -public record ExistsSelector(string Label) : ILabelSelector +/// The label that needs to exist on the entity/resource. +public record ExistsSelector(string Label) : LabelSelector { - public string ToExpression() => $"{Label}"; + protected override string ToExpression() => $"{Label}"; } diff --git a/src/KubeOps.KubernetesClient/LabelSelectors/Extensions.cs b/src/KubeOps.KubernetesClient/LabelSelectors/Extensions.cs index a9689914..f22e7b63 100644 --- a/src/KubeOps.KubernetesClient/LabelSelectors/Extensions.cs +++ b/src/KubeOps.KubernetesClient/LabelSelectors/Extensions.cs @@ -3,10 +3,10 @@ public static class Extensions { /// - /// Convert an enumerable list of s to a string. + /// Convert an enumerable list of s to a string. /// /// The list of selectors. /// A comma-joined string with all selectors converted to their expressions. - public static string ToExpression(this IEnumerable selectors) => - string.Join(",", selectors.Select(s => s.ToExpression())); + public static string ToExpression(this IEnumerable selectors) => + string.Join(",", selectors); } diff --git a/src/KubeOps.KubernetesClient/LabelSelectors/ILabelSelector.cs b/src/KubeOps.KubernetesClient/LabelSelectors/ILabelSelector.cs deleted file mode 100644 index e5524824..00000000 --- a/src/KubeOps.KubernetesClient/LabelSelectors/ILabelSelector.cs +++ /dev/null @@ -1,13 +0,0 @@ -namespace KubeOps.KubernetesClient.LabelSelectors; - -/// -/// Different label selectors for querying the Kubernetes API. -/// -public interface ILabelSelector -{ - /// - /// Create an expression from the label selector. - /// - /// A string that represents the label selector. - string ToExpression(); -} diff --git a/src/KubeOps.KubernetesClient/LabelSelectors/LabelSelector.cs b/src/KubeOps.KubernetesClient/LabelSelectors/LabelSelector.cs new file mode 100644 index 00000000..543b1c03 --- /dev/null +++ b/src/KubeOps.KubernetesClient/LabelSelectors/LabelSelector.cs @@ -0,0 +1,20 @@ +namespace KubeOps.KubernetesClient.LabelSelectors; + +/// +/// Different label selectors for querying the Kubernetes API. +/// +public abstract record LabelSelector +{ + /// + /// Cast the label selector to a string. + /// + /// The selector. + /// A string representation of the label selector. + public static implicit operator string(LabelSelector selector) => selector.ToExpression(); + + /// + /// Create an expression from the label selector. + /// + /// A string that represents the label selector. + protected abstract string ToExpression(); +} diff --git a/src/KubeOps.KubernetesClient/LabelSelectors/NotEqualsSelector.cs b/src/KubeOps.KubernetesClient/LabelSelectors/NotEqualsSelector.cs index b78505aa..32a68d09 100644 --- a/src/KubeOps.KubernetesClient/LabelSelectors/NotEqualsSelector.cs +++ b/src/KubeOps.KubernetesClient/LabelSelectors/NotEqualsSelector.cs @@ -5,13 +5,9 @@ /// a specific value (out of a list of values). /// Note that "label notin (value)" is the same as "label != value". /// -public record NotEqualsSelector : ILabelSelector +/// The label that must not equal to one of the values. +/// The possible values. +public record NotEqualsSelector(string Label, params string[] Values) : LabelSelector { - public NotEqualsSelector(string label, params string[] values) => (Label, Values) = (label, values); - - public string Label { get; } - - public IEnumerable Values { get; } - - public string ToExpression() => $"{Label} notin ({string.Join(",", Values)})"; + protected override string ToExpression() => $"{Label} notin ({string.Join(",", Values)})"; } diff --git a/src/KubeOps.KubernetesClient/LabelSelectors/NotExistsSelector.cs b/src/KubeOps.KubernetesClient/LabelSelectors/NotExistsSelector.cs index a047f91a..52db320a 100644 --- a/src/KubeOps.KubernetesClient/LabelSelectors/NotExistsSelector.cs +++ b/src/KubeOps.KubernetesClient/LabelSelectors/NotExistsSelector.cs @@ -3,7 +3,8 @@ /// /// Selector that checks if a certain label does not exist. /// -public record NotExistsSelector(string Label) : ILabelSelector +/// The label that must not exist on the entity/resource. +public record NotExistsSelector(string Label) : LabelSelector { - public string ToExpression() => $"!{Label}"; + protected override string ToExpression() => $"!{Label}"; } diff --git a/src/KubeOps.Operator/Builder/OperatorBuilder.cs b/src/KubeOps.Operator/Builder/OperatorBuilder.cs index f82bbd75..624524d4 100644 --- a/src/KubeOps.Operator/Builder/OperatorBuilder.cs +++ b/src/KubeOps.Operator/Builder/OperatorBuilder.cs @@ -4,7 +4,7 @@ using KubeOps.Abstractions.Builder; using KubeOps.Abstractions.Controller; using KubeOps.Abstractions.Entities; -using KubeOps.Operator.Client; +using KubeOps.KubernetesClient; using KubeOps.Operator.Watcher; using Microsoft.Extensions.DependencyInjection; @@ -13,20 +13,17 @@ namespace KubeOps.Operator.Builder; internal class OperatorBuilder : IOperatorBuilder { - private readonly IKubernetesClientFactory _entityClientFactory = new KubernetesClientFactory(); - public OperatorBuilder(IServiceCollection services) { Services = services; - Services.AddSingleton(_entityClientFactory); } public IServiceCollection Services { get; } - public IOperatorBuilder AddEntityMetadata(EntityMetadata metadata) + public IOperatorBuilder AddEntity(EntityMetadata metadata) where TEntity : IKubernetesObject { - _entityClientFactory.RegisterMetadata(metadata); + Services.AddSingleton>(new KubernetesClient(metadata)); return this; } @@ -39,8 +36,8 @@ public IOperatorBuilder AddController() return this; } - public IOperatorBuilder AddController(EntityMetadata metadata) + public IOperatorBuilder AddControllerWithEntity(EntityMetadata metadata) where TImplementation : class, IEntityController where TEntity : IKubernetesObject => - AddController().AddEntityMetadata(metadata); + AddController().AddEntity(metadata); } diff --git a/src/KubeOps.Operator/Client/IKubernetesClientFactory.cs b/src/KubeOps.Operator/Client/IKubernetesClientFactory.cs deleted file mode 100644 index d47399ac..00000000 --- a/src/KubeOps.Operator/Client/IKubernetesClientFactory.cs +++ /dev/null @@ -1,19 +0,0 @@ -using System.Collections.Immutable; - -using k8s; -using k8s.Models; - -using KubeOps.Abstractions.Entities; - -namespace KubeOps.Operator.Client; - -public interface IKubernetesClientFactory -{ - IImmutableDictionary RegisteredMetadata { get; } - - void RegisterMetadata(EntityMetadata metadata) - where TEntity : IKubernetesObject; - - GenericClient GetClient(IKubernetes? kubernetes = null) - where TEntity : IKubernetesObject; -} diff --git a/src/KubeOps.Operator/Client/KubernetesClientFactory.cs b/src/KubeOps.Operator/Client/KubernetesClientFactory.cs deleted file mode 100644 index d049436c..00000000 --- a/src/KubeOps.Operator/Client/KubernetesClientFactory.cs +++ /dev/null @@ -1,45 +0,0 @@ -using System.Collections.Immutable; - -using k8s; -using k8s.Models; - -using KubeOps.Abstractions.Entities; - -namespace KubeOps.Operator.Client; - -internal class KubernetesClientFactory : IKubernetesClientFactory -{ - private readonly Dictionary _registeredMetadata = new(); - - public IImmutableDictionary RegisteredMetadata => _registeredMetadata.ToImmutableDictionary(); - - public void RegisterMetadata(EntityMetadata metadata) - where TEntity : IKubernetesObject - { - _registeredMetadata[typeof(TEntity)] = metadata; - } - - public GenericClient GetClient(IKubernetes? kubernetes = null) - where TEntity : IKubernetesObject - { - if (!_registeredMetadata.TryGetValue(typeof(TEntity), out var metadata)) - { - throw new InvalidOperationException( - $"No metadata registered for entity {typeof(TEntity).Name}. " + - "Please register metadata for this entity before using the client."); - } - - return metadata.Group switch - { - null => new GenericClient( - kubernetes ?? new Kubernetes(KubernetesClientConfiguration.BuildDefaultConfig()), - metadata.Version, - metadata.PluralName), - _ => new GenericClient( - kubernetes ?? new Kubernetes(KubernetesClientConfiguration.BuildDefaultConfig()), - metadata.Group, - metadata.Version, - metadata.PluralName), - }; - } -} diff --git a/src/KubeOps.Operator/KubeOps.Operator.csproj b/src/KubeOps.Operator/KubeOps.Operator.csproj index b64a8ffe..f099d2ea 100644 --- a/src/KubeOps.Operator/KubeOps.Operator.csproj +++ b/src/KubeOps.Operator/KubeOps.Operator.csproj @@ -22,6 +22,7 @@ + diff --git a/src/KubeOps.Operator/Watcher/ResourceWatcher{TEntity}.cs b/src/KubeOps.Operator/Watcher/ResourceWatcher{TEntity}.cs index bef1c028..fbc5ab7d 100644 --- a/src/KubeOps.Operator/Watcher/ResourceWatcher{TEntity}.cs +++ b/src/KubeOps.Operator/Watcher/ResourceWatcher{TEntity}.cs @@ -5,7 +5,7 @@ using k8s.Models; using KubeOps.Abstractions.Controller; -using KubeOps.Operator.Client; +using KubeOps.KubernetesClient; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; @@ -18,18 +18,18 @@ internal class ResourceWatcher : IHostedService { private readonly ILogger> _logger; private readonly IServiceProvider _provider; - private readonly GenericClient _client; + private readonly IKubernetesClient _client; private Watcher? _watcher; public ResourceWatcher( ILogger> logger, IServiceProvider provider, - IKubernetesClientFactory factory) + IKubernetesClient client) { _logger = logger; _provider = provider; - _client = factory.GetClient(); + _client = client; } public Task StartAsync(CancellationToken cancellationToken) @@ -61,7 +61,7 @@ private void WatchResource() } } - _watcher = _client.Watch(OnEvent, OnError, OnClosed); + _watcher = _client.Watch(OnEvent, OnError, OnClosed); } private void StopWatching() diff --git a/test/KubeOps.KubernetesClient.Test/GlobalUsings.cs b/test/KubeOps.KubernetesClient.Test/GlobalUsings.cs new file mode 100644 index 00000000..c802f448 --- /dev/null +++ b/test/KubeOps.KubernetesClient.Test/GlobalUsings.cs @@ -0,0 +1 @@ +global using Xunit; diff --git a/test/KubeOps.KubernetesClient.Test/KubeOps.KubernetesClient.Test.csproj b/test/KubeOps.KubernetesClient.Test/KubeOps.KubernetesClient.Test.csproj new file mode 100644 index 00000000..1104f6dd --- /dev/null +++ b/test/KubeOps.KubernetesClient.Test/KubeOps.KubernetesClient.Test.csproj @@ -0,0 +1,5 @@ + + + + + diff --git a/test/KubeOps.KubernetesClient.Test/KubernetesClient.Test.cs b/test/KubeOps.KubernetesClient.Test/KubernetesClient.Test.cs new file mode 100644 index 00000000..013db68b --- /dev/null +++ b/test/KubeOps.KubernetesClient.Test/KubernetesClient.Test.cs @@ -0,0 +1,138 @@ +using FluentAssertions; + +using k8s.Models; + +namespace KubeOps.KubernetesClient.Test; + +public class KubernetesClientTest : IDisposable +{ + private readonly IKubernetesClient _client = + new KubernetesClient(new("ConfigMap", "v1", null, "configmaps")); + + private readonly IList _objects = new List(); + + [Fact] + public async Task Should_Return_Namespace() + { + var ns = await _client.GetCurrentNamespace(); + ns.Should().Be("default"); + } + + [Fact] + public async Task Should_Create_Some_Object() + { + var config = await _client.Create( + new V1ConfigMap + { + Kind = V1ConfigMap.KubeKind, + ApiVersion = V1ConfigMap.KubeApiVersion, + Metadata = new(name: RandomName(), namespaceProperty: "default"), + Data = new Dictionary { { "Hello", "World" } }, + }); + + _objects.Add(config); + + config.Metadata.Should().NotBeNull(); + config.Metadata.ResourceVersion.Should().NotBeNullOrWhiteSpace(); + } + + [Fact] + public async Task Should_Update_Some_Object() + { + var config = await _client.Create( + new V1ConfigMap + { + Kind = V1ConfigMap.KubeKind, + ApiVersion = V1ConfigMap.KubeApiVersion, + Metadata = new V1ObjectMeta(name: RandomName(), namespaceProperty: "default"), + Data = new Dictionary { { "Hello", "World" } }, + }); + var r1 = config.Metadata.ResourceVersion; + _objects.Add(config); + + config.Data.Add("test", "value"); + config = await _client.Update(config); + var r2 = config.Metadata.ResourceVersion; + + r1.Should().NotBe(r2); + } + + [Fact] + public async Task Should_List_Some_Objects() + { + var config1 = await _client.Create( + new V1ConfigMap + { + Kind = V1ConfigMap.KubeKind, + ApiVersion = V1ConfigMap.KubeApiVersion, + Metadata = new(name: RandomName(), namespaceProperty: "default"), + Data = new Dictionary { { "Hello", "World" } }, + }); + var config2 = await _client.Create( + new V1ConfigMap + { + Kind = V1ConfigMap.KubeKind, + ApiVersion = V1ConfigMap.KubeApiVersion, + Metadata = new(name: RandomName(), namespaceProperty: "default"), + Data = new Dictionary { { "Hello", "World" } }, + }); + + _objects.Add(config1); + _objects.Add(config2); + + var configs = await _client.List("default"); + + // there are _at least_ 2 config maps (the two that were created) + configs.Count.Should().BeGreaterOrEqualTo(2); + } + + [Fact] + public async Task Should_Delete_Some_Object() + { + var config1 = await _client.Create( + new V1ConfigMap + { + Kind = V1ConfigMap.KubeKind, + ApiVersion = V1ConfigMap.KubeApiVersion, + Metadata = new(name: RandomName(), namespaceProperty: "default"), + Data = new Dictionary { { "Hello", "World" } }, + }); + var config2 = await _client.Create( + new V1ConfigMap + { + Kind = V1ConfigMap.KubeKind, + ApiVersion = V1ConfigMap.KubeApiVersion, + Metadata = new(name: RandomName(), namespaceProperty: "default"), + Data = new Dictionary { { "Hello", "World" } }, + }); + _objects.Add(config1); + + var configs = await _client.List("default"); + configs.Count.Should().BeGreaterOrEqualTo(2); + + await _client.Delete(config2); + + configs = await _client.List("default"); + configs.Count.Should().BeGreaterOrEqualTo(1); + } + + [Fact] + public async Task Should_Not_Throw_On_Not_Found_Delete() + { + var config = new V1ConfigMap + { + Kind = V1ConfigMap.KubeKind, + ApiVersion = V1ConfigMap.KubeApiVersion, + Metadata = new(name: RandomName(), namespaceProperty: "default"), + Data = new Dictionary { { "Hello", "World" } }, + }; + await _client.Delete(config); + } + + public void Dispose() + { + _client.Delete(_objects).Wait(); + } + + private static string RandomName() => "cm-" + Guid.NewGuid().ToString().ToLower(); +}