1108 lines
37 KiB
C#
1108 lines
37 KiB
C#
using EntKube.Provisioning.Domain;
|
|
using EntKube.Provisioning.Features.Apps.Reconcile;
|
|
using FluentAssertions;
|
|
|
|
namespace EntKube.Provisioning.Tests.Features;
|
|
|
|
public class ManifestGeneratorTests
|
|
{
|
|
// ─── Deployment Manifest ─────────────────────────────────────────────
|
|
|
|
[Fact]
|
|
public void GenerateDeployment_WithFullSpec_ProducesValidYaml()
|
|
{
|
|
// Arrange — A fully configured deployment spec for an API service.
|
|
// The generator should produce a Kubernetes Deployment manifest with
|
|
// the container image, port, replicas, resource limits, and env vars.
|
|
|
|
DeploymentSpec spec = new(
|
|
Image: "registry.example.com/api",
|
|
Tag: "v1.2.3",
|
|
Replicas: 3,
|
|
ContainerPort: 8080,
|
|
ServicePort: 80,
|
|
HostName: "api.dev.example.com",
|
|
PathPrefix: "/api",
|
|
EnvironmentVariables: new Dictionary<string, string>
|
|
{
|
|
["ASPNETCORE_ENVIRONMENT"] = "Development",
|
|
["LOG_LEVEL"] = "Debug"
|
|
},
|
|
Resources: new ResourceSpec("100m", "500m", "128Mi", "512Mi"));
|
|
|
|
// Act
|
|
|
|
string yaml = ManifestGenerator.GenerateDeployment("my-api", "api-dev", spec);
|
|
|
|
// Assert — The YAML should contain the key Kubernetes Deployment fields.
|
|
|
|
yaml.Should().Contain("kind: Deployment");
|
|
yaml.Should().Contain("name: my-api");
|
|
yaml.Should().Contain("namespace: api-dev");
|
|
yaml.Should().Contain("replicas: 3");
|
|
yaml.Should().Contain("registry.example.com/api:v1.2.3");
|
|
yaml.Should().Contain("containerPort: 8080");
|
|
yaml.Should().Contain("cpu: \"100m\"");
|
|
yaml.Should().Contain("cpu: \"500m\"");
|
|
yaml.Should().Contain("memory: \"128Mi\"");
|
|
yaml.Should().Contain("memory: \"512Mi\"");
|
|
yaml.Should().Contain("ASPNETCORE_ENVIRONMENT");
|
|
yaml.Should().Contain("Development");
|
|
}
|
|
|
|
[Fact]
|
|
public void GenerateDeployment_WithoutResources_OmitsResourceBlock()
|
|
{
|
|
// Arrange — A minimal deployment without resource constraints.
|
|
|
|
DeploymentSpec spec = new(
|
|
Image: "nginx",
|
|
Tag: "latest",
|
|
Replicas: 1,
|
|
ContainerPort: 80,
|
|
ServicePort: 80,
|
|
HostName: null,
|
|
PathPrefix: null,
|
|
EnvironmentVariables: null,
|
|
Resources: null);
|
|
|
|
// Act
|
|
|
|
string yaml = ManifestGenerator.GenerateDeployment("web", "default", spec);
|
|
|
|
// Assert
|
|
|
|
yaml.Should().Contain("kind: Deployment");
|
|
yaml.Should().Contain("nginx:latest");
|
|
yaml.Should().NotContain("resources:");
|
|
}
|
|
|
|
// ─── Service Manifest ────────────────────────────────────────────────
|
|
|
|
[Fact]
|
|
public void GenerateService_WithSpec_ProducesClusterIPService()
|
|
{
|
|
// Arrange — The generator creates a ClusterIP Service that maps
|
|
// the service port to the container port, matching pod labels.
|
|
|
|
DeploymentSpec spec = new(
|
|
Image: "registry.example.com/api",
|
|
Tag: "v1.0.0",
|
|
Replicas: 2,
|
|
ContainerPort: 8080,
|
|
ServicePort: 80,
|
|
HostName: null,
|
|
PathPrefix: null,
|
|
EnvironmentVariables: null,
|
|
Resources: null);
|
|
|
|
// Act
|
|
|
|
string yaml = ManifestGenerator.GenerateService("my-api", "api-dev", spec);
|
|
|
|
// Assert
|
|
|
|
yaml.Should().Contain("kind: Service");
|
|
yaml.Should().Contain("name: my-api");
|
|
yaml.Should().Contain("namespace: api-dev");
|
|
yaml.Should().Contain("port: 80");
|
|
yaml.Should().Contain("targetPort: 8080");
|
|
yaml.Should().Contain("app: my-api");
|
|
}
|
|
|
|
// ─── HTTPRoute Manifest ──────────────────────────────────────────────
|
|
|
|
[Fact]
|
|
public void GenerateHttpRoute_WithHostAndPath_ProducesRouteYaml()
|
|
{
|
|
// Arrange — When a hostname and path prefix are provided,
|
|
// we generate a Gateway API HTTPRoute resource.
|
|
|
|
DeploymentSpec spec = new(
|
|
Image: "api",
|
|
Tag: "v1",
|
|
Replicas: 1,
|
|
ContainerPort: 8080,
|
|
ServicePort: 80,
|
|
HostName: "api.dev.example.com",
|
|
PathPrefix: "/api",
|
|
EnvironmentVariables: null,
|
|
Resources: null);
|
|
|
|
// Act
|
|
|
|
string yaml = ManifestGenerator.GenerateHttpRoute("my-api", "api-dev", spec);
|
|
|
|
// Assert
|
|
|
|
yaml.Should().Contain("kind: HTTPRoute");
|
|
yaml.Should().Contain("name: my-api");
|
|
yaml.Should().Contain("namespace: api-dev");
|
|
yaml.Should().Contain("api.dev.example.com");
|
|
yaml.Should().Contain("/api");
|
|
yaml.Should().Contain("port: 80");
|
|
}
|
|
|
|
[Fact]
|
|
public void GenerateHttpRoute_WithoutHost_ReturnsEmpty()
|
|
{
|
|
// Arrange — No hostname means no ingress routing needed.
|
|
|
|
DeploymentSpec spec = new(
|
|
Image: "worker",
|
|
Tag: "v1",
|
|
Replicas: 1,
|
|
ContainerPort: 8080,
|
|
ServicePort: 80,
|
|
HostName: null,
|
|
PathPrefix: null,
|
|
EnvironmentVariables: null,
|
|
Resources: null);
|
|
|
|
// Act
|
|
|
|
string yaml = ManifestGenerator.GenerateHttpRoute("worker", "default", spec);
|
|
|
|
// Assert
|
|
|
|
yaml.Should().BeEmpty();
|
|
}
|
|
|
|
// ─── Secrets Manifest ────────────────────────────────────────────────
|
|
|
|
[Fact]
|
|
public void GenerateSecret_WithEntries_ProducesOpaqueSecret()
|
|
{
|
|
// Arrange — Secret values resolved from the vault become a
|
|
// Kubernetes Opaque Secret with base64-encoded data.
|
|
|
|
Dictionary<string, string> resolvedSecrets = new()
|
|
{
|
|
["DATABASE_URL"] = "postgresql://db:5432/mydb",
|
|
["API_KEY"] = "sk-abc123"
|
|
};
|
|
|
|
// Act
|
|
|
|
string yaml = ManifestGenerator.GenerateSecret("my-api", "api-dev", resolvedSecrets);
|
|
|
|
// Assert
|
|
|
|
yaml.Should().Contain("kind: Secret");
|
|
yaml.Should().Contain("name: my-api");
|
|
yaml.Should().Contain("namespace: api-dev");
|
|
yaml.Should().Contain("type: Opaque");
|
|
yaml.Should().Contain("DATABASE_URL:");
|
|
yaml.Should().Contain("API_KEY:");
|
|
}
|
|
|
|
[Fact]
|
|
public void GenerateSecret_Empty_ReturnsEmpty()
|
|
{
|
|
// Arrange — No secrets means no Secret resource needed.
|
|
|
|
Dictionary<string, string> empty = new();
|
|
|
|
// Act
|
|
|
|
string yaml = ManifestGenerator.GenerateSecret("my-api", "default", empty);
|
|
|
|
// Assert
|
|
|
|
yaml.Should().BeEmpty();
|
|
}
|
|
|
|
// ─── Environment Variable Injection ──────────────────────────────────
|
|
|
|
[Fact]
|
|
public void GenerateDeployment_WithSecrets_InjectsSecretKeyRefs()
|
|
{
|
|
// Arrange — When an app has secrets, the deployment should reference
|
|
// them via secretKeyRef so the container gets environment variables
|
|
// populated from the Kubernetes Secret resource.
|
|
|
|
DeploymentSpec spec = new(
|
|
Image: "api",
|
|
Tag: "v1",
|
|
Replicas: 1,
|
|
ContainerPort: 8080,
|
|
ServicePort: 80,
|
|
HostName: null,
|
|
PathPrefix: null,
|
|
EnvironmentVariables: null,
|
|
Resources: null);
|
|
|
|
List<AppSecret> secrets = new()
|
|
{
|
|
new AppSecret("DATABASE_URL", "db-conn-string"),
|
|
new AppSecret("API_KEY", "api-key")
|
|
};
|
|
|
|
// Act
|
|
|
|
string yaml = ManifestGenerator.GenerateDeployment("my-api", "api-dev", spec, secrets);
|
|
|
|
// Assert — Should have secretKeyRef entries for each secret.
|
|
|
|
yaml.Should().Contain("secretKeyRef:");
|
|
yaml.Should().Contain("name: DATABASE_URL");
|
|
yaml.Should().Contain("key: DATABASE_URL");
|
|
yaml.Should().Contain("name: API_KEY");
|
|
}
|
|
|
|
// ─── Volume Mounts ──────────────────────────────────────────────────
|
|
|
|
[Fact]
|
|
public void GenerateDeployment_WithVolumes_ProducesVolumeMountAndVolumeBlocks()
|
|
{
|
|
// Arrange — An app that needs persistent storage mounts a PVC
|
|
// into the container at a specific path. The manifest should include
|
|
// both a volumeMount on the container and a volume on the pod spec.
|
|
|
|
DeploymentSpec spec = new(
|
|
Image: "postgres",
|
|
Tag: "16",
|
|
Replicas: 1,
|
|
ContainerPort: 5432,
|
|
ServicePort: 5432,
|
|
HostName: null,
|
|
PathPrefix: null,
|
|
EnvironmentVariables: null,
|
|
Resources: null,
|
|
Volumes: new List<VolumeSpec>
|
|
{
|
|
new("data", new VolumePvcSource("postgres-data-pvc"), MountPath: "/var/lib/postgresql/data"),
|
|
new("config", new VolumeConfigMapSource("postgres-config"), MountPath: "/etc/postgresql")
|
|
},
|
|
Containers: null,
|
|
SecurityContext: null,
|
|
ServiceAccountName: null,
|
|
Annotations: null,
|
|
Labels: null,
|
|
Probes: null,
|
|
ConfigMaps: null);
|
|
|
|
// Act
|
|
|
|
string yaml = ManifestGenerator.GenerateDeployment("db", "data-ns", spec);
|
|
|
|
// Assert — The YAML should have volume mounts on the container
|
|
// and volume definitions on the pod spec.
|
|
|
|
yaml.Should().Contain("volumeMounts:");
|
|
yaml.Should().Contain("name: data");
|
|
yaml.Should().Contain("mountPath: \"/var/lib/postgresql/data\"");
|
|
yaml.Should().Contain("persistentVolumeClaim:");
|
|
yaml.Should().Contain("claimName: postgres-data-pvc");
|
|
yaml.Should().Contain("configMap:");
|
|
yaml.Should().Contain("name: postgres-config");
|
|
}
|
|
|
|
// ─── Security Context ───────────────────────────────────────────────
|
|
|
|
[Fact]
|
|
public void GenerateDeployment_WithSecurityContext_ProducesSecurityBlocks()
|
|
{
|
|
// Arrange — For hardened workloads, the deployment needs pod-level
|
|
// and container-level security context settings: non-root user,
|
|
// read-only root filesystem, dropped capabilities.
|
|
|
|
DeploymentSpec spec = new(
|
|
Image: "api",
|
|
Tag: "v1",
|
|
Replicas: 1,
|
|
ContainerPort: 8080,
|
|
ServicePort: 80,
|
|
HostName: null,
|
|
PathPrefix: null,
|
|
EnvironmentVariables: null,
|
|
Resources: null,
|
|
Volumes: null,
|
|
Containers: null,
|
|
SecurityContext: new PodSecurityContextSpec(
|
|
RunAsUser: 1000,
|
|
RunAsGroup: 1000,
|
|
FsGroup: 2000,
|
|
RunAsNonRoot: true),
|
|
ServiceAccountName: null,
|
|
Annotations: null,
|
|
Labels: null,
|
|
Probes: null,
|
|
ConfigMaps: null);
|
|
|
|
// Act
|
|
|
|
string yaml = ManifestGenerator.GenerateDeployment("secure-api", "prod", spec);
|
|
|
|
// Assert
|
|
|
|
yaml.Should().Contain("securityContext:");
|
|
yaml.Should().Contain("runAsUser: 1000");
|
|
yaml.Should().Contain("runAsGroup: 1000");
|
|
yaml.Should().Contain("fsGroup: 2000");
|
|
yaml.Should().Contain("runAsNonRoot: true");
|
|
}
|
|
|
|
// ─── Multiple Containers (Init + Sidecar) ───────────────────────────
|
|
|
|
[Fact]
|
|
public void GenerateDeployment_WithAdditionalContainers_ProducesMultiContainerPod()
|
|
{
|
|
// Arrange — Some workloads need init containers (e.g. DB migrations)
|
|
// and sidecar containers (e.g. proxy, log shipper). The spec supports
|
|
// adding extra containers beyond the primary one.
|
|
|
|
DeploymentSpec spec = new(
|
|
Image: "api",
|
|
Tag: "v2",
|
|
Replicas: 2,
|
|
ContainerPort: 8080,
|
|
ServicePort: 80,
|
|
HostName: null,
|
|
PathPrefix: null,
|
|
EnvironmentVariables: null,
|
|
Resources: null,
|
|
Volumes: null,
|
|
Containers: new AdditionalContainersSpec(
|
|
InitContainers: new List<ContainerSpec>
|
|
{
|
|
new("migrate", "api", "v2", 0, new[] { "dotnet", "ef", "database", "update" }.ToList(),
|
|
null, null, null, null, null)
|
|
},
|
|
Sidecars: new List<ContainerSpec>
|
|
{
|
|
new("log-shipper", "fluent/fluent-bit", "latest", 0, null,
|
|
null, null, null, null, null)
|
|
}),
|
|
SecurityContext: null,
|
|
ServiceAccountName: null,
|
|
Annotations: null,
|
|
Labels: null,
|
|
Probes: null,
|
|
ConfigMaps: null);
|
|
|
|
// Act
|
|
|
|
string yaml = ManifestGenerator.GenerateDeployment("my-api", "dev", spec);
|
|
|
|
// Assert — Should have initContainers and sidecar containers.
|
|
|
|
yaml.Should().Contain("initContainers:");
|
|
yaml.Should().Contain("name: migrate");
|
|
yaml.Should().Contain("command:");
|
|
yaml.Should().Contain("name: log-shipper");
|
|
yaml.Should().Contain("fluent/fluent-bit:latest");
|
|
}
|
|
|
|
// ─── Probes ─────────────────────────────────────────────────────────
|
|
|
|
[Fact]
|
|
public void GenerateDeployment_WithProbes_ProducesProbeBlocks()
|
|
{
|
|
// Arrange — Kubernetes uses probes to determine container health.
|
|
// A liveness probe restarts unhealthy containers; a readiness probe
|
|
// removes them from service endpoints until they're ready.
|
|
|
|
DeploymentSpec spec = new(
|
|
Image: "api",
|
|
Tag: "v1",
|
|
Replicas: 1,
|
|
ContainerPort: 8080,
|
|
ServicePort: 80,
|
|
HostName: null,
|
|
PathPrefix: null,
|
|
EnvironmentVariables: null,
|
|
Resources: null,
|
|
Volumes: null,
|
|
Containers: null,
|
|
SecurityContext: null,
|
|
ServiceAccountName: null,
|
|
Annotations: null,
|
|
Labels: null,
|
|
Probes: new ProbesSpec(
|
|
Liveness: new ProbeSpec("HTTP", "/healthz", 8080, 15, 20, 3),
|
|
Readiness: new ProbeSpec("HTTP", "/ready", 8080, 5, 10, 1),
|
|
Startup: new ProbeSpec("HTTP", "/healthz", 8080, 0, 5, 30)),
|
|
ConfigMaps: null);
|
|
|
|
// Act
|
|
|
|
string yaml = ManifestGenerator.GenerateDeployment("api", "prod", spec);
|
|
|
|
// Assert
|
|
|
|
yaml.Should().Contain("livenessProbe:");
|
|
yaml.Should().Contain("path: /healthz");
|
|
yaml.Should().Contain("readinessProbe:");
|
|
yaml.Should().Contain("path: /ready");
|
|
yaml.Should().Contain("startupProbe:");
|
|
}
|
|
|
|
// ─── ConfigMaps ─────────────────────────────────────────────────────
|
|
|
|
[Fact]
|
|
public void GenerateConfigMap_WithEntries_ProducesConfigMapYaml()
|
|
{
|
|
// Arrange — Apps may need ConfigMap resources for configuration files
|
|
// or simple key-value settings. The generator creates a ConfigMap
|
|
// manifest from structured data.
|
|
|
|
ConfigMapSpec configMap = new(
|
|
"app-config",
|
|
new Dictionary<string, string>
|
|
{
|
|
["appsettings.json"] = "{\"Logging\":{\"LogLevel\":{\"Default\":\"Information\"}}}",
|
|
["feature-flags.json"] = "{\"enableBeta\":true}"
|
|
});
|
|
|
|
// Act
|
|
|
|
string yaml = ManifestGenerator.GenerateConfigMap("my-api", "dev", configMap);
|
|
|
|
// Assert
|
|
|
|
yaml.Should().Contain("kind: ConfigMap");
|
|
yaml.Should().Contain("name: app-config");
|
|
yaml.Should().Contain("namespace: dev");
|
|
yaml.Should().Contain("appsettings.json:");
|
|
yaml.Should().Contain("feature-flags.json:");
|
|
}
|
|
|
|
// ─── Service Account ────────────────────────────────────────────────
|
|
|
|
[Fact]
|
|
public void GenerateDeployment_WithServiceAccount_IncludesServiceAccountName()
|
|
{
|
|
// Arrange — When a workload needs to access the Kubernetes API
|
|
// or cloud resources, it runs under a specific service account.
|
|
|
|
DeploymentSpec spec = new(
|
|
Image: "api",
|
|
Tag: "v1",
|
|
Replicas: 1,
|
|
ContainerPort: 8080,
|
|
ServicePort: 80,
|
|
HostName: null,
|
|
PathPrefix: null,
|
|
EnvironmentVariables: null,
|
|
Resources: null,
|
|
Volumes: null,
|
|
Containers: null,
|
|
SecurityContext: null,
|
|
ServiceAccountName: "api-service-account",
|
|
Annotations: null,
|
|
Labels: null,
|
|
Probes: null,
|
|
ConfigMaps: null);
|
|
|
|
// Act
|
|
|
|
string yaml = ManifestGenerator.GenerateDeployment("api", "prod", spec);
|
|
|
|
// Assert
|
|
|
|
yaml.Should().Contain("serviceAccountName: api-service-account");
|
|
}
|
|
|
|
// ─── Annotations & Labels ───────────────────────────────────────────
|
|
|
|
[Fact]
|
|
public void GenerateDeployment_WithAnnotationsAndLabels_IncludesMetadata()
|
|
{
|
|
// Arrange — Teams need custom annotations (e.g., Prometheus scrape config,
|
|
// Istio injection) and labels (e.g., team ownership, cost center).
|
|
|
|
DeploymentSpec spec = new(
|
|
Image: "api",
|
|
Tag: "v1",
|
|
Replicas: 1,
|
|
ContainerPort: 8080,
|
|
ServicePort: 80,
|
|
HostName: null,
|
|
PathPrefix: null,
|
|
EnvironmentVariables: null,
|
|
Resources: null,
|
|
Volumes: null,
|
|
Containers: null,
|
|
SecurityContext: null,
|
|
ServiceAccountName: null,
|
|
Annotations: new Dictionary<string, string>
|
|
{
|
|
["prometheus.io/scrape"] = "true",
|
|
["prometheus.io/port"] = "8080"
|
|
},
|
|
Labels: new Dictionary<string, string>
|
|
{
|
|
["team"] = "platform",
|
|
["cost-center"] = "engineering"
|
|
},
|
|
Probes: null,
|
|
ConfigMaps: null);
|
|
|
|
// Act
|
|
|
|
string yaml = ManifestGenerator.GenerateDeployment("api", "prod", spec);
|
|
|
|
// Assert
|
|
|
|
yaml.Should().Contain("prometheus.io/scrape: \"true\"");
|
|
yaml.Should().Contain("prometheus.io/port: \"8080\"");
|
|
yaml.Should().Contain("team: platform");
|
|
yaml.Should().Contain("cost-center: engineering");
|
|
}
|
|
|
|
// ─── Backward Compatibility ─────────────────────────────────────────
|
|
|
|
[Fact]
|
|
public void GenerateDeployment_MinimalSpecWithNewNullFields_StillWorks()
|
|
{
|
|
// Arrange — The existing minimal spec (with all new fields null/empty)
|
|
// should still produce the same output as before, ensuring backward
|
|
// compatibility for existing apps.
|
|
|
|
DeploymentSpec spec = new(
|
|
Image: "nginx",
|
|
Tag: "latest",
|
|
Replicas: 1,
|
|
ContainerPort: 80,
|
|
ServicePort: 80,
|
|
HostName: null,
|
|
PathPrefix: null,
|
|
EnvironmentVariables: null,
|
|
Resources: null,
|
|
Volumes: null,
|
|
Containers: null,
|
|
SecurityContext: null,
|
|
ServiceAccountName: null,
|
|
Annotations: null,
|
|
Labels: null,
|
|
Probes: null,
|
|
ConfigMaps: null);
|
|
|
|
// Act
|
|
|
|
string yaml = ManifestGenerator.GenerateDeployment("web", "default", spec);
|
|
|
|
// Assert — Should produce a basic deployment without any of the
|
|
// new blocks (no volumes, no security context, no probes, etc.)
|
|
|
|
yaml.Should().Contain("kind: Deployment");
|
|
yaml.Should().Contain("nginx:latest");
|
|
yaml.Should().NotContain("volumeMounts:");
|
|
yaml.Should().NotContain("securityContext:");
|
|
yaml.Should().NotContain("initContainers:");
|
|
yaml.Should().NotContain("livenessProbe:");
|
|
yaml.Should().NotContain("serviceAccountName:");
|
|
}
|
|
|
|
// ─── Multiple Services ──────────────────────────────────────────────
|
|
|
|
[Fact]
|
|
public void GenerateServices_WithMultipleSpecs_ProducesOneServicePerSpec()
|
|
{
|
|
// Arrange — An app might expose both an HTTP API on port 80 and a gRPC
|
|
// endpoint on port 9090, each needing its own Kubernetes Service.
|
|
// The services list replaces the old single ServicePort/ContainerPort pair.
|
|
|
|
List<ServiceSpec> services = new()
|
|
{
|
|
new ServiceSpec("my-api-http", 80, 8080, "TCP", "ClusterIP"),
|
|
new ServiceSpec("my-api-grpc", 9090, 9090, "TCP", "ClusterIP")
|
|
};
|
|
|
|
// Act
|
|
|
|
List<string> yamls = services.Select(s =>
|
|
ManifestGenerator.GenerateService("my-api", "api-dev", s)).ToList();
|
|
|
|
// Assert — Each service gets its own YAML with the correct ports.
|
|
|
|
yamls.Should().HaveCount(2);
|
|
yamls[0].Should().Contain("name: my-api-http");
|
|
yamls[0].Should().Contain("port: 80");
|
|
yamls[0].Should().Contain("targetPort: 8080");
|
|
yamls[1].Should().Contain("name: my-api-grpc");
|
|
yamls[1].Should().Contain("port: 9090");
|
|
}
|
|
|
|
[Fact]
|
|
public void GenerateService_WithUdpProtocol_SetsProtocolField()
|
|
{
|
|
// Arrange — A DNS or game server might need a UDP service.
|
|
|
|
ServiceSpec svc = new("dns-server", 53, 5353, "UDP", "ClusterIP");
|
|
|
|
// Act
|
|
|
|
string yaml = ManifestGenerator.GenerateService("dns", "infra", svc);
|
|
|
|
// Assert
|
|
|
|
yaml.Should().Contain("protocol: UDP");
|
|
yaml.Should().Contain("port: 53");
|
|
yaml.Should().Contain("targetPort: 5353");
|
|
}
|
|
|
|
[Fact]
|
|
public void GenerateService_WithNodePortType_SetsServiceType()
|
|
{
|
|
// Arrange — Some services need NodePort or LoadBalancer exposure.
|
|
|
|
ServiceSpec svc = new("web-public", 80, 8080, "TCP", "NodePort");
|
|
|
|
// Act
|
|
|
|
string yaml = ManifestGenerator.GenerateService("web", "prod", svc);
|
|
|
|
// Assert
|
|
|
|
yaml.Should().Contain("type: NodePort");
|
|
}
|
|
|
|
// ─── Gateway API Routes ─────────────────────────────────────────────
|
|
|
|
[Fact]
|
|
public void GenerateRoute_HttpRoute_WithMatchesAndFilters()
|
|
{
|
|
// Arrange — An HTTPRoute with multiple path matches, header matching,
|
|
// and a gateway reference. This is the full-featured version of the
|
|
// old GenerateHttpRoute that only supported hostname + path prefix.
|
|
|
|
RouteSpec route = new(
|
|
Name: "api-route",
|
|
Type: "HTTP",
|
|
Hostnames: new List<string> { "api.example.com", "api.internal.example.com" },
|
|
GatewayRef: new GatewayReference("main-gateway", "gateway-ns"),
|
|
Rules: new List<RouteRule>
|
|
{
|
|
new(
|
|
Matches: new List<RouteMatch>
|
|
{
|
|
new("PathPrefix", "/api/v1", null),
|
|
new("PathPrefix", "/api/v2", null)
|
|
},
|
|
BackendRefs: new List<RouteBackendRef>
|
|
{
|
|
new("my-api-http", 80, null)
|
|
},
|
|
Filters: null)
|
|
});
|
|
|
|
// Act
|
|
|
|
string yaml = ManifestGenerator.GenerateRoute("my-api", "api-dev", route);
|
|
|
|
// Assert
|
|
|
|
yaml.Should().Contain("kind: HTTPRoute");
|
|
yaml.Should().Contain("api.example.com");
|
|
yaml.Should().Contain("api.internal.example.com");
|
|
yaml.Should().Contain("name: main-gateway");
|
|
yaml.Should().Contain("namespace: gateway-ns");
|
|
yaml.Should().Contain("/api/v1");
|
|
yaml.Should().Contain("/api/v2");
|
|
yaml.Should().Contain("name: my-api-http");
|
|
yaml.Should().Contain("port: 80");
|
|
}
|
|
|
|
[Fact]
|
|
public void GenerateRoute_TlsRoute_ProducesTLSRouteManifest()
|
|
{
|
|
// Arrange — A TLSRoute performs TLS passthrough routing. The Gateway
|
|
// terminates at the SNI hostname level and passes the encrypted
|
|
// connection directly to the backend — useful for services that
|
|
// handle their own TLS (databases, MQTT brokers, etc.)
|
|
|
|
RouteSpec route = new(
|
|
Name: "db-tls-route",
|
|
Type: "TLS",
|
|
Hostnames: new List<string> { "db.secure.example.com" },
|
|
GatewayRef: new GatewayReference("tls-gateway", "gateway-ns"),
|
|
Rules: new List<RouteRule>
|
|
{
|
|
new(
|
|
Matches: null,
|
|
BackendRefs: new List<RouteBackendRef>
|
|
{
|
|
new("db-service", 5432, null)
|
|
},
|
|
Filters: null)
|
|
});
|
|
|
|
// Act
|
|
|
|
string yaml = ManifestGenerator.GenerateRoute("db-app", "data-ns", route);
|
|
|
|
// Assert
|
|
|
|
yaml.Should().Contain("kind: TLSRoute");
|
|
yaml.Should().Contain("apiVersion: gateway.networking.k8s.io/v1alpha2");
|
|
yaml.Should().Contain("db.secure.example.com");
|
|
yaml.Should().Contain("name: db-service");
|
|
yaml.Should().Contain("port: 5432");
|
|
}
|
|
|
|
[Fact]
|
|
public void GenerateRoute_TcpRoute_ProducesTCPRouteManifest()
|
|
{
|
|
// Arrange — A TCPRoute routes raw TCP traffic. No hostname matching —
|
|
// it's port-based. Used for databases, Redis, custom TCP protocols.
|
|
|
|
RouteSpec route = new(
|
|
Name: "redis-route",
|
|
Type: "TCP",
|
|
Hostnames: null,
|
|
GatewayRef: new GatewayReference("tcp-gateway", "gateway-ns"),
|
|
Rules: new List<RouteRule>
|
|
{
|
|
new(
|
|
Matches: null,
|
|
BackendRefs: new List<RouteBackendRef>
|
|
{
|
|
new("redis-svc", 6379, null)
|
|
},
|
|
Filters: null)
|
|
});
|
|
|
|
// Act
|
|
|
|
string yaml = ManifestGenerator.GenerateRoute("redis", "cache-ns", route);
|
|
|
|
// Assert
|
|
|
|
yaml.Should().Contain("kind: TCPRoute");
|
|
yaml.Should().Contain("apiVersion: gateway.networking.k8s.io/v1alpha2");
|
|
yaml.Should().Contain("name: redis-svc");
|
|
yaml.Should().Contain("port: 6379");
|
|
yaml.Should().NotContain("hostnames:");
|
|
}
|
|
|
|
[Fact]
|
|
public void GenerateRoute_UdpRoute_ProducesUDPRouteManifest()
|
|
{
|
|
// Arrange — A UDPRoute for services that use UDP: DNS servers,
|
|
// game servers, VoIP, etc.
|
|
|
|
RouteSpec route = new(
|
|
Name: "dns-route",
|
|
Type: "UDP",
|
|
Hostnames: null,
|
|
GatewayRef: new GatewayReference("udp-gateway", "gateway-ns"),
|
|
Rules: new List<RouteRule>
|
|
{
|
|
new(
|
|
Matches: null,
|
|
BackendRefs: new List<RouteBackendRef>
|
|
{
|
|
new("dns-svc", 53, null)
|
|
},
|
|
Filters: null)
|
|
});
|
|
|
|
// Act
|
|
|
|
string yaml = ManifestGenerator.GenerateRoute("dns", "infra", route);
|
|
|
|
// Assert
|
|
|
|
yaml.Should().Contain("kind: UDPRoute");
|
|
yaml.Should().Contain("apiVersion: gateway.networking.k8s.io/v1alpha2");
|
|
yaml.Should().Contain("name: dns-svc");
|
|
yaml.Should().Contain("port: 53");
|
|
}
|
|
|
|
[Fact]
|
|
public void GenerateRoute_GrpcRoute_ProducesGRPCRouteManifest()
|
|
{
|
|
// Arrange — A GRPCRoute for gRPC services. Uses method matching
|
|
// instead of path matching.
|
|
|
|
RouteSpec route = new(
|
|
Name: "grpc-route",
|
|
Type: "GRPC",
|
|
Hostnames: new List<string> { "grpc.example.com" },
|
|
GatewayRef: new GatewayReference("main-gateway", "gateway-ns"),
|
|
Rules: new List<RouteRule>
|
|
{
|
|
new(
|
|
Matches: null,
|
|
BackendRefs: new List<RouteBackendRef>
|
|
{
|
|
new("grpc-svc", 9090, null)
|
|
},
|
|
Filters: null)
|
|
});
|
|
|
|
// Act
|
|
|
|
string yaml = ManifestGenerator.GenerateRoute("grpc-app", "api-ns", route);
|
|
|
|
// Assert
|
|
|
|
yaml.Should().Contain("kind: GRPCRoute");
|
|
yaml.Should().Contain("grpc.example.com");
|
|
yaml.Should().Contain("port: 9090");
|
|
}
|
|
|
|
[Fact]
|
|
public void GenerateRoute_HttpRouteWithFilters_IncludesFilterBlocks()
|
|
{
|
|
// Arrange — HTTPRoute filters can add/remove headers, redirect,
|
|
// or rewrite URLs. This tests header modification filters.
|
|
|
|
RouteSpec route = new(
|
|
Name: "api-route",
|
|
Type: "HTTP",
|
|
Hostnames: new List<string> { "api.example.com" },
|
|
GatewayRef: null,
|
|
Rules: new List<RouteRule>
|
|
{
|
|
new(
|
|
Matches: new List<RouteMatch>
|
|
{
|
|
new("PathPrefix", "/api", null)
|
|
},
|
|
BackendRefs: new List<RouteBackendRef>
|
|
{
|
|
new("api-svc", 80, null)
|
|
},
|
|
Filters: new List<RouteFilter>
|
|
{
|
|
new("RequestHeaderModifier", new Dictionary<string, string>
|
|
{
|
|
["X-Forwarded-Proto"] = "https"
|
|
}, null, null)
|
|
})
|
|
});
|
|
|
|
// Act
|
|
|
|
string yaml = ManifestGenerator.GenerateRoute("api", "prod", route);
|
|
|
|
// Assert
|
|
|
|
yaml.Should().Contain("RequestHeaderModifier");
|
|
yaml.Should().Contain("X-Forwarded-Proto");
|
|
yaml.Should().Contain("https");
|
|
}
|
|
|
|
[Fact]
|
|
public void GenerateRoute_HttpRouteWithWeightedBackends_IncludesWeight()
|
|
{
|
|
// Arrange — Canary deployments split traffic between backends using weights.
|
|
|
|
RouteSpec route = new(
|
|
Name: "canary-route",
|
|
Type: "HTTP",
|
|
Hostnames: new List<string> { "app.example.com" },
|
|
GatewayRef: null,
|
|
Rules: new List<RouteRule>
|
|
{
|
|
new(
|
|
Matches: new List<RouteMatch> { new("PathPrefix", "/", null) },
|
|
BackendRefs: new List<RouteBackendRef>
|
|
{
|
|
new("app-stable", 80, 90),
|
|
new("app-canary", 80, 10)
|
|
},
|
|
Filters: null)
|
|
});
|
|
|
|
// Act
|
|
|
|
string yaml = ManifestGenerator.GenerateRoute("app", "prod", route);
|
|
|
|
// Assert
|
|
|
|
yaml.Should().Contain("name: app-stable");
|
|
yaml.Should().Contain("weight: 90");
|
|
yaml.Should().Contain("name: app-canary");
|
|
yaml.Should().Contain("weight: 10");
|
|
}
|
|
|
|
// ─── Legacy Service/Route backward compatibility ────────────────────
|
|
|
|
[Fact]
|
|
public void GenerateService_FromDeploymentSpec_StillWorksForBackwardCompat()
|
|
{
|
|
// Arrange — The old single-service path using DeploymentSpec's
|
|
// ServicePort/ContainerPort still works when no Services list is provided.
|
|
|
|
DeploymentSpec spec = new(
|
|
Image: "api",
|
|
Tag: "v1",
|
|
Replicas: 1,
|
|
ContainerPort: 8080,
|
|
ServicePort: 80,
|
|
HostName: null,
|
|
PathPrefix: null,
|
|
EnvironmentVariables: null,
|
|
Resources: null);
|
|
|
|
// Act — The old overload should still exist.
|
|
|
|
string yaml = ManifestGenerator.GenerateService("api", "dev", spec);
|
|
|
|
// Assert
|
|
|
|
yaml.Should().Contain("kind: Service");
|
|
yaml.Should().Contain("port: 80");
|
|
yaml.Should().Contain("targetPort: 8080");
|
|
}
|
|
|
|
// ─── Namespace Manifest ──────────────────────────────────────────────
|
|
|
|
[Fact]
|
|
public void GenerateNamespace_ProducesValidNamespaceManifest()
|
|
{
|
|
// Act
|
|
|
|
string yaml = ManifestGenerator.GenerateNamespace("my-app-dev");
|
|
|
|
// Assert
|
|
|
|
yaml.Should().Contain("kind: Namespace");
|
|
yaml.Should().Contain("name: my-app-dev");
|
|
yaml.Should().Contain("app.kubernetes.io/managed-by: entkube");
|
|
}
|
|
|
|
// ─── ServiceAccount Manifest ─────────────────────────────────────────
|
|
|
|
[Fact]
|
|
public void GenerateServiceAccount_ProducesValidManifest()
|
|
{
|
|
// Act
|
|
|
|
string yaml = ManifestGenerator.GenerateServiceAccount("my-sa", "my-ns");
|
|
|
|
// Assert
|
|
|
|
yaml.Should().Contain("kind: ServiceAccount");
|
|
yaml.Should().Contain("name: my-sa");
|
|
yaml.Should().Contain("namespace: my-ns");
|
|
}
|
|
|
|
// ─── PersistentVolumeClaim Manifest ──────────────────────────────────
|
|
|
|
[Fact]
|
|
public void GeneratePersistentVolumeClaim_ProducesValidManifest()
|
|
{
|
|
// Act
|
|
|
|
string yaml = ManifestGenerator.GeneratePersistentVolumeClaim("data-pvc", "my-ns", "5Gi");
|
|
|
|
// Assert
|
|
|
|
yaml.Should().Contain("kind: PersistentVolumeClaim");
|
|
yaml.Should().Contain("name: data-pvc");
|
|
yaml.Should().Contain("namespace: my-ns");
|
|
yaml.Should().Contain("storage: 5Gi");
|
|
yaml.Should().Contain("ReadWriteOnce");
|
|
}
|
|
|
|
[Fact]
|
|
public void GeneratePersistentVolumeClaim_WithStorageClass_IncludesIt()
|
|
{
|
|
// Act
|
|
|
|
string yaml = ManifestGenerator.GeneratePersistentVolumeClaim(
|
|
"data-pvc", "my-ns", "10Gi", "ReadWriteMany", "fast-ssd");
|
|
|
|
// Assert
|
|
|
|
yaml.Should().Contain("storageClassName: fast-ssd");
|
|
yaml.Should().Contain("ReadWriteMany");
|
|
}
|
|
|
|
// ─── Image Pull Secrets ──────────────────────────────────────────────
|
|
|
|
[Fact]
|
|
public void GenerateDeployment_WithHarborRegistry_IncludesImagePullSecrets()
|
|
{
|
|
// Arrange — When an app uses Harbor as its image registry, the
|
|
// generated deployment should include an imagePullSecrets reference
|
|
// so Kubernetes can authenticate against the private registry.
|
|
|
|
DeploymentSpec spec = new(
|
|
Image: "harbor.example.com/myproject/api",
|
|
Tag: "v1.0.0",
|
|
Replicas: 1,
|
|
ContainerPort: 8080,
|
|
ServicePort: 80,
|
|
HostName: null,
|
|
PathPrefix: null,
|
|
EnvironmentVariables: null,
|
|
Resources: null,
|
|
ImageRegistry: new ImageRegistryConfig(
|
|
ImageRegistrySource.Harbor,
|
|
HarborDomain: "harbor.example.com",
|
|
HarborProject: "myproject",
|
|
PullSecretName: null));
|
|
|
|
// Act
|
|
|
|
string yaml = ManifestGenerator.GenerateDeployment("my-api", "dev", spec);
|
|
|
|
// Assert — The YAML should contain the auto-generated pull secret name.
|
|
|
|
yaml.Should().Contain("imagePullSecrets:");
|
|
yaml.Should().Contain("harbor-pull-myproject-my-api");
|
|
}
|
|
|
|
[Fact]
|
|
public void GenerateDeployment_WithCustomRegistry_IncludesSpecifiedPullSecret()
|
|
{
|
|
// Arrange — A custom registry with a manually specified pull secret name.
|
|
|
|
DeploymentSpec spec = new(
|
|
Image: "ghcr.io/org/api",
|
|
Tag: "latest",
|
|
Replicas: 1,
|
|
ContainerPort: 8080,
|
|
ServicePort: 80,
|
|
HostName: null,
|
|
PathPrefix: null,
|
|
EnvironmentVariables: null,
|
|
Resources: null,
|
|
ImageRegistry: new ImageRegistryConfig(
|
|
ImageRegistrySource.Custom,
|
|
HarborDomain: null,
|
|
HarborProject: null,
|
|
PullSecretName: "my-ghcr-secret"));
|
|
|
|
// Act
|
|
|
|
string yaml = ManifestGenerator.GenerateDeployment("api", "prod", spec);
|
|
|
|
// Assert
|
|
|
|
yaml.Should().Contain("imagePullSecrets:");
|
|
yaml.Should().Contain("my-ghcr-secret");
|
|
}
|
|
|
|
[Fact]
|
|
public void GenerateDeployment_WithPublicRegistry_OmitsImagePullSecrets()
|
|
{
|
|
// Arrange — A public registry needs no pull secret.
|
|
|
|
DeploymentSpec spec = new(
|
|
Image: "nginx",
|
|
Tag: "latest",
|
|
Replicas: 1,
|
|
ContainerPort: 80,
|
|
ServicePort: 80,
|
|
HostName: null,
|
|
PathPrefix: null,
|
|
EnvironmentVariables: null,
|
|
Resources: null);
|
|
|
|
// Act
|
|
|
|
string yaml = ManifestGenerator.GenerateDeployment("web", "default", spec);
|
|
|
|
// Assert — No imagePullSecrets section should appear.
|
|
|
|
yaml.Should().NotContain("imagePullSecrets:");
|
|
}
|
|
}
|