too much for one commit

This commit is contained in:
Nils Blomgren
2026-05-13 14:01:32 +02:00
parent a96dd33039
commit 328d494530
394 changed files with 98104 additions and 78 deletions

View File

@@ -0,0 +1,634 @@
using EntKube.Clusters.Domain;
using EntKube.Clusters.Features.AdoptCluster;
using FluentAssertions;
namespace EntKube.Clusters.Tests.Features;
/// <summary>
/// Tests for the component configuration schema system. Schemas define what
/// parameters each component accepts, their types, validation constraints,
/// and how they're grouped in the UI. This enables the frontend to render
/// proper typed forms instead of raw key/value editors.
/// </summary>
public class ConfigurationSchemaTests
{
[Fact]
public void ConfigurationParameter_WithRequiredString_ValidatesPresence()
{
// Arrange — A required parameter with no value provided.
ConfigurationParameter param = new(
Key: "email",
DisplayName: "Email Address",
Description: "The email registered with the certificate authority",
Type: ParameterType.String,
Group: "General",
IsRequired: true);
// Act
ParameterValidationResult result = param.Validate(null);
// Assert
result.IsValid.Should().BeFalse();
result.Error.Should().Contain("required");
}
[Fact]
public void ConfigurationParameter_WithRequiredString_PassesWhenProvided()
{
// Arrange
ConfigurationParameter param = new(
Key: "email",
DisplayName: "Email Address",
Description: "The email registered with the certificate authority",
Type: ParameterType.String,
Group: "General",
IsRequired: true);
// Act
ParameterValidationResult result = param.Validate("admin@example.com");
// Assert
result.IsValid.Should().BeTrue();
}
[Fact]
public void ConfigurationParameter_WithIntegerType_RejectsNonNumeric()
{
// Arrange — An integer parameter with a non-numeric value.
ConfigurationParameter param = new(
Key: "replicas",
DisplayName: "Replicas",
Description: "Number of pod replicas",
Type: ParameterType.Integer,
Group: "Scaling",
IsRequired: true,
Min: 1,
Max: 10);
// Act
ParameterValidationResult result = param.Validate("not-a-number");
// Assert
result.IsValid.Should().BeFalse();
result.Error.Should().Contain("integer");
}
[Fact]
public void ConfigurationParameter_WithIntegerType_RejectsOutOfRange()
{
// Arrange — An integer outside the allowed range.
ConfigurationParameter param = new(
Key: "replicas",
DisplayName: "Replicas",
Description: "Number of pod replicas",
Type: ParameterType.Integer,
Group: "Scaling",
IsRequired: true,
Min: 1,
Max: 10);
// Act
ParameterValidationResult result = param.Validate("15");
// Assert
result.IsValid.Should().BeFalse();
result.Error.Should().Contain("10");
}
[Fact]
public void ConfigurationParameter_WithIntegerType_AcceptsValidValue()
{
// Arrange
ConfigurationParameter param = new(
Key: "replicas",
DisplayName: "Replicas",
Description: "Number of pod replicas",
Type: ParameterType.Integer,
Group: "Scaling",
IsRequired: true,
Min: 1,
Max: 10);
// Act
ParameterValidationResult result = param.Validate("3");
// Assert
result.IsValid.Should().BeTrue();
}
[Fact]
public void ConfigurationParameter_WithBooleanType_RejectsInvalidValues()
{
// Arrange — A boolean parameter with a non-boolean value.
ConfigurationParameter param = new(
Key: "grafanaEnabled",
DisplayName: "Enable Grafana",
Description: "Whether to deploy Grafana alongside Prometheus",
Type: ParameterType.Boolean,
Group: "Features");
// Act
ParameterValidationResult result = param.Validate("maybe");
// Assert
result.IsValid.Should().BeFalse();
result.Error.Should().Contain("true");
}
[Fact]
public void ConfigurationParameter_WithBooleanType_AcceptsTrueAndFalse()
{
// Arrange
ConfigurationParameter param = new(
Key: "grafanaEnabled",
DisplayName: "Enable Grafana",
Description: "Whether to deploy Grafana alongside Prometheus",
Type: ParameterType.Boolean,
Group: "Features");
// Act & Assert
param.Validate("true").IsValid.Should().BeTrue();
param.Validate("false").IsValid.Should().BeTrue();
}
[Fact]
public void ConfigurationParameter_WithSelectType_RejectsUnknownOption()
{
// Arrange — A select parameter with allowed values.
ConfigurationParameter param = new(
Key: "provider",
DisplayName: "Ingress Provider",
Description: "Which ingress controller to use",
Type: ParameterType.Select,
Group: "General",
IsRequired: true,
AllowedValues: new List<string> { "traefik", "istio" });
// Act
ParameterValidationResult result = param.Validate("nginx");
// Assert
result.IsValid.Should().BeFalse();
result.Error.Should().Contain("traefik");
}
[Fact]
public void ConfigurationParameter_WithSelectType_AcceptsAllowedValue()
{
// Arrange
ConfigurationParameter param = new(
Key: "provider",
DisplayName: "Ingress Provider",
Description: "Which ingress controller to use",
Type: ParameterType.Select,
Group: "General",
IsRequired: true,
AllowedValues: new List<string> { "traefik", "istio" });
// Act
ParameterValidationResult result = param.Validate("istio");
// Assert
result.IsValid.Should().BeTrue();
}
[Fact]
public void ConfigurationParameter_Optional_AcceptsNullOrEmpty()
{
// Arrange — An optional parameter with no value is valid.
ConfigurationParameter param = new(
Key: "storageSize",
DisplayName: "Storage Size",
Description: "PVC storage size",
Type: ParameterType.String,
Group: "Storage",
IsRequired: false,
DefaultValue: "50Gi");
// Act & Assert
param.Validate(null).IsValid.Should().BeTrue();
param.Validate("").IsValid.Should().BeTrue();
}
[Fact]
public void ConfigurationSchema_ValidateAll_ReturnsAllErrors()
{
// Arrange — A schema with multiple parameters, some invalid.
ConfigurationSchema schema = new(
ComponentName: "monitoring",
DisplayName: "Monitoring (kube-prometheus-stack)",
Description: "Prometheus, Grafana, and Alertmanager stack",
Parameters: new List<ConfigurationParameter>
{
new(Key: "retention", DisplayName: "Retention", Description: "Data retention period",
Type: ParameterType.String, Group: "Storage", IsRequired: true),
new(Key: "replicas", DisplayName: "Prometheus Replicas", Description: "Number of Prometheus replicas",
Type: ParameterType.Integer, Group: "Scaling", IsRequired: true, Min: 1, Max: 10),
new(Key: "grafanaEnabled", DisplayName: "Enable Grafana", Description: "Deploy Grafana",
Type: ParameterType.Boolean, Group: "Features")
});
Dictionary<string, string> values = new()
{
["replicas"] = "999",
["grafanaEnabled"] = "invalid"
};
// Act
SchemaValidationResult result = schema.Validate(values);
// Assert — Three errors: retention missing, replicas out of range, grafana invalid.
result.IsValid.Should().BeFalse();
result.Errors.Should().HaveCount(3);
result.Errors.Should().ContainKey("retention");
result.Errors.Should().ContainKey("replicas");
result.Errors.Should().ContainKey("grafanaEnabled");
}
[Fact]
public void ConfigurationSchema_ValidateAll_PassesWithValidValues()
{
// Arrange
ConfigurationSchema schema = new(
ComponentName: "monitoring",
DisplayName: "Monitoring (kube-prometheus-stack)",
Description: "Prometheus, Grafana, and Alertmanager stack",
Parameters: new List<ConfigurationParameter>
{
new(Key: "retention", DisplayName: "Retention", Description: "Data retention period",
Type: ParameterType.String, Group: "Storage", IsRequired: true),
new(Key: "replicas", DisplayName: "Prometheus Replicas", Description: "Number of Prometheus replicas",
Type: ParameterType.Integer, Group: "Scaling", IsRequired: true, Min: 1, Max: 10)
});
Dictionary<string, string> values = new()
{
["retention"] = "30d",
["replicas"] = "2"
};
// Act
SchemaValidationResult result = schema.Validate(values);
// Assert
result.IsValid.Should().BeTrue();
result.Errors.Should().BeEmpty();
}
[Fact]
public void ConfigurationSchema_GroupsParameters_ByGroupName()
{
// Arrange — Schema parameters can be grouped for UI rendering.
ConfigurationSchema schema = new(
ComponentName: "monitoring",
DisplayName: "Monitoring",
Description: "Prometheus stack",
Parameters: new List<ConfigurationParameter>
{
new(Key: "retention", DisplayName: "Retention", Description: "Retention",
Type: ParameterType.String, Group: "Storage"),
new(Key: "storageSize", DisplayName: "Storage Size", Description: "PVC size",
Type: ParameterType.String, Group: "Storage"),
new(Key: "replicas", DisplayName: "Replicas", Description: "Replicas",
Type: ParameterType.Integer, Group: "Scaling", Min: 1, Max: 10),
new(Key: "grafanaEnabled", DisplayName: "Grafana", Description: "Enable Grafana",
Type: ParameterType.Boolean, Group: "Features")
});
// Act
Dictionary<string, List<ConfigurationParameter>> groups = schema.GetParametersByGroup();
// Assert
groups.Should().HaveCount(3);
groups["Storage"].Should().HaveCount(2);
groups["Scaling"].Should().HaveCount(1);
groups["Features"].Should().HaveCount(1);
}
[Fact]
public void CertManagerSchema_DoesNotContainLetsEncryptParameters()
{
// Arrange & Act — The cert-manager schema should only have scaling
// parameters. Let's Encrypt configuration belongs to the letsencrypt component.
ConfigurationSchema schema = ComponentSchemas.GetSchema("cert-manager");
// Assert — No Let's Encrypt-related keys should exist.
List<string> paramKeys = schema.Parameters.Select(p => p.Key).ToList();
paramKeys.Should().NotContain("letsEncryptEmail");
paramKeys.Should().NotContain("httpSolverEnabled");
paramKeys.Should().NotContain("dnsSolverEnabled");
paramKeys.Should().NotContain("dnsSolverProvider");
paramKeys.Should().Contain("replicas");
}
[Fact]
public void LetsEncryptSchema_ContainsSolverAndEmailParameters()
{
// Arrange & Act — The letsencrypt schema should own all the ACME/solver
// configuration that was previously in cert-manager.
ConfigurationSchema schema = ComponentSchemas.GetSchema("letsencrypt");
// Assert
List<string> paramKeys = schema.Parameters.Select(p => p.Key).ToList();
paramKeys.Should().Contain("email");
paramKeys.Should().Contain("httpSolverEnabled");
paramKeys.Should().Contain("dnsSolverEnabled");
paramKeys.Should().Contain("dnsSolverProvider");
schema.Parameters.First(p => p.Key == "email").IsRequired.Should().BeTrue();
}
[Fact]
public void CertManagerSchema_OnlyHasScalingGroup()
{
// The cert-manager component is purely the runtime engine — its schema
// should only expose scaling settings, not TLS/issuer configuration.
ConfigurationSchema schema = ComponentSchemas.GetSchema("cert-manager");
Dictionary<string, List<ConfigurationParameter>> groups = schema.GetParametersByGroup();
groups.Should().HaveCount(1);
groups.Keys.Should().Contain("Scaling");
}
// ─── Contextual Schema Enrichment ─────────────────────────────────────
[Fact]
public void EnrichForCluster_WithIstioIngress_DefaultsToGatewayHTTPRoute()
{
// Arrange — A cluster where the ingress component reports Istio as the
// provider, with an internal gateway detected. When configuring Let's
// Encrypt on this cluster, the HTTP-01 solver should default to
// gatewayHTTPRoute mode with the internal gateway pre-selected.
List<ClusterComponent> components = new()
{
BuildComponent("ingress", ComponentStatus.Installed, new Dictionary<string, string>
{
["provider"] = "istio",
["hasInternalGateway"] = "True",
["hasExternalGateway"] = "True"
})
};
// Act — Enrich the letsencrypt schema using the cluster's component state.
ConfigurationSchema enriched = ComponentSchemas.EnrichForCluster("letsencrypt", components);
// Assert — The HTTP-01 solver should default to Gateway API mode.
ConfigurationParameter modeParam = enriched.Parameters.First(p => p.Key == "httpSolverMode");
modeParam.DefaultValue.Should().Be("gatewayHTTPRoute");
ConfigurationParameter gatewayNameParam = enriched.Parameters.First(p => p.Key == "httpSolverGatewayName");
gatewayNameParam.DefaultValue.Should().Be("internal");
ConfigurationParameter gatewayNsParam = enriched.Parameters.First(p => p.Key == "httpSolverGatewayNamespace");
gatewayNsParam.DefaultValue.Should().Be("internal-ingress");
}
[Fact]
public void EnrichForCluster_WithTraefikIngress_DefaultsToIngressMode()
{
// Arrange — A cluster using Traefik for ingress. The HTTP-01 solver
// should default to ingress mode with "traefik" as the class.
List<ClusterComponent> components = new()
{
BuildComponent("ingress", ComponentStatus.Installed, new Dictionary<string, string>
{
["provider"] = "traefik",
["ingressClassRegistered"] = "True"
})
};
// Act
ConfigurationSchema enriched = ComponentSchemas.EnrichForCluster("letsencrypt", components);
// Assert — Should remain in ingress mode with traefik class.
ConfigurationParameter modeParam = enriched.Parameters.First(p => p.Key == "httpSolverMode");
modeParam.DefaultValue.Should().Be("ingress");
ConfigurationParameter classParam = enriched.Parameters.First(p => p.Key == "httpSolverIngressClass");
classParam.DefaultValue.Should().Be("traefik");
}
[Fact]
public void EnrichForCluster_WithNoIngressComponent_UsesStaticDefaults()
{
// Arrange — No ingress component detected on the cluster. The schema
// should return unchanged static defaults.
List<ClusterComponent> components = new();
// Act
ConfigurationSchema enriched = ComponentSchemas.EnrichForCluster("letsencrypt", components);
// Assert — Static defaults should be preserved.
ConfigurationParameter modeParam = enriched.Parameters.First(p => p.Key == "httpSolverMode");
modeParam.DefaultValue.Should().Be("ingress");
}
[Fact]
public void EnrichForCluster_WithIstioAndDiscoveredGateways_SetsGatewayAsSelect()
{
// Arrange — A cluster with Istio and discovered gateways stored in
// the ingress component's config. The httpSolverGatewayName should
// become a Select parameter with the available gateways as options.
List<ClusterComponent> components = new()
{
BuildComponent("ingress", ComponentStatus.Installed, new Dictionary<string, string>
{
["provider"] = "istio",
["hasInternalGateway"] = "True",
["hasExternalGateway"] = "True"
}),
BuildComponent("letsencrypt", ComponentStatus.Installed, new Dictionary<string, string>
{
["availableGateways"] = "[{\"name\":\"internal\",\"namespace\":\"internal-ingress\"},{\"name\":\"external\",\"namespace\":\"external-ingress\"}]"
})
};
// Act
ConfigurationSchema enriched = ComponentSchemas.EnrichForCluster("letsencrypt", components);
// Assert — Gateway name should be a Select with discovered gateways as options.
ConfigurationParameter gatewayParam = enriched.Parameters.First(p => p.Key == "httpSolverGatewayName");
gatewayParam.Type.Should().Be(ParameterType.Select);
gatewayParam.AllowedValues.Should().Contain("internal");
gatewayParam.AllowedValues.Should().Contain("external");
}
[Fact]
public void EnrichForCluster_NonLetsEncryptComponent_ReturnsUnchanged()
{
// Arrange — Enriching a component other than letsencrypt should
// return the static schema unchanged.
List<ClusterComponent> components = new()
{
BuildComponent("ingress", ComponentStatus.Installed, new Dictionary<string, string>
{
["provider"] = "istio"
})
};
// Act
ConfigurationSchema enriched = ComponentSchemas.EnrichForCluster("cert-manager", components);
// Assert — cert-manager schema should be unchanged.
ConfigurationSchema original = ComponentSchemas.GetSchema("cert-manager");
enriched.Parameters.Should().HaveCount(original.Parameters.Count);
}
[Fact]
public void EnrichForCluster_WithIstioAndExistingLetsEncryptConfig_PreservesDiscoveredValues()
{
// Arrange — A cluster where Let's Encrypt was already configured with
// specific values. The enrichment should set defaults from the ingress
// provider but the existing discovered config (email, solver type) is
// separate and handled by the UI pre-fill, not schema defaults.
List<ClusterComponent> components = new()
{
BuildComponent("ingress", ComponentStatus.Installed, new Dictionary<string, string>
{
["provider"] = "istio",
["hasInternalGateway"] = "True",
["hasExternalGateway"] = "False"
})
};
// Act
ConfigurationSchema enriched = ComponentSchemas.EnrichForCluster("letsencrypt", components);
// Assert — With only internal gateway, external should not appear in options.
// The gateway namespace should default to internal-ingress.
ConfigurationParameter gatewayNsParam = enriched.Parameters.First(p => p.Key == "httpSolverGatewayNamespace");
gatewayNsParam.DefaultValue.Should().Be("internal-ingress");
}
[Fact]
public void EnrichForCluster_IngressWithExternalGateway_DefaultsEnableExternalToTrue()
{
// Arrange — The cluster scan detected an external gateway. When the
// user opens the ingress configuration, the enable_external toggle
// should default to true so it reflects reality.
List<ClusterComponent> components = new()
{
BuildComponent("ingress", ComponentStatus.Installed, new Dictionary<string, string>
{
["provider"] = "istio",
["hasExternalGateway"] = "True"
})
};
// Act
ConfigurationSchema enriched = ComponentSchemas.EnrichForCluster("ingress", components);
// Assert — enable_external should default to "true".
ConfigurationParameter extParam = enriched.Parameters.First(p => p.Key == "enable_external");
extParam.DefaultValue.Should().Be("true");
}
[Fact]
public void EnrichForCluster_IngressWithoutExternalGateway_DefaultsEnableExternalToFalse()
{
// Arrange — No external gateway detected. The default should stay false.
List<ClusterComponent> components = new()
{
BuildComponent("ingress", ComponentStatus.Installed, new Dictionary<string, string>
{
["provider"] = "istio",
["hasExternalGateway"] = "False"
})
};
// Act
ConfigurationSchema enriched = ComponentSchemas.EnrichForCluster("ingress", components);
// Assert — enable_external should remain "false".
ConfigurationParameter extParam = enriched.Parameters.First(p => p.Key == "enable_external");
extParam.DefaultValue.Should().Be("false");
}
// ─── Helpers ──────────────────────────────────────────────────────────
private static ClusterComponent BuildComponent(
string name, ComponentStatus status, Dictionary<string, string> config)
{
ComponentCheckResult checkResult = new(
name, status, new List<string>(), new List<string>(),
new DiscoveredConfiguration(null, null, null, config));
return ClusterComponent.FromCheckResult(checkResult);
}
[Fact]
public void ParameterType_IncludesPostgresCluster()
{
// The UI needs a PostgresCluster parameter type to render a CNPG cluster
// picker, similar to how StorageBucket renders a bucket picker.
ParameterType type = ParameterType.PostgresCluster;
type.Should().BeDefined();
}
}