diff --git a/_old/src/KubeOps/Operator/Webhooks/IValidationWebhook{TEntity}.cs b/_old/src/KubeOps/Operator/Webhooks/IValidationWebhook{TEntity}.cs deleted file mode 100644 index fb8b1909..00000000 --- a/_old/src/KubeOps/Operator/Webhooks/IValidationWebhook{TEntity}.cs +++ /dev/null @@ -1,67 +0,0 @@ -namespace KubeOps.Operator.Webhooks; - -/// -/// -/// Validation webhook for kubernetes. -/// This is used by the https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/. -/// Implement a class with this interface, overwrite the needed functions. -/// -/// -/// If there are _any_ webhooks registered in the system, the build process -/// will create the needed certificates for the CA and the service for the -/// application to run. -/// -/// -/// All methods have a default implementation. They *MUST* return in 10s, otherwise kubernetes will fail the call. -/// The async methods do call the sync ones and the sync ones -/// return a "not implemented" result by default. -/// -/// -/// Overwrite the needed ones as defined in . -/// The async implementations take precedence over the synchronous ones. -/// -/// -/// Note that the operator must use HTTPS (in some form) to use validators. -/// For local development, this could be done with ngrok (https://ngrok.com/). -/// -/// -/// The operator generator (if enabled during build) will generate the CA certificate -/// and the operator will generate the server certificate during pod startup. -/// -/// -/// The type of the entity that should be validated. -public interface IValidationWebhook : IAdmissionWebhook -{ - /// - string IAdmissionWebhook.Endpoint - { - get => WebhookEndpointFactory.Create(GetType(), "/validate"); - } - - /// - ValidationResult IAdmissionWebhook.Create(TEntity newEntity, bool dryRun) - => AdmissionResult.NotImplemented(); - - /// - ValidationResult IAdmissionWebhook.Update( - TEntity oldEntity, - TEntity newEntity, - bool dryRun) - => AdmissionResult.NotImplemented(); - - /// - ValidationResult IAdmissionWebhook.Delete(TEntity oldEntity, bool dryRun) - => AdmissionResult.NotImplemented(); - - AdmissionResponse IAdmissionWebhook.TransformResult( - ValidationResult result, - AdmissionRequest request) - => new() - { - Allowed = result.Valid, - Status = result.StatusMessage == null - ? null - : new AdmissionResponse.Reason { Code = result.StatusCode ?? 0, Message = result.StatusMessage, }, - Warnings = result.Warnings.ToArray(), - }; -} diff --git a/_old/src/KubeOps/Operator/Webhooks/MutatingWebhookBuilder.cs b/_old/src/KubeOps/Operator/Webhooks/MutatingWebhookBuilder.cs deleted file mode 100644 index 0c17fc75..00000000 --- a/_old/src/KubeOps/Operator/Webhooks/MutatingWebhookBuilder.cs +++ /dev/null @@ -1,94 +0,0 @@ -using System.Reflection; - -using k8s.Models; - -using KubeOps.KubernetesClient.Entities; -using KubeOps.Operator.Builder; -using KubeOps.Operator.Entities.Extensions; -using KubeOps.Operator.Util; - -namespace KubeOps.Operator.Webhooks; - -internal class MutatingWebhookBuilder -{ - private readonly IComponentRegistrar _componentRegistrar; - private readonly IWebhookMetadataBuilder _webhookMetadataBuilder; - private readonly IServiceProvider _services; - - public MutatingWebhookBuilder( - IComponentRegistrar componentRegistrar, - IWebhookMetadataBuilder webhookMetadataBuilder, - IServiceProvider services) - { - _componentRegistrar = componentRegistrar; - _webhookMetadataBuilder = webhookMetadataBuilder; - _services = services; - } - - public List BuildWebhooks(WebhookConfig webhookConfig) - { - using var scope = _services.CreateScope(); - return _componentRegistrar.MutatorRegistrations - .Select( - wh => - { - (Type mutatorType, Type entityType) = wh; - - var instance = scope.ServiceProvider.GetRequiredService(mutatorType); - - var (name, endpoint) = - _webhookMetadataBuilder.GetMetadata(instance, entityType); - - var clientConfig = new Admissionregistrationv1WebhookClientConfig(); - if (!string.IsNullOrWhiteSpace(webhookConfig.BaseUrl)) - { - clientConfig.Url = webhookConfig.BaseUrl.FormatWebhookUrl(endpoint); - } - else - { - clientConfig.Service = webhookConfig.Service?.DeepClone(); - if (clientConfig.Service != null) - { - clientConfig.Service.Path = endpoint; - } - - clientConfig.CaBundle = webhookConfig.CaBundle; - } - - var operationsProperty = typeof(IAdmissionWebhook<,>) - .MakeGenericType(entityType, typeof(MutationResult)) - .GetProperties(BindingFlags.Instance | BindingFlags.NonPublic) - .First(m => m.Name == "SupportedOperations"); - - var crd = entityType.ToEntityDefinition(); - - var webhook = new V1MutatingWebhook - { - Name = name.TrimWebhookName(), - AdmissionReviewVersions = new[] { "v1" }, - SideEffects = "None", - MatchPolicy = "Exact", - Rules = new List - { - new() - { - Operations = operationsProperty.GetValue(instance) as IList, - Resources = new[] { crd.Plural }, - Scope = "*", - ApiGroups = new[] { crd.Group }, - ApiVersions = new[] { crd.Version }, - }, - }, - ClientConfig = clientConfig, - }; - - if (instance is IConfigurableMutationWebhook configurable) - { - configurable.Configure(webhook); - } - - return webhook; - }) - .ToList(); - } -} diff --git a/_old/src/KubeOps/Operator/Webhooks/MutatingWebhookConfigurationBuilder.cs b/_old/src/KubeOps/Operator/Webhooks/MutatingWebhookConfigurationBuilder.cs deleted file mode 100644 index 5c7e8d89..00000000 --- a/_old/src/KubeOps/Operator/Webhooks/MutatingWebhookConfigurationBuilder.cs +++ /dev/null @@ -1,25 +0,0 @@ -using k8s.Models; - -using KubeOps.Operator.Util; - -namespace KubeOps.Operator.Webhooks; - -internal class MutatingWebhookConfigurationBuilder -{ - private readonly MutatingWebhookBuilder _webhookBuilder; - - public MutatingWebhookConfigurationBuilder(MutatingWebhookBuilder webhookBuilder) - { - _webhookBuilder = webhookBuilder; - } - - public V1MutatingWebhookConfiguration BuildWebhookConfiguration(WebhookConfig webhookConfig) => - new() - { - Kind = V1MutatingWebhookConfiguration.KubeKind, - ApiVersion = - $"{V1MutatingWebhookConfiguration.KubeGroup}/{V1MutatingWebhookConfiguration.KubeApiVersion}", - Metadata = new V1ObjectMeta { Name = webhookConfig.OperatorName.TrimWebhookName("mutators."), }, - Webhooks = _webhookBuilder.BuildWebhooks(webhookConfig), - }; -} diff --git a/_old/src/KubeOps/Operator/Webhooks/ValidatingWebhookBuilder.cs b/_old/src/KubeOps/Operator/Webhooks/ValidatingWebhookBuilder.cs deleted file mode 100644 index 66414cea..00000000 --- a/_old/src/KubeOps/Operator/Webhooks/ValidatingWebhookBuilder.cs +++ /dev/null @@ -1,94 +0,0 @@ -using System.Reflection; - -using k8s.Models; - -using KubeOps.KubernetesClient.Entities; -using KubeOps.Operator.Builder; -using KubeOps.Operator.Entities.Extensions; -using KubeOps.Operator.Util; - -namespace KubeOps.Operator.Webhooks; - -internal class ValidatingWebhookBuilder -{ - private readonly IComponentRegistrar _componentRegistrar; - private readonly IWebhookMetadataBuilder _webhookMetadataBuilder; - private readonly IServiceProvider _services; - - public ValidatingWebhookBuilder( - IComponentRegistrar componentRegistrar, - IWebhookMetadataBuilder webhookMetadataBuilder, - IServiceProvider services) - { - _componentRegistrar = componentRegistrar; - _webhookMetadataBuilder = webhookMetadataBuilder; - _services = services; - } - - public List BuildWebhooks(WebhookConfig webhookConfig) - { - using var scope = _services.CreateScope(); - return _componentRegistrar.ValidatorRegistrations - .Select( - wh => - { - (Type validatorType, Type entityType) = wh; - - var instance = scope.ServiceProvider.GetRequiredService(validatorType); - - var (name, endpoint) = - _webhookMetadataBuilder.GetMetadata(instance, entityType); - - var clientConfig = new Admissionregistrationv1WebhookClientConfig(); - if (!string.IsNullOrWhiteSpace(webhookConfig.BaseUrl)) - { - clientConfig.Url = webhookConfig.BaseUrl.FormatWebhookUrl(endpoint); - } - else - { - clientConfig.Service = webhookConfig.Service?.DeepClone(); - if (clientConfig.Service != null) - { - clientConfig.Service.Path = endpoint; - } - - clientConfig.CaBundle = webhookConfig.CaBundle; - } - - var operationsProperty = typeof(IAdmissionWebhook<,>) - .MakeGenericType(entityType, typeof(ValidationResult)) - .GetProperties(BindingFlags.Instance | BindingFlags.NonPublic) - .First(m => m.Name == "SupportedOperations"); - - var crd = entityType.ToEntityDefinition(); - - var webhook = new V1ValidatingWebhook - { - Name = name.TrimWebhookName(), - AdmissionReviewVersions = new[] { "v1" }, - SideEffects = "None", - MatchPolicy = "Exact", - Rules = new List - { - new() - { - Operations = operationsProperty.GetValue(instance) as IList, - Resources = new[] { crd.Plural }, - Scope = "*", - ApiGroups = new[] { crd.Group }, - ApiVersions = new[] { crd.Version }, - }, - }, - ClientConfig = clientConfig, - }; - - if (instance is IConfigurableValidationWebhook configurable) - { - configurable.Configure(webhook); - } - - return webhook; - }) - .ToList(); - } -} diff --git a/_old/src/KubeOps/Operator/Webhooks/ValidatingWebhookConfigurationBuilder.cs b/_old/src/KubeOps/Operator/Webhooks/ValidatingWebhookConfigurationBuilder.cs deleted file mode 100644 index 5b824010..00000000 --- a/_old/src/KubeOps/Operator/Webhooks/ValidatingWebhookConfigurationBuilder.cs +++ /dev/null @@ -1,25 +0,0 @@ -using k8s.Models; - -using KubeOps.Operator.Util; - -namespace KubeOps.Operator.Webhooks; - -internal class ValidatingWebhookConfigurationBuilder -{ - private readonly ValidatingWebhookBuilder _webhookBuilder; - - public ValidatingWebhookConfigurationBuilder(ValidatingWebhookBuilder webhookBuilder) - { - _webhookBuilder = webhookBuilder; - } - - public V1ValidatingWebhookConfiguration BuildWebhookConfiguration(WebhookConfig webhookConfig) => - new() - { - Kind = V1ValidatingWebhookConfiguration.KubeKind, - ApiVersion = - $"{V1ValidatingWebhookConfiguration.KubeGroup}/{V1ValidatingWebhookConfiguration.KubeApiVersion}", - Metadata = new() { Name = webhookConfig.OperatorName.TrimWebhookName("validators."), }, - Webhooks = _webhookBuilder.BuildWebhooks(webhookConfig), - }; -} diff --git a/_old/src/KubeOps/Operator/Webhooks/ValidationResult.cs b/_old/src/KubeOps/Operator/Webhooks/ValidationResult.cs deleted file mode 100644 index f775cf4f..00000000 --- a/_old/src/KubeOps/Operator/Webhooks/ValidationResult.cs +++ /dev/null @@ -1,37 +0,0 @@ -namespace KubeOps.Operator.Webhooks; - -/// -/// Result that should be returned to kubernetes. -/// This result gets translated to a result object that is then transmitted to kubernetes. -/// Describes results of an . -/// -public sealed class ValidationResult : AdmissionResult -{ - /// - /// Utility method that creates a successful (valid) result with an optional - /// list of warnings. - /// - /// An optional list of warnings to append. - /// A valid . - public static ValidationResult Success(params string[] warnings) => new() { Warnings = warnings }; - - /// - /// Utility method that creates a fail result without any further information. - /// - /// An invalid . - public static ValidationResult Fail() => new() { Valid = false }; - - /// - /// Utility method that creates a fail result with a customized http status code - /// and status message. - /// - /// The custom http-status-code. - /// The custom status-message. - /// An invalid with a custom status-code and status-message. - public static ValidationResult Fail(int statusCode, string statusMessage) => new() - { - Valid = false, - StatusCode = statusCode, - StatusMessage = statusMessage, - }; -} diff --git a/_old/src/KubeOps/Operator/Webhooks/WebhookConfig.cs b/_old/src/KubeOps/Operator/Webhooks/WebhookConfig.cs deleted file mode 100644 index 552d3298..00000000 --- a/_old/src/KubeOps/Operator/Webhooks/WebhookConfig.cs +++ /dev/null @@ -1,9 +0,0 @@ -using k8s.Models; - -namespace KubeOps.Operator.Webhooks; - -internal record WebhookConfig( - string OperatorName, - string? BaseUrl, - byte[]? CaBundle, - Admissionregistrationv1ServiceReference? Service); diff --git a/examples/WebhookOperator/Controller/V1TestEntityController.cs b/examples/WebhookOperator/Controller/V1TestEntityController.cs index 53b6de2d..96c19795 100644 --- a/examples/WebhookOperator/Controller/V1TestEntityController.cs +++ b/examples/WebhookOperator/Controller/V1TestEntityController.cs @@ -1,30 +1,30 @@ -using KubeOps.Abstractions.Controller; -using KubeOps.Abstractions.Rbac; - -using WebhookOperator.Entities; - -namespace WebhookOperator.Controller; - -[EntityRbac(typeof(V1TestEntity), Verbs = RbacVerb.All)] -public class V1TestEntityController : IEntityController -{ - private readonly ILogger _logger; - - public V1TestEntityController( - ILogger logger) - { - _logger = logger; - } - - public Task ReconcileAsync(V1TestEntity entity) - { - _logger.LogInformation("Reconciling entity {Entity}.", entity); - return Task.CompletedTask; - } - - public Task DeletedAsync(V1TestEntity entity) - { - _logger.LogInformation("Deleted entity {Entity}.", entity); - return Task.CompletedTask; - } -} +using KubeOps.Abstractions.Controller; +using KubeOps.Abstractions.Rbac; + +using WebhookOperator.Entities; + +namespace WebhookOperator.Controller; + +[EntityRbac(typeof(V1TestEntity), Verbs = RbacVerb.All)] +public class V1TestEntityController : IEntityController +{ + private readonly ILogger _logger; + + public V1TestEntityController( + ILogger logger) + { + _logger = logger; + } + + public Task ReconcileAsync(V1TestEntity entity) + { + _logger.LogInformation("Reconciling entity {Entity}.", entity); + return Task.CompletedTask; + } + + public Task DeletedAsync(V1TestEntity entity) + { + _logger.LogInformation("Deleted entity {Entity}.", entity); + return Task.CompletedTask; + } +} diff --git a/examples/WebhookOperator/Entities/V1TestEntity.cs b/examples/WebhookOperator/Entities/V1TestEntity.cs index 8610df26..7855d56b 100644 --- a/examples/WebhookOperator/Entities/V1TestEntity.cs +++ b/examples/WebhookOperator/Entities/V1TestEntity.cs @@ -1,16 +1,16 @@ -using k8s.Models; - -using KubeOps.Abstractions.Entities; - -namespace WebhookOperator.Entities; - -[KubernetesEntity(Group = "webhook.dev", ApiVersion = "v1", Kind = "TestEntity")] -public partial class V1TestEntity : CustomKubernetesEntity -{ - public override string ToString() => $"Test Entity ({Metadata.Name}): {Spec.Username}"; - - public class EntitySpec - { - public string Username { get; set; } = string.Empty; - } -} +using k8s.Models; + +using KubeOps.Abstractions.Entities; + +namespace WebhookOperator.Entities; + +[KubernetesEntity(Group = "webhook.dev", ApiVersion = "v1", Kind = "TestEntity")] +public partial class V1TestEntity : CustomKubernetesEntity +{ + public override string ToString() => $"Test Entity ({Metadata.Name}): {Spec.Username}"; + + public class EntitySpec + { + public string Username { get; set; } = string.Empty; + } +} diff --git a/examples/WebhookOperator/Program.cs b/examples/WebhookOperator/Program.cs index 2395f682..aa264da5 100644 --- a/examples/WebhookOperator/Program.cs +++ b/examples/WebhookOperator/Program.cs @@ -1,17 +1,17 @@ -using KubeOps.Operator; - -var builder = WebApplication.CreateBuilder(args); -builder.Services - .AddKubernetesOperator() - .RegisterComponents(); - -builder.Services - .AddControllers(); - -var app = builder.Build(); - -app.UseRouting(); -app.UseDeveloperExceptionPage(); -app.MapControllers(); - -await app.RunAsync(); +using KubeOps.Operator; + +var builder = WebApplication.CreateBuilder(args); +builder.Services + .AddKubernetesOperator() + .RegisterComponents(); + +builder.Services + .AddControllers(); + +var app = builder.Build(); + +app.UseRouting(); +app.UseDeveloperExceptionPage(); +app.MapControllers(); + +await app.RunAsync(); diff --git a/examples/WebhookOperator/Webhooks/TestMutationWebhook.cs b/examples/WebhookOperator/Webhooks/TestMutationWebhook.cs new file mode 100644 index 00000000..27b274d5 --- /dev/null +++ b/examples/WebhookOperator/Webhooks/TestMutationWebhook.cs @@ -0,0 +1,20 @@ +using KubeOps.Operator.Web.Webhooks.Mutation; + +using WebhookOperator.Entities; + +namespace WebhookOperator.Webhooks; + +[MutationWebhook(typeof(V1TestEntity))] +public class TestMutationWebhook : MutationWebhook +{ + public override MutationResult Create(V1TestEntity entity, bool dryRun) + { + if (entity.Spec.Username == "overwrite") + { + entity.Spec.Username = "random overwritten"; + return Modified(entity); + } + + return NoChanges(); + } +} diff --git a/examples/WebhookOperator/Webhooks/TestValidationWebhook.cs b/examples/WebhookOperator/Webhooks/TestValidationWebhook.cs index 061a1e0a..e2904325 100644 --- a/examples/WebhookOperator/Webhooks/TestValidationWebhook.cs +++ b/examples/WebhookOperator/Webhooks/TestValidationWebhook.cs @@ -1,29 +1,29 @@ -using KubeOps.Operator.Web.Webhooks.Validation; - -using WebhookOperator.Entities; - -namespace WebhookOperator.Webhooks; - -[ValidationWebhook(typeof(V1TestEntity))] -public class TestValidationWebhook : ValidationWebhook -{ - public override ValidationResult Create(V1TestEntity entity, bool dryRun) - { - if (entity.Spec.Username == "forbidden") - { - return Fail("name may not be 'forbidden'.", 422); - } - - return Success(); - } - - public override ValidationResult Update(V1TestEntity oldEntity, V1TestEntity newEntity, bool dryRun) - { - if (newEntity.Spec.Username == "forbidden") - { - return Fail("name may not be 'forbidden'."); - } - - return Success(); - } -} +using KubeOps.Operator.Web.Webhooks.Validation; + +using WebhookOperator.Entities; + +namespace WebhookOperator.Webhooks; + +[ValidationWebhook(typeof(V1TestEntity))] +public class TestValidationWebhook : ValidationWebhook +{ + public override ValidationResult Create(V1TestEntity entity, bool dryRun) + { + if (entity.Spec.Username == "forbidden") + { + return Fail("name may not be 'forbidden'.", 422); + } + + return Success(); + } + + public override ValidationResult Update(V1TestEntity oldEntity, V1TestEntity newEntity, bool dryRun) + { + if (newEntity.Spec.Username == "forbidden") + { + return Fail("name may not be 'forbidden'."); + } + + return Success(); + } +} diff --git a/examples/WebhookOperator/test_entity.yaml b/examples/WebhookOperator/test_entity.yaml index 9fa577d7..6f1975cf 100644 --- a/examples/WebhookOperator/test_entity.yaml +++ b/examples/WebhookOperator/test_entity.yaml @@ -3,4 +3,4 @@ kind: TestEntity metadata: name: test-webhook-entity spec: - username: my-username + username: overwrite diff --git a/examples/WebhookOperator/validation_webhook_config.yaml b/examples/WebhookOperator/validation_webhook_config.yaml deleted file mode 100644 index d5dc67e2..00000000 --- a/examples/WebhookOperator/validation_webhook_config.yaml +++ /dev/null @@ -1,24 +0,0 @@ -apiVersion: admissionregistration.k8s.io/v1 -kind: ValidatingWebhookConfiguration -metadata: - name: webhook-operator-dev -webhooks: - - admissionReviewVersions: - - v1 - clientConfig: - url: https://290f-2a02-169-601-0-ad64-cd7-b178-7a5b.ngrok-free.app/validate/v1testentity - failurePolicy: Fail - matchPolicy: Exact - name: test.web.hook.com - rules: - - apiGroups: - - webhook.dev - apiVersions: - - '*' - operations: - - '*' - resources: - - testentitys - scope: '*' - sideEffects: None - timeoutSeconds: 10 diff --git a/examples/WebhookOperator/webhook_configs.yaml b/examples/WebhookOperator/webhook_configs.yaml new file mode 100644 index 00000000..3f1821d1 --- /dev/null +++ b/examples/WebhookOperator/webhook_configs.yaml @@ -0,0 +1,49 @@ +apiVersion: admissionregistration.k8s.io/v1 +kind: ValidatingWebhookConfiguration +metadata: + name: validation-webhooks +webhooks: + - admissionReviewVersions: + - v1 + clientConfig: + url: https://e9f2-85-195-221-58.ngrok-free.app/validate/v1testentity + matchPolicy: Exact + name: test.web.hook.com + rules: + - apiGroups: + - webhook.dev + apiVersions: + - v1 + operations: + - CREATE + - UPDATE + - DELETE + resources: + - testentitys + sideEffects: None + timeoutSeconds: 10 +--- +apiVersion: admissionregistration.k8s.io/v1 +kind: MutatingWebhookConfiguration +metadata: + name: mutation-webhooks +webhooks: + - admissionReviewVersions: + - v1 + clientConfig: + url: https://e9f2-85-195-221-58.ngrok-free.app/mutate/v1testentity + matchPolicy: Exact + name: test.web.hook.com + rules: + - apiGroups: + - webhook.dev + apiVersions: + - v1 + operations: + - CREATE + - UPDATE + - DELETE + resources: + - testentitys + sideEffects: None + timeoutSeconds: 10 diff --git a/src/KubeOps.Abstractions/Kustomize/KustomizationSecretGenerator.cs b/src/KubeOps.Abstractions/Kustomize/KustomizationSecretGenerator.cs index e8cc080e..856dce6e 100644 --- a/src/KubeOps.Abstractions/Kustomize/KustomizationSecretGenerator.cs +++ b/src/KubeOps.Abstractions/Kustomize/KustomizationSecretGenerator.cs @@ -1,23 +1,23 @@ -namespace KubeOps.Abstractions.Kustomize; - -/// -/// Entitiy for config map generators in a kustomization.yaml file. -/// -public class KustomizationSecretGenerator -{ - /// - /// The name of the config map. - /// - public string Name { get; set; } = string.Empty; - - /// - /// List of files that should be added to the generated config map. - /// - public IList? Files { get; set; } - - /// - /// Config literals to add to the config map in the form of: - /// - NAME=value. - /// - public IList? Literals { get; set; } -} +namespace KubeOps.Abstractions.Kustomize; + +/// +/// Entitiy for config map generators in a kustomization.yaml file. +/// +public class KustomizationSecretGenerator +{ + /// + /// The name of the config map. + /// + public string Name { get; set; } = string.Empty; + + /// + /// List of files that should be added to the generated config map. + /// + public IList? Files { get; set; } + + /// + /// Config literals to add to the config map in the form of: + /// - NAME=value. + /// + public IList? Literals { get; set; } +} diff --git a/src/KubeOps.Cli/Certificates/CertificateGenerator.cs b/src/KubeOps.Cli/Certificates/CertificateGenerator.cs index dba5654a..6e3ccf57 100644 --- a/src/KubeOps.Cli/Certificates/CertificateGenerator.cs +++ b/src/KubeOps.Cli/Certificates/CertificateGenerator.cs @@ -1,130 +1,130 @@ -using Org.BouncyCastle.Asn1.X509; -using Org.BouncyCastle.Crypto; -using Org.BouncyCastle.Crypto.Generators; -using Org.BouncyCastle.Crypto.Operators; -using Org.BouncyCastle.Crypto.Prng; -using Org.BouncyCastle.Math; -using Org.BouncyCastle.Security; -using Org.BouncyCastle.Utilities; -using Org.BouncyCastle.X509; -using Org.BouncyCastle.X509.Extension; - -namespace KubeOps.Cli.Certificates; - -internal static class CertificateGenerator -{ - public static (X509Certificate Certificate, AsymmetricCipherKeyPair Key) CreateCaCertificate() - { - var randomGenerator = new CryptoApiRandomGenerator(); - var random = new SecureRandom(randomGenerator); - - // The Certificate Generator - var certificateGenerator = new X509V3CertificateGenerator(); - - // Serial Number - var serialNumber = BigIntegers.CreateRandomInRange(BigInteger.One, BigInteger.ValueOf(long.MaxValue), random); - certificateGenerator.SetSerialNumber(serialNumber); - - // Issuer and Subject Name - var name = new X509Name("CN=Operator Root CA, C=DEV, L=Kubernetes"); - certificateGenerator.SetIssuerDN(name); - certificateGenerator.SetSubjectDN(name); - - // Valid For - var notBefore = DateTime.UtcNow.Date; - var notAfter = notBefore.AddYears(5); - certificateGenerator.SetNotBefore(notBefore); - certificateGenerator.SetNotAfter(notAfter); - - // Cert Extensions - certificateGenerator.AddExtension( - X509Extensions.BasicConstraints, - true, - new BasicConstraints(true)); - certificateGenerator.AddExtension( - X509Extensions.KeyUsage, - true, - new KeyUsage(KeyUsage.KeyCertSign | KeyUsage.CrlSign | KeyUsage.KeyEncipherment)); - - // Subject Public Key - const int keyStrength = 256; - var keyGenerator = new ECKeyPairGenerator("ECDSA"); - keyGenerator.Init(new KeyGenerationParameters(random, keyStrength)); - var key = keyGenerator.GenerateKeyPair(); - - certificateGenerator.SetPublicKey(key.Public); - - var signatureFactory = new Asn1SignatureFactory("SHA512WITHECDSA", key.Private, random); - var certificate = certificateGenerator.Generate(signatureFactory); - - return (certificate, key); - } - - public static (X509Certificate Certificate, AsymmetricCipherKeyPair Key) CreateServerCertificate( - (X509Certificate Certificate, AsymmetricCipherKeyPair Key) ca, string serverName, string serverNamespace) - { - var randomGenerator = new CryptoApiRandomGenerator(); - var random = new SecureRandom(randomGenerator); - - // The Certificate Generator - var certificateGenerator = new X509V3CertificateGenerator(); - - // Serial Number - var serialNumber = BigIntegers.CreateRandomInRange(BigInteger.One, BigInteger.ValueOf(long.MaxValue), random); - certificateGenerator.SetSerialNumber(serialNumber); - - // Issuer and Subject Name - certificateGenerator.SetIssuerDN(ca.Certificate.SubjectDN); - certificateGenerator.SetSubjectDN(new X509Name("CN=Operator Service, C=DEV, L=Kubernetes")); - - // Valid For - var notBefore = DateTime.UtcNow.Date; - var notAfter = notBefore.AddYears(5); - certificateGenerator.SetNotBefore(notBefore); - certificateGenerator.SetNotAfter(notAfter); - - // Cert Extensions - certificateGenerator.AddExtension( - X509Extensions.BasicConstraints, - false, - new BasicConstraints(false)); - certificateGenerator.AddExtension( - X509Extensions.KeyUsage, - true, - new KeyUsage(KeyUsage.NonRepudiation | KeyUsage.KeyEncipherment | KeyUsage.DigitalSignature)); - certificateGenerator.AddExtension( - X509Extensions.ExtendedKeyUsage, - false, - new ExtendedKeyUsage(KeyPurposeID.id_kp_clientAuth, KeyPurposeID.id_kp_serverAuth)); - certificateGenerator.AddExtension( - X509Extensions.SubjectKeyIdentifier, - false, - new SubjectKeyIdentifierStructure(ca.Key.Public)); - certificateGenerator.AddExtension( - X509Extensions.AuthorityKeyIdentifier, - false, - new AuthorityKeyIdentifierStructure(ca.Certificate)); - certificateGenerator.AddExtension( - X509Extensions.SubjectAlternativeName, - false, - new GeneralNames(new[] - { - new GeneralName(GeneralName.DnsName, $"{serverName}.{serverNamespace}.svc"), - new GeneralName(GeneralName.DnsName, $"*.{serverNamespace}.svc"), - new GeneralName(GeneralName.DnsName, "*.svc"), - })); - - // Subject Public Key - const int keyStrength = 256; - var keyGenerator = new ECKeyPairGenerator("ECDSA"); - keyGenerator.Init(new KeyGenerationParameters(random, keyStrength)); - var key = keyGenerator.GenerateKeyPair(); - - certificateGenerator.SetPublicKey(key.Public); - - var signatureFactory = new Asn1SignatureFactory("SHA512WITHECDSA", ca.Key.Private, random); - var certificate = certificateGenerator.Generate(signatureFactory); - - return (certificate, key); - } -} +using Org.BouncyCastle.Asn1.X509; +using Org.BouncyCastle.Crypto; +using Org.BouncyCastle.Crypto.Generators; +using Org.BouncyCastle.Crypto.Operators; +using Org.BouncyCastle.Crypto.Prng; +using Org.BouncyCastle.Math; +using Org.BouncyCastle.Security; +using Org.BouncyCastle.Utilities; +using Org.BouncyCastle.X509; +using Org.BouncyCastle.X509.Extension; + +namespace KubeOps.Cli.Certificates; + +internal static class CertificateGenerator +{ + public static (X509Certificate Certificate, AsymmetricCipherKeyPair Key) CreateCaCertificate() + { + var randomGenerator = new CryptoApiRandomGenerator(); + var random = new SecureRandom(randomGenerator); + + // The Certificate Generator + var certificateGenerator = new X509V3CertificateGenerator(); + + // Serial Number + var serialNumber = BigIntegers.CreateRandomInRange(BigInteger.One, BigInteger.ValueOf(long.MaxValue), random); + certificateGenerator.SetSerialNumber(serialNumber); + + // Issuer and Subject Name + var name = new X509Name("CN=Operator Root CA, C=DEV, L=Kubernetes"); + certificateGenerator.SetIssuerDN(name); + certificateGenerator.SetSubjectDN(name); + + // Valid For + var notBefore = DateTime.UtcNow.Date; + var notAfter = notBefore.AddYears(5); + certificateGenerator.SetNotBefore(notBefore); + certificateGenerator.SetNotAfter(notAfter); + + // Cert Extensions + certificateGenerator.AddExtension( + X509Extensions.BasicConstraints, + true, + new BasicConstraints(true)); + certificateGenerator.AddExtension( + X509Extensions.KeyUsage, + true, + new KeyUsage(KeyUsage.KeyCertSign | KeyUsage.CrlSign | KeyUsage.KeyEncipherment)); + + // Subject Public Key + const int keyStrength = 256; + var keyGenerator = new ECKeyPairGenerator("ECDSA"); + keyGenerator.Init(new KeyGenerationParameters(random, keyStrength)); + var key = keyGenerator.GenerateKeyPair(); + + certificateGenerator.SetPublicKey(key.Public); + + var signatureFactory = new Asn1SignatureFactory("SHA512WITHECDSA", key.Private, random); + var certificate = certificateGenerator.Generate(signatureFactory); + + return (certificate, key); + } + + public static (X509Certificate Certificate, AsymmetricCipherKeyPair Key) CreateServerCertificate( + (X509Certificate Certificate, AsymmetricCipherKeyPair Key) ca, string serverName, string serverNamespace) + { + var randomGenerator = new CryptoApiRandomGenerator(); + var random = new SecureRandom(randomGenerator); + + // The Certificate Generator + var certificateGenerator = new X509V3CertificateGenerator(); + + // Serial Number + var serialNumber = BigIntegers.CreateRandomInRange(BigInteger.One, BigInteger.ValueOf(long.MaxValue), random); + certificateGenerator.SetSerialNumber(serialNumber); + + // Issuer and Subject Name + certificateGenerator.SetIssuerDN(ca.Certificate.SubjectDN); + certificateGenerator.SetSubjectDN(new X509Name("CN=Operator Service, C=DEV, L=Kubernetes")); + + // Valid For + var notBefore = DateTime.UtcNow.Date; + var notAfter = notBefore.AddYears(5); + certificateGenerator.SetNotBefore(notBefore); + certificateGenerator.SetNotAfter(notAfter); + + // Cert Extensions + certificateGenerator.AddExtension( + X509Extensions.BasicConstraints, + false, + new BasicConstraints(false)); + certificateGenerator.AddExtension( + X509Extensions.KeyUsage, + true, + new KeyUsage(KeyUsage.NonRepudiation | KeyUsage.KeyEncipherment | KeyUsage.DigitalSignature)); + certificateGenerator.AddExtension( + X509Extensions.ExtendedKeyUsage, + false, + new ExtendedKeyUsage(KeyPurposeID.id_kp_clientAuth, KeyPurposeID.id_kp_serverAuth)); + certificateGenerator.AddExtension( + X509Extensions.SubjectKeyIdentifier, + false, + new SubjectKeyIdentifierStructure(ca.Key.Public)); + certificateGenerator.AddExtension( + X509Extensions.AuthorityKeyIdentifier, + false, + new AuthorityKeyIdentifierStructure(ca.Certificate)); + certificateGenerator.AddExtension( + X509Extensions.SubjectAlternativeName, + false, + new GeneralNames(new[] + { + new GeneralName(GeneralName.DnsName, $"{serverName}.{serverNamespace}.svc"), + new GeneralName(GeneralName.DnsName, $"*.{serverNamespace}.svc"), + new GeneralName(GeneralName.DnsName, "*.svc"), + })); + + // Subject Public Key + const int keyStrength = 256; + var keyGenerator = new ECKeyPairGenerator("ECDSA"); + keyGenerator.Init(new KeyGenerationParameters(random, keyStrength)); + var key = keyGenerator.GenerateKeyPair(); + + certificateGenerator.SetPublicKey(key.Public); + + var signatureFactory = new Asn1SignatureFactory("SHA512WITHECDSA", ca.Key.Private, random); + var certificate = certificateGenerator.Generate(signatureFactory); + + return (certificate, key); + } +} diff --git a/src/KubeOps.Cli/Certificates/Extensions.cs b/src/KubeOps.Cli/Certificates/Extensions.cs index dfe53b83..a72bf103 100644 --- a/src/KubeOps.Cli/Certificates/Extensions.cs +++ b/src/KubeOps.Cli/Certificates/Extensions.cs @@ -1,22 +1,22 @@ -using System.Text; - -using Org.BouncyCastle.Crypto; -using Org.BouncyCastle.OpenSsl; -using Org.BouncyCastle.X509; - -namespace KubeOps.Cli.Certificates; - -internal static class Extensions -{ - public static string ToPem(this X509Certificate cert) => ObjToPem(cert); - - public static string ToPem(this AsymmetricCipherKeyPair key) => ObjToPem(key); - - private static string ObjToPem(object obj) - { - var sb = new StringBuilder(); - using var writer = new PemWriter(new StringWriter(sb)); - writer.WriteObject(obj); - return sb.ToString(); - } -} +using System.Text; + +using Org.BouncyCastle.Crypto; +using Org.BouncyCastle.OpenSsl; +using Org.BouncyCastle.X509; + +namespace KubeOps.Cli.Certificates; + +internal static class Extensions +{ + public static string ToPem(this X509Certificate cert) => ObjToPem(cert); + + public static string ToPem(this AsymmetricCipherKeyPair key) => ObjToPem(key); + + private static string ObjToPem(object obj) + { + var sb = new StringBuilder(); + using var writer = new PemWriter(new StringWriter(sb)); + writer.WriteObject(obj); + return sb.ToString(); + } +} diff --git a/src/KubeOps.Cli/Commands/Generator/WebhookOperatorGenerator.cs b/src/KubeOps.Cli/Commands/Generator/WebhookOperatorGenerator.cs index c2e2d313..5d3472c7 100644 --- a/src/KubeOps.Cli/Commands/Generator/WebhookOperatorGenerator.cs +++ b/src/KubeOps.Cli/Commands/Generator/WebhookOperatorGenerator.cs @@ -1,264 +1,265 @@ -using System.CommandLine; -using System.CommandLine.Invocation; -using System.Text; - -using k8s; -using k8s.Models; - -using KubeOps.Abstractions.Kustomize; -using KubeOps.Cli.Certificates; -using KubeOps.Cli.Output; -using KubeOps.Cli.Transpilation; -using KubeOps.Transpiler; - -using Spectre.Console; - -namespace KubeOps.Cli.Commands.Generator; - -internal static class WebhookOperatorGenerator -{ - public static Command Command - { - get - { - var cmd = new Command( - "webhook-operator", - "Generates deployments and other resources for an operator with webhooks to run.") - { - Options.OutputFormat, - Options.OutputPath, - Options.SolutionProjectRegex, - Options.TargetFramework, - Arguments.OperatorName, - Arguments.SolutionOrProjectFile, - }; - cmd.AddAlias("wh-op"); - cmd.SetHandler(ctx => Handler(AnsiConsole.Console, ctx)); - - return cmd; - } - } - - internal static async Task Handler(IAnsiConsole console, InvocationContext ctx) - { - var name = ctx.ParseResult.GetValueForArgument(Arguments.OperatorName); - var file = ctx.ParseResult.GetValueForArgument(Arguments.SolutionOrProjectFile); - var outPath = ctx.ParseResult.GetValueForOption(Options.OutputPath); - var format = ctx.ParseResult.GetValueForOption(Options.OutputFormat); - - var result = new ResultOutput(console, format); - console.WriteLine("Generate webhook resources."); - - console.MarkupLine("Generate [cyan]CA[/] certificate and private key."); - var (caCert, caKey) = Certificates.CertificateGenerator.CreateCaCertificate(); - - result.Add("ca.pem", caCert.ToPem(), OutputFormat.Plain); - result.Add("ca-key.pem", caKey.ToPem(), OutputFormat.Plain); - - console.MarkupLine("Generate [cyan]server[/] certificate and private key."); - var (srvCert, srvKey) = Certificates.CertificateGenerator.CreateServerCertificate( - (caCert, caKey), - name, - $"{name}-system"); - - result.Add("svc.pem", srvCert.ToPem(), OutputFormat.Plain); - result.Add("svc-key.pem", srvKey.ToPem(), OutputFormat.Plain); - - console.MarkupLine("Generate [cyan]deployment[/]."); - result.Add( - $"deployment.{format.ToString().ToLowerInvariant()}", - new V1Deployment( - metadata: new V1ObjectMeta( - labels: new Dictionary { { "operator-deployment", "kubernetes-operator" } }, - name: "operator"), - spec: new V1DeploymentSpec - { - Replicas = 1, - RevisionHistoryLimit = 0, - Selector = new V1LabelSelector( - matchLabels: - new Dictionary { { "operator-deployment", "kubernetes-operator" } }), - Template = new V1PodTemplateSpec - { - Metadata = new V1ObjectMeta( - labels: - new Dictionary { { "operator-deployment", "kubernetes-operator" }, }), - Spec = new V1PodSpec - { - TerminationGracePeriodSeconds = 10, - Volumes = new List - { - new() { Name = "certificates", Secret = new() { SecretName = "webhook-cert" }, }, - new() { Name = "ca-certificates", Secret = new() { SecretName = "webhook-ca" }, }, - }, - Containers = new List - { - new() - { - Image = "operator", - Name = "operator", - VolumeMounts = new List - { - new() - { - Name = "certificates", - MountPath = "/certs", - ReadOnlyProperty = true, - }, - new() - { - Name = "ca-certificates", - MountPath = "/ca", - ReadOnlyProperty = true, - }, - }, - Env = new List - { - new() - { - Name = "POD_NAMESPACE", - ValueFrom = - new V1EnvVarSource - { - FieldRef = new V1ObjectFieldSelector - { - FieldPath = "metadata.namespace", - }, - }, - }, - }, - EnvFrom = - new List - { - new() { ConfigMapRef = new() { Name = "webhook-config" } }, - }, - Ports = new List { new(5001, name: "https"), }, - Resources = new V1ResourceRequirements - { - Requests = new Dictionary - { - { "cpu", new ResourceQuantity("100m") }, - { "memory", new ResourceQuantity("64Mi") }, - }, - Limits = new Dictionary - { - { "cpu", new ResourceQuantity("100m") }, - { "memory", new ResourceQuantity("128Mi") }, - }, - }, - }, - }, - }, - }, - }).Initialize()); - - console.MarkupLine("Generate [cyan]service[/]."); - result.Add( - $"service.{format.ToString().ToLowerInvariant()}", - new V1Service( - metadata: new V1ObjectMeta(name: "operator"), - spec: new V1ServiceSpec - { - Ports = - new List { new() { Name = "https", TargetPort = "https", Port = 443, }, }, - Selector = new Dictionary { { "operator-deployment", "kubernetes-operator" }, }, - }).Initialize()); - - console.MarkupLine("Generate [cyan]webhook configurations[/]."); - var parser = file switch - { - { Extension: ".csproj", Exists: true } => await AssemblyLoader.ForProject(console, file), - { Extension: ".sln", Exists: true } => await AssemblyLoader.ForSolution( - console, - file, - ctx.ParseResult.GetValueForOption(Options.SolutionProjectRegex), - ctx.ParseResult.GetValueForOption(Options.TargetFramework)), - { Exists: false } => throw new FileNotFoundException($"The file {file.Name} does not exist."), - _ => throw new NotSupportedException("Only *.csproj and *.sln files are supported."), - }; - var validatedEntities = parser.GetValidatedEntities().ToList(); - var validatorConfig = new V1ValidatingWebhookConfiguration( - metadata: new V1ObjectMeta(name: "validators"), - webhooks: new List()).Initialize(); - - foreach (var entity in validatedEntities) - { - var (metadata, _) = parser.ToEntityMetadata(entity); - validatorConfig.Webhooks.Add(new V1ValidatingWebhook - { - Name = $"validate.{metadata.SingularName}.{metadata.Group}.{metadata.Version}", - MatchPolicy = "Exact", - AdmissionReviewVersions = new[] { "v1" }, - SideEffects = "None", - Rules = new[] - { - new V1RuleWithOperations - { - Operations = new[] { "CREATE", "UPDATE", "DELETE" }, - Resources = new[] { metadata.PluralName }, - ApiGroups = new[] { metadata.Group }, - ApiVersions = new[] { metadata.Version }, - }, - }, - ClientConfig = new Admissionregistrationv1WebhookClientConfig - { - CaBundle = Encoding.ASCII.GetBytes(Convert.ToBase64String(Encoding.ASCII.GetBytes(caCert.ToPem()))), - Service = new Admissionregistrationv1ServiceReference - { - Name = "operator", Path = $"/validate/{entity.Name.ToLowerInvariant()}", - }, - }, - }); - } - - if (validatedEntities.Any()) - { - result.Add( - $"validators.{format.ToString().ToLowerInvariant()}", validatorConfig); - } - - result.Add( - $"kustomization.{format.ToString().ToLowerInvariant()}", - new KustomizationConfig - { - Resources = - new List - { - $"deployment.{format.ToString().ToLowerInvariant()}", - $"service.{format.ToString().ToLowerInvariant()}", - validatorConfig.Webhooks.Any() - ? $"validators.{format.ToString().ToLowerInvariant()}" - : string.Empty, - }.Where(s => !string.IsNullOrWhiteSpace(s)).ToList(), - CommonLabels = new Dictionary { { "operator-element", "operator-instance" }, }, - ConfigMapGenerator = new List - { - new() - { - Name = "webhook-config", - Literals = new List - { - "KESTREL__ENDPOINTS__HTTP__URL=http://0.0.0.0:5000", - "KESTREL__ENDPOINTS__HTTPS__URL=https://0.0.0.0:5001", - "KESTREL__ENDPOINTS__HTTPS__CERTIFICATE__PATH=/certs/svc.pem", - "KESTREL__ENDPOINTS__HTTPS__CERTIFICATE__KEYPATH=/certs/svc-key.pem", - }, - }, - }, - SecretGenerator = new List - { - new() { Name = "webhook-ca", Files = new List { "ca.pem", "ca-key.pem", }, }, - new() { Name = "webhook-cert", Files = new List { "svc.pem", "svc-key.pem", }, }, - }, - }); - - if (outPath is not null) - { - await result.Write(outPath); - } - else - { - result.Write(); - } - } -} +using System.CommandLine; +using System.CommandLine.Invocation; +using System.Text; + +using k8s; +using k8s.Models; + +using KubeOps.Abstractions.Kustomize; +using KubeOps.Cli.Certificates; +using KubeOps.Cli.Output; +using KubeOps.Cli.Transpilation; +using KubeOps.Transpiler; + +using Spectre.Console; + +namespace KubeOps.Cli.Commands.Generator; + +internal static class WebhookOperatorGenerator +{ + public static Command Command + { + get + { + var cmd = new Command( + "webhook-operator", + "Generates deployments and other resources for an operator with webhooks to run.") + { + Options.OutputFormat, + Options.OutputPath, + Options.SolutionProjectRegex, + Options.TargetFramework, + Arguments.OperatorName, + Arguments.SolutionOrProjectFile, + }; + cmd.AddAlias("wh-op"); + cmd.SetHandler(ctx => Handler(AnsiConsole.Console, ctx)); + + return cmd; + } + } + + internal static async Task Handler(IAnsiConsole console, InvocationContext ctx) + { + var name = ctx.ParseResult.GetValueForArgument(Arguments.OperatorName); + var file = ctx.ParseResult.GetValueForArgument(Arguments.SolutionOrProjectFile); + var outPath = ctx.ParseResult.GetValueForOption(Options.OutputPath); + var format = ctx.ParseResult.GetValueForOption(Options.OutputFormat); + + var result = new ResultOutput(console, format); + console.WriteLine("Generate webhook resources."); + + console.MarkupLine("Generate [cyan]CA[/] certificate and private key."); + var (caCert, caKey) = Certificates.CertificateGenerator.CreateCaCertificate(); + + result.Add("ca.pem", caCert.ToPem(), OutputFormat.Plain); + result.Add("ca-key.pem", caKey.ToPem(), OutputFormat.Plain); + + console.MarkupLine("Generate [cyan]server[/] certificate and private key."); + var (srvCert, srvKey) = Certificates.CertificateGenerator.CreateServerCertificate( + (caCert, caKey), + name, + $"{name}-system"); + + result.Add("svc.pem", srvCert.ToPem(), OutputFormat.Plain); + result.Add("svc-key.pem", srvKey.ToPem(), OutputFormat.Plain); + + console.MarkupLine("Generate [cyan]deployment[/]."); + result.Add( + $"deployment.{format.ToString().ToLowerInvariant()}", + new V1Deployment( + metadata: new V1ObjectMeta( + labels: new Dictionary { { "operator-deployment", "kubernetes-operator" } }, + name: "operator"), + spec: new V1DeploymentSpec + { + Replicas = 1, + RevisionHistoryLimit = 0, + Selector = new V1LabelSelector( + matchLabels: + new Dictionary { { "operator-deployment", "kubernetes-operator" } }), + Template = new V1PodTemplateSpec + { + Metadata = new V1ObjectMeta( + labels: + new Dictionary { { "operator-deployment", "kubernetes-operator" }, }), + Spec = new V1PodSpec + { + TerminationGracePeriodSeconds = 10, + Volumes = new List + { + new() { Name = "certificates", Secret = new() { SecretName = "webhook-cert" }, }, + new() { Name = "ca-certificates", Secret = new() { SecretName = "webhook-ca" }, }, + }, + Containers = new List + { + new() + { + Image = "operator", + Name = "operator", + VolumeMounts = new List + { + new() + { + Name = "certificates", + MountPath = "/certs", + ReadOnlyProperty = true, + }, + new() + { + Name = "ca-certificates", + MountPath = "/ca", + ReadOnlyProperty = true, + }, + }, + Env = new List + { + new() + { + Name = "POD_NAMESPACE", + ValueFrom = + new V1EnvVarSource + { + FieldRef = new V1ObjectFieldSelector + { + FieldPath = "metadata.namespace", + }, + }, + }, + }, + EnvFrom = + new List + { + new() { ConfigMapRef = new() { Name = "webhook-config" } }, + }, + Ports = new List { new(5001, name: "https"), }, + Resources = new V1ResourceRequirements + { + Requests = new Dictionary + { + { "cpu", new ResourceQuantity("100m") }, + { "memory", new ResourceQuantity("64Mi") }, + }, + Limits = new Dictionary + { + { "cpu", new ResourceQuantity("100m") }, + { "memory", new ResourceQuantity("128Mi") }, + }, + }, + }, + }, + }, + }, + }).Initialize()); + + console.MarkupLine("Generate [cyan]service[/]."); + result.Add( + $"service.{format.ToString().ToLowerInvariant()}", + new V1Service( + metadata: new V1ObjectMeta(name: "operator"), + spec: new V1ServiceSpec + { + Ports = + new List { new() { Name = "https", TargetPort = "https", Port = 443, }, }, + Selector = new Dictionary { { "operator-deployment", "kubernetes-operator" }, }, + }).Initialize()); + + console.MarkupLine("Generate [cyan]webhook configurations[/]."); + var parser = file switch + { + { Extension: ".csproj", Exists: true } => await AssemblyLoader.ForProject(console, file), + { Extension: ".sln", Exists: true } => await AssemblyLoader.ForSolution( + console, + file, + ctx.ParseResult.GetValueForOption(Options.SolutionProjectRegex), + ctx.ParseResult.GetValueForOption(Options.TargetFramework)), + { Exists: false } => throw new FileNotFoundException($"The file {file.Name} does not exist."), + _ => throw new NotSupportedException("Only *.csproj and *.sln files are supported."), + }; + var validatedEntities = parser.GetValidatedEntities().ToList(); + var validatorConfig = new V1ValidatingWebhookConfiguration( + metadata: new V1ObjectMeta(name: "validators"), + webhooks: new List()).Initialize(); + + foreach (var entity in validatedEntities) + { + var (metadata, _) = parser.ToEntityMetadata(entity); + validatorConfig.Webhooks.Add(new V1ValidatingWebhook + { + Name = $"validate.{metadata.SingularName}.{metadata.Group}.{metadata.Version}", + MatchPolicy = "Exact", + AdmissionReviewVersions = new[] { "v1" }, + SideEffects = "None", + Rules = new[] + { + new V1RuleWithOperations + { + Operations = new[] { "CREATE", "UPDATE", "DELETE" }, + Resources = new[] { metadata.PluralName }, + ApiGroups = new[] { metadata.Group }, + ApiVersions = new[] { metadata.Version }, + }, + }, + ClientConfig = new Admissionregistrationv1WebhookClientConfig + { + CaBundle = Encoding.ASCII.GetBytes(Convert.ToBase64String(Encoding.ASCII.GetBytes(caCert.ToPem()))), + Service = new Admissionregistrationv1ServiceReference + { + Name = "operator", + Path = $"/validate/{entity.Name.ToLowerInvariant()}", + }, + }, + }); + } + + if (validatedEntities.Any()) + { + result.Add( + $"validators.{format.ToString().ToLowerInvariant()}", validatorConfig); + } + + result.Add( + $"kustomization.{format.ToString().ToLowerInvariant()}", + new KustomizationConfig + { + Resources = + new List + { + $"deployment.{format.ToString().ToLowerInvariant()}", + $"service.{format.ToString().ToLowerInvariant()}", + validatorConfig.Webhooks.Any() + ? $"validators.{format.ToString().ToLowerInvariant()}" + : string.Empty, + }.Where(s => !string.IsNullOrWhiteSpace(s)).ToList(), + CommonLabels = new Dictionary { { "operator-element", "operator-instance" }, }, + ConfigMapGenerator = new List + { + new() + { + Name = "webhook-config", + Literals = new List + { + "KESTREL__ENDPOINTS__HTTP__URL=http://0.0.0.0:5000", + "KESTREL__ENDPOINTS__HTTPS__URL=https://0.0.0.0:5001", + "KESTREL__ENDPOINTS__HTTPS__CERTIFICATE__PATH=/certs/svc.pem", + "KESTREL__ENDPOINTS__HTTPS__CERTIFICATE__KEYPATH=/certs/svc-key.pem", + }, + }, + }, + SecretGenerator = new List + { + new() { Name = "webhook-ca", Files = new List { "ca.pem", "ca-key.pem", }, }, + new() { Name = "webhook-cert", Files = new List { "svc.pem", "svc-key.pem", }, }, + }, + }); + + if (outPath is not null) + { + await result.Write(outPath); + } + else + { + result.Write(); + } + } +} diff --git a/src/KubeOps.Cli/Commands/Management/Install.cs b/src/KubeOps.Cli/Commands/Management/Install.cs index ca94b0b3..ed16f0ba 100644 --- a/src/KubeOps.Cli/Commands/Management/Install.cs +++ b/src/KubeOps.Cli/Commands/Management/Install.cs @@ -78,7 +78,7 @@ internal static async Task Handler(IAnsiConsole console, IKubernetes client, Inv case { Items: [var existing] }: console.MarkupLineInterpolated( $"""[yellow]CRD "{crd.Spec.Group}/{crd.Spec.Names.Kind}" already exists.[/]"""); - if (!force && console.Confirm("[yellow]Should the CRD be overwritten?[/]")) + if (!force && !console.Confirm("[yellow]Should the CRD be overwritten?[/]")) { ctx.ExitCode = ExitCodes.Aborted; return; diff --git a/src/KubeOps.Cli/Transpilation/AssemblyLoader.cs b/src/KubeOps.Cli/Transpilation/AssemblyLoader.cs index 84273738..98cdc3b3 100644 --- a/src/KubeOps.Cli/Transpilation/AssemblyLoader.cs +++ b/src/KubeOps.Cli/Transpilation/AssemblyLoader.cs @@ -1,177 +1,177 @@ -using System.Reflection; -using System.Text; -using System.Text.RegularExpressions; - -using k8s.Models; - -using KubeOps.Abstractions.Entities.Attributes; -using KubeOps.Abstractions.Rbac; -using KubeOps.Operator.Web.Webhooks.Validation; -using KubeOps.Transpiler; - -using Microsoft.Build.Locator; -using Microsoft.CodeAnalysis.MSBuild; - -using Spectre.Console; - -namespace KubeOps.Cli.Transpilation; - -/// -/// AssemblyLoader. -/// -internal static partial class AssemblyLoader -{ - static AssemblyLoader() - { - MSBuildLocator.RegisterDefaults(); - } - - public static Task ForProject( - IAnsiConsole console, - FileInfo projectFile) - => console.Status().StartAsync($"Compiling {projectFile.Name}...", async _ => - { - console.MarkupLineInterpolated($"Compile project [aqua]{projectFile.FullName}[/]."); - using var workspace = MSBuildWorkspace.Create(); - workspace.SkipUnrecognizedProjects = true; - workspace.LoadMetadataForReferencedProjects = true; - console.WriteLine("Load project."); - var project = await workspace.OpenProjectAsync(projectFile.FullName); - console.MarkupLine("[green]Project loaded.[/]"); - console.WriteLine("Load compilation context."); - var compilation = await project.GetCompilationAsync(); - console.MarkupLine("[green]Compilation context loaded.[/]"); - if (compilation is null) - { - throw new AggregateException("Compilation could not be found."); - } - - using var assemblyStream = new MemoryStream(); - console.WriteLine("Start compilation."); - switch (compilation.Emit(assemblyStream)) - { - case { Success: false, Diagnostics: var diag }: - throw new AggregateException( - $"Compilation failed: {diag.Aggregate(new StringBuilder(), (sb, d) => sb.AppendLine(d.ToString()))}"); - } - - console.MarkupLine("[green]Compilation successful.[/]"); - console.WriteLine(); - var mlc = new MetadataLoadContext( - new PathAssemblyResolver(project.MetadataReferences.Select(m => m.Display ?? string.Empty) - .Concat(new[] { typeof(object).Assembly.Location }))); - mlc.LoadFromByteArray(assemblyStream.ToArray()); - - return mlc; - }); - - public static Task ForSolution( - IAnsiConsole console, - FileInfo slnFile, - Regex? projectFilter = null, - string? tfm = null) - => console.Status().StartAsync($"Compiling {slnFile.Name}...", async _ => - { - projectFilter ??= DefaultRegex(); - tfm ??= "latest"; - - console.MarkupLineInterpolated($"Compile solution [aqua]{slnFile.FullName}[/]."); -#pragma warning disable RCS1097 - console.MarkupLineInterpolated($"[grey]With project filter:[/] {projectFilter.ToString()}"); -#pragma warning restore RCS1097 - console.MarkupLineInterpolated($"[grey]With Target Platform:[/] {tfm}"); - - using var workspace = MSBuildWorkspace.Create(); - workspace.SkipUnrecognizedProjects = true; - workspace.LoadMetadataForReferencedProjects = true; - console.WriteLine("Load solution."); - var solution = await workspace.OpenSolutionAsync(slnFile.FullName); - console.MarkupLine("[green]Solution loaded.[/]"); - - var assemblies = await Task.WhenAll(solution.Projects - .Select(p => - { - var name = TfmComparer.TfmRegex().Replace(p.Name, string.Empty); - var tfm = TfmComparer.TfmRegex().Match(p.Name).Groups["tfm"].Value; - return (name, tfm, project: p); - }) - .Where(p => projectFilter.IsMatch(p.name)) - .Where(p => tfm == "latest" || p.tfm.Length == 0 || p.tfm == tfm) - .OrderByDescending(p => p.tfm, new TfmComparer()) - .GroupBy(p => p.name) - .Select(p => p.FirstOrDefault()) - .Where(p => p != default) - .Select(async p => - { - console.MarkupLineInterpolated( - $"Load compilation context for [aqua]{p.name}[/]{(p.tfm.Length > 0 ? $" [grey]{p.tfm}[/]" : string.Empty)}."); - var compilation = await p.project.GetCompilationAsync(); - console.MarkupLineInterpolated($"[green]Compilation context loaded for {p.name}.[/]"); - if (compilation is null) - { - throw new AggregateException("Compilation could not be found."); - } - - using var assemblyStream = new MemoryStream(); - console.MarkupLineInterpolated( - $"Start compilation for [aqua]{p.name}[/]{(p.tfm.Length > 0 ? $" [grey]{p.tfm}[/]" : string.Empty)}."); - switch (compilation.Emit(assemblyStream)) - { - case { Success: false, Diagnostics: var diag }: - throw new AggregateException( - $"Compilation failed: {diag.Aggregate(new StringBuilder(), (sb, d) => sb.AppendLine(d.ToString()))}"); - } - - console.MarkupLineInterpolated($"[green]Compilation successful for {p.name}.[/]"); - return (Assembly: assemblyStream.ToArray(), - Refs: p.project.MetadataReferences.Select(m => m.Display ?? string.Empty)); - })); - - console.WriteLine(); - var mlc = new MetadataLoadContext( - new PathAssemblyResolver(assemblies.SelectMany(a => a.Refs) - .Concat(new[] { typeof(object).Assembly.Location }).Distinct())); - foreach (var assembly in assemblies) - { - mlc.LoadFromByteArray(assembly.Assembly); - } - - return mlc; - }); - - public static IEnumerable GetEntities(this MetadataLoadContext context) => context.GetAssemblies() - .SelectMany(a => a.DefinedTypes) - .Select(t => (t, attrs: CustomAttributeData.GetCustomAttributes(t))) - .Where(e => e.attrs.Any(a => a.AttributeType.Name == nameof(KubernetesEntityAttribute)) && - e.attrs.All(a => a.AttributeType.Name != nameof(IgnoreAttribute))) - .Select(e => e.t); - - public static IEnumerable GetRbacAttributes(this MetadataLoadContext context) - { - foreach (var type in context.GetAssemblies() - .SelectMany(a => a.DefinedTypes) - .SelectMany(t => - t.GetCustomAttributesData())) - { - yield return type; - } - - foreach (var type in context.GetAssemblies() - .SelectMany(a => a.DefinedTypes) - .SelectMany(t => - t.GetCustomAttributesData())) - { - yield return type; - } - } - - public static IEnumerable GetValidatedEntities(this MetadataLoadContext context) => context.GetAssemblies() - .SelectMany(a => a.DefinedTypes) - .Where(t => t.BaseType?.Name == typeof(ValidationWebhook<>).Name && - t.BaseType?.Namespace == typeof(ValidationWebhook<>).Namespace) - .Select(t => t.BaseType!.GenericTypeArguments[0]) - .Distinct(); - - [GeneratedRegex(".*")] - private static partial Regex DefaultRegex(); -} +using System.Reflection; +using System.Text; +using System.Text.RegularExpressions; + +using k8s.Models; + +using KubeOps.Abstractions.Entities.Attributes; +using KubeOps.Abstractions.Rbac; +using KubeOps.Operator.Web.Webhooks.Validation; +using KubeOps.Transpiler; + +using Microsoft.Build.Locator; +using Microsoft.CodeAnalysis.MSBuild; + +using Spectre.Console; + +namespace KubeOps.Cli.Transpilation; + +/// +/// AssemblyLoader. +/// +internal static partial class AssemblyLoader +{ + static AssemblyLoader() + { + MSBuildLocator.RegisterDefaults(); + } + + public static Task ForProject( + IAnsiConsole console, + FileInfo projectFile) + => console.Status().StartAsync($"Compiling {projectFile.Name}...", async _ => + { + console.MarkupLineInterpolated($"Compile project [aqua]{projectFile.FullName}[/]."); + using var workspace = MSBuildWorkspace.Create(); + workspace.SkipUnrecognizedProjects = true; + workspace.LoadMetadataForReferencedProjects = true; + console.WriteLine("Load project."); + var project = await workspace.OpenProjectAsync(projectFile.FullName); + console.MarkupLine("[green]Project loaded.[/]"); + console.WriteLine("Load compilation context."); + var compilation = await project.GetCompilationAsync(); + console.MarkupLine("[green]Compilation context loaded.[/]"); + if (compilation is null) + { + throw new AggregateException("Compilation could not be found."); + } + + using var assemblyStream = new MemoryStream(); + console.WriteLine("Start compilation."); + switch (compilation.Emit(assemblyStream)) + { + case { Success: false, Diagnostics: var diag }: + throw new AggregateException( + $"Compilation failed: {diag.Aggregate(new StringBuilder(), (sb, d) => sb.AppendLine(d.ToString()))}"); + } + + console.MarkupLine("[green]Compilation successful.[/]"); + console.WriteLine(); + var mlc = new MetadataLoadContext( + new PathAssemblyResolver(project.MetadataReferences.Select(m => m.Display ?? string.Empty) + .Concat(new[] { typeof(object).Assembly.Location }))); + mlc.LoadFromByteArray(assemblyStream.ToArray()); + + return mlc; + }); + + public static Task ForSolution( + IAnsiConsole console, + FileInfo slnFile, + Regex? projectFilter = null, + string? tfm = null) + => console.Status().StartAsync($"Compiling {slnFile.Name}...", async _ => + { + projectFilter ??= DefaultRegex(); + tfm ??= "latest"; + + console.MarkupLineInterpolated($"Compile solution [aqua]{slnFile.FullName}[/]."); +#pragma warning disable RCS1097 + console.MarkupLineInterpolated($"[grey]With project filter:[/] {projectFilter.ToString()}"); +#pragma warning restore RCS1097 + console.MarkupLineInterpolated($"[grey]With Target Platform:[/] {tfm}"); + + using var workspace = MSBuildWorkspace.Create(); + workspace.SkipUnrecognizedProjects = true; + workspace.LoadMetadataForReferencedProjects = true; + console.WriteLine("Load solution."); + var solution = await workspace.OpenSolutionAsync(slnFile.FullName); + console.MarkupLine("[green]Solution loaded.[/]"); + + var assemblies = await Task.WhenAll(solution.Projects + .Select(p => + { + var name = TfmComparer.TfmRegex().Replace(p.Name, string.Empty); + var tfm = TfmComparer.TfmRegex().Match(p.Name).Groups["tfm"].Value; + return (name, tfm, project: p); + }) + .Where(p => projectFilter.IsMatch(p.name)) + .Where(p => tfm == "latest" || p.tfm.Length == 0 || p.tfm == tfm) + .OrderByDescending(p => p.tfm, new TfmComparer()) + .GroupBy(p => p.name) + .Select(p => p.FirstOrDefault()) + .Where(p => p != default) + .Select(async p => + { + console.MarkupLineInterpolated( + $"Load compilation context for [aqua]{p.name}[/]{(p.tfm.Length > 0 ? $" [grey]{p.tfm}[/]" : string.Empty)}."); + var compilation = await p.project.GetCompilationAsync(); + console.MarkupLineInterpolated($"[green]Compilation context loaded for {p.name}.[/]"); + if (compilation is null) + { + throw new AggregateException("Compilation could not be found."); + } + + using var assemblyStream = new MemoryStream(); + console.MarkupLineInterpolated( + $"Start compilation for [aqua]{p.name}[/]{(p.tfm.Length > 0 ? $" [grey]{p.tfm}[/]" : string.Empty)}."); + switch (compilation.Emit(assemblyStream)) + { + case { Success: false, Diagnostics: var diag }: + throw new AggregateException( + $"Compilation failed: {diag.Aggregate(new StringBuilder(), (sb, d) => sb.AppendLine(d.ToString()))}"); + } + + console.MarkupLineInterpolated($"[green]Compilation successful for {p.name}.[/]"); + return (Assembly: assemblyStream.ToArray(), + Refs: p.project.MetadataReferences.Select(m => m.Display ?? string.Empty)); + })); + + console.WriteLine(); + var mlc = new MetadataLoadContext( + new PathAssemblyResolver(assemblies.SelectMany(a => a.Refs) + .Concat(new[] { typeof(object).Assembly.Location }).Distinct())); + foreach (var assembly in assemblies) + { + mlc.LoadFromByteArray(assembly.Assembly); + } + + return mlc; + }); + + public static IEnumerable GetEntities(this MetadataLoadContext context) => context.GetAssemblies() + .SelectMany(a => a.DefinedTypes) + .Select(t => (t, attrs: CustomAttributeData.GetCustomAttributes(t))) + .Where(e => e.attrs.Any(a => a.AttributeType.Name == nameof(KubernetesEntityAttribute)) && + e.attrs.All(a => a.AttributeType.Name != nameof(IgnoreAttribute))) + .Select(e => e.t); + + public static IEnumerable GetRbacAttributes(this MetadataLoadContext context) + { + foreach (var type in context.GetAssemblies() + .SelectMany(a => a.DefinedTypes) + .SelectMany(t => + t.GetCustomAttributesData())) + { + yield return type; + } + + foreach (var type in context.GetAssemblies() + .SelectMany(a => a.DefinedTypes) + .SelectMany(t => + t.GetCustomAttributesData())) + { + yield return type; + } + } + + public static IEnumerable GetValidatedEntities(this MetadataLoadContext context) => context.GetAssemblies() + .SelectMany(a => a.DefinedTypes) + .Where(t => t.BaseType?.Name == typeof(ValidationWebhook<>).Name && + t.BaseType?.Namespace == typeof(ValidationWebhook<>).Namespace) + .Select(t => t.BaseType!.GenericTypeArguments[0]) + .Distinct(); + + [GeneratedRegex(".*")] + private static partial Regex DefaultRegex(); +} diff --git a/src/KubeOps.Cli/Transpilation/TfmComparer.cs b/src/KubeOps.Cli/Transpilation/TfmComparer.cs index a965232d..b35fc4c4 100644 --- a/src/KubeOps.Cli/Transpilation/TfmComparer.cs +++ b/src/KubeOps.Cli/Transpilation/TfmComparer.cs @@ -1,58 +1,58 @@ -using System.Text.RegularExpressions; - -namespace KubeOps.Cli.Transpilation; - -/// -/// Tfm Comparer. -/// -internal sealed partial class TfmComparer : IComparer -{ - [GeneratedRegex( - "[(]?(?(?(netcoreapp|net|netstandard){1})(?[0-9]+)[.](?[0-9]+))[)]?", - RegexOptions.Compiled)] - public static partial Regex TfmRegex(); - - public int Compare(string? x, string? y) - { - if (x == null || y == null) - { - return StringComparer.CurrentCulture.Compare(x, y); - } - - switch (TfmRegex().Match(x), TfmRegex().Match(y)) - { - case ({ Success: false }, _) or (_, { Success: false }): - return StringComparer.CurrentCulture.Compare(x, y); - case ({ } matchX, { } matchY): - var platformX = matchX.Groups["name"].Value; - var platformY = matchY.Groups["name"].Value; - if (platformX != platformY) - { - return (platformX, platformY) switch - { - ("netstandard", _) or (_, "net") => -1, - (_, "netstandard") or ("net", _) => 1, - _ => 0, - }; - } - - var majorX = matchX.Groups["major"].Value; - var majorY = matchY.Groups["major"].Value; - if (majorX != majorY) - { - return int.Parse(majorX) - int.Parse(majorY); - } - - var minorX = matchX.Groups["minor"].Value; - var minorY = matchY.Groups["minor"].Value; - if (minorX != minorY) - { - return int.Parse(minorX) - int.Parse(minorY); - } - - return 0; - default: - return 0; - } - } -} +using System.Text.RegularExpressions; + +namespace KubeOps.Cli.Transpilation; + +/// +/// Tfm Comparer. +/// +internal sealed partial class TfmComparer : IComparer +{ + [GeneratedRegex( + "[(]?(?(?(netcoreapp|net|netstandard){1})(?[0-9]+)[.](?[0-9]+))[)]?", + RegexOptions.Compiled)] + public static partial Regex TfmRegex(); + + public int Compare(string? x, string? y) + { + if (x == null || y == null) + { + return StringComparer.CurrentCulture.Compare(x, y); + } + + switch (TfmRegex().Match(x), TfmRegex().Match(y)) + { + case ({ Success: false }, _) or (_, { Success: false }): + return StringComparer.CurrentCulture.Compare(x, y); + case ({ } matchX, { } matchY): + var platformX = matchX.Groups["name"].Value; + var platformY = matchY.Groups["name"].Value; + if (platformX != platformY) + { + return (platformX, platformY) switch + { + ("netstandard", _) or (_, "net") => -1, + (_, "netstandard") or ("net", _) => 1, + _ => 0, + }; + } + + var majorX = matchX.Groups["major"].Value; + var majorY = matchY.Groups["major"].Value; + if (majorX != majorY) + { + return int.Parse(majorX) - int.Parse(majorY); + } + + var minorX = matchX.Groups["minor"].Value; + var minorY = matchY.Groups["minor"].Value; + if (minorX != minorY) + { + return int.Parse(minorX) - int.Parse(minorY); + } + + return 0; + default: + return 0; + } + } +} diff --git a/src/KubeOps.Operator.Web/KubeOps.Operator.Web.csproj b/src/KubeOps.Operator.Web/KubeOps.Operator.Web.csproj index ffc03a4e..be9e3c20 100644 --- a/src/KubeOps.Operator.Web/KubeOps.Operator.Web.csproj +++ b/src/KubeOps.Operator.Web/KubeOps.Operator.Web.csproj @@ -24,6 +24,10 @@ + + + + build/ diff --git a/src/KubeOps.Operator.Web/README.md b/src/KubeOps.Operator.Web/README.md index 0cfc3782..1e6de779 100644 --- a/src/KubeOps.Operator.Web/README.md +++ b/src/KubeOps.Operator.Web/README.md @@ -86,7 +86,43 @@ like "normal" `IActionResult` creation methods. ## Mutation Hooks -TODO. +To create a mutation webhook, first create a new class +that implements the `MutationWebhook` base class. +Then decorate the webhook with the `MutationWebhookAttribute` +to set the route correctly. + +After that setup, you may overwrite any of the following methods: + +- Create +- CreateAsync +- Update +- UpdateAsync +- Delete +- DeleteAsync + +The async methods take precedence over the sync methods. + +An example of such a mutation webhook looks like: + +```csharp +[MutationWebhook(typeof(V1TestEntity))] +public class TestMutationWebhook : MutationWebhook +{ + public override MutationResult Create(V1TestEntity entity, bool dryRun) + { + if (entity.Spec.Username == "overwrite") + { + entity.Spec.Username = "random overwritten"; + return Modified(entity); + } + + return NoChanges(); + } +} +``` + +To create the mutation results, use the `protected` methods (`NoChanges`, `Modified`, and `Fail`) +like "normal" `IActionResult` creation methods. ## Conversion Hooks diff --git a/src/KubeOps.Operator.Web/Webhooks/AdmissionRequest.cs b/src/KubeOps.Operator.Web/Webhooks/AdmissionRequest.cs index d4de8d21..eb64862e 100644 --- a/src/KubeOps.Operator.Web/Webhooks/AdmissionRequest.cs +++ b/src/KubeOps.Operator.Web/Webhooks/AdmissionRequest.cs @@ -1,20 +1,20 @@ -using System.Text.Json.Serialization; - -using k8s; -using k8s.Models; - -namespace KubeOps.Operator.Web.Webhooks; - -/// -/// Incoming admission request for a webhook. -/// -/// The type of the entity. -public sealed class AdmissionRequest : AdmissionReview - where TEntity : IKubernetesObject -{ - /// - /// Admission request data. - /// - [JsonPropertyName("request")] - public AdmissionRequestData Request { get; init; } = new(); -} +using System.Text.Json.Serialization; + +using k8s; +using k8s.Models; + +namespace KubeOps.Operator.Web.Webhooks; + +/// +/// Incoming admission request for a webhook. +/// +/// The type of the entity. +public sealed class AdmissionRequest : AdmissionReview + where TEntity : IKubernetesObject +{ + /// + /// Admission request data. + /// + [JsonPropertyName("request")] + public AdmissionRequestData Request { get; init; } = new(); +} diff --git a/src/KubeOps.Operator.Web/Webhooks/AdmissionRequestData.cs b/src/KubeOps.Operator.Web/Webhooks/AdmissionRequestData.cs index 73c8839f..9aae3919 100644 --- a/src/KubeOps.Operator.Web/Webhooks/AdmissionRequestData.cs +++ b/src/KubeOps.Operator.Web/Webhooks/AdmissionRequestData.cs @@ -1,48 +1,48 @@ -using System.Text.Json.Serialization; - -using k8s; -using k8s.Models; - -namespace KubeOps.Operator.Web.Webhooks; - -/// -/// Data for an incoming admission request. -/// -/// The type of the entity. -public sealed class AdmissionRequestData - where TEntity : IKubernetesObject -{ - /// - /// The unique ID of the admission request. - /// - [JsonPropertyName("uid")] - public string Uid { get; init; } = string.Empty; - - /// - /// The operation that is used. - /// Valid values are: "CREATE", "UPDATE", "DELETE". - /// "CONNECT" does exist, but is not supported by the operator-sdk. - /// - [JsonPropertyName("operation")] - public string Operation { get; init; } = string.Empty; - - /// - /// If set, the object that is passed to the webhook. - /// This is set in CREATE and UPDATE operations. - /// - [JsonPropertyName("object")] - public TEntity? Object { get; init; } - - /// - /// If set, the old object that is passed to the webhook. - /// This is set in UPDATE and DELETE operations. - /// - [JsonPropertyName("oldObject")] - public TEntity? OldObject { get; init; } - - /// - /// A flag to indicate if the API was called with the "dryRun" flag. - /// - [JsonPropertyName("dryRun")] - public bool DryRun { get; init; } -} +using System.Text.Json.Serialization; + +using k8s; +using k8s.Models; + +namespace KubeOps.Operator.Web.Webhooks; + +/// +/// Data for an incoming admission request. +/// +/// The type of the entity. +public sealed class AdmissionRequestData + where TEntity : IKubernetesObject +{ + /// + /// The unique ID of the admission request. + /// + [JsonPropertyName("uid")] + public string Uid { get; init; } = string.Empty; + + /// + /// The operation that is used. + /// Valid values are: "CREATE", "UPDATE", "DELETE". + /// "CONNECT" does exist, but is not supported by the operator-sdk. + /// + [JsonPropertyName("operation")] + public string Operation { get; init; } = string.Empty; + + /// + /// If set, the object that is passed to the webhook. + /// This is set in CREATE and UPDATE operations. + /// + [JsonPropertyName("object")] + public TEntity? Object { get; init; } + + /// + /// If set, the old object that is passed to the webhook. + /// This is set in UPDATE and DELETE operations. + /// + [JsonPropertyName("oldObject")] + public TEntity? OldObject { get; init; } + + /// + /// A flag to indicate if the API was called with the "dryRun" flag. + /// + [JsonPropertyName("dryRun")] + public bool DryRun { get; init; } +} diff --git a/src/KubeOps.Operator.Web/Webhooks/AdmissionResponse.cs b/src/KubeOps.Operator.Web/Webhooks/AdmissionResponse.cs index 2e586a98..bc8774a4 100644 --- a/src/KubeOps.Operator.Web/Webhooks/AdmissionResponse.cs +++ b/src/KubeOps.Operator.Web/Webhooks/AdmissionResponse.cs @@ -1,20 +1,20 @@ -using System.Text.Json; -using System.Text.Json.Serialization; - -namespace KubeOps.Operator.Web.Webhooks; - -internal sealed class AdmissionResponse : AdmissionReview -{ - public static readonly JsonSerializerOptions SerializerOptions = new() - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - ReferenceHandler = ReferenceHandler.IgnoreCycles, - }; - - [JsonPropertyName("response")] -#if NET7_0_OR_GREATER - public required AdmissionResponseData Response { get; init; } -#else - public AdmissionResponseData Response { get; init; } = new(); -#endif -} +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace KubeOps.Operator.Web.Webhooks; + +internal sealed class AdmissionResponse : AdmissionReview +{ + public static readonly JsonSerializerOptions SerializerOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + ReferenceHandler = ReferenceHandler.IgnoreCycles, + }; + + [JsonPropertyName("response")] +#if NET7_0_OR_GREATER + public required AdmissionResponseData Response { get; init; } +#else + public AdmissionResponseData Response { get; init; } = new(); +#endif +} diff --git a/src/KubeOps.Operator.Web/Webhooks/AdmissionResponseData.cs b/src/KubeOps.Operator.Web/Webhooks/AdmissionResponseData.cs index 416a09b6..95db5729 100644 --- a/src/KubeOps.Operator.Web/Webhooks/AdmissionResponseData.cs +++ b/src/KubeOps.Operator.Web/Webhooks/AdmissionResponseData.cs @@ -1,22 +1,28 @@ -using System.Text.Json.Serialization; - -namespace KubeOps.Operator.Web.Webhooks; - -internal sealed class AdmissionResponseData -{ - [JsonPropertyName("uid")] -#if NET7_0_OR_GREATER - public required string Uid { get; init; } -#else - public string Uid { get; init; } = string.Empty; -#endif - - [JsonPropertyName("allowed")] - public bool Allowed { get; init; } - - [JsonPropertyName("status")] - public AdmissionStatus? Status { get; init; } - - [JsonPropertyName("warnings")] - public string[]? Warnings { get; init; } -} +using System.Text.Json.Serialization; + +namespace KubeOps.Operator.Web.Webhooks; + +internal sealed class AdmissionResponseData +{ + [JsonPropertyName("uid")] +#if NET7_0_OR_GREATER + public required string Uid { get; init; } +#else + public string Uid { get; init; } = string.Empty; +#endif + + [JsonPropertyName("allowed")] + public bool Allowed { get; init; } + + [JsonPropertyName("status")] + public AdmissionStatus? Status { get; init; } + + [JsonPropertyName("warnings")] + public string[]? Warnings { get; init; } + + [JsonPropertyName("patch")] + public string? Patch { get; init; } + + [JsonPropertyName("patchType")] + public string? PatchType { get; init; } +} diff --git a/src/KubeOps.Operator.Web/Webhooks/AdmissionReview.cs b/src/KubeOps.Operator.Web/Webhooks/AdmissionReview.cs index d04fdbcc..077455ac 100644 --- a/src/KubeOps.Operator.Web/Webhooks/AdmissionReview.cs +++ b/src/KubeOps.Operator.Web/Webhooks/AdmissionReview.cs @@ -1,15 +1,15 @@ -using System.Text.Json.Serialization; - -namespace KubeOps.Operator.Web.Webhooks; - -/// -/// Base class for admission review requests. -/// -public abstract class AdmissionReview -{ - [JsonPropertyName("apiVersion")] - public string ApiVersion => "admission.k8s.io/v1"; - - [JsonPropertyName("kind")] - public string Kind => "AdmissionReview"; -} +using System.Text.Json.Serialization; + +namespace KubeOps.Operator.Web.Webhooks; + +/// +/// Base class for admission review requests. +/// +public abstract class AdmissionReview +{ + [JsonPropertyName("apiVersion")] + public string ApiVersion => "admission.k8s.io/v1"; + + [JsonPropertyName("kind")] + public string Kind => "AdmissionReview"; +} diff --git a/src/KubeOps.Operator.Web/Webhooks/AdmissionStatus.cs b/src/KubeOps.Operator.Web/Webhooks/AdmissionStatus.cs index a3b85a15..e543b86b 100644 --- a/src/KubeOps.Operator.Web/Webhooks/AdmissionStatus.cs +++ b/src/KubeOps.Operator.Web/Webhooks/AdmissionStatus.cs @@ -1,10 +1,10 @@ -using Microsoft.AspNetCore.Http; - -namespace KubeOps.Operator.Web.Webhooks; - -/// -/// The admission status for the response to the API. -/// -/// A message that is passed to the API. -/// A custom status code to provide more detailed information. -public record AdmissionStatus(string Message, int? Code = StatusCodes.Status200OK); +using Microsoft.AspNetCore.Http; + +namespace KubeOps.Operator.Web.Webhooks; + +/// +/// The admission status for the response to the API. +/// +/// A message that is passed to the API. +/// A custom status code to provide more detailed information. +public record AdmissionStatus(string Message, int? Code = StatusCodes.Status200OK); diff --git a/src/KubeOps.Operator.Web/Webhooks/Mutation/JsonDiffer.cs b/src/KubeOps.Operator.Web/Webhooks/Mutation/JsonDiffer.cs new file mode 100644 index 00000000..21d43c59 --- /dev/null +++ b/src/KubeOps.Operator.Web/Webhooks/Mutation/JsonDiffer.cs @@ -0,0 +1,27 @@ +using System.Text; +using System.Text.Json.JsonDiffPatch; +using System.Text.Json.JsonDiffPatch.Diffs.Formatters; +using System.Text.Json.Nodes; + +using k8s; + +namespace KubeOps.Operator.Web.Webhooks.Mutation; + +internal static class JsonDiffer +{ + private static readonly JsonPatchDeltaFormatter Formatter = new(); + + public static string Base64Diff(this JsonNode from, object? to) + { + var toToken = GetNode(to); + var patch = from.Diff(toToken, Formatter)!; + + return Convert.ToBase64String(Encoding.UTF8.GetBytes(patch.ToString())); + } + + public static JsonNode? GetNode(object? o) + { + var json = KubernetesJson.Serialize(o); + return JsonNode.Parse(json); + } +} diff --git a/src/KubeOps.Operator.Web/Webhooks/Mutation/MutationResult.cs b/src/KubeOps.Operator.Web/Webhooks/Mutation/MutationResult.cs new file mode 100644 index 00000000..df363c7c --- /dev/null +++ b/src/KubeOps.Operator.Web/Webhooks/Mutation/MutationResult.cs @@ -0,0 +1,78 @@ +using System.Text.Json.JsonDiffPatch; +using System.Text.Json.JsonDiffPatch.Diffs.Formatters; +using System.Text.Json.Nodes; + +using k8s; +using k8s.Models; + +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace KubeOps.Operator.Web.Webhooks.Mutation; + +/// +/// The mutation result for the mutation (admission) request to a webhook. +/// +public record MutationResult(TEntity? ModifiedObject = default) : IActionResult + where TEntity : IKubernetesObject +{ + private const string JsonPatch = "JSONPatch"; + + internal string Uid { get; init; } = string.Empty; + + internal JsonNode? OriginalObject { get; init; } + + public bool Valid { get; init; } = true; + + /// + /// Provides additional information to the validation result. + /// The message is displayed to the user if the validation fails. + /// The status code can provide more information about the error. + /// + /// See "Extensible Admission Controller Response" + /// + /// + public AdmissionStatus? Status { get; init; } + + /// + /// Despite being "valid", the validation can add a list of warnings to the user. + /// If this is not yet supported by the cluster, the field is ignored. + /// Warnings may contain up to 256 characters but they should be limited to 120 characters. + /// If more than 4096 characters are submitted, additional messages are ignored. + /// + public IList Warnings { get; init; } = new List(); + + /// + public async Task ExecuteResultAsync(ActionContext context) + { + var response = context.HttpContext.Response; + if (string.IsNullOrWhiteSpace(Uid)) + { + response.StatusCode = StatusCodes.Status500InternalServerError; + await response.WriteAsync("No request UID was provided."); + return; + } + + if (ModifiedObject is not null && OriginalObject is null) + { + response.StatusCode = StatusCodes.Status500InternalServerError; + await response.WriteAsync("No original object was provided."); + return; + } + + await response.WriteAsJsonAsync( + new AdmissionResponse + { + Response = new() + { + Uid = Uid, + Allowed = Valid, + Status = Status, + Warnings = Warnings.ToArray(), + PatchType = ModifiedObject is null ? null : JsonPatch, + Patch = ModifiedObject is null ? null : OriginalObject!.Base64Diff(ModifiedObject), + }, + }, + AdmissionResponse.SerializerOptions); + } +} diff --git a/src/KubeOps.Operator.Web/Webhooks/Mutation/MutationWebhookAttribute.cs b/src/KubeOps.Operator.Web/Webhooks/Mutation/MutationWebhookAttribute.cs new file mode 100644 index 00000000..74682625 --- /dev/null +++ b/src/KubeOps.Operator.Web/Webhooks/Mutation/MutationWebhookAttribute.cs @@ -0,0 +1,17 @@ +using Microsoft.AspNetCore.Mvc; + +namespace KubeOps.Operator.Web.Webhooks.Mutation; + +/// +/// Defines an MVC controller as "mutation webhook". The route is automatically set to +/// /mutate/[lower-case-name-of-the-type]. +/// This must be used in conjunction with the class. +/// +[AttributeUsage(AttributeTargets.Class)] +public class MutationWebhookAttribute : RouteAttribute +{ + public MutationWebhookAttribute(Type entityType) + : base($"/mutate/{entityType.Name.ToLowerInvariant()}") + { + } +} diff --git a/src/KubeOps.Operator.Web/Webhooks/Mutation/MutationWebhook{TEntity}.cs b/src/KubeOps.Operator.Web/Webhooks/Mutation/MutationWebhook{TEntity}.cs new file mode 100644 index 00000000..4d017660 --- /dev/null +++ b/src/KubeOps.Operator.Web/Webhooks/Mutation/MutationWebhook{TEntity}.cs @@ -0,0 +1,167 @@ +using k8s; +using k8s.Models; + +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace KubeOps.Operator.Web.Webhooks.Mutation; + +/// +/// The abstract base for any mutation webhook. To use them, attach controllers in the +/// main program and map controllers as well. A mutating webhook must be decorated +/// with the and the type must be provided. +/// There are async and sync methods for each operation. The async will take +/// precedence if both are implemented (i.e. overridden). +/// +/// The type of the entity that is mutated. +/// +/// Simple example of a webhook that sets all usernames to "hidden". +/// +/// [MutationWebhook(typeof(V1TestEntity))] +/// public class TestMutationWebhook : MutationWebhook<V1TestEntity> +/// { +/// public override MutationResult<V1TestEntity> Create(V1TestEntity entity, bool dryRun) +/// { +/// entity.Spec.Username = "hidden"; +/// return Modified(entity); +/// } +/// } +/// +/// +[ApiController] +public abstract class MutationWebhook : ControllerBase + where TEntity : IKubernetesObject +{ + private const string CreateOperation = "CREATE"; + private const string UpdateOperation = "UPDATE"; + private const string DeleteOperation = "DELETE"; + + /// + /// Mutation callback for entities that are created. + /// + /// The (soon to be) new entity. + /// Flag that indicates if the webhook was called in a dry-run. + /// A . + [NonAction] + public virtual Task> CreateAsync(TEntity entity, bool dryRun) => + Task.FromResult(Create(entity, dryRun)); + + /// + [NonAction] + public virtual MutationResult Create(TEntity entity, bool dryRun) => NoChanges(); + + /// + /// Mutation callback for entities that are updated. + /// + /// The currently stored entity in the Kubernetes API. + /// The new entity that should be stored. + /// Flag that indicates if the webhook was called in a dry-run. + /// A . + [NonAction] + public virtual Task> UpdateAsync(TEntity oldEntity, TEntity newEntity, bool dryRun) => + Task.FromResult(Update(oldEntity, newEntity, dryRun)); + + /// + [NonAction] + public virtual MutationResult Update(TEntity oldEntity, TEntity newEntity, bool dryRun) => NoChanges(); + + /// + /// Mutation callback for entities that are to be deleted. + /// + /// The (soon to be removed) entity. + /// Flag that indicates if the webhook was called in a dry-run. + /// A . + [NonAction] + public virtual Task> DeleteAsync(TEntity entity, bool dryRun) => + Task.FromResult(Delete(entity, dryRun)); + + /// + [NonAction] + public virtual MutationResult Delete(TEntity entity, bool dryRun) => NoChanges(); + + /// + /// Public, non-virtual method that is called by the controller. + /// This method will call the correct method based on the operation. + /// + /// The incoming admission request for an entity. + /// The . + [HttpPost] + public async Task Validate([FromBody] AdmissionRequest request) + { + var original = JsonDiffer.GetNode(request.Request.Operation switch + { + CreateOperation or UpdateOperation => request.Request.Object!, + _ => request.Request.OldObject!, + }); + + var result = request.Request.Operation switch + { + CreateOperation => await CreateAsync(request.Request.Object!, request.Request.DryRun), + UpdateOperation => await UpdateAsync( + request.Request.OldObject!, + request.Request.Object!, + request.Request.DryRun), + DeleteOperation => await DeleteAsync(request.Request.OldObject!, request.Request.DryRun), + _ => Fail( + $"Operation {request.Request.Operation} is not supported.", + StatusCodes.Status422UnprocessableEntity), + }; + + return result with { Uid = request.Request.Uid, OriginalObject = original }; + } + + /// + /// Create a with an optional list of warnings. + /// The mutation result indicates that no changes are required for the entity. + /// + /// A list of warnings that is presented to the user. + /// A . + [NonAction] + protected MutationResult NoChanges(params string[] warnings) + => new() { Warnings = warnings }; + + /// + /// Create a with an optional list of warnings. + /// The mutation result indicates that the entity needs to be patched before + /// it is definitely stored. This creates a JSON Patch between the original object + /// and the changed entity. If warnings are provided, they are presented to the user. + /// + /// The modified entity. + /// A list of warnings that is presented to the user. + /// A that indicates changes. + [NonAction] + protected MutationResult Modified(TEntity entity, params string[] warnings) + => new(entity) { Warnings = warnings }; + + /// + /// Create a that will fail the mutation. + /// The user will only see that the mutation failed. + /// + /// A . + [NonAction] + protected MutationResult Fail() + => new() { Valid = false }; + + /// + /// Create a that will fail the mutation. + /// The reason is presented to the user. + /// + /// A reason for the failure of the mutation. + /// A . + [NonAction] + protected MutationResult Fail(string reason) + => Fail() with { Status = new(reason) }; + + /// + /// Create a that will fail the mutation. + /// The reason is presented to the user with the custom status code. + /// The custom status code may provide further specific information about the + /// failure, but not all Kubernetes clusters support custom status codes. + /// + /// A reason for the failure of the mutation. + /// The custom status code. + /// A . + [NonAction] + protected MutationResult Fail(string reason, int statusCode) => + Fail() with { Status = new(reason, statusCode), }; +} diff --git a/src/KubeOps.Operator.Web/Webhooks/Validation/ValidationResult.cs b/src/KubeOps.Operator.Web/Webhooks/Validation/ValidationResult.cs index 0e398e9f..84d43295 100644 --- a/src/KubeOps.Operator.Web/Webhooks/Validation/ValidationResult.cs +++ b/src/KubeOps.Operator.Web/Webhooks/Validation/ValidationResult.cs @@ -1,56 +1,56 @@ -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc; - -namespace KubeOps.Operator.Web.Webhooks.Validation; - -/// -/// The validation result for the validation (admission) request to a webhook. -/// -/// Whether the validation / the entity is valid or not. -public record ValidationResult(bool Valid = true) : IActionResult -{ - internal string Uid { get; init; } = string.Empty; - - /// - /// Provides additional information to the validation result. - /// The message is displayed to the user if the validation fails. - /// The status code can provide more information about the error. - /// - /// See "Extensible Admission Controller Response" - /// - /// - public AdmissionStatus? Status { get; init; } - - /// - /// Despite being "valid", the validation can add a list of warnings to the user. - /// If this is not yet supported by the cluster, the field is ignored. - /// Warnings may contain up to 256 characters but they should be limited to 120 characters. - /// If more than 4096 characters are submitted, additional messages are ignored. - /// - public IList Warnings { get; init; } = new List(); - - /// - public async Task ExecuteResultAsync(ActionContext context) - { - var response = context.HttpContext.Response; - if (string.IsNullOrWhiteSpace(Uid)) - { - response.StatusCode = StatusCodes.Status500InternalServerError; - await response.WriteAsync("No request UID was provided."); - return; - } - - await response.WriteAsJsonAsync( - new AdmissionResponse - { - Response = new() - { - Uid = Uid, - Allowed = Valid, - Status = Status, - Warnings = Warnings.ToArray(), - }, - }, - AdmissionResponse.SerializerOptions); - } -} +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace KubeOps.Operator.Web.Webhooks.Validation; + +/// +/// The validation result for the validation (admission) request to a webhook. +/// +/// Whether the validation / the entity is valid or not. +public record ValidationResult(bool Valid = true) : IActionResult +{ + internal string Uid { get; init; } = string.Empty; + + /// + /// Provides additional information to the validation result. + /// The message is displayed to the user if the validation fails. + /// The status code can provide more information about the error. + /// + /// See "Extensible Admission Controller Response" + /// + /// + public AdmissionStatus? Status { get; init; } + + /// + /// Despite being "valid", the validation can add a list of warnings to the user. + /// If this is not yet supported by the cluster, the field is ignored. + /// Warnings may contain up to 256 characters but they should be limited to 120 characters. + /// If more than 4096 characters are submitted, additional messages are ignored. + /// + public IList Warnings { get; init; } = new List(); + + /// + public async Task ExecuteResultAsync(ActionContext context) + { + var response = context.HttpContext.Response; + if (string.IsNullOrWhiteSpace(Uid)) + { + response.StatusCode = StatusCodes.Status500InternalServerError; + await response.WriteAsync("No request UID was provided."); + return; + } + + await response.WriteAsJsonAsync( + new AdmissionResponse + { + Response = new() + { + Uid = Uid, + Allowed = Valid, + Status = Status, + Warnings = Warnings.ToArray(), + }, + }, + AdmissionResponse.SerializerOptions); + } +} diff --git a/src/KubeOps.Operator.Web/Webhooks/Validation/ValidationWebhookAttribute.cs b/src/KubeOps.Operator.Web/Webhooks/Validation/ValidationWebhookAttribute.cs index a0f674c1..17c22b13 100644 --- a/src/KubeOps.Operator.Web/Webhooks/Validation/ValidationWebhookAttribute.cs +++ b/src/KubeOps.Operator.Web/Webhooks/Validation/ValidationWebhookAttribute.cs @@ -1,17 +1,17 @@ -using Microsoft.AspNetCore.Mvc; - -namespace KubeOps.Operator.Web.Webhooks.Validation; - -/// -/// Defines an MVC controller as "validation webhook". The route is automatically set to -/// /validate/[lower-case-name-of-the-type]. -/// This must be used in conjunction with the class. -/// -[AttributeUsage(AttributeTargets.Class)] -public class ValidationWebhookAttribute : RouteAttribute -{ - public ValidationWebhookAttribute(Type entityType) - : base($"/validate/{entityType.Name.ToLowerInvariant()}") - { - } -} +using Microsoft.AspNetCore.Mvc; + +namespace KubeOps.Operator.Web.Webhooks.Validation; + +/// +/// Defines an MVC controller as "validation webhook". The route is automatically set to +/// /validate/[lower-case-name-of-the-type]. +/// This must be used in conjunction with the class. +/// +[AttributeUsage(AttributeTargets.Class)] +public class ValidationWebhookAttribute : RouteAttribute +{ + public ValidationWebhookAttribute(Type entityType) + : base($"/validate/{entityType.Name.ToLowerInvariant()}") + { + } +} diff --git a/src/KubeOps.Operator.Web/Webhooks/Validation/ValidationWebhook{TEntity}.cs b/src/KubeOps.Operator.Web/Webhooks/Validation/ValidationWebhook{TEntity}.cs index 3cb1914d..5b184a1f 100644 --- a/src/KubeOps.Operator.Web/Webhooks/Validation/ValidationWebhook{TEntity}.cs +++ b/src/KubeOps.Operator.Web/Webhooks/Validation/ValidationWebhook{TEntity}.cs @@ -1,146 +1,146 @@ -using k8s; -using k8s.Models; - -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc; - -namespace KubeOps.Operator.Web.Webhooks.Validation; - -/// -/// The abstract base for any validation webhook. To use them, attach controllers in the -/// main program and map controllers as well. A validation webhook must be decorated -/// with the and the type must be provided. -/// There are async and sync methods for each operation. The async will take -/// precedence if both are implemented (i.e. overridden). -/// -/// The type of the entity that is validated. -/// -/// Simple example of a webhook that checks nothing but is called on every "CREATE" event. -/// -/// [ValidationWebhook(typeof(V1TestEntity))] -/// public class TestValidationWebhook : ValidationWebhook<V1TestEntity> -/// { -/// public override ValidationResult Create(V1TestEntity entity, bool dryRun) -/// { -/// return Success(); -/// } -/// } -/// -/// -[ApiController] -public abstract class ValidationWebhook : ControllerBase - where TEntity : IKubernetesObject -{ - private const string CreateOperation = "CREATE"; - private const string UpdateOperation = "UPDATE"; - private const string DeleteOperation = "DELETE"; - - /// - /// Validation callback for entities that are created. - /// - /// The (soon to be) new entity. - /// Flag that indicates if the webhook was called in a dry-run. - /// A . - [NonAction] - public virtual Task CreateAsync(TEntity entity, bool dryRun) => - Task.FromResult(Create(entity, dryRun)); - - /// - [NonAction] - public virtual ValidationResult Create(TEntity entity, bool dryRun) => Success(); - - /// - /// Validation callback for entities that are updated. - /// - /// The currently stored entity in the Kubernetes API. - /// The new entity that should be stored. - /// Flag that indicates if the webhook was called in a dry-run. - /// A . - [NonAction] - public virtual Task UpdateAsync(TEntity oldEntity, TEntity newEntity, bool dryRun) => - Task.FromResult(Update(oldEntity, newEntity, dryRun)); - - /// - [NonAction] - public virtual ValidationResult Update(TEntity oldEntity, TEntity newEntity, bool dryRun) => Success(); - - /// - /// Validation callback for entities that are to be deleted. - /// - /// The (soon to be removed) entity. - /// Flag that indicates if the webhook was called in a dry-run. - /// A . - [NonAction] - public virtual Task DeleteAsync(TEntity entity, bool dryRun) => - Task.FromResult(Delete(entity, dryRun)); - - /// - [NonAction] - public virtual ValidationResult Delete(TEntity entity, bool dryRun) => Success(); - - /// - /// Public, non-virtual method that is called by the controller. - /// This method will call the correct method based on the operation. - /// - /// The incoming admission request for an entity. - /// The . - [HttpPost] - public async Task Validate([FromBody] AdmissionRequest request) - { - var result = request.Request.Operation switch - { - CreateOperation => await CreateAsync(request.Request.Object!, request.Request.DryRun), - UpdateOperation => await UpdateAsync( - request.Request.OldObject!, - request.Request.Object!, - request.Request.DryRun), - DeleteOperation => await DeleteAsync(request.Request.OldObject!, request.Request.DryRun), - _ => Fail( - $"Operation {request.Request.Operation} is not supported.", - StatusCodes.Status422UnprocessableEntity), - }; - - return result with { Uid = request.Request.Uid }; - } - - /// - /// Create a with an optional list of warnings. - /// The validation will succeed, such that the operation will proceed. - /// - /// A list of warnings that is presented to the user. - /// A . - [NonAction] - protected ValidationResult Success(params string[] warnings) - => new() { Warnings = warnings }; - - /// - /// Create a that will fail the validation. - /// The user will only see that the validation failed. - /// - /// A . - [NonAction] - protected ValidationResult Fail() - => new(false); - - /// - /// Create a that will fail the validation. - /// The reason is presented to the user. - /// - /// A reason for the failure of the validation. - /// A . - [NonAction] - protected ValidationResult Fail(string reason) - => new(false) { Status = new(reason) }; - - /// - /// Create a that will fail the validation. - /// The reason is presented to the user with the custom status code. - /// The custom status code may provide further specific information about the - /// failure, but not all Kubernetes clusters support custom status codes. - /// - /// A reason for the failure of the validation. - /// The custom status code. - /// A . - [NonAction] - protected ValidationResult Fail(string reason, int statusCode) => new(false) { Status = new(reason, statusCode), }; -} +using k8s; +using k8s.Models; + +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace KubeOps.Operator.Web.Webhooks.Validation; + +/// +/// The abstract base for any validation webhook. To use them, attach controllers in the +/// main program and map controllers as well. A validation webhook must be decorated +/// with the and the type must be provided. +/// There are async and sync methods for each operation. The async will take +/// precedence if both are implemented (i.e. overridden). +/// +/// The type of the entity that is validated. +/// +/// Simple example of a webhook that checks nothing but is called on every "CREATE" event. +/// +/// [ValidationWebhook(typeof(V1TestEntity))] +/// public class TestValidationWebhook : ValidationWebhook<V1TestEntity> +/// { +/// public override ValidationResult Create(V1TestEntity entity, bool dryRun) +/// { +/// return Success(); +/// } +/// } +/// +/// +[ApiController] +public abstract class ValidationWebhook : ControllerBase + where TEntity : IKubernetesObject +{ + private const string CreateOperation = "CREATE"; + private const string UpdateOperation = "UPDATE"; + private const string DeleteOperation = "DELETE"; + + /// + /// Validation callback for entities that are created. + /// + /// The (soon to be) new entity. + /// Flag that indicates if the webhook was called in a dry-run. + /// A . + [NonAction] + public virtual Task CreateAsync(TEntity entity, bool dryRun) => + Task.FromResult(Create(entity, dryRun)); + + /// + [NonAction] + public virtual ValidationResult Create(TEntity entity, bool dryRun) => Success(); + + /// + /// Validation callback for entities that are updated. + /// + /// The currently stored entity in the Kubernetes API. + /// The new entity that should be stored. + /// Flag that indicates if the webhook was called in a dry-run. + /// A . + [NonAction] + public virtual Task UpdateAsync(TEntity oldEntity, TEntity newEntity, bool dryRun) => + Task.FromResult(Update(oldEntity, newEntity, dryRun)); + + /// + [NonAction] + public virtual ValidationResult Update(TEntity oldEntity, TEntity newEntity, bool dryRun) => Success(); + + /// + /// Validation callback for entities that are to be deleted. + /// + /// The (soon to be removed) entity. + /// Flag that indicates if the webhook was called in a dry-run. + /// A . + [NonAction] + public virtual Task DeleteAsync(TEntity entity, bool dryRun) => + Task.FromResult(Delete(entity, dryRun)); + + /// + [NonAction] + public virtual ValidationResult Delete(TEntity entity, bool dryRun) => Success(); + + /// + /// Public, non-virtual method that is called by the controller. + /// This method will call the correct method based on the operation. + /// + /// The incoming admission request for an entity. + /// The . + [HttpPost] + public async Task Validate([FromBody] AdmissionRequest request) + { + var result = request.Request.Operation switch + { + CreateOperation => await CreateAsync(request.Request.Object!, request.Request.DryRun), + UpdateOperation => await UpdateAsync( + request.Request.OldObject!, + request.Request.Object!, + request.Request.DryRun), + DeleteOperation => await DeleteAsync(request.Request.OldObject!, request.Request.DryRun), + _ => Fail( + $"Operation {request.Request.Operation} is not supported.", + StatusCodes.Status422UnprocessableEntity), + }; + + return result with { Uid = request.Request.Uid }; + } + + /// + /// Create a with an optional list of warnings. + /// The validation will succeed, such that the operation will proceed. + /// + /// A list of warnings that is presented to the user. + /// A . + [NonAction] + protected ValidationResult Success(params string[] warnings) + => new() { Warnings = warnings }; + + /// + /// Create a that will fail the validation. + /// The user will only see that the validation failed. + /// + /// A . + [NonAction] + protected ValidationResult Fail() + => new(false); + + /// + /// Create a that will fail the validation. + /// The reason is presented to the user. + /// + /// A reason for the failure of the validation. + /// A . + [NonAction] + protected ValidationResult Fail(string reason) + => Fail() with { Status = new(reason) }; + + /// + /// Create a that will fail the validation. + /// The reason is presented to the user with the custom status code. + /// The custom status code may provide further specific information about the + /// failure, but not all Kubernetes clusters support custom status codes. + /// + /// A reason for the failure of the validation. + /// The custom status code. + /// A . + [NonAction] + protected ValidationResult Fail(string reason, int statusCode) => Fail() with { Status = new(reason, statusCode), }; +} diff --git a/src/KubeOps.Transpiler/ContextCreator.cs b/src/KubeOps.Transpiler/ContextCreator.cs index d94f1cdb..850023dd 100644 --- a/src/KubeOps.Transpiler/ContextCreator.cs +++ b/src/KubeOps.Transpiler/ContextCreator.cs @@ -1,36 +1,36 @@ -using System.Reflection; - -namespace KubeOps.Transpiler; - -/// -/// Helper to create s. -/// -public static class ContextCreator -{ - /// - /// Create a new with the given - /// and directly load an assembly into it. - /// - /// A list of paths. - /// The byte array that contains the assembly to load. - /// Optional core assembly name. - /// The configured . - public static MetadataLoadContext Create( - IEnumerable assemblyPaths, - byte[] assembly, - string? coreAssemblyName = null) - { - var mlc = Create(assemblyPaths, coreAssemblyName); - mlc.LoadFromByteArray(assembly); - return mlc; - } - - /// - /// Create a new with the given . - /// - /// A list of paths. - /// Optional core assembly name. - /// The configured . - public static MetadataLoadContext Create(IEnumerable assemblyPaths, string? coreAssemblyName = null) => - new(new PathAssemblyResolver(assemblyPaths), coreAssemblyName: coreAssemblyName); -} +using System.Reflection; + +namespace KubeOps.Transpiler; + +/// +/// Helper to create s. +/// +public static class ContextCreator +{ + /// + /// Create a new with the given + /// and directly load an assembly into it. + /// + /// A list of paths. + /// The byte array that contains the assembly to load. + /// Optional core assembly name. + /// The configured . + public static MetadataLoadContext Create( + IEnumerable assemblyPaths, + byte[] assembly, + string? coreAssemblyName = null) + { + var mlc = Create(assemblyPaths, coreAssemblyName); + mlc.LoadFromByteArray(assembly); + return mlc; + } + + /// + /// Create a new with the given . + /// + /// A list of paths. + /// Optional core assembly name. + /// The configured . + public static MetadataLoadContext Create(IEnumerable assemblyPaths, string? coreAssemblyName = null) => + new(new PathAssemblyResolver(assemblyPaths), coreAssemblyName: coreAssemblyName); +} diff --git a/src/KubeOps.Transpiler/Crds.cs b/src/KubeOps.Transpiler/Crds.cs index 1d47b540..31d9a772 100644 --- a/src/KubeOps.Transpiler/Crds.cs +++ b/src/KubeOps.Transpiler/Crds.cs @@ -1,475 +1,475 @@ -using System.Collections; -using System.Collections.ObjectModel; -using System.Diagnostics.CodeAnalysis; -using System.Reflection; -using System.Text.Json.Serialization; - -using k8s; -using k8s.Models; - -using KubeOps.Abstractions.Entities; -using KubeOps.Abstractions.Entities.Attributes; -using KubeOps.Transpiler.Kubernetes; - -namespace KubeOps.Transpiler; - -/// -/// CRD transpiler for Kubernetes entities. -/// -public static class Crds -{ - private const string Integer = "integer"; - private const string Number = "number"; - private const string String = "string"; - private const string Boolean = "boolean"; - private const string Object = "object"; - private const string Array = "array"; - - private const string Int32 = "int32"; - private const string Int64 = "int64"; - private const string Float = "float"; - private const string Double = "double"; - private const string DateTime = "date-time"; - - private static readonly string[] IgnoredToplevelProperties = { "metadata", "apiversion", "kind" }; - - /// - /// Transpile a single type to a CRD. - /// - /// The . - /// The type to convert. - /// The converted custom resource definition. - public static V1CustomResourceDefinition Transpile(this MetadataLoadContext context, Type type) - { - type = context.GetContextType(type); - var (meta, scope) = context.ToEntityMetadata(type); - var crd = new V1CustomResourceDefinition(new()).Initialize(); - - crd.Metadata.Name = $"{meta.PluralName}.{meta.Group}"; - crd.Spec.Group = meta.Group; - - crd.Spec.Names = - new V1CustomResourceDefinitionNames - { - Kind = meta.Kind, - ListKind = meta.ListKind, - Singular = meta.SingularName, - Plural = meta.PluralName, - }; - crd.Spec.Scope = scope; - if (type.GetCustomAttributeData()?.ConstructorArguments[0].Value is - ReadOnlyCollection shortNames) - { - crd.Spec.Names.ShortNames = shortNames.Select(a => a.Value?.ToString()).ToList(); - } - - var version = new V1CustomResourceDefinitionVersion(meta.Version, true, true); - if - (type.GetProperty("Status") != null - || type.GetProperty("status") != null) - { - version.Subresources = new V1CustomResourceSubresources(null, new object()); - } - - version.Schema = new V1CustomResourceValidation(new V1JSONSchemaProps - { - Type = Object, - Description = - type.GetCustomAttributeData()?.GetCustomAttributeCtorArg(context, 0), - Properties = type.GetProperties() - .Where(p => !IgnoredToplevelProperties.Contains(p.Name.ToLowerInvariant())) - .Where(p => p.GetCustomAttributeData() == null) - .Select(p => (Name: p.GetPropertyName(context), Schema: context.Map(p))) - .ToDictionary(t => t.Name, t => t.Schema), - }); - - version.AdditionalPrinterColumns = context.MapPrinterColumns(type).ToList() switch - { - { Count: > 0 } l => l, - _ => null, - }; - crd.Spec.Versions = new List { version }; - crd.Validate(); - - return crd; - } - - /// - /// Transpile a list of entities to CRDs and group them by version. - /// - /// The . - /// The types to convert. - /// The converted custom resource definitions. - public static IEnumerable Transpile( - this MetadataLoadContext context, - IEnumerable types) - => types - .Select(context.GetContextType) - .Where(type => type.Assembly != context.GetContextType().Assembly) - .Where(type => type.GetCustomAttributesData().Any()) - .Where(type => !type.GetCustomAttributesData().Any()) - .Select(type => (Props: context.Transpile(type), - IsStorage: type.GetCustomAttributesData().Any())) - .GroupBy(grp => grp.Props.Metadata.Name) - .Select( - group => - { - if (group.Count(def => def.IsStorage) > 1) - { - throw new ArgumentException("There are multiple stored versions on an entity."); - } - - var crd = group.First().Props; - crd.Spec.Versions = group - .SelectMany( - c => c.Props.Spec.Versions.Select( - v => - { - v.Served = true; - v.Storage = c.IsStorage; - return v; - })) - .OrderByDescending(v => v.Name, new KubernetesVersionComparer()) - .ToList(); - - // when only one version exists, or when no StorageVersion attributes are found - // the first version in the list is the stored one. - if (crd.Spec.Versions.Count == 1 || !group.Any(def => def.IsStorage)) - { - crd.Spec.Versions[0].Storage = true; - } - - return crd; - }); - - private static string GetPropertyName(this PropertyInfo prop, MetadataLoadContext context) - { - var name = prop.GetCustomAttributeData() switch - { - null => prop.Name, - { } attr => attr.GetCustomAttributeCtorArg(context, 0) ?? prop.Name, - }; - - return $"{name[..1].ToLowerInvariant()}{name[1..]}"; - } - - private static IEnumerable MapPrinterColumns( - this MetadataLoadContext context, - Type type) - { - var props = type.GetProperties().Select(p => (Prop: p, Path: string.Empty)).ToList(); - while (props.Count > 0) - { - var (prop, path) = props[0]; - props.RemoveAt(0); - - if (prop.PropertyType.IsClass) - { - props.AddRange(prop.PropertyType.GetProperties() - .Select(p => (Prop: p, Path: $"{path}.{prop.GetPropertyName(context)}"))); - } - - if (prop.GetCustomAttributeData() is not { } attr) - { - continue; - } - - var mapped = context.Map(prop); - yield return new V1CustomResourceColumnDefinition - { - Name = attr.GetCustomAttributeCtorArg(context, 1) ?? prop.GetPropertyName(context), - JsonPath = $"{path}.{prop.GetPropertyName(context)}", - Type = mapped.Type, - Description = mapped.Description, - Format = mapped.Format, - Priority = attr.GetCustomAttributeCtorArg(context, 0) switch - { - PrinterColumnPriority.StandardView => 0, - _ => 1, - }, - }; - } - - foreach (var attr in type.GetCustomAttributesData()) - { - yield return new V1CustomResourceColumnDefinition - { - Name = attr.GetCustomAttributeCtorArg(context, 1), - JsonPath = attr.GetCustomAttributeCtorArg(context, 0), - Type = attr.GetCustomAttributeCtorArg(context, 2), - Description = attr.GetCustomAttributeNamedArg(context, "Description"), - Format = attr.GetCustomAttributeNamedArg(context, "Format"), - Priority = attr.GetCustomAttributeNamedArg(context, "Priority") switch - { - PrinterColumnPriority.StandardView => 0, - _ => 1, - }, - }; - } - } - - private static V1JSONSchemaProps Map(this MetadataLoadContext context, PropertyInfo prop) - { - var props = context.Map(prop.PropertyType); - - props.Description ??= prop.GetCustomAttributeData() - ?.GetCustomAttributeCtorArg(context, 0); - - props.Nullable = prop.IsNullable(); - - if (prop.GetCustomAttributeData() is { } extDocs) - { - props.ExternalDocs = new V1ExternalDocumentation( - extDocs.GetCustomAttributeCtorArg(context, 0), - extDocs.GetCustomAttributeCtorArg(context, 1)); - } - - if (prop.GetCustomAttributeData() is { } items) - { - props.MinItems = items.GetCustomAttributeCtorArg(context, 0); - props.MaxItems = items.GetCustomAttributeCtorArg(context, 1); - } - - if (prop.GetCustomAttributeData() is { } length) - { - props.MinLength = length.GetCustomAttributeCtorArg(context, 0); - props.MaxLength = length.GetCustomAttributeCtorArg(context, 1); - } - - if (prop.GetCustomAttributeData() is { } multi) - { - props.MultipleOf = multi.GetCustomAttributeCtorArg(context, 0); - } - - if (prop.GetCustomAttributeData() is { } pattern) - { - props.Pattern = pattern.GetCustomAttributeCtorArg(context, 0); - } - - if (prop.GetCustomAttributeData() is { } rangeMax) - { - props.Maximum = rangeMax.GetCustomAttributeCtorArg(context, 0); - props.ExclusiveMaximum = - rangeMax.GetCustomAttributeCtorArg(context, 1); - } - - if (prop.GetCustomAttributeData() is { } rangeMin) - { - props.Minimum = rangeMin.GetCustomAttributeCtorArg(context, 0); - props.ExclusiveMinimum = - rangeMin.GetCustomAttributeCtorArg(context, 1); - } - - if (prop.GetCustomAttributeData() is not null) - { - props.XKubernetesPreserveUnknownFields = true; - } - - if (prop.GetCustomAttributeData() is not null) - { - props.XKubernetesEmbeddedResource = true; - props.XKubernetesPreserveUnknownFields = true; - props.Type = Object; - props.Properties = null; - } - - return props; - } - - private static V1JSONSchemaProps Map(this MetadataLoadContext context, Type type) - { - if (type == context.GetContextType()) - { - return new V1JSONSchemaProps { Type = Object }; - } - - if (type.IsArray && type.GetElementType() != null) - { - var items = context.Map(type.GetElementType()!); - items.Nullable = type.GetElementType()!.IsNullable(); - return new V1JSONSchemaProps { Type = Array, Items = items }; - } - - if (!IsSimpleType(type) - && type.IsGenericType - && type.GetGenericTypeDefinition() == context.GetContextType(typeof(IDictionary<,>)) - && type.GenericTypeArguments.Contains(context.GetContextType(typeof(ResourceQuantity)))) - { - return new V1JSONSchemaProps { Type = Object, XKubernetesPreserveUnknownFields = true }; - } - - if (!IsSimpleType(type) && - type.IsGenericType && - type.GetGenericTypeDefinition() == context.GetContextType(typeof(IEnumerable<>)) && - type.GenericTypeArguments.Length == 1 && - type.GenericTypeArguments.Single().IsGenericType && - type.GenericTypeArguments.Single().GetGenericTypeDefinition() == - context.GetContextType(typeof(KeyValuePair<,>))) - { - var props = context.Map(type.GenericTypeArguments.Single().GenericTypeArguments[1]); - props.Nullable = type.GenericTypeArguments.Single().GenericTypeArguments[1].IsNullable(); - return new V1JSONSchemaProps { Type = Object, AdditionalProperties = props, }; - } - - if (!IsSimpleType(type) - && type.IsGenericType - && type.GetGenericTypeDefinition() == context.GetContextType(typeof(IDictionary<,>))) - { - var props = context.Map(type.GenericTypeArguments[1]); - props.Nullable = type.GenericTypeArguments[1].IsNullable(); - return new V1JSONSchemaProps { Type = Object, AdditionalProperties = props, }; - } - - if (!IsSimpleType(type) && - (context.GetContextType().IsAssignableFrom(type) || - (type.IsGenericType && - type.GetGenericArguments().FirstOrDefault()?.IsGenericType == true && - type.GetGenericArguments().FirstOrDefault()?.GetGenericTypeDefinition() == - context.GetContextType(typeof(KeyValuePair<,>))))) - { - return new V1JSONSchemaProps { Type = Object, XKubernetesPreserveUnknownFields = true }; - } - - if (!IsSimpleType(type) && IsGenericEnumerableType(type, out Type? closingType)) - { - var items = context.Map(closingType); - items.Nullable = closingType.IsNullable(); - return new V1JSONSchemaProps { Type = Array, Items = items }; - } - - if (type == context.GetContextType()) - { - return new V1JSONSchemaProps { XKubernetesIntOrString = true }; - } - - if (context.GetContextType().IsAssignableFrom(type) && - type is { IsAbstract: false, IsInterface: false } && - type.Assembly == context.GetContextType().Assembly) - { - return new V1JSONSchemaProps - { - Type = Object, - Properties = null, - XKubernetesPreserveUnknownFields = true, - XKubernetesEmbeddedResource = true, - }; - } - - if (type == context.GetContextType() || - type == context.GetContextType()) - { - return new V1JSONSchemaProps { Type = Integer, Format = Int32 }; - } - - if (type == context.GetContextType() || - type == context.GetContextType()) - { - return new V1JSONSchemaProps { Type = Integer, Format = Int64 }; - } - - if (type == context.GetContextType() || - type == context.GetContextType()) - { - return new V1JSONSchemaProps { Type = Number, Format = Float }; - } - - if (type == context.GetContextType() || - type == context.GetContextType()) - { - return new V1JSONSchemaProps { Type = Number, Format = Double }; - } - - if (type == context.GetContextType() || - type == context.GetContextType()) - { - return new V1JSONSchemaProps { Type = String }; - } - - if (type == context.GetContextType() || - type == context.GetContextType()) - { - return new V1JSONSchemaProps { Type = Boolean }; - } - - if (type == context.GetContextType() || - type == context.GetContextType()) - { - return new V1JSONSchemaProps { Type = String, Format = DateTime }; - } - - if (type.IsEnum) - { - return new V1JSONSchemaProps { Type = String, EnumProperty = Enum.GetNames(type).Cast().ToList() }; - } - - if (type.IsGenericType && type.FullName?.Contains("Nullable") == true && type.GetGenericArguments()[0].IsEnum) - { - return new V1JSONSchemaProps - { - Type = String, - EnumProperty = Enum.GetNames(type.GetGenericArguments()[0]).Cast().ToList(), - }; - } - - if (!IsSimpleType(type)) - { - return new V1JSONSchemaProps - { - Type = Object, - Description = - type.GetCustomAttributeData()?.GetCustomAttributeCtorArg(context, 0), - Properties = type - .GetProperties() - .Where(p => p.GetCustomAttributeData() == null) - .Select(p => (Name: p.GetPropertyName(context), Schema: context.Map(p))) - .ToDictionary(t => t.Name, t => t.Schema), - Required = type.GetProperties() - .Where(p => p.GetCustomAttributeData() != null) - .Where(p => p.GetCustomAttributeData() == null) - .Select(p => p.GetPropertyName(context)) - .ToList() switch - { - { Count: > 0 } p => p, - _ => null, - }, - }; - } - - throw new ArgumentException($"The given type {type.FullName} is not a valid Kubernetes entity."); - - bool IsSimpleType(Type t) => - t.IsPrimitive || - new[] - { - context.GetContextType(), context.GetContextType(), - context.GetContextType(), context.GetContextType(), - context.GetContextType(), context.GetContextType(), - }.Contains(t) || - t.IsEnum || - Convert.GetTypeCode(t) != TypeCode.Object || - (t.IsGenericType && - t.GetGenericTypeDefinition() == context.GetContextType(typeof(Nullable<>)) && - IsSimpleType(t.GetGenericArguments()[0])); - - bool IsGenericEnumerableType( - Type theType, - [NotNullWhen(true)] out Type? enclosingType) - { - if (theType.IsGenericType && context.GetContextType(typeof(IEnumerable<>)) - .IsAssignableFrom(theType.GetGenericTypeDefinition())) - { - enclosingType = theType.GetGenericArguments()[0]; - return true; - } - - enclosingType = theType - .GetInterfaces() - .Where(t => t.IsGenericType && - t.GetGenericTypeDefinition() == context.GetContextType(typeof(IEnumerable<>))) - .Select(t => t.GetGenericArguments()[0]) - .FirstOrDefault(); - - return enclosingType != null; - } - } -} +using System.Collections; +using System.Collections.ObjectModel; +using System.Diagnostics.CodeAnalysis; +using System.Reflection; +using System.Text.Json.Serialization; + +using k8s; +using k8s.Models; + +using KubeOps.Abstractions.Entities; +using KubeOps.Abstractions.Entities.Attributes; +using KubeOps.Transpiler.Kubernetes; + +namespace KubeOps.Transpiler; + +/// +/// CRD transpiler for Kubernetes entities. +/// +public static class Crds +{ + private const string Integer = "integer"; + private const string Number = "number"; + private const string String = "string"; + private const string Boolean = "boolean"; + private const string Object = "object"; + private const string Array = "array"; + + private const string Int32 = "int32"; + private const string Int64 = "int64"; + private const string Float = "float"; + private const string Double = "double"; + private const string DateTime = "date-time"; + + private static readonly string[] IgnoredToplevelProperties = { "metadata", "apiversion", "kind" }; + + /// + /// Transpile a single type to a CRD. + /// + /// The . + /// The type to convert. + /// The converted custom resource definition. + public static V1CustomResourceDefinition Transpile(this MetadataLoadContext context, Type type) + { + type = context.GetContextType(type); + var (meta, scope) = context.ToEntityMetadata(type); + var crd = new V1CustomResourceDefinition(new()).Initialize(); + + crd.Metadata.Name = $"{meta.PluralName}.{meta.Group}"; + crd.Spec.Group = meta.Group; + + crd.Spec.Names = + new V1CustomResourceDefinitionNames + { + Kind = meta.Kind, + ListKind = meta.ListKind, + Singular = meta.SingularName, + Plural = meta.PluralName, + }; + crd.Spec.Scope = scope; + if (type.GetCustomAttributeData()?.ConstructorArguments[0].Value is + ReadOnlyCollection shortNames) + { + crd.Spec.Names.ShortNames = shortNames.Select(a => a.Value?.ToString()).ToList(); + } + + var version = new V1CustomResourceDefinitionVersion(meta.Version, true, true); + if + (type.GetProperty("Status") != null + || type.GetProperty("status") != null) + { + version.Subresources = new V1CustomResourceSubresources(null, new object()); + } + + version.Schema = new V1CustomResourceValidation(new V1JSONSchemaProps + { + Type = Object, + Description = + type.GetCustomAttributeData()?.GetCustomAttributeCtorArg(context, 0), + Properties = type.GetProperties() + .Where(p => !IgnoredToplevelProperties.Contains(p.Name.ToLowerInvariant())) + .Where(p => p.GetCustomAttributeData() == null) + .Select(p => (Name: p.GetPropertyName(context), Schema: context.Map(p))) + .ToDictionary(t => t.Name, t => t.Schema), + }); + + version.AdditionalPrinterColumns = context.MapPrinterColumns(type).ToList() switch + { + { Count: > 0 } l => l, + _ => null, + }; + crd.Spec.Versions = new List { version }; + crd.Validate(); + + return crd; + } + + /// + /// Transpile a list of entities to CRDs and group them by version. + /// + /// The . + /// The types to convert. + /// The converted custom resource definitions. + public static IEnumerable Transpile( + this MetadataLoadContext context, + IEnumerable types) + => types + .Select(context.GetContextType) + .Where(type => type.Assembly != context.GetContextType().Assembly) + .Where(type => type.GetCustomAttributesData().Any()) + .Where(type => !type.GetCustomAttributesData().Any()) + .Select(type => (Props: context.Transpile(type), + IsStorage: type.GetCustomAttributesData().Any())) + .GroupBy(grp => grp.Props.Metadata.Name) + .Select( + group => + { + if (group.Count(def => def.IsStorage) > 1) + { + throw new ArgumentException("There are multiple stored versions on an entity."); + } + + var crd = group.First().Props; + crd.Spec.Versions = group + .SelectMany( + c => c.Props.Spec.Versions.Select( + v => + { + v.Served = true; + v.Storage = c.IsStorage; + return v; + })) + .OrderByDescending(v => v.Name, new KubernetesVersionComparer()) + .ToList(); + + // when only one version exists, or when no StorageVersion attributes are found + // the first version in the list is the stored one. + if (crd.Spec.Versions.Count == 1 || !group.Any(def => def.IsStorage)) + { + crd.Spec.Versions[0].Storage = true; + } + + return crd; + }); + + private static string GetPropertyName(this PropertyInfo prop, MetadataLoadContext context) + { + var name = prop.GetCustomAttributeData() switch + { + null => prop.Name, + { } attr => attr.GetCustomAttributeCtorArg(context, 0) ?? prop.Name, + }; + + return $"{name[..1].ToLowerInvariant()}{name[1..]}"; + } + + private static IEnumerable MapPrinterColumns( + this MetadataLoadContext context, + Type type) + { + var props = type.GetProperties().Select(p => (Prop: p, Path: string.Empty)).ToList(); + while (props.Count > 0) + { + var (prop, path) = props[0]; + props.RemoveAt(0); + + if (prop.PropertyType.IsClass) + { + props.AddRange(prop.PropertyType.GetProperties() + .Select(p => (Prop: p, Path: $"{path}.{prop.GetPropertyName(context)}"))); + } + + if (prop.GetCustomAttributeData() is not { } attr) + { + continue; + } + + var mapped = context.Map(prop); + yield return new V1CustomResourceColumnDefinition + { + Name = attr.GetCustomAttributeCtorArg(context, 1) ?? prop.GetPropertyName(context), + JsonPath = $"{path}.{prop.GetPropertyName(context)}", + Type = mapped.Type, + Description = mapped.Description, + Format = mapped.Format, + Priority = attr.GetCustomAttributeCtorArg(context, 0) switch + { + PrinterColumnPriority.StandardView => 0, + _ => 1, + }, + }; + } + + foreach (var attr in type.GetCustomAttributesData()) + { + yield return new V1CustomResourceColumnDefinition + { + Name = attr.GetCustomAttributeCtorArg(context, 1), + JsonPath = attr.GetCustomAttributeCtorArg(context, 0), + Type = attr.GetCustomAttributeCtorArg(context, 2), + Description = attr.GetCustomAttributeNamedArg(context, "Description"), + Format = attr.GetCustomAttributeNamedArg(context, "Format"), + Priority = attr.GetCustomAttributeNamedArg(context, "Priority") switch + { + PrinterColumnPriority.StandardView => 0, + _ => 1, + }, + }; + } + } + + private static V1JSONSchemaProps Map(this MetadataLoadContext context, PropertyInfo prop) + { + var props = context.Map(prop.PropertyType); + + props.Description ??= prop.GetCustomAttributeData() + ?.GetCustomAttributeCtorArg(context, 0); + + props.Nullable = prop.IsNullable(); + + if (prop.GetCustomAttributeData() is { } extDocs) + { + props.ExternalDocs = new V1ExternalDocumentation( + extDocs.GetCustomAttributeCtorArg(context, 0), + extDocs.GetCustomAttributeCtorArg(context, 1)); + } + + if (prop.GetCustomAttributeData() is { } items) + { + props.MinItems = items.GetCustomAttributeCtorArg(context, 0); + props.MaxItems = items.GetCustomAttributeCtorArg(context, 1); + } + + if (prop.GetCustomAttributeData() is { } length) + { + props.MinLength = length.GetCustomAttributeCtorArg(context, 0); + props.MaxLength = length.GetCustomAttributeCtorArg(context, 1); + } + + if (prop.GetCustomAttributeData() is { } multi) + { + props.MultipleOf = multi.GetCustomAttributeCtorArg(context, 0); + } + + if (prop.GetCustomAttributeData() is { } pattern) + { + props.Pattern = pattern.GetCustomAttributeCtorArg(context, 0); + } + + if (prop.GetCustomAttributeData() is { } rangeMax) + { + props.Maximum = rangeMax.GetCustomAttributeCtorArg(context, 0); + props.ExclusiveMaximum = + rangeMax.GetCustomAttributeCtorArg(context, 1); + } + + if (prop.GetCustomAttributeData() is { } rangeMin) + { + props.Minimum = rangeMin.GetCustomAttributeCtorArg(context, 0); + props.ExclusiveMinimum = + rangeMin.GetCustomAttributeCtorArg(context, 1); + } + + if (prop.GetCustomAttributeData() is not null) + { + props.XKubernetesPreserveUnknownFields = true; + } + + if (prop.GetCustomAttributeData() is not null) + { + props.XKubernetesEmbeddedResource = true; + props.XKubernetesPreserveUnknownFields = true; + props.Type = Object; + props.Properties = null; + } + + return props; + } + + private static V1JSONSchemaProps Map(this MetadataLoadContext context, Type type) + { + if (type == context.GetContextType()) + { + return new V1JSONSchemaProps { Type = Object }; + } + + if (type.IsArray && type.GetElementType() != null) + { + var items = context.Map(type.GetElementType()!); + items.Nullable = type.GetElementType()!.IsNullable(); + return new V1JSONSchemaProps { Type = Array, Items = items }; + } + + if (!IsSimpleType(type) + && type.IsGenericType + && type.GetGenericTypeDefinition() == context.GetContextType(typeof(IDictionary<,>)) + && type.GenericTypeArguments.Contains(context.GetContextType(typeof(ResourceQuantity)))) + { + return new V1JSONSchemaProps { Type = Object, XKubernetesPreserveUnknownFields = true }; + } + + if (!IsSimpleType(type) && + type.IsGenericType && + type.GetGenericTypeDefinition() == context.GetContextType(typeof(IEnumerable<>)) && + type.GenericTypeArguments.Length == 1 && + type.GenericTypeArguments.Single().IsGenericType && + type.GenericTypeArguments.Single().GetGenericTypeDefinition() == + context.GetContextType(typeof(KeyValuePair<,>))) + { + var props = context.Map(type.GenericTypeArguments.Single().GenericTypeArguments[1]); + props.Nullable = type.GenericTypeArguments.Single().GenericTypeArguments[1].IsNullable(); + return new V1JSONSchemaProps { Type = Object, AdditionalProperties = props, }; + } + + if (!IsSimpleType(type) + && type.IsGenericType + && type.GetGenericTypeDefinition() == context.GetContextType(typeof(IDictionary<,>))) + { + var props = context.Map(type.GenericTypeArguments[1]); + props.Nullable = type.GenericTypeArguments[1].IsNullable(); + return new V1JSONSchemaProps { Type = Object, AdditionalProperties = props, }; + } + + if (!IsSimpleType(type) && + (context.GetContextType().IsAssignableFrom(type) || + (type.IsGenericType && + type.GetGenericArguments().FirstOrDefault()?.IsGenericType == true && + type.GetGenericArguments().FirstOrDefault()?.GetGenericTypeDefinition() == + context.GetContextType(typeof(KeyValuePair<,>))))) + { + return new V1JSONSchemaProps { Type = Object, XKubernetesPreserveUnknownFields = true }; + } + + if (!IsSimpleType(type) && IsGenericEnumerableType(type, out Type? closingType)) + { + var items = context.Map(closingType); + items.Nullable = closingType.IsNullable(); + return new V1JSONSchemaProps { Type = Array, Items = items }; + } + + if (type == context.GetContextType()) + { + return new V1JSONSchemaProps { XKubernetesIntOrString = true }; + } + + if (context.GetContextType().IsAssignableFrom(type) && + type is { IsAbstract: false, IsInterface: false } && + type.Assembly == context.GetContextType().Assembly) + { + return new V1JSONSchemaProps + { + Type = Object, + Properties = null, + XKubernetesPreserveUnknownFields = true, + XKubernetesEmbeddedResource = true, + }; + } + + if (type == context.GetContextType() || + type == context.GetContextType()) + { + return new V1JSONSchemaProps { Type = Integer, Format = Int32 }; + } + + if (type == context.GetContextType() || + type == context.GetContextType()) + { + return new V1JSONSchemaProps { Type = Integer, Format = Int64 }; + } + + if (type == context.GetContextType() || + type == context.GetContextType()) + { + return new V1JSONSchemaProps { Type = Number, Format = Float }; + } + + if (type == context.GetContextType() || + type == context.GetContextType()) + { + return new V1JSONSchemaProps { Type = Number, Format = Double }; + } + + if (type == context.GetContextType() || + type == context.GetContextType()) + { + return new V1JSONSchemaProps { Type = String }; + } + + if (type == context.GetContextType() || + type == context.GetContextType()) + { + return new V1JSONSchemaProps { Type = Boolean }; + } + + if (type == context.GetContextType() || + type == context.GetContextType()) + { + return new V1JSONSchemaProps { Type = String, Format = DateTime }; + } + + if (type.IsEnum) + { + return new V1JSONSchemaProps { Type = String, EnumProperty = Enum.GetNames(type).Cast().ToList() }; + } + + if (type.IsGenericType && type.FullName?.Contains("Nullable") == true && type.GetGenericArguments()[0].IsEnum) + { + return new V1JSONSchemaProps + { + Type = String, + EnumProperty = Enum.GetNames(type.GetGenericArguments()[0]).Cast().ToList(), + }; + } + + if (!IsSimpleType(type)) + { + return new V1JSONSchemaProps + { + Type = Object, + Description = + type.GetCustomAttributeData()?.GetCustomAttributeCtorArg(context, 0), + Properties = type + .GetProperties() + .Where(p => p.GetCustomAttributeData() == null) + .Select(p => (Name: p.GetPropertyName(context), Schema: context.Map(p))) + .ToDictionary(t => t.Name, t => t.Schema), + Required = type.GetProperties() + .Where(p => p.GetCustomAttributeData() != null) + .Where(p => p.GetCustomAttributeData() == null) + .Select(p => p.GetPropertyName(context)) + .ToList() switch + { + { Count: > 0 } p => p, + _ => null, + }, + }; + } + + throw new ArgumentException($"The given type {type.FullName} is not a valid Kubernetes entity."); + + bool IsSimpleType(Type t) => + t.IsPrimitive || + new[] + { + context.GetContextType(), context.GetContextType(), + context.GetContextType(), context.GetContextType(), + context.GetContextType(), context.GetContextType(), + }.Contains(t) || + t.IsEnum || + Convert.GetTypeCode(t) != TypeCode.Object || + (t.IsGenericType && + t.GetGenericTypeDefinition() == context.GetContextType(typeof(Nullable<>)) && + IsSimpleType(t.GetGenericArguments()[0])); + + bool IsGenericEnumerableType( + Type theType, + [NotNullWhen(true)] out Type? enclosingType) + { + if (theType.IsGenericType && context.GetContextType(typeof(IEnumerable<>)) + .IsAssignableFrom(theType.GetGenericTypeDefinition())) + { + enclosingType = theType.GetGenericArguments()[0]; + return true; + } + + enclosingType = theType + .GetInterfaces() + .Where(t => t.IsGenericType && + t.GetGenericTypeDefinition() == context.GetContextType(typeof(IEnumerable<>))) + .Select(t => t.GetGenericArguments()[0]) + .FirstOrDefault(); + + return enclosingType != null; + } + } +} diff --git a/src/KubeOps.Transpiler/Entities.cs b/src/KubeOps.Transpiler/Entities.cs index 1dee95b5..b3abf191 100644 --- a/src/KubeOps.Transpiler/Entities.cs +++ b/src/KubeOps.Transpiler/Entities.cs @@ -1,46 +1,46 @@ -using System.Reflection; - -using k8s.Models; - -using KubeOps.Abstractions.Entities; -using KubeOps.Abstractions.Entities.Attributes; - -namespace KubeOps.Transpiler; - -/// -/// Transpiler for Kubernetes entities to create entity metadata. -/// -public static class Entities -{ - /// - /// Create a metadata / scope tuple out of a given entity type. - /// - /// The context that loaded the types. - /// The type to convert. - /// A tuple that contains and a scope. - /// Thrown when the type contains no . - public static (EntityMetadata Metadata, string Scope) ToEntityMetadata(this MetadataLoadContext context, Type entityType) - => (context.GetContextType(entityType).GetCustomAttributeData(), - context.GetContextType(entityType).GetCustomAttributeData()) switch - { - (null, _) => throw new ArgumentException("The given type is not a valid Kubernetes entity."), - ({ } attr, var scope) => (new( - Defaulted( - attr.GetCustomAttributeNamedArg(context, nameof(KubernetesEntityAttribute.Kind)), - entityType.Name), - Defaulted( - attr.GetCustomAttributeNamedArg(context, nameof(KubernetesEntityAttribute.ApiVersion)), - "v1"), - attr.GetCustomAttributeNamedArg(context, nameof(KubernetesEntityAttribute.Group)), - attr.GetCustomAttributeNamedArg(context, nameof(KubernetesEntityAttribute.PluralName))), - scope switch - { - null => Enum.GetName(EntityScope.Namespaced) ?? "namespaced", - _ => Enum.GetName( - scope.GetCustomAttributeCtorArg(context, 0)) ?? "namespaced", - }), - }; - - private static string Defaulted(string? value, string defaultValue) => - string.IsNullOrWhiteSpace(value) ? defaultValue : value; -} +using System.Reflection; + +using k8s.Models; + +using KubeOps.Abstractions.Entities; +using KubeOps.Abstractions.Entities.Attributes; + +namespace KubeOps.Transpiler; + +/// +/// Transpiler for Kubernetes entities to create entity metadata. +/// +public static class Entities +{ + /// + /// Create a metadata / scope tuple out of a given entity type. + /// + /// The context that loaded the types. + /// The type to convert. + /// A tuple that contains and a scope. + /// Thrown when the type contains no . + public static (EntityMetadata Metadata, string Scope) ToEntityMetadata(this MetadataLoadContext context, Type entityType) + => (context.GetContextType(entityType).GetCustomAttributeData(), + context.GetContextType(entityType).GetCustomAttributeData()) switch + { + (null, _) => throw new ArgumentException("The given type is not a valid Kubernetes entity."), + ({ } attr, var scope) => (new( + Defaulted( + attr.GetCustomAttributeNamedArg(context, nameof(KubernetesEntityAttribute.Kind)), + entityType.Name), + Defaulted( + attr.GetCustomAttributeNamedArg(context, nameof(KubernetesEntityAttribute.ApiVersion)), + "v1"), + attr.GetCustomAttributeNamedArg(context, nameof(KubernetesEntityAttribute.Group)), + attr.GetCustomAttributeNamedArg(context, nameof(KubernetesEntityAttribute.PluralName))), + scope switch + { + null => Enum.GetName(EntityScope.Namespaced) ?? "namespaced", + _ => Enum.GetName( + scope.GetCustomAttributeCtorArg(context, 0)) ?? "namespaced", + }), + }; + + private static string Defaulted(string? value, string defaultValue) => + string.IsNullOrWhiteSpace(value) ? defaultValue : value; +} diff --git a/src/KubeOps.Transpiler/Kubernetes/KubernetesVersionComparer.cs b/src/KubeOps.Transpiler/Kubernetes/KubernetesVersionComparer.cs index 59a66e30..5246e01a 100644 --- a/src/KubeOps.Transpiler/Kubernetes/KubernetesVersionComparer.cs +++ b/src/KubeOps.Transpiler/Kubernetes/KubernetesVersionComparer.cs @@ -1,73 +1,73 @@ -using System.Text.RegularExpressions; - -namespace KubeOps.Transpiler.Kubernetes; - -/// -/// Comparer for Kubernetes Versions. Uses the version priority according to -/// -/// the Kubernetes documentation -/// . -/// -public sealed partial class KubernetesVersionComparer : IComparer -{ -#if !NET7_0_OR_GREATER - private static readonly Regex KubernetesVersionRegex = - new("^v(?[0-9]+)((?alpha|beta)(?[0-9]+))?$", RegexOptions.Compiled); -#endif - - private enum Stream - { - Alpha = 1, - Beta = 2, - Final = 3, - } - - public int Compare(string? x, string? y) - { - if (x == null || y == null) - { - return StringComparer.CurrentCulture.Compare(x, y); - } - -#if NET7_0_OR_GREATER - var matchX = KubernetesVersionRegex().Match(x); -#else - var matchX = KubernetesVersionRegex.Match(x); -#endif - if (!matchX.Success) - { - return StringComparer.CurrentCulture.Compare(x, y); - } - -#if NET7_0_OR_GREATER - var matchY = KubernetesVersionRegex().Match(y); -#else - var matchY = KubernetesVersionRegex.Match(y); -#endif - if (!matchY.Success) - { - return StringComparer.CurrentCulture.Compare(x, y); - } - - var versionX = ExtractVersion(matchX); - var versionY = ExtractVersion(matchY); - return versionX.CompareTo(versionY); - } - -#if NET7_0_OR_GREATER - [GeneratedRegex("^v(?[0-9]+)((?alpha|beta)(?[0-9]+))?$", RegexOptions.Compiled)] - private static partial Regex KubernetesVersionRegex(); -#endif - - private Version ExtractVersion(Match match) - { - var major = int.Parse(match.Groups["major"].Value); - if (!Enum.TryParse(match.Groups["stream"].Value, true, out var stream)) - { - stream = Stream.Final; - } - - _ = int.TryParse(match.Groups["minor"].Value, out var minor); - return new Version(major, (int)stream, minor); - } -} +using System.Text.RegularExpressions; + +namespace KubeOps.Transpiler.Kubernetes; + +/// +/// Comparer for Kubernetes Versions. Uses the version priority according to +/// +/// the Kubernetes documentation +/// . +/// +public sealed partial class KubernetesVersionComparer : IComparer +{ +#if !NET7_0_OR_GREATER + private static readonly Regex KubernetesVersionRegex = + new("^v(?[0-9]+)((?alpha|beta)(?[0-9]+))?$", RegexOptions.Compiled); +#endif + + private enum Stream + { + Alpha = 1, + Beta = 2, + Final = 3, + } + + public int Compare(string? x, string? y) + { + if (x == null || y == null) + { + return StringComparer.CurrentCulture.Compare(x, y); + } + +#if NET7_0_OR_GREATER + var matchX = KubernetesVersionRegex().Match(x); +#else + var matchX = KubernetesVersionRegex.Match(x); +#endif + if (!matchX.Success) + { + return StringComparer.CurrentCulture.Compare(x, y); + } + +#if NET7_0_OR_GREATER + var matchY = KubernetesVersionRegex().Match(y); +#else + var matchY = KubernetesVersionRegex.Match(y); +#endif + if (!matchY.Success) + { + return StringComparer.CurrentCulture.Compare(x, y); + } + + var versionX = ExtractVersion(matchX); + var versionY = ExtractVersion(matchY); + return versionX.CompareTo(versionY); + } + +#if NET7_0_OR_GREATER + [GeneratedRegex("^v(?[0-9]+)((?alpha|beta)(?[0-9]+))?$", RegexOptions.Compiled)] + private static partial Regex KubernetesVersionRegex(); +#endif + + private Version ExtractVersion(Match match) + { + var major = int.Parse(match.Groups["major"].Value); + if (!Enum.TryParse(match.Groups["stream"].Value, true, out var stream)) + { + stream = Stream.Final; + } + + _ = int.TryParse(match.Groups["minor"].Value, out var minor); + return new Version(major, (int)stream, minor); + } +} diff --git a/src/KubeOps.Transpiler/Rbac.cs b/src/KubeOps.Transpiler/Rbac.cs index 18b3b061..8369bc67 100644 --- a/src/KubeOps.Transpiler/Rbac.cs +++ b/src/KubeOps.Transpiler/Rbac.cs @@ -1,96 +1,96 @@ -using System.Reflection; - -using k8s.Models; - -using KubeOps.Abstractions.Rbac; - -namespace KubeOps.Transpiler; - -/// -/// Transpiler for Kubernetes RBAC attributes to create s. -/// -public static class Rbac -{ - /// - /// Convert a list of s to a list of s. - /// The rules are grouped by entity type and verbs. - /// - /// The that was used to load the attributes. - /// List of s. - /// A converted, grouped list of s. - public static IEnumerable Transpile( - this MetadataLoadContext context, - IEnumerable attributes) - { - var list = attributes.ToList(); - - var generic = list - .Where(a => a.AttributeType == context.GetContextType()) - .Select(a => new V1PolicyRule - { - ApiGroups = a.GetCustomAttributeNamedArrayArg(nameof(GenericRbacAttribute.Groups)), - Resources = a.GetCustomAttributeNamedArrayArg(nameof(GenericRbacAttribute.Resources)), - NonResourceURLs = a.GetCustomAttributeNamedArrayArg(nameof(GenericRbacAttribute.Urls)), - Verbs = ConvertToStrings( - a.GetCustomAttributeNamedArg(context, nameof(GenericRbacAttribute.Verbs))), - }); - - var entities = list - .Where(a => a.AttributeType == context.GetContextType()) - .SelectMany(attribute => - attribute.GetCustomAttributeCtorArrayArg(0).Select(type => - (EntityType: type, - Verbs: attribute.GetCustomAttributeNamedArg( - context, - nameof(GenericRbacAttribute.Verbs))))) - .GroupBy(e => e.EntityType) - .Select( - group => ( - Crd: context.ToEntityMetadata(group.Key), - Verbs: group.Aggregate(RbacVerb.None, (accumulator, element) => accumulator | element.Verbs))) - .GroupBy(group => group.Verbs) - .Select( - group => ( - Verbs: group.Key, - Crds: group.Select(element => element.Crd).ToList())) - .Select( - group => new V1PolicyRule - { - ApiGroups = group.Crds.Select(crd => crd.Metadata.Group).Distinct().ToList(), - Resources = group.Crds.Select(crd => crd.Metadata.PluralName).Distinct().ToList(), - Verbs = ConvertToStrings(group.Verbs), - }); - - var entityStatus = list - .Where(a => a.AttributeType == context.GetContextType()) - .SelectMany(attribute => - attribute.GetCustomAttributeCtorArrayArg(0).Select(type => - (EntityType: type, - Verbs: attribute.GetCustomAttributeNamedArg( - context, - nameof(GenericRbacAttribute.Verbs))))) - .Where(e => e.EntityType.GetProperty("Status") != null) - .GroupBy(e => e.EntityType) - .Select(group => context.ToEntityMetadata(group.Key)) - .Select( - crd => new V1PolicyRule - { - ApiGroups = new[] { crd.Metadata.Group }, - Resources = new[] { $"{crd.Metadata.PluralName}/status" }, - Verbs = ConvertToStrings(RbacVerb.Get | RbacVerb.Patch | RbacVerb.Update), - }); - - return generic.Concat(entities).Concat(entityStatus); - } - - private static string[] ConvertToStrings(RbacVerb verbs) => verbs switch - { - RbacVerb.None => Array.Empty(), - _ when verbs.HasFlag(RbacVerb.All) => new[] { "*" }, - _ => - Enum.GetValues() - .Where(v => verbs.HasFlag(v) && v != RbacVerb.All && v != RbacVerb.None) - .Select(v => v.ToString().ToLowerInvariant()) - .ToArray(), - }; -} +using System.Reflection; + +using k8s.Models; + +using KubeOps.Abstractions.Rbac; + +namespace KubeOps.Transpiler; + +/// +/// Transpiler for Kubernetes RBAC attributes to create s. +/// +public static class Rbac +{ + /// + /// Convert a list of s to a list of s. + /// The rules are grouped by entity type and verbs. + /// + /// The that was used to load the attributes. + /// List of s. + /// A converted, grouped list of s. + public static IEnumerable Transpile( + this MetadataLoadContext context, + IEnumerable attributes) + { + var list = attributes.ToList(); + + var generic = list + .Where(a => a.AttributeType == context.GetContextType()) + .Select(a => new V1PolicyRule + { + ApiGroups = a.GetCustomAttributeNamedArrayArg(nameof(GenericRbacAttribute.Groups)), + Resources = a.GetCustomAttributeNamedArrayArg(nameof(GenericRbacAttribute.Resources)), + NonResourceURLs = a.GetCustomAttributeNamedArrayArg(nameof(GenericRbacAttribute.Urls)), + Verbs = ConvertToStrings( + a.GetCustomAttributeNamedArg(context, nameof(GenericRbacAttribute.Verbs))), + }); + + var entities = list + .Where(a => a.AttributeType == context.GetContextType()) + .SelectMany(attribute => + attribute.GetCustomAttributeCtorArrayArg(0).Select(type => + (EntityType: type, + Verbs: attribute.GetCustomAttributeNamedArg( + context, + nameof(GenericRbacAttribute.Verbs))))) + .GroupBy(e => e.EntityType) + .Select( + group => ( + Crd: context.ToEntityMetadata(group.Key), + Verbs: group.Aggregate(RbacVerb.None, (accumulator, element) => accumulator | element.Verbs))) + .GroupBy(group => group.Verbs) + .Select( + group => ( + Verbs: group.Key, + Crds: group.Select(element => element.Crd).ToList())) + .Select( + group => new V1PolicyRule + { + ApiGroups = group.Crds.Select(crd => crd.Metadata.Group).Distinct().ToList(), + Resources = group.Crds.Select(crd => crd.Metadata.PluralName).Distinct().ToList(), + Verbs = ConvertToStrings(group.Verbs), + }); + + var entityStatus = list + .Where(a => a.AttributeType == context.GetContextType()) + .SelectMany(attribute => + attribute.GetCustomAttributeCtorArrayArg(0).Select(type => + (EntityType: type, + Verbs: attribute.GetCustomAttributeNamedArg( + context, + nameof(GenericRbacAttribute.Verbs))))) + .Where(e => e.EntityType.GetProperty("Status") != null) + .GroupBy(e => e.EntityType) + .Select(group => context.ToEntityMetadata(group.Key)) + .Select( + crd => new V1PolicyRule + { + ApiGroups = new[] { crd.Metadata.Group }, + Resources = new[] { $"{crd.Metadata.PluralName}/status" }, + Verbs = ConvertToStrings(RbacVerb.Get | RbacVerb.Patch | RbacVerb.Update), + }); + + return generic.Concat(entities).Concat(entityStatus); + } + + private static string[] ConvertToStrings(RbacVerb verbs) => verbs switch + { + RbacVerb.None => Array.Empty(), + _ when verbs.HasFlag(RbacVerb.All) => new[] { "*" }, + _ => + Enum.GetValues() + .Where(v => verbs.HasFlag(v) && v != RbacVerb.All && v != RbacVerb.None) + .Select(v => v.ToString().ToLowerInvariant()) + .ToArray(), + }; +} diff --git a/src/KubeOps.Transpiler/Utilities.cs b/src/KubeOps.Transpiler/Utilities.cs index 44f92c00..c5b1338c 100644 --- a/src/KubeOps.Transpiler/Utilities.cs +++ b/src/KubeOps.Transpiler/Utilities.cs @@ -1,161 +1,161 @@ -using System.Collections.ObjectModel; -using System.Reflection; - -namespace KubeOps.Transpiler; - -/// -/// Utilities for loading attributes and information. -/// -public static class Utilities -{ - /// - /// Load a custom attribute from a read-only-reflected type. - /// - /// The type. - /// The type of the attribute to load. - /// The custom attribute data if an attribute is found. - public static CustomAttributeData? GetCustomAttributeData(this Type type) - where TAttribute : Attribute - => CustomAttributeData - .GetCustomAttributes(type) - .FirstOrDefault(a => a.AttributeType.Name == typeof(TAttribute).Name); - - /// - /// Load a custom attribute from a read-only-reflected property. - /// - /// The property. - /// The type of the attribute to load. - /// The custom attribute data if an attribute is found. - public static CustomAttributeData? GetCustomAttributeData(this PropertyInfo prop) - where TAttribute : Attribute - => CustomAttributeData - .GetCustomAttributes(prop) - .FirstOrDefault(a => a.AttributeType.Name == typeof(TAttribute).Name); - - /// - /// Load an enumerable of custom attributes from a read-only-reflected type. - /// - /// The type. - /// The type of the attribute to load. - /// The custom attribute data list if any were found. - public static IEnumerable GetCustomAttributesData(this Type type) - where TAttribute : Attribute - => CustomAttributeData - .GetCustomAttributes(type) - .Where(a => a.AttributeType.Name == typeof(TAttribute).Name); - - /// - /// Load a specific named argument from a custom attribute. - /// Named arguments are in the property-notation: - /// [KubernetesEntity(Kind = "foobar")]. - /// - /// The attribute in question. - /// The metadata load context that loaded everything. - /// The name of the argument. - /// What target type the argument has. - /// The argument value if found. - /// Thrown if the data did not match the target type. - public static T? - GetCustomAttributeNamedArg(this CustomAttributeData attr, MetadataLoadContext ctx, string name) => - attr.NamedArguments.FirstOrDefault(a => a.MemberName == name).TypedValue.ArgumentType == ctx.GetContextType() - ? (T)attr.NamedArguments.FirstOrDefault(a => a.MemberName == name).TypedValue.Value! - : default; - - /// - /// Load a specific named argument array from a custom attribute. - /// Named arguments are in the property-notation: - /// [Test(Foo = new[]{"bar", "baz"})]. - /// - /// The attribute in question. - /// The name of the argument. - /// What target type the arguments have. - /// The list of arguments if found. - /// Thrown if the data did not match the target type. - public static IList GetCustomAttributeNamedArrayArg(this CustomAttributeData attr, string name) => - attr.NamedArguments.FirstOrDefault(a => a.MemberName == name).TypedValue.Value is - ReadOnlyCollection value - ? value.Select(v => (T)v.Value!).ToList() - : new List(); - - /// - /// Load a specific constructor argument from a custom attribute. - /// Constructor arguments are in the "new" format: - /// [KubernetesEntity("foobar")]. - /// - /// The attribute in question. - /// The metadata load context that loaded everything. - /// Index of the value in the constructor notation. - /// What target type the argument has. - /// The argument value if found. - /// Thrown if the data did not match the target type. - public static T? GetCustomAttributeCtorArg(this CustomAttributeData attr, MetadataLoadContext ctx, int index) => - attr.ConstructorArguments.Count >= index + 1 && - attr.ConstructorArguments[index].ArgumentType == ctx.GetContextType() - ? (T)attr.ConstructorArguments[index].Value! - : default; - - /// - /// Load a specific constructor argument array from a custom attribute. - /// Constructor arguments are in the "new" format: - /// [KubernetesEntity(new[]{"foobar", "barbaz"})]. - /// - /// The attribute in question. - /// Index of the value in the constructor notation. - /// What target type the arguments have. - /// The list of arguments if found. - /// Thrown if the data did not match the target type. - public static IList GetCustomAttributeCtorArrayArg( - this CustomAttributeData attr, - int index) => - attr.ConstructorArguments.Count >= index + 1 && - attr.ConstructorArguments[index].Value is - ReadOnlyCollection value - ? value.Select(v => (T)v.Value!).ToList() - : new List(); - - /// - /// Load a type from a metadata load context. - /// - /// The context. - /// The type. - /// The loaded reflected type. - public static Type GetContextType(this MetadataLoadContext context) - => context.GetContextType(typeof(T)); - - /// - /// Load a type from a metadata load context. - /// - /// The context. - /// The type. - /// The loaded reflected type. - public static Type GetContextType(this MetadataLoadContext context, Type type) - { - foreach (var assembly in context.GetAssemblies()) - { - if (assembly.GetType(type.FullName!) is { } t) - { - return t; - } - } - - var newAssembly = context.LoadFromAssemblyPath(type.Assembly.Location); - return newAssembly.GetType(type.FullName!)!; - } - - /// - /// Check if a type is nullable. - /// - /// The type. - /// True if the type is nullable (i.e. contains "nullable" in its name). - public static bool IsNullable(this Type type) - => type.FullName?.Contains("Nullable") == true; - - /// - /// Check if a property is nullable. - /// - /// The property. - /// True if the type is nullable (i.e. contains "nullable" in its name). - public static bool IsNullable(this PropertyInfo prop) - => new NullabilityInfoContext().Create(prop).ReadState == NullabilityState.Nullable || - prop.PropertyType.FullName?.Contains("Nullable") == true; -} +using System.Collections.ObjectModel; +using System.Reflection; + +namespace KubeOps.Transpiler; + +/// +/// Utilities for loading attributes and information. +/// +public static class Utilities +{ + /// + /// Load a custom attribute from a read-only-reflected type. + /// + /// The type. + /// The type of the attribute to load. + /// The custom attribute data if an attribute is found. + public static CustomAttributeData? GetCustomAttributeData(this Type type) + where TAttribute : Attribute + => CustomAttributeData + .GetCustomAttributes(type) + .FirstOrDefault(a => a.AttributeType.Name == typeof(TAttribute).Name); + + /// + /// Load a custom attribute from a read-only-reflected property. + /// + /// The property. + /// The type of the attribute to load. + /// The custom attribute data if an attribute is found. + public static CustomAttributeData? GetCustomAttributeData(this PropertyInfo prop) + where TAttribute : Attribute + => CustomAttributeData + .GetCustomAttributes(prop) + .FirstOrDefault(a => a.AttributeType.Name == typeof(TAttribute).Name); + + /// + /// Load an enumerable of custom attributes from a read-only-reflected type. + /// + /// The type. + /// The type of the attribute to load. + /// The custom attribute data list if any were found. + public static IEnumerable GetCustomAttributesData(this Type type) + where TAttribute : Attribute + => CustomAttributeData + .GetCustomAttributes(type) + .Where(a => a.AttributeType.Name == typeof(TAttribute).Name); + + /// + /// Load a specific named argument from a custom attribute. + /// Named arguments are in the property-notation: + /// [KubernetesEntity(Kind = "foobar")]. + /// + /// The attribute in question. + /// The metadata load context that loaded everything. + /// The name of the argument. + /// What target type the argument has. + /// The argument value if found. + /// Thrown if the data did not match the target type. + public static T? + GetCustomAttributeNamedArg(this CustomAttributeData attr, MetadataLoadContext ctx, string name) => + attr.NamedArguments.FirstOrDefault(a => a.MemberName == name).TypedValue.ArgumentType == ctx.GetContextType() + ? (T)attr.NamedArguments.FirstOrDefault(a => a.MemberName == name).TypedValue.Value! + : default; + + /// + /// Load a specific named argument array from a custom attribute. + /// Named arguments are in the property-notation: + /// [Test(Foo = new[]{"bar", "baz"})]. + /// + /// The attribute in question. + /// The name of the argument. + /// What target type the arguments have. + /// The list of arguments if found. + /// Thrown if the data did not match the target type. + public static IList GetCustomAttributeNamedArrayArg(this CustomAttributeData attr, string name) => + attr.NamedArguments.FirstOrDefault(a => a.MemberName == name).TypedValue.Value is + ReadOnlyCollection value + ? value.Select(v => (T)v.Value!).ToList() + : new List(); + + /// + /// Load a specific constructor argument from a custom attribute. + /// Constructor arguments are in the "new" format: + /// [KubernetesEntity("foobar")]. + /// + /// The attribute in question. + /// The metadata load context that loaded everything. + /// Index of the value in the constructor notation. + /// What target type the argument has. + /// The argument value if found. + /// Thrown if the data did not match the target type. + public static T? GetCustomAttributeCtorArg(this CustomAttributeData attr, MetadataLoadContext ctx, int index) => + attr.ConstructorArguments.Count >= index + 1 && + attr.ConstructorArguments[index].ArgumentType == ctx.GetContextType() + ? (T)attr.ConstructorArguments[index].Value! + : default; + + /// + /// Load a specific constructor argument array from a custom attribute. + /// Constructor arguments are in the "new" format: + /// [KubernetesEntity(new[]{"foobar", "barbaz"})]. + /// + /// The attribute in question. + /// Index of the value in the constructor notation. + /// What target type the arguments have. + /// The list of arguments if found. + /// Thrown if the data did not match the target type. + public static IList GetCustomAttributeCtorArrayArg( + this CustomAttributeData attr, + int index) => + attr.ConstructorArguments.Count >= index + 1 && + attr.ConstructorArguments[index].Value is + ReadOnlyCollection value + ? value.Select(v => (T)v.Value!).ToList() + : new List(); + + /// + /// Load a type from a metadata load context. + /// + /// The context. + /// The type. + /// The loaded reflected type. + public static Type GetContextType(this MetadataLoadContext context) + => context.GetContextType(typeof(T)); + + /// + /// Load a type from a metadata load context. + /// + /// The context. + /// The type. + /// The loaded reflected type. + public static Type GetContextType(this MetadataLoadContext context, Type type) + { + foreach (var assembly in context.GetAssemblies()) + { + if (assembly.GetType(type.FullName!) is { } t) + { + return t; + } + } + + var newAssembly = context.LoadFromAssemblyPath(type.Assembly.Location); + return newAssembly.GetType(type.FullName!)!; + } + + /// + /// Check if a type is nullable. + /// + /// The type. + /// True if the type is nullable (i.e. contains "nullable" in its name). + public static bool IsNullable(this Type type) + => type.FullName?.Contains("Nullable") == true; + + /// + /// Check if a property is nullable. + /// + /// The property. + /// True if the type is nullable (i.e. contains "nullable" in its name). + public static bool IsNullable(this PropertyInfo prop) + => new NullabilityInfoContext().Create(prop).ReadState == NullabilityState.Nullable || + prop.PropertyType.FullName?.Contains("Nullable") == true; +} diff --git a/test/KubeOps.Operator.Test/HostBuilder.cs b/test/KubeOps.Operator.Test/HostBuilder.cs index d8518787..2f6a021a 100644 --- a/test/KubeOps.Operator.Test/HostBuilder.cs +++ b/test/KubeOps.Operator.Test/HostBuilder.cs @@ -1,43 +1,43 @@ -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; - -namespace KubeOps.Operator.Test; - -public sealed class HostBuilder : IAsyncDisposable -{ - private IHost? _host; - private bool _isRunning; - - public IServiceProvider Services => _host?.Services ?? throw new InvalidOperationException(); - - public async Task ConfigureAndStart(Action configure) - { - if (_host is not null && _isRunning) - { - return; - } - - var builder = Host.CreateApplicationBuilder(); -#if DEBUG - builder.Logging.SetMinimumLevel(LogLevel.Trace); -#else - builder.Logging.SetMinimumLevel(LogLevel.None); -#endif - configure(builder); - _host = builder.Build(); - await _host.StartAsync(); - _isRunning = true; - } - - public async ValueTask DisposeAsync() - { - _isRunning = false; - if (_host is null) - { - return; - } - - await _host.StopAsync(); - _host.Dispose(); - } -} +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace KubeOps.Operator.Test; + +public sealed class HostBuilder : IAsyncDisposable +{ + private IHost? _host; + private bool _isRunning; + + public IServiceProvider Services => _host?.Services ?? throw new InvalidOperationException(); + + public async Task ConfigureAndStart(Action configure) + { + if (_host is not null && _isRunning) + { + return; + } + + var builder = Host.CreateApplicationBuilder(); +#if DEBUG + builder.Logging.SetMinimumLevel(LogLevel.Trace); +#else + builder.Logging.SetMinimumLevel(LogLevel.None); +#endif + configure(builder); + _host = builder.Build(); + await _host.StartAsync(); + _isRunning = true; + } + + public async ValueTask DisposeAsync() + { + _isRunning = false; + if (_host is null) + { + return; + } + + await _host.StopAsync(); + _host.Dispose(); + } +} diff --git a/test/KubeOps.Operator.Test/MlcProvider.cs b/test/KubeOps.Operator.Test/MlcProvider.cs index a81c17ca..b7be8089 100644 --- a/test/KubeOps.Operator.Test/MlcProvider.cs +++ b/test/KubeOps.Operator.Test/MlcProvider.cs @@ -1,55 +1,55 @@ -using System.Reflection; -using System.Runtime.InteropServices; - -using KubeOps.Transpiler; - -using Microsoft.Build.Locator; -using Microsoft.CodeAnalysis.MSBuild; - -namespace KubeOps.Operator.Test; - -public class MlcProvider : IAsyncLifetime -{ - private static readonly SemaphoreSlim Semaphore = new(1, 1); - - static MlcProvider() - { - MSBuildLocator.RegisterDefaults(); - } - - public MetadataLoadContext Mlc { get; private set; } = null!; - - public async Task InitializeAsync() - { - var assemblyConfigurationAttribute = - typeof(MlcProvider).Assembly.GetCustomAttribute(); - var buildConfigurationName = assemblyConfigurationAttribute?.Configuration ?? "Debug"; - - try - { - await Semaphore.WaitAsync(); - using var workspace = MSBuildWorkspace.Create(new Dictionary - { - { "Configuration", buildConfigurationName }, - }); - workspace.SkipUnrecognizedProjects = true; - workspace.LoadMetadataForReferencedProjects = true; - var project = await workspace.OpenProjectAsync("../../../KubeOps.Operator.Test.csproj"); - - Mlc = ContextCreator.Create(Directory - .GetFiles(RuntimeEnvironment.GetRuntimeDirectory(), "*.dll") - .Concat(Directory.GetFiles(Path.GetDirectoryName(project.OutputFilePath)!, "*.dll")) - .Distinct(), coreAssemblyName: typeof(object).Assembly.GetName().Name); - } - finally - { - Semaphore.Release(); - } - } - - public Task DisposeAsync() - { - Mlc.Dispose(); - return Task.CompletedTask; - } -} +using System.Reflection; +using System.Runtime.InteropServices; + +using KubeOps.Transpiler; + +using Microsoft.Build.Locator; +using Microsoft.CodeAnalysis.MSBuild; + +namespace KubeOps.Operator.Test; + +public class MlcProvider : IAsyncLifetime +{ + private static readonly SemaphoreSlim Semaphore = new(1, 1); + + static MlcProvider() + { + MSBuildLocator.RegisterDefaults(); + } + + public MetadataLoadContext Mlc { get; private set; } = null!; + + public async Task InitializeAsync() + { + var assemblyConfigurationAttribute = + typeof(MlcProvider).Assembly.GetCustomAttribute(); + var buildConfigurationName = assemblyConfigurationAttribute?.Configuration ?? "Debug"; + + try + { + await Semaphore.WaitAsync(); + using var workspace = MSBuildWorkspace.Create(new Dictionary + { + { "Configuration", buildConfigurationName }, + }); + workspace.SkipUnrecognizedProjects = true; + workspace.LoadMetadataForReferencedProjects = true; + var project = await workspace.OpenProjectAsync("../../../KubeOps.Operator.Test.csproj"); + + Mlc = ContextCreator.Create(Directory + .GetFiles(RuntimeEnvironment.GetRuntimeDirectory(), "*.dll") + .Concat(Directory.GetFiles(Path.GetDirectoryName(project.OutputFilePath)!, "*.dll")) + .Distinct(), coreAssemblyName: typeof(object).Assembly.GetName().Name); + } + finally + { + Semaphore.Release(); + } + } + + public Task DisposeAsync() + { + Mlc.Dispose(); + return Task.CompletedTask; + } +} diff --git a/test/KubeOps.Transpiler.Test/Crds.Test.cs b/test/KubeOps.Transpiler.Test/Crds.Test.cs index a367b9f0..032c598a 100644 --- a/test/KubeOps.Transpiler.Test/Crds.Test.cs +++ b/test/KubeOps.Transpiler.Test/Crds.Test.cs @@ -1,914 +1,914 @@ -using System.Collections; -using System.Collections.ObjectModel; -using System.Text.Json.Serialization; - -using FluentAssertions; - -using k8s.Models; - -using KubeOps.Abstractions.Entities; -using KubeOps.Abstractions.Entities.Attributes; - -namespace KubeOps.Transpiler.Test; - -public class CrdsTest : TranspilerTestBase -{ - public CrdsTest(MlcProvider provider) : base(provider) - { - } - - [Theory] - [InlineData(typeof(StringTestEntity), "string", null, false)] - [InlineData(typeof(NullableStringTestEntity), "string", null, true)] - [InlineData(typeof(IntTestEntity), "integer", "int32", false)] - [InlineData(typeof(NullableIntTestEntity), "integer", "int32", true)] - [InlineData(typeof(LongTestEntity), "integer", "int64", false)] - [InlineData(typeof(NullableLongTestEntity), "integer", "int64", true)] - [InlineData(typeof(FloatTestEntity), "number", "float", false)] - [InlineData(typeof(NullableFloatTestEntity), "number", "float", true)] - [InlineData(typeof(DoubleTestEntity), "number", "double", false)] - [InlineData(typeof(NullableDoubleTestEntity), "number", "double", true)] - [InlineData(typeof(BoolTestEntity), "boolean", null, false)] - [InlineData(typeof(NullableBoolTestEntity), "boolean", null, true)] - [InlineData(typeof(DateTimeTestEntity), "string", "date-time", false)] - [InlineData(typeof(NullableDateTimeTestEntity), "string", "date-time", true)] - [InlineData(typeof(V1ObjectMetaTestEntity), "object", null, false)] - [InlineData(typeof(StringArrayEntity), "array", null, false)] - [InlineData(typeof(NullableStringArrayEntity), "array", null, true)] - [InlineData(typeof(EnumerableIntEntity), "array", null, false)] - [InlineData(typeof(HashSetIntEntity), "array", null, false)] - [InlineData(typeof(SetIntEntity), "array", null, false)] - [InlineData(typeof(InheritedEnumerableEntity), "array", null, false)] - [InlineData(typeof(EnumEntity), "string", null, false)] - [InlineData(typeof(NullableEnumEntity), "string", null, true)] - [InlineData(typeof(DictionaryEntity), "object", null, false)] - [InlineData(typeof(EnumerableKeyPairsEntity), "object", null, false)] - [InlineData(typeof(IntstrOrStringEntity), null, null, false)] - [InlineData(typeof(EmbeddedResourceEntity), "object", null, false)] - [InlineData(typeof(EmbeddedResourceListEntity), "array", null, false)] - public void Should_Transpile_Entity_Type_Correctly(Type type, string? expectedType, string? expectedFormat, - bool isNullable) - { - var crd = _mlc.Transpile(type); - var prop = crd.Spec.Versions.First().Schema.OpenAPIV3Schema.Properties["property"]; - prop.Type.Should().Be(expectedType); - prop.Format.Should().Be(expectedFormat); - prop.Nullable.Should().Be(isNullable); - } - - [Theory] - [InlineData(typeof(StringArrayEntity), "string", false)] - [InlineData(typeof(NullableStringArrayEntity), "string", false)] - [InlineData(typeof(EnumerableIntEntity), "integer", false)] - [InlineData(typeof(EnumerableNullableIntEntity), "integer", true)] - [InlineData(typeof(HashSetIntEntity), "integer", false)] - [InlineData(typeof(SetIntEntity), "integer", false)] - [InlineData(typeof(InheritedEnumerableEntity), "integer", false)] - [InlineData(typeof(EmbeddedResourceListEntity), "object", false)] - public void Should_Set_Correct_Array_Type(Type type, string expectedType, bool isNullable) - { - var crd = _mlc.Transpile(type); - var prop = crd.Spec.Versions.First().Schema.OpenAPIV3Schema.Properties["property"].Items as V1JSONSchemaProps; - prop!.Type.Should().Be(expectedType); - prop.Nullable.Should().Be(isNullable); - } - - [Fact] - public void Should_Ignore_Entity() - { - var crds = _mlc.Transpile(new[] { typeof(IgnoredEntity) }); - crds.Count().Should().Be(0); - } - - [Fact] - public void Should_Ignore_NonEntity() - { - var crds = _mlc.Transpile(new[] { typeof(NonEntity) }); - crds.Count().Should().Be(0); - } - - [Fact] - public void Should_Ignore_Kubernetes_Entities() - { - var crds = _mlc.Transpile(new[] { typeof(V1Pod) }); - crds.Count().Should().Be(0); - } - - [Fact] - public void Should_Set_Highest_Version_As_Storage() - { - var crds = _mlc.Transpile(new[] - { - typeof(V1Alpha1VersionedEntity), typeof(V1Beta1VersionedEntity), typeof(V2Beta2VersionedEntity), - typeof(V2VersionedEntity), typeof(V1VersionedEntity), typeof(V1AttributeVersionedEntity), - typeof(V2AttributeVersionedEntity), - }); - var crd = crds.First(c => c.Spec.Names.Kind == "VersionedEntity"); - crd.Spec.Versions.Count(v => v.Storage).Should().Be(1); - crd.Spec.Versions.First(v => v.Storage).Name.Should().Be("v2"); - } - - [Fact] - public void Should_Set_Storage_When_Attribute_Is_Set() - { - var crds = _mlc.Transpile(new[] - { - typeof(V1Alpha1VersionedEntity), typeof(V1Beta1VersionedEntity), typeof(V2Beta2VersionedEntity), - typeof(V2VersionedEntity), typeof(V1VersionedEntity), typeof(V1AttributeVersionedEntity), - typeof(V2AttributeVersionedEntity), - }); - var crd = crds.First(c => c.Spec.Names.Kind == "AttributeVersionedEntity"); - crd.Spec.Versions.Count(v => v.Storage).Should().Be(1); - crd.Spec.Versions.First(v => v.Storage).Name.Should().Be("v1"); - } - - [Fact] - public void Should_Add_Multiple_Versions_To_Crd() - { - var crds = _mlc.Transpile(new[] - { - typeof(V1Alpha1VersionedEntity), typeof(V1Beta1VersionedEntity), typeof(V2Beta2VersionedEntity), - typeof(V2VersionedEntity), typeof(V1VersionedEntity), typeof(V1AttributeVersionedEntity), - typeof(V2AttributeVersionedEntity), - }).ToList(); - crds - .First(c => c.Spec.Names.Kind == "VersionedEntity") - .Spec.Versions.Should() - .HaveCount(5); - crds - .First(c => c.Spec.Names.Kind == "AttributeVersionedEntity") - .Spec.Versions.Should() - .HaveCount(2); - } - - [Fact] - public void Should_Use_Correct_CRD() - { - var crd = _mlc.Transpile(typeof(Entity)); - var (ced, scope) = _mlc.ToEntityMetadata(typeof(Entity)); - - crd.Kind.Should().Be(V1CustomResourceDefinition.KubeKind); - crd.Metadata.Name.Should().Be($"{ced.PluralName}.{ced.Group}"); - crd.Spec.Names.Kind.Should().Be(ced.Kind); - crd.Spec.Names.ListKind.Should().Be(ced.ListKind); - crd.Spec.Names.Singular.Should().Be(ced.SingularName); - crd.Spec.Names.Plural.Should().Be(ced.PluralName); - crd.Spec.Scope.Should().Be(scope); - } - - [Fact] - public void Should_Not_Add_Status_SubResource_If_Absent() - { - var crd = _mlc.Transpile(typeof(Entity)); - crd.Spec.Versions.First().Subresources?.Status?.Should().BeNull(); - } - - [Fact] - public void Should_Add_Status_SubResource_If_Present() - { - var crd = _mlc.Transpile(typeof(EntityWithStatus)); - crd.Spec.Versions.First().Subresources.Status.Should().NotBeNull(); - } - - [Fact] - public void Should_Add_ShortNames_To_Crd() - { - // TODO. - var crd = _mlc.Transpile(typeof(ShortnamesEntity)); - crd.Spec.Names.ShortNames.Should() - .NotBeNull() - .And - .Contain(new[] { "foo", "bar", "baz" }); - } - - [Fact] - public void Should_Set_Description_On_Class() - { - var crd = _mlc.Transpile(typeof(ClassDescriptionAttrEntity)); - - var specProperties = crd.Spec.Versions.First().Schema.OpenAPIV3Schema.Properties["spec"]; - specProperties.Description.Should().NotBe(""); - } - - [Fact] - public void Should_Set_Description() - { - var crd = _mlc.Transpile(typeof(DescriptionAttrEntity)); - - var specProperties = crd.Spec.Versions.First().Schema.OpenAPIV3Schema.Properties["property"]; - specProperties.Description.Should().NotBe(""); - } - - [Fact] - public void Should_Set_ExternalDocs() - { - var crd = _mlc.Transpile(typeof(ExtDocsAttrEntity)); - - var specProperties = crd.Spec.Versions.First().Schema.OpenAPIV3Schema.Properties["property"]; - specProperties.ExternalDocs.Url.Should().NotBe(""); - } - - [Fact] - public void Should_Set_ExternalDocs_Description() - { - var crd = _mlc.Transpile(typeof(ExtDocsWithDescriptionAttrEntity)); - - var specProperties = crd.Spec.Versions.First().Schema.OpenAPIV3Schema.Properties["property"]; - specProperties.ExternalDocs.Description.Should().NotBe(""); - } - - [Fact] - public void Should_Set_Items_Information() - { - var crd = _mlc.Transpile(typeof(ItemsAttrEntity)); - - var specProperties = crd.Spec.Versions.First().Schema.OpenAPIV3Schema.Properties["property"]; - - specProperties.Type.Should().Be("array"); - (specProperties.Items as V1JSONSchemaProps)?.Type?.Should().Be("string"); - specProperties.MaxItems.Should().Be(42); - specProperties.MinItems.Should().Be(13); - } - - [Fact] - public void Should_Set_Length_Information() - { - var crd = _mlc.Transpile(typeof(LengthAttrEntity)); - - var specProperties = crd.Spec.Versions.First().Schema.OpenAPIV3Schema.Properties["property"]; - - specProperties.MinLength.Should().Be(2); - specProperties.MaxLength.Should().Be(42); - } - - [Fact] - public void Should_Set_MultipleOf() - { - var crd = _mlc.Transpile(typeof(MultipleOfAttrEntity)); - - var specProperties = crd.Spec.Versions.First().Schema.OpenAPIV3Schema.Properties["property"]; - - specProperties.MultipleOf.Should().Be(2); - } - - [Fact] - public void Should_Set_Pattern() - { - var crd = _mlc.Transpile(typeof(PatternAttrEntity)); - - var specProperties = crd.Spec.Versions.First().Schema.OpenAPIV3Schema.Properties["property"]; - - specProperties.Pattern.Should().Be(@"/\d*/"); - } - - [Fact] - public void Should_Set_RangeMinimum() - { - var crd = _mlc.Transpile(typeof(RangeMinimumAttrEntity)); - - var specProperties = crd.Spec.Versions.First().Schema.OpenAPIV3Schema.Properties["property"]; - - specProperties.Minimum.Should().Be(15); - specProperties.ExclusiveMinimum.Should().BeTrue(); - } - - [Fact] - public void Should_Set_RangeMaximum() - { - var crd = _mlc.Transpile(typeof(RangeMaximumAttrEntity)); - - var specProperties = crd.Spec.Versions.First().Schema.OpenAPIV3Schema.Properties["property"]; - - specProperties.Maximum.Should().Be(15); - specProperties.ExclusiveMaximum.Should().BeTrue(); - } - - [Fact] - public void Should_Set_Required() - { - var crd = _mlc.Transpile(typeof(RequiredAttrEntity)); - - var specProperties = crd.Spec.Versions.First().Schema.OpenAPIV3Schema.Properties["spec"]; - specProperties.Required.Should().Contain("property"); - } - - [Fact] - public void Should_Not_Contain_Ignored_Property() - { - var crd = _mlc.Transpile(typeof(IgnoreAttrEntity)); - - var specProperties = crd.Spec.Versions.First().Schema.OpenAPIV3Schema.Properties["spec"]; - specProperties.Properties.Should().NotContainKey("property"); - } - - [Fact] - public void Should_Set_Preserve_Unknown_Fields() - { - var crd = _mlc.Transpile(typeof(PreserveUnknownFieldsAttrEntity)); - - var specProperties = crd.Spec.Versions.First().Schema.OpenAPIV3Schema.Properties["property"]; - specProperties.XKubernetesPreserveUnknownFields.Should().BeTrue(); - } - - [Fact] - public void Should_Set_EmbeddedResource_Fields() - { - var crd = _mlc.Transpile(typeof(EmbeddedResourceAttrEntity)); - - var specProperties = crd.Spec.Versions.First().Schema.OpenAPIV3Schema.Properties["property"]; - specProperties.XKubernetesEmbeddedResource.Should().BeTrue(); - } - - [Fact] - public void Should_Set_Preserve_Unknown_Fields_On_Dictionaries() - { - var crd = _mlc.Transpile(typeof(SimpleDictionaryEntity)); - - var specProperties = crd.Spec.Versions.First().Schema.OpenAPIV3Schema.Properties["property"]; - specProperties.XKubernetesPreserveUnknownFields.Should().BeTrue(); - } - - [Fact] - public void Should_Not_Set_Preserve_Unknown_Fields_On_Generic_Dictionaries() - { - var crd = _mlc.Transpile(typeof(DictionaryEntity)); - - var specProperties = crd.Spec.Versions.First().Schema.OpenAPIV3Schema.Properties["property"]; - specProperties.XKubernetesPreserveUnknownFields.Should().BeNull(); - } - - [Fact] - public void Should_Not_Set_Preserve_Unknown_Fields_On_KeyValuePair_Enumerable() - { - var crd = _mlc.Transpile(typeof(EnumerableKeyPairsEntity)); - - var specProperties = crd.Spec.Versions.First().Schema.OpenAPIV3Schema.Properties["property"]; - specProperties.XKubernetesPreserveUnknownFields.Should().BeNull(); - } - - [Fact] - public void Should_Not_Set_Properties_On_Dictionaries() - { - var crd = _mlc.Transpile(typeof(SimpleDictionaryEntity)); - - var specProperties = crd.Spec.Versions.First().Schema.OpenAPIV3Schema.Properties["property"]; - specProperties.Properties.Should().BeNull(); - } - - [Fact] - public void Should_Not_Set_Properties_On_Generic_Dictionaries() - { - var crd = _mlc.Transpile(typeof(DictionaryEntity)); - - var specProperties = crd.Spec.Versions.First().Schema.OpenAPIV3Schema.Properties["property"]; - specProperties.Properties.Should().BeNull(); - } - - [Fact] - public void Should_Not_Set_Properties_On_KeyValuePair_Enumerable() - { - var crd = _mlc.Transpile(typeof(EnumerableKeyPairsEntity)); - - var specProperties = crd.Spec.Versions.First().Schema.OpenAPIV3Schema.Properties["property"]; - specProperties.Properties.Should().BeNull(); - } - - [Fact] - public void Should_Set_AdditionalProperties_On_Dictionaries_For_Value_type() - { - var crd = _mlc.Transpile(typeof(DictionaryEntity)); - - var specProperties = crd.Spec.Versions.First().Schema.OpenAPIV3Schema.Properties["property"]; - specProperties.AdditionalProperties.Should().NotBeNull(); - } - - [Fact] - public void Should_Set_AdditionalProperties_On_KeyValuePair_For_Value_type() - { - var crd = _mlc.Transpile(typeof(EnumerableKeyPairsEntity)); - - var specProperties = crd.Spec.Versions.First().Schema.OpenAPIV3Schema.Properties["property"]; - specProperties.AdditionalProperties.Should().NotBeNull(); - } - - [Fact] - public void Should_Set_IntOrString() - { - var crd = _mlc.Transpile(typeof(IntstrOrStringEntity)); - - var specProperties = crd.Spec.Versions.First().Schema.OpenAPIV3Schema.Properties["property"]; - specProperties.Properties.Should().BeNull(); - specProperties.XKubernetesIntOrString.Should().BeTrue(); - } - - [Fact] - public void Should_Use_PropertyName_From_JsonPropertyAttribute() - { - var crd = _mlc.Transpile(typeof(JsonPropNameAttrEntity)); - - var specProperties = crd.Spec.Versions.First().Schema.OpenAPIV3Schema.Properties; - specProperties.Should().Contain(p => p.Key == "otherName"); - } - - [Fact] - public void Must_Not_Contain_Ignored_TopLevel_Properties() - { - var crd = _mlc.Transpile(typeof(Entity)); - - var specProperties = crd.Spec.Versions.First().Schema.OpenAPIV3Schema.Properties; - specProperties.Should().NotContainKeys("metadata", "apiVersion", "kind"); - } - - [Fact] - public void Should_Add_AdditionalPrinterColumns() - { - var crd = _mlc.Transpile(typeof(AdditionalPrinterColumnAttrEntity)); - var apc = crd.Spec.Versions.First().AdditionalPrinterColumns; - apc.Should().ContainSingle(def => def.JsonPath == ".property"); - } - - [Fact] - public void Should_Add_AdditionalPrinterColumns_With_Prio() - { - var crd = _mlc.Transpile(typeof(AdditionalPrinterColumnWideAttrEntity)); - var apc = crd.Spec.Versions.First().AdditionalPrinterColumns; - apc.Should().ContainSingle(def => def.JsonPath == ".property" && def.Priority == 1); - } - - [Fact] - public void Should_Add_AdditionalPrinterColumns_With_Name() - { - var crd = _mlc.Transpile(typeof(AdditionalPrinterColumnNameAttrEntity)); - var apc = crd.Spec.Versions.First().AdditionalPrinterColumns; - apc.Should().ContainSingle(def => def.JsonPath == ".property" && def.Name == "OtherName"); - } - - [Fact] - public void Should_Add_GenericAdditionalPrinterColumns() - { - var crd = _mlc.Transpile(typeof(GenericAdditionalPrinterColumnAttrEntity)); - var apc = crd.Spec.Versions.First().AdditionalPrinterColumns; - - apc.Should().NotBeNull(); - apc.Should().ContainSingle(def => def.JsonPath == ".metadata.namespace" && def.Name == "Namespace"); - } - - [Fact] - public void Should_Correctly_Use_Entity_Scope_Attribute() - { - var scopedCrd = _mlc.Transpile(typeof(Entity)); - var clusterCrd = _mlc.Transpile(typeof(ScopeAttrEntity)); - - scopedCrd.Spec.Scope.Should().Be("Namespaced"); - clusterCrd.Spec.Scope.Should().Be("Cluster"); - } - - #region Test Entity Classes - - [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] - private class StringTestEntity : CustomKubernetesEntity - { - public string Property { get; set; } = string.Empty; - } - - [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] - private class NullableStringTestEntity : CustomKubernetesEntity - { - public string? Property { get; set; } = string.Empty; - } - - [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] - private class IntTestEntity : CustomKubernetesEntity - { - public int Property { get; set; } - } - - [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] - private class NullableIntTestEntity : CustomKubernetesEntity - { - public int? Property { get; set; } - } - - [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] - private class LongTestEntity : CustomKubernetesEntity - { - public long Property { get; set; } - } - - [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] - private class NullableLongTestEntity : CustomKubernetesEntity - { - public long? Property { get; set; } - } - - [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] - private class FloatTestEntity : CustomKubernetesEntity - { - public float Property { get; set; } - } - - [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] - private class NullableFloatTestEntity : CustomKubernetesEntity - { - public float? Property { get; set; } - } - - [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] - private class DoubleTestEntity : CustomKubernetesEntity - { - public double Property { get; set; } - } - - [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] - private class NullableDoubleTestEntity : CustomKubernetesEntity - { - public double? Property { get; set; } - } - - [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] - private class BoolTestEntity : CustomKubernetesEntity - { - public bool Property { get; set; } - } - - [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] - private class NullableBoolTestEntity : CustomKubernetesEntity - { - public bool? Property { get; set; } - } - - [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] - private class DateTimeTestEntity : CustomKubernetesEntity - { - public DateTime Property { get; set; } - } - - [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] - private class NullableDateTimeTestEntity : CustomKubernetesEntity - { - public DateTime? Property { get; set; } - } - - [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] - private class V1ObjectMetaTestEntity : CustomKubernetesEntity - { - public V1ObjectMeta Property { get; set; } = null!; - } - - [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] - private class StringArrayEntity : CustomKubernetesEntity - { - public string[] Property { get; set; } = null!; - } - - [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] - private class NullableStringArrayEntity : CustomKubernetesEntity - { - public string[]? Property { get; set; } = null!; - } - - [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] - private class EnumerableNullableIntEntity : CustomKubernetesEntity - { - public IEnumerable Property { get; set; } = null!; - } - - [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] - private class EnumerableIntEntity : CustomKubernetesEntity - { - public IEnumerable Property { get; set; } = null!; - } - - [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] - private class HashSetIntEntity : CustomKubernetesEntity - { - public HashSet Property { get; set; } = null!; - } - - [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] - private class SetIntEntity : CustomKubernetesEntity - { - public ISet Property { get; set; } = null!; - } - - [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] - private class InheritedEnumerableEntity : CustomKubernetesEntity - { - public IntegerList Property { get; set; } = null!; - - public class IntegerList : Collection - { - } - } - - [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] - private class EnumEntity : CustomKubernetesEntity - { - public TestSpecEnum Property { get; set; } - - public enum TestSpecEnum - { - Value1, - Value2, - } - } - - [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] - private class NullableEnumEntity : CustomKubernetesEntity - { - public TestSpecEnum? Property { get; set; } - - public enum TestSpecEnum - { - Value1, - Value2, - } - } - - [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] - private class SimpleDictionaryEntity : CustomKubernetesEntity - { - public IDictionary Property { get; set; } = null!; - } - - [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] - private class DictionaryEntity : CustomKubernetesEntity - { - public IDictionary Property { get; set; } = null!; - } - - [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] - private class EnumerableKeyPairsEntity : CustomKubernetesEntity - { - public IEnumerable> Property { get; set; } = null!; - } - - [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] - private class IntstrOrStringEntity : CustomKubernetesEntity - { - public IntstrIntOrString Property { get; set; } = null!; - } - - [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] - private class EmbeddedResourceEntity : CustomKubernetesEntity - { - public V1Pod Property { get; set; } = null!; - } - - [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] - private class EmbeddedResourceListEntity : CustomKubernetesEntity - { - public IList Property { get; set; } = null!; - } - - [Ignore] - [KubernetesEntity] - private class IgnoredEntity : CustomKubernetesEntity - { - } - - public class NonEntity - { - } - - [KubernetesEntity( - ApiVersion = "v1alpha1", - Kind = "VersionedEntity", - Group = "kubeops.test.dev", - PluralName = "versionedentities")] - public class V1Alpha1VersionedEntity : CustomKubernetesEntity - { - } - - [KubernetesEntity( - ApiVersion = "v1beta1", - Kind = "VersionedEntity", - Group = "kubeops.test.dev", - PluralName = "versionedentities")] - public class V1Beta1VersionedEntity : CustomKubernetesEntity - { - } - - [KubernetesEntity( - ApiVersion = "v2beta2", - Kind = "VersionedEntity", - Group = "kubeops.test.dev", - PluralName = "versionedentities")] - public class V2Beta2VersionedEntity : CustomKubernetesEntity - { - } - - [KubernetesEntity( - ApiVersion = "v2", - Kind = "VersionedEntity", - Group = "kubeops.test.dev", - PluralName = "versionedentities")] - public class V2VersionedEntity : CustomKubernetesEntity - { - } - - [KubernetesEntity( - ApiVersion = "v1", - Kind = "VersionedEntity", - Group = "kubeops.test.dev", - PluralName = "versionedentities")] - public class V1VersionedEntity : CustomKubernetesEntity - { - } - - [KubernetesEntity( - ApiVersion = "v1", - Kind = "AttributeVersionedEntity", - Group = "kubeops.test.dev", - PluralName = "attributeversionedentities")] - [StorageVersion] - public class V1AttributeVersionedEntity : CustomKubernetesEntity - { - } - - [KubernetesEntity( - ApiVersion = "v2", - Kind = "AttributeVersionedEntity", - Group = "kubeops.test.dev", - PluralName = "attributeversionedentities")] - public class V2AttributeVersionedEntity : CustomKubernetesEntity - { - } - - [KubernetesEntity( - ApiVersion = "v1337", - Kind = "Kind", - Group = "Group", - PluralName = "Plural")] - public class Entity : CustomKubernetesEntity - { - } - - [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] - public class EntityWithStatus : CustomKubernetesEntity - { - public class EntitySpec - { - } - - public class EntityStatus - { - } - } - - [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] - [KubernetesEntityShortNames("foo", "bar", "baz")] - public class ShortnamesEntity : CustomKubernetesEntity - { - } - - [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] - public class DescriptionAttrEntity : CustomKubernetesEntity - { - [Description("Description")] - public string Property { get; set; } = null!; - } - - [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] - public class ExtDocsAttrEntity : CustomKubernetesEntity - { - [ExternalDocs("url")] - public string Property { get; set; } = null!; - } - - [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] - public class ExtDocsWithDescriptionAttrEntity : CustomKubernetesEntity - { - [ExternalDocs("url", "description")] - public string Property { get; set; } = null!; - } - - [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] - public class ItemsAttrEntity : CustomKubernetesEntity - { - [Items(13, 42)] - public string[] Property { get; set; } = null!; - } - - [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] - public class LengthAttrEntity : CustomKubernetesEntity - { - [Length(2, 42)] - public string Property { get; set; } = null!; - } - - [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] - public class MultipleOfAttrEntity : CustomKubernetesEntity - { - [MultipleOf(2)] - public string Property { get; set; } = null!; - } - - [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] - public class PatternAttrEntity : CustomKubernetesEntity - { - [Pattern(@"/\d*/")] - public string Property { get; set; } = null!; - } - - [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] - public class RangeMinimumAttrEntity : CustomKubernetesEntity - { - [RangeMinimum(15, true)] - public string Property { get; set; } = null!; - } - - [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] - public class RangeMaximumAttrEntity : CustomKubernetesEntity - { - [RangeMaximum(15, true)] - public string Property { get; set; } = null!; - } - - [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] - public class RequiredAttrEntity : CustomKubernetesEntity - { - public class EntitySpec - { - [Required] - public string Property { get; set; } = null!; - } - } - - [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] - public class IgnoreAttrEntity : CustomKubernetesEntity - { - public class EntitySpec - { - [Ignore] - public string Property { get; set; } = null!; - } - } - - [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] - public class PreserveUnknownFieldsAttrEntity : CustomKubernetesEntity - { - [PreserveUnknownFields] - public string Property { get; set; } = null!; - } - - [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] - public class EmbeddedResourceAttrEntity : CustomKubernetesEntity - { - [EmbeddedResource] - public string Property { get; set; } = null!; - } - - [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] - public class AdditionalPrinterColumnAttrEntity : CustomKubernetesEntity - { - [AdditionalPrinterColumn] - public string Property { get; set; } = null!; - } - - [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] - public class AdditionalPrinterColumnWideAttrEntity : CustomKubernetesEntity - { - [AdditionalPrinterColumn(PrinterColumnPriority.WideView)] - public string Property { get; set; } = null!; - } - - [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] - public class AdditionalPrinterColumnNameAttrEntity : CustomKubernetesEntity - { - [AdditionalPrinterColumn(name: "OtherName")] - public string Property { get; set; } = null!; - } - - [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] - public class ClassDescriptionAttrEntity : CustomKubernetesEntity - { - [Description("Description")] - public class EntitySpec - { - } - } - - [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] - [GenericAdditionalPrinterColumn(".metadata.namespace", "Namespace", "string")] - public class GenericAdditionalPrinterColumnAttrEntity : CustomKubernetesEntity - { - public string Property { get; set; } = null!; - } - - [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] - [EntityScope(EntityScope.Cluster)] - public class ScopeAttrEntity : CustomKubernetesEntity - { - public string Property { get; set; } = null!; - } - - [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] - public class JsonPropNameAttrEntity : CustomKubernetesEntity - { - [JsonPropertyName("otherName")] - public string Property { get; set; } = null!; - } - - #endregion -} +using System.Collections; +using System.Collections.ObjectModel; +using System.Text.Json.Serialization; + +using FluentAssertions; + +using k8s.Models; + +using KubeOps.Abstractions.Entities; +using KubeOps.Abstractions.Entities.Attributes; + +namespace KubeOps.Transpiler.Test; + +public class CrdsTest : TranspilerTestBase +{ + public CrdsTest(MlcProvider provider) : base(provider) + { + } + + [Theory] + [InlineData(typeof(StringTestEntity), "string", null, false)] + [InlineData(typeof(NullableStringTestEntity), "string", null, true)] + [InlineData(typeof(IntTestEntity), "integer", "int32", false)] + [InlineData(typeof(NullableIntTestEntity), "integer", "int32", true)] + [InlineData(typeof(LongTestEntity), "integer", "int64", false)] + [InlineData(typeof(NullableLongTestEntity), "integer", "int64", true)] + [InlineData(typeof(FloatTestEntity), "number", "float", false)] + [InlineData(typeof(NullableFloatTestEntity), "number", "float", true)] + [InlineData(typeof(DoubleTestEntity), "number", "double", false)] + [InlineData(typeof(NullableDoubleTestEntity), "number", "double", true)] + [InlineData(typeof(BoolTestEntity), "boolean", null, false)] + [InlineData(typeof(NullableBoolTestEntity), "boolean", null, true)] + [InlineData(typeof(DateTimeTestEntity), "string", "date-time", false)] + [InlineData(typeof(NullableDateTimeTestEntity), "string", "date-time", true)] + [InlineData(typeof(V1ObjectMetaTestEntity), "object", null, false)] + [InlineData(typeof(StringArrayEntity), "array", null, false)] + [InlineData(typeof(NullableStringArrayEntity), "array", null, true)] + [InlineData(typeof(EnumerableIntEntity), "array", null, false)] + [InlineData(typeof(HashSetIntEntity), "array", null, false)] + [InlineData(typeof(SetIntEntity), "array", null, false)] + [InlineData(typeof(InheritedEnumerableEntity), "array", null, false)] + [InlineData(typeof(EnumEntity), "string", null, false)] + [InlineData(typeof(NullableEnumEntity), "string", null, true)] + [InlineData(typeof(DictionaryEntity), "object", null, false)] + [InlineData(typeof(EnumerableKeyPairsEntity), "object", null, false)] + [InlineData(typeof(IntstrOrStringEntity), null, null, false)] + [InlineData(typeof(EmbeddedResourceEntity), "object", null, false)] + [InlineData(typeof(EmbeddedResourceListEntity), "array", null, false)] + public void Should_Transpile_Entity_Type_Correctly(Type type, string? expectedType, string? expectedFormat, + bool isNullable) + { + var crd = _mlc.Transpile(type); + var prop = crd.Spec.Versions.First().Schema.OpenAPIV3Schema.Properties["property"]; + prop.Type.Should().Be(expectedType); + prop.Format.Should().Be(expectedFormat); + prop.Nullable.Should().Be(isNullable); + } + + [Theory] + [InlineData(typeof(StringArrayEntity), "string", false)] + [InlineData(typeof(NullableStringArrayEntity), "string", false)] + [InlineData(typeof(EnumerableIntEntity), "integer", false)] + [InlineData(typeof(EnumerableNullableIntEntity), "integer", true)] + [InlineData(typeof(HashSetIntEntity), "integer", false)] + [InlineData(typeof(SetIntEntity), "integer", false)] + [InlineData(typeof(InheritedEnumerableEntity), "integer", false)] + [InlineData(typeof(EmbeddedResourceListEntity), "object", false)] + public void Should_Set_Correct_Array_Type(Type type, string expectedType, bool isNullable) + { + var crd = _mlc.Transpile(type); + var prop = crd.Spec.Versions.First().Schema.OpenAPIV3Schema.Properties["property"].Items as V1JSONSchemaProps; + prop!.Type.Should().Be(expectedType); + prop.Nullable.Should().Be(isNullable); + } + + [Fact] + public void Should_Ignore_Entity() + { + var crds = _mlc.Transpile(new[] { typeof(IgnoredEntity) }); + crds.Count().Should().Be(0); + } + + [Fact] + public void Should_Ignore_NonEntity() + { + var crds = _mlc.Transpile(new[] { typeof(NonEntity) }); + crds.Count().Should().Be(0); + } + + [Fact] + public void Should_Ignore_Kubernetes_Entities() + { + var crds = _mlc.Transpile(new[] { typeof(V1Pod) }); + crds.Count().Should().Be(0); + } + + [Fact] + public void Should_Set_Highest_Version_As_Storage() + { + var crds = _mlc.Transpile(new[] + { + typeof(V1Alpha1VersionedEntity), typeof(V1Beta1VersionedEntity), typeof(V2Beta2VersionedEntity), + typeof(V2VersionedEntity), typeof(V1VersionedEntity), typeof(V1AttributeVersionedEntity), + typeof(V2AttributeVersionedEntity), + }); + var crd = crds.First(c => c.Spec.Names.Kind == "VersionedEntity"); + crd.Spec.Versions.Count(v => v.Storage).Should().Be(1); + crd.Spec.Versions.First(v => v.Storage).Name.Should().Be("v2"); + } + + [Fact] + public void Should_Set_Storage_When_Attribute_Is_Set() + { + var crds = _mlc.Transpile(new[] + { + typeof(V1Alpha1VersionedEntity), typeof(V1Beta1VersionedEntity), typeof(V2Beta2VersionedEntity), + typeof(V2VersionedEntity), typeof(V1VersionedEntity), typeof(V1AttributeVersionedEntity), + typeof(V2AttributeVersionedEntity), + }); + var crd = crds.First(c => c.Spec.Names.Kind == "AttributeVersionedEntity"); + crd.Spec.Versions.Count(v => v.Storage).Should().Be(1); + crd.Spec.Versions.First(v => v.Storage).Name.Should().Be("v1"); + } + + [Fact] + public void Should_Add_Multiple_Versions_To_Crd() + { + var crds = _mlc.Transpile(new[] + { + typeof(V1Alpha1VersionedEntity), typeof(V1Beta1VersionedEntity), typeof(V2Beta2VersionedEntity), + typeof(V2VersionedEntity), typeof(V1VersionedEntity), typeof(V1AttributeVersionedEntity), + typeof(V2AttributeVersionedEntity), + }).ToList(); + crds + .First(c => c.Spec.Names.Kind == "VersionedEntity") + .Spec.Versions.Should() + .HaveCount(5); + crds + .First(c => c.Spec.Names.Kind == "AttributeVersionedEntity") + .Spec.Versions.Should() + .HaveCount(2); + } + + [Fact] + public void Should_Use_Correct_CRD() + { + var crd = _mlc.Transpile(typeof(Entity)); + var (ced, scope) = _mlc.ToEntityMetadata(typeof(Entity)); + + crd.Kind.Should().Be(V1CustomResourceDefinition.KubeKind); + crd.Metadata.Name.Should().Be($"{ced.PluralName}.{ced.Group}"); + crd.Spec.Names.Kind.Should().Be(ced.Kind); + crd.Spec.Names.ListKind.Should().Be(ced.ListKind); + crd.Spec.Names.Singular.Should().Be(ced.SingularName); + crd.Spec.Names.Plural.Should().Be(ced.PluralName); + crd.Spec.Scope.Should().Be(scope); + } + + [Fact] + public void Should_Not_Add_Status_SubResource_If_Absent() + { + var crd = _mlc.Transpile(typeof(Entity)); + crd.Spec.Versions.First().Subresources?.Status?.Should().BeNull(); + } + + [Fact] + public void Should_Add_Status_SubResource_If_Present() + { + var crd = _mlc.Transpile(typeof(EntityWithStatus)); + crd.Spec.Versions.First().Subresources.Status.Should().NotBeNull(); + } + + [Fact] + public void Should_Add_ShortNames_To_Crd() + { + // TODO. + var crd = _mlc.Transpile(typeof(ShortnamesEntity)); + crd.Spec.Names.ShortNames.Should() + .NotBeNull() + .And + .Contain(new[] { "foo", "bar", "baz" }); + } + + [Fact] + public void Should_Set_Description_On_Class() + { + var crd = _mlc.Transpile(typeof(ClassDescriptionAttrEntity)); + + var specProperties = crd.Spec.Versions.First().Schema.OpenAPIV3Schema.Properties["spec"]; + specProperties.Description.Should().NotBe(""); + } + + [Fact] + public void Should_Set_Description() + { + var crd = _mlc.Transpile(typeof(DescriptionAttrEntity)); + + var specProperties = crd.Spec.Versions.First().Schema.OpenAPIV3Schema.Properties["property"]; + specProperties.Description.Should().NotBe(""); + } + + [Fact] + public void Should_Set_ExternalDocs() + { + var crd = _mlc.Transpile(typeof(ExtDocsAttrEntity)); + + var specProperties = crd.Spec.Versions.First().Schema.OpenAPIV3Schema.Properties["property"]; + specProperties.ExternalDocs.Url.Should().NotBe(""); + } + + [Fact] + public void Should_Set_ExternalDocs_Description() + { + var crd = _mlc.Transpile(typeof(ExtDocsWithDescriptionAttrEntity)); + + var specProperties = crd.Spec.Versions.First().Schema.OpenAPIV3Schema.Properties["property"]; + specProperties.ExternalDocs.Description.Should().NotBe(""); + } + + [Fact] + public void Should_Set_Items_Information() + { + var crd = _mlc.Transpile(typeof(ItemsAttrEntity)); + + var specProperties = crd.Spec.Versions.First().Schema.OpenAPIV3Schema.Properties["property"]; + + specProperties.Type.Should().Be("array"); + (specProperties.Items as V1JSONSchemaProps)?.Type?.Should().Be("string"); + specProperties.MaxItems.Should().Be(42); + specProperties.MinItems.Should().Be(13); + } + + [Fact] + public void Should_Set_Length_Information() + { + var crd = _mlc.Transpile(typeof(LengthAttrEntity)); + + var specProperties = crd.Spec.Versions.First().Schema.OpenAPIV3Schema.Properties["property"]; + + specProperties.MinLength.Should().Be(2); + specProperties.MaxLength.Should().Be(42); + } + + [Fact] + public void Should_Set_MultipleOf() + { + var crd = _mlc.Transpile(typeof(MultipleOfAttrEntity)); + + var specProperties = crd.Spec.Versions.First().Schema.OpenAPIV3Schema.Properties["property"]; + + specProperties.MultipleOf.Should().Be(2); + } + + [Fact] + public void Should_Set_Pattern() + { + var crd = _mlc.Transpile(typeof(PatternAttrEntity)); + + var specProperties = crd.Spec.Versions.First().Schema.OpenAPIV3Schema.Properties["property"]; + + specProperties.Pattern.Should().Be(@"/\d*/"); + } + + [Fact] + public void Should_Set_RangeMinimum() + { + var crd = _mlc.Transpile(typeof(RangeMinimumAttrEntity)); + + var specProperties = crd.Spec.Versions.First().Schema.OpenAPIV3Schema.Properties["property"]; + + specProperties.Minimum.Should().Be(15); + specProperties.ExclusiveMinimum.Should().BeTrue(); + } + + [Fact] + public void Should_Set_RangeMaximum() + { + var crd = _mlc.Transpile(typeof(RangeMaximumAttrEntity)); + + var specProperties = crd.Spec.Versions.First().Schema.OpenAPIV3Schema.Properties["property"]; + + specProperties.Maximum.Should().Be(15); + specProperties.ExclusiveMaximum.Should().BeTrue(); + } + + [Fact] + public void Should_Set_Required() + { + var crd = _mlc.Transpile(typeof(RequiredAttrEntity)); + + var specProperties = crd.Spec.Versions.First().Schema.OpenAPIV3Schema.Properties["spec"]; + specProperties.Required.Should().Contain("property"); + } + + [Fact] + public void Should_Not_Contain_Ignored_Property() + { + var crd = _mlc.Transpile(typeof(IgnoreAttrEntity)); + + var specProperties = crd.Spec.Versions.First().Schema.OpenAPIV3Schema.Properties["spec"]; + specProperties.Properties.Should().NotContainKey("property"); + } + + [Fact] + public void Should_Set_Preserve_Unknown_Fields() + { + var crd = _mlc.Transpile(typeof(PreserveUnknownFieldsAttrEntity)); + + var specProperties = crd.Spec.Versions.First().Schema.OpenAPIV3Schema.Properties["property"]; + specProperties.XKubernetesPreserveUnknownFields.Should().BeTrue(); + } + + [Fact] + public void Should_Set_EmbeddedResource_Fields() + { + var crd = _mlc.Transpile(typeof(EmbeddedResourceAttrEntity)); + + var specProperties = crd.Spec.Versions.First().Schema.OpenAPIV3Schema.Properties["property"]; + specProperties.XKubernetesEmbeddedResource.Should().BeTrue(); + } + + [Fact] + public void Should_Set_Preserve_Unknown_Fields_On_Dictionaries() + { + var crd = _mlc.Transpile(typeof(SimpleDictionaryEntity)); + + var specProperties = crd.Spec.Versions.First().Schema.OpenAPIV3Schema.Properties["property"]; + specProperties.XKubernetesPreserveUnknownFields.Should().BeTrue(); + } + + [Fact] + public void Should_Not_Set_Preserve_Unknown_Fields_On_Generic_Dictionaries() + { + var crd = _mlc.Transpile(typeof(DictionaryEntity)); + + var specProperties = crd.Spec.Versions.First().Schema.OpenAPIV3Schema.Properties["property"]; + specProperties.XKubernetesPreserveUnknownFields.Should().BeNull(); + } + + [Fact] + public void Should_Not_Set_Preserve_Unknown_Fields_On_KeyValuePair_Enumerable() + { + var crd = _mlc.Transpile(typeof(EnumerableKeyPairsEntity)); + + var specProperties = crd.Spec.Versions.First().Schema.OpenAPIV3Schema.Properties["property"]; + specProperties.XKubernetesPreserveUnknownFields.Should().BeNull(); + } + + [Fact] + public void Should_Not_Set_Properties_On_Dictionaries() + { + var crd = _mlc.Transpile(typeof(SimpleDictionaryEntity)); + + var specProperties = crd.Spec.Versions.First().Schema.OpenAPIV3Schema.Properties["property"]; + specProperties.Properties.Should().BeNull(); + } + + [Fact] + public void Should_Not_Set_Properties_On_Generic_Dictionaries() + { + var crd = _mlc.Transpile(typeof(DictionaryEntity)); + + var specProperties = crd.Spec.Versions.First().Schema.OpenAPIV3Schema.Properties["property"]; + specProperties.Properties.Should().BeNull(); + } + + [Fact] + public void Should_Not_Set_Properties_On_KeyValuePair_Enumerable() + { + var crd = _mlc.Transpile(typeof(EnumerableKeyPairsEntity)); + + var specProperties = crd.Spec.Versions.First().Schema.OpenAPIV3Schema.Properties["property"]; + specProperties.Properties.Should().BeNull(); + } + + [Fact] + public void Should_Set_AdditionalProperties_On_Dictionaries_For_Value_type() + { + var crd = _mlc.Transpile(typeof(DictionaryEntity)); + + var specProperties = crd.Spec.Versions.First().Schema.OpenAPIV3Schema.Properties["property"]; + specProperties.AdditionalProperties.Should().NotBeNull(); + } + + [Fact] + public void Should_Set_AdditionalProperties_On_KeyValuePair_For_Value_type() + { + var crd = _mlc.Transpile(typeof(EnumerableKeyPairsEntity)); + + var specProperties = crd.Spec.Versions.First().Schema.OpenAPIV3Schema.Properties["property"]; + specProperties.AdditionalProperties.Should().NotBeNull(); + } + + [Fact] + public void Should_Set_IntOrString() + { + var crd = _mlc.Transpile(typeof(IntstrOrStringEntity)); + + var specProperties = crd.Spec.Versions.First().Schema.OpenAPIV3Schema.Properties["property"]; + specProperties.Properties.Should().BeNull(); + specProperties.XKubernetesIntOrString.Should().BeTrue(); + } + + [Fact] + public void Should_Use_PropertyName_From_JsonPropertyAttribute() + { + var crd = _mlc.Transpile(typeof(JsonPropNameAttrEntity)); + + var specProperties = crd.Spec.Versions.First().Schema.OpenAPIV3Schema.Properties; + specProperties.Should().Contain(p => p.Key == "otherName"); + } + + [Fact] + public void Must_Not_Contain_Ignored_TopLevel_Properties() + { + var crd = _mlc.Transpile(typeof(Entity)); + + var specProperties = crd.Spec.Versions.First().Schema.OpenAPIV3Schema.Properties; + specProperties.Should().NotContainKeys("metadata", "apiVersion", "kind"); + } + + [Fact] + public void Should_Add_AdditionalPrinterColumns() + { + var crd = _mlc.Transpile(typeof(AdditionalPrinterColumnAttrEntity)); + var apc = crd.Spec.Versions.First().AdditionalPrinterColumns; + apc.Should().ContainSingle(def => def.JsonPath == ".property"); + } + + [Fact] + public void Should_Add_AdditionalPrinterColumns_With_Prio() + { + var crd = _mlc.Transpile(typeof(AdditionalPrinterColumnWideAttrEntity)); + var apc = crd.Spec.Versions.First().AdditionalPrinterColumns; + apc.Should().ContainSingle(def => def.JsonPath == ".property" && def.Priority == 1); + } + + [Fact] + public void Should_Add_AdditionalPrinterColumns_With_Name() + { + var crd = _mlc.Transpile(typeof(AdditionalPrinterColumnNameAttrEntity)); + var apc = crd.Spec.Versions.First().AdditionalPrinterColumns; + apc.Should().ContainSingle(def => def.JsonPath == ".property" && def.Name == "OtherName"); + } + + [Fact] + public void Should_Add_GenericAdditionalPrinterColumns() + { + var crd = _mlc.Transpile(typeof(GenericAdditionalPrinterColumnAttrEntity)); + var apc = crd.Spec.Versions.First().AdditionalPrinterColumns; + + apc.Should().NotBeNull(); + apc.Should().ContainSingle(def => def.JsonPath == ".metadata.namespace" && def.Name == "Namespace"); + } + + [Fact] + public void Should_Correctly_Use_Entity_Scope_Attribute() + { + var scopedCrd = _mlc.Transpile(typeof(Entity)); + var clusterCrd = _mlc.Transpile(typeof(ScopeAttrEntity)); + + scopedCrd.Spec.Scope.Should().Be("Namespaced"); + clusterCrd.Spec.Scope.Should().Be("Cluster"); + } + + #region Test Entity Classes + + [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] + private class StringTestEntity : CustomKubernetesEntity + { + public string Property { get; set; } = string.Empty; + } + + [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] + private class NullableStringTestEntity : CustomKubernetesEntity + { + public string? Property { get; set; } = string.Empty; + } + + [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] + private class IntTestEntity : CustomKubernetesEntity + { + public int Property { get; set; } + } + + [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] + private class NullableIntTestEntity : CustomKubernetesEntity + { + public int? Property { get; set; } + } + + [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] + private class LongTestEntity : CustomKubernetesEntity + { + public long Property { get; set; } + } + + [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] + private class NullableLongTestEntity : CustomKubernetesEntity + { + public long? Property { get; set; } + } + + [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] + private class FloatTestEntity : CustomKubernetesEntity + { + public float Property { get; set; } + } + + [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] + private class NullableFloatTestEntity : CustomKubernetesEntity + { + public float? Property { get; set; } + } + + [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] + private class DoubleTestEntity : CustomKubernetesEntity + { + public double Property { get; set; } + } + + [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] + private class NullableDoubleTestEntity : CustomKubernetesEntity + { + public double? Property { get; set; } + } + + [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] + private class BoolTestEntity : CustomKubernetesEntity + { + public bool Property { get; set; } + } + + [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] + private class NullableBoolTestEntity : CustomKubernetesEntity + { + public bool? Property { get; set; } + } + + [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] + private class DateTimeTestEntity : CustomKubernetesEntity + { + public DateTime Property { get; set; } + } + + [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] + private class NullableDateTimeTestEntity : CustomKubernetesEntity + { + public DateTime? Property { get; set; } + } + + [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] + private class V1ObjectMetaTestEntity : CustomKubernetesEntity + { + public V1ObjectMeta Property { get; set; } = null!; + } + + [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] + private class StringArrayEntity : CustomKubernetesEntity + { + public string[] Property { get; set; } = null!; + } + + [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] + private class NullableStringArrayEntity : CustomKubernetesEntity + { + public string[]? Property { get; set; } = null!; + } + + [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] + private class EnumerableNullableIntEntity : CustomKubernetesEntity + { + public IEnumerable Property { get; set; } = null!; + } + + [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] + private class EnumerableIntEntity : CustomKubernetesEntity + { + public IEnumerable Property { get; set; } = null!; + } + + [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] + private class HashSetIntEntity : CustomKubernetesEntity + { + public HashSet Property { get; set; } = null!; + } + + [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] + private class SetIntEntity : CustomKubernetesEntity + { + public ISet Property { get; set; } = null!; + } + + [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] + private class InheritedEnumerableEntity : CustomKubernetesEntity + { + public IntegerList Property { get; set; } = null!; + + public class IntegerList : Collection + { + } + } + + [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] + private class EnumEntity : CustomKubernetesEntity + { + public TestSpecEnum Property { get; set; } + + public enum TestSpecEnum + { + Value1, + Value2, + } + } + + [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] + private class NullableEnumEntity : CustomKubernetesEntity + { + public TestSpecEnum? Property { get; set; } + + public enum TestSpecEnum + { + Value1, + Value2, + } + } + + [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] + private class SimpleDictionaryEntity : CustomKubernetesEntity + { + public IDictionary Property { get; set; } = null!; + } + + [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] + private class DictionaryEntity : CustomKubernetesEntity + { + public IDictionary Property { get; set; } = null!; + } + + [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] + private class EnumerableKeyPairsEntity : CustomKubernetesEntity + { + public IEnumerable> Property { get; set; } = null!; + } + + [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] + private class IntstrOrStringEntity : CustomKubernetesEntity + { + public IntstrIntOrString Property { get; set; } = null!; + } + + [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] + private class EmbeddedResourceEntity : CustomKubernetesEntity + { + public V1Pod Property { get; set; } = null!; + } + + [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] + private class EmbeddedResourceListEntity : CustomKubernetesEntity + { + public IList Property { get; set; } = null!; + } + + [Ignore] + [KubernetesEntity] + private class IgnoredEntity : CustomKubernetesEntity + { + } + + public class NonEntity + { + } + + [KubernetesEntity( + ApiVersion = "v1alpha1", + Kind = "VersionedEntity", + Group = "kubeops.test.dev", + PluralName = "versionedentities")] + public class V1Alpha1VersionedEntity : CustomKubernetesEntity + { + } + + [KubernetesEntity( + ApiVersion = "v1beta1", + Kind = "VersionedEntity", + Group = "kubeops.test.dev", + PluralName = "versionedentities")] + public class V1Beta1VersionedEntity : CustomKubernetesEntity + { + } + + [KubernetesEntity( + ApiVersion = "v2beta2", + Kind = "VersionedEntity", + Group = "kubeops.test.dev", + PluralName = "versionedentities")] + public class V2Beta2VersionedEntity : CustomKubernetesEntity + { + } + + [KubernetesEntity( + ApiVersion = "v2", + Kind = "VersionedEntity", + Group = "kubeops.test.dev", + PluralName = "versionedentities")] + public class V2VersionedEntity : CustomKubernetesEntity + { + } + + [KubernetesEntity( + ApiVersion = "v1", + Kind = "VersionedEntity", + Group = "kubeops.test.dev", + PluralName = "versionedentities")] + public class V1VersionedEntity : CustomKubernetesEntity + { + } + + [KubernetesEntity( + ApiVersion = "v1", + Kind = "AttributeVersionedEntity", + Group = "kubeops.test.dev", + PluralName = "attributeversionedentities")] + [StorageVersion] + public class V1AttributeVersionedEntity : CustomKubernetesEntity + { + } + + [KubernetesEntity( + ApiVersion = "v2", + Kind = "AttributeVersionedEntity", + Group = "kubeops.test.dev", + PluralName = "attributeversionedentities")] + public class V2AttributeVersionedEntity : CustomKubernetesEntity + { + } + + [KubernetesEntity( + ApiVersion = "v1337", + Kind = "Kind", + Group = "Group", + PluralName = "Plural")] + public class Entity : CustomKubernetesEntity + { + } + + [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] + public class EntityWithStatus : CustomKubernetesEntity + { + public class EntitySpec + { + } + + public class EntityStatus + { + } + } + + [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] + [KubernetesEntityShortNames("foo", "bar", "baz")] + public class ShortnamesEntity : CustomKubernetesEntity + { + } + + [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] + public class DescriptionAttrEntity : CustomKubernetesEntity + { + [Description("Description")] + public string Property { get; set; } = null!; + } + + [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] + public class ExtDocsAttrEntity : CustomKubernetesEntity + { + [ExternalDocs("url")] + public string Property { get; set; } = null!; + } + + [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] + public class ExtDocsWithDescriptionAttrEntity : CustomKubernetesEntity + { + [ExternalDocs("url", "description")] + public string Property { get; set; } = null!; + } + + [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] + public class ItemsAttrEntity : CustomKubernetesEntity + { + [Items(13, 42)] + public string[] Property { get; set; } = null!; + } + + [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] + public class LengthAttrEntity : CustomKubernetesEntity + { + [Length(2, 42)] + public string Property { get; set; } = null!; + } + + [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] + public class MultipleOfAttrEntity : CustomKubernetesEntity + { + [MultipleOf(2)] + public string Property { get; set; } = null!; + } + + [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] + public class PatternAttrEntity : CustomKubernetesEntity + { + [Pattern(@"/\d*/")] + public string Property { get; set; } = null!; + } + + [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] + public class RangeMinimumAttrEntity : CustomKubernetesEntity + { + [RangeMinimum(15, true)] + public string Property { get; set; } = null!; + } + + [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] + public class RangeMaximumAttrEntity : CustomKubernetesEntity + { + [RangeMaximum(15, true)] + public string Property { get; set; } = null!; + } + + [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] + public class RequiredAttrEntity : CustomKubernetesEntity + { + public class EntitySpec + { + [Required] + public string Property { get; set; } = null!; + } + } + + [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] + public class IgnoreAttrEntity : CustomKubernetesEntity + { + public class EntitySpec + { + [Ignore] + public string Property { get; set; } = null!; + } + } + + [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] + public class PreserveUnknownFieldsAttrEntity : CustomKubernetesEntity + { + [PreserveUnknownFields] + public string Property { get; set; } = null!; + } + + [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] + public class EmbeddedResourceAttrEntity : CustomKubernetesEntity + { + [EmbeddedResource] + public string Property { get; set; } = null!; + } + + [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] + public class AdditionalPrinterColumnAttrEntity : CustomKubernetesEntity + { + [AdditionalPrinterColumn] + public string Property { get; set; } = null!; + } + + [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] + public class AdditionalPrinterColumnWideAttrEntity : CustomKubernetesEntity + { + [AdditionalPrinterColumn(PrinterColumnPriority.WideView)] + public string Property { get; set; } = null!; + } + + [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] + public class AdditionalPrinterColumnNameAttrEntity : CustomKubernetesEntity + { + [AdditionalPrinterColumn(name: "OtherName")] + public string Property { get; set; } = null!; + } + + [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] + public class ClassDescriptionAttrEntity : CustomKubernetesEntity + { + [Description("Description")] + public class EntitySpec + { + } + } + + [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] + [GenericAdditionalPrinterColumn(".metadata.namespace", "Namespace", "string")] + public class GenericAdditionalPrinterColumnAttrEntity : CustomKubernetesEntity + { + public string Property { get; set; } = null!; + } + + [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] + [EntityScope(EntityScope.Cluster)] + public class ScopeAttrEntity : CustomKubernetesEntity + { + public string Property { get; set; } = null!; + } + + [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] + public class JsonPropNameAttrEntity : CustomKubernetesEntity + { + [JsonPropertyName("otherName")] + public string Property { get; set; } = null!; + } + + #endregion +} diff --git a/test/KubeOps.Transpiler.Test/GlobalUsings.cs b/test/KubeOps.Transpiler.Test/GlobalUsings.cs index c802f448..e1065597 100644 --- a/test/KubeOps.Transpiler.Test/GlobalUsings.cs +++ b/test/KubeOps.Transpiler.Test/GlobalUsings.cs @@ -1 +1 @@ -global using Xunit; +global using Xunit; diff --git a/test/KubeOps.Transpiler.Test/IntegrationTestCollection.cs b/test/KubeOps.Transpiler.Test/IntegrationTestCollection.cs index 746cfbac..7b91128c 100644 --- a/test/KubeOps.Transpiler.Test/IntegrationTestCollection.cs +++ b/test/KubeOps.Transpiler.Test/IntegrationTestCollection.cs @@ -1,20 +1,20 @@ -using System.Reflection; - -namespace KubeOps.Transpiler.Test; - -[CollectionDefinition(Name, DisableParallelization = true)] -public class TranspilerTestCollection : ICollectionFixture -{ - public const string Name = "Transpiler Tests"; -} - -[Collection(TranspilerTestCollection.Name)] -public abstract class TranspilerTestBase -{ - protected readonly MetadataLoadContext _mlc; - - protected TranspilerTestBase(MlcProvider provider) - { - _mlc = provider.Mlc; - } -} +using System.Reflection; + +namespace KubeOps.Transpiler.Test; + +[CollectionDefinition(Name, DisableParallelization = true)] +public class TranspilerTestCollection : ICollectionFixture +{ + public const string Name = "Transpiler Tests"; +} + +[Collection(TranspilerTestCollection.Name)] +public abstract class TranspilerTestBase +{ + protected readonly MetadataLoadContext _mlc; + + protected TranspilerTestBase(MlcProvider provider) + { + _mlc = provider.Mlc; + } +} diff --git a/test/KubeOps.Transpiler.Test/MlcProvider.cs b/test/KubeOps.Transpiler.Test/MlcProvider.cs index cdee1aa2..7d29eac8 100644 --- a/test/KubeOps.Transpiler.Test/MlcProvider.cs +++ b/test/KubeOps.Transpiler.Test/MlcProvider.cs @@ -1,44 +1,44 @@ -using System.Reflection; -using System.Runtime.InteropServices; - -using Microsoft.Build.Locator; -using Microsoft.CodeAnalysis.MSBuild; - -namespace KubeOps.Transpiler.Test; - -public class MlcProvider : IAsyncLifetime -{ - static MlcProvider() - { - MSBuildLocator.RegisterDefaults(); - } - - public MetadataLoadContext Mlc { get; private set; } = null!; - - public async Task InitializeAsync() - { - var assemblyConfigurationAttribute = - typeof(MlcProvider).Assembly.GetCustomAttribute(); - var buildConfigurationName = assemblyConfigurationAttribute?.Configuration ?? "Debug"; - - using var workspace = MSBuildWorkspace.Create(new Dictionary - { - { "Configuration", buildConfigurationName }, - }); - - workspace.SkipUnrecognizedProjects = true; - workspace.LoadMetadataForReferencedProjects = true; - var project = await workspace.OpenProjectAsync("../../../KubeOps.Transpiler.Test.csproj"); - - Mlc = ContextCreator.Create(Directory - .GetFiles(RuntimeEnvironment.GetRuntimeDirectory(), "*.dll") - .Concat(Directory.GetFiles(Path.GetDirectoryName(project.OutputFilePath)!, "*.dll")) - .Distinct(), coreAssemblyName: typeof(object).Assembly.GetName().Name); - } - - public Task DisposeAsync() - { - Mlc.Dispose(); - return Task.CompletedTask; - } -} +using System.Reflection; +using System.Runtime.InteropServices; + +using Microsoft.Build.Locator; +using Microsoft.CodeAnalysis.MSBuild; + +namespace KubeOps.Transpiler.Test; + +public class MlcProvider : IAsyncLifetime +{ + static MlcProvider() + { + MSBuildLocator.RegisterDefaults(); + } + + public MetadataLoadContext Mlc { get; private set; } = null!; + + public async Task InitializeAsync() + { + var assemblyConfigurationAttribute = + typeof(MlcProvider).Assembly.GetCustomAttribute(); + var buildConfigurationName = assemblyConfigurationAttribute?.Configuration ?? "Debug"; + + using var workspace = MSBuildWorkspace.Create(new Dictionary + { + { "Configuration", buildConfigurationName }, + }); + + workspace.SkipUnrecognizedProjects = true; + workspace.LoadMetadataForReferencedProjects = true; + var project = await workspace.OpenProjectAsync("../../../KubeOps.Transpiler.Test.csproj"); + + Mlc = ContextCreator.Create(Directory + .GetFiles(RuntimeEnvironment.GetRuntimeDirectory(), "*.dll") + .Concat(Directory.GetFiles(Path.GetDirectoryName(project.OutputFilePath)!, "*.dll")) + .Distinct(), coreAssemblyName: typeof(object).Assembly.GetName().Name); + } + + public Task DisposeAsync() + { + Mlc.Dispose(); + return Task.CompletedTask; + } +} diff --git a/test/KubeOps.Transpiler.Test/Rbac.Test.cs b/test/KubeOps.Transpiler.Test/Rbac.Test.cs index 01c517cf..5241d189 100644 --- a/test/KubeOps.Transpiler.Test/Rbac.Test.cs +++ b/test/KubeOps.Transpiler.Test/Rbac.Test.cs @@ -1,122 +1,122 @@ -using FluentAssertions; - -using k8s.Models; - -using KubeOps.Abstractions.Entities; -using KubeOps.Abstractions.Rbac; - -namespace KubeOps.Transpiler.Test; - -public class RbacTest : TranspilerTestBase -{ - public RbacTest(MlcProvider provider) : base(provider) - { - } - - [Fact] - public void Should_Create_Generic_Policy() - { - var role = _mlc - .Transpile(_mlc.GetContextType().GetCustomAttributesData()).ToList() - .First(); - role.ApiGroups.Should().Contain("group"); - role.Resources.Should().Contain("configmaps"); - role.NonResourceURLs.Should().Contain("url"); - role.NonResourceURLs.Should().Contain("foobar"); - role.Verbs.Should().Contain(new[] { "get", "delete" }); - } - - [Fact] - public void Should_Calculate_Max_Verbs_For_Types() - { - var role = _mlc - .Transpile(_mlc.GetContextType().GetCustomAttributesData()).ToList() - .First(); - role.Resources.Should().Contain("rbactest1s"); - role.Verbs.Should().Contain(new[] { "get", "update", "delete" }); - } - - [Fact] - public void Should_Correctly_Calculate_All_Verb() - { - var role = _mlc - .Transpile(_mlc.GetContextType().GetCustomAttributesData()).ToList() - .First(); - role.Resources.Should().Contain("rbactest2s"); - role.Verbs.Should().Contain("*").And.HaveCount(1); - } - - [Fact] - public void Should_Group_Same_Types_Together() - { - var roles = _mlc - .Transpile(_mlc.GetContextType().GetCustomAttributesData()).ToList(); - roles.Should() - .Contain( - rule => rule.Resources.Contains("rbactest1s")); - roles.Should() - .Contain( - rule => rule.Resources.Contains("rbactest2s")); - roles.Should().HaveCount(2); - } - - [Fact] - public void Should_Group_Types_With_Same_Verbs_Together() - { - var roles = _mlc - .Transpile(_mlc.GetContextType().GetCustomAttributesData()).ToList(); - roles.Should() - .Contain( - rule => rule.Resources.Contains("rbactest1s") && - rule.Resources.Contains("rbactest4s") && - rule.Verbs.Contains("get") && - rule.Verbs.Contains("update")); - roles.Should() - .Contain( - rule => rule.Resources.Contains("rbactest2s") && - rule.Resources.Contains("rbactest3s") && - rule.Verbs.Contains("delete")); - roles.Should().HaveCount(2); - } - - [KubernetesEntity(Group = "test", ApiVersion = "v1")] - [EntityRbac(typeof(RbacTest1), Verbs = RbacVerb.Get)] - [EntityRbac(typeof(RbacTest1), Verbs = RbacVerb.Update)] - [EntityRbac(typeof(RbacTest1), Verbs = RbacVerb.Delete)] - public class RbacTest1 : CustomKubernetesEntity - { - } - - [KubernetesEntity(Group = "test", ApiVersion = "v1")] - [EntityRbac(typeof(RbacTest2), Verbs = RbacVerb.All)] - [EntityRbac(typeof(RbacTest2), Verbs = RbacVerb.Delete)] - public class RbacTest2 : CustomKubernetesEntity - { - } - - [KubernetesEntity(Group = "test", ApiVersion = "v1")] - [EntityRbac(typeof(RbacTest1), Verbs = RbacVerb.Get)] - [EntityRbac(typeof(RbacTest1), Verbs = RbacVerb.Update)] - [EntityRbac(typeof(RbacTest2), Verbs = RbacVerb.Delete)] - public class RbacTest3 : CustomKubernetesEntity - { - } - - [KubernetesEntity(Group = "test", ApiVersion = "v1")] - [EntityRbac(typeof(RbacTest1), Verbs = RbacVerb.Get)] - [EntityRbac(typeof(RbacTest1), Verbs = RbacVerb.Update)] - [EntityRbac(typeof(RbacTest2), Verbs = RbacVerb.Delete)] - [EntityRbac(typeof(RbacTest2), Verbs = RbacVerb.Delete)] - [EntityRbac(typeof(RbacTest3), Verbs = RbacVerb.Delete)] - [EntityRbac(typeof(RbacTest4), Verbs = RbacVerb.Get | RbacVerb.Update)] - public class RbacTest4 : CustomKubernetesEntity - { - } - - [KubernetesEntity(Group = "test", ApiVersion = "v1")] - [GenericRbac(Urls = new[] { "url", "foobar" }, Resources = new[] { "configmaps" }, Groups = new[] { "group" }, - Verbs = RbacVerb.Delete | RbacVerb.Get)] - public class GenericRbacTest : CustomKubernetesEntity - { - } -} +using FluentAssertions; + +using k8s.Models; + +using KubeOps.Abstractions.Entities; +using KubeOps.Abstractions.Rbac; + +namespace KubeOps.Transpiler.Test; + +public class RbacTest : TranspilerTestBase +{ + public RbacTest(MlcProvider provider) : base(provider) + { + } + + [Fact] + public void Should_Create_Generic_Policy() + { + var role = _mlc + .Transpile(_mlc.GetContextType().GetCustomAttributesData()).ToList() + .First(); + role.ApiGroups.Should().Contain("group"); + role.Resources.Should().Contain("configmaps"); + role.NonResourceURLs.Should().Contain("url"); + role.NonResourceURLs.Should().Contain("foobar"); + role.Verbs.Should().Contain(new[] { "get", "delete" }); + } + + [Fact] + public void Should_Calculate_Max_Verbs_For_Types() + { + var role = _mlc + .Transpile(_mlc.GetContextType().GetCustomAttributesData()).ToList() + .First(); + role.Resources.Should().Contain("rbactest1s"); + role.Verbs.Should().Contain(new[] { "get", "update", "delete" }); + } + + [Fact] + public void Should_Correctly_Calculate_All_Verb() + { + var role = _mlc + .Transpile(_mlc.GetContextType().GetCustomAttributesData()).ToList() + .First(); + role.Resources.Should().Contain("rbactest2s"); + role.Verbs.Should().Contain("*").And.HaveCount(1); + } + + [Fact] + public void Should_Group_Same_Types_Together() + { + var roles = _mlc + .Transpile(_mlc.GetContextType().GetCustomAttributesData()).ToList(); + roles.Should() + .Contain( + rule => rule.Resources.Contains("rbactest1s")); + roles.Should() + .Contain( + rule => rule.Resources.Contains("rbactest2s")); + roles.Should().HaveCount(2); + } + + [Fact] + public void Should_Group_Types_With_Same_Verbs_Together() + { + var roles = _mlc + .Transpile(_mlc.GetContextType().GetCustomAttributesData()).ToList(); + roles.Should() + .Contain( + rule => rule.Resources.Contains("rbactest1s") && + rule.Resources.Contains("rbactest4s") && + rule.Verbs.Contains("get") && + rule.Verbs.Contains("update")); + roles.Should() + .Contain( + rule => rule.Resources.Contains("rbactest2s") && + rule.Resources.Contains("rbactest3s") && + rule.Verbs.Contains("delete")); + roles.Should().HaveCount(2); + } + + [KubernetesEntity(Group = "test", ApiVersion = "v1")] + [EntityRbac(typeof(RbacTest1), Verbs = RbacVerb.Get)] + [EntityRbac(typeof(RbacTest1), Verbs = RbacVerb.Update)] + [EntityRbac(typeof(RbacTest1), Verbs = RbacVerb.Delete)] + public class RbacTest1 : CustomKubernetesEntity + { + } + + [KubernetesEntity(Group = "test", ApiVersion = "v1")] + [EntityRbac(typeof(RbacTest2), Verbs = RbacVerb.All)] + [EntityRbac(typeof(RbacTest2), Verbs = RbacVerb.Delete)] + public class RbacTest2 : CustomKubernetesEntity + { + } + + [KubernetesEntity(Group = "test", ApiVersion = "v1")] + [EntityRbac(typeof(RbacTest1), Verbs = RbacVerb.Get)] + [EntityRbac(typeof(RbacTest1), Verbs = RbacVerb.Update)] + [EntityRbac(typeof(RbacTest2), Verbs = RbacVerb.Delete)] + public class RbacTest3 : CustomKubernetesEntity + { + } + + [KubernetesEntity(Group = "test", ApiVersion = "v1")] + [EntityRbac(typeof(RbacTest1), Verbs = RbacVerb.Get)] + [EntityRbac(typeof(RbacTest1), Verbs = RbacVerb.Update)] + [EntityRbac(typeof(RbacTest2), Verbs = RbacVerb.Delete)] + [EntityRbac(typeof(RbacTest2), Verbs = RbacVerb.Delete)] + [EntityRbac(typeof(RbacTest3), Verbs = RbacVerb.Delete)] + [EntityRbac(typeof(RbacTest4), Verbs = RbacVerb.Get | RbacVerb.Update)] + public class RbacTest4 : CustomKubernetesEntity + { + } + + [KubernetesEntity(Group = "test", ApiVersion = "v1")] + [GenericRbac(Urls = new[] { "url", "foobar" }, Resources = new[] { "configmaps" }, Groups = new[] { "group" }, + Verbs = RbacVerb.Delete | RbacVerb.Get)] + public class GenericRbacTest : CustomKubernetesEntity + { + } +}