diff --git a/.DS_Store b/.DS_Store
new file mode 100644
index 0000000..82727ea
Binary files /dev/null and b/.DS_Store differ
diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..f54105e
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,16 @@
+**/.dockerignore
+**/.git
+**/.gitignore
+**/.vs
+**/.vscode
+**/bin
+**/obj
+**/out
+**/node_modules
+**/Charts
+**/*.user
+**/*.dbmdl
+**/*.jfm
+**/secrets.dev.yaml
+**/values.dev.yaml
+**/Tiltfile
diff --git a/.gitignore b/.gitignore
index b559e23..f9808d5 100644
--- a/.gitignore
+++ b/.gitignore
@@ -66,6 +66,9 @@ artifacts/
# ASP.NET Scaffolding
ScaffoldingReadMe.txt
+# Local dev data persistence
+**/App_Data/
+
# StyleCop
StyleCopReport.xml
@@ -414,3 +417,8 @@ FodyWeavers.xsd
# Built Visual Studio Code Extensions
*.vsix
+
+# Local SQLite databases
+*.db
+*.db-shm
+*.db-wal
diff --git a/.vscode/launch.json b/.vscode/launch.json
new file mode 100644
index 0000000..06509dd
--- /dev/null
+++ b/.vscode/launch.json
@@ -0,0 +1,93 @@
+{
+ "version": "0.2.0",
+ "configurations": [
+ {
+ "name": "Web (BFF)",
+ "type": "coreclr",
+ "request": "launch",
+ "preLaunchTask": "build-web",
+ "program": "${workspaceFolder}/src/EntKube.Web/bin/Debug/net10.0/EntKube.Web.dll",
+ "args": [],
+ "cwd": "${workspaceFolder}/src/EntKube.Web",
+ "stopAtEntry": false,
+ "serverReadyAction": {
+ "action": "openExternally",
+ "pattern": "\\bNow listening on:\\s+(https?://\\S+)"
+ },
+ "env": {
+ "ASPNETCORE_ENVIRONMENT": "Development",
+ "ASPNETCORE_URLS": "http://localhost:5000"
+ }
+ },
+ {
+ "name": "Clusters API",
+ "type": "coreclr",
+ "request": "launch",
+ "preLaunchTask": "build-clusters",
+ "program": "${workspaceFolder}/src/EntKube.Clusters/bin/Debug/net10.0/EntKube.Clusters.dll",
+ "args": [],
+ "cwd": "${workspaceFolder}/src/EntKube.Clusters",
+ "stopAtEntry": false,
+ "env": {
+ "ASPNETCORE_ENVIRONMENT": "Development",
+ "ASPNETCORE_URLS": "http://localhost:5010"
+ }
+ },
+ {
+ "name": "Identity API",
+ "type": "coreclr",
+ "request": "launch",
+ "preLaunchTask": "build-identity",
+ "program": "${workspaceFolder}/src/EntKube.Identity/bin/Debug/net10.0/EntKube.Identity.dll",
+ "args": [],
+ "cwd": "${workspaceFolder}/src/EntKube.Identity",
+ "stopAtEntry": false,
+ "env": {
+ "ASPNETCORE_ENVIRONMENT": "Development",
+ "ASPNETCORE_URLS": "http://localhost:5030"
+ }
+ },
+ {
+ "name": "Provisioning API",
+ "type": "coreclr",
+ "request": "launch",
+ "preLaunchTask": "build-provisioning",
+ "program": "${workspaceFolder}/src/EntKube.Provisioning/bin/Debug/net10.0/EntKube.Provisioning.dll",
+ "args": [],
+ "cwd": "${workspaceFolder}/src/EntKube.Provisioning",
+ "stopAtEntry": false,
+ "env": {
+ "ASPNETCORE_ENVIRONMENT": "Development",
+ "ASPNETCORE_URLS": "http://localhost:5020"
+ }
+ },
+ {
+ "name": "Secrets API",
+ "type": "coreclr",
+ "request": "launch",
+ "preLaunchTask": "build-secrets",
+ "program": "${workspaceFolder}/src/EntKube.Secrets/bin/Debug/net10.0/EntKube.Secrets.dll",
+ "args": [],
+ "cwd": "${workspaceFolder}/src/EntKube.Secrets",
+ "stopAtEntry": false,
+ "env": {
+ "ASPNETCORE_ENVIRONMENT": "Development",
+ "ASPNETCORE_URLS": "http://localhost:5040"
+ }
+ }
+ ],
+ "compounds": [
+ {
+ "name": "All Services",
+ "configurations": [
+ "Web (BFF)",
+ "Clusters API",
+ "Identity API",
+ "Provisioning API",
+ "Secrets API"
+ ],
+ "preLaunchTask": "build-all",
+ "stopAll": true
+ }
+ ]
+}
diff --git a/.vscode/tasks.json b/.vscode/tasks.json
new file mode 100644
index 0000000..bfc5f33
--- /dev/null
+++ b/.vscode/tasks.json
@@ -0,0 +1,96 @@
+{
+ "version": "2.0.0",
+ "tasks": [
+ {
+ "label": "build-web",
+ "command": "dotnet",
+ "type": "process",
+ "args": [
+ "build",
+ "${workspaceFolder}/src/EntKube.Web/EntKube.Web.csproj",
+ "/property:GenerateFullPaths=true",
+ "/consoleloggerparameters:NoSummary;ForceNoAlign"
+ ],
+ "problemMatcher": "$msCompile"
+ },
+ {
+ "label": "build-clusters",
+ "command": "dotnet",
+ "type": "process",
+ "args": [
+ "build",
+ "${workspaceFolder}/src/EntKube.Clusters/EntKube.Clusters.csproj",
+ "/property:GenerateFullPaths=true",
+ "/consoleloggerparameters:NoSummary;ForceNoAlign"
+ ],
+ "problemMatcher": "$msCompile"
+ },
+ {
+ "label": "build-identity",
+ "command": "dotnet",
+ "type": "process",
+ "args": [
+ "build",
+ "${workspaceFolder}/src/EntKube.Identity/EntKube.Identity.csproj",
+ "/property:GenerateFullPaths=true",
+ "/consoleloggerparameters:NoSummary;ForceNoAlign"
+ ],
+ "problemMatcher": "$msCompile"
+ },
+ {
+ "label": "build-provisioning",
+ "command": "dotnet",
+ "type": "process",
+ "args": [
+ "build",
+ "${workspaceFolder}/src/EntKube.Provisioning/EntKube.Provisioning.csproj",
+ "/property:GenerateFullPaths=true",
+ "/consoleloggerparameters:NoSummary;ForceNoAlign"
+ ],
+ "problemMatcher": "$msCompile"
+ },
+ {
+ "label": "build-secrets",
+ "command": "dotnet",
+ "type": "process",
+ "args": [
+ "build",
+ "${workspaceFolder}/src/EntKube.Secrets/EntKube.Secrets.csproj",
+ "/property:GenerateFullPaths=true",
+ "/consoleloggerparameters:NoSummary;ForceNoAlign"
+ ],
+ "problemMatcher": "$msCompile"
+ },
+ {
+ "label": "build-all",
+ "command": "dotnet",
+ "type": "process",
+ "args": [
+ "build",
+ "${workspaceFolder}/EntKube.slnx",
+ "/property:GenerateFullPaths=true",
+ "/consoleloggerparameters:NoSummary;ForceNoAlign"
+ ],
+ "problemMatcher": "$msCompile",
+ "group": {
+ "kind": "build",
+ "isDefault": true
+ }
+ },
+ {
+ "label": "test-all",
+ "command": "dotnet",
+ "type": "process",
+ "args": [
+ "test",
+ "${workspaceFolder}/EntKube.slnx",
+ "--no-build"
+ ],
+ "problemMatcher": "$msCompile",
+ "group": {
+ "kind": "test",
+ "isDefault": true
+ }
+ }
+ ]
+}
diff --git a/Charts/entkube/templates/secrets-deployment.yaml b/Charts/entkube/templates/secrets-deployment.yaml
new file mode 100644
index 0000000..3e3a11c
--- /dev/null
+++ b/Charts/entkube/templates/secrets-deployment.yaml
@@ -0,0 +1,51 @@
+{{- if .Values.secrets.enabled }}
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: {{ include "entkube.fullname" . }}-secrets
+ labels:
+ {{- include "entkube.labels" . | nindent 4 }}
+ app.kubernetes.io/component: secrets
+spec:
+ replicas: {{ .Values.secrets.replicaCount }}
+ selector:
+ matchLabels:
+ {{- include "entkube.selectorLabels" . | nindent 6 }}
+ app.kubernetes.io/component: secrets
+ template:
+ metadata:
+ labels:
+ {{- include "entkube.selectorLabels" . | nindent 8 }}
+ app.kubernetes.io/component: secrets
+ spec:
+ {{- with .Values.imagePullSecrets }}
+ imagePullSecrets:
+ {{- toYaml . | nindent 8 }}
+ {{- end }}
+ serviceAccountName: {{ include "entkube.serviceAccountName" . }}
+ containers:
+ - name: secrets
+ image: {{ include "entkube.image" (dict "root" . "component" .Values.secrets) }}
+ imagePullPolicy: {{ .Values.image.pullPolicy }}
+ ports:
+ - name: http
+ containerPort: {{ .Values.secrets.port }}
+ protocol: TCP
+ livenessProbe:
+ httpGet:
+ path: /health
+ port: http
+ initialDelaySeconds: 10
+ periodSeconds: 30
+ readinessProbe:
+ httpGet:
+ path: /health/ready
+ port: http
+ initialDelaySeconds: 5
+ periodSeconds: 10
+ resources:
+ {{- toYaml .Values.secrets.resources | nindent 12 }}
+ env:
+ - name: ASPNETCORE_URLS
+ value: "http://+:{{ .Values.secrets.port }}"
+{{- end }}
diff --git a/Charts/entkube/templates/secrets-service.yaml b/Charts/entkube/templates/secrets-service.yaml
new file mode 100644
index 0000000..4ab2715
--- /dev/null
+++ b/Charts/entkube/templates/secrets-service.yaml
@@ -0,0 +1,19 @@
+{{- if .Values.secrets.enabled }}
+apiVersion: v1
+kind: Service
+metadata:
+ name: {{ include "entkube.fullname" . }}-secrets
+ labels:
+ {{- include "entkube.labels" . | nindent 4 }}
+ app.kubernetes.io/component: secrets
+spec:
+ type: ClusterIP
+ ports:
+ - port: {{ .Values.secrets.port }}
+ targetPort: http
+ protocol: TCP
+ name: http
+ selector:
+ {{- include "entkube.selectorLabels" . | nindent 4 }}
+ app.kubernetes.io/component: secrets
+{{- end }}
diff --git a/Charts/entkube/values-dev.yaml b/Charts/entkube/values-dev.yaml
new file mode 100644
index 0000000..fb073d2
--- /dev/null
+++ b/Charts/entkube/values-dev.yaml
@@ -0,0 +1,91 @@
+# Default values override for local development (k3d/kind/minikube)
+
+replicaCount: 1
+
+image:
+ registry: localhost:5050
+ pullPolicy: Always
+ tag: "dev"
+
+web:
+ enabled: true
+ replicaCount: 1
+ image:
+ repository: entkube/web
+ port: 5000
+ resources:
+ requests:
+ cpu: 50m
+ memory: 128Mi
+ limits:
+ cpu: 500m
+ memory: 512Mi
+
+clusters:
+ enabled: true
+ replicaCount: 1
+ image:
+ repository: entkube/clusters
+ port: 5010
+ resources:
+ requests:
+ cpu: 50m
+ memory: 128Mi
+ limits:
+ cpu: 500m
+ memory: 256Mi
+
+provisioning:
+ enabled: true
+ replicaCount: 1
+ image:
+ repository: entkube/provisioning
+ port: 5020
+ resources:
+ requests:
+ cpu: 50m
+ memory: 128Mi
+ limits:
+ cpu: 500m
+ memory: 256Mi
+
+identity:
+ enabled: true
+ replicaCount: 1
+ image:
+ repository: entkube/identity
+ port: 5030
+ resources:
+ requests:
+ cpu: 50m
+ memory: 128Mi
+ limits:
+ cpu: 500m
+ memory: 256Mi
+
+secrets:
+ enabled: true
+ replicaCount: 1
+ image:
+ repository: entkube/secrets
+ port: 5040
+ resources:
+ requests:
+ cpu: 50m
+ memory: 128Mi
+ limits:
+ cpu: 500m
+ memory: 256Mi
+
+ingress:
+ enabled: true
+ provider: traefik
+ host: entkube.localhost
+ tls:
+ enabled: false
+ secretName: ""
+
+serviceAccount:
+ create: true
+ name: ""
+ annotations: {}
diff --git a/Charts/entkube/values.yaml b/Charts/entkube/values.yaml
index bd0cc41..b515e19 100644
--- a/Charts/entkube/values.yaml
+++ b/Charts/entkube/values.yaml
@@ -68,6 +68,20 @@ identity:
cpu: 500m
memory: 256Mi
+secrets:
+ enabled: true
+ replicaCount: 1
+ image:
+ repository: entkube/secrets
+ port: 5040
+ resources:
+ requests:
+ cpu: 100m
+ memory: 128Mi
+ limits:
+ cpu: 500m
+ memory: 256Mi
+
# Ingress configuration — select your strategy
ingress:
enabled: true
diff --git a/Directory.Build.props b/Directory.Build.props
new file mode 100644
index 0000000..569df69
--- /dev/null
+++ b/Directory.Build.props
@@ -0,0 +1,7 @@
+
+
+ false
+ false
+ false
+
+
diff --git a/EntKube.slnx b/EntKube.slnx
index 56a677c..cbd8e1f 100644
--- a/EntKube.slnx
+++ b/EntKube.slnx
@@ -4,11 +4,13 @@
+
+
diff --git a/Tiltfile b/Tiltfile
new file mode 100644
index 0000000..ce0ceff
--- /dev/null
+++ b/Tiltfile
@@ -0,0 +1,92 @@
+# -*- mode: Python -*-
+
+# EntKube Tiltfile — Helm-based local Kubernetes development
+#
+# Prerequisites:
+# - Docker Desktop or Rancher Desktop
+# - k3d (recommended): k3d cluster create entkube --registry-create entkube-registry:0.0.0.0:5050 -p "8080:80@loadbalancer"
+# - Tilt: https://docs.tilt.dev/install.html
+# - Helm 3
+#
+# Usage:
+# tilt up
+#
+# This watches source code, rebuilds Docker images, and redeploys via Helm.
+
+# Allow the k3d local registry
+allow_k8s_contexts(['k3d-entkube'])
+
+# Use the local registry created by k3d
+default_registry('localhost:5050')
+
+# Docker builds for each service — context is the repo root so COPY paths work
+docker_build(
+ 'entkube/web',
+ '.',
+ dockerfile='src/EntKube.Web/Dockerfile',
+ live_update=[
+ sync('src/EntKube.Web/', '/src/src/EntKube.Web/'),
+ sync('src/EntKube.Web.Client/', '/src/src/EntKube.Web.Client/'),
+ sync('src/EntKube.SharedKernel/', '/src/src/EntKube.SharedKernel/'),
+ run('cd /src/src/EntKube.Web && dotnet build -c Release -o /app', trigger=['src/EntKube.Web/', 'src/EntKube.Web.Client/', 'src/EntKube.SharedKernel/']),
+ ]
+)
+
+docker_build(
+ 'entkube/clusters',
+ '.',
+ dockerfile='src/EntKube.Clusters/Dockerfile',
+ live_update=[
+ sync('src/EntKube.Clusters/', '/src/src/EntKube.Clusters/'),
+ sync('src/EntKube.SharedKernel/', '/src/src/EntKube.SharedKernel/'),
+ run('cd /src/src/EntKube.Clusters && dotnet build -c Release -o /app', trigger=['src/EntKube.Clusters/', 'src/EntKube.SharedKernel/']),
+ ]
+)
+
+docker_build(
+ 'entkube/identity',
+ '.',
+ dockerfile='src/EntKube.Identity/Dockerfile',
+ live_update=[
+ sync('src/EntKube.Identity/', '/src/src/EntKube.Identity/'),
+ sync('src/EntKube.SharedKernel/', '/src/src/EntKube.SharedKernel/'),
+ run('cd /src/src/EntKube.Identity && dotnet build -c Release -o /app', trigger=['src/EntKube.Identity/', 'src/EntKube.SharedKernel/']),
+ ]
+)
+
+docker_build(
+ 'entkube/provisioning',
+ '.',
+ dockerfile='src/EntKube.Provisioning/Dockerfile',
+ live_update=[
+ sync('src/EntKube.Provisioning/', '/src/src/EntKube.Provisioning/'),
+ sync('src/EntKube.SharedKernel/', '/src/src/EntKube.SharedKernel/'),
+ run('cd /src/src/EntKube.Provisioning && dotnet build -c Release -o /app', trigger=['src/EntKube.Provisioning/', 'src/EntKube.SharedKernel/']),
+ ]
+)
+
+docker_build(
+ 'entkube/secrets',
+ '.',
+ dockerfile='src/EntKube.Secrets/Dockerfile',
+ live_update=[
+ sync('src/EntKube.Secrets/', '/src/src/EntKube.Secrets/'),
+ sync('src/EntKube.SharedKernel/', '/src/src/EntKube.SharedKernel/'),
+ run('cd /src/src/EntKube.Secrets && dotnet build -c Release -o /app', trigger=['src/EntKube.Secrets/', 'src/EntKube.SharedKernel/']),
+ ]
+)
+
+# Deploy via Helm chart with dev values
+k8s_yaml(helm(
+ 'Charts/entkube',
+ name='entkube',
+ namespace='default',
+ values=['Charts/entkube/values-dev.yaml'],
+))
+
+# Resource grouping for the Tilt UI
+k8s_resource('entkube-web', port_forwards='5000:5000', labels=['entkube'])
+k8s_resource('entkube-clusters', port_forwards='5010:5010', labels=['entkube'])
+k8s_resource('entkube-provisioning', port_forwards='5020:5020', labels=['entkube'])
+k8s_resource('entkube-identity', port_forwards='5030:5030', labels=['entkube'])
+k8s_resource('entkube-secrets', port_forwards='5040:5040', labels=['entkube'])
diff --git a/src/EntKube.Clusters/Dockerfile b/src/EntKube.Clusters/Dockerfile
new file mode 100644
index 0000000..0ec1f22
--- /dev/null
+++ b/src/EntKube.Clusters/Dockerfile
@@ -0,0 +1,21 @@
+FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base
+WORKDIR /app
+EXPOSE 5010
+
+FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
+WORKDIR /src
+COPY ["src/EntKube.Clusters/EntKube.Clusters.csproj", "src/EntKube.Clusters/"]
+COPY ["src/EntKube.SharedKernel/EntKube.SharedKernel.csproj", "src/EntKube.SharedKernel/"]
+RUN dotnet restore "src/EntKube.Clusters/EntKube.Clusters.csproj"
+COPY . .
+WORKDIR "/src/src/EntKube.Clusters"
+RUN dotnet build -c Release -o /app/build
+
+FROM build AS publish
+RUN dotnet publish -c Release -o /app/publish /p:UseAppHost=false
+
+FROM base AS final
+WORKDIR /app
+COPY --from=publish /app/publish .
+ENV ASPNETCORE_URLS=http://+:5010
+ENTRYPOINT ["dotnet", "EntKube.Clusters.dll"]
diff --git a/src/EntKube.Clusters/Domain/ClusterComponent.cs b/src/EntKube.Clusters/Domain/ClusterComponent.cs
new file mode 100644
index 0000000..f3637b3
--- /dev/null
+++ b/src/EntKube.Clusters/Domain/ClusterComponent.cs
@@ -0,0 +1,83 @@
+using EntKube.Clusters.Features.AdoptCluster;
+
+namespace EntKube.Clusters.Domain;
+
+///
+/// Represents a tracked component on a cluster. Once detected during an adoption
+/// scan, the component is persisted so subsequent page loads can display state
+/// without hitting the cluster API again. Configuration changes (via deploy or
+/// configure operations) update this record in-place to reflect current state.
+///
+public class ClusterComponent
+{
+ public string ComponentName { get; private set; } = string.Empty;
+ public ComponentStatus Status { get; private set; }
+ public string? Version { get; private set; }
+ public string? Namespace { get; private set; }
+ public string? HelmReleaseName { get; private set; }
+ public Dictionary Configuration { get; private set; } = new();
+ public DateTimeOffset LastCheckedAt { get; private set; }
+
+ private ClusterComponent() { }
+
+ ///
+ /// Creates a component record from an adoption check result. This captures
+ /// a snapshot of what the scan discovered — status, version, namespace,
+ /// Helm release, and running configuration values.
+ ///
+ public static ClusterComponent FromCheckResult(ComponentCheckResult result)
+ {
+ return new ClusterComponent
+ {
+ ComponentName = result.ComponentName,
+ Status = result.Status,
+ Version = result.Configuration?.Version,
+ Namespace = result.Configuration?.Namespace,
+ HelmReleaseName = result.Configuration?.HelmReleaseName,
+ Configuration = result.Configuration?.Values ?? new Dictionary(),
+ LastCheckedAt = DateTimeOffset.UtcNow
+ };
+ }
+
+ ///
+ /// Merges new configuration values into the existing configuration.
+ /// Existing keys are overwritten, new keys are added, keys not in the
+ /// update are left unchanged.
+ ///
+ public void MergeConfiguration(Dictionary values)
+ {
+ foreach (KeyValuePair kvp in values)
+ {
+ Configuration[kvp.Key] = kvp.Value;
+ }
+ }
+
+ ///
+ /// After a successful deployment, mark the component as Installed.
+ ///
+ public void MarkInstalled()
+ {
+ Status = ComponentStatus.Installed;
+ LastCheckedAt = DateTimeOffset.UtcNow;
+ }
+
+ ///
+ /// Updates the tracked version after a successful upgrade. The version
+ /// string comes from Helm or the installer and reflects what's now
+ /// running on the cluster.
+ ///
+ public void UpdateVersion(string version)
+ {
+ Version = version;
+ LastCheckedAt = DateTimeOffset.UtcNow;
+ }
+
+ ///
+ /// After a successful uninstall, mark the component as NotInstalled.
+ ///
+ public void MarkUninstalled()
+ {
+ Status = ComponentStatus.NotInstalled;
+ LastCheckedAt = DateTimeOffset.UtcNow;
+ }
+}
diff --git a/src/EntKube.Clusters/Domain/KubernetesCluster.cs b/src/EntKube.Clusters/Domain/KubernetesCluster.cs
index e5a89b7..bca26ae 100644
--- a/src/EntKube.Clusters/Domain/KubernetesCluster.cs
+++ b/src/EntKube.Clusters/Domain/KubernetesCluster.cs
@@ -1,29 +1,45 @@
+using EntKube.Clusters.Features.AdoptCluster;
+
namespace EntKube.Clusters.Domain;
///
/// A KubernetesCluster represents a registered cluster that EntKube manages.
/// It holds the connection details, health state, and metadata needed to
-/// interact with the cluster's API server. This is the aggregate root for
-/// all cluster-related operations.
+/// interact with the cluster's API server. Every cluster belongs to exactly
+/// one tenant — no cluster can exist without an owning tenant. The kubeconfig
+/// is stored as the credential material for API access.
///
public class KubernetesCluster
{
public Guid Id { get; private set; }
+ public Guid TenantId { get; private set; }
+ public Guid EnvironmentId { get; private set; }
public string Name { get; private set; } = string.Empty;
public string ApiServerUrl { get; private set; } = string.Empty;
+ public string ContextName { get; private set; } = string.Empty;
public ClusterStatus Status { get; private set; }
- public string? KubeConfigSecret { get; private set; }
+ public CloudProvider Provider { get; private set; }
+ public ProviderCredentials? ProviderCredentials { get; private set; }
+ public string KubeConfig { get; private set; } = string.Empty;
public DateTimeOffset RegisteredAt { get; private set; }
public DateTimeOffset? LastHealthCheckAt { get; private set; }
+ public IReadOnlyList PrometheusEndpoints => prometheusEndpoints.AsReadOnly();
+ public IReadOnlyList Components => components.AsReadOnly();
+ public IReadOnlyList StorageBuckets => storageBuckets.AsReadOnly();
+
+ private List prometheusEndpoints = new();
+ private List components = new();
+ private List storageBuckets = new();
private KubernetesCluster() { }
///
- /// Registers a new cluster in the platform. At this point, we know the cluster
- /// exists and we have connection details — but we haven't verified connectivity yet.
+ /// Registers a new cluster in the platform. The caller must provide a tenant,
+ /// cluster name, API server URL, kubeconfig, and the environment it belongs to.
/// The cluster starts in a Pending state until a health check confirms it's reachable.
+ /// Every cluster must be assigned to an environment — no orphan clusters allowed.
///
- public static KubernetesCluster Register(string name, string apiServerUrl, string? kubeConfigSecret)
+ public static KubernetesCluster Register(string name, string apiServerUrl, string kubeConfig, Guid tenantId, string contextName, Guid environmentId)
{
if (string.IsNullOrWhiteSpace(name))
{
@@ -35,12 +51,35 @@ public class KubernetesCluster
throw new ArgumentException("API server URL is required.", nameof(apiServerUrl));
}
+ if (string.IsNullOrWhiteSpace(kubeConfig))
+ {
+ throw new ArgumentException("KubeConfig is required.", nameof(kubeConfig));
+ }
+
+ if (tenantId == Guid.Empty)
+ {
+ throw new ArgumentException("A tenant must be specified.", nameof(tenantId));
+ }
+
+ if (string.IsNullOrWhiteSpace(contextName))
+ {
+ throw new ArgumentException("A context name must be specified.", nameof(contextName));
+ }
+
+ if (environmentId == Guid.Empty)
+ {
+ throw new ArgumentException("An environment must be specified.", nameof(environmentId));
+ }
+
return new KubernetesCluster
{
Id = Guid.NewGuid(),
+ TenantId = tenantId,
+ EnvironmentId = environmentId,
Name = name,
ApiServerUrl = apiServerUrl,
- KubeConfigSecret = kubeConfigSecret,
+ ContextName = contextName,
+ KubeConfig = kubeConfig,
Status = ClusterStatus.Pending,
RegisteredAt = DateTimeOffset.UtcNow
};
@@ -66,8 +105,167 @@ public class KubernetesCluster
Status = ClusterStatus.Unreachable;
LastHealthCheckAt = DateTimeOffset.UtcNow;
}
+
+ ///
+ /// Assigns (or reassigns) this cluster to an environment. A cluster lives
+ /// in exactly one environment at a time — for example dev01 belongs to "dev",
+ /// test01 and test02 belong to "test", prod01 belongs to "prod".
+ ///
+ public void AssignToEnvironment(Guid environmentId)
+ {
+ if (environmentId == Guid.Empty)
+ {
+ throw new ArgumentException("Environment ID is required.", nameof(environmentId));
+ }
+
+ EnvironmentId = environmentId;
+ }
+
+ ///
+ /// Replaces all discovered Prometheus endpoints with a fresh scan result.
+ /// Called after the platform scans the cluster for Prometheus services.
+ /// If multiple instances are found, they are all stored so metrics can
+ /// be aggregated from all of them.
+ ///
+ public void SetPrometheusEndpoints(List endpoints)
+ {
+ prometheusEndpoints = endpoints ?? new List();
+ }
+
+ ///
+ /// Replaces all tracked components with a fresh scan result from the adoption
+ /// check. Each component check result is converted into a ClusterComponent
+ /// that persists the detected state — status, version, namespace, config.
+ /// This is called after every adoption scan so stored state stays current.
+ ///
+ public void UpdateComponents(List checkResults)
+ {
+ components = checkResults
+ .Select(ClusterComponent.FromCheckResult)
+ .ToList();
+ }
+
+ ///
+ /// Merges new configuration values into an already-tracked component.
+ /// Called after a successful configure operation so the persisted config
+ /// reflects what's actually deployed. If the component hasn't been
+ /// tracked yet (no prior scan), this is a no-op.
+ ///
+ public void UpdateComponentConfiguration(string componentName, Dictionary values)
+ {
+ ClusterComponent? component = components.FirstOrDefault(
+ c => c.ComponentName.Equals(componentName, StringComparison.OrdinalIgnoreCase));
+
+ component?.MergeConfiguration(values);
+ }
+
+ ///
+ /// Marks a tracked component as Installed after a successful deployment.
+ /// If the component hasn't been tracked yet, this is a no-op.
+ ///
+ public void MarkComponentInstalled(string componentName)
+ {
+ ClusterComponent? component = components.FirstOrDefault(
+ c => c.ComponentName.Equals(componentName, StringComparison.OrdinalIgnoreCase));
+
+ component?.MarkInstalled();
+ }
+
+ ///
+ /// Updates the version of a tracked component after a successful upgrade.
+ /// If the component hasn't been tracked yet, this is a no-op.
+ ///
+ public void UpdateComponentVersion(string componentName, string version)
+ {
+ ClusterComponent? component = components.FirstOrDefault(
+ c => c.ComponentName.Equals(componentName, StringComparison.OrdinalIgnoreCase));
+
+ component?.UpdateVersion(version);
+ }
+
+ ///
+ /// Marks a tracked component as NotInstalled after a successful uninstall.
+ /// If the component hasn't been tracked yet, this is a no-op.
+ ///
+ public void MarkComponentUninstalled(string componentName)
+ {
+ ClusterComponent? component = components.FirstOrDefault(
+ c => c.ComponentName.Equals(componentName, StringComparison.OrdinalIgnoreCase));
+
+ component?.MarkUninstalled();
+ }
+
+ ///
+ /// Assigns a cloud provider to this cluster. Providers like Cleura offer
+ /// additional APIs (OpenStack, Gardener) that EntKube can leverage for
+ /// provisioning, billing, and resource management. Credentials are required
+ /// for any provider other than None.
+ ///
+ public void SetProvider(CloudProvider provider, ProviderCredentials? credentials)
+ {
+ if (provider != CloudProvider.None && credentials is null)
+ {
+ throw new ArgumentException(
+ $"Credentials are required when setting provider to {provider}.",
+ nameof(credentials));
+ }
+
+ Provider = provider;
+ ProviderCredentials = provider == CloudProvider.None ? null : credentials;
+ }
+
+ ///
+ /// Registers a newly created storage bucket on this cluster. The bucket
+ /// has already been created on the S3 backend — this persists the metadata
+ /// so the platform knows about it and components can reference it.
+ ///
+ public void AddStorageBucket(StorageBucket bucket)
+ {
+ storageBuckets.Add(bucket);
+ }
+
+ ///
+ /// Removes a storage bucket from this cluster's tracked buckets.
+ /// Called after the bucket has been deleted from the S3 backend.
+ ///
+ public void RemoveStorageBucket(Guid bucketId)
+ {
+ storageBuckets.RemoveAll(b => b.Id == bucketId);
+ }
}
+///
+/// The cloud provider hosting a Kubernetes cluster. Determines which additional
+/// APIs are available for management (OpenStack, Gardener, billing, etc.).
+///
+public enum CloudProvider
+{
+ None,
+ Cleura,
+ Aws,
+ Azure,
+ Gcp
+}
+
+///
+/// Credentials for authenticating with a cluster's cloud provider APIs.
+/// Cleura exposes two separate APIs:
+/// 1. Cleura Cloud REST API (rest.cleura.cloud) — uses Cleura login credentials
+/// for account management, Gardener K8s cluster ops, billing, domain listing.
+/// 2. OpenStack API (e.g. sto2.citycloud.com:5000) — uses OpenStack/Keystone
+/// credentials for infrastructure: compute, networking, storage, load balancing.
+/// Both credential sets are stored here; either may be null if not configured.
+///
+public record ProviderCredentials(
+ string Username,
+ string Password,
+ string Region,
+ string? OpenStackAuthUrl = null,
+ string? OpenStackProjectId = null,
+ string? OpenStackUsername = null,
+ string? OpenStackPassword = null,
+ string? OpenStackUserDomainName = null);
+
public enum ClusterStatus
{
Pending,
diff --git a/src/EntKube.Clusters/Domain/PrometheusEndpoint.cs b/src/EntKube.Clusters/Domain/PrometheusEndpoint.cs
new file mode 100644
index 0000000..6fd85f0
--- /dev/null
+++ b/src/EntKube.Clusters/Domain/PrometheusEndpoint.cs
@@ -0,0 +1,9 @@
+namespace EntKube.Clusters.Domain;
+
+///
+/// A discovered Prometheus instance running inside a Kubernetes cluster.
+/// Stores the connection URL, the namespace where it runs, and the service name.
+/// Multiple Prometheus endpoints may exist per cluster — metrics are aggregated
+/// from all of them when queried.
+///
+public record PrometheusEndpoint(string Url, string Namespace, string ServiceName);
diff --git a/src/EntKube.Clusters/Domain/StorageBucket.cs b/src/EntKube.Clusters/Domain/StorageBucket.cs
new file mode 100644
index 0000000..bfd7901
--- /dev/null
+++ b/src/EntKube.Clusters/Domain/StorageBucket.cs
@@ -0,0 +1,51 @@
+namespace EntKube.Clusters.Domain;
+
+///
+/// A managed S3-compatible storage bucket provisioned through the platform.
+/// Tracks which credentials own the bucket, its encryption setting, and
+/// connection information so components can wire up to it as a storage backend.
+///
+public class StorageBucket
+{
+ public Guid Id { get; private set; }
+ public string Name { get; private set; } = string.Empty;
+ public string AccessKey { get; private set; } = string.Empty;
+ public string SecretKey { get; private set; } = string.Empty;
+ public string Endpoint { get; private set; } = string.Empty;
+ public string Region { get; private set; } = string.Empty;
+ public bool Encrypted { get; private set; }
+ public DateTimeOffset CreatedAt { get; private set; }
+
+ private StorageBucket() { }
+
+ ///
+ /// Creates a new storage bucket record after successfully provisioning
+ /// the bucket on the S3-compatible backend. The access key and secret key
+ /// are the credentials that own and can access this bucket.
+ ///
+ public static StorageBucket Create(
+ string name,
+ string accessKey,
+ string secretKey,
+ string endpoint,
+ string region,
+ bool encrypted)
+ {
+ if (string.IsNullOrWhiteSpace(name))
+ {
+ throw new ArgumentException("Bucket name is required.", nameof(name));
+ }
+
+ return new StorageBucket
+ {
+ Id = Guid.NewGuid(),
+ Name = name,
+ AccessKey = accessKey,
+ SecretKey = secretKey,
+ Endpoint = endpoint,
+ Region = region,
+ Encrypted = encrypted,
+ CreatedAt = DateTimeOffset.UtcNow
+ };
+ }
+}
diff --git a/src/EntKube.Clusters/EntKube.Clusters.csproj b/src/EntKube.Clusters/EntKube.Clusters.csproj
index 86d7ffb..6e18374 100644
--- a/src/EntKube.Clusters/EntKube.Clusters.csproj
+++ b/src/EntKube.Clusters/EntKube.Clusters.csproj
@@ -7,7 +7,16 @@
+
+
+
+
+
+
+
+
+
diff --git a/src/EntKube.Clusters/Features/AdoptCluster/AdoptClusterEndpoint.cs b/src/EntKube.Clusters/Features/AdoptCluster/AdoptClusterEndpoint.cs
new file mode 100644
index 0000000..5b8393e
--- /dev/null
+++ b/src/EntKube.Clusters/Features/AdoptCluster/AdoptClusterEndpoint.cs
@@ -0,0 +1,30 @@
+using EntKube.SharedKernel.Contracts;
+using EntKube.SharedKernel.Domain;
+
+namespace EntKube.Clusters.Features.AdoptCluster;
+
+///
+/// Maps the adoption endpoints:
+/// - POST /api/clusters/{id}/adopt — runs all checks, returns aggregate report
+/// - GET /api/clusters/{id}/components — lists available components and their status
+///
+public static class AdoptClusterEndpoint
+{
+ public static void Map(IEndpointRouteBuilder app)
+ {
+ app.MapPost("/api/clusters/{id:guid}/adopt", async (
+ Guid id,
+ AdoptClusterHandler handler,
+ CancellationToken ct) =>
+ {
+ Result result = await handler.HandleAsync(id, ct);
+
+ if (result.IsFailure)
+ {
+ return Results.BadRequest(ApiResponse.Fail(result.Error!));
+ }
+
+ return Results.Ok(ApiResponse.Ok(result.Value!));
+ });
+ }
+}
diff --git a/src/EntKube.Clusters/Features/AdoptCluster/AdoptClusterHandler.cs b/src/EntKube.Clusters/Features/AdoptCluster/AdoptClusterHandler.cs
new file mode 100644
index 0000000..c8ae965
--- /dev/null
+++ b/src/EntKube.Clusters/Features/AdoptCluster/AdoptClusterHandler.cs
@@ -0,0 +1,84 @@
+using EntKube.Clusters.Domain;
+using EntKube.SharedKernel.Domain;
+using Microsoft.Extensions.Logging;
+
+namespace EntKube.Clusters.Features.AdoptCluster;
+
+///
+/// Orchestrates the full adoption check by running all registered component checks
+/// against the cluster. Each check runs independently — a failure in one doesn't
+/// prevent the others from reporting their status. The results are aggregated into
+/// a single AdoptionReport that tells the caller exactly what's installed, degraded,
+/// or missing.
+///
+/// Think of this as "terraform plan" for a cluster: it inspects current state
+/// and reports the delta between what exists and what's required.
+///
+public class AdoptClusterHandler
+{
+ private readonly IClusterRepository repository;
+ private readonly IEnumerable checks;
+ private readonly ILogger logger;
+
+ public AdoptClusterHandler(IClusterRepository repository, IEnumerable checks, ILogger logger)
+ {
+ this.repository = repository;
+ this.checks = checks;
+ this.logger = logger;
+ }
+
+ public async Task> HandleAsync(Guid clusterId, CancellationToken ct = default)
+ {
+ logger.LogWarning("[DIAG] AdoptClusterHandler.HandleAsync called for cluster {ClusterId} with {CheckCount} checks registered", clusterId, checks.Count());
+
+ // Find the cluster — can't adopt something that doesn't exist.
+
+ KubernetesCluster? cluster = await repository.GetByIdAsync(clusterId, ct);
+
+ if (cluster is null)
+ {
+ return Result.Failure($"Cluster with ID '{clusterId}' was not found.");
+ }
+
+ // The cluster must be connected (reachable) so we can inspect it.
+ // A Pending or Unreachable cluster can't be checked.
+
+ if (cluster.Status != ClusterStatus.Connected)
+ {
+ return Result.Failure(
+ "Cluster must be connected before adoption. Run a health check to verify connectivity.");
+ }
+
+ // Run all component checks in parallel — they're independent of each other.
+ // Each check connects to the cluster API and inspects its own area.
+
+ List> checkTasks = checks
+ .Select(check => check.CheckAsync(cluster, ct))
+ .ToList();
+
+ ComponentCheckResult[] results = await Task.WhenAll(checkTasks);
+
+ // Log what each check returned for diagnostics.
+
+ foreach (ComponentCheckResult result in results)
+ {
+ logger.LogWarning("[DIAG] Check result: {Component} = {Status}, missing=[{Missing}], details=[{Details}]",
+ result.ComponentName, result.Status,
+ string.Join("; ", result.MissingItems),
+ string.Join("; ", result.Details));
+ }
+
+ // Persist the detected components on the cluster so we don't need to
+ // re-scan next time. The cluster aggregate stores a snapshot of each
+ // component's status and configuration as last seen.
+
+ cluster.UpdateComponents(results.ToList());
+ await repository.UpdateAsync(cluster, ct);
+
+ // Build the aggregate report from all individual results.
+
+ AdoptionReport report = AdoptionReport.FromChecks(results.ToList());
+
+ return Result.Success(report);
+ }
+}
diff --git a/src/EntKube.Clusters/Features/AdoptCluster/AdoptionReport.cs b/src/EntKube.Clusters/Features/AdoptCluster/AdoptionReport.cs
new file mode 100644
index 0000000..3949819
--- /dev/null
+++ b/src/EntKube.Clusters/Features/AdoptCluster/AdoptionReport.cs
@@ -0,0 +1,70 @@
+using System.Text.Json.Serialization;
+
+namespace EntKube.Clusters.Features.AdoptCluster;
+
+///
+/// Aggregates all component check results into a single adoption report.
+/// Each component has its own line item, and the overall status is derived
+/// from the worst individual result — if any component is NotInstalled,
+/// the cluster is NotReady; if some are Degraded, it's PartiallyReady.
+///
+public class AdoptionReport
+{
+ [JsonConverter(typeof(JsonStringEnumConverter))]
+ public AdoptionReadiness Status { get; }
+ public List Components { get; }
+
+ public AdoptionReport(AdoptionReadiness status, List components)
+ {
+ Status = status;
+ Components = components;
+ }
+
+ ///
+ /// Builds the aggregate report from individual component results.
+ /// The overall readiness is the worst status across all components.
+ /// Components are always sorted alphabetically by name for consistent display.
+ ///
+ public static AdoptionReport FromChecks(List results)
+ {
+ // Sort components alphabetically by name so the report is always
+ // presented in a consistent, predictable order.
+
+ List sorted = results
+ .OrderBy(r => r.ComponentName, StringComparer.OrdinalIgnoreCase)
+ .ToList();
+
+ // If any component is entirely missing, the cluster is not ready for adoption.
+
+ if (sorted.Any(r => r.Status == ComponentStatus.NotInstalled))
+ {
+ return new AdoptionReport(AdoptionReadiness.NotReady, sorted);
+ }
+
+ // If all are installed but some are degraded (unhealthy or incomplete),
+ // the cluster is partially ready — it can operate but needs attention.
+
+ if (sorted.Any(r => r.Status == ComponentStatus.Degraded))
+ {
+ return new AdoptionReport(AdoptionReadiness.PartiallyReady, sorted);
+ }
+
+ // Everything is present and healthy — cluster is ready for full adoption.
+
+ return new AdoptionReport(AdoptionReadiness.Ready, sorted);
+ }
+}
+
+///
+/// Overall cluster adoption readiness, derived from the aggregate of all
+/// component checks:
+/// - Ready: all components installed and healthy
+/// - PartiallyReady: some components degraded but present
+/// - NotReady: one or more components entirely missing
+///
+public enum AdoptionReadiness
+{
+ Ready,
+ PartiallyReady,
+ NotReady
+}
diff --git a/src/EntKube.Clusters/Features/AdoptCluster/ComponentSchemas.cs b/src/EntKube.Clusters/Features/AdoptCluster/ComponentSchemas.cs
new file mode 100644
index 0000000..d203c97
--- /dev/null
+++ b/src/EntKube.Clusters/Features/AdoptCluster/ComponentSchemas.cs
@@ -0,0 +1,876 @@
+using EntKube.Clusters.Domain;
+
+namespace EntKube.Clusters.Features.AdoptCluster;
+
+///
+/// Central registry of configuration schemas for all managed components.
+/// Each schema declares what parameters a component accepts, their types,
+/// validation constraints, defaults, and UI groupings. The UI reads these
+/// to render proper typed forms with validation instead of raw key/value editors.
+///
+public static class ComponentSchemas
+{
+ private static readonly Dictionary schemas = new(StringComparer.OrdinalIgnoreCase)
+ {
+ ["kyverno"] = new(
+ ComponentName: "kyverno",
+ DisplayName: "Kyverno",
+ Description: "Policy engine for Kubernetes — validates, mutates, and generates resources",
+ Parameters: new List
+ {
+ new(Key: "admissionReplicas", DisplayName: "Admission Controller Replicas", Description: "Number of admission controller pod replicas",
+ Type: ParameterType.Integer, Group: "Scaling", DefaultValue: "2", Min: 1, Max: 10),
+ new(Key: "backgroundReplicas", DisplayName: "Background Controller Replicas", Description: "Number of background controller pod replicas",
+ Type: ParameterType.Integer, Group: "Scaling", DefaultValue: "2", Min: 1, Max: 10)
+ }),
+
+ ["security-policies"] = new(
+ ComponentName: "security-policies",
+ DisplayName: "Security Policies",
+ Description: "Kyverno ClusterPolicies for security enforcement (image registries, load balancers, seccomp, privilege escalation)",
+ Parameters: new List
+ {
+ new(Key: "validationAction", DisplayName: "Validation Action", Description: "Whether policies audit or enforce violations",
+ Type: ParameterType.Select, Group: "Enforcement", DefaultValue: "Audit", IsRequired: true,
+ AllowedValues: new List { "Audit", "Enforce" }),
+ new(Key: "revertPatches", DisplayName: "Revert Seccomp & ReadOnly Patches", Description: "Deletes the seccomp and readOnlyRootFilesystem policies and reverts workload patches applied during adoption. Use this if workloads are failing due to these security constraints.",
+ Type: ParameterType.Boolean, Group: "Emergency", DefaultValue: "false")
+ }),
+
+ ["cert-manager"] = new(
+ ComponentName: "cert-manager",
+ DisplayName: "cert-manager",
+ Description: "Certificate lifecycle management for Kubernetes — automates TLS certificate issuance and renewal",
+ Parameters: new List
+ {
+ new(Key: "replicas", DisplayName: "Controller Replicas", Description: "Number of cert-manager controller replicas",
+ Type: ParameterType.Integer, Group: "Scaling", DefaultValue: "2", Min: 1, Max: 5)
+ }),
+
+ ["trust-manager"] = new(
+ ComponentName: "trust-manager",
+ DisplayName: "trust-manager",
+ Description: "Distributes trust bundles (CA certificates) across namespaces",
+ Parameters: new List
+ {
+ new(Key: "bundleName", DisplayName: "Bundle Name", Description: "Name of the trust Bundle resource",
+ Type: ParameterType.String, Group: "General", DefaultValue: "entkube-trust-bundle"),
+ new(Key: "addCertificate", DisplayName: "Add Certificate", Description: "Add a new CA certificate to the trust bundle",
+ Type: ParameterType.Boolean, Group: "Certificates", DefaultValue: "false"),
+ new(Key: "certificateName", DisplayName: "Certificate Name", Description: "Name identifier for the certificate being added",
+ Type: ParameterType.String, Group: "Certificates"),
+ new(Key: "certificateData", DisplayName: "Certificate PEM Data", Description: "PEM-encoded CA certificate to add",
+ Type: ParameterType.String, Group: "Certificates"),
+ new(Key: "removeCertificate", DisplayName: "Remove Certificate", Description: "Name of certificate to remove from the bundle",
+ Type: ParameterType.String, Group: "Certificates")
+ }),
+
+ ["internal-ca"] = new(
+ ComponentName: "internal-ca",
+ DisplayName: "Internal CA",
+ Description: "Self-signed Certificate Authority for internal service-to-service TLS",
+ Parameters: new List
+ {
+ new(Key: "caName", DisplayName: "CA Issuer Name", Description: "Name of the CA Issuer resource",
+ Type: ParameterType.String, Group: "General", DefaultValue: "internal-ca"),
+ new(Key: "bundleName", DisplayName: "Trust Bundle Name", Description: "Bundle to inject the CA certificate into",
+ Type: ParameterType.String, Group: "General", DefaultValue: "entkube-trust-bundle"),
+ new(Key: "caDurationDays", DisplayName: "CA Duration (days)", Description: "Lifetime of the CA certificate in days",
+ Type: ParameterType.Integer, Group: "Certificate", DefaultValue: "3650", Min: 30, Max: 36500),
+ new(Key: "caOrganization", DisplayName: "Organization", Description: "Organization name in the CA certificate subject",
+ Type: ParameterType.String, Group: "Certificate", DefaultValue: "EntKube")
+ }),
+
+ ["domain-ca"] = new(
+ ComponentName: "domain-ca",
+ DisplayName: "Domain CA",
+ Description: "Certificate Authority for domain-scoped TLS (e.g., *.tenant.example.com)",
+ Parameters: new List
+ {
+ new(Key: "caName", DisplayName: "CA Issuer Name", Description: "Name of the domain CA Issuer resource",
+ Type: ParameterType.String, Group: "General", DefaultValue: "domain-ca"),
+ new(Key: "domains", DisplayName: "Domains", Description: "Comma-separated list of domains this CA can issue for",
+ Type: ParameterType.String, Group: "General", IsRequired: true),
+ new(Key: "bundleName", DisplayName: "Trust Bundle Name", Description: "Bundle to inject the CA certificate into",
+ Type: ParameterType.String, Group: "General", DefaultValue: "entkube-trust-bundle")
+ }),
+
+ ["istio"] = new(
+ ComponentName: "istio",
+ DisplayName: "Istio",
+ Description: "Service mesh with mTLS, traffic management, and observability",
+ Parameters: new List
+ {
+ new(Key: "pilotReplicas", DisplayName: "Pilot Replicas", Description: "Number of istiod (pilot) replicas",
+ Type: ParameterType.Integer, Group: "Scaling", DefaultValue: "2", Min: 1, Max: 10),
+ new(Key: "enableGatewayApi", DisplayName: "Enable Gateway API", Description: "Enable Kubernetes Gateway API support in Istio",
+ Type: ParameterType.Boolean, Group: "Features", DefaultValue: "true")
+ }),
+
+ ["minio"] = new(
+ ComponentName: "minio",
+ DisplayName: "MinIO Operator",
+ Description: "Object storage operator — manages MinIO tenants for S3-compatible storage",
+ Parameters: new List
+ {
+ new(Key: "operatorReplicas", DisplayName: "Operator Replicas", Description: "Number of MinIO Operator pod replicas",
+ Type: ParameterType.Integer, Group: "Scaling", DefaultValue: "1", Min: 1, Max: 5)
+ }),
+
+ ["mongodb-community"] = new(
+ ComponentName: "mongodb-community",
+ DisplayName: "MongoDB Community Operator",
+ Description: "MongoDB operator — manages MongoDB replica sets with SCRAM authentication, scaling, and upgrades",
+ Parameters: new List
+ {
+ new(Key: "operatorReplicas", DisplayName: "Operator Replicas", Description: "Number of MongoDB operator pod replicas",
+ Type: ParameterType.Integer, Group: "Scaling", DefaultValue: "1", Min: 1, Max: 5),
+ new(Key: "watchNamespace", DisplayName: "Watch Namespace", Description: "Namespace to watch for MongoDBCommunity resources (* = all namespaces)",
+ Type: ParameterType.String, Group: "General", DefaultValue: "*")
+ }),
+
+ ["monitoring"] = new(
+ ComponentName: "monitoring",
+ DisplayName: "Monitoring (kube-prometheus-stack)",
+ Description: "Full observability stack — Prometheus, Alertmanager, Grafana, kube-state-metrics, node-exporter",
+ Parameters: new List
+ {
+ new(Key: "retention", DisplayName: "Retention Period", Description: "How long Prometheus keeps metric data (e.g., 30d, 90d)",
+ Type: ParameterType.String, Group: "Prometheus", DefaultValue: "30d", IsRequired: true),
+ new(Key: "retentionSize", DisplayName: "Retention Size", Description: "Maximum total size of stored metrics (e.g., 45GB)",
+ Type: ParameterType.String, Group: "Prometheus", DefaultValue: "45GB"),
+ new(Key: "replicas", DisplayName: "Prometheus Replicas", Description: "Number of Prometheus server replicas",
+ Type: ParameterType.Integer, Group: "Prometheus", DefaultValue: "2", Min: 1, Max: 10),
+ new(Key: "storageSize", DisplayName: "Storage Size", Description: "PVC size for each Prometheus replica (e.g., 50Gi)",
+ Type: ParameterType.String, Group: "Prometheus", DefaultValue: "50Gi"),
+ new(Key: "grafanaEnabled", DisplayName: "Enable Grafana", Description: "Deploy Grafana alongside Prometheus",
+ Type: ParameterType.Boolean, Group: "Grafana", DefaultValue: "true"),
+ new(Key: "alertmanagerEnabled", DisplayName: "Enable Alertmanager", Description: "Deploy Alertmanager for alert routing",
+ Type: ParameterType.Boolean, Group: "Alertmanager", DefaultValue: "true"),
+ new(Key: "alertmanagerReplicas", DisplayName: "Alertmanager Replicas", Description: "Number of Alertmanager replicas",
+ Type: ParameterType.Integer, Group: "Alertmanager", DefaultValue: "2", Min: 1, Max: 5),
+
+ // ─── Ingress / Gateway API ───────────────────────────────
+ new(Key: "ingressEnabled", DisplayName: "Enable Ingress", Description: "Publish monitoring services via ingress/Gateway API",
+ Type: ParameterType.Boolean, Group: "Ingress", DefaultValue: "false"),
+ new(Key: "ingressProvider", DisplayName: "Ingress Provider", Description: "How to expose monitoring services externally",
+ Type: ParameterType.Select, Group: "Ingress", DefaultValue: "gatewayapi",
+ AllowedValues: new List { "gatewayapi", "traefik", "istio" },
+ DependsOnKey: "ingressEnabled", DependsOnValues: new List { "true" }),
+ new(Key: "grafanaHostname", DisplayName: "Grafana Hostname", Description: "External hostname for Grafana (e.g., grafana.prod01.example.com)",
+ Type: ParameterType.String, Group: "Ingress",
+ DependsOnKey: "ingressEnabled", DependsOnValues: new List { "true" }),
+ new(Key: "prometheusHostname", DisplayName: "Prometheus Hostname", Description: "External hostname for Prometheus (optional)",
+ Type: ParameterType.String, Group: "Ingress",
+ DependsOnKey: "ingressEnabled", DependsOnValues: new List { "true" }),
+ new(Key: "alertmanagerHostname", DisplayName: "Alertmanager Hostname", Description: "External hostname for Alertmanager (optional)",
+ Type: ParameterType.String, Group: "Ingress",
+ DependsOnKey: "ingressEnabled", DependsOnValues: new List { "true" }),
+ new(Key: "tlsEnabled", DisplayName: "TLS Enabled", Description: "Enable TLS for ingress routes",
+ Type: ParameterType.Boolean, Group: "Ingress", DefaultValue: "true",
+ DependsOnKey: "ingressEnabled", DependsOnValues: new List { "true" }),
+ new(Key: "tlsSecretName", DisplayName: "TLS Secret Name", Description: "Kubernetes Secret containing the TLS certificate",
+ Type: ParameterType.String, Group: "Ingress",
+ DependsOnKey: "tlsEnabled", DependsOnValues: new List { "true" })
+ }),
+
+ ["grafana"] = new(
+ ComponentName: "grafana",
+ DisplayName: "Grafana",
+ Description: "Visualization and dashboarding — deployed as part of the monitoring stack",
+ Parameters: new List
+ {
+ new(Key: "enabled", DisplayName: "Enabled", Description: "Whether Grafana is deployed",
+ Type: ParameterType.Boolean, Group: "General", DefaultValue: "true"),
+ new(Key: "persistence", DisplayName: "Persistence", Description: "Enable persistent storage for Grafana data",
+ Type: ParameterType.Boolean, Group: "Storage", DefaultValue: "false"),
+ new(Key: "persistenceSize", DisplayName: "Storage Size", Description: "PVC size for Grafana persistence (e.g., 10Gi)",
+ Type: ParameterType.String, Group: "Storage", DefaultValue: "10Gi"),
+
+ // ─── OIDC Authentication ─────────────────────────────────
+ new(Key: "oidcEnabled", DisplayName: "Enable OIDC", Description: "Authenticate users via OpenID Connect (e.g., Entra ID, Keycloak)",
+ Type: ParameterType.Boolean, Group: "OIDC", DefaultValue: "false"),
+ new(Key: "oidcProvider", DisplayName: "OIDC Provider", Description: "OAuth provider type (generic_oauth for most IdPs)",
+ Type: ParameterType.Select, Group: "OIDC", DefaultValue: "generic_oauth",
+ AllowedValues: new List { "generic_oauth", "azuread", "github", "google" },
+ DependsOnKey: "oidcEnabled", DependsOnValues: new List { "true" }),
+ new(Key: "oidcClientId", DisplayName: "OIDC Client ID", Description: "Client ID from the identity provider",
+ Type: ParameterType.String, Group: "OIDC",
+ DependsOnKey: "oidcEnabled", DependsOnValues: new List { "true" }),
+ new(Key: "oidcClientSecretRef", DisplayName: "Client Secret (Vault Reference)", Description: "Reference to client secret in the secrets service (e.g., vault://monitoring/grafana-oidc-secret)",
+ Type: ParameterType.String, Group: "OIDC",
+ DependsOnKey: "oidcEnabled", DependsOnValues: new List { "true" }),
+ new(Key: "oidcAuthUrl", DisplayName: "Authorization URL", Description: "OIDC authorization endpoint",
+ Type: ParameterType.String, Group: "OIDC",
+ DependsOnKey: "oidcEnabled", DependsOnValues: new List { "true" }),
+ new(Key: "oidcTokenUrl", DisplayName: "Token URL", Description: "OIDC token endpoint",
+ Type: ParameterType.String, Group: "OIDC",
+ DependsOnKey: "oidcEnabled", DependsOnValues: new List { "true" }),
+ new(Key: "oidcApiUrl", DisplayName: "UserInfo URL", Description: "OIDC userinfo endpoint",
+ Type: ParameterType.String, Group: "OIDC",
+ DependsOnKey: "oidcEnabled", DependsOnValues: new List { "true" }),
+ new(Key: "oidcScopes", DisplayName: "Scopes", Description: "OIDC scopes to request (e.g., openid profile email)",
+ Type: ParameterType.String, Group: "OIDC", DefaultValue: "openid profile email",
+ DependsOnKey: "oidcEnabled", DependsOnValues: new List { "true" }),
+ new(Key: "oidcRoleAttributePath", DisplayName: "Role Attribute Path", Description: "JMESPath expression to map IdP claims to Grafana roles",
+ Type: ParameterType.String, Group: "OIDC",
+ DependsOnKey: "oidcEnabled", DependsOnValues: new List { "true" }),
+ new(Key: "oidcAutoLogin", DisplayName: "Auto Login", Description: "Skip the Grafana login form and redirect directly to the IdP",
+ Type: ParameterType.Boolean, Group: "OIDC", DefaultValue: "true",
+ DependsOnKey: "oidcEnabled", DependsOnValues: new List { "true" })
+ }),
+
+ ["harbor"] = new(
+ ComponentName: "harbor",
+ DisplayName: "Harbor",
+ Description: "Container registry with vulnerability scanning, image signing, and proxy cache",
+ Parameters: new List
+ {
+ new(Key: "harborDomain", DisplayName: "Harbor Domain", Description: "Domain name for Harbor registry access",
+ Type: ParameterType.String, Group: "General", IsRequired: true),
+ new(Key: "cnpgClusterName", DisplayName: "CNPG Cluster", Description: "CloudNativePG cluster to create the Harbor database on. When set, Harbor uses external PostgreSQL instead of embedded",
+ Type: ParameterType.PostgresCluster, Group: "Database"),
+ new(Key: "cnpgNamespace", DisplayName: "CNPG Namespace", Description: "Namespace where the CNPG cluster is running",
+ Type: ParameterType.String, Group: "Database"),
+ new(Key: "storageBucketId", DisplayName: "Registry Storage Bucket", Description: "Use a pre-created S3 bucket for registry image storage instead of PVC",
+ Type: ParameterType.StorageBucket, Group: "Storage"),
+ new(Key: "adminPassword", DisplayName: "Admin Password", Description: "Harbor admin account password",
+ Type: ParameterType.String, Group: "General"),
+ new(Key: "wireAllServices", DisplayName: "Wire All Services", Description: "Automatically configure all namespaces to use Harbor as image source",
+ Type: ParameterType.Boolean, Group: "Integration", DefaultValue: "false"),
+ new(Key: "addProxyCache", DisplayName: "Add Proxy Cache", Description: "Upstream registry URL to create a proxy cache for (e.g., docker.io)",
+ Type: ParameterType.String, Group: "Proxy Cache"),
+ new(Key: "proxyCacheProjectName", DisplayName: "Proxy Cache Project", Description: "Harbor project name for the proxy cache",
+ Type: ParameterType.String, Group: "Proxy Cache"),
+ new(Key: "setDefaultCache", DisplayName: "Set Default Cache", Description: "Set proxy cache as default for docker.io pulls",
+ Type: ParameterType.Boolean, Group: "Proxy Cache", DefaultValue: "false"),
+ new(Key: "createProject", DisplayName: "Create Project", Description: "Name of a new Harbor project to create",
+ Type: ParameterType.String, Group: "Projects"),
+ new(Key: "projectVisibility", DisplayName: "Project Visibility", Description: "Visibility for newly created projects",
+ Type: ParameterType.Select, Group: "Projects", DefaultValue: "private",
+ AllowedValues: new List { "public", "private" }),
+ new(Key: "enableScanOnPush", DisplayName: "Scan on Push", Description: "Automatically scan images when pushed",
+ Type: ParameterType.Boolean, Group: "Security", DefaultValue: "true"),
+ new(Key: "severityThreshold", DisplayName: "Severity Threshold", Description: "Minimum vulnerability severity to block deployment",
+ Type: ParameterType.Select, Group: "Security",
+ AllowedValues: new List { "none", "low", "medium", "high", "critical" }),
+ new(Key: "scanSchedule", DisplayName: "Scan Schedule", Description: "Cron expression for periodic vulnerability scanning",
+ Type: ParameterType.String, Group: "Security", DefaultValue: "0 2 * * *"),
+ new(Key: "createPullSecret", DisplayName: "Create Pull Secret", Description: "Create an image pull secret for Harbor access",
+ Type: ParameterType.Boolean, Group: "Integration", DefaultValue: "false"),
+ new(Key: "targetNamespace", DisplayName: "Target Namespace", Description: "Namespace to create the pull secret in",
+ Type: ParameterType.String, Group: "Integration")
+ }),
+
+ ["traefik"] = new(
+ ComponentName: "traefik",
+ DisplayName: "Traefik Ingress Controller",
+ Description: "High-performance ingress controller with automatic TLS, middleware support, and dashboard",
+ Parameters: new List
+ {
+ new(Key: "replicas", DisplayName: "Replicas", Description: "Number of Traefik ingress controller replicas",
+ Type: ParameterType.Integer, Group: "Scaling", DefaultValue: "2", Min: 1, Max: 20),
+ new(Key: "dashboardEnabled", DisplayName: "Dashboard", Description: "Expose the Traefik management dashboard",
+ Type: ParameterType.Boolean, Group: "Features", DefaultValue: "false"),
+ new(Key: "serviceType", DisplayName: "Service Type", Description: "Kubernetes Service type for the Traefik LoadBalancer",
+ Type: ParameterType.Select, Group: "Networking", DefaultValue: "LoadBalancer",
+ AllowedValues: new List { "LoadBalancer", "NodePort", "ClusterIP" }),
+ new(Key: "internalLoadBalancer", DisplayName: "Internal Load Balancer", Description: "Use cloud-provider internal LB annotations (private LB)",
+ Type: ParameterType.Boolean, Group: "Networking", DefaultValue: "false")
+ }),
+
+ ["ingress"] = new(
+ ComponentName: "ingress",
+ DisplayName: "Ingress Gateway Infrastructure",
+ Description: "Gateway infrastructure layer — auto-detects Istio or Traefik and deploys the appropriate load balancer resources",
+ Parameters: new List
+ {
+ new(Key: "enable_external", DisplayName: "External Gateway", Description: "Deploy an external-facing gateway (Istio only — Traefik uses a single LB)",
+ Type: ParameterType.Boolean, Group: "General", DefaultValue: "false")
+ }),
+
+ ["keycloak"] = new(
+ ComponentName: "keycloak",
+ DisplayName: "Keycloak",
+ Description: "Identity and access management — SSO, user federation, social login, OIDC/SAML",
+ Parameters: new List
+ {
+ new(Key: "keycloakDomain", DisplayName: "Domain", Description: "External domain for Keycloak access",
+ Type: ParameterType.String, Group: "General", IsRequired: true),
+ new(Key: "cnpgClusterName", DisplayName: "CNPG Cluster", Description: "CloudNativePG cluster to create the Keycloak database on",
+ Type: ParameterType.PostgresCluster, Group: "Database"),
+ new(Key: "cnpgNamespace", DisplayName: "CNPG Namespace", Description: "Namespace where the CNPG cluster is running",
+ Type: ParameterType.String, Group: "Database"),
+ new(Key: "createRealm", DisplayName: "Create Realm", Description: "Name of a new realm to create",
+ Type: ParameterType.String, Group: "Realms"),
+ new(Key: "realmDisplayName", DisplayName: "Realm Display Name", Description: "Human-readable name for the realm",
+ Type: ParameterType.String, Group: "Realms"),
+ new(Key: "addProvider", DisplayName: "Add Identity Provider", Description: "Type of identity provider to add",
+ Type: ParameterType.Select, Group: "Identity Providers",
+ AllowedValues: new List { "bankid", "oidc", "saml" }),
+ new(Key: "realmName", DisplayName: "Target Realm", Description: "Realm to add the identity provider to",
+ Type: ParameterType.String, Group: "Identity Providers"),
+ new(Key: "bankIdEnvironment", DisplayName: "BankID Environment", Description: "BankID environment (test or production)",
+ Type: ParameterType.Select, Group: "BankID",
+ AllowedValues: new List { "test", "production" }),
+ new(Key: "bankIdDisplayName", DisplayName: "BankID Display Name", Description: "Display name for BankID login button",
+ Type: ParameterType.String, Group: "BankID", DefaultValue: "BankID"),
+ new(Key: "bankIdCertificateP12", DisplayName: "BankID Certificate (Base64)", Description: "Base64-encoded PKCS#12 certificate",
+ Type: ParameterType.String, Group: "BankID"),
+ new(Key: "bankIdCertificatePassword", DisplayName: "Certificate Password", Description: "Password for the PKCS#12 certificate",
+ Type: ParameterType.String, Group: "BankID"),
+ new(Key: "oidcDiscoveryUrl", DisplayName: "OIDC Discovery URL", Description: "OpenID Connect discovery endpoint URL",
+ Type: ParameterType.String, Group: "OIDC"),
+ new(Key: "oidcClientId", DisplayName: "OIDC Client ID", Description: "Client ID for the OIDC provider",
+ Type: ParameterType.String, Group: "OIDC"),
+ new(Key: "oidcClientSecret", DisplayName: "OIDC Client Secret", Description: "Client secret for the OIDC provider",
+ Type: ParameterType.String, Group: "OIDC"),
+ new(Key: "samlEntityId", DisplayName: "SAML Entity ID", Description: "Entity ID of the SAML identity provider",
+ Type: ParameterType.String, Group: "SAML"),
+ new(Key: "samlSsoUrl", DisplayName: "SAML SSO URL", Description: "Single Sign-On URL of the SAML provider",
+ Type: ParameterType.String, Group: "SAML"),
+ new(Key: "samlCertificate", DisplayName: "SAML Certificate", Description: "PEM certificate for SAML signature verification",
+ Type: ParameterType.String, Group: "SAML"),
+ new(Key: "updateTheme", DisplayName: "Update Theme", Description: "Whether to apply theme customization",
+ Type: ParameterType.Boolean, Group: "Theming", DefaultValue: "false"),
+ new(Key: "themePrimaryColor", DisplayName: "Primary Color", Description: "Primary brand color (hex, e.g. #1a73e8)",
+ Type: ParameterType.String, Group: "Theming"),
+ new(Key: "themeSecondaryColor", DisplayName: "Secondary Color", Description: "Secondary brand color (hex)",
+ Type: ParameterType.String, Group: "Theming"),
+ new(Key: "themeLogo", DisplayName: "Logo URL", Description: "URL to the logo image",
+ Type: ParameterType.String, Group: "Theming"),
+ new(Key: "themeBackground", DisplayName: "Background URL", Description: "URL to the background image",
+ Type: ParameterType.String, Group: "Theming")
+ }),
+
+ ["letsencrypt"] = new(
+ ComponentName: "letsencrypt",
+ DisplayName: "Let's Encrypt",
+ Description: "Automated public TLS certificates via Let's Encrypt ACME protocol",
+ Parameters: new List
+ {
+ new(Key: "email", DisplayName: "Registration Email", Description: "Email address for Let's Encrypt account registration",
+ Type: ParameterType.String, Group: "General", IsRequired: true),
+ new(Key: "httpSolverEnabled", DisplayName: "HTTP-01 Solver", Description: "Enable HTTP-01 challenge solver",
+ Type: ParameterType.Boolean, Group: "HTTP-01 Solver", DefaultValue: "true"),
+ new(Key: "httpSolverMode", DisplayName: "HTTP-01 Mode", Description: "How cert-manager serves HTTP-01 challenges: via traditional Ingress or Gateway API HTTPRoute",
+ Type: ParameterType.Select, Group: "HTTP-01 Solver", DefaultValue: "ingress",
+ AllowedValues: new List { "ingress", "gatewayHTTPRoute" },
+ DependsOnKey: "httpSolverEnabled", DependsOnValues: new List { "true" }),
+ new(Key: "httpSolverIngressClass", DisplayName: "Ingress Class", Description: "Ingress class for HTTP-01 challenge pods",
+ Type: ParameterType.String, Group: "HTTP-01 Solver", DefaultValue: "traefik",
+ DependsOnKey: "httpSolverMode", DependsOnValues: new List { "ingress" }),
+ new(Key: "httpSolverGatewayName", DisplayName: "Gateway Name", Description: "Name of the Gateway resource for HTTP-01 challenges (e.g., 'internal')",
+ Type: ParameterType.String, Group: "HTTP-01 Solver", DefaultValue: "internal",
+ DependsOnKey: "httpSolverMode", DependsOnValues: new List { "gatewayHTTPRoute" }),
+ new(Key: "httpSolverGatewayNamespace", DisplayName: "Gateway Namespace", Description: "Namespace of the Gateway resource (e.g., 'internal-ingress')",
+ Type: ParameterType.String, Group: "HTTP-01 Solver",
+ DependsOnKey: "httpSolverMode", DependsOnValues: new List { "gatewayHTTPRoute" }),
+ new(Key: "dnsSolverEnabled", DisplayName: "DNS-01 Solver", Description: "Enable DNS-01 challenge solver for wildcard certificates and private networks",
+ Type: ParameterType.Boolean, Group: "DNS-01 Solver", DefaultValue: "false"),
+ new(Key: "dnsSolverProvider", DisplayName: "DNS Provider", Description: "Cloud DNS provider for DNS-01 challenges",
+ Type: ParameterType.Select, Group: "DNS-01 Solver",
+ AllowedValues: new List { "cloudflare", "route53", "azuredns", "clouddns" },
+ DependsOnKey: "dnsSolverEnabled", DependsOnValues: new List { "true" }),
+ new(Key: "dnsZones", DisplayName: "DNS Zones", Description: "Comma-separated DNS zones scoped to the DNS-01 solver (e.g., example.com, internal.example.com)",
+ Type: ParameterType.String, Group: "DNS-01 Solver",
+ DependsOnKey: "dnsSolverEnabled", DependsOnValues: new List { "true" }),
+
+ // ─── Cloudflare-specific fields ──────────────────────────────
+ new(Key: "dnsSolverSecretName", DisplayName: "API Token Secret", Description: "Kubernetes Secret containing the Cloudflare API token (key: api-token)",
+ Type: ParameterType.String, Group: "Cloudflare DNS",
+ DependsOnKey: "dnsSolverProvider", DependsOnValues: new List { "cloudflare" }),
+
+ // ─── Route53-specific fields ─────────────────────────────────
+ new(Key: "dnsSolverHostedZone", DisplayName: "Hosted Zone ID", Description: "AWS Route53 hosted zone ID (e.g., Z2ABCDEF123456)",
+ Type: ParameterType.String, Group: "AWS Route53",
+ DependsOnKey: "dnsSolverProvider", DependsOnValues: new List { "route53" }),
+
+ // ─── Azure DNS-specific fields ───────────────────────────────
+ new(Key: "dnsSolverClientId", DisplayName: "Client ID", Description: "Azure AD application (service principal) client ID for DNS zone access",
+ Type: ParameterType.String, Group: "Azure DNS",
+ DependsOnKey: "dnsSolverProvider", DependsOnValues: new List { "azuredns" }),
+ new(Key: "dnsSolverSubscriptionId", DisplayName: "Subscription ID", Description: "Azure subscription containing the DNS zone",
+ Type: ParameterType.String, Group: "Azure DNS",
+ DependsOnKey: "dnsSolverProvider", DependsOnValues: new List { "azuredns" }),
+ new(Key: "dnsSolverTenantId", DisplayName: "Tenant ID", Description: "Azure AD tenant ID for the service principal",
+ Type: ParameterType.String, Group: "Azure DNS",
+ DependsOnKey: "dnsSolverProvider", DependsOnValues: new List { "azuredns" }),
+ new(Key: "dnsSolverResourceGroup", DisplayName: "Resource Group", Description: "Azure resource group containing the DNS zone",
+ Type: ParameterType.String, Group: "Azure DNS",
+ DependsOnKey: "dnsSolverProvider", DependsOnValues: new List { "azuredns" }),
+ new(Key: "dnsSolverSecretNameAzure", DisplayName: "Client Secret Name", Description: "Kubernetes Secret containing the Azure service principal client secret (key: client-secret)",
+ Type: ParameterType.String, Group: "Azure DNS",
+ DependsOnKey: "dnsSolverProvider", DependsOnValues: new List { "azuredns" }),
+
+ // ─── Google Cloud DNS-specific fields ────────────────────────
+ new(Key: "dnsSolverProject", DisplayName: "GCP Project", Description: "Google Cloud project ID containing the DNS zone",
+ Type: ParameterType.String, Group: "Google Cloud DNS",
+ DependsOnKey: "dnsSolverProvider", DependsOnValues: new List { "clouddns" }),
+ new(Key: "dnsSolverSecretNameGcp", DisplayName: "Service Account Secret", Description: "Kubernetes Secret containing the GCP service account JSON key",
+ Type: ParameterType.String, Group: "Google Cloud DNS",
+ DependsOnKey: "dnsSolverProvider", DependsOnValues: new List { "clouddns" }),
+
+ // ─── Management ──────────────────────────────────────────────
+ new(Key: "deleteStaging", DisplayName: "Delete Staging Issuer", Description: "Remove the staging ClusterIssuer",
+ Type: ParameterType.Boolean, Group: "Management", DefaultValue: "false"),
+ new(Key: "deleteProduction", DisplayName: "Delete Production Issuer", Description: "Remove the production ClusterIssuer",
+ Type: ParameterType.Boolean, Group: "Management", DefaultValue: "false")
+ }),
+
+ ["network-policies"] = new(
+ ComponentName: "network-policies",
+ DisplayName: "Network Policies",
+ Description: "Namespace-scoped network segmentation — controls which pods can communicate",
+ Parameters: new List()),
+
+ ["cnpg"] = new(
+ ComponentName: "cnpg",
+ DisplayName: "CloudNativePG",
+ Description: "PostgreSQL operator — manages highly available PostgreSQL clusters on Kubernetes",
+ Parameters: new List
+ {
+ new(Key: "operatorReplicas", DisplayName: "Operator Replicas", Description: "Number of CNPG operator pod replicas",
+ Type: ParameterType.Integer, Group: "Scaling", DefaultValue: "1", Min: 1, Max: 5),
+ new(Key: "monitoringEnabled", DisplayName: "Monitoring", Description: "Enable Prometheus metrics for the operator",
+ Type: ParameterType.Boolean, Group: "Features", DefaultValue: "true"),
+ new(Key: "walBucketId", DisplayName: "WAL/Backup Bucket", Description: "Use a pre-created S3 bucket for WAL archiving and base backups instead of MinIO",
+ Type: ParameterType.StorageBucket, Group: "Backup Storage")
+ }),
+
+ ["redis"] = new(
+ ComponentName: "redis",
+ DisplayName: "Redis Operator",
+ Description: "In-memory data store operator — manages Redis clusters with Sentinel HA",
+ Parameters: new List
+ {
+ new(Key: "replicaCount", DisplayName: "Replica Count", Description: "Number of Redis replica pods",
+ Type: ParameterType.Integer, Group: "Scaling", DefaultValue: "3", Min: 1, Max: 10),
+ new(Key: "sentinelEnabled", DisplayName: "Sentinel HA", Description: "Enable Redis Sentinel for automatic failover",
+ Type: ParameterType.Boolean, Group: "High Availability", DefaultValue: "true"),
+ new(Key: "sentinelReplicas", DisplayName: "Sentinel Replicas", Description: "Number of Sentinel pods",
+ Type: ParameterType.Integer, Group: "High Availability", DefaultValue: "3", Min: 1, Max: 5),
+ new(Key: "maxMemory", DisplayName: "Max Memory", Description: "Maximum memory per Redis instance (e.g., 256mb, 1gb)",
+ Type: ParameterType.String, Group: "Resources", DefaultValue: "256mb"),
+ new(Key: "maxMemoryPolicy", DisplayName: "Eviction Policy", Description: "What happens when memory limit is reached",
+ Type: ParameterType.Select, Group: "Resources", DefaultValue: "allkeys-lru",
+ AllowedValues: new List { "noeviction", "allkeys-lru", "volatile-lru", "allkeys-random", "volatile-random", "volatile-ttl" }),
+ new(Key: "persistence", DisplayName: "Persistence", Description: "Enable AOF persistence",
+ Type: ParameterType.Boolean, Group: "Storage", DefaultValue: "true"),
+ new(Key: "persistenceSize", DisplayName: "Persistence Size", Description: "PVC size for Redis persistence",
+ Type: ParameterType.String, Group: "Storage", DefaultValue: "8Gi")
+ }),
+
+ ["external-secrets"] = new(
+ ComponentName: "external-secrets",
+ DisplayName: "External Secrets Operator",
+ Description: "Synchronizes secrets from external providers (AWS SSM, Azure KeyVault, GCP Secret Manager, Vault)",
+ Parameters: new List
+ {
+ new(Key: "replicas", DisplayName: "Operator Replicas", Description: "Number of ESO controller replicas",
+ Type: ParameterType.Integer, Group: "Scaling", DefaultValue: "2", Min: 1, Max: 5),
+ new(Key: "webhookReplicas", DisplayName: "Webhook Replicas", Description: "Number of webhook server replicas",
+ Type: ParameterType.Integer, Group: "Scaling", DefaultValue: "2", Min: 1, Max: 5),
+ new(Key: "certControllerReplicas", DisplayName: "Cert Controller Replicas", Description: "Number of cert-controller replicas",
+ Type: ParameterType.Integer, Group: "Scaling", DefaultValue: "2", Min: 1, Max: 5),
+ new(Key: "storeProvider", DisplayName: "Secret Store Provider", Description: "External secret provider type",
+ Type: ParameterType.Select, Group: "Secret Store",
+ AllowedValues: new List { "aws", "azure", "gcp", "vault" }),
+ new(Key: "storeEndpoint", DisplayName: "Store Endpoint", Description: "Provider endpoint URL (Vault server URL, etc.)",
+ Type: ParameterType.String, Group: "Secret Store"),
+ new(Key: "storeSecretName", DisplayName: "Auth Secret", Description: "Kubernetes secret with provider credentials",
+ Type: ParameterType.String, Group: "Secret Store"),
+ new(Key: "storeRegion", DisplayName: "Region", Description: "Cloud region for the secret store (AWS/Azure/GCP)",
+ Type: ParameterType.String, Group: "Secret Store")
+ }),
+
+ ["rabbitmq"] = new(
+ ComponentName: "rabbitmq",
+ DisplayName: "RabbitMQ Cluster Operator",
+ Description: "Message broker operator — manages RabbitMQ clusters with topology management",
+ Parameters: new List
+ {
+ new(Key: "operatorReplicas", DisplayName: "Operator Replicas", Description: "Number of RabbitMQ operator pod replicas",
+ Type: ParameterType.Integer, Group: "Scaling", DefaultValue: "1", Min: 1, Max: 5),
+ new(Key: "topologyOperator", DisplayName: "Topology Operator", Description: "Deploy the topology operator for declarative queue/exchange management",
+ Type: ParameterType.Boolean, Group: "Features", DefaultValue: "true"),
+ new(Key: "monitoringEnabled", DisplayName: "Monitoring", Description: "Enable Prometheus ServiceMonitor for the operator",
+ Type: ParameterType.Boolean, Group: "Features", DefaultValue: "true")
+ }),
+
+ ["custom-dns"] = new(
+ ComponentName: "custom-dns",
+ DisplayName: "Custom DNS",
+ Description: "ExternalDNS controller — automatically manages DNS records from Kubernetes resources",
+ Parameters: new List
+ {
+ new(Key: "provider", DisplayName: "DNS Provider", Description: "Cloud DNS provider",
+ Type: ParameterType.Select, Group: "General", IsRequired: true,
+ AllowedValues: new List { "cloudflare", "route53", "azure", "google", "digitalocean" }),
+ new(Key: "domainFilter", DisplayName: "Domain Filter", Description: "Only manage records in these domains (comma-separated)",
+ Type: ParameterType.String, Group: "General"),
+ new(Key: "txtOwnerId", DisplayName: "TXT Owner ID", Description: "Unique identifier for this ExternalDNS instance",
+ Type: ParameterType.String, Group: "General", DefaultValue: "entkube"),
+ new(Key: "policy", DisplayName: "Record Policy", Description: "How to handle existing DNS records",
+ Type: ParameterType.Select, Group: "General", DefaultValue: "upsert-only",
+ AllowedValues: new List { "upsert-only", "sync" }),
+ new(Key: "secretName", DisplayName: "Credentials Secret", Description: "Kubernetes secret with DNS provider credentials",
+ Type: ParameterType.String, Group: "Authentication")
+ }),
+
+ ["gitea"] = new(
+ ComponentName: "gitea",
+ DisplayName: "Gitea",
+ Description: "Self-hosted Git service with built-in CI/CD (Gitea Actions), package registry, and container registry",
+ Parameters: new List
+ {
+ // — Version —
+ new(Key: "version", DisplayName: "Gitea Version", Description: "Helm chart version to install or upgrade to",
+ Type: ParameterType.String, Group: "Version", DefaultValue: "10.6.0"),
+
+ // — Scaling —
+ new(Key: "replicas", DisplayName: "Replicas", Description: "Number of Gitea application replicas",
+ Type: ParameterType.Integer, Group: "Scaling", DefaultValue: "1", Min: 1, Max: 5),
+ new(Key: "runnerReplicas", DisplayName: "Runner Replicas", Description: "Number of Gitea Actions runner pods",
+ Type: ParameterType.Integer, Group: "Scaling", DefaultValue: "2", Min: 0, Max: 20),
+ new(Key: "runnerStorage", DisplayName: "Runner Storage", Description: "PVC size for each runner pod cache",
+ Type: ParameterType.String, Group: "Scaling", DefaultValue: "2Gi"),
+
+ // — Features —
+ new(Key: "actionsEnabled", DisplayName: "Gitea Actions", Description: "Enable built-in CI/CD with Gitea Actions",
+ Type: ParameterType.Boolean, Group: "Features", DefaultValue: "true"),
+ new(Key: "projectsEnabled", DisplayName: "Projects", Description: "Enable project boards for issue/task management",
+ Type: ParameterType.Boolean, Group: "Features", DefaultValue: "true"),
+ new(Key: "sshEnabled", DisplayName: "SSH Access", Description: "Enable Git over SSH",
+ Type: ParameterType.Boolean, Group: "Features", DefaultValue: "true"),
+ new(Key: "sshPort", DisplayName: "SSH Port", Description: "SSH service port number",
+ Type: ParameterType.Integer, Group: "Features", DefaultValue: "22", Min: 1, Max: 65535,
+ DependsOnKey: "sshEnabled", DependsOnValues: new List { "true" }),
+ new(Key: "metricsEnabled", DisplayName: "Metrics", Description: "Enable Prometheus metrics endpoint and ServiceMonitor",
+ Type: ParameterType.Boolean, Group: "Features", DefaultValue: "true"),
+ new(Key: "disableRegistration", DisplayName: "Disable Registration", Description: "Prevent self-registration (use OIDC instead)",
+ Type: ParameterType.Boolean, Group: "Features", DefaultValue: "true"),
+
+ // — Storage —
+ new(Key: "persistenceSize", DisplayName: "Storage Size", Description: "PVC size for Git repository storage",
+ Type: ParameterType.String, Group: "Storage", DefaultValue: "50Gi"),
+
+ // — Ingress (selectors, not textboxes) —
+ new(Key: "ingressHost", DisplayName: "Ingress Host", Description: "Hostname for Gitea web UI (leave empty to skip ingress)",
+ Type: ParameterType.String, Group: "Ingress"),
+ new(Key: "ingressProvider", DisplayName: "Ingress Provider", Description: "Ingress controller type",
+ Type: ParameterType.Select, Group: "Ingress", DefaultValue: "traefik",
+ AllowedValues: new List { "traefik", "istio", "gatewayapi" }),
+
+ // — Database (CNPG cluster selector — hides manual fields when selected) —
+ new(Key: "cnpgClusterName", DisplayName: "CNPG Cluster", Description: "CloudNativePG cluster to create the Gitea database on. Credentials are auto-generated and stored in the secrets vault",
+ Type: ParameterType.PostgresCluster, Group: "Database"),
+ new(Key: "cnpgNamespace", DisplayName: "CNPG Namespace", Description: "Namespace where the CNPG cluster is running (auto-filled when CNPG cluster is selected)",
+ Type: ParameterType.String, Group: "Database"),
+ new(Key: "dbName", DisplayName: "Database Name", Description: "PostgreSQL database name to create on the CNPG cluster",
+ Type: ParameterType.String, Group: "Database", DefaultValue: "gitea"),
+
+ // — Redis (cluster selector) —
+ new(Key: "redisClusterName", DisplayName: "Redis Cluster", Description: "Redis cluster for cache, sessions, and queues. Falls back to in-memory if not selected",
+ Type: ParameterType.RedisCluster, Group: "Cache"),
+
+ // — OIDC —
+ new(Key: "oidcEnabled", DisplayName: "OIDC Authentication", Description: "Enable OpenID Connect authentication (e.g. via Keycloak)",
+ Type: ParameterType.Boolean, Group: "OIDC", DefaultValue: "false"),
+ new(Key: "oidcProviderName", DisplayName: "Provider Name", Description: "Display name shown on the login button (e.g. 'Keycloak', 'Azure AD')",
+ Type: ParameterType.String, Group: "OIDC", DefaultValue: "Keycloak",
+ DependsOnKey: "oidcEnabled", DependsOnValues: new List { "true" }),
+ new(Key: "oidcDiscoveryUrl", DisplayName: "Discovery URL", Description: "OpenID Connect discovery endpoint (e.g. https://keycloak.example.com/realms/myrealm)",
+ Type: ParameterType.String, Group: "OIDC",
+ DependsOnKey: "oidcEnabled", DependsOnValues: new List { "true" }),
+ new(Key: "oidcClientId", DisplayName: "Client ID", Description: "OIDC client ID registered in the identity provider",
+ Type: ParameterType.String, Group: "OIDC",
+ DependsOnKey: "oidcEnabled", DependsOnValues: new List { "true" }),
+ new(Key: "oidcClientSecret", DisplayName: "Client Secret", Description: "OIDC client secret",
+ Type: ParameterType.String, Group: "OIDC",
+ DependsOnKey: "oidcEnabled", DependsOnValues: new List { "true" }),
+ new(Key: "oidcScopes", DisplayName: "Scopes", Description: "OIDC scopes to request",
+ Type: ParameterType.String, Group: "OIDC", DefaultValue: "openid profile email",
+ DependsOnKey: "oidcEnabled", DependsOnValues: new List { "true" }),
+ new(Key: "oidcAutoDiscoverUrl", DisplayName: "Auto Discovery", Description: "Use the discovery URL to auto-configure endpoints",
+ Type: ParameterType.Boolean, Group: "OIDC", DefaultValue: "true",
+ DependsOnKey: "oidcEnabled", DependsOnValues: new List { "true" }),
+
+ // — Integration (Harbor auto-detect) —
+ new(Key: "harborRegistryUrl", DisplayName: "Harbor Registry URL", Description: "Harbor registry URL for act-runner Docker mirror. Auto-detected from installed Harbor if available",
+ Type: ParameterType.String, Group: "Integration"),
+
+ // — Object Storage (cascading — selecting a bucket hides manual S3/Azure fields) —
+ new(Key: "objectStorageType", DisplayName: "Object Storage", Description: "How Gitea stores LFS objects, packages, and attachments",
+ Type: ParameterType.Select, Group: "Object Storage", DefaultValue: "none",
+ AllowedValues: new List { "none", "bucket", "s3", "azure" }),
+ new(Key: "storageBucketId", DisplayName: "Storage Bucket", Description: "Use a pre-created storage bucket from the Storage tab",
+ Type: ParameterType.StorageBucket, Group: "Object Storage",
+ DependsOnKey: "objectStorageType", DependsOnValues: new List { "bucket" }),
+ new(Key: "s3Endpoint", DisplayName: "S3 Endpoint", Description: "S3-compatible endpoint for object storage",
+ Type: ParameterType.String, Group: "Object Storage",
+ DependsOnKey: "objectStorageType", DependsOnValues: new List { "s3" }),
+ new(Key: "s3AccessKey", DisplayName: "S3 Access Key", Description: "S3 access key ID",
+ Type: ParameterType.String, Group: "Object Storage",
+ DependsOnKey: "objectStorageType", DependsOnValues: new List { "s3" }),
+ new(Key: "s3SecretKey", DisplayName: "S3 Secret Key", Description: "S3 secret access key",
+ Type: ParameterType.String, Group: "Object Storage",
+ DependsOnKey: "objectStorageType", DependsOnValues: new List { "s3" }),
+ new(Key: "s3Bucket", DisplayName: "S3 Bucket", Description: "S3 bucket name for Gitea storage",
+ Type: ParameterType.String, Group: "Object Storage", DefaultValue: "gitea",
+ DependsOnKey: "objectStorageType", DependsOnValues: new List { "s3" }),
+ new(Key: "s3Region", DisplayName: "S3 Region", Description: "S3 region",
+ Type: ParameterType.String, Group: "Object Storage",
+ DependsOnKey: "objectStorageType", DependsOnValues: new List { "s3" }),
+ new(Key: "azureAccountName", DisplayName: "Azure Account Name", Description: "Azure Storage account name",
+ Type: ParameterType.String, Group: "Object Storage",
+ DependsOnKey: "objectStorageType", DependsOnValues: new List { "azure" }),
+ new(Key: "azureAccountKey", DisplayName: "Azure Account Key", Description: "Azure Storage account key",
+ Type: ParameterType.String, Group: "Object Storage",
+ DependsOnKey: "objectStorageType", DependsOnValues: new List { "azure" }),
+ new(Key: "azureContainer", DisplayName: "Azure Container", Description: "Azure Blob container name",
+ Type: ParameterType.String, Group: "Object Storage", DefaultValue: "gitea",
+ DependsOnKey: "objectStorageType", DependsOnValues: new List { "azure" }),
+
+ // — Administration —
+ new(Key: "adminUsername", DisplayName: "Admin Username", Description: "Initial admin user (only used on first install)",
+ Type: ParameterType.String, Group: "Administration", DefaultValue: "gitea_admin"),
+ new(Key: "adminEmail", DisplayName: "Admin Email", Description: "Admin user email address",
+ Type: ParameterType.String, Group: "Administration")
+ })
+ };
+
+ ///
+ /// Retrieves the configuration schema for a component by name. Returns an
+ /// empty schema if the component has no registered parameters.
+ ///
+ public static ConfigurationSchema GetSchema(string componentName)
+ {
+ if (schemas.TryGetValue(componentName, out ConfigurationSchema? schema))
+ {
+ return schema;
+ }
+
+ return new ConfigurationSchema(
+ ComponentName: componentName,
+ DisplayName: componentName,
+ Description: $"No configuration schema defined for '{componentName}'",
+ Parameters: new List());
+ }
+
+ ///
+ /// Returns all registered schemas for use in API responses.
+ ///
+ public static IReadOnlyDictionary GetAll() => schemas;
+
+ ///
+ /// Returns a schema enriched with cluster-contextual defaults. Uses the
+ /// cluster's discovered component state (from adoption scans) to adjust
+ /// defaults so the configuration form pre-selects the right options.
+ ///
+ /// For Let's Encrypt:
+ /// - Detects the ingress provider (Istio vs Traefik) from the ingress component
+ /// - Sets httpSolverMode default to "gatewayHTTPRoute" when Istio is detected
+ /// - Sets httpSolverMode default to "ingress" when Traefik is detected
+ /// - Pre-fills gateway name/namespace from discovered gateways
+ /// - Converts gateway name to a Select with discovered options when available
+ ///
+ public static ConfigurationSchema EnrichForCluster(
+ string componentName, IReadOnlyList clusterComponents)
+ {
+ ConfigurationSchema baseSchema = GetSchema(componentName);
+
+ if (componentName.Equals("ingress", StringComparison.OrdinalIgnoreCase))
+ {
+ return EnrichIngressSchema(baseSchema, clusterComponents);
+ }
+
+ if (componentName.Equals("letsencrypt", StringComparison.OrdinalIgnoreCase))
+ {
+ return EnrichLetsEncryptSchema(baseSchema, clusterComponents);
+ }
+
+ return baseSchema;
+ }
+
+ ///
+ /// Enriches the ingress schema based on the cluster's discovered state.
+ /// When the cluster scan found an external gateway, the enable_external
+ /// parameter defaults to true so the toggle reflects reality.
+ ///
+ private static ConfigurationSchema EnrichIngressSchema(
+ ConfigurationSchema baseSchema, IReadOnlyList clusterComponents)
+ {
+ // Look up the ingress component's discovered configuration to see
+ // whether an external gateway was found during the cluster scan.
+
+ ClusterComponent? ingressComponent = clusterComponents.FirstOrDefault(
+ c => c.ComponentName.Equals("ingress", StringComparison.OrdinalIgnoreCase));
+
+ bool hasExternalGateway = ingressComponent?.Configuration is not null
+ && ingressComponent.Configuration.TryGetValue("hasExternalGateway", out string? extGw)
+ && extGw.Equals("True", StringComparison.OrdinalIgnoreCase);
+
+ if (!hasExternalGateway)
+ {
+ return baseSchema;
+ }
+
+ // The cluster already has an external gateway — flip the default so
+ // the UI toggle shows "Enabled" instead of contradicting the current state.
+
+ List enrichedParams = baseSchema.Parameters
+ .Select(p => p.Key == "enable_external" ? p with { DefaultValue = "true" } : p)
+ .ToList();
+
+ return baseSchema with { Parameters = enrichedParams };
+ }
+
+ ///
+ /// Enriches the Let's Encrypt schema based on the cluster's ingress provider
+ /// and discovered gateways. When Istio is detected, the HTTP-01 solver form
+ /// defaults to Gateway API mode. When Traefik is detected, it defaults to
+ /// ingress mode with the traefik class pre-selected.
+ ///
+ private static ConfigurationSchema EnrichLetsEncryptSchema(
+ ConfigurationSchema baseSchema, IReadOnlyList clusterComponents)
+ {
+ // Find the ingress component to determine the provider type.
+
+ ClusterComponent? ingressComponent = clusterComponents.FirstOrDefault(
+ c => c.ComponentName.Equals("ingress", StringComparison.OrdinalIgnoreCase));
+
+ string? ingressProvider = null;
+ bool hasInternalGateway = false;
+ bool hasExternalGateway = false;
+
+ if (ingressComponent?.Configuration is not null)
+ {
+ ingressComponent.Configuration.TryGetValue("provider", out ingressProvider);
+
+ hasInternalGateway = ingressComponent.Configuration.TryGetValue("hasInternalGateway", out string? intGw)
+ && intGw.Equals("True", StringComparison.OrdinalIgnoreCase);
+
+ hasExternalGateway = ingressComponent.Configuration.TryGetValue("hasExternalGateway", out string? extGw)
+ && extGw.Equals("True", StringComparison.OrdinalIgnoreCase);
+ }
+
+ // Find discovered gateways from the letsencrypt component's config.
+
+ ClusterComponent? letsEncryptComponent = clusterComponents.FirstOrDefault(
+ c => c.ComponentName.Equals("letsencrypt", StringComparison.OrdinalIgnoreCase));
+
+ List discoveredGateways = ParseDiscoveredGateways(letsEncryptComponent);
+
+ // If no gateways from the letsencrypt check, build from ingress info.
+
+ if (discoveredGateways.Count == 0)
+ {
+ if (hasInternalGateway)
+ {
+ discoveredGateways.Add(new DiscoveredGateway("internal", "internal-ingress"));
+ }
+
+ if (hasExternalGateway)
+ {
+ discoveredGateways.Add(new DiscoveredGateway("external", "external-ingress"));
+ }
+ }
+
+ // Build enriched parameters with adjusted defaults.
+
+ bool isIstio = string.Equals(ingressProvider, "istio", StringComparison.OrdinalIgnoreCase);
+ bool isTraefik = string.Equals(ingressProvider, "traefik", StringComparison.OrdinalIgnoreCase);
+
+ List enrichedParams = baseSchema.Parameters
+ .Select(p => EnrichLetsEncryptParameter(p, isIstio, isTraefik, discoveredGateways))
+ .ToList();
+
+ return baseSchema with { Parameters = enrichedParams };
+ }
+
+ ///
+ /// Adjusts a single Let's Encrypt parameter based on the detected provider.
+ /// Sets appropriate defaults for Istio (gateway mode) or Traefik (ingress mode)
+ /// and converts gateway name to a Select when gateways are discovered.
+ ///
+ private static ConfigurationParameter EnrichLetsEncryptParameter(
+ ConfigurationParameter param, bool isIstio, bool isTraefik,
+ List discoveredGateways)
+ {
+ return param.Key switch
+ {
+ // When Istio is detected, default to Gateway API mode.
+
+ "httpSolverMode" when isIstio => param with { DefaultValue = "gatewayHTTPRoute" },
+
+ // When Istio and gateways are discovered, show them as Select options.
+
+ "httpSolverGatewayName" when isIstio && discoveredGateways.Count > 0 =>
+ param with
+ {
+ Type = ParameterType.Select,
+ DefaultValue = discoveredGateways[0].Name,
+ AllowedValues = discoveredGateways.Select(g => g.Name).ToList()
+ },
+
+ // Default the gateway namespace to the first discovered gateway's namespace.
+
+ "httpSolverGatewayNamespace" when isIstio && discoveredGateways.Count > 0 =>
+ param with { DefaultValue = discoveredGateways[0].Namespace },
+
+ // When Traefik, ensure ingress class defaults to "traefik".
+
+ "httpSolverIngressClass" when isTraefik => param with { DefaultValue = "traefik" },
+
+ _ => param
+ };
+ }
+
+ ///
+ /// Parses the availableGateways JSON from the letsencrypt component's
+ /// discovered configuration. The JSON is an array of {name, namespace} objects
+ /// stored by the LetsEncryptCheck during gateway discovery.
+ ///
+ private static List ParseDiscoveredGateways(ClusterComponent? component)
+ {
+ List gateways = new();
+
+ if (component?.Configuration is null)
+ {
+ return gateways;
+ }
+
+ if (!component.Configuration.TryGetValue("availableGateways", out string? json)
+ || string.IsNullOrEmpty(json))
+ {
+ return gateways;
+ }
+
+ try
+ {
+ using System.Text.Json.JsonDocument doc = System.Text.Json.JsonDocument.Parse(json);
+
+ foreach (System.Text.Json.JsonElement element in doc.RootElement.EnumerateArray())
+ {
+ string? name = element.TryGetProperty("name", out System.Text.Json.JsonElement nameEl)
+ ? nameEl.GetString() : null;
+ string? ns = element.TryGetProperty("namespace", out System.Text.Json.JsonElement nsEl)
+ ? nsEl.GetString() : null;
+
+ if (!string.IsNullOrEmpty(name))
+ {
+ gateways.Add(new DiscoveredGateway(name, ns ?? "default"));
+ }
+ }
+ }
+ catch
+ {
+ // Malformed JSON — ignore and return empty list.
+ }
+
+ return gateways;
+ }
+
+ private record DiscoveredGateway(string Name, string Namespace);
+}
diff --git a/src/EntKube.Clusters/Features/AdoptCluster/Components/CertManager/CertManagerCheck.cs b/src/EntKube.Clusters/Features/AdoptCluster/Components/CertManager/CertManagerCheck.cs
new file mode 100644
index 0000000..882c479
--- /dev/null
+++ b/src/EntKube.Clusters/Features/AdoptCluster/Components/CertManager/CertManagerCheck.cs
@@ -0,0 +1,188 @@
+using System.Text;
+using EntKube.Clusters.Domain;
+using k8s;
+using k8s.Models;
+using Microsoft.Extensions.Logging;
+
+namespace EntKube.Clusters.Features.AdoptCluster.Components.CertManager;
+
+///
+/// Checks whether cert-manager is installed and healthy on the cluster.
+/// Cert-manager is a prerequisite for internal CA management, TLS certificate
+/// issuance, and trust bundle distribution. The Terraform bootstrap module
+/// deploys it as one of the first components.
+///
+public class CertManagerCheck : IAdoptionCheck
+{
+ private readonly ILogger logger;
+
+ public string ComponentName => "cert-manager";
+ public string DisplayName => "cert-manager (Certificate Management)";
+
+ public CertManagerCheck(ILogger logger)
+ {
+ this.logger = logger;
+ }
+
+ public async Task CheckAsync(KubernetesCluster cluster, CancellationToken ct = default)
+ {
+ Kubernetes client = BuildClient(cluster);
+ List details = new();
+ List missing = new();
+
+ // Look for cert-manager pods in the standard namespace.
+
+ List runningPods = await FindCertManagerPods(client, ct);
+
+ if (runningPods.Count == 0)
+ {
+ missing.Add("No cert-manager pods found in cert-manager namespace");
+ return new ComponentCheckResult(ComponentName, ComponentStatus.NotInstalled, details, missing);
+ }
+
+ details.AddRange(runningPods.Select(p => $"Pod running: {p}"));
+
+ // Check for the key components: controller, webhook, cainjector.
+
+ bool hasController = runningPods.Any(p => p.Contains("cert-manager") && !p.Contains("webhook") && !p.Contains("cainjector"));
+ bool hasWebhook = runningPods.Any(p => p.Contains("webhook"));
+ bool hasCainjector = runningPods.Any(p => p.Contains("cainjector"));
+
+ if (!hasController)
+ {
+ missing.Add("cert-manager controller pod not found");
+ }
+
+ if (!hasWebhook)
+ {
+ missing.Add("cert-manager webhook pod not found");
+ }
+
+ if (!hasCainjector)
+ {
+ missing.Add("cert-manager cainjector pod not found");
+ }
+
+ // Also check if the Certificate CRD exists (confirms CRDs are installed).
+
+ bool crdExists = await CertificateCrdExists(client, ct);
+
+ if (!crdExists)
+ {
+ missing.Add("Certificate CRD (certificates.cert-manager.io) not found");
+ }
+ else
+ {
+ details.Add("Certificate CRD present");
+ }
+
+ // Build discovered configuration from what we found on the cluster.
+
+ string? version = await GetVersionFromPods(client, ct);
+
+ DiscoveredConfiguration config = new(
+ Version: version,
+ Namespace: "cert-manager",
+ HelmReleaseName: "cert-manager",
+ Values: new Dictionary
+ {
+ ["controllerReplicas"] = runningPods.Count(p => p.Contains("cert-manager") && !p.Contains("webhook") && !p.Contains("cainjector")).ToString(),
+ ["hasWebhook"] = hasWebhook.ToString(),
+ ["hasCainjector"] = hasCainjector.ToString()
+ });
+
+ if (missing.Count > 0)
+ {
+ return new ComponentCheckResult(ComponentName, ComponentStatus.Degraded, details, missing, config);
+ }
+
+ return new ComponentCheckResult(ComponentName, ComponentStatus.Installed, details, missing, config);
+ }
+
+ private async Task GetVersionFromPods(Kubernetes client, CancellationToken ct)
+ {
+ try
+ {
+ // List all pods in the cert-manager namespace — the namespace is
+ // dedicated to cert-manager, so no label filtering needed. This
+ // catches all components (controller, webhook, cainjector) regardless
+ // of Helm release name or label configuration.
+
+ V1PodList podList = await client.CoreV1.ListNamespacedPodAsync(
+ "cert-manager",
+ cancellationToken: ct);
+
+ foreach (V1Pod pod in podList.Items)
+ {
+ if (pod.Spec?.Containers?.Count > 0)
+ {
+ string? image = pod.Spec.Containers[0].Image;
+
+ if (image?.Contains(':') == true)
+ {
+ return image.Split(':').Last().TrimStart('v');
+ }
+ }
+ }
+ }
+ catch { }
+
+ return null;
+ }
+
+ private async Task> FindCertManagerPods(Kubernetes client, CancellationToken ct)
+ {
+ List pods = new();
+
+ try
+ {
+ // List all running pods in the cert-manager namespace. Since this
+ // namespace is dedicated to cert-manager, we don't need to filter
+ // by labels — any running pod here is a cert-manager component.
+
+ V1PodList podList = await client.CoreV1.ListNamespacedPodAsync(
+ "cert-manager",
+ cancellationToken: ct);
+
+ foreach (V1Pod pod in podList.Items)
+ {
+ if (pod.Status?.Phase == "Running")
+ {
+ pods.Add(pod.Metadata.Name);
+ }
+ }
+ }
+ catch (k8s.Autorest.HttpOperationException ex) when (ex.Response.StatusCode == System.Net.HttpStatusCode.NotFound)
+ {
+ logger.LogDebug("cert-manager namespace not found on cluster");
+ }
+ catch (Exception ex)
+ {
+ logger.LogWarning(ex, "Error checking for cert-manager pods");
+ }
+
+ return pods;
+ }
+
+ private async Task CertificateCrdExists(Kubernetes client, CancellationToken ct)
+ {
+ try
+ {
+ await client.ApiextensionsV1.ReadCustomResourceDefinitionAsync(
+ "certificates.cert-manager.io", cancellationToken: ct);
+ return true;
+ }
+ catch
+ {
+ return false;
+ }
+ }
+
+ private static Kubernetes BuildClient(KubernetesCluster cluster)
+ {
+ byte[] kubeConfigBytes = Encoding.UTF8.GetBytes(cluster.KubeConfig);
+ using MemoryStream stream = new(kubeConfigBytes);
+ KubernetesClientConfiguration config = KubernetesClientConfiguration.BuildConfigFromConfigFile(stream, cluster.ContextName);
+ return new Kubernetes(config);
+ }
+}
diff --git a/src/EntKube.Clusters/Features/AdoptCluster/Components/CertManager/CertManagerInstaller.cs b/src/EntKube.Clusters/Features/AdoptCluster/Components/CertManager/CertManagerInstaller.cs
new file mode 100644
index 0000000..260d7e0
--- /dev/null
+++ b/src/EntKube.Clusters/Features/AdoptCluster/Components/CertManager/CertManagerInstaller.cs
@@ -0,0 +1,270 @@
+using System.Text;
+using EntKube.Clusters.Domain;
+using k8s;
+using k8s.Models;
+using Microsoft.Extensions.Logging;
+
+namespace EntKube.Clusters.Features.AdoptCluster.Components.CertManager;
+
+///
+/// Installs cert-manager via Helm. cert-manager provides the CRD runtime for
+/// Certificate, Issuer, and ClusterIssuer resources. Let's Encrypt ClusterIssuer
+/// configuration is handled separately by the LetsEncryptInstaller.
+///
+/// What gets deployed:
+/// 1. cert-manager namespace with controller, webhook, and cainjector
+/// 2. CRDs for Certificate, Issuer, ClusterIssuer, etc.
+///
+/// Configuration keys:
+/// - "replicas": cert-manager controller replicas (default: 1)
+///
+public class CertManagerInstaller : IComponentInstaller
+{
+ private readonly ILogger logger;
+
+ private const string DefaultVersion = "1.16.3";
+ private const string DefaultNamespace = "cert-manager";
+ private const string HelmRepo = "https://charts.jetstack.io";
+
+ public string ComponentName => "cert-manager";
+
+ public CertManagerInstaller(ILogger logger)
+ {
+ this.logger = logger;
+ }
+
+ public async Task InstallAsync(KubernetesCluster cluster, ComponentInstallOptions options, CancellationToken ct = default)
+ {
+ string version = options.Version ?? DefaultVersion;
+ string targetNamespace = options.Namespace ?? DefaultNamespace;
+ List actions = new();
+
+ string tempKubeConfig = Path.Combine(Path.GetTempPath(), $"entkube-certmgr-{Guid.NewGuid()}.kubeconfig");
+
+ try
+ {
+ await File.WriteAllTextAsync(tempKubeConfig, cluster.KubeConfig, ct);
+
+ // Ensure the namespace exists.
+
+ Kubernetes client = BuildClient(cluster);
+ await EnsureNamespace(client, targetNamespace, ct);
+ actions.Add($"Ensured namespace '{targetNamespace}'");
+
+ // Install cert-manager with CRDs via Helm.
+
+ await RunCommand("helm",
+ $"upgrade --install cert-manager cert-manager " +
+ $"--repo {HelmRepo} --version v{version} " +
+ $"--namespace {targetNamespace} " +
+ $"--kubeconfig \"{tempKubeConfig}\" --kube-context \"{cluster.ContextName}\" " +
+ $"--set crds.enabled=true " +
+ $"--set replicaCount=1 " +
+ $"--set webhook.replicaCount=1 " +
+ $"--set cainjector.replicaCount=1 " +
+ $"--set resources.requests.cpu=50m " +
+ $"--set resources.requests.memory=128Mi " +
+ $"--set resources.limits.cpu=200m " +
+ $"--set resources.limits.memory=256Mi " +
+ $"--set prometheus.enabled=true " +
+ $"--set prometheus.servicemonitor.enabled=false " +
+ $"--wait --timeout 10m",
+ ct);
+ actions.Add($"Helm install cert-manager v{version} with CRDs");
+
+ logger.LogInformation("cert-manager {Version} installed on cluster {Cluster}",
+ version, cluster.Name);
+
+ return new InstallResult(
+ Success: true,
+ ComponentName: ComponentName,
+ Message: $"cert-manager {version} installed",
+ Actions: actions);
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Failed to install cert-manager on cluster {Cluster}", cluster.Name);
+ actions.Add($"Error: {ex.Message}");
+
+ return new InstallResult(
+ Success: false,
+ ComponentName: ComponentName,
+ Message: $"Failed to install cert-manager: {ex.Message}",
+ Actions: actions);
+ }
+ finally
+ {
+ if (File.Exists(tempKubeConfig))
+ {
+ File.Delete(tempKubeConfig);
+ }
+ }
+ }
+
+ ///
+ /// Reconfigures cert-manager. Supports:
+ /// - "replicas": controller replicas
+ ///
+ public async Task ConfigureAsync(KubernetesCluster cluster, ComponentConfiguration configuration, CancellationToken ct = default)
+ {
+ string targetNamespace = configuration.Namespace ?? DefaultNamespace;
+ List actions = new();
+
+ string tempKubeConfig = Path.Combine(Path.GetTempPath(), $"entkube-certmgr-cfg-{Guid.NewGuid()}.kubeconfig");
+
+ try
+ {
+ await File.WriteAllTextAsync(tempKubeConfig, cluster.KubeConfig, ct);
+
+ // Handle Helm-level configuration (replicas, resources).
+
+ List setFlags = new();
+
+ if (configuration.Values.TryGetValue("replicas", out string? replicas))
+ {
+ setFlags.Add($"--set replicaCount={replicas}");
+ }
+
+ if (setFlags.Count > 0)
+ {
+ await RunCommand("helm",
+ $"upgrade cert-manager cert-manager --repo {HelmRepo} " +
+ $"--namespace {targetNamespace} " +
+ $"--kubeconfig \"{tempKubeConfig}\" --kube-context \"{cluster.ContextName}\" " +
+ $"--reuse-values {string.Join(" ", setFlags)} --wait --timeout 5m",
+ ct);
+ actions.Add($"Updated cert-manager Helm release: {string.Join(", ", setFlags)}");
+ }
+
+ return new InstallResult(
+ Success: true,
+ ComponentName: ComponentName,
+ Message: "cert-manager reconfigured",
+ Actions: actions);
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Failed to reconfigure cert-manager on cluster {Cluster}", cluster.Name);
+ return new InstallResult(
+ Success: false,
+ ComponentName: ComponentName,
+ Message: $"Failed to reconfigure cert-manager: {ex.Message}",
+ Actions: new List { $"Error: {ex.Message}" });
+ }
+ finally
+ {
+ if (File.Exists(tempKubeConfig))
+ {
+ File.Delete(tempKubeConfig);
+ }
+ }
+ }
+
+ ///
+ /// Uninstalls cert-manager from the cluster by removing the Helm release.
+ /// Warning: this removes all CRDs too — any Certificate, Issuer, and
+ /// ClusterIssuer resources on the cluster will be deleted.
+ ///
+ public async Task UninstallAsync(KubernetesCluster cluster, ComponentInstallOptions options, CancellationToken ct = default)
+ {
+ string targetNamespace = options.Namespace ?? DefaultNamespace;
+ List actions = new();
+
+ string tempKubeConfig = Path.Combine(Path.GetTempPath(), $"entkube-certmgr-rm-{Guid.NewGuid()}.kubeconfig");
+
+ try
+ {
+ await File.WriteAllTextAsync(tempKubeConfig, cluster.KubeConfig, ct);
+
+ await RunCommand("helm",
+ $"uninstall cert-manager --namespace {targetNamespace} " +
+ $"--kubeconfig \"{tempKubeConfig}\" --kube-context \"{cluster.ContextName}\" " +
+ $"--wait --timeout 5m",
+ ct);
+ actions.Add("Helm uninstall cert-manager");
+
+ logger.LogInformation("cert-manager uninstalled from cluster {Cluster}", cluster.Name);
+
+ return new InstallResult(
+ Success: true,
+ ComponentName: ComponentName,
+ Message: "cert-manager uninstalled successfully",
+ Actions: actions);
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Failed to uninstall cert-manager from cluster {Cluster}", cluster.Name);
+ return new InstallResult(
+ Success: false,
+ ComponentName: ComponentName,
+ Message: $"Failed to uninstall cert-manager: {ex.Message}",
+ Actions: new List { $"Error: {ex.Message}" });
+ }
+ finally
+ {
+ if (File.Exists(tempKubeConfig))
+ {
+ File.Delete(tempKubeConfig);
+ }
+ }
+ }
+
+ private async Task EnsureNamespace(Kubernetes client, string namespaceName, CancellationToken ct)
+ {
+ try
+ {
+ await client.CoreV1.ReadNamespaceAsync(namespaceName, cancellationToken: ct);
+ }
+ catch (k8s.Autorest.HttpOperationException ex) when (ex.Response.StatusCode == System.Net.HttpStatusCode.NotFound)
+ {
+ V1Namespace ns = new()
+ {
+ Metadata = new V1ObjectMeta
+ {
+ Name = namespaceName,
+ Labels = new Dictionary
+ {
+ ["app.kubernetes.io/managed-by"] = "entkube"
+ }
+ }
+ };
+ await client.CoreV1.CreateNamespaceAsync(ns, cancellationToken: ct);
+ }
+ }
+
+ private async Task RunCommand(string fileName, string arguments, CancellationToken ct)
+ {
+ System.Diagnostics.ProcessStartInfo psi = new(fileName, arguments)
+ {
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ UseShellExecute = false
+ };
+
+ using System.Diagnostics.Process? process = System.Diagnostics.Process.Start(psi);
+
+ if (process is null)
+ {
+ throw new InvalidOperationException($"Failed to start {fileName}");
+ }
+
+ string output = await process.StandardOutput.ReadToEndAsync(ct);
+ string error = await process.StandardError.ReadToEndAsync(ct);
+ await process.WaitForExitAsync(ct);
+
+ if (process.ExitCode != 0)
+ {
+ throw new InvalidOperationException($"{fileName} failed (exit {process.ExitCode}): {error}");
+ }
+
+ return output;
+ }
+
+ private static Kubernetes BuildClient(KubernetesCluster cluster)
+ {
+ byte[] kubeConfigBytes = Encoding.UTF8.GetBytes(cluster.KubeConfig);
+ using MemoryStream stream = new(kubeConfigBytes);
+ KubernetesClientConfiguration config = KubernetesClientConfiguration.BuildConfigFromConfigFile(stream, cluster.ContextName);
+ return new Kubernetes(config);
+ }
+}
diff --git a/src/EntKube.Clusters/Features/AdoptCluster/Components/CloudNativePG/CloudNativePGCheck.cs b/src/EntKube.Clusters/Features/AdoptCluster/Components/CloudNativePG/CloudNativePGCheck.cs
new file mode 100644
index 0000000..36793a1
--- /dev/null
+++ b/src/EntKube.Clusters/Features/AdoptCluster/Components/CloudNativePG/CloudNativePGCheck.cs
@@ -0,0 +1,201 @@
+using System.Text;
+using EntKube.Clusters.Domain;
+using k8s;
+using k8s.Models;
+using Microsoft.Extensions.Logging;
+
+namespace EntKube.Clusters.Features.AdoptCluster.Components.CloudNativePG;
+
+///
+/// Checks whether CloudNativePG (CNPG) operator is installed and healthy on the cluster.
+/// CNPG is the Kubernetes operator for managing PostgreSQL databases natively.
+/// It handles provisioning, failover, backup, and point-in-time recovery.
+///
+/// This check verifies:
+/// 1. CNPG operator pods running in cnpg-system namespace
+/// 2. Cluster CRD (clusters.postgresql.cnpg.io) is registered
+/// 3. Any existing PostgreSQL clusters managed by the operator
+///
+public class CloudNativePGCheck : IAdoptionCheck
+{
+ private readonly ILogger logger;
+
+ public string ComponentName => "cnpg";
+ public string DisplayName => "CloudNativePG (PostgreSQL Operator)";
+
+ public CloudNativePGCheck(ILogger logger)
+ {
+ this.logger = logger;
+ }
+
+ public async Task CheckAsync(KubernetesCluster cluster, CancellationToken ct = default)
+ {
+ Kubernetes client = BuildClient(cluster);
+ List details = new();
+ List missing = new();
+
+ // Check 1: CNPG operator pods in cnpg-system namespace.
+
+ List operatorPods = await FindOperatorPods(client, ct);
+
+ if (operatorPods.Count > 0)
+ {
+ details.AddRange(operatorPods.Select(p => $"Operator pod: {p}"));
+ }
+ else
+ {
+ missing.Add("No CNPG operator pods found in cnpg-system namespace");
+ return new ComponentCheckResult(ComponentName, ComponentStatus.NotInstalled, details, missing);
+ }
+
+ // Check 2: Cluster CRD exists (confirms operator CRDs installed).
+
+ bool crdExists = await ClusterCrdExists(client, ct);
+
+ if (crdExists)
+ {
+ details.Add("Cluster CRD (clusters.postgresql.cnpg.io) present");
+ }
+ else
+ {
+ missing.Add("Cluster CRD (clusters.postgresql.cnpg.io) not found");
+ }
+
+ // Check 3: Discover existing PostgreSQL clusters across all namespaces.
+
+ int clusterCount = await CountPostgresClusters(client, ct);
+ details.Add($"Managed PostgreSQL clusters: {clusterCount}");
+
+ // Discover operator version from the controller pod image tag.
+
+ string? version = await GetVersionFromPods(client, ct);
+
+ DiscoveredConfiguration config = new(
+ Version: version,
+ Namespace: "cnpg-system",
+ HelmReleaseName: "cnpg",
+ Values: new Dictionary
+ {
+ ["operatorReplicas"] = operatorPods.Count.ToString(),
+ ["managedClusters"] = clusterCount.ToString(),
+ ["crdInstalled"] = crdExists.ToString()
+ });
+
+ if (missing.Count > 0)
+ {
+ return new ComponentCheckResult(ComponentName, ComponentStatus.Degraded, details, missing, config);
+ }
+
+ return new ComponentCheckResult(ComponentName, ComponentStatus.Installed, details, missing, config);
+ }
+
+ private async Task GetVersionFromPods(Kubernetes client, CancellationToken ct)
+ {
+ try
+ {
+ V1PodList podList = await client.CoreV1.ListNamespacedPodAsync(
+ "cnpg-system",
+ labelSelector: "app.kubernetes.io/name=cloudnative-pg",
+ cancellationToken: ct);
+
+ foreach (V1Pod pod in podList.Items)
+ {
+ if (pod.Spec?.Containers?.Count > 0)
+ {
+ string? image = pod.Spec.Containers[0].Image;
+
+ if (image?.Contains(':') == true)
+ {
+ return image.Split(':').Last().TrimStart('v');
+ }
+ }
+ }
+ }
+ catch { }
+
+ return null;
+ }
+
+ private async Task> FindOperatorPods(Kubernetes client, CancellationToken ct)
+ {
+ List pods = new();
+
+ try
+ {
+ V1PodList podList = await client.CoreV1.ListNamespacedPodAsync(
+ "cnpg-system",
+ labelSelector: "app.kubernetes.io/name=cloudnative-pg",
+ cancellationToken: ct);
+
+ foreach (V1Pod pod in podList.Items)
+ {
+ if (pod.Status?.Phase == "Running")
+ {
+ pods.Add(pod.Metadata.Name);
+ }
+ }
+ }
+ catch (k8s.Autorest.HttpOperationException ex) when (ex.Response.StatusCode == System.Net.HttpStatusCode.NotFound)
+ {
+ logger.LogDebug("cnpg-system namespace not found on cluster");
+ }
+ catch (Exception ex)
+ {
+ logger.LogWarning(ex, "Error checking for CNPG operator pods");
+ }
+
+ return pods;
+ }
+
+ private async Task ClusterCrdExists(Kubernetes client, CancellationToken ct)
+ {
+ try
+ {
+ await client.ApiextensionsV1.ReadCustomResourceDefinitionAsync(
+ "clusters.postgresql.cnpg.io", cancellationToken: ct);
+ return true;
+ }
+ catch
+ {
+ return false;
+ }
+ }
+
+ private async Task CountPostgresClusters(Kubernetes client, CancellationToken ct)
+ {
+ try
+ {
+ object result = await client.CustomObjects.ListClusterCustomObjectAsync(
+ group: "postgresql.cnpg.io",
+ version: "v1",
+ plural: "clusters",
+ cancellationToken: ct);
+
+ // Serialize then parse — the k8s client can return different runtime
+ // types (JsonElement, JObject, Dictionary) depending on configuration.
+
+ string json = System.Text.Json.JsonSerializer.Serialize(result);
+
+ using System.Text.Json.JsonDocument doc = System.Text.Json.JsonDocument.Parse(json);
+
+ if (doc.RootElement.TryGetProperty("items", out System.Text.Json.JsonElement items))
+ {
+ return items.GetArrayLength();
+ }
+
+ return 0;
+ }
+ catch
+ {
+ return 0;
+ }
+ }
+
+ private static Kubernetes BuildClient(KubernetesCluster cluster)
+ {
+ byte[] kubeConfigBytes = Encoding.UTF8.GetBytes(cluster.KubeConfig);
+ using MemoryStream stream = new(kubeConfigBytes);
+ KubernetesClientConfiguration config = KubernetesClientConfiguration.BuildConfigFromConfigFile(stream, cluster.ContextName);
+ return new Kubernetes(config);
+ }
+}
diff --git a/src/EntKube.Clusters/Features/AdoptCluster/Components/CloudNativePG/CloudNativePGInstaller.cs b/src/EntKube.Clusters/Features/AdoptCluster/Components/CloudNativePG/CloudNativePGInstaller.cs
new file mode 100644
index 0000000..d2b8099
--- /dev/null
+++ b/src/EntKube.Clusters/Features/AdoptCluster/Components/CloudNativePG/CloudNativePGInstaller.cs
@@ -0,0 +1,353 @@
+using System.Text;
+using EntKube.Clusters.Domain;
+using k8s;
+using k8s.Models;
+using Microsoft.Extensions.Logging;
+
+namespace EntKube.Clusters.Features.AdoptCluster.Components.CloudNativePG;
+
+///
+/// Installs CloudNativePG (CNPG) operator via Helm. The operator manages the full
+/// lifecycle of PostgreSQL databases on Kubernetes — provisioning, HA failover,
+/// automated backups, point-in-time recovery, and rolling updates.
+///
+/// What gets deployed:
+/// 1. cnpg-system namespace with the CNPG operator controller
+/// 2. All required CRDs (Cluster, Backup, ScheduledBackup, Pooler, etc.)
+/// 3. Webhook configuration for validating/mutating PostgreSQL clusters
+///
+/// Configuration keys:
+/// - "operatorReplicas": Number of operator controller replicas (default: 1)
+/// - "monitoringEnabled": Whether to create ServiceMonitor for Prometheus scraping
+///
+/// Note: This installs the OPERATOR only. Actual PostgreSQL clusters are provisioned
+/// separately via the Provisioning service (per-tenant databases).
+///
+public class CloudNativePGInstaller : IComponentInstaller
+{
+ private readonly ILogger logger;
+
+ private const string DefaultVersion = "0.22.0";
+ private const string DefaultNamespace = "cnpg-system";
+ private const string HelmRepo = "https://cloudnative-pg.github.io/charts";
+
+ public string ComponentName => "cnpg";
+
+ public CloudNativePGInstaller(ILogger logger)
+ {
+ this.logger = logger;
+ }
+
+ public async Task InstallAsync(KubernetesCluster cluster, ComponentInstallOptions options, CancellationToken ct = default)
+ {
+ string version = options.Version ?? DefaultVersion;
+ string targetNamespace = options.Namespace ?? DefaultNamespace;
+ bool monitoringEnabled = options.Parameters?.TryGetValue("monitoringEnabled", out string? monVal) == true
+ && monVal == "true";
+ List actions = new();
+
+ string tempKubeConfig = Path.Combine(Path.GetTempPath(), $"entkube-cnpg-{Guid.NewGuid()}.kubeconfig");
+
+ try
+ {
+ await File.WriteAllTextAsync(tempKubeConfig, cluster.KubeConfig, ct);
+
+ // Ensure the namespace exists with management labels.
+
+ Kubernetes client = BuildClient(cluster);
+ await EnsureNamespaceWithLabels(client, targetNamespace, ct);
+ actions.Add($"Ensured namespace '{targetNamespace}' with management labels");
+
+ // Install CNPG operator via Helm. The chart includes all CRDs,
+ // the controller deployment, webhook configuration, and RBAC.
+
+ string monitoringFlag = monitoringEnabled
+ ? "--set monitoring.podMonitorEnabled=true --set monitoring.grafanaDashboard.create=true"
+ : "--set monitoring.podMonitorEnabled=false";
+
+ await RunCommand("helm",
+ $"upgrade --install cnpg cloudnative-pg " +
+ $"--repo {HelmRepo} --version {version} " +
+ $"--namespace {targetNamespace} " +
+ $"--kubeconfig \"{tempKubeConfig}\" --kube-context \"{cluster.ContextName}\" " +
+ $"--set replicaCount=1 " +
+ $"--set resources.requests.cpu=100m " +
+ $"--set resources.requests.memory=256Mi " +
+ $"--set resources.limits.cpu=500m " +
+ $"--set resources.limits.memory=512Mi " +
+ $"{monitoringFlag} " +
+ $"--wait --timeout 10m",
+ ct);
+ actions.Add($"Helm install cnpg operator v{version}");
+
+ // If the user selected a storage bucket for WAL/backup, create a
+ // Kubernetes secret with the bucket credentials so CNPG clusters can
+ // reference it for Barman object store (S3-based WAL archiving).
+
+ if (options.Parameters?.TryGetValue("walBucketId", out string? walBucketIdVal) == true
+ && !string.IsNullOrEmpty(walBucketIdVal) && Guid.TryParse(walBucketIdVal, out Guid walBucketId))
+ {
+ StorageBucket? walBucket = cluster.StorageBuckets.FirstOrDefault(b => b.Id == walBucketId);
+
+ if (walBucket is not null)
+ {
+ await EnsureBackupStorageSecret(client, targetNamespace, walBucket, ct);
+ actions.Add($"Created backup storage secret 'cnpg-backup-creds' with bucket '{walBucket.Name}'");
+ actions.Add($"CNPG clusters can use: endpointURL={walBucket.Endpoint}, bucket={walBucket.Name}, secret=cnpg-backup-creds");
+ logger.LogInformation("Created CNPG backup storage secret for bucket '{BucketName}'", walBucket.Name);
+ }
+ }
+
+ logger.LogInformation("CloudNativePG Operator {Version} installed on cluster {Cluster}",
+ version, cluster.Name);
+
+ return new InstallResult(
+ Success: true,
+ ComponentName: ComponentName,
+ Message: $"CloudNativePG Operator {version} installed",
+ Actions: actions);
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Failed to install CNPG on cluster {Cluster}", cluster.Name);
+ actions.Add($"Error: {ex.Message}");
+
+ return new InstallResult(
+ Success: false,
+ ComponentName: ComponentName,
+ Message: $"Failed to install CloudNativePG: {ex.Message}",
+ Actions: actions);
+ }
+ finally
+ {
+ if (File.Exists(tempKubeConfig))
+ {
+ File.Delete(tempKubeConfig);
+ }
+ }
+ }
+
+ ///
+ /// Reconfigures CNPG operator. Supports:
+ /// - "operatorReplicas": number of operator replicas
+ /// - "monitoringEnabled": enable/disable Prometheus monitoring
+ ///
+ public async Task ConfigureAsync(KubernetesCluster cluster, ComponentConfiguration configuration, CancellationToken ct = default)
+ {
+ string targetNamespace = configuration.Namespace ?? DefaultNamespace;
+ List actions = new();
+
+ string tempKubeConfig = Path.Combine(Path.GetTempPath(), $"entkube-cnpg-cfg-{Guid.NewGuid()}.kubeconfig");
+
+ try
+ {
+ await File.WriteAllTextAsync(tempKubeConfig, cluster.KubeConfig, ct);
+
+ List setFlags = new();
+
+ if (configuration.Values.TryGetValue("operatorReplicas", out string? replicas))
+ {
+ setFlags.Add($"--set replicaCount={replicas}");
+ }
+
+ if (configuration.Values.TryGetValue("monitoringEnabled", out string? monitoring))
+ {
+ setFlags.Add($"--set monitoring.podMonitorEnabled={monitoring}");
+ setFlags.Add($"--set monitoring.grafanaDashboard.create={monitoring}");
+ }
+
+ await RunCommand("helm",
+ $"upgrade cnpg cloudnative-pg --repo {HelmRepo} " +
+ $"--namespace {targetNamespace} " +
+ $"--kubeconfig \"{tempKubeConfig}\" --kube-context \"{cluster.ContextName}\" " +
+ $"--reuse-values {string.Join(" ", setFlags)} --wait --timeout 5m",
+ ct);
+
+ actions.Add($"Reconfigured CNPG: {string.Join(", ", configuration.Values.Select(kv => $"{kv.Key}={kv.Value}"))}");
+
+ return new InstallResult(
+ Success: true,
+ ComponentName: ComponentName,
+ Message: "CloudNativePG operator reconfigured",
+ Actions: actions);
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Failed to reconfigure CNPG on cluster {Cluster}", cluster.Name);
+ return new InstallResult(
+ Success: false,
+ ComponentName: ComponentName,
+ Message: $"Failed to reconfigure CloudNativePG: {ex.Message}",
+ Actions: new List { $"Error: {ex.Message}" });
+ }
+ finally
+ {
+ if (File.Exists(tempKubeConfig))
+ {
+ File.Delete(tempKubeConfig);
+ }
+ }
+ }
+
+ ///
+ /// Uninstalls CloudNativePG operator from the cluster. Warning: existing
+ /// PostgreSQL Cluster resources will become unmanaged.
+ ///
+ public async Task UninstallAsync(KubernetesCluster cluster, ComponentInstallOptions options, CancellationToken ct = default)
+ {
+ string targetNamespace = options.Namespace ?? DefaultNamespace;
+ List actions = new();
+
+ string tempKubeConfig = Path.Combine(Path.GetTempPath(), $"entkube-cnpg-rm-{Guid.NewGuid()}.kubeconfig");
+
+ try
+ {
+ await File.WriteAllTextAsync(tempKubeConfig, cluster.KubeConfig, ct);
+
+ await RunCommand("helm",
+ $"uninstall cnpg --namespace {targetNamespace} " +
+ $"--kubeconfig \"{tempKubeConfig}\" --kube-context \"{cluster.ContextName}\" " +
+ $"--wait --timeout 5m",
+ ct);
+ actions.Add("Helm uninstall cnpg");
+
+ logger.LogInformation("CloudNativePG uninstalled from cluster {Cluster}", cluster.Name);
+
+ return new InstallResult(
+ Success: true,
+ ComponentName: ComponentName,
+ Message: "CloudNativePG operator uninstalled successfully",
+ Actions: actions);
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Failed to uninstall CloudNativePG from cluster {Cluster}", cluster.Name);
+ return new InstallResult(
+ Success: false,
+ ComponentName: ComponentName,
+ Message: $"Failed to uninstall CloudNativePG: {ex.Message}",
+ Actions: new List { $"Error: {ex.Message}" });
+ }
+ finally
+ {
+ if (File.Exists(tempKubeConfig))
+ {
+ File.Delete(tempKubeConfig);
+ }
+ }
+ }
+
+ private async Task EnsureNamespaceWithLabels(Kubernetes client, string namespaceName, CancellationToken ct)
+ {
+ try
+ {
+ await client.CoreV1.ReadNamespaceAsync(namespaceName, cancellationToken: ct);
+ }
+ catch (k8s.Autorest.HttpOperationException ex) when (ex.Response.StatusCode == System.Net.HttpStatusCode.NotFound)
+ {
+ V1Namespace ns = new()
+ {
+ Metadata = new V1ObjectMeta
+ {
+ Name = namespaceName,
+ Labels = new Dictionary
+ {
+ ["app.kubernetes.io/managed-by"] = "entkube",
+ ["app.kubernetes.io/part-of"] = "cnpg-platform"
+ }
+ }
+ };
+ await client.CoreV1.CreateNamespaceAsync(ns, cancellationToken: ct);
+ }
+ }
+
+ private async Task RunCommand(string fileName, string arguments, CancellationToken ct)
+ {
+ System.Diagnostics.ProcessStartInfo psi = new(fileName, arguments)
+ {
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ UseShellExecute = false
+ };
+
+ using System.Diagnostics.Process? process = System.Diagnostics.Process.Start(psi);
+
+ if (process is null)
+ {
+ throw new InvalidOperationException($"Failed to start {fileName}");
+ }
+
+ string output = await process.StandardOutput.ReadToEndAsync(ct);
+ string error = await process.StandardError.ReadToEndAsync(ct);
+ await process.WaitForExitAsync(ct);
+
+ if (process.ExitCode != 0)
+ {
+ throw new InvalidOperationException($"{fileName} failed (exit {process.ExitCode}): {error}");
+ }
+
+ return output;
+ }
+
+ private static Kubernetes BuildClient(KubernetesCluster cluster)
+ {
+ byte[] kubeConfigBytes = Encoding.UTF8.GetBytes(cluster.KubeConfig);
+ using MemoryStream stream = new(kubeConfigBytes);
+ KubernetesClientConfiguration config = KubernetesClientConfiguration.BuildConfigFromConfigFile(stream, cluster.ContextName);
+ return new Kubernetes(config);
+ }
+
+ ///
+ /// Creates or updates a Kubernetes Secret with S3 bucket credentials that
+ /// CNPG clusters can reference for Barman object store (WAL archiving and
+ /// base backups). The secret keys match what CNPG expects:
+ /// - ACCESS_KEY_ID: S3 access key
+ /// - ACCESS_SECRET_KEY: S3 secret key
+ /// - BUCKET_NAME: target bucket name
+ /// - ENDPOINT_URL: S3 endpoint URL
+ ///
+ private async Task EnsureBackupStorageSecret(
+ Kubernetes client, string namespaceName, StorageBucket bucket, CancellationToken ct)
+ {
+ Dictionary secretData = new()
+ {
+ ["ACCESS_KEY_ID"] = Encoding.UTF8.GetBytes(bucket.AccessKey),
+ ["ACCESS_SECRET_KEY"] = Encoding.UTF8.GetBytes(bucket.SecretKey),
+ ["BUCKET_NAME"] = Encoding.UTF8.GetBytes(bucket.Name),
+ ["ENDPOINT_URL"] = Encoding.UTF8.GetBytes(bucket.Endpoint)
+ };
+
+ // Include the S3 region when available. External S3 providers like
+ // Cleura require a region for AWS Signature V4 authentication in
+ // barman-cloud. Without it, WAL archiving fails silently.
+
+ if (!string.IsNullOrWhiteSpace(bucket.Region))
+ {
+ secretData["AWS_DEFAULT_REGION"] = Encoding.UTF8.GetBytes(bucket.Region);
+ }
+
+ V1Secret secret = new()
+ {
+ Metadata = new V1ObjectMeta
+ {
+ Name = "cnpg-backup-creds",
+ NamespaceProperty = namespaceName,
+ Labels = new Dictionary
+ {
+ ["app.kubernetes.io/managed-by"] = "entkube",
+ ["app.kubernetes.io/component"] = "cnpg-backup"
+ }
+ },
+ Data = secretData
+ };
+
+ try
+ {
+ await client.CoreV1.ReplaceNamespacedSecretAsync(secret, "cnpg-backup-creds", namespaceName, cancellationToken: ct);
+ }
+ catch (k8s.Autorest.HttpOperationException ex) when (ex.Response.StatusCode == System.Net.HttpStatusCode.NotFound)
+ {
+ await client.CoreV1.CreateNamespacedSecretAsync(secret, namespaceName, cancellationToken: ct);
+ }
+ }
+}
diff --git a/src/EntKube.Clusters/Features/AdoptCluster/Components/CnpgDatabaseProvisioner.cs b/src/EntKube.Clusters/Features/AdoptCluster/Components/CnpgDatabaseProvisioner.cs
new file mode 100644
index 0000000..a8eba3d
--- /dev/null
+++ b/src/EntKube.Clusters/Features/AdoptCluster/Components/CnpgDatabaseProvisioner.cs
@@ -0,0 +1,139 @@
+using k8s.Models;
+
+namespace EntKube.Clusters.Features.AdoptCluster.Components;
+
+///
+/// Creates databases on an existing CNPG cluster by building Kubernetes Job
+/// and Secret manifests. The installers (Gitea, Harbor, Keycloak) use these
+/// manifests to provision databases on the shared CNPG PostgreSQL cluster.
+/// Pure manifest builders — no I/O, fully unit-testable.
+///
+public static class CnpgDatabaseProvisioner
+{
+ ///
+ /// Builds the CNPG primary service FQDN from cluster name and namespace.
+ /// The convention is {clusterName}-rw.{namespace}.svc.cluster.local.
+ ///
+ public static string GetPrimaryHost(string clusterName, string clusterNamespace)
+ {
+ return $"{clusterName}-rw.{clusterNamespace}.svc.cluster.local";
+ }
+
+ ///
+ /// Builds the Kubernetes Job manifest that creates a database and owner role
+ /// on a CNPG cluster. The Job runs psql against the cluster's read-write
+ /// primary service ({clusterName}-rw).
+ ///
+ public static V1Job BuildCreateDatabaseJob(
+ string clusterName,
+ string clusterNamespace,
+ string databaseName,
+ string ownerRole,
+ string ownerPassword)
+ {
+ // The CNPG primary service follows the naming convention {clusterName}-rw.
+
+ string primaryService = $"{clusterName}-rw";
+ string rawJobName = $"create-db-{databaseName}-{Guid.NewGuid():N}";
+ string jobName = rawJobName.Length > 63 ? rawJobName[..63] : rawJobName;
+
+ // Build a SQL script that creates the role (if not exists) and database.
+ // We use CREATE ROLE ... LOGIN so the service can authenticate directly.
+ // The IF NOT EXISTS pattern prevents failures on re-runs.
+
+ string sqlScript =
+ $"SELECT 'CREATE ROLE {ownerRole} LOGIN PASSWORD ''{ownerPassword}''' " +
+ $"WHERE NOT EXISTS (SELECT FROM pg_roles WHERE rolname = '{ownerRole}')\\gexec\n" +
+ $"SELECT 'CREATE DATABASE {databaseName} OWNER {ownerRole}' " +
+ $"WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = '{databaseName}')\\gexec";
+
+ return new V1Job
+ {
+ Metadata = new V1ObjectMeta
+ {
+ Name = jobName,
+ NamespaceProperty = clusterNamespace,
+ Labels = new Dictionary
+ {
+ ["app.kubernetes.io/managed-by"] = "entkube",
+ ["entkube.io/operation"] = "create-database"
+ }
+ },
+ Spec = new V1JobSpec
+ {
+ BackoffLimit = 3,
+ TtlSecondsAfterFinished = 300,
+ Template = new V1PodTemplateSpec
+ {
+ Spec = new V1PodSpec
+ {
+ RestartPolicy = "Never",
+ Containers = new List
+ {
+ new()
+ {
+ Name = "create-db",
+ Image = "postgres:16-alpine",
+ Command = new List { "sh", "-c" },
+ Args = new List
+ {
+ $"echo \"{sqlScript}\" | PGPASSWORD=$POSTGRES_PASSWORD psql -h {primaryService} -U postgres"
+ },
+ Env = new List
+ {
+ new()
+ {
+ Name = "POSTGRES_PASSWORD",
+ ValueFrom = new V1EnvVarSource
+ {
+ SecretKeyRef = new V1SecretKeySelector
+ {
+ Name = $"{clusterName}-superuser",
+ Key = "password"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ };
+ }
+
+ ///
+ /// Builds a Kubernetes Secret containing database credentials so the
+ /// consuming service (Gitea, Harbor, Keycloak) can reference them.
+ ///
+ public static V1Secret BuildCredentialsSecret(
+ string secretName,
+ string targetNamespace,
+ string host,
+ string databaseName,
+ string username,
+ string password)
+ {
+ return new V1Secret
+ {
+ Metadata = new V1ObjectMeta
+ {
+ Name = secretName,
+ NamespaceProperty = targetNamespace,
+ Labels = new Dictionary
+ {
+ ["app.kubernetes.io/managed-by"] = "entkube",
+ ["entkube.io/purpose"] = "database-credentials"
+ }
+ },
+ Type = "Opaque",
+ StringData = new Dictionary
+ {
+ ["host"] = host,
+ ["database"] = databaseName,
+ ["username"] = username,
+ ["password"] = password
+ }
+ };
+ }
+}
diff --git a/src/EntKube.Clusters/Features/AdoptCluster/Components/CustomDNS/CustomDNSCheck.cs b/src/EntKube.Clusters/Features/AdoptCluster/Components/CustomDNS/CustomDNSCheck.cs
new file mode 100644
index 0000000..7bfa8cf
--- /dev/null
+++ b/src/EntKube.Clusters/Features/AdoptCluster/Components/CustomDNS/CustomDNSCheck.cs
@@ -0,0 +1,310 @@
+using EntKube.Clusters.Domain;
+using k8s;
+using k8s.Models;
+using Microsoft.Extensions.Logging;
+
+namespace EntKube.Clusters.Features.AdoptCluster.Components.CustomDNS;
+
+///
+/// Checks the CoreDNS configuration on the cluster for customizations.
+/// CoreDNS is the default DNS provider in Kubernetes and its behaviour is
+/// controlled by the "coredns" ConfigMap in kube-system. This check looks for:
+///
+/// 1. CoreDNS pods running in kube-system
+/// 2. Custom stub zones (forwarding specific domains to external resolvers)
+/// 3. Custom Corefile entries (additional plugins, rewrites, logging)
+/// 4. Custom DNS records via the "coredns-custom" ConfigMap (static A/CNAME)
+///
+/// When customizations are detected the discovered configuration includes the
+/// raw Corefile content and any custom ConfigMap data so the platform knows
+/// exactly how DNS is configured on this cluster.
+///
+public class CustomDNSCheck : IAdoptionCheck
+{
+ private readonly ILogger logger;
+
+ public string ComponentName => "custom-dns";
+ public string DisplayName => "Custom DNS (CoreDNS)";
+
+ public CustomDNSCheck(ILogger logger)
+ {
+ this.logger = logger;
+ }
+
+ public async Task CheckAsync(KubernetesCluster cluster, CancellationToken ct = default)
+ {
+ Kubernetes client = BuildClient(cluster);
+ List details = new();
+ List missing = new();
+
+ // Check 1: CoreDNS pods running in kube-system.
+ // Every Kubernetes cluster should have CoreDNS running — if not,
+ // the cluster DNS is in a broken state.
+
+ List coreDnsPods = await FindCoreDnsPods(client, ct);
+
+ if (coreDnsPods.Count > 0)
+ {
+ details.AddRange(coreDnsPods.Select(p => $"CoreDNS pod: {p}"));
+ }
+ else
+ {
+ missing.Add("No CoreDNS pods found in kube-system namespace");
+ return new ComponentCheckResult(ComponentName, ComponentStatus.NotInstalled, details, missing);
+ }
+
+ // Check 2: Read the main Corefile from the "coredns" ConfigMap.
+ // This tells us what plugins and zones CoreDNS is configured with.
+
+ string? corefile = await GetCorefile(client, ct);
+ bool hasStubZones = false;
+ bool hasCustomEntries = false;
+
+ if (corefile is not null)
+ {
+ details.Add("Corefile ConfigMap present");
+ hasStubZones = ContainsStubZones(corefile);
+ hasCustomEntries = ContainsCustomEntries(corefile);
+
+ if (hasStubZones)
+ {
+ details.Add("Stub zones detected in Corefile");
+ }
+
+ if (hasCustomEntries)
+ {
+ details.Add("Custom Corefile entries detected");
+ }
+ }
+ else
+ {
+ missing.Add("coredns ConfigMap not found in kube-system");
+ }
+
+ // Check 3: Look for "coredns-custom" ConfigMap which holds additional
+ // server blocks, static records, or override entries that get imported
+ // into the main Corefile via the import directive.
+
+ Dictionary? customRecords = await GetCustomRecordsConfigMap(client, ct);
+ bool hasCustomRecords = customRecords is not null && customRecords.Count > 0;
+
+ if (hasCustomRecords)
+ {
+ details.Add($"coredns-custom ConfigMap found with {customRecords!.Count} entries");
+ }
+
+ // Discover the CoreDNS version from the pod image tag.
+
+ string? version = await GetVersionFromPods(client, ct);
+
+ // Build discovered configuration based on what we found.
+
+ Dictionary values = new()
+ {
+ ["replicas"] = coreDnsPods.Count.ToString(),
+ ["hasStubZones"] = hasStubZones.ToString(),
+ ["hasCustomEntries"] = hasCustomEntries.ToString(),
+ ["hasCustomRecords"] = hasCustomRecords.ToString()
+ };
+
+ if (corefile is not null)
+ {
+ values["corefile"] = corefile;
+ }
+
+ DiscoveredConfiguration config = new(
+ Version: version,
+ Namespace: "kube-system",
+ HelmReleaseName: null,
+ Values: values);
+
+ // If CoreDNS is running but has no customizations, report as Installed
+ // but without custom configuration. If it has customizations, still Installed.
+
+ ComponentStatus status = coreDnsPods.Count > 0
+ ? ComponentStatus.Installed
+ : ComponentStatus.NotInstalled;
+
+ if (missing.Count > 0)
+ {
+ status = ComponentStatus.Degraded;
+ }
+
+ return new ComponentCheckResult(ComponentName, status, details, missing, config);
+ }
+
+ private async Task> FindCoreDnsPods(Kubernetes client, CancellationToken ct)
+ {
+ try
+ {
+ V1PodList podList = await client.CoreV1.ListNamespacedPodAsync(
+ "kube-system",
+ labelSelector: "k8s-app=kube-dns",
+ cancellationToken: ct);
+
+ return podList.Items
+ .Where(p => p.Status?.Phase == "Running")
+ .Select(p => p.Metadata.Name)
+ .ToList();
+ }
+ catch (Exception ex)
+ {
+ logger.LogWarning(ex, "Failed to query CoreDNS pods");
+ return new();
+ }
+ }
+
+ private async Task GetCorefile(Kubernetes client, CancellationToken ct)
+ {
+ try
+ {
+ V1ConfigMap configMap = await client.CoreV1.ReadNamespacedConfigMapAsync(
+ "coredns", "kube-system", cancellationToken: ct);
+
+ if (configMap.Data?.TryGetValue("Corefile", out string? corefile) == true)
+ {
+ return corefile;
+ }
+
+ return null;
+ }
+ catch (Exception ex)
+ {
+ logger.LogWarning(ex, "Failed to read coredns ConfigMap");
+ return null;
+ }
+ }
+
+ private async Task?> GetCustomRecordsConfigMap(Kubernetes client, CancellationToken ct)
+ {
+ try
+ {
+ V1ConfigMap configMap = await client.CoreV1.ReadNamespacedConfigMapAsync(
+ "coredns-custom", "kube-system", cancellationToken: ct);
+
+ return configMap.Data?.ToDictionary(k => k.Key, v => v.Value);
+ }
+ catch (k8s.Autorest.HttpOperationException ex) when (ex.Response.StatusCode == System.Net.HttpStatusCode.NotFound)
+ {
+ // coredns-custom ConfigMap doesn't exist — that's fine, no custom records.
+ return null;
+ }
+ catch (Exception ex)
+ {
+ logger.LogWarning(ex, "Failed to read coredns-custom ConfigMap");
+ return null;
+ }
+ }
+
+ ///
+ /// Stub zones are identified by additional server blocks that forward
+ /// specific domains to upstream DNS servers, e.g.:
+ /// corp.internal:53 { forward . 10.0.0.53 }
+ ///
+ private static bool ContainsStubZones(string corefile)
+ {
+ // A stub zone typically appears as a separate server block with a forward directive
+ // pointing to a non-standard upstream (not /etc/resolv.conf or well-known public DNS).
+ string[] lines = corefile.Split('\n');
+ bool insideNonDefaultBlock = false;
+
+ foreach (string line in lines)
+ {
+ string trimmed = line.Trim();
+
+ // A server block for a specific domain (not the catch-all ".") indicates a stub zone.
+ if (trimmed.Contains(':') && trimmed.EndsWith('{') && !trimmed.StartsWith(".:"))
+ {
+ insideNonDefaultBlock = true;
+ }
+
+ if (insideNonDefaultBlock && trimmed.StartsWith("forward"))
+ {
+ return true;
+ }
+
+ if (trimmed == "}" && insideNonDefaultBlock)
+ {
+ insideNonDefaultBlock = false;
+ }
+ }
+
+ return false;
+ }
+
+ ///
+ /// Custom entries are non-default plugins like rewrite, log (when explicitly added),
+ /// template, or import directives pointing to custom configuration.
+ ///
+ private static bool ContainsCustomEntries(string corefile)
+ {
+ string[] customPlugins = { "rewrite", "template", "import", "hosts", "file" };
+
+ string[] lines = corefile.Split('\n');
+
+ foreach (string line in lines)
+ {
+ string trimmed = line.Trim();
+
+ foreach (string plugin in customPlugins)
+ {
+ if (trimmed.StartsWith(plugin + " ") || trimmed == plugin)
+ {
+ return true;
+ }
+ }
+ }
+
+ return false;
+ }
+
+ private async Task GetVersionFromPods(Kubernetes client, CancellationToken ct)
+ {
+ try
+ {
+ V1PodList podList = await client.CoreV1.ListNamespacedPodAsync(
+ "kube-system",
+ labelSelector: "k8s-app=kube-dns",
+ cancellationToken: ct);
+
+ V1Pod? firstPod = podList.Items.FirstOrDefault();
+
+ if (firstPod?.Spec?.Containers is null)
+ {
+ return null;
+ }
+
+ string? image = firstPod.Spec.Containers
+ .FirstOrDefault(c => c.Name == "coredns")?.Image;
+
+ if (image is null)
+ {
+ return null;
+ }
+
+ // Image format: registry/coredns:1.11.1 or coredns/coredns:1.11.1
+ int tagIndex = image.LastIndexOf(':');
+
+ if (tagIndex > 0 && tagIndex < image.Length - 1)
+ {
+ return image[(tagIndex + 1)..];
+ }
+
+ return null;
+ }
+ catch (Exception ex)
+ {
+ logger.LogWarning(ex, "Failed to determine CoreDNS version");
+ return null;
+ }
+ }
+
+ private static Kubernetes BuildClient(KubernetesCluster cluster)
+ {
+ KubernetesClientConfiguration config = KubernetesClientConfiguration.BuildConfigFromConfigFile(
+ new MemoryStream(System.Text.Encoding.UTF8.GetBytes(cluster.KubeConfig)),
+ currentContext: cluster.ContextName);
+
+ return new Kubernetes(config);
+ }
+}
diff --git a/src/EntKube.Clusters/Features/AdoptCluster/Components/CustomDNS/CustomDNSInstaller.cs b/src/EntKube.Clusters/Features/AdoptCluster/Components/CustomDNS/CustomDNSInstaller.cs
new file mode 100644
index 0000000..71722a6
--- /dev/null
+++ b/src/EntKube.Clusters/Features/AdoptCluster/Components/CustomDNS/CustomDNSInstaller.cs
@@ -0,0 +1,438 @@
+using System.Text;
+using EntKube.Clusters.Domain;
+using k8s;
+using k8s.Models;
+using Microsoft.Extensions.Logging;
+
+namespace EntKube.Clusters.Features.AdoptCluster.Components.CustomDNS;
+
+///
+/// Configures CoreDNS on the cluster by patching the "coredns" and "coredns-custom"
+/// ConfigMaps in kube-system. Unlike most other components that deploy via Helm,
+/// CoreDNS is already running on every Kubernetes cluster — we just customize it.
+///
+/// This installer supports three types of customization:
+///
+/// 1. **Stub zones** — Forward queries for specific domains to internal DNS servers.
+/// Format: "domain=ip1,ip2;domain2=ip3" → generates server blocks with forward directives.
+/// Example: "corp.internal=10.0.0.53,10.0.0.54;db.internal=10.1.0.53"
+///
+/// 2. **Custom Corefile entries** — Additional plugin directives injected into the
+/// main server block (e.g., rewrite rules, logging, template records).
+/// Format: Raw Corefile plugin text (newline-separated).
+///
+/// 3. **Custom DNS records** — Static A records stored in the "coredns-custom" ConfigMap
+/// and imported via the hosts plugin.
+/// Format: "hostname=ip;hostname2=ip2" → generates hosts-format entries.
+///
+/// Configuration keys:
+/// - "stubZones": Semicolon-separated zone definitions (zone=ip1,ip2)
+/// - "customCorefileEntries": Raw plugin text to inject into the main server block
+/// - "customRecords": Semicolon-separated static A records (hostname=ip)
+///
+/// After patching the ConfigMap, CoreDNS pods automatically reload the configuration
+/// (CoreDNS watches ConfigMap changes and hot-reloads within ~30s).
+///
+public class CustomDNSInstaller : IComponentInstaller
+{
+ private readonly ILogger logger;
+
+ private const string Namespace = "kube-system";
+ private const string CoreDnsConfigMap = "coredns";
+ private const string CoreDnsCustomConfigMap = "coredns-custom";
+
+ public string ComponentName => "custom-dns";
+
+ public CustomDNSInstaller(ILogger logger)
+ {
+ this.logger = logger;
+ }
+
+ public async Task InstallAsync(KubernetesCluster cluster, ComponentInstallOptions options, CancellationToken ct = default)
+ {
+ // CoreDNS is already installed on all K8s clusters. "Installing" custom DNS
+ // means applying the initial set of customizations to the CoreDNS ConfigMap.
+
+ Dictionary values = options.Parameters ?? new();
+ List actions = new();
+
+ try
+ {
+ Kubernetes client = BuildClient(cluster);
+
+ // Parse and apply stub zones if provided.
+
+ if (values.TryGetValue("stubZones", out string? stubZonesRaw) && !string.IsNullOrWhiteSpace(stubZonesRaw))
+ {
+ List stubZones = ParseStubZones(stubZonesRaw);
+ await ApplyStubZones(client, stubZones, ct);
+ actions.Add($"Stub zones configured: {string.Join(", ", stubZones.Select(z => z.Domain))}");
+ }
+
+ // Parse and apply custom Corefile entries if provided.
+
+ if (values.TryGetValue("customCorefileEntries", out string? corefileEntries) && !string.IsNullOrWhiteSpace(corefileEntries))
+ {
+ await ApplyCustomCorefileEntries(client, corefileEntries, ct);
+ actions.Add("Custom Corefile entries applied");
+ }
+
+ // Parse and apply custom static DNS records if provided.
+
+ if (values.TryGetValue("customRecords", out string? recordsRaw) && !string.IsNullOrWhiteSpace(recordsRaw))
+ {
+ Dictionary records = ParseRecords(recordsRaw);
+ await ApplyCustomRecords(client, records, ct);
+ actions.Add($"Custom records applied: {string.Join(", ", records.Keys)}");
+ }
+
+ if (actions.Count == 0)
+ {
+ actions.Add("No customizations specified — CoreDNS left unchanged");
+ }
+
+ logger.LogInformation("CoreDNS customized on cluster {Cluster}: {Actions}",
+ cluster.Name, string.Join("; ", actions));
+
+ return new InstallResult(
+ Success: true,
+ ComponentName: ComponentName,
+ Message: "CoreDNS customized",
+ Actions: actions);
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Failed to customize CoreDNS on cluster {Cluster}", cluster.Name);
+ actions.Add($"Error: {ex.Message}");
+
+ return new InstallResult(
+ Success: false,
+ ComponentName: ComponentName,
+ Message: $"Failed to customize CoreDNS: {ex.Message}",
+ Actions: actions);
+ }
+ }
+
+ ///
+ /// Reconfigures CoreDNS. Accepts the same keys as InstallAsync — stub zones,
+ /// custom Corefile entries, and custom records. Each key is applied independently,
+ /// so you can update just the stub zones without touching records.
+ ///
+ public async Task ConfigureAsync(KubernetesCluster cluster, ComponentConfiguration configuration, CancellationToken ct = default)
+ {
+ // ConfigureAsync delegates to the same logic as InstallAsync since
+ // patching ConfigMaps is inherently idempotent — the new content
+ // replaces whatever was there before.
+
+ ComponentInstallOptions options = new(
+ Namespace: configuration.Namespace,
+ Parameters: configuration.Values);
+
+ return await InstallAsync(cluster, options, ct);
+ }
+
+ ///
+ /// Uninstalls custom DNS configuration by removing the coredns-custom ConfigMap.
+ /// This reverts CoreDNS to its default configuration without stub zones or
+ /// custom records.
+ ///
+ public async Task UninstallAsync(KubernetesCluster cluster, ComponentInstallOptions options, CancellationToken ct = default)
+ {
+ List actions = new();
+
+ try
+ {
+ Kubernetes client = BuildClient(cluster);
+
+ // Delete the coredns-custom ConfigMap to remove all custom DNS entries.
+
+ try
+ {
+ await client.CoreV1.DeleteNamespacedConfigMapAsync(
+ CoreDnsCustomConfigMap, Namespace, cancellationToken: ct);
+ actions.Add($"Deleted ConfigMap '{CoreDnsCustomConfigMap}' in '{Namespace}'");
+ }
+ catch (k8s.Autorest.HttpOperationException ex) when (ex.Response.StatusCode == System.Net.HttpStatusCode.NotFound)
+ {
+ // Already gone.
+ }
+
+ logger.LogInformation("Custom DNS configuration removed from cluster {Cluster}", cluster.Name);
+
+ return new InstallResult(
+ Success: true,
+ ComponentName: ComponentName,
+ Message: "Custom DNS configuration removed",
+ Actions: actions);
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Failed to uninstall custom DNS from cluster {Cluster}", cluster.Name);
+ return new InstallResult(
+ Success: false,
+ ComponentName: ComponentName,
+ Message: $"Failed to uninstall custom DNS: {ex.Message}",
+ Actions: new List { $"Error: {ex.Message}" });
+ }
+ }
+
+ ///
+ /// Applies stub zones by patching the coredns ConfigMap's Corefile. Each stub zone
+ /// becomes a separate server block that forwards queries for that domain to the
+ /// specified upstream DNS servers. For example:
+ ///
+ /// corp.internal:53 {
+ /// errors
+ /// cache 30
+ /// forward . 10.0.0.53 10.0.0.54
+ /// }
+ ///
+ private async Task ApplyStubZones(Kubernetes client, List stubZones, CancellationToken ct)
+ {
+ // Read the current Corefile to preserve existing configuration.
+
+ V1ConfigMap configMap = await client.CoreV1.ReadNamespacedConfigMapAsync(
+ CoreDnsConfigMap, Namespace, cancellationToken: ct);
+
+ string existingCorefile = "";
+
+ if (configMap.Data is not null && configMap.Data.TryGetValue("Corefile", out string? corefileValue))
+ {
+ existingCorefile = corefileValue;
+ }
+
+ // Remove any previously managed stub zone blocks (marked with our comment).
+ // This makes the operation idempotent — reapplying won't duplicate zones.
+
+ string cleanedCorefile = RemoveManagedStubZones(existingCorefile);
+
+ // Build the new stub zone blocks.
+
+ StringBuilder stubZoneBlocks = new();
+ stubZoneBlocks.AppendLine("# BEGIN EntKube managed stub zones");
+
+ foreach (StubZone zone in stubZones)
+ {
+ string upstreams = string.Join(" ", zone.Upstreams);
+ stubZoneBlocks.AppendLine($"{zone.Domain}:53 {{");
+ stubZoneBlocks.AppendLine(" errors");
+ stubZoneBlocks.AppendLine(" cache 30");
+ stubZoneBlocks.AppendLine($" forward . {upstreams}");
+ stubZoneBlocks.AppendLine("}");
+ }
+
+ stubZoneBlocks.AppendLine("# END EntKube managed stub zones");
+
+ // Append stub zones after the main server block.
+
+ string updatedCorefile = cleanedCorefile.TrimEnd() + "\n" + stubZoneBlocks.ToString();
+
+ // Patch the ConfigMap with the updated Corefile.
+
+ configMap.Data ??= new Dictionary();
+ configMap.Data["Corefile"] = updatedCorefile;
+
+ await client.CoreV1.ReplaceNamespacedConfigMapAsync(configMap, CoreDnsConfigMap, Namespace, cancellationToken: ct);
+ }
+
+ ///
+ /// Applies custom Corefile entries by injecting them into the "coredns-custom"
+ /// ConfigMap. CoreDNS imports this via `import /etc/coredns/custom/*.server`.
+ /// This keeps custom entries separate from the main Corefile for cleaner management.
+ ///
+ private async Task ApplyCustomCorefileEntries(Kubernetes client, string entries, CancellationToken ct)
+ {
+ V1ConfigMap configMap = await EnsureCustomConfigMap(client, ct);
+
+ configMap.Data ??= new Dictionary();
+ configMap.Data["entkube-custom.server"] = entries;
+
+ await client.CoreV1.ReplaceNamespacedConfigMapAsync(configMap, CoreDnsCustomConfigMap, Namespace, cancellationToken: ct);
+ }
+
+ ///
+ /// Applies static DNS records via the "coredns-custom" ConfigMap using hosts plugin format.
+ /// Each record becomes a line in a hosts-format file that CoreDNS serves.
+ ///
+ /// Example output in ConfigMap:
+ /// 192.168.1.100 api.local
+ /// 192.168.1.101 cache.local
+ ///
+ private async Task ApplyCustomRecords(Kubernetes client, Dictionary records, CancellationToken ct)
+ {
+ V1ConfigMap configMap = await EnsureCustomConfigMap(client, ct);
+
+ // Build hosts-format content for the records.
+
+ StringBuilder hostsContent = new();
+ hostsContent.AppendLine("# BEGIN EntKube managed records");
+
+ foreach (KeyValuePair record in records)
+ {
+ hostsContent.AppendLine($"{record.Value} {record.Key}");
+ }
+
+ hostsContent.AppendLine("# END EntKube managed records");
+
+ // Store as a hosts-plugin compatible file in the custom ConfigMap.
+
+ configMap.Data ??= new Dictionary();
+ configMap.Data["entkube-records.override"] = hostsContent.ToString();
+
+ // Also ensure the main Corefile imports this. We add an import directive
+ // to the coredns-custom ConfigMap as a server block that uses the hosts plugin.
+
+ string hostsServerBlock =
+ $".:53 {{\n" +
+ $" hosts /etc/coredns/custom/entkube-records.override {{\n" +
+ $" fallthrough\n" +
+ $" }}\n" +
+ $"}}";
+
+ configMap.Data["entkube-hosts.server"] = hostsServerBlock;
+
+ await client.CoreV1.ReplaceNamespacedConfigMapAsync(configMap, CoreDnsCustomConfigMap, Namespace, cancellationToken: ct);
+ }
+
+ ///
+ /// Ensures the coredns-custom ConfigMap exists. If it doesn't, create it.
+ /// This ConfigMap is the standard extension point for CoreDNS customizations.
+ ///
+ private async Task EnsureCustomConfigMap(Kubernetes client, CancellationToken ct)
+ {
+ try
+ {
+ return await client.CoreV1.ReadNamespacedConfigMapAsync(
+ CoreDnsCustomConfigMap, Namespace, cancellationToken: ct);
+ }
+ catch (k8s.Autorest.HttpOperationException ex) when (ex.Response.StatusCode == System.Net.HttpStatusCode.NotFound)
+ {
+ // ConfigMap doesn't exist yet — create it with management labels.
+
+ V1ConfigMap newConfigMap = new()
+ {
+ Metadata = new V1ObjectMeta
+ {
+ Name = CoreDnsCustomConfigMap,
+ NamespaceProperty = Namespace,
+ Labels = new Dictionary
+ {
+ ["app.kubernetes.io/managed-by"] = "entkube"
+ }
+ },
+ Data = new Dictionary()
+ };
+
+ return await client.CoreV1.CreateNamespacedConfigMapAsync(newConfigMap, Namespace, cancellationToken: ct);
+ }
+ }
+
+ ///
+ /// Removes previously managed stub zone blocks from the Corefile so we can
+ /// reapply cleanly without duplicating entries.
+ ///
+ private static string RemoveManagedStubZones(string corefile)
+ {
+ string[] lines = corefile.Split('\n');
+ StringBuilder result = new();
+ bool insideManagedBlock = false;
+
+ foreach (string line in lines)
+ {
+ if (line.TrimStart().StartsWith("# BEGIN EntKube managed stub zones"))
+ {
+ insideManagedBlock = true;
+ continue;
+ }
+
+ if (line.TrimStart().StartsWith("# END EntKube managed stub zones"))
+ {
+ insideManagedBlock = false;
+ continue;
+ }
+
+ if (!insideManagedBlock)
+ {
+ result.AppendLine(line);
+ }
+ }
+
+ return result.ToString();
+ }
+
+ ///
+ /// Parses stub zone definitions from the format "domain=ip1,ip2;domain2=ip3".
+ /// Each semicolon-separated entry defines one stub zone. Within each entry,
+ /// the domain and upstream IPs are separated by '=', and multiple IPs by ','.
+ ///
+ private static List ParseStubZones(string raw)
+ {
+ List zones = new();
+
+ string[] entries = raw.Split(';', StringSplitOptions.RemoveEmptyEntries);
+
+ foreach (string entry in entries)
+ {
+ string[] parts = entry.Split('=', 2);
+
+ if (parts.Length != 2)
+ {
+ continue;
+ }
+
+ string domain = parts[0].Trim();
+ List upstreams = parts[1].Split(',', StringSplitOptions.RemoveEmptyEntries)
+ .Select(ip => ip.Trim())
+ .ToList();
+
+ if (!string.IsNullOrWhiteSpace(domain) && upstreams.Count > 0)
+ {
+ zones.Add(new StubZone(domain, upstreams));
+ }
+ }
+
+ return zones;
+ }
+
+ ///
+ /// Parses custom DNS records from the format "hostname=ip;hostname2=ip2".
+ /// Returns a dictionary mapping hostname → IP address.
+ ///
+ private static Dictionary ParseRecords(string raw)
+ {
+ Dictionary records = new();
+
+ string[] entries = raw.Split(';', StringSplitOptions.RemoveEmptyEntries);
+
+ foreach (string entry in entries)
+ {
+ string[] parts = entry.Split('=', 2);
+
+ if (parts.Length != 2)
+ {
+ continue;
+ }
+
+ string hostname = parts[0].Trim();
+ string ip = parts[1].Trim();
+
+ if (!string.IsNullOrWhiteSpace(hostname) && !string.IsNullOrWhiteSpace(ip))
+ {
+ records[hostname] = ip;
+ }
+ }
+
+ return records;
+ }
+
+ private static Kubernetes BuildClient(KubernetesCluster cluster)
+ {
+ KubernetesClientConfiguration config = KubernetesClientConfiguration.BuildConfigFromConfigFile(
+ new MemoryStream(Encoding.UTF8.GetBytes(cluster.KubeConfig)),
+ currentContext: cluster.ContextName);
+
+ return new Kubernetes(config);
+ }
+
+ private record StubZone(string Domain, List Upstreams);
+}
diff --git a/src/EntKube.Clusters/Features/AdoptCluster/Components/DomainCA/DomainCACheck.cs b/src/EntKube.Clusters/Features/AdoptCluster/Components/DomainCA/DomainCACheck.cs
new file mode 100644
index 0000000..fc44192
--- /dev/null
+++ b/src/EntKube.Clusters/Features/AdoptCluster/Components/DomainCA/DomainCACheck.cs
@@ -0,0 +1,334 @@
+using System.Text;
+using System.Text.Json;
+using EntKube.Clusters.Domain;
+using k8s;
+using k8s.Models;
+using Microsoft.Extensions.Logging;
+
+namespace EntKube.Clusters.Features.AdoptCluster.Components.DomainCA;
+
+///
+/// Checks whether domain-scoped CAs exist on the cluster. Unlike the platform
+/// internal-ca (which is a single general-purpose CA), domain CAs are scoped
+/// to specific DNS domains and may be:
+///
+/// - **Self-signed internal CAs** for private domains (e.g., *.internal.corp.com)
+/// - **Imported external CAs** with a purchased cert+key (e.g., DigiCert for *.example.com)
+/// - **Trust-only CAs** where only the root cert is imported (no key, can't sign)
+///
+/// Discovery looks for CA ClusterIssuers that have cert-manager Certificate policies
+/// (via annotations or Kyverno policies) restricting them to specific domains.
+/// Also detects TLS Secrets labeled as managed domain CAs.
+///
+public class DomainCACheck : IAdoptionCheck
+{
+ private readonly ILogger logger;
+
+ public string ComponentName => "domain-ca";
+ public string DisplayName => "Domain CA (Domain-Scoped Certificate Authorities)";
+
+ public DomainCACheck(ILogger logger)
+ {
+ this.logger = logger;
+ }
+
+ public async Task CheckAsync(KubernetesCluster cluster, CancellationToken ct = default)
+ {
+ Kubernetes client = BuildClient(cluster);
+ List details = new();
+ List missing = new();
+
+ // Check prerequisite: cert-manager must be installed.
+
+ bool certManagerPresent = await CertManagerCrdExists(client, ct);
+
+ if (!certManagerPresent)
+ {
+ missing.Add("cert-manager is not installed (prerequisite for domain CAs)");
+ return new ComponentCheckResult(ComponentName, ComponentStatus.NotInstalled, details, missing);
+ }
+
+ details.Add("cert-manager CRD present (prerequisite satisfied)");
+
+ // Look for CA ClusterIssuers with domain annotations (EntKube-managed).
+
+ List domainCAs = await FindDomainCAs(client, ct);
+
+ // Also look for domain CAs discovered through trust-manager Bundle CRs.
+ // These are certificates added to the trust bundle — often external CAs
+ // imported for trust without a corresponding ClusterIssuer (no private key).
+
+ List trustBundleCAs = await FindTrustBundleDomainCAs(client, ct);
+
+ // Merge trust bundle CAs that aren't already in the annotated list.
+
+ HashSet knownNames = domainCAs.Select(c => c.Name).ToHashSet();
+
+ foreach (DiscoveredDomainCA bundleCA in trustBundleCAs)
+ {
+ if (!knownNames.Contains(bundleCA.Name))
+ {
+ domainCAs.Add(bundleCA);
+ }
+ }
+
+ if (domainCAs.Count == 0)
+ {
+ // No domain CAs found — also check for managed cert ConfigMaps
+ // (trust-only external certs).
+
+ List managedCerts = await FindManagedCertConfigMaps(client, ct);
+
+ if (managedCerts.Count == 0)
+ {
+ missing.Add("No domain-scoped CAs or managed certificates found");
+ return new ComponentCheckResult(ComponentName, ComponentStatus.NotInstalled, details, missing);
+ }
+
+ details.AddRange(managedCerts.Select(c => $"Trust-only certificate: {c}"));
+ }
+ else
+ {
+ foreach (DiscoveredDomainCA ca in domainCAs)
+ {
+ details.Add($"Domain CA: {ca.Name} (domains: {string.Join(", ", ca.Domains)}, external: {ca.IsExternal})");
+ }
+ }
+
+ // Build discovered configuration summarizing what we found.
+
+ Dictionary values = new()
+ {
+ ["domainCACount"] = domainCAs.Count.ToString(),
+ ["domainCAs"] = string.Join(";", domainCAs.Select(c => $"{c.Name}:{string.Join(",", c.Domains)}"))
+ };
+
+ DiscoveredConfiguration config = new(
+ Version: null,
+ Namespace: "cert-manager",
+ HelmReleaseName: null,
+ Values: values);
+
+ return new ComponentCheckResult(ComponentName, ComponentStatus.Installed, details, missing, config);
+ }
+
+ private async Task> FindDomainCAs(Kubernetes client, CancellationToken ct)
+ {
+ List result = new();
+
+ try
+ {
+ JsonElement issuers = await client.CustomObjects.ListClusterCustomObjectAsync(
+ group: "cert-manager.io",
+ version: "v1",
+ plural: "clusterissuers",
+ cancellationToken: ct);
+
+ if (issuers.TryGetProperty("items", out JsonElement items))
+ {
+ foreach (JsonElement issuer in items.EnumerateArray())
+ {
+ // Check for EntKube domain annotation.
+
+ if (issuer.TryGetProperty("metadata", out JsonElement metadata) &&
+ metadata.TryGetProperty("annotations", out JsonElement annotations) &&
+ annotations.TryGetProperty("entkube.io/domains", out JsonElement domainsEl))
+ {
+ string? name = metadata.GetProperty("name").GetString();
+ string? domains = domainsEl.GetString();
+
+ bool isExternal = annotations.TryGetProperty("entkube.io/external-ca", out JsonElement extEl)
+ && extEl.GetString() == "true";
+
+ if (name is not null && domains is not null)
+ {
+ result.Add(new DiscoveredDomainCA(
+ name,
+ domains.Split(',', StringSplitOptions.RemoveEmptyEntries).ToList(),
+ isExternal));
+ }
+ }
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ logger.LogWarning(ex, "Failed to list ClusterIssuers while checking for domain CAs");
+ }
+
+ return result;
+ }
+
+ private async Task> FindManagedCertConfigMaps(Kubernetes client, CancellationToken ct)
+ {
+ List result = new();
+
+ try
+ {
+ V1ConfigMapList configMaps = await client.CoreV1.ListNamespacedConfigMapAsync(
+ "cert-manager",
+ labelSelector: "entkube.io/component=trust-bundle",
+ cancellationToken: ct);
+
+ foreach (V1ConfigMap cm in configMaps.Items)
+ {
+ result.Add(cm.Metadata.Name);
+ }
+ }
+ catch (Exception ex)
+ {
+ logger.LogDebug(ex, "Could not list managed cert ConfigMaps");
+ }
+
+ return result;
+ }
+
+ ///
+ /// Scans trust-manager Bundle CRs for certificate sources that represent
+ /// domain CAs. These are certificates imported into the trust bundle for
+ /// workload trust — often external CAs without a ClusterIssuer (trust-only).
+ /// This aligns DomainCACheck detection with CertificateAuthorityHandler,
+ /// which also discovers domain CAs from Bundle sources.
+ ///
+ private async Task> FindTrustBundleDomainCAs(Kubernetes client, CancellationToken ct)
+ {
+ List result = new();
+
+ try
+ {
+ JsonElement bundles = await client.CustomObjects.ListClusterCustomObjectAsync(
+ group: "trust.cert-manager.io",
+ version: "v1alpha1",
+ plural: "bundles",
+ cancellationToken: ct);
+
+ if (!bundles.TryGetProperty("items", out JsonElement items))
+ {
+ return result;
+ }
+
+ // Collect all Secret and ConfigMap source names referenced in Bundles.
+ // Each source is a CA certificate that workloads trust.
+
+ HashSet issuerSecretNames = await GetIssuerSecretNames(client, ct);
+
+ foreach (JsonElement bundle in items.EnumerateArray())
+ {
+ if (!bundle.TryGetProperty("spec", out JsonElement spec)
+ || !spec.TryGetProperty("sources", out JsonElement sources))
+ {
+ continue;
+ }
+
+ foreach (JsonElement source in sources.EnumerateArray())
+ {
+ // Secret sources — check if this Secret is already backing
+ // a ClusterIssuer (if so, it'll be found by FindDomainCAs
+ // if it has annotations, or it's an internal CA).
+
+ if (source.TryGetProperty("secret", out JsonElement secretSource)
+ && secretSource.TryGetProperty("name", out JsonElement nameEl))
+ {
+ string? name = nameEl.GetString();
+
+ if (name is not null && !issuerSecretNames.Contains(name))
+ {
+ result.Add(new DiscoveredDomainCA(name, new List(), IsExternal: true));
+ }
+ }
+
+ // ConfigMap sources — imported CA certificates stored as ConfigMaps.
+
+ if (source.TryGetProperty("configMap", out JsonElement cmSource)
+ && cmSource.TryGetProperty("name", out JsonElement cmNameEl))
+ {
+ string? name = cmNameEl.GetString();
+
+ if (name is not null)
+ {
+ result.Add(new DiscoveredDomainCA(name, new List(), IsExternal: true));
+ }
+ }
+ }
+ }
+ }
+ catch (k8s.Autorest.HttpOperationException ex) when (ex.Response.StatusCode == System.Net.HttpStatusCode.NotFound)
+ {
+ // Bundle CRD not found — trust-manager not installed.
+ logger.LogDebug("Bundle CRD not found — trust-manager may not be installed");
+ }
+ catch (Exception ex)
+ {
+ logger.LogDebug(ex, "Could not scan trust-manager Bundles for domain CAs");
+ }
+
+ return result;
+ }
+
+ ///
+ /// Collects the secret names used by all CA ClusterIssuers so we can
+ /// exclude them from trust bundle sources (they're already tracked
+ /// as ClusterIssuer-backed CAs, not trust-only CAs).
+ ///
+ private async Task> GetIssuerSecretNames(Kubernetes client, CancellationToken ct)
+ {
+ HashSet secretNames = new();
+
+ try
+ {
+ JsonElement issuers = await client.CustomObjects.ListClusterCustomObjectAsync(
+ group: "cert-manager.io",
+ version: "v1",
+ plural: "clusterissuers",
+ cancellationToken: ct);
+
+ if (issuers.TryGetProperty("items", out JsonElement items))
+ {
+ foreach (JsonElement issuer in items.EnumerateArray())
+ {
+ if (issuer.TryGetProperty("spec", out JsonElement spec)
+ && spec.TryGetProperty("ca", out JsonElement ca)
+ && ca.TryGetProperty("secretName", out JsonElement secretNameEl))
+ {
+ string? secretName = secretNameEl.GetString();
+
+ if (secretName is not null)
+ {
+ secretNames.Add(secretName);
+ }
+ }
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ logger.LogDebug(ex, "Could not list ClusterIssuer secret names");
+ }
+
+ return secretNames;
+ }
+
+ private async Task CertManagerCrdExists(Kubernetes client, CancellationToken ct)
+ {
+ try
+ {
+ await client.ApiextensionsV1.ReadCustomResourceDefinitionAsync(
+ "certificates.cert-manager.io", cancellationToken: ct);
+ return true;
+ }
+ catch
+ {
+ return false;
+ }
+ }
+
+ private static Kubernetes BuildClient(KubernetesCluster cluster)
+ {
+ byte[] kubeConfigBytes = Encoding.UTF8.GetBytes(cluster.KubeConfig);
+ using MemoryStream stream = new(kubeConfigBytes);
+ KubernetesClientConfiguration config = KubernetesClientConfiguration.BuildConfigFromConfigFile(stream, cluster.ContextName);
+ return new Kubernetes(config);
+ }
+}
+
+internal record DiscoveredDomainCA(string Name, List Domains, bool IsExternal);
diff --git a/src/EntKube.Clusters/Features/AdoptCluster/Components/DomainCA/DomainCAInstaller.cs b/src/EntKube.Clusters/Features/AdoptCluster/Components/DomainCA/DomainCAInstaller.cs
new file mode 100644
index 0000000..1a87e25
--- /dev/null
+++ b/src/EntKube.Clusters/Features/AdoptCluster/Components/DomainCA/DomainCAInstaller.cs
@@ -0,0 +1,814 @@
+using System.Text;
+using System.Text.Json;
+using EntKube.Clusters.Domain;
+using k8s;
+using k8s.Models;
+using Microsoft.Extensions.Logging;
+
+namespace EntKube.Clusters.Features.AdoptCluster.Components.DomainCA;
+
+///
+/// Provisions, imports, or adopts domain-scoped Certificate Authorities.
+/// Unlike the platform internal-ca (general purpose), domain CAs are restricted
+/// to signing certificates only for specific DNS domains.
+///
+/// Three modes of operation:
+///
+/// **1. Provision a new domain CA (self-signed)**
+/// Creates a new self-signed root CA scoped to specific domains.
+/// Use case: internal private domains like *.internal.corp.com
+/// Parameters: caName, domains, caOrganization, caDurationDays
+///
+/// **2. Import an external CA (cert + key provided)**
+/// Imports a purchased CA certificate and private key as a K8s TLS Secret,
+/// then creates a CA ClusterIssuer referencing it. cert-manager can now sign
+/// certificates for those domains using the imported CA.
+/// Use case: DigiCert/Sectigo intermediate CA for *.example.com
+/// Parameters: caName, importExternal=true, tlsCert, tlsKey, domains
+///
+/// **3. Trust-only import (cert only, no key)**
+/// Imports just the CA certificate into the trust bundle. No ClusterIssuer
+/// is created because we can't sign certs without the private key.
+/// Use case: Partner API CA, corporate PKI root that services need to trust
+/// Parameters: caName, importExternal=true, tlsCert (no tlsKey), domains (optional)
+///
+/// All modes add the CA root certificate to the trust-manager Bundle so
+/// workloads automatically trust certificates signed by these CAs.
+///
+/// Configuration keys:
+/// - "caName": Identifier for this domain CA (used as ClusterIssuer name)
+/// - "domains": Comma-separated list of domains this CA covers (e.g., "*.internal.corp.com,*.svc.local")
+/// - "importExternal": "true" to import an existing cert rather than self-signing
+/// - "tlsCert": Base64-encoded PEM certificate (required for import)
+/// - "tlsKey": Base64-encoded PEM private key (optional — if absent, trust-only)
+/// - "caOrganization": Organization field for self-signed CAs (default: "EntKube Domain CA")
+/// - "caDurationDays": Validity in days for self-signed CAs (default: "1825")
+/// - "bundleName": Trust bundle to add the CA to (default: "platform-trust-bundle")
+///
+public class DomainCAInstaller : IComponentInstaller
+{
+ private readonly ILogger logger;
+
+ private const string DefaultNamespace = "cert-manager";
+ private const string DefaultBundleName = "platform-trust-bundle";
+
+ public string ComponentName => "domain-ca";
+
+ public DomainCAInstaller(ILogger logger)
+ {
+ this.logger = logger;
+ }
+
+ public async Task InstallAsync(KubernetesCluster cluster, ComponentInstallOptions options, CancellationToken ct = default)
+ {
+ List actions = new();
+
+ try
+ {
+ Kubernetes client = BuildClient(cluster);
+
+ // Check prerequisite: cert-manager must be installed.
+
+ bool certManagerPresent = await CertManagerIsPresent(client, ct);
+
+ if (!certManagerPresent)
+ {
+ return new InstallResult(
+ Success: false,
+ ComponentName: ComponentName,
+ Message: "cert-manager is required but not installed",
+ Actions: new List { "Prerequisite check failed: cert-manager not found" });
+ }
+
+ // Read common parameters.
+
+ string caName = "domain-ca";
+
+ if (options.Parameters?.TryGetValue("caName", out string? caNameVal) == true
+ && !string.IsNullOrEmpty(caNameVal))
+ {
+ caName = caNameVal;
+ }
+
+ string bundleName = DefaultBundleName;
+
+ if (options.Parameters?.TryGetValue("bundleName", out string? bundleVal) == true
+ && !string.IsNullOrEmpty(bundleVal))
+ {
+ bundleName = bundleVal;
+ }
+
+ string domains = "";
+
+ if (options.Parameters?.TryGetValue("domains", out string? domainsVal) == true
+ && !string.IsNullOrEmpty(domainsVal))
+ {
+ domains = domainsVal;
+ }
+
+ // Route to the appropriate mode based on parameters.
+
+ bool importExternal = options.Parameters?.TryGetValue("importExternal", out string? importVal) == true
+ && importVal == "true";
+
+ if (importExternal)
+ {
+ return await ImportExternalCA(client, caName, domains, bundleName, options, actions, ct);
+ }
+
+ // ─── Mode 1: Provision a new self-signed domain CA ────────────────
+
+ return await ProvisionDomainCA(client, caName, domains, bundleName, options, actions, ct);
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Failed to provision/import domain CA on cluster {Cluster}", cluster.Name);
+ actions.Add($"Error: {ex.Message}");
+
+ return new InstallResult(
+ Success: false,
+ ComponentName: ComponentName,
+ Message: $"Failed to provision domain CA: {ex.Message}",
+ Actions: actions);
+ }
+ }
+
+ ///