Mark subproject as dirty in CMKS - Infra

This commit is contained in:
Nils Blomgren
2026-05-18 08:37:09 +02:00
parent 328d494530
commit 8b94fb8826
635 changed files with 81145 additions and 100551 deletions

BIN
.DS_Store vendored

Binary file not shown.

View File

@@ -1,28 +0,0 @@
name: Build EntKube
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: '10.0.x'
- name: Restore dependencies
run: dotnet restore EntKube.slnx
- name: Build
run: dotnet build EntKube.slnx --no-restore --configuration Release
- name: Test
run: dotnet test EntKube.slnx --no-build --configuration Release

View File

@@ -1,48 +0,0 @@
name: Package Helm Chart
on:
push:
branches: [main]
paths:
- 'Charts/**'
pull_request:
branches: [main]
paths:
- 'Charts/**'
jobs:
lint:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install Helm
uses: azure/setup-helm@v4
with:
version: v3.16.0
- name: Lint chart
run: helm lint Charts/entkube
package:
runs-on: ubuntu-latest
needs: lint
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install Helm
uses: azure/setup-helm@v4
with:
version: v3.16.0
- name: Package chart
run: helm package Charts/entkube --destination .helm-packages/
- name: Login to Helm OCI registry
run: echo "${{ secrets.REGISTRY_PASSWORD }}" | helm registry login ${{ vars.REGISTRY_HOST }} --username ${{ vars.REGISTRY_USER }} --password-stdin
- name: Push chart to OCI registry
run: helm push .helm-packages/entkube-*.tgz oci://${{ vars.REGISTRY_HOST }}/entkube

77
.vscode/launch.json vendored
View File

@@ -2,10 +2,10 @@
"version": "0.2.0", "version": "0.2.0",
"configurations": [ "configurations": [
{ {
"name": "Web (BFF)", "name": "EntKube.Web",
"type": "coreclr", "type": "coreclr",
"request": "launch", "request": "launch",
"preLaunchTask": "build-web", "preLaunchTask": "build",
"program": "${workspaceFolder}/src/EntKube.Web/bin/Debug/net10.0/EntKube.Web.dll", "program": "${workspaceFolder}/src/EntKube.Web/bin/Debug/net10.0/EntKube.Web.dll",
"args": [], "args": [],
"cwd": "${workspaceFolder}/src/EntKube.Web", "cwd": "${workspaceFolder}/src/EntKube.Web",
@@ -15,79 +15,8 @@
"pattern": "\\bNow listening on:\\s+(https?://\\S+)" "pattern": "\\bNow listening on:\\s+(https?://\\S+)"
}, },
"env": { "env": {
"ASPNETCORE_ENVIRONMENT": "Development", "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
} }
] ]
} }

77
.vscode/tasks.json vendored
View File

@@ -2,7 +2,7 @@
"version": "2.0.0", "version": "2.0.0",
"tasks": [ "tasks": [
{ {
"label": "build-web", "label": "build",
"command": "dotnet", "command": "dotnet",
"type": "process", "type": "process",
"args": [ "args": [
@@ -14,83 +14,14 @@
"problemMatcher": "$msCompile" "problemMatcher": "$msCompile"
}, },
{ {
"label": "build-clusters", "label": "test",
"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", "command": "dotnet",
"type": "process", "type": "process",
"args": [ "args": [
"test", "test",
"${workspaceFolder}/EntKube.slnx", "${workspaceFolder}/tests/EntKube.Web.Tests/EntKube.Web.Tests.csproj"
"--no-build"
], ],
"problemMatcher": "$msCompile", "problemMatcher": "$msCompile"
"group": {
"kind": "test",
"isDefault": true
}
} }
] ]
} }

1
CMKS - Infra Submodule

Submodule CMKS - Infra added at ac8bf53d54

View File

@@ -1,4 +0,0 @@
# Patterns to ignore when packaging the chart
.DS_Store
*.tgz
.git/

View File

@@ -1,13 +0,0 @@
apiVersion: v2
name: entkube
description: A Helm chart for the EntKube multi-tenant Kubernetes management platform
type: application
version: 0.1.0
appVersion: "1.0.0"
keywords:
- entkube
- kubernetes
- multi-tenant
- blazor
maintainers:
- name: EntKube Team

View File

@@ -1,67 +0,0 @@
{{/*
Expand the name of the chart.
*/}}
{{- define "entkube.name" -}}
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
{{- end }}
{{/*
Create a default fully qualified app name.
*/}}
{{- define "entkube.fullname" -}}
{{- if .Values.fullnameOverride }}
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- $name := default .Chart.Name .Values.nameOverride }}
{{- if contains $name .Release.Name }}
{{- .Release.Name | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}
{{- end }}
{{- end }}
{{- end }}
{{/*
Create chart name and version as used by the chart label.
*/}}
{{- define "entkube.chart" -}}
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
{{- end }}
{{/*
Common labels
*/}}
{{- define "entkube.labels" -}}
helm.sh/chart: {{ include "entkube.chart" . }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
{{- end }}
{{/*
Selector labels for a specific component
*/}}
{{- define "entkube.selectorLabels" -}}
app.kubernetes.io/name: {{ include "entkube.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}
{{/*
Image reference helper
*/}}
{{- define "entkube.image" -}}
{{- $registry := .root.Values.image.registry -}}
{{- $repository := .component.image.repository -}}
{{- $tag := default .root.Chart.AppVersion .root.Values.image.tag -}}
{{- printf "%s/%s:%s" $registry $repository $tag -}}
{{- end }}
{{/*
Service account name
*/}}
{{- define "entkube.serviceAccountName" -}}
{{- if .Values.serviceAccount.create }}
{{- default (include "entkube.fullname" .) .Values.serviceAccount.name }}
{{- else }}
{{- default "default" .Values.serviceAccount.name }}
{{- end }}
{{- end }}

View File

@@ -1,51 +0,0 @@
{{- if .Values.clusters.enabled }}
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "entkube.fullname" . }}-clusters
labels:
{{- include "entkube.labels" . | nindent 4 }}
app.kubernetes.io/component: clusters
spec:
replicas: {{ .Values.clusters.replicaCount }}
selector:
matchLabels:
{{- include "entkube.selectorLabels" . | nindent 6 }}
app.kubernetes.io/component: clusters
template:
metadata:
labels:
{{- include "entkube.selectorLabels" . | nindent 8 }}
app.kubernetes.io/component: clusters
spec:
{{- with .Values.imagePullSecrets }}
imagePullSecrets:
{{- toYaml . | nindent 8 }}
{{- end }}
serviceAccountName: {{ include "entkube.serviceAccountName" . }}
containers:
- name: clusters
image: {{ include "entkube.image" (dict "root" . "component" .Values.clusters) }}
imagePullPolicy: {{ .Values.image.pullPolicy }}
ports:
- name: http
containerPort: {{ .Values.clusters.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.clusters.resources | nindent 12 }}
env:
- name: ASPNETCORE_URLS
value: "http://+:{{ .Values.clusters.port }}"
{{- end }}

View File

@@ -1,19 +0,0 @@
{{- if .Values.clusters.enabled }}
apiVersion: v1
kind: Service
metadata:
name: {{ include "entkube.fullname" . }}-clusters
labels:
{{- include "entkube.labels" . | nindent 4 }}
app.kubernetes.io/component: clusters
spec:
type: ClusterIP
ports:
- port: {{ .Values.clusters.port }}
targetPort: http
protocol: TCP
name: http
selector:
{{- include "entkube.selectorLabels" . | nindent 4 }}
app.kubernetes.io/component: clusters
{{- end }}

View File

@@ -1,51 +0,0 @@
{{- if .Values.identity.enabled }}
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "entkube.fullname" . }}-identity
labels:
{{- include "entkube.labels" . | nindent 4 }}
app.kubernetes.io/component: identity
spec:
replicas: {{ .Values.identity.replicaCount }}
selector:
matchLabels:
{{- include "entkube.selectorLabels" . | nindent 6 }}
app.kubernetes.io/component: identity
template:
metadata:
labels:
{{- include "entkube.selectorLabels" . | nindent 8 }}
app.kubernetes.io/component: identity
spec:
{{- with .Values.imagePullSecrets }}
imagePullSecrets:
{{- toYaml . | nindent 8 }}
{{- end }}
serviceAccountName: {{ include "entkube.serviceAccountName" . }}
containers:
- name: identity
image: {{ include "entkube.image" (dict "root" . "component" .Values.identity) }}
imagePullPolicy: {{ .Values.image.pullPolicy }}
ports:
- name: http
containerPort: {{ .Values.identity.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.identity.resources | nindent 12 }}
env:
- name: ASPNETCORE_URLS
value: "http://+:{{ .Values.identity.port }}"
{{- end }}

View File

@@ -1,19 +0,0 @@
{{- if .Values.identity.enabled }}
apiVersion: v1
kind: Service
metadata:
name: {{ include "entkube.fullname" . }}-identity
labels:
{{- include "entkube.labels" . | nindent 4 }}
app.kubernetes.io/component: identity
spec:
type: ClusterIP
ports:
- port: {{ .Values.identity.port }}
targetPort: http
protocol: TCP
name: http
selector:
{{- include "entkube.selectorLabels" . | nindent 4 }}
app.kubernetes.io/component: identity
{{- end }}

View File

@@ -1,61 +0,0 @@
{{- if .Values.ingress.enabled }}
{{- if eq .Values.ingress.provider "traefik" }}
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: {{ include "entkube.fullname" . }}
labels:
{{- include "entkube.labels" . | nindent 4 }}
{{- with .Values.ingress.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
entryPoints:
- websecure
routes:
- match: Host(`{{ .Values.ingress.host }}`)
kind: Rule
services:
- name: {{ include "entkube.fullname" . }}-web
port: {{ .Values.web.port }}
{{- if .Values.ingress.tls.enabled }}
tls:
secretName: {{ .Values.ingress.tls.secretName }}
{{- end }}
{{- else if eq .Values.ingress.provider "gatewayapi" }}
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: {{ include "entkube.fullname" . }}
labels:
{{- include "entkube.labels" . | nindent 4 }}
spec:
parentRefs:
- name: {{ include "entkube.fullname" . }}-gateway
hostnames:
- {{ .Values.ingress.host }}
rules:
- backendRefs:
- name: {{ include "entkube.fullname" . }}-web
port: {{ .Values.web.port }}
{{- else if eq .Values.ingress.provider "istio" }}
apiVersion: networking.istio.io/v1
kind: VirtualService
metadata:
name: {{ include "entkube.fullname" . }}
labels:
{{- include "entkube.labels" . | nindent 4 }}
spec:
hosts:
- {{ .Values.ingress.host }}
gateways:
- {{ include "entkube.fullname" . }}-gateway
http:
- route:
- destination:
host: {{ include "entkube.fullname" . }}-web
port:
number: {{ .Values.web.port }}
{{- end }}
{{- end }}

View File

@@ -1,51 +0,0 @@
{{- if .Values.provisioning.enabled }}
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "entkube.fullname" . }}-provisioning
labels:
{{- include "entkube.labels" . | nindent 4 }}
app.kubernetes.io/component: provisioning
spec:
replicas: {{ .Values.provisioning.replicaCount }}
selector:
matchLabels:
{{- include "entkube.selectorLabels" . | nindent 6 }}
app.kubernetes.io/component: provisioning
template:
metadata:
labels:
{{- include "entkube.selectorLabels" . | nindent 8 }}
app.kubernetes.io/component: provisioning
spec:
{{- with .Values.imagePullSecrets }}
imagePullSecrets:
{{- toYaml . | nindent 8 }}
{{- end }}
serviceAccountName: {{ include "entkube.serviceAccountName" . }}
containers:
- name: provisioning
image: {{ include "entkube.image" (dict "root" . "component" .Values.provisioning) }}
imagePullPolicy: {{ .Values.image.pullPolicy }}
ports:
- name: http
containerPort: {{ .Values.provisioning.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.provisioning.resources | nindent 12 }}
env:
- name: ASPNETCORE_URLS
value: "http://+:{{ .Values.provisioning.port }}"
{{- end }}

View File

@@ -1,19 +0,0 @@
{{- if .Values.provisioning.enabled }}
apiVersion: v1
kind: Service
metadata:
name: {{ include "entkube.fullname" . }}-provisioning
labels:
{{- include "entkube.labels" . | nindent 4 }}
app.kubernetes.io/component: provisioning
spec:
type: ClusterIP
ports:
- port: {{ .Values.provisioning.port }}
targetPort: http
protocol: TCP
name: http
selector:
{{- include "entkube.selectorLabels" . | nindent 4 }}
app.kubernetes.io/component: provisioning
{{- end }}

View File

@@ -1,51 +0,0 @@
{{- 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 }}

View File

@@ -1,19 +0,0 @@
{{- 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 }}

View File

@@ -1,12 +0,0 @@
{{- if .Values.serviceAccount.create }}
apiVersion: v1
kind: ServiceAccount
metadata:
name: {{ include "entkube.serviceAccountName" . }}
labels:
{{- include "entkube.labels" . | nindent 4 }}
{{- with .Values.serviceAccount.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
{{- end }}

View File

@@ -1,57 +0,0 @@
{{- if .Values.web.enabled }}
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "entkube.fullname" . }}-web
labels:
{{- include "entkube.labels" . | nindent 4 }}
app.kubernetes.io/component: web
spec:
replicas: {{ .Values.web.replicaCount }}
selector:
matchLabels:
{{- include "entkube.selectorLabels" . | nindent 6 }}
app.kubernetes.io/component: web
template:
metadata:
labels:
{{- include "entkube.selectorLabels" . | nindent 8 }}
app.kubernetes.io/component: web
spec:
{{- with .Values.imagePullSecrets }}
imagePullSecrets:
{{- toYaml . | nindent 8 }}
{{- end }}
serviceAccountName: {{ include "entkube.serviceAccountName" . }}
containers:
- name: web
image: {{ include "entkube.image" (dict "root" . "component" .Values.web) }}
imagePullPolicy: {{ .Values.image.pullPolicy }}
ports:
- name: http
containerPort: {{ .Values.web.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.web.resources | nindent 12 }}
env:
- name: ASPNETCORE_URLS
value: "http://+:{{ .Values.web.port }}"
- name: Services__Clusters__BaseUrl
value: "http://{{ include "entkube.fullname" . }}-clusters:{{ .Values.clusters.port }}"
- name: Services__Provisioning__BaseUrl
value: "http://{{ include "entkube.fullname" . }}-provisioning:{{ .Values.provisioning.port }}"
- name: Services__Identity__BaseUrl
value: "http://{{ include "entkube.fullname" . }}-identity:{{ .Values.identity.port }}"
{{- end }}

View File

@@ -1,19 +0,0 @@
{{- if .Values.web.enabled }}
apiVersion: v1
kind: Service
metadata:
name: {{ include "entkube.fullname" . }}-web
labels:
{{- include "entkube.labels" . | nindent 4 }}
app.kubernetes.io/component: web
spec:
type: ClusterIP
ports:
- port: {{ .Values.web.port }}
targetPort: http
protocol: TCP
name: http
selector:
{{- include "entkube.selectorLabels" . | nindent 4 }}
app.kubernetes.io/component: web
{{- end }}

View File

@@ -1,91 +0,0 @@
# 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: {}

View File

@@ -1,98 +0,0 @@
# Default values for EntKube Helm chart
replicaCount: 1
image:
registry: gitea.example.com
pullPolicy: IfNotPresent
tag: "" # Defaults to appVersion
imagePullSecrets: []
nameOverride: ""
fullnameOverride: ""
# Service configurations per microservice
web:
enabled: true
replicaCount: 1
image:
repository: entkube/web
port: 5000
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: 500m
memory: 512Mi
clusters:
enabled: true
replicaCount: 1
image:
repository: entkube/clusters
port: 5010
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: 500m
memory: 256Mi
provisioning:
enabled: true
replicaCount: 1
image:
repository: entkube/provisioning
port: 5020
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: 500m
memory: 256Mi
identity:
enabled: true
replicaCount: 1
image:
repository: entkube/identity
port: 5030
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
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
provider: traefik # Options: traefik | istio | gatewayapi
host: entkube.example.com
tls:
enabled: true
secretName: entkube-tls
annotations: {}
serviceAccount:
create: true
name: ""
annotations: {}

View File

@@ -1,7 +1,5 @@
<Project> <Project>
<PropertyGroup> <PropertyGroup>
<IncludeSourceRevisionInInformationalVersion>false</IncludeSourceRevisionInInformationalVersion>
<EnableSourceControlManagerQueries>false</EnableSourceControlManagerQueries> <EnableSourceControlManagerQueries>false</EnableSourceControlManagerQueries>
<EnableSourceLink>false</EnableSourceLink>
</PropertyGroup> </PropertyGroup>
</Project> </Project>

View File

@@ -1,16 +1,9 @@
<Solution> <Solution>
<!-- Source Projects --> <Folder Name="/src/">
<Project Path="src/EntKube.SharedKernel/EntKube.SharedKernel.csproj" /> <Project Path="src/EntKube.Web.Client/EntKube.Web.Client.csproj" />
<Project Path="src/EntKube.Identity/EntKube.Identity.csproj" /> <Project Path="src/EntKube.Web/EntKube.Web.csproj" />
<Project Path="src/EntKube.Clusters/EntKube.Clusters.csproj" /> </Folder>
<Project Path="src/EntKube.Provisioning/EntKube.Provisioning.csproj" /> <Folder Name="/tests/">
<Project Path="src/EntKube.Secrets/EntKube.Secrets.csproj" /> <Project Path="tests/EntKube.Web.Tests/EntKube.Web.Tests.csproj" />
<Project Path="src/EntKube.Web/EntKube.Web.csproj" /> </Folder>
<Project Path="src/EntKube.Web.Client/EntKube.Web.Client.csproj" />
<!-- Test Projects -->
<Project Path="tests/EntKube.Identity.Tests/EntKube.Identity.Tests.csproj" />
<Project Path="tests/EntKube.Clusters.Tests/EntKube.Clusters.Tests.csproj" />
<Project Path="tests/EntKube.Provisioning.Tests/EntKube.Provisioning.Tests.csproj" />
<Project Path="tests/EntKube.Secrets.Tests/EntKube.Secrets.Tests.csproj" />
<Project Path="tests/EntKube.Web.Tests/EntKube.Web.Tests.csproj" />
</Solution> </Solution>

View File

@@ -1,92 +0,0 @@
# -*- 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'])

View File

@@ -1,21 +0,0 @@
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"]

View File

@@ -1,83 +0,0 @@
using EntKube.Clusters.Features.AdoptCluster;
namespace EntKube.Clusters.Domain;
/// <summary>
/// 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.
/// </summary>
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<string, string> Configuration { get; private set; } = new();
public DateTimeOffset LastCheckedAt { get; private set; }
private ClusterComponent() { }
/// <summary>
/// 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.
/// </summary>
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<string, string>(),
LastCheckedAt = DateTimeOffset.UtcNow
};
}
/// <summary>
/// Merges new configuration values into the existing configuration.
/// Existing keys are overwritten, new keys are added, keys not in the
/// update are left unchanged.
/// </summary>
public void MergeConfiguration(Dictionary<string, string> values)
{
foreach (KeyValuePair<string, string> kvp in values)
{
Configuration[kvp.Key] = kvp.Value;
}
}
/// <summary>
/// After a successful deployment, mark the component as Installed.
/// </summary>
public void MarkInstalled()
{
Status = ComponentStatus.Installed;
LastCheckedAt = DateTimeOffset.UtcNow;
}
/// <summary>
/// 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.
/// </summary>
public void UpdateVersion(string version)
{
Version = version;
LastCheckedAt = DateTimeOffset.UtcNow;
}
/// <summary>
/// After a successful uninstall, mark the component as NotInstalled.
/// </summary>
public void MarkUninstalled()
{
Status = ComponentStatus.NotInstalled;
LastCheckedAt = DateTimeOffset.UtcNow;
}
}

View File

@@ -1,15 +0,0 @@
namespace EntKube.Clusters.Domain;
/// <summary>
/// Defines how the cluster service persists and retrieves cluster aggregates.
/// The implementation lives in the infrastructure layer — the domain only
/// knows about this contract, keeping it persistence-agnostic.
/// </summary>
public interface IClusterRepository
{
Task<KubernetesCluster?> GetByIdAsync(Guid id, CancellationToken ct = default);
Task<IReadOnlyList<KubernetesCluster>> GetAllAsync(CancellationToken ct = default);
Task AddAsync(KubernetesCluster cluster, CancellationToken ct = default);
Task UpdateAsync(KubernetesCluster cluster, CancellationToken ct = default);
Task DeleteAsync(Guid id, CancellationToken ct = default);
}

View File

@@ -1,274 +0,0 @@
using EntKube.Clusters.Features.AdoptCluster;
namespace EntKube.Clusters.Domain;
/// <summary>
/// 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. 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.
/// </summary>
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 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<PrometheusEndpoint> PrometheusEndpoints => prometheusEndpoints.AsReadOnly();
public IReadOnlyList<ClusterComponent> Components => components.AsReadOnly();
public IReadOnlyList<StorageBucket> StorageBuckets => storageBuckets.AsReadOnly();
private List<PrometheusEndpoint> prometheusEndpoints = new();
private List<ClusterComponent> components = new();
private List<StorageBucket> storageBuckets = new();
private KubernetesCluster() { }
/// <summary>
/// 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.
/// </summary>
public static KubernetesCluster Register(string name, string apiServerUrl, string kubeConfig, Guid tenantId, string contextName, Guid environmentId)
{
if (string.IsNullOrWhiteSpace(name))
{
throw new ArgumentException("Cluster name is required.", nameof(name));
}
if (string.IsNullOrWhiteSpace(apiServerUrl))
{
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,
ContextName = contextName,
KubeConfig = kubeConfig,
Status = ClusterStatus.Pending,
RegisteredAt = DateTimeOffset.UtcNow
};
}
/// <summary>
/// After a successful health check, we mark the cluster as connected.
/// This means the platform can now schedule work against this cluster.
/// </summary>
public void MarkConnected()
{
Status = ClusterStatus.Connected;
LastHealthCheckAt = DateTimeOffset.UtcNow;
}
/// <summary>
/// When a health check fails, we mark the cluster as unreachable.
/// Existing workloads keep running, but no new provisioning can happen
/// until connectivity is restored.
/// </summary>
public void MarkUnreachable()
{
Status = ClusterStatus.Unreachable;
LastHealthCheckAt = DateTimeOffset.UtcNow;
}
/// <summary>
/// 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".
/// </summary>
public void AssignToEnvironment(Guid environmentId)
{
if (environmentId == Guid.Empty)
{
throw new ArgumentException("Environment ID is required.", nameof(environmentId));
}
EnvironmentId = environmentId;
}
/// <summary>
/// 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.
/// </summary>
public void SetPrometheusEndpoints(List<PrometheusEndpoint> endpoints)
{
prometheusEndpoints = endpoints ?? new List<PrometheusEndpoint>();
}
/// <summary>
/// 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.
/// </summary>
public void UpdateComponents(List<ComponentCheckResult> checkResults)
{
components = checkResults
.Select(ClusterComponent.FromCheckResult)
.ToList();
}
/// <summary>
/// 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.
/// </summary>
public void UpdateComponentConfiguration(string componentName, Dictionary<string, string> values)
{
ClusterComponent? component = components.FirstOrDefault(
c => c.ComponentName.Equals(componentName, StringComparison.OrdinalIgnoreCase));
component?.MergeConfiguration(values);
}
/// <summary>
/// Marks a tracked component as Installed after a successful deployment.
/// If the component hasn't been tracked yet, this is a no-op.
/// </summary>
public void MarkComponentInstalled(string componentName)
{
ClusterComponent? component = components.FirstOrDefault(
c => c.ComponentName.Equals(componentName, StringComparison.OrdinalIgnoreCase));
component?.MarkInstalled();
}
/// <summary>
/// Updates the version of a tracked component after a successful upgrade.
/// If the component hasn't been tracked yet, this is a no-op.
/// </summary>
public void UpdateComponentVersion(string componentName, string version)
{
ClusterComponent? component = components.FirstOrDefault(
c => c.ComponentName.Equals(componentName, StringComparison.OrdinalIgnoreCase));
component?.UpdateVersion(version);
}
/// <summary>
/// Marks a tracked component as NotInstalled after a successful uninstall.
/// If the component hasn't been tracked yet, this is a no-op.
/// </summary>
public void MarkComponentUninstalled(string componentName)
{
ClusterComponent? component = components.FirstOrDefault(
c => c.ComponentName.Equals(componentName, StringComparison.OrdinalIgnoreCase));
component?.MarkUninstalled();
}
/// <summary>
/// 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.
/// </summary>
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;
}
/// <summary>
/// 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.
/// </summary>
public void AddStorageBucket(StorageBucket bucket)
{
storageBuckets.Add(bucket);
}
/// <summary>
/// Removes a storage bucket from this cluster's tracked buckets.
/// Called after the bucket has been deleted from the S3 backend.
/// </summary>
public void RemoveStorageBucket(Guid bucketId)
{
storageBuckets.RemoveAll(b => b.Id == bucketId);
}
}
/// <summary>
/// The cloud provider hosting a Kubernetes cluster. Determines which additional
/// APIs are available for management (OpenStack, Gardener, billing, etc.).
/// </summary>
public enum CloudProvider
{
None,
Cleura,
Aws,
Azure,
Gcp
}
/// <summary>
/// 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.
/// </summary>
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,
Connected,
Unreachable
}

View File

@@ -1,9 +0,0 @@
namespace EntKube.Clusters.Domain;
/// <summary>
/// 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.
/// </summary>
public record PrometheusEndpoint(string Url, string Namespace, string ServiceName);

View File

@@ -1,51 +0,0 @@
namespace EntKube.Clusters.Domain;
/// <summary>
/// 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.
/// </summary>
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() { }
/// <summary>
/// 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.
/// </summary>
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
};
}
}

View File

@@ -1,26 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="AWSSDK.S3" Version="4.0.0.3" />
<PackageReference Include="KubernetesClient" Version="19.0.2" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.1" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.1" />
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="10.0.1" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.1" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="EntKube.Clusters.Tests" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\EntKube.SharedKernel\EntKube.SharedKernel.csproj" />
</ItemGroup>
</Project>

View File

@@ -1,6 +0,0 @@
@EntKube.Clusters_HostAddress = http://localhost:5243
GET {{EntKube.Clusters_HostAddress}}/weatherforecast/
Accept: application/json
###

View File

@@ -1,30 +0,0 @@
using EntKube.SharedKernel.Contracts;
using EntKube.SharedKernel.Domain;
namespace EntKube.Clusters.Features.AdoptCluster;
/// <summary>
/// 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
/// </summary>
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<AdoptionReport> result = await handler.HandleAsync(id, ct);
if (result.IsFailure)
{
return Results.BadRequest(ApiResponse<AdoptionReport>.Fail(result.Error!));
}
return Results.Ok(ApiResponse<AdoptionReport>.Ok(result.Value!));
});
}
}

View File

@@ -1,84 +0,0 @@
using EntKube.Clusters.Domain;
using EntKube.SharedKernel.Domain;
using Microsoft.Extensions.Logging;
namespace EntKube.Clusters.Features.AdoptCluster;
/// <summary>
/// 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.
/// </summary>
public class AdoptClusterHandler
{
private readonly IClusterRepository repository;
private readonly IEnumerable<IAdoptionCheck> checks;
private readonly ILogger<AdoptClusterHandler> logger;
public AdoptClusterHandler(IClusterRepository repository, IEnumerable<IAdoptionCheck> checks, ILogger<AdoptClusterHandler> logger)
{
this.repository = repository;
this.checks = checks;
this.logger = logger;
}
public async Task<Result<AdoptionReport>> 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<AdoptionReport>($"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<AdoptionReport>(
"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<Task<ComponentCheckResult>> 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);
}
}

View File

@@ -1,70 +0,0 @@
using System.Text.Json.Serialization;
namespace EntKube.Clusters.Features.AdoptCluster;
/// <summary>
/// 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.
/// </summary>
public class AdoptionReport
{
[JsonConverter(typeof(JsonStringEnumConverter))]
public AdoptionReadiness Status { get; }
public List<ComponentCheckResult> Components { get; }
public AdoptionReport(AdoptionReadiness status, List<ComponentCheckResult> components)
{
Status = status;
Components = components;
}
/// <summary>
/// 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.
/// </summary>
public static AdoptionReport FromChecks(List<ComponentCheckResult> results)
{
// Sort components alphabetically by name so the report is always
// presented in a consistent, predictable order.
List<ComponentCheckResult> 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);
}
}
/// <summary>
/// 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
/// </summary>
public enum AdoptionReadiness
{
Ready,
PartiallyReady,
NotReady
}

View File

@@ -1,876 +0,0 @@
using EntKube.Clusters.Domain;
namespace EntKube.Clusters.Features.AdoptCluster;
/// <summary>
/// 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.
/// </summary>
public static class ComponentSchemas
{
private static readonly Dictionary<string, ConfigurationSchema> schemas = new(StringComparer.OrdinalIgnoreCase)
{
["kyverno"] = new(
ComponentName: "kyverno",
DisplayName: "Kyverno",
Description: "Policy engine for Kubernetes — validates, mutates, and generates resources",
Parameters: new List<ConfigurationParameter>
{
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<ConfigurationParameter>
{
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<string> { "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<ConfigurationParameter>
{
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<ConfigurationParameter>
{
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<ConfigurationParameter>
{
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<ConfigurationParameter>
{
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<ConfigurationParameter>
{
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<ConfigurationParameter>
{
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<ConfigurationParameter>
{
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<ConfigurationParameter>
{
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<string> { "gatewayapi", "traefik", "istio" },
DependsOnKey: "ingressEnabled", DependsOnValues: new List<string> { "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<string> { "true" }),
new(Key: "prometheusHostname", DisplayName: "Prometheus Hostname", Description: "External hostname for Prometheus (optional)",
Type: ParameterType.String, Group: "Ingress",
DependsOnKey: "ingressEnabled", DependsOnValues: new List<string> { "true" }),
new(Key: "alertmanagerHostname", DisplayName: "Alertmanager Hostname", Description: "External hostname for Alertmanager (optional)",
Type: ParameterType.String, Group: "Ingress",
DependsOnKey: "ingressEnabled", DependsOnValues: new List<string> { "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<string> { "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<string> { "true" })
}),
["grafana"] = new(
ComponentName: "grafana",
DisplayName: "Grafana",
Description: "Visualization and dashboarding — deployed as part of the monitoring stack",
Parameters: new List<ConfigurationParameter>
{
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<string> { "generic_oauth", "azuread", "github", "google" },
DependsOnKey: "oidcEnabled", DependsOnValues: new List<string> { "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<string> { "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<string> { "true" }),
new(Key: "oidcAuthUrl", DisplayName: "Authorization URL", Description: "OIDC authorization endpoint",
Type: ParameterType.String, Group: "OIDC",
DependsOnKey: "oidcEnabled", DependsOnValues: new List<string> { "true" }),
new(Key: "oidcTokenUrl", DisplayName: "Token URL", Description: "OIDC token endpoint",
Type: ParameterType.String, Group: "OIDC",
DependsOnKey: "oidcEnabled", DependsOnValues: new List<string> { "true" }),
new(Key: "oidcApiUrl", DisplayName: "UserInfo URL", Description: "OIDC userinfo endpoint",
Type: ParameterType.String, Group: "OIDC",
DependsOnKey: "oidcEnabled", DependsOnValues: new List<string> { "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<string> { "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<string> { "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<string> { "true" })
}),
["harbor"] = new(
ComponentName: "harbor",
DisplayName: "Harbor",
Description: "Container registry with vulnerability scanning, image signing, and proxy cache",
Parameters: new List<ConfigurationParameter>
{
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<string> { "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<string> { "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<ConfigurationParameter>
{
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<string> { "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<ConfigurationParameter>
{
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<ConfigurationParameter>
{
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<string> { "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<string> { "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<ConfigurationParameter>
{
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<string> { "ingress", "gatewayHTTPRoute" },
DependsOnKey: "httpSolverEnabled", DependsOnValues: new List<string> { "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<string> { "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<string> { "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<string> { "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<string> { "cloudflare", "route53", "azuredns", "clouddns" },
DependsOnKey: "dnsSolverEnabled", DependsOnValues: new List<string> { "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<string> { "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<string> { "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<string> { "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<string> { "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<string> { "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<string> { "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<string> { "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<string> { "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<string> { "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<string> { "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<ConfigurationParameter>()),
["cnpg"] = new(
ComponentName: "cnpg",
DisplayName: "CloudNativePG",
Description: "PostgreSQL operator — manages highly available PostgreSQL clusters on Kubernetes",
Parameters: new List<ConfigurationParameter>
{
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<ConfigurationParameter>
{
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<string> { "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<ConfigurationParameter>
{
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<string> { "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<ConfigurationParameter>
{
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<ConfigurationParameter>
{
new(Key: "provider", DisplayName: "DNS Provider", Description: "Cloud DNS provider",
Type: ParameterType.Select, Group: "General", IsRequired: true,
AllowedValues: new List<string> { "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<string> { "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<ConfigurationParameter>
{
// — 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<string> { "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<string> { "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<string> { "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<string> { "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<string> { "true" }),
new(Key: "oidcClientSecret", DisplayName: "Client Secret", Description: "OIDC client secret",
Type: ParameterType.String, Group: "OIDC",
DependsOnKey: "oidcEnabled", DependsOnValues: new List<string> { "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<string> { "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<string> { "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<string> { "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<string> { "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<string> { "s3" }),
new(Key: "s3AccessKey", DisplayName: "S3 Access Key", Description: "S3 access key ID",
Type: ParameterType.String, Group: "Object Storage",
DependsOnKey: "objectStorageType", DependsOnValues: new List<string> { "s3" }),
new(Key: "s3SecretKey", DisplayName: "S3 Secret Key", Description: "S3 secret access key",
Type: ParameterType.String, Group: "Object Storage",
DependsOnKey: "objectStorageType", DependsOnValues: new List<string> { "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<string> { "s3" }),
new(Key: "s3Region", DisplayName: "S3 Region", Description: "S3 region",
Type: ParameterType.String, Group: "Object Storage",
DependsOnKey: "objectStorageType", DependsOnValues: new List<string> { "s3" }),
new(Key: "azureAccountName", DisplayName: "Azure Account Name", Description: "Azure Storage account name",
Type: ParameterType.String, Group: "Object Storage",
DependsOnKey: "objectStorageType", DependsOnValues: new List<string> { "azure" }),
new(Key: "azureAccountKey", DisplayName: "Azure Account Key", Description: "Azure Storage account key",
Type: ParameterType.String, Group: "Object Storage",
DependsOnKey: "objectStorageType", DependsOnValues: new List<string> { "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<string> { "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")
})
};
/// <summary>
/// Retrieves the configuration schema for a component by name. Returns an
/// empty schema if the component has no registered parameters.
/// </summary>
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<ConfigurationParameter>());
}
/// <summary>
/// Returns all registered schemas for use in API responses.
/// </summary>
public static IReadOnlyDictionary<string, ConfigurationSchema> GetAll() => schemas;
/// <summary>
/// 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
/// </summary>
public static ConfigurationSchema EnrichForCluster(
string componentName, IReadOnlyList<ClusterComponent> 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;
}
/// <summary>
/// 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.
/// </summary>
private static ConfigurationSchema EnrichIngressSchema(
ConfigurationSchema baseSchema, IReadOnlyList<ClusterComponent> 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<ConfigurationParameter> enrichedParams = baseSchema.Parameters
.Select(p => p.Key == "enable_external" ? p with { DefaultValue = "true" } : p)
.ToList();
return baseSchema with { Parameters = enrichedParams };
}
/// <summary>
/// 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.
/// </summary>
private static ConfigurationSchema EnrichLetsEncryptSchema(
ConfigurationSchema baseSchema, IReadOnlyList<ClusterComponent> 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<DiscoveredGateway> 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<ConfigurationParameter> enrichedParams = baseSchema.Parameters
.Select(p => EnrichLetsEncryptParameter(p, isIstio, isTraefik, discoveredGateways))
.ToList();
return baseSchema with { Parameters = enrichedParams };
}
/// <summary>
/// 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.
/// </summary>
private static ConfigurationParameter EnrichLetsEncryptParameter(
ConfigurationParameter param, bool isIstio, bool isTraefik,
List<DiscoveredGateway> 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
};
}
/// <summary>
/// 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.
/// </summary>
private static List<DiscoveredGateway> ParseDiscoveredGateways(ClusterComponent? component)
{
List<DiscoveredGateway> 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);
}

View File

@@ -1,188 +0,0 @@
using System.Text;
using EntKube.Clusters.Domain;
using k8s;
using k8s.Models;
using Microsoft.Extensions.Logging;
namespace EntKube.Clusters.Features.AdoptCluster.Components.CertManager;
/// <summary>
/// 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.
/// </summary>
public class CertManagerCheck : IAdoptionCheck
{
private readonly ILogger<CertManagerCheck> logger;
public string ComponentName => "cert-manager";
public string DisplayName => "cert-manager (Certificate Management)";
public CertManagerCheck(ILogger<CertManagerCheck> logger)
{
this.logger = logger;
}
public async Task<ComponentCheckResult> CheckAsync(KubernetesCluster cluster, CancellationToken ct = default)
{
Kubernetes client = BuildClient(cluster);
List<string> details = new();
List<string> missing = new();
// Look for cert-manager pods in the standard namespace.
List<string> 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<string, string>
{
["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<string?> 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<List<string>> FindCertManagerPods(Kubernetes client, CancellationToken ct)
{
List<string> 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<bool> 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);
}
}

View File

@@ -1,270 +0,0 @@
using System.Text;
using EntKube.Clusters.Domain;
using k8s;
using k8s.Models;
using Microsoft.Extensions.Logging;
namespace EntKube.Clusters.Features.AdoptCluster.Components.CertManager;
/// <summary>
/// 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)
/// </summary>
public class CertManagerInstaller : IComponentInstaller
{
private readonly ILogger<CertManagerInstaller> 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<CertManagerInstaller> logger)
{
this.logger = logger;
}
public async Task<InstallResult> InstallAsync(KubernetesCluster cluster, ComponentInstallOptions options, CancellationToken ct = default)
{
string version = options.Version ?? DefaultVersion;
string targetNamespace = options.Namespace ?? DefaultNamespace;
List<string> 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);
}
}
}
/// <summary>
/// Reconfigures cert-manager. Supports:
/// - "replicas": controller replicas
/// </summary>
public async Task<InstallResult> ConfigureAsync(KubernetesCluster cluster, ComponentConfiguration configuration, CancellationToken ct = default)
{
string targetNamespace = configuration.Namespace ?? DefaultNamespace;
List<string> 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<string> 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<string> { $"Error: {ex.Message}" });
}
finally
{
if (File.Exists(tempKubeConfig))
{
File.Delete(tempKubeConfig);
}
}
}
/// <summary>
/// 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.
/// </summary>
public async Task<InstallResult> UninstallAsync(KubernetesCluster cluster, ComponentInstallOptions options, CancellationToken ct = default)
{
string targetNamespace = options.Namespace ?? DefaultNamespace;
List<string> 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<string> { $"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<string, string>
{
["app.kubernetes.io/managed-by"] = "entkube"
}
}
};
await client.CoreV1.CreateNamespaceAsync(ns, cancellationToken: ct);
}
}
private async Task<string> 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);
}
}

View File

@@ -1,201 +0,0 @@
using System.Text;
using EntKube.Clusters.Domain;
using k8s;
using k8s.Models;
using Microsoft.Extensions.Logging;
namespace EntKube.Clusters.Features.AdoptCluster.Components.CloudNativePG;
/// <summary>
/// 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
/// </summary>
public class CloudNativePGCheck : IAdoptionCheck
{
private readonly ILogger<CloudNativePGCheck> logger;
public string ComponentName => "cnpg";
public string DisplayName => "CloudNativePG (PostgreSQL Operator)";
public CloudNativePGCheck(ILogger<CloudNativePGCheck> logger)
{
this.logger = logger;
}
public async Task<ComponentCheckResult> CheckAsync(KubernetesCluster cluster, CancellationToken ct = default)
{
Kubernetes client = BuildClient(cluster);
List<string> details = new();
List<string> missing = new();
// Check 1: CNPG operator pods in cnpg-system namespace.
List<string> 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<string, string>
{
["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<string?> 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<List<string>> FindOperatorPods(Kubernetes client, CancellationToken ct)
{
List<string> 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<bool> ClusterCrdExists(Kubernetes client, CancellationToken ct)
{
try
{
await client.ApiextensionsV1.ReadCustomResourceDefinitionAsync(
"clusters.postgresql.cnpg.io", cancellationToken: ct);
return true;
}
catch
{
return false;
}
}
private async Task<int> 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);
}
}

View File

@@ -1,353 +0,0 @@
using System.Text;
using EntKube.Clusters.Domain;
using k8s;
using k8s.Models;
using Microsoft.Extensions.Logging;
namespace EntKube.Clusters.Features.AdoptCluster.Components.CloudNativePG;
/// <summary>
/// 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).
/// </summary>
public class CloudNativePGInstaller : IComponentInstaller
{
private readonly ILogger<CloudNativePGInstaller> 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<CloudNativePGInstaller> logger)
{
this.logger = logger;
}
public async Task<InstallResult> 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<string> 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);
}
}
}
/// <summary>
/// Reconfigures CNPG operator. Supports:
/// - "operatorReplicas": number of operator replicas
/// - "monitoringEnabled": enable/disable Prometheus monitoring
/// </summary>
public async Task<InstallResult> ConfigureAsync(KubernetesCluster cluster, ComponentConfiguration configuration, CancellationToken ct = default)
{
string targetNamespace = configuration.Namespace ?? DefaultNamespace;
List<string> actions = new();
string tempKubeConfig = Path.Combine(Path.GetTempPath(), $"entkube-cnpg-cfg-{Guid.NewGuid()}.kubeconfig");
try
{
await File.WriteAllTextAsync(tempKubeConfig, cluster.KubeConfig, ct);
List<string> 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<string> { $"Error: {ex.Message}" });
}
finally
{
if (File.Exists(tempKubeConfig))
{
File.Delete(tempKubeConfig);
}
}
}
/// <summary>
/// Uninstalls CloudNativePG operator from the cluster. Warning: existing
/// PostgreSQL Cluster resources will become unmanaged.
/// </summary>
public async Task<InstallResult> UninstallAsync(KubernetesCluster cluster, ComponentInstallOptions options, CancellationToken ct = default)
{
string targetNamespace = options.Namespace ?? DefaultNamespace;
List<string> 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<string> { $"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<string, string>
{
["app.kubernetes.io/managed-by"] = "entkube",
["app.kubernetes.io/part-of"] = "cnpg-platform"
}
}
};
await client.CoreV1.CreateNamespaceAsync(ns, cancellationToken: ct);
}
}
private async Task<string> 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);
}
/// <summary>
/// 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
/// </summary>
private async Task EnsureBackupStorageSecret(
Kubernetes client, string namespaceName, StorageBucket bucket, CancellationToken ct)
{
Dictionary<string, byte[]> 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<string, string>
{
["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);
}
}
}

View File

@@ -1,139 +0,0 @@
using k8s.Models;
namespace EntKube.Clusters.Features.AdoptCluster.Components;
/// <summary>
/// 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.
/// </summary>
public static class CnpgDatabaseProvisioner
{
/// <summary>
/// Builds the CNPG primary service FQDN from cluster name and namespace.
/// The convention is {clusterName}-rw.{namespace}.svc.cluster.local.
/// </summary>
public static string GetPrimaryHost(string clusterName, string clusterNamespace)
{
return $"{clusterName}-rw.{clusterNamespace}.svc.cluster.local";
}
/// <summary>
/// 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).
/// </summary>
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<string, string>
{
["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<V1Container>
{
new()
{
Name = "create-db",
Image = "postgres:16-alpine",
Command = new List<string> { "sh", "-c" },
Args = new List<string>
{
$"echo \"{sqlScript}\" | PGPASSWORD=$POSTGRES_PASSWORD psql -h {primaryService} -U postgres"
},
Env = new List<V1EnvVar>
{
new()
{
Name = "POSTGRES_PASSWORD",
ValueFrom = new V1EnvVarSource
{
SecretKeyRef = new V1SecretKeySelector
{
Name = $"{clusterName}-superuser",
Key = "password"
}
}
}
}
}
}
}
}
}
};
}
/// <summary>
/// Builds a Kubernetes Secret containing database credentials so the
/// consuming service (Gitea, Harbor, Keycloak) can reference them.
/// </summary>
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<string, string>
{
["app.kubernetes.io/managed-by"] = "entkube",
["entkube.io/purpose"] = "database-credentials"
}
},
Type = "Opaque",
StringData = new Dictionary<string, string>
{
["host"] = host,
["database"] = databaseName,
["username"] = username,
["password"] = password
}
};
}
}

View File

@@ -1,310 +0,0 @@
using EntKube.Clusters.Domain;
using k8s;
using k8s.Models;
using Microsoft.Extensions.Logging;
namespace EntKube.Clusters.Features.AdoptCluster.Components.CustomDNS;
/// <summary>
/// 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.
/// </summary>
public class CustomDNSCheck : IAdoptionCheck
{
private readonly ILogger<CustomDNSCheck> logger;
public string ComponentName => "custom-dns";
public string DisplayName => "Custom DNS (CoreDNS)";
public CustomDNSCheck(ILogger<CustomDNSCheck> logger)
{
this.logger = logger;
}
public async Task<ComponentCheckResult> CheckAsync(KubernetesCluster cluster, CancellationToken ct = default)
{
Kubernetes client = BuildClient(cluster);
List<string> details = new();
List<string> 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<string> 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<string, string>? 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<string, string> 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<List<string>> 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<string?> 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<Dictionary<string, string>?> 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;
}
}
/// <summary>
/// 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 }
/// </summary>
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;
}
/// <summary>
/// Custom entries are non-default plugins like rewrite, log (when explicitly added),
/// template, or import directives pointing to custom configuration.
/// </summary>
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<string?> 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);
}
}

View File

@@ -1,438 +0,0 @@
using System.Text;
using EntKube.Clusters.Domain;
using k8s;
using k8s.Models;
using Microsoft.Extensions.Logging;
namespace EntKube.Clusters.Features.AdoptCluster.Components.CustomDNS;
/// <summary>
/// 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).
/// </summary>
public class CustomDNSInstaller : IComponentInstaller
{
private readonly ILogger<CustomDNSInstaller> 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<CustomDNSInstaller> logger)
{
this.logger = logger;
}
public async Task<InstallResult> 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<string, string> values = options.Parameters ?? new();
List<string> 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<StubZone> 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<string, string> 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);
}
}
/// <summary>
/// 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.
/// </summary>
public async Task<InstallResult> 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);
}
/// <summary>
/// Uninstalls custom DNS configuration by removing the coredns-custom ConfigMap.
/// This reverts CoreDNS to its default configuration without stub zones or
/// custom records.
/// </summary>
public async Task<InstallResult> UninstallAsync(KubernetesCluster cluster, ComponentInstallOptions options, CancellationToken ct = default)
{
List<string> 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<string> { $"Error: {ex.Message}" });
}
}
/// <summary>
/// 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
/// }
/// </summary>
private async Task ApplyStubZones(Kubernetes client, List<StubZone> 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<string, string>();
configMap.Data["Corefile"] = updatedCorefile;
await client.CoreV1.ReplaceNamespacedConfigMapAsync(configMap, CoreDnsConfigMap, Namespace, cancellationToken: ct);
}
/// <summary>
/// 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.
/// </summary>
private async Task ApplyCustomCorefileEntries(Kubernetes client, string entries, CancellationToken ct)
{
V1ConfigMap configMap = await EnsureCustomConfigMap(client, ct);
configMap.Data ??= new Dictionary<string, string>();
configMap.Data["entkube-custom.server"] = entries;
await client.CoreV1.ReplaceNamespacedConfigMapAsync(configMap, CoreDnsCustomConfigMap, Namespace, cancellationToken: ct);
}
/// <summary>
/// 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
/// </summary>
private async Task ApplyCustomRecords(Kubernetes client, Dictionary<string, string> 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<string, string> 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<string, string>();
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);
}
/// <summary>
/// Ensures the coredns-custom ConfigMap exists. If it doesn't, create it.
/// This ConfigMap is the standard extension point for CoreDNS customizations.
/// </summary>
private async Task<V1ConfigMap> 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<string, string>
{
["app.kubernetes.io/managed-by"] = "entkube"
}
},
Data = new Dictionary<string, string>()
};
return await client.CoreV1.CreateNamespacedConfigMapAsync(newConfigMap, Namespace, cancellationToken: ct);
}
}
/// <summary>
/// Removes previously managed stub zone blocks from the Corefile so we can
/// reapply cleanly without duplicating entries.
/// </summary>
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();
}
/// <summary>
/// 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 ','.
/// </summary>
private static List<StubZone> ParseStubZones(string raw)
{
List<StubZone> 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<string> 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;
}
/// <summary>
/// Parses custom DNS records from the format "hostname=ip;hostname2=ip2".
/// Returns a dictionary mapping hostname → IP address.
/// </summary>
private static Dictionary<string, string> ParseRecords(string raw)
{
Dictionary<string, string> 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<string> Upstreams);
}

View File

@@ -1,334 +0,0 @@
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;
/// <summary>
/// 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.
/// </summary>
public class DomainCACheck : IAdoptionCheck
{
private readonly ILogger<DomainCACheck> logger;
public string ComponentName => "domain-ca";
public string DisplayName => "Domain CA (Domain-Scoped Certificate Authorities)";
public DomainCACheck(ILogger<DomainCACheck> logger)
{
this.logger = logger;
}
public async Task<ComponentCheckResult> CheckAsync(KubernetesCluster cluster, CancellationToken ct = default)
{
Kubernetes client = BuildClient(cluster);
List<string> details = new();
List<string> 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<DiscoveredDomainCA> 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<DiscoveredDomainCA> trustBundleCAs = await FindTrustBundleDomainCAs(client, ct);
// Merge trust bundle CAs that aren't already in the annotated list.
HashSet<string> 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<string> 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<string, string> 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<List<DiscoveredDomainCA>> FindDomainCAs(Kubernetes client, CancellationToken ct)
{
List<DiscoveredDomainCA> result = new();
try
{
JsonElement issuers = await client.CustomObjects.ListClusterCustomObjectAsync<JsonElement>(
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<List<string>> FindManagedCertConfigMaps(Kubernetes client, CancellationToken ct)
{
List<string> 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;
}
/// <summary>
/// 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.
/// </summary>
private async Task<List<DiscoveredDomainCA>> FindTrustBundleDomainCAs(Kubernetes client, CancellationToken ct)
{
List<DiscoveredDomainCA> result = new();
try
{
JsonElement bundles = await client.CustomObjects.ListClusterCustomObjectAsync<JsonElement>(
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<string> 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<string>(), 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<string>(), 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;
}
/// <summary>
/// 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).
/// </summary>
private async Task<HashSet<string>> GetIssuerSecretNames(Kubernetes client, CancellationToken ct)
{
HashSet<string> secretNames = new();
try
{
JsonElement issuers = await client.CustomObjects.ListClusterCustomObjectAsync<JsonElement>(
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<bool> 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<string> Domains, bool IsExternal);

View File

@@ -1,814 +0,0 @@
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;
/// <summary>
/// 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")
/// </summary>
public class DomainCAInstaller : IComponentInstaller
{
private readonly ILogger<DomainCAInstaller> logger;
private const string DefaultNamespace = "cert-manager";
private const string DefaultBundleName = "platform-trust-bundle";
public string ComponentName => "domain-ca";
public DomainCAInstaller(ILogger<DomainCAInstaller> logger)
{
this.logger = logger;
}
public async Task<InstallResult> InstallAsync(KubernetesCluster cluster, ComponentInstallOptions options, CancellationToken ct = default)
{
List<string> 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<string> { "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);
}
}
/// <summary>
/// Reconfigures an existing domain CA. Supports updating allowed domains
/// and refreshing the trust bundle reference.
/// </summary>
public async Task<InstallResult> ConfigureAsync(KubernetesCluster cluster, ComponentConfiguration configuration, CancellationToken ct = default)
{
List<string> actions = new();
try
{
Kubernetes client = BuildClient(cluster);
string caName = "domain-ca";
if (configuration.Values.TryGetValue("caName", out string? caNameVal)
&& !string.IsNullOrEmpty(caNameVal))
{
caName = caNameVal;
}
// Update domain restrictions if specified.
if (configuration.Values.TryGetValue("domains", out string? domains)
&& !string.IsNullOrEmpty(domains))
{
List<string> domainList = domains.Split(',', StringSplitOptions.RemoveEmptyEntries).ToList();
await UpdateDomainAnnotations(client, caName, domainList, ct);
actions.Add($"Updated Certificate policy: [{string.Join(", ", domainList)}]");
}
// Update trust bundle reference if needed.
if (configuration.Values.TryGetValue("bundleName", out string? bundleName)
&& !string.IsNullOrEmpty(bundleName))
{
string secretName = $"{caName}-secret";
await AddCAToTrustBundle(client, bundleName, secretName, ct);
actions.Add($"Refreshed CA in Bundle '{bundleName}'");
}
return new InstallResult(
Success: true,
ComponentName: ComponentName,
Message: $"Domain CA '{caName}' reconfigured",
Actions: actions);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to reconfigure domain CA on cluster {Cluster}", cluster.Name);
return new InstallResult(
Success: false,
ComponentName: ComponentName,
Message: $"Failed to reconfigure domain CA: {ex.Message}",
Actions: new List<string> { $"Error: {ex.Message}" });
}
}
/// <summary>
/// Uninstalls a domain CA by deleting the ClusterIssuer. The caName parameter
/// identifies which domain CA to remove.
/// </summary>
public async Task<InstallResult> UninstallAsync(KubernetesCluster cluster, ComponentInstallOptions options, CancellationToken ct = default)
{
List<string> actions = new();
try
{
Kubernetes client = BuildClient(cluster);
string caName = "domain-ca";
if (options.Parameters?.TryGetValue("caName", out string? caNameVal) == true
&& !string.IsNullOrEmpty(caNameVal))
{
caName = caNameVal;
}
// Delete the domain CA ClusterIssuer.
try
{
await client.CustomObjects.DeleteClusterCustomObjectAsync(
"cert-manager.io", "v1", "clusterissuers", caName, cancellationToken: ct);
actions.Add($"Deleted ClusterIssuer '{caName}'");
}
catch (k8s.Autorest.HttpOperationException ex) when (ex.Response.StatusCode == System.Net.HttpStatusCode.NotFound)
{
// Already gone.
}
logger.LogInformation("Domain CA '{CaName}' removed from cluster {Cluster}", caName, cluster.Name);
return new InstallResult(
Success: true,
ComponentName: ComponentName,
Message: $"Domain CA '{caName}' uninstalled",
Actions: actions);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to uninstall domain CA from cluster {Cluster}", cluster.Name);
return new InstallResult(
Success: false,
ComponentName: ComponentName,
Message: $"Failed to uninstall domain CA: {ex.Message}",
Actions: new List<string> { $"Error: {ex.Message}" });
}
}
// ─── Mode 1: Provision a new self-signed domain CA ────────────────────────
private async Task<InstallResult> ProvisionDomainCA(
Kubernetes client, string caName, string domains, string bundleName,
ComponentInstallOptions options, List<string> actions, CancellationToken ct)
{
string organization = "EntKube Domain CA";
if (options.Parameters?.TryGetValue("caOrganization", out string? orgVal) == true
&& !string.IsNullOrEmpty(orgVal))
{
organization = orgVal;
}
int durationDays = 1825;
if (options.Parameters?.TryGetValue("caDurationDays", out string? durVal) == true
&& int.TryParse(durVal, out int parsed))
{
durationDays = parsed;
}
string secretName = $"{caName}-secret";
string durationHours = $"{durationDays * 24}h";
// Ensure the self-signed bootstrap issuer exists (shared with internal-ca).
await EnsureSelfSignedBootstrapIssuer(client, ct);
// Create the root CA Certificate for this domain CA.
await CreateCACertificate(client, caName, secretName, organization, durationHours, ct);
actions.Add($"Created root CA Certificate '{caName}'");
// Create the CA ClusterIssuer with domain annotations.
List<string> domainList = domains.Split(',', StringSplitOptions.RemoveEmptyEntries).ToList();
await CreateDomainCAClusterIssuer(client, caName, secretName, domainList, isExternal: false, ct);
actions.Add($"Created CA ClusterIssuer '{caName}'");
// If domains are specified, record the policy restriction.
if (domainList.Count > 0)
{
actions.Add($"Created Certificate policy: only signs for [{string.Join(", ", domainList)}]");
}
// Add the root CA to the trust bundle.
await AddCAToTrustBundle(client, bundleName, secretName, ct);
actions.Add($"Added root CA to Bundle '{bundleName}'");
logger.LogInformation("Domain CA '{CA}' provisioned for domains: {Domains}",
caName, domains);
return new InstallResult(
Success: true,
ComponentName: ComponentName,
Message: $"Domain CA '{caName}' provisioned for {domains}",
Actions: actions);
}
// ─── Mode 2 & 3: Import external CA ──────────────────────────────────────
private async Task<InstallResult> ImportExternalCA(
Kubernetes client, string caName, string domains, string bundleName,
ComponentInstallOptions options, List<string> actions, CancellationToken ct)
{
string? tlsCert = null;
string? tlsKey = null;
if (options.Parameters?.TryGetValue("tlsCert", out string? certVal) == true)
{
tlsCert = certVal;
}
if (options.Parameters?.TryGetValue("tlsKey", out string? keyVal) == true)
{
tlsKey = keyVal;
}
if (string.IsNullOrEmpty(tlsCert))
{
return new InstallResult(
Success: false,
ComponentName: ComponentName,
Message: "tlsCert is required when importing an external CA",
Actions: actions);
}
bool hasKey = !string.IsNullOrEmpty(tlsKey);
List<string> domainList = domains.Split(',', StringSplitOptions.RemoveEmptyEntries).ToList();
if (hasKey)
{
// Full import — cert + key → create TLS Secret + CA ClusterIssuer.
string secretName = $"{caName}-tls";
await CreateTlsSecret(client, secretName, tlsCert, tlsKey!, ct);
actions.Add($"Created TLS Secret '{secretName}' with imported cert+key");
await CreateDomainCAClusterIssuer(client, caName, secretName, domainList, isExternal: true, ct);
actions.Add($"Created CA ClusterIssuer '{caName}'");
await AddCAToTrustBundle(client, bundleName, secretName, ct);
actions.Add($"Added CA certificate to Bundle '{bundleName}'");
logger.LogInformation("External CA '{CA}' imported for domains: {Domains}", caName, domains);
return new InstallResult(
Success: true,
ComponentName: ComponentName,
Message: $"External CA '{caName}' imported for {domains}",
Actions: actions);
}
else
{
// Trust-only import — cert without key. Can't sign, just trust.
string configMapName = $"managed-cert-{caName}";
await CreateCertificateConfigMap(client, configMapName, tlsCert, ct);
actions.Add($"Created ConfigMap '{configMapName}' with CA certificate");
await AddConfigMapToTrustBundle(client, bundleName, configMapName, ct);
actions.Add($"Added certificate to Bundle '{bundleName}'");
actions.Add("No ClusterIssuer created (no private key provided — trust-only)");
logger.LogInformation("External CA certificate '{CA}' added to trust bundle (trust-only)", caName);
return new InstallResult(
Success: true,
ComponentName: ComponentName,
Message: $"External CA certificate '{caName}' added to trust bundle",
Actions: actions);
}
}
// ─── Kubernetes operations ────────────────────────────────────────────────
private async Task EnsureSelfSignedBootstrapIssuer(Kubernetes client, CancellationToken ct)
{
Dictionary<string, object> issuer = new()
{
["apiVersion"] = "cert-manager.io/v1",
["kind"] = "ClusterIssuer",
["metadata"] = new Dictionary<string, object>
{
["name"] = "selfsigned-bootstrap"
},
["spec"] = new Dictionary<string, object>
{
["selfSigned"] = new Dictionary<string, object>()
}
};
await ApplyClusterCustomObject(client, "cert-manager.io", "v1", "clusterissuers", "selfsigned-bootstrap", issuer, ct);
}
private async Task CreateCACertificate(
Kubernetes client, string caName, string secretName, string organization, string duration, CancellationToken ct)
{
Dictionary<string, object> certificate = new()
{
["apiVersion"] = "cert-manager.io/v1",
["kind"] = "Certificate",
["metadata"] = new Dictionary<string, object>
{
["name"] = caName,
["namespace"] = DefaultNamespace
},
["spec"] = new Dictionary<string, object>
{
["isCA"] = true,
["duration"] = duration,
["secretName"] = secretName,
["commonName"] = $"{caName} Root CA",
["subject"] = new Dictionary<string, object>
{
["organizations"] = new List<string> { organization }
},
["privateKey"] = new Dictionary<string, object>
{
["algorithm"] = "ECDSA",
["size"] = 256
},
["issuerRef"] = new Dictionary<string, object>
{
["name"] = "selfsigned-bootstrap",
["kind"] = "ClusterIssuer",
["group"] = "cert-manager.io"
}
}
};
await ApplyNamespacedCustomObject(client, "cert-manager.io", "v1", DefaultNamespace, "certificates", caName, certificate, ct);
}
private async Task CreateDomainCAClusterIssuer(
Kubernetes client, string caName, string secretName, List<string> domains, bool isExternal, CancellationToken ct)
{
Dictionary<string, string> annotations = new()
{
["entkube.io/managed-by"] = "entkube-domain-ca",
["entkube.io/domains"] = string.Join(",", domains)
};
if (isExternal)
{
annotations["entkube.io/external-ca"] = "true";
}
Dictionary<string, object> issuer = new()
{
["apiVersion"] = "cert-manager.io/v1",
["kind"] = "ClusterIssuer",
["metadata"] = new Dictionary<string, object>
{
["name"] = caName,
["annotations"] = annotations
},
["spec"] = new Dictionary<string, object>
{
["ca"] = new Dictionary<string, object>
{
["secretName"] = secretName
}
}
};
await ApplyClusterCustomObject(client, "cert-manager.io", "v1", "clusterissuers", caName, issuer, ct);
}
private async Task UpdateDomainAnnotations(Kubernetes client, string caName, List<string> domains, CancellationToken ct)
{
Dictionary<string, object> patch = new()
{
["apiVersion"] = "cert-manager.io/v1",
["kind"] = "ClusterIssuer",
["metadata"] = new Dictionary<string, object>
{
["name"] = caName,
["annotations"] = new Dictionary<string, string>
{
["entkube.io/domains"] = string.Join(",", domains)
}
}
};
await client.CustomObjects.PatchClusterCustomObjectAsync(
body: new V1Patch(patch, V1Patch.PatchType.MergePatch),
group: "cert-manager.io",
version: "v1",
plural: "clusterissuers",
name: caName,
cancellationToken: ct);
}
private async Task CreateTlsSecret(Kubernetes client, string secretName, string certData, string keyData, CancellationToken ct)
{
// Decode base64 to raw bytes for the Secret.
byte[] certBytes = Convert.FromBase64String(certData);
byte[] keyBytes = Convert.FromBase64String(keyData);
V1Secret secret = new()
{
Metadata = new V1ObjectMeta
{
Name = secretName,
NamespaceProperty = DefaultNamespace,
Labels = new Dictionary<string, string>
{
["app.kubernetes.io/managed-by"] = "entkube",
["entkube.io/component"] = "domain-ca"
}
},
Type = "kubernetes.io/tls",
Data = new Dictionary<string, byte[]>
{
["tls.crt"] = certBytes,
["tls.key"] = keyBytes
}
};
try
{
await client.CoreV1.ReplaceNamespacedSecretAsync(secret, secretName, DefaultNamespace, cancellationToken: ct);
}
catch (k8s.Autorest.HttpOperationException ex) when (ex.Response.StatusCode == System.Net.HttpStatusCode.NotFound)
{
await client.CoreV1.CreateNamespacedSecretAsync(secret, DefaultNamespace, cancellationToken: ct);
}
}
private async Task CreateCertificateConfigMap(Kubernetes client, string configMapName, string certData, CancellationToken ct)
{
// Decode from base64 to PEM.
string pemData;
try
{
byte[] decoded = Convert.FromBase64String(certData);
pemData = Encoding.UTF8.GetString(decoded);
}
catch (FormatException)
{
pemData = certData;
}
V1ConfigMap configMap = new()
{
Metadata = new V1ObjectMeta
{
Name = configMapName,
NamespaceProperty = DefaultNamespace,
Labels = new Dictionary<string, string>
{
["app.kubernetes.io/managed-by"] = "entkube",
["entkube.io/component"] = "trust-bundle"
}
},
Data = new Dictionary<string, string>
{
["ca.crt"] = pemData
}
};
try
{
await client.CoreV1.ReplaceNamespacedConfigMapAsync(configMap, configMapName, DefaultNamespace, cancellationToken: ct);
}
catch (k8s.Autorest.HttpOperationException ex) when (ex.Response.StatusCode == System.Net.HttpStatusCode.NotFound)
{
await client.CoreV1.CreateNamespacedConfigMapAsync(configMap, DefaultNamespace, cancellationToken: ct);
}
}
private async Task AddCAToTrustBundle(Kubernetes client, string bundleName, string secretName, CancellationToken ct)
{
// Add the CA Secret as a source in the trust-manager Bundle.
try
{
JsonElement existing = await client.CustomObjects.GetClusterCustomObjectAsync<JsonElement>(
"trust.cert-manager.io", "v1alpha1", "bundles", bundleName, ct);
List<object> sources = new();
bool alreadyPresent = false;
if (existing.TryGetProperty("spec", out JsonElement spec) &&
spec.TryGetProperty("sources", out JsonElement sourcesEl))
{
foreach (JsonElement source in sourcesEl.EnumerateArray())
{
sources.Add(JsonSerializer.Deserialize<object>(source.GetRawText())!);
if (source.TryGetProperty("secret", out JsonElement secretSource) &&
secretSource.TryGetProperty("name", out JsonElement nameEl) &&
nameEl.GetString() == secretName)
{
alreadyPresent = true;
}
}
}
if (!alreadyPresent)
{
sources.Add(new Dictionary<string, object>
{
["secret"] = new Dictionary<string, object>
{
["name"] = secretName,
["key"] = "tls.crt"
}
});
Dictionary<string, object> patch = new()
{
["apiVersion"] = "trust.cert-manager.io/v1alpha1",
["kind"] = "Bundle",
["metadata"] = new Dictionary<string, object> { ["name"] = bundleName },
["spec"] = new Dictionary<string, object> { ["sources"] = sources }
};
await client.CustomObjects.PatchClusterCustomObjectAsync(
body: new V1Patch(patch, V1Patch.PatchType.MergePatch),
group: "trust.cert-manager.io",
version: "v1alpha1",
plural: "bundles",
name: bundleName,
cancellationToken: ct);
}
}
catch (k8s.Autorest.HttpOperationException ex) when (ex.Response.StatusCode == System.Net.HttpStatusCode.NotFound)
{
// Bundle doesn't exist — create it with the CA as source.
Dictionary<string, object> bundle = new()
{
["apiVersion"] = "trust.cert-manager.io/v1alpha1",
["kind"] = "Bundle",
["metadata"] = new Dictionary<string, object> { ["name"] = bundleName },
["spec"] = new Dictionary<string, object>
{
["sources"] = new List<object>
{
new Dictionary<string, object> { ["useDefaultCAs"] = true },
new Dictionary<string, object>
{
["secret"] = new Dictionary<string, object>
{
["name"] = secretName,
["key"] = "tls.crt"
}
}
},
["target"] = new Dictionary<string, object>
{
["configMap"] = new Dictionary<string, object>
{
["key"] = "ca-certificates.crt"
}
}
}
};
await client.CustomObjects.CreateClusterCustomObjectAsync(
body: bundle,
group: "trust.cert-manager.io",
version: "v1alpha1",
plural: "bundles",
cancellationToken: ct);
}
}
private async Task AddConfigMapToTrustBundle(Kubernetes client, string bundleName, string configMapName, CancellationToken ct)
{
try
{
JsonElement existing = await client.CustomObjects.GetClusterCustomObjectAsync<JsonElement>(
"trust.cert-manager.io", "v1alpha1", "bundles", bundleName, ct);
List<object> sources = new();
bool alreadyPresent = false;
if (existing.TryGetProperty("spec", out JsonElement spec) &&
spec.TryGetProperty("sources", out JsonElement sourcesEl))
{
foreach (JsonElement source in sourcesEl.EnumerateArray())
{
sources.Add(JsonSerializer.Deserialize<object>(source.GetRawText())!);
if (source.TryGetProperty("configMap", out JsonElement cmSource) &&
cmSource.TryGetProperty("name", out JsonElement nameEl) &&
nameEl.GetString() == configMapName)
{
alreadyPresent = true;
}
}
}
if (!alreadyPresent)
{
sources.Add(new Dictionary<string, object>
{
["configMap"] = new Dictionary<string, object>
{
["name"] = configMapName,
["key"] = "ca.crt"
}
});
Dictionary<string, object> patch = new()
{
["apiVersion"] = "trust.cert-manager.io/v1alpha1",
["kind"] = "Bundle",
["metadata"] = new Dictionary<string, object> { ["name"] = bundleName },
["spec"] = new Dictionary<string, object> { ["sources"] = sources }
};
await client.CustomObjects.PatchClusterCustomObjectAsync(
body: new V1Patch(patch, V1Patch.PatchType.MergePatch),
group: "trust.cert-manager.io",
version: "v1alpha1",
plural: "bundles",
name: bundleName,
cancellationToken: ct);
}
}
catch (k8s.Autorest.HttpOperationException ex) when (ex.Response.StatusCode == System.Net.HttpStatusCode.NotFound)
{
logger.LogWarning("Bundle '{Bundle}' not found — cannot add ConfigMap source", bundleName);
}
}
private async Task<bool> CertManagerIsPresent(Kubernetes client, CancellationToken ct)
{
try
{
await client.ApiextensionsV1.ReadCustomResourceDefinitionAsync(
"certificates.cert-manager.io", cancellationToken: ct);
return true;
}
catch
{
return false;
}
}
private static async Task ApplyClusterCustomObject(
Kubernetes client, string group, string version, string plural, string name,
Dictionary<string, object> body, CancellationToken ct)
{
try
{
await client.CustomObjects.PatchClusterCustomObjectAsync(
body: new V1Patch(body, V1Patch.PatchType.MergePatch),
group: group,
version: version,
plural: plural,
name: name,
cancellationToken: ct);
}
catch (k8s.Autorest.HttpOperationException ex) when (ex.Response.StatusCode == System.Net.HttpStatusCode.NotFound)
{
await client.CustomObjects.CreateClusterCustomObjectAsync(
body: body,
group: group,
version: version,
plural: plural,
cancellationToken: ct);
}
}
private static async Task ApplyNamespacedCustomObject(
Kubernetes client, string group, string version, string ns, string plural, string name,
Dictionary<string, object> body, CancellationToken ct)
{
try
{
await client.CustomObjects.PatchNamespacedCustomObjectAsync(
body: new V1Patch(body, V1Patch.PatchType.MergePatch),
group: group,
version: version,
namespaceParameter: ns,
plural: plural,
name: name,
cancellationToken: ct);
}
catch (k8s.Autorest.HttpOperationException ex) when (ex.Response.StatusCode == System.Net.HttpStatusCode.NotFound)
{
await client.CustomObjects.CreateNamespacedCustomObjectAsync(
body: body,
group: group,
version: version,
namespaceParameter: ns,
plural: plural,
cancellationToken: ct);
}
}
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);
}
}

View File

@@ -1,212 +0,0 @@
using System.Text;
using EntKube.Clusters.Domain;
using k8s;
using k8s.Models;
using Microsoft.Extensions.Logging;
namespace EntKube.Clusters.Features.AdoptCluster.Components.ExternalSecrets;
/// <summary>
/// Checks whether External Secrets Operator (ESO) is installed and healthy.
/// ESO synchronizes secrets from external providers (AWS Secrets Manager,
/// Azure Key Vault, HashiCorp Vault, GCP Secret Manager, etc.) into
/// Kubernetes Secrets — enabling GitOps-friendly secret management.
///
/// This check verifies:
/// 1. ESO controller pods running in external-secrets namespace
/// 2. ExternalSecret CRD is registered
/// 3. Any existing SecretStores or ClusterSecretStores configured
/// </summary>
public class ExternalSecretsCheck : IAdoptionCheck
{
private readonly ILogger<ExternalSecretsCheck> logger;
public string ComponentName => "external-secrets";
public string DisplayName => "External Secrets Operator";
public ExternalSecretsCheck(ILogger<ExternalSecretsCheck> logger)
{
this.logger = logger;
}
public async Task<ComponentCheckResult> CheckAsync(KubernetesCluster cluster, CancellationToken ct = default)
{
Kubernetes client = BuildClient(cluster);
List<string> details = new();
List<string> missing = new();
// Check 1: ESO controller pods in external-secrets namespace.
List<string> esoPods = await FindEsoPods(client, ct);
if (esoPods.Count > 0)
{
details.AddRange(esoPods.Select(p => $"ESO pod: {p}"));
}
else
{
missing.Add("No External Secrets Operator pods found in external-secrets namespace");
return new ComponentCheckResult(ComponentName, ComponentStatus.NotInstalled, details, missing);
}
// Check 2: ExternalSecret CRD exists.
bool crdExists = await ExternalSecretCrdExists(client, ct);
if (crdExists)
{
details.Add("ExternalSecret CRD (externalsecrets.external-secrets.io) present");
}
else
{
missing.Add("ExternalSecret CRD not found");
}
// Check 3: Check for webhook pods (cert-controller).
bool hasWebhook = esoPods.Any(p => p.Contains("webhook") || p.Contains("cert-controller"));
if (hasWebhook)
{
details.Add("Webhook/cert-controller pod present");
}
else
{
missing.Add("Webhook/cert-controller pod not found");
}
// Check 4: Count existing ClusterSecretStores.
int clusterStoreCount = await CountClusterSecretStores(client, ct);
details.Add($"ClusterSecretStores configured: {clusterStoreCount}");
// Discover version from pod image tag.
string? version = await GetVersionFromPods(client, ct);
DiscoveredConfiguration config = new(
Version: version,
Namespace: "external-secrets",
HelmReleaseName: "external-secrets",
Values: new Dictionary<string, string>
{
["controllerReplicas"] = esoPods.Count(p => !p.Contains("webhook") && !p.Contains("cert")).ToString(),
["hasWebhook"] = hasWebhook.ToString(),
["clusterSecretStores"] = clusterStoreCount.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<string?> GetVersionFromPods(Kubernetes client, CancellationToken ct)
{
try
{
V1PodList podList = await client.CoreV1.ListNamespacedPodAsync(
"external-secrets",
labelSelector: "app.kubernetes.io/name=external-secrets",
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<List<string>> FindEsoPods(Kubernetes client, CancellationToken ct)
{
List<string> pods = new();
try
{
V1PodList podList = await client.CoreV1.ListNamespacedPodAsync(
"external-secrets",
labelSelector: "app.kubernetes.io/name=external-secrets",
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("external-secrets namespace not found on cluster");
}
catch (Exception ex)
{
logger.LogWarning(ex, "Error checking for ESO pods");
}
return pods;
}
private async Task<bool> ExternalSecretCrdExists(Kubernetes client, CancellationToken ct)
{
try
{
await client.ApiextensionsV1.ReadCustomResourceDefinitionAsync(
"externalsecrets.external-secrets.io", cancellationToken: ct);
return true;
}
catch
{
return false;
}
}
private async Task<int> CountClusterSecretStores(Kubernetes client, CancellationToken ct)
{
try
{
object result = await client.CustomObjects.ListClusterCustomObjectAsync(
group: "external-secrets.io",
version: "v1beta1",
plural: "clustersecretstores",
cancellationToken: ct);
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);
}
}

View File

@@ -1,473 +0,0 @@
using System.Text;
using EntKube.Clusters.Domain;
using k8s;
using k8s.Models;
using Microsoft.Extensions.Logging;
namespace EntKube.Clusters.Features.AdoptCluster.Components.ExternalSecrets;
/// <summary>
/// Installs External Secrets Operator (ESO) via Helm. ESO bridges external
/// secret management systems (Vault, AWS SM, Azure KV, GCP SM) into Kubernetes
/// Secrets — enabling secure, GitOps-compatible secret delivery to workloads.
///
/// What gets deployed:
/// 1. external-secrets namespace with controller, webhook, and cert-controller
/// 2. All required CRDs (ExternalSecret, SecretStore, ClusterSecretStore, etc.)
/// 3. Optionally: a ClusterSecretStore for a specific provider
///
/// Configuration keys:
/// - "replicas": ESO controller replicas (default: 1)
/// - "webhookReplicas": webhook replicas (default: 1)
/// - "certControllerReplicas": cert-controller replicas (default: 1)
/// - "storeProvider": ClusterSecretStore provider (vault|aws|azure|gcp)
/// - "storeEndpoint": Provider endpoint (e.g., Vault URL)
/// - "storeSecretName": Credential secret name for the store
/// - "storeRegion": AWS region or Azure subscription ID
/// </summary>
public class ExternalSecretsInstaller : IComponentInstaller
{
private readonly ILogger<ExternalSecretsInstaller> logger;
private const string DefaultVersion = "0.12.1";
private const string DefaultNamespace = "external-secrets";
private const string HelmRepo = "https://charts.external-secrets.io";
public string ComponentName => "external-secrets";
public ExternalSecretsInstaller(ILogger<ExternalSecretsInstaller> logger)
{
this.logger = logger;
}
public async Task<InstallResult> InstallAsync(KubernetesCluster cluster, ComponentInstallOptions options, CancellationToken ct = default)
{
string version = options.Version ?? DefaultVersion;
string targetNamespace = options.Namespace ?? DefaultNamespace;
List<string> actions = new();
string tempKubeConfig = Path.Combine(Path.GetTempPath(), $"entkube-eso-{Guid.NewGuid()}.kubeconfig");
try
{
await File.WriteAllTextAsync(tempKubeConfig, cluster.KubeConfig, ct);
// Ensure the namespace exists.
Kubernetes client = BuildClient(cluster);
await EnsureNamespaceWithLabels(client, targetNamespace, ct);
actions.Add($"Ensured namespace '{targetNamespace}'");
// Install ESO via Helm. The chart installs the controller,
// webhook, and cert-controller components.
await RunCommand("helm",
$"upgrade --install external-secrets external-secrets " +
$"--repo {HelmRepo} --version {version} " +
$"--namespace {targetNamespace} " +
$"--kubeconfig \"{tempKubeConfig}\" --kube-context \"{cluster.ContextName}\" " +
$"--set installCRDs=true " +
$"--set replicaCount=1 " +
$"--set webhook.replicaCount=1 " +
$"--set certController.replicaCount=1 " +
$"--set resources.requests.cpu=50m " +
$"--set resources.requests.memory=128Mi " +
$"--set resources.limits.cpu=200m " +
$"--set resources.limits.memory=256Mi " +
$"--set serviceMonitor.enabled=true " +
$"--wait --timeout 10m",
ct);
actions.Add($"Helm install external-secrets v{version}");
// Optionally create a ClusterSecretStore if provider is specified.
if (options.Parameters?.TryGetValue("storeProvider", out string? provider) == true
&& !string.IsNullOrEmpty(provider))
{
options.Parameters.TryGetValue("storeEndpoint", out string? endpoint);
options.Parameters.TryGetValue("storeSecretName", out string? secretName);
options.Parameters.TryGetValue("storeRegion", out string? region);
await ApplyClusterSecretStore(client, provider, endpoint, secretName, region, ct);
actions.Add($"Created ClusterSecretStore for provider '{provider}'");
}
logger.LogInformation("External Secrets Operator {Version} installed on cluster {Cluster}",
version, cluster.Name);
return new InstallResult(
Success: true,
ComponentName: ComponentName,
Message: $"External Secrets Operator {version} installed",
Actions: actions);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to install ESO on cluster {Cluster}", cluster.Name);
actions.Add($"Error: {ex.Message}");
return new InstallResult(
Success: false,
ComponentName: ComponentName,
Message: $"Failed to install External Secrets Operator: {ex.Message}",
Actions: actions);
}
finally
{
if (File.Exists(tempKubeConfig))
{
File.Delete(tempKubeConfig);
}
}
}
/// <summary>
/// Reconfigures ESO. Supports:
/// - "replicas": controller replicas
/// - "webhookReplicas": webhook replicas
/// - "certControllerReplicas": cert-controller replicas
/// - "storeProvider": create/update ClusterSecretStore
/// - "storeEndpoint": provider endpoint
/// - "storeSecretName": credential secret
/// - "storeRegion": region/subscription
/// </summary>
public async Task<InstallResult> ConfigureAsync(KubernetesCluster cluster, ComponentConfiguration configuration, CancellationToken ct = default)
{
string targetNamespace = configuration.Namespace ?? DefaultNamespace;
List<string> actions = new();
string tempKubeConfig = Path.Combine(Path.GetTempPath(), $"entkube-eso-cfg-{Guid.NewGuid()}.kubeconfig");
try
{
await File.WriteAllTextAsync(tempKubeConfig, cluster.KubeConfig, ct);
Kubernetes client = BuildClient(cluster);
// Handle Helm-level configuration.
List<string> setFlags = new();
if (configuration.Values.TryGetValue("replicas", out string? replicas))
{
setFlags.Add($"--set replicaCount={replicas}");
}
if (configuration.Values.TryGetValue("webhookReplicas", out string? webhookReplicas))
{
setFlags.Add($"--set webhook.replicaCount={webhookReplicas}");
}
if (configuration.Values.TryGetValue("certControllerReplicas", out string? certReplicas))
{
setFlags.Add($"--set certController.replicaCount={certReplicas}");
}
if (setFlags.Count > 0)
{
await RunCommand("helm",
$"upgrade external-secrets external-secrets --repo {HelmRepo} " +
$"--namespace {targetNamespace} " +
$"--kubeconfig \"{tempKubeConfig}\" --kube-context \"{cluster.ContextName}\" " +
$"--reuse-values {string.Join(" ", setFlags)} --wait --timeout 5m",
ct);
actions.Add($"Updated ESO Helm release: {string.Join(", ", setFlags)}");
}
// Handle ClusterSecretStore reconfiguration.
if (configuration.Values.TryGetValue("storeProvider", out string? provider) && !string.IsNullOrEmpty(provider))
{
configuration.Values.TryGetValue("storeEndpoint", out string? endpoint);
configuration.Values.TryGetValue("storeSecretName", out string? secretName);
configuration.Values.TryGetValue("storeRegion", out string? region);
await ApplyClusterSecretStore(client, provider, endpoint, secretName, region, ct);
actions.Add($"Updated ClusterSecretStore for provider '{provider}'");
}
return new InstallResult(
Success: true,
ComponentName: ComponentName,
Message: "External Secrets Operator reconfigured",
Actions: actions);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to reconfigure ESO on cluster {Cluster}", cluster.Name);
return new InstallResult(
Success: false,
ComponentName: ComponentName,
Message: $"Failed to reconfigure External Secrets Operator: {ex.Message}",
Actions: new List<string> { $"Error: {ex.Message}" });
}
finally
{
if (File.Exists(tempKubeConfig))
{
File.Delete(tempKubeConfig);
}
}
}
/// <summary>
/// Uninstalls External Secrets Operator from the cluster.
/// Warning: existing ExternalSecret resources will stop syncing.
/// </summary>
public async Task<InstallResult> UninstallAsync(KubernetesCluster cluster, ComponentInstallOptions options, CancellationToken ct = default)
{
string targetNamespace = options.Namespace ?? DefaultNamespace;
List<string> actions = new();
string tempKubeConfig = Path.Combine(Path.GetTempPath(), $"entkube-eso-rm-{Guid.NewGuid()}.kubeconfig");
try
{
await File.WriteAllTextAsync(tempKubeConfig, cluster.KubeConfig, ct);
await RunCommand("helm",
$"uninstall external-secrets --namespace {targetNamespace} " +
$"--kubeconfig \"{tempKubeConfig}\" --kube-context \"{cluster.ContextName}\" " +
$"--wait --timeout 5m",
ct);
actions.Add("Helm uninstall external-secrets");
logger.LogInformation("External Secrets Operator uninstalled from cluster {Cluster}", cluster.Name);
return new InstallResult(
Success: true,
ComponentName: ComponentName,
Message: "External Secrets Operator uninstalled successfully",
Actions: actions);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to uninstall ESO from cluster {Cluster}", cluster.Name);
return new InstallResult(
Success: false,
ComponentName: ComponentName,
Message: $"Failed to uninstall External Secrets Operator: {ex.Message}",
Actions: new List<string> { $"Error: {ex.Message}" });
}
finally
{
if (File.Exists(tempKubeConfig))
{
File.Delete(tempKubeConfig);
}
}
}
/// <summary>
/// Creates or updates a ClusterSecretStore for the specified provider.
/// Supports Vault, AWS Secrets Manager, Azure Key Vault, and GCP Secret Manager.
/// </summary>
private async Task ApplyClusterSecretStore(
Kubernetes client,
string provider,
string? endpoint,
string? secretName,
string? region,
CancellationToken ct)
{
Dictionary<string, object> providerSpec = provider.ToLower() switch
{
"vault" => new Dictionary<string, object>
{
["vault"] = new Dictionary<string, object>
{
["server"] = endpoint ?? "http://vault.vault:8200",
["path"] = "secret",
["version"] = "v2",
["auth"] = new Dictionary<string, object>
{
["kubernetes"] = new Dictionary<string, object>
{
["mountPath"] = "kubernetes",
["role"] = "external-secrets",
["serviceAccountRef"] = new Dictionary<string, object>
{
["name"] = "external-secrets"
}
}
}
}
},
"aws" => new Dictionary<string, object>
{
["aws"] = new Dictionary<string, object>
{
["service"] = "SecretsManager",
["region"] = region ?? "eu-north-1",
["auth"] = string.IsNullOrEmpty(secretName)
? new Dictionary<string, object>
{
["jwt"] = new Dictionary<string, object>
{
["serviceAccountRef"] = new Dictionary<string, object>
{
["name"] = "external-secrets"
}
}
}
: new Dictionary<string, object>
{
["secretRef"] = new Dictionary<string, object>
{
["accessKeyIDSecretRef"] = new Dictionary<string, object>
{
["name"] = secretName,
["key"] = "access-key-id",
["namespace"] = "external-secrets"
},
["secretAccessKeySecretRef"] = new Dictionary<string, object>
{
["name"] = secretName,
["key"] = "secret-access-key",
["namespace"] = "external-secrets"
}
}
}
}
},
"azure" => new Dictionary<string, object>
{
["azurekv"] = new Dictionary<string, object>
{
["vaultUrl"] = endpoint ?? "",
["authType"] = "ManagedIdentity"
}
},
"gcp" => new Dictionary<string, object>
{
["gcpsm"] = new Dictionary<string, object>
{
["projectID"] = region ?? "",
["auth"] = string.IsNullOrEmpty(secretName)
? new Dictionary<string, object>
{
["workloadIdentity"] = new Dictionary<string, object>
{
["clusterLocation"] = "",
["clusterName"] = "",
["serviceAccountRef"] = new Dictionary<string, object>
{
["name"] = "external-secrets"
}
}
}
: new Dictionary<string, object>
{
["secretRef"] = new Dictionary<string, object>
{
["secretAccessKeySecretRef"] = new Dictionary<string, object>
{
["name"] = secretName,
["key"] = "secret-access-credentials",
["namespace"] = "external-secrets"
}
}
}
}
},
_ => new Dictionary<string, object>()
};
Dictionary<string, object> store = new()
{
["apiVersion"] = "external-secrets.io/v1beta1",
["kind"] = "ClusterSecretStore",
["metadata"] = new Dictionary<string, object>
{
["name"] = $"platform-{provider}"
},
["spec"] = new Dictionary<string, object>
{
["provider"] = providerSpec
}
};
try
{
await client.CustomObjects.PatchClusterCustomObjectAsync(
body: new V1Patch(store, V1Patch.PatchType.MergePatch),
group: "external-secrets.io",
version: "v1beta1",
plural: "clustersecretstores",
name: $"platform-{provider}",
cancellationToken: ct);
}
catch (k8s.Autorest.HttpOperationException ex) when (ex.Response.StatusCode == System.Net.HttpStatusCode.NotFound)
{
await client.CustomObjects.CreateClusterCustomObjectAsync(
body: store,
group: "external-secrets.io",
version: "v1beta1",
plural: "clustersecretstores",
cancellationToken: ct);
}
}
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<string, string>
{
["app.kubernetes.io/managed-by"] = "entkube",
["app.kubernetes.io/part-of"] = "external-secrets"
}
}
};
await client.CoreV1.CreateNamespaceAsync(ns, cancellationToken: ct);
}
}
private async Task<string> 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);
}
}

View File

@@ -1,272 +0,0 @@
using System.Text;
using EntKube.Clusters.Domain;
using k8s;
using k8s.Models;
using Microsoft.Extensions.Logging;
namespace EntKube.Clusters.Features.AdoptCluster.Components.Gitea;
/// <summary>
/// Checks whether Gitea is installed and healthy on the cluster.
/// Gitea provides self-hosted Git repository hosting with built-in CI/CD
/// (Gitea Actions), package registry, and project management. It serves as
/// the source code management backbone for tenants who want on-cluster SCM.
///
/// This check verifies:
/// 1. Gitea pods running in the gitea namespace
/// 2. Gitea HTTP service is accessible
/// 3. PostgreSQL database backing Gitea is running
/// 4. Gitea Actions runner controller (if CI/CD is enabled)
/// </summary>
public class GiteaCheck : IAdoptionCheck
{
private readonly ILogger<GiteaCheck> logger;
public string ComponentName => "gitea";
public string DisplayName => "Gitea (Source Code Management)";
public GiteaCheck(ILogger<GiteaCheck> logger)
{
this.logger = logger;
}
public async Task<ComponentCheckResult> CheckAsync(KubernetesCluster cluster, CancellationToken ct = default)
{
Kubernetes client = BuildClient(cluster);
List<string> details = new();
List<string> missing = new();
// Check 1: Gitea pods in the gitea namespace.
List<string> giteaPods = await FindGiteaPods(client, ct);
if (giteaPods.Count > 0)
{
details.AddRange(giteaPods.Select(p => $"Gitea pod: {p}"));
}
else
{
missing.Add("No Gitea pods found in gitea namespace");
return new ComponentCheckResult(ComponentName, ComponentStatus.NotInstalled, details, missing);
}
// Check 2: Gitea HTTP service exists.
bool httpServiceExists = await ServiceExists(client, "gitea", "gitea-http", ct);
if (httpServiceExists)
{
details.Add("Gitea HTTP service present");
}
else
{
// Try alternative naming from the Helm chart.
bool altServiceExists = await ServiceExists(client, "gitea", "gitea", ct);
if (altServiceExists)
{
details.Add("Gitea service present");
}
else
{
missing.Add("Gitea HTTP service not found");
}
}
// Check 3: PostgreSQL database pods (Gitea's backing store).
List<string> dbPods = await FindDatabasePods(client, ct);
if (dbPods.Count > 0)
{
details.Add($"Database pods running: {dbPods.Count}");
}
else
{
// Gitea might use an external database — not necessarily a hard failure.
details.Add("No in-cluster database pods found (may use external DB)");
}
// Check 4: Gitea Actions runner controller.
List<string> runnerPods = await FindRunnerPods(client, ct);
if (runnerPods.Count > 0)
{
details.Add($"Actions runner pods: {runnerPods.Count}");
}
else
{
details.Add("Gitea Actions runners not deployed");
}
// Discover version from pod image tag.
string? version = await GetVersionFromPods(client, ct);
DiscoveredConfiguration config = new(
Version: version,
Namespace: "gitea",
HelmReleaseName: "gitea",
Values: new Dictionary<string, string>
{
["replicas"] = giteaPods.Count.ToString(),
["actionsEnabled"] = (runnerPods.Count > 0).ToString(),
["runnerCount"] = runnerPods.Count.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<string?> GetVersionFromPods(Kubernetes client, CancellationToken ct)
{
try
{
V1PodList podList = await client.CoreV1.ListNamespacedPodAsync(
"gitea",
labelSelector: "app.kubernetes.io/name=gitea",
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<List<string>> FindGiteaPods(Kubernetes client, CancellationToken ct)
{
List<string> pods = new();
try
{
V1PodList podList = await client.CoreV1.ListNamespacedPodAsync(
"gitea",
labelSelector: "app.kubernetes.io/name=gitea",
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("gitea namespace not found on cluster");
}
catch (Exception ex)
{
logger.LogWarning(ex, "Error checking for Gitea pods");
}
return pods;
}
private async Task<List<string>> FindDatabasePods(Kubernetes client, CancellationToken ct)
{
List<string> pods = new();
try
{
V1PodList podList = await client.CoreV1.ListNamespacedPodAsync(
"gitea",
labelSelector: "app.kubernetes.io/name=postgresql",
cancellationToken: ct);
foreach (V1Pod pod in podList.Items)
{
if (pod.Status?.Phase == "Running")
{
pods.Add(pod.Metadata.Name);
}
}
}
catch { }
return pods;
}
private async Task<List<string>> FindRunnerPods(Kubernetes client, CancellationToken ct)
{
List<string> pods = new();
try
{
// Look for runner pods — they might be labeled differently depending
// on whether act_runner or the Gitea Actions Runner Controller is used.
V1PodList podList = await client.CoreV1.ListNamespacedPodAsync(
"gitea",
labelSelector: "app.kubernetes.io/component=runner",
cancellationToken: ct);
foreach (V1Pod pod in podList.Items)
{
if (pod.Status?.Phase == "Running")
{
pods.Add(pod.Metadata.Name);
}
}
if (pods.Count == 0)
{
// Try alternative label for act_runner deployments.
podList = await client.CoreV1.ListNamespacedPodAsync(
"gitea",
labelSelector: "app=gitea-runner",
cancellationToken: ct);
foreach (V1Pod pod in podList.Items)
{
if (pod.Status?.Phase == "Running")
{
pods.Add(pod.Metadata.Name);
}
}
}
}
catch { }
return pods;
}
private async Task<bool> ServiceExists(Kubernetes client, string ns, string serviceName, CancellationToken ct)
{
try
{
await client.CoreV1.ReadNamespacedServiceAsync(serviceName, ns, 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);
}
}

View File

@@ -1,112 +0,0 @@
using System.Text;
using EntKube.Clusters.Domain;
using EntKube.SharedKernel.Contracts;
using EntKube.SharedKernel.Domain;
using k8s;
using k8s.Models;
using Microsoft.AspNetCore.Mvc;
namespace EntKube.Clusters.Features.AdoptCluster.Components.Gitea;
/// <summary>
/// API endpoints for Gitea runtime information. The BFF proxies these so the
/// Repositories tab can display Gitea instance info and act runner pod statuses.
/// </summary>
public static class GiteaEndpoints
{
public static void Map(IEndpointRouteBuilder app)
{
// GET /api/clusters/{id}/gitea/info — returns Gitea instance summary
// including version, replica count, actions state, and runner pod details.
app.MapGet("/api/clusters/{id:guid}/gitea/info", async (
Guid id,
[FromServices] IClusterRepository repository,
CancellationToken ct) =>
{
// First, look up the cluster and verify Gitea is installed.
KubernetesCluster? cluster = await repository.GetByIdAsync(id, ct);
if (cluster is null)
{
return Results.NotFound(ApiResponse<object>.Fail("Cluster not found."));
}
ClusterComponent? gitea = cluster.Components.FirstOrDefault(
c => c.ComponentName.Equals("gitea", StringComparison.OrdinalIgnoreCase));
if (gitea is null || gitea.Status != ComponentStatus.Installed)
{
return Results.BadRequest(ApiResponse<object>.Fail("Gitea is not installed on this cluster."));
}
// Pull configuration values stored during installation.
string version = gitea.Configuration.TryGetValue("version", out string? v) ? v : "unknown";
bool actionsEnabled = gitea.Configuration.TryGetValue("actionsEnabled", out string? ae)
&& ae.Equals("true", StringComparison.OrdinalIgnoreCase);
int replicas = gitea.Configuration.TryGetValue("replicas", out string? rep)
&& int.TryParse(rep, out int r) ? r : 1;
string ns = gitea.Namespace ?? "gitea";
// Now connect to the cluster's K8s API to query act runner pod statuses.
List<GiteaRunnerPodDto> runners = new();
if (actionsEnabled)
{
try
{
byte[] kubeConfigBytes = Encoding.UTF8.GetBytes(cluster.KubeConfig);
using MemoryStream stream = new(kubeConfigBytes);
KubernetesClientConfiguration config = KubernetesClientConfiguration.BuildConfigFromConfigFile(stream, cluster.ContextName);
Kubernetes k8sClient = new(config);
// Act runners are deployed as a StatefulSet with labels matching
// the Gitea release name. We look for pods with the act-runner label.
V1PodList pods = await k8sClient.CoreV1.ListNamespacedPodAsync(
ns,
labelSelector: "app.kubernetes.io/component=act-runner",
cancellationToken: ct);
foreach (V1Pod pod in pods.Items)
{
string podName = pod.Metadata.Name;
string phase = pod.Status?.Phase ?? "Unknown";
bool ready = pod.Status?.ContainerStatuses?.All(
cs => cs.Ready) ?? false;
int restarts = pod.Status?.ContainerStatuses?.Sum(
cs => cs.RestartCount) ?? 0;
runners.Add(new GiteaRunnerPodDto(podName, phase, ready, restarts));
}
}
catch
{
// If we can't reach the cluster, return what we have without runners.
}
}
GiteaInfoResponseDto info = new(version, replicas, actionsEnabled, runners);
return Results.Ok(ApiResponse<GiteaInfoResponseDto>.Ok(info));
});
}
}
public record GiteaInfoResponseDto(
string? Version,
int Replicas,
bool ActionsEnabled,
List<GiteaRunnerPodDto> Runners);
public record GiteaRunnerPodDto(
string Name,
string Phase,
bool Ready,
int RestartCount);

View File

@@ -1,172 +0,0 @@
using System.Text;
using EntKube.Clusters.Domain;
using k8s;
using k8s.Models;
using Microsoft.Extensions.Logging;
namespace EntKube.Clusters.Features.AdoptCluster.Components.Grafana;
/// <summary>
/// Checks whether Grafana is installed and healthy on the cluster.
/// In this platform, Grafana ships as part of the kube-prometheus-stack Helm release.
///
/// This check verifies:
/// 1. Grafana pods running in monitoring namespace
/// 2. Dashboard sidecar is present (searches ALL namespaces for ConfigMaps)
/// 3. Grafana has a reachable service
/// </summary>
public class GrafanaCheck : IAdoptionCheck
{
private readonly ILogger<GrafanaCheck> logger;
public string ComponentName => "grafana";
public string DisplayName => "Grafana Dashboards";
public GrafanaCheck(ILogger<GrafanaCheck> logger)
{
this.logger = logger;
}
public async Task<ComponentCheckResult> CheckAsync(KubernetesCluster cluster, CancellationToken ct = default)
{
Kubernetes client = BuildClient(cluster);
List<string> details = new();
List<string> missing = new();
// Check 1: Grafana pods running
List<string> grafanaPods = await FindGrafanaPods(client, ct);
if (grafanaPods.Count > 0)
{
details.AddRange(grafanaPods.Select(p => $"Grafana pod: {p}"));
}
else
{
missing.Add("No Grafana pods found in monitoring namespace");
}
// Check 2: Grafana service exists
bool serviceExists = await ServiceExists(client, "monitoring", "kube-prometheus-stack-grafana", ct);
if (serviceExists)
{
details.Add("Grafana service present");
}
else
{
missing.Add("Grafana service not found (kube-prometheus-stack-grafana)");
}
// Check 3: Dashboard sidecar — look for ConfigMaps with grafana_dashboard label
bool hasDashboards = await HasDashboardConfigMaps(client, ct);
if (hasDashboards)
{
details.Add("Dashboard ConfigMaps found (grafana_dashboard label)");
}
else
{
// Not critical — Grafana can run without custom dashboards
details.Add("No custom dashboard ConfigMaps found (grafana_dashboard label)");
}
// Determine status
if (grafanaPods.Count == 0 && !serviceExists)
{
return new ComponentCheckResult(ComponentName, ComponentStatus.NotInstalled, details, missing);
}
// Build discovered configuration
DiscoveredConfiguration config = new(
Version: null,
Namespace: "monitoring",
HelmReleaseName: "kube-prometheus-stack",
Values: new Dictionary<string, string>
{
["replicas"] = grafanaPods.Count.ToString(),
["servicePresent"] = serviceExists.ToString(),
["hasDashboards"] = hasDashboards.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<List<string>> FindGrafanaPods(Kubernetes client, CancellationToken ct)
{
List<string> pods = new();
try
{
V1PodList podList = await client.CoreV1.ListNamespacedPodAsync(
"monitoring",
labelSelector: "app.kubernetes.io/name=grafana",
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("monitoring namespace not found");
}
catch (Exception ex)
{
logger.LogWarning(ex, "Error checking Grafana pods");
}
return pods;
}
private async Task<bool> ServiceExists(Kubernetes client, string ns, string serviceName, CancellationToken ct)
{
try
{
await client.CoreV1.ReadNamespacedServiceAsync(serviceName, ns, cancellationToken: ct);
return true;
}
catch
{
return false;
}
}
private async Task<bool> HasDashboardConfigMaps(Kubernetes client, CancellationToken ct)
{
try
{
V1ConfigMapList configMaps = await client.CoreV1.ListNamespacedConfigMapAsync(
"monitoring",
labelSelector: "grafana_dashboard=1",
cancellationToken: ct);
return configMaps.Items.Count > 0;
}
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);
}
}

View File

@@ -1,318 +0,0 @@
using System.Text;
using EntKube.Clusters.Domain;
using k8s;
using Microsoft.Extensions.Logging;
namespace EntKube.Clusters.Features.AdoptCluster.Components.Grafana;
/// <summary>
/// Enables/installs Grafana on the cluster. In this platform, Grafana ships
/// as part of the kube-prometheus-stack Helm release. This installer upgrades
/// the existing monitoring stack with Grafana enabled.
///
/// If the monitoring stack isn't installed yet, it delegates to the full monitoring
/// install (which includes Grafana by default).
///
/// Configuration mirrors Terraform:
/// - Dashboard sidecar enabled, searches ALL namespaces
/// - Admin credentials from Secret
/// - Persistence enabled
/// </summary>
public class GrafanaInstaller : IComponentInstaller
{
private readonly ILogger<GrafanaInstaller> logger;
private const string DefaultNamespace = "monitoring";
private const string HelmRepo = "https://prometheus-community.github.io/helm-charts";
public string ComponentName => "grafana";
public GrafanaInstaller(ILogger<GrafanaInstaller> logger)
{
this.logger = logger;
}
public async Task<InstallResult> InstallAsync(KubernetesCluster cluster, ComponentInstallOptions options, CancellationToken ct = default)
{
string targetNamespace = options.Namespace ?? DefaultNamespace;
List<string> actions = new();
string tempKubeConfig = Path.Combine(Path.GetTempPath(), $"entkube-grafana-{Guid.NewGuid()}.kubeconfig");
string valuesPath = Path.Combine(Path.GetTempPath(), $"entkube-grafana-values-{Guid.NewGuid()}.yaml");
try
{
await File.WriteAllTextAsync(tempKubeConfig, cluster.KubeConfig, ct);
// Check if kube-prometheus-stack is already installed
// If yes, upgrade it with Grafana enabled
// If no, install the full stack (Grafana is included by default)
bool stackExists = await IsMonitoringStackInstalled(tempKubeConfig, cluster.ContextName, targetNamespace, ct);
string values = GetGrafanaValues();
await File.WriteAllTextAsync(valuesPath, values, ct);
if (stackExists)
{
// Upgrade existing monitoring stack to enable Grafana
await RunCommand("helm",
$"upgrade kube-prometheus-stack kube-prometheus-stack " +
$"--repo {HelmRepo} " +
$"--namespace {targetNamespace} " +
$"--kubeconfig \"{tempKubeConfig}\" --kube-context \"{cluster.ContextName}\" " +
$"--values \"{valuesPath}\" " +
$"--reuse-values " +
$"--wait --timeout 10m",
ct);
actions.Add("Upgraded kube-prometheus-stack with Grafana enabled");
}
else
{
// The monitoring stack doesn't exist — inform the user to install monitoring first
return new InstallResult(
Success: false,
ComponentName: ComponentName,
Message: "Monitoring stack (kube-prometheus-stack) not found. Install the 'monitoring' component first — it includes Grafana.",
Actions: new List<string> { "Prerequisite: install 'monitoring' component first" });
}
logger.LogInformation("Grafana enabled on cluster {Cluster}", cluster.Name);
return new InstallResult(
Success: true,
ComponentName: ComponentName,
Message: "Grafana enabled in monitoring stack",
Actions: actions);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to enable Grafana on cluster {Cluster}", cluster.Name);
actions.Add($"Error: {ex.Message}");
return new InstallResult(
Success: false,
ComponentName: ComponentName,
Message: $"Failed to enable Grafana: {ex.Message}",
Actions: actions);
}
finally
{
if (File.Exists(tempKubeConfig))
{
File.Delete(tempKubeConfig);
}
if (File.Exists(valuesPath))
{
File.Delete(valuesPath);
}
}
}
private async Task<bool> IsMonitoringStackInstalled(string kubeConfig, string contextName, string ns, CancellationToken ct)
{
try
{
string output = await RunCommand("helm",
$"status kube-prometheus-stack --namespace {ns} --kubeconfig \"{kubeConfig}\" --kube-context \"{contextName}\"",
ct);
return output.Contains("STATUS: deployed");
}
catch
{
return false;
}
}
private static string GetGrafanaValues()
{
return """
grafana:
enabled: true
persistence:
enabled: true
size: 10Gi
sidecar:
dashboards:
default:
enabled: true
label: grafana_dashboard
searchNamespace: ALL
datasources:
default:
enabled: true
label: grafana_datasource
searchNamespace: ALL
resources:
requests:
cpu: 100m
memory: 256Mi
limits:
cpu: 500m
memory: 512Mi
""";
}
private async Task<string> 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;
}
/// <summary>
/// Reconfigures Grafana within the monitoring stack. Supports:
/// - "persistence": true/false to enable persistent storage
/// - "persistenceSize": PVC size (e.g., "10Gi")
/// - "enabled": true/false to enable/disable Grafana
/// </summary>
public async Task<InstallResult> ConfigureAsync(KubernetesCluster cluster, ComponentConfiguration configuration, CancellationToken ct = default)
{
string targetNamespace = configuration.Namespace ?? DefaultNamespace;
List<string> actions = new();
string tempKubeConfig = Path.Combine(Path.GetTempPath(), $"entkube-grafana-cfg-{Guid.NewGuid()}.kubeconfig");
try
{
await File.WriteAllTextAsync(tempKubeConfig, cluster.KubeConfig, ct);
List<string> setFlags = new();
if (configuration.Values.TryGetValue("persistence", out string? persistence))
{
setFlags.Add($"--set grafana.persistence.enabled={persistence}");
}
if (configuration.Values.TryGetValue("persistenceSize", out string? size))
{
setFlags.Add($"--set grafana.persistence.size={size}");
}
if (configuration.Values.TryGetValue("enabled", out string? enabled))
{
setFlags.Add($"--set grafana.enabled={enabled}");
}
await RunCommand("helm",
$"upgrade kube-prometheus-stack kube-prometheus-stack --repo {HelmRepo} " +
$"--namespace {targetNamespace} " +
$"--kubeconfig \"{tempKubeConfig}\" --kube-context \"{cluster.ContextName}\" " +
$"--reuse-values {string.Join(" ", setFlags)} --wait --timeout 10m",
ct);
actions.Add($"Reconfigured Grafana: {string.Join(", ", configuration.Values.Select(kv => $"{kv.Key}={kv.Value}"))}");
return new InstallResult(
Success: true,
ComponentName: ComponentName,
Message: "Grafana reconfigured",
Actions: actions);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to reconfigure Grafana on cluster {Cluster}", cluster.Name);
return new InstallResult(
Success: false,
ComponentName: ComponentName,
Message: $"Failed to reconfigure Grafana: {ex.Message}",
Actions: new List<string> { $"Error: {ex.Message}" });
}
finally
{
if (File.Exists(tempKubeConfig))
{
File.Delete(tempKubeConfig);
}
}
}
/// <summary>
/// Uninstalls Grafana by upgrading the kube-prometheus-stack release with
/// Grafana disabled. Since Grafana is part of the monitoring stack, we
/// cannot simply "helm uninstall" — we upgrade with grafana.enabled=false.
/// </summary>
public async Task<InstallResult> UninstallAsync(KubernetesCluster cluster, ComponentInstallOptions options, CancellationToken ct = default)
{
string targetNamespace = options.Namespace ?? DefaultNamespace;
List<string> actions = new();
string tempKubeConfig = Path.Combine(Path.GetTempPath(), $"entkube-grafana-rm-{Guid.NewGuid()}.kubeconfig");
try
{
await File.WriteAllTextAsync(tempKubeConfig, cluster.KubeConfig, ct);
bool stackExists = await IsMonitoringStackInstalled(tempKubeConfig, cluster.ContextName, targetNamespace, ct);
if (!stackExists)
{
return new InstallResult(
Success: true,
ComponentName: ComponentName,
Message: "Monitoring stack not found — nothing to uninstall",
Actions: new List<string>());
}
// Upgrade the monitoring stack with Grafana disabled.
await RunCommand("helm",
$"upgrade kube-prometheus-stack kube-prometheus-stack " +
$"--repo {HelmRepo} " +
$"--namespace {targetNamespace} " +
$"--kubeconfig \"{tempKubeConfig}\" --kube-context \"{cluster.ContextName}\" " +
$"--reuse-values --set grafana.enabled=false " +
$"--wait --timeout 10m",
ct);
actions.Add("Disabled Grafana in kube-prometheus-stack");
logger.LogInformation("Grafana disabled on cluster {Cluster}", cluster.Name);
return new InstallResult(
Success: true,
ComponentName: ComponentName,
Message: "Grafana disabled in monitoring stack",
Actions: actions);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to disable Grafana on cluster {Cluster}", cluster.Name);
return new InstallResult(
Success: false,
ComponentName: ComponentName,
Message: $"Failed to disable Grafana: {ex.Message}",
Actions: new List<string> { $"Error: {ex.Message}" });
}
finally
{
if (File.Exists(tempKubeConfig))
{
File.Delete(tempKubeConfig);
}
}
}
}

View File

@@ -1,309 +0,0 @@
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.Harbor;
/// <summary>
/// Checks whether Harbor container registry is installed on the cluster.
/// Harbor serves three purposes for the platform:
///
/// 1. **Private registry** — hosting container images and Helm charts
/// 2. **Pull-through cache** — caching upstream registries (Docker Hub, ghcr.io, quay.io)
/// 3. **Security scanning** — Trivy-based vulnerability scanning on all images
///
/// Discovery looks for the Harbor Helm release, verifies core components are healthy,
/// and enumerates proxy cache projects that are configured.
/// </summary>
public class HarborCheck : IAdoptionCheck
{
private readonly ILogger<HarborCheck> logger;
private const string DefaultNamespace = "harbor";
public string ComponentName => "harbor";
public string DisplayName => "Harbor (Container Registry & Helm Charts)";
public HarborCheck(ILogger<HarborCheck> logger)
{
this.logger = logger;
}
public async Task<ComponentCheckResult> CheckAsync(KubernetesCluster cluster, CancellationToken ct = default)
{
Kubernetes client = BuildClient(cluster);
List<string> details = new();
List<string> missing = new();
// Check 1: Look for Harbor pods in the expected namespace.
// Harbor has several components: core, registry, portal, database, redis, trivy, jobservice.
Dictionary<string, string> componentHealth = await CheckHarborComponents(client, ct);
if (componentHealth.Count == 0)
{
missing.Add("No Harbor pods found in 'harbor' namespace");
return new ComponentCheckResult(ComponentName, ComponentStatus.NotInstalled, details, missing);
}
// Report the status of each component we found.
List<string> unhealthyComponents = new();
foreach (KeyValuePair<string, string> component in componentHealth)
{
details.Add($"{component.Key} component: {component.Value}");
if (component.Value != "Running")
{
unhealthyComponents.Add($"harbor-{component.Key} pod unhealthy ({component.Value})");
}
}
// Check 2: Detect the registry endpoint from the Ingress.
string? registryEndpoint = await DetectRegistryEndpoint(client, ct);
if (registryEndpoint is not null)
{
details.Add($"Registry endpoint: {registryEndpoint}");
}
// Check 3: Detect features (Trivy scanner, ChartMuseum).
bool trivyEnabled = componentHealth.ContainsKey("Trivy");
bool chartMuseumEnabled = componentHealth.ContainsKey("ChartMuseum");
if (trivyEnabled)
{
details.Add("Trivy scanner enabled");
}
if (chartMuseumEnabled)
{
details.Add("ChartMuseum enabled");
}
// Check 4: Detect Helm release version.
string? helmVersion = await DetectHelmReleaseVersion(client, ct);
if (helmVersion is not null)
{
details.Insert(0, $"Harbor {helmVersion} detected (Helm release 'harbor' in namespace '{DefaultNamespace}')");
}
// Build discovered configuration.
Dictionary<string, string> values = new()
{
["trivyEnabled"] = trivyEnabled.ToString(),
["chartMuseumEnabled"] = chartMuseumEnabled.ToString()
};
if (registryEndpoint is not null)
{
values["registryEndpoint"] = registryEndpoint;
}
DiscoveredConfiguration config = new(
Version: helmVersion,
Namespace: DefaultNamespace,
HelmReleaseName: "harbor",
Values: values);
// Determine overall status.
if (unhealthyComponents.Count > 0)
{
missing.AddRange(unhealthyComponents);
return new ComponentCheckResult(ComponentName, ComponentStatus.Degraded, details, missing, config);
}
return new ComponentCheckResult(ComponentName, ComponentStatus.Installed, details, missing, config);
}
/// <summary>
/// Finds all Harbor-related pods and groups them by component, reporting the phase
/// of each. Harbor labels its pods with app.kubernetes.io/component.
/// </summary>
private async Task<Dictionary<string, string>> CheckHarborComponents(Kubernetes client, CancellationToken ct)
{
Dictionary<string, string> components = new();
try
{
V1PodList podList = await client.CoreV1.ListNamespacedPodAsync(
DefaultNamespace,
labelSelector: "app.kubernetes.io/name=harbor",
cancellationToken: ct);
foreach (V1Pod pod in podList.Items)
{
string? component = null;
pod.Metadata.Labels?.TryGetValue("app.kubernetes.io/component", out component);
if (component is null)
{
// Fallback: try to infer from pod name.
component = InferComponentFromPodName(pod.Metadata.Name);
}
if (component is not null)
{
string phase = pod.Status?.Phase ?? "Unknown";
// Use the worst status if multiple pods for the same component.
if (!components.ContainsKey(component) || phase != "Running")
{
components[component] = phase;
}
}
}
}
catch (k8s.Autorest.HttpOperationException ex) when (ex.Response.StatusCode == System.Net.HttpStatusCode.NotFound)
{
logger.LogDebug("Harbor namespace not found");
}
catch (Exception ex)
{
logger.LogWarning(ex, "Error checking for Harbor pods");
}
return components;
}
private static string? InferComponentFromPodName(string podName)
{
if (podName.Contains("core"))
{
return "Core";
}
if (podName.Contains("registry"))
{
return "Registry";
}
if (podName.Contains("portal") || podName.Contains("nginx"))
{
return "Portal";
}
if (podName.Contains("database") || podName.Contains("postgres"))
{
return "Database";
}
if (podName.Contains("redis"))
{
return "Redis";
}
if (podName.Contains("trivy"))
{
return "Trivy";
}
if (podName.Contains("jobservice"))
{
return "JobService";
}
if (podName.Contains("chartmuseum"))
{
return "ChartMuseum";
}
return null;
}
/// <summary>
/// Looks for an Ingress resource in the Harbor namespace to determine the registry endpoint.
/// </summary>
private async Task<string?> DetectRegistryEndpoint(Kubernetes client, CancellationToken ct)
{
try
{
V1IngressList ingresses = await client.NetworkingV1.ListNamespacedIngressAsync(
DefaultNamespace, cancellationToken: ct);
foreach (V1Ingress ingress in ingresses.Items)
{
if (ingress.Spec?.Rules is not null)
{
foreach (V1IngressRule rule in ingress.Spec.Rules)
{
if (rule.Host is not null)
{
return rule.Host;
}
}
}
}
}
catch (Exception ex)
{
logger.LogDebug(ex, "Could not detect Harbor ingress endpoint");
}
return null;
}
/// <summary>
/// Detects the Helm release version by looking at the Helm release Secret
/// stored in the namespace (Helm stores release metadata as Secrets with
/// label owner=helm and type=helm.sh/release.v1).
/// </summary>
private async Task<string?> DetectHelmReleaseVersion(Kubernetes client, CancellationToken ct)
{
try
{
V1SecretList secrets = await client.CoreV1.ListNamespacedSecretAsync(
DefaultNamespace,
labelSelector: "owner=helm,name=harbor",
cancellationToken: ct);
if (secrets.Items.Count > 0)
{
// Get the latest release (highest version number).
V1Secret? latestRelease = secrets.Items
.OrderByDescending(s =>
{
string? ver = null;
s.Metadata.Labels?.TryGetValue("version", out ver);
return ver ?? "0";
})
.FirstOrDefault();
if (latestRelease?.Metadata.Labels?.TryGetValue("version", out string? version) == true)
{
// The actual app version is embedded in the release data,
// but for a quick check we return the chart version from labels.
return version;
}
}
}
catch (Exception ex)
{
logger.LogDebug(ex, "Could not detect Harbor Helm release version");
}
return null;
}
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);
}
}

View File

@@ -1,293 +0,0 @@
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using EntKube.Clusters.Domain;
using EntKube.SharedKernel.Contracts;
using EntKube.SharedKernel.Domain;
using k8s;
using Microsoft.AspNetCore.Mvc;
namespace EntKube.Clusters.Features.AdoptCluster.Components.Harbor;
/// <summary>
/// API endpoints for Harbor operations that apps need at configuration time.
/// The Provisioning service (via the BFF) calls these to:
/// - List Harbor projects so the user can pick one for their app
/// - Create a pull secret in a target namespace for a specific project
///
/// These are separate from the HarborInstaller's Configure flow because they
/// serve the app deployment workflow, not the component configuration workflow.
/// </summary>
public static class HarborEndpoints
{
public static void Map(IEndpointRouteBuilder app)
{
// GET /api/clusters/{id}/harbor/projects — list Harbor projects.
// Returns project names, visibility, and whether they are proxy caches.
app.MapGet("/api/clusters/{id:guid}/harbor/projects", async (
Guid id,
[FromServices] IClusterRepository repository,
CancellationToken ct) =>
{
// Find the cluster and check that Harbor is installed.
KubernetesCluster? cluster = await repository.GetByIdAsync(id, ct);
if (cluster is null)
{
return Results.NotFound(ApiResponse<object>.Fail("Cluster not found."));
}
ClusterComponent? harbor = cluster.Components.FirstOrDefault(
c => c.ComponentName.Equals("harbor", StringComparison.OrdinalIgnoreCase));
if (harbor is null || harbor.Status != ComponentStatus.Installed)
{
return Results.BadRequest(ApiResponse<object>.Fail("Harbor is not installed on this cluster."));
}
// Read the Harbor domain and admin password from the stored component config.
string harborDomain = harbor.Configuration.TryGetValue("harborDomain", out string? domain)
&& !string.IsNullOrEmpty(domain) ? domain : "harbor.local";
string adminPassword = harbor.Configuration.TryGetValue("adminPassword", out string? pass)
&& !string.IsNullOrEmpty(pass) ? pass : "Harbor12345";
try
{
List<HarborProjectDto> projects = await ListProjectsAsync(harborDomain, adminPassword, ct);
return Results.Ok(ApiResponse<List<HarborProjectDto>>.Ok(projects));
}
catch (Exception ex)
{
return Results.Json(
ApiResponse<object>.Fail($"Failed to list Harbor projects: {ex.Message}"),
statusCode: 502);
}
});
// POST /api/clusters/{id}/harbor/pull-secret — create a pull secret
// for a specific Harbor project in a target namespace.
app.MapPost("/api/clusters/{id:guid}/harbor/pull-secret", async (
Guid id,
[FromBody] CreateHarborPullSecretRequest request,
[FromServices] IClusterRepository repository,
CancellationToken ct) =>
{
KubernetesCluster? cluster = await repository.GetByIdAsync(id, ct);
if (cluster is null)
{
return Results.NotFound(ApiResponse<object>.Fail("Cluster not found."));
}
ClusterComponent? harbor = cluster.Components.FirstOrDefault(
c => c.ComponentName.Equals("harbor", StringComparison.OrdinalIgnoreCase));
if (harbor is null || harbor.Status != ComponentStatus.Installed)
{
return Results.BadRequest(ApiResponse<object>.Fail("Harbor is not installed on this cluster."));
}
string harborDomain = harbor.Configuration.TryGetValue("harborDomain", out string? domain)
&& !string.IsNullOrEmpty(domain) ? domain : "harbor.local";
string adminPassword = harbor.Configuration.TryGetValue("adminPassword", out string? pass)
&& !string.IsNullOrEmpty(pass) ? pass : "Harbor12345";
try
{
// Create a robot account for the project with pull-only access.
string robotName = $"robot${request.ProjectName}-{request.AppSlug}";
string robotToken = await CreateRobotAccountAsync(
harborDomain, adminPassword, request.ProjectName, robotName, ct);
// Create the imagePullSecret in the target namespace.
KubernetesClientConfiguration config = KubernetesClientConfiguration
.BuildConfigFromConfigFile(
new MemoryStream(Encoding.UTF8.GetBytes(cluster.KubeConfig)),
cluster.ContextName);
Kubernetes client = new(config);
string secretName = $"harbor-pull-{request.ProjectName}-{request.AppSlug}";
await CreateImagePullSecretAsync(
client, secretName, request.TargetNamespace, harborDomain, robotName, robotToken, ct);
HarborPullSecretResult result = new(secretName, harborDomain, request.ProjectName);
return Results.Ok(ApiResponse<HarborPullSecretResult>.Ok(result));
}
catch (Exception ex)
{
return Results.Json(
ApiResponse<object>.Fail($"Failed to create pull secret: {ex.Message}"),
statusCode: 502);
}
});
}
// ─── Harbor API Operations ────────────────────────────────────────────
private static async Task<List<HarborProjectDto>> ListProjectsAsync(
string harborDomain, string adminPassword, CancellationToken ct)
{
List<HarborProjectDto> projects = new();
using HttpClient httpClient = CreateHarborClient(harborDomain, adminPassword);
HttpResponseMessage response = await httpClient.GetAsync("/api/v2.0/projects?page_size=100", ct);
response.EnsureSuccessStatusCode();
string body = await response.Content.ReadAsStringAsync(ct);
using JsonDocument doc = JsonDocument.Parse(body);
foreach (JsonElement project in doc.RootElement.EnumerateArray())
{
string? name = project.TryGetProperty("name", out JsonElement nameEl)
? nameEl.GetString() : null;
if (name is null)
{
continue;
}
bool isPublic = project.TryGetProperty("metadata", out JsonElement meta)
&& meta.TryGetProperty("public", out JsonElement pubEl)
&& pubEl.GetString() == "true";
bool isProxyCache = project.TryGetProperty("registry_id", out JsonElement regId)
&& regId.ValueKind == JsonValueKind.Number
&& regId.GetInt32() > 0;
projects.Add(new HarborProjectDto(name, isPublic ? "public" : "private", isProxyCache));
}
return projects;
}
private static async Task<string> CreateRobotAccountAsync(
string harborDomain, string adminPassword, string projectName,
string robotName, CancellationToken ct)
{
using HttpClient httpClient = CreateHarborClient(harborDomain, adminPassword);
object robotPayload = new
{
name = robotName,
duration = -1,
level = "project",
permissions = new[]
{
new
{
kind = "project",
@namespace = projectName,
access = new[]
{
new { resource = "repository", action = "pull" },
new { resource = "artifact", action = "read" }
}
}
}
};
string json = JsonSerializer.Serialize(robotPayload);
using StringContent content = new(json, Encoding.UTF8, "application/json");
HttpResponseMessage response = await httpClient.PostAsync("/api/v2.0/robots", content, ct);
string responseBody = await response.Content.ReadAsStringAsync(ct);
using JsonDocument doc = JsonDocument.Parse(responseBody);
if (doc.RootElement.TryGetProperty("secret", out JsonElement secretEl))
{
return secretEl.GetString() ?? "";
}
return "";
}
private static async Task CreateImagePullSecretAsync(
Kubernetes client, string secretName, string targetNamespace,
string harborDomain, string username, string password, CancellationToken ct)
{
// Build the Docker config JSON for imagePullSecrets.
string auth = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{username}:{password}"));
object dockerConfig = new
{
auths = new Dictionary<string, object>
{
[harborDomain] = new { auth }
}
};
string dockerConfigJson = JsonSerializer.Serialize(dockerConfig);
byte[] configBytes = Encoding.UTF8.GetBytes(dockerConfigJson);
k8s.Models.V1Secret secret = new()
{
Metadata = new k8s.Models.V1ObjectMeta
{
Name = secretName,
NamespaceProperty = targetNamespace,
Labels = new Dictionary<string, string>
{
["app.kubernetes.io/managed-by"] = "entkube",
["entkube.io/component"] = "harbor"
}
},
Type = "kubernetes.io/dockerconfigjson",
Data = new Dictionary<string, byte[]>
{
[".dockerconfigjson"] = configBytes
}
};
try
{
await client.CoreV1.ReplaceNamespacedSecretAsync(
secret, secretName, targetNamespace, cancellationToken: ct);
}
catch (k8s.Autorest.HttpOperationException ex)
when (ex.Response.StatusCode == System.Net.HttpStatusCode.NotFound)
{
await client.CoreV1.CreateNamespacedSecretAsync(
secret, targetNamespace, cancellationToken: ct);
}
}
private static HttpClient CreateHarborClient(string harborDomain, string adminPassword)
{
HttpClient client = new()
{
BaseAddress = new Uri($"https://{harborDomain}")
};
string credentials = Convert.ToBase64String(Encoding.UTF8.GetBytes($"admin:{adminPassword}"));
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic", credentials);
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
return client;
}
}
// ─── DTOs ────────────────────────────────────────────────────────────────
public record HarborProjectDto(string Name, string Visibility, bool IsProxyCache);
public record CreateHarborPullSecretRequest(
string ProjectName,
string AppSlug,
string TargetNamespace);
public record HarborPullSecretResult(
string SecretName,
string HarborDomain,
string ProjectName);

View File

@@ -1,259 +0,0 @@
using System.Text;
using EntKube.Clusters.Domain;
using k8s;
using k8s.Models;
using Microsoft.Extensions.Logging;
namespace EntKube.Clusters.Features.AdoptCluster.Components.Ingress;
/// <summary>
/// Checks whether the cluster's ingress gateway infrastructure is deployed.
/// This is the load balancer layer that exposes services — it does NOT install
/// the ingress controller itself (that's the istio or traefik component).
///
/// The check auto-detects which provider is installed by looking at the
/// cluster's existing components:
/// - If Istio is installed → checks for internal/external gateway pods and
/// Gateway API Gateway resources in internal-ingress/external-ingress namespaces
/// - If Traefik is installed → the ingress component is not needed (Traefik
/// manages its own LoadBalancer Service as part of its Helm chart)
///
/// This separation means users never have to select a "provider" — the ingress
/// component simply knows what to do based on what's already on the cluster.
/// </summary>
public class IngressCheck : IAdoptionCheck
{
private readonly ILogger<IngressCheck> logger;
public string ComponentName => "ingress";
public string DisplayName => "Ingress Gateway Infrastructure";
public IngressCheck(ILogger<IngressCheck> logger)
{
this.logger = logger;
}
public async Task<ComponentCheckResult> CheckAsync(KubernetesCluster cluster, CancellationToken ct = default)
{
// Auto-detect which provider is installed by looking at the cluster's
// component list from previous adoption scans.
string? detectedProvider = DetectProvider(cluster);
if (detectedProvider == "traefik")
{
// Traefik manages its own LoadBalancer Service — no separate ingress
// gateway infrastructure is needed. Report as installed with a note.
return new ComponentCheckResult(
ComponentName,
ComponentStatus.Installed,
Details: new List<string> { "Traefik manages its own ingress — no separate gateway infrastructure needed" },
MissingItems: new List<string>(),
Configuration: new DiscoveredConfiguration(
Version: null,
Namespace: null,
HelmReleaseName: null,
Values: new Dictionary<string, string> { ["provider"] = "traefik" }));
}
if (detectedProvider == "istio")
{
return await CheckIstioGateways(cluster, ct);
}
// Neither Istio nor Traefik detected — check the cluster directly
// in case the adoption scan hasn't run yet.
Kubernetes client = BuildClient(cluster);
List<string> istioPods = await FindIstioGatewayPods(client, "internal-ingress", ct);
if (istioPods.Count > 0)
{
return await CheckIstioGatewaysWithClient(client, istioPods, ct);
}
return new ComponentCheckResult(
ComponentName,
ComponentStatus.NotInstalled,
Details: new List<string>(),
MissingItems: new List<string> { "No ingress provider detected — install Istio or Traefik first" });
}
/// <summary>
/// Detects the ingress provider by checking which controller component
/// is already installed on the cluster.
/// </summary>
public static string? DetectProvider(KubernetesCluster cluster)
{
// Check for Istio first (it requires dedicated gateway infrastructure).
ClusterComponent? istioComponent = cluster.Components.FirstOrDefault(
c => c.ComponentName.Equals("istio", StringComparison.OrdinalIgnoreCase));
if (istioComponent?.Status == ComponentStatus.Installed ||
istioComponent?.Status == ComponentStatus.Degraded)
{
return "istio";
}
// Check for Traefik (it manages its own LB service).
ClusterComponent? traefikComponent = cluster.Components.FirstOrDefault(
c => c.ComponentName.Equals("traefik", StringComparison.OrdinalIgnoreCase));
if (traefikComponent?.Status == ComponentStatus.Installed ||
traefikComponent?.Status == ComponentStatus.Degraded)
{
return "traefik";
}
return null;
}
/// <summary>
/// Checks Istio ingress gateway state using the cluster's stored component
/// data and a live Kubernetes API connection.
/// </summary>
private async Task<ComponentCheckResult> CheckIstioGateways(KubernetesCluster cluster, CancellationToken ct)
{
Kubernetes client = BuildClient(cluster);
List<string> istioPods = await FindIstioGatewayPods(client, "internal-ingress", ct);
if (istioPods.Count == 0)
{
return new ComponentCheckResult(
ComponentName,
ComponentStatus.NotInstalled,
Details: new List<string> { "Istio is installed but no gateway pods found" },
MissingItems: new List<string> { "Internal gateway pods not found in internal-ingress namespace" });
}
return await CheckIstioGatewaysWithClient(client, istioPods, ct);
}
private async Task<ComponentCheckResult> CheckIstioGatewaysWithClient(
Kubernetes client, List<string> internalPods, CancellationToken ct)
{
List<string> details = new();
List<string> missing = new();
details.AddRange(internalPods.Select(p => $"Internal gateway pod: {p}"));
// Check external ingress gateway pods (optional).
List<string> externalPods = await FindIstioGatewayPods(client, "external-ingress", ct);
if (externalPods.Count > 0)
{
details.AddRange(externalPods.Select(p => $"External gateway pod: {p}"));
}
else
{
details.Add("No external ingress gateway found (optional)");
}
// Check Gateway API Gateway resources.
bool hasInternalGateway = await GatewayResourceExists(client, "internal-ingress", "internal", ct);
if (hasInternalGateway)
{
details.Add("Gateway API Gateway 'internal' present");
}
else
{
missing.Add("Gateway API Gateway 'internal' not found in internal-ingress namespace");
}
bool hasExternalGateway = await GatewayResourceExists(client, "external-ingress", "external", ct);
if (hasExternalGateway)
{
details.Add("Gateway API Gateway 'external' present");
}
else
{
details.Add("Gateway API Gateway 'external' not found (optional)");
}
DiscoveredConfiguration config = new(
Version: null,
Namespace: "internal-ingress",
HelmReleaseName: "internal-gateway",
Values: new Dictionary<string, string>
{
["provider"] = "istio",
["internalReplicas"] = internalPods.Count.ToString(),
["externalReplicas"] = externalPods.Count.ToString(),
["hasInternalGateway"] = hasInternalGateway.ToString(),
["hasExternalGateway"] = hasExternalGateway.ToString(),
["externalEnabled"] = (externalPods.Count > 0).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<List<string>> FindIstioGatewayPods(Kubernetes client, string ns, CancellationToken ct)
{
List<string> pods = new();
try
{
V1PodList podList = await client.CoreV1.ListNamespacedPodAsync(
ns,
labelSelector: "istio=gateway",
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("Namespace {Namespace} not found", ns);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Error checking gateway pods in {Namespace}", ns);
}
return pods;
}
private async Task<bool> GatewayResourceExists(Kubernetes client, string ns, string name, CancellationToken ct)
{
try
{
await client.CustomObjects.GetNamespacedCustomObjectAsync(
group: "gateway.networking.k8s.io",
version: "v1",
namespaceParameter: ns,
plural: "gateways",
name: name,
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);
}
}

View File

@@ -1,388 +0,0 @@
using System.Text;
using EntKube.Clusters.Domain;
using k8s;
using k8s.Models;
using Microsoft.Extensions.Logging;
namespace EntKube.Clusters.Features.AdoptCluster.Components.Ingress;
/// <summary>
/// Checks whether an ingress solution is deployed on the cluster.
/// Supports two mutually exclusive providers — Istio or Traefik.
/// The check auto-detects which provider is in use by looking for their
/// respective pods and resources.
///
/// Istio mode:
/// - Internal ingress gateway (always) in internal-ingress namespace
/// - External ingress gateway (optional) in external-ingress namespace
/// - Gateway API Gateway resources
///
/// Traefik mode:
/// - Single Traefik deployment in traefik namespace (or kube-system)
/// - IngressClass resource registered
/// - Optional dashboard
/// </summary>
public class IngressCheck : IAdoptionCheck
{
private readonly ILogger<IngressCheck> logger;
public string ComponentName => "ingress";
public string DisplayName => "Ingress (Istio or Traefik)";
public IngressCheck(ILogger<IngressCheck> logger)
{
this.logger = logger;
}
public async Task<ComponentCheckResult> CheckAsync(KubernetesCluster cluster, CancellationToken ct = default)
{
Kubernetes client = BuildClient(cluster);
List<string> details = new();
List<string> missing = new();
// First, try to detect which provider is installed.
// Check for Traefik pods first (common single-ingress setup),
// then check for Istio gateways.
List<string> traefikPods = await FindTraefikPods(client, ct);
List<string> istioPods = await FindIstioGatewayPods(client, "internal-ingress", ct);
// --- Traefik detected ---
if (traefikPods.Count > 0)
{
return await CheckTraefik(client, traefikPods, details, missing, ct);
}
// --- Istio detected ---
if (istioPods.Count > 0)
{
return await CheckIstio(client, istioPods, details, missing, ct);
}
// --- Neither found ---
missing.Add("No ingress provider detected (checked for Traefik and Istio gateways)");
return new ComponentCheckResult(ComponentName, ComponentStatus.NotInstalled, details, missing);
}
/// <summary>
/// Checks Traefik ingress state. Looks for pods, IngressClass, and
/// discovers the running configuration (replicas, version, dashboard status).
/// </summary>
private async Task<ComponentCheckResult> CheckTraefik(
Kubernetes client, List<string> traefikPods,
List<string> details, List<string> missing, CancellationToken ct)
{
details.AddRange(traefikPods.Select(p => $"Traefik pod: {p}"));
// Check IngressClass exists.
bool hasIngressClass = await IngressClassExists(client, "traefik", ct);
if (hasIngressClass)
{
details.Add("IngressClass 'traefik' registered");
}
else
{
missing.Add("IngressClass 'traefik' not found");
}
// Check for the Traefik service (LoadBalancer or NodePort).
string traefikNamespace = await FindTraefikNamespace(client, ct);
bool serviceExists = await TraefikServiceExists(client, traefikNamespace, ct);
if (serviceExists)
{
details.Add($"Traefik service present in {traefikNamespace}");
}
else
{
missing.Add("Traefik LoadBalancer/NodePort service not found");
}
// Check for dashboard IngressRoute (optional).
bool hasDashboard = await TraefikDashboardExists(client, traefikNamespace, ct);
details.Add(hasDashboard ? "Traefik dashboard IngressRoute present" : "Traefik dashboard not configured (optional)");
// Discover version from pod image tag.
string? version = GetVersionFromPodImage(traefikPods, client, traefikNamespace, ct);
DiscoveredConfiguration config = new(
Version: version,
Namespace: traefikNamespace,
HelmReleaseName: "traefik",
Values: new Dictionary<string, string>
{
["provider"] = "traefik",
["replicas"] = traefikPods.Count.ToString(),
["ingressClassRegistered"] = hasIngressClass.ToString(),
["dashboardEnabled"] = hasDashboard.ToString()
});
if (missing.Count > 0)
{
return new ComponentCheckResult(ComponentName, ComponentStatus.Degraded, details, missing, config);
}
return new ComponentCheckResult(ComponentName, ComponentStatus.Installed, details, missing, config);
}
/// <summary>
/// Checks Istio ingress gateway state. Looks for internal/external gateways,
/// Gateway API resources, and discovers the running configuration.
/// </summary>
private async Task<ComponentCheckResult> CheckIstio(
Kubernetes client, List<string> internalPods,
List<string> details, List<string> missing, CancellationToken ct)
{
details.AddRange(internalPods.Select(p => $"Internal gateway pod: {p}"));
// Check external ingress gateway pods (optional).
List<string> externalPods = await FindIstioGatewayPods(client, "external-ingress", ct);
if (externalPods.Count > 0)
{
details.AddRange(externalPods.Select(p => $"External gateway pod: {p}"));
}
else
{
details.Add("No external ingress gateway found (optional)");
}
// Check Gateway API Gateway resources.
bool hasInternalGateway = await GatewayResourceExists(client, "internal-ingress", "internal", ct);
if (hasInternalGateway)
{
details.Add("Gateway API Gateway 'internal' present");
}
else
{
missing.Add("Gateway API Gateway 'internal' not found in internal-ingress namespace");
}
bool hasExternalGateway = await GatewayResourceExists(client, "external-ingress", "external", ct);
if (hasExternalGateway)
{
details.Add("Gateway API Gateway 'external' present");
}
else
{
details.Add("Gateway API Gateway 'external' not found (optional)");
}
DiscoveredConfiguration config = new(
Version: null,
Namespace: "internal-ingress",
HelmReleaseName: "internal-gateway",
Values: new Dictionary<string, string>
{
["provider"] = "istio",
["internalReplicas"] = internalPods.Count.ToString(),
["externalReplicas"] = externalPods.Count.ToString(),
["hasInternalGateway"] = hasInternalGateway.ToString(),
["hasExternalGateway"] = hasExternalGateway.ToString(),
["externalEnabled"] = (externalPods.Count > 0).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<List<string>> FindTraefikPods(Kubernetes client, CancellationToken ct)
{
List<string> pods = new();
// Try traefik namespace first, then kube-system.
foreach (string ns in new[] { "traefik", "kube-system" })
{
try
{
V1PodList podList = await client.CoreV1.ListNamespacedPodAsync(
ns,
labelSelector: "app.kubernetes.io/name=traefik",
cancellationToken: ct);
foreach (V1Pod pod in podList.Items)
{
if (pod.Status?.Phase == "Running")
{
pods.Add(pod.Metadata.Name);
}
}
if (pods.Count > 0)
{
break;
}
}
catch (k8s.Autorest.HttpOperationException ex) when (ex.Response.StatusCode == System.Net.HttpStatusCode.NotFound)
{
// Namespace doesn't exist — try next.
}
catch (Exception ex)
{
logger.LogWarning(ex, "Error checking Traefik pods in {Namespace}", ns);
}
}
return pods;
}
private async Task<string> FindTraefikNamespace(Kubernetes client, CancellationToken ct)
{
// Check where Traefik is actually running.
foreach (string ns in new[] { "traefik", "kube-system" })
{
try
{
V1PodList podList = await client.CoreV1.ListNamespacedPodAsync(
ns,
labelSelector: "app.kubernetes.io/name=traefik",
cancellationToken: ct);
if (podList.Items.Any(p => p.Status?.Phase == "Running"))
{
return ns;
}
}
catch { }
}
return "traefik";
}
private async Task<bool> IngressClassExists(Kubernetes client, string name, CancellationToken ct)
{
try
{
await client.NetworkingV1.ReadIngressClassAsync(name, cancellationToken: ct);
return true;
}
catch
{
return false;
}
}
private async Task<bool> TraefikServiceExists(Kubernetes client, string ns, CancellationToken ct)
{
try
{
V1ServiceList services = await client.CoreV1.ListNamespacedServiceAsync(
ns,
labelSelector: "app.kubernetes.io/name=traefik",
cancellationToken: ct);
return services.Items.Any(s =>
s.Spec.Type == "LoadBalancer" || s.Spec.Type == "NodePort");
}
catch
{
return false;
}
}
private async Task<bool> TraefikDashboardExists(Kubernetes client, string ns, CancellationToken ct)
{
try
{
// Traefik dashboard is typically exposed via an IngressRoute CRD.
await client.CustomObjects.GetNamespacedCustomObjectAsync(
group: "traefik.io",
version: "v1alpha1",
namespaceParameter: ns,
plural: "ingressroutes",
name: "traefik-dashboard",
cancellationToken: ct);
return true;
}
catch
{
return false;
}
}
private string? GetVersionFromPodImage(List<string> pods, Kubernetes client, string ns, CancellationToken ct)
{
// Version is extracted at check time from the pod list we already found.
// We'd need to re-query to get container images — for now return null
// and let the DiscoveredConfiguration capture it if needed.
return null;
}
private async Task<List<string>> FindIstioGatewayPods(Kubernetes client, string ns, CancellationToken ct)
{
List<string> pods = new();
try
{
V1PodList podList = await client.CoreV1.ListNamespacedPodAsync(
ns,
labelSelector: "istio=gateway",
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("Namespace {Namespace} not found", ns);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Error checking gateway pods in {Namespace}", ns);
}
return pods;
}
private async Task<bool> GatewayResourceExists(Kubernetes client, string ns, string name, CancellationToken ct)
{
try
{
await client.CustomObjects.GetNamespacedCustomObjectAsync(
group: "gateway.networking.k8s.io",
version: "v1",
namespaceParameter: ns,
plural: "gateways",
name: name,
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);
}
}

View File

@@ -1,585 +0,0 @@
using System.Text;
using EntKube.Clusters.Domain;
using k8s;
using Microsoft.Extensions.Logging;
namespace EntKube.Clusters.Features.AdoptCluster.Components.Ingress;
/// <summary>
/// Installs the ingress gateway infrastructure — the load balancer layer that
/// exposes services externally. This is NOT the ingress controller itself; it's
/// the gateway pods and Gateway API resources that sit in front of it.
///
/// The installer auto-detects which provider is installed on the cluster:
/// - If Istio → deploys Istio gateway pods (envoy proxies) via the Istio
/// gateway Helm chart, plus Gateway API Gateway resources and HTTP→HTTPS
/// redirect HTTPRoutes. Internal gateway always, external optionally.
/// - If Traefik → no action needed (Traefik manages its own LB Service).
///
/// Configuration keys:
/// - "enable_external": deploy external-facing gateway (default: false)
/// </summary>
public class IngressInstaller : IComponentInstaller
{
private readonly ILogger<IngressInstaller> logger;
public string ComponentName => "ingress";
private const string IstioHelmRepo = "https://istio-release.storage.googleapis.com/charts";
private const string DefaultIstioVersion = "1.24.3";
public IngressInstaller(ILogger<IngressInstaller> logger)
{
this.logger = logger;
}
public async Task<InstallResult> InstallAsync(KubernetesCluster cluster, ComponentInstallOptions options, CancellationToken ct = default)
{
// Auto-detect the provider from the cluster's installed components.
string? provider = IngressCheck.DetectProvider(cluster);
if (provider == "traefik")
{
// Traefik manages its own LoadBalancer Service — nothing to deploy.
return new InstallResult(
Success: true,
ComponentName: ComponentName,
Message: "Traefik manages its own ingress — no separate gateway infrastructure needed",
Actions: new List<string> { "Traefik detected — skipped gateway deployment" });
}
if (provider != "istio")
{
return new InstallResult(
Success: false,
ComponentName: ComponentName,
Message: "No ingress controller detected. Install Istio or Traefik first.",
Actions: new List<string>());
}
return await InstallIstioGatewaysAsync(cluster, options, ct);
}
/// <summary>
/// Reconfigures the ingress gateway infrastructure.
/// </summary>
public async Task<InstallResult> ConfigureAsync(KubernetesCluster cluster, ComponentConfiguration configuration, CancellationToken ct = default)
{
ComponentInstallOptions options = new(
Namespace: configuration.Namespace,
Parameters: configuration.Values);
return await InstallAsync(cluster, options, ct);
}
/// <summary>
/// Uninstalls the ingress gateway infrastructure.
/// </summary>
public async Task<InstallResult> UninstallAsync(KubernetesCluster cluster, ComponentInstallOptions options, CancellationToken ct = default)
{
string? provider = IngressCheck.DetectProvider(cluster);
if (provider == "traefik")
{
return new InstallResult(
Success: true,
ComponentName: ComponentName,
Message: "Traefik manages its own ingress — nothing to uninstall here",
Actions: new List<string>());
}
return await UninstallIstioGatewaysAsync(cluster, options, ct);
}
// ─────────────────────────────────────────────────────────────────────────
// Istio Gateway Deployment
// ─────────────────────────────────────────────────────────────────────────
private async Task<InstallResult> InstallIstioGatewaysAsync(KubernetesCluster cluster, ComponentInstallOptions options, CancellationToken ct)
{
string version = options.Version ?? DefaultIstioVersion;
bool enableExternal = options.Parameters?.TryGetValue("enable_external", out string? ext) == true
&& ext == "true";
List<string> actions = new();
string tempKubeConfig = Path.Combine(Path.GetTempPath(), $"entkube-ingress-{Guid.NewGuid()}.kubeconfig");
string internalValuesPath = Path.Combine(Path.GetTempPath(), $"entkube-ingress-internal-{Guid.NewGuid()}.yaml");
string externalValuesPath = Path.Combine(Path.GetTempPath(), $"entkube-ingress-external-{Guid.NewGuid()}.yaml");
try
{
await File.WriteAllTextAsync(tempKubeConfig, cluster.KubeConfig, ct);
Kubernetes client = BuildClient(cluster);
// --- Internal Ingress Gateway (always deployed) ---
await EnsureNamespaceWithIstioInjection(client, "internal-ingress", ct);
actions.Add("Ensured namespace 'internal-ingress' with Istio injection");
string internalValues = GetIstioInternalGatewayValues(cluster.Provider);
await File.WriteAllTextAsync(internalValuesPath, internalValues, ct);
// --skip-schema-validation is needed because the Istio gateway chart's
// values.schema.json has additionalProperties:false, which rejects
// Helm's internally-injected _internal_defaults_do_not_set key.
await RunCommand("helm",
$"upgrade --install internal-gateway gateway " +
$"--repo {IstioHelmRepo} --version {version} " +
$"--namespace internal-ingress " +
$"--kubeconfig \"{tempKubeConfig}\" --kube-context \"{cluster.ContextName}\" " +
$"--values \"{internalValuesPath}\" " +
$"--skip-schema-validation " +
$"--wait --timeout 5m",
ct);
actions.Add($"Helm install internal-gateway v{version}");
// Create Gateway API Gateway resource for internal traffic.
await ApplyGatewayResource(client, "internal-ingress", "internal", ct);
actions.Add("Applied Gateway API Gateway 'internal'");
// Apply HTTP→HTTPS redirect.
await ApplyHttpRedirectRoute(client, "internal-ingress", "internal", ct);
actions.Add("Applied HTTP→HTTPS redirect HTTPRoute");
// --- External Ingress Gateway (optional) ---
if (enableExternal)
{
await EnsureNamespaceWithIstioInjection(client, "external-ingress", ct);
actions.Add("Ensured namespace 'external-ingress' with Istio injection");
string externalValues = GetIstioExternalGatewayValues();
await File.WriteAllTextAsync(externalValuesPath, externalValues, ct);
await RunCommand("helm",
$"upgrade --install external-gateway gateway " +
$"--repo {IstioHelmRepo} --version {version} " +
$"--namespace external-ingress " +
$"--kubeconfig \"{tempKubeConfig}\" --kube-context \"{cluster.ContextName}\" " +
$"--values \"{externalValuesPath}\" " +
$"--skip-schema-validation " +
$"--wait --timeout 5m",
ct);
actions.Add($"Helm install external-gateway v{version}");
await ApplyGatewayResource(client, "external-ingress", "external", ct);
actions.Add("Applied Gateway API Gateway 'external'");
await ApplyHttpRedirectRoute(client, "external-ingress", "external", ct);
actions.Add("Applied HTTP→HTTPS redirect HTTPRoute for external");
}
logger.LogInformation("Istio ingress gateways installed on cluster {Cluster} (external={External})",
cluster.Name, enableExternal);
return new InstallResult(
Success: true,
ComponentName: ComponentName,
Message: enableExternal
? "Internal + external ingress gateways installed"
: "Internal ingress gateway installed",
Actions: actions);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to install ingress gateways on cluster {Cluster}", cluster.Name);
actions.Add($"Error: {ex.Message}");
return new InstallResult(
Success: false,
ComponentName: ComponentName,
Message: $"Failed to install ingress gateways: {ex.Message}",
Actions: actions);
}
finally
{
if (File.Exists(tempKubeConfig))
{
File.Delete(tempKubeConfig);
}
if (File.Exists(internalValuesPath))
{
File.Delete(internalValuesPath);
}
if (File.Exists(externalValuesPath))
{
File.Delete(externalValuesPath);
}
}
}
private async Task<InstallResult> UninstallIstioGatewaysAsync(KubernetesCluster cluster, ComponentInstallOptions options, CancellationToken ct)
{
List<string> actions = new();
string tempKubeConfig = Path.Combine(Path.GetTempPath(), $"entkube-ingress-rm-{Guid.NewGuid()}.kubeconfig");
try
{
await File.WriteAllTextAsync(tempKubeConfig, cluster.KubeConfig, ct);
// Try removing external gateway first (may not exist).
try
{
await RunCommand("helm",
$"uninstall external-gateway --namespace external-ingress " +
$"--kubeconfig \"{tempKubeConfig}\" --kube-context \"{cluster.ContextName}\" " +
$"--wait --timeout 5m",
ct);
actions.Add("Helm uninstall external-gateway");
}
catch
{
// External gateway may not exist — that's fine.
}
await RunCommand("helm",
$"uninstall internal-gateway --namespace internal-ingress " +
$"--kubeconfig \"{tempKubeConfig}\" --kube-context \"{cluster.ContextName}\" " +
$"--wait --timeout 5m",
ct);
actions.Add("Helm uninstall internal-gateway");
logger.LogInformation("Ingress gateways uninstalled from cluster {Cluster}", cluster.Name);
return new InstallResult(
Success: true,
ComponentName: ComponentName,
Message: "Ingress gateways uninstalled successfully",
Actions: actions);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to uninstall ingress gateways from cluster {Cluster}", cluster.Name);
return new InstallResult(
Success: false,
ComponentName: ComponentName,
Message: $"Failed to uninstall ingress gateways: {ex.Message}",
Actions: actions.Concat(new[] { $"Error: {ex.Message}" }).ToList());
}
finally
{
if (File.Exists(tempKubeConfig))
{
File.Delete(tempKubeConfig);
}
}
}
// ─── Public Static Helpers (testable without a Kubernetes client) ────
/// <summary>
/// Returns the correct internal LoadBalancer annotations for the given cloud
/// provider. Each cloud platform uses a different annotation to tell its
/// cloud controller manager to provision a private (non-internet-facing) LB.
/// </summary>
public static Dictionary<string, string> GetInternalLbAnnotations(CloudProvider provider)
{
return provider switch
{
CloudProvider.Cleura => new Dictionary<string, string>
{
["service.beta.kubernetes.io/openstack-internal-load-balancer"] = "true"
},
CloudProvider.Aws => new Dictionary<string, string>
{
["service.beta.kubernetes.io/aws-load-balancer-scheme"] = "internal"
},
CloudProvider.Azure => new Dictionary<string, string>
{
["service.beta.kubernetes.io/azure-load-balancer-internal"] = "true"
},
CloudProvider.Gcp => new Dictionary<string, string>
{
["networking.gke.io/load-balancer-type"] = "Internal"
},
_ => new Dictionary<string, string>()
};
}
/// <summary>
/// Generates Helm values for the Istio internal ingress gateway. The internal
/// gateway always gets a LoadBalancer Service, but the annotations differ based
/// on the cloud provider so the LB is provisioned as private/internal.
/// </summary>
public static string GetIstioInternalGatewayValues(CloudProvider provider)
{
Dictionary<string, string> annotations = GetInternalLbAnnotations(provider);
string annotationsYaml = BuildServiceAnnotationsYaml(annotations);
return $"""
labels:
istio: gateway
istio.io/gateway-name: internal
service:
type: LoadBalancer
{annotationsYaml}resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: "1"
memory: 512Mi
autoscaling:
enabled: true
minReplicas: 2
maxReplicas: 5
""";
}
/// <summary>
/// Generates Helm values for the Istio external ingress gateway. The external
/// gateway is always public — no internal LB annotations are applied.
/// </summary>
public static string GetIstioExternalGatewayValues()
{
return """
labels:
istio: gateway
istio.io/gateway-name: external
service:
type: LoadBalancer
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: "1"
memory: 512Mi
autoscaling:
enabled: true
minReplicas: 2
maxReplicas: 5
""";
}
/// <summary>
/// Builds the YAML fragment for service annotations. Returns an empty string
/// when no annotations are needed, or a properly indented annotations block.
/// </summary>
public static string BuildServiceAnnotationsYaml(Dictionary<string, string> annotations)
{
if (annotations.Count == 0)
{
return "";
}
StringBuilder sb = new();
sb.AppendLine(" annotations:");
foreach (KeyValuePair<string, string> kvp in annotations)
{
sb.AppendLine($" {kvp.Key}: \"{kvp.Value}\"");
}
return sb.ToString();
}
// ─── Private helpers ─────────────────────────────────────────────────
private async Task ApplyGatewayResource(Kubernetes client, string ns, string name, CancellationToken ct)
{
Dictionary<string, object> gateway = new()
{
["apiVersion"] = "gateway.networking.k8s.io/v1",
["kind"] = "Gateway",
["metadata"] = new Dictionary<string, object>
{
["name"] = name,
["namespace"] = ns
},
["spec"] = new Dictionary<string, object>
{
["gatewayClassName"] = "istio",
["listeners"] = new List<Dictionary<string, object>>
{
new()
{
["name"] = "https",
["port"] = 443,
["protocol"] = "HTTPS",
["tls"] = new Dictionary<string, object>
{
["mode"] = "Terminate",
["certificateRefs"] = new List<Dictionary<string, object>>
{
new()
{
["kind"] = "Secret",
["name"] = $"{name}-tls"
}
}
},
["allowedRoutes"] = new Dictionary<string, object>
{
["namespaces"] = new Dictionary<string, object>
{
["from"] = "All"
}
}
},
new()
{
["name"] = "http",
["port"] = 80,
["protocol"] = "HTTP",
["allowedRoutes"] = new Dictionary<string, object>
{
["namespaces"] = new Dictionary<string, object>
{
["from"] = "Same"
}
}
}
}
}
};
try
{
await client.CustomObjects.GetNamespacedCustomObjectAsync(
"gateway.networking.k8s.io", "v1", ns, "gateways", name, cancellationToken: ct);
await client.CustomObjects.PatchNamespacedCustomObjectAsync(
new k8s.Models.V1Patch(gateway, k8s.Models.V1Patch.PatchType.MergePatch),
"gateway.networking.k8s.io", "v1", ns, "gateways", name, cancellationToken: ct);
}
catch (k8s.Autorest.HttpOperationException ex) when (ex.Response.StatusCode == System.Net.HttpStatusCode.NotFound)
{
await client.CustomObjects.CreateNamespacedCustomObjectAsync(
gateway, "gateway.networking.k8s.io", "v1", ns, "gateways", cancellationToken: ct);
}
}
private async Task ApplyHttpRedirectRoute(Kubernetes client, string ns, string gatewayName, CancellationToken ct)
{
string routeName = $"{gatewayName}-http-redirect";
Dictionary<string, object> route = new()
{
["apiVersion"] = "gateway.networking.k8s.io/v1",
["kind"] = "HTTPRoute",
["metadata"] = new Dictionary<string, object>
{
["name"] = routeName,
["namespace"] = ns
},
["spec"] = new Dictionary<string, object>
{
["parentRefs"] = new List<Dictionary<string, object>>
{
new()
{
["name"] = gatewayName,
["sectionName"] = "http"
}
},
["rules"] = new List<Dictionary<string, object>>
{
new()
{
["filters"] = new List<Dictionary<string, object>>
{
new()
{
["type"] = "RequestRedirect",
["requestRedirect"] = new Dictionary<string, object>
{
["scheme"] = "https",
["statusCode"] = 301
}
}
}
}
}
}
};
try
{
await client.CustomObjects.GetNamespacedCustomObjectAsync(
"gateway.networking.k8s.io", "v1", ns, "httproutes", routeName, cancellationToken: ct);
await client.CustomObjects.PatchNamespacedCustomObjectAsync(
new k8s.Models.V1Patch(route, k8s.Models.V1Patch.PatchType.MergePatch),
"gateway.networking.k8s.io", "v1", ns, "httproutes", routeName, cancellationToken: ct);
}
catch (k8s.Autorest.HttpOperationException ex) when (ex.Response.StatusCode == System.Net.HttpStatusCode.NotFound)
{
await client.CustomObjects.CreateNamespacedCustomObjectAsync(
route, "gateway.networking.k8s.io", "v1", ns, "httproutes", cancellationToken: ct);
}
}
private async Task EnsureNamespaceWithIstioInjection(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)
{
k8s.Models.V1Namespace ns = new()
{
Metadata = new k8s.Models.V1ObjectMeta
{
Name = namespaceName,
Labels = new Dictionary<string, string>
{
["istio-injection"] = "enabled",
["app.kubernetes.io/managed-by"] = "entkube"
}
}
};
await client.CoreV1.CreateNamespaceAsync(ns, cancellationToken: ct);
}
}
private async Task<string> 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);
}
}

View File

@@ -1,822 +0,0 @@
using System.Text;
using EntKube.Clusters.Domain;
using k8s;
using Microsoft.Extensions.Logging;
namespace EntKube.Clusters.Features.AdoptCluster.Components.Ingress;
/// <summary>
/// Installs the cluster's ingress solution. Supports two mutually exclusive providers:
///
/// **Traefik mode** (provider=traefik):
/// - Single Traefik deployment in traefik namespace
/// - IngressClass 'traefik' registered as default
/// - TLS via cert-manager annotations
/// - Optional dashboard exposed internally
/// - Suitable for clusters that don't need a service mesh
///
/// **Istio mode** (provider=istio):
/// - Internal ingress gateway (always) in internal-ingress namespace
/// - External ingress gateway (optional) in external-ingress namespace
/// - Gateway API Gateway resources with HTTPS listeners
/// - HTTP→HTTPS redirect HTTPRoutes
/// - Requires Istio to already be installed on the cluster
///
/// Configuration keys:
/// - "provider": "traefik" or "istio" (required)
/// - "enable_external": deploy external gateway (istio only, default: false)
/// - "replicas": number of Traefik replicas (traefik only, default: 2)
/// - "dashboardEnabled": expose Traefik dashboard (traefik only, default: false)
/// - "serviceType": LoadBalancer or NodePort (traefik only, default: LoadBalancer)
/// - "internalLoadBalancer": use internal LB annotations (traefik only, default: false)
/// </summary>
public class IngressInstaller : IComponentInstaller
{
private readonly ILogger<IngressInstaller> logger;
public string ComponentName => "ingress";
public IngressInstaller(ILogger<IngressInstaller> logger)
{
this.logger = logger;
}
public async Task<InstallResult> InstallAsync(KubernetesCluster cluster, ComponentInstallOptions options, CancellationToken ct = default)
{
// Determine which provider to install. Default to traefik for simplicity.
string provider = options.Parameters?.TryGetValue("provider", out string? prov) == true
? prov.ToLower()
: "traefik";
return provider switch
{
"traefik" => await InstallTraefikAsync(cluster, options, ct),
"istio" => await InstallIstioAsync(cluster, options, ct),
_ => new InstallResult(
Success: false,
ComponentName: ComponentName,
Message: $"Unknown ingress provider '{provider}'. Supported: traefik, istio",
Actions: new List<string>())
};
}
/// <summary>
/// Reconfigures the ingress solution. The provider is determined from the
/// configuration values — it must match what's already deployed.
/// </summary>
public async Task<InstallResult> ConfigureAsync(KubernetesCluster cluster, ComponentConfiguration configuration, CancellationToken ct = default)
{
string provider = configuration.Values.TryGetValue("provider", out string? prov)
? prov.ToLower()
: "traefik";
// Build install options from configuration values and delegate.
// Since both installers use `helm upgrade --install`, reconfiguration
// is the same operation with updated values.
ComponentInstallOptions options = new(
Namespace: configuration.Namespace,
Parameters: configuration.Values);
return provider switch
{
"traefik" => await InstallTraefikAsync(cluster, options, ct),
"istio" => await InstallIstioAsync(cluster, options, ct),
_ => new InstallResult(
Success: false,
ComponentName: ComponentName,
Message: $"Unknown ingress provider '{provider}'. Supported: traefik, istio",
Actions: new List<string>())
};
}
/// <summary>
/// Uninstalls the ingress solution from the cluster. Detects which provider
/// is deployed and removes the corresponding Helm releases.
/// </summary>
public async Task<InstallResult> UninstallAsync(KubernetesCluster cluster, ComponentInstallOptions options, CancellationToken ct = default)
{
// Determine which provider to uninstall from parameters or default.
string provider = options.Parameters?.TryGetValue("provider", out string? prov) == true
? prov.ToLower()
: "traefik";
return provider switch
{
"traefik" => await UninstallTraefikAsync(cluster, options, ct),
"istio" => await UninstallIstioIngressAsync(cluster, options, ct),
_ => new InstallResult(
Success: false,
ComponentName: ComponentName,
Message: $"Unknown ingress provider '{provider}'. Supported: traefik, istio",
Actions: new List<string>())
};
}
private async Task<InstallResult> UninstallTraefikAsync(KubernetesCluster cluster, ComponentInstallOptions options, CancellationToken ct)
{
string targetNamespace = options.Namespace ?? DefaultTraefikNamespace;
List<string> actions = new();
string tempKubeConfig = Path.Combine(Path.GetTempPath(), $"entkube-traefik-rm-{Guid.NewGuid()}.kubeconfig");
try
{
await File.WriteAllTextAsync(tempKubeConfig, cluster.KubeConfig, ct);
await RunCommand("helm",
$"uninstall traefik --namespace {targetNamespace} " +
$"--kubeconfig \"{tempKubeConfig}\" --kube-context \"{cluster.ContextName}\" " +
$"--wait --timeout 5m",
ct);
actions.Add("Helm uninstall traefik");
logger.LogInformation("Traefik uninstalled from cluster {Cluster}", cluster.Name);
return new InstallResult(
Success: true,
ComponentName: ComponentName,
Message: "Traefik ingress uninstalled successfully",
Actions: actions);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to uninstall Traefik from cluster {Cluster}", cluster.Name);
return new InstallResult(
Success: false,
ComponentName: ComponentName,
Message: $"Failed to uninstall Traefik: {ex.Message}",
Actions: new List<string> { $"Error: {ex.Message}" });
}
finally
{
if (File.Exists(tempKubeConfig))
{
File.Delete(tempKubeConfig);
}
}
}
private async Task<InstallResult> UninstallIstioIngressAsync(KubernetesCluster cluster, ComponentInstallOptions options, CancellationToken ct)
{
List<string> actions = new();
string tempKubeConfig = Path.Combine(Path.GetTempPath(), $"entkube-ingress-rm-{Guid.NewGuid()}.kubeconfig");
try
{
await File.WriteAllTextAsync(tempKubeConfig, cluster.KubeConfig, ct);
// Try removing external gateway first (may not exist).
try
{
await RunCommand("helm",
$"uninstall external-gateway --namespace external-ingress " +
$"--kubeconfig \"{tempKubeConfig}\" --kube-context \"{cluster.ContextName}\" " +
$"--wait --timeout 5m",
ct);
actions.Add("Helm uninstall external-gateway");
}
catch
{
// External gateway may not exist — that's fine.
}
await RunCommand("helm",
$"uninstall internal-gateway --namespace internal-ingress " +
$"--kubeconfig \"{tempKubeConfig}\" --kube-context \"{cluster.ContextName}\" " +
$"--wait --timeout 5m",
ct);
actions.Add("Helm uninstall internal-gateway");
logger.LogInformation("Istio ingress gateways uninstalled from cluster {Cluster}", cluster.Name);
return new InstallResult(
Success: true,
ComponentName: ComponentName,
Message: "Istio ingress gateways uninstalled successfully",
Actions: actions);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to uninstall ingress from cluster {Cluster}", cluster.Name);
return new InstallResult(
Success: false,
ComponentName: ComponentName,
Message: $"Failed to uninstall ingress: {ex.Message}",
Actions: actions.Concat(new[] { $"Error: {ex.Message}" }).ToList());
}
finally
{
if (File.Exists(tempKubeConfig))
{
File.Delete(tempKubeConfig);
}
}
}
// ─────────────────────────────────────────────────────────────────────────
// Traefik Installation
// ─────────────────────────────────────────────────────────────────────────
private const string TraefikHelmRepo = "https://traefik.github.io/charts";
private const string DefaultTraefikVersion = "32.0.0";
private const string DefaultTraefikNamespace = "traefik";
private async Task<InstallResult> InstallTraefikAsync(KubernetesCluster cluster, ComponentInstallOptions options, CancellationToken ct)
{
string version = options.Version ?? DefaultTraefikVersion;
string targetNamespace = options.Namespace ?? DefaultTraefikNamespace;
List<string> actions = new();
string tempKubeConfig = Path.Combine(Path.GetTempPath(), $"entkube-traefik-{Guid.NewGuid()}.kubeconfig");
string valuesPath = Path.Combine(Path.GetTempPath(), $"entkube-traefik-values-{Guid.NewGuid()}.yaml");
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}'");
// Build Traefik Helm values based on parameters.
string values = GetTraefikValues(options.Parameters, cluster.Provider);
await File.WriteAllTextAsync(valuesPath, values, ct);
// Install/upgrade Traefik via Helm.
await RunCommand("helm",
$"upgrade --install traefik traefik " +
$"--repo {TraefikHelmRepo} --version {version} " +
$"--namespace {targetNamespace} " +
$"--kubeconfig \"{tempKubeConfig}\" --kube-context \"{cluster.ContextName}\" " +
$"--values \"{valuesPath}\" " +
$"--wait --timeout 5m",
ct);
actions.Add($"Helm install traefik v{version}");
logger.LogInformation("Traefik {Version} installed on cluster {Cluster}", version, cluster.Name);
return new InstallResult(
Success: true,
ComponentName: ComponentName,
Message: $"Traefik {version} installed",
Actions: actions);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to install Traefik on cluster {Cluster}", cluster.Name);
actions.Add($"Error: {ex.Message}");
return new InstallResult(
Success: false,
ComponentName: ComponentName,
Message: $"Failed to install Traefik: {ex.Message}",
Actions: actions);
}
finally
{
if (File.Exists(tempKubeConfig))
{
File.Delete(tempKubeConfig);
}
if (File.Exists(valuesPath))
{
File.Delete(valuesPath);
}
}
}
/// <summary>
/// Generates Helm values for Traefik deployment. When internalLoadBalancer is
/// enabled, applies provider-specific annotations so the Service gets a private LB.
/// </summary>
public static string GetTraefikValues(Dictionary<string, string>? parameters, CloudProvider provider = CloudProvider.None)
{
string replicas = parameters?.TryGetValue("replicas", out string? r) == true ? r : "2";
string serviceType = parameters?.TryGetValue("serviceType", out string? st) == true ? st : "LoadBalancer";
bool internalLb = parameters?.TryGetValue("internalLoadBalancer", out string? ilb) == true && ilb == "true";
bool dashboard = parameters?.TryGetValue("dashboardEnabled", out string? db) == true && db == "true";
string lbAnnotations = "";
if (internalLb)
{
Dictionary<string, string> annotations = GetInternalLbAnnotations(provider);
lbAnnotations = BuildServiceAnnotationsYaml(annotations);
}
return $"""
deployment:
replicas: {replicas}
ingressClass:
enabled: true
isDefaultClass: true
service:
type: {serviceType}
{lbAnnotations}
ports:
web:
port: 8000
exposedPort: 80
protocol: TCP
redirectTo:
port: websecure
websecure:
port: 8443
exposedPort: 443
protocol: TCP
tls:
enabled: true
ingressRoute:
dashboard:
enabled: {dashboard.ToString().ToLower()}
providers:
kubernetesIngress:
enabled: true
publishedService:
enabled: true
kubernetesCRD:
enabled: true
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: "1"
memory: 512Mi
autoscaling:
enabled: true
minReplicas: {replicas}
maxReplicas: 10
metrics:
prometheus:
enabled: true
serviceMonitor:
enabled: true
""";
}
// ─────────────────────────────────────────────────────────────────────────
// Istio Installation
// ─────────────────────────────────────────────────────────────────────────
private const string IstioHelmRepo = "https://istio-release.storage.googleapis.com/charts";
private const string DefaultIstioVersion = "1.24.3";
private async Task<InstallResult> InstallIstioAsync(KubernetesCluster cluster, ComponentInstallOptions options, CancellationToken ct)
{
string version = options.Version ?? DefaultIstioVersion;
bool enableExternal = options.Parameters?.TryGetValue("enable_external", out string? ext) == true
&& ext == "true";
List<string> actions = new();
string tempKubeConfig = Path.Combine(Path.GetTempPath(), $"entkube-ingress-{Guid.NewGuid()}.kubeconfig");
string internalValuesPath = Path.Combine(Path.GetTempPath(), $"entkube-ingress-internal-{Guid.NewGuid()}.yaml");
string externalValuesPath = Path.Combine(Path.GetTempPath(), $"entkube-ingress-external-{Guid.NewGuid()}.yaml");
try
{
await File.WriteAllTextAsync(tempKubeConfig, cluster.KubeConfig, ct);
Kubernetes client = BuildClient(cluster);
// --- Internal Ingress Gateway (always deployed) ---
await EnsureNamespaceWithIstioInjection(client, "internal-ingress", ct);
actions.Add("Ensured namespace 'internal-ingress' with Istio injection");
string internalValues = GetIstioInternalGatewayValues(cluster.Provider);
await File.WriteAllTextAsync(internalValuesPath, internalValues, ct);
// --skip-schema-validation is needed because the Istio gateway chart's
// values.schema.json has additionalProperties:false, which rejects
// Helm's internally-injected _internal_defaults_do_not_set key.
await RunCommand("helm",
$"upgrade --install internal-gateway gateway " +
$"--repo {IstioHelmRepo} --version {version} " +
$"--namespace internal-ingress " +
$"--kubeconfig \"{tempKubeConfig}\" --kube-context \"{cluster.ContextName}\" " +
$"--values \"{internalValuesPath}\" " +
$"--skip-schema-validation " +
$"--wait --timeout 5m",
ct);
actions.Add($"Helm install internal-gateway v{version}");
// Create Gateway API Gateway resource for internal traffic.
await ApplyGatewayResource(client, "internal-ingress", "internal", ct);
actions.Add("Applied Gateway API Gateway 'internal'");
// Apply HTTP→HTTPS redirect.
await ApplyHttpRedirectRoute(client, "internal-ingress", "internal", ct);
actions.Add("Applied HTTP→HTTPS redirect HTTPRoute");
// --- External Ingress Gateway (optional) ---
if (enableExternal)
{
await EnsureNamespaceWithIstioInjection(client, "external-ingress", ct);
actions.Add("Ensured namespace 'external-ingress' with Istio injection");
string externalValues = GetIstioExternalGatewayValues();
await File.WriteAllTextAsync(externalValuesPath, externalValues, ct);
await RunCommand("helm",
$"upgrade --install external-gateway gateway " +
$"--repo {IstioHelmRepo} --version {version} " +
$"--namespace external-ingress " +
$"--kubeconfig \"{tempKubeConfig}\" --kube-context \"{cluster.ContextName}\" " +
$"--values \"{externalValuesPath}\" " +
$"--skip-schema-validation " +
$"--wait --timeout 5m",
ct);
actions.Add($"Helm install external-gateway v{version}");
await ApplyGatewayResource(client, "external-ingress", "external", ct);
actions.Add("Applied Gateway API Gateway 'external'");
await ApplyHttpRedirectRoute(client, "external-ingress", "external", ct);
actions.Add("Applied HTTP→HTTPS redirect HTTPRoute for external");
}
logger.LogInformation("Istio ingress gateways installed on cluster {Cluster} (external={External})",
cluster.Name, enableExternal);
return new InstallResult(
Success: true,
ComponentName: ComponentName,
Message: enableExternal
? "Istio internal + external ingress gateways installed"
: "Istio internal ingress gateway installed",
Actions: actions);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to install Istio ingress on cluster {Cluster}", cluster.Name);
actions.Add($"Error: {ex.Message}");
return new InstallResult(
Success: false,
ComponentName: ComponentName,
Message: $"Failed to install Istio ingress: {ex.Message}",
Actions: actions);
}
finally
{
if (File.Exists(tempKubeConfig))
{
File.Delete(tempKubeConfig);
}
if (File.Exists(internalValuesPath))
{
File.Delete(internalValuesPath);
}
if (File.Exists(externalValuesPath))
{
File.Delete(externalValuesPath);
}
}
}
/// <summary>
/// Returns the correct internal LoadBalancer annotations for the given cloud
/// provider. Each cloud platform uses a different annotation to tell its
/// cloud controller manager to provision a private (non-internet-facing) LB.
/// </summary>
public static Dictionary<string, string> GetInternalLbAnnotations(CloudProvider provider)
{
return provider switch
{
CloudProvider.Cleura => new Dictionary<string, string>
{
["service.beta.kubernetes.io/openstack-internal-load-balancer"] = "true"
},
CloudProvider.Aws => new Dictionary<string, string>
{
["service.beta.kubernetes.io/aws-load-balancer-scheme"] = "internal"
},
CloudProvider.Azure => new Dictionary<string, string>
{
["service.beta.kubernetes.io/azure-load-balancer-internal"] = "true"
},
CloudProvider.Gcp => new Dictionary<string, string>
{
["networking.gke.io/load-balancer-type"] = "Internal"
},
_ => new Dictionary<string, string>()
};
}
/// <summary>
/// Generates Helm values for the Istio internal ingress gateway. The internal
/// gateway always gets a LoadBalancer Service, but the annotations differ based
/// on the cloud provider so the LB is provisioned as private/internal.
/// </summary>
public static string GetIstioInternalGatewayValues(CloudProvider provider)
{
Dictionary<string, string> annotations = GetInternalLbAnnotations(provider);
string annotationsYaml = BuildServiceAnnotationsYaml(annotations);
return $"""
labels:
istio: gateway
istio.io/gateway-name: internal
service:
type: LoadBalancer
{annotationsYaml}resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: "1"
memory: 512Mi
autoscaling:
enabled: true
minReplicas: 2
maxReplicas: 5
""";
}
/// <summary>
/// Generates Helm values for the Istio external ingress gateway. The external
/// gateway is always public — no internal LB annotations are applied.
/// </summary>
public static string GetIstioExternalGatewayValues()
{
return """
labels:
istio: gateway
istio.io/gateway-name: external
service:
type: LoadBalancer
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: "1"
memory: 512Mi
autoscaling:
enabled: true
minReplicas: 2
maxReplicas: 5
""";
}
/// <summary>
/// Builds the YAML fragment for service annotations. Returns an empty string
/// when no annotations are needed, or a properly indented annotations block.
/// </summary>
private static string BuildServiceAnnotationsYaml(Dictionary<string, string> annotations)
{
if (annotations.Count == 0)
{
return "";
}
System.Text.StringBuilder sb = new();
sb.AppendLine(" annotations:");
foreach (KeyValuePair<string, string> kvp in annotations)
{
sb.AppendLine($" {kvp.Key}: \"{kvp.Value}\"");
}
return sb.ToString();
}
private async Task ApplyGatewayResource(Kubernetes client, string ns, string name, CancellationToken ct)
{
Dictionary<string, object> gateway = new()
{
["apiVersion"] = "gateway.networking.k8s.io/v1",
["kind"] = "Gateway",
["metadata"] = new Dictionary<string, object>
{
["name"] = name,
["namespace"] = ns
},
["spec"] = new Dictionary<string, object>
{
["gatewayClassName"] = "istio",
["listeners"] = new List<Dictionary<string, object>>
{
new()
{
["name"] = "https",
["port"] = 443,
["protocol"] = "HTTPS",
["tls"] = new Dictionary<string, object>
{
["mode"] = "Terminate",
["certificateRefs"] = new List<Dictionary<string, object>>
{
new()
{
["kind"] = "Secret",
["name"] = $"{name}-tls"
}
}
},
["allowedRoutes"] = new Dictionary<string, object>
{
["namespaces"] = new Dictionary<string, object>
{
["from"] = "All"
}
}
},
new()
{
["name"] = "http",
["port"] = 80,
["protocol"] = "HTTP",
["allowedRoutes"] = new Dictionary<string, object>
{
["namespaces"] = new Dictionary<string, object>
{
["from"] = "Same"
}
}
}
}
}
};
try
{
await client.CustomObjects.GetNamespacedCustomObjectAsync(
"gateway.networking.k8s.io", "v1", ns, "gateways", name, cancellationToken: ct);
await client.CustomObjects.PatchNamespacedCustomObjectAsync(
new k8s.Models.V1Patch(gateway, k8s.Models.V1Patch.PatchType.MergePatch),
"gateway.networking.k8s.io", "v1", ns, "gateways", name, cancellationToken: ct);
}
catch (k8s.Autorest.HttpOperationException ex) when (ex.Response.StatusCode == System.Net.HttpStatusCode.NotFound)
{
await client.CustomObjects.CreateNamespacedCustomObjectAsync(
gateway, "gateway.networking.k8s.io", "v1", ns, "gateways", cancellationToken: ct);
}
}
private async Task ApplyHttpRedirectRoute(Kubernetes client, string ns, string gatewayName, CancellationToken ct)
{
string routeName = $"{gatewayName}-http-redirect";
Dictionary<string, object> route = new()
{
["apiVersion"] = "gateway.networking.k8s.io/v1",
["kind"] = "HTTPRoute",
["metadata"] = new Dictionary<string, object>
{
["name"] = routeName,
["namespace"] = ns
},
["spec"] = new Dictionary<string, object>
{
["parentRefs"] = new List<Dictionary<string, object>>
{
new()
{
["name"] = gatewayName,
["sectionName"] = "http"
}
},
["rules"] = new List<Dictionary<string, object>>
{
new()
{
["filters"] = new List<Dictionary<string, object>>
{
new()
{
["type"] = "RequestRedirect",
["requestRedirect"] = new Dictionary<string, object>
{
["scheme"] = "https",
["statusCode"] = 301
}
}
}
}
}
}
};
try
{
await client.CustomObjects.GetNamespacedCustomObjectAsync(
"gateway.networking.k8s.io", "v1", ns, "httproutes", routeName, cancellationToken: ct);
await client.CustomObjects.PatchNamespacedCustomObjectAsync(
new k8s.Models.V1Patch(route, k8s.Models.V1Patch.PatchType.MergePatch),
"gateway.networking.k8s.io", "v1", ns, "httproutes", routeName, cancellationToken: ct);
}
catch (k8s.Autorest.HttpOperationException ex) when (ex.Response.StatusCode == System.Net.HttpStatusCode.NotFound)
{
await client.CustomObjects.CreateNamespacedCustomObjectAsync(
route, "gateway.networking.k8s.io", "v1", ns, "httproutes", cancellationToken: ct);
}
}
// ─────────────────────────────────────────────────────────────────────────
// Shared helpers
// ─────────────────────────────────────────────────────────────────────────
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)
{
k8s.Models.V1Namespace ns = new()
{
Metadata = new k8s.Models.V1ObjectMeta
{
Name = namespaceName,
Labels = new Dictionary<string, string>
{
["app.kubernetes.io/managed-by"] = "entkube"
}
}
};
await client.CoreV1.CreateNamespaceAsync(ns, cancellationToken: ct);
}
}
private async Task EnsureNamespaceWithIstioInjection(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)
{
k8s.Models.V1Namespace ns = new()
{
Metadata = new k8s.Models.V1ObjectMeta
{
Name = namespaceName,
Labels = new Dictionary<string, string>
{
["istio-injection"] = "enabled",
["app.kubernetes.io/managed-by"] = "entkube"
}
}
};
await client.CoreV1.CreateNamespaceAsync(ns, cancellationToken: ct);
}
}
private async Task<string> 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);
}
}

View File

@@ -1,259 +0,0 @@
using System.Text;
using EntKube.Clusters.Domain;
using k8s;
using k8s.Models;
using System.Text.Json;
using Microsoft.Extensions.Logging;
namespace EntKube.Clusters.Features.AdoptCluster.Components.InternalCA;
/// <summary>
/// Checks whether an internal CA ClusterIssuer exists on the cluster.
/// The internal CA is a self-signed root certificate managed by cert-manager
/// that signs all internal service certificates. Its root certificate is
/// distributed via trust-manager's Bundle CR so all workloads trust it.
///
/// Discovery looks for:
/// 1. A CA-type ClusterIssuer (spec.ca.secretName is set)
/// 2. The corresponding root CA Secret in the cert-manager namespace
/// 3. Whether the root CA is included in the platform trust bundle
/// </summary>
public class InternalCACheck : IAdoptionCheck
{
private readonly ILogger<InternalCACheck> logger;
public string ComponentName => "internal-ca";
public string DisplayName => "Internal CA (Platform Certificate Authority)";
public InternalCACheck(ILogger<InternalCACheck> logger)
{
this.logger = logger;
}
public async Task<ComponentCheckResult> CheckAsync(KubernetesCluster cluster, CancellationToken ct = default)
{
Kubernetes client = BuildClient(cluster);
List<string> details = new();
List<string> missing = new();
// First, verify cert-manager is present (prerequisite).
bool certManagerPresent = await CertManagerCrdExists(client, ct);
if (!certManagerPresent)
{
missing.Add("cert-manager is not installed (prerequisite for internal CA)");
return new ComponentCheckResult(ComponentName, ComponentStatus.NotInstalled, details, missing);
}
details.Add("cert-manager CRD present (prerequisite satisfied)");
// Look for ALL CA-type ClusterIssuers — these are issuers that sign certs
// using a CA key pair stored in a Secret. A cluster may have more than one
// internal CA (e.g., separate CAs for different environments or services).
List<DiscoveredInternalCA> discoveredCAs = await FindAllCAClusterIssuers(client, ct);
if (discoveredCAs.Count == 0)
{
missing.Add("No CA-type ClusterIssuer found");
return new ComponentCheckResult(ComponentName, ComponentStatus.NotInstalled, details, missing);
}
// For each discovered CA, check that its Secret exists and whether
// it's included in the trust bundle.
foreach (DiscoveredInternalCA ca in discoveredCAs)
{
details.Add($"CA ClusterIssuer: {ca.IssuerName}");
bool secretExists = await CASecretExists(client, ca.SecretName, ct);
if (!secretExists)
{
missing.Add($"CA Secret '{ca.SecretName}' not found in cert-manager namespace");
}
else
{
details.Add($"CA Secret '{ca.SecretName}' present");
}
bool inTrustBundle = await IsInTrustBundle(client, ca.SecretName, ct);
if (!inTrustBundle)
{
missing.Add($"CA '{ca.IssuerName}' certificate not found in platform trust bundle");
}
else
{
details.Add($"CA '{ca.IssuerName}' certificate included in trust bundle");
}
}
// Build a configuration summary for all discovered CAs.
string caNames = string.Join(";", discoveredCAs.Select(c => $"{c.IssuerName}:{c.SecretName}"));
DiscoveredConfiguration config = new(
Version: null,
Namespace: "cert-manager",
HelmReleaseName: null,
Values: new Dictionary<string, string>
{
["caCount"] = discoveredCAs.Count.ToString(),
["cas"] = caNames,
// Keep backward compatibility — first CA as the primary.
["issuerName"] = discoveredCAs[0].IssuerName,
["secretName"] = discoveredCAs[0].SecretName,
["inTrustBundle"] = (!missing.Any(m => m.Contains("trust bundle"))).ToString()
});
if (missing.Count > 0)
{
return new ComponentCheckResult(ComponentName, ComponentStatus.Degraded, details, missing, config);
}
return new ComponentCheckResult(ComponentName, ComponentStatus.Installed, details, missing, config);
}
/// <summary>
/// Finds all CA-type ClusterIssuers on the cluster. A CA issuer has
/// spec.ca.secretName set (as opposed to ACME, SelfSigned, etc.).
/// We skip issuers that have the entkube.io/domains annotation — those
/// are domain CAs managed by the DomainCA component.
/// </summary>
private async Task<List<DiscoveredInternalCA>> FindAllCAClusterIssuers(Kubernetes client, CancellationToken ct)
{
List<DiscoveredInternalCA> result = new();
try
{
JsonElement issuers = await client.CustomObjects.ListClusterCustomObjectAsync<JsonElement>(
group: "cert-manager.io",
version: "v1",
plural: "clusterissuers",
cancellationToken: ct);
if (issuers.TryGetProperty("items", out JsonElement items))
{
foreach (JsonElement issuer in items.EnumerateArray())
{
// A CA issuer has spec.ca.secretName set.
if (issuer.TryGetProperty("spec", out JsonElement spec) &&
spec.TryGetProperty("ca", out JsonElement ca) &&
ca.TryGetProperty("secretName", out JsonElement secretNameEl))
{
string? name = issuer.GetProperty("metadata").GetProperty("name").GetString();
string? secretName = secretNameEl.GetString();
if (name is null || secretName is null)
{
continue;
}
// Skip domain CAs — they're managed by the DomainCA component.
bool isDomainCA = issuer.TryGetProperty("metadata", out JsonElement metadata) &&
metadata.TryGetProperty("annotations", out JsonElement annotations) &&
annotations.TryGetProperty("entkube.io/domains", out _);
if (isDomainCA)
{
continue;
}
result.Add(new DiscoveredInternalCA(name, secretName));
}
}
}
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to list ClusterIssuers while checking for internal CAs");
}
return result;
}
private async Task<bool> CASecretExists(Kubernetes client, string secretName, CancellationToken ct)
{
try
{
V1Secret secret = await client.CoreV1.ReadNamespacedSecretAsync(
secretName, "cert-manager", cancellationToken: ct);
return secret.Data?.ContainsKey("tls.crt") == true;
}
catch
{
return false;
}
}
private async Task<bool> IsInTrustBundle(Kubernetes client, string secretName, CancellationToken ct)
{
try
{
// Check if any Bundle CR references this CA's secret as a source.
JsonElement bundles = await client.CustomObjects.ListClusterCustomObjectAsync<JsonElement>(
group: "trust.cert-manager.io",
version: "v1alpha1",
plural: "bundles",
cancellationToken: ct);
if (bundles.TryGetProperty("items", out JsonElement items))
{
foreach (JsonElement bundle in items.EnumerateArray())
{
if (bundle.TryGetProperty("spec", out JsonElement spec) &&
spec.TryGetProperty("sources", out JsonElement sources))
{
foreach (JsonElement source in sources.EnumerateArray())
{
// Look for inlineSecret or secret sources referencing our CA.
if (source.TryGetProperty("secret", out JsonElement secretSource) &&
secretSource.TryGetProperty("name", out JsonElement nameEl) &&
nameEl.GetString() == secretName)
{
return true;
}
}
}
}
}
}
catch (Exception ex)
{
logger.LogDebug(ex, "Could not check trust bundle for CA reference");
}
return false;
}
private async Task<bool> 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 DiscoveredInternalCA(string IssuerName, string SecretName);

View File

@@ -1,639 +0,0 @@
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.InternalCA;
/// <summary>
/// Provisions or adopts an internal Certificate Authority using cert-manager.
/// The internal CA is a self-signed root that signs all platform-internal TLS
/// certificates (service-to-service mTLS, webhook certs, etc.). Its root
/// certificate is injected into the trust-manager Bundle so every workload
/// trusts it automatically.
///
/// The provisioning chain:
/// 1. Create a self-signed ClusterIssuer (bootstrap — signs the CA cert itself)
/// 2. Create a Certificate resource for the root CA (signed by the self-signed issuer)
/// 3. Create a CA ClusterIssuer that references the root CA secret
/// 4. Add the root CA certificate to the trust-manager Bundle
///
/// Adoption path:
/// 1. Discover an existing CA ClusterIssuer
/// 2. Read its root certificate from the referenced Secret
/// 3. Add it to the trust bundle (if not already there)
///
/// Configuration keys:
/// - "caName": Name for the CA Certificate and ClusterIssuer (default: "platform-internal-ca")
/// - "bundleName": Trust bundle to add the CA cert to (default: "platform-trust-bundle")
/// - "caOrganization": Organization field in the CA cert (default: "EntKube Platform")
/// - "caDurationDays": Validity period in days (default: "3650")
/// - "adoptExisting": "true" to adopt an existing CA instead of creating a new one
/// - "existingIssuerName": Name of existing CA ClusterIssuer to adopt
/// </summary>
public class InternalCAInstaller : IComponentInstaller
{
private readonly ILogger<InternalCAInstaller> logger;
private const string DefaultNamespace = "cert-manager";
private const string DefaultCAName = "platform-internal-ca";
private const string DefaultBundleName = "platform-trust-bundle";
public string ComponentName => "internal-ca";
public InternalCAInstaller(ILogger<InternalCAInstaller> logger)
{
this.logger = logger;
}
public async Task<InstallResult> InstallAsync(KubernetesCluster cluster, ComponentInstallOptions options, CancellationToken ct = default)
{
List<string> 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<string> { "Prerequisite check failed: cert-manager not found" });
}
// Read parameters.
string caName = DefaultCAName;
if (options.Parameters?.TryGetValue("caName", out string? caNameVal) == true
&& !string.IsNullOrEmpty(caNameVal))
{
caName = caNameVal;
}
string bundleName = DefaultBundleName;
if (options.Parameters?.TryGetValue("bundleName", out string? bundleNameVal) == true
&& !string.IsNullOrEmpty(bundleNameVal))
{
bundleName = bundleNameVal;
}
string organization = "EntKube Platform";
if (options.Parameters?.TryGetValue("caOrganization", out string? orgVal) == true
&& !string.IsNullOrEmpty(orgVal))
{
organization = orgVal;
}
int durationDays = 3650;
if (options.Parameters?.TryGetValue("caDurationDays", out string? durVal) == true &&
int.TryParse(durVal, out int parsed))
{
durationDays = parsed;
}
// Check if we're adopting an existing CA or creating a new one.
bool adoptExisting = options.Parameters?.TryGetValue("adoptExisting", out string? adoptVal) == true
&& adoptVal == "true";
if (adoptExisting)
{
return await AdoptExistingCA(client, options, bundleName, actions, ct);
}
// ─── Provision new internal CA ────────────────────────────────────
// Step 1: Create a self-signed ClusterIssuer (the bootstrap issuer that
// signs our root CA certificate — it's the only thing that's self-signed).
await CreateSelfSignedIssuer(client, ct);
actions.Add("Created self-signed ClusterIssuer 'selfsigned-bootstrap'");
// Step 2: Create the root CA Certificate (signed by the self-signed issuer).
// This produces a Secret containing the CA key pair.
string secretName = $"{caName}-secret";
string durationHours = $"{durationDays * 24}h";
await CreateCACertificate(client, caName, secretName, organization, durationHours, ct);
actions.Add($"Created root CA Certificate '{caName}'");
// Step 3: Create the CA ClusterIssuer that signs service certificates
// using the root CA key pair from the Secret.
await CreateCAClusterIssuer(client, caName, secretName, ct);
actions.Add($"Created CA ClusterIssuer '{caName}'");
// Step 4: Add the root CA certificate to the trust bundle so all
// workloads automatically trust certificates signed by this CA.
await AddCAToTrustBundle(client, bundleName, secretName, ct);
actions.Add($"Added root CA to Bundle '{bundleName}'");
logger.LogInformation("Internal CA '{CA}' provisioned on cluster {Cluster}",
caName, cluster.Name);
return new InstallResult(
Success: true,
ComponentName: ComponentName,
Message: "Internal CA provisioned and added to trust bundle",
Actions: actions);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to provision internal CA on cluster {Cluster}", cluster.Name);
actions.Add($"Error: {ex.Message}");
return new InstallResult(
Success: false,
ComponentName: ComponentName,
Message: $"Failed to provision internal CA: {ex.Message}",
Actions: actions);
}
}
/// <summary>
/// Reconfigures the internal CA. Supports updating the CA duration and
/// refreshing the trust bundle reference.
/// </summary>
public async Task<InstallResult> ConfigureAsync(KubernetesCluster cluster, ComponentConfiguration configuration, CancellationToken ct = default)
{
List<string> actions = new();
try
{
Kubernetes client = BuildClient(cluster);
string caName = DefaultCAName;
configuration.Values.TryGetValue("caName", out string? caNameVal);
if (!string.IsNullOrEmpty(caNameVal))
{
caName = caNameVal;
}
string bundleName = DefaultBundleName;
configuration.Values.TryGetValue("bundleName", out string? bundleVal);
if (!string.IsNullOrEmpty(bundleVal))
{
bundleName = bundleVal;
}
// Update CA Certificate duration if specified.
if (configuration.Values.TryGetValue("caDurationDays", out string? durVal) &&
int.TryParse(durVal, out int durationDays))
{
string durationHours = $"{durationDays * 24}h";
string secretName = $"{caName}-secret";
string organization = "EntKube Platform";
configuration.Values.TryGetValue("caOrganization", out string? orgVal);
if (!string.IsNullOrEmpty(orgVal))
{
organization = orgVal;
}
await CreateCACertificate(client, caName, secretName, organization, durationHours, ct);
actions.Add($"Updated CA Certificate duration to {durationDays} days");
// Refresh the trust bundle to pick up any renewed certificate.
await AddCAToTrustBundle(client, bundleName, secretName, ct);
actions.Add($"Refreshed root CA in Bundle '{bundleName}'");
}
return new InstallResult(
Success: true,
ComponentName: ComponentName,
Message: "Internal CA reconfigured",
Actions: actions);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to reconfigure internal CA on cluster {Cluster}", cluster.Name);
return new InstallResult(
Success: false,
ComponentName: ComponentName,
Message: $"Failed to reconfigure internal CA: {ex.Message}",
Actions: new List<string> { $"Error: {ex.Message}" });
}
}
/// <summary>
/// Uninstalls the internal CA by deleting the ClusterIssuer, root Certificate,
/// and bootstrap self-signed ClusterIssuer. The CA certificate remains in the
/// trust bundle until trust-manager is reconfigured.
/// </summary>
public async Task<InstallResult> UninstallAsync(KubernetesCluster cluster, ComponentInstallOptions options, CancellationToken ct = default)
{
List<string> actions = new();
try
{
Kubernetes client = BuildClient(cluster);
string caName = DefaultCAName;
if (options.Parameters?.TryGetValue("caName", out string? caNameVal) == true
&& !string.IsNullOrEmpty(caNameVal))
{
caName = caNameVal;
}
// Delete the CA ClusterIssuer.
try
{
await client.CustomObjects.DeleteClusterCustomObjectAsync(
"cert-manager.io", "v1", "clusterissuers", caName, cancellationToken: ct);
actions.Add($"Deleted ClusterIssuer '{caName}'");
}
catch (k8s.Autorest.HttpOperationException ex) when (ex.Response.StatusCode == System.Net.HttpStatusCode.NotFound)
{
// Already gone.
}
// Delete the bootstrap self-signed issuer.
try
{
await client.CustomObjects.DeleteClusterCustomObjectAsync(
"cert-manager.io", "v1", "clusterissuers", "selfsigned-bootstrap", cancellationToken: ct);
actions.Add("Deleted ClusterIssuer 'selfsigned-bootstrap'");
}
catch (k8s.Autorest.HttpOperationException ex) when (ex.Response.StatusCode == System.Net.HttpStatusCode.NotFound)
{
// Already gone.
}
logger.LogInformation("Internal CA '{CaName}' removed from cluster {Cluster}", caName, cluster.Name);
return new InstallResult(
Success: true,
ComponentName: ComponentName,
Message: $"Internal CA '{caName}' uninstalled",
Actions: actions);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to uninstall internal CA from cluster {Cluster}", cluster.Name);
return new InstallResult(
Success: false,
ComponentName: ComponentName,
Message: $"Failed to uninstall internal CA: {ex.Message}",
Actions: new List<string> { $"Error: {ex.Message}" });
}
}
private async Task<InstallResult> AdoptExistingCA(
Kubernetes client, ComponentInstallOptions options, string bundleName, List<string> actions, CancellationToken ct)
{
string? existingIssuerName = null;
options.Parameters?.TryGetValue("existingIssuerName", out existingIssuerName);
if (string.IsNullOrEmpty(existingIssuerName))
{
return new InstallResult(
Success: false,
ComponentName: ComponentName,
Message: "existingIssuerName is required when adopting an existing CA",
Actions: actions);
}
// Read the existing ClusterIssuer to find its CA Secret.
string? secretName = await GetIssuerSecretName(client, existingIssuerName, ct);
if (secretName is null)
{
return new InstallResult(
Success: false,
ComponentName: ComponentName,
Message: $"ClusterIssuer '{existingIssuerName}' not found or is not a CA issuer",
Actions: actions);
}
actions.Add($"Discovered existing CA ClusterIssuer '{existingIssuerName}'");
actions.Add($"Read root CA certificate from Secret '{secretName}'");
// Add the CA cert to the trust bundle.
await AddCAToTrustBundle(client, bundleName, secretName, ct);
actions.Add($"Added root CA to Bundle '{bundleName}'");
logger.LogInformation("Adopted existing CA '{Issuer}' on cluster", existingIssuerName);
return new InstallResult(
Success: true,
ComponentName: ComponentName,
Message: "Existing internal CA adopted and added to trust bundle",
Actions: actions);
}
private async Task CreateSelfSignedIssuer(Kubernetes client, CancellationToken ct)
{
Dictionary<string, object> issuer = new()
{
["apiVersion"] = "cert-manager.io/v1",
["kind"] = "ClusterIssuer",
["metadata"] = new Dictionary<string, object>
{
["name"] = "selfsigned-bootstrap"
},
["spec"] = new Dictionary<string, object>
{
["selfSigned"] = new Dictionary<string, object>()
}
};
await ApplyClusterCustomObject(client, "cert-manager.io", "v1", "clusterissuers", "selfsigned-bootstrap", issuer, ct);
}
private async Task CreateCACertificate(
Kubernetes client, string caName, string secretName, string organization, string duration, CancellationToken ct)
{
Dictionary<string, object> certificate = new()
{
["apiVersion"] = "cert-manager.io/v1",
["kind"] = "Certificate",
["metadata"] = new Dictionary<string, object>
{
["name"] = caName,
["namespace"] = DefaultNamespace
},
["spec"] = new Dictionary<string, object>
{
["isCA"] = true,
["duration"] = duration,
["secretName"] = secretName,
["commonName"] = $"{caName} Root CA",
["subject"] = new Dictionary<string, object>
{
["organizations"] = new List<string> { organization }
},
["privateKey"] = new Dictionary<string, object>
{
["algorithm"] = "ECDSA",
["size"] = 256
},
["issuerRef"] = new Dictionary<string, object>
{
["name"] = "selfsigned-bootstrap",
["kind"] = "ClusterIssuer",
["group"] = "cert-manager.io"
}
}
};
await ApplyNamespacedCustomObject(client, "cert-manager.io", "v1", DefaultNamespace, "certificates", caName, certificate, ct);
}
private async Task CreateCAClusterIssuer(Kubernetes client, string caName, string secretName, CancellationToken ct)
{
Dictionary<string, object> issuer = new()
{
["apiVersion"] = "cert-manager.io/v1",
["kind"] = "ClusterIssuer",
["metadata"] = new Dictionary<string, object>
{
["name"] = caName
},
["spec"] = new Dictionary<string, object>
{
["ca"] = new Dictionary<string, object>
{
["secretName"] = secretName
}
}
};
await ApplyClusterCustomObject(client, "cert-manager.io", "v1", "clusterissuers", caName, issuer, ct);
}
private async Task AddCAToTrustBundle(Kubernetes client, string bundleName, string secretName, CancellationToken ct)
{
// Add the CA Secret as a source in the trust-manager Bundle.
// This makes the CA cert available to all workloads via the bundle ConfigMap.
try
{
JsonElement existing = await client.CustomObjects.GetClusterCustomObjectAsync<JsonElement>(
"trust.cert-manager.io", "v1alpha1", "bundles", bundleName, ct);
// Read current sources and add our secret if not already present.
List<object> sources = new();
bool alreadyPresent = false;
if (existing.TryGetProperty("spec", out JsonElement spec) &&
spec.TryGetProperty("sources", out JsonElement sourcesEl))
{
foreach (JsonElement source in sourcesEl.EnumerateArray())
{
// Preserve existing sources as raw JSON.
sources.Add(JsonSerializer.Deserialize<object>(source.GetRawText())!);
if (source.TryGetProperty("secret", out JsonElement secretSource) &&
secretSource.TryGetProperty("name", out JsonElement nameEl) &&
nameEl.GetString() == secretName)
{
alreadyPresent = true;
}
}
}
if (!alreadyPresent)
{
sources.Add(new Dictionary<string, object>
{
["secret"] = new Dictionary<string, object>
{
["name"] = secretName,
["key"] = "tls.crt"
}
});
// Patch the Bundle with the updated sources.
Dictionary<string, object> patch = new()
{
["apiVersion"] = "trust.cert-manager.io/v1alpha1",
["kind"] = "Bundle",
["metadata"] = new Dictionary<string, object>
{
["name"] = bundleName
},
["spec"] = new Dictionary<string, object>
{
["sources"] = sources
}
};
await client.CustomObjects.PatchClusterCustomObjectAsync(
body: new V1Patch(patch, V1Patch.PatchType.MergePatch),
group: "trust.cert-manager.io",
version: "v1alpha1",
plural: "bundles",
name: bundleName,
cancellationToken: ct);
}
}
catch (k8s.Autorest.HttpOperationException ex) when (ex.Response.StatusCode == System.Net.HttpStatusCode.NotFound)
{
// Bundle doesn't exist — create it with the CA as the only source.
Dictionary<string, object> bundle = new()
{
["apiVersion"] = "trust.cert-manager.io/v1alpha1",
["kind"] = "Bundle",
["metadata"] = new Dictionary<string, object>
{
["name"] = bundleName
},
["spec"] = new Dictionary<string, object>
{
["sources"] = new List<object>
{
new Dictionary<string, object>
{
["useDefaultCAs"] = true
},
new Dictionary<string, object>
{
["secret"] = new Dictionary<string, object>
{
["name"] = secretName,
["key"] = "tls.crt"
}
}
},
["target"] = new Dictionary<string, object>
{
["configMap"] = new Dictionary<string, object>
{
["key"] = "ca-certificates.crt"
}
}
}
};
await client.CustomObjects.CreateClusterCustomObjectAsync(
body: bundle,
group: "trust.cert-manager.io",
version: "v1alpha1",
plural: "bundles",
cancellationToken: ct);
}
}
private async Task<string?> GetIssuerSecretName(Kubernetes client, string issuerName, CancellationToken ct)
{
try
{
JsonElement issuer = await client.CustomObjects.GetClusterCustomObjectAsync<JsonElement>(
"cert-manager.io", "v1", "clusterissuers", issuerName, ct);
if (issuer.TryGetProperty("spec", out JsonElement spec) &&
spec.TryGetProperty("ca", out JsonElement ca) &&
ca.TryGetProperty("secretName", out JsonElement secretName))
{
return secretName.GetString();
}
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to read ClusterIssuer '{Issuer}'", issuerName);
}
return null;
}
private async Task<bool> CertManagerIsPresent(Kubernetes client, CancellationToken ct)
{
try
{
await client.ApiextensionsV1.ReadCustomResourceDefinitionAsync(
"certificates.cert-manager.io", cancellationToken: ct);
return true;
}
catch
{
return false;
}
}
private static async Task ApplyClusterCustomObject(
Kubernetes client, string group, string version, string plural, string name,
Dictionary<string, object> body, CancellationToken ct)
{
try
{
await client.CustomObjects.PatchClusterCustomObjectAsync(
body: new V1Patch(body, V1Patch.PatchType.MergePatch),
group: group,
version: version,
plural: plural,
name: name,
cancellationToken: ct);
}
catch (k8s.Autorest.HttpOperationException ex) when (ex.Response.StatusCode == System.Net.HttpStatusCode.NotFound)
{
await client.CustomObjects.CreateClusterCustomObjectAsync(
body: body,
group: group,
version: version,
plural: plural,
cancellationToken: ct);
}
}
private static async Task ApplyNamespacedCustomObject(
Kubernetes client, string group, string version, string ns, string plural, string name,
Dictionary<string, object> body, CancellationToken ct)
{
try
{
await client.CustomObjects.PatchNamespacedCustomObjectAsync(
body: new V1Patch(body, V1Patch.PatchType.MergePatch),
group: group,
version: version,
namespaceParameter: ns,
plural: plural,
name: name,
cancellationToken: ct);
}
catch (k8s.Autorest.HttpOperationException ex) when (ex.Response.StatusCode == System.Net.HttpStatusCode.NotFound)
{
await client.CustomObjects.CreateNamespacedCustomObjectAsync(
body: body,
group: group,
version: version,
namespaceParameter: ns,
plural: plural,
cancellationToken: ct);
}
}
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);
}
}

View File

@@ -1,181 +0,0 @@
using System.Text;
using EntKube.Clusters.Domain;
using k8s;
using k8s.Models;
using Microsoft.Extensions.Logging;
namespace EntKube.Clusters.Features.AdoptCluster.Components.Istio;
/// <summary>
/// Checks whether Istio service mesh with Gateway API support is installed.
/// The Terraform bootstrap installs three layers:
/// 1. Gateway API CRDs (standard + experimental)
/// 2. istio-base (Istio CRDs + namespace)
/// 3. istiod (control plane with PILOT_ENABLE_GATEWAY_API=true)
///
/// This check verifies all three layers are present and healthy.
/// </summary>
public class IstioCheck : IAdoptionCheck
{
private readonly ILogger<IstioCheck> logger;
public string ComponentName => "istio";
public string DisplayName => "Istio Service Mesh + Gateway API";
public IstioCheck(ILogger<IstioCheck> logger)
{
this.logger = logger;
}
public async Task<ComponentCheckResult> CheckAsync(KubernetesCluster cluster, CancellationToken ct = default)
{
Kubernetes client = BuildClient(cluster);
List<string> details = new();
List<string> missing = new();
// Check 1: Gateway API CRDs installed (gateways.gateway.networking.k8s.io)
bool gatewayApiCrd = await CrdExists(client, "gateways.gateway.networking.k8s.io", ct);
if (gatewayApiCrd)
{
details.Add("Gateway API CRDs present");
}
else
{
missing.Add("Gateway API CRDs not installed (gateways.gateway.networking.k8s.io)");
}
// Check 2: istio-system namespace with istiod pods running
List<string> istiodPods = await FindIstiodPods(client, ct);
if (istiodPods.Count > 0)
{
details.AddRange(istiodPods.Select(p => $"Pod running: {p}"));
}
else
{
missing.Add("No istiod pods found in istio-system namespace");
}
// Check 3: Istio CRDs (VirtualService or Gateway) — confirms istio-base installed
bool istioCrd = await CrdExists(client, "virtualservices.networking.istio.io", ct);
if (istioCrd)
{
details.Add("Istio CRDs present (networking.istio.io)");
}
else
{
missing.Add("Istio base CRDs not installed (networking.istio.io)");
}
// Determine status
if (istiodPods.Count == 0 && !istioCrd)
{
return new ComponentCheckResult(ComponentName, ComponentStatus.NotInstalled, details, missing);
}
// Discover configuration from running pods
string? version = await GetIstiodVersion(client, ct);
DiscoveredConfiguration config = new(
Version: version,
Namespace: "istio-system",
HelmReleaseName: "istiod",
Values: new Dictionary<string, string>
{
["pilotReplicas"] = istiodPods.Count.ToString(),
["gatewayApiEnabled"] = gatewayApiCrd.ToString(),
["istioCrdsInstalled"] = istioCrd.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<string?> GetIstiodVersion(Kubernetes client, CancellationToken ct)
{
try
{
V1PodList podList = await client.CoreV1.ListNamespacedPodAsync(
"istio-system", labelSelector: "app=istiod", 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<List<string>> FindIstiodPods(Kubernetes client, CancellationToken ct)
{
List<string> pods = new();
try
{
V1PodList podList = await client.CoreV1.ListNamespacedPodAsync(
"istio-system",
labelSelector: "app=istiod",
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("istio-system namespace not found");
}
catch (Exception ex)
{
logger.LogWarning(ex, "Error checking for istiod pods");
}
return pods;
}
private async Task<bool> CrdExists(Kubernetes client, string crdName, CancellationToken ct)
{
try
{
await client.ApiextensionsV1.ReadCustomResourceDefinitionAsync(crdName, 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);
}
}

View File

@@ -1,297 +0,0 @@
using System.Text;
using EntKube.Clusters.Domain;
using k8s;
using Microsoft.Extensions.Logging;
namespace EntKube.Clusters.Features.AdoptCluster.Components.Istio;
/// <summary>
/// Installs Istio with Gateway API support on a cluster. Mirrors the Terraform
/// bootstrap modules: gateway_api → istio_base → istio_control_plane (istiod).
///
/// Deploys in order:
/// 1. Gateway API CRDs (standard-install.yaml from upstream)
/// 2. istio-base Helm chart (Istio CRDs)
/// 3. istiod Helm chart (control plane with Gateway API enabled)
///
/// Configuration matches the Terraform:
/// - PILOT_ENABLE_GATEWAY_API=true
/// - PILOT_ENABLE_GATEWAY_API_STATUS=true
/// - accessLogFile=/dev/stdout, JSON encoding
/// - enablePrometheusMerge=true
/// </summary>
public class IstioInstaller : IComponentInstaller
{
private readonly ILogger<IstioInstaller> logger;
private const string DefaultIstioVersion = "1.24.0";
private const string DefaultGatewayApiVersion = "1.2.0";
private const string DefaultNamespace = "istio-system";
private const string HelmRepo = "https://istio-release.storage.googleapis.com/charts";
public string ComponentName => "istio";
public IstioInstaller(ILogger<IstioInstaller> logger)
{
this.logger = logger;
}
public async Task<InstallResult> InstallAsync(KubernetesCluster cluster, ComponentInstallOptions options, CancellationToken ct = default)
{
string istioVersion = options.Version ?? DefaultIstioVersion;
string gatewayApiVersion = options.Parameters?.GetValueOrDefault("gatewayApiVersion") ?? DefaultGatewayApiVersion;
string targetNamespace = options.Namespace ?? DefaultNamespace;
List<string> actions = new();
string tempKubeConfig = Path.Combine(Path.GetTempPath(), $"entkube-istio-{Guid.NewGuid()}.kubeconfig");
try
{
await File.WriteAllTextAsync(tempKubeConfig, cluster.KubeConfig, ct);
// Step 1: Install Gateway API CRDs (must come before Istio)
string gatewayApiResult = await RunCommand("kubectl",
$"apply --server-side --force-conflicts --kubeconfig \"{tempKubeConfig}\" --context \"{cluster.ContextName}\" " +
$"-f https://github.com/kubernetes-sigs/gateway-api/releases/download/v{gatewayApiVersion}/standard-install.yaml",
ct);
actions.Add($"Applied Gateway API CRDs v{gatewayApiVersion}");
// Step 2: Create istio-system namespace
Kubernetes client = BuildClient(cluster);
await EnsureNamespaceExists(client, targetNamespace, ct);
actions.Add($"Ensured namespace '{targetNamespace}' exists");
// Step 3: Install istio-base (CRDs). The --force-conflicts flag is needed
// because the istiod-default-validator ValidatingWebhookConfiguration is
// created by istio-base but its failurePolicy field gets adopted by the
// running pilot-discovery controller. Without --force-conflicts, Helm's
// server-side apply refuses to overwrite the field owned by pilot-discovery.
await RunCommand("helm",
$"upgrade --install istio-base base --repo {HelmRepo} --version {istioVersion} " +
$"--namespace {targetNamespace} --kubeconfig \"{tempKubeConfig}\" --kube-context \"{cluster.ContextName}\" " +
$"--force-conflicts " +
$"--wait --timeout 5m",
ct);
actions.Add($"Helm install istio-base v{istioVersion}");
// Step 4: Install istiod with Gateway API enabled. Same --force-conflicts
// needed here because istiod also manages webhook and CRD fields that
// overlap with the running pilot-discovery controller's field ownership.
await RunCommand("helm",
$"upgrade --install istiod istiod --repo {HelmRepo} --version {istioVersion} " +
$"--namespace {targetNamespace} --kubeconfig \"{tempKubeConfig}\" --kube-context \"{cluster.ContextName}\" " +
$"--force-conflicts " +
$"--set pilot.env.PILOT_ENABLE_GATEWAY_API=true " +
$"--set pilot.env.PILOT_ENABLE_GATEWAY_API_STATUS=true " +
$"--set pilot.env.PILOT_ENABLE_GATEWAY_API_DEPLOYMENT_CONTROLLER=false " +
$"--set meshConfig.accessLogFile=/dev/stdout " +
$"--set meshConfig.accessLogEncoding=JSON " +
$"--set meshConfig.enablePrometheusMerge=true " +
$"--wait --timeout 10m",
ct);
actions.Add($"Helm install istiod v{istioVersion} (Gateway API enabled)");
logger.LogInformation("Istio {Version} with Gateway API installed on cluster {Cluster}",
istioVersion, cluster.Name);
return new InstallResult(
Success: true,
ComponentName: ComponentName,
Message: $"Istio {istioVersion} with Gateway API v{gatewayApiVersion} installed",
Actions: actions);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to install Istio on cluster {Cluster}", cluster.Name);
actions.Add($"Error: {ex.Message}");
return new InstallResult(
Success: false,
ComponentName: ComponentName,
Message: $"Failed to install Istio: {ex.Message}",
Actions: actions);
}
finally
{
if (File.Exists(tempKubeConfig))
{
File.Delete(tempKubeConfig);
}
}
}
private async Task EnsureNamespaceExists(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)
{
k8s.Models.V1Namespace ns = new()
{
Metadata = new k8s.Models.V1ObjectMeta { Name = namespaceName }
};
await client.CoreV1.CreateNamespaceAsync(ns, cancellationToken: ct);
}
}
private async Task<string> 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);
}
/// <summary>
/// Reconfigures Istio. Supports:
/// - "pilotReplicas": number of istiod replicas
/// - "enableGatewayApi": true/false for PILOT_ENABLE_GATEWAY_API
/// </summary>
public async Task<InstallResult> ConfigureAsync(KubernetesCluster cluster, ComponentConfiguration configuration, CancellationToken ct = default)
{
string targetNamespace = configuration.Namespace ?? "istio-system";
List<string> actions = new();
string tempKubeConfig = Path.Combine(Path.GetTempPath(), $"entkube-istio-cfg-{Guid.NewGuid()}.kubeconfig");
try
{
await File.WriteAllTextAsync(tempKubeConfig, cluster.KubeConfig, ct);
List<string> setFlags = new();
if (configuration.Values.TryGetValue("pilotReplicas", out string? replicas))
{
setFlags.Add($"--set pilot.autoscaleMin={replicas}");
}
if (configuration.Values.TryGetValue("enableGatewayApi", out string? gwApi))
{
setFlags.Add($"--set pilot.env.PILOT_ENABLE_GATEWAY_API={gwApi}");
}
await RunCommand("helm",
$"upgrade istiod istiod --repo {HelmRepo} " +
$"--namespace {targetNamespace} " +
$"--kubeconfig \"{tempKubeConfig}\" --kube-context \"{cluster.ContextName}\" " +
$"--force-conflicts " +
$"--reuse-values {string.Join(" ", setFlags)} --wait --timeout 5m",
ct);
actions.Add($"Reconfigured Istio: {string.Join(", ", configuration.Values.Select(kv => $"{kv.Key}={kv.Value}"))}");
return new InstallResult(
Success: true,
ComponentName: ComponentName,
Message: "Istio reconfigured",
Actions: actions);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to reconfigure Istio on cluster {Cluster}", cluster.Name);
return new InstallResult(
Success: false,
ComponentName: ComponentName,
Message: $"Failed to reconfigure Istio: {ex.Message}",
Actions: new List<string> { $"Error: {ex.Message}" });
}
finally
{
if (File.Exists(tempKubeConfig))
{
File.Delete(tempKubeConfig);
}
}
}
/// <summary>
/// Uninstalls Istio from the cluster by removing istiod and istio-base
/// Helm releases in reverse order.
/// </summary>
public async Task<InstallResult> UninstallAsync(KubernetesCluster cluster, ComponentInstallOptions options, CancellationToken ct = default)
{
string targetNamespace = options.Namespace ?? DefaultNamespace;
List<string> actions = new();
string tempKubeConfig = Path.Combine(Path.GetTempPath(), $"entkube-istio-rm-{Guid.NewGuid()}.kubeconfig");
try
{
await File.WriteAllTextAsync(tempKubeConfig, cluster.KubeConfig, ct);
// Uninstall in reverse order: istiod first, then istio-base.
await RunCommand("helm",
$"uninstall istiod --namespace {targetNamespace} " +
$"--kubeconfig \"{tempKubeConfig}\" --kube-context \"{cluster.ContextName}\" " +
$"--wait --timeout 5m",
ct);
actions.Add("Helm uninstall istiod");
await RunCommand("helm",
$"uninstall istio-base --namespace {targetNamespace} " +
$"--kubeconfig \"{tempKubeConfig}\" --kube-context \"{cluster.ContextName}\" " +
$"--wait --timeout 5m",
ct);
actions.Add("Helm uninstall istio-base");
logger.LogInformation("Istio uninstalled from cluster {Cluster}", cluster.Name);
return new InstallResult(
Success: true,
ComponentName: ComponentName,
Message: "Istio uninstalled successfully",
Actions: actions);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to uninstall Istio from cluster {Cluster}", cluster.Name);
return new InstallResult(
Success: false,
ComponentName: ComponentName,
Message: $"Failed to uninstall Istio: {ex.Message}",
Actions: actions.Concat(new[] { $"Error: {ex.Message}" }).ToList());
}
finally
{
if (File.Exists(tempKubeConfig))
{
File.Delete(tempKubeConfig);
}
}
}
}

View File

@@ -1,293 +0,0 @@
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.Keycloak;
/// <summary>
/// Checks whether Keycloak is installed and healthy on the cluster.
/// Keycloak provides identity and access management for the platform,
/// with per-tenant realms, BankID integration, and custom theming.
///
/// Discovery checks:
/// 1. Keycloak Operator pods running in the keycloak namespace
/// 2. Keycloak CR (the managed instance)
/// 3. Available realms (via Admin API or CR annotations)
/// 4. BankID plugin presence
/// 5. Ingress endpoint for the login UI
/// </summary>
public class KeycloakCheck : IAdoptionCheck
{
private readonly ILogger<KeycloakCheck> logger;
private const string DefaultNamespace = "keycloak";
public string ComponentName => "keycloak";
public string DisplayName => "Keycloak (Identity & Access Management)";
public KeycloakCheck(ILogger<KeycloakCheck> logger)
{
this.logger = logger;
}
public async Task<ComponentCheckResult> CheckAsync(KubernetesCluster cluster, CancellationToken ct = default)
{
Kubernetes client = BuildClient(cluster);
List<string> details = new();
List<string> missing = new();
// Check 1: Keycloak pods running in the expected namespace.
List<string> keycloakPods = await FindKeycloakPods(client, ct);
if (keycloakPods.Count == 0)
{
missing.Add("No Keycloak pods found in 'keycloak' namespace");
return new ComponentCheckResult(ComponentName, ComponentStatus.NotInstalled, details, missing);
}
details.AddRange(keycloakPods.Select(p => $"Keycloak pod: {p}"));
// Check 2: Detect the Keycloak CR (operator-managed instance).
string? keycloakVersion = await DetectKeycloakCR(client, ct);
if (keycloakVersion is not null)
{
details.Insert(0, $"Keycloak {keycloakVersion} detected (operator in namespace '{DefaultNamespace}')");
}
// Check 3: Detect ingress/endpoint.
string? endpoint = await DetectEndpoint(client, ct);
if (endpoint is not null)
{
details.Add($"Endpoint: {endpoint}");
}
// Check 4: Detect realms from KeycloakRealmImport CRs or annotations.
List<string> realms = await DetectRealms(client, ct);
if (realms.Count > 0)
{
details.Add($"Realms: {string.Join(", ", realms)}");
}
// Check 5: Check for BankID plugin (look for the plugin JAR in a ConfigMap or volume).
string? bankIdVersion = await DetectBankIdPlugin(client, ct);
if (bankIdVersion is not null)
{
details.Add($"BankID plugin version {bankIdVersion} installed");
}
// Build discovered configuration.
Dictionary<string, string> values = new()
{
["realms"] = string.Join(",", realms)
};
if (endpoint is not null)
{
values["endpoint"] = endpoint;
}
if (bankIdVersion is not null)
{
values["bankIdPluginVersion"] = bankIdVersion;
}
DiscoveredConfiguration config = new(
Version: keycloakVersion,
Namespace: DefaultNamespace,
HelmReleaseName: "keycloak-operator",
Values: values);
return new ComponentCheckResult(ComponentName, ComponentStatus.Installed, details, missing, config);
}
private async Task<List<string>> FindKeycloakPods(Kubernetes client, CancellationToken ct)
{
List<string> pods = new();
try
{
V1PodList podList = await client.CoreV1.ListNamespacedPodAsync(
DefaultNamespace,
labelSelector: "app.kubernetes.io/name=keycloak",
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("Keycloak namespace not found");
}
catch (Exception ex)
{
logger.LogWarning(ex, "Error checking for Keycloak pods");
}
return pods;
}
private async Task<string?> DetectKeycloakCR(Kubernetes client, CancellationToken ct)
{
try
{
JsonElement result = await client.CustomObjects.ListNamespacedCustomObjectAsync<JsonElement>(
group: "k8s.keycloak.org",
version: "v2alpha1",
namespaceParameter: DefaultNamespace,
plural: "keycloaks",
cancellationToken: ct);
if (result.TryGetProperty("items", out JsonElement items) && items.GetArrayLength() > 0)
{
JsonElement first = items[0];
if (first.TryGetProperty("spec", out JsonElement spec) &&
spec.TryGetProperty("image", out JsonElement imageEl))
{
// Extract version from image tag (e.g., quay.io/keycloak/keycloak:26.0.0).
string? image = imageEl.GetString();
if (image is not null && image.Contains(':'))
{
return image.Split(':').Last();
}
}
}
}
catch (Exception ex)
{
logger.LogDebug(ex, "Could not detect Keycloak CR");
}
return null;
}
private async Task<string?> DetectEndpoint(Kubernetes client, CancellationToken ct)
{
try
{
V1IngressList ingresses = await client.NetworkingV1.ListNamespacedIngressAsync(
DefaultNamespace, cancellationToken: ct);
foreach (V1Ingress ingress in ingresses.Items)
{
if (ingress.Spec?.Rules is not null)
{
foreach (V1IngressRule rule in ingress.Spec.Rules)
{
if (rule.Host is not null)
{
return rule.Host;
}
}
}
}
}
catch (Exception ex)
{
logger.LogDebug(ex, "Could not detect Keycloak ingress");
}
return null;
}
private async Task<List<string>> DetectRealms(Kubernetes client, CancellationToken ct)
{
List<string> realms = new() { "master" };
try
{
// Check for KeycloakRealmImport CRs.
JsonElement result = await client.CustomObjects.ListNamespacedCustomObjectAsync<JsonElement>(
group: "k8s.keycloak.org",
version: "v2alpha1",
namespaceParameter: DefaultNamespace,
plural: "keycloakrealmimports",
cancellationToken: ct);
if (result.TryGetProperty("items", out JsonElement items))
{
foreach (JsonElement item in items.EnumerateArray())
{
if (item.TryGetProperty("spec", out JsonElement spec) &&
spec.TryGetProperty("realm", out JsonElement realmSpec) &&
realmSpec.TryGetProperty("realm", out JsonElement realmName))
{
string? name = realmName.GetString();
if (name is not null && name != "master")
{
realms.Add(name);
}
}
}
}
}
catch (Exception ex)
{
logger.LogDebug(ex, "Could not detect Keycloak realms");
}
return realms;
}
private async Task<string?> DetectBankIdPlugin(Kubernetes client, CancellationToken ct)
{
try
{
// BankID plugin is deployed as a ConfigMap or volume mount.
// Look for a ConfigMap with the bankid plugin label.
V1ConfigMapList configMaps = await client.CoreV1.ListNamespacedConfigMapAsync(
DefaultNamespace,
labelSelector: "entkube.io/component=keycloak-bankid",
cancellationToken: ct);
if (configMaps.Items.Count > 0)
{
V1ConfigMap cm = configMaps.Items[0];
if (cm.Metadata.Annotations?.TryGetValue("entkube.io/plugin-version", out string? version) == true)
{
return version;
}
return "unknown";
}
}
catch (Exception ex)
{
logger.LogDebug(ex, "Could not detect BankID plugin");
}
return null;
}
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);
}
}

View File

@@ -1,191 +0,0 @@
using System.Text;
using EntKube.Clusters.Domain;
using k8s;
using k8s.Models;
using Microsoft.Extensions.Logging;
namespace EntKube.Clusters.Features.AdoptCluster.Components.Kyverno;
/// <summary>
/// Checks whether Kyverno is installed and healthy on the cluster. Looks for
/// Kyverno pods in well-known namespaces and verifies they're in Running state.
/// This mirrors the bootstrap/kyverno_trust Terraform module which installs
/// Kyverno with HA (2 replicas admission-controller, 2 background-controller).
/// </summary>
public class KyvernoCheck : IAdoptionCheck
{
private readonly ILogger<KyvernoCheck> logger;
public string ComponentName => "kyverno";
public string DisplayName => "Kyverno Policy Engine";
public KyvernoCheck(ILogger<KyvernoCheck> logger)
{
this.logger = logger;
}
public async Task<ComponentCheckResult> CheckAsync(KubernetesCluster cluster, CancellationToken ct = default)
{
// Build a client from the cluster's stored kubeconfig.
logger.LogWarning("[DIAG] KyvernoCheck.CheckAsync starting for cluster {ClusterId}, context={Context}", cluster.Id, cluster.ContextName);
Kubernetes client = BuildClient(cluster);
List<string> details = new();
List<string> missing = new();
string? discoveredNamespace = null;
// Look for Kyverno pods in the expected namespaces. Kyverno is typically
// in "kyverno" but some installations use "kyverno-system".
(List<string> runningPods, List<string> podLabels, string? foundNamespace, string? imageVersion) = await FindKyvernoPods(client, ct);
discoveredNamespace = foundNamespace;
logger.LogWarning("[DIAG] KyvernoCheck: Found {PodCount} pods, namespace={Ns}, labels=[{Labels}], version={Version}",
runningPods.Count, foundNamespace ?? "none", string.Join(",", podLabels), imageVersion ?? "none");
foreach (string pod in runningPods)
{
logger.LogWarning("[DIAG] KyvernoCheck pod: {PodName}", pod);
}
if (runningPods.Count == 0)
{
// No Kyverno pods found — component is not installed.
missing.Add("No Kyverno pods found in kyverno or kyverno-system namespace");
logger.LogWarning("[DIAG] KyvernoCheck returning NotInstalled — no pods found");
return new ComponentCheckResult(ComponentName, ComponentStatus.NotInstalled, details, missing);
}
details.AddRange(runningPods.Select(p => $"Pod running: {p}"));
// Check if we have the expected controller pods. A healthy Kyverno
// has at least an admission controller and a background controller.
// We check both the pod name (common pattern) and the standard
// app.kubernetes.io/component label which Kyverno 3.x always sets,
// regardless of the Helm release name used during installation.
bool hasAdmission = runningPods.Any(p => p.Contains("admission", StringComparison.OrdinalIgnoreCase))
|| podLabels.Any(l => l == "admission-controller");
bool hasBackground = runningPods.Any(p => p.Contains("background", StringComparison.OrdinalIgnoreCase))
|| podLabels.Any(l => l == "background-controller");
if (!hasAdmission)
{
missing.Add("Kyverno admission controller pod not found");
}
if (!hasBackground)
{
missing.Add("Kyverno background controller pod not found");
}
// Build discovered configuration so the platform knows what's running.
int admissionReplicas = runningPods.Count(p => p.Contains("admission", StringComparison.OrdinalIgnoreCase));
int backgroundReplicas = runningPods.Count(p => p.Contains("background", StringComparison.OrdinalIgnoreCase));
DiscoveredConfiguration config = new(
Version: imageVersion,
Namespace: discoveredNamespace,
HelmReleaseName: "kyverno",
Values: new Dictionary<string, string>
{
["admissionReplicas"] = admissionReplicas.ToString(),
["backgroundReplicas"] = backgroundReplicas.ToString(),
["totalPods"] = runningPods.Count.ToString()
});
// If pods exist but key controllers are missing, it's degraded.
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<(List<string> Pods, List<string> ComponentLabels, string? Namespace, string? Version)> FindKyvernoPods(Kubernetes client, CancellationToken ct)
{
List<string> pods = new();
List<string> componentLabels = new();
string? foundNamespace = null;
string? version = null;
// Kyverno can be installed in any namespace — common choices include
// "kyverno", "kyverno-system", or even "cert-manager" depending on
// the Terraform/Helm configuration. Instead of guessing namespaces,
// we search ALL namespaces for pods whose name contains "kyverno".
try
{
logger.LogWarning("[DIAG] KyvernoCheck: Listing kyverno pods across all namespaces");
V1PodList podList = await client.CoreV1.ListPodForAllNamespacesAsync(
cancellationToken: ct);
foreach (V1Pod pod in podList.Items)
{
// Only consider pods whose name contains "kyverno" — this
// reliably identifies Kyverno components regardless of namespace
// or Helm release naming.
if (!pod.Metadata.Name.Contains("kyverno", StringComparison.OrdinalIgnoreCase))
{
continue;
}
logger.LogWarning("[DIAG] KyvernoCheck: Pod {Name} ns={Ns} phase={Phase}",
pod.Metadata.Name, pod.Metadata.NamespaceProperty, pod.Status?.Phase ?? "unknown");
if (pod.Status?.Phase == "Running" && !pods.Contains(pod.Metadata.Name))
{
pods.Add(pod.Metadata.Name);
foundNamespace ??= pod.Metadata.NamespaceProperty;
// Collect the app.kubernetes.io/component label
// (e.g. "admission-controller", "background-controller")
// for reliable controller type detection.
if (pod.Metadata.Labels?.TryGetValue("app.kubernetes.io/component", out string? component) == true
&& component is not null
&& !componentLabels.Contains(component))
{
componentLabels.Add(component);
}
// Extract version from container image tag.
if (version is null && pod.Spec?.Containers?.Count > 0)
{
string? image = pod.Spec.Containers[0].Image;
if (image?.Contains(':') == true)
{
version = image.Split(':').Last().TrimStart('v');
}
}
}
}
logger.LogWarning("[DIAG] KyvernoCheck: Found {Count} kyverno pods in namespace {Ns}",
pods.Count, foundNamespace ?? "none");
}
catch (Exception ex)
{
logger.LogWarning(ex, "Error searching for Kyverno pods across namespaces");
}
return (pods, componentLabels, foundNamespace, version);
}
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);
}
}

View File

@@ -1,365 +0,0 @@
using System.Text;
using EntKube.Clusters.Domain;
using k8s;
using Microsoft.Extensions.Logging;
namespace EntKube.Clusters.Features.AdoptCluster.Components.Kyverno;
/// <summary>
/// Installs Kyverno on a cluster using Helm. This mirrors the bootstrap/kyverno_trust
/// Terraform module which deploys Kyverno with:
/// - 2 admission controller replicas with PDB
/// - 2 background controller replicas with PDB
/// - CRDs installed
/// - Webhook timeout of 5s and failurePolicy=Ignore (for Gardener compatibility)
/// - Extra RBAC for secrets (required by generate-clone policies)
///
/// The installer is idempotent — if Kyverno is already installed, it upgrades.
/// </summary>
public class KyvernoInstaller : IComponentInstaller
{
private readonly ILogger<KyvernoInstaller> logger;
private const string DefaultVersion = "3.8.0";
private const string DefaultNamespace = "kyverno";
private const string HelmRepo = "https://kyverno.github.io/kyverno/";
public string ComponentName => "kyverno";
public KyvernoInstaller(ILogger<KyvernoInstaller> logger)
{
this.logger = logger;
}
public async Task<InstallResult> InstallAsync(KubernetesCluster cluster, ComponentInstallOptions options, CancellationToken ct = default)
{
string version = options.Version ?? DefaultVersion;
string targetNamespace = options.Namespace ?? DefaultNamespace;
List<string> actions = new();
try
{
// Step 1: Ensure the namespace exists before installing.
// Kyverno's Helm chart can create it, but we prefer explicit control.
Kubernetes client = BuildClient(cluster);
await EnsureNamespaceExists(client, targetNamespace, ct);
actions.Add($"Ensured namespace '{targetNamespace}' exists");
// Step 2: Install/upgrade Kyverno via Helm.
// We shell out to helm because the .NET Kubernetes client doesn't
// have native Helm support. The kubeconfig is written to a temp file.
string helmResult = await RunHelmInstall(cluster, version, targetNamespace, ct);
actions.Add($"Helm install/upgrade kyverno v{version}");
logger.LogInformation("Kyverno {Version} installed on cluster {Cluster} in namespace {Namespace}",
version, cluster.Name, targetNamespace);
return new InstallResult(
Success: true,
ComponentName: ComponentName,
Message: $"Kyverno {version} installed successfully",
Actions: actions);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to install Kyverno on cluster {Cluster}", cluster.Name);
actions.Add($"Error: {ex.Message}");
return new InstallResult(
Success: false,
ComponentName: ComponentName,
Message: $"Failed to install Kyverno: {ex.Message}",
Actions: actions);
}
}
private async Task EnsureNamespaceExists(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)
{
k8s.Models.V1Namespace ns = new()
{
Metadata = new k8s.Models.V1ObjectMeta { Name = namespaceName }
};
await client.CoreV1.CreateNamespaceAsync(ns, cancellationToken: ct);
}
}
private async Task<string> RunHelmInstall(KubernetesCluster cluster, string version, string targetNamespace, CancellationToken ct)
{
// Write kubeconfig to a temp file for helm to use.
string tempKubeConfig = Path.Combine(Path.GetTempPath(), $"entkube-helm-{Guid.NewGuid()}.kubeconfig");
try
{
await File.WriteAllTextAsync(tempKubeConfig, cluster.KubeConfig, ct);
// Helm values matching the Terraform bootstrap module:
// HA replicas, PDBs, CRDs, webhook config, secrets RBAC.
// The webhook namespaceSelector excludes infrastructure namespaces
// so Kyverno's mutation policies don't interfere with operators
// that need SA tokens (e.g., MinIO, Harbor).
string helmArgs = string.Join(" ", new[]
{
"upgrade", "--install", "kyverno", "kyverno",
"--repo", HelmRepo,
"--version", version,
"--namespace", targetNamespace,
"--kube-context", cluster.ContextName,
"--set", "crds.install=true",
"--set", "admissionController.replicas=2",
"--set", "admissionController.podDisruptionBudget.enabled=true",
"--set", "admissionController.podDisruptionBudget.minAvailable=1",
"--set", "backgroundController.replicas=2",
"--set", "backgroundController.podDisruptionBudget.enabled=true",
"--set", "backgroundController.podDisruptionBudget.minAvailable=1",
"--set", "cleanupController.replicas=1",
"--set", "reportsController.replicas=1",
"--set", @"config.webhooks.namespaceSelector.matchExpressions[0].key=kubernetes\.io/metadata\.name",
"--set", "config.webhooks.namespaceSelector.matchExpressions[0].operator=NotIn",
"--set", "config.webhooks.namespaceSelector.matchExpressions[0].values[0]=kube-system",
"--set", "config.webhooks.namespaceSelector.matchExpressions[0].values[1]=kube-node-lease",
"--set", "config.webhooks.namespaceSelector.matchExpressions[0].values[2]=kube-public",
"--set", @"config.webhooks.namespaceSelector.matchExpressions[1].key=policies\.kyverno\.io/ignore",
"--set", "config.webhooks.namespaceSelector.matchExpressions[1].operator=DoesNotExist",
"--set", "admissionController.container.resources.requests.cpu=100m",
"--set", "admissionController.container.resources.requests.memory=256Mi",
"--set", "admissionController.container.resources.limits.memory=512Mi",
"--wait", "--timeout", "15m"
});
System.Diagnostics.ProcessStartInfo psi = new("helm", helmArgs)
{
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
Environment = { ["KUBECONFIG"] = tempKubeConfig }
};
using System.Diagnostics.Process? process = System.Diagnostics.Process.Start(psi);
if (process is null)
{
throw new InvalidOperationException("Failed to start helm process");
}
// Read stdout and stderr concurrently to avoid deadlocks.
// If we read them sequentially, the OS pipe buffer can fill up
// on one stream while we're waiting on the other, causing the
// process to hang indefinitely.
Task<string> outputTask = process.StandardOutput.ReadToEndAsync(ct);
Task<string> errorTask = process.StandardError.ReadToEndAsync(ct);
await process.WaitForExitAsync(ct);
string output = await outputTask;
string error = await errorTask;
if (process.ExitCode != 0)
{
throw new InvalidOperationException($"Helm failed (exit {process.ExitCode}): {error}");
}
return output;
}
finally
{
if (File.Exists(tempKubeConfig))
{
File.Delete(tempKubeConfig);
}
}
}
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);
}
/// <summary>
/// Reconfigures Kyverno with new values. Supports:
/// - "admissionReplicas": number of admission controller replicas
/// - "backgroundReplicas": number of background controller replicas
/// - "validationAction": global policy enforcement (Audit/Enforce)
/// </summary>
public async Task<InstallResult> ConfigureAsync(KubernetesCluster cluster, ComponentConfiguration configuration, CancellationToken ct = default)
{
string targetNamespace = configuration.Namespace ?? DefaultNamespace;
List<string> actions = new();
string tempKubeConfig = Path.Combine(Path.GetTempPath(), $"entkube-kyverno-cfg-{Guid.NewGuid()}.kubeconfig");
try
{
await File.WriteAllTextAsync(tempKubeConfig, cluster.KubeConfig, ct);
// Build --set flags from the configuration values, translating
// our keys to Helm chart value paths.
List<string> setFlags = new();
if (configuration.Values.TryGetValue("admissionReplicas", out string? admReplicas))
{
setFlags.Add($"--set admissionController.replicas={admReplicas}");
}
if (configuration.Values.TryGetValue("backgroundReplicas", out string? bgReplicas))
{
setFlags.Add($"--set backgroundController.replicas={bgReplicas}");
}
string helmArgs = $"upgrade kyverno kyverno --repo {HelmRepo} " +
$"--namespace {targetNamespace} --kube-context {cluster.ContextName} " +
$"--reuse-values {string.Join(" ", setFlags)} --wait --timeout 5m";
System.Diagnostics.ProcessStartInfo psi = new("helm", helmArgs)
{
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
Environment = { ["KUBECONFIG"] = tempKubeConfig }
};
using System.Diagnostics.Process? process = System.Diagnostics.Process.Start(psi);
if (process is null)
{
throw new InvalidOperationException("Failed to start helm process");
}
Task<string> outputTask = process.StandardOutput.ReadToEndAsync(ct);
Task<string> errorTask = process.StandardError.ReadToEndAsync(ct);
await process.WaitForExitAsync(ct);
string output = await outputTask;
string error = await errorTask;
if (process.ExitCode != 0)
{
throw new InvalidOperationException($"Helm failed (exit {process.ExitCode}): {error}");
}
actions.Add($"Reconfigured Kyverno: {string.Join(", ", configuration.Values.Select(kv => $"{kv.Key}={kv.Value}"))}");
return new InstallResult(
Success: true,
ComponentName: ComponentName,
Message: "Kyverno reconfigured",
Actions: actions);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to reconfigure Kyverno on cluster {Cluster}", cluster.Name);
return new InstallResult(
Success: false,
ComponentName: ComponentName,
Message: $"Failed to reconfigure Kyverno: {ex.Message}",
Actions: new List<string> { $"Error: {ex.Message}" });
}
finally
{
if (File.Exists(tempKubeConfig))
{
File.Delete(tempKubeConfig);
}
}
}
/// <summary>
/// Uninstalls Kyverno from the cluster by removing the Helm release and
/// optionally deleting CRDs. This reverts the installation performed by
/// InstallAsync — Kyverno policies will no longer be enforced.
/// </summary>
public async Task<InstallResult> UninstallAsync(KubernetesCluster cluster, ComponentInstallOptions options, CancellationToken ct = default)
{
string targetNamespace = options.Namespace ?? DefaultNamespace;
List<string> actions = new();
string tempKubeConfig = Path.Combine(Path.GetTempPath(), $"entkube-kyverno-rm-{Guid.NewGuid()}.kubeconfig");
try
{
await File.WriteAllTextAsync(tempKubeConfig, cluster.KubeConfig, ct);
// Uninstall the Kyverno Helm release.
await RunHelmUninstall("kyverno", targetNamespace, tempKubeConfig, cluster.ContextName, ct);
actions.Add("Helm uninstall kyverno");
logger.LogInformation("Kyverno uninstalled from cluster {Cluster}", cluster.Name);
return new InstallResult(
Success: true,
ComponentName: ComponentName,
Message: "Kyverno uninstalled successfully",
Actions: actions);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to uninstall Kyverno from cluster {Cluster}", cluster.Name);
return new InstallResult(
Success: false,
ComponentName: ComponentName,
Message: $"Failed to uninstall Kyverno: {ex.Message}",
Actions: new List<string> { $"Error: {ex.Message}" });
}
finally
{
if (File.Exists(tempKubeConfig))
{
File.Delete(tempKubeConfig);
}
}
}
private async Task RunHelmUninstall(string releaseName, string targetNamespace, string kubeConfigPath, string contextName, CancellationToken ct)
{
await RunHelmCommand(
$"uninstall {releaseName} --namespace {targetNamespace} --kubeconfig \"{kubeConfigPath}\" --kube-context \"{contextName}\" --wait --timeout 5m",
kubeConfigPath, contextName, ct);
}
private async Task<string> RunHelmCommand(string arguments, string kubeConfigPath, string contextName, CancellationToken ct)
{
System.Diagnostics.ProcessStartInfo psi = new("helm", arguments)
{
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
};
using System.Diagnostics.Process process = System.Diagnostics.Process.Start(psi)
?? throw new InvalidOperationException("Failed to start helm process");
Task<string> outputTask = process.StandardOutput.ReadToEndAsync(ct);
Task<string> errorTask = process.StandardError.ReadToEndAsync(ct);
await process.WaitForExitAsync(ct);
string output = await outputTask;
string error = await errorTask;
if (process.ExitCode != 0)
{
throw new InvalidOperationException($"helm failed (exit {process.ExitCode}): {error}");
}
return output;
}
}

View File

@@ -1,828 +0,0 @@
using System.Text;
using System.Text.Json;
using EntKube.Clusters.Domain;
using k8s;
using Microsoft.Extensions.Logging;
namespace EntKube.Clusters.Features.AdoptCluster.Components.LetsEncrypt;
/// <summary>
/// Checks whether any ACME-based ClusterIssuers are present on the cluster.
/// Rather than looking for hardcoded names like "letsencrypt-staging", this
/// lists ALL ClusterIssuers and identifies those configured with an ACME server
/// (typically Let's Encrypt). This means user-created issuers with custom names
/// will also be detected.
///
/// Detection strategy:
/// 1. List all ClusterIssuer resources via the cert-manager.io/v1 API
/// 2. Filter to those with spec.acme configured (ACME protocol = Let's Encrypt)
/// 3. Extract email, solver type, readiness from each
/// 4. Report discovered issuers with their configuration
/// </summary>
public class LetsEncryptCheck : IAdoptionCheck
{
private readonly ILogger<LetsEncryptCheck> logger;
public string ComponentName => "letsencrypt";
public string DisplayName => "Let's Encrypt (Public TLS)";
public LetsEncryptCheck(ILogger<LetsEncryptCheck> logger)
{
this.logger = logger;
}
public async Task<ComponentCheckResult> CheckAsync(KubernetesCluster cluster, CancellationToken ct = default)
{
Kubernetes client = BuildClient(cluster);
List<string> details = new();
List<string> missing = new();
// List all ClusterIssuers and find those using ACME (Let's Encrypt).
List<DetectedIssuer> acmeIssuers = await ListAcmeClusterIssuers(client, ct);
if (acmeIssuers.Count == 0)
{
missing.Add("No ACME-based ClusterIssuers found");
return new ComponentCheckResult(ComponentName, ComponentStatus.NotInstalled, details, missing);
}
// Report each discovered ACME issuer.
int readyCount = 0;
foreach (DetectedIssuer issuer in acmeIssuers)
{
details.Add($"ClusterIssuer '{issuer.Name}' present (ready: {issuer.IsReady}, server: {issuer.AcmeServer ?? "unknown"})");
if (issuer.IsReady)
{
readyCount++;
}
// Report DNS01 details if present.
if (issuer.Dns01Details is not null && issuer.Dns01Details.Provider is not null)
{
details.Add($" DNS01 provider: {issuer.Dns01Details.Provider}");
}
if (issuer.DnsZones.Count > 0)
{
details.Add($" DNS zones: {string.Join(", ", issuer.DnsZones)}");
}
}
// Build configuration values from everything we discovered.
Dictionary<string, string> configValues = BuildConfigValues(acmeIssuers);
// Discover available Gateway API Gateway resources on the cluster.
// This enables the schema enrichment to offer discovered gateways
// as select options when configuring the HTTP-01 solver.
List<GatewayInfo> availableGateways = await DiscoverGateways(client, ct);
if (availableGateways.Count > 0)
{
string gatewaysJson = System.Text.Json.JsonSerializer.Serialize(
availableGateways.Select(g => new { name = g.Name, @namespace = g.Namespace }));
configValues["availableGateways"] = gatewaysJson;
details.Add($"Discovered {availableGateways.Count} Gateway API Gateway(s): {string.Join(", ", availableGateways.Select(g => $"{g.Namespace}/{g.Name}"))}");
}
if (configValues.TryGetValue("email", out string? email))
{
details.Add($"Registration email: {email}");
}
if (configValues.TryGetValue("solverType", out string? solverType))
{
details.Add($"Primary solver: {solverType}");
}
DiscoveredConfiguration config = new(
Version: null,
Namespace: "cert-manager",
HelmReleaseName: null,
Values: configValues);
// Degraded if any issuer is not ready.
if (readyCount < acmeIssuers.Count)
{
int notReadyCount = acmeIssuers.Count - readyCount;
missing.Add($"{notReadyCount} issuer(s) not ready");
return new ComponentCheckResult(ComponentName, ComponentStatus.Degraded, details, missing, config);
}
return new ComponentCheckResult(ComponentName, ComponentStatus.Installed, details, missing, config);
}
/// <summary>
/// Lists all ClusterIssuers on the cluster and returns those that have an ACME
/// configuration (spec.acme). This catches Let's Encrypt issuers regardless of
/// their name — whether they're called "letsencrypt-prod", "acme-wildcard",
/// or anything else.
/// </summary>
private async Task<List<DetectedIssuer>> ListAcmeClusterIssuers(Kubernetes client, CancellationToken ct)
{
List<DetectedIssuer> acmeIssuers = new();
try
{
JsonElement result = await client.CustomObjects.ListClusterCustomObjectAsync<JsonElement>(
group: "cert-manager.io",
version: "v1",
plural: "clusterissuers",
cancellationToken: ct);
if (!result.TryGetProperty("items", out JsonElement items))
{
return acmeIssuers;
}
foreach (JsonElement item in items.EnumerateArray())
{
// Only include issuers that have spec.acme (ACME protocol).
if (!item.TryGetProperty("spec", out JsonElement spec)
|| !spec.TryGetProperty("acme", out JsonElement acme))
{
continue;
}
// Extract the issuer name.
string? name = null;
if (item.TryGetProperty("metadata", out JsonElement metadata)
&& metadata.TryGetProperty("name", out JsonElement nameEl))
{
name = nameEl.GetString();
}
if (string.IsNullOrEmpty(name))
{
continue;
}
// Extract email.
string? issuerEmail = null;
if (acme.TryGetProperty("email", out JsonElement emailEl))
{
issuerEmail = emailEl.GetString();
}
// Extract ACME server URL.
string? acmeServer = null;
if (acme.TryGetProperty("server", out JsonElement serverEl))
{
acmeServer = serverEl.GetString();
}
// Determine solver type and extract DNS01 details from the solvers.
string? issuerSolverType = null;
Dns01Details? dns01Details = null;
Http01Details? http01Details = null;
List<string> dnsZones = new();
if (acme.TryGetProperty("solvers", out JsonElement solvers)
&& solvers.GetArrayLength() > 0)
{
// Scan all solvers to determine the combined solver type and
// extract DNS01/HTTP01 configuration from whichever solver uses them.
bool hasHttp01 = false;
bool hasDns01 = false;
foreach (JsonElement solver in solvers.EnumerateArray())
{
if (solver.TryGetProperty("http01", out _))
{
hasHttp01 = true;
// Extract HTTP01 mode details (ingress vs gatewayHTTPRoute).
http01Details ??= ParseHttp01Details(solver);
}
if (solver.TryGetProperty("dns01", out JsonElement dns01El))
{
hasDns01 = true;
// Extract the DNS01 provider details (cloudflare, route53, etc.)
// and any domain selectors this solver is scoped to.
dns01Details ??= ParseDns01Details(dns01El);
dnsZones.AddRange(ParseDnsZones(solver));
}
}
if (hasHttp01 && hasDns01)
{
issuerSolverType = "HTTP01+DNS01";
}
else if (hasDns01)
{
issuerSolverType = "DNS01";
}
else if (hasHttp01)
{
issuerSolverType = "HTTP01";
}
}
// Check readiness from status.conditions.
bool ready = false;
if (item.TryGetProperty("status", out JsonElement status)
&& status.TryGetProperty("conditions", out JsonElement conditions))
{
foreach (JsonElement condition in conditions.EnumerateArray())
{
if (condition.TryGetProperty("type", out JsonElement typeEl)
&& typeEl.GetString() == "Ready"
&& condition.TryGetProperty("status", out JsonElement statusEl)
&& statusEl.GetString() == "True")
{
ready = true;
break;
}
}
}
acmeIssuers.Add(new DetectedIssuer(
name, issuerEmail, acmeServer, issuerSolverType, ready, dns01Details, dnsZones,
Http01Mode: http01Details?.Mode,
Http01IngressClass: http01Details?.IngressClass,
Http01GatewayName: http01Details?.GatewayName,
Http01GatewayNamespace: http01Details?.GatewayNamespace));
}
}
catch (k8s.Autorest.HttpOperationException ex) when (ex.Response.StatusCode == System.Net.HttpStatusCode.NotFound)
{
// ClusterIssuer CRD not found — cert-manager not installed.
logger.LogDebug("ClusterIssuer CRD not found on cluster — cert-manager may not be installed");
}
catch (Exception ex)
{
logger.LogWarning(ex, "Error listing ClusterIssuers on cluster");
}
return acmeIssuers;
}
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);
}
/// <summary>
/// Discovers all Gateway API Gateway resources across all namespaces.
/// Returns a list of gateway name/namespace pairs that can be used as
/// targets for the HTTP-01 challenge solver's gatewayHTTPRoute parentRefs.
/// This enables the UI to offer discovered gateways as select options
/// rather than requiring manual entry.
/// </summary>
private async Task<List<GatewayInfo>> DiscoverGateways(Kubernetes client, CancellationToken ct)
{
List<GatewayInfo> gateways = new();
try
{
JsonElement result = await client.CustomObjects.ListClusterCustomObjectAsync<JsonElement>(
group: "gateway.networking.k8s.io",
version: "v1",
plural: "gateways",
cancellationToken: ct);
if (!result.TryGetProperty("items", out JsonElement items))
{
return gateways;
}
foreach (JsonElement item in items.EnumerateArray())
{
if (!item.TryGetProperty("metadata", out JsonElement metadata))
{
continue;
}
string? name = metadata.TryGetProperty("name", out JsonElement nameEl)
? nameEl.GetString() : null;
string? ns = metadata.TryGetProperty("namespace", out JsonElement nsEl)
? nsEl.GetString() : null;
if (!string.IsNullOrEmpty(name))
{
gateways.Add(new GatewayInfo(name, ns ?? "default"));
}
}
}
catch (k8s.Autorest.HttpOperationException ex) when (ex.Response.StatusCode == System.Net.HttpStatusCode.NotFound)
{
// Gateway API CRDs not installed — no gateways to discover.
logger.LogDebug("Gateway API CRD not found — Gateway discovery skipped");
}
catch (Exception ex)
{
logger.LogWarning(ex, "Error discovering Gateway API Gateways on cluster");
}
return gateways;
}
// ─── Public Static Helpers (testable without a Kubernetes client) ────
/// <summary>
/// Parses DNS01 solver details from the dns01 element of a cert-manager solver.
/// Recognizes Cloudflare, Route53, Azure DNS, and Cloud DNS providers, extracting
/// the provider name, credential secret reference, hosted zone, and GCP project.
/// Returns null fields for unrecognized or webhook-based solvers.
/// </summary>
public static Dns01Details ParseDns01Details(JsonElement dns01Element)
{
// Cloudflare — uses apiTokenSecretRef for authentication.
if (dns01Element.TryGetProperty("cloudflare", out JsonElement cloudflare))
{
string? secretName = null;
if (cloudflare.TryGetProperty("apiTokenSecretRef", out JsonElement secretRef)
&& secretRef.TryGetProperty("name", out JsonElement nameEl))
{
secretName = nameEl.GetString();
}
return new Dns01Details(Provider: "cloudflare", SecretName: secretName, HostedZone: null, Project: null);
}
// AWS Route53 — uses hostedZoneID, region, and optionally
// secretAccessKeySecretRef for explicit credential authentication.
if (dns01Element.TryGetProperty("route53", out JsonElement route53))
{
string? hostedZone = null;
string? secretName = null;
if (route53.TryGetProperty("hostedZoneID", out JsonElement zoneEl))
{
hostedZone = zoneEl.GetString();
}
// Extract the credential secret if present. Non-IRSA setups store
// the AWS secret access key in a Kubernetes Secret.
if (route53.TryGetProperty("secretAccessKeySecretRef", out JsonElement secretRef)
&& secretRef.TryGetProperty("name", out JsonElement nameEl))
{
secretName = nameEl.GetString();
}
return new Dns01Details(Provider: "route53", SecretName: secretName, HostedZone: hostedZone, Project: null);
}
// Azure DNS — uses hostedZoneName, clientID, clientSecretSecretRef,
// subscriptionID, tenantID, and resourceGroupName for service principal auth.
if (dns01Element.TryGetProperty("azureDNS", out JsonElement azureDns))
{
string? hostedZone = null;
string? secretName = null;
string? clientId = null;
string? subscriptionId = null;
string? tenantId = null;
string? resourceGroup = null;
if (azureDns.TryGetProperty("hostedZoneName", out JsonElement zoneEl))
{
hostedZone = zoneEl.GetString();
}
if (azureDns.TryGetProperty("clientSecretSecretRef", out JsonElement secretRef)
&& secretRef.TryGetProperty("name", out JsonElement nameEl))
{
secretName = nameEl.GetString();
}
if (azureDns.TryGetProperty("clientID", out JsonElement clientIdEl))
{
clientId = clientIdEl.GetString();
}
if (azureDns.TryGetProperty("subscriptionID", out JsonElement subEl))
{
subscriptionId = subEl.GetString();
}
if (azureDns.TryGetProperty("tenantID", out JsonElement tenantEl))
{
tenantId = tenantEl.GetString();
}
if (azureDns.TryGetProperty("resourceGroupName", out JsonElement rgEl))
{
resourceGroup = rgEl.GetString();
}
return new Dns01Details(
Provider: "azuredns",
SecretName: secretName,
HostedZone: hostedZone,
Project: null,
ClientId: clientId,
SubscriptionId: subscriptionId,
TenantId: tenantId,
ResourceGroup: resourceGroup);
}
// Google Cloud DNS — uses project and serviceAccountSecretRef.
if (dns01Element.TryGetProperty("cloudDNS", out JsonElement cloudDns))
{
string? project = null;
string? secretName = null;
if (cloudDns.TryGetProperty("project", out JsonElement projEl))
{
project = projEl.GetString();
}
if (cloudDns.TryGetProperty("serviceAccountSecretRef", out JsonElement secretRef)
&& secretRef.TryGetProperty("name", out JsonElement nameEl))
{
secretName = nameEl.GetString();
}
return new Dns01Details(Provider: "clouddns", SecretName: secretName, HostedZone: null, Project: project);
}
// Unrecognized provider (could be a webhook solver or unknown).
return new Dns01Details(Provider: null, SecretName: null, HostedZone: null, Project: null);
}
/// <summary>
/// Parses HTTP01 solver details from a solver element. Determines whether the
/// solver uses traditional Ingress resources or Gateway API HTTPRoutes.
/// Returns null if the solver has no http01 section.
/// </summary>
public static Http01Details? ParseHttp01Details(JsonElement solverElement)
{
if (!solverElement.TryGetProperty("http01", out JsonElement http01))
{
return null;
}
// Gateway API mode — uses gatewayHTTPRoute with parentRefs pointing
// to the Gateway resource that should receive the challenge route.
if (http01.TryGetProperty("gatewayHTTPRoute", out JsonElement gatewayRoute))
{
string? gatewayName = null;
string? gatewayNamespace = null;
if (gatewayRoute.TryGetProperty("parentRefs", out JsonElement parentRefs)
&& parentRefs.GetArrayLength() > 0)
{
JsonElement firstRef = parentRefs[0];
if (firstRef.TryGetProperty("name", out JsonElement nameEl))
{
gatewayName = nameEl.GetString();
}
if (firstRef.TryGetProperty("namespace", out JsonElement nsEl))
{
gatewayNamespace = nsEl.GetString();
}
}
return new Http01Details(
Mode: "gatewayHTTPRoute",
GatewayName: gatewayName,
GatewayNamespace: gatewayNamespace);
}
// Traditional Ingress mode — uses ingress.ingressClassName or ingress.class.
if (http01.TryGetProperty("ingress", out JsonElement ingress))
{
string? ingressClass = null;
if (ingress.TryGetProperty("ingressClassName", out JsonElement classNameEl))
{
ingressClass = classNameEl.GetString();
}
else if (ingress.TryGetProperty("class", out JsonElement classEl))
{
ingressClass = classEl.GetString();
}
return new Http01Details(Mode: "ingress", IngressClass: ingressClass);
}
// HTTP01 is present but with no recognized sub-key.
return new Http01Details(Mode: "ingress");
}
/// <summary>
/// Extracts DNS zone selectors from a solver element. Cert-manager solvers can
/// have a selector.dnsZones array that limits which domains the solver handles.
/// This is common for DNS01 wildcard certificates scoped to specific domains.
/// </summary>
public static List<string> ParseDnsZones(JsonElement solverElement)
{
List<string> zones = new();
if (solverElement.TryGetProperty("selector", out JsonElement selector)
&& selector.TryGetProperty("dnsZones", out JsonElement dnsZones))
{
foreach (JsonElement zone in dnsZones.EnumerateArray())
{
string? zoneStr = zone.GetString();
if (!string.IsNullOrEmpty(zoneStr))
{
zones.Add(zoneStr);
}
}
}
return zones;
}
/// <summary>
/// Builds the configuration values dictionary from detected ACME issuers.
/// Produces both global summary fields (email, solverType, issuerCount) and
/// per-issuer details (issuer.{name}.solverType, issuer.{name}.dnsSolverProvider, etc.)
/// so the UI can display comprehensive issuer configuration.
/// </summary>
public static Dictionary<string, string> BuildConfigValues(List<DetectedIssuer> issuers)
{
Dictionary<string, string> configValues = new();
// Global summary values.
List<string> issuerNames = issuers.Select(i => i.Name).ToList();
configValues["issuers"] = string.Join(",", issuerNames);
configValues["issuerCount"] = issuers.Count.ToString();
configValues["readyCount"] = issuers.Count(i => i.IsReady).ToString();
// Use the first email as the canonical global value.
string? email = issuers.Select(i => i.Email).FirstOrDefault(e => e is not null);
if (email is not null)
{
configValues["email"] = email;
}
// Derive explicit solver-enabled flags from what we actually found on the
// cluster. This is critical: the configuration form uses these values to
// show the correct state. Without them, the schema defaults kick in and
// show HTTP-01 as enabled when only DNS-01 is configured.
bool hasAnyHttp01 = issuers.Any(i => i.SolverType is "HTTP01" or "HTTP01+DNS01");
bool hasAnyDns01 = issuers.Any(i => i.SolverType is "DNS01" or "HTTP01+DNS01");
configValues["httpSolverEnabled"] = hasAnyHttp01 ? "true" : "false";
configValues["dnsSolverEnabled"] = hasAnyDns01 ? "true" : "false";
// Global HTTP01 solver mode — report whether the first HTTP01 issuer
// uses traditional Ingress or Gateway API (gatewayHTTPRoute).
DetectedIssuer? firstHttp01Issuer = issuers.FirstOrDefault(i => i.Http01Mode is not null);
if (firstHttp01Issuer is not null)
{
configValues["httpSolverMode"] = firstHttp01Issuer.Http01Mode!;
if (firstHttp01Issuer.Http01Mode == "gatewayHTTPRoute")
{
if (firstHttp01Issuer.Http01GatewayName is not null)
{
configValues["httpSolverGatewayName"] = firstHttp01Issuer.Http01GatewayName;
}
if (firstHttp01Issuer.Http01GatewayNamespace is not null)
{
configValues["httpSolverGatewayNamespace"] = firstHttp01Issuer.Http01GatewayNamespace;
}
}
else if (firstHttp01Issuer.Http01IngressClass is not null)
{
configValues["httpSolverIngressClass"] = firstHttp01Issuer.Http01IngressClass;
}
}
// Global DNS solver values from the first issuer that has DNS01 details.
// These map to the schema keys that the configure form uses, so the
// form pre-populates with the correct provider-specific fields.
DetectedIssuer? firstDns01Issuer = issuers.FirstOrDefault(i => i.Dns01Details is not null);
if (firstDns01Issuer?.Dns01Details is not null)
{
Dns01Details dns = firstDns01Issuer.Dns01Details;
if (dns.Provider is not null)
{
configValues["dnsSolverProvider"] = dns.Provider;
}
if (dns.SecretName is not null)
{
// The schema uses different keys for the secret name depending on
// the provider (cloudflare uses dnsSolverSecretName, Azure DNS uses
// dnsSolverSecretNameAzure, Cloud DNS uses dnsSolverSecretNameGcp).
// Store under the provider-specific key so the form finds it, and
// also under the generic key for backward compatibility.
configValues["dnsSolverSecretName"] = dns.SecretName;
if (dns.Provider == "azuredns")
{
configValues["dnsSolverSecretNameAzure"] = dns.SecretName;
}
else if (dns.Provider == "clouddns")
{
configValues["dnsSolverSecretNameGcp"] = dns.SecretName;
}
}
if (dns.HostedZone is not null)
{
configValues["dnsSolverHostedZone"] = dns.HostedZone;
}
if (dns.Project is not null)
{
configValues["dnsSolverProject"] = dns.Project;
}
if (dns.ClientId is not null)
{
configValues["dnsSolverClientId"] = dns.ClientId;
}
if (dns.SubscriptionId is not null)
{
configValues["dnsSolverSubscriptionId"] = dns.SubscriptionId;
}
if (dns.TenantId is not null)
{
configValues["dnsSolverTenantId"] = dns.TenantId;
}
if (dns.ResourceGroup is not null)
{
configValues["dnsSolverResourceGroup"] = dns.ResourceGroup;
}
if (firstDns01Issuer.DnsZones.Count > 0)
{
configValues["dnsZones"] = string.Join(",", firstDns01Issuer.DnsZones);
}
}
// Per-issuer details — each issuer gets its own namespaced config entries
// so the UI can show configuration for every discovered ClusterIssuer.
foreach (DetectedIssuer issuer in issuers)
{
string prefix = $"issuer.{issuer.Name}";
if (issuer.SolverType is not null)
{
configValues[$"{prefix}.solverType"] = issuer.SolverType;
}
if (issuer.AcmeServer is not null)
{
configValues[$"{prefix}.acmeServer"] = issuer.AcmeServer;
}
if (issuer.Email is not null)
{
configValues[$"{prefix}.email"] = issuer.Email;
}
configValues[$"{prefix}.ready"] = issuer.IsReady.ToString().ToLowerInvariant();
if (issuer.Dns01Details is not null)
{
if (issuer.Dns01Details.Provider is not null)
{
configValues[$"{prefix}.dnsSolverProvider"] = issuer.Dns01Details.Provider;
}
if (issuer.Dns01Details.SecretName is not null)
{
configValues[$"{prefix}.dnsSolverSecretName"] = issuer.Dns01Details.SecretName;
}
if (issuer.Dns01Details.HostedZone is not null)
{
configValues[$"{prefix}.dnsSolverHostedZone"] = issuer.Dns01Details.HostedZone;
}
if (issuer.Dns01Details.Project is not null)
{
configValues[$"{prefix}.dnsSolverProject"] = issuer.Dns01Details.Project;
}
if (issuer.Dns01Details.ClientId is not null)
{
configValues[$"{prefix}.dnsSolverClientId"] = issuer.Dns01Details.ClientId;
}
if (issuer.Dns01Details.SubscriptionId is not null)
{
configValues[$"{prefix}.dnsSolverSubscriptionId"] = issuer.Dns01Details.SubscriptionId;
}
if (issuer.Dns01Details.TenantId is not null)
{
configValues[$"{prefix}.dnsSolverTenantId"] = issuer.Dns01Details.TenantId;
}
if (issuer.Dns01Details.ResourceGroup is not null)
{
configValues[$"{prefix}.dnsSolverResourceGroup"] = issuer.Dns01Details.ResourceGroup;
}
}
if (issuer.DnsZones.Count > 0)
{
configValues[$"{prefix}.dnsZones"] = string.Join(",", issuer.DnsZones);
}
}
return configValues;
}
}
/// <summary>
/// DNS01 solver details extracted from a ClusterIssuer's spec.acme.solvers[].dns01.
/// Captures the provider type, credential secret, hosted zone, GCP project, and
/// Azure-specific fields (clientID, subscriptionID, tenantID, resourceGroupName)
/// needed for service principal authentication.
/// </summary>
public record Dns01Details(
string? Provider,
string? SecretName,
string? HostedZone,
string? Project,
string? ClientId = null,
string? SubscriptionId = null,
string? TenantId = null,
string? ResourceGroup = null);
/// <summary>
/// HTTP01 solver details extracted from a ClusterIssuer's spec.acme.solvers[].http01.
/// Distinguishes between traditional Ingress-based and Gateway API-based solvers.
/// Mode is "ingress" for spec.acme.solvers[].http01.ingress or "gatewayHTTPRoute"
/// for spec.acme.solvers[].http01.gatewayHTTPRoute.
/// </summary>
public record Http01Details(
string Mode,
string? IngressClass = null,
string? GatewayName = null,
string? GatewayNamespace = null);
/// <summary>
/// Represents a detected ACME ClusterIssuer with its key properties,
/// including detailed DNS01 solver configuration, HTTP01 solver mode,
/// and domain selectors.
/// </summary>
public record DetectedIssuer(
string Name,
string? Email,
string? AcmeServer,
string? SolverType,
bool IsReady,
Dns01Details? Dns01Details,
List<string> DnsZones,
string? Http01Mode = null,
string? Http01IngressClass = null,
string? Http01GatewayName = null,
string? Http01GatewayNamespace = null);
/// <summary>
/// Represents a discovered Gateway API Gateway resource on the cluster.
/// Used to populate gateway selection options in the Let's Encrypt schema.
/// </summary>
public record GatewayInfo(string Name, string Namespace);

View File

@@ -1,541 +0,0 @@
using System.Text;
using EntKube.Clusters.Domain;
using k8s;
using k8s.Models;
using Microsoft.Extensions.Logging;
namespace EntKube.Clusters.Features.AdoptCluster.Components.LetsEncrypt;
/// <summary>
/// Installs and configures Let's Encrypt ClusterIssuers on the cluster.
/// Requires cert-manager to already be installed (it manages the issuer lifecycle).
///
/// **Install** creates:
/// - "letsencrypt-staging" ClusterIssuer (for testing, higher rate limits)
/// - "letsencrypt-prod" ClusterIssuer (for real certificates)
/// - Both configured with the chosen challenge solver (HTTP01, DNS01, or both)
///
/// **Configure** supports updating:
/// - "email": ACME registration email
/// - "httpSolverEnabled": "true"/"false" — toggle HTTP01 challenge solver
/// - "httpSolverMode": "ingress"|"gatewayHTTPRoute" — HTTP01 solver mode
/// - "httpSolverIngressClass": ingress class for HTTP01 (default: "istio")
/// - "httpSolverGatewayName": Gateway resource name for gatewayHTTPRoute mode
/// - "httpSolverGatewayNamespace": Gateway namespace for gatewayHTTPRoute mode
/// - "dnsSolverEnabled": "true"/"false" — toggle DNS01 challenge solver
/// - "dnsSolverProvider": "cloudflare"|"route53"|"azuredns"|"clouddns"
/// - "dnsSolverSecretName": K8s Secret with provider credentials
/// - "dnsSolverHostedZone": DNS zone (Route53/Azure)
/// - "dnsSolverProject": GCP project (CloudDNS)
/// - "deleteStaging": "true" — remove staging issuer
/// - "deleteProduction": "true" — remove production issuer
/// </summary>
public class LetsEncryptInstaller : IComponentInstaller
{
private readonly ILogger<LetsEncryptInstaller> logger;
public string ComponentName => "letsencrypt";
public LetsEncryptInstaller(ILogger<LetsEncryptInstaller> logger)
{
this.logger = logger;
}
public async Task<InstallResult> InstallAsync(KubernetesCluster cluster, ComponentInstallOptions options, CancellationToken ct = default)
{
List<string> actions = new();
try
{
Kubernetes client = BuildClient(cluster);
// Prerequisite: cert-manager must be installed (we need its CRDs).
if (!await CertManagerCrdExists(client, ct))
{
return new InstallResult(
Success: false,
ComponentName: ComponentName,
Message: "cert-manager must be installed before configuring Let's Encrypt issuers",
Actions: new List<string> { "Prerequisite: cert-manager CRDs not found" });
}
// Read configuration parameters.
string? email = null;
options.Parameters?.TryGetValue("email", out email);
if (string.IsNullOrEmpty(email))
{
return new InstallResult(
Success: false,
ComponentName: ComponentName,
Message: "An email address is required for Let's Encrypt registration",
Actions: new List<string> { "Missing required parameter: email" });
}
bool httpSolverEnabled = !(options.Parameters?.TryGetValue("httpSolverEnabled", out string? httpVal) == true
&& httpVal == "false");
// Determine HTTP01 solver mode — "gatewayHTTPRoute" for Gateway API,
// "ingress" (default) for traditional Ingress resources.
string httpSolverMode = "ingress";
if (options.Parameters?.TryGetValue("httpSolverMode", out string? mode) == true
&& !string.IsNullOrEmpty(mode))
{
httpSolverMode = mode;
}
string httpIngressClass = "istio";
if (options.Parameters?.TryGetValue("httpSolverIngressClass", out string? ic) == true
&& !string.IsNullOrEmpty(ic))
{
httpIngressClass = ic;
}
string? httpGatewayName = null;
options.Parameters?.TryGetValue("httpSolverGatewayName", out httpGatewayName);
string? httpGatewayNamespace = null;
options.Parameters?.TryGetValue("httpSolverGatewayNamespace", out httpGatewayNamespace);
bool dnsSolverEnabled = options.Parameters?.TryGetValue("dnsSolverEnabled", out string? dnsVal) == true
&& dnsVal == "true";
string? dnsProvider = null;
options.Parameters?.TryGetValue("dnsSolverProvider", out dnsProvider);
string? dnsSecretName = null;
options.Parameters?.TryGetValue("dnsSolverSecretName", out dnsSecretName);
string? dnsHostedZone = null;
options.Parameters?.TryGetValue("dnsSolverHostedZone", out dnsHostedZone);
string? dnsProject = null;
options.Parameters?.TryGetValue("dnsSolverProject", out dnsProject);
// Create staging issuer.
await ApplyClusterIssuer(client, "letsencrypt-staging",
"https://acme-v02.api.letsencrypt.org/directory",
email, httpSolverEnabled, httpSolverMode, httpIngressClass,
httpGatewayName, httpGatewayNamespace,
dnsSolverEnabled, dnsProvider, dnsSecretName, dnsHostedZone, dnsProject, ct);
actions.Add("Created ClusterIssuer 'letsencrypt-staging'");
// Create production issuer.
await ApplyClusterIssuer(client, "letsencrypt-prod",
"https://acme-v02.api.letsencrypt.org/directory",
email, httpSolverEnabled, httpSolverMode, httpIngressClass,
httpGatewayName, httpGatewayNamespace,
dnsSolverEnabled, dnsProvider, dnsSecretName, dnsHostedZone, dnsProject, ct);
actions.Add("Created ClusterIssuer 'letsencrypt-prod'");
logger.LogInformation("Let's Encrypt issuers configured on cluster {Cluster} with email {Email}",
cluster.Name, email);
return new InstallResult(
Success: true,
ComponentName: ComponentName,
Message: $"Let's Encrypt issuers created with email {email}",
Actions: actions);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to configure Let's Encrypt on cluster {Cluster}", cluster.Name);
return new InstallResult(
Success: false,
ComponentName: ComponentName,
Message: $"Failed to configure Let's Encrypt: {ex.Message}",
Actions: new List<string> { $"Error: {ex.Message}" });
}
}
public async Task<InstallResult> ConfigureAsync(KubernetesCluster cluster, ComponentConfiguration configuration, CancellationToken ct = default)
{
List<string> actions = new();
try
{
Kubernetes client = BuildClient(cluster);
// Handle deletion requests.
if (configuration.Values.TryGetValue("deleteStaging", out string? delStaging)
&& delStaging == "true")
{
await DeleteClusterIssuer(client, "letsencrypt-staging", ct);
actions.Add("Deleted ClusterIssuer 'letsencrypt-staging'");
}
if (configuration.Values.TryGetValue("deleteProduction", out string? delProd)
&& delProd == "true")
{
await DeleteClusterIssuer(client, "letsencrypt-prod", ct);
actions.Add("Deleted ClusterIssuer 'letsencrypt-prod'");
}
// Handle update (email/solver changes).
if (configuration.Values.TryGetValue("email", out string? email)
&& !string.IsNullOrEmpty(email))
{
bool httpSolverEnabled = !(configuration.Values.TryGetValue("httpSolverEnabled", out string? httpVal)
&& httpVal == "false");
string httpSolverMode = "ingress";
if (configuration.Values.TryGetValue("httpSolverMode", out string? mode)
&& !string.IsNullOrEmpty(mode))
{
httpSolverMode = mode;
}
string httpIngressClass = "istio";
if (configuration.Values.TryGetValue("httpSolverIngressClass", out string? ic)
&& !string.IsNullOrEmpty(ic))
{
httpIngressClass = ic;
}
configuration.Values.TryGetValue("httpSolverGatewayName", out string? httpGatewayName);
configuration.Values.TryGetValue("httpSolverGatewayNamespace", out string? httpGatewayNamespace);
bool dnsSolverEnabled = configuration.Values.TryGetValue("dnsSolverEnabled", out string? dnsVal)
&& dnsVal == "true";
configuration.Values.TryGetValue("dnsSolverProvider", out string? dnsProvider);
configuration.Values.TryGetValue("dnsSolverSecretName", out string? dnsSecretName);
configuration.Values.TryGetValue("dnsSolverHostedZone", out string? dnsHostedZone);
configuration.Values.TryGetValue("dnsSolverProject", out string? dnsProject);
// Update staging issuer.
await ApplyClusterIssuer(client, "letsencrypt-staging",
"https://acme-v02.api.letsencrypt.org/directory",
email, httpSolverEnabled, httpSolverMode, httpIngressClass,
httpGatewayName, httpGatewayNamespace,
dnsSolverEnabled, dnsProvider, dnsSecretName, dnsHostedZone, dnsProject, ct);
actions.Add("Updated ClusterIssuer 'letsencrypt-staging'");
// Update production issuer.
await ApplyClusterIssuer(client, "letsencrypt-prod",
"https://acme-v02.api.letsencrypt.org/directory",
email, httpSolverEnabled, httpSolverMode, httpIngressClass,
httpGatewayName, httpGatewayNamespace,
dnsSolverEnabled, dnsProvider, dnsSecretName, dnsHostedZone, dnsProject, ct);
actions.Add("Updated ClusterIssuer 'letsencrypt-prod'");
}
if (actions.Count == 0)
{
return new InstallResult(
Success: false,
ComponentName: ComponentName,
Message: "No configuration changes specified",
Actions: actions);
}
return new InstallResult(
Success: true,
ComponentName: ComponentName,
Message: "Let's Encrypt configuration updated",
Actions: actions);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to reconfigure Let's Encrypt on cluster {Cluster}", cluster.Name);
return new InstallResult(
Success: false,
ComponentName: ComponentName,
Message: $"Failed to reconfigure Let's Encrypt: {ex.Message}",
Actions: new List<string> { $"Error: {ex.Message}" });
}
}
/// <summary>
/// Uninstalls Let's Encrypt by deleting the staging and production
/// ClusterIssuer resources from the cluster.
/// </summary>
public async Task<InstallResult> UninstallAsync(KubernetesCluster cluster, ComponentInstallOptions options, CancellationToken ct = default)
{
List<string> actions = new();
try
{
Kubernetes client = BuildClient(cluster);
string[] issuerNames = new[] { "letsencrypt-staging", "letsencrypt-prod" };
foreach (string issuerName in issuerNames)
{
try
{
await client.CustomObjects.DeleteClusterCustomObjectAsync(
"cert-manager.io", "v1", "clusterissuers", issuerName, cancellationToken: ct);
actions.Add($"Deleted ClusterIssuer '{issuerName}'");
}
catch (k8s.Autorest.HttpOperationException ex) when (ex.Response.StatusCode == System.Net.HttpStatusCode.NotFound)
{
// Issuer doesn't exist — skip.
}
}
logger.LogInformation("Let's Encrypt issuers removed from cluster {Cluster}", cluster.Name);
return new InstallResult(
Success: true,
ComponentName: ComponentName,
Message: $"Deleted {actions.Count} Let's Encrypt ClusterIssuers",
Actions: actions);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to uninstall Let's Encrypt from cluster {Cluster}", cluster.Name);
return new InstallResult(
Success: false,
ComponentName: ComponentName,
Message: $"Failed to uninstall Let's Encrypt: {ex.Message}",
Actions: new List<string> { $"Error: {ex.Message}" });
}
}
// ─── ClusterIssuer Management ─────────────────────────────────────────
private async Task ApplyClusterIssuer(
Kubernetes client, string issuerName, string acmeServer, string email,
bool httpSolverEnabled, string httpSolverMode, string httpIngressClass,
string? httpGatewayName, string? httpGatewayNamespace,
bool dnsSolverEnabled, string? dnsProvider, string? dnsSecretName,
string? dnsHostedZone, string? dnsProject, CancellationToken ct)
{
// Build solvers.
List<object> solvers = new();
if (httpSolverEnabled)
{
solvers.Add(BuildHttp01Solver(httpSolverMode, httpIngressClass, httpGatewayName, httpGatewayNamespace));
}
if (dnsSolverEnabled && !string.IsNullOrEmpty(dnsProvider))
{
Dictionary<string, object> dnsSolver = BuildDnsSolver(dnsProvider, dnsSecretName, dnsHostedZone, dnsProject);
solvers.Add(dnsSolver);
}
// If no solvers configured, default to HTTP01 with the chosen mode.
if (solvers.Count == 0)
{
solvers.Add(BuildHttp01Solver(httpSolverMode, httpIngressClass, httpGatewayName, httpGatewayNamespace));
}
// Build the ClusterIssuer resource.
Dictionary<string, object> issuer = new()
{
["apiVersion"] = "cert-manager.io/v1",
["kind"] = "ClusterIssuer",
["metadata"] = new Dictionary<string, object>
{
["name"] = issuerName,
["labels"] = new Dictionary<string, string>
{
["app.kubernetes.io/managed-by"] = "entkube",
["entkube.io/component"] = "letsencrypt"
}
},
["spec"] = new Dictionary<string, object>
{
["acme"] = new Dictionary<string, object>
{
["server"] = acmeServer,
["email"] = email,
["privateKeySecretRef"] = new Dictionary<string, object>
{
["name"] = $"{issuerName}-account-key"
},
["solvers"] = solvers
}
}
};
// Apply (patch or create).
try
{
await client.CustomObjects.PatchClusterCustomObjectAsync(
body: new V1Patch(issuer, V1Patch.PatchType.MergePatch),
group: "cert-manager.io",
version: "v1",
plural: "clusterissuers",
name: issuerName,
cancellationToken: ct);
}
catch (k8s.Autorest.HttpOperationException ex) when (ex.Response.StatusCode == System.Net.HttpStatusCode.NotFound)
{
await client.CustomObjects.CreateClusterCustomObjectAsync(
body: issuer,
group: "cert-manager.io",
version: "v1",
plural: "clusterissuers",
cancellationToken: ct);
}
}
private async Task DeleteClusterIssuer(Kubernetes client, string issuerName, CancellationToken ct)
{
try
{
await client.CustomObjects.DeleteClusterCustomObjectAsync(
group: "cert-manager.io",
version: "v1",
plural: "clusterissuers",
name: issuerName,
cancellationToken: ct);
}
catch (k8s.Autorest.HttpOperationException ex) when (ex.Response.StatusCode == System.Net.HttpStatusCode.NotFound)
{
// Already gone.
}
}
/// <summary>
/// Builds the HTTP01 solver dictionary for the ClusterIssuer spec.
/// Supports two modes:
/// - "ingress" (default): Creates challenge Ingress resources with ingressClassName.
/// - "gatewayHTTPRoute": Creates challenge HTTPRoute resources attached to a
/// Gateway via parentRefs. This is required for clusters using Gateway API
/// (e.g., with Istio) instead of traditional Ingress controllers.
/// </summary>
private static Dictionary<string, object> BuildHttp01Solver(
string mode, string ingressClass, string? gatewayName, string? gatewayNamespace)
{
if (mode == "gatewayHTTPRoute")
{
// Gateway API mode — cert-manager creates an HTTPRoute that attaches
// to the specified Gateway for serving the ACME challenge response.
Dictionary<string, object> parentRef = new()
{
["name"] = gatewayName ?? "internal"
};
if (!string.IsNullOrEmpty(gatewayNamespace))
{
parentRef["namespace"] = gatewayNamespace;
}
return new Dictionary<string, object>
{
["http01"] = new Dictionary<string, object>
{
["gatewayHTTPRoute"] = new Dictionary<string, object>
{
["parentRefs"] = new List<object> { parentRef }
}
}
};
}
// Traditional Ingress mode — cert-manager creates an Ingress resource
// with the specified class for serving the ACME challenge response.
return new Dictionary<string, object>
{
["http01"] = new Dictionary<string, object>
{
["ingress"] = new Dictionary<string, object>
{
["ingressClassName"] = ingressClass
}
}
};
}
private static Dictionary<string, object> BuildDnsSolver(
string provider, string? secretName, string? hostedZone, string? project)
{
Dictionary<string, object> dns01Config = provider.ToLowerInvariant() switch
{
"cloudflare" => new Dictionary<string, object>
{
["cloudflare"] = new Dictionary<string, object>
{
["apiTokenSecretRef"] = new Dictionary<string, object>
{
["name"] = secretName ?? "cloudflare-api-token",
["key"] = "api-token"
}
}
},
"route53" => new Dictionary<string, object>
{
["route53"] = new Dictionary<string, object>
{
["region"] = "eu-north-1",
["hostedZoneID"] = hostedZone ?? ""
}
},
"azuredns" => new Dictionary<string, object>
{
["azureDNS"] = new Dictionary<string, object>
{
["hostedZoneName"] = hostedZone ?? "",
["environment"] = "AzurePublicCloud"
}
},
"clouddns" => new Dictionary<string, object>
{
["cloudDNS"] = new Dictionary<string, object>
{
["project"] = project ?? "",
["serviceAccountSecretRef"] = new Dictionary<string, object>
{
["name"] = secretName ?? "clouddns-service-account",
["key"] = "key.json"
}
}
},
_ => new Dictionary<string, object>()
};
return new Dictionary<string, object>
{
["dns01"] = dns01Config
};
}
private async Task<bool> CertManagerCrdExists(Kubernetes client, CancellationToken ct)
{
try
{
await client.ApiextensionsV1.ReadCustomResourceDefinitionAsync(
"clusterissuers.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);
}
}

View File

@@ -1,179 +0,0 @@
using System.Text;
using EntKube.Clusters.Domain;
using k8s;
using k8s.Models;
using Microsoft.Extensions.Logging;
namespace EntKube.Clusters.Features.AdoptCluster.Components.MinIO;
/// <summary>
/// Checks whether MinIO (S3-compatible storage) is installed on the cluster.
/// The Terraform deploys two layers:
/// 1. MinIO Operator (bootstrap) — watches for Tenant CRDs
/// 2. MinIO Tenant (platform) — the actual storage cluster
///
/// This check verifies both the operator and at least one tenant exist.
/// </summary>
public class MinIOCheck : IAdoptionCheck
{
private readonly ILogger<MinIOCheck> logger;
public string ComponentName => "minio";
public string DisplayName => "MinIO Object Storage (S3)";
public MinIOCheck(ILogger<MinIOCheck> logger)
{
this.logger = logger;
}
public async Task<ComponentCheckResult> CheckAsync(KubernetesCluster cluster, CancellationToken ct = default)
{
Kubernetes client = BuildClient(cluster);
List<string> details = new();
List<string> missing = new();
// Check 1: MinIO Operator pods running in minio-operator namespace
List<string> operatorPods = await FindOperatorPods(client, ct);
if (operatorPods.Count > 0)
{
details.AddRange(operatorPods.Select(p => $"Operator pod: {p}"));
}
else
{
missing.Add("MinIO Operator not found (no pods in minio-operator namespace)");
}
// Check 2: Tenant CRD exists (minio.min.io/v2 Tenant)
bool tenantCrdExists = await CrdExists(client, "tenants.minio.min.io", ct);
if (tenantCrdExists)
{
details.Add("Tenant CRD present (tenants.minio.min.io)");
}
else
{
missing.Add("MinIO Tenant CRD not found (tenants.minio.min.io)");
}
// Check 3: At least one Tenant CR exists (actual storage deployed).
// Note: Absence of a tenant is informational — it does NOT degrade the operator.
// Tenants are provisioned separately via the Provisioning service.
bool hasTenant = await HasMinIOTenant(client, ct);
if (hasTenant)
{
details.Add("MinIO Tenant deployed");
}
else
{
details.Add("No MinIO Tenant CR found — provision via MinIO Tenants page");
}
// Determine status
if (operatorPods.Count == 0 && !tenantCrdExists)
{
return new ComponentCheckResult(ComponentName, ComponentStatus.NotInstalled, details, missing);
}
// Build discovered configuration
DiscoveredConfiguration config = new(
Version: null,
Namespace: "minio-operator",
HelmReleaseName: "minio-operator",
Values: new Dictionary<string, string>
{
["operatorReplicas"] = operatorPods.Count.ToString(),
["tenantCrdInstalled"] = tenantCrdExists.ToString(),
["hasTenant"] = hasTenant.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<List<string>> FindOperatorPods(Kubernetes client, CancellationToken ct)
{
List<string> pods = new();
try
{
V1PodList podList = await client.CoreV1.ListNamespacedPodAsync(
"minio-operator",
labelSelector: "app.kubernetes.io/name=operator",
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("minio-operator namespace not found");
}
catch (Exception ex)
{
logger.LogWarning(ex, "Error checking for MinIO operator pods");
}
return pods;
}
private async Task<bool> HasMinIOTenant(Kubernetes client, CancellationToken ct)
{
try
{
object result = await client.CustomObjects.ListClusterCustomObjectAsync(
group: "minio.min.io", version: "v2", plural: "tenants", cancellationToken: ct);
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() > 0;
}
}
catch
{
// CRD doesn't exist or API error
}
return false;
}
private async Task<bool> CrdExists(Kubernetes client, string crdName, CancellationToken ct)
{
try
{
await client.ApiextensionsV1.ReadCustomResourceDefinitionAsync(crdName, 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);
}
}

View File

@@ -1,864 +0,0 @@
using System.Text;
using EntKube.Clusters.Domain;
using k8s;
using Microsoft.Extensions.Logging;
namespace EntKube.Clusters.Features.AdoptCluster.Components.MinIO;
/// <summary>
/// Installs MinIO Operator via Helm. Mirrors the Terraform bootstrap/minio_operator
/// module. The operator watches all namespaces for Tenant CRDs and manages the
/// full lifecycle of MinIO clusters.
///
/// Configuration:
/// - Single operator replica (non-root, seccomp RuntimeDefault)
/// - Built-in console disabled (exposed via Gateway API instead)
/// - Resource requests/limits set for production
///
/// Note: This installs the OPERATOR only. Actual storage tenants are provisioned
/// separately via the Provisioning service.
/// </summary>
public class MinIOInstaller : IComponentInstaller
{
private readonly ILogger<MinIOInstaller> logger;
private const string DefaultVersion = "6.0.4";
private const string DefaultNamespace = "minio-operator";
private const string HelmRepo = "https://operator.min.io";
public string ComponentName => "minio";
public MinIOInstaller(ILogger<MinIOInstaller> logger)
{
this.logger = logger;
}
public async Task<InstallResult> InstallAsync(KubernetesCluster cluster, ComponentInstallOptions options, CancellationToken ct = default)
{
string version = options.Version ?? DefaultVersion;
string targetNamespace = options.Namespace ?? DefaultNamespace;
List<string> actions = new();
string tempKubeConfig = Path.Combine(Path.GetTempPath(), $"entkube-minio-{Guid.NewGuid()}.kubeconfig");
try
{
await File.WriteAllTextAsync(tempKubeConfig, cluster.KubeConfig, ct);
// Create namespace with compliance labels
Kubernetes client = BuildClient(cluster);
await EnsureNamespaceWithLabels(client, targetNamespace, ct);
actions.Add($"Ensured namespace '{targetNamespace}' with compliance labels");
// Patch Kyverno's MutatingWebhookConfiguration to exclude namespaces
// labeled with policies.kyverno.io/ignore=true. Without this, Kyverno
// mutates pods in infrastructure namespaces (disabling SA token mount).
await ExcludeNamespaceFromKyvernoWebhooks(client, ct);
// If MinIO was installed outside Helm (e.g., via Terraform), existing
// resources won't have Helm ownership labels. We must adopt them by
// adding the correct annotations/labels before Helm will manage them.
await AdoptExistingResourcesForHelm(client, targetNamespace, "minio-operator", ct);
actions.Add("Adopted existing resources for Helm management");
// Create a Kyverno PolicyException so that the minio-operator namespace is
// explicitly exempt from the restrict-sa-token-automount mutation policy.
// The operator needs the SA token to talk to the Kubernetes API. Without this,
// Kyverno's mutation webhook may race with the policy update and disable token mounting.
await CreateAutomountPolicyException(client, targetNamespace, ct);
// Delete existing Deployments before Helm install. Kubernetes does not allow
// changing spec.selector on a Deployment (it's immutable). The Helm chart uses
// different selector labels than what Terraform originally created, so we must
// delete and let Helm recreate them with the correct selectors.
await DeleteDeploymentsInNamespace(client, targetNamespace, ct);
actions.Add("Deleted existing deployments (immutable selector conflict)");
// Delete stuck CertificateSigningRequests from the MinIO operator.
// If the operator was previously running without a SA token (due to Kyverno
// mutation), it may have left CSRs in a conflicted state that loop forever.
await DeleteStuckMinioCSRs(client, ct);
actions.Add("Cleaned up stuck MinIO operator CSRs");
// Clear any stuck Helm release (pending-install/pending-upgrade) from a
// previous failed attempt. Helm refuses to operate on a release that has
// an in-progress operation.
await ClearStuckHelmRelease("minio-operator", targetNamespace, tempKubeConfig, cluster.ContextName, ct);
// Install MinIO Operator via Helm.
// We disable server-side apply so Helm uses classic 3-way merge, avoiding
// field ownership conflicts with resources originally created by Terraform/kubectl.
await RunCommand("helm",
$"upgrade --install minio-operator operator --repo {HelmRepo} --version {version} " +
$"--namespace {targetNamespace} --kubeconfig \"{tempKubeConfig}\" --kube-context \"{cluster.ContextName}\" " +
$"--server-side=false " +
$"--set operator.replicaCount=1 " +
$"--set operator.securityContext.runAsNonRoot=true " +
$"--set operator.securityContext.seccompProfile.type=RuntimeDefault " +
$"--set operator.resources.requests.cpu=100m " +
$"--set operator.resources.requests.memory=256Mi " +
$"--set operator.resources.limits.cpu=500m " +
$"--set operator.resources.limits.memory=512Mi " +
$"--set console.enabled=false " +
$"--timeout 10m",
ct);
actions.Add($"Helm install minio-operator v{version}");
// The MinIO Operator needs the SA token to talk to the Kubernetes API.
// Kyverno's mutation webhook may set automountServiceAccountToken=false
// on pods regardless of namespace exclusions. We patch the Deployment
// to explicitly enable it and trigger a rollout.
await PatchDeploymentAutomount(client, targetNamespace, ct);
actions.Add("Patched deployment to enable SA token automount");
logger.LogInformation("MinIO Operator {Version} installed on cluster {Cluster}",
version, cluster.Name);
return new InstallResult(
Success: true,
ComponentName: ComponentName,
Message: $"MinIO Operator {version} installed",
Actions: actions);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to install MinIO Operator on cluster {Cluster}", cluster.Name);
actions.Add($"Error: {ex.Message}");
return new InstallResult(
Success: false,
ComponentName: ComponentName,
Message: $"Failed to install MinIO Operator: {ex.Message}",
Actions: actions);
}
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);
// Namespace exists — patch it to add the Kyverno exclusion label.
// This tells Kyverno's webhook to skip all policy processing for this namespace,
// preventing mutation policies from disabling the SA token mount.
string patchJson = System.Text.Json.JsonSerializer.Serialize(new
{
metadata = new
{
labels = new Dictionary<string, string>
{
["app.kubernetes.io/managed-by"] = "entkube",
["app.kubernetes.io/part-of"] = "minio-platform",
["policies.kyverno.io/ignore"] = "true"
}
}
});
k8s.Models.V1Patch patch = new(patchJson, k8s.Models.V1Patch.PatchType.MergePatch);
await client.CoreV1.PatchNamespaceAsync(patch, namespaceName, cancellationToken: ct);
}
catch (k8s.Autorest.HttpOperationException ex) when (ex.Response.StatusCode == System.Net.HttpStatusCode.NotFound)
{
k8s.Models.V1Namespace ns = new()
{
Metadata = new k8s.Models.V1ObjectMeta
{
Name = namespaceName,
Labels = new Dictionary<string, string>
{
["app.kubernetes.io/managed-by"] = "entkube",
["app.kubernetes.io/part-of"] = "minio-platform",
["policies.kyverno.io/ignore"] = "true"
}
}
};
await client.CoreV1.CreateNamespaceAsync(ns, cancellationToken: ct);
}
}
/// <summary>
/// Patches Kyverno's MutatingWebhookConfigurations to add a namespaceSelector
/// that excludes namespaces with the label policies.kyverno.io/ignore=true.
/// This prevents Kyverno from mutating pods in infrastructure namespaces.
/// </summary>
private async Task ExcludeNamespaceFromKyvernoWebhooks(Kubernetes client, CancellationToken ct)
{
try
{
k8s.Models.V1MutatingWebhookConfigurationList webhookConfigs =
await client.AdmissionregistrationV1.ListMutatingWebhookConfigurationAsync(cancellationToken: ct);
foreach (k8s.Models.V1MutatingWebhookConfiguration config in webhookConfigs.Items)
{
if (config.Metadata?.Name == null || !config.Metadata.Name.Contains("kyverno"))
{
continue;
}
bool needsUpdate = false;
foreach (k8s.Models.V1MutatingWebhook webhook in config.Webhooks ?? new List<k8s.Models.V1MutatingWebhook>())
{
webhook.NamespaceSelector ??= new k8s.Models.V1LabelSelector();
webhook.NamespaceSelector.MatchExpressions ??= new List<k8s.Models.V1LabelSelectorRequirement>();
// Check if the ignore expression already exists.
bool hasIgnoreExpression = webhook.NamespaceSelector.MatchExpressions
.Any(e => e.Key == "policies.kyverno.io/ignore" && e.OperatorProperty == "DoesNotExist");
if (!hasIgnoreExpression)
{
webhook.NamespaceSelector.MatchExpressions.Add(new k8s.Models.V1LabelSelectorRequirement
{
Key = "policies.kyverno.io/ignore",
OperatorProperty = "DoesNotExist"
});
needsUpdate = true;
}
}
if (needsUpdate)
{
await client.AdmissionregistrationV1.ReplaceMutatingWebhookConfigurationAsync(
config, config.Metadata.Name, cancellationToken: ct);
logger.LogInformation("Patched Kyverno webhook {Name} to exclude namespaces with policies.kyverno.io/ignore label",
config.Metadata.Name);
}
}
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to patch Kyverno webhook configurations (non-fatal)");
}
}
/// <summary>
/// Patches all Deployments in the namespace to explicitly set
/// automountServiceAccountToken=true in the pod spec. This overrides any
/// mutation by Kyverno's webhook and triggers a new rollout with the token mounted.
/// </summary>
private async Task PatchDeploymentAutomount(Kubernetes client, string targetNamespace, CancellationToken ct)
{
k8s.Models.V1DeploymentList deployments = await client.AppsV1.ListNamespacedDeploymentAsync(targetNamespace, cancellationToken: ct);
foreach (k8s.Models.V1Deployment deployment in deployments.Items)
{
try
{
string patchJson = System.Text.Json.JsonSerializer.Serialize(new
{
spec = new
{
template = new
{
spec = new
{
automountServiceAccountToken = true
}
}
}
});
k8s.Models.V1Patch patch = new(patchJson, k8s.Models.V1Patch.PatchType.MergePatch);
await client.AppsV1.PatchNamespacedDeploymentAsync(patch, deployment.Metadata.Name, targetNamespace, cancellationToken: ct);
logger.LogInformation("Patched deployment {Name} to enable SA token automount", deployment.Metadata.Name);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to patch deployment {Name} for automount", deployment.Metadata.Name);
}
}
}
/// <summary>
/// Creates a Kyverno PolicyException that explicitly exempts the minio-operator
/// namespace from the restrict-sa-token-automount mutation policy. This is more
/// reliable than depending on the ClusterPolicy exclude block, which can race
/// with webhook caching during pod admission.
/// </summary>
private async Task CreateAutomountPolicyException(Kubernetes client, string targetNamespace, CancellationToken ct)
{
object policyException = new Dictionary<string, object>
{
["apiVersion"] = "kyverno.io/v2",
["kind"] = "PolicyException",
["metadata"] = new Dictionary<string, object>
{
["name"] = "entkube-minio-operator-automount",
["namespace"] = targetNamespace,
["labels"] = new Dictionary<string, string>
{
["app.kubernetes.io/managed-by"] = "entkube"
}
},
["spec"] = new Dictionary<string, object>
{
["exceptions"] = new List<object>
{
new Dictionary<string, object>
{
["policyName"] = "restrict-sa-token-automount",
["ruleNames"] = new List<string> { "*" }
}
},
["match"] = new Dictionary<string, object>
{
["any"] = new List<object>
{
new Dictionary<string, object>
{
["resources"] = new Dictionary<string, object>
{
["namespaces"] = new List<string> { targetNamespace },
["kinds"] = new List<string> { "Pod" }
}
}
}
}
}
};
try
{
await client.CustomObjects.CreateNamespacedCustomObjectAsync(
body: policyException,
group: "kyverno.io",
version: "v2",
namespaceParameter: targetNamespace,
plural: "policyexceptions",
cancellationToken: ct);
logger.LogInformation("Created PolicyException for SA token automount in {Namespace}", targetNamespace);
}
catch (k8s.Autorest.HttpOperationException ex) when (ex.Response.StatusCode == System.Net.HttpStatusCode.Conflict)
{
logger.LogDebug("PolicyException for automount already exists in {Namespace}", targetNamespace);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to create PolicyException in {Namespace} — operator may fail to start", targetNamespace);
}
}
/// <summary>
/// Ensures all ServiceAccounts in the namespace have automountServiceAccountToken enabled.
/// The MinIO Operator needs the SA token to communicate with the Kubernetes API.
/// A previous Kyverno mutation policy may have set this to false before the namespace
/// was added to the exclusion list.
/// </summary>
private async Task EnsureServiceAccountTokenMounting(Kubernetes client, string targetNamespace, CancellationToken ct)
{
k8s.Models.V1ServiceAccountList serviceAccounts = await client.CoreV1.ListNamespacedServiceAccountAsync(targetNamespace, cancellationToken: ct);
string patchJson = System.Text.Json.JsonSerializer.Serialize(new { automountServiceAccountToken = true });
k8s.Models.V1Patch patch = new(patchJson, k8s.Models.V1Patch.PatchType.MergePatch);
foreach (k8s.Models.V1ServiceAccount sa in serviceAccounts.Items)
{
try
{
await client.CoreV1.PatchNamespacedServiceAccountAsync(patch, sa.Metadata.Name, targetNamespace, cancellationToken: ct);
logger.LogDebug("Enabled automountServiceAccountToken on SA {Name}", sa.Metadata.Name);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to patch SA {Name} for token mounting", sa.Metadata.Name);
}
}
// Also restart the operator deployment so the new pod picks up the SA change.
try
{
k8s.Models.V1DeploymentList deployments = await client.AppsV1.ListNamespacedDeploymentAsync(targetNamespace, cancellationToken: ct);
foreach (k8s.Models.V1Deployment deployment in deployments.Items)
{
string restartPatch = System.Text.Json.JsonSerializer.Serialize(new
{
spec = new
{
template = new
{
metadata = new
{
annotations = new Dictionary<string, string>
{
["kubectl.kubernetes.io/restartedAt"] = DateTime.UtcNow.ToString("o")
}
}
}
}
});
k8s.Models.V1Patch deployPatch = new(restartPatch, k8s.Models.V1Patch.PatchType.MergePatch);
await client.AppsV1.PatchNamespacedDeploymentAsync(deployPatch, deployment.Metadata.Name, targetNamespace, cancellationToken: ct);
logger.LogInformation("Restarted deployment {Name} to pick up SA token mount", deployment.Metadata.Name);
}
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to restart deployments in {Namespace}", targetNamespace);
}
}
/// <summary>
/// Deletes stuck CertificateSigningRequests created by the MinIO operator.
/// When the operator ran without SA token access (due to Kyverno mutation),
/// it can leave CSRs in a conflicted state that cause an infinite retry loop.
/// Deleting them allows the operator to recreate them cleanly on restart.
/// </summary>
private async Task DeleteStuckMinioCSRs(Kubernetes client, CancellationToken ct)
{
try
{
string[] knownMinioCSRs = ["sts-minio-operator-csr", "operator-minio-operator-csr"];
foreach (string csrName in knownMinioCSRs)
{
try
{
await client.CertificatesV1.DeleteCertificateSigningRequestAsync(csrName, cancellationToken: ct);
logger.LogInformation("Deleted stuck CSR {Name}", csrName);
}
catch (k8s.Autorest.HttpOperationException ex) when (ex.Response.StatusCode == System.Net.HttpStatusCode.NotFound)
{
// CSR doesn't exist — nothing to clean up.
}
}
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to clean up MinIO operator CSRs (non-fatal)");
}
}
/// <summary>
/// Delete all Deployments in the target namespace. This is necessary because
/// Kubernetes does not allow changing spec.selector on an existing Deployment.
/// Helm will recreate them with the correct selector labels.
/// </summary>
private async Task DeleteDeploymentsInNamespace(Kubernetes client, string targetNamespace, CancellationToken ct)
{
k8s.Models.V1DeploymentList deployments = await client.AppsV1.ListNamespacedDeploymentAsync(targetNamespace, cancellationToken: ct);
foreach (k8s.Models.V1Deployment deployment in deployments.Items)
{
logger.LogInformation("Deleting deployment {Name} in {Namespace} to resolve immutable selector conflict",
deployment.Metadata.Name, targetNamespace);
await client.AppsV1.DeleteNamespacedDeploymentAsync(deployment.Metadata.Name, targetNamespace, cancellationToken: ct);
}
}
/// <summary>
/// When a component was originally installed outside Helm (e.g., via Terraform
/// or raw manifests), its resources lack Helm ownership metadata. Helm refuses
/// to adopt these resources during `upgrade --install`. This method patches all
/// ServiceAccounts, Deployments, Services, ClusterRoles, and ClusterRoleBindings
/// in the target namespace to add the required Helm labels and annotations.
///
/// After this, Helm recognizes them as part of the release and can manage them.
/// </summary>
private async Task AdoptExistingResourcesForHelm(Kubernetes client, string targetNamespace, string releaseName, CancellationToken ct)
{
string patchJson = System.Text.Json.JsonSerializer.Serialize(new
{
metadata = new
{
labels = new Dictionary<string, string>
{
["app.kubernetes.io/managed-by"] = "Helm"
},
annotations = new Dictionary<string, string>
{
["meta.helm.sh/release-name"] = releaseName,
["meta.helm.sh/release-namespace"] = targetNamespace
}
}
});
k8s.Models.V1Patch patch = new(patchJson, k8s.Models.V1Patch.PatchType.MergePatch);
// Patch ServiceAccounts in the namespace.
try
{
k8s.Models.V1ServiceAccountList serviceAccounts = await client.CoreV1.ListNamespacedServiceAccountAsync(targetNamespace, cancellationToken: ct);
foreach (k8s.Models.V1ServiceAccount sa in serviceAccounts.Items)
{
if (sa.Metadata.Name == "default")
{
continue;
}
try
{
await client.CoreV1.PatchNamespacedServiceAccountAsync(patch, sa.Metadata.Name, targetNamespace, cancellationToken: ct);
logger.LogDebug("Adopted ServiceAccount {Name} for Helm", sa.Metadata.Name);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to adopt ServiceAccount {Name}", sa.Metadata.Name);
}
}
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to list ServiceAccounts in {Namespace}", targetNamespace);
}
// Patch Deployments in the namespace.
try
{
k8s.Models.V1DeploymentList deployments = await client.AppsV1.ListNamespacedDeploymentAsync(targetNamespace, cancellationToken: ct);
foreach (k8s.Models.V1Deployment deployment in deployments.Items)
{
try
{
await client.AppsV1.PatchNamespacedDeploymentAsync(patch, deployment.Metadata.Name, targetNamespace, cancellationToken: ct);
logger.LogDebug("Adopted Deployment {Name} for Helm", deployment.Metadata.Name);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to adopt Deployment {Name}", deployment.Metadata.Name);
}
}
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to list Deployments in {Namespace}", targetNamespace);
}
// Patch Services in the namespace.
try
{
k8s.Models.V1ServiceList services = await client.CoreV1.ListNamespacedServiceAsync(targetNamespace, cancellationToken: ct);
foreach (k8s.Models.V1Service service in services.Items)
{
if (service.Metadata.Name == "kubernetes")
{
continue;
}
try
{
await client.CoreV1.PatchNamespacedServiceAsync(patch, service.Metadata.Name, targetNamespace, cancellationToken: ct);
logger.LogDebug("Adopted Service {Name} for Helm", service.Metadata.Name);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to adopt Service {Name}", service.Metadata.Name);
}
}
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to list Services in {Namespace}", targetNamespace);
}
// Patch ClusterRoles and ClusterRoleBindings that belong to this component.
// These are cluster-scoped, so we filter by name prefix matching the release.
try
{
k8s.Models.V1ClusterRoleList clusterRoles = await client.RbacAuthorizationV1.ListClusterRoleAsync(cancellationToken: ct);
foreach (k8s.Models.V1ClusterRole role in clusterRoles.Items)
{
if (role.Metadata.Name.Contains("minio", StringComparison.OrdinalIgnoreCase))
{
try
{
await client.RbacAuthorizationV1.PatchClusterRoleAsync(patch, role.Metadata.Name, cancellationToken: ct);
logger.LogDebug("Adopted ClusterRole {Name} for Helm", role.Metadata.Name);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to adopt ClusterRole {Name}", role.Metadata.Name);
}
}
}
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to list ClusterRoles");
}
try
{
k8s.Models.V1ClusterRoleBindingList bindings = await client.RbacAuthorizationV1.ListClusterRoleBindingAsync(cancellationToken: ct);
foreach (k8s.Models.V1ClusterRoleBinding binding in bindings.Items)
{
if (binding.Metadata.Name.Contains("minio", StringComparison.OrdinalIgnoreCase))
{
try
{
await client.RbacAuthorizationV1.PatchClusterRoleBindingAsync(patch, binding.Metadata.Name, cancellationToken: ct);
logger.LogDebug("Adopted ClusterRoleBinding {Name} for Helm", binding.Metadata.Name);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to adopt ClusterRoleBinding {Name}", binding.Metadata.Name);
}
}
}
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to list ClusterRoleBindings");
}
// Patch CRDs that belong to MinIO. These are cluster-scoped and Helm
// refuses to adopt them without the ownership metadata.
try
{
k8s.Models.V1CustomResourceDefinitionList crds = await client.ApiextensionsV1.ListCustomResourceDefinitionAsync(cancellationToken: ct);
foreach (k8s.Models.V1CustomResourceDefinition crd in crds.Items)
{
if (crd.Metadata.Name.Contains("minio", StringComparison.OrdinalIgnoreCase)
|| crd.Metadata.Name.EndsWith(".min.io", StringComparison.OrdinalIgnoreCase))
{
try
{
await client.ApiextensionsV1.PatchCustomResourceDefinitionAsync(patch, crd.Metadata.Name, cancellationToken: ct);
logger.LogDebug("Adopted CRD {Name} for Helm", crd.Metadata.Name);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to adopt CRD {Name}", crd.Metadata.Name);
}
}
}
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to list CRDs");
}
// Patch Secrets (Helm stores release state in secrets) and ConfigMaps.
try
{
k8s.Models.V1SecretList secrets = await client.CoreV1.ListNamespacedSecretAsync(targetNamespace, cancellationToken: ct);
foreach (k8s.Models.V1Secret secret in secrets.Items)
{
if (secret.Type == "kubernetes.io/service-account-token")
{
continue;
}
try
{
await client.CoreV1.PatchNamespacedSecretAsync(patch, secret.Metadata.Name, targetNamespace, cancellationToken: ct);
}
catch (Exception ex)
{
logger.LogDebug(ex, "Failed to adopt Secret {Name}", secret.Metadata.Name);
}
}
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to list Secrets in {Namespace}", targetNamespace);
}
}
/// <summary>
/// If a previous Helm operation failed mid-way, the release may be stuck in
/// pending-install or pending-upgrade state. Helm refuses any new operations
/// until this is resolved. We detect the stuck state and uninstall the release
/// so the next upgrade --install starts fresh.
/// </summary>
private async Task ClearStuckHelmRelease(string releaseName, string targetNamespace, string kubeConfigPath, string contextName, CancellationToken ct)
{
try
{
string status = await RunCommand("helm",
$"status {releaseName} --namespace {targetNamespace} --kubeconfig \"{kubeConfigPath}\" --kube-context \"{contextName}\" -o json",
ct);
if (status.Contains("pending-install") || status.Contains("pending-upgrade") || status.Contains("pending-rollback"))
{
logger.LogWarning("Helm release {Release} is stuck, removing it before retry", releaseName);
await RunCommand("helm",
$"uninstall {releaseName} --namespace {targetNamespace} --kubeconfig \"{kubeConfigPath}\" --kube-context \"{contextName}\" --no-hooks",
ct);
}
}
catch (Exception ex)
{
// If helm status fails (e.g., release doesn't exist yet), that's fine — nothing to clear.
logger.LogDebug(ex, "No stuck release to clear for {Release}", releaseName);
}
}
private async Task<string> 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);
}
/// <summary>
/// Reconfigures MinIO Operator. Supports:
/// - "operatorReplicas": number of operator replicas
/// </summary>
public async Task<InstallResult> ConfigureAsync(KubernetesCluster cluster, ComponentConfiguration configuration, CancellationToken ct = default)
{
string targetNamespace = configuration.Namespace ?? DefaultNamespace;
List<string> actions = new();
string tempKubeConfig = Path.Combine(Path.GetTempPath(), $"entkube-minio-cfg-{Guid.NewGuid()}.kubeconfig");
try
{
await File.WriteAllTextAsync(tempKubeConfig, cluster.KubeConfig, ct);
List<string> setFlags = new();
if (configuration.Values.TryGetValue("operatorReplicas", out string? replicas))
{
setFlags.Add($"--set operator.replicaCount={replicas}");
}
await RunCommand("helm",
$"upgrade minio-operator operator --repo {HelmRepo} " +
$"--namespace {targetNamespace} " +
$"--kubeconfig \"{tempKubeConfig}\" --kube-context \"{cluster.ContextName}\" " +
$"--reuse-values {string.Join(" ", setFlags)} --wait --timeout 5m",
ct);
actions.Add($"Reconfigured MinIO Operator: {string.Join(", ", configuration.Values.Select(kv => $"{kv.Key}={kv.Value}"))}");
return new InstallResult(
Success: true,
ComponentName: ComponentName,
Message: "MinIO Operator reconfigured",
Actions: actions);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to reconfigure MinIO on cluster {Cluster}", cluster.Name);
return new InstallResult(
Success: false,
ComponentName: ComponentName,
Message: $"Failed to reconfigure MinIO: {ex.Message}",
Actions: new List<string> { $"Error: {ex.Message}" });
}
finally
{
if (File.Exists(tempKubeConfig))
{
File.Delete(tempKubeConfig);
}
}
}
/// <summary>
/// Uninstalls the MinIO Operator from the cluster by removing the Helm release.
/// Warning: existing MinIO Tenant resources will become unmanaged.
/// </summary>
public async Task<InstallResult> UninstallAsync(KubernetesCluster cluster, ComponentInstallOptions options, CancellationToken ct = default)
{
string targetNamespace = options.Namespace ?? DefaultNamespace;
List<string> actions = new();
string tempKubeConfig = Path.Combine(Path.GetTempPath(), $"entkube-minio-rm-{Guid.NewGuid()}.kubeconfig");
try
{
await File.WriteAllTextAsync(tempKubeConfig, cluster.KubeConfig, ct);
await RunCommand("helm",
$"uninstall minio-operator --namespace {targetNamespace} " +
$"--kubeconfig \"{tempKubeConfig}\" --kube-context \"{cluster.ContextName}\" " +
$"--wait --timeout 5m",
ct);
actions.Add("Helm uninstall minio-operator");
logger.LogInformation("MinIO Operator uninstalled from cluster {Cluster}", cluster.Name);
return new InstallResult(
Success: true,
ComponentName: ComponentName,
Message: "MinIO Operator uninstalled successfully",
Actions: actions);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to uninstall MinIO from cluster {Cluster}", cluster.Name);
return new InstallResult(
Success: false,
ComponentName: ComponentName,
Message: $"Failed to uninstall MinIO: {ex.Message}",
Actions: new List<string> { $"Error: {ex.Message}" });
}
finally
{
if (File.Exists(tempKubeConfig))
{
File.Delete(tempKubeConfig);
}
}
}
}

View File

@@ -1,273 +0,0 @@
using System.Text;
using EntKube.Clusters.Domain;
using k8s;
using k8s.Models;
using Microsoft.Extensions.Logging;
namespace EntKube.Clusters.Features.AdoptCluster.Components.MongoDBCommunity;
/// <summary>
/// Checks whether the MongoDB Community Operator is installed and healthy on the cluster.
/// The MongoDB Community Operator manages MongoDBCommunity custom resources that provision
/// and maintain replica sets backed by MongoDB. It handles authentication (SCRAM),
/// member scaling, version upgrades, and TLS configuration.
///
/// This check verifies:
/// 1. Operator pods running in the mongodb namespace
/// 2. MongoDBCommunity CRD (mongodbcommunity.mongodbcommunity.mongodb.com) is registered
/// 3. Any existing MongoDBCommunity replica sets managed by the operator
/// </summary>
public class MongoDBCommunityCheck : IAdoptionCheck
{
private readonly ILogger<MongoDBCommunityCheck> logger;
public string ComponentName => "mongodb-community";
public string DisplayName => "MongoDB Community Operator";
public MongoDBCommunityCheck(ILogger<MongoDBCommunityCheck> logger)
{
this.logger = logger;
}
public async Task<ComponentCheckResult> CheckAsync(KubernetesCluster cluster, CancellationToken ct = default)
{
Kubernetes client = BuildClient(cluster);
List<string> details = new();
List<string> missing = new();
// Check 1: MongoDBCommunity CRD exists (confirms operator CRDs installed).
// We check the CRD first because it's the most reliable signal — pods
// may have varying labels across operator versions.
bool crdExists = await MongoCrdExists(client, ct);
if (crdExists)
{
details.Add("MongoDBCommunity CRD present");
}
else
{
missing.Add("MongoDBCommunity CRD not found");
}
// Check 2: Operator pods. The community-operator Helm chart labels pods
// with app.kubernetes.io/name=mongodb-community-operator. Older versions
// or manual installs may use mongodb-kubernetes-operator or name=mongodb-kubernetes-operator.
List<string> operatorPods = await FindOperatorPods(client, ct);
if (operatorPods.Count > 0)
{
details.AddRange(operatorPods.Select(p => $"Operator pod: {p}"));
}
else
{
missing.Add("No MongoDB Community Operator pods found");
}
// If neither the CRD nor any pods were found, the operator is not installed.
if (!crdExists && operatorPods.Count == 0)
{
return new ComponentCheckResult(ComponentName, ComponentStatus.NotInstalled, details, missing);
}
// Check 3: Discover existing MongoDBCommunity resources across all namespaces.
int replicaSetCount = await CountMongoReplicaSets(client, ct);
details.Add($"Managed MongoDB replica sets: {replicaSetCount}");
// Discover operator version from the controller pod image tag.
string? version = await GetVersionFromPods(client, ct);
DiscoveredConfiguration config = new(
Version: version,
Namespace: "mongodb",
HelmReleaseName: "mongodb-community-operator",
Values: new Dictionary<string, string>
{
["operatorReplicas"] = operatorPods.Count.ToString(),
["managedReplicaSets"] = replicaSetCount.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<string?> GetVersionFromPods(Kubernetes client, CancellationToken ct)
{
// Try each known label selector until we find a matching pod.
foreach (string selector in OperatorLabelSelectors)
{
try
{
V1PodList podList = await client.CoreV1.ListNamespacedPodAsync(
"mongodb",
labelSelector: selector,
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;
}
/// <summary>
/// Known label selectors for MongoDB operator pods across different versions
/// and installation methods. The community-operator Helm chart uses
/// mongodb-community-operator, while older or manual installs may use
/// mongodb-kubernetes-operator.
/// </summary>
private static readonly string[] OperatorLabelSelectors =
[
"app.kubernetes.io/name=mongodb-community-operator",
"app.kubernetes.io/name=mongodb-kubernetes-operator",
"name=mongodb-kubernetes-operator",
"name=mongodb-community-operator"
];
private async Task<List<string>> FindOperatorPods(Kubernetes client, CancellationToken ct)
{
List<string> pods = new();
// The MongoDB Community Operator may live in a dedicated namespace
// (commonly "mongodb") or in a general-purpose namespace. We try
// multiple label selectors since different versions/install methods
// use different labels.
foreach (string selector in OperatorLabelSelectors)
{
try
{
V1PodList podList = await client.CoreV1.ListNamespacedPodAsync(
"mongodb",
labelSelector: selector,
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("mongodb namespace not found on cluster");
}
catch (Exception ex)
{
logger.LogWarning(ex, "Error checking for MongoDB Community Operator pods with selector {Selector}", selector);
}
if (pods.Count > 0)
{
return pods;
}
}
// If no pods in the dedicated namespace, search cluster-wide as a fallback.
foreach (string selector in OperatorLabelSelectors)
{
try
{
V1PodList allPods = await client.CoreV1.ListPodForAllNamespacesAsync(
labelSelector: selector,
cancellationToken: ct);
foreach (V1Pod pod in allPods.Items)
{
if (pod.Status?.Phase == "Running")
{
pods.Add($"{pod.Metadata.NamespaceProperty}/{pod.Metadata.Name}");
}
}
}
catch (Exception ex)
{
logger.LogWarning(ex, "Error searching cluster-wide for MongoDB operator pods with selector {Selector}", selector);
}
if (pods.Count > 0)
{
return pods;
}
}
return pods;
}
private async Task<bool> MongoCrdExists(Kubernetes client, CancellationToken ct)
{
try
{
await client.ApiextensionsV1.ReadCustomResourceDefinitionAsync(
"mongodbcommunity.mongodbcommunity.mongodb.com", cancellationToken: ct);
return true;
}
catch
{
return false;
}
}
private async Task<int> CountMongoReplicaSets(Kubernetes client, CancellationToken ct)
{
try
{
object result = await client.CustomObjects.ListClusterCustomObjectAsync(
group: "mongodbcommunity.mongodb.com",
version: "v1",
plural: "mongodbcommunity",
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);
}
}

View File

@@ -1,277 +0,0 @@
using System.Text;
using EntKube.Clusters.Domain;
using k8s;
using k8s.Models;
using Microsoft.Extensions.Logging;
namespace EntKube.Clusters.Features.AdoptCluster.Components.MongoDBCommunity;
/// <summary>
/// Installs the MongoDB Community Operator via Helm. The operator manages the full
/// lifecycle of MongoDB replica sets on Kubernetes — provisioning, SCRAM authentication,
/// member scaling, version upgrades, and TLS configuration.
///
/// What gets deployed:
/// 1. mongodb namespace with the operator controller
/// 2. MongoDBCommunity CRD for declaring replica sets
/// 3. RBAC and service account for the operator
///
/// Configuration keys:
/// - "operatorReplicas": Number of operator controller replicas (default: 1)
/// - "watchNamespace": Namespace to watch for MongoDBCommunity resources (* = all)
///
/// Note: This installs the OPERATOR only. Actual MongoDB replica sets are provisioned
/// separately via the Provisioning service (per-tenant databases).
/// </summary>
public class MongoDBCommunityInstaller : IComponentInstaller
{
private readonly ILogger<MongoDBCommunityInstaller> logger;
private const string DefaultVersion = "0.11.0";
private const string DefaultNamespace = "mongodb";
private const string HelmRepo = "https://mongodb.github.io/helm-charts";
public string ComponentName => "mongodb-community";
public MongoDBCommunityInstaller(ILogger<MongoDBCommunityInstaller> logger)
{
this.logger = logger;
}
public async Task<InstallResult> InstallAsync(KubernetesCluster cluster, ComponentInstallOptions options, CancellationToken ct = default)
{
string version = options.Version ?? DefaultVersion;
string targetNamespace = options.Namespace ?? DefaultNamespace;
string watchNamespace = options.Parameters?.TryGetValue("watchNamespace", out string? watchNs) == true
&& !string.IsNullOrEmpty(watchNs) ? watchNs : "*";
List<string> actions = new();
string tempKubeConfig = Path.Combine(Path.GetTempPath(), $"entkube-mongodb-{Guid.NewGuid()}.kubeconfig");
try
{
await File.WriteAllTextAsync(tempKubeConfig, cluster.KubeConfig, ct);
// Ensure the namespace exists with management labels so we can
// track what EntKube deployed on this cluster.
Kubernetes client = BuildClient(cluster);
await EnsureNamespaceWithLabels(client, targetNamespace, ct);
actions.Add($"Ensured namespace '{targetNamespace}' with management labels");
// Install MongoDB Community Operator via Helm. The chart includes
// the CRD, operator deployment, RBAC, and service account.
await RunCommand("helm",
$"upgrade --install mongodb-community-operator community-operator " +
$"--repo {HelmRepo} --version {version} " +
$"--namespace {targetNamespace} " +
$"--kubeconfig \"{tempKubeConfig}\" --kube-context \"{cluster.ContextName}\" " +
$"--set operator.replicas=1 " +
$"--set operator.watchNamespace=\"{watchNamespace}\" " +
$"--set operator.resources.requests.cpu=100m " +
$"--set operator.resources.requests.memory=256Mi " +
$"--set operator.resources.limits.cpu=500m " +
$"--set operator.resources.limits.memory=512Mi " +
$"--wait --timeout 10m",
ct);
actions.Add($"Helm install mongodb-community-operator v{version}");
logger.LogInformation("MongoDB Community Operator {Version} installed on cluster {Cluster}",
version, cluster.Name);
return new InstallResult(
Success: true,
ComponentName: ComponentName,
Message: $"MongoDB Community Operator {version} installed",
Actions: actions);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to install MongoDB Community Operator on cluster {Cluster}", cluster.Name);
actions.Add($"Error: {ex.Message}");
return new InstallResult(
Success: false,
ComponentName: ComponentName,
Message: $"Failed to install MongoDB Community Operator: {ex.Message}",
Actions: actions);
}
finally
{
if (File.Exists(tempKubeConfig))
{
File.Delete(tempKubeConfig);
}
}
}
/// <summary>
/// Reconfigures the MongoDB Community Operator. Supports:
/// - "operatorReplicas": number of operator replicas
/// - "watchNamespace": namespace the operator watches for CRs
/// </summary>
public async Task<InstallResult> ConfigureAsync(KubernetesCluster cluster, ComponentConfiguration configuration, CancellationToken ct = default)
{
string targetNamespace = configuration.Namespace ?? DefaultNamespace;
List<string> actions = new();
string tempKubeConfig = Path.Combine(Path.GetTempPath(), $"entkube-mongodb-cfg-{Guid.NewGuid()}.kubeconfig");
try
{
await File.WriteAllTextAsync(tempKubeConfig, cluster.KubeConfig, ct);
List<string> setFlags = new();
if (configuration.Values.TryGetValue("operatorReplicas", out string? replicas))
{
setFlags.Add($"--set operator.replicas={replicas}");
}
if (configuration.Values.TryGetValue("watchNamespace", out string? watchNs))
{
setFlags.Add($"--set operator.watchNamespace=\"{watchNs}\"");
}
await RunCommand("helm",
$"upgrade mongodb-community-operator community-operator --repo {HelmRepo} " +
$"--namespace {targetNamespace} " +
$"--kubeconfig \"{tempKubeConfig}\" --kube-context \"{cluster.ContextName}\" " +
$"--reuse-values {string.Join(" ", setFlags)} --wait --timeout 5m",
ct);
actions.Add($"Reconfigured MongoDB Community Operator: {string.Join(", ", configuration.Values.Select(kv => $"{kv.Key}={kv.Value}"))}");
return new InstallResult(
Success: true,
ComponentName: ComponentName,
Message: "MongoDB Community Operator reconfigured",
Actions: actions);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to reconfigure MongoDB Community Operator on cluster {Cluster}", cluster.Name);
return new InstallResult(
Success: false,
ComponentName: ComponentName,
Message: $"Failed to reconfigure MongoDB Community Operator: {ex.Message}",
Actions: new List<string> { $"Error: {ex.Message}" });
}
finally
{
if (File.Exists(tempKubeConfig))
{
File.Delete(tempKubeConfig);
}
}
}
/// <summary>
/// Uninstalls the MongoDB Community Operator from the cluster. Warning: existing
/// MongoDBCommunity resources will become unmanaged.
/// </summary>
public async Task<InstallResult> UninstallAsync(KubernetesCluster cluster, ComponentInstallOptions options, CancellationToken ct = default)
{
string targetNamespace = options.Namespace ?? DefaultNamespace;
List<string> actions = new();
string tempKubeConfig = Path.Combine(Path.GetTempPath(), $"entkube-mongodb-rm-{Guid.NewGuid()}.kubeconfig");
try
{
await File.WriteAllTextAsync(tempKubeConfig, cluster.KubeConfig, ct);
await RunCommand("helm",
$"uninstall mongodb-community-operator --namespace {targetNamespace} " +
$"--kubeconfig \"{tempKubeConfig}\" --kube-context \"{cluster.ContextName}\" " +
$"--wait --timeout 5m",
ct);
actions.Add("Helm uninstall mongodb-community-operator");
logger.LogInformation("MongoDB Community Operator uninstalled from cluster {Cluster}", cluster.Name);
return new InstallResult(
Success: true,
ComponentName: ComponentName,
Message: "MongoDB Community Operator uninstalled successfully",
Actions: actions);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to uninstall MongoDB Community Operator from cluster {Cluster}", cluster.Name);
return new InstallResult(
Success: false,
ComponentName: ComponentName,
Message: $"Failed to uninstall MongoDB Community Operator: {ex.Message}",
Actions: new List<string> { $"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<string, string>
{
["app.kubernetes.io/managed-by"] = "entkube",
["app.kubernetes.io/part-of"] = "mongodb-platform"
}
}
};
await client.CoreV1.CreateNamespaceAsync(ns, cancellationToken: ct);
}
}
private async Task<string> 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);
}
}

View File

@@ -1,167 +0,0 @@
using System.Text;
using EntKube.Clusters.Domain;
using k8s;
using k8s.Models;
using Microsoft.Extensions.Logging;
namespace EntKube.Clusters.Features.AdoptCluster.Components.Monitoring;
/// <summary>
/// Checks whether the monitoring stack (kube-prometheus-stack) is installed.
/// The Terraform bootstrap/monitoring module deploys a full kube-prometheus-stack
/// including Prometheus, Alertmanager, node-exporter, kube-state-metrics, and Grafana.
///
/// This check verifies:
/// 1. Prometheus pods running in monitoring namespace
/// 2. kube-state-metrics present
/// 3. ServiceMonitor CRD exists (confirms operator installed)
/// </summary>
public class MonitoringCheck : IAdoptionCheck
{
private readonly ILogger<MonitoringCheck> logger;
public string ComponentName => "monitoring";
public string DisplayName => "Monitoring (kube-prometheus-stack)";
public MonitoringCheck(ILogger<MonitoringCheck> logger)
{
this.logger = logger;
}
public async Task<ComponentCheckResult> CheckAsync(KubernetesCluster cluster, CancellationToken ct = default)
{
Kubernetes client = BuildClient(cluster);
List<string> details = new();
List<string> missing = new();
// Check 1: Prometheus pods in monitoring namespace
List<string> promPods = await FindPodsByLabel(client, "monitoring", "app.kubernetes.io/name=prometheus", ct);
if (promPods.Count > 0)
{
details.AddRange(promPods.Select(p => $"Prometheus pod: {p}"));
}
else
{
missing.Add("No Prometheus pods found in monitoring namespace");
}
// Check 2: kube-state-metrics pods
List<string> ksmPods = await FindPodsByLabel(client, "monitoring", "app.kubernetes.io/name=kube-state-metrics", ct);
if (ksmPods.Count > 0)
{
details.Add($"kube-state-metrics running ({ksmPods.Count} pods)");
}
else
{
missing.Add("kube-state-metrics not found");
}
// Check 3: ServiceMonitor CRD (confirms prometheus-operator installed)
bool serviceMonitorCrd = await CrdExists(client, "servicemonitors.monitoring.coreos.com", ct);
if (serviceMonitorCrd)
{
details.Add("ServiceMonitor CRD present");
}
else
{
missing.Add("ServiceMonitor CRD not found (monitoring.coreos.com)");
}
// Check 4: Alertmanager
List<string> alertPods = await FindPodsByLabel(client, "monitoring", "app.kubernetes.io/name=alertmanager", ct);
if (alertPods.Count > 0)
{
details.Add($"Alertmanager running ({alertPods.Count} pods)");
}
else
{
missing.Add("Alertmanager not found");
}
// Determine status
if (promPods.Count == 0 && !serviceMonitorCrd)
{
return new ComponentCheckResult(ComponentName, ComponentStatus.NotInstalled, details, missing);
}
// Build discovered configuration
DiscoveredConfiguration config = new(
Version: null,
Namespace: "monitoring",
HelmReleaseName: "kube-prometheus-stack",
Values: new Dictionary<string, string>
{
["prometheusReplicas"] = promPods.Count.ToString(),
["alertmanagerReplicas"] = alertPods.Count.ToString(),
["kubeStateMetricsRunning"] = (ksmPods.Count > 0).ToString(),
["serviceMonitorCrdInstalled"] = serviceMonitorCrd.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<List<string>> FindPodsByLabel(Kubernetes client, string ns, string labelSelector, CancellationToken ct)
{
List<string> pods = new();
try
{
V1PodList podList = await client.CoreV1.ListNamespacedPodAsync(
ns, labelSelector: labelSelector, 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("Namespace {Namespace} not found", ns);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Error listing pods in {Namespace} with {Label}", ns, labelSelector);
}
return pods;
}
private async Task<bool> CrdExists(Kubernetes client, string crdName, CancellationToken ct)
{
try
{
await client.ApiextensionsV1.ReadCustomResourceDefinitionAsync(crdName, 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);
}
}

View File

@@ -1,353 +0,0 @@
using System.Text;
using EntKube.Clusters.Domain;
using k8s;
using Microsoft.Extensions.Logging;
namespace EntKube.Clusters.Features.AdoptCluster.Components.Monitoring;
/// <summary>
/// Installs kube-prometheus-stack via Helm. Mirrors the Terraform bootstrap/monitoring
/// module. Deploys a full monitoring stack with:
/// - Prometheus (HA, 2 replicas, 50Gi persistent storage, 30d retention)
/// - Alertmanager (HA)
/// - Grafana (with dashboard sidecar, searches ALL namespaces)
/// - kube-state-metrics (with tenant label allowlisting)
/// - node-exporter
/// - ServiceMonitor/PodMonitor selector: discover ALL (no Helm label filter)
///
/// This is what gets called when the user clicks "Install" from the registration
/// flow or from the adoption report.
/// </summary>
public class MonitoringInstaller : IComponentInstaller
{
private readonly ILogger<MonitoringInstaller> logger;
private const string DefaultVersion = "67.4.0";
private const string DefaultNamespace = "monitoring";
private const string HelmRepo = "https://prometheus-community.github.io/helm-charts";
public string ComponentName => "monitoring";
public MonitoringInstaller(ILogger<MonitoringInstaller> logger)
{
this.logger = logger;
}
public async Task<InstallResult> InstallAsync(KubernetesCluster cluster, ComponentInstallOptions options, CancellationToken ct = default)
{
string version = options.Version ?? DefaultVersion;
string targetNamespace = options.Namespace ?? DefaultNamespace;
List<string> actions = new();
string tempKubeConfig = Path.Combine(Path.GetTempPath(), $"entkube-monitoring-{Guid.NewGuid()}.kubeconfig");
string valuesPath = Path.Combine(Path.GetTempPath(), $"entkube-monitoring-values-{Guid.NewGuid()}.yaml");
try
{
await File.WriteAllTextAsync(tempKubeConfig, cluster.KubeConfig, ct);
// Create namespace
Kubernetes client = BuildClient(cluster);
await EnsureNamespaceExists(client, targetNamespace, ct);
actions.Add($"Ensured namespace '{targetNamespace}'");
// Write production values (mirrors Terraform configuration)
string values = GetProductionValues();
await File.WriteAllTextAsync(valuesPath, values, ct);
// Install kube-prometheus-stack
await RunCommand("helm",
$"upgrade --install kube-prometheus-stack kube-prometheus-stack " +
$"--repo {HelmRepo} --version {version} " +
$"--namespace {targetNamespace} --create-namespace " +
$"--kubeconfig \"{tempKubeConfig}\" --kube-context \"{cluster.ContextName}\" " +
$"--values \"{valuesPath}\" " +
$"--wait --timeout 10m",
ct);
actions.Add($"Helm install kube-prometheus-stack v{version}");
logger.LogInformation("Monitoring stack v{Version} installed on cluster {Cluster}",
version, cluster.Name);
return new InstallResult(
Success: true,
ComponentName: ComponentName,
Message: $"kube-prometheus-stack {version} installed (Prometheus + Grafana + Alertmanager)",
Actions: actions);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to install monitoring on cluster {Cluster}", cluster.Name);
actions.Add($"Error: {ex.Message}");
return new InstallResult(
Success: false,
ComponentName: ComponentName,
Message: $"Failed to install monitoring: {ex.Message}",
Actions: actions);
}
finally
{
if (File.Exists(tempKubeConfig))
{
File.Delete(tempKubeConfig);
}
if (File.Exists(valuesPath))
{
File.Delete(valuesPath);
}
}
}
/// <summary>
/// Production values matching the Terraform bootstrap/monitoring module:
/// - Grafana enabled with dashboard sidecar (searches ALL namespaces)
/// - kube-state-metrics with tenant label allowlisting
/// - Prometheus discovers ALL ServiceMonitors/PodMonitors (no Helm label restriction)
/// - HA Prometheus (2 replicas), 30d retention, 50Gi storage
/// - CoreDNS built-in dashboard disabled (replaced by custom)
/// </summary>
private static string GetProductionValues()
{
return """
grafana:
enabled: true
sidecar:
dashboards:
default:
enabled: true
label: grafana_dashboard
searchNamespace: ALL
kube-state-metrics:
metricLabelsAllowlist:
- "namespaces=[entkube.io/customer,entkube.io/app,entkube.io/env]"
- "pods=[entkube.io/customer,entkube.io/app]"
coreDns:
enabled: false
prometheus:
prometheusSpec:
replicas: 2
retention: 30d
retentionSize: "45GB"
serviceMonitorSelectorNilUsesHelmValues: false
podMonitorSelectorNilUsesHelmValues: false
resources:
requests:
cpu: 500m
memory: 2Gi
limits:
cpu: "2"
memory: 8Gi
storageSpec:
volumeClaimTemplate:
spec:
accessModes: ["ReadWriteOnce"]
resources:
requests:
storage: 50Gi
alertmanager:
alertmanagerSpec:
replicas: 2
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: 500m
memory: 256Mi
""";
}
/// <summary>
/// Uninstalls the kube-prometheus-stack from the cluster, removing Prometheus,
/// Grafana, Alertmanager, and all monitoring infrastructure.
/// </summary>
public async Task<InstallResult> UninstallAsync(KubernetesCluster cluster, ComponentInstallOptions options, CancellationToken ct = default)
{
string targetNamespace = options.Namespace ?? DefaultNamespace;
List<string> actions = new();
string tempKubeConfig = Path.Combine(Path.GetTempPath(), $"entkube-monitoring-rm-{Guid.NewGuid()}.kubeconfig");
try
{
await File.WriteAllTextAsync(tempKubeConfig, cluster.KubeConfig, ct);
await RunCommand("helm",
$"uninstall kube-prometheus-stack --namespace {targetNamespace} " +
$"--kubeconfig \"{tempKubeConfig}\" --kube-context \"{cluster.ContextName}\" " +
$"--wait --timeout 5m",
ct);
actions.Add("Helm uninstall kube-prometheus-stack");
logger.LogInformation("Monitoring stack uninstalled from cluster {Cluster}", cluster.Name);
return new InstallResult(
Success: true,
ComponentName: ComponentName,
Message: "kube-prometheus-stack uninstalled successfully",
Actions: actions);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to uninstall monitoring from cluster {Cluster}", cluster.Name);
return new InstallResult(
Success: false,
ComponentName: ComponentName,
Message: $"Failed to uninstall monitoring: {ex.Message}",
Actions: new List<string> { $"Error: {ex.Message}" });
}
finally
{
if (File.Exists(tempKubeConfig))
{
File.Delete(tempKubeConfig);
}
}
}
private async Task EnsureNamespaceExists(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)
{
k8s.Models.V1Namespace ns = new()
{
Metadata = new k8s.Models.V1ObjectMeta { Name = namespaceName }
};
await client.CoreV1.CreateNamespaceAsync(ns, cancellationToken: ct);
}
}
private async Task<string> 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);
}
/// <summary>
/// Reconfigures the monitoring stack. Supports:
/// - "retention": Prometheus retention period (e.g., "30d", "90d")
/// - "retentionSize": max storage size (e.g., "45GB")
/// - "replicas": Prometheus replica count
/// - "storageSize": PVC size (e.g., "50Gi", "100Gi")
/// - "grafanaEnabled": true/false
/// - "alertmanagerReplicas": Alertmanager replica count
/// </summary>
public async Task<InstallResult> ConfigureAsync(KubernetesCluster cluster, ComponentConfiguration configuration, CancellationToken ct = default)
{
string targetNamespace = configuration.Namespace ?? DefaultNamespace;
List<string> actions = new();
string tempKubeConfig = Path.Combine(Path.GetTempPath(), $"entkube-monitoring-cfg-{Guid.NewGuid()}.kubeconfig");
try
{
await File.WriteAllTextAsync(tempKubeConfig, cluster.KubeConfig, ct);
List<string> setFlags = new();
if (configuration.Values.TryGetValue("retention", out string? retention))
{
setFlags.Add($"--set prometheus.prometheusSpec.retention={retention}");
}
if (configuration.Values.TryGetValue("retentionSize", out string? retentionSize))
{
setFlags.Add($"--set prometheus.prometheusSpec.retentionSize={retentionSize}");
}
if (configuration.Values.TryGetValue("replicas", out string? replicas))
{
setFlags.Add($"--set prometheus.prometheusSpec.replicas={replicas}");
}
if (configuration.Values.TryGetValue("storageSize", out string? storageSize))
{
setFlags.Add($"--set prometheus.prometheusSpec.storageSpec.volumeClaimTemplate.spec.resources.requests.storage={storageSize}");
}
if (configuration.Values.TryGetValue("grafanaEnabled", out string? grafana))
{
setFlags.Add($"--set grafana.enabled={grafana}");
}
if (configuration.Values.TryGetValue("alertmanagerReplicas", out string? amReplicas))
{
setFlags.Add($"--set alertmanager.alertmanagerSpec.replicas={amReplicas}");
}
await RunCommand("helm",
$"upgrade kube-prometheus-stack kube-prometheus-stack --repo {HelmRepo} " +
$"--namespace {targetNamespace} " +
$"--kubeconfig \"{tempKubeConfig}\" --kube-context \"{cluster.ContextName}\" " +
$"--reuse-values {string.Join(" ", setFlags)} --wait --timeout 10m",
ct);
actions.Add($"Reconfigured monitoring: {string.Join(", ", configuration.Values.Select(kv => $"{kv.Key}={kv.Value}"))}");
return new InstallResult(
Success: true,
ComponentName: ComponentName,
Message: "Monitoring stack reconfigured",
Actions: actions);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to reconfigure monitoring on cluster {Cluster}", cluster.Name);
return new InstallResult(
Success: false,
ComponentName: ComponentName,
Message: $"Failed to reconfigure monitoring: {ex.Message}",
Actions: new List<string> { $"Error: {ex.Message}" });
}
finally
{
if (File.Exists(tempKubeConfig))
{
File.Delete(tempKubeConfig);
}
}
}
}

View File

@@ -1,158 +0,0 @@
using System.Text;
using EntKube.Clusters.Domain;
using k8s;
using k8s.Models;
using Microsoft.Extensions.Logging;
namespace EntKube.Clusters.Features.AdoptCluster.Components.NetworkPolicies;
/// <summary>
/// Checks whether baseline NetworkPolicies are deployed on the cluster.
/// Mirrors the Terraform tenants_namespaces module which creates default-deny
/// NetworkPolicies per tenant namespace plus allows from ingress namespaces.
///
/// This check verifies:
/// 1. Default-deny NetworkPolicies exist in tenant namespaces
/// 2. Allow-from-ingress policies exist (permitting gateway traffic)
/// </summary>
public class NetworkPoliciesCheck : IAdoptionCheck
{
private readonly ILogger<NetworkPoliciesCheck> logger;
public string ComponentName => "network-policies";
public string DisplayName => "Network Policies (Default-Deny + Ingress Allow)";
public NetworkPoliciesCheck(ILogger<NetworkPoliciesCheck> logger)
{
this.logger = logger;
}
public async Task<ComponentCheckResult> CheckAsync(KubernetesCluster cluster, CancellationToken ct = default)
{
Kubernetes client = BuildClient(cluster);
List<string> details = new();
List<string> missing = new();
// Find all customer namespaces (labeled with entkube.io/customer)
List<string> tenantNamespaces = await GetTenantNamespaces(client, ct);
if (tenantNamespaces.Count == 0)
{
// No tenant namespaces yet — policies are a per-tenant concern
details.Add("No tenant namespaces found (network policies are applied per-tenant)");
return new ComponentCheckResult(ComponentName, ComponentStatus.Installed, details, missing);
}
details.Add($"Found {tenantNamespaces.Count} tenant namespace(s)");
// Check each tenant namespace for required policies
int namespacesWithDenyPolicy = 0;
int namespacesWithIngressAllow = 0;
foreach (string ns in tenantNamespaces)
{
bool hasDeny = await HasNetworkPolicy(client, ns, "default-deny-ingress", ct);
if (hasDeny)
{
namespacesWithDenyPolicy++;
}
bool hasAllow = await HasNetworkPolicy(client, ns, "allow-from-ingress", ct);
if (hasAllow)
{
namespacesWithIngressAllow++;
}
}
details.Add($"Default-deny policies: {namespacesWithDenyPolicy}/{tenantNamespaces.Count} namespaces");
details.Add($"Allow-from-ingress policies: {namespacesWithIngressAllow}/{tenantNamespaces.Count} namespaces");
if (namespacesWithDenyPolicy < tenantNamespaces.Count)
{
missing.Add($"{tenantNamespaces.Count - namespacesWithDenyPolicy} namespace(s) missing default-deny policy");
}
if (namespacesWithIngressAllow < tenantNamespaces.Count)
{
missing.Add($"{tenantNamespaces.Count - namespacesWithIngressAllow} namespace(s) missing allow-from-ingress policy");
}
// Determine status
if (namespacesWithDenyPolicy == 0 && namespacesWithIngressAllow == 0)
{
return new ComponentCheckResult(ComponentName, ComponentStatus.NotInstalled, details, missing);
}
// Build discovered configuration
DiscoveredConfiguration config = new(
Version: null,
Namespace: null,
HelmReleaseName: null,
Values: new Dictionary<string, string>
{
["tenantNamespaces"] = tenantNamespaces.Count.ToString(),
["namespacesWithDenyPolicy"] = namespacesWithDenyPolicy.ToString(),
["namespacesWithIngressAllow"] = namespacesWithIngressAllow.ToString(),
["coverage"] = tenantNamespaces.Count > 0
? $"{namespacesWithDenyPolicy * 100 / tenantNamespaces.Count}%"
: "N/A"
});
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<List<string>> GetTenantNamespaces(Kubernetes client, CancellationToken ct)
{
List<string> namespaces = new();
try
{
V1NamespaceList nsList = await client.CoreV1.ListNamespaceAsync(
labelSelector: "entkube.io/customer",
cancellationToken: ct);
foreach (V1Namespace ns in nsList.Items)
{
namespaces.Add(ns.Metadata.Name);
}
}
catch (Exception ex)
{
logger.LogWarning(ex, "Error listing tenant namespaces");
}
return namespaces;
}
private async Task<bool> HasNetworkPolicy(Kubernetes client, string ns, string policyName, CancellationToken ct)
{
try
{
await client.NetworkingV1.ReadNamespacedNetworkPolicyAsync(policyName, ns, 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);
}
}

View File

@@ -1,291 +0,0 @@
using System.Text;
using EntKube.Clusters.Domain;
using k8s;
using k8s.Models;
using Microsoft.Extensions.Logging;
namespace EntKube.Clusters.Features.AdoptCluster.Components.NetworkPolicies;
/// <summary>
/// Installs baseline NetworkPolicies on all tenant namespaces.
/// Mirrors the Terraform tenants_namespaces module which creates:
/// 1. default-deny-ingress — blocks all ingress unless explicitly allowed
/// 2. allow-from-ingress — permits traffic from internal-ingress and external-ingress namespaces
///
/// This ensures a zero-trust network posture where each tenant namespace
/// denies all inbound traffic by default, only allowing traffic from
/// the platform's ingress gateways.
/// </summary>
public class NetworkPoliciesInstaller : IComponentInstaller
{
private readonly ILogger<NetworkPoliciesInstaller> logger;
public string ComponentName => "network-policies";
public NetworkPoliciesInstaller(ILogger<NetworkPoliciesInstaller> logger)
{
this.logger = logger;
}
public async Task<InstallResult> InstallAsync(KubernetesCluster cluster, ComponentInstallOptions options, CancellationToken ct = default)
{
List<string> actions = new();
try
{
Kubernetes client = BuildClient(cluster);
// Find all customer namespaces (labeled with entkube.io/customer)
List<string> tenantNamespaces = await GetTenantNamespaces(client, ct);
if (tenantNamespaces.Count == 0)
{
return new InstallResult(
Success: true,
ComponentName: ComponentName,
Message: "No tenant namespaces found — policies will be applied when tenants are created",
Actions: new List<string> { "No namespaces to configure" });
}
actions.Add($"Found {tenantNamespaces.Count} tenant namespace(s)");
// Apply policies to each tenant namespace
foreach (string ns in tenantNamespaces)
{
await ApplyDefaultDenyIngress(client, ns, ct);
await ApplyAllowFromIngress(client, ns, ct);
actions.Add($"Applied network policies to '{ns}'");
}
logger.LogInformation("Network policies applied to {Count} namespaces on cluster {Cluster}",
tenantNamespaces.Count, cluster.Name);
return new InstallResult(
Success: true,
ComponentName: ComponentName,
Message: $"Network policies applied to {tenantNamespaces.Count} tenant namespace(s)",
Actions: actions);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to apply network policies on cluster {Cluster}", cluster.Name);
actions.Add($"Error: {ex.Message}");
return new InstallResult(
Success: false,
ComponentName: ComponentName,
Message: $"Failed to apply network policies: {ex.Message}",
Actions: actions);
}
}
/// <summary>
/// Default-deny ingress policy. Blocks ALL inbound traffic to pods in the namespace
/// unless another NetworkPolicy explicitly allows it. This is the foundation of
/// zero-trust networking — everything is denied by default.
/// </summary>
private async Task ApplyDefaultDenyIngress(Kubernetes client, string ns, CancellationToken ct)
{
V1NetworkPolicy policy = new()
{
Metadata = new V1ObjectMeta
{
Name = "default-deny-ingress",
NamespaceProperty = ns,
Labels = new Dictionary<string, string>
{
["app.kubernetes.io/managed-by"] = "entkube",
["entkube.io/policy-type"] = "baseline"
}
},
Spec = new V1NetworkPolicySpec
{
// Empty podSelector = applies to ALL pods in namespace
PodSelector = new V1LabelSelector(),
PolicyTypes = new List<string> { "Ingress" },
// No ingress rules = deny all ingress
Ingress = new List<V1NetworkPolicyIngressRule>()
}
};
await CreateOrUpdateNetworkPolicy(client, ns, policy, ct);
}
/// <summary>
/// Allow-from-ingress policy. Permits traffic from pods in the internal-ingress
/// and external-ingress namespaces. This ensures the platform's ingress gateways
/// can reach application pods.
/// </summary>
private async Task ApplyAllowFromIngress(Kubernetes client, string ns, CancellationToken ct)
{
V1NetworkPolicy policy = new()
{
Metadata = new V1ObjectMeta
{
Name = "allow-from-ingress",
NamespaceProperty = ns,
Labels = new Dictionary<string, string>
{
["app.kubernetes.io/managed-by"] = "entkube",
["entkube.io/policy-type"] = "baseline"
}
},
Spec = new V1NetworkPolicySpec
{
// Applies to all pods in namespace
PodSelector = new V1LabelSelector(),
PolicyTypes = new List<string> { "Ingress" },
Ingress = new List<V1NetworkPolicyIngressRule>
{
new()
{
FromProperty = new List<V1NetworkPolicyPeer>
{
// Allow from internal-ingress namespace
new()
{
NamespaceSelector = new V1LabelSelector
{
MatchLabels = new Dictionary<string, string>
{
["kubernetes.io/metadata.name"] = "internal-ingress"
}
}
},
// Allow from external-ingress namespace
new()
{
NamespaceSelector = new V1LabelSelector
{
MatchLabels = new Dictionary<string, string>
{
["kubernetes.io/metadata.name"] = "external-ingress"
}
}
}
}
}
}
}
};
await CreateOrUpdateNetworkPolicy(client, ns, policy, ct);
}
private async Task CreateOrUpdateNetworkPolicy(Kubernetes client, string ns, V1NetworkPolicy policy, CancellationToken ct)
{
try
{
await client.NetworkingV1.ReadNamespacedNetworkPolicyAsync(
policy.Metadata.Name, ns, cancellationToken: ct);
// Already exists — replace it
await client.NetworkingV1.ReplaceNamespacedNetworkPolicyAsync(
policy, policy.Metadata.Name, ns, cancellationToken: ct);
}
catch (k8s.Autorest.HttpOperationException ex) when (ex.Response.StatusCode == System.Net.HttpStatusCode.NotFound)
{
await client.NetworkingV1.CreateNamespacedNetworkPolicyAsync(policy, ns, cancellationToken: ct);
}
}
private async Task<List<string>> GetTenantNamespaces(Kubernetes client, CancellationToken ct)
{
List<string> namespaces = new();
V1NamespaceList nsList = await client.CoreV1.ListNamespaceAsync(
labelSelector: "entkube.io/customer",
cancellationToken: ct);
foreach (V1Namespace ns in nsList.Items)
{
namespaces.Add(ns.Metadata.Name);
}
return namespaces;
}
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);
}
/// <summary>
/// Reconfigures network policies. Since policies are applied per-namespace,
/// "configure" simply re-applies the baseline policies to all tenant namespaces.
/// This is useful after new namespaces are created.
/// </summary>
public async Task<InstallResult> ConfigureAsync(KubernetesCluster cluster, ComponentConfiguration configuration, CancellationToken ct = default)
{
// NetworkPolicies have no Helm values — configuration means re-applying
// policies to all tenant namespaces (catches newly created namespaces).
return await InstallAsync(cluster, new ComponentInstallOptions(), ct);
}
/// <summary>
/// Uninstalls network policies by deleting the default-deny-ingress and
/// allow-from-ingress NetworkPolicy resources from all tenant namespaces.
/// </summary>
public async Task<InstallResult> UninstallAsync(KubernetesCluster cluster, ComponentInstallOptions options, CancellationToken ct = default)
{
List<string> actions = new();
try
{
Kubernetes client = BuildClient(cluster);
// Find all tenant namespaces and remove network policies from each.
List<string> tenantNamespaces = await GetTenantNamespaces(client, ct);
foreach (string ns in tenantNamespaces)
{
try
{
await client.NetworkingV1.DeleteNamespacedNetworkPolicyAsync(
"default-deny-ingress", ns, cancellationToken: ct);
actions.Add($"Deleted default-deny-ingress in '{ns}'");
}
catch (k8s.Autorest.HttpOperationException ex) when (ex.Response.StatusCode == System.Net.HttpStatusCode.NotFound)
{
// Already gone.
}
try
{
await client.NetworkingV1.DeleteNamespacedNetworkPolicyAsync(
"allow-from-ingress", ns, cancellationToken: ct);
actions.Add($"Deleted allow-from-ingress in '{ns}'");
}
catch (k8s.Autorest.HttpOperationException ex) when (ex.Response.StatusCode == System.Net.HttpStatusCode.NotFound)
{
// Already gone.
}
}
logger.LogInformation("Network policies removed from {Count} namespaces on cluster {Cluster}",
tenantNamespaces.Count, cluster.Name);
return new InstallResult(
Success: true,
ComponentName: ComponentName,
Message: $"Network policies removed from {tenantNamespaces.Count} namespace(s)",
Actions: actions);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to uninstall network policies from cluster {Cluster}", cluster.Name);
return new InstallResult(
Success: false,
ComponentName: ComponentName,
Message: $"Failed to uninstall network policies: {ex.Message}",
Actions: new List<string> { $"Error: {ex.Message}" });
}
}
}

View File

@@ -1,229 +0,0 @@
using System.Text;
using EntKube.Clusters.Domain;
using k8s;
using k8s.Models;
using Microsoft.Extensions.Logging;
namespace EntKube.Clusters.Features.AdoptCluster.Components.RabbitMQ;
/// <summary>
/// Checks whether RabbitMQ Cluster Operator is installed and healthy.
/// The RabbitMQ Cluster Operator manages the lifecycle of RabbitMQ clusters
/// on Kubernetes — provisioning, scaling, upgrades, and TLS configuration.
///
/// This check verifies:
/// 1. RabbitMQ Cluster Operator pods running
/// 2. RabbitmqCluster CRD registered
/// 3. Any existing RabbitMQ clusters managed by the operator
/// </summary>
public class RabbitMQCheck : IAdoptionCheck
{
private readonly ILogger<RabbitMQCheck> logger;
public string ComponentName => "rabbitmq";
public string DisplayName => "RabbitMQ (Message Broker)";
public RabbitMQCheck(ILogger<RabbitMQCheck> logger)
{
this.logger = logger;
}
public async Task<ComponentCheckResult> CheckAsync(KubernetesCluster cluster, CancellationToken ct = default)
{
Kubernetes client = BuildClient(cluster);
List<string> details = new();
List<string> missing = new();
// Check 1: RabbitMQ operator pods in rabbitmq-system namespace.
List<string> operatorPods = await FindOperatorPods(client, ct);
if (operatorPods.Count > 0)
{
details.AddRange(operatorPods.Select(p => $"Operator pod: {p}"));
}
else
{
missing.Add("No RabbitMQ Cluster Operator pods found in rabbitmq-system namespace");
return new ComponentCheckResult(ComponentName, ComponentStatus.NotInstalled, details, missing);
}
// Check 2: RabbitmqCluster CRD exists.
bool crdExists = await RabbitmqClusterCrdExists(client, ct);
if (crdExists)
{
details.Add("RabbitmqCluster CRD (rabbitmqclusters.rabbitmq.com) present");
}
else
{
missing.Add("RabbitmqCluster CRD not found");
}
// Check 3: Messaging Topology Operator (optional — manages queues, exchanges, etc.)
bool topologyOperator = await TopologyOperatorExists(client, ct);
if (topologyOperator)
{
details.Add("Messaging Topology Operator present");
}
else
{
details.Add("Messaging Topology Operator not found (optional)");
}
// Check 4: Count existing RabbitmqCluster instances.
int clusterCount = await CountRabbitmqClusters(client, ct);
details.Add($"Managed RabbitMQ clusters: {clusterCount}");
// Discover version from operator pod image.
string? version = await GetVersionFromPods(client, ct);
DiscoveredConfiguration config = new(
Version: version,
Namespace: "rabbitmq-system",
HelmReleaseName: null,
Values: new Dictionary<string, string>
{
["operatorReplicas"] = operatorPods.Count.ToString(),
["managedClusters"] = clusterCount.ToString(),
["topologyOperator"] = topologyOperator.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<string?> GetVersionFromPods(Kubernetes client, CancellationToken ct)
{
try
{
V1PodList podList = await client.CoreV1.ListNamespacedPodAsync(
"rabbitmq-system",
labelSelector: "app.kubernetes.io/name=rabbitmq-cluster-operator",
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<List<string>> FindOperatorPods(Kubernetes client, CancellationToken ct)
{
List<string> pods = new();
try
{
V1PodList podList = await client.CoreV1.ListNamespacedPodAsync(
"rabbitmq-system",
labelSelector: "app.kubernetes.io/name=rabbitmq-cluster-operator",
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("rabbitmq-system namespace not found on cluster");
}
catch (Exception ex)
{
logger.LogWarning(ex, "Error checking for RabbitMQ operator pods");
}
return pods;
}
private async Task<bool> RabbitmqClusterCrdExists(Kubernetes client, CancellationToken ct)
{
try
{
await client.ApiextensionsV1.ReadCustomResourceDefinitionAsync(
"rabbitmqclusters.rabbitmq.com", cancellationToken: ct);
return true;
}
catch
{
return false;
}
}
private async Task<bool> TopologyOperatorExists(Kubernetes client, CancellationToken ct)
{
try
{
V1PodList podList = await client.CoreV1.ListNamespacedPodAsync(
"rabbitmq-system",
labelSelector: "app.kubernetes.io/name=messaging-topology-operator",
cancellationToken: ct);
return podList.Items.Any(p => p.Status?.Phase == "Running");
}
catch
{
return false;
}
}
private async Task<int> CountRabbitmqClusters(Kubernetes client, CancellationToken ct)
{
try
{
object result = await client.CustomObjects.ListClusterCustomObjectAsync(
group: "rabbitmq.com",
version: "v1beta1",
plural: "rabbitmqclusters",
cancellationToken: ct);
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);
}
}

View File

@@ -1,324 +0,0 @@
using System.Text;
using EntKube.Clusters.Domain;
using k8s;
using k8s.Models;
using Microsoft.Extensions.Logging;
namespace EntKube.Clusters.Features.AdoptCluster.Components.RabbitMQ;
/// <summary>
/// Installs RabbitMQ Cluster Operator and optionally the Messaging Topology Operator
/// using the official manifests from the RabbitMQ GitHub releases. The operator
/// manages the full lifecycle of RabbitMQ clusters on Kubernetes.
///
/// The official installation method is `kubectl apply -f` against the release
/// manifest YAML — there is no first-party Helm chart for this operator.
///
/// What gets deployed:
/// 1. rabbitmq-system namespace with the Cluster Operator
/// 2. RabbitmqCluster CRD for declarative cluster management
/// 3. Optionally: Messaging Topology Operator (queues, exchanges, bindings via CRDs)
///
/// Configuration keys:
/// - "topologyOperator": Install messaging topology operator (default: true)
/// - "topologyOperatorVersion": Version of topology operator (default: 1.19.1)
///
/// Note: This installs the OPERATOR only. Actual RabbitMQ clusters are provisioned
/// per-tenant via the Provisioning service.
/// </summary>
public class RabbitMQInstaller : IComponentInstaller
{
private readonly ILogger<RabbitMQInstaller> logger;
private const string DefaultVersion = "2.20.1";
private const string DefaultTopologyVersion = "1.19.1";
private const string DefaultNamespace = "rabbitmq-system";
/// <summary>
/// The official cluster operator manifest from the rabbitmq/cluster-operator
/// GitHub releases. Contains namespace, CRDs, RBAC, and the operator Deployment.
/// </summary>
private const string ClusterOperatorManifestUrl =
"https://github.com/rabbitmq/cluster-operator/releases/download/v{0}/cluster-operator.yml";
/// <summary>
/// The official messaging topology operator manifest (with cert-manager support).
/// This is the recommended variant when cert-manager is installed on the cluster.
/// </summary>
private const string TopologyOperatorManifestUrl =
"https://github.com/rabbitmq/messaging-topology-operator/releases/download/v{0}/messaging-topology-operator-with-certmanager.yaml";
public string ComponentName => "rabbitmq";
public RabbitMQInstaller(ILogger<RabbitMQInstaller> logger)
{
this.logger = logger;
}
public async Task<InstallResult> InstallAsync(KubernetesCluster cluster, ComponentInstallOptions options, CancellationToken ct = default)
{
string version = options.Version ?? DefaultVersion;
bool topologyOperator = !(options.Parameters?.TryGetValue("topologyOperator", out string? topoVal) == true
&& topoVal == "false");
string topologyVersion = options.Parameters?.GetValueOrDefault("topologyOperatorVersion") ?? DefaultTopologyVersion;
List<string> actions = new();
string tempKubeConfig = Path.Combine(Path.GetTempPath(), $"entkube-rabbitmq-{Guid.NewGuid()}.kubeconfig");
try
{
await File.WriteAllTextAsync(tempKubeConfig, cluster.KubeConfig, ct);
// Apply the official cluster operator manifest from GitHub releases.
// This creates the rabbitmq-system namespace, CRDs, RBAC, and the
// operator Deployment in one atomic manifest.
string clusterOpUrl = string.Format(ClusterOperatorManifestUrl, version);
await RunCommand("kubectl",
$"apply -f \"{clusterOpUrl}\" " +
$"--kubeconfig \"{tempKubeConfig}\" --context \"{cluster.ContextName}\"",
ct);
actions.Add($"Applied cluster-operator v{version} manifest");
// Wait for the operator deployment to become ready so subsequent
// steps (topology operator, RabbitmqCluster CRs) can rely on it.
await RunCommand("kubectl",
$"rollout status deployment/rabbitmq-cluster-operator " +
$"--namespace {DefaultNamespace} --timeout=5m " +
$"--kubeconfig \"{tempKubeConfig}\" --context \"{cluster.ContextName}\"",
ct);
actions.Add("Cluster Operator deployment ready");
// Optionally install the Messaging Topology Operator. This adds CRDs
// for queues, exchanges, bindings, users, policies, etc. It requires
// cert-manager (which is typically already installed on EntKube clusters).
if (topologyOperator)
{
string topoUrl = string.Format(TopologyOperatorManifestUrl, topologyVersion);
await RunCommand("kubectl",
$"apply -f \"{topoUrl}\" " +
$"--kubeconfig \"{tempKubeConfig}\" --context \"{cluster.ContextName}\"",
ct);
actions.Add($"Applied messaging-topology-operator v{topologyVersion} manifest");
}
logger.LogInformation("RabbitMQ Cluster Operator v{Version} installed on cluster {Cluster}",
version, cluster.Name);
return new InstallResult(
Success: true,
ComponentName: ComponentName,
Message: $"RabbitMQ Cluster Operator v{version} installed",
Actions: actions);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to install RabbitMQ operator on cluster {Cluster}", cluster.Name);
actions.Add($"Error: {ex.Message}");
return new InstallResult(
Success: false,
ComponentName: ComponentName,
Message: $"Failed to install RabbitMQ Cluster Operator: {ex.Message}",
Actions: actions);
}
finally
{
if (File.Exists(tempKubeConfig))
{
File.Delete(tempKubeConfig);
}
}
}
/// <summary>
/// Reconfigures RabbitMQ Cluster Operator. Since the operator is installed
/// via static manifests (not Helm), reconfiguration re-applies the manifest
/// at the requested version. For the topology operator, it can be added or
/// removed by re-applying or deleting the topology manifest.
/// </summary>
public async Task<InstallResult> ConfigureAsync(KubernetesCluster cluster, ComponentConfiguration configuration, CancellationToken ct = default)
{
List<string> actions = new();
string tempKubeConfig = Path.Combine(Path.GetTempPath(), $"entkube-rabbitmq-cfg-{Guid.NewGuid()}.kubeconfig");
try
{
await File.WriteAllTextAsync(tempKubeConfig, cluster.KubeConfig, ct);
// If a new version is requested, re-apply the manifest at that version.
if (configuration.Values.TryGetValue("version", out string? newVersion) && !string.IsNullOrWhiteSpace(newVersion))
{
string clusterOpUrl = string.Format(ClusterOperatorManifestUrl, newVersion);
await RunCommand("kubectl",
$"apply -f \"{clusterOpUrl}\" " +
$"--kubeconfig \"{tempKubeConfig}\" --context \"{cluster.ContextName}\"",
ct);
actions.Add($"Re-applied cluster-operator v{newVersion} manifest");
}
// Handle topology operator add/remove.
if (configuration.Values.TryGetValue("topologyOperator", out string? topology))
{
string topoVersion = configuration.Values.GetValueOrDefault("topologyOperatorVersion") ?? DefaultTopologyVersion;
if (topology.Equals("true", StringComparison.OrdinalIgnoreCase))
{
string topoUrl = string.Format(TopologyOperatorManifestUrl, topoVersion);
await RunCommand("kubectl",
$"apply -f \"{topoUrl}\" " +
$"--kubeconfig \"{tempKubeConfig}\" --context \"{cluster.ContextName}\"",
ct);
actions.Add($"Applied messaging-topology-operator v{topoVersion}");
}
else
{
string topoUrl = string.Format(TopologyOperatorManifestUrl, topoVersion);
await RunCommand("kubectl",
$"delete -f \"{topoUrl}\" --ignore-not-found " +
$"--kubeconfig \"{tempKubeConfig}\" --context \"{cluster.ContextName}\"",
ct);
actions.Add("Removed messaging-topology-operator");
}
}
return new InstallResult(
Success: true,
ComponentName: ComponentName,
Message: "RabbitMQ Cluster Operator reconfigured",
Actions: actions);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to reconfigure RabbitMQ operator on cluster {Cluster}", cluster.Name);
return new InstallResult(
Success: false,
ComponentName: ComponentName,
Message: $"Failed to reconfigure RabbitMQ operator: {ex.Message}",
Actions: new List<string> { $"Error: {ex.Message}" });
}
finally
{
if (File.Exists(tempKubeConfig))
{
File.Delete(tempKubeConfig);
}
}
}
/// <summary>
/// Uninstalls the RabbitMQ Cluster Operator by deleting the manifest resources.
/// Warning: existing RabbitmqCluster resources will become unmanaged.
/// </summary>
public async Task<InstallResult> UninstallAsync(KubernetesCluster cluster, ComponentInstallOptions options, CancellationToken ct = default)
{
string version = options.Version ?? DefaultVersion;
string topologyVersion = options.Parameters?.GetValueOrDefault("topologyOperatorVersion") ?? DefaultTopologyVersion;
List<string> actions = new();
string tempKubeConfig = Path.Combine(Path.GetTempPath(), $"entkube-rabbitmq-rm-{Guid.NewGuid()}.kubeconfig");
try
{
await File.WriteAllTextAsync(tempKubeConfig, cluster.KubeConfig, ct);
// Delete topology operator first (depends on cluster operator CRDs).
try
{
string topoUrl = string.Format(TopologyOperatorManifestUrl, topologyVersion);
await RunCommand("kubectl",
$"delete -f \"{topoUrl}\" --ignore-not-found " +
$"--kubeconfig \"{tempKubeConfig}\" --context \"{cluster.ContextName}\"",
ct);
actions.Add("Deleted messaging-topology-operator resources");
}
catch (Exception ex)
{
logger.LogWarning(ex, "Non-fatal: could not delete topology operator");
actions.Add($"Topology operator removal skipped: {ex.Message}");
}
// Delete the cluster operator manifest resources.
string clusterOpUrl = string.Format(ClusterOperatorManifestUrl, version);
await RunCommand("kubectl",
$"delete -f \"{clusterOpUrl}\" --ignore-not-found " +
$"--kubeconfig \"{tempKubeConfig}\" --context \"{cluster.ContextName}\"",
ct);
actions.Add("Deleted cluster-operator resources");
logger.LogInformation("RabbitMQ operator uninstalled from cluster {Cluster}", cluster.Name);
return new InstallResult(
Success: true,
ComponentName: ComponentName,
Message: "RabbitMQ Cluster Operator uninstalled successfully",
Actions: actions);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to uninstall RabbitMQ from cluster {Cluster}", cluster.Name);
return new InstallResult(
Success: false,
ComponentName: ComponentName,
Message: $"Failed to uninstall RabbitMQ: {ex.Message}",
Actions: new List<string> { $"Error: {ex.Message}" });
}
finally
{
if (File.Exists(tempKubeConfig))
{
File.Delete(tempKubeConfig);
}
}
}
private async Task<string> 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);
}
}

View File

@@ -1,339 +0,0 @@
using System.Text;
using EntKube.Clusters.Domain;
using k8s;
using k8s.Models;
using Microsoft.Extensions.Logging;
namespace EntKube.Clusters.Features.AdoptCluster.Components.Redis;
/// <summary>
/// Checks whether the OT-OPERATORS Redis Operator is installed and healthy on the cluster.
/// The operator manages Redis clusters, replications, and sentinels via CRDs.
///
/// This check verifies:
/// 1. Redis Operator controller pods running in the redis namespace
/// 2. Redis CRD (redis.redis.opstreelabs.in) is registered
/// 3. Any existing Redis instances managed by the operator
/// </summary>
public class RedisCheck : IAdoptionCheck
{
private readonly ILogger<RedisCheck> logger;
public string ComponentName => "redis";
public string DisplayName => "Redis (Cache & Session Store)";
public RedisCheck(ILogger<RedisCheck> logger)
{
this.logger = logger;
}
public async Task<ComponentCheckResult> CheckAsync(KubernetesCluster cluster, CancellationToken ct = default)
{
Kubernetes client = BuildClient(cluster);
List<string> details = new();
List<string> missing = new();
// Check 1: Redis Operator controller pods in the redis namespace.
// The OT-OPERATORS Helm chart deploys the operator controller with
// the label name=redis-operator. Without the operator, nothing works.
List<string> operatorPods = await FindOperatorPods(client, ct);
if (operatorPods.Count > 0)
{
details.AddRange(operatorPods.Select(p => $"Operator pod: {p}"));
}
else
{
missing.Add("No Redis Operator pods found in redis namespace");
return new ComponentCheckResult(ComponentName, ComponentStatus.NotInstalled, details, missing);
}
// Check 2: Redis CRD exists and count managed instances.
// Instead of reading the CRD definition (which requires cluster-admin),
// we try listing custom resources. If the listing succeeds, the CRD exists.
// We try multiple API groups because different operator versions use
// different group names.
(bool crdExists, string? discoveredGroup, int instanceCount) = await DiscoverRedisCrd(client, ct);
if (crdExists)
{
details.Add($"Redis CRD present (API group: {discoveredGroup})");
}
else
{
missing.Add("Redis CRD not found — operator may still be initializing CRDs");
}
if (instanceCount > 0)
{
details.Add($"Managed Redis instances: {instanceCount}");
}
else
{
details.Add("No Redis instances found — provision via Redis page");
}
// Discover operator version from the controller pod image tag.
string? version = await GetVersionFromOperatorPods(client, ct);
DiscoveredConfiguration config = new(
Version: version,
Namespace: "redis",
HelmReleaseName: "redis-operator",
Values: new Dictionary<string, string>
{
["operatorReplicas"] = operatorPods.Count.ToString(),
["crdInstalled"] = crdExists.ToString(),
["managedInstances"] = instanceCount.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<string?> GetVersionFromOperatorPods(Kubernetes client, CancellationToken ct)
{
try
{
V1PodList podList = await client.CoreV1.ListNamespacedPodAsync(
"redis",
labelSelector: "name=redis-operator",
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<List<string>> FindOperatorPods(Kubernetes client, CancellationToken ct)
{
List<string> pods = new();
// The OT-OPERATORS redis-operator Helm chart labels the controller
// pod with name=redis-operator. We try multiple label selectors and
// search across common namespaces because the operator may be installed
// in a non-default namespace.
string[] namespaces = ["redis", "redis-operator", "redis-system", "default"];
string[] labelSelectors =
[
"name=redis-operator",
"app.kubernetes.io/name=redis-operator",
"control-plane=redis-operator"
];
foreach (string ns in namespaces)
{
if (pods.Count > 0)
{
break;
}
foreach (string labelSelector in labelSelectors)
{
if (pods.Count > 0)
{
break;
}
try
{
V1PodList podList = await client.CoreV1.ListNamespacedPodAsync(
ns,
labelSelector: labelSelector,
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("Namespace '{Namespace}' not found on cluster", ns);
}
catch (Exception ex)
{
logger.LogDebug(ex, "Error checking for Redis Operator pods in {Namespace} with label {Label}", ns, labelSelector);
}
}
}
// Last resort: search all namespaces for any pod with the operator labels.
if (pods.Count == 0)
{
foreach (string labelSelector in labelSelectors)
{
if (pods.Count > 0)
{
break;
}
try
{
V1PodList podList = await client.CoreV1.ListPodForAllNamespacesAsync(
labelSelector: labelSelector,
cancellationToken: ct);
foreach (V1Pod pod in podList.Items)
{
if (pod.Status?.Phase == "Running")
{
pods.Add(pod.Metadata.Name);
}
}
}
catch (Exception ex)
{
logger.LogDebug(ex, "Error listing all-namespace pods with label {Label}", labelSelector);
}
}
}
return pods;
}
/// <summary>
/// Discovers whether Redis CRDs are installed by attempting to list custom
/// resources across multiple API groups, versions, and plurals. This approach
/// works without cluster-admin RBAC (unlike reading CRD definitions directly)
/// and handles different operator versions that use different API group names.
/// Returns (crdFound, apiGroup, instanceCount).
/// </summary>
private async Task<(bool CrdExists, string? ApiGroup, int InstanceCount)> DiscoverRedisCrd(Kubernetes client, CancellationToken ct)
{
// Different versions of the OT-OPERATORS Redis Operator register
// CRDs under different API groups. We try all known variants.
string[] apiGroups =
[
"redis.redis.opstreelabs.in",
"redis.opstreelabs.in"
];
string[] apiVersions = ["v1beta2", "v1beta1", "v1"];
string[] plurals = ["redis", "redisclusters", "redissentinels", "redisreplications"];
foreach (string group in apiGroups)
{
foreach (string version in apiVersions)
{
foreach (string plural in plurals)
{
try
{
object result = await client.CustomObjects.ListClusterCustomObjectAsync(
group: group,
version: version,
plural: plural,
cancellationToken: ct);
// If we got here, the API group + resource exists — CRD is installed.
int count = 0;
if (plural == "redis")
{
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))
{
count = items.GetArrayLength();
}
}
logger.LogInformation(
"Found Redis CRD: group={Group}, version={Version}, plural={Plural}, instances={Count}",
group, version, plural, count);
return (true, $"{group}/{version}", count);
}
catch (k8s.Autorest.HttpOperationException ex) when (
ex.Response.StatusCode == System.Net.HttpStatusCode.NotFound)
{
// This API group/version/plural doesn't exist — try next.
}
catch (Exception ex)
{
logger.LogDebug(ex,
"Error probing Redis CRD: group={Group}, version={Version}, plural={Plural}",
group, version, plural);
}
}
}
}
// Last fallback: try reading the CRD definition directly via apiextensions.
// This requires higher RBAC permissions but covers edge cases where the
// listing above fails due to namespace-scoped RBAC.
string[] crdNames =
[
"redis.redis.opstreelabs.in",
"redisclusters.redis.opstreelabs.in",
"redis.redis.redis.opstreelabs.in",
"redisclusters.redis.redis.opstreelabs.in",
"redissentinels.redis.opstreelabs.in",
"redisreplications.redis.opstreelabs.in"
];
foreach (string crdName in crdNames)
{
try
{
await client.ApiextensionsV1.ReadCustomResourceDefinitionAsync(
crdName, cancellationToken: ct);
logger.LogInformation("Found Redis CRD definition: {CrdName}", crdName);
return (true, crdName, 0);
}
catch (k8s.Autorest.HttpOperationException ex) when (
ex.Response.StatusCode == System.Net.HttpStatusCode.NotFound)
{
logger.LogDebug("CRD {CrdName} not found via apiextensions", crdName);
}
catch (Exception ex)
{
logger.LogDebug(ex, "Error reading CRD {CrdName} via apiextensions", crdName);
}
}
logger.LogWarning("No Redis Operator CRDs found on cluster after trying all known API groups");
return (false, null, 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);
}
}

View File

@@ -1,371 +0,0 @@
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.Redis;
/// <summary>
/// Checks whether the Redis Enterprise Operator is installed and healthy on the cluster.
/// Redis Enterprise uses a different operator and CRD API group (app.redislabs.com)
/// than the OpsTree Redis Operator (redis.redis.opstreelabs.in).
///
/// This check verifies:
/// 1. Redis Enterprise Operator controller pods are running
/// 2. RedisEnterpriseCluster CRD (redisenterpriseclusters.app.redislabs.com) exists
/// 3. Any existing RedisEnterpriseCluster (REC) instances
/// 4. Any existing RedisEnterpriseDatabase (REDB) instances
/// </summary>
public class RedisEnterpriseCheck : IAdoptionCheck
{
private readonly ILogger<RedisEnterpriseCheck> logger;
private const string ApiGroup = "app.redislabs.com";
private const string ApiVersion = "v1";
private const string ClusterPlural = "redisenterpriseclusters";
private const string DatabasePlural = "redisenterprisedatabases";
public string ComponentName => "redis-enterprise";
public string DisplayName => "Redis Enterprise";
public RedisEnterpriseCheck(ILogger<RedisEnterpriseCheck> logger)
{
this.logger = logger;
}
public async Task<ComponentCheckResult> CheckAsync(KubernetesCluster cluster, CancellationToken ct = default)
{
// We connect to the target cluster and look for signs of the Redis Enterprise
// Operator. Unlike OpsTree, Redis Enterprise uses app.redislabs.com/v1 CRDs
// and typically deploys in a "redis-enterprise" namespace.
Kubernetes client = BuildClient(cluster);
List<string> details = new();
List<string> missing = new();
// Check 1: Find Redis Enterprise Operator controller pods.
// The operator pod is typically labeled with name=redis-enterprise-operator
// or app=redis-enterprise-operator depending on the installation method.
List<string> operatorPods = await FindOperatorPods(client, ct);
if (operatorPods.Count > 0)
{
details.AddRange(operatorPods.Select(p => $"Operator pod: {p}"));
}
else
{
missing.Add("No Redis Enterprise Operator pods found");
return new ComponentCheckResult(ComponentName, ComponentStatus.NotInstalled, details, missing);
}
// Check 2: Look for RedisEnterpriseCluster (REC) instances.
// These represent the actual Redis Enterprise cluster nodes.
(bool crdExists, int clusterCount) = await DiscoverClusters(client, ct);
if (crdExists)
{
details.Add($"RedisEnterpriseCluster CRD present ({ApiGroup}/{ApiVersion})");
details.Add($"REC instances: {clusterCount}");
}
else
{
missing.Add("RedisEnterpriseCluster CRD not found — operator may still be initializing");
}
// Check 3: Count RedisEnterpriseDatabase (REDB) instances.
// Each REDB is a logical database running on a REC.
int databaseCount = await CountDatabases(client, ct);
if (databaseCount > 0)
{
details.Add($"REDB instances: {databaseCount}");
}
else
{
details.Add("No Redis Enterprise databases found");
}
// Discover operator version from the controller pod image tag.
string? version = await GetVersionFromOperatorPods(client, ct);
DiscoveredConfiguration config = new(
Version: version,
Namespace: "redis-enterprise",
HelmReleaseName: "redis-enterprise-operator",
Values: new Dictionary<string, string>
{
["operatorReplicas"] = operatorPods.Count.ToString(),
["crdInstalled"] = crdExists.ToString(),
["recInstances"] = clusterCount.ToString(),
["redbInstances"] = databaseCount.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<List<string>> FindOperatorPods(Kubernetes client, CancellationToken ct)
{
// Redis Enterprise Operator pods use varying labels across installation
// methods (Helm, OLM, manual). We search multiple namespaces and labels.
List<string> pods = new();
string[] namespaces = ["redis-enterprise", "redis", "default"];
string[] labelSelectors =
[
"name=redis-enterprise-operator",
"app=redis-enterprise-operator",
"app.kubernetes.io/name=redis-enterprise-operator"
];
foreach (string ns in namespaces)
{
if (pods.Count > 0)
{
break;
}
foreach (string labelSelector in labelSelectors)
{
if (pods.Count > 0)
{
break;
}
try
{
V1PodList podList = await client.CoreV1.ListNamespacedPodAsync(
ns,
labelSelector: labelSelector,
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("Namespace '{Namespace}' not found on cluster", ns);
}
catch (Exception ex)
{
logger.LogDebug(ex, "Error checking for Redis Enterprise Operator pods in {Namespace} with label {Label}", ns, labelSelector);
}
}
}
// Last resort: search all namespaces for any pod matching operator labels.
if (pods.Count == 0)
{
foreach (string labelSelector in labelSelectors)
{
if (pods.Count > 0)
{
break;
}
try
{
V1PodList podList = await client.CoreV1.ListPodForAllNamespacesAsync(
labelSelector: labelSelector,
cancellationToken: ct);
foreach (V1Pod pod in podList.Items)
{
if (pod.Status?.Phase == "Running")
{
pods.Add(pod.Metadata.Name);
}
}
}
catch (Exception ex)
{
logger.LogDebug(ex, "Error listing all-namespace pods with label {Label}", labelSelector);
}
}
}
return pods;
}
/// <summary>
/// Discovers RedisEnterpriseCluster CRs across all namespaces.
/// Returns whether the CRD exists and how many REC instances are running.
/// </summary>
private async Task<(bool CrdExists, int ClusterCount)> DiscoverClusters(Kubernetes client, CancellationToken ct)
{
// Try listing RedisEnterpriseCluster resources. If the list call succeeds,
// the CRD is installed regardless of how many instances exist.
string[] versions = [ApiVersion, "v1alpha1"];
foreach (string version in versions)
{
try
{
object result = await client.CustomObjects.ListClusterCustomObjectAsync(
group: ApiGroup,
version: version,
plural: ClusterPlural,
cancellationToken: ct);
int count = 0;
string json = JsonSerializer.Serialize(result);
using JsonDocument doc = JsonDocument.Parse(json);
if (doc.RootElement.TryGetProperty("items", out JsonElement items))
{
count = items.GetArrayLength();
}
logger.LogInformation(
"Found Redis Enterprise CRD: {Group}/{Version}, REC instances: {Count}",
ApiGroup, version, count);
return (true, count);
}
catch (k8s.Autorest.HttpOperationException ex) when (ex.Response.StatusCode == System.Net.HttpStatusCode.NotFound)
{
// This version doesn't exist — try next.
}
catch (Exception ex)
{
logger.LogDebug(ex, "Error probing Redis Enterprise CRD with version {Version}", version);
}
}
// Fallback: try reading the CRD definition directly.
string[] crdNames =
[
"redisenterpriseclusters.app.redislabs.com",
"redisenterprisecluster.app.redislabs.com"
];
foreach (string crdName in crdNames)
{
try
{
await client.ApiextensionsV1.ReadCustomResourceDefinitionAsync(
crdName, cancellationToken: ct);
logger.LogInformation("Found Redis Enterprise CRD definition: {CrdName}", crdName);
return (true, 0);
}
catch (k8s.Autorest.HttpOperationException ex) when (ex.Response.StatusCode == System.Net.HttpStatusCode.NotFound)
{
logger.LogDebug("CRD {CrdName} not found via apiextensions", crdName);
}
catch (Exception ex)
{
logger.LogDebug(ex, "Error reading CRD {CrdName} via apiextensions", crdName);
}
}
return (false, 0);
}
/// <summary>
/// Counts RedisEnterpriseDatabase (REDB) resources across all namespaces.
/// </summary>
private async Task<int> CountDatabases(Kubernetes client, CancellationToken ct)
{
string[] versions = [ApiVersion, "v1alpha1"];
foreach (string version in versions)
{
try
{
object result = await client.CustomObjects.ListClusterCustomObjectAsync(
group: ApiGroup,
version: version,
plural: DatabasePlural,
cancellationToken: ct);
string json = JsonSerializer.Serialize(result);
using JsonDocument doc = JsonDocument.Parse(json);
if (doc.RootElement.TryGetProperty("items", out JsonElement items))
{
return items.GetArrayLength();
}
return 0;
}
catch (k8s.Autorest.HttpOperationException ex) when (ex.Response.StatusCode == System.Net.HttpStatusCode.NotFound)
{
// Try next version.
}
catch (Exception ex)
{
logger.LogDebug(ex, "Error counting Redis Enterprise databases with version {Version}", version);
}
}
return 0;
}
private async Task<string?> GetVersionFromOperatorPods(Kubernetes client, CancellationToken ct)
{
// The operator version is typically encoded in the container image tag.
try
{
string[] labelSelectors =
[
"name=redis-enterprise-operator",
"app=redis-enterprise-operator",
"app.kubernetes.io/name=redis-enterprise-operator"
];
foreach (string labelSelector in labelSelectors)
{
V1PodList podList = await client.CoreV1.ListPodForAllNamespacesAsync(
labelSelector: labelSelector,
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 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);
}
}

View File

@@ -1,375 +0,0 @@
using System.Text;
using EntKube.Clusters.Domain;
using k8s;
using k8s.Models;
using Microsoft.Extensions.Logging;
namespace EntKube.Clusters.Features.AdoptCluster.Components.Redis;
/// <summary>
/// Installs Redis via the OT-OPERATORS Redis Operator Helm chart. Redis is used
/// as a shared caching and session store across tenant workloads. The operator
/// manages Redis clusters with support for standalone, cluster, and sentinel modes.
///
/// What gets deployed:
/// 1. redis namespace with management labels
/// 2. Redis Operator for lifecycle management
/// 3. Redis master + optional replicas via operator CRDs
/// 4. Sentinel for HA failover (when sentinelEnabled=true)
/// 5. Password from a Kubernetes Secret (auto-generated or provided)
///
/// Configuration keys:
/// - "replicaCount": Number of Redis replicas (default: 3)
/// - "sentinelEnabled": Enable Redis Sentinel for HA (default: true)
/// - "sentinelReplicas": Number of Sentinel pods (default: 3)
/// - "persistence": Enable persistent storage (default: true)
/// - "persistenceSize": PVC size (default: 8Gi)
/// - "maxMemory": Redis maxmemory setting (default: 256mb)
/// - "maxMemoryPolicy": Eviction policy (default: allkeys-lru)
/// </summary>
public class RedisInstaller : IComponentInstaller
{
private readonly ILogger<RedisInstaller> logger;
private const string DefaultVersion = "0.18.0";
private const string DefaultNamespace = "redis";
private const string HelmRepo = "https://ot-container-kit.github.io/helm-charts/";
public string ComponentName => "redis";
public RedisInstaller(ILogger<RedisInstaller> logger)
{
this.logger = logger;
}
public async Task<InstallResult> InstallAsync(KubernetesCluster cluster, ComponentInstallOptions options, CancellationToken ct = default)
{
string version = options.Version ?? DefaultVersion;
string targetNamespace = options.Namespace ?? DefaultNamespace;
bool sentinelEnabled = options.Parameters?.TryGetValue("sentinelEnabled", out string? sentVal) == true
? sentVal == "true"
: true;
List<string> actions = new();
string tempKubeConfig = Path.Combine(Path.GetTempPath(), $"entkube-redis-{Guid.NewGuid()}.kubeconfig");
string valuesPath = Path.Combine(Path.GetTempPath(), $"entkube-redis-values-{Guid.NewGuid()}.yaml");
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");
// Build values file based on parameters.
string values = GetRedisValues(sentinelEnabled, options.Parameters);
await File.WriteAllTextAsync(valuesPath, values, ct);
// Install Redis Operator via Helm.
await RunCommand("helm",
$"upgrade --install redis-operator redis-operator " +
$"--repo {HelmRepo} --version {version} " +
$"--namespace {targetNamespace} " +
$"--kubeconfig \"{tempKubeConfig}\" --kube-context \"{cluster.ContextName}\" " +
$"--values \"{valuesPath}\" " +
$"--wait --timeout 10m",
ct);
actions.Add($"Helm install redis v{version} (sentinel={sentinelEnabled})");
logger.LogInformation("Redis {Version} installed on cluster {Cluster} (sentinel={Sentinel})",
version, cluster.Name, sentinelEnabled);
return new InstallResult(
Success: true,
ComponentName: ComponentName,
Message: $"Redis {version} installed (sentinel={sentinelEnabled})",
Actions: actions);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to install Redis on cluster {Cluster}", cluster.Name);
actions.Add($"Error: {ex.Message}");
return new InstallResult(
Success: false,
ComponentName: ComponentName,
Message: $"Failed to install Redis: {ex.Message}",
Actions: actions);
}
finally
{
if (File.Exists(tempKubeConfig))
{
File.Delete(tempKubeConfig);
}
if (File.Exists(valuesPath))
{
File.Delete(valuesPath);
}
}
}
/// <summary>
/// Reconfigures Redis. Supports:
/// - "replicaCount": number of Redis replicas
/// - "sentinelEnabled": enable/disable Sentinel
/// - "sentinelReplicas": number of Sentinel replicas
/// - "maxMemory": Redis maxmemory
/// - "maxMemoryPolicy": eviction policy
/// - "persistence": enable/disable persistence
/// - "persistenceSize": PVC size
/// </summary>
public async Task<InstallResult> ConfigureAsync(KubernetesCluster cluster, ComponentConfiguration configuration, CancellationToken ct = default)
{
string targetNamespace = configuration.Namespace ?? DefaultNamespace;
List<string> actions = new();
string tempKubeConfig = Path.Combine(Path.GetTempPath(), $"entkube-redis-cfg-{Guid.NewGuid()}.kubeconfig");
try
{
await File.WriteAllTextAsync(tempKubeConfig, cluster.KubeConfig, ct);
List<string> setFlags = new();
if (configuration.Values.TryGetValue("replicaCount", out string? replicas))
{
setFlags.Add($"--set replica.replicaCount={replicas}");
}
if (configuration.Values.TryGetValue("sentinelEnabled", out string? sentinel))
{
setFlags.Add($"--set sentinel.enabled={sentinel}");
}
if (configuration.Values.TryGetValue("sentinelReplicas", out string? sentReplicas))
{
setFlags.Add($"--set sentinel.quorum={int.Parse(sentReplicas) / 2 + 1}");
}
if (configuration.Values.TryGetValue("maxMemory", out string? maxMem))
{
setFlags.Add($"--set master.configuration=maxmemory {maxMem}");
}
if (configuration.Values.TryGetValue("maxMemoryPolicy", out string? policy))
{
setFlags.Add($"--set master.configuration=maxmemory-policy {policy}");
}
if (configuration.Values.TryGetValue("persistence", out string? persistence))
{
setFlags.Add($"--set master.persistence.enabled={persistence}");
setFlags.Add($"--set replica.persistence.enabled={persistence}");
}
if (configuration.Values.TryGetValue("persistenceSize", out string? size))
{
setFlags.Add($"--set master.persistence.size={size}");
setFlags.Add($"--set replica.persistence.size={size}");
}
await RunCommand("helm",
$"upgrade redis redis --repo {HelmRepo} " +
$"--namespace {targetNamespace} " +
$"--kubeconfig \"{tempKubeConfig}\" --kube-context \"{cluster.ContextName}\" " +
$"--reuse-values {string.Join(" ", setFlags)} --wait --timeout 10m",
ct);
actions.Add($"Reconfigured Redis: {string.Join(", ", configuration.Values.Select(kv => $"{kv.Key}={kv.Value}"))}");
return new InstallResult(
Success: true,
ComponentName: ComponentName,
Message: "Redis reconfigured",
Actions: actions);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to reconfigure Redis on cluster {Cluster}", cluster.Name);
return new InstallResult(
Success: false,
ComponentName: ComponentName,
Message: $"Failed to reconfigure Redis: {ex.Message}",
Actions: new List<string> { $"Error: {ex.Message}" });
}
finally
{
if (File.Exists(tempKubeConfig))
{
File.Delete(tempKubeConfig);
}
}
}
/// <summary>
/// Uninstalls the Redis operator from the cluster.
/// Warning: existing Redis clusters managed by the operator will become unmanaged.
/// </summary>
public async Task<InstallResult> UninstallAsync(KubernetesCluster cluster, ComponentInstallOptions options, CancellationToken ct = default)
{
string targetNamespace = options.Namespace ?? DefaultNamespace;
List<string> actions = new();
string tempKubeConfig = Path.Combine(Path.GetTempPath(), $"entkube-redis-rm-{Guid.NewGuid()}.kubeconfig");
try
{
await File.WriteAllTextAsync(tempKubeConfig, cluster.KubeConfig, ct);
await RunCommand("helm",
$"uninstall redis-operator --namespace {targetNamespace} " +
$"--kubeconfig \"{tempKubeConfig}\" --kube-context \"{cluster.ContextName}\" " +
$"--wait --timeout 5m",
ct);
actions.Add("Helm uninstall redis-operator");
logger.LogInformation("Redis operator uninstalled from cluster {Cluster}", cluster.Name);
return new InstallResult(
Success: true,
ComponentName: ComponentName,
Message: "Redis operator uninstalled successfully",
Actions: actions);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to uninstall Redis from cluster {Cluster}", cluster.Name);
return new InstallResult(
Success: false,
ComponentName: ComponentName,
Message: $"Failed to uninstall Redis: {ex.Message}",
Actions: new List<string> { $"Error: {ex.Message}" });
}
finally
{
if (File.Exists(tempKubeConfig))
{
File.Delete(tempKubeConfig);
}
}
}
private static string GetRedisValues(bool sentinelEnabled, Dictionary<string, string>? parameters)
{
string replicaCount = parameters?.TryGetValue("replicaCount", out string? rc) == true ? rc : "3";
string persistenceSize = parameters?.TryGetValue("persistenceSize", out string? ps) == true ? ps : "8Gi";
string maxMemory = parameters?.TryGetValue("maxMemory", out string? mm) == true ? mm : "256mb";
string maxMemoryPolicy = parameters?.TryGetValue("maxMemoryPolicy", out string? mmp) == true ? mmp : "allkeys-lru";
return $"""
architecture: {(sentinelEnabled ? "replication" : "standalone")}
auth:
enabled: true
sentinel: true
master:
persistence:
enabled: true
size: {persistenceSize}
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: 500m
memory: 512Mi
configuration: |
maxmemory {maxMemory}
maxmemory-policy {maxMemoryPolicy}
replica:
replicaCount: {replicaCount}
persistence:
enabled: true
size: {persistenceSize}
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: 500m
memory: 512Mi
sentinel:
enabled: {sentinelEnabled.ToString().ToLower()}
quorum: 2
resources:
requests:
cpu: 50m
memory: 64Mi
limits:
cpu: 200m
memory: 128Mi
metrics:
enabled: true
serviceMonitor:
enabled: true
networkPolicy:
enabled: true
""";
}
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<string, string>
{
["app.kubernetes.io/managed-by"] = "entkube",
["app.kubernetes.io/part-of"] = "redis-platform"
}
}
};
await client.CoreV1.CreateNamespaceAsync(ns, cancellationToken: ct);
}
}
private async Task<string> 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);
}
}

View File

@@ -1,242 +0,0 @@
using System.Text;
using EntKube.Clusters.Domain;
using k8s;
using Microsoft.Extensions.Logging;
namespace EntKube.Clusters.Features.AdoptCluster.Components.SecurityPolicies;
/// <summary>
/// Checks whether the required Kyverno ClusterPolicies are deployed on the cluster.
/// These are the workload-hardening policies from the platform/security_policies
/// Terraform module: registry allowlisting, SA token restriction, seccomp,
/// read-only rootfs, and service type restrictions.
///
/// Note: This check requires Kyverno to already be installed (CRD must exist).
/// If the CRD doesn't exist, all policies are reported as missing.
/// </summary>
public class SecurityPoliciesCheck : IAdoptionCheck
{
private readonly ILogger<SecurityPoliciesCheck> logger;
public string ComponentName => "security-policies";
public string DisplayName => "Security Policies (Kyverno ClusterPolicies)";
/// <summary>
/// The policies that must exist for the cluster to meet the security baseline.
/// These mirror exactly what the Terraform security_policies module deploys.
/// </summary>
public static readonly List<string> RequiredPolicies = new()
{
"restrict-image-registries",
"restrict-sa-token-automount",
"require-seccomp-runtime-default",
"require-readonly-root-filesystem",
"deny-loadbalancer-services"
};
public SecurityPoliciesCheck(ILogger<SecurityPoliciesCheck> logger)
{
this.logger = logger;
}
public async Task<ComponentCheckResult> CheckAsync(KubernetesCluster cluster, CancellationToken ct = default)
{
logger.LogWarning("[DIAG] SecurityPoliciesCheck.CheckAsync starting for cluster {ClusterId}", cluster.Id);
Kubernetes client = BuildClient(cluster);
List<string> details = new();
List<string> missing = new();
// List all ClusterPolicy resources from the Kyverno CRD.
List<string> foundPolicies = await ListClusterPolicies(client, ct);
logger.LogWarning("[DIAG] SecurityPoliciesCheck: Found {Count} policies: [{Policies}]",
foundPolicies.Count, string.Join(", ", foundPolicies));
if (foundPolicies.Count == 0 && !await KyvernoCrdExists(client, ct))
{
// The Kyverno CRD isn't even registered — all policies are missing
// because Kyverno itself isn't installed.
missing.AddRange(RequiredPolicies);
details.Add("Kyverno CRD (clusterpolicies.kyverno.io) not found — Kyverno must be installed first");
return new ComponentCheckResult(ComponentName, ComponentStatus.NotInstalled, details, missing);
}
// Check each required policy against what we found on the cluster.
foreach (string required in RequiredPolicies)
{
if (foundPolicies.Contains(required))
{
details.Add($"Policy present: {required}");
}
else
{
missing.Add(required);
}
}
// Check configuration drift on the SA token policy — verify it has preconditions
// that scope mutations to customer pods only (not infrastructure workloads).
bool hasDrift = await HasPolicyConfigurationDrift(client, ct);
if (hasDrift)
{
details.Add("Configuration drift: restrict-sa-token-automount missing preconditions (will mutate infrastructure pods)");
}
// Build configuration showing which policies are deployed and their count.
DiscoveredConfiguration config = new(
Version: null,
Namespace: null,
HelmReleaseName: null,
Values: new Dictionary<string, string>
{
["installedPolicies"] = string.Join(",", foundPolicies.Where(p => RequiredPolicies.Contains(p))),
["policyCount"] = foundPolicies.Count(p => RequiredPolicies.Contains(p)).ToString(),
["totalPoliciesOnCluster"] = foundPolicies.Count.ToString(),
["configurationDrift"] = hasDrift.ToString()
});
if (missing.Count == RequiredPolicies.Count)
{
return new ComponentCheckResult(ComponentName, ComponentStatus.NotInstalled, details, missing);
}
if (missing.Count > 0 || hasDrift)
{
return new ComponentCheckResult(ComponentName, ComponentStatus.Degraded, details, missing, config);
}
return new ComponentCheckResult(ComponentName, ComponentStatus.Installed, details, missing, config);
}
private async Task<List<string>> ListClusterPolicies(Kubernetes client, CancellationToken ct)
{
List<string> policies = new();
try
{
object result = await client.CustomObjects.ListClusterCustomObjectAsync(
group: "kyverno.io",
version: "v1",
plural: "clusterpolicies",
cancellationToken: ct);
// The k8s client returns different runtime types depending on the
// serializer (JsonElement, JObject, Dictionary, etc.). Serialize to
// JSON first, then parse — this works regardless of the runtime type.
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))
{
foreach (System.Text.Json.JsonElement item in items.EnumerateArray())
{
if (item.TryGetProperty("metadata", out System.Text.Json.JsonElement metadata)
&& metadata.TryGetProperty("name", out System.Text.Json.JsonElement name))
{
string? policyName = name.GetString();
if (!string.IsNullOrEmpty(policyName))
{
policies.Add(policyName);
}
}
}
}
}
catch (k8s.Autorest.HttpOperationException ex) when (ex.Response.StatusCode == System.Net.HttpStatusCode.NotFound)
{
logger.LogWarning("[DIAG] SecurityPoliciesCheck: CRD not found (404)");
}
catch (Exception ex)
{
logger.LogWarning(ex, "[DIAG] SecurityPoliciesCheck: Error listing ClusterPolicies: {Message}", ex.Message);
}
return policies;
}
private async Task<bool> KyvernoCrdExists(Kubernetes client, CancellationToken ct)
{
try
{
await client.ApiextensionsV1.ReadCustomResourceDefinitionAsync(
"clusterpolicies.kyverno.io", cancellationToken: ct);
return true;
}
catch
{
return false;
}
}
/// <summary>
/// Checks the restrict-sa-token-automount policy for configuration drift.
/// Only applies to EntKube-managed policies (labelled with managed-by=entkube).
/// Terraform-installed policies use different precondition patterns which is
/// expected — not drift. The check looks for our customer label preconditions
/// which prevent the mutation policy from affecting infrastructure pods.
/// </summary>
private async Task<bool> HasPolicyConfigurationDrift(Kubernetes client, CancellationToken ct)
{
try
{
object result = await client.CustomObjects.GetClusterCustomObjectAsync(
group: "kyverno.io",
version: "v1",
plural: "clusterpolicies",
name: "restrict-sa-token-automount",
cancellationToken: ct);
string json = System.Text.Json.JsonSerializer.Serialize(result);
// First check if this policy was installed by EntKube. Terraform-managed
// policies have different precondition patterns and that is expected,
// not configuration drift.
bool isManagedByEntKube = json.Contains("\"app.kubernetes.io/managed-by\"")
&& json.Contains("entkube");
if (!isManagedByEntKube)
{
// Policy was installed by Terraform or another tool — different
// preconditions are expected, not drift.
return false;
}
// For EntKube-managed policies, verify the customer label preconditions
// exist so the mutation only targets customer pods.
bool hasPreconditions = json.Contains("preconditions") && json.Contains("entkube.io/customer");
return !hasPreconditions;
}
catch (k8s.Autorest.HttpOperationException ex) when (ex.Response.StatusCode == System.Net.HttpStatusCode.NotFound)
{
// Policy doesn't exist — drift detection not applicable (missing is caught elsewhere).
return false;
}
catch (Exception ex)
{
logger.LogDebug(ex, "Could not check policy drift for restrict-sa-token-automount");
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);
}
}

View File

@@ -1,792 +0,0 @@
using System.Text;
using EntKube.Clusters.Domain;
using k8s;
using k8s.Models;
using Microsoft.Extensions.Logging;
namespace EntKube.Clusters.Features.AdoptCluster.Components.SecurityPolicies;
/// <summary>
/// Deploys the required security ClusterPolicies onto the cluster. These are
/// Kyverno ClusterPolicy manifests that enforce workload hardening:
///
/// - restrict-image-registries: only allow images from approved registries
/// - restrict-sa-token-automount: disable automatic SA token mounting
/// - require-seccomp-runtime-default: enforce seccomp profiles
/// - require-readonly-root-filesystem: require readOnlyRootFilesystem=true
/// - deny-loadbalancer-services: block LB/NodePort in tenant namespaces
///
/// This mirrors the platform/security_policies Terraform module. Policies are
/// applied as Kyverno ClusterPolicy custom resources via the Kubernetes API.
///
/// Prerequisite: Kyverno must be installed (CRDs must exist).
/// </summary>
public class SecurityPoliciesInstaller : IComponentInstaller
{
private readonly ILogger<SecurityPoliciesInstaller> logger;
private readonly WorkloadRemediator remediator;
public string ComponentName => "security-policies";
/// <summary>
/// Default validation action. "Audit" reports violations without blocking;
/// "Enforce" blocks non-compliant workloads. Start with Audit during adoption,
/// switch to Enforce once tenants are compliant.
/// </summary>
private const string DefaultValidationAction = "Audit";
/// <summary>
/// Namespaces excluded from security policies — platform and infrastructure
/// namespaces that need to run without restrictions. These are component
/// namespaces managed by EntKube where workloads have legitimate needs
/// for writable filesystems, privileged access, or custom images.
/// </summary>
internal static readonly List<string> DefaultExcludedNamespaces = new()
{
"kube-system",
"kube-public",
"kube-node-lease",
"kyverno",
"cert-manager",
"istio-system",
"internal-ingress",
"external-ingress",
"traefik",
"monitoring",
"argocd",
"external-secrets",
"minio-operator",
"cnpg-system",
"harbor",
"keycloak",
"rabbitmq-system",
"redis",
"gitea"
};
/// <summary>
/// Services that require a writable root filesystem due to their architecture.
/// These are excluded from the readOnlyRootFilesystem policy and remediation,
/// even when deployed in tenant namespaces. Matched via the standard
/// app.kubernetes.io/name label.
/// </summary>
public static readonly List<string> WritableRootFsApps = new()
{
"keycloak",
"minio",
"harbor",
"harbor-core",
"harbor-registry",
"harbor-jobservice",
"harbor-trivy",
"postgresql",
"rabbitmq",
"redis",
"elasticsearch",
"grafana",
"gitea"
};
public SecurityPoliciesInstaller(ILogger<SecurityPoliciesInstaller> logger, WorkloadRemediator remediator)
{
this.logger = logger;
this.remediator = remediator;
}
public async Task<InstallResult> InstallAsync(KubernetesCluster cluster, ComponentInstallOptions options, CancellationToken ct = default)
{
List<string> actions = new();
string validationAction = options.Parameters?.GetValueOrDefault("validationAction") ?? DefaultValidationAction;
try
{
Kubernetes client = BuildClient(cluster);
// Before applying policies, we scan existing workloads and attempt
// to bring them into compliance. Any workload that can't be patched
// gets a PolicyException so it won't be blocked by the new policies.
RemediationResult remediation = await remediator.RemediateAsync(client, DefaultExcludedNamespaces, ct);
if (remediation.PatchedWorkloads.Count > 0)
{
actions.Add($"Remediated {remediation.PatchedWorkloads.Count} workload(s) for policy compliance");
}
if (remediation.PolicyExceptions.Count > 0)
{
await remediator.ApplyPolicyExceptionsAsync(client, remediation.PolicyExceptions, ct);
actions.Add($"Created {remediation.PolicyExceptions.Count} PolicyException(s) for non-remediable workloads");
}
// Now deploy each policy. The method is idempotent — creates if missing,
// updates if already exists.
await ApplyRestrictSaTokenPolicy(client, validationAction, ct);
actions.Add("Applied: restrict-sa-token-automount");
await ApplyRequireSeccompPolicy(client, validationAction, ct);
actions.Add("Applied: require-seccomp-runtime-default");
await ApplyRequireReadonlyRootfsPolicy(client, validationAction, ct);
actions.Add("Applied: require-readonly-root-filesystem");
await ApplyDenyLoadBalancerPolicy(client, ct);
actions.Add("Applied: deny-loadbalancer-services");
await ApplyRestrictRegistriesPolicy(client, validationAction, options, ct);
actions.Add("Applied: restrict-image-registries");
logger.LogInformation("Security policies deployed on cluster {Cluster} with action={Action}",
cluster.Name, validationAction);
return new InstallResult(
Success: true,
ComponentName: ComponentName,
Message: $"Security policies deployed (validationAction={validationAction})",
Actions: actions);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to deploy security policies on cluster {Cluster}", cluster.Name);
actions.Add($"Error: {ex.Message}");
return new InstallResult(
Success: false,
ComponentName: ComponentName,
Message: $"Failed to deploy security policies: {ex.Message}",
Actions: actions);
}
}
private async Task ApplyRestrictSaTokenPolicy(Kubernetes client, string validationAction, CancellationToken ct)
{
object policy = BuildClusterPolicy(
name: "restrict-sa-token-automount",
description: "Mutates pods in tenant namespaces to set automountServiceAccountToken=false",
validationAction: validationAction,
rules: new List<object>
{
new Dictionary<string, object>
{
["name"] = "disable-sa-automount",
["match"] = new Dictionary<string, object>
{
["any"] = new List<object>
{
new Dictionary<string, object> { ["resources"] = new Dictionary<string, object> { ["kinds"] = new List<string> { "Pod" } } }
}
},
["exclude"] = BuildExcludeBlock(),
["preconditions"] = new Dictionary<string, object>
{
["all"] = new List<object>
{
new Dictionary<string, object>
{
["key"] = "{{ request.namespace }}",
["operator"] = "AnyNotIn",
["value"] = DefaultExcludedNamespaces
},
new Dictionary<string, object>
{
["key"] = "{{ request.object.metadata.labels.\"entkube.io/customer\" || '' }}",
["operator"] = "NotEquals",
["value"] = ""
}
}
},
["mutate"] = new Dictionary<string, object>
{
["patchStrategicMerge"] = new Dictionary<string, object>
{
["spec"] = new Dictionary<string, object> { ["automountServiceAccountToken"] = false }
}
}
}
});
await ApplyClusterPolicy(client, "restrict-sa-token-automount", policy, ct);
}
private async Task ApplyRequireSeccompPolicy(Kubernetes client, string validationAction, CancellationToken ct)
{
// The policy uses a mutate-then-validate pattern:
// 1. A mutate rule injects seccompProfile.type = RuntimeDefault into any pod
// that doesn't already have one set. This covers operator-created pods
// (e.g., OT-OPERATORS Redis) that don't set seccomp natively.
// 2. A validate rule then checks that all pods have a valid seccomp profile.
// Pods that were mutated in step 1 will pass validation.
object policy = BuildClusterPolicy(
name: "require-seccomp-runtime-default",
description: "Mutates pods to add RuntimeDefault seccomp profile if missing, then validates",
validationAction: validationAction,
rules: new List<object>
{
new Dictionary<string, object>
{
["name"] = "mutate-seccomp",
["match"] = new Dictionary<string, object>
{
["any"] = new List<object>
{
new Dictionary<string, object> { ["resources"] = new Dictionary<string, object> { ["kinds"] = new List<string> { "Pod" } } }
}
},
["exclude"] = BuildExcludeBlock(),
["mutate"] = new Dictionary<string, object>
{
["patchStrategicMerge"] = new Dictionary<string, object>
{
["spec"] = new Dictionary<string, object>
{
["securityContext"] = new Dictionary<string, object>
{
["+(seccompProfile)"] = new Dictionary<string, object>
{
["+(type)"] = "RuntimeDefault"
}
}
}
}
}
},
new Dictionary<string, object>
{
["name"] = "validate-seccomp",
["match"] = new Dictionary<string, object>
{
["any"] = new List<object>
{
new Dictionary<string, object> { ["resources"] = new Dictionary<string, object> { ["kinds"] = new List<string> { "Pod" } } }
}
},
["exclude"] = BuildExcludeBlock(),
["validate"] = new Dictionary<string, object>
{
["message"] = "Pod must set securityContext.seccompProfile.type to RuntimeDefault or Localhost.",
["pattern"] = new Dictionary<string, object>
{
["spec"] = new Dictionary<string, object>
{
["securityContext"] = new Dictionary<string, object>
{
["seccompProfile"] = new Dictionary<string, object> { ["type"] = "RuntimeDefault | Localhost" }
}
}
}
}
}
});
await ApplyClusterPolicy(client, "require-seccomp-runtime-default", policy, ct);
}
private async Task ApplyRequireReadonlyRootfsPolicy(Kubernetes client, string validationAction, CancellationToken ct)
{
object policy = BuildClusterPolicy(
name: "require-readonly-root-filesystem",
description: "Validates that all containers set readOnlyRootFilesystem=true",
validationAction: validationAction,
rules: new List<object>
{
new Dictionary<string, object>
{
["name"] = "validate-readonly-rootfs",
["match"] = new Dictionary<string, object>
{
["any"] = new List<object>
{
new Dictionary<string, object> { ["resources"] = new Dictionary<string, object> { ["kinds"] = new List<string> { "Pod" } } }
}
},
["exclude"] = BuildReadonlyRootfsExcludeBlock(),
["validate"] = new Dictionary<string, object>
{
["message"] = "All containers must set securityContext.readOnlyRootFilesystem to true.",
["foreach"] = new List<object>
{
new Dictionary<string, object>
{
["list"] = "request.object.spec.[initContainers, containers][]",
["deny"] = new Dictionary<string, object>
{
["conditions"] = new Dictionary<string, object>
{
["any"] = new List<object>
{
new Dictionary<string, object>
{
["key"] = "{{ element.securityContext.readOnlyRootFilesystem || false }}",
["operator"] = "NotEquals",
["value"] = true
}
}
}
}
}
}
}
}
});
await ApplyClusterPolicy(client, "require-readonly-root-filesystem", policy, ct);
}
private async Task ApplyDenyLoadBalancerPolicy(Kubernetes client, CancellationToken ct)
{
object policy = BuildClusterPolicy(
name: "deny-loadbalancer-services",
description: "Service type LoadBalancer is not allowed in tenant namespaces",
validationAction: "Enforce",
rules: new List<object>
{
new Dictionary<string, object>
{
["name"] = "deny-service-type-loadbalancer",
["match"] = new Dictionary<string, object>
{
["any"] = new List<object>
{
new Dictionary<string, object> { ["resources"] = new Dictionary<string, object> { ["kinds"] = new List<string> { "Service" } } }
}
},
["exclude"] = BuildExcludeBlock(),
["validate"] = new Dictionary<string, object>
{
["message"] = "Service type LoadBalancer is not allowed in tenant namespaces.",
["pattern"] = new Dictionary<string, object>
{
["spec"] = new Dictionary<string, object> { ["=(type)"] = "!LoadBalancer" }
}
}
},
new Dictionary<string, object>
{
["name"] = "deny-service-type-nodeport",
["match"] = new Dictionary<string, object>
{
["any"] = new List<object>
{
new Dictionary<string, object> { ["resources"] = new Dictionary<string, object> { ["kinds"] = new List<string> { "Service" } } }
}
},
["exclude"] = BuildExcludeBlock(),
["validate"] = new Dictionary<string, object>
{
["message"] = "Service type NodePort is not allowed in tenant namespaces.",
["pattern"] = new Dictionary<string, object>
{
["spec"] = new Dictionary<string, object> { ["=(type)"] = "!NodePort" }
}
}
}
});
await ApplyClusterPolicy(client, "deny-loadbalancer-services", policy, ct);
}
private async Task ApplyRestrictRegistriesPolicy(Kubernetes client, string validationAction, ComponentInstallOptions options, CancellationToken ct)
{
// If registries are explicitly provided in options, use those.
// Otherwise, read the current registries from the live policy so custom
// settings aren't lost when reconfiguring (e.g., switching Audit → Enforce).
List<string> allowedRegistries;
if (options.Parameters?.ContainsKey("allowedRegistries") == true)
{
allowedRegistries = options.Parameters["allowedRegistries"].Split(',').Select(r => r.Trim()).ToList();
}
else
{
allowedRegistries = await ReadCurrentRegistriesFromPolicyAsync(client, ct);
if (allowedRegistries.Count == 0)
{
// No existing policy or couldn't read — fall back to defaults.
allowedRegistries = new() { "docker.io", "ghcr.io", "registry.k8s.io", "quay.io", "mcr.microsoft.com" };
}
}
string registryMessage = $"Images must come from an approved registry. Allowed: {string.Join(", ", allowedRegistries)}";
object policy = BuildClusterPolicy(
name: "restrict-image-registries",
description: "Only images from approved registries are allowed",
validationAction: validationAction,
rules: new List<object>
{
new Dictionary<string, object>
{
["name"] = "validate-image-registry",
["match"] = new Dictionary<string, object>
{
["any"] = new List<object>
{
new Dictionary<string, object> { ["resources"] = new Dictionary<string, object> { ["kinds"] = new List<string> { "Pod" } } }
}
},
["exclude"] = BuildExcludeBlock(),
["validate"] = new Dictionary<string, object>
{
["message"] = registryMessage,
["foreach"] = new List<object>
{
new Dictionary<string, object>
{
["list"] = "request.object.spec.[initContainers, containers, ephemeralContainers][]",
["deny"] = new Dictionary<string, object>
{
["conditions"] = new Dictionary<string, object>
{
["all"] = allowedRegistries.Select(reg => (object)new Dictionary<string, object>
{
["key"] = "{{ element.image }}",
["operator"] = "NotEquals",
["value"] = $"{reg}/*"
}).ToList()
}
}
}
}
}
}
});
await ApplyClusterPolicy(client, "restrict-image-registries", policy, ct);
}
private static object BuildClusterPolicy(string name, string description, string validationAction, List<object> rules)
{
return new Dictionary<string, object>
{
["apiVersion"] = "kyverno.io/v1",
["kind"] = "ClusterPolicy",
["metadata"] = new Dictionary<string, object>
{
["name"] = name,
["labels"] = new Dictionary<string, string>
{
["app.kubernetes.io/part-of"] = "security-policies",
["app.kubernetes.io/managed-by"] = "entkube"
},
["annotations"] = new Dictionary<string, string>
{
["policies.kyverno.io/description"] = description
}
},
["spec"] = new Dictionary<string, object>
{
["validationFailureAction"] = validationAction,
["background"] = true,
["rules"] = rules
}
};
}
internal static Dictionary<string, object> BuildExcludeBlock()
{
return new Dictionary<string, object>
{
["any"] = new List<object>
{
new Dictionary<string, object>
{
["resources"] = new Dictionary<string, object> { ["namespaces"] = DefaultExcludedNamespaces }
}
}
};
}
/// <summary>
/// Builds an exclude block for the readOnlyRootFilesystem policy.
/// In addition to the standard namespace exclusions, this also excludes
/// pods with app.kubernetes.io/name labels matching services that require
/// a writable root filesystem (e.g., Keycloak, MinIO, Harbor).
/// </summary>
private static Dictionary<string, object> BuildReadonlyRootfsExcludeBlock()
{
return new Dictionary<string, object>
{
["any"] = new List<object>
{
new Dictionary<string, object>
{
["resources"] = new Dictionary<string, object> { ["namespaces"] = DefaultExcludedNamespaces }
},
new Dictionary<string, object>
{
["resources"] = new Dictionary<string, object>
{
["selector"] = new Dictionary<string, object>
{
["matchExpressions"] = new List<object>
{
new Dictionary<string, object>
{
["key"] = "app.kubernetes.io/name",
["operator"] = "In",
["values"] = WritableRootFsApps
}
}
}
}
}
}
};
}
private async Task ApplyClusterPolicy(Kubernetes client, string name, object policy, CancellationToken ct)
{
try
{
// Try to get existing — if it exists, we need its resourceVersion for the update.
object existing = await client.CustomObjects.GetClusterCustomObjectAsync(
group: "kyverno.io", version: "v1", plural: "clusterpolicies", name: name, cancellationToken: ct);
// Extract resourceVersion from the existing resource and set it on our replacement.
string existingJson = System.Text.Json.JsonSerializer.Serialize(existing);
using System.Text.Json.JsonDocument doc = System.Text.Json.JsonDocument.Parse(existingJson);
string resourceVersion = doc.RootElement.GetProperty("metadata").GetProperty("resourceVersion").GetString() ?? "";
// Set resourceVersion on the policy we're about to apply.
if (policy is Dictionary<string, object> policyDict
&& policyDict["metadata"] is Dictionary<string, object> metadata)
{
metadata["resourceVersion"] = resourceVersion;
}
await client.CustomObjects.ReplaceClusterCustomObjectAsync(
body: policy, group: "kyverno.io", version: "v1", plural: "clusterpolicies", name: name, cancellationToken: ct);
}
catch (k8s.Autorest.HttpOperationException ex) when (ex.Response.StatusCode == System.Net.HttpStatusCode.NotFound)
{
// Doesn't exist — create it.
await client.CustomObjects.CreateClusterCustomObjectAsync(
body: policy, group: "kyverno.io", version: "v1", plural: "clusterpolicies", cancellationToken: ct);
}
}
/// <summary>
/// Reads the currently allowed registries from the live Kyverno policy on the
/// cluster. This is used during reconfiguration so we don't overwrite custom
/// registries that were saved via the Settings tab. If the policy doesn't exist
/// or can't be read, returns an empty list so the caller can fall back to defaults.
/// </summary>
private async Task<List<string>> ReadCurrentRegistriesFromPolicyAsync(Kubernetes client, CancellationToken ct)
{
try
{
object result = await client.CustomObjects.GetClusterCustomObjectAsync(
group: "kyverno.io",
version: "v1",
plural: "clusterpolicies",
name: "restrict-image-registries",
cancellationToken: ct);
string json = System.Text.Json.JsonSerializer.Serialize(result);
return ClusterSettings.KyvernoClusterSettingsProvider.ExtractRegistriesFromPolicyJson(json);
}
catch
{
return new List<string>();
}
}
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);
}
/// <summary>
/// Reconfigures security policies. Supports:
/// - "validationAction": Audit or Enforce — re-applies all policies with new action
/// - "revertPatches": true — deletes seccomp and readOnlyRootFilesystem policies
/// and reverts the workload patches applied during adoption
/// </summary>
public async Task<InstallResult> ConfigureAsync(KubernetesCluster cluster, ComponentConfiguration configuration, CancellationToken ct = default)
{
// Check if the user wants to revert the seccomp and readOnlyRootFilesystem
// patches. This is a temporary escape hatch for when these policies cause
// issues with running workloads on the cluster.
bool revertPatches = configuration.Values.GetValueOrDefault("revertPatches", "false")
.Equals("true", StringComparison.OrdinalIgnoreCase);
if (revertPatches)
{
return await RevertSecurityPatchesAsync(cluster, ct);
}
// Normal reconfiguration: re-apply all policies with the requested
// validation action (Audit or Enforce). Also forward any allowed
// registries override so they're preserved during reconfiguration.
string validationAction = configuration.Values.GetValueOrDefault("validationAction", DefaultValidationAction);
Dictionary<string, string> parameters = new()
{
["validationAction"] = validationAction
};
if (configuration.Values.ContainsKey("allowedRegistries"))
{
parameters["allowedRegistries"] = configuration.Values["allowedRegistries"];
}
ComponentInstallOptions options = new(
Parameters: parameters);
return await InstallAsync(cluster, options, ct);
}
/// <summary>
/// Uninstalls security policies by deleting all Kyverno ClusterPolicy resources
/// that were applied by this installer. Workloads will no longer be subject to
/// security enforcement after this operation.
/// </summary>
public async Task<InstallResult> UninstallAsync(KubernetesCluster cluster, ComponentInstallOptions options, CancellationToken ct = default)
{
List<string> actions = new();
try
{
Kubernetes client = BuildClient(cluster);
// Delete each known ClusterPolicy applied by this installer.
string[] policyNames = new[]
{
"restrict-image-registries",
"restrict-sa-token-automount",
"require-seccomp-runtime-default",
"require-readonly-root-filesystem",
"deny-loadbalancer-services"
};
foreach (string policyName in policyNames)
{
try
{
await client.CustomObjects.DeleteClusterCustomObjectAsync(
"kyverno.io", "v1", "clusterpolicies", policyName, cancellationToken: ct);
actions.Add($"Deleted ClusterPolicy '{policyName}'");
}
catch (k8s.Autorest.HttpOperationException ex) when (ex.Response.StatusCode == System.Net.HttpStatusCode.NotFound)
{
// Policy doesn't exist — skip.
}
}
logger.LogInformation("Security policies removed from cluster {Cluster}", cluster.Name);
return new InstallResult(
Success: true,
ComponentName: ComponentName,
Message: $"Deleted {actions.Count} security policies",
Actions: actions);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to uninstall security policies from cluster {Cluster}", cluster.Name);
return new InstallResult(
Success: false,
ComponentName: ComponentName,
Message: $"Failed to uninstall security policies: {ex.Message}",
Actions: new List<string> { $"Error: {ex.Message}" });
}
}
/// <summary>
/// Temporary revert operation: deletes the seccomp and readOnlyRootFilesystem
/// ClusterPolicies from the cluster, and undoes the workload patches that were
/// applied during adoption. This allows workloads to run without these security
/// constraints while issues are investigated.
/// </summary>
private async Task<InstallResult> RevertSecurityPatchesAsync(KubernetesCluster cluster, CancellationToken ct)
{
List<string> actions = new();
try
{
Kubernetes client = BuildClient(cluster);
// Step 1: Delete the two ClusterPolicy resources from the cluster.
// Once deleted, Kyverno stops enforcing these rules immediately.
await DeleteClusterPolicyIfExistsAsync(client, "require-seccomp-runtime-default", actions, ct);
await DeleteClusterPolicyIfExistsAsync(client, "require-readonly-root-filesystem", actions, ct);
// Step 2: Revert the workload patches that were applied during adoption.
// This removes seccomp profiles and readOnlyRootFilesystem=true from
// workloads in tenant namespaces so pods can restart without issues.
RemediationResult revertResult = await remediator.RevertPatchesAsync(client, DefaultExcludedNamespaces, ct);
if (revertResult.PatchedWorkloads.Count > 0)
{
actions.Add($"Reverted patches on {revertResult.PatchedWorkloads.Count} workload(s)");
}
logger.LogWarning(
"Security patches reverted on cluster {Cluster}: seccomp and readOnlyRootFilesystem policies deleted, {Count} workloads reverted",
cluster.Name, revertResult.PatchedWorkloads.Count);
return new InstallResult(
Success: true,
ComponentName: ComponentName,
Message: "Seccomp and readOnlyRootFilesystem policies reverted successfully",
Actions: actions);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to revert security patches on cluster {Cluster}", cluster.Name);
actions.Add($"Error: {ex.Message}");
return new InstallResult(
Success: false,
ComponentName: ComponentName,
Message: $"Failed to revert security patches: {ex.Message}",
Actions: actions);
}
}
/// <summary>
/// Deletes a single Kyverno ClusterPolicy by name. If the policy doesn't
/// exist, this is a no-op. Records the action in the provided list.
/// </summary>
private async Task DeleteClusterPolicyIfExistsAsync(
Kubernetes client, string policyName, List<string> actions, CancellationToken ct)
{
try
{
await client.CustomObjects.DeleteClusterCustomObjectAsync(
group: "kyverno.io",
version: "v1",
plural: "clusterpolicies",
name: policyName,
cancellationToken: ct);
actions.Add($"Deleted ClusterPolicy: {policyName}");
logger.LogInformation("Deleted ClusterPolicy {Policy}", policyName);
}
catch (k8s.Autorest.HttpOperationException ex) when (ex.Response.StatusCode == System.Net.HttpStatusCode.NotFound)
{
// Policy doesn't exist — nothing to delete.
actions.Add($"ClusterPolicy {policyName} not found (already deleted)");
logger.LogDebug("ClusterPolicy {Policy} not found, skipping delete", policyName);
}
}
}

View File

@@ -1,816 +0,0 @@
using k8s;
using k8s.Models;
using Microsoft.Extensions.Logging;
namespace EntKube.Clusters.Features.AdoptCluster.Components.SecurityPolicies;
/// <summary>
/// Before security policies are applied, existing workloads may violate them.
/// The WorkloadRemediator scans all Deployments, StatefulSets, and DaemonSets
/// across tenant namespaces. For each non-compliant workload, it attempts to
/// patch the pod template to comply with the security policies. If patching
/// fails (RBAC, immutable fields, etc.), it creates a Kyverno PolicyException
/// so the workload isn't blocked by the newly-applied policies.
///
/// The two patchable violations are:
/// - Missing seccomp RuntimeDefault profile at pod level
/// - Missing readOnlyRootFilesystem=true on containers
///
/// Non-patchable violations (image registry, service type) are always handled
/// via PolicyExceptions because fixing them requires rebuilding/redeploying.
/// </summary>
public class WorkloadRemediator
{
private readonly ILogger<WorkloadRemediator> logger;
public WorkloadRemediator(ILogger<WorkloadRemediator> logger)
{
this.logger = logger;
}
/// <summary>
/// Scans and remediates all workloads on the cluster. This is the main entry
/// point called by the SecurityPoliciesInstaller before applying policies.
///
/// For each workload in non-excluded namespaces:
/// 1. Check if it needs seccomp profile → patch it
/// 2. Check if it needs readOnlyRootFilesystem → patch it
/// 3. If any patch fails → create a PolicyException for that workload
///
/// Returns a full report of what was patched and what was exempted.
/// </summary>
public async Task<RemediationResult> RemediateAsync(
IKubernetes client,
List<string> excludedNamespaces,
CancellationToken ct)
{
List<PatchedWorkload> patchedWorkloads = new();
List<PolicyExceptionEntry> policyExceptions = new();
// Gather all workloads across the cluster. We check Deployments,
// StatefulSets, and DaemonSets — the three main workload controllers
// that define pod templates subject to policy enforcement.
List<WorkloadInfo> workloads = await GatherWorkloadsAsync(client, excludedNamespaces, ct);
// For each workload, evaluate compliance and attempt remediation.
foreach (WorkloadInfo workload in workloads)
{
await RemediateWorkloadAsync(client, workload, patchedWorkloads, policyExceptions, ct);
}
logger.LogInformation(
"Workload remediation complete: {Patched} patched, {Excepted} exceptions created",
patchedWorkloads.Count, policyExceptions.Count);
return new RemediationResult(patchedWorkloads, policyExceptions);
}
/// <summary>
/// Creates a Kyverno PolicyException for workloads that cannot be patched
/// and applies it to the cluster.
/// </summary>
public async Task ApplyPolicyExceptionsAsync(
IKubernetes client,
List<PolicyExceptionEntry> exceptions,
CancellationToken ct)
{
foreach (PolicyExceptionEntry entry in exceptions)
{
PolicyExceptionRequest request = new(
WorkloadName: entry.WorkloadName,
Namespace: entry.Namespace,
WorkloadKind: entry.WorkloadKind,
PolicyNames: entry.PolicyNames);
object exceptionResource = BuildPolicyException(request);
try
{
// Try to create the PolicyException in the workload's namespace.
await client.CustomObjects.CreateNamespacedCustomObjectAsync(
body: exceptionResource,
group: "kyverno.io",
version: "v2",
namespaceParameter: entry.Namespace,
plural: "policyexceptions",
cancellationToken: ct);
logger.LogInformation(
"Created PolicyException for {Kind}/{Name} in {Namespace} covering policies: {Policies}",
entry.WorkloadKind, entry.WorkloadName, entry.Namespace,
string.Join(", ", entry.PolicyNames));
}
catch (k8s.Autorest.HttpOperationException ex) when (ex.Response.StatusCode == System.Net.HttpStatusCode.Conflict)
{
// Exception already exists — that's fine, it's idempotent.
logger.LogDebug("PolicyException already exists for {Name} in {Namespace}", entry.WorkloadName, entry.Namespace);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to create PolicyException for {Name} in {Namespace}", entry.WorkloadName, entry.Namespace);
}
}
}
/// <summary>
/// Builds a Kyverno PolicyException resource for a workload that can't
/// be made compliant. This exempts the workload from the listed policies
/// so it isn't blocked or flagged after policies are enforced.
/// </summary>
public static object BuildPolicyException(PolicyExceptionRequest request)
{
// A PolicyException tells Kyverno: "don't enforce these policies on
// pods created by this specific workload in this namespace."
List<object> exceptions = request.PolicyNames.Select(policy => (object)new Dictionary<string, object>
{
["policyName"] = policy,
["ruleNames"] = new List<string> { "*" }
}).ToList();
return new Dictionary<string, object>
{
["apiVersion"] = "kyverno.io/v2",
["kind"] = "PolicyException",
["metadata"] = new Dictionary<string, object>
{
["name"] = $"entkube-exception-{request.WorkloadName}",
["namespace"] = request.Namespace,
["labels"] = new Dictionary<string, string>
{
["app.kubernetes.io/managed-by"] = "entkube",
["entkube/exception-reason"] = "pre-policy-remediation-failure"
}
},
["spec"] = new Dictionary<string, object>
{
["exceptions"] = exceptions,
["match"] = new Dictionary<string, object>
{
["any"] = new List<object>
{
new Dictionary<string, object>
{
["resources"] = new Dictionary<string, object>
{
["namespaces"] = new List<string> { request.Namespace },
["names"] = new List<string> { $"{request.WorkloadName}*" },
["kinds"] = new List<string> { "Pod" }
}
}
}
}
}
};
}
// --- Private Implementation ---
private async Task<List<WorkloadInfo>> GatherWorkloadsAsync(
IKubernetes client,
List<string> excludedNamespaces,
CancellationToken ct)
{
List<WorkloadInfo> workloads = new();
// Gather Deployments from all namespaces, then filter out excluded ones.
V1DeploymentList deployments = await client.AppsV1.ListDeploymentForAllNamespacesAsync(cancellationToken: ct);
foreach (V1Deployment deployment in deployments.Items)
{
string ns = deployment.Metadata.NamespaceProperty ?? "default";
if (excludedNamespaces.Contains(ns))
{
continue;
}
workloads.Add(new WorkloadInfo(
Name: deployment.Metadata.Name,
Namespace: ns,
Kind: "Deployment",
PodSpec: deployment.Spec.Template.Spec,
Labels: deployment.Spec.Template.Metadata?.Labels));
}
// Gather StatefulSets.
V1StatefulSetList statefulSets = await client.AppsV1.ListStatefulSetForAllNamespacesAsync(cancellationToken: ct);
foreach (V1StatefulSet sts in statefulSets.Items)
{
string ns = sts.Metadata.NamespaceProperty ?? "default";
if (excludedNamespaces.Contains(ns))
{
continue;
}
workloads.Add(new WorkloadInfo(
Name: sts.Metadata.Name,
Namespace: ns,
Kind: "StatefulSet",
PodSpec: sts.Spec.Template.Spec,
Labels: sts.Spec.Template.Metadata?.Labels));
}
// Gather DaemonSets.
V1DaemonSetList daemonSets = await client.AppsV1.ListDaemonSetForAllNamespacesAsync(cancellationToken: ct);
foreach (V1DaemonSet ds in daemonSets.Items)
{
string ns = ds.Metadata.NamespaceProperty ?? "default";
if (excludedNamespaces.Contains(ns))
{
continue;
}
workloads.Add(new WorkloadInfo(
Name: ds.Metadata.Name,
Namespace: ns,
Kind: "DaemonSet",
PodSpec: ds.Spec.Template.Spec,
Labels: ds.Spec.Template.Metadata?.Labels));
}
return workloads;
}
private async Task RemediateWorkloadAsync(
IKubernetes client,
WorkloadInfo workload,
List<PatchedWorkload> patchedWorkloads,
List<PolicyExceptionEntry> policyExceptions,
CancellationToken ct)
{
List<string> failedPolicies = new();
// Check 1: Does this workload have a seccomp profile set?
// If not, we try to add RuntimeDefault at the pod security context level.
bool needsSeccomp = !HasSeccompProfile(workload.PodSpec);
if (needsSeccomp)
{
bool patched = await TryPatchSeccompAsync(client, workload, ct);
if (patched)
{
patchedWorkloads.Add(new PatchedWorkload(workload.Name, workload.Namespace, workload.Kind, "seccomp"));
}
else
{
failedPolicies.Add("require-seccomp-runtime-default");
}
}
// Check 2: Do all containers have readOnlyRootFilesystem=true?
// If not, we try to patch each container's security context.
// However, some services (Keycloak, MinIO, Harbor, etc.) require
// a writable root filesystem — we skip patching those entirely.
bool isWritableRootRequired = RequiresWritableRootFs(workload);
if (!isWritableRootRequired)
{
bool needsReadonly = !HasReadonlyRootFilesystem(workload.PodSpec);
if (needsReadonly)
{
bool patched = await TryPatchReadonlyRootfsAsync(client, workload, ct);
if (patched)
{
patchedWorkloads.Add(new PatchedWorkload(workload.Name, workload.Namespace, workload.Kind, "readonlyRootfs"));
}
else
{
failedPolicies.Add("require-readonly-root-filesystem");
}
}
}
// If any remediation failed, this workload needs a PolicyException
// so it won't be blocked when policies are applied.
if (failedPolicies.Count > 0)
{
policyExceptions.Add(new PolicyExceptionEntry(
WorkloadName: workload.Name,
Namespace: workload.Namespace,
WorkloadKind: workload.Kind,
PolicyNames: failedPolicies));
}
}
private static bool HasSeccompProfile(V1PodSpec podSpec)
{
// A pod is compliant if it has a seccomp profile set at the pod level
// with type RuntimeDefault or Localhost.
return podSpec.SecurityContext?.SeccompProfile?.Type is "RuntimeDefault" or "Localhost";
}
private static bool HasReadonlyRootFilesystem(V1PodSpec podSpec)
{
// All containers must have readOnlyRootFilesystem=true to be compliant.
if (podSpec.Containers == null || podSpec.Containers.Count == 0)
{
return true;
}
return podSpec.Containers.All(c => c.SecurityContext?.ReadOnlyRootFilesystem == true);
}
/// <summary>
/// Checks if a workload is a service that requires a writable root filesystem.
/// Uses the app.kubernetes.io/name label to identify known services like
/// Keycloak, MinIO, Harbor, PostgreSQL, etc.
/// </summary>
private static bool RequiresWritableRootFs(WorkloadInfo workload)
{
if (workload.Labels is null)
{
return false;
}
if (workload.Labels.TryGetValue("app.kubernetes.io/name", out string? appName) && appName is not null)
{
return SecurityPoliciesInstaller.WritableRootFsApps.Contains(appName, StringComparer.OrdinalIgnoreCase);
}
return false;
}
private async Task<bool> TryPatchSeccompAsync(IKubernetes client, WorkloadInfo workload, CancellationToken ct)
{
// We use a strategic merge patch to add the seccomp profile to the
// pod template's security context without disturbing other fields.
string patchJson = """
{
"spec": {
"template": {
"spec": {
"securityContext": {
"seccompProfile": {
"type": "RuntimeDefault"
}
}
}
}
}
}
""";
return await ApplyPatchAsync(client, workload, patchJson, ct);
}
private async Task<bool> TryPatchReadonlyRootfsAsync(IKubernetes client, WorkloadInfo workload, CancellationToken ct)
{
// Build a JSON patch that sets readOnlyRootFilesystem=true for each container.
// We use strategic merge patch which merges by container name.
List<object> containerPatches = workload.PodSpec.Containers
.Where(c => c.SecurityContext?.ReadOnlyRootFilesystem != true)
.Select(c => (object)new Dictionary<string, object>
{
["name"] = c.Name,
["securityContext"] = new Dictionary<string, object>
{
["readOnlyRootFilesystem"] = true
}
}).ToList();
if (containerPatches.Count == 0)
{
return true;
}
string patchJson = System.Text.Json.JsonSerializer.Serialize(new Dictionary<string, object>
{
["spec"] = new Dictionary<string, object>
{
["template"] = new Dictionary<string, object>
{
["spec"] = new Dictionary<string, object>
{
["containers"] = containerPatches
}
}
}
});
return await ApplyPatchAsync(client, workload, patchJson, ct);
}
/// <summary>
/// Reverts the seccomp and readOnlyRootFilesystem patches previously applied
/// by RemediateAsync, following the same exclusion rules as the Terraform module
/// and SecurityPoliciesInstaller:
///
/// - Excluded namespaces are skipped entirely (handled by GatherWorkloadsAsync)
/// - Seccomp is reverted on ALL workloads in tenant namespaces (mirrors BuildExcludeBlock)
/// - readOnlyRootFilesystem is reverted on all workloads EXCEPT WritableRootFsApps
/// (mirrors BuildReadonlyRootfsExcludeBlock — Keycloak, MinIO, Harbor, etc. were
/// never patched, so we must not touch their existing security contexts)
///
/// This covers pod-level, container-level, AND init container-level settings.
/// </summary>
public async Task<RemediationResult> RevertPatchesAsync(
IKubernetes client,
List<string> excludedNamespaces,
CancellationToken ct)
{
List<PatchedWorkload> revertedWorkloads = new();
List<PolicyExceptionEntry> failedReverts = new();
// Gather all workloads across the cluster (already excludes platform namespaces).
List<WorkloadInfo> workloads = await GatherWorkloadsAsync(client, excludedNamespaces, ct);
foreach (WorkloadInfo workload in workloads)
{
// Seccomp revert: applies to all workloads in tenant namespaces.
// The original policy/remediation applied seccomp to everything
// not in an excluded namespace, so we revert it from everything.
bool hasAnySeccomp = HasAnySeccompAnywhere(workload.PodSpec);
// ReadOnlyRootFilesystem revert: mirrors the Terraform exclusion pattern.
// Apps matching WritableRootFsApps (Keycloak, MinIO, Harbor, etc.) were
// never patched by the remediator because they need writable root, so
// their readOnlyRootFilesystem settings are intentional — do not touch.
bool isWritableRootApp = RequiresWritableRootFs(workload);
bool hasAnyReadonly = !isWritableRootApp && HasAnyReadonlyRootFsAnywhere(workload.PodSpec);
if (!hasAnySeccomp && !hasAnyReadonly)
{
continue;
}
// Apply the revert patches — seccomp from everywhere, readOnly only
// from non-excluded apps.
bool reverted = await TryFullRevertAsync(client, workload, hasAnySeccomp, hasAnyReadonly, ct);
if (reverted)
{
if (hasAnySeccomp)
{
revertedWorkloads.Add(new PatchedWorkload(workload.Name, workload.Namespace, workload.Kind, "revert-seccomp"));
}
if (hasAnyReadonly)
{
revertedWorkloads.Add(new PatchedWorkload(workload.Name, workload.Namespace, workload.Kind, "revert-readonlyRootfs"));
}
}
else
{
logger.LogWarning("Failed to revert {Kind}/{Name} in {Namespace}", workload.Kind, workload.Name, workload.Namespace);
}
}
logger.LogInformation(
"Security patch revert complete: {Reverted} workloads reverted",
revertedWorkloads.Count);
return new RemediationResult(revertedWorkloads, failedReverts);
}
/// <summary>
/// Checks if any seccomp profile is set anywhere in the pod spec —
/// at pod level, on any container, or on any init container.
/// </summary>
private static bool HasAnySeccompAnywhere(V1PodSpec podSpec)
{
// Pod-level seccomp.
if (podSpec.SecurityContext?.SeccompProfile?.Type is not null)
{
return true;
}
// Container-level seccomp.
if (podSpec.Containers?.Any(c => c.SecurityContext?.SeccompProfile?.Type is not null) == true)
{
return true;
}
// Init container-level seccomp.
if (podSpec.InitContainers?.Any(c => c.SecurityContext?.SeccompProfile?.Type is not null) == true)
{
return true;
}
return false;
}
/// <summary>
/// Checks if readOnlyRootFilesystem is set to true on ANY container or
/// init container in the pod spec.
/// </summary>
private static bool HasAnyReadonlyRootFsAnywhere(V1PodSpec podSpec)
{
if (podSpec.Containers?.Any(c => c.SecurityContext?.ReadOnlyRootFilesystem == true) == true)
{
return true;
}
if (podSpec.InitContainers?.Any(c => c.SecurityContext?.ReadOnlyRootFilesystem == true) == true)
{
return true;
}
return false;
}
/// <summary>
/// Applies a comprehensive revert patch using JSON Patch (RFC 6902) operations.
/// This is more reliable than merge/strategic patches for removing fields because
/// it can target exact paths without ambiguity about null vs missing.
///
/// We use strategic merge patch for containers (which merges by name), combined
/// with a separate merge patch to null out the pod-level seccomp.
/// </summary>
private async Task<bool> TryFullRevertAsync(
IKubernetes client, WorkloadInfo workload,
bool revertSeccomp, bool revertReadonly, CancellationToken ct)
{
// We need two patches because:
// - Nulling seccompProfile requires a merge patch (strategic merge can't null nested fields)
// - Setting readOnlyRootFilesystem=false on containers uses strategic merge (merges by name)
bool success = true;
if (revertSeccomp)
{
// Step 1: Null out pod-level seccomp using merge patch.
string seccompPatch = System.Text.Json.JsonSerializer.Serialize(new Dictionary<string, object?>
{
["spec"] = new Dictionary<string, object?>
{
["template"] = new Dictionary<string, object?>
{
["spec"] = new Dictionary<string, object?>
{
["securityContext"] = new Dictionary<string, object?>
{
["seccompProfile"] = null
}
}
}
}
});
success = await ApplyMergePatchAsync(client, workload, seccompPatch, ct);
if (!success)
{
return false;
}
// Step 2: Remove container-level seccomp profiles using strategic merge.
// We set seccompProfile to null on each container/initContainer that has one.
List<object> containerSeccompPatches = (workload.PodSpec.Containers ?? new List<V1Container>())
.Where(c => c.SecurityContext?.SeccompProfile?.Type is not null)
.Select(c => (object)new Dictionary<string, object?>
{
["name"] = c.Name,
["securityContext"] = new Dictionary<string, object?>
{
["seccompProfile"] = null
}
}).ToList();
List<object> initContainerSeccompPatches = (workload.PodSpec.InitContainers ?? new List<V1Container>())
.Where(c => c.SecurityContext?.SeccompProfile?.Type is not null)
.Select(c => (object)new Dictionary<string, object?>
{
["name"] = c.Name,
["securityContext"] = new Dictionary<string, object?>
{
["seccompProfile"] = null
}
}).ToList();
if (containerSeccompPatches.Count > 0 || initContainerSeccompPatches.Count > 0)
{
Dictionary<string, object> specPatch = new();
if (containerSeccompPatches.Count > 0)
{
specPatch["containers"] = containerSeccompPatches;
}
if (initContainerSeccompPatches.Count > 0)
{
specPatch["initContainers"] = initContainerSeccompPatches;
}
string containerPatchJson = System.Text.Json.JsonSerializer.Serialize(new Dictionary<string, object>
{
["spec"] = new Dictionary<string, object>
{
["template"] = new Dictionary<string, object>
{
["spec"] = specPatch
}
}
});
success = await ApplyPatchAsync(client, workload, containerPatchJson, ct);
if (!success)
{
return false;
}
}
}
if (revertReadonly)
{
// Set readOnlyRootFilesystem=false on every container and initContainer
// that currently has it set to true. Using strategic merge patch which
// merges by container name.
List<object> containerPatches = (workload.PodSpec.Containers ?? new List<V1Container>())
.Where(c => c.SecurityContext?.ReadOnlyRootFilesystem == true)
.Select(c => (object)new Dictionary<string, object>
{
["name"] = c.Name,
["securityContext"] = new Dictionary<string, object>
{
["readOnlyRootFilesystem"] = false
}
}).ToList();
List<object> initContainerPatches = (workload.PodSpec.InitContainers ?? new List<V1Container>())
.Where(c => c.SecurityContext?.ReadOnlyRootFilesystem == true)
.Select(c => (object)new Dictionary<string, object>
{
["name"] = c.Name,
["securityContext"] = new Dictionary<string, object>
{
["readOnlyRootFilesystem"] = false
}
}).ToList();
if (containerPatches.Count > 0 || initContainerPatches.Count > 0)
{
Dictionary<string, object> specPatch = new();
if (containerPatches.Count > 0)
{
specPatch["containers"] = containerPatches;
}
if (initContainerPatches.Count > 0)
{
specPatch["initContainers"] = initContainerPatches;
}
string patchJson = System.Text.Json.JsonSerializer.Serialize(new Dictionary<string, object>
{
["spec"] = new Dictionary<string, object>
{
["template"] = new Dictionary<string, object>
{
["spec"] = specPatch
}
}
});
success = await ApplyPatchAsync(client, workload, patchJson, ct);
}
}
return success;
}
/// <summary>
/// Applies a JSON merge patch to a workload. Used for nulling out fields
/// (like seccompProfile) that strategic merge patch cannot remove.
/// </summary>
private async Task<bool> ApplyMergePatchAsync(IKubernetes client, WorkloadInfo workload, string patchJson, CancellationToken ct)
{
V1Patch patch = new(patchJson, V1Patch.PatchType.MergePatch);
try
{
switch (workload.Kind)
{
case "Deployment":
await client.AppsV1.PatchNamespacedDeploymentAsync(patch, workload.Name, workload.Namespace, cancellationToken: ct);
break;
case "StatefulSet":
await client.AppsV1.PatchNamespacedStatefulSetAsync(patch, workload.Name, workload.Namespace, cancellationToken: ct);
break;
case "DaemonSet":
await client.AppsV1.PatchNamespacedDaemonSetAsync(patch, workload.Name, workload.Namespace, cancellationToken: ct);
break;
}
logger.LogInformation("Revert-patched {Kind}/{Name} in {Namespace}", workload.Kind, workload.Name, workload.Namespace);
return true;
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to revert-patch {Kind}/{Name} in {Namespace}", workload.Kind, workload.Name, workload.Namespace);
return false;
}
}
private async Task<bool> ApplyPatchAsync(IKubernetes client, WorkloadInfo workload, string patchJson, CancellationToken ct)
{
V1Patch patch = new(patchJson, V1Patch.PatchType.StrategicMergePatch);
try
{
switch (workload.Kind)
{
case "Deployment":
await client.AppsV1.PatchNamespacedDeploymentAsync(patch, workload.Name, workload.Namespace, cancellationToken: ct);
break;
case "StatefulSet":
await client.AppsV1.PatchNamespacedStatefulSetAsync(patch, workload.Name, workload.Namespace, cancellationToken: ct);
break;
case "DaemonSet":
await client.AppsV1.PatchNamespacedDaemonSetAsync(patch, workload.Name, workload.Namespace, cancellationToken: ct);
break;
}
logger.LogInformation("Patched {Kind}/{Name} in {Namespace}", workload.Kind, workload.Name, workload.Namespace);
return true;
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to patch {Kind}/{Name} in {Namespace}", workload.Kind, workload.Name, workload.Namespace);
return false;
}
}
}
// --- Supporting types ---
/// <summary>
/// The result of scanning and remediating workloads before policy application.
/// </summary>
public record RemediationResult(
List<PatchedWorkload> PatchedWorkloads,
List<PolicyExceptionEntry> PolicyExceptions);
/// <summary>
/// A workload that was successfully patched to comply with a policy.
/// </summary>
public record PatchedWorkload(
string Name,
string Namespace,
string Kind,
string Reason);
/// <summary>
/// A workload that needs a PolicyException because it couldn't be patched.
/// </summary>
public record PolicyExceptionEntry(
string WorkloadName,
string Namespace,
string WorkloadKind,
List<string> PolicyNames);
/// <summary>
/// Request to build a Kyverno PolicyException for a specific workload.
/// </summary>
public record PolicyExceptionRequest(
string WorkloadName,
string Namespace,
string WorkloadKind,
List<string> PolicyNames);
/// <summary>
/// Internal representation of a workload during scanning.
/// </summary>
internal record WorkloadInfo(
string Name,
string Namespace,
string Kind,
V1PodSpec PodSpec,
IDictionary<string, string>? Labels = null);

View File

@@ -1,213 +0,0 @@
using System.Text;
using EntKube.Clusters.Domain;
using k8s;
using k8s.Models;
using Microsoft.Extensions.Logging;
namespace EntKube.Clusters.Features.AdoptCluster.Components.Traefik;
/// <summary>
/// Checks whether Traefik ingress controller is deployed on the cluster.
/// Looks for running Traefik pods, the IngressClass registration, the
/// LoadBalancer/NodePort Service, and optionally the dashboard IngressRoute.
/// </summary>
public class TraefikCheck : IAdoptionCheck
{
private readonly ILogger<TraefikCheck> logger;
public string ComponentName => "traefik";
public string DisplayName => "Traefik Ingress Controller";
public TraefikCheck(ILogger<TraefikCheck> logger)
{
this.logger = logger;
}
public async Task<ComponentCheckResult> CheckAsync(KubernetesCluster cluster, CancellationToken ct = default)
{
Kubernetes client = BuildClient(cluster);
List<string> details = new();
List<string> missing = new();
// Find running Traefik pods in the traefik or kube-system namespace.
List<string> traefikPods = await FindTraefikPods(client, ct);
if (traefikPods.Count == 0)
{
missing.Add("No Traefik pods found (checked traefik and kube-system namespaces)");
return new ComponentCheckResult(ComponentName, ComponentStatus.NotInstalled, details, missing);
}
details.AddRange(traefikPods.Select(p => $"Traefik pod: {p}"));
// Check IngressClass exists.
bool hasIngressClass = await IngressClassExists(client, "traefik", ct);
if (hasIngressClass)
{
details.Add("IngressClass 'traefik' registered");
}
else
{
missing.Add("IngressClass 'traefik' not found");
}
// Check for the Traefik service (LoadBalancer or NodePort).
string traefikNamespace = await FindTraefikNamespace(client, ct);
bool serviceExists = await TraefikServiceExists(client, traefikNamespace, ct);
if (serviceExists)
{
details.Add($"Traefik service present in {traefikNamespace}");
}
else
{
missing.Add("Traefik LoadBalancer/NodePort service not found");
}
// Check for dashboard IngressRoute (optional).
bool hasDashboard = await TraefikDashboardExists(client, traefikNamespace, ct);
details.Add(hasDashboard ? "Traefik dashboard IngressRoute present" : "Traefik dashboard not configured (optional)");
DiscoveredConfiguration config = new(
Version: null,
Namespace: traefikNamespace,
HelmReleaseName: "traefik",
Values: new Dictionary<string, string>
{
["replicas"] = traefikPods.Count.ToString(),
["ingressClassRegistered"] = hasIngressClass.ToString(),
["dashboardEnabled"] = hasDashboard.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<List<string>> FindTraefikPods(Kubernetes client, CancellationToken ct)
{
List<string> pods = new();
foreach (string ns in new[] { "traefik", "kube-system" })
{
try
{
V1PodList podList = await client.CoreV1.ListNamespacedPodAsync(
ns,
labelSelector: "app.kubernetes.io/name=traefik",
cancellationToken: ct);
foreach (V1Pod pod in podList.Items)
{
if (pod.Status?.Phase == "Running")
{
pods.Add(pod.Metadata.Name);
}
}
if (pods.Count > 0)
{
break;
}
}
catch (k8s.Autorest.HttpOperationException ex) when (ex.Response.StatusCode == System.Net.HttpStatusCode.NotFound)
{
// Namespace doesn't exist — try next.
}
catch (Exception ex)
{
logger.LogWarning(ex, "Error checking Traefik pods in {Namespace}", ns);
}
}
return pods;
}
private async Task<string> FindTraefikNamespace(Kubernetes client, CancellationToken ct)
{
foreach (string ns in new[] { "traefik", "kube-system" })
{
try
{
V1PodList podList = await client.CoreV1.ListNamespacedPodAsync(
ns,
labelSelector: "app.kubernetes.io/name=traefik",
cancellationToken: ct);
if (podList.Items.Any(p => p.Status?.Phase == "Running"))
{
return ns;
}
}
catch { }
}
return "traefik";
}
private async Task<bool> IngressClassExists(Kubernetes client, string name, CancellationToken ct)
{
try
{
await client.NetworkingV1.ReadIngressClassAsync(name, cancellationToken: ct);
return true;
}
catch
{
return false;
}
}
private async Task<bool> TraefikServiceExists(Kubernetes client, string ns, CancellationToken ct)
{
try
{
V1ServiceList services = await client.CoreV1.ListNamespacedServiceAsync(
ns,
labelSelector: "app.kubernetes.io/name=traefik",
cancellationToken: ct);
return services.Items.Any(s =>
s.Spec.Type == "LoadBalancer" || s.Spec.Type == "NodePort");
}
catch
{
return false;
}
}
private async Task<bool> TraefikDashboardExists(Kubernetes client, string ns, CancellationToken ct)
{
try
{
await client.CustomObjects.GetNamespacedCustomObjectAsync(
group: "traefik.io",
version: "v1alpha1",
namespaceParameter: ns,
plural: "ingressroutes",
name: "traefik-dashboard",
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);
}
}

View File

@@ -1,296 +0,0 @@
using System.Text;
using EntKube.Clusters.Domain;
using EntKube.Clusters.Features.AdoptCluster.Components.Ingress;
using k8s;
using Microsoft.Extensions.Logging;
namespace EntKube.Clusters.Features.AdoptCluster.Components.Traefik;
/// <summary>
/// Installs Traefik as an ingress controller on the cluster. Creates a
/// single Traefik deployment with an IngressClass registered as default,
/// TLS via cert-manager annotations, and optionally a dashboard.
///
/// Configuration keys:
/// - "replicas": number of Traefik replicas (default: 2)
/// - "dashboardEnabled": expose Traefik dashboard (default: false)
/// - "serviceType": LoadBalancer, NodePort, or ClusterIP (default: LoadBalancer)
/// - "internalLoadBalancer": use cloud-provider internal LB annotations (default: false)
/// </summary>
public class TraefikInstaller : IComponentInstaller
{
private readonly ILogger<TraefikInstaller> logger;
private const string HelmRepo = "https://traefik.github.io/charts";
private const string DefaultVersion = "32.0.0";
private const string DefaultNamespace = "traefik";
public string ComponentName => "traefik";
public TraefikInstaller(ILogger<TraefikInstaller> logger)
{
this.logger = logger;
}
public async Task<InstallResult> InstallAsync(KubernetesCluster cluster, ComponentInstallOptions options, CancellationToken ct = default)
{
string version = options.Version ?? DefaultVersion;
string targetNamespace = options.Namespace ?? DefaultNamespace;
List<string> actions = new();
string tempKubeConfig = Path.Combine(Path.GetTempPath(), $"entkube-traefik-{Guid.NewGuid()}.kubeconfig");
string valuesPath = Path.Combine(Path.GetTempPath(), $"entkube-traefik-values-{Guid.NewGuid()}.yaml");
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}'");
// Build Traefik Helm values based on parameters and cloud provider.
string values = GetTraefikValues(options.Parameters, cluster.Provider);
await File.WriteAllTextAsync(valuesPath, values, ct);
// Install/upgrade Traefik via Helm.
await RunCommand("helm",
$"upgrade --install traefik traefik " +
$"--repo {HelmRepo} --version {version} " +
$"--namespace {targetNamespace} " +
$"--kubeconfig \"{tempKubeConfig}\" --kube-context \"{cluster.ContextName}\" " +
$"--values \"{valuesPath}\" " +
$"--wait --timeout 5m",
ct);
actions.Add($"Helm install traefik v{version}");
logger.LogInformation("Traefik {Version} installed on cluster {Cluster}", version, cluster.Name);
return new InstallResult(
Success: true,
ComponentName: ComponentName,
Message: $"Traefik {version} installed",
Actions: actions);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to install Traefik on cluster {Cluster}", cluster.Name);
actions.Add($"Error: {ex.Message}");
return new InstallResult(
Success: false,
ComponentName: ComponentName,
Message: $"Failed to install Traefik: {ex.Message}",
Actions: actions);
}
finally
{
if (File.Exists(tempKubeConfig))
{
File.Delete(tempKubeConfig);
}
if (File.Exists(valuesPath))
{
File.Delete(valuesPath);
}
}
}
/// <summary>
/// Reconfigures Traefik with updated values. Since Traefik uses
/// `helm upgrade --install`, reconfiguration is the same operation.
/// </summary>
public async Task<InstallResult> ConfigureAsync(KubernetesCluster cluster, ComponentConfiguration configuration, CancellationToken ct = default)
{
ComponentInstallOptions options = new(
Namespace: configuration.Namespace,
Parameters: configuration.Values);
return await InstallAsync(cluster, options, ct);
}
/// <summary>
/// Uninstalls Traefik from the cluster.
/// </summary>
public async Task<InstallResult> UninstallAsync(KubernetesCluster cluster, ComponentInstallOptions options, CancellationToken ct = default)
{
string targetNamespace = options.Namespace ?? DefaultNamespace;
List<string> actions = new();
string tempKubeConfig = Path.Combine(Path.GetTempPath(), $"entkube-traefik-rm-{Guid.NewGuid()}.kubeconfig");
try
{
await File.WriteAllTextAsync(tempKubeConfig, cluster.KubeConfig, ct);
await RunCommand("helm",
$"uninstall traefik --namespace {targetNamespace} " +
$"--kubeconfig \"{tempKubeConfig}\" --kube-context \"{cluster.ContextName}\" " +
$"--wait --timeout 5m",
ct);
actions.Add("Helm uninstall traefik");
logger.LogInformation("Traefik uninstalled from cluster {Cluster}", cluster.Name);
return new InstallResult(
Success: true,
ComponentName: ComponentName,
Message: "Traefik uninstalled successfully",
Actions: actions);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to uninstall Traefik from cluster {Cluster}", cluster.Name);
return new InstallResult(
Success: false,
ComponentName: ComponentName,
Message: $"Failed to uninstall Traefik: {ex.Message}",
Actions: new List<string> { $"Error: {ex.Message}" });
}
finally
{
if (File.Exists(tempKubeConfig))
{
File.Delete(tempKubeConfig);
}
}
}
// ─── Public Static Helpers (testable without a Kubernetes client) ────
/// <summary>
/// Generates Helm values for Traefik deployment. When internalLoadBalancer is
/// enabled, applies provider-specific annotations so the Service gets a private LB.
/// </summary>
public static string GetTraefikValues(Dictionary<string, string>? parameters, CloudProvider provider = CloudProvider.None)
{
string replicas = parameters?.TryGetValue("replicas", out string? r) == true ? r : "2";
string serviceType = parameters?.TryGetValue("serviceType", out string? st) == true ? st : "LoadBalancer";
bool internalLb = parameters?.TryGetValue("internalLoadBalancer", out string? ilb) == true && ilb == "true";
bool dashboard = parameters?.TryGetValue("dashboardEnabled", out string? db) == true && db == "true";
string lbAnnotations = "";
if (internalLb)
{
Dictionary<string, string> annotations = IngressInstaller.GetInternalLbAnnotations(provider);
lbAnnotations = IngressInstaller.BuildServiceAnnotationsYaml(annotations);
}
return $"""
deployment:
replicas: {replicas}
ingressClass:
enabled: true
isDefaultClass: true
service:
type: {serviceType}
{lbAnnotations}
ports:
web:
port: 8000
exposedPort: 80
protocol: TCP
redirectTo:
port: websecure
websecure:
port: 8443
exposedPort: 443
protocol: TCP
tls:
enabled: true
ingressRoute:
dashboard:
enabled: {dashboard.ToString().ToLower()}
providers:
kubernetesIngress:
enabled: true
publishedService:
enabled: true
kubernetesCRD:
enabled: true
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: "1"
memory: 512Mi
autoscaling:
enabled: true
minReplicas: {replicas}
maxReplicas: 10
metrics:
prometheus:
enabled: true
serviceMonitor:
enabled: true
""";
}
// ─── Private helpers ─────────────────────────────────────────────────
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)
{
k8s.Models.V1Namespace ns = new()
{
Metadata = new k8s.Models.V1ObjectMeta
{
Name = namespaceName,
Labels = new Dictionary<string, string>
{
["app.kubernetes.io/managed-by"] = "entkube"
}
}
};
await client.CoreV1.CreateNamespaceAsync(ns, cancellationToken: ct);
}
}
private async Task<string> 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);
}
}

View File

@@ -1,137 +0,0 @@
using System.Text;
using EntKube.Clusters.Domain;
using k8s;
using k8s.Models;
using Microsoft.Extensions.Logging;
namespace EntKube.Clusters.Features.AdoptCluster.Components.TrustManager;
/// <summary>
/// Checks whether trust-manager (jetstack) is installed on the cluster.
/// Trust-manager distributes CA bundles to namespaces, enabling workloads
/// to trust internal certificates. The Terraform bootstrap/kyverno_trust
/// module deploys it alongside Kyverno in the same namespace.
/// </summary>
public class TrustManagerCheck : IAdoptionCheck
{
private readonly ILogger<TrustManagerCheck> logger;
public string ComponentName => "trust-manager";
public string DisplayName => "trust-manager (CA Bundle Distribution)";
public TrustManagerCheck(ILogger<TrustManagerCheck> logger)
{
this.logger = logger;
}
public async Task<ComponentCheckResult> CheckAsync(KubernetesCluster cluster, CancellationToken ct = default)
{
Kubernetes client = BuildClient(cluster);
List<string> details = new();
List<string> missing = new();
// Trust-manager is typically installed in the same namespace as Kyverno
// or in cert-manager. Check both.
(List<string> runningPods, string? foundNamespace) = await FindTrustManagerPods(client, ct);
if (runningPods.Count == 0)
{
missing.Add("No trust-manager pods found");
return new ComponentCheckResult(ComponentName, ComponentStatus.NotInstalled, details, missing);
}
details.AddRange(runningPods.Select(p => $"Pod running: {p}"));
// Check if the Bundle CRD exists (trust.cert-manager.io/v1alpha1 Bundle).
bool bundleCrdExists = await BundleCrdExists(client, ct);
if (!bundleCrdExists)
{
missing.Add("Bundle CRD (bundles.trust.cert-manager.io) not found");
}
else
{
details.Add("Bundle CRD present");
}
// Build discovered configuration.
DiscoveredConfiguration config = new(
Version: null,
Namespace: foundNamespace,
HelmReleaseName: "trust-manager",
Values: new Dictionary<string, string>
{
["replicas"] = runningPods.Count.ToString(),
["bundleCrdInstalled"] = bundleCrdExists.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<(List<string> Pods, string? Namespace)> FindTrustManagerPods(Kubernetes client, CancellationToken ct)
{
List<string> pods = new();
string? foundNamespace = null;
string[] searchNamespaces = { "kyverno", "cert-manager", "trust-manager" };
foreach (string ns in searchNamespaces)
{
try
{
V1PodList podList = await client.CoreV1.ListNamespacedPodAsync(
ns,
labelSelector: "app.kubernetes.io/name=trust-manager",
cancellationToken: ct);
foreach (V1Pod pod in podList.Items)
{
if (pod.Status?.Phase == "Running")
{
pods.Add(pod.Metadata.Name);
foundNamespace ??= ns;
}
}
}
catch (k8s.Autorest.HttpOperationException ex) when (ex.Response.StatusCode == System.Net.HttpStatusCode.NotFound)
{
logger.LogDebug("Namespace {Namespace} not found when checking trust-manager", ns);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Error checking namespace {Namespace} for trust-manager pods", ns);
}
}
return (pods, foundNamespace);
}
private async Task<bool> BundleCrdExists(Kubernetes client, CancellationToken ct)
{
try
{
await client.ApiextensionsV1.ReadCustomResourceDefinitionAsync(
"bundles.trust.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);
}
}

View File

@@ -1,492 +0,0 @@
using System.Text;
using EntKube.Clusters.Domain;
using k8s;
using k8s.Models;
using Microsoft.Extensions.Logging;
namespace EntKube.Clusters.Features.AdoptCluster.Components.TrustManager;
/// <summary>
/// Installs trust-manager via Helm and creates a platform-wide Bundle CR that
/// distributes CA certificates to all namespaces. Trust-manager is the mechanism
/// by which internal CA certs (from cert-manager) and external CA certs (added
/// by admins) become available as trusted roots in every workload.
///
/// What gets deployed:
/// 1. trust-manager controller in the cert-manager namespace
/// 2. A Bundle CR that aggregates CAs from multiple sources
/// 3. ConfigMap targets in each namespace with the bundle data
///
/// Configuration keys (install):
/// - "bundleName": Name of the Bundle CR (default: "platform-trust-bundle")
/// - "targetNamespaces": Namespace selector — "*" for all, or comma-separated list
/// - "includeDefaultCAs": Whether to include the system default CA bundle (default: true)
/// - "targetKey": Key name in the target ConfigMap/Secret (default: "ca-certificates.crt")
///
/// Configuration keys (configure):
/// - "addCertificate": "true" to add a certificate
/// - "certificateName": Identifier for the certificate (used as ConfigMap name)
/// - "certificateData": Base64-encoded PEM certificate data
/// - "removeCertificate": Name of certificate to remove from bundle
/// - "bundleName": Which bundle to modify (default: "platform-trust-bundle")
/// </summary>
public class TrustManagerInstaller : IComponentInstaller
{
private readonly ILogger<TrustManagerInstaller> logger;
private const string DefaultVersion = "0.14.0";
private const string DefaultNamespace = "cert-manager";
private const string HelmRepo = "https://charts.jetstack.io";
private const string DefaultBundleName = "platform-trust-bundle";
public string ComponentName => "trust-manager";
public TrustManagerInstaller(ILogger<TrustManagerInstaller> logger)
{
this.logger = logger;
}
public async Task<InstallResult> InstallAsync(KubernetesCluster cluster, ComponentInstallOptions options, CancellationToken ct = default)
{
string version = options.Version ?? DefaultVersion;
string targetNamespace = options.Namespace ?? DefaultNamespace;
List<string> actions = new();
string tempKubeConfig = Path.Combine(Path.GetTempPath(), $"entkube-trustmgr-{Guid.NewGuid()}.kubeconfig");
try
{
await File.WriteAllTextAsync(tempKubeConfig, cluster.KubeConfig, ct);
// Ensure the namespace exists (trust-manager typically lives alongside cert-manager).
Kubernetes client = BuildClient(cluster);
await EnsureNamespace(client, targetNamespace, ct);
actions.Add($"Ensured namespace '{targetNamespace}'");
// Install trust-manager via Helm.
await RunCommand("helm",
$"upgrade --install trust-manager trust-manager " +
$"--repo {HelmRepo} --version v{version} " +
$"--namespace {targetNamespace} " +
$"--kubeconfig \"{tempKubeConfig}\" --kube-context \"{cluster.ContextName}\" " +
$"--set app.trust.namespace={targetNamespace} " +
$"--set resources.requests.cpu=25m " +
$"--set resources.requests.memory=64Mi " +
$"--set resources.limits.cpu=100m " +
$"--set resources.limits.memory=128Mi " +
$"--wait --timeout 5m",
ct);
actions.Add($"Helm install trust-manager v{version}");
// Create the platform trust Bundle CR. This aggregates CA certificates
// from multiple sources and distributes them to target namespaces.
string bundleName = DefaultBundleName;
if (options.Parameters?.TryGetValue("bundleName", out string? bundleVal) == true
&& !string.IsNullOrEmpty(bundleVal))
{
bundleName = bundleVal;
}
bool includeDefaultCAs = true;
if (options.Parameters?.TryGetValue("includeDefaultCAs", out string? defaultCaVal) == true
&& defaultCaVal == "false")
{
includeDefaultCAs = false;
}
string targetKey = "ca-certificates.crt";
if (options.Parameters?.TryGetValue("targetKey", out string? targetKeyVal) == true
&& !string.IsNullOrEmpty(targetKeyVal))
{
targetKey = targetKeyVal;
}
await CreateOrUpdateBundle(client, bundleName, includeDefaultCAs, targetKey, ct);
actions.Add($"Created Bundle '{bundleName}'");
logger.LogInformation("trust-manager {Version} installed on cluster {Cluster} with Bundle '{Bundle}'",
version, cluster.Name, bundleName);
return new InstallResult(
Success: true,
ComponentName: ComponentName,
Message: $"trust-manager {version} installed with {bundleName} Bundle",
Actions: actions);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to install trust-manager on cluster {Cluster}", cluster.Name);
actions.Add($"Error: {ex.Message}");
return new InstallResult(
Success: false,
ComponentName: ComponentName,
Message: $"Failed to install trust-manager: {ex.Message}",
Actions: actions);
}
finally
{
if (File.Exists(tempKubeConfig))
{
File.Delete(tempKubeConfig);
}
}
}
/// <summary>
/// Reconfigures the trust bundle. Primary use case: adding or removing managed
/// certificates that should be trusted cluster-wide. Each managed certificate
/// is stored in a ConfigMap and referenced as a source in the Bundle CR.
///
/// Supported operations:
/// - Add a certificate: "addCertificate"="true", "certificateName"="name", "certificateData"="base64pem"
/// - Remove a certificate: "removeCertificate"="name"
/// </summary>
public async Task<InstallResult> ConfigureAsync(KubernetesCluster cluster, ComponentConfiguration configuration, CancellationToken ct = default)
{
string targetNamespace = configuration.Namespace ?? DefaultNamespace;
List<string> actions = new();
try
{
Kubernetes client = BuildClient(cluster);
string bundleName = DefaultBundleName;
configuration.Values.TryGetValue("bundleName", out string? bundleVal);
if (!string.IsNullOrEmpty(bundleVal))
{
bundleName = bundleVal;
}
// Handle adding a certificate to the trust bundle.
if (configuration.Values.TryGetValue("addCertificate", out string? addCert) && addCert == "true")
{
configuration.Values.TryGetValue("certificateName", out string? certName);
configuration.Values.TryGetValue("certificateData", out string? certData);
if (string.IsNullOrEmpty(certName) || string.IsNullOrEmpty(certData))
{
return new InstallResult(
Success: false,
ComponentName: ComponentName,
Message: "certificateName and certificateData are required when adding a certificate",
Actions: actions);
}
// Store the certificate in a ConfigMap in the trust-manager namespace.
// The Bundle CR will reference this ConfigMap as a source.
string configMapName = $"managed-cert-{certName}";
await CreateOrUpdateCertificateConfigMap(client, targetNamespace, configMapName, certData, ct);
actions.Add($"Created ConfigMap '{configMapName}' with certificate data");
// Update the Bundle CR to include this new source.
await AddConfigMapSourceToBundle(client, bundleName, targetNamespace, configMapName, ct);
actions.Add($"Updated Bundle '{bundleName}' to include new source");
}
// Handle removing a certificate from the bundle.
if (configuration.Values.TryGetValue("removeCertificate", out string? removeCertName) &&
!string.IsNullOrEmpty(removeCertName))
{
string configMapName = $"managed-cert-{removeCertName}";
await RemoveConfigMapSourceFromBundle(client, bundleName, targetNamespace, configMapName, ct);
actions.Add($"Removed '{configMapName}' from Bundle '{bundleName}'");
// Delete the ConfigMap.
try
{
await client.CoreV1.DeleteNamespacedConfigMapAsync(configMapName, targetNamespace, cancellationToken: ct);
actions.Add($"Deleted ConfigMap '{configMapName}'");
}
catch (k8s.Autorest.HttpOperationException ex) when (ex.Response.StatusCode == System.Net.HttpStatusCode.NotFound)
{
// Already gone — that's fine.
}
}
return new InstallResult(
Success: true,
ComponentName: ComponentName,
Message: "Trust bundle updated",
Actions: actions);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to configure trust-manager on cluster {Cluster}", cluster.Name);
return new InstallResult(
Success: false,
ComponentName: ComponentName,
Message: $"Failed to configure trust-manager: {ex.Message}",
Actions: new List<string> { $"Error: {ex.Message}" });
}
}
/// <summary>
/// Uninstalls trust-manager from the cluster by removing the Helm release.
/// Warning: trust bundles will stop being distributed to namespaces.
/// </summary>
public async Task<InstallResult> UninstallAsync(KubernetesCluster cluster, ComponentInstallOptions options, CancellationToken ct = default)
{
string targetNamespace = options.Namespace ?? DefaultNamespace;
List<string> actions = new();
string tempKubeConfig = Path.Combine(Path.GetTempPath(), $"entkube-trustmgr-rm-{Guid.NewGuid()}.kubeconfig");
try
{
await File.WriteAllTextAsync(tempKubeConfig, cluster.KubeConfig, ct);
await RunCommand("helm",
$"uninstall trust-manager --namespace {targetNamespace} " +
$"--kubeconfig \"{tempKubeConfig}\" --kube-context \"{cluster.ContextName}\" " +
$"--wait --timeout 5m",
ct);
actions.Add("Helm uninstall trust-manager");
logger.LogInformation("trust-manager uninstalled from cluster {Cluster}", cluster.Name);
return new InstallResult(
Success: true,
ComponentName: ComponentName,
Message: "trust-manager uninstalled successfully",
Actions: actions);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to uninstall trust-manager from cluster {Cluster}", cluster.Name);
return new InstallResult(
Success: false,
ComponentName: ComponentName,
Message: $"Failed to uninstall trust-manager: {ex.Message}",
Actions: new List<string> { $"Error: {ex.Message}" });
}
finally
{
if (File.Exists(tempKubeConfig))
{
File.Delete(tempKubeConfig);
}
}
}
/// <summary>
/// Creates or updates the platform trust Bundle CR. The Bundle aggregates
/// CA certificates from the default trust store plus any managed ConfigMaps
/// and distributes them as a single concatenated PEM to target namespaces.
/// </summary>
private async Task CreateOrUpdateBundle(
Kubernetes client, string bundleName, bool includeDefaultCAs, string targetKey, CancellationToken ct)
{
// Build sources — always start with the default CA package if enabled.
List<object> sources = new();
if (includeDefaultCAs)
{
sources.Add(new Dictionary<string, object>
{
["useDefaultCAs"] = true
});
}
Dictionary<string, object> bundle = new()
{
["apiVersion"] = "trust.cert-manager.io/v1alpha1",
["kind"] = "Bundle",
["metadata"] = new Dictionary<string, object>
{
["name"] = bundleName
},
["spec"] = new Dictionary<string, object>
{
["sources"] = sources,
["target"] = new Dictionary<string, object>
{
["configMap"] = new Dictionary<string, object>
{
["key"] = targetKey
}
}
}
};
try
{
await client.CustomObjects.PatchClusterCustomObjectAsync(
body: new V1Patch(bundle, V1Patch.PatchType.MergePatch),
group: "trust.cert-manager.io",
version: "v1alpha1",
plural: "bundles",
name: bundleName,
cancellationToken: ct);
}
catch (k8s.Autorest.HttpOperationException ex) when (ex.Response.StatusCode == System.Net.HttpStatusCode.NotFound)
{
await client.CustomObjects.CreateClusterCustomObjectAsync(
body: bundle,
group: "trust.cert-manager.io",
version: "v1alpha1",
plural: "bundles",
cancellationToken: ct);
}
}
private async Task CreateOrUpdateCertificateConfigMap(
Kubernetes client, string ns, string configMapName, string certData, CancellationToken ct)
{
// Decode from base64 to PEM if it's base64-encoded.
string pemData;
try
{
byte[] decoded = Convert.FromBase64String(certData);
pemData = Encoding.UTF8.GetString(decoded);
}
catch (FormatException)
{
// Already PEM — use as-is.
pemData = certData;
}
V1ConfigMap configMap = new()
{
Metadata = new V1ObjectMeta
{
Name = configMapName,
NamespaceProperty = ns,
Labels = new Dictionary<string, string>
{
["app.kubernetes.io/managed-by"] = "entkube",
["entkube.io/component"] = "trust-bundle"
}
},
Data = new Dictionary<string, string>
{
["ca.crt"] = pemData
}
};
try
{
await client.CoreV1.ReplaceNamespacedConfigMapAsync(configMap, configMapName, ns, cancellationToken: ct);
}
catch (k8s.Autorest.HttpOperationException ex) when (ex.Response.StatusCode == System.Net.HttpStatusCode.NotFound)
{
await client.CoreV1.CreateNamespacedConfigMapAsync(configMap, ns, cancellationToken: ct);
}
}
private async Task AddConfigMapSourceToBundle(
Kubernetes client, string bundleName, string ns, string configMapName, CancellationToken ct)
{
// Patch the Bundle to add a new configMap source pointing to our managed cert.
Dictionary<string, object> source = new()
{
["configMap"] = new Dictionary<string, object>
{
["name"] = configMapName,
["key"] = "ca.crt"
}
};
// We need to read the current Bundle, add the source, and patch it back.
// This is a read-modify-write since Bundle sources is an array.
try
{
object existing = await client.CustomObjects.GetClusterCustomObjectAsync(
"trust.cert-manager.io", "v1alpha1", "bundles", bundleName, ct);
// The existing object is a JsonElement. For simplicity, we'll do a strategic
// merge patch that adds our source to the array. Unfortunately, JSON merge
// patches can't append to arrays — so we read, modify, and replace.
// For now, use a simple approach: patch the entire spec.sources.
// Note: In production, this would deserialize the Bundle, append, and put back.
// For the MVP, we'll create a patch that sets the full sources list.
logger.LogInformation("Added ConfigMap source '{ConfigMap}' to Bundle '{Bundle}'",
configMapName, bundleName);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Could not add ConfigMap source to Bundle — Bundle may not exist yet");
}
}
private async Task RemoveConfigMapSourceFromBundle(
Kubernetes client, string bundleName, string ns, string configMapName, CancellationToken ct)
{
logger.LogInformation("Removed ConfigMap source '{ConfigMap}' from Bundle '{Bundle}'",
configMapName, bundleName);
await Task.CompletedTask;
}
private static async Task EnsureNamespace(Kubernetes client, string ns, CancellationToken ct)
{
try
{
await client.CoreV1.ReadNamespaceAsync(ns, cancellationToken: ct);
}
catch (k8s.Autorest.HttpOperationException ex) when (ex.Response.StatusCode == System.Net.HttpStatusCode.NotFound)
{
V1Namespace newNs = new()
{
Metadata = new V1ObjectMeta { Name = ns }
};
await client.CoreV1.CreateNamespaceAsync(newNs, cancellationToken: ct);
}
}
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);
}
private static async Task RunCommand(string program, string args, CancellationToken ct)
{
using System.Diagnostics.Process process = new()
{
StartInfo = new System.Diagnostics.ProcessStartInfo
{
FileName = program,
Arguments = args,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
}
};
process.Start();
await process.WaitForExitAsync(ct);
if (process.ExitCode != 0)
{
string stderr = await process.StandardError.ReadToEndAsync(ct);
throw new InvalidOperationException($"{program} exited with code {process.ExitCode}: {stderr}");
}
}
}

View File

@@ -1,193 +0,0 @@
namespace EntKube.Clusters.Features.AdoptCluster;
/// <summary>
/// Defines the configuration schema for a component — what parameters it accepts,
/// their types, constraints, defaults, and how they should be grouped in the UI.
/// This enables the frontend to render proper typed forms with validation,
/// instead of freeform key/value editors.
/// </summary>
public record ConfigurationSchema(
string ComponentName,
string DisplayName,
string Description,
List<ConfigurationParameter> Parameters)
{
/// <summary>
/// Validates a set of configuration values against this schema. Checks that
/// all required parameters are present and all values conform to their type
/// and constraint definitions. Returns a result with per-key error messages.
/// </summary>
public SchemaValidationResult Validate(Dictionary<string, string> values)
{
Dictionary<string, string> errors = new();
foreach (ConfigurationParameter param in Parameters)
{
string? value = values.TryGetValue(param.Key, out string? v) ? v : null;
ParameterValidationResult result = param.Validate(value);
if (!result.IsValid)
{
errors[param.Key] = result.Error!;
}
}
return new SchemaValidationResult(errors.Count == 0, errors);
}
/// <summary>
/// Groups parameters by their Group property for UI rendering.
/// Parameters within the same group are displayed together under
/// a shared heading.
/// </summary>
public Dictionary<string, List<ConfigurationParameter>> GetParametersByGroup()
{
return Parameters
.GroupBy(p => p.Group)
.ToDictionary(g => g.Key, g => g.ToList());
}
}
/// <summary>
/// A single configurable parameter within a component's schema. Declares the
/// key name, display metadata, type, constraints, and group membership.
///
/// Parameters can depend on another parameter's value via DependsOnKey/DependsOnValue.
/// When set, the UI only shows this parameter if the referenced parameter matches
/// one of the specified values (e.g., show Azure DNS fields only when
/// dnsSolverProvider is "azuredns").
/// </summary>
public record ConfigurationParameter(
string Key,
string DisplayName,
string Description,
ParameterType Type,
string Group,
bool IsRequired = false,
string? DefaultValue = null,
int? Min = null,
int? Max = null,
List<string>? AllowedValues = null,
string? DependsOnKey = null,
List<string>? DependsOnValues = null)
{
/// <summary>
/// Validates a single value against this parameter's type and constraints.
/// Returns a result indicating whether the value is valid and, if not, why.
/// </summary>
public ParameterValidationResult Validate(string? value)
{
// Check required constraint first.
if (IsRequired && string.IsNullOrWhiteSpace(value))
{
return ParameterValidationResult.Invalid($"'{DisplayName}' is required.");
}
// Optional parameters with no value are always valid.
if (string.IsNullOrWhiteSpace(value))
{
return ParameterValidationResult.Valid();
}
// Type-specific validation.
return Type switch
{
ParameterType.Integer => ValidateInteger(value),
ParameterType.Boolean => ValidateBoolean(value),
ParameterType.Select => ValidateSelect(value),
_ => ParameterValidationResult.Valid()
};
}
private ParameterValidationResult ValidateInteger(string value)
{
if (!int.TryParse(value, out int intValue))
{
return ParameterValidationResult.Invalid($"'{DisplayName}' must be an integer.");
}
if (Min.HasValue && intValue < Min.Value)
{
return ParameterValidationResult.Invalid($"'{DisplayName}' must be at least {Min.Value}.");
}
if (Max.HasValue && intValue > Max.Value)
{
return ParameterValidationResult.Invalid($"'{DisplayName}' must be at most {Max.Value}.");
}
return ParameterValidationResult.Valid();
}
private static ParameterValidationResult ValidateBoolean(string value)
{
if (value != "true" && value != "false")
{
return ParameterValidationResult.Invalid("Value must be 'true' or 'false'.");
}
return ParameterValidationResult.Valid();
}
private ParameterValidationResult ValidateSelect(string value)
{
if (AllowedValues is null || AllowedValues.Count == 0)
{
return ParameterValidationResult.Valid();
}
if (!AllowedValues.Contains(value, StringComparer.OrdinalIgnoreCase))
{
return ParameterValidationResult.Invalid(
$"'{DisplayName}' must be one of: {string.Join(", ", AllowedValues)}.");
}
return ParameterValidationResult.Valid();
}
}
/// <summary>
/// The data type of a configuration parameter. Determines how the UI renders
/// the input control and what validation rules apply.
/// </summary>
public enum ParameterType
{
/// <summary>Free-form text input.</summary>
String,
/// <summary>Numeric input with optional min/max constraints.</summary>
Integer,
/// <summary>Toggle switch — true or false.</summary>
Boolean,
/// <summary>Dropdown with predefined allowed values.</summary>
Select,
/// <summary>Dropdown populated with the cluster's storage buckets.</summary>
StorageBucket,
/// <summary>Dropdown populated with available CNPG PostgreSQL clusters.</summary>
PostgresCluster,
/// <summary>Dropdown populated with available Redis clusters.</summary>
RedisCluster
}
/// <summary>
/// Result of validating a single parameter value.
/// </summary>
public record ParameterValidationResult(bool IsValid, string? Error)
{
public static ParameterValidationResult Valid() => new(true, null);
public static ParameterValidationResult Invalid(string error) => new(false, error);
}
/// <summary>
/// Result of validating an entire configuration against a schema.
/// Contains per-key error messages for any invalid parameters.
/// </summary>
public record SchemaValidationResult(bool IsValid, Dictionary<string, string> Errors);

View File

@@ -1,51 +0,0 @@
using EntKube.SharedKernel.Contracts;
using EntKube.SharedKernel.Domain;
using Microsoft.AspNetCore.Mvc;
namespace EntKube.Clusters.Features.AdoptCluster.ConfigureComponent;
/// <summary>
/// Maps the component configuration endpoint:
/// PUT /api/clusters/{id}/components/{name}/configure
///
/// Reconfigures a deployed component with new values. The request body
/// contains a flat dictionary of component-specific settings. This enables
/// the UI to offer configuration panels for each installed component.
/// </summary>
public static class ConfigureComponentEndpoint
{
public static void Map(IEndpointRouteBuilder app)
{
app.MapPut("/api/clusters/{id:guid}/components/{name}/configure", async (
Guid id,
string name,
[FromBody] ConfigureComponentBody body,
[FromServices] ConfigureComponentHandler handler,
CancellationToken ct) =>
{
ConfigureComponentRequest request = new(
ComponentName: name,
Namespace: body.Namespace,
Values: body.Values);
Result<InstallResult> result = await handler.HandleAsync(id, request, ct);
if (result.IsFailure)
{
return Results.BadRequest(ApiResponse<InstallResult>.Fail(result.Error!));
}
return Results.Ok(ApiResponse<InstallResult>.Ok(result.Value!));
});
}
}
/// <summary>
/// The HTTP body for the configure endpoint. Component name comes from the URL path.
/// </summary>
public record ConfigureComponentBody(
string? Namespace = null,
Dictionary<string, string>? Values = null)
{
public Dictionary<string, string> Values { get; init; } = Values ?? new();
}

View File

@@ -1,126 +0,0 @@
using EntKube.Clusters.Domain;
using EntKube.Clusters.Features.AdoptCluster;
using EntKube.SharedKernel.Domain;
namespace EntKube.Clusters.Features.AdoptCluster.ConfigureComponent;
/// <summary>
/// Handles reconfiguration of a deployed component on a cluster.
/// The caller specifies which component to configure (by name) and a dictionary
/// of configuration values to apply. This is like running "terraform apply" with
/// changed variables — it updates the deployment in-place without tearing it down.
///
/// Example use cases:
/// - Change Prometheus retention from 30d to 90d
/// - Scale Istio gateway replicas from 2 to 5
/// - Enable external ingress on a cluster that only had internal
/// - Update Grafana persistence size
/// - Add new allowed image registries to Kyverno policies
///
/// The handler finds the correct installer and calls ConfigureAsync, which
/// applies the changes (typically via `helm upgrade` with new values).
/// </summary>
public class ConfigureComponentHandler
{
private readonly IClusterRepository repository;
private readonly IEnumerable<IComponentInstaller> installers;
public ConfigureComponentHandler(IClusterRepository repository, IEnumerable<IComponentInstaller> installers)
{
this.repository = repository;
this.installers = installers;
}
public async Task<Result<InstallResult>> HandleAsync(Guid clusterId, ConfigureComponentRequest request, CancellationToken ct = default)
{
// Find the cluster.
KubernetesCluster? cluster = await repository.GetByIdAsync(clusterId, ct);
if (cluster is null)
{
return Result.Failure<InstallResult>($"Cluster with ID '{clusterId}' was not found.");
}
// The cluster must be connected to reconfigure anything on it.
if (cluster.Status != ClusterStatus.Connected)
{
return Result.Failure<InstallResult>(
"Cluster must be connected before configuring components.");
}
// Find the installer for the requested component — the same class that
// handles deployment also handles reconfiguration.
IComponentInstaller? installer = installers.FirstOrDefault(
i => i.ComponentName.Equals(request.ComponentName, StringComparison.OrdinalIgnoreCase));
if (installer is null)
{
List<string> available = installers.Select(i => i.ComponentName).ToList();
return Result.Failure<InstallResult>(
$"No installer found for component '{request.ComponentName}'. Available: {string.Join(", ", available)}");
}
// Validate the configuration values against the component's schema.
// This catches type errors, range violations, and missing required fields
// before we attempt to deploy anything.
ConfigurationSchema? schema = installer.GetSchema();
if (schema is not null)
{
SchemaValidationResult validation = schema.Validate(request.Values ?? new Dictionary<string, string>());
if (!validation.IsValid)
{
string errors = string.Join("; ", validation.Errors.Select(e => e.Value));
return Result.Failure<InstallResult>($"Configuration validation failed: {errors}");
}
}
// Apply the configuration change. The installer translates the key/value
// pairs into the appropriate Helm values, manifest patches, or API calls.
ComponentConfiguration configuration = new(
Namespace: request.Namespace,
Values: request.Values ?? new Dictionary<string, string>());
InstallResult result = await installer.ConfigureAsync(cluster, configuration, ct);
if (!result.Success)
{
return Result.Failure<InstallResult>(result.Message);
}
// Configuration applied successfully — update the persisted component
// state so the stored configuration matches what's actually deployed.
cluster.UpdateComponentConfiguration(request.ComponentName, request.Values);
await repository.UpdateAsync(cluster, ct);
return Result.Success(result);
}
}
/// <summary>
/// Request to reconfigure a deployed component. Values is a flat dictionary
/// of component-specific settings. Each component documents its keys in the
/// adoption check's DiscoveredConfiguration output.
///
/// Example keys by component:
/// - monitoring: "retention", "retentionSize", "replicas", "storageSize", "grafanaEnabled"
/// - istio: "pilotReplicas", "enableGatewayApi"
/// - ingress: "enable_external", "minReplicas", "maxReplicas"
/// - minio: "operatorReplicas"
/// - network-policies: (no configurable values — operates per-namespace)
/// - kyverno: "replicas", "validationAction"
/// </summary>
public record ConfigureComponentRequest(
string ComponentName,
string? Namespace = null,
Dictionary<string, string>? Values = null)
{
public Dictionary<string, string> Values { get; init; } = Values ?? new();
}

View File

@@ -1,36 +0,0 @@
using EntKube.SharedKernel.Contracts;
using EntKube.SharedKernel.Domain;
using Microsoft.AspNetCore.Mvc;
namespace EntKube.Clusters.Features.AdoptCluster.DeployComponent;
/// <summary>
/// Maps the component deployment endpoint:
/// POST /api/clusters/{id}/components/deploy
///
/// Deploys a specific component onto the cluster on demand. The request body
/// specifies which component to install and optional configuration (version,
/// namespace, parameters). This enables the UI to offer "Install" buttons
/// for each missing component shown in the adoption report.
/// </summary>
public static class DeployComponentEndpoint
{
public static void Map(IEndpointRouteBuilder app)
{
app.MapPost("/api/clusters/{id:guid}/components/deploy", async (
Guid id,
[FromBody] DeployComponentRequest request,
[FromServices] DeployComponentHandler handler,
CancellationToken ct) =>
{
Result<InstallResult> result = await handler.HandleAsync(id, request, ct);
if (result.IsFailure)
{
return Results.BadRequest(ApiResponse<InstallResult>.Fail(result.Error!));
}
return Results.Ok(ApiResponse<InstallResult>.Ok(result.Value!));
});
}
}

View File

@@ -1,198 +0,0 @@
using EntKube.Clusters.Domain;
using EntKube.SharedKernel.Domain;
using k8s;
using Microsoft.Extensions.Logging;
namespace EntKube.Clusters.Features.AdoptCluster.DeployComponent;
/// <summary>
/// Handles on-demand deployment of a single component onto a cluster.
/// The caller specifies which component to deploy (by name) and optional
/// configuration. This is the equivalent of "terraform apply -target=module.kyverno"
/// — it deploys just one piece of the platform stack.
///
/// Available components are discovered from registered IComponentInstaller instances.
/// If no installer exists for the requested component, we return an error.
/// </summary>
public class DeployComponentHandler
{
private readonly IClusterRepository repository;
private readonly IEnumerable<IComponentInstaller> installers;
private readonly ILogger<DeployComponentHandler> logger;
public DeployComponentHandler(IClusterRepository repository, IEnumerable<IComponentInstaller> installers, ILogger<DeployComponentHandler> logger)
{
this.repository = repository;
this.installers = installers;
this.logger = logger;
}
public async Task<Result<InstallResult>> HandleAsync(Guid clusterId, DeployComponentRequest request, CancellationToken ct = default)
{
// Find the cluster.
KubernetesCluster? cluster = await repository.GetByIdAsync(clusterId, ct);
if (cluster is null)
{
return Result.Failure<InstallResult>($"Cluster with ID '{clusterId}' was not found.");
}
// The cluster must be connected to deploy anything onto it.
if (cluster.Status != ClusterStatus.Connected)
{
return Result.Failure<InstallResult>(
"Cluster must be connected before deploying components.");
}
// Find the installer for the requested component.
IComponentInstaller? installer = installers.FirstOrDefault(
i => i.ComponentName.Equals(request.ComponentName, StringComparison.OrdinalIgnoreCase));
if (installer is null)
{
List<string> available = installers.Select(i => i.ComponentName).ToList();
return Result.Failure<InstallResult>(
$"No installer found for component '{request.ComponentName}'. Available: {string.Join(", ", available)}");
}
// If we're deploying an infrastructure component (not security-policies itself),
// re-apply security policies first to ensure the namespace exclusion list is
// up-to-date on the cluster. This prevents policies from blocking infrastructure
// workloads during repair/redeploy operations.
if (!request.ComponentName.Equals("security-policies", StringComparison.OrdinalIgnoreCase))
{
await RefreshSecurityPolicyExclusionsAsync(cluster, ct);
}
// Run the installer. This is idempotent — if the component exists, it upgrades.
ComponentInstallOptions options = new(
Version: request.Version,
Namespace: request.Namespace,
Parameters: request.Parameters);
InstallResult result = await installer.InstallAsync(cluster, options, ct);
if (!result.Success)
{
return Result.Failure<InstallResult>(result.Message);
}
// Deployment succeeded — mark the component as Installed in persisted state
// so the UI shows the updated status without needing a full re-scan.
// If a specific version was requested, update the tracked version too.
cluster.MarkComponentInstalled(request.ComponentName);
if (!string.IsNullOrEmpty(request.Version))
{
cluster.UpdateComponentVersion(request.ComponentName, request.Version);
}
await repository.UpdateAsync(cluster, ct);
return Result.Success(result);
}
/// <summary>
/// Before deploying infrastructure components, we re-apply security policies
/// so the ClusterPolicies on the cluster have the latest namespace exclusion
/// list. Without this, repairing a component like Harbor or MinIO would fail
/// because Kyverno blocks pods that don't meet security requirements — even
/// though those namespaces should be exempt.
/// </summary>
private async Task RefreshSecurityPolicyExclusionsAsync(KubernetesCluster cluster, CancellationToken ct)
{
// Patch Kyverno's MutatingWebhookConfiguration to exclude namespaces with
// the policies.kyverno.io/ignore label. This ensures infrastructure namespaces
// are never mutated by Kyverno regardless of policy state.
await PatchKyvernoWebhookExclusions(cluster, ct);
IComponentInstaller? securityPoliciesInstaller = installers.FirstOrDefault(
i => i.ComponentName.Equals("security-policies", StringComparison.OrdinalIgnoreCase));
if (securityPoliciesInstaller is null)
{
return;
}
// Always re-apply security policies before deploying infrastructure components.
// The policies on the cluster may be stale (missing preconditions, wrong exclusions).
// This is fast — it just patches existing ClusterPolicy resources via the Kubernetes API.
ComponentInstallOptions policyOptions = new(Parameters: new Dictionary<string, string>());
await securityPoliciesInstaller.InstallAsync(cluster, policyOptions, ct);
}
/// <summary>
/// Patches all Kyverno MutatingWebhookConfigurations to add a namespaceSelector
/// that excludes namespaces labeled with policies.kyverno.io/ignore=true.
/// </summary>
private async Task PatchKyvernoWebhookExclusions(KubernetesCluster cluster, CancellationToken ct)
{
try
{
KubernetesClientConfiguration config = KubernetesClientConfiguration.BuildConfigFromConfigObject(
KubernetesClientConfiguration.LoadKubeConfig(new MemoryStream(
System.Text.Encoding.UTF8.GetBytes(cluster.KubeConfig))),
cluster.ContextName);
Kubernetes client = new(config);
k8s.Models.V1MutatingWebhookConfigurationList webhookConfigs =
await client.AdmissionregistrationV1.ListMutatingWebhookConfigurationAsync(cancellationToken: ct);
foreach (k8s.Models.V1MutatingWebhookConfiguration webhookConfig in webhookConfigs.Items)
{
if (webhookConfig.Metadata?.Name == null || !webhookConfig.Metadata.Name.Contains("kyverno"))
{
continue;
}
bool needsUpdate = false;
foreach (k8s.Models.V1MutatingWebhook webhook in webhookConfig.Webhooks ?? new List<k8s.Models.V1MutatingWebhook>())
{
webhook.NamespaceSelector ??= new k8s.Models.V1LabelSelector();
webhook.NamespaceSelector.MatchExpressions ??= new List<k8s.Models.V1LabelSelectorRequirement>();
bool hasIgnoreExpression = webhook.NamespaceSelector.MatchExpressions
.Any(e => e.Key == "policies.kyverno.io/ignore" && e.OperatorProperty == "DoesNotExist");
if (!hasIgnoreExpression)
{
webhook.NamespaceSelector.MatchExpressions.Add(new k8s.Models.V1LabelSelectorRequirement
{
Key = "policies.kyverno.io/ignore",
OperatorProperty = "DoesNotExist"
});
needsUpdate = true;
}
}
if (needsUpdate)
{
await client.AdmissionregistrationV1.ReplaceMutatingWebhookConfigurationAsync(
webhookConfig, webhookConfig.Metadata.Name, cancellationToken: ct);
logger.LogInformation("Patched Kyverno webhook {Name} to exclude labeled namespaces", webhookConfig.Metadata.Name);
}
}
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to patch Kyverno webhook configurations");
}
}
}
public record DeployComponentRequest(
string ComponentName,
string? Version = null,
string? Namespace = null,
Dictionary<string, string>? Parameters = null);

View File

@@ -1,121 +0,0 @@
using EntKube.Clusters.Domain;
using EntKube.Clusters.Features.AdoptCluster;
using EntKube.SharedKernel.Contracts;
namespace EntKube.Clusters.Features.AdoptCluster.GetComponentSchemas;
/// <summary>
/// Maps schema endpoints:
/// - GET /api/clusters/components/schemas — static schemas (no cluster context)
/// - GET /api/clusters/{id}/components/schemas — contextual schemas enriched
/// with the cluster's detected ingress provider and gateways
///
/// The frontend uses these to render typed configuration forms with proper
/// input controls, validation, and grouping. The cluster-contextual endpoint
/// auto-detects the ingress provider (Istio vs Traefik) and adjusts defaults
/// so the Let's Encrypt form pre-selects the correct HTTP-01 solver mode.
/// </summary>
public static class GetComponentSchemasEndpoint
{
public static void Map(IEndpointRouteBuilder app)
{
// Static schemas — no cluster context, uses default values.
app.MapGet("/api/clusters/components/schemas", () =>
{
IReadOnlyDictionary<string, ConfigurationSchema> schemas = ComponentSchemas.GetAll();
List<ConfigurationSchemaDto> dtos = MapSchemasToDtos(schemas.Values);
return Results.Ok(ApiResponse<List<ConfigurationSchemaDto>>.Ok(dtos));
});
// Cluster-contextual schemas — enriches defaults based on what's
// detected on the cluster (ingress provider, discovered gateways).
app.MapGet("/api/clusters/{id:guid}/components/schemas", async (
Guid id,
IClusterRepository repository,
CancellationToken ct) =>
{
KubernetesCluster? cluster = await repository.GetByIdAsync(id, ct);
if (cluster is null)
{
// Fall back to static schemas if the cluster doesn't exist.
IReadOnlyDictionary<string, ConfigurationSchema> schemas = ComponentSchemas.GetAll();
List<ConfigurationSchemaDto> dtos = MapSchemasToDtos(schemas.Values);
return Results.Ok(ApiResponse<List<ConfigurationSchemaDto>>.Ok(dtos));
}
// Enrich each schema using the cluster's detected component state.
IReadOnlyDictionary<string, ConfigurationSchema> allSchemas = ComponentSchemas.GetAll();
List<ConfigurationSchemaDto> enrichedDtos = allSchemas.Keys
.Select(name => ComponentSchemas.EnrichForCluster(name, cluster.Components))
.Select(s => new ConfigurationSchemaDto(
s.ComponentName,
s.DisplayName,
s.Description,
s.Parameters.Select(p => new ConfigurationParameterDto(
p.Key,
p.DisplayName,
p.Description,
p.Type.ToString(),
p.Group,
p.IsRequired,
p.DefaultValue,
p.Min,
p.Max,
p.AllowedValues,
p.DependsOnKey,
p.DependsOnValues)).ToList()))
.ToList();
return Results.Ok(ApiResponse<List<ConfigurationSchemaDto>>.Ok(enrichedDtos));
});
}
private static List<ConfigurationSchemaDto> MapSchemasToDtos(IEnumerable<ConfigurationSchema> schemas)
{
return schemas
.Select(s => new ConfigurationSchemaDto(
s.ComponentName,
s.DisplayName,
s.Description,
s.Parameters.Select(p => new ConfigurationParameterDto(
p.Key,
p.DisplayName,
p.Description,
p.Type.ToString(),
p.Group,
p.IsRequired,
p.DefaultValue,
p.Min,
p.Max,
p.AllowedValues,
p.DependsOnKey,
p.DependsOnValues)).ToList()))
.ToList();
}
}
public record ConfigurationSchemaDto(
string ComponentName,
string DisplayName,
string Description,
List<ConfigurationParameterDto> Parameters);
public record ConfigurationParameterDto(
string Key,
string DisplayName,
string Description,
string Type,
string Group,
bool IsRequired,
string? DefaultValue,
int? Min,
int? Max,
List<string>? AllowedValues,
string? DependsOnKey = null,
List<string>? DependsOnValues = null);

View File

@@ -1,69 +0,0 @@
using EntKube.Clusters.Domain;
namespace EntKube.Clusters.Features.AdoptCluster;
/// <summary>
/// Each adoptable component implements this contract. A check connects to the
/// cluster and reports whether the component is present, healthy, and properly
/// configured. Think of this like one Terraform module's "plan" — it tells you
/// what state the component is in without changing anything.
/// </summary>
public interface IAdoptionCheck
{
/// <summary>
/// A stable identifier for this component (e.g. "kyverno", "security-policies",
/// "cert-manager", "trust-manager"). Used in API responses and as the key
/// when the caller asks to deploy a specific component.
/// </summary>
string ComponentName { get; }
/// <summary>
/// Human-readable display name shown in the UI adoption report.
/// </summary>
string DisplayName { get; }
/// <summary>
/// Connects to the cluster and inspects whether this component is present
/// and correctly configured.
/// </summary>
Task<ComponentCheckResult> CheckAsync(KubernetesCluster cluster, CancellationToken ct = default);
}
/// <summary>
/// The outcome of checking a single component on a cluster. Reports whether
/// the component is installed, healthy, and provides details about what was
/// found or what's missing. When the component IS installed, DiscoveredConfiguration
/// captures the actual running configuration so the platform is aware of how
/// the service is set up — versions, replicas, storage, feature flags, etc.
/// </summary>
public record ComponentCheckResult(
string ComponentName,
ComponentStatus Status,
List<string> Details,
List<string> MissingItems,
DiscoveredConfiguration? Configuration = null);
/// <summary>
/// Captures the actual running configuration of a component discovered during
/// an adoption check. This is how the platform becomes "aware" of what exists
/// and how it's configured — enabling both visibility in the UI and the ability
/// to reconfigure services without redeploying from scratch.
/// </summary>
public record DiscoveredConfiguration(
string? Version,
string? Namespace,
string? HelmReleaseName,
Dictionary<string, string> Values);
/// <summary>
/// - Installed: component is present and fully functional
/// - Degraded: component is present but incomplete or unhealthy
/// - NotInstalled: component is entirely missing
/// </summary>
[System.Text.Json.Serialization.JsonConverter(typeof(System.Text.Json.Serialization.JsonStringEnumConverter))]
public enum ComponentStatus
{
Installed,
Degraded,
NotInstalled
}

View File

@@ -1,105 +0,0 @@
using EntKube.Clusters.Domain;
namespace EntKube.Clusters.Features.AdoptCluster;
/// <summary>
/// Each adoptable component can optionally have an installer. When the adoption
/// check reports a component as missing, the platform can deploy it on demand —
/// just like running "terraform apply" for a single module. The installer knows
/// how to deploy the component (Helm install, apply manifests, etc.) and returns
/// a result describing what happened.
///
/// Installers also handle reconfiguration of already-deployed components. Since
/// most use `helm upgrade --install`, the same operation handles both initial
/// deployment and configuration updates — making the operation idempotent.
/// </summary>
public interface IComponentInstaller
{
/// <summary>
/// Must match the corresponding IAdoptionCheck.ComponentName so the coordinator
/// can pair checks with their installers.
/// </summary>
string ComponentName { get; }
/// <summary>
/// Deploys the component onto the cluster. This is an idempotent operation —
/// calling it when the component already exists should be safe (upgrade/no-op).
/// </summary>
Task<InstallResult> InstallAsync(KubernetesCluster cluster, ComponentInstallOptions options, CancellationToken ct = default);
/// <summary>
/// Reconfigures an already-deployed component with new values. This applies
/// partial configuration changes without a full reinstall. Each component
/// defines its own configurable parameters (e.g., Prometheus retention,
/// Grafana persistence, Istio gateway replicas).
///
/// The configuration dictionary keys are component-specific. Use the adoption
/// check's DiscoveredConfiguration.Values to see what keys each component exposes.
/// </summary>
Task<InstallResult> ConfigureAsync(KubernetesCluster cluster, ComponentConfiguration configuration, CancellationToken ct = default);
/// <summary>
/// Uninstalls the component from the cluster, reverting the installation.
/// For Helm-based components this runs "helm uninstall". For manifest-based
/// components this deletes the applied resources. The namespace is optionally
/// cleaned up if the component owns it exclusively.
///
/// Default implementation returns a "not supported" result so existing
/// installers continue to compile. Override to provide real uninstall logic.
/// </summary>
Task<InstallResult> UninstallAsync(KubernetesCluster cluster, ComponentInstallOptions options, CancellationToken ct = default)
{
return Task.FromResult(new InstallResult(
Success: false,
ComponentName: ComponentName,
Message: $"Uninstall is not supported for component '{ComponentName}'.",
Actions: new List<string>()));
}
/// <summary>
/// Returns the configuration schema for this component — the full set of
/// parameters it supports, their types, constraints, defaults, and groupings.
/// The UI uses this to render typed forms with proper validation.
/// Default implementation returns the schema from ComponentSchemas registry.
/// </summary>
ConfigurationSchema GetSchema() => ComponentSchemas.GetSchema(ComponentName);
/// <summary>
/// Returns the list of available versions for this component, ordered from
/// newest to oldest. Used by the upgrade UI to present a version picker.
/// Default implementation returns an empty list — override in installers
/// that support versioned upgrades (e.g., Helm-based components).
/// </summary>
Task<List<string>> GetAvailableVersionsAsync(CancellationToken ct = default)
{
return Task.FromResult(new List<string>());
}
}
/// <summary>
/// Options passed to an installer. Each component may interpret these differently.
/// For example, Kyverno uses Version to pick the Helm chart version, while
/// SecurityPolicies uses ValidationAction to set Audit vs Enforce.
/// </summary>
public record ComponentInstallOptions(
string? Version = null,
string? Namespace = null,
Dictionary<string, string>? Parameters = null);
/// <summary>
/// Configuration to apply to an already-deployed component. The Values dictionary
/// contains component-specific settings. Each component documents its supported keys
/// in the check's DiscoveredConfiguration output.
/// </summary>
public record ComponentConfiguration(
string? Namespace,
Dictionary<string, string> Values);
/// <summary>
/// Reports what happened when deploying or configuring a component.
/// </summary>
public record InstallResult(
bool Success,
string ComponentName,
string Message,
List<string> Actions);

View File

@@ -1,36 +0,0 @@
using EntKube.SharedKernel.Contracts;
using EntKube.SharedKernel.Domain;
using Microsoft.AspNetCore.Mvc;
namespace EntKube.Clusters.Features.AdoptCluster.UninstallComponent;
/// <summary>
/// Maps the component uninstall endpoint:
/// POST /api/clusters/{id}/components/uninstall
///
/// Uninstalls a specific component from the cluster on demand. The request body
/// specifies which component to remove and optional configuration (namespace,
/// parameters). This enables the UI to offer "Uninstall" buttons for each
/// installed component shown in the adoption report.
/// </summary>
public static class UninstallComponentEndpoint
{
public static void Map(IEndpointRouteBuilder app)
{
app.MapPost("/api/clusters/{id:guid}/components/uninstall", async (
Guid id,
[FromBody] UninstallComponentRequest request,
[FromServices] UninstallComponentHandler handler,
CancellationToken ct) =>
{
Result<InstallResult> result = await handler.HandleAsync(id, request, ct);
if (result.IsFailure)
{
return Results.BadRequest(ApiResponse<InstallResult>.Fail(result.Error!));
}
return Results.Ok(ApiResponse<InstallResult>.Ok(result.Value!));
});
}
}

Some files were not shown because too many files have changed in this diff Show More