Files
Entkube/tests/EntKube.Provisioning.Tests/Features/ManifestGeneratorTests.cs
2026-05-13 14:01:32 +02:00

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:");
}
}